Skip to main content

timebomb/
output.rs

1use crate::annotation::{Fuse, Status};
2use crate::scanner::ScanResult;
3use chrono::NaiveDate;
4use colored::Colorize;
5use serde::Serialize;
6use std::path::Path;
7
8/// Output format selection.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum OutputFormat {
11    /// Human-readable terminal output with color.
12    Terminal,
13    /// Machine-readable JSON.
14    Json,
15    /// GitHub Actions annotation format.
16    GitHub,
17    /// Comma-separated values.
18    Csv,
19    /// Fixed-width aligned table (manifest only).
20    Table,
21}
22
23impl OutputFormat {
24    /// Auto-detect the best default format based on environment variables.
25    /// If `GITHUB_ACTIONS=true` is set, default to GitHub format.
26    pub fn auto_detect() -> Self {
27        if std::env::var("GITHUB_ACTIONS").as_deref() == Ok("true") {
28            OutputFormat::GitHub
29        } else {
30            OutputFormat::Terminal
31        }
32    }
33
34    /// Parse from a string (as provided by --format CLI flag).
35    pub fn parse_format(s: &str) -> Option<Self> {
36        match s.to_lowercase().as_str() {
37            "terminal" | "term" => Some(OutputFormat::Terminal),
38            "json" => Some(OutputFormat::Json),
39            "github" | "gh" => Some(OutputFormat::GitHub),
40            "csv" => Some(OutputFormat::Csv),
41            "table" => Some(OutputFormat::Table),
42            _ => None,
43        }
44    }
45}
46
47/// Whether color output should be enabled.
48fn color_enabled() -> bool {
49    // Respect NO_COLOR convention (https://no-color.org/)
50    std::env::var("NO_COLOR").is_err()
51}
52
53// ─── Terminal formatter ───────────────────────────────────────────────────────
54
55/// Format a relative days label for terminal/GitHub output.
56fn days_label(fuse: &Fuse, today: NaiveDate) -> String {
57    let delta = fuse.days_from_today(today);
58    match fuse.status {
59        Status::Detonated => format!(" ({} days overdue)", delta.unsigned_abs()),
60        Status::Ticking => format!(" (in {} days)", delta),
61        Status::Inert => String::new(),
62    }
63}
64
65/// Print a `ScanResult` to stdout using the terminal (colored) format.
66pub fn print_terminal(
67    result: &ScanResult,
68    _fuse_days: u32,
69    _show_ok: bool,
70    today: NaiveDate,
71    show_stats: bool,
72) {
73    let use_color = color_enabled();
74    for fuse in &result.fuses {
75        print_fuse_terminal(fuse, use_color, today);
76    }
77    println!();
78    print_summary_line(result, use_color);
79    if show_stats {
80        print_tag_stats(result, use_color);
81    }
82}
83
84/// Print a per-tag breakdown of detonated/ticking counts to stderr.
85/// Only called for terminal format; silently skipped for JSON/GitHub.
86pub fn print_tag_stats(result: &ScanResult, use_color: bool) {
87    use std::collections::BTreeMap;
88
89    // Build tag -> (detonated, ticking) in one pass; skip inert-only tags.
90    let mut counts: BTreeMap<&str, (usize, usize)> = BTreeMap::new();
91    for fuse in &result.fuses {
92        let entry = counts.entry(fuse.tag.as_str()).or_insert((0, 0));
93        match fuse.status {
94            Status::Detonated => entry.0 += 1,
95            Status::Ticking => entry.1 += 1,
96            Status::Inert => {}
97        }
98    }
99
100    let relevant: Vec<_> = counts
101        .iter()
102        .filter(|(_, (d, t))| *d > 0 || *t > 0)
103        .collect();
104
105    if relevant.is_empty() {
106        return;
107    }
108
109    eprintln!();
110    for (tag, (detonated, ticking)) in &relevant {
111        let line = format!(
112            "  {:<12}  {:>3} detonated  {:>3} ticking",
113            tag, detonated, ticking
114        );
115        if use_color {
116            if *detonated > 0 {
117                eprintln!("{}", line.red().bold());
118            } else {
119                eprintln!("{}", line.yellow());
120            }
121        } else {
122            eprintln!("{}", line);
123        }
124    }
125}
126
127/// Print only the summary line — used by `sweep --summary`.
128pub fn print_scan_summary(result: &ScanResult) {
129    print_summary_line(result, color_enabled());
130}
131
132/// Shared summary-line renderer used by both `print_terminal` and `print_scan_summary`.
133fn print_summary_line(result: &ScanResult, use_color: bool) {
134    let (detonated_count, ticking_count, inert_count) =
135        result
136            .fuses
137            .iter()
138            .fold((0usize, 0usize, 0usize), |(d, t, i), fuse| {
139                match fuse.status {
140                    Status::Detonated => (d + 1, t, i),
141                    Status::Ticking => (d, t + 1, i),
142                    Status::Inert => (d, t, i + 1),
143                }
144            });
145
146    let summary = format!(
147        "Swept {} file(s) · {} fuse(s) total · {} detonated · {} ticking · {} inert",
148        result.swept_files,
149        result.total(),
150        detonated_count,
151        ticking_count,
152        inert_count,
153    );
154
155    if use_color {
156        if detonated_count > 0 {
157            eprintln!("{}", summary.red().bold());
158        } else if ticking_count > 0 {
159            eprintln!("{}", summary.yellow());
160        } else {
161            eprintln!("{}", summary.green());
162        }
163    } else {
164        eprintln!("{}", summary);
165    }
166}
167
168/// Format the owner column: `[owner]` if explicit, `[~blame]` if inferred, empty otherwise.
169fn owner_display(fuse: &Fuse) -> String {
170    if let Some(o) = &fuse.owner {
171        format!(" [{}]", o)
172    } else if let Some(b) = &fuse.blamed_owner {
173        format!(" [~{}]", b)
174    } else {
175        String::new()
176    }
177}
178
179/// Compact signed age: `-98d` (overdue) or `+12d` (future), fixed 7-char wide column.
180/// Used by `manifest` (list) output.
181fn age_col(fuse: &Fuse, today: NaiveDate) -> String {
182    let delta = fuse.days_from_today(today);
183    let raw = if delta < 0 {
184        format!("-{}d", delta.unsigned_abs())
185    } else {
186        format!("+{}d", delta)
187    };
188    format!("{:<7}", raw)
189}
190
191/// How to render the time-relative field for a fuse line.
192enum AgeStyle {
193    /// Compact `+Xd` / `-Xd` column (manifest).
194    Compact,
195    /// Verbose `(X days overdue)` / `(in X days)` suffix (sweep).
196    Verbose,
197}
198
199/// Shared single-fuse terminal renderer used by both sweep and manifest output.
200///
201/// `sweep` uses `AgeStyle::Verbose`; `manifest` uses `AgeStyle::Compact`.
202fn print_fuse_line(fuse: &Fuse, use_color: bool, today: NaiveDate, age_style: AgeStyle) {
203    let status_label = match fuse.status {
204        Status::Detonated => "DETONATED",
205        Status::Ticking => "TICKING  ",
206        Status::Inert => "INERT    ",
207    };
208
209    let location = format!("{:<40}", fuse.location());
210    let tag_date = format!("{}[{}]", fuse.tag, fuse.date_str());
211    let tag_date_col = format!("{:<20}", tag_date);
212    let owner_part = owner_display(fuse);
213
214    let line = match age_style {
215        AgeStyle::Compact => {
216            let age = age_col(fuse, today);
217            format!(
218                "{} {}  {}  {}{}  {}",
219                status_label, location, tag_date_col, age, owner_part, fuse.message
220            )
221        }
222        AgeStyle::Verbose => {
223            let days_str = days_label(fuse, today);
224            format!(
225                "{} {}  {}{}{}  {}",
226                status_label, location, tag_date_col, days_str, owner_part, fuse.message
227            )
228        }
229    };
230
231    if use_color {
232        let colored_line = match fuse.status {
233            Status::Detonated => line.red().bold().to_string(),
234            Status::Ticking => line.yellow().to_string(),
235            Status::Inert => line.dimmed().to_string(),
236        };
237        println!("{}", colored_line);
238    } else {
239        println!("{}", line);
240    }
241}
242
243fn print_fuse_terminal(fuse: &Fuse, use_color: bool, today: NaiveDate) {
244    print_fuse_line(fuse, use_color, today, AgeStyle::Verbose);
245}
246
247/// Print a single fuse in terminal format (used by `manifest` subcommand).
248pub fn print_fuse_line_terminal(fuse: &Fuse, use_color: bool, today: NaiveDate) {
249    print_fuse_line(fuse, use_color, today, AgeStyle::Compact);
250}
251
252// ─── JSON formatter ───────────────────────────────────────────────────────────
253
254/// Serializable wrapper for the full JSON output.
255#[derive(Debug, Serialize)]
256pub struct JsonOutput<'a> {
257    pub swept_files: usize,
258    pub total_fuses: usize,
259    pub detonated: Vec<JsonFuse<'a>>,
260    pub ticking: Vec<JsonFuse<'a>>,
261    pub inert: Vec<JsonFuse<'a>>,
262}
263
264/// A single fuse serialized for JSON output.
265#[derive(Debug, Serialize)]
266pub struct JsonFuse<'a> {
267    pub file: String,
268    pub line: usize,
269    pub tag: &'a str,
270    pub date: String,
271    /// Days until expiry (positive) or overdue (negative).
272    pub days: i64,
273    pub owner: Option<&'a str>,
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub blamed_owner: Option<&'a str>,
276    pub message: &'a str,
277    pub status: &'a str,
278}
279
280impl<'a> JsonFuse<'a> {
281    fn from_fuse(fuse: &'a Fuse, today: NaiveDate) -> Self {
282        JsonFuse {
283            file: fuse.file.display().to_string(),
284            line: fuse.line,
285            tag: &fuse.tag,
286            date: fuse.date_str(),
287            days: fuse.days_from_today(today),
288            owner: fuse.owner.as_deref(),
289            blamed_owner: fuse.blamed_owner.as_deref(),
290            message: &fuse.message,
291            status: fuse.status.as_str(),
292        }
293    }
294}
295
296/// Print the full scan result as JSON to stdout.
297pub fn print_json(result: &ScanResult, today: NaiveDate) {
298    let detonated: Vec<JsonFuse> = result
299        .detonated()
300        .iter()
301        .map(|f| JsonFuse::from_fuse(f, today))
302        .collect();
303
304    let ticking: Vec<JsonFuse> = result
305        .ticking()
306        .iter()
307        .map(|f| JsonFuse::from_fuse(f, today))
308        .collect();
309
310    let inert: Vec<JsonFuse> = result
311        .inert()
312        .iter()
313        .map(|f| JsonFuse::from_fuse(f, today))
314        .collect();
315
316    let output = JsonOutput {
317        swept_files: result.swept_files,
318        total_fuses: result.total(),
319        detonated,
320        ticking,
321        inert,
322    };
323
324    match serde_json::to_string_pretty(&output) {
325        Ok(json) => println!("{}", json),
326        Err(e) => eprintln!("error: failed to serialize JSON output: {}", e),
327    }
328}
329
330/// Serialize the scan result as JSON and write it to a file (used by `sweep --output`).
331pub fn write_json_report(
332    result: &ScanResult,
333    path: &Path,
334    today: NaiveDate,
335) -> std::io::Result<()> {
336    let detonated: Vec<JsonFuse> = result
337        .detonated()
338        .iter()
339        .map(|f| JsonFuse::from_fuse(f, today))
340        .collect();
341    let ticking: Vec<JsonFuse> = result
342        .ticking()
343        .iter()
344        .map(|f| JsonFuse::from_fuse(f, today))
345        .collect();
346    let inert: Vec<JsonFuse> = result
347        .inert()
348        .iter()
349        .map(|f| JsonFuse::from_fuse(f, today))
350        .collect();
351    let output = JsonOutput {
352        swept_files: result.swept_files,
353        total_fuses: result.total(),
354        detonated,
355        ticking,
356        inert,
357    };
358    let json = serde_json::to_string_pretty(&output).map_err(std::io::Error::other)?;
359    std::fs::write(path, json)
360}
361
362/// Serialize a slice of fuses as a JSON array (used by `manifest --format json`).
363pub fn print_json_list(fuses: &[&Fuse], today: NaiveDate) {
364    let items: Vec<JsonFuse> = fuses
365        .iter()
366        .map(|f| JsonFuse::from_fuse(f, today))
367        .collect();
368
369    match serde_json::to_string_pretty(&items) {
370        Ok(json) => println!("{}", json),
371        Err(e) => eprintln!("error: failed to serialize JSON output: {}", e),
372    }
373}
374
375/// Write a slice of fuses as a JSON array to any `Write` sink (used by `manifest --output`).
376pub fn print_json_list_to_writer(
377    fuses: &[&Fuse],
378    writer: impl std::io::Write,
379    today: NaiveDate,
380) -> std::io::Result<()> {
381    let items: Vec<JsonFuse> = fuses
382        .iter()
383        .map(|f| JsonFuse::from_fuse(f, today))
384        .collect();
385    serde_json::to_writer_pretty(writer, &items).map_err(std::io::Error::other)
386}
387
388// ─── CSV formatter ────────────────────────────────────────────────────────────
389
390/// Wrap a CSV field in quotes if it contains a comma, quote, or newline.
391fn csv_field(s: &str) -> String {
392    if s.contains(',') || s.contains('"') || s.contains('\n') {
393        format!("\"{}\"", s.replace('"', "\"\""))
394    } else {
395        s.to_string()
396    }
397}
398
399/// Print fuses as CSV to stdout (used by `manifest --format csv`).
400pub fn print_csv_list(fuses: &[&Fuse]) {
401    println!("file,line,tag,date,owner,status,message");
402    for fuse in fuses {
403        println!(
404            "{},{},{},{},{},{},{}",
405            csv_field(&fuse.file.display().to_string()),
406            fuse.line,
407            csv_field(&fuse.tag),
408            csv_field(&fuse.date_str()),
409            csv_field(fuse.owner.as_deref().unwrap_or("")),
410            fuse.status.as_str(),
411            csv_field(&fuse.message),
412        );
413    }
414}
415
416/// Write fuses as CSV to any `Write` sink (used by `manifest --format csv --output file`).
417pub fn print_csv_list_to_writer(
418    fuses: &[&Fuse],
419    mut writer: impl std::io::Write,
420) -> std::io::Result<()> {
421    writeln!(writer, "file,line,tag,date,owner,status,message")?;
422    for fuse in fuses {
423        writeln!(
424            writer,
425            "{},{},{},{},{},{},{}",
426            csv_field(&fuse.file.display().to_string()),
427            fuse.line,
428            csv_field(&fuse.tag),
429            csv_field(&fuse.date_str()),
430            csv_field(fuse.owner.as_deref().unwrap_or("")),
431            fuse.status.as_str(),
432            csv_field(&fuse.message),
433        )?;
434    }
435    Ok(())
436}
437
438// ─── Table formatter ──────────────────────────────────────────────────────────
439
440/// Compute column widths for the table format: (file, line, tag, status).
441fn compute_table_widths(fuses: &[&Fuse]) -> (usize, usize, usize, usize) {
442    let mut w_file = "FILE".len();
443    let mut w_line = "LINE".len();
444    let mut w_tag = "TAG".len();
445    let mut w_status = "STATUS".len();
446    for fuse in fuses {
447        w_file = w_file.max(fuse.file.display().to_string().len());
448        w_line = w_line.max(fuse.line.to_string().len());
449        w_tag = w_tag.max(fuse.tag.len());
450        w_status = w_status.max(fuse.status.as_str().len());
451    }
452    (w_file, w_line, w_tag, w_status)
453}
454
455/// Print fuses as a fixed-width aligned table to stdout (used by `manifest --format table`).
456pub fn print_table_list(fuses: &[&Fuse]) {
457    let (w_file, w_line, w_tag, w_status) = compute_table_widths(fuses);
458    println!(
459        "{:<w_file$}  {:>w_line$}  {:<w_tag$}  {:<10}  {:<w_status$}  MESSAGE",
460        "FILE",
461        "LINE",
462        "TAG",
463        "DATE",
464        "STATUS",
465        w_file = w_file,
466        w_line = w_line,
467        w_tag = w_tag,
468        w_status = w_status,
469    );
470    for fuse in fuses {
471        println!(
472            "{:<w_file$}  {:>w_line$}  {:<w_tag$}  {:<10}  {:<w_status$}  {}",
473            fuse.file.display(),
474            fuse.line,
475            fuse.tag,
476            fuse.date_str(),
477            fuse.status.as_str(),
478            fuse.message,
479            w_file = w_file,
480            w_line = w_line,
481            w_tag = w_tag,
482            w_status = w_status,
483        );
484    }
485}
486
487/// Write fuses as a fixed-width table to any `Write` sink (used by `manifest --format table --output`).
488pub fn print_table_list_to_writer(
489    fuses: &[&Fuse],
490    mut writer: impl std::io::Write,
491) -> std::io::Result<()> {
492    let (w_file, w_line, w_tag, w_status) = compute_table_widths(fuses);
493    writeln!(
494        writer,
495        "{:<w_file$}  {:>w_line$}  {:<w_tag$}  {:<10}  {:<w_status$}  MESSAGE",
496        "FILE",
497        "LINE",
498        "TAG",
499        "DATE",
500        "STATUS",
501        w_file = w_file,
502        w_line = w_line,
503        w_tag = w_tag,
504        w_status = w_status,
505    )?;
506    for fuse in fuses {
507        writeln!(
508            writer,
509            "{:<w_file$}  {:>w_line$}  {:<w_tag$}  {:<10}  {:<w_status$}  {}",
510            fuse.file.display(),
511            fuse.line,
512            fuse.tag,
513            fuse.date_str(),
514            fuse.status.as_str(),
515            fuse.message,
516            w_file = w_file,
517            w_line = w_line,
518            w_tag = w_tag,
519            w_status = w_status,
520        )?;
521    }
522    Ok(())
523}
524
525// ─── GitHub Actions formatter ─────────────────────────────────────────────────
526
527/// Print fuses in GitHub Actions workflow command format.
528///
529/// Detonated → `::error`
530/// Ticking → `::warning`
531/// Inert → silently skipped
532pub fn print_github(result: &ScanResult, _fuse_days: u32, today: NaiveDate) {
533    for fuse in &result.fuses {
534        print_fuse_github(fuse, 0, today);
535    }
536}
537
538/// Print a single fuse in GitHub Actions format.
539pub fn print_fuse_github(fuse: &Fuse, _fuse_days: u32, today: NaiveDate) {
540    let file = fuse.file.display().to_string();
541    let line = fuse.line;
542    let delta = fuse.days_from_today(today);
543
544    match fuse.status {
545        Status::Detonated => {
546            println!(
547                "::error file={},line={}::{} detonated on {} ({} days overdue): {}",
548                file,
549                line,
550                fuse.tag,
551                fuse.date_str(),
552                delta.unsigned_abs(),
553                fuse.message
554            );
555        }
556        Status::Ticking => {
557            println!(
558                "::warning file={},line={}::{} detonates on {} (in {} days): {}",
559                file,
560                line,
561                fuse.tag,
562                fuse.date_str(),
563                delta,
564                fuse.message
565            );
566        }
567        Status::Inert => {
568            // Don't emit anything for inert fuses in CI output
569        }
570    }
571}
572
573/// Print a slice of fuses in GitHub Actions format for the `manifest` subcommand.
574pub fn print_github_list(fuses: &[&Fuse], fuse_days: u32, today: NaiveDate) {
575    for fuse in fuses {
576        print_fuse_github(fuse, fuse_days, today);
577    }
578}
579
580// ─── Dispatch helpers ─────────────────────────────────────────────────────────
581
582/// Top-level dispatch: print a `ScanResult` in whatever format was requested.
583pub fn print_scan_result(
584    result: &ScanResult,
585    format: &OutputFormat,
586    fuse_days: u32,
587    today: NaiveDate,
588    show_stats: bool,
589) {
590    match format {
591        OutputFormat::Terminal => print_terminal(result, fuse_days, false, today, show_stats),
592        OutputFormat::Json => print_json(result, today),
593        OutputFormat::GitHub => print_github(result, fuse_days, today),
594        // CSV and Table are not supported for sweep — callers must validate before reaching here.
595        OutputFormat::Csv | OutputFormat::Table => {
596            print_terminal(result, fuse_days, false, today, show_stats)
597        }
598    }
599}
600
601/// Top-level dispatch for the `manifest` subcommand.
602pub fn print_list(
603    fuses: &[&Fuse],
604    format: &OutputFormat,
605    fuse_days: u32,
606    scan_root: &Path,
607    today: NaiveDate,
608) {
609    let _ = scan_root; // available for future use (e.g. relative path display)
610    let use_color = color_enabled();
611
612    match format {
613        OutputFormat::Terminal => {
614            for fuse in fuses {
615                print_fuse_line_terminal(fuse, use_color, today);
616            }
617            println!();
618            eprintln!("{} fuse(s) listed", fuses.len());
619        }
620        OutputFormat::Json => {
621            print_json_list(fuses, today);
622        }
623        OutputFormat::GitHub => {
624            print_github_list(fuses, fuse_days, today);
625        }
626        OutputFormat::Csv => {
627            print_csv_list(fuses);
628        }
629        OutputFormat::Table => {
630            print_table_list(fuses);
631        }
632    }
633}
634
635// ─── Tests ───────────────────────────────────────────────────────────────────
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640    use crate::annotation::Status;
641    use chrono::NaiveDate;
642    use std::path::PathBuf;
643
644    fn date(s: &str) -> NaiveDate {
645        NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
646    }
647
648    fn fixed_today() -> NaiveDate {
649        date("2026-03-23")
650    }
651
652    fn make_fuse(tag: &str, expiry: &str, status: Status, msg: &str) -> Fuse {
653        Fuse {
654            file: PathBuf::from("src/foo.rs"),
655            line: 42,
656            tag: tag.to_string(),
657            date: date(expiry),
658            owner: None,
659            message: msg.to_string(),
660            status,
661            blamed_owner: None,
662        }
663    }
664
665    fn make_fuse_with_owner(
666        tag: &str,
667        expiry: &str,
668        status: Status,
669        msg: &str,
670        owner: &str,
671    ) -> Fuse {
672        Fuse {
673            file: PathBuf::from("src/foo.rs"),
674            line: 10,
675            tag: tag.to_string(),
676            date: date(expiry),
677            owner: Some(owner.to_string()),
678            message: msg.to_string(),
679            status,
680            blamed_owner: None,
681        }
682    }
683
684    #[test]
685    fn test_output_format_from_str() {
686        assert_eq!(OutputFormat::parse_format("json"), Some(OutputFormat::Json));
687        assert_eq!(OutputFormat::parse_format("JSON"), Some(OutputFormat::Json));
688        assert_eq!(
689            OutputFormat::parse_format("github"),
690            Some(OutputFormat::GitHub)
691        );
692        assert_eq!(OutputFormat::parse_format("gh"), Some(OutputFormat::GitHub));
693        assert_eq!(
694            OutputFormat::parse_format("terminal"),
695            Some(OutputFormat::Terminal)
696        );
697        assert_eq!(
698            OutputFormat::parse_format("term"),
699            Some(OutputFormat::Terminal)
700        );
701        assert_eq!(OutputFormat::parse_format("unknown"), None);
702    }
703
704    #[test]
705    fn test_json_fuse_from_fuse() {
706        let today = fixed_today();
707        let fuse = make_fuse("TODO", "2020-01-01", Status::Detonated, "remove this");
708        let j = JsonFuse::from_fuse(&fuse, today);
709        assert_eq!(j.file, "src/foo.rs");
710        assert_eq!(j.line, 42);
711        assert_eq!(j.tag, "TODO");
712        assert_eq!(j.date, "2020-01-01");
713        assert_eq!(j.owner, None);
714        assert_eq!(j.message, "remove this");
715        assert_eq!(j.status, "detonated");
716        assert!(j.days < 0, "detonated fuse should have negative days");
717    }
718
719    #[test]
720    fn test_json_fuse_days_positive_for_future() {
721        let today = fixed_today();
722        let fuse = make_fuse("HACK", "2099-01-01", Status::Inert, "far future");
723        let j = JsonFuse::from_fuse(&fuse, today);
724        assert!(j.days > 0, "future fuse should have positive days");
725    }
726
727    #[test]
728    fn test_json_fuse_with_owner() {
729        let today = fixed_today();
730        let fuse =
731            make_fuse_with_owner("FIXME", "2099-01-01", Status::Inert, "upgrade later", "bob");
732        let j = JsonFuse::from_fuse(&fuse, today);
733        assert_eq!(j.owner, Some("bob"));
734        assert_eq!(j.status, "inert");
735    }
736
737    #[test]
738    fn test_json_fuse_ticking_status() {
739        let today = fixed_today();
740        let fuse = make_fuse("HACK", "2025-06-10", Status::Ticking, "temp hack");
741        let j = JsonFuse::from_fuse(&fuse, today);
742        assert_eq!(j.status, "ticking");
743    }
744
745    #[test]
746    fn test_print_json_does_not_panic() {
747        use crate::scanner::ScanResult;
748        let result = ScanResult {
749            fuses: vec![
750                make_fuse("TODO", "2020-01-01", Status::Detonated, "detonated"),
751                make_fuse("FIXME", "2099-01-01", Status::Inert, "future"),
752            ],
753            swept_files: 5,
754            skipped_files: 1,
755        };
756        print_json(&result, fixed_today());
757    }
758
759    #[test]
760    fn test_print_json_list_does_not_panic() {
761        let fuse = make_fuse("TODO", "2020-01-01", Status::Detonated, "detonated");
762        print_json_list(&[&fuse], fixed_today());
763    }
764
765    #[test]
766    fn test_print_github_detonated_format() {
767        let fuse = make_fuse(
768            "TODO",
769            "2020-01-01",
770            Status::Detonated,
771            "remove legacy oauth",
772        );
773        print_fuse_github(&fuse, 14, fixed_today());
774    }
775
776    #[test]
777    fn test_print_github_ticking_format() {
778        let fuse = make_fuse("FIXME", "2026-04-01", Status::Ticking, "fix before release");
779        print_fuse_github(&fuse, 14, fixed_today());
780    }
781
782    #[test]
783    fn test_print_github_inert_is_silent() {
784        let fuse = make_fuse("HACK", "2099-01-01", Status::Inert, "fine for now");
785        print_fuse_github(&fuse, 0, fixed_today());
786    }
787
788    #[test]
789    fn test_auto_detect_no_github_env() {
790        // When GITHUB_ACTIONS is not set (or not "true"), should default to Terminal
791        // We can't reliably unset env vars in tests, so just verify the logic path
792        let format = if std::env::var("GITHUB_ACTIONS").as_deref() == Ok("true") {
793            OutputFormat::GitHub
794        } else {
795            OutputFormat::Terminal
796        };
797        // Just make sure this doesn't panic
798        let _ = format;
799    }
800
801    #[test]
802    fn test_color_enabled_respects_no_color() {
803        // We can't easily set/unset env vars in a portable, safe way in parallel tests,
804        // but we can verify the function returns a bool without panicking.
805        let _enabled = color_enabled();
806    }
807
808    #[test]
809    fn test_print_terminal_does_not_panic() {
810        use crate::scanner::ScanResult;
811        let result = ScanResult {
812            fuses: vec![
813                make_fuse("TODO", "2020-01-01", Status::Detonated, "old"),
814                make_fuse("FIXME", "2026-04-15", Status::Ticking, "soon"),
815                make_fuse("HACK", "2099-12-31", Status::Inert, "future"),
816            ],
817            swept_files: 3,
818            skipped_files: 0,
819        };
820        print_terminal(&result, 14, true, fixed_today(), false);
821    }
822
823    #[test]
824    fn test_print_fuse_line_terminal_with_owner() {
825        let fuse = make_fuse_with_owner(
826            "TODO",
827            "2020-01-01",
828            Status::Detonated,
829            "remove me",
830            "alice",
831        );
832        print_fuse_line_terminal(&fuse, false, fixed_today());
833    }
834
835    #[test]
836    fn test_print_list_terminal_does_not_panic() {
837        let fuse = make_fuse("TODO", "2020-01-01", Status::Detonated, "list item");
838        print_list(
839            &[&fuse],
840            &OutputFormat::Terminal,
841            14,
842            std::path::Path::new("."),
843            fixed_today(),
844        );
845    }
846
847    #[test]
848    fn test_print_list_json_does_not_panic() {
849        let fuse = make_fuse("FIXME", "2099-01-01", Status::Inert, "future item");
850        print_list(
851            &[&fuse],
852            &OutputFormat::Json,
853            0,
854            std::path::Path::new("."),
855            fixed_today(),
856        );
857    }
858
859    #[test]
860    fn test_print_list_github_does_not_panic() {
861        let fuse = make_fuse("HACK", "2020-01-01", Status::Detonated, "github list");
862        print_list(
863            &[&fuse],
864            &OutputFormat::GitHub,
865            0,
866            std::path::Path::new("."),
867            fixed_today(),
868        );
869    }
870
871    #[test]
872    fn test_print_scan_result_dispatch() {
873        use crate::scanner::ScanResult;
874        let result = ScanResult {
875            fuses: vec![make_fuse("TODO", "2020-01-01", Status::Detonated, "x")],
876            swept_files: 1,
877            skipped_files: 0,
878        };
879        print_scan_result(&result, &OutputFormat::Terminal, 0, fixed_today(), false);
880        print_scan_result(&result, &OutputFormat::Json, 0, fixed_today(), false);
881        print_scan_result(&result, &OutputFormat::GitHub, 0, fixed_today(), false);
882    }
883
884    // ── blamed_owner display ──────────────────────────────────────────────────
885
886    #[test]
887    fn test_owner_display_explicit_owner() {
888        let fuse = make_fuse_with_owner("TODO", "2020-01-01", Status::Detonated, "msg", "alice");
889        assert_eq!(owner_display(&fuse), " [alice]");
890    }
891
892    #[test]
893    fn test_owner_display_blamed_owner() {
894        let mut fuse = make_fuse("TODO", "2020-01-01", Status::Detonated, "msg");
895        fuse.blamed_owner = Some("bob".to_string());
896        assert_eq!(owner_display(&fuse), " [~bob]");
897    }
898
899    #[test]
900    fn test_owner_display_no_owner() {
901        let fuse = make_fuse("TODO", "2020-01-01", Status::Detonated, "msg");
902        assert_eq!(owner_display(&fuse), "");
903    }
904
905    #[test]
906    fn test_owner_display_explicit_takes_precedence_over_blamed() {
907        // When both owner and blamed_owner are set, explicit owner wins.
908        let mut fuse =
909            make_fuse_with_owner("TODO", "2020-01-01", Status::Detonated, "msg", "alice");
910        fuse.blamed_owner = Some("bob".to_string());
911        // Should show explicit owner, not blamed_owner.
912        assert_eq!(owner_display(&fuse), " [alice]");
913    }
914
915    #[test]
916    fn test_json_fuse_includes_blamed_owner() {
917        let mut fuse = make_fuse("TODO", "2020-01-01", Status::Detonated, "msg");
918        fuse.blamed_owner = Some("dave".to_string());
919        let j = JsonFuse::from_fuse(&fuse, fixed_today());
920        assert_eq!(j.blamed_owner, Some("dave"));
921        assert_eq!(j.owner, None);
922    }
923
924    #[test]
925    fn test_json_fuse_blamed_owner_absent_when_none() {
926        let fuse = make_fuse("TODO", "2020-01-01", Status::Detonated, "msg");
927        let j = JsonFuse::from_fuse(&fuse, fixed_today());
928        assert_eq!(j.blamed_owner, None);
929        // The field is skip_serializing_if = None, so it must not appear in the JSON string.
930        let json = serde_json::to_string(&j).unwrap();
931        assert!(!json.contains("blamed_owner"));
932    }
933
934    #[test]
935    fn test_print_fuse_line_terminal_with_blamed_owner() {
936        let mut fuse = make_fuse("TODO", "2020-01-01", Status::Detonated, "msg");
937        fuse.blamed_owner = Some("eve".to_string());
938        print_fuse_line_terminal(&fuse, false, fixed_today());
939    }
940
941    // ── table format ─────────────────────────────────────────────────────────
942
943    #[test]
944    fn test_print_table_list_does_not_panic() {
945        let fuses = [
946            make_fuse("TODO", "2020-01-01", Status::Detonated, "remove this"),
947            make_fuse("FIXME", "2026-04-01", Status::Ticking, "fix soon"),
948            make_fuse("HACK", "2099-01-01", Status::Inert, "far future"),
949        ];
950        print_table_list(&fuses.iter().collect::<Vec<_>>());
951    }
952
953    #[test]
954    fn test_print_table_list_empty() {
955        // Should print header only without panicking.
956        print_table_list(&[]);
957    }
958
959    #[test]
960    fn test_output_format_parse_table() {
961        assert_eq!(
962            OutputFormat::parse_format("table"),
963            Some(OutputFormat::Table)
964        );
965    }
966
967    #[test]
968    fn test_print_tag_stats_does_not_panic() {
969        use crate::scanner::ScanResult;
970        let result = ScanResult {
971            fuses: vec![
972                make_fuse("TODO", "2020-01-01", Status::Detonated, "d1"),
973                make_fuse("TODO", "2020-06-01", Status::Detonated, "d2"),
974                make_fuse("FIXME", "2026-04-01", Status::Ticking, "t1"),
975                make_fuse("HACK", "2099-01-01", Status::Inert, "i1"),
976            ],
977            swept_files: 4,
978            skipped_files: 0,
979        };
980        print_tag_stats(&result, false);
981    }
982
983    #[test]
984    fn test_print_tag_stats_skips_inert_only_tags() {
985        use crate::scanner::ScanResult;
986        // HACK is inert-only; should not appear in stats output.
987        let result = ScanResult {
988            fuses: vec![make_fuse("HACK", "2099-01-01", Status::Inert, "fine")],
989            swept_files: 1,
990            skipped_files: 0,
991        };
992        // Just verify it doesn't panic; inert-only tags produce no output.
993        print_tag_stats(&result, false);
994    }
995
996    // ── days_label ────────────────────────────────────────────────────────────
997
998    #[test]
999    fn test_days_label_detonated_shows_overdue() {
1000        let fuse = make_fuse("TODO", "2020-01-01", Status::Detonated, "msg");
1001        let label = days_label(&fuse, fixed_today());
1002        assert!(
1003            label.contains("overdue"),
1004            "expected 'overdue' in '{}'",
1005            label
1006        );
1007        assert!(
1008            !label.contains("in "),
1009            "detonated should not say 'in X days'"
1010        );
1011    }
1012
1013    #[test]
1014    fn test_days_label_ticking_shows_days_remaining() {
1015        let fuse = make_fuse("FIXME", "2026-04-01", Status::Ticking, "msg");
1016        let label = days_label(&fuse, fixed_today());
1017        assert!(label.contains("in "), "expected 'in X days' in '{}'", label);
1018        assert!(label.contains("days"), "expected 'days' in '{}'", label);
1019    }
1020
1021    #[test]
1022    fn test_days_label_inert_is_empty() {
1023        let fuse = make_fuse("HACK", "2099-01-01", Status::Inert, "msg");
1024        let label = days_label(&fuse, fixed_today());
1025        assert!(label.is_empty(), "inert fuses should have no days label");
1026    }
1027}