Skip to main content

scope/display/
terminal.rs

1//! # Terminal Display Helpers
2//!
3//! Styled output utilities for rich, user-friendly terminal presentation.
4//! Uses `owo-colors` for color and `unicode` box-drawing characters for structure.
5//! All helpers respect non-TTY contexts (piped output) by falling back to plain text.
6
7use owo_colors::OwoColorize;
8use std::io::IsTerminal;
9
10// ============================================================================
11// Color-aware helpers
12// ============================================================================
13
14/// Returns `true` when stdout is an interactive terminal (not piped).
15pub fn is_tty() -> bool {
16    std::io::stdout().is_terminal()
17}
18
19/// Section header with colored title and box-drawing underline.
20///
21/// ```text
22/// ┌─ Token Health ─────────────────────────────
23/// ```
24pub fn section_header(title: &str) -> String {
25    section_header_styled(title, is_tty())
26}
27
28fn section_header_styled(title: &str, tty: bool) -> String {
29    let width: usize = 50;
30    let pad = width.saturating_sub(title.len() + 4);
31    if tty {
32        format!(
33            "{}",
34            format!("\n┌─ {} {}", title.bold(), "─".repeat(pad)).cyan()
35        )
36    } else {
37        format!("\n┌─ {} {}", title, "─".repeat(pad))
38    }
39}
40
41/// Sub-section header (lighter weight).
42///
43/// ```text
44/// │
45/// ├── DEX Analytics
46/// ```
47pub fn subsection_header(title: &str) -> String {
48    subsection_header_styled(title, is_tty())
49}
50
51fn subsection_header_styled(title: &str, tty: bool) -> String {
52    if tty {
53        format!("{}\n{}", "│".cyan(), format!("├── {}", title.bold()).cyan())
54    } else {
55        format!("│\n├── {}", title)
56    }
57}
58
59/// A key-value row inside a section, with aligned values.
60///
61/// ```text
62/// │  Price            $0.9999
63/// ```
64pub fn kv_row(key: &str, value: &str) -> String {
65    kv_row_styled(key, value, is_tty())
66}
67
68fn kv_row_styled(key: &str, value: &str, tty: bool) -> String {
69    if tty {
70        format!("{}  {:<18}{}", "│".cyan(), key.dimmed(), value)
71    } else {
72        format!("│  {:<18}{}", key, value)
73    }
74}
75
76/// A key-value row where the value is colored based on positive/negative.
77pub fn kv_row_delta(key: &str, value: f64, formatted: &str) -> String {
78    kv_row_delta_styled(key, value, formatted, is_tty())
79}
80
81fn kv_row_delta_styled(key: &str, value: f64, formatted: &str, tty: bool) -> String {
82    if tty {
83        let colored_val = if value > 0.0 {
84            format!("{}", formatted.green())
85        } else if value < 0.0 {
86            format!("{}", formatted.red())
87        } else {
88            format!("{}", formatted.dimmed())
89        };
90        format!("{}  {:<18}{}", "│".cyan(), key.dimmed(), colored_val)
91    } else {
92        format!("│  {:<18}{}", key, formatted)
93    }
94}
95
96/// Health check pass line.
97///
98/// ```text
99/// │  ✓ No sells below peg
100/// ```
101pub fn check_pass(msg: &str) -> String {
102    check_pass_styled(msg, is_tty())
103}
104
105fn check_pass_styled(msg: &str, tty: bool) -> String {
106    if tty {
107        format!("{}  {} {}", "│".cyan(), "✓".green(), msg)
108    } else {
109        format!("│  ✓ {}", msg)
110    }
111}
112
113/// Health check fail line.
114///
115/// ```text
116/// │  ✗ Bid depth: 0 USDT < 3000 USDT minimum
117/// ```
118pub fn check_fail(msg: &str) -> String {
119    check_fail_styled(msg, is_tty())
120}
121
122fn check_fail_styled(msg: &str, tty: bool) -> String {
123    if tty {
124        format!("{}  {} {}", "│".cyan(), "✗".red(), msg)
125    } else {
126        format!("│  ✗ {}", msg)
127    }
128}
129
130/// Overall status line (healthy / unhealthy).
131pub fn status_line(healthy: bool) -> String {
132    status_line_styled(healthy, is_tty())
133}
134
135fn status_line_styled(healthy: bool, tty: bool) -> String {
136    if tty {
137        if healthy {
138            format!("{}  {}", "│".cyan(), "HEALTHY".green().bold())
139        } else {
140            format!("{}  {}", "│".cyan(), "UNHEALTHY".red().bold())
141        }
142    } else if healthy {
143        "│  HEALTHY".to_string()
144    } else {
145        "│  UNHEALTHY".to_string()
146    }
147}
148
149/// Section footer (closing box line).
150///
151/// ```text
152/// └──────────────────────────────────────────────
153/// ```
154pub fn section_footer() -> String {
155    section_footer_styled(is_tty())
156}
157
158fn section_footer_styled(tty: bool) -> String {
159    let line = "─".repeat(50);
160    if tty {
161        format!("{}", format!("└{}", line).cyan())
162    } else {
163        format!("└{}", line)
164    }
165}
166
167/// A separator row inside a section.
168///
169/// ```text
170/// ├──────────────────────────────────────────────
171/// ```
172pub fn separator() -> String {
173    separator_styled(is_tty())
174}
175
176fn separator_styled(tty: bool) -> String {
177    let line = "─".repeat(50);
178    if tty {
179        format!("{}", format!("├{}", line).cyan())
180    } else {
181        format!("├{}", line)
182    }
183}
184
185/// Format a price with color for peg deviation.
186/// Green if within 0.1% of target, yellow if within 0.5%, red otherwise.
187pub fn format_price_peg(price: f64, target: f64) -> String {
188    format_price_peg_styled(price, target, is_tty())
189}
190
191fn format_price_peg_styled(price: f64, target: f64, tty: bool) -> String {
192    let deviation = ((price - target) / target).abs();
193    let text = format!("{:.4}", price);
194    if !tty {
195        return text;
196    }
197    if deviation < 0.001 {
198        format!("{}", text.green())
199    } else if deviation < 0.005 {
200        format!("{}", text.yellow())
201    } else {
202        format!("{}", text.red())
203    }
204}
205
206/// Empty line with continuation bar.
207pub fn blank_row() -> String {
208    blank_row_styled(is_tty())
209}
210
211fn blank_row_styled(tty: bool) -> String {
212    if tty {
213        format!("{}", "│".cyan())
214    } else {
215        "│".to_string()
216    }
217}
218
219/// An order book level row with price coloring relative to peg.
220pub fn orderbook_level(price: f64, quantity: f64, base: &str, value: f64, peg: f64) -> String {
221    orderbook_level_styled(price, quantity, base, value, peg, is_tty())
222}
223
224fn orderbook_level_styled(
225    price: f64,
226    quantity: f64,
227    base: &str,
228    value: f64,
229    peg: f64,
230    tty: bool,
231) -> String {
232    let price_str = format_price_peg_styled(price, peg, tty);
233    if tty {
234        format!(
235            "{}    {}  {:>10.2} {}  {:>10.2} USDT",
236            "│".cyan(),
237            price_str,
238            quantity,
239            base.dimmed(),
240            value
241        )
242    } else {
243        format!(
244            "│    {:.4}  {:>10.2} {}  {:>10.2} USDT",
245            price, quantity, base, value
246        )
247    }
248}
249
250// ============================================================================
251// Unit Tests
252// ============================================================================
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_section_header_contains_title() {
260        let header = section_header("Token Health");
261        assert!(header.contains("Token Health"));
262        assert!(header.contains("┌─"));
263    }
264
265    #[test]
266    fn test_subsection_header_contains_title() {
267        let header = subsection_header("DEX Analytics");
268        assert!(header.contains("DEX Analytics"));
269        assert!(header.contains("├──"));
270    }
271
272    #[test]
273    fn test_kv_row_contains_key_value() {
274        let row = kv_row("Price", "$1.00");
275        assert!(row.contains("Price"));
276        assert!(row.contains("$1.00"));
277        assert!(row.contains("│"));
278    }
279
280    #[test]
281    fn test_kv_row_delta_positive() {
282        let row = kv_row_delta("24h Change", 5.0, "+5.00%");
283        assert!(row.contains("+5.00%"));
284    }
285
286    #[test]
287    fn test_kv_row_delta_negative() {
288        let row = kv_row_delta("24h Change", -3.0, "-3.00%");
289        assert!(row.contains("-3.00%"));
290    }
291
292    #[test]
293    fn test_check_pass() {
294        let line = check_pass("No sells below peg");
295        assert!(line.contains("✓"));
296        assert!(line.contains("No sells below peg"));
297    }
298
299    #[test]
300    fn test_check_fail() {
301        let line = check_fail("Bid depth too low");
302        assert!(line.contains("✗"));
303        assert!(line.contains("Bid depth too low"));
304    }
305
306    #[test]
307    fn test_status_line_healthy() {
308        let line = status_line(true);
309        assert!(line.contains("HEALTHY"));
310    }
311
312    #[test]
313    fn test_status_line_unhealthy() {
314        let line = status_line(false);
315        assert!(line.contains("UNHEALTHY"));
316    }
317
318    #[test]
319    fn test_section_footer() {
320        let footer = section_footer();
321        assert!(footer.contains("└"));
322    }
323
324    #[test]
325    fn test_separator() {
326        let sep = separator();
327        assert!(sep.contains("├"));
328    }
329
330    #[test]
331    fn test_format_price_peg_near() {
332        let s = format_price_peg(1.0001, 1.0);
333        assert!(s.contains("1.0001"));
334    }
335
336    #[test]
337    fn test_format_price_peg_far() {
338        let s = format_price_peg(0.95, 1.0);
339        assert!(s.contains("0.9500"));
340    }
341
342    #[test]
343    fn test_blank_row() {
344        let row = blank_row();
345        assert!(row.contains("│"));
346    }
347
348    #[test]
349    fn test_orderbook_level() {
350        let row = orderbook_level(1.0001, 500.0, "PUSD", 500.05, 1.0);
351        assert!(row.contains("PUSD"));
352        assert!(row.contains("USDT"));
353    }
354
355    #[test]
356    fn test_kv_row_delta_zero_value() {
357        // Non-TTY: no color, but zero value (else/dimmed branch) still returns formatted string
358        let row = kv_row_delta("Change", 0.0, "0.00%");
359        assert!(row.contains("Change"));
360        assert!(row.contains("0.00%"));
361        assert!(row.contains("│"));
362    }
363
364    #[test]
365    fn test_format_price_peg_moderate_deviation() {
366        // 0.2% deviation: price 1.002, target 1.0 -> in non-TTY returns plain price string
367        let s = format_price_peg(1.002, 1.0);
368        assert!(s.contains("1.0020"));
369    }
370
371    #[test]
372    fn test_orderbook_level_various_prices() {
373        let row_low = orderbook_level(0.9990, 100.0, "PUSD", 99.90, 1.0);
374        let row_mid = orderbook_level(1.0000, 100.0, "PUSD", 100.0, 1.0);
375        let row_high = orderbook_level(1.0015, 100.0, "PUSD", 100.15, 1.0);
376        assert!(row_low.contains("0.9990"));
377        assert!(row_mid.contains("1.0000"));
378        assert!(row_high.contains("1.0015"));
379        assert!(row_low.contains("│"));
380        assert!(row_mid.contains("│"));
381        assert!(row_high.contains("│"));
382    }
383
384    #[test]
385    fn test_non_tty_returns_unicode_box_characters() {
386        // In CI (non-TTY), all helpers still emit Unicode box-drawing chars
387        let header = section_header("Test");
388        let sub = subsection_header("Sub");
389        let kv = kv_row("Key", "Val");
390        let pass = check_pass("ok");
391        let fail = check_fail("err");
392        let footer = section_footer();
393        let sep = separator();
394        let blank = blank_row();
395        let status_healthy = status_line(true);
396        let status_unhealthy = status_line(false);
397
398        assert!(header.contains('┌'), "section_header should contain ┌");
399        assert!(header.contains('─'), "section_header should contain ─");
400        assert!(sub.contains('│'), "subsection_header should contain │");
401        assert!(sub.contains('├'), "subsection_header should contain ├");
402        assert!(kv.contains('│'), "kv_row should contain │");
403        assert!(pass.contains('✓'), "check_pass should contain ✓");
404        assert!(fail.contains('✗'), "check_fail should contain ✗");
405        assert!(footer.contains('└'), "section_footer should contain └");
406        assert!(sep.contains('├'), "separator should contain ├");
407        assert!(blank.contains('│'), "blank_row should contain │");
408        assert!(status_healthy.contains("HEALTHY"));
409        assert!(status_unhealthy.contains("UNHEALTHY"));
410    }
411
412    // ============================================================
413    // TTY-branch tests (via _styled variants with tty=true)
414    // ============================================================
415
416    #[test]
417    fn test_section_header_tty() {
418        let header = section_header_styled("Token Health", true);
419        assert!(header.contains("Token Health"));
420        assert!(header.contains("┌─"));
421    }
422
423    #[test]
424    fn test_subsection_header_tty() {
425        let header = subsection_header_styled("DEX", true);
426        assert!(header.contains("DEX"));
427        assert!(header.contains("├──"));
428    }
429
430    #[test]
431    fn test_kv_row_tty() {
432        let row = kv_row_styled("Price", "$1.00", true);
433        assert!(row.contains("Price"));
434        assert!(row.contains("$1.00"));
435    }
436
437    #[test]
438    fn test_kv_row_delta_positive_tty() {
439        let row = kv_row_delta_styled("Change", 5.0, "+5%", true);
440        assert!(row.contains("+5%"));
441    }
442
443    #[test]
444    fn test_kv_row_delta_negative_tty() {
445        let row = kv_row_delta_styled("Change", -3.0, "-3%", true);
446        assert!(row.contains("-3%"));
447    }
448
449    #[test]
450    fn test_kv_row_delta_zero_tty() {
451        let row = kv_row_delta_styled("Change", 0.0, "0.00%", true);
452        assert!(row.contains("0.00%"));
453    }
454
455    #[test]
456    fn test_check_pass_tty() {
457        let line = check_pass_styled("ok", true);
458        assert!(line.contains("✓"));
459        assert!(line.contains("ok"));
460    }
461
462    #[test]
463    fn test_check_fail_tty() {
464        let line = check_fail_styled("err", true);
465        assert!(line.contains("✗"));
466        assert!(line.contains("err"));
467    }
468
469    #[test]
470    fn test_status_line_healthy_tty() {
471        let line = status_line_styled(true, true);
472        assert!(line.contains("HEALTHY"));
473    }
474
475    #[test]
476    fn test_status_line_unhealthy_tty() {
477        let line = status_line_styled(false, true);
478        assert!(line.contains("UNHEALTHY"));
479    }
480
481    #[test]
482    fn test_section_footer_tty() {
483        let footer = section_footer_styled(true);
484        assert!(footer.contains("└"));
485    }
486
487    #[test]
488    fn test_separator_tty() {
489        let sep = separator_styled(true);
490        assert!(sep.contains("├"));
491    }
492
493    #[test]
494    fn test_format_price_peg_tty_near() {
495        let s = format_price_peg_styled(1.0001, 1.0, true);
496        assert!(s.contains("1.0001"));
497    }
498
499    #[test]
500    fn test_format_price_peg_tty_moderate() {
501        let s = format_price_peg_styled(1.003, 1.0, true);
502        assert!(s.contains("1.0030"));
503    }
504
505    #[test]
506    fn test_format_price_peg_tty_far() {
507        let s = format_price_peg_styled(0.95, 1.0, true);
508        assert!(s.contains("0.9500"));
509    }
510
511    #[test]
512    fn test_blank_row_tty() {
513        let row = blank_row_styled(true);
514        assert!(row.contains("│"));
515    }
516
517    #[test]
518    fn test_orderbook_level_tty() {
519        let row = orderbook_level_styled(1.0001, 500.0, "PUSD", 500.05, 1.0, true);
520        assert!(row.contains("PUSD"));
521        assert!(row.contains("USDT"));
522    }
523}