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.

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:
- the tutorial,
- the special topics, including "Why winnow? How does it compare to ...?",
- the API reference, and
- the list of combinators.
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
- Sign up to get 200 free credits, with no card.
- Open /settings/apikeys, press Create Key, name it
log-triage, and copy it. - Export it. Never hard-code it:
export JEVSTATION_API_KEY="sk_..."
- Check it for free. A
GETreturns 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
| Job | Tool | Why |
|---|---|---|
| Split a line into level, code and message | winnow | Exact, fast, free, and the same every time |
| Count occurrences, compare timestamps, do sums | Rust code | Jev is unreliable at arithmetic and counting |
| Judge whether the message is urgent or odd | Jev | Meaning, returned as a probability you can threshold |
| Decide what to do with the probability | Rust code | Thresholds 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
- Create a free account and a key at /settings/apikeys.
- Read the API docs for question types, limits and error codes.
- Read Designing Questions Jev Can Answer to write sharper questions.
- Check pricing when you outgrow the free credits: one-time packs, no subscription.
winnow is independent of JevStation. For parser questions, use its repository and the docs at docs.rs.