1use crate::annotation::{Fuse, Status};
2use crate::scanner::ScanResult;
3use chrono::NaiveDate;
4use colored::Colorize;
5use serde::Serialize;
6use std::path::Path;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum OutputFormat {
11 Terminal,
13 Json,
15 GitHub,
17 Csv,
19 Table,
21}
22
23impl OutputFormat {
24 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 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
47fn color_enabled() -> bool {
49 std::env::var("NO_COLOR").is_err()
51}
52
53fn 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
65pub 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
84pub fn print_tag_stats(result: &ScanResult, use_color: bool) {
87 use std::collections::BTreeMap;
88
89 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
127pub fn print_scan_summary(result: &ScanResult) {
129 print_summary_line(result, color_enabled());
130}
131
132fn 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
168fn 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
179fn 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
191enum AgeStyle {
193 Compact,
195 Verbose,
197}
198
199fn 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
247pub 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#[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#[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 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
296pub 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
330pub 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
362pub 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
375pub 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
388fn 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
399pub 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
416pub 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
438fn 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
455pub 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
487pub 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
525pub 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
538pub 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 }
570 }
571}
572
573pub 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
580pub 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 OutputFormat::Csv | OutputFormat::Table => {
596 print_terminal(result, fuse_days, false, today, show_stats)
597 }
598 }
599}
600
601pub 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; 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#[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 let format = if std::env::var("GITHUB_ACTIONS").as_deref() == Ok("true") {
793 OutputFormat::GitHub
794 } else {
795 OutputFormat::Terminal
796 };
797 let _ = format;
799 }
800
801 #[test]
802 fn test_color_enabled_respects_no_color() {
803 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 #[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 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 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 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 #[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 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 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 print_tag_stats(&result, false);
994 }
995
996 #[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}