1use ratatui::buffer::Buffer;
48use ratatui::layout::Rect;
49use ratatui::style::{Modifier, Style};
50use ratatui::text::{Line, Span};
51use ratatui::widgets::Widget;
52use zero_engine_client::Evaluation;
53
54use crate::theme::Theme;
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum VerdictSeverity {
61 Pass,
62 Hold,
63 Reject,
64 Unknown,
65}
66
67impl VerdictSeverity {
68 #[must_use]
69 pub fn parse(s: &str) -> Self {
70 match s.trim().to_ascii_uppercase().as_str() {
71 "PASS" | "APPROVE" | "OK" => Self::Pass,
72 "HOLD" | "WAIT" | "PARTIAL" => Self::Hold,
73 "REJECT" | "DENY" | "FAIL" => Self::Reject,
74 _ => Self::Unknown,
75 }
76 }
77
78 #[must_use]
79 const fn label(self) -> &'static str {
80 match self {
81 Self::Pass => "PASS",
82 Self::Hold => "HOLD",
83 Self::Reject => "REJECT",
84 Self::Unknown => "?",
85 }
86 }
87
88 fn style(self, theme: &Theme) -> Style {
89 match self {
90 Self::Pass => Style::default()
91 .fg(theme.primary)
92 .add_modifier(Modifier::BOLD),
93 Self::Hold => Style::default()
94 .fg(theme.caution)
95 .add_modifier(Modifier::BOLD),
96 Self::Reject => Style::default()
97 .fg(theme.alert)
98 .add_modifier(Modifier::BOLD),
99 Self::Unknown => Style::default()
100 .fg(theme.metadata)
101 .add_modifier(Modifier::DIM),
102 }
103 }
104}
105
106#[derive(Debug)]
107pub struct VerdictBlock<'a> {
108 pub evaluation: &'a Evaluation,
109 pub theme: Theme,
110}
111
112impl Widget for VerdictBlock<'_> {
113 fn render(self, area: Rect, buf: &mut Buffer) {
114 if area.height == 0 || area.width == 0 {
115 return;
116 }
117 for y in area.top()..area.bottom() {
120 for x in area.left()..area.right() {
121 buf[(x, y)].set_char(' ');
122 }
123 }
124
125 if self.evaluation.layers.is_empty() && self.evaluation.direction.is_none() {
131 let line = Line::from(vec![Span::styled(
132 " (no verdict — `/evaluate <coin>` to request one)",
133 Style::default().fg(self.theme.metadata),
134 )]);
135 line.render(row(area, 0), buf);
136 return;
137 }
138
139 let sev = VerdictSeverity::parse(self.evaluation.verdict());
140
141 let chip = Span::styled(
143 format!(" {} ", sev.label()),
144 sev.style(&self.theme).add_modifier(Modifier::REVERSED),
145 );
146 let coin = Span::styled(
147 format!(" {}", self.evaluation.coin.as_deref().unwrap_or("?")),
148 Style::default()
149 .fg(self.theme.primary)
150 .add_modifier(Modifier::BOLD),
151 );
152 let conf = Span::styled(
153 format!(" conf {}%", confidence_pct(self.evaluation.conviction)),
154 Style::default().fg(self.theme.metadata),
155 );
156 Line::from(vec![chip, coin, conf]).render(row(area, 0), buf);
157
158 let layer_count = self.evaluation.layers.len();
165 for (i, layer) in self.evaluation.layers.iter().enumerate() {
166 let y = 1 + u16::try_from(i).unwrap_or(u16::MAX);
167 let target_row = row(area, y);
168 if target_row.height == 0 {
169 break;
170 }
171 let is_last = i + 1 == layer_count;
172 let connector = if is_last { "└─ " } else { "├─ " };
173 let status = if layer.passed { "PASS" } else { "REJECT" };
174 let gate_sev = VerdictSeverity::parse(status);
175 Line::from(vec![
176 Span::styled(
177 format!(" {connector}"),
178 Style::default().fg(self.theme.metadata),
179 ),
180 Span::styled(
181 format!("{:<10}", layer.layer),
182 Style::default().fg(self.theme.metadata),
183 ),
184 Span::styled(format!(" : {status}"), gate_sev.style(&self.theme)),
185 ])
186 .render(target_row, buf);
187 }
188
189 let rationale = synthesize_rationale(self.evaluation);
194 if !rationale.is_empty() {
195 let y = 1 + u16::try_from(layer_count).unwrap_or(u16::MAX);
196 let target_row = row(area, y);
197 if target_row.height > 0 {
198 let available = usize::from(target_row.width).saturating_sub(13);
199 let clipped = truncate_with_ellipsis(&rationale, available);
200 Line::from(vec![
201 Span::styled(" rationale: ", Style::default().fg(self.theme.metadata)),
202 Span::styled(clipped, Style::default().fg(self.theme.primary)),
203 ])
204 .render(target_row, buf);
205 }
206 }
207 }
208}
209
210fn synthesize_rationale(e: &Evaluation) -> String {
214 let mut parts: Vec<String> = Vec::new();
215 if let Some(dir) = e.direction.as_deref().filter(|d| !d.is_empty()) {
216 parts.push(format!("direction {dir}"));
217 }
218 if let Some(reg) = e.regime.as_deref().filter(|s| !s.is_empty()) {
219 parts.push(format!("regime {reg}"));
220 }
221 if let Some(cons) = e.consensus {
222 parts.push(format!("consensus {cons}"));
223 }
224 parts.join(" · ")
225}
226
227fn confidence_pct(v: Option<f64>) -> i32 {
228 let Some(x) = v else {
229 return 0;
230 };
231 #[allow(clippy::cast_possible_truncation)]
235 let pct = (x.clamp(0.0, 1.0) * 100.0).round() as i32;
236 pct
237}
238
239fn truncate_with_ellipsis(s: &str, max_chars: usize) -> String {
240 if max_chars == 0 {
241 return String::new();
242 }
243 let total = s.chars().count();
244 if total <= max_chars {
245 return s.to_string();
246 }
247 let keep = max_chars.saturating_sub(1);
249 let prefix: String = s.chars().take(keep).collect();
250 format!("{prefix}…")
251}
252
253fn row(area: Rect, y_offset: u16) -> Rect {
254 let abs_y = area.y.saturating_add(y_offset);
255 if abs_y >= area.bottom() {
256 return Rect {
257 x: area.x,
258 y: area.bottom().saturating_sub(1),
259 width: area.width,
260 height: 0,
261 };
262 }
263 Rect {
264 x: area.x,
265 y: abs_y,
266 width: area.width,
267 height: 1,
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use ratatui::Terminal;
275 use ratatui::backend::TestBackend;
276 use zero_engine_client::models::EvaluationLayer;
277
278 fn render(e: &Evaluation, width: u16, height: u16) -> Vec<String> {
279 let backend = TestBackend::new(width, height);
280 let mut term = Terminal::new(backend).expect("term");
281 term.draw(|f| {
282 let w = VerdictBlock {
283 evaluation: e,
284 theme: Theme::default(),
285 };
286 f.render_widget(w, f.area());
287 })
288 .expect("draw");
289 let buf = term.backend().buffer().clone();
290 (0..buf.area.height)
291 .map(|y| {
292 (0..buf.area.width)
293 .map(|x| buf[(x, y)].symbol().to_string())
294 .collect::<String>()
295 .trim_end()
296 .to_string()
297 })
298 .collect()
299 }
300
301 fn layer(name: &str, passed: bool) -> EvaluationLayer {
302 EvaluationLayer {
303 layer: name.into(),
304 passed,
305 value: serde_json::Value::Null,
306 detail: String::new(),
307 }
308 }
309
310 fn pass_eval() -> Evaluation {
311 Evaluation {
312 coin: Some("BTC".into()),
313 direction: Some("LONG".into()),
314 conviction: Some(0.72),
315 regime: Some("trending".into()),
316 consensus: Some(8),
317 layers: vec![
318 layer("layer_0", true),
319 layer("layer_1", true),
320 layer("layer_2", true),
321 ],
322 ..Default::default()
323 }
324 }
325
326 #[test]
327 fn renders_verdict_coin_and_confidence() {
328 let lines = render(&pass_eval(), 60, 6);
329 assert!(lines[0].contains("PASS"), "verdict chip missing: {lines:?}");
330 assert!(lines[0].contains("BTC"), "coin missing: {lines:?}");
331 assert!(
332 lines[0].contains("conf 72%"),
333 "confidence missing: {lines:?}"
334 );
335 }
336
337 #[test]
338 fn layers_render_in_engine_order_with_tree_connectors() {
339 let lines = render(&pass_eval(), 60, 6);
340 assert!(lines[1].contains("├─ layer_0"), "row1 wrong: {lines:?}");
341 assert!(lines[2].contains("├─ layer_1"), "row2 wrong: {lines:?}");
342 assert!(
343 lines[3].contains("└─ layer_2"),
344 "row3 wrong (last): {lines:?}"
345 );
346 }
347
348 #[test]
349 fn rejected_layer_marks_overall_verdict_reject() {
350 let mut e = pass_eval();
351 e.layers[1].passed = false;
352 let lines = render(&e, 60, 6);
353 assert!(
354 lines[0].contains("REJECT"),
355 "overall verdict should flip to REJECT when any layer fails: {lines:?}"
356 );
357 }
358
359 #[test]
360 fn rationale_synthesizes_from_direction_regime_consensus() {
361 let lines = render(&pass_eval(), 60, 6);
362 let rat = lines
363 .iter()
364 .find(|l| l.contains("rationale:"))
365 .expect("rationale row must render");
366 assert!(rat.contains("direction LONG"), "direction missing: {rat:?}");
367 assert!(rat.contains("regime trending"), "regime missing: {rat:?}");
368 assert!(rat.contains("consensus 8"), "consensus missing: {rat:?}");
369 }
370
371 #[test]
372 fn long_rationale_truncates_to_fit() {
373 let mut e = pass_eval();
374 e.regime = Some("x".repeat(500));
375 let lines = render(&e, 40, 6);
376 let rat = lines
377 .iter()
378 .find(|l| l.contains("rationale:"))
379 .expect("rationale row must render");
380 assert!(rat.contains('…'), "long rationale must ellipsize: {rat:?}");
381 assert!(
382 rat.chars().count() <= 40,
383 "rationale must fit within width: {rat:?}"
384 );
385 }
386
387 #[test]
388 fn missing_verdict_renders_honest_empty_row() {
389 let e = Evaluation::default();
390 let lines = render(&e, 60, 3);
391 assert!(
392 lines[0].contains("no verdict"),
393 "expected honest empty state: {lines:?}"
394 );
395 for needle in ["PASS", "REJECT", "├─", "conf "] {
396 for line in &lines {
397 assert!(!line.contains(needle), "fake {needle} leaked: {line:?}");
398 }
399 }
400 }
401
402 #[test]
403 fn hold_when_all_pass_but_direction_none() {
404 let mut e = pass_eval();
405 e.direction = Some("NONE".into());
406 let lines = render(&e, 60, 6);
407 assert!(
408 lines[0].contains("HOLD"),
409 "direction=NONE with all layers passing should be HOLD: {lines:?}"
410 );
411 }
412
413 #[test]
414 fn confidence_clamps_out_of_range_values() {
415 assert_eq!(confidence_pct(Some(-0.2)), 0);
416 assert_eq!(confidence_pct(Some(1.4)), 100);
417 assert_eq!(confidence_pct(None), 0);
418 assert_eq!(confidence_pct(Some(0.5)), 50);
419 }
420
421 #[test]
422 fn verdict_severity_parses_common_strings() {
423 assert_eq!(VerdictSeverity::parse("PASS"), VerdictSeverity::Pass);
424 assert_eq!(VerdictSeverity::parse("pass"), VerdictSeverity::Pass);
425 assert_eq!(VerdictSeverity::parse("HOLD"), VerdictSeverity::Hold);
426 assert_eq!(VerdictSeverity::parse("REJECT"), VerdictSeverity::Reject);
427 assert_eq!(VerdictSeverity::parse(""), VerdictSeverity::Unknown);
428 assert_eq!(VerdictSeverity::parse("idk"), VerdictSeverity::Unknown);
429 }
430}