1use std::fmt::Write;
5
6use crate::rules::Outcome;
7use crate::{Answer, DecisionResponse};
8use serde_json::{json, Value};
9use unicode_width::UnicodeWidthStr;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Format {
14 Text,
16 Table,
18 Json,
20}
21
22pub 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
32pub 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
43struct 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
108fn 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 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
130const COLUMNS: [&str; 5] = ["question", "type", "answer", "confidence", "probabilities"];
133const DROP_ORDER: [usize; 3] = [4, 1, 3];
134
135const SQUEEZE_ORDER: [usize; 5] = [4, 3, 1, 2, 0];
138
139fn 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
151const 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
157fn 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 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
183fn 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 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 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 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 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
257fn 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
264fn 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
270fn columns(text: &str) -> usize {
273 UnicodeWidthStr::width(text)
274}
275
276fn pad(text: &str, width: usize) -> String {
278 format!("{text}{}", " ".repeat(width.saturating_sub(columns(text))))
279}
280
281fn 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 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
319pub 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
330fn 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 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 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 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 let narrow = render_outcome(Format::Table, &reply, &outcome, 34).unwrap();
484 assert!(!narrow.contains("rules") && narrow.contains("page billing"), "{narrow}");
485 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}