Skip to main content

jev/
print.rs

1//! A reply in the format asked for: text or a table for a person, with the questions in the order
2//! they were asked and then what the call used, or JSON for scripts.
3
4use std::fmt::Write;
5
6use crate::rules::Outcome;
7use crate::{Answer, DecisionResponse};
8use serde_json::{json, Value};
9use unicode_width::UnicodeWidthStr;
10
11/// How the reply is printed.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Format {
14    /// One line per question.
15    Text,
16    /// A table with a column per field.
17    Table,
18    /// The reply as this crate reads it (the fields it knows, re-encoded), for scripts.
19    Json,
20}
21
22/// `reply`'s answers to `ids`, in that order, in `format`. A table is kept within `width`
23/// columns. Ends with a newline.
24pub fn render(format: Format, reply: &DecisionResponse, ids: &[String], width: usize) -> serde_json::Result<String> {
25    Ok(match format {
26        Format::Text => text(reply, ids),
27        Format::Table => table(reply, ids, width),
28        Format::Json => serde_json::to_string_pretty(reply)? + "\n",
29    })
30}
31
32/// What rules made of `reply`, in `format`: a line or a row per item, or `{"reply", "outcome"}` as
33/// JSON so a script still has every answer. A table is kept within `width` columns. Ends with a
34/// newline.
35pub fn render_outcome(format: Format, reply: &DecisionResponse, outcome: &Outcome, width: usize) -> serde_json::Result<String> {
36    Ok(match format {
37        Format::Text => outcome.text() + &usage(reply),
38        Format::Table => outcome_table(outcome, width) + &usage(reply),
39        Format::Json => serde_json::to_string_pretty(&json!({"reply": reply, "outcome": outcome}))? + "\n",
40    })
41}
42
43/// One answer's cells: its type, the answer, its confidence, and every option's probability.
44struct Row {
45    kind: &'static str,
46    answer: String,
47    confidence: Option<f64>,
48    probabilities: String,
49}
50
51impl Row {
52    fn of(answer: Option<&Answer>) -> Row {
53        match answer {
54            None => Row { kind: "-", answer: "no answer".to_owned(), confidence: None, probabilities: String::new() },
55            Some(Answer::Noul(answer)) => Row {
56                kind: "noul",
57                answer: if answer.noul >= 0.5 { "yes" } else { "no" }.to_owned(),
58                confidence: None,
59                probabilities: format!("yes {:.2}", answer.noul),
60            },
61            Some(Answer::Choice(answer)) => {
62                let mut options: Vec<_> = answer.probabilities.iter().collect();
63                options.sort_by(|a, b| b.1.total_cmp(a.1).then_with(|| a.0.cmp(b.0)));
64                let options: Vec<_> = options.iter().map(|(name, p)| format!("{name} {p:.2}")).collect();
65                Row {
66                    kind: "choice",
67                    answer: answer.choice.clone(),
68                    confidence: Some(answer.confidence),
69                    probabilities: options.join(", "),
70                }
71            }
72            Some(Answer::Score(answer)) => {
73                let top = answer.probabilities.keys().max().copied().unwrap_or(0);
74                let nearest = answer.score.round().clamp(0.0, f64::from(top)) as u8;
75                let described = match answer.legend.get(&nearest) {
76                    Some(level) => format!(", nearest {}", level_text(level)),
77                    None => String::new(),
78                };
79                let levels: Vec<_> = answer
80                    .probabilities
81                    .iter()
82                    .map(|(level, p)| match answer.legend.get(level) {
83                        Some(Value::String(name)) => format!("{name} {p:.2}"),
84                        _ => format!("{level} {p:.2}"),
85                    })
86                    .collect();
87                Row {
88                    kind: "score",
89                    answer: format!("{:.2} of {top}{described}", answer.score),
90                    confidence: Some(answer.confidence),
91                    probabilities: levels.join(", "),
92                }
93            }
94            Some(other @ Answer::Other(_)) => Row {
95                kind: "other",
96                answer: format!("{}: a type this version doesn't know (see --json)", other.kind()),
97                confidence: None,
98                probabilities: String::new(),
99            },
100        }
101    }
102
103    fn confidence(&self) -> String {
104        self.confidence.map_or_else(|| "-".to_owned(), |confidence| format!("{confidence:.2}"))
105    }
106}
107
108/// One line per question: `id  answer  confidence  (probabilities)`.
109fn text(reply: &DecisionResponse, ids: &[String]) -> String {
110    let width = ids.iter().map(|id| columns(id)).max().unwrap_or(0);
111    let mut out = String::new();
112    for id in ids {
113        let row = Row::of(reply.answers.get(id));
114        let mut line = match reply.answers.get(id) {
115            // A noul's answer is its probability; yes or no alone would hide it.
116            Some(Answer::Noul(answer)) => format!("{:.2} {}", answer.noul, row.answer),
117            _ => row.answer.clone(),
118        };
119        if row.confidence.is_some() {
120            let _ = write!(line, "  confidence {}", row.confidence());
121        }
122        if row.kind == "choice" {
123            let _ = write!(line, "  ({})", row.probabilities);
124        }
125        let _ = writeln!(out, "{}  {line}", pad(id, width));
126    }
127    out + &usage(reply)
128}
129
130/// The columns, and the order they are dropped in when the table doesn't fit: the probabilities
131/// first, since the answer already summarizes them, then the type, which the answer implies.
132const COLUMNS: [&str; 5] = ["question", "type", "answer", "confidence", "probabilities"];
133const DROP_ORDER: [usize; 3] = [4, 1, 3];
134
135/// Which column gives up space first, for the same reason: the question ids and their answers are
136/// what the table is for, so they keep their width while the rest wraps.
137const SQUEEZE_ORDER: [usize; 5] = [4, 3, 1, 2, 0];
138
139/// A boxed table: question, type, answer, confidence, probabilities, within `width` columns.
140/// Cells wrap, and columns come out when wrapping alone would leave them unreadable.
141fn table(reply: &DecisionResponse, ids: &[String], width: usize) -> String {
142    let mut rows = vec![COLUMNS.map(String::from).to_vec()];
143    for id in ids {
144        let row = Row::of(reply.answers.get(id));
145        let confidence = row.confidence();
146        rows.push(vec![id.clone(), row.kind.to_owned(), row.answer, confidence, row.probabilities]);
147    }
148    boxed(rows, &DROP_ORDER, &SQUEEZE_ORDER, width) + &usage(reply)
149}
150
151/// An outcome's columns. The rules behind each score come out first when the table doesn't fit,
152/// and give up space first: the item and whether it is a yes are what the table is for.
153const OUTCOME_COLUMNS: [&str; 4] = ["item", "score", "yes?", "rules"];
154const OUTCOME_DROP_ORDER: [usize; 1] = [3];
155const OUTCOME_SQUEEZE_ORDER: [usize; 4] = [3, 2, 1, 0];
156
157/// A boxed table of an outcome: each item, its score, whether it is a yes, and every rule that
158/// gave it a score, with what that rule came to.
159fn outcome_table(outcome: &Outcome, width: usize) -> String {
160    let mut rows = vec![OUTCOME_COLUMNS.map(String::from).to_vec()];
161    for item in &outcome.items {
162        let rules: Vec<String> = item.rules.iter().map(|rule| format!("{} = {:.2}", rule.when, rule.score)).collect();
163        let yes = if item.yes { "yes" } else { "" };
164        rows.push(vec![item.item.clone(), format!("{:.2}", item.score), yes.to_owned(), rules.join("; ")]);
165    }
166    // An output's row: its value, and each set a rule concluded in, with those rules.
167    for output in &outcome.outputs {
168        let sets: Vec<String> = output
169            .sets
170            .iter()
171            .filter(|set| !set.rules.is_empty())
172            .map(|set| {
173                let rules: Vec<String> = set.rules.iter().map(|rule| format!("{} = {:.2}", rule.when, rule.score)).collect();
174                format!("{}: {}", set.set, rules.join(", "))
175            })
176            .collect();
177        rows.push(vec![output.output.clone(), output.value_text(), String::new(), sets.join("; ")]);
178    }
179    let threshold = if outcome.items.is_empty() { String::new() } else { format!("threshold {:.2}\n", outcome.threshold) };
180    boxed(rows, &OUTCOME_DROP_ORDER, &OUTCOME_SQUEEZE_ORDER, width) + &threshold
181}
182
183/// `rows`, the first of them the header, in a boxed table within `width` columns. When it doesn't
184/// fit, the columns in `drop_order` come out until every one left fits at its narrowest, and then
185/// the ones in `squeeze_order` give up space in turn.
186fn boxed(mut rows: Vec<Vec<String>>, drop_order: &[usize], squeeze_order: &[usize], width: usize) -> String {
187    let count = rows[0].len();
188    let natural = |rows: &[Vec<String>], column: usize| rows.iter().map(|row| columns(&row[column])).max().unwrap_or(0);
189    // A column wraps down to its longest word, which is as narrow as it gets without cutting a
190    // word in half. Question ids are one word, so they stay whole.
191    let wrapped_to =
192        |rows: &[Vec<String>], column: usize| rows.iter().flat_map(|row| row[column].split_whitespace()).map(columns).max().unwrap_or(0);
193
194    let mut widths: Vec<usize> = (0..count).map(|column| natural(&rows, column)).collect();
195    let mut narrowest: Vec<usize> = (0..count).map(|column| wrapped_to(&rows, column)).collect();
196
197    // Drop columns until what's left fits with every column at its narrowest, then share the rest.
198    for &column in drop_order {
199        if fits(&narrowest, width) {
200            break;
201        }
202        widths[column] = 0;
203        narrowest[column] = 0;
204        for row in &mut rows {
205            row[column] = String::new();
206        }
207    }
208    let kept: Vec<usize> = (0..count).filter(|column| widths[*column] > 0).collect();
209    let order: Vec<usize> = squeeze_order.iter().filter_map(|column| kept.iter().position(|kept| kept == column)).collect();
210    let narrowest: Vec<usize> = kept.iter().map(|column| narrowest[*column]).collect();
211    let mut widths: Vec<usize> = kept.iter().map(|column| widths[*column]).collect();
212    let rows: Vec<Vec<String>> = rows.iter().map(|row| kept.iter().map(|column| row[*column].clone()).collect()).collect();
213
214    // Squeeze the least important column down to its narrowest, then the next, until the table
215    // fits. Below that, words have to be cut, so it only happens when nothing else is left.
216    for floor in [&narrowest[..], &vec![1; widths.len()][..]] {
217        for &column in &order {
218            while !fits(&widths, width) && widths[column] > floor[column] {
219                widths[column] -= 1;
220            }
221        }
222    }
223    // Whatever is left over goes to the columns that matter most, up to what they'd take anyway.
224    for &column in order.iter().rev() {
225        while widths[column] < natural(&rows, column) && fits_with(&widths, column, width) {
226            widths[column] += 1;
227        }
228    }
229
230    let wrapped: Vec<Vec<Vec<String>>> =
231        rows.iter().map(|row| row.iter().zip(&widths).map(|(cell, width)| wrap(cell, *width)).collect()).collect();
232    let rule = |left: &str, middle: &str, right: &str| {
233        let lines: Vec<String> = widths.iter().map(|width| "─".repeat(width + 2)).collect();
234        format!("{left}{}{right}\n", lines.join(middle))
235    };
236    let block = |cells: &Vec<Vec<String>>| {
237        let height = cells.iter().map(Vec::len).max().unwrap_or(1);
238        let mut out = String::new();
239        for line in 0..height {
240            let empty = String::new();
241            let cells: Vec<String> =
242                cells.iter().zip(&widths).map(|(cell, width)| format!(" {} ", pad(cell.get(line).unwrap_or(&empty), *width))).collect();
243            let _ = writeln!(out, "│{}│", cells.join("│"));
244        }
245        out
246    };
247    let mut out = rule("┌", "┬", "┐");
248    out += &block(&wrapped[0]);
249    out += &rule("├", "┼", "┤");
250    for row in &wrapped[1..] {
251        out += &block(row);
252    }
253    out += &rule("└", "┴", "┘");
254    out
255}
256
257/// Whether the table fits with one more column of space for `column`.
258fn fits_with(widths: &[usize], column: usize, width: usize) -> bool {
259    let mut widths = widths.to_vec();
260    widths[column] += 1;
261    fits(&widths, width)
262}
263
264/// Whether a table of these columns fits: a border, and each column padded by a space on each side.
265fn fits(widths: &[usize], width: usize) -> bool {
266    let columns: Vec<usize> = widths.iter().copied().filter(|width| *width > 0).collect();
267    columns.iter().sum::<usize>() + 3 * columns.len() < width
268}
269
270/// How many terminal columns `text` takes. A CJK character or an emoji takes two, so counting
271/// `char`s would leave a table's borders out of line.
272fn columns(text: &str) -> usize {
273    UnicodeWidthStr::width(text)
274}
275
276/// `text` in a cell `width` columns wide, with the spaces to fill it.
277fn pad(text: &str, width: usize) -> String {
278    format!("{text}{}", " ".repeat(width.saturating_sub(columns(text))))
279}
280
281/// `text` in lines of at most `width` columns, split between words, and inside a word too long
282/// for a line of its own.
283fn wrap(text: &str, width: usize) -> Vec<String> {
284    let mut lines: Vec<String> = Vec::new();
285    for word in text.split_whitespace() {
286        let mut word = word;
287        match lines.last_mut() {
288            Some(line) if columns(line) + 1 + columns(word) <= width => {
289                line.push(' ');
290                line.push_str(word);
291                continue;
292            }
293            _ => {}
294        }
295        // A word of its own is cut only when no line could ever hold it, at the last character
296        // that still fits: a character is one or two columns wide, so the cut isn't a count.
297        while columns(word) > width {
298            let mut cut = word.len();
299            let mut so_far = 0;
300            for (at, character) in word.char_indices() {
301                so_far += columns(character.encode_utf8(&mut [0; 4]));
302                if so_far > width {
303                    cut = at;
304                    break;
305                }
306            }
307            let cut = cut.max(word.chars().next().map_or(1, char::len_utf8));
308            lines.push(word[..cut].to_owned());
309            word = &word[cut..];
310        }
311        lines.push(word.to_owned());
312    }
313    if lines.is_empty() {
314        lines.push(String::new());
315    }
316    lines
317}
318
319/// What the call used, and the model that answered.
320pub fn usage(reply: &DecisionResponse) -> String {
321    let usage = &reply.usage;
322    let mut out = format!("{} tokens in, {} out", usage.input_tokens, usage.output_tokens);
323    if let Some(cost) = usage.cost {
324        let _ = write!(out, ", ${cost:.6}");
325    }
326    let _ = writeln!(out, ", {}", reply.model);
327    out
328}
329
330/// A level's description: quoted text, or compact JSON when it's structured.
331fn level_text(level: &Value) -> String {
332    match level {
333        Value::String(text) => format!("\"{text}\""),
334        other => other.to_string(),
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    fn fixture() -> (DecisionResponse, [String; 4]) {
343        let reply = serde_json::from_str(include_str!("../tests/fixtures/decision.json")).unwrap();
344        (reply, ["is_urgent", "department", "frustration", "missing"].map(String::from))
345    }
346
347    #[test]
348    fn prints_text_in_the_order_asked() {
349        let (reply, ids) = fixture();
350        assert_eq!(
351            render(Format::Text, &reply, &ids, 120).unwrap(),
352            "\
353is_urgent    0.95 yes
354department   billing  confidence 0.82  (billing 0.88, technical 0.12, sales 0.00)
355frustration  1.04 of 2, nearest \"Frustrated\"  confidence 0.94
356missing      no answer
357427 tokens in, 73 out, $0.000018, typesafe/jev-1.13-20260917
358"
359        );
360    }
361
362    #[test]
363    fn prints_a_table() {
364        let (reply, ids) = fixture();
365        assert_eq!(
366            render(Format::Table, &reply, &ids, 120).unwrap(),
367            "\
368┌─────────────┬────────┬─────────────────────────────────┬────────────┬─────────────────────────────────────────────┐
369│ question    │ type   │ answer                          │ confidence │ probabilities                               │
370├─────────────┼────────┼─────────────────────────────────┼────────────┼─────────────────────────────────────────────┤
371│ is_urgent   │ noul   │ yes                             │ -          │ yes 0.95                                    │
372│ department  │ choice │ billing                         │ 0.82       │ billing 0.88, technical 0.12, sales 0.00    │
373│ frustration │ score  │ 1.04 of 2, nearest \"Frustrated\" │ 0.94       │ Calm 0.00, Frustrated 0.96, Very angry 0.04 │
374│ missing     │ -      │ no answer                       │ -          │                                             │
375└─────────────┴────────┴─────────────────────────────────┴────────────┴─────────────────────────────────────────────┘
376427 tokens in, 73 out, $0.000018, typesafe/jev-1.13-20260917
377"
378        );
379    }
380
381    #[test]
382    fn fits_a_table_to_a_narrow_terminal() {
383        let (reply, ids) = fixture();
384        for width in [30, 40, 60, 80, 120] {
385            let table = render(Format::Table, &reply, &ids, width).unwrap();
386            // The line about what the call used stands on its own, outside the table.
387            let widest =
388                table.lines().filter(|line| line.starts_with(['┌', '│', '├', '└'])).map(|line| line.chars().count()).max().unwrap();
389            assert!(widest <= width, "{width} columns: a line of {widest}\n{table}");
390            assert!(table.contains("question") && table.contains("answer"), "{width} columns dropped a column it should keep\n{table}");
391        }
392    }
393
394    #[test]
395    fn keeps_the_question_ids_whole_while_anything_else_can_give() {
396        let (reply, ids) = fixture();
397        for width in [30, 40, 60, 80] {
398            let table = render(Format::Table, &reply, &ids, width).unwrap();
399            for id in &ids {
400                assert!(table.contains(id.as_str()), "{width} columns broke up `{id}`\n{table}");
401            }
402        }
403    }
404
405    #[test]
406    fn measures_wide_characters_as_two_columns() {
407        let mut reply: DecisionResponse = serde_json::from_str(include_str!("../tests/fixtures/decision.json")).unwrap();
408        let answer = reply.answers.remove("department").unwrap();
409        reply.answers.insert("緊急度".to_owned(), answer);
410        let ids = ["緊急度".to_owned()];
411        for width in [30, 40, 80] {
412            let table = render(Format::Table, &reply, &ids, width).unwrap();
413            let lines: Vec<&str> = table.lines().filter(|line| line.starts_with(['┌', '│', '├', '└'])).collect();
414            let widest = lines.iter().map(|line| columns(line)).max().unwrap();
415            assert!(widest <= width, "{width} columns: a line of {widest}\n{table}");
416            // Every line of a table is the same width, or its borders don't line up.
417            assert!(lines.iter().all(|line| columns(line) == widest), "{width} columns: ragged borders\n{table}");
418        }
419        assert_eq!(wrap("緊急度です", 4), ["緊急", "度で", "す"]);
420    }
421
422    #[test]
423    fn wraps_words_and_cuts_only_what_cannot_fit() {
424        assert_eq!(wrap("billing 0.88, technical 0.12", 14), ["billing 0.88,", "technical 0.12"]);
425        assert_eq!(wrap("", 5), [""]);
426        assert_eq!(wrap("unsplittable", 5), ["unspl", "ittab", "le"]);
427    }
428
429    /// The fixture's answers through two rules: an urgent, fairly frustrated billing ticket.
430    fn outcome() -> (DecisionResponse, Outcome) {
431        let (reply, _) = fixture();
432        let questions = [
433            ("is_urgent".to_owned(), crate::Question::noul("Urgent?")),
434            ("department".to_owned(), crate::Question::choice("Team?", [("billing", ""), ("technical", ""), ("sales", "")])),
435            ("frustration".to_owned(), crate::Question::score("Frustrated?", ["Calm", "Frustrated", "Very angry"])),
436        ];
437        let rules = crate::rules::Rules::parse(
438            r#"
439            [terms]
440            urgent = "is_urgent"
441            billing = "department.billing"
442            angry = "frustration.Very angry"
443            [[rule]]
444            if = "urgent AND billing"
445            then = "page billing"
446            [[rule]]
447            if = "VERY angry"
448            then = "escalate"
449            "#,
450            questions.iter().map(|(id, question)| (id.as_str(), question)),
451        )
452        .unwrap();
453        let outcome = rules.evaluate(&reply).unwrap();
454        (reply, outcome)
455    }
456
457    #[test]
458    fn prints_an_outcome() {
459        let (reply, outcome) = outcome();
460        assert_eq!(
461            render_outcome(Format::Text, &reply, &outcome, 120).unwrap(),
462            "\
463page billing  0.88  yes
464escalate      0.00
465threshold 0.50
466427 tokens in, 73 out, $0.000018, typesafe/jev-1.13-20260917
467"
468        );
469        assert_eq!(
470            render_outcome(Format::Table, &reply, &outcome, 120).unwrap(),
471            "\
472┌──────────────┬───────┬──────┬───────────────────────────┐
473│ item         │ score │ yes? │ rules                     │
474├──────────────┼───────┼──────┼───────────────────────────┤
475│ page billing │ 0.88  │ yes  │ urgent AND billing = 0.88 │
476│ escalate     │ 0.00  │      │ VERY angry = 0.00         │
477└──────────────┴───────┴──────┴───────────────────────────┘
478threshold 0.50
479427 tokens in, 73 out, $0.000018, typesafe/jev-1.13-20260917
480"
481        );
482        // Narrow, the rules come out and the rest stays whole.
483        let narrow = render_outcome(Format::Table, &reply, &outcome, 34).unwrap();
484        assert!(!narrow.contains("rules") && narrow.contains("page billing"), "{narrow}");
485        // JSON keeps the reply, so a script loses nothing by asking for the outcome.
486        let json: Value = serde_json::from_str(&render_outcome(Format::Json, &reply, &outcome, 120).unwrap()).unwrap();
487        assert_eq!(json["reply"]["answers"]["is_urgent"]["noul"], 0.95);
488        assert_eq!(json["outcome"]["items"][0]["item"], "page billing");
489        assert_eq!(json["outcome"]["items"][0]["yes"], true);
490    }
491
492    #[test]
493    fn prints_json_that_reads_back() {
494        let (reply, ids) = fixture();
495        let json = render(Format::Json, &reply, &ids, 120).unwrap();
496        assert_eq!(serde_json::from_str::<DecisionResponse>(&json).unwrap(), reply);
497        assert!(json.ends_with("}\n"));
498    }
499}