1//! A call to Jev as a window a visitor can read: the request and the response, printed from the 2//! raw bodies, indented and coloured, and a probability drawn as lmjtfy draws it. This is lmjtfy's 3//! tool-call panel, lifted out so another site shows its own calls the same way. 4//! 5//! The rule it keeps (lmjtfy's, 2026-10-02): **printing may add whitespace and nothing else.** No 6//! reordered keys, no dropped or summarised fields. A site's tests parse what is printed back and 7//! compare it to the body it holds (`printed_json` here does the parsing). 8 9use maud::{Markup, html}; 10use serde_json::Value; 11 12use crate::components::{Icon, pixel_icon}; 13 14/// A JSON body, indented and coloured for reading. Only whitespace is added: keys keep their order 15/// and every value is printed as it was sent. Text that is not JSON is shown as it is. 16pub fn json(text: &str) -> Markup { 17 match serde_json::from_str::<Value>(text) { 18 Ok(value) => html! { pre .json { (node(&value, 0)) } }, 19 Err(_) => html! { pre { (text) } }, 20 } 21} 22 23fn node(value: &Value, depth: usize) -> Markup { 24 let indent = " ".repeat(depth + 1); 25 let close = " ".repeat(depth); 26 match value { 27 Value::Object(map) if !map.is_empty() => html! { 28 "{\n" 29 @for (i, (key, child)) in map.iter().enumerate() { 30 (indent) span .k { (quoted(key)) } ": " (node(child, depth + 1)) 31 @if i + 1 < map.len() { "," } "\n" 32 } 33 (close) "}" 34 }, 35 Value::Array(items) if !items.is_empty() => html! { 36 "[\n" 37 @for (i, child) in items.iter().enumerate() { 38 (indent) (node(child, depth + 1)) 39 @if i + 1 < items.len() { "," } "\n" 40 } 41 (close) "]" 42 }, 43 Value::String(text) => html! { span .s { (quoted(text)) } }, 44 // Numbers, booleans, null, and the empty object and array. 45 other => html! { span .n { (other.to_string()) } }, 46 } 47} 48 49/// A string as JSON writes it, quotes and escapes included. 50fn quoted(text: &str) -> String { 51 Value::String(text.to_owned()).to_string() 52} 53 54/// One labelled block of a call: an optional plain head (the request line and headers) over a body. 55pub struct Panel<'a> { 56 /// `request`, `response`. 57 pub label: &'a str, 58 /// Lines shown as they are above the body, as `POST /v1/systemone`. 59 pub head: Option<&'a str>, 60 pub body: &'a str, 61} 62 63/// A call as a window that folds (`open` or shut): a title bar (`title`, and `meta` at the right) over its 64/// panels. 65pub fn exchange(icon: &Icon, title: &str, meta: &str, open: bool, panels: &[Panel<'_>]) -> Markup { 66 html! { 67 details .win open[open] { 68 summary .bar-title { 69 span .fold {} (pixel_icon(icon, "")) (title) 70 @if !meta.is_empty() { span .meta { (meta) } } 71 } 72 div .body { 73 @for panel in panels { 74 div .wire { 75 span .label-s { (panel.label) } 76 @if let Some(head) = panel.head { pre { (head) } } 77 (json(panel.body)) 78 } 79 } 80 } 81 } 82 } 83} 84 85/// One option of a choice (or the one side of a yes-or-no) and its probability. 86pub struct Bar<'a> { 87 pub label: &'a str, 88 pub p: f64, 89 /// The winner is drawn inverted, with a filled square. 90 pub won: bool, 91} 92 93fn percent(p: f64) -> String { 94 format!("{:.0}%", p * 100.0) 95} 96 97/// Where the square sits on its rule, from a probability. 98fn at(p: f64) -> String { 99 format!("left: {:.1}%", (p * 100.0).clamp(0.0, 100.0)) 100} 101 102/// Probabilities as lmjtfy draws them: an inverted label, the figure at the right, and a rule with 103/// a square at the value. `threshold` draws a tick on every rule where a decision is made on it, 104/// named `threshold_label`. 105pub fn bars(bars: &[Bar<'_>], threshold: Option<(f64, &str)>) -> Markup { 106 html! { 107 div .bars { 108 @for bar in bars { 109 div .bar.won[bar.won] { 110 span .label { (bar.label) } 111 span .pct { (percent(bar.p)) } 112 span .track { 113 @if let Some((t, name)) = threshold { span .cut style=(at(t)) title=(name) {} } 114 span .thumb style=(at(bar.p)) {} 115 } 116 } 117 } 118 } 119 } 120} 121 122/// The JSON a panel printed, read back out of its markup: the text with tags gone and entities 123/// undone. A site's test compares it to the body it passed in. 124pub fn printed_json(markup: &str) -> Option<Value> { 125 let mut text = String::new(); 126 let mut in_tag = false; 127 for ch in markup.chars() { 128 match ch { 129 '<' => in_tag = true, 130 '>' => in_tag = false, 131 c if !in_tag => text.push(c), 132 _ => {} 133 } 134 } 135 let text = text.replace("<", "<").replace(">", ">").replace(""", "\"").replace("&", "&"); 136 serde_json::from_str(&text).ok() 137} 138 139#[cfg(test)] 140mod tests { 141 use super::*; 142 use crate::components::icons; 143 144 const RAW: &str = r#"{"model":"m","questions":{"b":{"type":"noul","instructions":"Is <it> \"ok\" & fine?"}},"state":{"z":1,"a":[true,null,"x"],"e":{},"l":[]}}"#; 145 146 #[test] 147 fn what_is_printed_is_the_body_with_whitespace_added_and_nothing_else() { 148 let html = json(RAW).into_string(); 149 let back = printed_json(&html).expect("the printed text is JSON"); 150 let sent: Value = serde_json::from_str(RAW).unwrap(); 151 assert_eq!(back, sent); 152 // Keys are in the order they were sent, not sorted. 153 assert_eq!(serde_json::to_string(&back).unwrap(), RAW); 154 assert!(html.contains("<span class=\"k\">")); 155 } 156 157 #[test] 158 fn text_that_is_not_json_is_shown_as_it_is() { 159 assert_eq!(json("upstream <down>").into_string(), "<pre>upstream <down></pre>"); 160 } 161 162 #[test] 163 fn a_call_is_a_window_with_a_labelled_panel_for_each_body() { 164 let html = exchange( 165 &icons::JEV, 166 "jev · 2 questions", 167 "200", 168 true, 169 &[Panel { label: "request", head: Some("POST /v1/systemone"), body: RAW }, Panel { label: "response", head: None, body: "{\"a\":1}" }], 170 ) 171 .into_string(); 172 assert!(html.starts_with("<details class=\"win\" open>")); 173 assert!(html.contains("<span class=\"label-s\">request</span>") && html.contains("<span class=\"label-s\">response</span>")); 174 assert!(html.contains("<pre>POST /v1/systemone</pre>")); 175 assert!(html.find("request").unwrap() < html.find("response").unwrap()); 176 } 177 178 #[test] 179 fn a_probability_is_a_square_on_a_rule_and_a_threshold_is_a_tick_on_it() { 180 let html = bars(&[Bar { label: "yes", p: 0.9, won: true }, Bar { label: "no", p: 0.1, won: false }], Some((0.85, "allowed from here"))).into_string(); 181 assert!(html.contains("<span class=\"pct\">90%</span>") && html.contains("left: 90.0%")); 182 assert_eq!(html.matches("class=\"cut\"").count(), 2); 183 assert!(html.contains("left: 85.0%") && html.contains("title=\"allowed from here\"")); 184 assert!(html.contains("class=\"bar won\"")); 185 assert!(!bars(&[Bar { label: "x", p: 2.0, won: false }], None).into_string().contains("left: 200")); 186 } 187}