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#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum OutputFormat {
12 Terminal,
14 Json,
16 GitHub,
18 Csv,
20 Table,
22}
23
24impl OutputFormat {
25 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 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
48fn color_enabled() -> bool {
50 std::env::var("NO_COLOR").is_err()
52}
53
54fn 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
66pub 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
85pub fn print_tag_stats(result: &ScanResult, use_color: bool) {
88 use std::collections::BTreeMap;
89
90 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
128pub fn print_scan_summary(result: &ScanResult) {
130 print_summary_line(result, color_enabled());
131}
132
133fn 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
172fn 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
196fn 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
208enum AgeStyle {
210 Compact,
212 Verbose,
214}
215
216fn 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
264pub 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#[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#[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 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
313pub 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
347pub 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
379pub 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
392pub 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
405pub 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
414pub 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
499pub 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
506pub 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
530pub 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
537pub 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
566fn 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
577pub 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
594pub 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
616fn 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
633pub 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
665pub 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
703pub 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
716pub 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 }
748 }
749}
750
751pub 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
758pub 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 OutputFormat::Csv | OutputFormat::Table => {
774 print_terminal(result, fuse_days, false, today, show_stats)
775 }
776 }
777}
778
779pub 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; 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#[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 let format = if std::env::var("GITHUB_ACTIONS").as_deref() == Ok("true") {
1053 OutputFormat::GitHub
1054 } else {
1055 OutputFormat::Terminal
1056 };
1057 let _ = format;
1059 }
1060
1061 #[test]
1062 fn test_color_enabled_respects_no_color() {
1063 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 #[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 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 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 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 #[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 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 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 print_tag_stats(&result, false);
1254 }
1255
1256 #[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}