Skip to main content

seshat_cli/
format.rs

1//! Shared output formatting utilities for CLI reports.
2//!
3//! All colored output flows through this module so that `NO_COLOR` support
4//! and verbosity filtering are centralised in one place.
5//!
6//! ## Color Policy
7//!
8//! The [`NO_COLOR`](https://no-color.org/) environment variable disables all
9//! color output when set (to any value). Check once at startup via
10//! `color_enabled()` and pass the result through to the formatting helpers.
11//!
12//! ## Verbosity Levels
13//!
14//! Three levels control how much output the user sees:
15//!
16//! | Level     | Errors | Warnings | Summary | Findings | Verbose details |
17//! |-----------|--------|----------|---------|----------|-----------------|
18//! | `Quiet`   | yes    | no       | final   | no       | no              |
19//! | `Default` | yes    | yes      | yes     | key      | no              |
20//! | `Verbose` | yes    | yes      | yes     | all      | yes             |
21
22use std::fmt::Write;
23
24use owo_colors::OwoColorize;
25
26// ── Color support ────────────────────────────────────────────────────
27
28/// Returns `true` if colored output is enabled.
29///
30/// Color is disabled when the `NO_COLOR` environment variable is set
31/// (to any value, including empty string).
32pub fn color_enabled() -> bool {
33    std::env::var_os("NO_COLOR").is_none()
34}
35
36// ── Verbosity ────────────────────────────────────────────────────────
37
38/// CLI output verbosity level.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Verbosity {
41    /// Errors + final summary line only.
42    Quiet,
43    /// Errors + warnings + summary + key findings (default).
44    Default,
45    /// Everything: skipped files, detector details, timing breakdown.
46    Verbose,
47}
48
49impl Verbosity {
50    /// Create from the `--verbose` / `--quiet` flags.
51    ///
52    /// If both are set, `--quiet` wins (principle of least output).
53    pub fn from_flags(verbose: bool, quiet: bool) -> Self {
54        if quiet {
55            Self::Quiet
56        } else if verbose {
57            Self::Verbose
58        } else {
59            Self::Default
60        }
61    }
62
63    /// Whether to show warnings (not shown in quiet mode).
64    pub fn show_warnings(self) -> bool {
65        self != Self::Quiet
66    }
67
68    /// Whether to show the main findings list (not shown in quiet mode).
69    pub fn show_findings(self) -> bool {
70        self != Self::Quiet
71    }
72
73    /// Whether to show verbose details (skipped files, timing, detector table).
74    pub fn show_verbose(self) -> bool {
75        self == Self::Verbose
76    }
77}
78
79// ── Section header ───────────────────────────────────────────────────
80
81/// Total width of the header line (including the title text).
82const HEADER_WIDTH: usize = 60;
83
84/// Format a section header using box-drawing characters.
85///
86/// Produces: `── Title ──────────────────────────────────────────`
87/// padded to ~60 characters.
88///
89/// When `color` is `true`, the dashes are dimmed.
90pub fn format_section_header(title: &str, color: bool) -> String {
91    let prefix = "── ";
92    let separator = " ";
93    // Count display characters, not bytes (─ is 3 bytes in UTF-8).
94    let used = prefix.chars().count() + title.chars().count() + separator.chars().count();
95    let remaining = HEADER_WIDTH.saturating_sub(used);
96    let dashes: String = "─".repeat(remaining);
97
98    if color {
99        format!(
100            "{}{}{}{}",
101            "── ".dimmed(),
102            title.bold(),
103            " ".dimmed(),
104            dashes.dimmed()
105        )
106    } else {
107        format!("{prefix}{title}{separator}{dashes}")
108    }
109}
110
111// ── Bar chart ────────────────────────────────────────────────────────
112
113/// Maximum width of the bar (in characters).
114const BAR_WIDTH: usize = 20;
115
116/// Format a horizontal bar chart entry.
117///
118/// Produces: `  ▓▓▓▓▓▓▓░░░░░░░░░░░░░  34.5%  Rust (42 files)`
119///
120/// `fraction` should be in `0.0..=1.0`.
121pub fn format_bar_chart(
122    label: &str,
123    count: usize,
124    fraction: f64,
125    unit: &str,
126    color: bool,
127) -> String {
128    let filled = (fraction * BAR_WIDTH as f64).round() as usize;
129    let empty = BAR_WIDTH.saturating_sub(filled);
130
131    let bar_filled: String = "\u{2593}".repeat(filled); // ▓
132    let bar_empty: String = "\u{2591}".repeat(empty); // ░
133    let pct = fraction * 100.0;
134
135    if color {
136        format!(
137            "  {}{} {:>5.1}%  {} ({} {})",
138            bar_filled.cyan(),
139            bar_empty.dimmed(),
140            pct,
141            label.bold(),
142            count,
143            unit,
144        )
145    } else {
146        format!("  {bar_filled}{bar_empty} {pct:>5.1}%  {label} ({count} {unit})",)
147    }
148}
149
150// ── Tier bullets ─────────────────────────────────────────────────────
151
152/// Format a confidence tier bullet.
153///
154/// - `●` (filled circle) for high confidence (> 85%)
155/// - `◐` (half circle) for medium confidence (50–85%)
156/// - `○` (empty circle) for low confidence (< 50%)
157///
158/// Returns: `● High (12)` or `◐ Medium (5)` etc.
159pub fn format_tier_bullet(label: &str, count: usize, tier: ConfidenceTier, color: bool) -> String {
160    let bullet = styled_tier_bullet(tier, color);
161    format!("{bullet} {label} ({count})")
162}
163
164/// Confidence tier for display purposes.
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum ConfidenceTier {
167    /// Confidence > 85%.
168    High,
169    /// Confidence 50–85%.
170    Medium,
171    /// Confidence < 50%.
172    Low,
173}
174
175impl ConfidenceTier {
176    /// Classify a confidence percentage into a tier.
177    pub fn from_confidence(confidence: f64) -> Self {
178        if confidence > 85.0 {
179            Self::High
180        } else if confidence >= 50.0 {
181            Self::Medium
182        } else {
183            Self::Low
184        }
185    }
186}
187
188/// Return the bullet character for a confidence tier.
189///
190/// - `●` for [`ConfidenceTier::High`]
191/// - `◐` for [`ConfidenceTier::Medium`]
192/// - `○` for [`ConfidenceTier::Low`]
193pub fn tier_bullet_char(tier: ConfidenceTier) -> &'static str {
194    match tier {
195        ConfidenceTier::High => "\u{25CF}",   // ●
196        ConfidenceTier::Medium => "\u{25D0}", // ◐
197        ConfidenceTier::Low => "\u{25CB}",    // ○
198    }
199}
200
201/// Return the colored bullet string for a confidence tier.
202///
203/// When `color` is `false`, returns the plain bullet character.
204/// When `color` is `true`, applies green/yellow/red coloring.
205pub fn styled_tier_bullet(tier: ConfidenceTier, color: bool) -> String {
206    let bullet = tier_bullet_char(tier);
207    if color {
208        match tier {
209            ConfidenceTier::High => bullet.green().to_string(),
210            ConfidenceTier::Medium => bullet.yellow().to_string(),
211            ConfidenceTier::Low => bullet.red().to_string(),
212        }
213    } else {
214        bullet.to_owned()
215    }
216}
217
218// ── Human-readable sizes ─────────────────────────────────────────────
219
220/// Format a byte count as a human-readable size.
221///
222/// Uses base-10 units: KB (10^3), MB (10^6), GB (10^9).
223///
224/// Examples:
225/// - `0` → `"0 B"`
226/// - `1023` → `"1023 B"`
227/// - `1024` → `"1.0 KB"`
228/// - `1_500_000` → `"1.5 MB"`
229/// - `2_500_000_000` → `"2.5 GB"`
230pub fn format_human_size(bytes: u64) -> String {
231    const KB: f64 = 1_000.0;
232    const MB: f64 = 1_000_000.0;
233    const GB: f64 = 1_000_000_000.0;
234
235    let b = bytes as f64;
236    if b < KB {
237        format!("{bytes} B")
238    } else if b < MB {
239        format!("{:.1} KB", b / KB)
240    } else if b < GB {
241        format!("{:.1} MB", b / MB)
242    } else {
243        format!("{:.1} GB", b / GB)
244    }
245}
246
247// ── Number formatting ────────────────────────────────────────────────
248
249/// Format a number with thousands separators.
250///
251/// Examples: `1234` → `"1,234"`, `1234567` → `"1,234,567"`.
252pub fn format_number(n: u64) -> String {
253    let s = n.to_string();
254    let bytes = s.as_bytes();
255    let len = bytes.len();
256    if len <= 3 {
257        return s;
258    }
259
260    let mut result = String::with_capacity(len + (len - 1) / 3);
261    for (i, &b) in bytes.iter().enumerate() {
262        if i > 0 && (len - i) % 3 == 0 {
263            result.push(',');
264        }
265        result.push(b as char);
266    }
267    result
268}
269
270// ── Error / hint formatting ──────────────────────────────────────────
271
272/// Format an error message with optional hint lines.
273///
274/// Produces:
275/// ```text
276/// error: something went wrong
277///
278/// hint: try doing X instead
279/// hint: see https://example.com for details
280/// ```
281pub fn format_error_hint(message: &str, hints: &[&str], color: bool) -> String {
282    let mut buf = String::new();
283
284    if color {
285        let _ = write!(buf, "{} {message}", "error:".red().bold());
286    } else {
287        let _ = write!(buf, "error: {message}");
288    }
289
290    if !hints.is_empty() {
291        buf.push('\n');
292        for hint in hints {
293            buf.push('\n');
294            if color {
295                let _ = write!(buf, "{} {hint}", "hint:".cyan());
296            } else {
297                let _ = write!(buf, "hint: {hint}");
298            }
299        }
300    }
301
302    buf
303}
304
305// ── Bordered box ─────────────────────────────────────────────────────
306
307/// Format text inside a bordered box using box-drawing characters.
308///
309/// ```text
310/// ┌────────────────────────────────────────┐
311/// │ your content here                      │
312/// │ second line                            │
313/// └────────────────────────────────────────┘
314/// ```
315///
316/// Used for code/config snippet display (e.g., future `seshat init` output).
317pub fn format_bordered_box(lines: &[&str], color: bool) -> String {
318    let max_width = lines.iter().map(|l| l.len()).max().unwrap_or(0);
319    // Minimum inner width of 20, padded by 1 space on each side.
320    let inner = max_width.max(20);
321
322    let mut buf = String::new();
323
324    // Top border.
325    let top = format!("\u{250C}{}\u{2510}", "\u{2500}".repeat(inner + 2)); // ┌─┐
326    if color {
327        let _ = writeln!(buf, "{}", top.dimmed());
328    } else {
329        let _ = writeln!(buf, "{top}");
330    }
331
332    // Content lines.
333    for line in lines {
334        let padded = format!("{line:<width$}", width = inner);
335        if color {
336            let _ = writeln!(
337                buf,
338                "{} {padded} {}",
339                "\u{2502}".dimmed(), // │
340                "\u{2502}".dimmed(),
341            );
342        } else {
343            let _ = writeln!(buf, "\u{2502} {padded} \u{2502}");
344        }
345    }
346
347    // Bottom border.
348    let bottom = format!("\u{2514}{}\u{2518}", "\u{2500}".repeat(inner + 2)); // └─┘
349    if color {
350        let _ = write!(buf, "{}", bottom.dimmed());
351    } else {
352        let _ = write!(buf, "{bottom}");
353    }
354
355    buf
356}
357
358// ── Copy block ───────────────────────────────────────────────────────
359
360/// Format a "copy this" block — content framed by horizontal rules but with
361/// no vertical border characters, so the user can select and paste the content
362/// directly without stripping `│` symbols.
363///
364/// ```text
365///   ── copy ─────────────────────────────────────────────────
366///     "seshat": {
367///       "command": "seshat"
368///     }
369///   ─────────────────────────────────────────────────────────
370/// ```
371///
372/// - Top rule contains `" copy "` as a visual cue.
373/// - Both rules are dimmed when `color` is `true`.
374/// - Content lines are printed with 4-space indent, default terminal color.
375/// - Width is fixed at `HEADER_WIDTH` characters (60), matching section headers.
376pub fn format_copy_block(lines: &[&str], color: bool) -> String {
377    // Width of the horizontal rules (60 chars total, 2-space left margin).
378    let rule_inner = HEADER_WIDTH - 2; // 58 chars of dashes/text
379
380    // Top rule: "── copy " + dashes to fill
381    let copy_label = "── copy ";
382    let top_dashes = "─".repeat(rule_inner.saturating_sub(copy_label.chars().count()));
383    let top_rule = format!("  {copy_label}{top_dashes}");
384
385    // Bottom rule: all dashes
386    let bottom_dashes = "─".repeat(rule_inner);
387    let bottom_rule = format!("  {bottom_dashes}");
388
389    let mut buf = String::new();
390
391    if color {
392        buf.push_str(&top_rule.dimmed().to_string());
393    } else {
394        buf.push_str(&top_rule);
395    }
396    buf.push('\n');
397
398    for line in lines {
399        buf.push_str("    "); // 4-space indent
400        buf.push_str(line);
401        buf.push('\n');
402    }
403
404    if color {
405        buf.push_str(&bottom_rule.dimmed().to_string());
406    } else {
407        buf.push_str(&bottom_rule);
408    }
409    buf.push('\n');
410
411    buf
412}
413
414// ── Level-prefixed messages ──────────────────────────────────────────
415
416/// Format a warning message: `warn: {message}`.
417pub fn format_warn(message: &str, color: bool) -> String {
418    if color {
419        format!("{} {message}", "warn:".yellow().bold())
420    } else {
421        format!("warn: {message}")
422    }
423}
424
425/// Format an info message: `info: {message}`.
426pub fn format_info(message: &str, color: bool) -> String {
427    if color {
428        format!("{} {message}", "info:".blue())
429    } else {
430        format!("info: {message}")
431    }
432}
433
434// ══════════════════════════════════════════════════════════════════════
435// Tests
436// ══════════════════════════════════════════════════════════════════════
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    // ── color_enabled ────────────────────────────────────────────────
443
444    #[test]
445    fn test_no_color_respected() {
446        // We can't safely set env vars in parallel tests, so just verify
447        // the function returns a bool based on current env. The real test
448        // is that format functions accept a `color: bool` parameter and
449        // produce different output for `true` vs `false`.
450        let _ = color_enabled(); // should not panic
451    }
452
453    // ── format_section_header ────────────────────────────────────────
454
455    #[test]
456    fn test_section_header_no_color() {
457        let h = format_section_header("Project Overview", false);
458        assert!(h.starts_with("── Project Overview "));
459        assert!(h.contains("─────"));
460        // Should be exactly 60 display characters (not bytes — ─ is 3 bytes in UTF-8).
461        assert_eq!(h.chars().count(), HEADER_WIDTH);
462    }
463
464    #[test]
465    fn test_section_header_with_color_contains_title() {
466        let h = format_section_header("Project Overview", true);
467        // Must still contain the title text even with ANSI codes.
468        assert!(h.contains("Project Overview"));
469    }
470
471    #[test]
472    fn test_section_header_long_title() {
473        let title = "A".repeat(70);
474        let h = format_section_header(&title, false);
475        // Title longer than HEADER_WIDTH — remaining dashes is 0.
476        assert!(h.contains(&title));
477        assert!(!h.ends_with("──")); // no trailing dashes when title is too long
478    }
479
480    // ── format_bar_chart ─────────────────────────────────────────────
481
482    #[test]
483    fn test_bar_chart_full() {
484        let b = format_bar_chart("Rust", 42, 1.0, "files", false);
485        assert!(b.contains("▓".repeat(BAR_WIDTH).as_str()));
486        assert!(!b.contains('░'));
487        assert!(b.contains("100.0%"));
488        assert!(b.contains("Rust"));
489        assert!(b.contains("42 files"));
490    }
491
492    #[test]
493    fn test_bar_chart_empty() {
494        let b = format_bar_chart("Python", 0, 0.0, "files", false);
495        assert!(b.contains("░".repeat(BAR_WIDTH).as_str()));
496        assert!(b.contains("0.0%"));
497    }
498
499    #[test]
500    fn test_bar_chart_half() {
501        let b = format_bar_chart("TypeScript", 10, 0.5, "files", false);
502        let filled = "▓".repeat(BAR_WIDTH / 2);
503        let empty = "░".repeat(BAR_WIDTH / 2);
504        assert!(b.contains(&filled));
505        assert!(b.contains(&empty));
506        assert!(b.contains("50.0%"));
507    }
508
509    #[test]
510    fn test_bar_chart_with_color() {
511        let b = format_bar_chart("Rust", 42, 0.75, "files", true);
512        // Should contain the label and count even with ANSI codes.
513        assert!(b.contains("Rust"));
514        assert!(b.contains("42 files"));
515        assert!(b.contains("75.0%"));
516    }
517
518    // ── format_tier_bullet ───────────────────────────────────────────
519
520    #[test]
521    fn test_tier_bullet_high() {
522        let b = format_tier_bullet("High", 12, ConfidenceTier::High, false);
523        assert!(b.contains('●'));
524        assert!(b.contains("High (12)"));
525    }
526
527    #[test]
528    fn test_tier_bullet_medium() {
529        let b = format_tier_bullet("Medium", 5, ConfidenceTier::Medium, false);
530        assert!(b.contains('◐'));
531        assert!(b.contains("Medium (5)"));
532    }
533
534    #[test]
535    fn test_tier_bullet_low() {
536        let b = format_tier_bullet("Low", 3, ConfidenceTier::Low, false);
537        assert!(b.contains('○'));
538        assert!(b.contains("Low (3)"));
539    }
540
541    #[test]
542    fn test_tier_bullet_with_color() {
543        let b = format_tier_bullet("High", 12, ConfidenceTier::High, true);
544        // Should contain the text even with ANSI codes.
545        assert!(b.contains("High (12)"));
546    }
547
548    // ── ConfidenceTier::from_confidence ──────────────────────────────
549
550    #[test]
551    fn test_confidence_tier_boundaries() {
552        assert_eq!(ConfidenceTier::from_confidence(100.0), ConfidenceTier::High);
553        assert_eq!(ConfidenceTier::from_confidence(86.0), ConfidenceTier::High);
554        assert_eq!(
555            ConfidenceTier::from_confidence(85.0),
556            ConfidenceTier::Medium,
557        );
558        assert_eq!(
559            ConfidenceTier::from_confidence(50.0),
560            ConfidenceTier::Medium,
561        );
562        assert_eq!(ConfidenceTier::from_confidence(49.9), ConfidenceTier::Low);
563        assert_eq!(ConfidenceTier::from_confidence(0.0), ConfidenceTier::Low);
564    }
565
566    // ── format_human_size ────────────────────────────────────────────
567
568    #[test]
569    fn test_human_size_bytes() {
570        assert_eq!(format_human_size(0), "0 B");
571        assert_eq!(format_human_size(999), "999 B");
572    }
573
574    #[test]
575    fn test_human_size_kilobytes() {
576        assert_eq!(format_human_size(1_000), "1.0 KB");
577        assert_eq!(format_human_size(1_500), "1.5 KB");
578        assert_eq!(format_human_size(999_999), "1000.0 KB");
579    }
580
581    #[test]
582    fn test_human_size_megabytes() {
583        assert_eq!(format_human_size(1_000_000), "1.0 MB");
584        assert_eq!(format_human_size(12_400_000), "12.4 MB");
585    }
586
587    #[test]
588    fn test_human_size_gigabytes() {
589        assert_eq!(format_human_size(2_500_000_000), "2.5 GB");
590    }
591
592    // ── format_number ────────────────────────────────────────────────
593
594    #[test]
595    fn test_format_number_no_separator() {
596        assert_eq!(format_number(0), "0");
597        assert_eq!(format_number(999), "999");
598    }
599
600    #[test]
601    fn test_format_number_with_separators() {
602        assert_eq!(format_number(1_234), "1,234");
603        assert_eq!(format_number(1_234_567), "1,234,567");
604        assert_eq!(format_number(1_000_000_000), "1,000,000,000");
605    }
606
607    // ── format_error_hint ────────────────────────────────────────────
608
609    #[test]
610    fn test_error_hint_no_hints() {
611        let e = format_error_hint("something broke", &[], false);
612        assert_eq!(e, "error: something broke");
613    }
614
615    #[test]
616    fn test_error_hint_with_hints() {
617        let e = format_error_hint("bad path", &["check the path", "try again"], false);
618        assert!(e.contains("error: bad path"));
619        assert!(e.contains("hint: check the path"));
620        assert!(e.contains("hint: try again"));
621    }
622
623    #[test]
624    fn test_error_hint_with_color() {
625        let e = format_error_hint("something broke", &["try X"], true);
626        assert!(e.contains("something broke"));
627        assert!(e.contains("try X"));
628    }
629
630    // ── format_bordered_box ──────────────────────────────────────────
631
632    #[test]
633    fn test_bordered_box_basic() {
634        let b = format_bordered_box(&["hello", "world"], false);
635        assert!(b.contains('┌'));
636        assert!(b.contains('┐'));
637        assert!(b.contains('│'));
638        assert!(b.contains('└'));
639        assert!(b.contains('┘'));
640        assert!(b.contains("hello"));
641        assert!(b.contains("world"));
642    }
643
644    #[test]
645    fn test_bordered_box_empty() {
646        let b = format_bordered_box(&[], false);
647        // Should still have top and bottom border.
648        assert!(b.contains('┌'));
649        assert!(b.contains('└'));
650    }
651
652    #[test]
653    fn test_bordered_box_with_color() {
654        let b = format_bordered_box(&["test"], true);
655        assert!(b.contains("test"));
656    }
657
658    // ── format_warn / format_info ────────────────────────────────────
659
660    #[test]
661    fn test_warn_no_color() {
662        assert_eq!(format_warn("oops", false), "warn: oops");
663    }
664
665    #[test]
666    fn test_info_no_color() {
667        assert_eq!(format_info("hello", false), "info: hello");
668    }
669
670    #[test]
671    fn test_warn_with_color() {
672        let w = format_warn("oops", true);
673        assert!(w.contains("oops"));
674    }
675
676    // ── Verbosity ────────────────────────────────────────────────────
677
678    #[test]
679    fn test_verbosity_from_flags_default() {
680        assert_eq!(Verbosity::from_flags(false, false), Verbosity::Default);
681    }
682
683    #[test]
684    fn test_verbosity_from_flags_verbose() {
685        assert_eq!(Verbosity::from_flags(true, false), Verbosity::Verbose);
686    }
687
688    #[test]
689    fn test_verbosity_from_flags_quiet() {
690        assert_eq!(Verbosity::from_flags(false, true), Verbosity::Quiet);
691    }
692
693    #[test]
694    fn test_verbosity_from_flags_both_quiet_wins() {
695        // When both are set, quiet takes precedence.
696        assert_eq!(Verbosity::from_flags(true, true), Verbosity::Quiet);
697    }
698
699    #[test]
700    fn test_verbosity_show_warnings() {
701        assert!(!Verbosity::Quiet.show_warnings());
702        assert!(Verbosity::Default.show_warnings());
703        assert!(Verbosity::Verbose.show_warnings());
704    }
705
706    #[test]
707    fn test_verbosity_show_findings() {
708        assert!(!Verbosity::Quiet.show_findings());
709        assert!(Verbosity::Default.show_findings());
710        assert!(Verbosity::Verbose.show_findings());
711    }
712
713    #[test]
714    fn test_verbosity_show_verbose() {
715        assert!(!Verbosity::Quiet.show_verbose());
716        assert!(!Verbosity::Default.show_verbose());
717        assert!(Verbosity::Verbose.show_verbose());
718    }
719
720    // ── format_copy_block ────────────────────────────────────────────
721
722    #[test]
723    fn test_copy_block_contains_content() {
724        let b = format_copy_block(&[r#""seshat": {"#, r#"  "command": "seshat""#, "}"], false);
725        assert!(b.contains(r#""seshat": {"#));
726        assert!(b.contains(r#""command": "seshat""#));
727        assert!(b.contains('}'));
728    }
729
730    #[test]
731    fn test_copy_block_has_copy_label() {
732        let b = format_copy_block(&["line"], false);
733        assert!(b.contains("── copy"));
734    }
735
736    #[test]
737    fn test_copy_block_no_vertical_bars() {
738        let b = format_copy_block(&[r#""key": "value""#], false);
739        // No │ characters — safe to copy/paste.
740        assert!(!b.contains('│'));
741    }
742
743    #[test]
744    fn test_copy_block_four_space_indent() {
745        let b = format_copy_block(&["hello"], false);
746        // Content line should have 4-space indent.
747        let content_line = b.lines().find(|l| l.contains("hello")).unwrap();
748        assert!(content_line.starts_with("    hello"));
749    }
750
751    #[test]
752    fn test_copy_block_empty() {
753        let b = format_copy_block(&[], false);
754        // Should still have both rules.
755        assert!(b.contains("── copy"));
756        assert!(b.lines().count() == 2); // top rule + bottom rule
757    }
758
759    #[test]
760    fn test_copy_block_with_color() {
761        let b = format_copy_block(&["test"], true);
762        assert!(b.contains("test"));
763        assert!(!b.contains('│'));
764    }
765
766    // ── NO_COLOR integration ─────────────────────────────────────────
767    //
768    // We test that format functions produce different output with
769    // color=true vs color=false, which proves the NO_COLOR path works
770    // (since the caller passes `color_enabled()` → `false` when NO_COLOR
771    // is set).
772
773    #[test]
774    fn test_no_color_produces_different_output() {
775        let with_color = format_section_header("Test", true);
776        let without_color = format_section_header("Test", false);
777        // ANSI codes make the colored version longer.
778        assert_ne!(with_color.len(), without_color.len());
779    }
780
781    #[test]
782    fn test_no_color_bar_chart_different() {
783        let with_color = format_bar_chart("Rust", 10, 0.5, "files", true);
784        let without_color = format_bar_chart("Rust", 10, 0.5, "files", false);
785        assert_ne!(with_color.len(), without_color.len());
786    }
787
788    #[test]
789    fn test_no_color_error_hint_different() {
790        let with_color = format_error_hint("fail", &["hint1"], true);
791        let without_color = format_error_hint("fail", &["hint1"], false);
792        assert_ne!(with_color.len(), without_color.len());
793    }
794}