Skip to main content

jev_repl/
format.rs

1//! Turning answers, questions and errors into styled transcript lines.
2
3use ratatui::style::{Color, Modifier, Style};
4use ratatui::text::{Line, Span};
5use serde_json::Value;
6use typesafe::{Answer, Error, Question};
7
8pub const NOUL: Color = Color::Cyan;
9pub const CHOICE: Color = Color::Magenta;
10pub const SCORE: Color = Color::Green;
11pub const DIM: Color = Color::DarkGray;
12pub const WARN: Color = Color::Yellow;
13pub const BAD: Color = Color::Red;
14pub const ACCENT: Color = Color::LightBlue;
15
16pub fn dim(text: impl Into<String>) -> Span<'static> {
17    Span::styled(text.into(), Style::new().fg(DIM))
18}
19
20pub fn plain(text: impl Into<String>) -> Line<'static> {
21    Line::from(text.into())
22}
23
24pub fn styled(text: impl Into<String>, color: Color) -> Line<'static> {
25    Line::from(Span::styled(text.into(), Style::new().fg(color)))
26}
27
28pub fn bold(text: impl Into<String>) -> Span<'static> {
29    Span::styled(text.into(), Style::new().add_modifier(Modifier::BOLD))
30}
31
32/// A probability meter. Eighteen columns is enough to read a distribution at a glance.
33pub fn bar(p: f64, width: usize) -> String {
34    let filled = ((p.clamp(0.0, 1.0) * width as f64).round() as usize).min(width);
35    format!("{}{}", "█".repeat(filled), "░".repeat(width - filled))
36}
37
38pub fn color_for(kind: &str) -> Color {
39    match kind {
40        "noul" => NOUL,
41        "choice" => CHOICE,
42        "score" => SCORE,
43        _ => DIM,
44    }
45}
46
47/// Render one answer: the number, the distribution it came from, and what it means.
48pub fn answer_lines(name: &str, answer: &Answer, threshold: f64) -> Vec<Line<'static>> {
49    let kind = answer.kind();
50    let mut out = vec![Line::from(vec![
51        Span::raw("  "),
52        bold(name.to_owned()),
53        Span::raw("  "),
54        Span::styled(kind.to_owned(), Style::new().fg(color_for(kind))),
55    ])];
56
57    match answer {
58        Answer::Noul(a) => {
59            let yes = a.is_yes(threshold);
60            out.push(Line::from(vec![
61                Span::raw("    "),
62                bold(format!("{:.2}", a.noul)),
63                Span::raw("  "),
64                Span::styled(bar(a.noul, 18), Style::new().fg(NOUL)),
65                Span::raw("  "),
66                Span::styled(
67                    if yes { "yes" } else { "no" }.to_owned(),
68                    Style::new()
69                        .fg(if yes { SCORE } else { DIM })
70                        .add_modifier(Modifier::BOLD),
71                ),
72                dim(format!(" at threshold {threshold:.2}")),
73            ]));
74        }
75        Answer::Choice(a) => {
76            out.push(Line::from(vec![
77                Span::raw("    → "),
78                Span::styled(
79                    a.choice.clone(),
80                    Style::new().fg(CHOICE).add_modifier(Modifier::BOLD),
81                ),
82                Span::raw("   "),
83                dim("confidence "),
84                confidence_span(a.confidence),
85            ]));
86            let ranked = a.ranked();
87            let pad = ranked.iter().map(|(l, _)| l.len()).max().unwrap_or(0);
88            for (label, p) in ranked {
89                out.push(Line::from(vec![
90                    Span::raw("      "),
91                    Span::styled(
92                        format!("{label:pad$}"),
93                        Style::new().fg(if label == a.choice { Color::Reset } else { DIM }),
94                    ),
95                    Span::raw("  "),
96                    Span::raw(format!("{p:.2}")),
97                    Span::raw("  "),
98                    Span::styled(bar(p, 18), Style::new().fg(CHOICE)),
99                ]));
100            }
101        }
102        Answer::Score(a) => {
103            let top = a.legend.keys().next_back().copied().unwrap_or(0);
104            out.push(Line::from(vec![
105                Span::raw("    "),
106                Span::styled(
107                    format!("{:.2}", a.score),
108                    Style::new().fg(SCORE).add_modifier(Modifier::BOLD),
109                ),
110                dim(format!(" of {top}")),
111                Span::raw("   "),
112                dim("confidence "),
113                confidence_span(a.confidence),
114                dim(format!(
115                    "   most likely level {}",
116                    a.most_likely_level()
117                        .map(|l| l.to_string())
118                        .unwrap_or_else(|| "-".into())
119                )),
120            ]));
121            let labels: Vec<(u32, String)> =
122                a.legend.iter().map(|(i, v)| (*i, text_of(v))).collect();
123            let pad = labels
124                .iter()
125                .map(|(_, l)| l.len())
126                .max()
127                .unwrap_or(0)
128                .min(40);
129            for (level, label) in labels {
130                let p = a.probabilities.get(&level).copied().unwrap_or(0.0);
131                let marker = if a.rounded_level() == level {
132                    "▸"
133                } else {
134                    " "
135                };
136                out.push(Line::from(vec![
137                    Span::raw(format!("     {marker} ")),
138                    dim(format!("{level} ")),
139                    Span::styled(format!("{label:pad$}"), Style::new().fg(Color::Reset)),
140                    Span::raw("  "),
141                    Span::raw(format!("{p:.2}")),
142                    Span::raw("  "),
143                    Span::styled(bar(p, 18), Style::new().fg(SCORE)),
144                ]));
145            }
146        }
147        _ => out.push(styled(
148            format!("    (this SDK version does not model {kind} answers; see :last)"),
149            WARN,
150        )),
151    }
152    out
153}
154
155fn confidence_span(c: f64) -> Span<'static> {
156    let color = if c >= 0.6 {
157        SCORE
158    } else if c >= 0.35 {
159        WARN
160    } else {
161        BAD
162    };
163    Span::styled(format!("{c:.2}"), Style::new().fg(color))
164}
165
166/// One line per question, the way it will go on the wire.
167pub fn question_lines(index: usize, name: &str, question: &Question) -> Vec<Line<'static>> {
168    let v = serde_json::to_value(question).unwrap_or(Value::Null);
169    let kind = v.get("type").and_then(Value::as_str).unwrap_or("raw");
170    let instructions = v.get("instructions").map(text_of).unwrap_or_default();
171    let mut lines = vec![Line::from(vec![
172        dim(format!("  {}. ", index + 1)),
173        bold(name.to_owned()),
174        Span::raw("  "),
175        Span::styled(kind.to_owned(), Style::new().fg(color_for(kind))),
176        Span::raw("  "),
177        dim(instructions),
178    ])];
179    match (kind, v.get("criteria")) {
180        ("choice", Some(Value::Object(map))) => {
181            for (label, desc) in map {
182                lines.push(Line::from(vec![
183                    Span::raw("       "),
184                    Span::styled(label.clone(), Style::new().fg(CHOICE)),
185                    dim(match desc {
186                        Value::Null => String::new(),
187                        v => format!(" — {}", text_of(v)),
188                    }),
189                ]));
190            }
191        }
192        ("score", Some(Value::Array(levels))) => {
193            for (i, level) in levels.iter().enumerate() {
194                lines.push(Line::from(vec![
195                    Span::raw("       "),
196                    Span::styled(i.to_string(), Style::new().fg(SCORE)),
197                    dim(format!(" {}", text_of(level))),
198                ]));
199            }
200        }
201        ("noul", Some(Value::Object(map))) => {
202            for (key, v) in map {
203                let label = if key == "true" { "yes" } else { "no" };
204                lines.push(Line::from(vec![
205                    Span::raw("       "),
206                    Span::styled(label.to_owned(), Style::new().fg(NOUL)),
207                    dim(format!(" — {}", text_of(v))),
208                ]));
209            }
210        }
211        _ => {}
212    }
213    lines
214}
215
216/// Errors are part of the lesson: show the variant, what it means, and what to do.
217pub fn error_lines(err: &Error) -> Vec<Line<'static>> {
218    let (variant, advice) = match err {
219        Error::Config(_) => ("Config", "Fix the client settings — :key sets an API key."),
220        Error::InvalidRequest(_) => (
221            "InvalidRequest",
222            "Rejected before anything was sent; nothing reached the API.",
223        ),
224        Error::Api(e) => (
225            "Api",
226            match e.status {
227                401 => "The API key is missing or wrong.",
228                403 => "The key is valid but not allowed to do this.",
229                422 => "The server rejected the body — check the question criteria.",
230                429 => "Rate limited; the SDK already retried with backoff.",
231                s if s >= 500 => "Server-side; the SDK already retried with backoff.",
232                _ => "Non-2xx after retries.",
233            },
234        ),
235        Error::Connection(_) => (
236            "Connection",
237            "No response: DNS, TLS, reset or a dropped body.",
238        ),
239        Error::Timeout(_) => (
240            "Timeout",
241            "An attempt ran past its per-attempt timeout — see :timeout.",
242        ),
243        Error::ResponseValidation(_) => (
244            "ResponseValidation",
245            "A 2xx body was missing required data; field_path points at it.",
246        ),
247        _ => ("Error", "Unhandled variant."),
248    };
249
250    let mut lines = vec![Line::from(vec![
251        Span::styled(
252            format!("  {variant}  "),
253            Style::new().fg(BAD).add_modifier(Modifier::BOLD),
254        ),
255        Span::raw(err.to_string()),
256    ])];
257    if let Error::ResponseValidation(e) = err {
258        lines.push(Line::from(vec![
259            Span::raw("    "),
260            dim(format!("field_path: {}", e.field_path)),
261        ]));
262    }
263    if let Some(api) = err.as_api() {
264        lines.push(Line::from(vec![
265            Span::raw("    "),
266            dim(format!("kind: {:?}", api.kind)),
267            dim(match api.retry_after() {
268                Some(d) => format!("   retry after {:.1}s", d.as_secs_f64()),
269                None => String::new(),
270            }),
271        ]));
272    }
273    if let Some(id) = err.request_id() {
274        lines.push(Line::from(vec![
275            Span::raw("    "),
276            dim(format!("request_id: {id}")),
277        ]));
278    }
279    lines.push(Line::from(vec![Span::raw("    "), dim(advice)]));
280    lines
281}
282
283/// JSON strings read better unquoted; everything else stays JSON.
284pub fn text_of(v: &Value) -> String {
285    match v {
286        Value::String(s) => s.clone(),
287        other => other.to_string(),
288    }
289}