1use super::theme::{Painter, Role};
10use crate::market::health::{PegBand, PegBands};
11use std::sync::atomic::{AtomicUsize, Ordering};
12use unicode_width::UnicodeWidthStr;
13
14pub use super::theme::{
20 color_enabled, color_enabled_stderr, color_enabled_with, set_color_override,
21};
22
23static WIDTH_OVERRIDE: AtomicUsize = AtomicUsize::new(0);
26
27pub fn set_width_override(width: Option<usize>) {
31 WIDTH_OVERRIDE.store(width.unwrap_or(0), Ordering::Relaxed);
32}
33
34pub fn width_override() -> Option<usize> {
36 match WIDTH_OVERRIDE.load(Ordering::Relaxed) {
37 0 => None,
38 w => Some(w),
39 }
40}
41
42pub fn terminal_width() -> usize {
48 width_or_real(width_override())
49}
50
51fn 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#[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
68const RULE_WIDTH: usize = 50;
70
71fn 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
83pub 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 current_line.push_str(word);
110 current_len = word_len;
111 } else if current_len + 1 + word_len <= content_width {
112 current_line.push(' ');
114 current_line.push_str(word);
115 current_len += 1 + word_len;
116 } else {
117 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
135fn content_width_for(prefix_width: usize) -> usize {
141 terminal_width().saturating_sub(prefix_width)
142}
143
144const MARK_PREFIX_COLS: usize = 5;
150
151const LABEL_COLS: usize = 18;
153
154fn rail(p: Painter) -> String {
156 p.paint(Role::Accent, "│")
157}
158
159fn label(p: Painter, text: &str) -> String {
162 p.paint(Role::Secondary, &format!("{:<LABEL_COLS$}", text))
163}
164
165fn 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
176pub 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
189fn section_header_at(title: &str, tty: bool, cap: Option<usize>) -> String {
191 let p = Painter::new(tty);
192 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
203pub 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
223pub 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 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 for line in &wrapped[1..] {
242 out.push_str(&format!("\n{} {:<LABEL_COLS$}{}", rail(p), "", line));
243 }
244 out
245}
246
247pub 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
264pub 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
278pub 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
292pub 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
306pub 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
320pub fn labelled_warn(label: &str, label_cols: usize, detail: &str) -> String {
328 labelled_warn_styled(label, label_cols, detail, color_enabled())
329}
330
331fn labelled_warn_styled(label: &str, label_cols: usize, detail: &str, tty: bool) -> String {
332 let p = Painter::new(tty);
333 let indent = MARK_PREFIX_COLS + label_cols;
334 let wrapped = wrap_lines(detail, content_width_for(indent));
335 let mut out = format!(
336 "{} {} {}{}",
337 rail(p),
338 p.paint_strong(Role::Warn, "!"),
339 p.paint(Role::Warn, &format!("{label:<label_cols$}")),
340 wrapped[0]
341 );
342 for line in &wrapped[1..] {
343 out.push_str(&format!(
344 "\n{} {:indent$}{line}",
345 rail(p),
346 "",
347 indent = indent - 2
348 ));
349 }
350 out
351}
352
353pub fn status_line(healthy: bool) -> String {
355 status_line_styled(healthy, color_enabled())
356}
357
358fn status_line_styled(healthy: bool, tty: bool) -> String {
359 let p = Painter::new(tty);
360 let verdict = if healthy {
361 p.paint_strong(Role::Ok, "HEALTHY")
362 } else {
363 p.paint_strong(Role::Err, "UNHEALTHY")
364 };
365 format!("{} {}", rail(p), verdict)
366}
367
368pub fn section_footer() -> String {
374 section_footer_styled(color_enabled())
375}
376
377fn section_footer_styled(tty: bool) -> String {
378 section_footer_at(tty, width_override())
379}
380
381fn section_footer_at(tty: bool, cap: Option<usize>) -> String {
383 let line = rule_at(RULE_WIDTH, 1, cap);
384 Painter::new(tty).paint(Role::Accent, &format!("└{}", line))
385}
386
387pub fn separator() -> String {
393 separator_styled(color_enabled())
394}
395
396fn separator_styled(tty: bool) -> String {
397 separator_at(tty, width_override())
398}
399
400fn separator_at(tty: bool, cap: Option<usize>) -> String {
402 let line = rule_at(RULE_WIDTH, 1, cap);
403 Painter::new(tty).paint(Role::Accent, &format!("├{}", line))
404}
405
406pub fn format_price_peg(price: f64, peg: &PegBands) -> String {
410 format_price_peg_styled(price, peg, color_enabled())
411}
412
413fn format_price_peg_styled(price: f64, peg: &PegBands, tty: bool) -> String {
414 let role = match peg.classify(price) {
415 PegBand::OnPeg => Role::Ok,
416 PegBand::Slight => Role::Warn,
417 PegBand::Off => Role::Err,
418 };
419 let decimals = crate::display::format::price_decimals(peg.target());
421 Painter::new(tty).paint(role, &format!("{price:.decimals$}"))
422}
423
424#[cfg(test)]
426fn usd_peg() -> PegBands {
427 crate::market::health::HealthThresholds::default().peg_bands()
428}
429
430pub fn blank_row() -> String {
432 blank_row_styled(color_enabled())
433}
434
435fn blank_row_styled(tty: bool) -> String {
436 rail(Painter::new(tty))
437}
438
439pub fn painter() -> Painter {
441 Painter::new(color_enabled())
442}
443
444pub fn chart_row(line: &str) -> String {
451 format!("{} {line}", rail(painter()))
452}
453
454pub fn orderbook_level(
456 price: f64,
457 quantity: f64,
458 base: &str,
459 value: f64,
460 quote: &str,
461 peg: &PegBands,
462) -> String {
463 orderbook_level_styled(price, quantity, base, value, quote, peg, color_enabled())
464}
465
466fn orderbook_level_styled(
467 price: f64,
468 quantity: f64,
469 base: &str,
470 value: f64,
471 quote: &str,
472 peg: &PegBands,
473 tty: bool,
474) -> String {
475 let p = Painter::new(tty);
476 format!(
477 "{} {} {:>10.2} {} {:>10.2} {quote}",
478 rail(p),
479 format_price_peg_styled(price, peg, tty),
480 quantity,
481 p.paint(Role::Secondary, base),
482 value
483 )
484}
485
486pub fn score_bar(label: &str, score: u32, max: u32) -> String {
496 score_bar_styled(label, score, max, color_enabled())
497}
498
499fn score_bar_styled(label_text: &str, score: u32, max: u32, tty: bool) -> String {
500 let p = Painter::new(tty);
501 let width = 20usize;
502 let filled = ((score as f64 / max as f64) * width as f64).round() as usize;
503 let filled = filled.min(width);
504 let empty = width - filled;
505 let bar = format!(
506 "[{}{}] {}/{}",
507 "█".repeat(filled),
508 "─".repeat(empty),
509 score,
510 max
511 );
512 let role = if score >= 80 {
513 Role::Ok
514 } else if score >= 50 {
515 Role::Warn
516 } else {
517 Role::Err
518 };
519 format!(
520 "{} {}{}",
521 rail(p),
522 label(p, label_text),
523 p.paint(role, &bar)
524 )
525}
526
527pub fn severity_label(severity: &str) -> String {
532 severity_label_styled(severity, color_enabled())
533}
534
535fn severity_label_styled(severity: &str, tty: bool) -> String {
536 let p = Painter::new(tty);
537 match severity.to_lowercase().as_str() {
538 "critical" => p.paint_strong(Role::Err, severity),
539 "high" => p.paint(Role::Err, severity),
540 "medium" => p.paint(Role::Warn, severity),
541 "low" => p.paint(Role::Accent, severity),
542 _ => p.paint(Role::Secondary, severity),
543 }
544}
545
546pub fn warning_row(msg: &str) -> String {
552 warning_row_styled(msg, color_enabled())
553}
554
555fn warning_row_styled(msg: &str, tty: bool) -> String {
556 let p = Painter::new(tty);
557 marked_row(p, p.paint_strong(Role::Warn, "⚠"), Role::Warn, msg)
558}
559
560pub fn info_row(msg: &str) -> String {
566 info_row_styled(msg, color_enabled())
567}
568
569fn info_row_styled(msg: &str, tty: bool) -> String {
570 let p = Painter::new(tty);
571 marked_row(p, p.paint(Role::Info, "ℹ"), Role::Secondary, msg)
572}
573
574pub fn link_row(label: &str, url: &str) -> String {
580 link_row_styled(label, url, color_enabled())
581}
582
583fn link_row_styled(label_text: &str, url: &str, tty: bool) -> String {
584 let p = Painter::new(tty);
585 format!(
586 "{} {}{}",
587 rail(p),
588 label(p, label_text),
589 p.paint(Role::Link, url)
590 )
591}
592
593pub fn detail_row(msg: &str) -> String {
599 detail_row_styled(msg, color_enabled())
600}
601
602fn detail_row_styled(msg: &str, tty: bool) -> String {
603 let p = Painter::new(tty);
604 let avail = content_width_for(7);
606 let wrapped = wrap_lines(msg, avail);
607
608 let mut out = String::new();
609 for (i, line) in wrapped.iter().enumerate() {
610 if i > 0 {
611 out.push('\n');
612 }
613 out.push_str(&format!(
614 "{} {}",
615 rail(p),
616 p.paint(Role::Secondary, line)
617 ));
618 }
619 out
620}
621
622pub fn bullet_row(msg: &str) -> String {
628 bullet_row_styled(msg, color_enabled())
629}
630
631fn bullet_row_styled(msg: &str, tty: bool) -> String {
632 let p = Painter::new(tty);
633 let avail = content_width_for(7);
635 let wrapped = wrap_lines(msg, avail);
636
637 let mut out = format!(
638 "{} {} {}",
639 rail(p),
640 p.paint(Role::Secondary, "•"),
641 wrapped[0]
642 );
643 for line in &wrapped[1..] {
644 out.push_str(&format!("\n{} {}", rail(p), line));
645 }
646 out
647}
648
649pub struct Col<'a> {
655 pub label: &'a str,
657 pub width: usize,
659 pub align: char,
661}
662
663pub fn table_header(cols: &[Col]) -> String {
670 table_header_styled(cols, color_enabled())
671}
672
673fn table_header_styled(cols: &[Col], tty: bool) -> String {
674 table_header_at(cols, tty, width_override())
675}
676
677fn table_header_at(cols: &[Col], tty: bool, cap: Option<usize>) -> String {
679 let p = Painter::new(tty);
680 let mut header = String::new();
681 for col in cols {
682 if col.align == '>' {
683 header.push_str(&format!("{:>width$} ", col.label, width = col.width));
684 } else {
685 header.push_str(&format!("{:<width$} ", col.label, width = col.width));
686 }
687 }
688 let header = header.trim_end().to_string();
689 let rule_len = cols.iter().map(|c| c.width + 2).sum::<usize>();
690 let line = rule_at(rule_len, 3, cap);
691
692 format!(
693 "{} {}\n{} {}",
694 rail(p),
695 p.paint_strong(Role::Accent, &header),
696 rail(p),
697 p.paint(Role::Accent, &line)
698 )
699}
700
701pub fn table_row(cols: &[Col], values: &[&str]) -> String {
709 table_row_styled(cols, values, color_enabled())
710}
711
712fn table_row_styled(cols: &[Col], values: &[&str], tty: bool) -> String {
713 let mut row = String::new();
714 for (i, col) in cols.iter().enumerate() {
715 let val = values.get(i).copied().unwrap_or("");
716 if col.align == '>' {
717 row.push_str(&format!("{:>width$} ", val, width = col.width));
718 } else {
719 row.push_str(&format!("{:<width$} ", val, width = col.width));
720 }
721 }
722 let row = row.trim_end().to_string();
723 format!("{} {}", rail(Painter::new(tty)), row)
724}
725
726pub fn numbered_row(index: usize, msg: &str) -> String {
732 numbered_row_styled(index, msg, color_enabled())
733}
734
735fn numbered_row_styled(index: usize, msg: &str, tty: bool) -> String {
736 let p = Painter::new(tty);
737 let prefix_len = 4 + format!("{}.", index).len();
739 let avail = terminal_width().saturating_sub(prefix_len);
740 let wrapped = wrap_lines(msg, avail);
741
742 let num = format!("{}.", index);
743 let mut out = format!(
744 "{} {} {}",
745 rail(p),
746 p.paint(Role::Secondary, &num),
747 wrapped[0]
748 );
749 let cont_pad = " ".repeat(num.len() + 1);
750 for line in &wrapped[1..] {
751 out.push_str(&format!("\n{} {}{}", rail(p), cont_pad, line));
752 }
753 out
754}
755
756#[cfg(test)]
761mod golden_tests;
762
763#[cfg(test)]
764mod tests {
765 use super::*;
766
767 #[test]
768 fn test_section_header_contains_title() {
769 let header = section_header("Token Health");
770 assert!(header.contains("Token Health"));
771 assert!(header.contains("┌─"));
772 }
773
774 #[test]
775 fn test_subsection_header_contains_title() {
776 let header = subsection_header("DEX Analytics");
777 assert!(header.contains("DEX Analytics"));
778 assert!(header.contains("├──"));
779 }
780
781 #[test]
782 fn test_kv_row_contains_key_value() {
783 let row = kv_row("Price", "$1.00");
784 assert!(row.contains("Price"));
785 assert!(row.contains("$1.00"));
786 assert!(row.contains("│"));
787 }
788
789 #[test]
790 fn test_kv_row_delta_positive() {
791 let row = kv_row_delta("24h Change", 5.0, "+5.00%");
792 assert!(row.contains("+5.00%"));
793 }
794
795 #[test]
796 fn test_kv_row_delta_negative() {
797 let row = kv_row_delta("24h Change", -3.0, "-3.00%");
798 assert!(row.contains("-3.00%"));
799 }
800
801 #[test]
802 fn test_check_pass() {
803 let line = check_pass("No sells below peg");
804 assert!(line.contains("✓"));
805 assert!(line.contains("No sells below peg"));
806 }
807
808 #[test]
809 fn test_check_fail() {
810 let line = check_fail("Bid depth too low");
811 assert!(line.contains("✗"));
812 assert!(line.contains("Bid depth too low"));
813 }
814
815 #[test]
816 fn test_status_line_healthy() {
817 let line = status_line(true);
818 assert!(line.contains("HEALTHY"));
819 }
820
821 #[test]
822 fn test_status_line_unhealthy() {
823 let line = status_line(false);
824 assert!(line.contains("UNHEALTHY"));
825 }
826
827 #[test]
828 fn test_section_footer() {
829 let footer = section_footer();
830 assert!(footer.contains("└"));
831 }
832
833 #[test]
834 fn test_separator() {
835 let sep = separator();
836 assert!(sep.contains("├"));
837 }
838
839 #[test]
840 fn test_format_price_peg_near() {
841 let s = format_price_peg(1.0001, &usd_peg());
842 assert!(s.contains("1.0001"));
843 }
844
845 #[test]
846 fn test_format_price_peg_far() {
847 let s = format_price_peg(0.95, &usd_peg());
848 assert!(s.contains("0.9500"));
849 }
850
851 #[test]
852 fn test_blank_row() {
853 let row = blank_row();
854 assert!(row.contains("│"));
855 }
856
857 #[test]
858 fn test_orderbook_level() {
859 let row = orderbook_level(1.0001, 500.0, "DAI", 500.05, "USDT", &usd_peg());
860 assert!(row.contains("DAI"));
861 assert!(row.contains("USDT"));
862 }
863
864 #[test]
865 fn test_kv_row_delta_zero_value() {
866 let row = kv_row_delta("Change", 0.0, "0.00%");
868 assert!(row.contains("Change"));
869 assert!(row.contains("0.00%"));
870 assert!(row.contains("│"));
871 }
872
873 #[test]
874 fn test_format_price_peg_moderate_deviation() {
875 let s = format_price_peg(1.002, &usd_peg());
877 assert!(s.contains("1.0020"));
878 }
879
880 #[test]
881 fn test_orderbook_level_various_prices() {
882 let row_low = orderbook_level(0.9990, 100.0, "DAI", 99.90, "USDT", &usd_peg());
883 let row_mid = orderbook_level(1.0000, 100.0, "DAI", 100.0, "USDT", &usd_peg());
884 let row_high = orderbook_level(1.0015, 100.0, "DAI", 100.15, "USDT", &usd_peg());
885 assert!(row_low.contains("0.9990"));
886 assert!(row_mid.contains("1.0000"));
887 assert!(row_high.contains("1.0015"));
888 assert!(row_low.contains("│"));
889 assert!(row_mid.contains("│"));
890 assert!(row_high.contains("│"));
891 }
892
893 #[test]
894 fn test_non_tty_returns_unicode_box_characters() {
895 let header = section_header("Test");
897 let sub = subsection_header("Sub");
898 let kv = kv_row("Key", "Val");
899 let pass = check_pass("ok");
900 let fail = check_fail("err");
901 let footer = section_footer();
902 let sep = separator();
903 let blank = blank_row();
904 let status_healthy = status_line(true);
905 let status_unhealthy = status_line(false);
906
907 assert!(header.contains('┌'), "section_header should contain ┌");
908 assert!(header.contains('─'), "section_header should contain ─");
909 assert!(sub.contains('│'), "subsection_header should contain │");
910 assert!(sub.contains('├'), "subsection_header should contain ├");
911 assert!(kv.contains('│'), "kv_row should contain │");
912 assert!(pass.contains('✓'), "check_pass should contain ✓");
913 assert!(fail.contains('✗'), "check_fail should contain ✗");
914 assert!(footer.contains('└'), "section_footer should contain └");
915 assert!(sep.contains('├'), "separator should contain ├");
916 assert!(blank.contains('│'), "blank_row should contain │");
917 assert!(status_healthy.contains("HEALTHY"));
918 assert!(status_unhealthy.contains("UNHEALTHY"));
919 }
920
921 #[test]
926 fn test_section_header_tty() {
927 let header = section_header_styled("Token Health", true);
928 assert!(header.contains("Token Health"));
929 assert!(header.contains("┌─"));
930 }
931
932 #[test]
933 fn test_subsection_header_tty() {
934 let header = subsection_header_styled("DEX", true);
935 assert!(header.contains("DEX"));
936 assert!(header.contains("├──"));
937 }
938
939 #[test]
940 fn test_kv_row_tty() {
941 let row = kv_row_styled("Price", "$1.00", true);
942 assert!(row.contains("Price"));
943 assert!(row.contains("$1.00"));
944 }
945
946 #[test]
947 fn test_kv_row_delta_positive_tty() {
948 let row = kv_row_delta_styled("Change", 5.0, "+5%", true);
949 assert!(row.contains("+5%"));
950 }
951
952 #[test]
953 fn test_kv_row_delta_negative_tty() {
954 let row = kv_row_delta_styled("Change", -3.0, "-3%", true);
955 assert!(row.contains("-3%"));
956 }
957
958 #[test]
959 fn test_kv_row_delta_zero_tty() {
960 let row = kv_row_delta_styled("Change", 0.0, "0.00%", true);
961 assert!(row.contains("0.00%"));
962 }
963
964 #[test]
965 fn test_check_pass_tty() {
966 let line = check_pass_styled("ok", true);
967 assert!(line.contains("✓"));
968 assert!(line.contains("ok"));
969 }
970
971 #[test]
972 fn test_check_fail_tty() {
973 let line = check_fail_styled("err", true);
974 assert!(line.contains("✗"));
975 assert!(line.contains("err"));
976 }
977
978 #[test]
979 fn test_status_line_healthy_tty() {
980 let line = status_line_styled(true, true);
981 assert!(line.contains("HEALTHY"));
982 }
983
984 #[test]
985 fn test_status_line_unhealthy_tty() {
986 let line = status_line_styled(false, true);
987 assert!(line.contains("UNHEALTHY"));
988 }
989
990 #[test]
991 fn test_section_footer_tty() {
992 let footer = section_footer_styled(true);
993 assert!(footer.contains("└"));
994 }
995
996 #[test]
997 fn test_separator_tty() {
998 let sep = separator_styled(true);
999 assert!(sep.contains("├"));
1000 }
1001
1002 #[test]
1003 fn test_format_price_peg_tty_near() {
1004 let s = format_price_peg_styled(1.0001, &usd_peg(), true);
1005 assert!(s.contains("1.0001"));
1006 }
1007
1008 #[test]
1009 fn test_format_price_peg_tty_moderate() {
1010 let s = format_price_peg_styled(1.003, &usd_peg(), true);
1011 assert!(s.contains("1.0030"));
1012 }
1013
1014 #[test]
1015 fn test_format_price_peg_tty_far() {
1016 let s = format_price_peg_styled(0.95, &usd_peg(), true);
1017 assert!(s.contains("0.9500"));
1018 }
1019
1020 #[test]
1021 fn test_blank_row_tty() {
1022 let row = blank_row_styled(true);
1023 assert!(row.contains("│"));
1024 }
1025
1026 #[test]
1027 fn test_orderbook_level_tty() {
1028 let row = orderbook_level_styled(1.0001, 500.0, "DAI", 500.05, "USDT", &usd_peg(), true);
1029 assert!(row.contains("DAI"));
1030 assert!(row.contains("USDT"));
1031 }
1032
1033 #[test]
1038 fn test_score_bar_high() {
1039 let bar = score_bar("Security Score", 85, 100);
1040 assert!(bar.contains("85/100"));
1041 assert!(bar.contains("│"));
1042 assert!(bar.contains("█"));
1043 }
1044
1045 #[test]
1046 fn test_score_bar_low() {
1047 let bar = score_bar("Security Score", 20, 100);
1048 assert!(bar.contains("20/100"));
1049 }
1050
1051 #[test]
1052 fn test_score_bar_zero() {
1053 let bar = score_bar("Score", 0, 100);
1054 assert!(bar.contains("0/100"));
1055 assert!(bar.contains("│"));
1056 }
1057
1058 #[test]
1059 fn test_score_bar_max() {
1060 let bar = score_bar("Score", 100, 100);
1061 assert!(bar.contains("100/100"));
1062 }
1063
1064 #[test]
1065 fn test_severity_label_critical() {
1066 let label = severity_label("Critical");
1067 assert!(label.contains("Critical"));
1068 }
1069
1070 #[test]
1071 fn test_severity_label_high() {
1072 let label = severity_label("High");
1073 assert!(label.contains("High"));
1074 }
1075
1076 #[test]
1077 fn test_severity_label_medium() {
1078 let label = severity_label("Medium");
1079 assert!(label.contains("Medium"));
1080 }
1081
1082 #[test]
1083 fn test_severity_label_low() {
1084 let label = severity_label("Low");
1085 assert!(label.contains("Low"));
1086 }
1087
1088 #[test]
1089 fn test_severity_label_informational() {
1090 let label = severity_label("Informational");
1091 assert!(label.contains("Informational"));
1092 }
1093
1094 #[test]
1095 fn test_warning_row() {
1096 let row = warning_row("Source code is NOT verified");
1097 assert!(row.contains("⚠"));
1098 assert!(row.contains("Source code is NOT verified"));
1099 assert!(row.contains("│"));
1100 }
1101
1102 #[test]
1103 fn test_info_row() {
1104 let row = info_row("No findings triggered");
1105 assert!(row.contains("ℹ"));
1106 assert!(row.contains("No findings triggered"));
1107 assert!(row.contains("│"));
1108 }
1109
1110 #[test]
1111 fn test_link_row() {
1112 let row = link_row("Explorer", "https://etherscan.io");
1113 assert!(row.contains("Explorer"));
1114 assert!(row.contains("https://etherscan.io"));
1115 assert!(row.contains("│"));
1116 }
1117
1118 #[test]
1119 fn test_detail_row() {
1120 let row = detail_row("Contract is not verified");
1121 assert!(row.contains("Contract is not verified"));
1122 assert!(row.contains("│"));
1123 }
1124
1125 #[test]
1126 fn test_bullet_row() {
1127 let row = bullet_row("mint (Critical): Can mint tokens");
1128 assert!(row.contains("•"));
1129 assert!(row.contains("mint (Critical): Can mint tokens"));
1130 assert!(row.contains("│"));
1131 }
1132
1133 #[test]
1138 fn test_score_bar_tty_high() {
1139 let bar = score_bar_styled("Security Score", 85, 100, true);
1140 assert!(bar.contains("85/100"));
1141 assert!(bar.contains("█"));
1142 }
1143
1144 #[test]
1145 fn test_score_bar_tty_medium() {
1146 let bar = score_bar_styled("Security Score", 55, 100, true);
1147 assert!(bar.contains("55/100"));
1148 }
1149
1150 #[test]
1151 fn test_score_bar_tty_low() {
1152 let bar = score_bar_styled("Security Score", 20, 100, true);
1153 assert!(bar.contains("20/100"));
1154 }
1155
1156 #[test]
1157 fn test_severity_label_tty_critical() {
1158 let label = severity_label_styled("Critical", true);
1159 assert!(label.contains("Critical"));
1160 }
1161
1162 #[test]
1163 fn test_severity_label_tty_low() {
1164 let label = severity_label_styled("Low", true);
1165 assert!(label.contains("Low"));
1166 }
1167
1168 #[test]
1169 fn test_severity_label_tty_unknown() {
1170 let label = severity_label_styled("Unknown", true);
1171 assert!(label.contains("Unknown"));
1172 }
1173
1174 #[test]
1175 fn test_warning_row_tty() {
1176 let row = warning_row_styled("Alert!", true);
1177 assert!(row.contains("⚠"));
1178 assert!(row.contains("Alert!"));
1179 }
1180
1181 #[test]
1182 fn test_info_row_tty() {
1183 let row = info_row_styled("Note", true);
1184 assert!(row.contains("ℹ"));
1185 assert!(row.contains("Note"));
1186 }
1187
1188 #[test]
1189 fn test_link_row_tty() {
1190 let row = link_row_styled("Explorer", "https://example.com", true);
1191 assert!(row.contains("Explorer"));
1192 assert!(row.contains("https://example.com"));
1193 }
1194
1195 #[test]
1196 fn test_detail_row_tty() {
1197 let row = detail_row_styled("Some detail", true);
1198 assert!(row.contains("Some detail"));
1199 assert!(row.contains("│"));
1200 }
1201
1202 #[test]
1203 fn test_bullet_row_tty() {
1204 let row = bullet_row_styled("Item one", true);
1205 assert!(row.contains("•"));
1206 assert!(row.contains("Item one"));
1207 }
1208
1209 #[test]
1214 fn test_wrap_lines_short_text() {
1215 let lines = wrap_lines("hello world", 80);
1216 assert_eq!(lines, vec!["hello world"]);
1217 }
1218
1219 #[test]
1220 fn test_wrap_lines_exact_fit() {
1221 let lines = wrap_lines("abcde fghij", 11);
1222 assert_eq!(lines, vec!["abcde fghij"]);
1223 }
1224
1225 #[test]
1226 fn test_wrap_lines_wraps_at_word_boundary() {
1227 let lines = wrap_lines("hello world foo", 11);
1228 assert_eq!(lines, vec!["hello world", "foo"]);
1229 }
1230
1231 #[test]
1232 fn test_wrap_lines_multiple_wraps() {
1233 let lines = wrap_lines("a b c d e f g", 3);
1234 assert_eq!(lines, vec!["a b", "c d", "e f", "g"]);
1235 }
1236
1237 #[test]
1238 fn test_wrap_lines_long_word_exceeds_width() {
1239 let lines = wrap_lines(
1241 "https://etherscan.io/address/0xdAC17F958D2ee523a2206206994597C13D831ec7",
1242 40,
1243 );
1244 assert_eq!(lines.len(), 1);
1245 assert!(lines[0].starts_with("https://"));
1246 }
1247
1248 #[test]
1249 fn test_wrap_lines_long_word_after_short() {
1250 let lines = wrap_lines(
1251 "Explorer: https://etherscan.io/address/0xdAC17F958D2ee523a2206206994597C13D831ec7",
1252 30,
1253 );
1254 assert_eq!(lines.len(), 2);
1255 assert_eq!(lines[0], "Explorer:");
1256 assert!(lines[1].starts_with("https://"));
1257 }
1258
1259 #[test]
1260 fn test_wrap_lines_empty_string() {
1261 let lines = wrap_lines("", 80);
1262 assert_eq!(lines, vec![""]);
1263 }
1264
1265 #[test]
1266 fn test_wrap_lines_zero_width() {
1267 let lines = wrap_lines("hello", 0);
1268 assert_eq!(lines, vec!["hello"]);
1269 }
1270
1271 #[test]
1272 fn test_wrap_lines_single_word() {
1273 let lines = wrap_lines("hello", 80);
1274 assert_eq!(lines, vec!["hello"]);
1275 }
1276
1277 #[test]
1278 fn test_wrap_lines_preserves_all_words() {
1279 let input = "Contract source code is not verified. Full vulnerability analysis requires verified source code.";
1280 let lines = wrap_lines(input, 40);
1281 let reassembled: String = lines.join(" ");
1282 assert_eq!(reassembled, input);
1283 }
1284
1285 #[test]
1286 fn test_terminal_width_returns_positive() {
1287 let _lock = width_lock();
1288 let w = terminal_width();
1289 assert!(w > 0);
1290 }
1291
1292 #[test]
1293 fn test_content_width_for_reasonable_prefix() {
1294 let _lock = width_lock();
1295 let w = content_width_for(7);
1296 assert!(w > 0 || terminal_width() <= 7);
1298 }
1299
1300 #[test]
1305 fn test_detail_row_wraps_long_text() {
1306 let _lock = width_lock();
1307 let long = "Contract source code is not verified and full vulnerability analysis requires verified source code for accurate results";
1309 let row = detail_row_styled(long, false);
1310 let line_count = row.lines().count();
1312 assert!(row.contains("│"), "should contain box-drawing prefix");
1314 for line in row.lines() {
1316 assert!(
1317 line.starts_with('│'),
1318 "continuation line should start with │: {}",
1319 line
1320 );
1321 }
1322 assert!(row.contains("Contract"));
1324 assert!(row.contains("results"));
1325 if terminal_width() < 80 {
1327 assert!(line_count > 1, "should wrap on narrow terminal");
1328 }
1329 }
1330
1331 #[test]
1332 fn test_check_pass_wraps_long_text() {
1333 let _lock = width_lock();
1334 let long = "No sells detected below the configured peg target during the monitoring window across all tracked pairs";
1335 let row = check_pass_styled(long, false);
1336 assert!(row.contains("✓"));
1337 assert!(row.contains("No sells"));
1338 assert!(row.contains("pairs"));
1339 for line in row.lines() {
1340 assert!(line.starts_with('│'));
1341 }
1342 }
1343
1344 #[test]
1345 fn test_check_fail_wraps_long_text() {
1346 let _lock = width_lock();
1347 let long = "Bid depth is significantly below the minimum threshold required for healthy market conditions on this trading pair";
1348 let row = check_fail_styled(long, false);
1349 assert!(row.contains("✗"));
1350 for line in row.lines() {
1351 assert!(line.starts_with('│'));
1352 }
1353 }
1354
1355 #[test]
1356 fn test_warning_row_wraps_long_text() {
1357 let _lock = width_lock();
1358 let long = "Source code is NOT verified — unable to perform source-level analysis. Consider requesting verification.";
1359 let row = warning_row_styled(long, false);
1360 assert!(row.contains("⚠"));
1361 for line in row.lines() {
1362 assert!(line.starts_with('│'));
1363 }
1364 }
1365
1366 #[test]
1367 fn test_info_row_wraps_long_text() {
1368 let _lock = width_lock();
1369 let long = "No audit reports found. Check block explorer and auditor databases manually for third-party audit information.";
1370 let row = info_row_styled(long, false);
1371 assert!(row.contains("ℹ"));
1372 for line in row.lines() {
1373 assert!(line.starts_with('│'));
1374 }
1375 }
1376
1377 #[test]
1378 fn test_bullet_row_wraps_long_text() {
1379 let _lock = width_lock();
1380 let long = "Uniswap V3 integration detected with slippage protection enabled and deadline protection enabled for all swap calls";
1381 let row = bullet_row_styled(long, false);
1382 assert!(row.contains("•"));
1383 for line in row.lines() {
1384 assert!(line.starts_with('│'));
1385 }
1386 }
1387
1388 #[test]
1389 fn test_kv_row_wraps_long_value() {
1390 let _lock = width_lock();
1391 let long_val = "Verified contract with comprehensive access controls and multiple security features including role-based permissions";
1392 let row = kv_row_styled("Summary", long_val, false);
1393 assert!(row.contains("Summary"));
1394 assert!(row.contains("Verified"));
1395 assert!(row.contains("permissions"));
1396 for line in row.lines() {
1397 assert!(line.starts_with('│'));
1398 }
1399 }
1400
1401 #[test]
1402 fn test_kv_row_short_value_no_wrap() {
1403 let _lock = width_lock();
1404 let row = kv_row_styled("Chain", "ethereum", false);
1405 assert_eq!(row.lines().count(), 1);
1406 assert!(row.contains("Chain"));
1407 assert!(row.contains("ethereum"));
1408 }
1409
1410 #[test]
1411 fn test_detail_row_wraps_tty() {
1412 let _lock = width_lock();
1413 let long = "Contract source code is not verified and full vulnerability analysis requires verified source code for accurate results";
1414 let row = detail_row_styled(long, true);
1415 assert!(row.contains("│"));
1416 assert!(row.contains("Contract"));
1417 assert!(row.contains("results"));
1418 }
1419
1420 #[test]
1421 fn test_bullet_row_wraps_tty() {
1422 let _lock = width_lock();
1423 let long = "Uniswap V3 integration detected with slippage protection enabled and deadline protection enabled for all swap calls";
1424 let row = bullet_row_styled(long, true);
1425 assert!(row.contains("•"));
1426 assert!(row.contains("Uniswap"));
1427 assert!(row.contains("calls"));
1428 }
1429
1430 #[test]
1431 fn test_kv_row_wraps_tty() {
1432 let _lock = width_lock();
1433 let long_val = "Verified contract with comprehensive access controls and multiple security features including role-based permissions";
1434 let row = kv_row_styled("Summary", long_val, true);
1435 assert!(row.contains("Summary"));
1436 assert!(row.contains("Verified"));
1437 assert!(row.contains("permissions"));
1438 }
1439
1440 #[test]
1445 fn test_table_header_contains_labels() {
1446 let cols = &[
1447 Col {
1448 label: "Rank",
1449 width: 6,
1450 align: '>',
1451 },
1452 Col {
1453 label: "Name",
1454 width: 20,
1455 align: '<',
1456 },
1457 ];
1458 let header = table_header(cols);
1459 assert!(header.contains("Rank"));
1460 assert!(header.contains("Name"));
1461 assert!(header.contains("│"));
1462 assert!(header.contains("─"));
1463 }
1464
1465 #[test]
1466 fn test_table_row_contains_values() {
1467 let cols = &[
1468 Col {
1469 label: "Rank",
1470 width: 6,
1471 align: '>',
1472 },
1473 Col {
1474 label: "Name",
1475 width: 20,
1476 align: '<',
1477 },
1478 ];
1479 let row = table_row(cols, &["1", "TestToken"]);
1480 assert!(row.contains("1"));
1481 assert!(row.contains("TestToken"));
1482 assert!(row.contains("│"));
1483 }
1484
1485 #[test]
1486 fn test_table_row_missing_values() {
1487 let cols = &[
1488 Col {
1489 label: "A",
1490 width: 5,
1491 align: '<',
1492 },
1493 Col {
1494 label: "B",
1495 width: 5,
1496 align: '<',
1497 },
1498 ];
1499 let row = table_row(cols, &["only"]);
1500 assert!(row.contains("only"));
1501 assert!(row.contains("│"));
1502 }
1503
1504 #[test]
1505 fn test_table_header_tty() {
1506 let cols = &[Col {
1507 label: "Price",
1508 width: 10,
1509 align: '>',
1510 }];
1511 let header = table_header_styled(cols, true);
1512 assert!(header.contains("Price"));
1513 assert!(header.contains("│"));
1514 assert!(header.contains("\x1b[36m\x1b[1m"), "{header:?}");
1516 let plain = table_header_styled(cols, false);
1517 assert!(!plain.contains('\x1b'), "plain output has no escapes");
1518 }
1519
1520 #[test]
1521 fn test_table_row_tty() {
1522 let cols = &[Col {
1523 label: "Price",
1524 width: 10,
1525 align: '>',
1526 }];
1527 let row = table_row_styled(cols, &["$1.00"], true);
1528 assert!(row.contains("$1.00"));
1529 assert!(row.contains("│"));
1530 }
1531
1532 #[test]
1533 fn test_numbered_row_basic() {
1534 let row = numbered_row(1, "First item");
1535 assert!(row.contains("1."));
1536 assert!(row.contains("First item"));
1537 assert!(row.contains("│"));
1538 }
1539
1540 #[test]
1541 fn test_numbered_row_wraps() {
1542 let _lock = width_lock();
1543 let long = "This is a very long description that should eventually wrap to the next line when the terminal width is narrow enough";
1544 let row = numbered_row(1, long);
1545 assert!(row.contains("1."));
1546 assert!(row.contains("This"));
1547 assert!(row.contains("enough"));
1548 for line in row.lines() {
1549 assert!(line.contains('│'));
1550 }
1551 }
1552
1553 #[test]
1554 fn test_numbered_row_tty() {
1555 let row = numbered_row_styled(5, "Fifth item", true);
1556 assert!(row.contains("5."));
1557 assert!(row.contains("Fifth item"));
1558 }
1559
1560 #[test]
1561 fn test_numbered_row_double_digits() {
1562 let row = numbered_row(12, "Twelfth item");
1563 assert!(row.contains("12."));
1564 assert!(row.contains("Twelfth item"));
1565 }
1566
1567 #[test]
1571 fn test_check_pass_wraps_tty() {
1572 let _lock = width_lock();
1573 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);
1575 let row = check_pass_styled(long, true);
1576 assert!(row.contains("✓"));
1577 assert!(row.lines().count() > 1, "should wrap to multiple lines");
1578 }
1579
1580 #[test]
1581 fn test_check_fail_wraps_tty() {
1582 let _lock = width_lock();
1583 let long = &format!("{} ", "Bid depth is significantly below the minimum threshold required for healthy market conditions on this particular trading pair").repeat(4);
1585 let row = check_fail_styled(long, true);
1586 assert!(row.contains("✗"));
1587 assert!(row.lines().count() > 1, "should wrap to multiple lines");
1588 }
1589
1590 #[test]
1591 fn test_check_na_plain_and_tty_wrap() {
1592 let _lock = width_lock();
1593 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);
1596 for tty in [false, true] {
1597 let row = check_na_styled(long, tty);
1598 assert!(row.contains("–"), "tty={}", tty);
1599 assert!(!row.contains("✓") && !row.contains("✗"), "tty={}", tty);
1600 assert!(row.lines().count() > 1, "should wrap, tty={}", tty);
1601 }
1602 assert!(check_na_styled("short", false).starts_with("│ – short"));
1603 }
1604
1605 #[test]
1606 fn test_warning_row_wraps_tty() {
1607 let _lock = width_lock();
1608 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);
1610 let row = warning_row_styled(long, true);
1611 assert!(row.contains("⚠"));
1612 assert!(row.lines().count() > 1, "should wrap to multiple lines");
1613 }
1614
1615 #[test]
1616 fn test_info_row_wraps_tty() {
1617 let _lock = width_lock();
1618 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);
1620 let row = info_row_styled(long, true);
1621 assert!(row.contains("ℹ"));
1622 assert!(row.lines().count() > 1, "should wrap to multiple lines");
1623 }
1624
1625 #[test]
1626 fn test_numbered_row_wraps_tty() {
1627 let _lock = width_lock();
1628 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);
1630 let row = numbered_row_styled(1, long, true);
1631 assert!(row.contains("1."));
1632 assert!(row.lines().count() > 1, "should wrap to multiple lines");
1633 }
1634
1635 #[test]
1636 fn test_check_warn_styled_keeps_text_and_continuation_bar() {
1637 let msg = "word ".repeat(60);
1638 let plain = check_warn_styled(&msg, false);
1639 let styled = check_warn_styled(&msg, true);
1640 assert!(plain.starts_with("│ ! "));
1641 assert!(!plain.contains('\x1b'));
1642 assert!(styled.contains('\x1b'), "color on a color-enabled stream");
1643 assert!(plain.lines().count() > 1);
1645 assert_eq!(styled.lines().count(), plain.lines().count());
1646 let words = |s: &str| s.matches("word").count();
1647 assert_eq!(words(&plain), 60);
1648 assert_eq!(words(&styled), 60);
1649 }
1650
1651 fn widest(text: &str) -> usize {
1656 text.lines().map(UnicodeWidthStr::width).max().unwrap_or(0)
1657 }
1658
1659 fn two_cols() -> [Col<'static>; 2] {
1660 [
1661 Col {
1662 label: "Rank",
1663 width: 150,
1664 align: '<',
1665 },
1666 Col {
1667 label: "Value",
1668 width: 150,
1669 align: '>',
1670 },
1671 ]
1672 }
1673
1674 #[test]
1675 fn test_rules_fit_a_narrow_pane() {
1676 let cap = Some(40);
1678 assert!(widest(§ion_footer_at(false, cap)) <= 40);
1679 assert!(widest(&separator_at(false, cap)) <= 40);
1680 assert!(widest(§ion_header_at("Available Venues", false, cap)) <= 40);
1681 let header = table_header_at(&two_cols(), false, cap);
1682 let rule = header.lines().nth(1).unwrap_or_default();
1683 assert_eq!(rule.width(), 40, "{header}");
1684 }
1685
1686 #[test]
1687 fn test_a_wide_pane_keeps_the_fixed_rules() {
1688 let cap = Some(200);
1690 assert_eq!(section_footer_at(false, cap).width(), 51);
1691 assert_eq!(separator_at(false, cap).width(), 51);
1692 let header = section_header_at("Available Venues", false, cap);
1693 assert_eq!(header.trim().width(), 50);
1694 }
1695
1696 #[test]
1697 fn test_without_an_override_rules_keep_their_fixed_widths() {
1698 let _lock = width_lock();
1701 assert_eq!(width_override(), None);
1702 assert_eq!(section_footer_styled(false).width(), 51);
1703 assert_eq!(separator_styled(false).width(), 51);
1704 let header = section_header_styled("Available Venues", false);
1705 assert_eq!(header.trim().width(), 50);
1706 let table = table_header_styled(&two_cols(), false);
1709 assert_eq!(table.lines().nth(1).unwrap_or_default().width(), 3 + 304);
1710 assert_eq!(section_footer_at(false, None), section_footer_styled(false));
1711 }
1712
1713 #[test]
1714 fn test_terminal_width_uses_the_override_first() {
1715 assert_eq!(width_or_real(Some(40)), 40);
1716 let real = crossterm::terminal::size()
1717 .map(|(w, _)| w as usize)
1718 .unwrap_or(80);
1719 assert_eq!(width_or_real(None), real);
1720 }
1721
1722 #[test]
1723 fn test_width_override_set_and_clear() {
1724 let _lock = width_lock();
1727 let real = terminal_width();
1728 set_width_override(Some(real));
1729 assert_eq!(width_override(), Some(real));
1730 set_width_override(Some(0));
1731 assert_eq!(width_override(), None, "Some(0) removes it");
1732 set_width_override(Some(real));
1733 set_width_override(None);
1734 assert_eq!(width_override(), None);
1735 assert_eq!(terminal_width(), real);
1736 }
1737
1738 #[test]
1739 fn test_wrap_lines_counts_display_width_of_cjk() {
1740 let lines = wrap_lines("日本 語 です", 7);
1743 assert_eq!(lines, vec!["日本 語", "です"]);
1744 for l in &lines {
1745 assert!(l.width() <= 7, "{l}");
1746 }
1747 }
1748}