Skip to main content

scope/display/
terminal.rs

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