1use crate::error::{Error, Result};
2use crate::output::OutputFormat;
3use crate::report::{Report, ReportAnnotation};
4use colored::Colorize;
5use serde::Serialize;
6use std::collections::HashSet;
7use std::path::Path;
8
9#[derive(Debug, Serialize)]
11pub struct TrendResult {
12 pub from_timestamp: String,
13 pub to_timestamp: String,
14 pub detonated_delta: i64,
16 pub ticking_delta: i64,
17 pub total_delta: i64,
18 pub newly_detonated: Vec<ReportAnnotation>,
20 pub resolved: Vec<ReportAnnotation>,
22 pub snoozed: Vec<ReportAnnotation>,
24}
25
26fn load_report(path: &Path) -> Result<Report> {
29 let content = std::fs::read_to_string(path).map_err(|e| Error::Io {
30 source: e,
31 path: Some(path.to_path_buf()),
32 })?;
33 serde_json::from_str(&content).map_err(|e| Error::Io {
34 source: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
35 path: Some(path.to_path_buf()),
36 })
37}
38
39fn annotation_key(a: &ReportAnnotation) -> String {
41 format!("{}:{}", a.file, a.line)
42}
43
44fn key_set(anns: &[ReportAnnotation]) -> HashSet<String> {
45 anns.iter().map(annotation_key).collect()
46}
47
48pub fn compute_trend(a: &Report, b: &Report) -> TrendResult {
51 let a_detonated_keys = key_set(&a.detonated);
52 let b_detonated_keys = key_set(&b.detonated);
53 let b_ticking_keys = key_set(&b.ticking);
54 let b_inert_keys = key_set(&b.inert);
55
56 let b_all_keys: HashSet<&str> = b_detonated_keys
59 .iter()
60 .chain(b_ticking_keys.iter())
61 .chain(b_inert_keys.iter())
62 .map(String::as_str)
63 .collect();
64
65 let newly_detonated: Vec<ReportAnnotation> = b
66 .detonated
67 .iter()
68 .filter(|ann| !a_detonated_keys.contains(&annotation_key(ann)))
69 .cloned()
70 .collect();
71
72 let resolved: Vec<ReportAnnotation> = a
73 .detonated
74 .iter()
75 .filter(|ann| !b_all_keys.contains(annotation_key(ann).as_str()))
76 .cloned()
77 .collect();
78
79 let snoozed: Vec<ReportAnnotation> = a
80 .detonated
81 .iter()
82 .filter(|ann| b_ticking_keys.contains(&annotation_key(ann)))
83 .cloned()
84 .collect();
85
86 let detonated_delta = b.detonated.len() as i64 - a.detonated.len() as i64;
87 let ticking_delta = b.ticking.len() as i64 - a.ticking.len() as i64;
88 let a_total = (a.detonated.len() + a.ticking.len() + a.inert.len()) as i64;
89 let b_total = (b.detonated.len() + b.ticking.len() + b.inert.len()) as i64;
90 let total_delta = b_total - a_total;
91
92 TrendResult {
93 from_timestamp: a.generated_at.clone(),
94 to_timestamp: b.generated_at.clone(),
95 detonated_delta,
96 ticking_delta,
97 total_delta,
98 newly_detonated,
99 resolved,
100 snoozed,
101 }
102}
103
104fn color_enabled() -> bool {
107 std::env::var("NO_COLOR").is_err()
108}
109
110fn fmt_delta(delta: i64, use_color: bool) -> String {
111 let s = if delta > 0 {
112 format!("+{}", delta)
113 } else {
114 format!("{}", delta)
115 };
116 if use_color {
117 if delta > 0 {
118 s.red().to_string()
119 } else if delta < 0 {
120 s.green().to_string()
121 } else {
122 s
123 }
124 } else {
125 s
126 }
127}
128
129pub fn print_trend(trend: &TrendResult, format: &OutputFormat) {
130 match format {
131 OutputFormat::Json => match serde_json::to_string_pretty(trend) {
132 Ok(json) => println!("{}", json),
133 Err(e) => eprintln!("error serializing trend: {}", e),
134 },
135 OutputFormat::GitHub => print_trend_github(trend),
136 OutputFormat::Terminal | OutputFormat::Csv | OutputFormat::Table => {
137 print_trend_terminal(trend)
138 }
139 }
140}
141
142fn print_trend_terminal(trend: &TrendResult) {
143 let use_color = color_enabled();
144
145 println!("Trend: {} → {}", trend.from_timestamp, trend.to_timestamp);
146 println!();
147
148 println!(
149 " Detonated: {}",
150 fmt_delta(trend.detonated_delta, use_color)
151 );
152 println!(
153 " Ticking: {}",
154 fmt_delta(trend.ticking_delta, use_color)
155 );
156 println!(
157 " Total: {}",
158 fmt_delta(trend.total_delta, use_color)
159 );
160 println!();
161
162 let n = trend.newly_detonated.len();
164 let header = format!(" Newly detonated ({}):", n);
165 if use_color && n > 0 {
166 println!("{}", header.red().bold());
167 } else {
168 println!("{}", header);
169 }
170 if trend.newly_detonated.is_empty() {
171 println!(" (none)");
172 } else {
173 for ann in &trend.newly_detonated {
174 let line = format!(
175 " {}:{} {}[{}] {}",
176 ann.file, ann.line, ann.tag, ann.date, ann.message
177 );
178 if use_color {
179 println!("{}", line.red());
180 } else {
181 println!("{}", line);
182 }
183 }
184 }
185 println!();
186
187 let n = trend.resolved.len();
189 let header = format!(" Resolved ({}):", n);
190 if use_color && n > 0 {
191 println!("{}", header.green().bold());
192 } else {
193 println!("{}", header);
194 }
195 if trend.resolved.is_empty() {
196 println!(" (none)");
197 } else {
198 for ann in &trend.resolved {
199 let line = format!(
200 " {}:{} {}[{}] (removed)",
201 ann.file, ann.line, ann.tag, ann.date
202 );
203 if use_color {
204 println!("{}", line.green());
205 } else {
206 println!("{}", line);
207 }
208 }
209 }
210 println!();
211
212 let n = trend.snoozed.len();
214 println!(" Snoozed ({}):", n);
215 if trend.snoozed.is_empty() {
216 println!(" (none)");
217 } else {
218 for ann in &trend.snoozed {
219 println!(
220 " {}:{} {}[{}] {}",
221 ann.file, ann.line, ann.tag, ann.date, ann.message
222 );
223 }
224 }
225}
226
227fn print_trend_github(trend: &TrendResult) {
228 for ann in &trend.newly_detonated {
229 println!(
230 "::error file={},line={}::{} detonated on {}: {}",
231 ann.file, ann.line, ann.tag, ann.date, ann.message
232 );
233 }
234 for ann in &trend.resolved {
235 println!(
236 "::notice file={},line={}::{} fuse resolved (removed from codebase)",
237 ann.file, ann.line, ann.tag
238 );
239 }
240}
241
242pub fn run_trend(report_a_path: &Path, report_b_path: &Path, format: &OutputFormat) -> Result<i32> {
245 let a = load_report(report_a_path)?;
246 let b = load_report(report_b_path)?;
247 let trend = compute_trend(&a, &b);
248 print_trend(&trend, format);
249 Ok(0)
250}
251
252#[cfg(test)]
255mod tests {
256 use super::*;
257 use crate::report::Report;
258
259 fn make_report_ann(file: &str, line: usize, tag: &str, date: &str) -> ReportAnnotation {
260 ReportAnnotation {
261 file: file.to_string(),
262 line,
263 tag: tag.to_string(),
264 date: date.to_string(),
265 owner: None,
266 message: format!("message at {}:{}", file, line),
267 status: "detonated".to_string(),
268 }
269 }
270
271 fn make_report(
272 generated_at: &str,
273 detonated: Vec<ReportAnnotation>,
274 ticking: Vec<ReportAnnotation>,
275 inert: Vec<ReportAnnotation>,
276 ) -> Report {
277 let total = detonated.len() + ticking.len() + inert.len();
278 Report {
279 generated_at: generated_at.to_string(),
280 swept_files: 1,
281 total_fuses: total,
282 detonated,
283 ticking,
284 inert,
285 }
286 }
287
288 #[test]
291 fn test_compute_trend_newly_detonated() {
292 let a = make_report("2025-01-01T00:00:00Z", vec![], vec![], vec![]);
293 let ann = make_report_ann("src/foo.rs", 10, "TODO", "2025-01-15");
295 let b = make_report("2025-02-01T00:00:00Z", vec![ann.clone()], vec![], vec![]);
296
297 let trend = compute_trend(&a, &b);
298 assert_eq!(trend.newly_detonated.len(), 1);
299 assert_eq!(trend.newly_detonated[0].file, "src/foo.rs");
300 assert_eq!(trend.detonated_delta, 1);
301 }
302
303 #[test]
304 fn test_compute_trend_resolved() {
305 let ann = make_report_ann("src/old.rs", 5, "FIXME", "2020-12-01");
307 let a = make_report("2025-01-01T00:00:00Z", vec![ann.clone()], vec![], vec![]);
308 let b = make_report("2025-02-01T00:00:00Z", vec![], vec![], vec![]);
309
310 let trend = compute_trend(&a, &b);
311 assert_eq!(trend.resolved.len(), 1);
312 assert_eq!(trend.resolved[0].file, "src/old.rs");
313 assert_eq!(trend.detonated_delta, -1);
314 }
315
316 #[test]
317 fn test_compute_trend_snoozed() {
318 let ann = make_report_ann("src/worker.rs", 88, "TODO", "2025-03-01");
320 let a = make_report("2025-01-01T00:00:00Z", vec![ann.clone()], vec![], vec![]);
321
322 let mut snoozed_ann = ann.clone();
324 snoozed_ann.date = "2026-06-01".to_string();
325 snoozed_ann.status = "ticking".to_string();
326 let b = make_report("2025-02-01T00:00:00Z", vec![], vec![snoozed_ann], vec![]);
327
328 let trend = compute_trend(&a, &b);
329 assert_eq!(trend.snoozed.len(), 1);
330 assert_eq!(trend.snoozed[0].file, "src/worker.rs");
331 }
332
333 #[test]
334 fn test_compute_trend_delta_math() {
335 let ann1 = make_report_ann("src/a.rs", 1, "TODO", "2020-01-01");
336 let ann2 = make_report_ann("src/b.rs", 2, "FIXME", "2020-06-01");
337 let ann3 = make_report_ann("src/c.rs", 3, "HACK", "2021-01-01");
338
339 let mut ticking_ann = ann1.clone();
340 ticking_ann.status = "ticking".to_string();
341
342 let a = make_report(
343 "2025-01-01T00:00:00Z",
344 vec![ann1.clone(), ann2.clone()],
345 vec![ticking_ann.clone()],
346 vec![],
347 );
348
349 let b = make_report("2025-02-01T00:00:00Z", vec![ann3.clone()], vec![], vec![]);
351
352 let trend = compute_trend(&a, &b);
353 assert_eq!(trend.detonated_delta, -1);
355 assert_eq!(trend.ticking_delta, -1);
357 assert_eq!(trend.total_delta, -2);
359 }
360
361 #[test]
362 fn test_compute_trend_timestamps() {
363 let a = make_report("2025-01-01T00:00:00Z", vec![], vec![], vec![]);
364 let b = make_report("2025-03-15T12:00:00Z", vec![], vec![], vec![]);
365 let trend = compute_trend(&a, &b);
366 assert_eq!(trend.from_timestamp, "2025-01-01T00:00:00Z");
367 assert_eq!(trend.to_timestamp, "2025-03-15T12:00:00Z");
368 }
369
370 #[test]
371 fn test_compute_trend_no_change() {
372 let ann = make_report_ann("src/x.rs", 7, "TODO", "2020-01-01");
373 let a = make_report("2025-01-01T00:00:00Z", vec![ann.clone()], vec![], vec![]);
374 let b = make_report("2025-02-01T00:00:00Z", vec![ann.clone()], vec![], vec![]);
375 let trend = compute_trend(&a, &b);
376 assert_eq!(trend.detonated_delta, 0);
377 assert!(trend.newly_detonated.is_empty());
378 assert!(trend.resolved.is_empty());
379 assert!(trend.snoozed.is_empty());
380 }
381
382 fn make_nontrivial_trend() -> TrendResult {
385 TrendResult {
386 from_timestamp: "2025-01-01T00:00:00Z".to_string(),
387 to_timestamp: "2025-02-01T00:00:00Z".to_string(),
388 detonated_delta: 2,
389 ticking_delta: -1,
390 total_delta: 1,
391 newly_detonated: vec![make_report_ann("src/foo.rs", 42, "TODO", "2026-01-15")],
392 resolved: vec![make_report_ann("src/old.rs", 5, "TODO", "2025-12-01")],
393 snoozed: vec![],
394 }
395 }
396
397 #[test]
398 fn test_print_trend_terminal_does_not_panic() {
399 let trend = make_nontrivial_trend();
400 print_trend(&trend, &OutputFormat::Terminal);
401 }
402
403 #[test]
404 fn test_print_trend_json_does_not_panic() {
405 let trend = make_nontrivial_trend();
406 print_trend(&trend, &OutputFormat::Json);
407 }
408
409 #[test]
410 fn test_print_trend_github_does_not_panic() {
411 let trend = make_nontrivial_trend();
412 print_trend(&trend, &OutputFormat::GitHub);
413 }
414
415 #[test]
418 fn test_run_trend_reads_json_files() {
419 use crate::annotation::{Fuse, Status};
420 use crate::report::{build_report, write_report};
421 use crate::scanner::ScanResult;
422 use chrono::NaiveDate;
423 use std::path::PathBuf;
424
425 let tmp = tempfile::tempdir().unwrap();
426
427 let make_fuse = |file: &str, line: usize, date_str: &str, status: Status| Fuse {
428 file: PathBuf::from(file),
429 line,
430 tag: "TODO".to_string(),
431 date: NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap(),
432 owner: None,
433 message: "test".to_string(),
434 status,
435 blamed_owner: None,
436 };
437
438 let result_a = ScanResult {
439 fuses: vec![make_fuse("src/a.rs", 1, "2020-01-01", Status::Detonated)],
440 swept_files: 1,
441 skipped_files: 0,
442 };
443 let report_a = build_report(&result_a, "2025-01-01T00:00:00Z");
444 let path_a = tmp.path().join("report_a.json");
445 write_report(&report_a, &path_a).unwrap();
446
447 let result_b = ScanResult {
448 fuses: vec![make_fuse("src/b.rs", 2, "2021-06-01", Status::Detonated)],
449 swept_files: 1,
450 skipped_files: 0,
451 };
452 let report_b = build_report(&result_b, "2025-02-01T00:00:00Z");
453 let path_b = tmp.path().join("report_b.json");
454 write_report(&report_b, &path_b).unwrap();
455
456 let code = run_trend(&path_a, &path_b, &OutputFormat::Json).unwrap();
457 assert_eq!(code, 0);
458 }
459
460 #[test]
461 fn test_run_trend_error_on_missing_file() {
462 let tmp = tempfile::tempdir().unwrap();
463 let missing = tmp.path().join("does_not_exist.json");
464 let result = run_trend(&missing, &missing, &OutputFormat::Terminal);
466 assert!(result.is_err());
467 }
468
469 #[test]
472 fn test_annotation_key_format() {
473 let ann = make_report_ann("src/lib.rs", 42, "TODO", "2025-01-01");
474 assert_eq!(annotation_key(&ann), "src/lib.rs:42");
475 }
476
477 #[test]
480 fn test_compute_trend_unchanged_detonated_is_neither_new_nor_resolved() {
481 let ann = make_report_ann("src/x.rs", 10, "TODO", "2020-01-01");
483 let a = make_report("2025-01-01T00:00:00Z", vec![ann.clone()], vec![], vec![]);
484 let b = make_report("2025-02-01T00:00:00Z", vec![ann.clone()], vec![], vec![]);
485
486 let trend = compute_trend(&a, &b);
487 assert!(trend.newly_detonated.is_empty(), "not newly detonated");
488 assert!(trend.resolved.is_empty(), "not resolved");
489 assert!(trend.snoozed.is_empty(), "not snoozed");
490 assert_eq!(trend.detonated_delta, 0);
491 }
492
493 #[test]
496 fn test_compute_trend_multiple_snoozed() {
497 let ann1 = make_report_ann("src/a.rs", 1, "TODO", "2025-01-01");
498 let ann2 = make_report_ann("src/b.rs", 2, "FIXME", "2025-02-01");
499 let a = make_report(
500 "2025-01-01T00:00:00Z",
501 vec![ann1.clone(), ann2.clone()],
502 vec![],
503 vec![],
504 );
505
506 let mut snoozed1 = ann1.clone();
507 snoozed1.date = "2026-12-01".to_string();
508 snoozed1.status = "ticking".to_string();
509 let mut snoozed2 = ann2.clone();
510 snoozed2.date = "2027-06-01".to_string();
511 snoozed2.status = "ticking".to_string();
512
513 let b = make_report(
514 "2025-02-01T00:00:00Z",
515 vec![],
516 vec![snoozed1, snoozed2],
517 vec![],
518 );
519
520 let trend = compute_trend(&a, &b);
521 assert_eq!(trend.snoozed.len(), 2);
522 assert_eq!(trend.detonated_delta, -2);
523 }
524
525 #[test]
528 fn test_compute_trend_moved_to_inert_is_resolved() {
529 let ann = make_report_ann("src/z.rs", 99, "HACK", "2020-05-01");
531 let a = make_report("2025-01-01T00:00:00Z", vec![ann.clone()], vec![], vec![]);
532
533 let mut inert_ann = ann.clone();
534 inert_ann.date = "2099-01-01".to_string();
535 inert_ann.status = "inert".to_string();
536 let b = make_report("2025-02-01T00:00:00Z", vec![], vec![], vec![inert_ann]);
537
538 let trend = compute_trend(&a, &b);
539 assert!(trend.resolved.is_empty());
541 assert!(trend.snoozed.is_empty());
542 }
543
544 #[test]
547 fn test_compute_trend_both_empty() {
548 let a = make_report("2025-01-01T00:00:00Z", vec![], vec![], vec![]);
549 let b = make_report("2025-02-01T00:00:00Z", vec![], vec![], vec![]);
550 let trend = compute_trend(&a, &b);
551 assert_eq!(trend.detonated_delta, 0);
552 assert_eq!(trend.ticking_delta, 0);
553 assert_eq!(trend.total_delta, 0);
554 assert!(trend.newly_detonated.is_empty());
555 assert!(trend.resolved.is_empty());
556 assert!(trend.snoozed.is_empty());
557 }
558}