1use std::path::{Path, PathBuf};
4
5use fallow_types::results::{ActiveSuppression, AnalysisResults};
6use rustc_hash::{FxHashMap, FxHashSet};
7use serde::{Deserialize, Serialize};
8
9use crate::audit::{AuditSummary, AuditVerdict};
10use crate::report::ci::fingerprint::fingerprint_hash;
11use crate::report::format_display_path;
12
13const STORE_SCHEMA_VERSION: u32 = 2;
14
15const MAX_RECORDS: usize = 200;
16
17const MAX_CONTAINMENT: usize = 200;
18
19const TREND_TOLERANCE: i64 = 0;
20
21const STORE_FILE: &str = "impact.json";
22
23const MAX_RECENT_RESOLVED: usize = 50;
24
25const ID_SEP: &str = "\u{1f}";
26
27const CODE_DUPLICATION_KIND: &str = "code-duplication";
28
29const BLANKET_SUPPRESSION: &str = "*";
30
31#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
33#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
34pub struct ImpactCounts {
35 pub total_issues: usize,
36 pub dead_code: usize,
37 pub complexity: usize,
38 pub duplication: usize,
39}
40
41impl ImpactCounts {
42 fn from_summary(summary: &AuditSummary) -> Self {
43 Self {
44 total_issues: summary.dead_code_issues
45 + summary.complexity_findings
46 + summary.duplication_clone_groups,
47 dead_code: summary.dead_code_issues,
48 complexity: summary.complexity_findings,
49 duplication: summary.duplication_clone_groups,
50 }
51 }
52
53 pub(crate) fn from_combined(dead_code: usize, complexity: usize, duplication: usize) -> Self {
54 Self {
55 total_issues: dead_code + complexity + duplication,
56 dead_code,
57 complexity,
58 duplication,
59 }
60 }
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct ImpactRecord {
65 pub timestamp: String,
66 pub version: String,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub git_sha: Option<String>,
69 pub verdict: String,
70 #[serde(default)]
71 pub gate: bool,
72 pub counts: ImpactCounts,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct PendingContainment {
77 pub blocked_at: String,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub git_sha: Option<String>,
80 pub blocked_counts: ImpactCounts,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
85pub struct ContainmentEvent {
86 pub blocked_at: String,
87 pub cleared_at: String,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub git_sha: Option<String>,
90 pub blocked_counts: ImpactCounts,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct FrontierFinding {
95 pub id: String,
96 pub kind: String,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub symbol: Option<String>,
99}
100
101impl FrontierFinding {
102 fn move_key(&self) -> String {
103 match &self.symbol {
104 Some(symbol) => format!("{}{ID_SEP}{symbol}", self.kind),
105 None => self.id.clone(),
106 }
107 }
108}
109
110#[derive(Debug, Clone, Default, Serialize, Deserialize)]
111pub struct FileFrontier {
112 #[serde(default)]
113 pub findings: Vec<FrontierFinding>,
114 #[serde(default)]
115 pub suppressions: Vec<String>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
120pub struct ResolutionEvent {
121 pub kind: String,
122 pub path: String,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub symbol: Option<String>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub git_sha: Option<String>,
127 pub timestamp: String,
128}
129
130#[derive(Debug, Clone, Default, Serialize, Deserialize)]
131pub struct ImpactStore {
132 #[serde(default)]
133 pub schema_version: u32,
134 #[serde(default)]
135 pub enabled: bool,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub first_recorded: Option<String>,
138 #[serde(default)]
139 pub records: Vec<ImpactRecord>,
140 #[serde(default)]
141 pub project_records: Vec<ImpactRecord>,
142 #[serde(default)]
143 pub containment: Vec<ContainmentEvent>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub pending_containment: Option<PendingContainment>,
146 #[serde(default)]
147 pub frontier: FxHashMap<String, FileFrontier>,
148 #[serde(default)]
149 pub clone_frontier: FxHashMap<String, Vec<String>>,
150 #[serde(default)]
151 pub resolved_total: usize,
152 #[serde(default)]
153 pub suppressed_total: usize,
154 #[serde(default)]
155 pub recent_resolved: Vec<ResolutionEvent>,
156}
157
158fn store_path(root: &Path) -> PathBuf {
159 root.join(".fallow").join(STORE_FILE)
160}
161
162pub fn load(root: &Path) -> ImpactStore {
165 let path = store_path(root);
166 let Ok(content) = std::fs::read_to_string(&path) else {
167 return ImpactStore::default();
168 };
169 match serde_json::from_str::<ImpactStore>(&content) {
170 Ok(store) => {
171 if store.schema_version > STORE_SCHEMA_VERSION {
172 tracing::warn!(
173 "fallow impact: store at {} has schema_version {} but this build understands up to {}; reading it as best-effort, fields this build does not know are dropped on the next write. Upgrade fallow to read it fully.",
174 path.display(),
175 store.schema_version,
176 STORE_SCHEMA_VERSION,
177 );
178 }
179 store
180 }
181 Err(err) => {
182 tracing::warn!(
183 "fallow impact: ignoring unreadable store at {} ({err}); run `fallow impact enable` to reset it",
184 path.display()
185 );
186 ImpactStore::default()
187 }
188 }
189}
190
191fn save(store: &ImpactStore, root: &Path) {
193 let path = store_path(root);
194 if let Some(parent) = path.parent()
195 && std::fs::create_dir_all(parent).is_err()
196 {
197 return;
198 }
199 if let Ok(json) = serde_json::to_string_pretty(store) {
200 let _ = fallow_config::atomic_write(&path, json.as_bytes());
201 }
202}
203
204pub fn enable(root: &Path) -> bool {
206 let mut store = load(root);
207 let was_enabled = store.enabled;
208 store.enabled = true;
209 if store.schema_version == 0 {
210 store.schema_version = STORE_SCHEMA_VERSION;
211 }
212 save(&store, root);
213 ensure_fallow_gitignored(root);
214 !was_enabled
215}
216
217fn ensure_fallow_gitignored(root: &Path) {
219 let path = root.join(".gitignore");
220 let existing = std::fs::read_to_string(&path).unwrap_or_default();
221 let already = existing
222 .lines()
223 .any(|line| matches!(line.trim(), ".fallow" | ".fallow/"));
224 if already {
225 return;
226 }
227 let mut contents = existing;
228 if !contents.is_empty() && !contents.ends_with('\n') {
229 contents.push('\n');
230 }
231 contents.push_str(".fallow/\n");
232 let _ = fallow_config::atomic_write(&path, contents.as_bytes());
233}
234
235pub fn disable(root: &Path) -> bool {
238 let mut store = load(root);
239 let was_enabled = store.enabled;
240 store.enabled = false;
241 save(&store, root);
242 was_enabled
243}
244
245#[expect(
247 clippy::too_many_arguments,
248 reason = "best-effort recorder threading the v1 record fields plus the v1.5 attribution input; a params struct would not improve the single call site"
249)]
250pub fn record_audit_run(
251 root: &Path,
252 summary: &AuditSummary,
253 verdict: AuditVerdict,
254 gate: bool,
255 git_sha: Option<&str>,
256 version: &str,
257 timestamp: &str,
258 attribution: Option<&AttributionInput<'_>>,
259) {
260 let mut store = load(root);
261 if !store.enabled {
262 return;
263 }
264 store.schema_version = STORE_SCHEMA_VERSION;
265
266 let counts = ImpactCounts::from_summary(summary);
267 let verdict_str = verdict_label(verdict);
268
269 if store.first_recorded.is_none() {
270 store.first_recorded = Some(timestamp.to_owned());
271 }
272
273 apply_containment(&mut store, verdict, gate, git_sha, timestamp, &counts);
274
275 store.records.push(ImpactRecord {
276 timestamp: timestamp.to_owned(),
277 version: version.to_owned(),
278 git_sha: git_sha.map(ToOwned::to_owned),
279 verdict: verdict_str.to_owned(),
280 gate,
281 counts,
282 });
283 compact(&mut store);
284
285 if let Some(attribution) = attribution {
286 apply_attribution(&mut store, attribution, git_sha, timestamp);
287 }
288
289 save(&store, root);
290}
291
292pub fn record_combined_run(
294 root: &Path,
295 counts: ImpactCounts,
296 git_sha: Option<&str>,
297 version: &str,
298 timestamp: &str,
299 attribution: Option<&AttributionInput<'_>>,
300) {
301 let mut store = load(root);
302 if !store.enabled {
303 return;
304 }
305 store.schema_version = STORE_SCHEMA_VERSION;
306
307 if store.first_recorded.is_none() {
308 store.first_recorded = Some(timestamp.to_owned());
309 }
310
311 let verdict_str = if counts.total_issues == 0 {
312 "pass"
313 } else {
314 "warn"
315 };
316 store.project_records.push(ImpactRecord {
317 timestamp: timestamp.to_owned(),
318 version: version.to_owned(),
319 git_sha: git_sha.map(ToOwned::to_owned),
320 verdict: verdict_str.to_owned(),
321 gate: false,
322 counts,
323 });
324 if store.project_records.len() > MAX_RECORDS {
325 let overflow = store.project_records.len() - MAX_RECORDS;
326 store.project_records.drain(0..overflow);
327 }
328
329 if let Some(attribution) = attribution {
330 apply_attribution(&mut store, attribution, git_sha, timestamp);
331 }
332
333 save(&store, root);
334}
335
336fn apply_containment(
338 store: &mut ImpactStore,
339 verdict: AuditVerdict,
340 gate: bool,
341 git_sha: Option<&str>,
342 timestamp: &str,
343 counts: &ImpactCounts,
344) {
345 if !gate {
346 return;
347 }
348 if verdict == AuditVerdict::Fail {
349 if store.pending_containment.is_none() {
350 store.pending_containment = Some(PendingContainment {
351 blocked_at: timestamp.to_owned(),
352 git_sha: git_sha.map(ToOwned::to_owned),
353 blocked_counts: counts.clone(),
354 });
355 }
356 } else if let Some(pending) = store.pending_containment.take() {
357 store.containment.push(ContainmentEvent {
358 blocked_at: pending.blocked_at,
359 cleared_at: timestamp.to_owned(),
360 git_sha: pending.git_sha,
361 blocked_counts: pending.blocked_counts,
362 });
363 if store.containment.len() > MAX_CONTAINMENT {
364 let overflow = store.containment.len() - MAX_CONTAINMENT;
365 store.containment.drain(0..overflow);
366 }
367 }
368}
369
370fn compact(store: &mut ImpactStore) {
371 if store.records.len() > MAX_RECORDS {
372 let overflow = store.records.len() - MAX_RECORDS;
373 store.records.drain(0..overflow);
374 }
375}
376
377#[derive(Debug, Clone)]
378pub struct FindingInput {
379 pub path: PathBuf,
380 pub kind: &'static str,
381 pub symbol: Option<String>,
382}
383
384#[derive(Debug, Clone)]
385pub struct CloneInput {
386 pub fingerprint: String,
387 pub instance_paths: Vec<PathBuf>,
388}
389
390pub enum Scope<'a> {
391 ChangedFiles(&'a [PathBuf]),
392 WholeProject,
393}
394
395pub struct AttributionInput<'a> {
396 pub root: &'a Path,
397 pub scope: Scope<'a>,
398 pub findings: Vec<FindingInput>,
399 pub clones: Vec<CloneInput>,
400 pub suppressions: &'a [ActiveSuppression],
401}
402
403fn finding_id(kind: &str, rel_path: &str, symbol: Option<&str>) -> String {
404 fingerprint_hash(&[kind, rel_path, symbol.unwrap_or("")])
405}
406
407fn covered_by(present: &FxHashSet<String>, kind: &str) -> bool {
408 present.contains(BLANKET_SUPPRESSION) || present.contains(kind)
409}
410
411fn apply_attribution(
412 store: &mut ImpactStore,
413 input: &AttributionInput<'_>,
414 git_sha: Option<&str>,
415 timestamp: &str,
416) {
417 let root = input.root;
418 let changed: FxHashSet<String> = match input.scope {
419 Scope::ChangedFiles(files) => files.iter().map(|p| format_display_path(p, root)).collect(),
420 Scope::WholeProject => whole_project_scope(store, input, root),
421 };
422
423 let mut current_findings: FxHashMap<String, Vec<FrontierFinding>> = FxHashMap::default();
424 for f in &input.findings {
425 let rel = format_display_path(&f.path, root);
426 if !changed.contains(&rel) {
427 continue;
428 }
429 let id = finding_id(f.kind, &rel, f.symbol.as_deref());
430 current_findings
431 .entry(rel)
432 .or_default()
433 .push(FrontierFinding {
434 id,
435 kind: f.kind.to_owned(),
436 symbol: f.symbol.clone(),
437 });
438 }
439 let mut current_supps: FxHashMap<String, FxHashSet<String>> = FxHashMap::default();
440 for s in input.suppressions {
441 let rel = format_display_path(&s.path, root);
442 if !changed.contains(&rel) {
443 continue;
444 }
445 let key = s
446 .kind
447 .clone()
448 .unwrap_or_else(|| BLANKET_SUPPRESSION.to_owned());
449 current_supps.entry(rel).or_default().insert(key);
450 }
451
452 let mut appeared_move_keys: FxHashSet<String> = FxHashSet::default();
453 for (rel, findings) in ¤t_findings {
454 let prior_ids: FxHashSet<&str> = store
455 .frontier
456 .get(rel)
457 .map(|f| f.findings.iter().map(|x| x.id.as_str()).collect())
458 .unwrap_or_default();
459 for ff in findings {
460 if !prior_ids.contains(ff.id.as_str()) {
461 appeared_move_keys.insert(ff.move_key());
462 }
463 }
464 }
465
466 uncredit_cross_run_moves(store, &appeared_move_keys);
467
468 classify_file_disappearances(
469 store,
470 &changed,
471 ¤t_findings,
472 ¤t_supps,
473 &appeared_move_keys,
474 git_sha,
475 timestamp,
476 );
477 update_file_frontier(store, &changed, current_findings, current_supps);
478 classify_clone_disappearances(store, input, &changed, git_sha, timestamp);
479 prune_frontier(store, root);
480 bound_recent_resolved(store);
481}
482
483fn whole_project_scope(
484 store: &ImpactStore,
485 input: &AttributionInput<'_>,
486 root: &Path,
487) -> FxHashSet<String> {
488 let mut set: FxHashSet<String> = store.frontier.keys().cloned().collect();
489 for paths in store.clone_frontier.values() {
490 for p in paths {
491 set.insert(p.clone());
492 }
493 }
494 for f in &input.findings {
495 set.insert(format_display_path(&f.path, root));
496 }
497 for c in &input.clones {
498 for p in &c.instance_paths {
499 set.insert(format_display_path(p, root));
500 }
501 }
502 set
503}
504
505fn classify_file_disappearances(
506 store: &mut ImpactStore,
507 changed: &FxHashSet<String>,
508 current_findings: &FxHashMap<String, Vec<FrontierFinding>>,
509 current_supps: &FxHashMap<String, FxHashSet<String>>,
510 appeared_move_keys: &FxHashSet<String>,
511 git_sha: Option<&str>,
512 timestamp: &str,
513) {
514 let empty_supps = FxHashSet::default();
515 for rel in changed {
516 let Some(prior) = store.frontier.get(rel) else {
517 continue;
518 };
519 let now_ids: FxHashSet<&str> = current_findings
520 .get(rel)
521 .map(|fs| fs.iter().map(|f| f.id.as_str()).collect())
522 .unwrap_or_default();
523 let now_supps = current_supps.get(rel).unwrap_or(&empty_supps);
524 let prior_supps: FxHashSet<&str> = prior.suppressions.iter().map(String::as_str).collect();
525 let new_supp_kinds: FxHashSet<String> = now_supps
526 .iter()
527 .filter(|k| !prior_supps.contains(k.as_str()))
528 .cloned()
529 .collect();
530
531 let mut resolved = Vec::new();
532 let mut suppressed = 0usize;
533 for pf in &prior.findings {
534 if now_ids.contains(pf.id.as_str()) {
535 continue; }
537 if appeared_move_keys.contains(&pf.move_key()) {
538 continue; }
540 if covered_by(&new_supp_kinds, &pf.kind) {
541 suppressed += 1; } else {
543 resolved.push(pf.clone());
544 }
545 }
546 store.suppressed_total += suppressed;
547 for pf in resolved {
548 store.resolved_total += 1;
549 store.recent_resolved.push(ResolutionEvent {
550 kind: pf.kind,
551 path: rel.clone(),
552 symbol: pf.symbol,
553 git_sha: git_sha.map(ToOwned::to_owned),
554 timestamp: timestamp.to_owned(),
555 });
556 }
557 }
558}
559
560fn update_file_frontier(
561 store: &mut ImpactStore,
562 changed: &FxHashSet<String>,
563 mut current_findings: FxHashMap<String, Vec<FrontierFinding>>,
564 mut current_supps: FxHashMap<String, FxHashSet<String>>,
565) {
566 for rel in changed {
567 let findings = current_findings.remove(rel).unwrap_or_default();
568 let mut suppressions: Vec<String> = current_supps
569 .remove(rel)
570 .unwrap_or_default()
571 .into_iter()
572 .collect();
573 suppressions.sort_unstable();
574 if findings.is_empty() && suppressions.is_empty() {
575 store.frontier.remove(rel);
576 } else {
577 store.frontier.insert(
578 rel.clone(),
579 FileFrontier {
580 findings,
581 suppressions,
582 },
583 );
584 }
585 }
586}
587
588fn classify_clone_disappearances(
589 store: &mut ImpactStore,
590 input: &AttributionInput<'_>,
591 changed: &FxHashSet<String>,
592 git_sha: Option<&str>,
593 timestamp: &str,
594) {
595 let root = input.root;
596 let mut current: FxHashMap<String, Vec<String>> = FxHashMap::default();
597 for c in &input.clones {
598 let mut paths: Vec<String> = c
599 .instance_paths
600 .iter()
601 .map(|p| format_display_path(p, root))
602 .collect();
603 paths.sort_unstable();
604 paths.dedup();
605 if paths.iter().any(|p| changed.contains(p)) {
606 current.insert(c.fingerprint.clone(), paths);
607 }
608 }
609
610 let dup_suppressed = |paths: &[String]| -> bool {
611 paths.iter().any(|p| {
612 changed.contains(p)
613 && store.frontier.get(p).is_some_and(|f| {
614 f.suppressions
615 .iter()
616 .any(|k| k == CODE_DUPLICATION_KIND || k == BLANKET_SUPPRESSION)
617 })
618 })
619 };
620
621 let still_duplicated: FxHashSet<&String> = current.values().flatten().collect();
622
623 let disappeared: Vec<(String, Vec<String>)> = store
624 .clone_frontier
625 .iter()
626 .filter(|(fp, paths)| {
627 paths.iter().any(|p| changed.contains(p)) && !current.contains_key(*fp)
628 })
629 .map(|(fp, paths)| (fp.clone(), paths.clone()))
630 .collect();
631
632 for (fp, paths) in disappeared {
633 store.clone_frontier.remove(&fp);
634 if paths.iter().any(|p| still_duplicated.contains(p)) {
635 continue;
636 }
637 if dup_suppressed(&paths) {
638 store.suppressed_total += 1;
639 } else {
640 store.resolved_total += 1;
641 let path = paths.first().cloned().unwrap_or_default();
642 store.recent_resolved.push(ResolutionEvent {
643 kind: CODE_DUPLICATION_KIND.to_owned(),
644 path,
645 symbol: None,
646 git_sha: git_sha.map(ToOwned::to_owned),
647 timestamp: timestamp.to_owned(),
648 });
649 }
650 }
651
652 for (fp, paths) in current {
653 store.clone_frontier.insert(fp, paths);
654 }
655}
656
657fn prune_frontier(store: &mut ImpactStore, root: &Path) {
658 store.frontier.retain(|rel, _| root.join(rel).exists());
659 store
660 .clone_frontier
661 .retain(|_, paths| paths.iter().any(|p| root.join(p).exists()));
662}
663
664fn bound_recent_resolved(store: &mut ImpactStore) {
665 if store.recent_resolved.len() > MAX_RECENT_RESOLVED {
666 let overflow = store.recent_resolved.len() - MAX_RECENT_RESOLVED;
667 store.recent_resolved.drain(0..overflow);
668 }
669}
670
671fn event_move_key(ev: &ResolutionEvent) -> Option<String> {
672 ev.symbol
673 .as_ref()
674 .map(|symbol| format!("{}{ID_SEP}{symbol}", ev.kind))
675}
676
677fn uncredit_cross_run_moves(store: &mut ImpactStore, appeared_move_keys: &FxHashSet<String>) {
678 if appeared_move_keys.is_empty() {
679 return;
680 }
681 let mut uncredited = 0usize;
682 store.recent_resolved.retain(|ev| match event_move_key(ev) {
683 Some(mk) if appeared_move_keys.contains(&mk) => {
684 uncredited += 1;
685 false
686 }
687 _ => true,
688 });
689 store.resolved_total = store.resolved_total.saturating_sub(uncredited);
690}
691
692#[must_use]
693pub fn collect_dead_code_findings(results: &AnalysisResults) -> Vec<FindingInput> {
694 let mut out = Vec::new();
695 let mut push = |path: &Path, kind: &'static str, symbol: Option<String>| {
696 out.push(FindingInput {
697 path: path.to_path_buf(),
698 kind,
699 symbol,
700 });
701 };
702 for f in &results.unused_files {
703 push(&f.file.path, "unused-file", None);
704 }
705 for f in &results.unused_exports {
706 push(
707 &f.export.path,
708 "unused-export",
709 Some(f.export.export_name.clone()),
710 );
711 }
712 for f in &results.unused_types {
713 push(
714 &f.export.path,
715 "unused-type",
716 Some(f.export.export_name.clone()),
717 );
718 }
719 for f in &results.private_type_leaks {
720 push(
721 &f.leak.path,
722 "private-type-leak",
723 Some(format!(
724 "{}{ID_SEP}{}",
725 f.leak.export_name, f.leak.type_name
726 )),
727 );
728 }
729 for f in &results.unused_enum_members {
730 push(
731 &f.member.path,
732 "unused-enum-member",
733 Some(format!(
734 "{}{ID_SEP}{}",
735 f.member.parent_name, f.member.member_name
736 )),
737 );
738 }
739 for f in &results.unused_class_members {
740 push(
741 &f.member.path,
742 "unused-class-member",
743 Some(format!(
744 "{}{ID_SEP}{}",
745 f.member.parent_name, f.member.member_name
746 )),
747 );
748 }
749 for f in &results.unresolved_imports {
750 push(
751 &f.import.path,
752 "unresolved-import",
753 Some(f.import.specifier.clone()),
754 );
755 }
756 for f in &results.boundary_violations {
757 let to_path = f.violation.to_path.to_string_lossy().replace('\\', "/");
758 push(
759 &f.violation.from_path,
760 "boundary-violation",
761 Some(format!("{to_path}{ID_SEP}{}", f.violation.import_specifier)),
762 );
763 }
764 for f in &results.unused_dependencies {
765 push(
766 &f.dep.path,
767 "unused-dependency",
768 Some(f.dep.package_name.clone()),
769 );
770 }
771 for f in &results.unused_dev_dependencies {
772 push(
773 &f.dep.path,
774 "unused-dev-dependency",
775 Some(f.dep.package_name.clone()),
776 );
777 }
778 for f in &results.unused_optional_dependencies {
779 push(
780 &f.dep.path,
781 "unused-optional-dependency",
782 Some(f.dep.package_name.clone()),
783 );
784 }
785 for f in &results.type_only_dependencies {
786 push(
787 &f.dep.path,
788 "type-only-dependency",
789 Some(f.dep.package_name.clone()),
790 );
791 }
792 for f in &results.test_only_dependencies {
793 push(
794 &f.dep.path,
795 "test-only-dependency",
796 Some(f.dep.package_name.clone()),
797 );
798 }
799 for f in &results.unused_catalog_entries {
800 push(
801 &f.entry.path,
802 "unused-catalog-entry",
803 Some(format!(
804 "{}{ID_SEP}{}",
805 f.entry.catalog_name, f.entry.entry_name
806 )),
807 );
808 }
809 for f in &results.empty_catalog_groups {
810 push(
811 &f.group.path,
812 "empty-catalog-group",
813 Some(f.group.catalog_name.clone()),
814 );
815 }
816 for f in &results.unresolved_catalog_references {
817 push(
818 &f.reference.path,
819 "unresolved-catalog-reference",
820 Some(format!(
821 "{}{ID_SEP}{}",
822 f.reference.catalog_name, f.reference.entry_name
823 )),
824 );
825 }
826 for f in &results.unused_dependency_overrides {
827 push(
828 &f.entry.path,
829 "unused-dependency-override",
830 Some(f.entry.raw_key.clone()),
831 );
832 }
833 for f in &results.misconfigured_dependency_overrides {
834 push(
835 &f.entry.path,
836 "misconfigured-dependency-override",
837 Some(f.entry.raw_key.clone()),
838 );
839 }
840 out
841}
842
843#[must_use]
847pub fn collect_complexity_findings(
848 report: &crate::health_types::HealthReport,
849) -> Vec<FindingInput> {
850 report
851 .findings
852 .iter()
853 .map(|f| FindingInput {
854 path: f.path.clone(),
855 kind: "complexity",
856 symbol: Some(f.name.clone()),
857 })
858 .collect()
859}
860
861#[must_use]
865pub fn collect_clone_findings(
866 report: &fallow_core::duplicates::DuplicationReport,
867) -> Vec<CloneInput> {
868 report
869 .clone_groups
870 .iter()
871 .map(|g| CloneInput {
872 fingerprint: fallow_core::duplicates::clone_fingerprint(&g.instances),
873 instance_paths: g.instances.iter().map(|i| i.file.clone()).collect(),
874 })
875 .collect()
876}
877
878const fn verdict_label(verdict: AuditVerdict) -> &'static str {
879 match verdict {
880 AuditVerdict::Pass => "pass",
881 AuditVerdict::Warn => "warn",
882 AuditVerdict::Fail => "fail",
883 }
884}
885
886#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
888#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
889#[serde(rename_all = "snake_case")]
890pub enum ImpactTrendDirection {
891 Improving,
893 Declining,
895 Stable,
897}
898
899#[derive(Debug, Clone, Serialize)]
901#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
902pub struct TrendSummary {
903 pub direction: ImpactTrendDirection,
904 pub total_delta: i64,
906 pub previous_total: usize,
907 pub current_total: usize,
908}
909
910fn direction_for(delta: i64) -> ImpactTrendDirection {
911 if delta < -TREND_TOLERANCE {
912 ImpactTrendDirection::Improving
913 } else if delta > TREND_TOLERANCE {
914 ImpactTrendDirection::Declining
915 } else {
916 ImpactTrendDirection::Stable
917 }
918}
919
920#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
927#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
928pub enum ImpactReportSchemaVersion {
929 #[serde(rename = "1")]
931 V1,
932}
933
934#[derive(Debug, Clone, Serialize)]
936#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
937#[cfg_attr(feature = "schema", schemars(title = "fallow impact --format json"))]
938pub struct ImpactReport {
939 pub schema_version: ImpactReportSchemaVersion,
943 pub enabled: bool,
944 pub record_count: usize,
945 #[serde(default, skip_serializing_if = "Option::is_none")]
946 pub first_recorded: Option<String>,
947 #[serde(default, skip_serializing_if = "Option::is_none")]
954 pub latest_git_sha: Option<String>,
955 #[serde(default, skip_serializing_if = "Option::is_none")]
960 pub surfacing: Option<ImpactCounts>,
961 #[serde(default, skip_serializing_if = "Option::is_none")]
963 pub trend: Option<TrendSummary>,
964 #[serde(default, skip_serializing_if = "Option::is_none")]
969 pub project_surfacing: Option<ImpactCounts>,
970 #[serde(default, skip_serializing_if = "Option::is_none")]
974 pub project_trend: Option<TrendSummary>,
975 pub containment_count: usize,
976 pub recent_containment: Vec<ContainmentEvent>,
978 pub resolved_total: usize,
981 pub suppressed_total: usize,
984 pub recent_resolved: Vec<ResolutionEvent>,
986 pub attribution_active: bool,
990}
991
992fn trend_for(records: &[ImpactRecord]) -> Option<TrendSummary> {
998 if records.len() < 2 {
999 return None;
1000 }
1001 let current = &records[records.len() - 1];
1002 let previous = &records[records.len() - 2];
1003 let current_total = current.counts.total_issues;
1004 let previous_total = previous.counts.total_issues;
1005 let total_delta = current_total as i64 - previous_total as i64;
1006 Some(TrendSummary {
1007 direction: direction_for(total_delta),
1008 total_delta,
1009 previous_total,
1010 current_total,
1011 })
1012}
1013
1014pub fn build_report(store: &ImpactStore) -> ImpactReport {
1015 let surfacing = store.records.last().map(|r| r.counts.clone());
1016 let trend = trend_for(&store.records);
1017 let project_surfacing = store.project_records.last().map(|r| r.counts.clone());
1018 let project_trend = trend_for(&store.project_records);
1019
1020 let recent_containment = store
1021 .containment
1022 .iter()
1023 .rev()
1024 .take(5)
1025 .rev()
1026 .cloned()
1027 .collect();
1028
1029 let latest_git_sha = store.records.last().and_then(|r| r.git_sha.clone());
1030
1031 let recent_resolved = store
1032 .recent_resolved
1033 .iter()
1034 .rev()
1035 .take(5)
1036 .rev()
1037 .cloned()
1038 .collect();
1039 let attribution_active = !store.frontier.is_empty()
1040 || !store.clone_frontier.is_empty()
1041 || store.resolved_total > 0
1042 || store.suppressed_total > 0;
1043
1044 ImpactReport {
1045 schema_version: ImpactReportSchemaVersion::V1,
1046 enabled: store.enabled,
1047 record_count: store.records.len(),
1048 first_recorded: store.first_recorded.clone(),
1049 latest_git_sha,
1050 surfacing,
1051 trend,
1052 project_surfacing,
1053 project_trend,
1054 containment_count: store.containment.len(),
1055 recent_containment,
1056 resolved_total: store.resolved_total,
1057 suppressed_total: store.suppressed_total,
1058 recent_resolved,
1059 attribution_active,
1060 }
1061}
1062
1063#[expect(
1069 clippy::format_push_string,
1070 reason = "small report renderer; readability over avoiding the extra allocation"
1071)]
1072fn render_project_section(out: &mut String, report: &ImpactReport) {
1073 let Some(s) = &report.project_surfacing else {
1074 return;
1075 };
1076 out.push_str(&format!(
1077 " WHOLE PROJECT (whole-repo context, not a to-do)\n {} issue{} across the whole project at your last full `fallow` run\n",
1078 s.total_issues,
1079 plural(s.total_issues),
1080 ));
1081 if let Some(t) = &report.project_trend {
1082 let arrow = trend_arrow(t.direction);
1083 out.push_str(&format!(
1084 " {} -> {} ({}) across your last two full runs (comparable over time)\n",
1085 t.previous_total, t.current_total, arrow,
1086 ));
1087 } else {
1088 out.push_str(" project trend starts after your next full `fallow` run\n");
1089 }
1090 out.push_str(" advances only on your local full `fallow` runs, not CI\n\n");
1091}
1092
1093#[expect(
1095 clippy::format_push_string,
1096 reason = "small report renderer; readability over avoiding the extra allocation"
1097)]
1098pub fn render_human(report: &ImpactReport) -> String {
1099 let mut out = String::new();
1100 out.push_str("FALLOW IMPACT\n\n");
1101
1102 if !report.enabled {
1103 out.push_str(
1104 "Impact tracking is off. Enable it with `fallow impact enable`, then\n\
1105 let your pre-commit gate run a few times to build history.\n",
1106 );
1107 return out;
1108 }
1109
1110 if report.record_count == 0 && report.project_surfacing.is_none() {
1111 out.push_str(
1112 "Tracking enabled. No history yet: check back after your next few\n\
1113 commits (Impact records each `fallow audit` / pre-commit gate run,\n\
1114 and each full `fallow` run for the whole-project view).\n",
1115 );
1116 return out;
1117 }
1118
1119 if let Some(s) = &report.surfacing {
1120 out.push_str(&format!(
1121 " LATEST RUN (changed files, act on these now)\n {} issue{} flagged in your last `fallow audit` run\n",
1122 s.total_issues,
1123 plural(s.total_issues),
1124 ));
1125 out.push_str(&format!(
1126 " dead code {} · complexity {} · duplication {}\n\n",
1127 s.dead_code, s.complexity, s.duplication,
1128 ));
1129 }
1130
1131 if let Some(t) = &report.trend {
1132 let arrow = trend_arrow(t.direction);
1133 out.push_str(&format!(
1134 " TREND\n {} -> {} issues ({}) across your last two recorded runs\n each run is changed-file scope, so consecutive runs may cover different changes\n\n",
1135 t.previous_total, t.current_total, arrow,
1136 ));
1137 }
1138
1139 render_project_section(&mut out, report);
1140
1141 out.push_str(&format!(
1142 " CONTAINED AT COMMIT\n {} time{} fallow blocked a commit until it was fixed\n",
1143 report.containment_count,
1144 plural(report.containment_count),
1145 ));
1146
1147 if report.resolved_total > 0 {
1148 out.push_str(&format!(
1149 "\n RESOLVED\n {} finding{} you cleared since fallow started tracking\n",
1150 report.resolved_total,
1151 plural(report.resolved_total),
1152 ));
1153 for ev in &report.recent_resolved {
1154 match &ev.symbol {
1155 Some(symbol) => {
1156 out.push_str(&format!(" {} {} in {}\n", ev.kind, symbol, ev.path));
1157 }
1158 None => out.push_str(&format!(" {} in {}\n", ev.kind, ev.path)),
1159 }
1160 }
1161 } else if report.attribution_active {
1162 out.push_str(
1163 "\n RESOLVED\n none yet; a finding is credited when fallow re-analyzes the\n file it left (a fix that reverts a file to its base state\n may not be individually credited)\n",
1164 );
1165 } else {
1166 out.push_str("\n RESOLVED\n resolution tracking starts from your next gate run\n");
1167 }
1168
1169 if report.suppressed_total > 0 {
1170 out.push_str(&format!(
1171 " {} finding{} you marked intentional (fallow-ignore), not counted as resolved\n",
1172 report.suppressed_total,
1173 plural(report.suppressed_total),
1174 ));
1175 }
1176
1177 out.push('\n');
1178 let since = report
1179 .first_recorded
1180 .as_deref()
1181 .map_or("the first run", date_only);
1182 if report.record_count > 0 {
1183 out.push_str(&format!(
1184 "Based on {} recorded audit run{} since {}. Local-only; never uploaded.\n\
1185 Changed-file scope: each audit run only sees files differing from your base.\n",
1186 report.record_count,
1187 plural(report.record_count),
1188 since,
1189 ));
1190 } else {
1191 out.push_str(&format!(
1192 "Tracking since {since}. Local-only; never uploaded.\n",
1193 ));
1194 }
1195 out.push_str(
1196 "Resolution tracking is a local-developer signal: it accrues where\n\
1197 .fallow/impact.json persists across runs, not in ephemeral CI runners.\n",
1198 );
1199 out
1200}
1201
1202pub fn render_json(report: &ImpactReport) -> String {
1204 let value = crate::output_envelope::serialize_root_output(
1205 crate::output_envelope::FallowOutput::Impact(report.clone()),
1206 )
1207 .unwrap_or_else(|_| serde_json::json!({"error":"failed to serialize impact report"}));
1208 serde_json::to_string_pretty(&value)
1209 .unwrap_or_else(|_| "{\"error\":\"failed to serialize impact report\"}".to_owned())
1210}
1211
1212#[expect(
1216 clippy::format_push_string,
1217 reason = "small report renderer; readability over avoiding the extra allocation"
1218)]
1219fn render_project_markdown(out: &mut String, report: &ImpactReport) {
1220 let Some(s) = &report.project_surfacing else {
1221 return;
1222 };
1223 out.push_str(&format!(
1224 "- **Whole project (whole-repo context, last full `fallow` run):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
1225 s.total_issues,
1226 plural(s.total_issues),
1227 s.dead_code,
1228 s.complexity,
1229 s.duplication,
1230 ));
1231 if let Some(t) = &report.project_trend {
1232 let arrow = trend_arrow(t.direction);
1233 out.push_str(&format!(
1234 "- **Project trend (whole project, last two full runs):** {} -> {} ({})\n",
1235 t.previous_total, t.current_total, arrow,
1236 ));
1237 }
1238}
1239
1240#[expect(
1242 clippy::format_push_string,
1243 reason = "small report renderer; readability over avoiding the extra allocation"
1244)]
1245pub fn render_markdown(report: &ImpactReport) -> String {
1246 let mut out = String::new();
1247 out.push_str("## Fallow impact\n\n");
1248
1249 if !report.enabled {
1250 out.push_str("Impact tracking is off. Run `fallow impact enable` to start.\n");
1251 return out;
1252 }
1253 if report.record_count == 0 && report.project_surfacing.is_none() {
1254 out.push_str("Tracking enabled. No history yet; check back after a few commits.\n");
1255 return out;
1256 }
1257
1258 if let Some(s) = &report.surfacing {
1259 out.push_str(&format!(
1260 "- **Latest run (changed files):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
1261 s.total_issues,
1262 plural(s.total_issues),
1263 s.dead_code,
1264 s.complexity,
1265 s.duplication,
1266 ));
1267 }
1268 if let Some(t) = &report.trend {
1269 out.push_str(&format!(
1270 "- **Trend (changed-file scope, last two runs):** {} -> {} ({})\n",
1271 t.previous_total,
1272 t.current_total,
1273 trend_arrow(t.direction),
1274 ));
1275 }
1276 render_project_markdown(&mut out, report);
1277 out.push_str(&format!(
1278 "- **Contained at commit:** {} time{}\n",
1279 report.containment_count,
1280 plural(report.containment_count),
1281 ));
1282 if report.resolved_total > 0 {
1283 out.push_str(&format!(
1284 "- **Resolved:** {} finding{} cleared since tracking started\n",
1285 report.resolved_total,
1286 plural(report.resolved_total),
1287 ));
1288 } else if report.attribution_active {
1289 out.push_str("- **Resolved:** none yet; tracking active\n");
1290 } else {
1291 out.push_str("- **Resolved:** resolution tracking starts from your next gate run\n");
1292 }
1293 if report.suppressed_total > 0 {
1294 out.push_str(&format!(
1295 "- **Marked intentional:** {} finding{} (`fallow-ignore`), not counted as resolved\n",
1296 report.suppressed_total,
1297 plural(report.suppressed_total),
1298 ));
1299 }
1300 let since = report
1301 .first_recorded
1302 .as_deref()
1303 .map_or("the first run", date_only);
1304 if report.record_count > 0 {
1305 out.push_str(&format!(
1306 "\n_Based on {} recorded audit run{} since {}. Local-only; resolution is a local-developer signal._\n",
1307 report.record_count,
1308 plural(report.record_count),
1309 since,
1310 ));
1311 } else {
1312 out.push_str(&format!(
1313 "\n_Tracking since {since}. Local-only; resolution is a local-developer signal._\n",
1314 ));
1315 }
1316 out
1317}
1318
1319const fn plural(n: usize) -> &'static str {
1320 if n == 1 { "" } else { "s" }
1321}
1322
1323fn date_only(ts: &str) -> &str {
1329 ts.split_once('T').map_or(ts, |(date, _)| date)
1330}
1331
1332const fn trend_arrow(direction: ImpactTrendDirection) -> &'static str {
1336 match direction {
1337 ImpactTrendDirection::Improving => "down",
1338 ImpactTrendDirection::Declining => "up",
1339 ImpactTrendDirection::Stable => "flat",
1340 }
1341}
1342
1343#[cfg(test)]
1344mod tests {
1345 use super::*;
1346
1347 fn summary(dead: usize, complexity: usize, dupes: usize) -> AuditSummary {
1348 AuditSummary {
1349 dead_code_issues: dead,
1350 dead_code_has_errors: dead > 0,
1351 complexity_findings: complexity,
1352 max_cyclomatic: None,
1353 duplication_clone_groups: dupes,
1354 }
1355 }
1356
1357 fn record_v1(
1359 root: &Path,
1360 summary: &AuditSummary,
1361 verdict: AuditVerdict,
1362 gate: bool,
1363 git_sha: Option<&str>,
1364 version: &str,
1365 timestamp: &str,
1366 ) {
1367 record_audit_run(
1368 root, summary, verdict, gate, git_sha, version, timestamp, None,
1369 );
1370 }
1371
1372 fn touch(root: &Path, rel: &str) -> PathBuf {
1375 let p = root.join(rel);
1376 if let Some(parent) = p.parent() {
1377 std::fs::create_dir_all(parent).unwrap();
1378 }
1379 std::fs::write(&p, b"x").unwrap();
1380 p
1381 }
1382
1383 fn fi(path: &Path, kind: &'static str, symbol: &str) -> FindingInput {
1384 FindingInput {
1385 path: path.to_path_buf(),
1386 kind,
1387 symbol: Some(symbol.to_owned()),
1388 }
1389 }
1390
1391 fn supp(path: &Path, kind: &str) -> ActiveSuppression {
1392 ActiveSuppression {
1393 path: path.to_path_buf(),
1394 kind: Some(kind.to_owned()),
1395 is_file_level: false,
1396 }
1397 }
1398
1399 fn run(
1401 root: &Path,
1402 changed: &[&Path],
1403 findings: Vec<FindingInput>,
1404 clones: Vec<CloneInput>,
1405 supps: &[ActiveSuppression],
1406 ts: &str,
1407 ) {
1408 let changed_files: Vec<PathBuf> = changed.iter().map(|p| p.to_path_buf()).collect();
1409 let input = AttributionInput {
1410 root,
1411 scope: Scope::ChangedFiles(&changed_files),
1412 findings,
1413 clones,
1414 suppressions: supps,
1415 };
1416 record_audit_run(
1417 root,
1418 &summary(0, 0, 0),
1419 AuditVerdict::Pass,
1420 true,
1421 Some("sha"),
1422 "2.0.0",
1423 ts,
1424 Some(&input),
1425 );
1426 }
1427
1428 #[test]
1429 fn disabled_store_does_not_record() {
1430 let dir = tempfile::tempdir().unwrap();
1431 let root = dir.path();
1432 record_v1(
1433 root,
1434 &summary(3, 1, 0),
1435 AuditVerdict::Fail,
1436 true,
1437 Some("abc1234"),
1438 "2.0.0",
1439 "2026-05-29T10:00:00Z",
1440 );
1441 let store = load(root);
1442 assert!(store.records.is_empty());
1443 assert!(!store.enabled);
1444 }
1445
1446 #[test]
1447 fn enable_then_record_accrues_history() {
1448 let dir = tempfile::tempdir().unwrap();
1449 let root = dir.path();
1450 assert!(enable(root));
1451 assert!(!enable(root)); record_v1(
1453 root,
1454 &summary(2, 1, 0),
1455 AuditVerdict::Warn,
1456 false,
1457 None,
1458 "2.0.0",
1459 "2026-05-29T10:00:00Z",
1460 );
1461 let store = load(root);
1462 assert_eq!(store.records.len(), 1);
1463 assert_eq!(store.records[0].counts.total_issues, 3);
1464 assert_eq!(
1465 store.first_recorded.as_deref(),
1466 Some("2026-05-29T10:00:00Z")
1467 );
1468 }
1469
1470 #[test]
1471 fn enable_gitignores_the_store() {
1472 let dir = tempfile::tempdir().unwrap();
1473 let root = dir.path();
1474 enable(root);
1475 let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1476 assert!(
1477 gitignore.lines().any(|l| l.trim() == ".fallow/"),
1478 "enable must gitignore .fallow/, got: {gitignore:?}"
1479 );
1480 enable(root);
1481 let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1482 assert_eq!(
1483 gitignore.lines().filter(|l| l.trim() == ".fallow/").count(),
1484 1,
1485 "re-enabling must not duplicate the .fallow/ entry"
1486 );
1487 }
1488
1489 #[test]
1490 fn single_record_yields_no_trend_no_spike() {
1491 let mut store = ImpactStore {
1492 enabled: true,
1493 ..Default::default()
1494 };
1495 store.records.push(ImpactRecord {
1496 timestamp: "t0".into(),
1497 version: "2.0.0".into(),
1498 git_sha: None,
1499 verdict: "warn".into(),
1500 gate: false,
1501 counts: ImpactCounts {
1502 total_issues: 5,
1503 dead_code: 5,
1504 complexity: 0,
1505 duplication: 0,
1506 },
1507 });
1508 let report = build_report(&store);
1509 assert!(report.trend.is_none());
1510 assert_eq!(report.surfacing.unwrap().total_issues, 5);
1511 }
1512
1513 #[test]
1514 fn empty_store_report_is_first_run() {
1515 let store = ImpactStore::default();
1516 let report = build_report(&store);
1517 assert_eq!(report.record_count, 0);
1518 assert!(report.trend.is_none());
1519 assert!(report.surfacing.is_none());
1520 let human = render_human(&report);
1521 assert!(human.contains("off")); }
1523
1524 #[test]
1525 fn enabled_empty_store_shows_check_back() {
1526 let store = ImpactStore {
1527 enabled: true,
1528 ..Default::default()
1529 };
1530 let report = build_report(&store);
1531 let human = render_human(&report);
1532 assert!(human.contains("No history yet"));
1533 assert!(!human.contains("0 issues"));
1534 }
1535
1536 #[test]
1537 fn trend_improving_when_issues_drop() {
1538 let mut store = ImpactStore {
1539 enabled: true,
1540 ..Default::default()
1541 };
1542 for total in [8usize, 3usize] {
1543 store.records.push(ImpactRecord {
1544 timestamp: format!("t{total}"),
1545 version: "2.0.0".into(),
1546 git_sha: None,
1547 verdict: "warn".into(),
1548 gate: false,
1549 counts: ImpactCounts {
1550 total_issues: total,
1551 dead_code: total,
1552 complexity: 0,
1553 duplication: 0,
1554 },
1555 });
1556 }
1557 let report = build_report(&store);
1558 let trend = report.trend.unwrap();
1559 assert_eq!(trend.direction, ImpactTrendDirection::Improving);
1560 assert_eq!(trend.total_delta, -5);
1561 }
1562
1563 #[test]
1564 fn containment_blocked_then_cleared_records_one_event() {
1565 let dir = tempfile::tempdir().unwrap();
1566 let root = dir.path();
1567 enable(root);
1568 record_v1(
1569 root,
1570 &summary(2, 0, 0),
1571 AuditVerdict::Fail,
1572 true,
1573 Some("sha1"),
1574 "2.0.0",
1575 "t0",
1576 );
1577 let store = load(root);
1578 assert!(store.pending_containment.is_some());
1579 assert!(store.containment.is_empty());
1580
1581 record_v1(
1582 root,
1583 &summary(0, 0, 0),
1584 AuditVerdict::Pass,
1585 true,
1586 Some("sha2"),
1587 "2.0.0",
1588 "t1",
1589 );
1590 let store = load(root);
1591 assert!(store.pending_containment.is_none());
1592 assert_eq!(store.containment.len(), 1);
1593 assert_eq!(store.containment[0].blocked_at, "t0");
1594 assert_eq!(store.containment[0].cleared_at, "t1");
1595 }
1596
1597 #[test]
1598 fn non_gate_run_never_creates_containment() {
1599 let dir = tempfile::tempdir().unwrap();
1600 let root = dir.path();
1601 enable(root);
1602 record_v1(
1603 root,
1604 &summary(2, 0, 0),
1605 AuditVerdict::Fail,
1606 false,
1607 None,
1608 "2.0.0",
1609 "t0",
1610 );
1611 let store = load(root);
1612 assert!(store.pending_containment.is_none());
1613 assert!(store.containment.is_empty());
1614 }
1615
1616 #[test]
1617 fn corrupt_store_loads_as_default_no_panic() {
1618 let dir = tempfile::tempdir().unwrap();
1619 let root = dir.path();
1620 std::fs::create_dir_all(root.join(".fallow")).unwrap();
1621 std::fs::write(store_path(root), b"{ not valid json ][").unwrap();
1622 let store = load(root);
1623 assert!(!store.enabled);
1624 assert!(store.records.is_empty());
1625 record_v1(
1626 root,
1627 &summary(1, 0, 0),
1628 AuditVerdict::Fail,
1629 true,
1630 None,
1631 "2.0.0",
1632 "t0",
1633 );
1634 }
1635
1636 #[test]
1637 fn records_are_bounded() {
1638 let mut store = ImpactStore {
1639 enabled: true,
1640 ..Default::default()
1641 };
1642 for i in 0..(MAX_RECORDS + 50) {
1643 store.records.push(ImpactRecord {
1644 timestamp: format!("t{i}"),
1645 version: "2.0.0".into(),
1646 git_sha: None,
1647 verdict: "pass".into(),
1648 gate: false,
1649 counts: ImpactCounts::default(),
1650 });
1651 }
1652 compact(&mut store);
1653 assert_eq!(store.records.len(), MAX_RECORDS);
1654 assert_eq!(store.records[0].timestamp, "t50");
1655 }
1656
1657 #[test]
1658 fn report_always_carries_schema_version() {
1659 let empty = build_report(&ImpactStore::default());
1660 assert_eq!(empty.schema_version, ImpactReportSchemaVersion::V1);
1661 let json = render_json(&empty);
1662 assert!(
1663 json.contains("\"schema_version\": \"1\""),
1664 "schema_version must be present (as the \"1\" const) even when disabled: {json}"
1665 );
1666
1667 let mut store = ImpactStore {
1668 enabled: true,
1669 ..Default::default()
1670 };
1671 store.records.push(ImpactRecord {
1672 timestamp: "2026-05-29T10:00:00Z".into(),
1673 version: "2.0.0".into(),
1674 git_sha: None,
1675 verdict: "pass".into(),
1676 gate: false,
1677 counts: ImpactCounts::default(),
1678 });
1679 assert_eq!(
1680 build_report(&store).schema_version,
1681 ImpactReportSchemaVersion::V1
1682 );
1683 }
1684
1685 #[test]
1686 fn date_only_trims_iso_timestamp() {
1687 assert_eq!(date_only("2026-05-29T18:15:23Z"), "2026-05-29");
1688 assert_eq!(date_only("2026-05-29"), "2026-05-29");
1689 assert_eq!(date_only("the first run"), "the first run");
1690 }
1691
1692 #[test]
1693 fn human_footer_shows_date_only() {
1694 let mut store = ImpactStore {
1695 enabled: true,
1696 ..Default::default()
1697 };
1698 store.first_recorded = Some("2026-05-29T18:15:23Z".into());
1699 store.records.push(ImpactRecord {
1700 timestamp: "2026-05-29T18:15:23Z".into(),
1701 version: "2.0.0".into(),
1702 git_sha: None,
1703 verdict: "pass".into(),
1704 gate: false,
1705 counts: ImpactCounts::default(),
1706 });
1707 let report = build_report(&store);
1708 let human = render_human(&report);
1709 assert!(
1710 human.contains("since 2026-05-29.") && !human.contains("18:15:23"),
1711 "human footer must show date-only: {human}"
1712 );
1713 let md = render_markdown(&report);
1714 assert!(
1715 md.contains("since 2026-05-29.") && !md.contains("18:15:23"),
1716 "markdown footer must show date-only: {md}"
1717 );
1718 }
1719
1720 #[test]
1721 fn future_schema_version_store_loads_without_panic_or_loss() {
1722 let dir = tempfile::tempdir().unwrap();
1723 let root = dir.path();
1724 std::fs::create_dir_all(root.join(".fallow")).unwrap();
1725 let future = format!(
1726 "{{\"schema_version\":{},\"enabled\":true,\"records\":[],\"containment\":[]}}",
1727 STORE_SCHEMA_VERSION + 1
1728 );
1729 std::fs::write(store_path(root), future).unwrap();
1730 let store = load(root);
1731 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION + 1);
1732 assert!(
1733 store.enabled,
1734 "future-version store must not degrade to default"
1735 );
1736 }
1737
1738 #[test]
1739 fn removed_finding_is_credited_as_resolved() {
1740 let dir = tempfile::tempdir().unwrap();
1741 let root = dir.path();
1742 enable(root);
1743 let a = touch(root, "src/a.ts");
1744 run(
1745 root,
1746 &[&a],
1747 vec![fi(&a, "unused-export", "foo")],
1748 vec![],
1749 &[],
1750 "t0",
1751 );
1752 assert_eq!(
1753 load(root).resolved_total,
1754 0,
1755 "first run only establishes a baseline"
1756 );
1757 run(root, &[&a], vec![], vec![], &[], "t1");
1758 let store = load(root);
1759 assert_eq!(store.resolved_total, 1);
1760 assert_eq!(store.suppressed_total, 0);
1761 assert_eq!(store.recent_resolved.len(), 1);
1762 assert_eq!(store.recent_resolved[0].kind, "unused-export");
1763 assert_eq!(store.recent_resolved[0].symbol.as_deref(), Some("foo"));
1764 assert_eq!(store.recent_resolved[0].path, "src/a.ts");
1765 }
1766
1767 #[test]
1768 fn suppressed_finding_is_not_a_win() {
1769 let dir = tempfile::tempdir().unwrap();
1770 let root = dir.path();
1771 enable(root);
1772 let a = touch(root, "src/a.ts");
1773 run(
1774 root,
1775 &[&a],
1776 vec![fi(&a, "unused-export", "foo")],
1777 vec![],
1778 &[],
1779 "t0",
1780 );
1781 run(
1782 root,
1783 &[&a],
1784 vec![],
1785 vec![],
1786 &[supp(&a, "unused-export")],
1787 "t1",
1788 );
1789 let store = load(root);
1790 assert_eq!(
1791 store.resolved_total, 0,
1792 "a suppression must never count as a win"
1793 );
1794 assert_eq!(store.suppressed_total, 1);
1795 }
1796
1797 #[test]
1798 fn fix_and_suppress_same_kind_credits_zero_resolved() {
1799 let dir = tempfile::tempdir().unwrap();
1800 let root = dir.path();
1801 enable(root);
1802 let a = touch(root, "src/a.ts");
1803 run(
1804 root,
1805 &[&a],
1806 vec![
1807 fi(&a, "unused-export", "foo"),
1808 fi(&a, "unused-export", "bar"),
1809 ],
1810 vec![],
1811 &[],
1812 "t0",
1813 );
1814 run(
1815 root,
1816 &[&a],
1817 vec![],
1818 vec![],
1819 &[supp(&a, "unused-export")],
1820 "t1",
1821 );
1822 let store = load(root);
1823 assert_eq!(store.resolved_total, 0);
1824 assert_eq!(store.suppressed_total, 2);
1825 }
1826
1827 #[test]
1828 fn within_file_move_is_not_resolved() {
1829 let dir = tempfile::tempdir().unwrap();
1830 let root = dir.path();
1831 enable(root);
1832 let a = touch(root, "src/a.ts");
1833 run(
1834 root,
1835 &[&a],
1836 vec![fi(&a, "unused-export", "foo")],
1837 vec![],
1838 &[],
1839 "t0",
1840 );
1841 run(
1842 root,
1843 &[&a],
1844 vec![fi(&a, "unused-export", "foo")],
1845 vec![],
1846 &[],
1847 "t1",
1848 );
1849 let store = load(root);
1850 assert_eq!(store.resolved_total, 0);
1851 assert_eq!(store.suppressed_total, 0);
1852 }
1853
1854 #[test]
1855 fn cross_file_move_in_same_run_is_not_resolved() {
1856 let dir = tempfile::tempdir().unwrap();
1857 let root = dir.path();
1858 enable(root);
1859 let a = touch(root, "src/a.ts");
1860 let b = touch(root, "src/b.ts");
1861 run(
1862 root,
1863 &[&a],
1864 vec![fi(&a, "unused-export", "foo")],
1865 vec![],
1866 &[],
1867 "t0",
1868 );
1869 run(
1870 root,
1871 &[&a, &b],
1872 vec![fi(&b, "unused-export", "foo")],
1873 vec![],
1874 &[],
1875 "t1",
1876 );
1877 assert_eq!(
1878 load(root).resolved_total,
1879 0,
1880 "a cross-file move is not a resolution"
1881 );
1882 }
1883
1884 #[test]
1885 fn cross_run_move_uncredits_the_prior_resolution() {
1886 let dir = tempfile::tempdir().unwrap();
1887 let root = dir.path();
1888 enable(root);
1889 let a = touch(root, "src/a.ts");
1890 let b = touch(root, "src/b.ts");
1891 run(
1892 root,
1893 &[&a],
1894 vec![fi(&a, "unused-export", "foo")],
1895 vec![],
1896 &[],
1897 "t0",
1898 );
1899 run(root, &[&a], vec![], vec![], &[], "t1");
1900 assert_eq!(
1901 load(root).resolved_total,
1902 1,
1903 "source disappearance credited in run A"
1904 );
1905 run(
1906 root,
1907 &[&b],
1908 vec![fi(&b, "unused-export", "foo")],
1909 vec![],
1910 &[],
1911 "t2",
1912 );
1913 let store = load(root);
1914 assert_eq!(
1915 store.resolved_total, 0,
1916 "cross-run move must un-credit the phantom win"
1917 );
1918 assert!(
1919 store.recent_resolved.is_empty(),
1920 "the stale resolution event is dropped"
1921 );
1922 }
1923
1924 #[test]
1925 fn resolved_complexity_finding_and_suppressed_complexity() {
1926 let dir = tempfile::tempdir().unwrap();
1927 let root = dir.path();
1928 enable(root);
1929 let a = touch(root, "src/a.ts");
1930 run(
1931 root,
1932 &[&a],
1933 vec![fi(&a, "complexity", "bigFn")],
1934 vec![],
1935 &[],
1936 "t0",
1937 );
1938 run(root, &[&a], vec![], vec![], &[supp(&a, "complexity")], "t1");
1939 let store = load(root);
1940 assert_eq!(store.resolved_total, 0);
1941 assert_eq!(store.suppressed_total, 1);
1942
1943 let b = touch(root, "src/b.ts");
1944 run(
1945 root,
1946 &[&b],
1947 vec![fi(&b, "complexity", "huge")],
1948 vec![],
1949 &[],
1950 "t2",
1951 );
1952 run(root, &[&b], vec![], vec![], &[], "t3");
1953 assert_eq!(load(root).resolved_total, 1);
1954 }
1955
1956 #[test]
1957 fn resolved_duplication_clone_group() {
1958 let dir = tempfile::tempdir().unwrap();
1959 let root = dir.path();
1960 enable(root);
1961 let a = touch(root, "src/a.ts");
1962 let b = touch(root, "src/b.ts");
1963 let clone = CloneInput {
1964 fingerprint: "dup:abc12345".to_owned(),
1965 instance_paths: vec![a.clone(), b],
1966 };
1967 run(root, &[&a], vec![], vec![clone], &[], "t0");
1968 run(root, &[&a], vec![], vec![], &[], "t1");
1969 let store = load(root);
1970 assert_eq!(store.resolved_total, 1);
1971 assert_eq!(store.recent_resolved[0].kind, "code-duplication");
1972 }
1973
1974 #[test]
1975 fn blanket_suppression_covers_any_kind() {
1976 let dir = tempfile::tempdir().unwrap();
1977 let root = dir.path();
1978 enable(root);
1979 let a = touch(root, "src/a.ts");
1980 run(
1981 root,
1982 &[&a],
1983 vec![fi(&a, "unused-export", "foo")],
1984 vec![],
1985 &[],
1986 "t0",
1987 );
1988 let blanket = ActiveSuppression {
1989 path: a.clone(),
1990 kind: None,
1991 is_file_level: true,
1992 };
1993 run(root, &[&a], vec![], vec![], &[blanket], "t1");
1994 let store = load(root);
1995 assert_eq!(store.resolved_total, 0);
1996 assert_eq!(store.suppressed_total, 1);
1997 }
1998
1999 #[test]
2000 fn v1_store_loads_and_upgrades_to_v2() {
2001 let dir = tempfile::tempdir().unwrap();
2002 let root = dir.path();
2003 std::fs::create_dir_all(root.join(".fallow")).unwrap();
2004 let v1 = r#"{"schema_version":1,"enabled":true,"first_recorded":"t0","records":[{"timestamp":"t0","version":"2.0.0","verdict":"warn","gate":false,"counts":{"total_issues":1,"dead_code":1,"complexity":0,"duplication":0}}],"containment":[]}"#;
2005 std::fs::write(store_path(root), v1).unwrap();
2006 let store = load(root);
2007 assert_eq!(store.schema_version, 1);
2008 assert!(store.frontier.is_empty());
2009 assert_eq!(store.resolved_total, 0);
2010 let a = touch(root, "src/a.ts");
2011 run(
2012 root,
2013 &[&a],
2014 vec![fi(&a, "unused-export", "foo")],
2015 vec![],
2016 &[],
2017 "t1",
2018 );
2019 let store = load(root);
2020 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
2021 assert!(store.frontier.contains_key("src/a.ts"));
2022 }
2023
2024 #[test]
2025 fn recent_resolved_is_bounded() {
2026 let mut store = ImpactStore {
2027 enabled: true,
2028 ..Default::default()
2029 };
2030 for i in 0..(MAX_RECENT_RESOLVED + 25) {
2031 store.recent_resolved.push(ResolutionEvent {
2032 kind: "unused-export".into(),
2033 path: format!("src/f{i}.ts"),
2034 symbol: Some(format!("s{i}")),
2035 git_sha: None,
2036 timestamp: format!("t{i}"),
2037 });
2038 }
2039 bound_recent_resolved(&mut store);
2040 assert_eq!(store.recent_resolved.len(), MAX_RECENT_RESOLVED);
2041 assert_eq!(store.recent_resolved[0].path, "src/f25.ts");
2042 }
2043
2044 #[test]
2045 fn frontier_prunes_deleted_files() {
2046 let dir = tempfile::tempdir().unwrap();
2047 let root = dir.path();
2048 enable(root);
2049 let a = touch(root, "src/a.ts");
2050 run(
2051 root,
2052 &[&a],
2053 vec![fi(&a, "unused-export", "foo")],
2054 vec![],
2055 &[],
2056 "t0",
2057 );
2058 assert!(load(root).frontier.contains_key("src/a.ts"));
2059 std::fs::remove_file(&a).unwrap();
2060 let b = touch(root, "src/b.ts");
2061 run(root, &[&b], vec![], vec![], &[], "t1");
2062 assert!(!load(root).frontier.contains_key("src/a.ts"));
2063 }
2064
2065 #[test]
2066 fn honest_empty_state_before_attribution_baseline() {
2067 let store = ImpactStore {
2068 enabled: true,
2069 records: vec![ImpactRecord {
2070 timestamp: "t0".into(),
2071 version: "2.0.0".into(),
2072 git_sha: None,
2073 verdict: "warn".into(),
2074 gate: false,
2075 counts: ImpactCounts::default(),
2076 }],
2077 ..Default::default()
2078 };
2079 let report = build_report(&store);
2080 assert!(!report.attribution_active);
2081 let human = render_human(&report);
2082 assert!(human.contains("resolution tracking starts from your next gate run"));
2083 assert!(!human.contains("0 finding"));
2084 }
2085
2086 #[test]
2087 fn suppression_only_state_renders_under_a_resolved_header() {
2088 let report = ImpactReport {
2089 schema_version: ImpactReportSchemaVersion::V1,
2090 enabled: true,
2091 record_count: 2,
2092 first_recorded: Some("2026-05-29T10:00:00Z".into()),
2093 latest_git_sha: None,
2094 surfacing: Some(ImpactCounts::default()),
2095 trend: None,
2096 project_surfacing: None,
2097 project_trend: None,
2098 containment_count: 0,
2099 recent_containment: vec![],
2100 resolved_total: 0,
2101 suppressed_total: 2,
2102 recent_resolved: vec![],
2103 attribution_active: true,
2104 };
2105 let human = render_human(&report);
2106 let resolved_idx = human.find(" RESOLVED").expect("RESOLVED header present");
2107 let supp_idx = human
2108 .find("2 findings you marked intentional")
2109 .expect("suppression line present");
2110 assert!(
2111 resolved_idx < supp_idx,
2112 "suppression must render under RESOLVED"
2113 );
2114 assert!(human.contains("none yet"));
2115
2116 let md = render_markdown(&report);
2117 assert!(
2118 md.contains("- **Resolved:**"),
2119 "markdown always has a Resolved bullet"
2120 );
2121 assert!(md.contains("- **Marked intentional:** 2 finding"));
2122 }
2123
2124 fn clone_at(fingerprint: &str, paths: &[&Path]) -> CloneInput {
2126 CloneInput {
2127 fingerprint: fingerprint.to_owned(),
2128 instance_paths: paths.iter().map(|p| p.to_path_buf()).collect(),
2129 }
2130 }
2131
2132 fn run_wp(
2136 root: &Path,
2137 findings: Vec<FindingInput>,
2138 clones: Vec<CloneInput>,
2139 supps: &[ActiveSuppression],
2140 ts: &str,
2141 ) {
2142 let input = AttributionInput {
2143 root,
2144 scope: Scope::WholeProject,
2145 findings,
2146 clones,
2147 suppressions: supps,
2148 };
2149 record_combined_run(
2150 root,
2151 ImpactCounts::default(),
2152 Some("sha"),
2153 "2.0.0",
2154 ts,
2155 Some(&input),
2156 );
2157 }
2158
2159 #[test]
2160 fn whole_project_run_does_not_double_credit_after_audit() {
2161 let dir = tempfile::tempdir().unwrap();
2162 let root = dir.path();
2163 enable(root);
2164 let a = touch(root, "src/a.ts");
2165 let b = touch(root, "src/b.ts");
2166 run(
2167 root,
2168 &[&a, &b],
2169 vec![],
2170 vec![clone_at("dup:abc", &[&a, &b])],
2171 &[],
2172 "t1",
2173 );
2174 assert_eq!(load(root).clone_frontier.len(), 1);
2175
2176 run(root, &[&a, &b], vec![], vec![], &[], "t2");
2177 assert_eq!(load(root).resolved_total, 1);
2178 assert!(load(root).clone_frontier.is_empty());
2179
2180 run_wp(root, vec![], vec![], &[], "t3");
2181 assert_eq!(
2182 load(root).resolved_total,
2183 1,
2184 "whole-project run re-credited a resolution"
2185 );
2186 }
2187
2188 #[test]
2189 fn whole_project_run_credits_suppressed_not_resolved() {
2190 let dir = tempfile::tempdir().unwrap();
2191 let root = dir.path();
2192 enable(root);
2193 let util = touch(root, "src/util.ts");
2194 run(
2195 root,
2196 &[&util],
2197 vec![fi(&util, "unused-export", "dead")],
2198 vec![],
2199 &[],
2200 "t1",
2201 );
2202 assert_eq!(load(root).frontier.len(), 1);
2203
2204 run_wp(root, vec![], vec![], &[supp(&util, "unused-export")], "t2");
2205 let store = load(root);
2206 assert_eq!(
2207 store.suppressed_total, 1,
2208 "suppressed finding not counted suppressed"
2209 );
2210 assert_eq!(
2211 store.resolved_total, 0,
2212 "suppressed finding wrongly counted resolved"
2213 );
2214 }
2215
2216 #[test]
2217 fn clone_reshape_three_to_two_not_credited_as_resolved() {
2218 let dir = tempfile::tempdir().unwrap();
2219 let root = dir.path();
2220 enable(root);
2221 let a = touch(root, "src/a.ts");
2222 let b = touch(root, "src/b.ts");
2223 let c = touch(root, "src/c.ts");
2224 run(
2225 root,
2226 &[&a, &b, &c],
2227 vec![],
2228 vec![clone_at("dup:aaa", &[&a, &b, &c])],
2229 &[],
2230 "t1",
2231 );
2232 assert_eq!(load(root).clone_frontier.len(), 1);
2233
2234 run_wp(
2235 root,
2236 vec![],
2237 vec![clone_at("dup:bbb", &[&a, &b])],
2238 &[],
2239 "t2",
2240 );
2241 let store = load(root);
2242 assert_eq!(
2243 store.resolved_total, 0,
2244 "clone reshape miscredited as resolved"
2245 );
2246 assert!(store.clone_frontier.contains_key("dup:bbb"));
2247 assert!(!store.clone_frontier.contains_key("dup:aaa"));
2248 }
2249
2250 fn rcounts(total: usize, dead: usize, complexity: usize, dup: usize) -> ImpactCounts {
2251 ImpactCounts {
2252 total_issues: total,
2253 dead_code: dead,
2254 complexity,
2255 duplication: dup,
2256 }
2257 }
2258
2259 fn rtrend(prev: usize, cur: usize) -> TrendSummary {
2260 TrendSummary {
2261 direction: direction_for(cur as i64 - prev as i64),
2262 total_delta: cur as i64 - prev as i64,
2263 previous_total: prev,
2264 current_total: cur,
2265 }
2266 }
2267
2268 fn rreport(
2270 record_count: usize,
2271 first_recorded: Option<&str>,
2272 surfacing: Option<ImpactCounts>,
2273 trend: Option<TrendSummary>,
2274 project_surfacing: Option<ImpactCounts>,
2275 project_trend: Option<TrendSummary>,
2276 attribution_active: bool,
2277 ) -> ImpactReport {
2278 ImpactReport {
2279 schema_version: ImpactReportSchemaVersion::V1,
2280 enabled: true,
2281 record_count,
2282 first_recorded: first_recorded.map(ToOwned::to_owned),
2283 latest_git_sha: None,
2284 surfacing,
2285 trend,
2286 project_surfacing,
2287 project_trend,
2288 containment_count: 0,
2289 recent_containment: vec![],
2290 resolved_total: 0,
2291 suppressed_total: 0,
2292 recent_resolved: vec![],
2293 attribution_active,
2294 }
2295 }
2296
2297 #[test]
2298 fn render_human_project_only_store_shows_whole_project_not_empty_state() {
2299 let r = rreport(
2300 0,
2301 Some("2026-05-30T10:00:00Z"),
2302 None,
2303 None,
2304 Some(rcounts(1, 1, 0, 0)),
2305 None,
2306 true,
2307 );
2308 let human = render_human(&r);
2309 assert!(
2310 human.contains("WHOLE PROJECT (whole-repo context, not a to-do)"),
2311 "project-only must render the labeled section"
2312 );
2313 assert!(human.contains("1 issue across the whole project"));
2314 assert!(
2315 human.contains("project trend starts after your next full `fallow` run"),
2316 "single project record => no trend line, shows the next-run hint"
2317 );
2318 assert!(human.contains("Tracking since 2026-05-30"));
2319 assert!(
2320 !human.contains("No history yet"),
2321 "must not show the empty-state copy"
2322 );
2323 assert!(
2324 !human.contains("LATEST RUN"),
2325 "no changed-file track recorded"
2326 );
2327 assert!(
2328 !human.contains("recorded audit run"),
2329 "no audit runs => no changed-file footer"
2330 );
2331 }
2332
2333 #[test]
2334 fn render_human_both_tracks_label_actionable_vs_context() {
2335 let r = rreport(
2336 3,
2337 Some("2026-05-29T10:00:00Z"),
2338 Some(rcounts(4, 4, 0, 0)),
2339 Some(rtrend(6, 4)),
2340 Some(rcounts(40, 30, 5, 5)),
2341 Some(rtrend(45, 40)),
2342 true,
2343 );
2344 let human = render_human(&r);
2345 let latest = human
2346 .find("LATEST RUN (changed files, act on these now)")
2347 .expect("LATEST RUN labeled actionable");
2348 let whole = human
2349 .find("WHOLE PROJECT (whole-repo context, not a to-do)")
2350 .expect("WHOLE PROJECT labeled context");
2351 assert!(
2352 latest < whole,
2353 "changed-file section renders before whole-project"
2354 );
2355 assert!(human.contains("45 -> 40 (down) across your last two full runs"));
2356 assert!(human.contains("advances only on your local full `fallow` runs, not CI"));
2357 }
2358
2359 #[test]
2360 fn render_markdown_project_only_store_shows_whole_project_not_empty_state() {
2361 let r = rreport(
2362 0,
2363 Some("2026-05-30T10:00:00Z"),
2364 None,
2365 None,
2366 Some(rcounts(1, 1, 0, 0)),
2367 None,
2368 true,
2369 );
2370 let md = render_markdown(&r);
2371 assert!(
2372 md.contains(
2373 "- **Whole project (whole-repo context, last full `fallow` run):** 1 issue"
2374 ),
2375 "project-only md must render the labeled whole-project line"
2376 );
2377 assert!(
2378 !md.contains("No history yet"),
2379 "project-only md must not show empty state"
2380 );
2381 assert!(md.contains("Tracking since 2026-05-30"));
2382 }
2383}