1use crate::annotation::{Fuse, Status};
2use crate::output::OutputFormat;
3use colored::Colorize;
4use serde::Serialize;
5use std::collections::HashMap;
6
7#[derive(Debug, Clone, Serialize)]
9pub struct OwnerRow {
10 pub owner: String,
11 pub total: usize,
12 pub detonated: usize,
13 pub ticking: usize,
14 pub inert: usize,
15}
16
17#[derive(Debug, Clone, Serialize)]
19pub struct TagRow {
20 pub tag: String,
21 pub total: usize,
22 pub detonated: usize,
23 pub ticking: usize,
24 pub inert: usize,
25}
26
27#[derive(Debug, Clone, Serialize)]
29pub struct MonthRow {
30 pub month: String,
32 pub total: usize,
33 pub detonated: usize,
34 pub ticking: usize,
35 pub inert: usize,
36}
37
38#[derive(Debug, Serialize)]
40pub struct StatsResult {
41 pub total_fuses: usize,
42 pub total_detonated: usize,
43 pub total_ticking: usize,
44 pub total_inert: usize,
45 pub by_owner: Vec<OwnerRow>,
46 pub by_tag: Vec<TagRow>,
47 pub by_month: Vec<MonthRow>,
48}
49
50pub fn compute_stats(fuses: &[Fuse]) -> StatsResult {
54 let mut owner_map: HashMap<String, OwnerRow> = HashMap::new();
55 let mut tag_map: HashMap<String, TagRow> = HashMap::new();
56 let mut month_map: HashMap<String, MonthRow> = HashMap::new();
57
58 let mut total_fuses = 0usize;
59 let mut total_detonated = 0usize;
60 let mut total_ticking = 0usize;
61 let mut total_inert = 0usize;
62
63 for fuse in fuses {
64 let owner_key = fuse.owner.as_deref().unwrap_or("(unowned)");
66
67 total_fuses += 1;
68
69 let (is_detonated, is_ticking, is_inert) = match fuse.status {
70 Status::Detonated => {
71 total_detonated += 1;
72 (1usize, 0usize, 0usize)
73 }
74 Status::Ticking => {
75 total_ticking += 1;
76 (0, 1, 0)
77 }
78 Status::Inert => {
79 total_inert += 1;
80 (0, 0, 1)
81 }
82 };
83
84 let orow = owner_map
86 .entry(owner_key.to_string())
87 .or_insert_with(|| OwnerRow {
88 owner: owner_key.to_string(),
89 total: 0,
90 detonated: 0,
91 ticking: 0,
92 inert: 0,
93 });
94 orow.total += 1;
95 orow.detonated += is_detonated;
96 orow.ticking += is_ticking;
97 orow.inert += is_inert;
98
99 let trow = tag_map.entry(fuse.tag.clone()).or_insert_with(|| TagRow {
101 tag: fuse.tag.clone(),
102 total: 0,
103 detonated: 0,
104 ticking: 0,
105 inert: 0,
106 });
107 trow.total += 1;
108 trow.detonated += is_detonated;
109 trow.ticking += is_ticking;
110 trow.inert += is_inert;
111
112 let mrow = month_map
116 .entry(fuse.date.format("%Y-%m").to_string())
117 .or_insert_with(|| MonthRow {
118 month: fuse.date.format("%Y-%m").to_string(),
119 total: 0,
120 detonated: 0,
121 ticking: 0,
122 inert: 0,
123 });
124 mrow.total += 1;
125 mrow.detonated += is_detonated;
126 mrow.ticking += is_ticking;
127 mrow.inert += is_inert;
128 }
129
130 let mut by_owner: Vec<OwnerRow> = owner_map.into_values().collect();
131 by_owner.sort_by(|a, b| {
132 b.detonated
133 .cmp(&a.detonated)
134 .then(b.total.cmp(&a.total))
135 .then(a.owner.cmp(&b.owner))
136 });
137
138 let mut by_tag: Vec<TagRow> = tag_map.into_values().collect();
139 by_tag.sort_by(|a, b| {
140 b.detonated
141 .cmp(&a.detonated)
142 .then(b.total.cmp(&a.total))
143 .then(a.tag.cmp(&b.tag))
144 });
145
146 let mut by_month: Vec<MonthRow> = month_map.into_values().collect();
148 by_month.sort_by(|a, b| a.month.cmp(&b.month));
149
150 StatsResult {
151 total_fuses,
152 total_detonated,
153 total_ticking,
154 total_inert,
155 by_owner,
156 by_tag,
157 by_month,
158 }
159}
160
161fn truncate_name(name: &str) -> String {
165 if name.chars().count() > 18 {
166 let end = name
168 .char_indices()
169 .nth(18)
170 .map(|(i, _)| i)
171 .unwrap_or(name.len());
172 format!("{}..", &name[..end])
173 } else {
174 name.to_string()
175 }
176}
177
178fn color_enabled() -> bool {
180 std::env::var("NO_COLOR").is_err()
181}
182
183fn fmt_detonated(count: usize, use_color: bool) -> String {
185 let s = format!("{:>8}", count);
186 if use_color && count > 0 {
187 s.red().to_string()
188 } else {
189 s
190 }
191}
192
193pub fn print_stats_terminal(result: &StatsResult) {
195 let use_color = color_enabled();
196
197 println!("BY OWNER");
199 println!("--------");
200 println!(
201 "{:<20}{:>8}{:>10}{:>8}{:>8}",
202 "OWNER", "TOTAL", "DETONATED", "TICKING", "INERT"
203 );
204 for row in &result.by_owner {
205 let name = truncate_name(&row.owner);
206 println!(
207 "{:<20}{:>8}{}{:>8}{:>8}",
208 name,
209 row.total,
210 fmt_detonated(row.detonated, use_color),
211 row.ticking,
212 row.inert,
213 );
214 }
215
216 println!();
217
218 println!("BY TAG");
220 println!("------");
221 println!(
222 "{:<20}{:>8}{:>10}{:>8}{:>8}",
223 "TAG", "TOTAL", "DETONATED", "TICKING", "INERT"
224 );
225 for row in &result.by_tag {
226 let name = truncate_name(&row.tag);
227 println!(
228 "{:<20}{:>8}{}{:>8}{:>8}",
229 name,
230 row.total,
231 fmt_detonated(row.detonated, use_color),
232 row.ticking,
233 row.inert,
234 );
235 }
236
237 println!();
238 println!(
239 "{} fuse(s) total · {} detonated · {} ticking · {} inert",
240 result.total_fuses, result.total_detonated, result.total_ticking, result.total_inert,
241 );
242}
243
244pub fn print_stats_json(result: &StatsResult) {
246 let json = serde_json::to_string_pretty(result).expect("Failed to serialize stats to JSON");
247 println!("{}", json);
248}
249
250pub fn print_stats_github(result: &StatsResult) {
252 for row in &result.by_owner {
253 if row.detonated > 0 {
254 println!(
255 "::warning ::OWNER {} has {} detonated fuse(s)",
256 row.owner, row.detonated
257 );
258 }
259 }
260 for row in &result.by_tag {
261 if row.detonated > 0 {
262 println!(
263 "::warning ::TAG {} has {} detonated fuse(s)",
264 row.tag, row.detonated
265 );
266 }
267 }
268}
269
270pub fn print_stats_month_terminal(result: &StatsResult) {
272 let use_color = color_enabled();
273 println!("BY MONTH");
274 println!("--------");
275 println!(
276 "{:<20}{:>8}{:>10}{:>8}{:>8}",
277 "MONTH", "TOTAL", "DETONATED", "TICKING", "INERT"
278 );
279 for row in &result.by_month {
280 println!(
281 "{:<20}{:>8}{}{:>8}{:>8}",
282 row.month,
283 row.total,
284 fmt_detonated(row.detonated, use_color),
285 row.ticking,
286 row.inert,
287 );
288 }
289 println!();
290 println!(
291 "{} fuse(s) total · {} detonated · {} ticking · {} inert",
292 result.total_fuses, result.total_detonated, result.total_ticking, result.total_inert,
293 );
294}
295
296pub fn print_stats(result: &StatsResult, format: &OutputFormat) {
298 match format {
299 OutputFormat::Terminal | OutputFormat::Csv | OutputFormat::Table => {
300 print_stats_terminal(result)
301 }
302 OutputFormat::Json => print_stats_json(result),
303 OutputFormat::GitHub => print_stats_github(result),
304 }
305}
306
307pub fn print_stats_month(result: &StatsResult, format: &OutputFormat) {
309 match format {
310 OutputFormat::Terminal | OutputFormat::Csv | OutputFormat::Table => {
311 print_stats_month_terminal(result)
312 }
313 OutputFormat::Json => print_stats_json(result),
314 OutputFormat::GitHub => {
315 for row in &result.by_month {
316 if row.detonated > 0 {
317 println!(
318 "::warning ::MONTH {} has {} detonated fuse(s)",
319 row.month, row.detonated
320 );
321 }
322 }
323 }
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330 use chrono::NaiveDate;
331 use std::path::PathBuf;
332
333 fn make_fuse(tag: &str, owner: Option<&str>, status: Status) -> Fuse {
334 let date = match status {
335 Status::Detonated => NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
336 Status::Ticking => NaiveDate::from_ymd_opt(2025, 6, 10).unwrap(),
337 Status::Inert => NaiveDate::from_ymd_opt(2099, 1, 1).unwrap(),
338 };
339 Fuse {
340 file: PathBuf::from("src/foo.rs"),
341 line: 1,
342 tag: tag.to_string(),
343 date,
344 owner: owner.map(|s| s.to_string()),
345 message: "test message".to_string(),
346 status,
347 blamed_owner: None,
348 }
349 }
350
351 #[test]
352 fn test_compute_stats_empty() {
353 let result = compute_stats(&[]);
354 assert_eq!(result.total_fuses, 0);
355 assert_eq!(result.total_detonated, 0);
356 assert_eq!(result.total_ticking, 0);
357 assert_eq!(result.total_inert, 0);
358 assert!(result.by_owner.is_empty());
359 assert!(result.by_tag.is_empty());
360 assert!(result.by_month.is_empty());
361 }
362
363 #[test]
364 fn test_compute_stats_single_detonated() {
365 let fuses = vec![make_fuse("TODO", Some("alice"), Status::Detonated)];
366 let result = compute_stats(&fuses);
367 assert_eq!(result.total_fuses, 1);
368 assert_eq!(result.total_detonated, 1);
369 assert_eq!(result.total_ticking, 0);
370 assert_eq!(result.total_inert, 0);
371
372 assert_eq!(result.by_owner.len(), 1);
373 assert_eq!(result.by_owner[0].detonated, 1);
374
375 assert_eq!(result.by_tag.len(), 1);
376 assert_eq!(result.by_tag[0].detonated, 1);
377 }
378
379 #[test]
380 fn test_compute_stats_unowned() {
381 let fuses = vec![make_fuse("TODO", None, Status::Inert)];
382 let result = compute_stats(&fuses);
383 assert_eq!(result.by_owner.len(), 1);
384 assert_eq!(result.by_owner[0].owner, "(unowned)");
385 }
386
387 #[test]
388 fn test_compute_stats_owner_grouping() {
389 let fuses = vec![
390 make_fuse("TODO", Some("alice"), Status::Inert),
391 make_fuse("FIXME", Some("alice"), Status::Detonated),
392 ];
393 let result = compute_stats(&fuses);
394 assert_eq!(result.by_owner.len(), 1);
395 assert_eq!(result.by_owner[0].owner, "alice");
396 assert_eq!(result.by_owner[0].total, 2);
397 }
398
399 #[test]
400 fn test_compute_stats_tag_grouping() {
401 let fuses = vec![
402 make_fuse("TODO", Some("alice"), Status::Inert),
403 make_fuse("TODO", Some("bob"), Status::Detonated),
404 ];
405 let result = compute_stats(&fuses);
406 assert_eq!(result.by_tag.len(), 1);
407 assert_eq!(result.by_tag[0].tag, "TODO");
408 assert_eq!(result.by_tag[0].total, 2);
409 }
410
411 #[test]
412 fn test_compute_stats_sort_order() {
413 let fuses = vec![
414 make_fuse("TODO", Some("alice"), Status::Detonated),
415 make_fuse("TODO", Some("alice"), Status::Detonated),
416 make_fuse("TODO", Some("alice"), Status::Detonated),
417 make_fuse("TODO", Some("bob"), Status::Detonated),
418 ];
419 let result = compute_stats(&fuses);
420 assert_eq!(result.by_owner.len(), 2);
421 assert_eq!(result.by_owner[0].owner, "alice");
423 assert_eq!(result.by_owner[0].detonated, 3);
424 assert_eq!(result.by_owner[1].owner, "bob");
425 assert_eq!(result.by_owner[1].detonated, 1);
426 }
427
428 #[test]
429 fn test_owner_row_name_truncation() {
430 let long_name = "a".repeat(19); let truncated = truncate_name(&long_name);
433 assert_eq!(truncated.len(), 20); assert!(truncated.ends_with(".."));
435
436 let exact_name = "b".repeat(18);
438 let not_truncated = truncate_name(&exact_name);
439 assert_eq!(not_truncated, exact_name);
440
441 let short_name = "hello";
443 let not_truncated_short = truncate_name(short_name);
444 assert_eq!(not_truncated_short, short_name);
445 }
446
447 #[test]
448 fn test_print_stats_json_does_not_panic() {
449 let fuses = vec![
450 make_fuse("TODO", Some("alice"), Status::Detonated),
451 make_fuse("FIXME", None, Status::Inert),
452 ];
453 let result = compute_stats(&fuses);
454 print_stats_json(&result);
455 }
456
457 #[test]
458 fn test_print_stats_terminal_does_not_panic() {
459 let fuses = vec![
460 make_fuse("TODO", Some("alice"), Status::Detonated),
461 make_fuse("FIXME", None, Status::Ticking),
462 make_fuse("HACK", Some("bob"), Status::Inert),
463 ];
464 let result = compute_stats(&fuses);
465 print_stats_terminal(&result);
466 }
467
468 #[test]
469 fn test_print_stats_github_does_not_panic() {
470 let fuses = vec![
471 make_fuse("TODO", Some("alice"), Status::Detonated),
472 make_fuse("FIXME", None, Status::Inert),
473 ];
474 let result = compute_stats(&fuses);
475 print_stats_github(&result);
476 }
477
478 fn make_fuse_on_date(tag: &str, owner: Option<&str>, status: Status, date: NaiveDate) -> Fuse {
479 Fuse {
480 file: PathBuf::from("src/foo.rs"),
481 line: 1,
482 tag: tag.to_string(),
483 date,
484 owner: owner.map(|s| s.to_string()),
485 message: "test message".to_string(),
486 status,
487 blamed_owner: None,
488 }
489 }
490
491 #[test]
492 fn test_by_month_grouping() {
493 let fuses = vec![
494 make_fuse_on_date(
495 "TODO",
496 None,
497 Status::Detonated,
498 NaiveDate::from_ymd_opt(2020, 3, 15).unwrap(),
499 ),
500 make_fuse_on_date(
501 "FIXME",
502 None,
503 Status::Detonated,
504 NaiveDate::from_ymd_opt(2020, 3, 28).unwrap(),
505 ),
506 make_fuse_on_date(
507 "HACK",
508 None,
509 Status::Inert,
510 NaiveDate::from_ymd_opt(2099, 1, 1).unwrap(),
511 ),
512 ];
513 let result = compute_stats(&fuses);
514 assert_eq!(result.by_month.len(), 2);
515 assert_eq!(result.by_month[0].month, "2020-03");
517 assert_eq!(result.by_month[0].total, 2);
518 assert_eq!(result.by_month[0].detonated, 2);
519 assert_eq!(result.by_month[1].month, "2099-01");
520 assert_eq!(result.by_month[1].total, 1);
521 assert_eq!(result.by_month[1].inert, 1);
522 }
523
524 #[test]
525 fn test_by_month_sorted_chronologically() {
526 let fuses = vec![
527 make_fuse_on_date(
528 "TODO",
529 None,
530 Status::Inert,
531 NaiveDate::from_ymd_opt(2099, 6, 1).unwrap(),
532 ),
533 make_fuse_on_date(
534 "TODO",
535 None,
536 Status::Detonated,
537 NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
538 ),
539 make_fuse_on_date(
540 "TODO",
541 None,
542 Status::Detonated,
543 NaiveDate::from_ymd_opt(2020, 3, 1).unwrap(),
544 ),
545 ];
546 let result = compute_stats(&fuses);
547 let months: Vec<&str> = result.by_month.iter().map(|r| r.month.as_str()).collect();
548 assert_eq!(months, vec!["2020-01", "2020-03", "2099-06"]);
549 }
550
551 #[test]
552 fn test_by_month_empty() {
553 let result = compute_stats(&[]);
554 assert!(result.by_month.is_empty());
555 }
556
557 #[test]
558 fn test_print_stats_month_terminal_does_not_panic() {
559 let fuses = vec![
560 make_fuse("TODO", Some("alice"), Status::Detonated),
561 make_fuse("FIXME", None, Status::Ticking),
562 ];
563 let result = compute_stats(&fuses);
564 print_stats_month_terminal(&result);
565 }
566}