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