Skip to main content

scope/display/
terminal.rs

1//! # Terminal Display Helpers
2//!
3//! Styled output utilities for rich, user-friendly terminal presentation.
4//! Uses `owo-colors` for color and `unicode` box-drawing characters for structure.
5//! All helpers respect non-TTY contexts (piped output) by falling back to plain text.
6//! Long text is automatically word-wrapped to the detected terminal width.
7
8use owo_colors::OwoColorize;
9use std::io::IsTerminal;
10
11// ============================================================================
12// Terminal detection
13// ============================================================================
14
15/// Returns `true` when stdout is an interactive terminal (not piped).
16pub fn is_tty() -> bool {
17    std::io::stdout().is_terminal()
18}
19
20/// Returns the current terminal width in columns.
21///
22/// Uses `crossterm::terminal::size()` when available, falls back to 80 columns.
23pub fn terminal_width() -> usize {
24    crossterm::terminal::size()
25        .map(|(w, _)| w as usize)
26        .unwrap_or(80)
27}
28
29// ============================================================================
30// Word-wrapping engine
31// ============================================================================
32
33/// Word-wrap `text` so each line fits within `content_width` columns.
34///
35/// Returns a `Vec<String>` where the first element is the first line of text
36/// and subsequent elements are continuation lines. Words are split on whitespace;
37/// a word longer than the content width is kept on its own line unbroken (e.g. URLs).
38///
39/// This function operates on the raw text — callers apply the box-drawing prefix
40/// and styling to each returned line.
41pub fn wrap_lines(text: &str, content_width: usize) -> Vec<String> {
42    if content_width == 0 {
43        return vec![text.to_string()];
44    }
45
46    let mut lines: Vec<String> = Vec::new();
47    let mut current_line = String::new();
48    let mut current_len = 0usize;
49
50    for word in text.split_whitespace() {
51        let word_len = word.len();
52
53        if current_len == 0 {
54            // First word on the line — always accept it even if it exceeds width
55            current_line.push_str(word);
56            current_len = word_len;
57        } else if current_len + 1 + word_len <= content_width {
58            // Fits on current line with a space
59            current_line.push(' ');
60            current_line.push_str(word);
61            current_len += 1 + word_len;
62        } else {
63            // Doesn't fit — wrap to next line
64            lines.push(current_line);
65            current_line = word.to_string();
66            current_len = word_len;
67        }
68    }
69
70    if !current_line.is_empty() {
71        lines.push(current_line);
72    }
73
74    if lines.is_empty() {
75        lines.push(String::new());
76    }
77
78    lines
79}
80
81/// Compute the available content width for a given prefix.
82///
83/// `prefix_width` is the number of visible columns consumed by the prefix
84/// (e.g. "│  " = 3, "│      " = 7, "│    • " = 6).
85/// Returns the remaining columns for text content.
86fn content_width_for(prefix_width: usize) -> usize {
87    terminal_width().saturating_sub(prefix_width)
88}
89
90// ============================================================================
91// Color-aware helpers
92// ============================================================================
93
94/// Section header with colored title and box-drawing underline.
95///
96/// ```text
97/// ┌─ Token Health ─────────────────────────────
98/// ```
99pub fn section_header(title: &str) -> String {
100    section_header_styled(title, is_tty())
101}
102
103fn section_header_styled(title: &str, tty: bool) -> String {
104    let width: usize = 50;
105    let pad = width.saturating_sub(title.len() + 4);
106    if tty {
107        format!(
108            "\n{} {} {}",
109            "┌─".cyan(),
110            title.bold().bright_white(),
111            "─".repeat(pad).cyan()
112        )
113    } else {
114        format!("\n┌─ {} {}", title, "─".repeat(pad))
115    }
116}
117
118/// Sub-section header (lighter weight).
119///
120/// ```text
121/// │
122/// ├── DEX Analytics
123/// ```
124pub fn subsection_header(title: &str) -> String {
125    subsection_header_styled(title, is_tty())
126}
127
128fn subsection_header_styled(title: &str, tty: bool) -> String {
129    if tty {
130        format!("{}\n{}", "│".cyan(), format!("├── {}", title.bold()).cyan())
131    } else {
132        format!("│\n├── {}", title)
133    }
134}
135
136/// A key-value row inside a section, with aligned values.
137///
138/// ```text
139/// │  Price            $0.9999
140/// ```
141pub fn kv_row(key: &str, value: &str) -> String {
142    kv_row_styled(key, value, is_tty())
143}
144
145fn kv_row_styled(key: &str, value: &str, tty: bool) -> String {
146    // Prefix: "│  " (3) + key pad (18) = 21 visible columns before value starts
147    let prefix_cols = 21;
148    let avail = terminal_width().saturating_sub(prefix_cols);
149    let wrapped = wrap_lines(value, avail);
150
151    let mut out = if tty {
152        format!("{}  {:<18}{}", "│".cyan(), key.dimmed(), wrapped[0])
153    } else {
154        format!("│  {:<18}{}", key, wrapped[0])
155    };
156
157    // Continuation lines align under the value column
158    let cont_prefix = format!("│  {:<18}", "");
159    for line in &wrapped[1..] {
160        if tty {
161            out.push_str(&format!("\n{}  {:<18}{}", "│".cyan(), "", line));
162        } else {
163            out.push_str(&format!("\n{}{}", cont_prefix, line));
164        }
165    }
166    out
167}
168
169/// A key-value row where the value is colored based on positive/negative.
170pub fn kv_row_delta(key: &str, value: f64, formatted: &str) -> String {
171    kv_row_delta_styled(key, value, formatted, is_tty())
172}
173
174fn kv_row_delta_styled(key: &str, value: f64, formatted: &str, tty: bool) -> String {
175    if tty {
176        let colored_val = if value > 0.0 {
177            format!("{}", formatted.green())
178        } else if value < 0.0 {
179            format!("{}", formatted.red())
180        } else {
181            format!("{}", formatted.dimmed())
182        };
183        format!("{}  {:<18}{}", "│".cyan(), key.dimmed(), colored_val)
184    } else {
185        format!("│  {:<18}{}", key, formatted)
186    }
187}
188
189/// Health check pass line.
190///
191/// ```text
192/// │  ✓ No sells below peg
193/// ```
194pub fn check_pass(msg: &str) -> String {
195    check_pass_styled(msg, is_tty())
196}
197
198fn check_pass_styled(msg: &str, tty: bool) -> String {
199    // Prefix: "│  ✓ " = 5 visible columns
200    let avail = content_width_for(5);
201    let wrapped = wrap_lines(msg, avail);
202
203    let mut out = if tty {
204        format!("{}  {} {}", "│".cyan(), "✓".green(), wrapped[0])
205    } else {
206        format!("│  ✓ {}", wrapped[0])
207    };
208
209    for line in &wrapped[1..] {
210        if tty {
211            out.push_str(&format!("\n{}    {}", "│".cyan(), line));
212        } else {
213            out.push_str(&format!("\n│    {}", line));
214        }
215    }
216    out
217}
218
219/// Health check fail line.
220///
221/// ```text
222/// │  ✗ Bid depth: 0 USDT < 3000 USDT minimum
223/// ```
224pub fn check_fail(msg: &str) -> String {
225    check_fail_styled(msg, is_tty())
226}
227
228fn check_fail_styled(msg: &str, tty: bool) -> String {
229    // Prefix: "│  ✗ " = 5 visible columns
230    let avail = content_width_for(5);
231    let wrapped = wrap_lines(msg, avail);
232
233    let mut out = if tty {
234        format!("{}  {} {}", "│".cyan(), "✗".red(), wrapped[0])
235    } else {
236        format!("│  ✗ {}", wrapped[0])
237    };
238
239    for line in &wrapped[1..] {
240        if tty {
241            out.push_str(&format!("\n{}    {}", "│".cyan(), line));
242        } else {
243            out.push_str(&format!("\n│    {}", line));
244        }
245    }
246    out
247}
248
249/// Overall status line (healthy / unhealthy).
250pub fn status_line(healthy: bool) -> String {
251    status_line_styled(healthy, is_tty())
252}
253
254fn status_line_styled(healthy: bool, tty: bool) -> String {
255    if tty {
256        if healthy {
257            format!("{}  {}", "│".cyan(), "HEALTHY".green().bold())
258        } else {
259            format!("{}  {}", "│".cyan(), "UNHEALTHY".red().bold())
260        }
261    } else if healthy {
262        "│  HEALTHY".to_string()
263    } else {
264        "│  UNHEALTHY".to_string()
265    }
266}
267
268/// Section footer (closing box line).
269///
270/// ```text
271/// └──────────────────────────────────────────────
272/// ```
273pub fn section_footer() -> String {
274    section_footer_styled(is_tty())
275}
276
277fn section_footer_styled(tty: bool) -> String {
278    let line = "─".repeat(50);
279    if tty {
280        format!("{}", format!("└{}", line).cyan())
281    } else {
282        format!("└{}", line)
283    }
284}
285
286/// A separator row inside a section.
287///
288/// ```text
289/// ├──────────────────────────────────────────────
290/// ```
291pub fn separator() -> String {
292    separator_styled(is_tty())
293}
294
295fn separator_styled(tty: bool) -> String {
296    let line = "─".repeat(50);
297    if tty {
298        format!("{}", format!("├{}", line).cyan())
299    } else {
300        format!("├{}", line)
301    }
302}
303
304/// Format a price with color for peg deviation.
305/// Green if within 0.1% of target, yellow if within 0.5%, red otherwise.
306pub fn format_price_peg(price: f64, target: f64) -> String {
307    format_price_peg_styled(price, target, is_tty())
308}
309
310fn format_price_peg_styled(price: f64, target: f64, tty: bool) -> String {
311    let deviation = ((price - target) / target).abs();
312    let text = format!("{:.4}", price);
313    if !tty {
314        return text;
315    }
316    if deviation < 0.001 {
317        format!("{}", text.green())
318    } else if deviation < 0.005 {
319        format!("{}", text.yellow())
320    } else {
321        format!("{}", text.red())
322    }
323}
324
325/// Empty line with continuation bar.
326pub fn blank_row() -> String {
327    blank_row_styled(is_tty())
328}
329
330fn blank_row_styled(tty: bool) -> String {
331    if tty {
332        format!("{}", "│".cyan())
333    } else {
334        "│".to_string()
335    }
336}
337
338/// An order book level row with price coloring relative to peg.
339pub fn orderbook_level(price: f64, quantity: f64, base: &str, value: f64, peg: f64) -> String {
340    orderbook_level_styled(price, quantity, base, value, peg, is_tty())
341}
342
343fn orderbook_level_styled(
344    price: f64,
345    quantity: f64,
346    base: &str,
347    value: f64,
348    peg: f64,
349    tty: bool,
350) -> String {
351    let price_str = format_price_peg_styled(price, peg, tty);
352    if tty {
353        format!(
354            "{}    {}  {:>10.2} {}  {:>10.2} USDT",
355            "│".cyan(),
356            price_str,
357            quantity,
358            base.dimmed(),
359            value
360        )
361    } else {
362        format!(
363            "│    {:.4}  {:>10.2} {}  {:>10.2} USDT",
364            price, quantity, base, value
365        )
366    }
367}
368
369// ============================================================================
370// Score / severity / link helpers
371// ============================================================================
372
373/// A visual score bar with color coding.
374///
375/// ```text
376/// │  Security Score   [████████████────────] 60/100
377/// ```
378pub fn score_bar(label: &str, score: u32, max: u32) -> String {
379    score_bar_styled(label, score, max, is_tty())
380}
381
382fn score_bar_styled(label: &str, score: u32, max: u32, tty: bool) -> String {
383    let width = 20usize;
384    let filled = ((score as f64 / max as f64) * width as f64).round() as usize;
385    let filled = filled.min(width);
386    let empty = width - filled;
387    let bar = format!(
388        "[{}{}] {}/{}",
389        "█".repeat(filled),
390        "─".repeat(empty),
391        score,
392        max
393    );
394    if tty {
395        let colored_bar = if score >= 80 {
396            format!("{}", bar.green())
397        } else if score >= 50 {
398            format!("{}", bar.yellow())
399        } else {
400            format!("{}", bar.red())
401        };
402        format!("{}  {:<18}{}", "│".cyan(), label.dimmed(), colored_bar)
403    } else {
404        format!("│  {:<18}{}", label, bar)
405    }
406}
407
408/// Severity label with color coding.
409///
410/// Returns a colored severity string: Critical (red bold), High (red),
411/// Medium (yellow), Low (cyan), Informational (dimmed).
412pub fn severity_label(severity: &str) -> String {
413    severity_label_styled(severity, is_tty())
414}
415
416fn severity_label_styled(severity: &str, tty: bool) -> String {
417    if !tty {
418        return severity.to_string();
419    }
420    match severity.to_lowercase().as_str() {
421        "critical" => format!("{}", severity.red().bold()),
422        "high" => format!("{}", severity.red()),
423        "medium" => format!("{}", severity.yellow()),
424        "low" => format!("{}", severity.cyan()),
425        _ => format!("{}", severity.dimmed()),
426    }
427}
428
429/// A warning banner inside a section box.
430///
431/// ```text
432/// │  ⚠ WARNING: Source code is NOT verified
433/// ```
434pub fn warning_row(msg: &str) -> String {
435    warning_row_styled(msg, is_tty())
436}
437
438fn warning_row_styled(msg: &str, tty: bool) -> String {
439    // Prefix: "│  ⚠ " = 5 visible columns
440    let avail = content_width_for(5);
441    let wrapped = wrap_lines(msg, avail);
442
443    let mut out = if tty {
444        format!(
445            "{}  {} {}",
446            "│".cyan(),
447            "⚠".yellow().bold(),
448            wrapped[0].yellow()
449        )
450    } else {
451        format!("│  ⚠ {}", wrapped[0])
452    };
453
454    for line in &wrapped[1..] {
455        if tty {
456            out.push_str(&format!("\n{}    {}", "│".cyan(), line.yellow()));
457        } else {
458            out.push_str(&format!("\n│    {}", line));
459        }
460    }
461    out
462}
463
464/// An informational note row inside a section box.
465///
466/// ```text
467/// │  ℹ No heuristic findings triggered.
468/// ```
469pub fn info_row(msg: &str) -> String {
470    info_row_styled(msg, is_tty())
471}
472
473fn info_row_styled(msg: &str, tty: bool) -> String {
474    // Prefix: "│  ℹ " = 5 visible columns
475    let avail = content_width_for(5);
476    let wrapped = wrap_lines(msg, avail);
477
478    let mut out = if tty {
479        format!("{}  {} {}", "│".cyan(), "ℹ".blue(), wrapped[0].dimmed())
480    } else {
481        format!("│  ℹ {}", wrapped[0])
482    };
483
484    for line in &wrapped[1..] {
485        if tty {
486            out.push_str(&format!("\n{}    {}", "│".cyan(), line.dimmed()));
487        } else {
488            out.push_str(&format!("\n│    {}", line));
489        }
490    }
491    out
492}
493
494/// A link row inside a section box.
495///
496/// ```text
497/// │  Explorer          https://etherscan.io/address/0x...
498/// ```
499pub fn link_row(label: &str, url: &str) -> String {
500    link_row_styled(label, url, is_tty())
501}
502
503fn link_row_styled(label: &str, url: &str, tty: bool) -> String {
504    if tty {
505        format!("{}  {:<18}{}", "│".cyan(), label.dimmed(), url.underline())
506    } else {
507        format!("│  {:<18}{}", label, url)
508    }
509}
510
511/// An indented detail line inside a section (for sub-items like vulnerability descriptions).
512///
513/// ```text
514/// │      Contract source code is not verified.
515/// ```
516pub fn detail_row(msg: &str) -> String {
517    detail_row_styled(msg, is_tty())
518}
519
520fn detail_row_styled(msg: &str, tty: bool) -> String {
521    // Prefix: "│      " = 7 visible columns
522    let avail = content_width_for(7);
523    let wrapped = wrap_lines(msg, avail);
524
525    let mut out = if tty {
526        format!("{}      {}", "│".cyan(), wrapped[0].dimmed())
527    } else {
528        format!("│      {}", wrapped[0])
529    };
530
531    for line in &wrapped[1..] {
532        if tty {
533            out.push_str(&format!("\n{}      {}", "│".cyan(), line.dimmed()));
534        } else {
535            out.push_str(&format!("\n│      {}", line));
536        }
537    }
538    out
539}
540
541/// A bullet-point row inside a section.
542///
543/// ```text
544/// │    • mint (Critical): Can mint tokens
545/// ```
546pub fn bullet_row(msg: &str) -> String {
547    bullet_row_styled(msg, is_tty())
548}
549
550fn bullet_row_styled(msg: &str, tty: bool) -> String {
551    // Prefix: "│    • " = 7 visible columns
552    let avail = content_width_for(7);
553    let wrapped = wrap_lines(msg, avail);
554
555    let mut out = if tty {
556        format!("{}    {} {}", "│".cyan(), "•".dimmed(), wrapped[0])
557    } else {
558        format!("│    • {}", wrapped[0])
559    };
560
561    for line in &wrapped[1..] {
562        if tty {
563            out.push_str(&format!("\n{}      {}", "│".cyan(), line));
564        } else {
565            out.push_str(&format!("\n│      {}", line));
566        }
567    }
568    out
569}
570
571// ============================================================================
572// Table helpers (for columnar data inside sections)
573// ============================================================================
574
575/// Column specification for table formatting.
576pub struct Col<'a> {
577    /// Column header text.
578    pub label: &'a str,
579    /// Minimum width in characters.
580    pub width: usize,
581    /// Alignment: `'<'` for left, `'>'` for right.
582    pub align: char,
583}
584
585/// Format a table header row inside a section box.
586///
587/// ```text
588/// │    Rank      Percent                Balance  Address
589/// │  ─────────────────────────────────────────────────────
590/// ```
591pub fn table_header(cols: &[Col]) -> String {
592    table_header_styled(cols, is_tty())
593}
594
595fn table_header_styled(cols: &[Col], tty: bool) -> String {
596    let mut header = String::new();
597    for col in cols {
598        if col.align == '>' {
599            header.push_str(&format!("{:>width$}  ", col.label, width = col.width));
600        } else {
601            header.push_str(&format!("{:<width$}  ", col.label, width = col.width));
602        }
603    }
604    let header = header.trim_end().to_string();
605    let rule_len = cols.iter().map(|c| c.width + 2).sum::<usize>();
606    let rule = "─".repeat(rule_len);
607
608    if tty {
609        format!(
610            "{}    {}\n{}  {}",
611            "│".cyan(),
612            header.dimmed(),
613            "│".cyan(),
614            rule.cyan()
615        )
616    } else {
617        format!("│    {}\n│  {}", header, rule)
618    }
619}
620
621/// Format a table data row inside a section box.
622///
623/// Each value is aligned according to the column specification.
624///
625/// ```text
626/// │       1     12.50%         1,000,000  0xdAC1...1ec7
627/// ```
628pub fn table_row(cols: &[Col], values: &[&str]) -> String {
629    table_row_styled(cols, values, is_tty())
630}
631
632fn table_row_styled(cols: &[Col], values: &[&str], tty: bool) -> String {
633    let mut row = String::new();
634    for (i, col) in cols.iter().enumerate() {
635        let val = values.get(i).copied().unwrap_or("");
636        if col.align == '>' {
637            row.push_str(&format!("{:>width$}  ", val, width = col.width));
638        } else {
639            row.push_str(&format!("{:<width$}  ", val, width = col.width));
640        }
641    }
642    let row = row.trim_end().to_string();
643
644    if tty {
645        format!("{}    {}", "│".cyan(), row)
646    } else {
647        format!("│    {}", row)
648    }
649}
650
651/// Format an enumerated list item inside a section box.
652///
653/// ```text
654/// │  1. Uniswap ETH/USDC - $1.2M ($500K liq)
655/// ```
656pub fn numbered_row(index: usize, msg: &str) -> String {
657    numbered_row_styled(index, msg, is_tty())
658}
659
660fn numbered_row_styled(index: usize, msg: &str, tty: bool) -> String {
661    // Prefix: "│  N. " = ~6 visible columns (varies with digit count)
662    let prefix_len = 4 + format!("{}.", index).len();
663    let avail = terminal_width().saturating_sub(prefix_len);
664    let wrapped = wrap_lines(msg, avail);
665
666    let num = format!("{}.", index);
667    let mut out = if tty {
668        format!("{}  {} {}", "│".cyan(), num.dimmed(), wrapped[0])
669    } else {
670        format!("│  {} {}", num, wrapped[0])
671    };
672
673    let cont_pad = " ".repeat(num.len() + 1);
674    for line in &wrapped[1..] {
675        if tty {
676            out.push_str(&format!("\n{}  {}{}", "│".cyan(), cont_pad, line));
677        } else {
678            out.push_str(&format!("\n│  {}{}", cont_pad, line));
679        }
680    }
681    out
682}
683
684// ============================================================================
685// Unit Tests
686// ============================================================================
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691
692    #[test]
693    fn test_section_header_contains_title() {
694        let header = section_header("Token Health");
695        assert!(header.contains("Token Health"));
696        assert!(header.contains("┌─"));
697    }
698
699    #[test]
700    fn test_subsection_header_contains_title() {
701        let header = subsection_header("DEX Analytics");
702        assert!(header.contains("DEX Analytics"));
703        assert!(header.contains("├──"));
704    }
705
706    #[test]
707    fn test_kv_row_contains_key_value() {
708        let row = kv_row("Price", "$1.00");
709        assert!(row.contains("Price"));
710        assert!(row.contains("$1.00"));
711        assert!(row.contains("│"));
712    }
713
714    #[test]
715    fn test_kv_row_delta_positive() {
716        let row = kv_row_delta("24h Change", 5.0, "+5.00%");
717        assert!(row.contains("+5.00%"));
718    }
719
720    #[test]
721    fn test_kv_row_delta_negative() {
722        let row = kv_row_delta("24h Change", -3.0, "-3.00%");
723        assert!(row.contains("-3.00%"));
724    }
725
726    #[test]
727    fn test_check_pass() {
728        let line = check_pass("No sells below peg");
729        assert!(line.contains("✓"));
730        assert!(line.contains("No sells below peg"));
731    }
732
733    #[test]
734    fn test_check_fail() {
735        let line = check_fail("Bid depth too low");
736        assert!(line.contains("✗"));
737        assert!(line.contains("Bid depth too low"));
738    }
739
740    #[test]
741    fn test_status_line_healthy() {
742        let line = status_line(true);
743        assert!(line.contains("HEALTHY"));
744    }
745
746    #[test]
747    fn test_status_line_unhealthy() {
748        let line = status_line(false);
749        assert!(line.contains("UNHEALTHY"));
750    }
751
752    #[test]
753    fn test_section_footer() {
754        let footer = section_footer();
755        assert!(footer.contains("└"));
756    }
757
758    #[test]
759    fn test_separator() {
760        let sep = separator();
761        assert!(sep.contains("├"));
762    }
763
764    #[test]
765    fn test_format_price_peg_near() {
766        let s = format_price_peg(1.0001, 1.0);
767        assert!(s.contains("1.0001"));
768    }
769
770    #[test]
771    fn test_format_price_peg_far() {
772        let s = format_price_peg(0.95, 1.0);
773        assert!(s.contains("0.9500"));
774    }
775
776    #[test]
777    fn test_blank_row() {
778        let row = blank_row();
779        assert!(row.contains("│"));
780    }
781
782    #[test]
783    fn test_orderbook_level() {
784        let row = orderbook_level(1.0001, 500.0, "DAI", 500.05, 1.0);
785        assert!(row.contains("DAI"));
786        assert!(row.contains("USDT"));
787    }
788
789    #[test]
790    fn test_kv_row_delta_zero_value() {
791        // Non-TTY: no color, but zero value (else/dimmed branch) still returns formatted string
792        let row = kv_row_delta("Change", 0.0, "0.00%");
793        assert!(row.contains("Change"));
794        assert!(row.contains("0.00%"));
795        assert!(row.contains("│"));
796    }
797
798    #[test]
799    fn test_format_price_peg_moderate_deviation() {
800        // 0.2% deviation: price 1.002, target 1.0 -> in non-TTY returns plain price string
801        let s = format_price_peg(1.002, 1.0);
802        assert!(s.contains("1.0020"));
803    }
804
805    #[test]
806    fn test_orderbook_level_various_prices() {
807        let row_low = orderbook_level(0.9990, 100.0, "DAI", 99.90, 1.0);
808        let row_mid = orderbook_level(1.0000, 100.0, "DAI", 100.0, 1.0);
809        let row_high = orderbook_level(1.0015, 100.0, "DAI", 100.15, 1.0);
810        assert!(row_low.contains("0.9990"));
811        assert!(row_mid.contains("1.0000"));
812        assert!(row_high.contains("1.0015"));
813        assert!(row_low.contains("│"));
814        assert!(row_mid.contains("│"));
815        assert!(row_high.contains("│"));
816    }
817
818    #[test]
819    fn test_non_tty_returns_unicode_box_characters() {
820        // In CI (non-TTY), all helpers still emit Unicode box-drawing chars
821        let header = section_header("Test");
822        let sub = subsection_header("Sub");
823        let kv = kv_row("Key", "Val");
824        let pass = check_pass("ok");
825        let fail = check_fail("err");
826        let footer = section_footer();
827        let sep = separator();
828        let blank = blank_row();
829        let status_healthy = status_line(true);
830        let status_unhealthy = status_line(false);
831
832        assert!(header.contains('┌'), "section_header should contain ┌");
833        assert!(header.contains('─'), "section_header should contain ─");
834        assert!(sub.contains('│'), "subsection_header should contain │");
835        assert!(sub.contains('├'), "subsection_header should contain ├");
836        assert!(kv.contains('│'), "kv_row should contain │");
837        assert!(pass.contains('✓'), "check_pass should contain ✓");
838        assert!(fail.contains('✗'), "check_fail should contain ✗");
839        assert!(footer.contains('└'), "section_footer should contain └");
840        assert!(sep.contains('├'), "separator should contain ├");
841        assert!(blank.contains('│'), "blank_row should contain │");
842        assert!(status_healthy.contains("HEALTHY"));
843        assert!(status_unhealthy.contains("UNHEALTHY"));
844    }
845
846    // ============================================================
847    // TTY-branch tests (via _styled variants with tty=true)
848    // ============================================================
849
850    #[test]
851    fn test_section_header_tty() {
852        let header = section_header_styled("Token Health", true);
853        assert!(header.contains("Token Health"));
854        assert!(header.contains("┌─"));
855    }
856
857    #[test]
858    fn test_subsection_header_tty() {
859        let header = subsection_header_styled("DEX", true);
860        assert!(header.contains("DEX"));
861        assert!(header.contains("├──"));
862    }
863
864    #[test]
865    fn test_kv_row_tty() {
866        let row = kv_row_styled("Price", "$1.00", true);
867        assert!(row.contains("Price"));
868        assert!(row.contains("$1.00"));
869    }
870
871    #[test]
872    fn test_kv_row_delta_positive_tty() {
873        let row = kv_row_delta_styled("Change", 5.0, "+5%", true);
874        assert!(row.contains("+5%"));
875    }
876
877    #[test]
878    fn test_kv_row_delta_negative_tty() {
879        let row = kv_row_delta_styled("Change", -3.0, "-3%", true);
880        assert!(row.contains("-3%"));
881    }
882
883    #[test]
884    fn test_kv_row_delta_zero_tty() {
885        let row = kv_row_delta_styled("Change", 0.0, "0.00%", true);
886        assert!(row.contains("0.00%"));
887    }
888
889    #[test]
890    fn test_check_pass_tty() {
891        let line = check_pass_styled("ok", true);
892        assert!(line.contains("✓"));
893        assert!(line.contains("ok"));
894    }
895
896    #[test]
897    fn test_check_fail_tty() {
898        let line = check_fail_styled("err", true);
899        assert!(line.contains("✗"));
900        assert!(line.contains("err"));
901    }
902
903    #[test]
904    fn test_status_line_healthy_tty() {
905        let line = status_line_styled(true, true);
906        assert!(line.contains("HEALTHY"));
907    }
908
909    #[test]
910    fn test_status_line_unhealthy_tty() {
911        let line = status_line_styled(false, true);
912        assert!(line.contains("UNHEALTHY"));
913    }
914
915    #[test]
916    fn test_section_footer_tty() {
917        let footer = section_footer_styled(true);
918        assert!(footer.contains("└"));
919    }
920
921    #[test]
922    fn test_separator_tty() {
923        let sep = separator_styled(true);
924        assert!(sep.contains("├"));
925    }
926
927    #[test]
928    fn test_format_price_peg_tty_near() {
929        let s = format_price_peg_styled(1.0001, 1.0, true);
930        assert!(s.contains("1.0001"));
931    }
932
933    #[test]
934    fn test_format_price_peg_tty_moderate() {
935        let s = format_price_peg_styled(1.003, 1.0, true);
936        assert!(s.contains("1.0030"));
937    }
938
939    #[test]
940    fn test_format_price_peg_tty_far() {
941        let s = format_price_peg_styled(0.95, 1.0, true);
942        assert!(s.contains("0.9500"));
943    }
944
945    #[test]
946    fn test_blank_row_tty() {
947        let row = blank_row_styled(true);
948        assert!(row.contains("│"));
949    }
950
951    #[test]
952    fn test_orderbook_level_tty() {
953        let row = orderbook_level_styled(1.0001, 500.0, "DAI", 500.05, 1.0, true);
954        assert!(row.contains("DAI"));
955        assert!(row.contains("USDT"));
956    }
957
958    // ============================================================
959    // Score / severity / link helper tests (non-TTY)
960    // ============================================================
961
962    #[test]
963    fn test_score_bar_high() {
964        let bar = score_bar("Security Score", 85, 100);
965        assert!(bar.contains("85/100"));
966        assert!(bar.contains("│"));
967        assert!(bar.contains("█"));
968    }
969
970    #[test]
971    fn test_score_bar_low() {
972        let bar = score_bar("Security Score", 20, 100);
973        assert!(bar.contains("20/100"));
974    }
975
976    #[test]
977    fn test_score_bar_zero() {
978        let bar = score_bar("Score", 0, 100);
979        assert!(bar.contains("0/100"));
980        assert!(bar.contains("│"));
981    }
982
983    #[test]
984    fn test_score_bar_max() {
985        let bar = score_bar("Score", 100, 100);
986        assert!(bar.contains("100/100"));
987    }
988
989    #[test]
990    fn test_severity_label_critical() {
991        let label = severity_label("Critical");
992        assert!(label.contains("Critical"));
993    }
994
995    #[test]
996    fn test_severity_label_high() {
997        let label = severity_label("High");
998        assert!(label.contains("High"));
999    }
1000
1001    #[test]
1002    fn test_severity_label_medium() {
1003        let label = severity_label("Medium");
1004        assert!(label.contains("Medium"));
1005    }
1006
1007    #[test]
1008    fn test_severity_label_low() {
1009        let label = severity_label("Low");
1010        assert!(label.contains("Low"));
1011    }
1012
1013    #[test]
1014    fn test_severity_label_informational() {
1015        let label = severity_label("Informational");
1016        assert!(label.contains("Informational"));
1017    }
1018
1019    #[test]
1020    fn test_warning_row() {
1021        let row = warning_row("Source code is NOT verified");
1022        assert!(row.contains("⚠"));
1023        assert!(row.contains("Source code is NOT verified"));
1024        assert!(row.contains("│"));
1025    }
1026
1027    #[test]
1028    fn test_info_row() {
1029        let row = info_row("No findings triggered");
1030        assert!(row.contains("ℹ"));
1031        assert!(row.contains("No findings triggered"));
1032        assert!(row.contains("│"));
1033    }
1034
1035    #[test]
1036    fn test_link_row() {
1037        let row = link_row("Explorer", "https://etherscan.io");
1038        assert!(row.contains("Explorer"));
1039        assert!(row.contains("https://etherscan.io"));
1040        assert!(row.contains("│"));
1041    }
1042
1043    #[test]
1044    fn test_detail_row() {
1045        let row = detail_row("Contract is not verified");
1046        assert!(row.contains("Contract is not verified"));
1047        assert!(row.contains("│"));
1048    }
1049
1050    #[test]
1051    fn test_bullet_row() {
1052        let row = bullet_row("mint (Critical): Can mint tokens");
1053        assert!(row.contains("•"));
1054        assert!(row.contains("mint (Critical): Can mint tokens"));
1055        assert!(row.contains("│"));
1056    }
1057
1058    // ============================================================
1059    // Score / severity / link helper tests (TTY)
1060    // ============================================================
1061
1062    #[test]
1063    fn test_score_bar_tty_high() {
1064        let bar = score_bar_styled("Security Score", 85, 100, true);
1065        assert!(bar.contains("85/100"));
1066        assert!(bar.contains("█"));
1067    }
1068
1069    #[test]
1070    fn test_score_bar_tty_medium() {
1071        let bar = score_bar_styled("Security Score", 55, 100, true);
1072        assert!(bar.contains("55/100"));
1073    }
1074
1075    #[test]
1076    fn test_score_bar_tty_low() {
1077        let bar = score_bar_styled("Security Score", 20, 100, true);
1078        assert!(bar.contains("20/100"));
1079    }
1080
1081    #[test]
1082    fn test_severity_label_tty_critical() {
1083        let label = severity_label_styled("Critical", true);
1084        assert!(label.contains("Critical"));
1085    }
1086
1087    #[test]
1088    fn test_severity_label_tty_low() {
1089        let label = severity_label_styled("Low", true);
1090        assert!(label.contains("Low"));
1091    }
1092
1093    #[test]
1094    fn test_severity_label_tty_unknown() {
1095        let label = severity_label_styled("Unknown", true);
1096        assert!(label.contains("Unknown"));
1097    }
1098
1099    #[test]
1100    fn test_warning_row_tty() {
1101        let row = warning_row_styled("Alert!", true);
1102        assert!(row.contains("⚠"));
1103        assert!(row.contains("Alert!"));
1104    }
1105
1106    #[test]
1107    fn test_info_row_tty() {
1108        let row = info_row_styled("Note", true);
1109        assert!(row.contains("ℹ"));
1110        assert!(row.contains("Note"));
1111    }
1112
1113    #[test]
1114    fn test_link_row_tty() {
1115        let row = link_row_styled("Explorer", "https://example.com", true);
1116        assert!(row.contains("Explorer"));
1117        assert!(row.contains("https://example.com"));
1118    }
1119
1120    #[test]
1121    fn test_detail_row_tty() {
1122        let row = detail_row_styled("Some detail", true);
1123        assert!(row.contains("Some detail"));
1124        assert!(row.contains("│"));
1125    }
1126
1127    #[test]
1128    fn test_bullet_row_tty() {
1129        let row = bullet_row_styled("Item one", true);
1130        assert!(row.contains("•"));
1131        assert!(row.contains("Item one"));
1132    }
1133
1134    // ============================================================
1135    // Word-wrapping engine tests
1136    // ============================================================
1137
1138    #[test]
1139    fn test_wrap_lines_short_text() {
1140        let lines = wrap_lines("hello world", 80);
1141        assert_eq!(lines, vec!["hello world"]);
1142    }
1143
1144    #[test]
1145    fn test_wrap_lines_exact_fit() {
1146        let lines = wrap_lines("abcde fghij", 11);
1147        assert_eq!(lines, vec!["abcde fghij"]);
1148    }
1149
1150    #[test]
1151    fn test_wrap_lines_wraps_at_word_boundary() {
1152        let lines = wrap_lines("hello world foo", 11);
1153        assert_eq!(lines, vec!["hello world", "foo"]);
1154    }
1155
1156    #[test]
1157    fn test_wrap_lines_multiple_wraps() {
1158        let lines = wrap_lines("a b c d e f g", 3);
1159        assert_eq!(lines, vec!["a b", "c d", "e f", "g"]);
1160    }
1161
1162    #[test]
1163    fn test_wrap_lines_long_word_exceeds_width() {
1164        // A single word longer than the width stays on its own line unbroken
1165        let lines = wrap_lines(
1166            "https://etherscan.io/address/0xdAC17F958D2ee523a2206206994597C13D831ec7",
1167            40,
1168        );
1169        assert_eq!(lines.len(), 1);
1170        assert!(lines[0].starts_with("https://"));
1171    }
1172
1173    #[test]
1174    fn test_wrap_lines_long_word_after_short() {
1175        let lines = wrap_lines(
1176            "Explorer: https://etherscan.io/address/0xdAC17F958D2ee523a2206206994597C13D831ec7",
1177            30,
1178        );
1179        assert_eq!(lines.len(), 2);
1180        assert_eq!(lines[0], "Explorer:");
1181        assert!(lines[1].starts_with("https://"));
1182    }
1183
1184    #[test]
1185    fn test_wrap_lines_empty_string() {
1186        let lines = wrap_lines("", 80);
1187        assert_eq!(lines, vec![""]);
1188    }
1189
1190    #[test]
1191    fn test_wrap_lines_zero_width() {
1192        let lines = wrap_lines("hello", 0);
1193        assert_eq!(lines, vec!["hello"]);
1194    }
1195
1196    #[test]
1197    fn test_wrap_lines_single_word() {
1198        let lines = wrap_lines("hello", 80);
1199        assert_eq!(lines, vec!["hello"]);
1200    }
1201
1202    #[test]
1203    fn test_wrap_lines_preserves_all_words() {
1204        let input = "Contract source code is not verified. Full vulnerability analysis requires verified source code.";
1205        let lines = wrap_lines(input, 40);
1206        let reassembled: String = lines.join(" ");
1207        assert_eq!(reassembled, input);
1208    }
1209
1210    #[test]
1211    fn test_terminal_width_returns_positive() {
1212        let w = terminal_width();
1213        assert!(w > 0);
1214    }
1215
1216    #[test]
1217    fn test_content_width_for_reasonable_prefix() {
1218        let w = content_width_for(7);
1219        // Should be terminal_width - 7; at minimum > 0 even in CI
1220        assert!(w > 0 || terminal_width() <= 7);
1221    }
1222
1223    // ============================================================
1224    // Word-wrapping integration tests (helpers produce multiline)
1225    // ============================================================
1226
1227    #[test]
1228    fn test_detail_row_wraps_long_text() {
1229        // detail_row prefix is 7 cols ("│      "); with width=40 that gives 33 cols for content
1230        let long = "Contract source code is not verified and full vulnerability analysis requires verified source code for accurate results";
1231        let row = detail_row_styled(long, false);
1232        // Should contain continuation lines with │ prefix
1233        let line_count = row.lines().count();
1234        // At a typical terminal width this will wrap; in narrow CI it will certainly wrap
1235        assert!(row.contains("│"), "should contain box-drawing prefix");
1236        // All lines should start with │
1237        for line in row.lines() {
1238            assert!(
1239                line.starts_with('│'),
1240                "continuation line should start with │: {}",
1241                line
1242            );
1243        }
1244        // Verify no content is lost
1245        assert!(row.contains("Contract"));
1246        assert!(row.contains("results"));
1247        // If terminal is wide enough, it might not wrap; otherwise verify multiple lines
1248        if terminal_width() < 80 {
1249            assert!(line_count > 1, "should wrap on narrow terminal");
1250        }
1251    }
1252
1253    #[test]
1254    fn test_check_pass_wraps_long_text() {
1255        let long = "No sells detected below the configured peg target during the monitoring window across all tracked pairs";
1256        let row = check_pass_styled(long, false);
1257        assert!(row.contains("✓"));
1258        assert!(row.contains("No sells"));
1259        assert!(row.contains("pairs"));
1260        for line in row.lines() {
1261            assert!(line.starts_with('│'));
1262        }
1263    }
1264
1265    #[test]
1266    fn test_check_fail_wraps_long_text() {
1267        let long = "Bid depth is significantly below the minimum threshold required for healthy market conditions on this trading pair";
1268        let row = check_fail_styled(long, false);
1269        assert!(row.contains("✗"));
1270        for line in row.lines() {
1271            assert!(line.starts_with('│'));
1272        }
1273    }
1274
1275    #[test]
1276    fn test_warning_row_wraps_long_text() {
1277        let long = "Source code is NOT verified — unable to perform source-level analysis. Consider requesting verification.";
1278        let row = warning_row_styled(long, false);
1279        assert!(row.contains("⚠"));
1280        for line in row.lines() {
1281            assert!(line.starts_with('│'));
1282        }
1283    }
1284
1285    #[test]
1286    fn test_info_row_wraps_long_text() {
1287        let long = "No audit reports found. Check block explorer and auditor databases manually for third-party audit information.";
1288        let row = info_row_styled(long, false);
1289        assert!(row.contains("ℹ"));
1290        for line in row.lines() {
1291            assert!(line.starts_with('│'));
1292        }
1293    }
1294
1295    #[test]
1296    fn test_bullet_row_wraps_long_text() {
1297        let long = "Uniswap V3 integration detected with slippage protection enabled and deadline protection enabled for all swap calls";
1298        let row = bullet_row_styled(long, false);
1299        assert!(row.contains("•"));
1300        for line in row.lines() {
1301            assert!(line.starts_with('│'));
1302        }
1303    }
1304
1305    #[test]
1306    fn test_kv_row_wraps_long_value() {
1307        let long_val = "Verified contract with comprehensive access controls and multiple security features including role-based permissions";
1308        let row = kv_row_styled("Summary", long_val, false);
1309        assert!(row.contains("Summary"));
1310        assert!(row.contains("Verified"));
1311        assert!(row.contains("permissions"));
1312        for line in row.lines() {
1313            assert!(line.starts_with('│'));
1314        }
1315    }
1316
1317    #[test]
1318    fn test_kv_row_short_value_no_wrap() {
1319        let row = kv_row_styled("Chain", "ethereum", false);
1320        assert_eq!(row.lines().count(), 1);
1321        assert!(row.contains("Chain"));
1322        assert!(row.contains("ethereum"));
1323    }
1324
1325    #[test]
1326    fn test_detail_row_wraps_tty() {
1327        let long = "Contract source code is not verified and full vulnerability analysis requires verified source code for accurate results";
1328        let row = detail_row_styled(long, true);
1329        assert!(row.contains("│"));
1330        assert!(row.contains("Contract"));
1331        assert!(row.contains("results"));
1332    }
1333
1334    #[test]
1335    fn test_bullet_row_wraps_tty() {
1336        let long = "Uniswap V3 integration detected with slippage protection enabled and deadline protection enabled for all swap calls";
1337        let row = bullet_row_styled(long, true);
1338        assert!(row.contains("•"));
1339        assert!(row.contains("Uniswap"));
1340        assert!(row.contains("calls"));
1341    }
1342
1343    #[test]
1344    fn test_kv_row_wraps_tty() {
1345        let long_val = "Verified contract with comprehensive access controls and multiple security features including role-based permissions";
1346        let row = kv_row_styled("Summary", long_val, true);
1347        assert!(row.contains("Summary"));
1348        assert!(row.contains("Verified"));
1349        assert!(row.contains("permissions"));
1350    }
1351
1352    // ============================================================
1353    // Table / numbered row helper tests
1354    // ============================================================
1355
1356    #[test]
1357    fn test_table_header_contains_labels() {
1358        let cols = &[
1359            Col {
1360                label: "Rank",
1361                width: 6,
1362                align: '>',
1363            },
1364            Col {
1365                label: "Name",
1366                width: 20,
1367                align: '<',
1368            },
1369        ];
1370        let header = table_header(cols);
1371        assert!(header.contains("Rank"));
1372        assert!(header.contains("Name"));
1373        assert!(header.contains("│"));
1374        assert!(header.contains("─"));
1375    }
1376
1377    #[test]
1378    fn test_table_row_contains_values() {
1379        let cols = &[
1380            Col {
1381                label: "Rank",
1382                width: 6,
1383                align: '>',
1384            },
1385            Col {
1386                label: "Name",
1387                width: 20,
1388                align: '<',
1389            },
1390        ];
1391        let row = table_row(cols, &["1", "TestToken"]);
1392        assert!(row.contains("1"));
1393        assert!(row.contains("TestToken"));
1394        assert!(row.contains("│"));
1395    }
1396
1397    #[test]
1398    fn test_table_row_missing_values() {
1399        let cols = &[
1400            Col {
1401                label: "A",
1402                width: 5,
1403                align: '<',
1404            },
1405            Col {
1406                label: "B",
1407                width: 5,
1408                align: '<',
1409            },
1410        ];
1411        let row = table_row(cols, &["only"]);
1412        assert!(row.contains("only"));
1413        assert!(row.contains("│"));
1414    }
1415
1416    #[test]
1417    fn test_table_header_tty() {
1418        let cols = &[Col {
1419            label: "Price",
1420            width: 10,
1421            align: '>',
1422        }];
1423        let header = table_header_styled(cols, true);
1424        assert!(header.contains("Price"));
1425        assert!(header.contains("│"));
1426    }
1427
1428    #[test]
1429    fn test_table_row_tty() {
1430        let cols = &[Col {
1431            label: "Price",
1432            width: 10,
1433            align: '>',
1434        }];
1435        let row = table_row_styled(cols, &["$1.00"], true);
1436        assert!(row.contains("$1.00"));
1437        assert!(row.contains("│"));
1438    }
1439
1440    #[test]
1441    fn test_numbered_row_basic() {
1442        let row = numbered_row(1, "First item");
1443        assert!(row.contains("1."));
1444        assert!(row.contains("First item"));
1445        assert!(row.contains("│"));
1446    }
1447
1448    #[test]
1449    fn test_numbered_row_wraps() {
1450        let long = "This is a very long description that should eventually wrap to the next line when the terminal width is narrow enough";
1451        let row = numbered_row(1, long);
1452        assert!(row.contains("1."));
1453        assert!(row.contains("This"));
1454        assert!(row.contains("enough"));
1455        for line in row.lines() {
1456            assert!(line.contains('│'));
1457        }
1458    }
1459
1460    #[test]
1461    fn test_numbered_row_tty() {
1462        let row = numbered_row_styled(5, "Fifth item", true);
1463        assert!(row.contains("5."));
1464        assert!(row.contains("Fifth item"));
1465    }
1466
1467    #[test]
1468    fn test_numbered_row_double_digits() {
1469        let row = numbered_row(12, "Twelfth item");
1470        assert!(row.contains("12."));
1471        assert!(row.contains("Twelfth item"));
1472    }
1473
1474    // ── TTY wrapping continuation tests ──
1475    // Exercise the tty=true branch for multi-line wrapping in each helper.
1476
1477    #[test]
1478    fn test_check_pass_wraps_tty() {
1479        let long = "No sells detected below the configured peg target during the monitoring window across all tracked pairs and venues in scope";
1480        let row = check_pass_styled(long, true);
1481        assert!(row.contains("✓"));
1482        assert!(row.lines().count() > 1, "should wrap to multiple lines");
1483    }
1484
1485    #[test]
1486    fn test_check_fail_wraps_tty() {
1487        let long = "Bid depth is significantly below the minimum threshold required for healthy market conditions on this particular trading pair";
1488        let row = check_fail_styled(long, true);
1489        assert!(row.contains("✗"));
1490        assert!(row.lines().count() > 1, "should wrap to multiple lines");
1491    }
1492
1493    #[test]
1494    fn test_warning_row_wraps_tty() {
1495        let long = "Source code is NOT verified — unable to perform full source-level analysis on this contract. Consider requesting verification from the deployer.";
1496        let row = warning_row_styled(long, true);
1497        assert!(row.contains("⚠"));
1498        assert!(row.lines().count() > 1, "should wrap to multiple lines");
1499    }
1500
1501    #[test]
1502    fn test_info_row_wraps_tty() {
1503        let long = "No audit reports found in any public database. Check block explorer and auditor databases manually for third-party audit information and verification.";
1504        let row = info_row_styled(long, true);
1505        assert!(row.contains("ℹ"));
1506        assert!(row.lines().count() > 1, "should wrap to multiple lines");
1507    }
1508
1509    #[test]
1510    fn test_numbered_row_wraps_tty() {
1511        let long = "This is a very long description that should eventually wrap to the next line when the terminal width is narrow enough to force it";
1512        let row = numbered_row_styled(1, long, true);
1513        assert!(row.contains("1."));
1514        assert!(row.lines().count() > 1, "should wrap to multiple lines");
1515    }
1516}