Back to blog

winnow Rust Parser Tutorial + Jev API Example

winnow is a Rust parser-combinator library. Learn the basics, then pair it with a JevStation API key: parse the structure with winnow, judge meaning with Jev.

JevStationJevStation
winnow Rust Parser Tutorial + Jev API Example

Independent project notice. JevStation is not affiliated with, endorsed by or sponsored by the winnow project or its contributors. winnow is a separate open-source Rust library (MIT licence) at github.com/winnow-rs/winnow. We did not write it and do not maintain it. winnow has no Jev integration: its README does not mention Jev, any LLM or any API. This article is our own example of using the two together, written from the public docs as of 1 October 2026.

winnow is a Rust library for building parsers out of small, composable pieces. It descends from the nom crate, and its README describes it as "the building blocks" for a parser of any format. This tutorial covers the core ideas, then shows a practical pairing: let winnow parse the structure of a log line (cheap, exact, deterministic), and let Jev judge the meaning of the free-text part through a JevStation API key.

What winnow is for

A parser-combinator library lets you write a grammar as ordinary Rust functions. You build a parser for a number, a parser for a bracketed level, and combine them into a parser for a whole line. winnow's README points to four places to learn more:

Use it when you have a format you control or a stable text grammar: log lines, config files, small protocols, command syntaxes. It is the right tool when the answer is defined by the text's shape.

It is the wrong tool when the answer depends on what the text means. A parser cannot tell you whether "card declined 3 times" is urgent. That split is the whole idea of this article.

Install winnow

The README links the crate rather than listing commands, so use the standard Cargo workflow:

cargo new log-triage && cd log-triage
cargo add winnow

The examples below were compiled against winnow 1.0.4 on a current stable toolchain at the time of writing. Parser functions take &mut &str and return a ModalResult; if you are on an older release you may see PResult in its place, so check the docs for your version.

A first parser: one log line

Take lines shaped like [ERROR] E4012 payout failed: card declined 3 times. Three small parsers cover it:

use winnow::ascii::{digit1, space1};
use winnow::combinator::{delimited, preceded};
use winnow::prelude::*;
use winnow::token::{take_till, take_while};

#[derive(Debug)]
struct LogLine<'a> {
    level: &'a str,
    code: u32,
    message: &'a str,
}

// `[ERROR]` -> "ERROR"
fn level<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
    delimited('[', take_while(1.., |c: char| c.is_ascii_uppercase()), ']').parse_next(input)
}

// `E4012` -> 4012
fn code(input: &mut &str) -> ModalResult<u32> {
    preceded('E', digit1.parse_to()).parse_next(input)
}

fn log_line<'a>(input: &mut &'a str) -> ModalResult<LogLine<'a>> {
    let level = level.parse_next(input)?;
    space1.parse_next(input)?;
    let code = code.parse_next(input)?;
    space1.parse_next(input)?;
    let message = take_till(0.., '\n').parse_next(input)?;
    Ok(LogLine { level, code, message })
}

delimited, preceded, take_while and take_till are the combinators doing the work. Each parser consumes part of the input, returns a value, and leaves the rest for the next one. The message field borrows from the original string, so no copy is made. A line that does not match returns an Err, which you can count or log.

Add Jev: judge the message, not the format

winnow now hands you clean fields. What it cannot do is decide whether message deserves attention. That is a bounded semantic question, which is exactly what a Noul (a yes-or-no question answered with a probability) is for.

1. Create your API key

  1. Sign up to get 200 free credits, with no card.
  2. Open /settings/apikeys, press Create Key, name it log-triage, and copy it.
  3. Export it. Never hard-code it:
export JEVSTATION_API_KEY="sk_..."
  1. Check it for free. A GET returns your remaining credits and rate limits:
curl https://jevstation.com/api/v1/systemone \
  -H "Authorization: Bearer $JEVSTATION_API_KEY"

2. Add the HTTP dependencies

cargo add reqwest --features blocking,json
cargo add serde_json

3. The full program

This ties the parser to a JevStation call. Only lines that parse and are not INFO are sent, so you spend credits only on the lines that matter:

use std::env;

use serde_json::{json, Value};

// ...the LogLine, level, code and log_line items from above...

fn urgency(
    client: &reqwest::blocking::Client,
    key: &str,
    message: &str,
) -> Result<f64, Box<dyn std::error::Error>> {
    let body = json!({
        "state": message,
        "questions": {
            "urgent": {
                "type": "noul",
                "instructions": "Does this log message describe a problem that needs attention now?"
            }
        }
    });
    let resp: Value = client
        .post("https://jevstation.com/api/v1/systemone")
        .bearer_auth(key)
        .json(&body)
        .send()?
        .error_for_status()?
        .json()?;
    resp["data"]["answers"]["urgent"]["noul"]
        .as_f64()
        .ok_or_else(|| "missing noul answer".into())
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let key = env::var("JEVSTATION_API_KEY")?;
    let client = reqwest::blocking::Client::new();

    let log = "[ERROR] E4012 payout failed: card declined 3 times\n\
               [INFO] E1001 nightly backup finished\n\
               not a log line";

    for raw in log.lines() {
        let mut input = raw;
        match log_line.parse_next(&mut input) {
            Ok(line) if line.level != "INFO" => {
                let p = urgency(&client, &key, line.message)?;
                println!("E{} {} -> urgent probability {p:.2}", line.code, line.message);
            }
            Ok(line) => println!("E{} skipped ({})", line.code, line.level),
            Err(e) => eprintln!("skipping unparsable line: {e}"),
        }
    }
    Ok(())
}

Run it with cargo run. We compiled this program and ran it against a local mock of the endpoint to confirm the control flow; the response shape (data.answers.<id>.noul) follows the JevStation API docs. Your live probabilities will depend on the real model.

4. Read the response

A successful call returns {"code": 0, "message": "ok", "data": {...}}. For a Noul question, data.answers.urgent.noul is the probability of "yes" between 0 and 1. data.credits shows what was charged and what remains. Use two thresholds rather than one: page someone above 0.8, send 0.4 to 0.8 to a review queue, and ignore the rest. A failed evaluation is refunded, and error_for_status() turns a 401, 402, 429 or 5xx into an error you can handle.

Why this split works

JobToolWhy
Split a line into level, code and messagewinnowExact, fast, free, and the same every time
Count occurrences, compare timestamps, do sumsRust codeJev is unreliable at arithmetic and counting
Judge whether the message is urgent or oddJevMeaning, returned as a probability you can threshold
Decide what to do with the probabilityRust codeThresholds and routing stay auditable

The principle applies well beyond logs. Parse every structured thing with a parser, and ask Jev only about what is left. A smaller, cleaner state also costs less: one call is 1 credit while the state is 8,000 characters or fewer and you ask five questions or fewer, and 3 credits above either limit. What a Jev evaluation costs walks through the rule. If you want to design the question before writing Rust, try the support ticket triage tool or the playground.

Batching is also an option. If you parse a whole file first, you can export the extracted messages to CSV or JSONL and run one question set over up to 1,000 rows with Batch, instead of calling the API line by line.

Next steps

winnow is independent of JevStation. For parser questions, use its repository and the docs at docs.rs.