1use std::path::{Path, PathBuf};
25
26use fallow_types::results::{ActiveSuppression, AnalysisResults};
27use rustc_hash::{FxHashMap, FxHashSet};
28use serde::{Deserialize, Serialize};
29
30use crate::audit::{AuditSummary, AuditVerdict};
31use crate::report::ci::fingerprint::fingerprint_hash;
32use crate::report::format_display_path;
33
34const STORE_SCHEMA_VERSION: u32 = 2;
46
47const MAX_RECORDS: usize = 200;
50
51const MAX_CONTAINMENT: usize = 200;
53
54const TREND_TOLERANCE: i64 = 0;
58
59const STORE_FILE: &str = "impact.json";
61
62const MAX_RECENT_RESOLVED: usize = 50;
66
67const ID_SEP: &str = "\u{1f}";
71
72const CODE_DUPLICATION_KIND: &str = "code-duplication";
75
76const BLANKET_SUPPRESSION: &str = "*";
79
80#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
82#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
83pub struct ImpactCounts {
84 pub total_issues: usize,
85 pub dead_code: usize,
86 pub complexity: usize,
87 pub duplication: usize,
88}
89
90impl ImpactCounts {
91 fn from_summary(summary: &AuditSummary) -> Self {
92 Self {
93 total_issues: summary.dead_code_issues
94 + summary.complexity_findings
95 + summary.duplication_clone_groups,
96 dead_code: summary.dead_code_issues,
97 complexity: summary.complexity_findings,
98 duplication: summary.duplication_clone_groups,
99 }
100 }
101
102 pub(crate) fn from_combined(dead_code: usize, complexity: usize, duplication: usize) -> Self {
106 Self {
107 total_issues: dead_code + complexity + duplication,
108 dead_code,
109 complexity,
110 duplication,
111 }
112 }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct ImpactRecord {
118 pub timestamp: String,
119 pub version: String,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub git_sha: Option<String>,
122 pub verdict: String,
124 #[serde(default)]
126 pub gate: bool,
127 pub counts: ImpactCounts,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct PendingContainment {
133 pub blocked_at: String,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub git_sha: Option<String>,
136 pub blocked_counts: ImpactCounts,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
141#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
142pub struct ContainmentEvent {
143 pub blocked_at: String,
144 pub cleared_at: String,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub git_sha: Option<String>,
147 pub blocked_counts: ImpactCounts,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct FrontierFinding {
158 pub id: String,
159 pub kind: String,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub symbol: Option<String>,
162}
163
164impl FrontierFinding {
165 fn move_key(&self) -> String {
171 match &self.symbol {
172 Some(symbol) => format!("{}{ID_SEP}{symbol}", self.kind),
173 None => self.id.clone(),
174 }
175 }
176}
177
178#[derive(Debug, Clone, Default, Serialize, Deserialize)]
181pub struct FileFrontier {
182 #[serde(default)]
183 pub findings: Vec<FrontierFinding>,
184 #[serde(default)]
188 pub suppressions: Vec<String>,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
193#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
194pub struct ResolutionEvent {
195 pub kind: String,
197 pub path: String,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub symbol: Option<String>,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub git_sha: Option<String>,
206 pub timestamp: String,
208}
209
210#[derive(Debug, Clone, Default, Serialize, Deserialize)]
212pub struct ImpactStore {
213 #[serde(default)]
214 pub schema_version: u32,
215 #[serde(default)]
217 pub enabled: bool,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub first_recorded: Option<String>,
220 #[serde(default)]
221 pub records: Vec<ImpactRecord>,
222 #[serde(default)]
227 pub project_records: Vec<ImpactRecord>,
228 #[serde(default)]
229 pub containment: Vec<ContainmentEvent>,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub pending_containment: Option<PendingContainment>,
232 #[serde(default)]
236 pub frontier: FxHashMap<String, FileFrontier>,
237 #[serde(default)]
241 pub clone_frontier: FxHashMap<String, Vec<String>>,
242 #[serde(default)]
244 pub resolved_total: usize,
245 #[serde(default)]
248 pub suppressed_total: usize,
249 #[serde(default)]
252 pub recent_resolved: Vec<ResolutionEvent>,
253}
254
255fn store_path(root: &Path) -> PathBuf {
257 root.join(".fallow").join(STORE_FILE)
258}
259
260pub fn load(root: &Path) -> ImpactStore {
267 let path = store_path(root);
268 let Ok(content) = std::fs::read_to_string(&path) else {
269 return ImpactStore::default();
270 };
271 match serde_json::from_str::<ImpactStore>(&content) {
272 Ok(store) => {
273 if store.schema_version > STORE_SCHEMA_VERSION {
274 tracing::warn!(
275 "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.",
276 path.display(),
277 store.schema_version,
278 STORE_SCHEMA_VERSION,
279 );
280 }
281 store
282 }
283 Err(err) => {
284 tracing::warn!(
285 "fallow impact: ignoring unreadable store at {} ({err}); run `fallow impact enable` to reset it",
286 path.display()
287 );
288 ImpactStore::default()
289 }
290 }
291}
292
293fn save(store: &ImpactStore, root: &Path) {
300 let path = store_path(root);
301 if let Some(parent) = path.parent()
302 && std::fs::create_dir_all(parent).is_err()
303 {
304 return;
305 }
306 if let Ok(json) = serde_json::to_string_pretty(store) {
307 let _ = fallow_config::atomic_write(&path, json.as_bytes());
308 }
309}
310
311pub fn enable(root: &Path) -> bool {
319 let mut store = load(root);
320 let was_enabled = store.enabled;
321 store.enabled = true;
322 if store.schema_version == 0 {
323 store.schema_version = STORE_SCHEMA_VERSION;
324 }
325 save(&store, root);
326 ensure_fallow_gitignored(root);
327 !was_enabled
328}
329
330fn ensure_fallow_gitignored(root: &Path) {
337 let path = root.join(".gitignore");
338 let existing = std::fs::read_to_string(&path).unwrap_or_default();
339 let already = existing
340 .lines()
341 .any(|line| matches!(line.trim(), ".fallow" | ".fallow/"));
342 if already {
343 return;
344 }
345 let mut contents = existing;
346 if !contents.is_empty() && !contents.ends_with('\n') {
347 contents.push('\n');
348 }
349 contents.push_str(".fallow/\n");
350 let _ = fallow_config::atomic_write(&path, contents.as_bytes());
353}
354
355pub fn disable(root: &Path) -> bool {
358 let mut store = load(root);
359 let was_enabled = store.enabled;
360 store.enabled = false;
361 save(&store, root);
362 was_enabled
363}
364
365#[expect(
376 clippy::too_many_arguments,
377 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"
378)]
379pub fn record_audit_run(
380 root: &Path,
381 summary: &AuditSummary,
382 verdict: AuditVerdict,
383 gate: bool,
384 git_sha: Option<&str>,
385 version: &str,
386 timestamp: &str,
387 attribution: Option<&AttributionInput<'_>>,
388) {
389 let mut store = load(root);
390 if !store.enabled {
391 return;
392 }
393 store.schema_version = STORE_SCHEMA_VERSION;
395
396 let counts = ImpactCounts::from_summary(summary);
397 let verdict_str = verdict_label(verdict);
398
399 if store.first_recorded.is_none() {
400 store.first_recorded = Some(timestamp.to_owned());
401 }
402
403 apply_containment(&mut store, verdict, gate, git_sha, timestamp, &counts);
404
405 store.records.push(ImpactRecord {
406 timestamp: timestamp.to_owned(),
407 version: version.to_owned(),
408 git_sha: git_sha.map(ToOwned::to_owned),
409 verdict: verdict_str.to_owned(),
410 gate,
411 counts,
412 });
413 compact(&mut store);
414
415 if let Some(attribution) = attribution {
416 apply_attribution(&mut store, attribution, git_sha, timestamp);
417 }
418
419 save(&store, root);
420}
421
422pub fn record_combined_run(
432 root: &Path,
433 counts: ImpactCounts,
434 git_sha: Option<&str>,
435 version: &str,
436 timestamp: &str,
437 attribution: Option<&AttributionInput<'_>>,
438) {
439 let mut store = load(root);
440 if !store.enabled {
441 return;
442 }
443 store.schema_version = STORE_SCHEMA_VERSION;
444
445 if store.first_recorded.is_none() {
446 store.first_recorded = Some(timestamp.to_owned());
447 }
448
449 let verdict_str = if counts.total_issues == 0 {
450 "pass"
451 } else {
452 "warn"
453 };
454 store.project_records.push(ImpactRecord {
455 timestamp: timestamp.to_owned(),
456 version: version.to_owned(),
457 git_sha: git_sha.map(ToOwned::to_owned),
458 verdict: verdict_str.to_owned(),
459 gate: false,
460 counts,
461 });
462 if store.project_records.len() > MAX_RECORDS {
463 let overflow = store.project_records.len() - MAX_RECORDS;
464 store.project_records.drain(0..overflow);
465 }
466
467 if let Some(attribution) = attribution {
468 apply_attribution(&mut store, attribution, git_sha, timestamp);
469 }
470
471 save(&store, root);
472}
473
474fn apply_containment(
476 store: &mut ImpactStore,
477 verdict: AuditVerdict,
478 gate: bool,
479 git_sha: Option<&str>,
480 timestamp: &str,
481 counts: &ImpactCounts,
482) {
483 if !gate {
484 return;
485 }
486 if verdict == AuditVerdict::Fail {
487 if store.pending_containment.is_none() {
489 store.pending_containment = Some(PendingContainment {
490 blocked_at: timestamp.to_owned(),
491 git_sha: git_sha.map(ToOwned::to_owned),
492 blocked_counts: counts.clone(),
493 });
494 }
495 } else if let Some(pending) = store.pending_containment.take() {
496 store.containment.push(ContainmentEvent {
498 blocked_at: pending.blocked_at,
499 cleared_at: timestamp.to_owned(),
500 git_sha: pending.git_sha,
501 blocked_counts: pending.blocked_counts,
502 });
503 if store.containment.len() > MAX_CONTAINMENT {
504 let overflow = store.containment.len() - MAX_CONTAINMENT;
505 store.containment.drain(0..overflow);
506 }
507 }
508}
509
510fn compact(store: &mut ImpactStore) {
512 if store.records.len() > MAX_RECORDS {
513 let overflow = store.records.len() - MAX_RECORDS;
514 store.records.drain(0..overflow);
515 }
516}
517
518#[derive(Debug, Clone)]
522pub struct FindingInput {
523 pub path: PathBuf,
524 pub kind: &'static str,
525 pub symbol: Option<String>,
526}
527
528#[derive(Debug, Clone)]
531pub struct CloneInput {
532 pub fingerprint: String,
533 pub instance_paths: Vec<PathBuf>,
534}
535
536pub enum Scope<'a> {
548 ChangedFiles(&'a [PathBuf]),
550 WholeProject,
552}
553
554pub struct AttributionInput<'a> {
555 pub root: &'a Path,
556 pub scope: Scope<'a>,
557 pub findings: Vec<FindingInput>,
558 pub clones: Vec<CloneInput>,
559 pub suppressions: &'a [ActiveSuppression],
560}
561
562fn finding_id(kind: &str, rel_path: &str, symbol: Option<&str>) -> String {
565 fingerprint_hash(&[kind, rel_path, symbol.unwrap_or("")])
566}
567
568fn covered_by(present: &FxHashSet<String>, kind: &str) -> bool {
571 present.contains(BLANKET_SUPPRESSION) || present.contains(kind)
572}
573
574fn apply_attribution(
578 store: &mut ImpactStore,
579 input: &AttributionInput<'_>,
580 git_sha: Option<&str>,
581 timestamp: &str,
582) {
583 let root = input.root;
584 let changed: FxHashSet<String> = match input.scope {
585 Scope::ChangedFiles(files) => files.iter().map(|p| format_display_path(p, root)).collect(),
586 Scope::WholeProject => whole_project_scope(store, input, root),
587 };
588
589 let mut current_findings: FxHashMap<String, Vec<FrontierFinding>> = FxHashMap::default();
591 for f in &input.findings {
592 let rel = format_display_path(&f.path, root);
593 if !changed.contains(&rel) {
594 continue;
595 }
596 let id = finding_id(f.kind, &rel, f.symbol.as_deref());
597 current_findings
598 .entry(rel)
599 .or_default()
600 .push(FrontierFinding {
601 id,
602 kind: f.kind.to_owned(),
603 symbol: f.symbol.clone(),
604 });
605 }
606 let mut current_supps: FxHashMap<String, FxHashSet<String>> = FxHashMap::default();
607 for s in input.suppressions {
608 let rel = format_display_path(&s.path, root);
609 if !changed.contains(&rel) {
610 continue;
611 }
612 let key = s
613 .kind
614 .clone()
615 .unwrap_or_else(|| BLANKET_SUPPRESSION.to_owned());
616 current_supps.entry(rel).or_default().insert(key);
617 }
618
619 let mut appeared_move_keys: FxHashSet<String> = FxHashSet::default();
623 for (rel, findings) in ¤t_findings {
624 let prior_ids: FxHashSet<&str> = store
625 .frontier
626 .get(rel)
627 .map(|f| f.findings.iter().map(|x| x.id.as_str()).collect())
628 .unwrap_or_default();
629 for ff in findings {
630 if !prior_ids.contains(ff.id.as_str()) {
631 appeared_move_keys.insert(ff.move_key());
632 }
633 }
634 }
635
636 uncredit_cross_run_moves(store, &appeared_move_keys);
645
646 classify_file_disappearances(
647 store,
648 &changed,
649 ¤t_findings,
650 ¤t_supps,
651 &appeared_move_keys,
652 git_sha,
653 timestamp,
654 );
655 update_file_frontier(store, &changed, current_findings, current_supps);
656 classify_clone_disappearances(store, input, &changed, git_sha, timestamp);
657 prune_frontier(store, root);
658 bound_recent_resolved(store);
659}
660
661fn whole_project_scope(
669 store: &ImpactStore,
670 input: &AttributionInput<'_>,
671 root: &Path,
672) -> FxHashSet<String> {
673 let mut set: FxHashSet<String> = store.frontier.keys().cloned().collect();
674 for paths in store.clone_frontier.values() {
675 for p in paths {
676 set.insert(p.clone());
677 }
678 }
679 for f in &input.findings {
680 set.insert(format_display_path(&f.path, root));
681 }
682 for c in &input.clones {
683 for p in &c.instance_paths {
684 set.insert(format_display_path(p, root));
685 }
686 }
687 set
688}
689
690fn classify_file_disappearances(
692 store: &mut ImpactStore,
693 changed: &FxHashSet<String>,
694 current_findings: &FxHashMap<String, Vec<FrontierFinding>>,
695 current_supps: &FxHashMap<String, FxHashSet<String>>,
696 appeared_move_keys: &FxHashSet<String>,
697 git_sha: Option<&str>,
698 timestamp: &str,
699) {
700 let empty_supps = FxHashSet::default();
701 for rel in changed {
702 let Some(prior) = store.frontier.get(rel) else {
703 continue;
704 };
705 let now_ids: FxHashSet<&str> = current_findings
706 .get(rel)
707 .map(|fs| fs.iter().map(|f| f.id.as_str()).collect())
708 .unwrap_or_default();
709 let now_supps = current_supps.get(rel).unwrap_or(&empty_supps);
710 let prior_supps: FxHashSet<&str> = prior.suppressions.iter().map(String::as_str).collect();
711 let new_supp_kinds: FxHashSet<String> = now_supps
713 .iter()
714 .filter(|k| !prior_supps.contains(k.as_str()))
715 .cloned()
716 .collect();
717
718 let mut resolved = Vec::new();
719 let mut suppressed = 0usize;
720 for pf in &prior.findings {
721 if now_ids.contains(pf.id.as_str()) {
722 continue; }
724 if appeared_move_keys.contains(&pf.move_key()) {
725 continue; }
727 if covered_by(&new_supp_kinds, &pf.kind) {
728 suppressed += 1; } else {
730 resolved.push(pf.clone());
731 }
732 }
733 store.suppressed_total += suppressed;
734 for pf in resolved {
735 store.resolved_total += 1;
736 store.recent_resolved.push(ResolutionEvent {
737 kind: pf.kind,
738 path: rel.clone(),
739 symbol: pf.symbol,
740 git_sha: git_sha.map(ToOwned::to_owned),
741 timestamp: timestamp.to_owned(),
742 });
743 }
744 }
745}
746
747fn update_file_frontier(
750 store: &mut ImpactStore,
751 changed: &FxHashSet<String>,
752 mut current_findings: FxHashMap<String, Vec<FrontierFinding>>,
753 mut current_supps: FxHashMap<String, FxHashSet<String>>,
754) {
755 for rel in changed {
756 let findings = current_findings.remove(rel).unwrap_or_default();
757 let mut suppressions: Vec<String> = current_supps
758 .remove(rel)
759 .unwrap_or_default()
760 .into_iter()
761 .collect();
762 suppressions.sort_unstable();
763 if findings.is_empty() && suppressions.is_empty() {
764 store.frontier.remove(rel);
765 } else {
766 store.frontier.insert(
767 rel.clone(),
768 FileFrontier {
769 findings,
770 suppressions,
771 },
772 );
773 }
774 }
775}
776
777fn classify_clone_disappearances(
783 store: &mut ImpactStore,
784 input: &AttributionInput<'_>,
785 changed: &FxHashSet<String>,
786 git_sha: Option<&str>,
787 timestamp: &str,
788) {
789 let root = input.root;
790 let mut current: FxHashMap<String, Vec<String>> = FxHashMap::default();
792 for c in &input.clones {
793 let mut paths: Vec<String> = c
794 .instance_paths
795 .iter()
796 .map(|p| format_display_path(p, root))
797 .collect();
798 paths.sort_unstable();
799 paths.dedup();
800 if paths.iter().any(|p| changed.contains(p)) {
801 current.insert(c.fingerprint.clone(), paths);
802 }
803 }
804
805 let dup_suppressed = |paths: &[String]| -> bool {
811 paths.iter().any(|p| {
812 changed.contains(p)
813 && store.frontier.get(p).is_some_and(|f| {
814 f.suppressions
815 .iter()
816 .any(|k| k == CODE_DUPLICATION_KIND || k == BLANKET_SUPPRESSION)
817 })
818 })
819 };
820
821 let still_duplicated: FxHashSet<&String> = current.values().flatten().collect();
829
830 let disappeared: Vec<(String, Vec<String>)> = store
833 .clone_frontier
834 .iter()
835 .filter(|(fp, paths)| {
836 paths.iter().any(|p| changed.contains(p)) && !current.contains_key(*fp)
837 })
838 .map(|(fp, paths)| (fp.clone(), paths.clone()))
839 .collect();
840
841 for (fp, paths) in disappeared {
842 store.clone_frontier.remove(&fp);
843 if paths.iter().any(|p| still_duplicated.contains(p)) {
844 continue;
848 }
849 if dup_suppressed(&paths) {
850 store.suppressed_total += 1;
851 } else {
852 store.resolved_total += 1;
853 let path = paths.first().cloned().unwrap_or_default();
854 store.recent_resolved.push(ResolutionEvent {
855 kind: CODE_DUPLICATION_KIND.to_owned(),
856 path,
857 symbol: None,
858 git_sha: git_sha.map(ToOwned::to_owned),
859 timestamp: timestamp.to_owned(),
860 });
861 }
862 }
863
864 for (fp, paths) in current {
866 store.clone_frontier.insert(fp, paths);
867 }
868}
869
870fn prune_frontier(store: &mut ImpactStore, root: &Path) {
873 store.frontier.retain(|rel, _| root.join(rel).exists());
874 store
875 .clone_frontier
876 .retain(|_, paths| paths.iter().any(|p| root.join(p).exists()));
877}
878
879fn bound_recent_resolved(store: &mut ImpactStore) {
881 if store.recent_resolved.len() > MAX_RECENT_RESOLVED {
882 let overflow = store.recent_resolved.len() - MAX_RECENT_RESOLVED;
883 store.recent_resolved.drain(0..overflow);
884 }
885}
886
887fn event_move_key(ev: &ResolutionEvent) -> Option<String> {
893 ev.symbol
894 .as_ref()
895 .map(|symbol| format!("{}{ID_SEP}{symbol}", ev.kind))
896}
897
898fn uncredit_cross_run_moves(store: &mut ImpactStore, appeared_move_keys: &FxHashSet<String>) {
903 if appeared_move_keys.is_empty() {
904 return;
905 }
906 let mut uncredited = 0usize;
907 store.recent_resolved.retain(|ev| match event_move_key(ev) {
908 Some(mk) if appeared_move_keys.contains(&mk) => {
909 uncredited += 1;
910 false
911 }
912 _ => true,
913 });
914 store.resolved_total = store.resolved_total.saturating_sub(uncredited);
915}
916
917#[must_use]
925pub fn collect_dead_code_findings(results: &AnalysisResults) -> Vec<FindingInput> {
926 let mut out = Vec::new();
927 let mut push = |path: &Path, kind: &'static str, symbol: Option<String>| {
928 out.push(FindingInput {
929 path: path.to_path_buf(),
930 kind,
931 symbol,
932 });
933 };
934 for f in &results.unused_files {
935 push(&f.file.path, "unused-file", None);
936 }
937 for f in &results.unused_exports {
938 push(
939 &f.export.path,
940 "unused-export",
941 Some(f.export.export_name.clone()),
942 );
943 }
944 for f in &results.unused_types {
945 push(
946 &f.export.path,
947 "unused-type",
948 Some(f.export.export_name.clone()),
949 );
950 }
951 for f in &results.private_type_leaks {
952 push(
953 &f.leak.path,
954 "private-type-leak",
955 Some(format!(
956 "{}{ID_SEP}{}",
957 f.leak.export_name, f.leak.type_name
958 )),
959 );
960 }
961 for f in &results.unused_enum_members {
962 push(
963 &f.member.path,
964 "unused-enum-member",
965 Some(format!(
966 "{}{ID_SEP}{}",
967 f.member.parent_name, f.member.member_name
968 )),
969 );
970 }
971 for f in &results.unused_class_members {
972 push(
973 &f.member.path,
974 "unused-class-member",
975 Some(format!(
976 "{}{ID_SEP}{}",
977 f.member.parent_name, f.member.member_name
978 )),
979 );
980 }
981 for f in &results.unresolved_imports {
982 push(
983 &f.import.path,
984 "unresolved-import",
985 Some(f.import.specifier.clone()),
986 );
987 }
988 for f in &results.boundary_violations {
989 let to_path = f.violation.to_path.to_string_lossy().replace('\\', "/");
992 push(
993 &f.violation.from_path,
994 "boundary-violation",
995 Some(format!("{to_path}{ID_SEP}{}", f.violation.import_specifier)),
996 );
997 }
998 for f in &results.unused_dependencies {
999 push(
1000 &f.dep.path,
1001 "unused-dependency",
1002 Some(f.dep.package_name.clone()),
1003 );
1004 }
1005 for f in &results.unused_dev_dependencies {
1006 push(
1007 &f.dep.path,
1008 "unused-dev-dependency",
1009 Some(f.dep.package_name.clone()),
1010 );
1011 }
1012 for f in &results.unused_optional_dependencies {
1013 push(
1014 &f.dep.path,
1015 "unused-optional-dependency",
1016 Some(f.dep.package_name.clone()),
1017 );
1018 }
1019 for f in &results.type_only_dependencies {
1020 push(
1021 &f.dep.path,
1022 "type-only-dependency",
1023 Some(f.dep.package_name.clone()),
1024 );
1025 }
1026 for f in &results.test_only_dependencies {
1027 push(
1028 &f.dep.path,
1029 "test-only-dependency",
1030 Some(f.dep.package_name.clone()),
1031 );
1032 }
1033 for f in &results.unused_catalog_entries {
1034 push(
1035 &f.entry.path,
1036 "unused-catalog-entry",
1037 Some(format!(
1038 "{}{ID_SEP}{}",
1039 f.entry.catalog_name, f.entry.entry_name
1040 )),
1041 );
1042 }
1043 for f in &results.empty_catalog_groups {
1044 push(
1045 &f.group.path,
1046 "empty-catalog-group",
1047 Some(f.group.catalog_name.clone()),
1048 );
1049 }
1050 for f in &results.unresolved_catalog_references {
1051 push(
1052 &f.reference.path,
1053 "unresolved-catalog-reference",
1054 Some(format!(
1055 "{}{ID_SEP}{}",
1056 f.reference.catalog_name, f.reference.entry_name
1057 )),
1058 );
1059 }
1060 for f in &results.unused_dependency_overrides {
1061 push(
1062 &f.entry.path,
1063 "unused-dependency-override",
1064 Some(f.entry.raw_key.clone()),
1065 );
1066 }
1067 for f in &results.misconfigured_dependency_overrides {
1068 push(
1069 &f.entry.path,
1070 "misconfigured-dependency-override",
1071 Some(f.entry.raw_key.clone()),
1072 );
1073 }
1074 out
1075}
1076
1077#[must_use]
1081pub fn collect_complexity_findings(
1082 report: &crate::health_types::HealthReport,
1083) -> Vec<FindingInput> {
1084 report
1085 .findings
1086 .iter()
1087 .map(|f| FindingInput {
1088 path: f.path.clone(),
1089 kind: "complexity",
1090 symbol: Some(f.name.clone()),
1091 })
1092 .collect()
1093}
1094
1095#[must_use]
1099pub fn collect_clone_findings(
1100 report: &fallow_core::duplicates::DuplicationReport,
1101) -> Vec<CloneInput> {
1102 report
1103 .clone_groups
1104 .iter()
1105 .map(|g| CloneInput {
1106 fingerprint: fallow_core::duplicates::clone_fingerprint(&g.instances),
1107 instance_paths: g.instances.iter().map(|i| i.file.clone()).collect(),
1108 })
1109 .collect()
1110}
1111
1112const fn verdict_label(verdict: AuditVerdict) -> &'static str {
1113 match verdict {
1114 AuditVerdict::Pass => "pass",
1115 AuditVerdict::Warn => "warn",
1116 AuditVerdict::Fail => "fail",
1117 }
1118}
1119
1120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1123#[serde(rename_all = "snake_case")]
1124pub enum ImpactTrendDirection {
1125 Improving,
1127 Declining,
1129 Stable,
1131}
1132
1133#[derive(Debug, Clone, Serialize)]
1135#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1136pub struct TrendSummary {
1137 pub direction: ImpactTrendDirection,
1138 pub total_delta: i64,
1140 pub previous_total: usize,
1141 pub current_total: usize,
1142}
1143
1144fn direction_for(delta: i64) -> ImpactTrendDirection {
1145 if delta < -TREND_TOLERANCE {
1146 ImpactTrendDirection::Improving
1147 } else if delta > TREND_TOLERANCE {
1148 ImpactTrendDirection::Declining
1149 } else {
1150 ImpactTrendDirection::Stable
1151 }
1152}
1153
1154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1161#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1162pub enum ImpactReportSchemaVersion {
1163 #[serde(rename = "1")]
1165 V1,
1166}
1167
1168#[derive(Debug, Clone, Serialize)]
1170#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1171#[cfg_attr(feature = "schema", schemars(title = "fallow impact --format json"))]
1172pub struct ImpactReport {
1173 pub schema_version: ImpactReportSchemaVersion,
1177 pub enabled: bool,
1178 pub record_count: usize,
1179 #[serde(default, skip_serializing_if = "Option::is_none")]
1180 pub first_recorded: Option<String>,
1181 #[serde(default, skip_serializing_if = "Option::is_none")]
1188 pub latest_git_sha: Option<String>,
1189 #[serde(default, skip_serializing_if = "Option::is_none")]
1194 pub surfacing: Option<ImpactCounts>,
1195 #[serde(default, skip_serializing_if = "Option::is_none")]
1197 pub trend: Option<TrendSummary>,
1198 #[serde(default, skip_serializing_if = "Option::is_none")]
1203 pub project_surfacing: Option<ImpactCounts>,
1204 #[serde(default, skip_serializing_if = "Option::is_none")]
1208 pub project_trend: Option<TrendSummary>,
1209 pub containment_count: usize,
1210 pub recent_containment: Vec<ContainmentEvent>,
1212 pub resolved_total: usize,
1215 pub suppressed_total: usize,
1218 pub recent_resolved: Vec<ResolutionEvent>,
1220 pub attribution_active: bool,
1224}
1225
1226fn trend_for(records: &[ImpactRecord]) -> Option<TrendSummary> {
1232 if records.len() < 2 {
1233 return None;
1234 }
1235 let current = &records[records.len() - 1];
1236 let previous = &records[records.len() - 2];
1237 let current_total = current.counts.total_issues;
1238 let previous_total = previous.counts.total_issues;
1239 let total_delta = current_total as i64 - previous_total as i64;
1240 Some(TrendSummary {
1241 direction: direction_for(total_delta),
1242 total_delta,
1243 previous_total,
1244 current_total,
1245 })
1246}
1247
1248pub fn build_report(store: &ImpactStore) -> ImpactReport {
1249 let surfacing = store.records.last().map(|r| r.counts.clone());
1250 let trend = trend_for(&store.records);
1251 let project_surfacing = store.project_records.last().map(|r| r.counts.clone());
1252 let project_trend = trend_for(&store.project_records);
1253
1254 let recent_containment = store
1255 .containment
1256 .iter()
1257 .rev()
1258 .take(5)
1259 .rev()
1260 .cloned()
1261 .collect();
1262
1263 let latest_git_sha = store.records.last().and_then(|r| r.git_sha.clone());
1264
1265 let recent_resolved = store
1266 .recent_resolved
1267 .iter()
1268 .rev()
1269 .take(5)
1270 .rev()
1271 .cloned()
1272 .collect();
1273 let attribution_active = !store.frontier.is_empty()
1276 || !store.clone_frontier.is_empty()
1277 || store.resolved_total > 0
1278 || store.suppressed_total > 0;
1279
1280 ImpactReport {
1281 schema_version: ImpactReportSchemaVersion::V1,
1282 enabled: store.enabled,
1283 record_count: store.records.len(),
1284 first_recorded: store.first_recorded.clone(),
1285 latest_git_sha,
1286 surfacing,
1287 trend,
1288 project_surfacing,
1289 project_trend,
1290 containment_count: store.containment.len(),
1291 recent_containment,
1292 resolved_total: store.resolved_total,
1293 suppressed_total: store.suppressed_total,
1294 recent_resolved,
1295 attribution_active,
1296 }
1297}
1298
1299#[expect(
1305 clippy::format_push_string,
1306 reason = "small report renderer; readability over avoiding the extra allocation"
1307)]
1308fn render_project_section(out: &mut String, report: &ImpactReport) {
1309 let Some(s) = &report.project_surfacing else {
1310 return;
1311 };
1312 out.push_str(&format!(
1313 " WHOLE PROJECT (whole-repo context, not a to-do)\n {} issue{} across the whole project at your last full `fallow` run\n",
1314 s.total_issues,
1315 plural(s.total_issues),
1316 ));
1317 if let Some(t) = &report.project_trend {
1318 let arrow = trend_arrow(t.direction);
1319 out.push_str(&format!(
1320 " {} -> {} ({}) across your last two full runs (comparable over time)\n",
1321 t.previous_total, t.current_total, arrow,
1322 ));
1323 } else {
1324 out.push_str(" project trend starts after your next full `fallow` run\n");
1325 }
1326 out.push_str(" advances only on your local full `fallow` runs, not CI\n\n");
1327}
1328
1329#[expect(
1331 clippy::format_push_string,
1332 reason = "small report renderer; readability over avoiding the extra allocation"
1333)]
1334pub fn render_human(report: &ImpactReport) -> String {
1335 let mut out = String::new();
1336 out.push_str("FALLOW IMPACT\n\n");
1337
1338 if !report.enabled {
1339 out.push_str(
1340 "Impact tracking is off. Enable it with `fallow impact enable`, then\n\
1341 let your pre-commit gate run a few times to build history.\n",
1342 );
1343 return out;
1344 }
1345
1346 if report.record_count == 0 && report.project_surfacing.is_none() {
1347 out.push_str(
1348 "Tracking enabled. No history yet: check back after your next few\n\
1349 commits (Impact records each `fallow audit` / pre-commit gate run,\n\
1350 and each full `fallow` run for the whole-project view).\n",
1351 );
1352 return out;
1353 }
1354
1355 if let Some(s) = &report.surfacing {
1356 out.push_str(&format!(
1357 " LATEST RUN (changed files, act on these now)\n {} issue{} flagged in your last `fallow audit` run\n",
1358 s.total_issues,
1359 plural(s.total_issues),
1360 ));
1361 out.push_str(&format!(
1362 " dead code {} · complexity {} · duplication {}\n\n",
1363 s.dead_code, s.complexity, s.duplication,
1364 ));
1365 }
1366
1367 if let Some(t) = &report.trend {
1368 let arrow = trend_arrow(t.direction);
1369 out.push_str(&format!(
1370 " 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",
1371 t.previous_total, t.current_total, arrow,
1372 ));
1373 }
1374
1375 render_project_section(&mut out, report);
1376
1377 out.push_str(&format!(
1378 " CONTAINED AT COMMIT\n {} time{} fallow blocked a commit until it was fixed\n",
1379 report.containment_count,
1380 plural(report.containment_count),
1381 ));
1382
1383 if report.resolved_total > 0 {
1386 out.push_str(&format!(
1387 "\n RESOLVED\n {} finding{} you cleared since fallow started tracking\n",
1388 report.resolved_total,
1389 plural(report.resolved_total),
1390 ));
1391 for ev in &report.recent_resolved {
1392 match &ev.symbol {
1393 Some(symbol) => {
1394 out.push_str(&format!(" {} {} in {}\n", ev.kind, symbol, ev.path));
1395 }
1396 None => out.push_str(&format!(" {} in {}\n", ev.kind, ev.path)),
1397 }
1398 }
1399 } else if report.attribution_active {
1400 out.push_str(
1401 "\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",
1402 );
1403 } else {
1404 out.push_str("\n RESOLVED\n resolution tracking starts from your next gate run\n");
1405 }
1406
1407 if report.suppressed_total > 0 {
1409 out.push_str(&format!(
1410 " {} finding{} you marked intentional (fallow-ignore), not counted as resolved\n",
1411 report.suppressed_total,
1412 plural(report.suppressed_total),
1413 ));
1414 }
1415
1416 out.push('\n');
1417 let since = report
1418 .first_recorded
1419 .as_deref()
1420 .map_or("the first run", date_only);
1421 if report.record_count > 0 {
1422 out.push_str(&format!(
1423 "Based on {} recorded audit run{} since {}. Local-only; never uploaded.\n\
1424 Changed-file scope: each audit run only sees files differing from your base.\n",
1425 report.record_count,
1426 plural(report.record_count),
1427 since,
1428 ));
1429 } else {
1430 out.push_str(&format!(
1431 "Tracking since {since}. Local-only; never uploaded.\n",
1432 ));
1433 }
1434 out.push_str(
1435 "Resolution tracking is a local-developer signal: it accrues where\n\
1436 .fallow/impact.json persists across runs, not in ephemeral CI runners.\n",
1437 );
1438 out
1439}
1440
1441pub fn render_json(report: &ImpactReport) -> String {
1443 serde_json::to_string_pretty(report)
1444 .unwrap_or_else(|_| "{\"error\":\"failed to serialize impact report\"}".to_owned())
1445}
1446
1447#[expect(
1451 clippy::format_push_string,
1452 reason = "small report renderer; readability over avoiding the extra allocation"
1453)]
1454fn render_project_markdown(out: &mut String, report: &ImpactReport) {
1455 let Some(s) = &report.project_surfacing else {
1456 return;
1457 };
1458 out.push_str(&format!(
1459 "- **Whole project (whole-repo context, last full `fallow` run):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
1460 s.total_issues,
1461 plural(s.total_issues),
1462 s.dead_code,
1463 s.complexity,
1464 s.duplication,
1465 ));
1466 if let Some(t) = &report.project_trend {
1467 let arrow = trend_arrow(t.direction);
1468 out.push_str(&format!(
1469 "- **Project trend (whole project, last two full runs):** {} -> {} ({})\n",
1470 t.previous_total, t.current_total, arrow,
1471 ));
1472 }
1473}
1474
1475#[expect(
1477 clippy::format_push_string,
1478 reason = "small report renderer; readability over avoiding the extra allocation"
1479)]
1480pub fn render_markdown(report: &ImpactReport) -> String {
1481 let mut out = String::new();
1482 out.push_str("## Fallow impact\n\n");
1483
1484 if !report.enabled {
1485 out.push_str("Impact tracking is off. Run `fallow impact enable` to start.\n");
1486 return out;
1487 }
1488 if report.record_count == 0 && report.project_surfacing.is_none() {
1489 out.push_str("Tracking enabled. No history yet; check back after a few commits.\n");
1490 return out;
1491 }
1492
1493 if let Some(s) = &report.surfacing {
1494 out.push_str(&format!(
1495 "- **Latest run (changed files):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
1496 s.total_issues,
1497 plural(s.total_issues),
1498 s.dead_code,
1499 s.complexity,
1500 s.duplication,
1501 ));
1502 }
1503 if let Some(t) = &report.trend {
1504 out.push_str(&format!(
1505 "- **Trend (changed-file scope, last two runs):** {} -> {} ({})\n",
1506 t.previous_total,
1507 t.current_total,
1508 trend_arrow(t.direction),
1509 ));
1510 }
1511 render_project_markdown(&mut out, report);
1512 out.push_str(&format!(
1513 "- **Contained at commit:** {} time{}\n",
1514 report.containment_count,
1515 plural(report.containment_count),
1516 ));
1517 if report.resolved_total > 0 {
1520 out.push_str(&format!(
1521 "- **Resolved:** {} finding{} cleared since tracking started\n",
1522 report.resolved_total,
1523 plural(report.resolved_total),
1524 ));
1525 } else if report.attribution_active {
1526 out.push_str("- **Resolved:** none yet; tracking active\n");
1527 } else {
1528 out.push_str("- **Resolved:** resolution tracking starts from your next gate run\n");
1529 }
1530 if report.suppressed_total > 0 {
1531 out.push_str(&format!(
1532 "- **Marked intentional:** {} finding{} (`fallow-ignore`), not counted as resolved\n",
1533 report.suppressed_total,
1534 plural(report.suppressed_total),
1535 ));
1536 }
1537 let since = report
1538 .first_recorded
1539 .as_deref()
1540 .map_or("the first run", date_only);
1541 if report.record_count > 0 {
1542 out.push_str(&format!(
1543 "\n_Based on {} recorded audit run{} since {}. Local-only; resolution is a local-developer signal._\n",
1544 report.record_count,
1545 plural(report.record_count),
1546 since,
1547 ));
1548 } else {
1549 out.push_str(&format!(
1550 "\n_Tracking since {since}. Local-only; resolution is a local-developer signal._\n",
1551 ));
1552 }
1553 out
1554}
1555
1556const fn plural(n: usize) -> &'static str {
1557 if n == 1 { "" } else { "s" }
1558}
1559
1560fn date_only(ts: &str) -> &str {
1566 ts.split_once('T').map_or(ts, |(date, _)| date)
1567}
1568
1569const fn trend_arrow(direction: ImpactTrendDirection) -> &'static str {
1573 match direction {
1574 ImpactTrendDirection::Improving => "down",
1575 ImpactTrendDirection::Declining => "up",
1576 ImpactTrendDirection::Stable => "flat",
1577 }
1578}
1579
1580#[cfg(test)]
1581mod tests {
1582 use super::*;
1583
1584 fn summary(dead: usize, complexity: usize, dupes: usize) -> AuditSummary {
1585 AuditSummary {
1586 dead_code_issues: dead,
1587 dead_code_has_errors: dead > 0,
1588 complexity_findings: complexity,
1589 max_cyclomatic: None,
1590 duplication_clone_groups: dupes,
1591 }
1592 }
1593
1594 fn record_v1(
1596 root: &Path,
1597 summary: &AuditSummary,
1598 verdict: AuditVerdict,
1599 gate: bool,
1600 git_sha: Option<&str>,
1601 version: &str,
1602 timestamp: &str,
1603 ) {
1604 record_audit_run(
1605 root, summary, verdict, gate, git_sha, version, timestamp, None,
1606 );
1607 }
1608
1609 fn touch(root: &Path, rel: &str) -> PathBuf {
1614 let p = root.join(rel);
1615 if let Some(parent) = p.parent() {
1616 std::fs::create_dir_all(parent).unwrap();
1617 }
1618 std::fs::write(&p, b"x").unwrap();
1619 p
1620 }
1621
1622 fn fi(path: &Path, kind: &'static str, symbol: &str) -> FindingInput {
1623 FindingInput {
1624 path: path.to_path_buf(),
1625 kind,
1626 symbol: Some(symbol.to_owned()),
1627 }
1628 }
1629
1630 fn supp(path: &Path, kind: &str) -> ActiveSuppression {
1631 ActiveSuppression {
1632 path: path.to_path_buf(),
1633 kind: Some(kind.to_owned()),
1634 is_file_level: false,
1635 }
1636 }
1637
1638 fn run(
1640 root: &Path,
1641 changed: &[&Path],
1642 findings: Vec<FindingInput>,
1643 clones: Vec<CloneInput>,
1644 supps: &[ActiveSuppression],
1645 ts: &str,
1646 ) {
1647 let changed_files: Vec<PathBuf> = changed.iter().map(|p| p.to_path_buf()).collect();
1648 let input = AttributionInput {
1649 root,
1650 scope: Scope::ChangedFiles(&changed_files),
1651 findings,
1652 clones,
1653 suppressions: supps,
1654 };
1655 record_audit_run(
1656 root,
1657 &summary(0, 0, 0),
1658 AuditVerdict::Pass,
1659 true,
1660 Some("sha"),
1661 "2.0.0",
1662 ts,
1663 Some(&input),
1664 );
1665 }
1666
1667 #[test]
1668 fn disabled_store_does_not_record() {
1669 let dir = tempfile::tempdir().unwrap();
1670 let root = dir.path();
1671 record_v1(
1673 root,
1674 &summary(3, 1, 0),
1675 AuditVerdict::Fail,
1676 true,
1677 Some("abc1234"),
1678 "2.0.0",
1679 "2026-05-29T10:00:00Z",
1680 );
1681 let store = load(root);
1682 assert!(store.records.is_empty());
1683 assert!(!store.enabled);
1684 }
1685
1686 #[test]
1687 fn enable_then_record_accrues_history() {
1688 let dir = tempfile::tempdir().unwrap();
1689 let root = dir.path();
1690 assert!(enable(root));
1691 assert!(!enable(root)); record_v1(
1693 root,
1694 &summary(2, 1, 0),
1695 AuditVerdict::Warn,
1696 false,
1697 None,
1698 "2.0.0",
1699 "2026-05-29T10:00:00Z",
1700 );
1701 let store = load(root);
1702 assert_eq!(store.records.len(), 1);
1703 assert_eq!(store.records[0].counts.total_issues, 3);
1704 assert_eq!(
1705 store.first_recorded.as_deref(),
1706 Some("2026-05-29T10:00:00Z")
1707 );
1708 }
1709
1710 #[test]
1711 fn enable_gitignores_the_store() {
1712 let dir = tempfile::tempdir().unwrap();
1713 let root = dir.path();
1714 enable(root);
1715 let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1716 assert!(
1717 gitignore.lines().any(|l| l.trim() == ".fallow/"),
1718 "enable must gitignore .fallow/, got: {gitignore:?}"
1719 );
1720 enable(root);
1723 let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1724 assert_eq!(
1725 gitignore.lines().filter(|l| l.trim() == ".fallow/").count(),
1726 1,
1727 "re-enabling must not duplicate the .fallow/ entry"
1728 );
1729 }
1730
1731 #[test]
1732 fn single_record_yields_no_trend_no_spike() {
1733 let mut store = ImpactStore {
1734 enabled: true,
1735 ..Default::default()
1736 };
1737 store.records.push(ImpactRecord {
1738 timestamp: "t0".into(),
1739 version: "2.0.0".into(),
1740 git_sha: None,
1741 verdict: "warn".into(),
1742 gate: false,
1743 counts: ImpactCounts {
1744 total_issues: 5,
1745 dead_code: 5,
1746 complexity: 0,
1747 duplication: 0,
1748 },
1749 });
1750 let report = build_report(&store);
1751 assert!(report.trend.is_none());
1754 assert_eq!(report.surfacing.unwrap().total_issues, 5);
1755 }
1756
1757 #[test]
1758 fn empty_store_report_is_first_run() {
1759 let store = ImpactStore::default();
1760 let report = build_report(&store);
1761 assert_eq!(report.record_count, 0);
1762 assert!(report.trend.is_none());
1763 assert!(report.surfacing.is_none());
1764 let human = render_human(&report);
1765 assert!(human.contains("off")); }
1767
1768 #[test]
1769 fn enabled_empty_store_shows_check_back() {
1770 let store = ImpactStore {
1771 enabled: true,
1772 ..Default::default()
1773 };
1774 let report = build_report(&store);
1775 let human = render_human(&report);
1776 assert!(human.contains("No history yet"));
1777 assert!(!human.contains("0 issues"));
1779 }
1780
1781 #[test]
1782 fn trend_improving_when_issues_drop() {
1783 let mut store = ImpactStore {
1784 enabled: true,
1785 ..Default::default()
1786 };
1787 for total in [8usize, 3usize] {
1788 store.records.push(ImpactRecord {
1789 timestamp: format!("t{total}"),
1790 version: "2.0.0".into(),
1791 git_sha: None,
1792 verdict: "warn".into(),
1793 gate: false,
1794 counts: ImpactCounts {
1795 total_issues: total,
1796 dead_code: total,
1797 complexity: 0,
1798 duplication: 0,
1799 },
1800 });
1801 }
1802 let report = build_report(&store);
1803 let trend = report.trend.unwrap();
1804 assert_eq!(trend.direction, ImpactTrendDirection::Improving);
1805 assert_eq!(trend.total_delta, -5);
1806 }
1807
1808 #[test]
1809 fn containment_blocked_then_cleared_records_one_event() {
1810 let dir = tempfile::tempdir().unwrap();
1811 let root = dir.path();
1812 enable(root);
1813 record_v1(
1815 root,
1816 &summary(2, 0, 0),
1817 AuditVerdict::Fail,
1818 true,
1819 Some("sha1"),
1820 "2.0.0",
1821 "t0",
1822 );
1823 let store = load(root);
1824 assert!(store.pending_containment.is_some());
1825 assert!(store.containment.is_empty());
1826
1827 record_v1(
1829 root,
1830 &summary(0, 0, 0),
1831 AuditVerdict::Pass,
1832 true,
1833 Some("sha2"),
1834 "2.0.0",
1835 "t1",
1836 );
1837 let store = load(root);
1838 assert!(store.pending_containment.is_none());
1839 assert_eq!(store.containment.len(), 1);
1840 assert_eq!(store.containment[0].blocked_at, "t0");
1841 assert_eq!(store.containment[0].cleared_at, "t1");
1842 }
1843
1844 #[test]
1845 fn non_gate_run_never_creates_containment() {
1846 let dir = tempfile::tempdir().unwrap();
1847 let root = dir.path();
1848 enable(root);
1849 record_v1(
1851 root,
1852 &summary(2, 0, 0),
1853 AuditVerdict::Fail,
1854 false,
1855 None,
1856 "2.0.0",
1857 "t0",
1858 );
1859 let store = load(root);
1860 assert!(store.pending_containment.is_none());
1861 assert!(store.containment.is_empty());
1862 }
1863
1864 #[test]
1865 fn corrupt_store_loads_as_default_no_panic() {
1866 let dir = tempfile::tempdir().unwrap();
1867 let root = dir.path();
1868 std::fs::create_dir_all(root.join(".fallow")).unwrap();
1869 std::fs::write(store_path(root), b"{ not valid json ][").unwrap();
1870 let store = load(root);
1872 assert!(!store.enabled);
1873 assert!(store.records.is_empty());
1874 record_v1(
1876 root,
1877 &summary(1, 0, 0),
1878 AuditVerdict::Fail,
1879 true,
1880 None,
1881 "2.0.0",
1882 "t0",
1883 );
1884 }
1885
1886 #[test]
1887 fn records_are_bounded() {
1888 let mut store = ImpactStore {
1889 enabled: true,
1890 ..Default::default()
1891 };
1892 for i in 0..(MAX_RECORDS + 50) {
1893 store.records.push(ImpactRecord {
1894 timestamp: format!("t{i}"),
1895 version: "2.0.0".into(),
1896 git_sha: None,
1897 verdict: "pass".into(),
1898 gate: false,
1899 counts: ImpactCounts::default(),
1900 });
1901 }
1902 compact(&mut store);
1903 assert_eq!(store.records.len(), MAX_RECORDS);
1904 assert_eq!(store.records[0].timestamp, "t50");
1906 }
1907
1908 #[test]
1909 fn report_always_carries_schema_version() {
1910 let empty = build_report(&ImpactStore::default());
1913 assert_eq!(empty.schema_version, ImpactReportSchemaVersion::V1);
1914 let json = render_json(&empty);
1915 assert!(
1916 json.contains("\"schema_version\": \"1\""),
1917 "schema_version must be present (as the \"1\" const) even when disabled: {json}"
1918 );
1919
1920 let mut store = ImpactStore {
1921 enabled: true,
1922 ..Default::default()
1923 };
1924 store.records.push(ImpactRecord {
1925 timestamp: "2026-05-29T10:00:00Z".into(),
1926 version: "2.0.0".into(),
1927 git_sha: None,
1928 verdict: "pass".into(),
1929 gate: false,
1930 counts: ImpactCounts::default(),
1931 });
1932 assert_eq!(
1933 build_report(&store).schema_version,
1934 ImpactReportSchemaVersion::V1
1935 );
1936 }
1937
1938 #[test]
1939 fn date_only_trims_iso_timestamp() {
1940 assert_eq!(date_only("2026-05-29T18:15:23Z"), "2026-05-29");
1941 assert_eq!(date_only("2026-05-29"), "2026-05-29");
1943 assert_eq!(date_only("the first run"), "the first run");
1944 }
1945
1946 #[test]
1947 fn human_footer_shows_date_only() {
1948 let mut store = ImpactStore {
1949 enabled: true,
1950 ..Default::default()
1951 };
1952 store.first_recorded = Some("2026-05-29T18:15:23Z".into());
1953 store.records.push(ImpactRecord {
1954 timestamp: "2026-05-29T18:15:23Z".into(),
1955 version: "2.0.0".into(),
1956 git_sha: None,
1957 verdict: "pass".into(),
1958 gate: false,
1959 counts: ImpactCounts::default(),
1960 });
1961 let report = build_report(&store);
1962 let human = render_human(&report);
1963 assert!(
1964 human.contains("since 2026-05-29.") && !human.contains("18:15:23"),
1965 "human footer must show date-only: {human}"
1966 );
1967 let md = render_markdown(&report);
1968 assert!(
1969 md.contains("since 2026-05-29.") && !md.contains("18:15:23"),
1970 "markdown footer must show date-only: {md}"
1971 );
1972 }
1973
1974 #[test]
1975 fn future_schema_version_store_loads_without_panic_or_loss() {
1976 let dir = tempfile::tempdir().unwrap();
1977 let root = dir.path();
1978 std::fs::create_dir_all(root.join(".fallow")).unwrap();
1979 let future = format!(
1982 "{{\"schema_version\":{},\"enabled\":true,\"records\":[],\"containment\":[]}}",
1983 STORE_SCHEMA_VERSION + 1
1984 );
1985 std::fs::write(store_path(root), future).unwrap();
1986 let store = load(root);
1987 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION + 1);
1988 assert!(
1989 store.enabled,
1990 "future-version store must not degrade to default"
1991 );
1992 }
1993
1994 #[test]
1997 fn removed_finding_is_credited_as_resolved() {
1998 let dir = tempfile::tempdir().unwrap();
1999 let root = dir.path();
2000 enable(root);
2001 let a = touch(root, "src/a.ts");
2002 run(
2003 root,
2004 &[&a],
2005 vec![fi(&a, "unused-export", "foo")],
2006 vec![],
2007 &[],
2008 "t0",
2009 );
2010 assert_eq!(
2011 load(root).resolved_total,
2012 0,
2013 "first run only establishes a baseline"
2014 );
2015 run(root, &[&a], vec![], vec![], &[], "t1");
2016 let store = load(root);
2017 assert_eq!(store.resolved_total, 1);
2018 assert_eq!(store.suppressed_total, 0);
2019 assert_eq!(store.recent_resolved.len(), 1);
2020 assert_eq!(store.recent_resolved[0].kind, "unused-export");
2021 assert_eq!(store.recent_resolved[0].symbol.as_deref(), Some("foo"));
2022 assert_eq!(store.recent_resolved[0].path, "src/a.ts");
2023 }
2024
2025 #[test]
2026 fn suppressed_finding_is_not_a_win() {
2027 let dir = tempfile::tempdir().unwrap();
2028 let root = dir.path();
2029 enable(root);
2030 let a = touch(root, "src/a.ts");
2031 run(
2032 root,
2033 &[&a],
2034 vec![fi(&a, "unused-export", "foo")],
2035 vec![],
2036 &[],
2037 "t0",
2038 );
2039 run(
2040 root,
2041 &[&a],
2042 vec![],
2043 vec![],
2044 &[supp(&a, "unused-export")],
2045 "t1",
2046 );
2047 let store = load(root);
2048 assert_eq!(
2049 store.resolved_total, 0,
2050 "a suppression must never count as a win"
2051 );
2052 assert_eq!(store.suppressed_total, 1);
2053 }
2054
2055 #[test]
2056 fn fix_and_suppress_same_kind_credits_zero_resolved() {
2057 let dir = tempfile::tempdir().unwrap();
2058 let root = dir.path();
2059 enable(root);
2060 let a = touch(root, "src/a.ts");
2061 run(
2062 root,
2063 &[&a],
2064 vec![
2065 fi(&a, "unused-export", "foo"),
2066 fi(&a, "unused-export", "bar"),
2067 ],
2068 vec![],
2069 &[],
2070 "t0",
2071 );
2072 run(
2073 root,
2074 &[&a],
2075 vec![],
2076 vec![],
2077 &[supp(&a, "unused-export")],
2078 "t1",
2079 );
2080 let store = load(root);
2081 assert_eq!(store.resolved_total, 0);
2082 assert_eq!(store.suppressed_total, 2);
2083 }
2084
2085 #[test]
2086 fn within_file_move_is_not_resolved() {
2087 let dir = tempfile::tempdir().unwrap();
2088 let root = dir.path();
2089 enable(root);
2090 let a = touch(root, "src/a.ts");
2091 run(
2092 root,
2093 &[&a],
2094 vec![fi(&a, "unused-export", "foo")],
2095 vec![],
2096 &[],
2097 "t0",
2098 );
2099 run(
2100 root,
2101 &[&a],
2102 vec![fi(&a, "unused-export", "foo")],
2103 vec![],
2104 &[],
2105 "t1",
2106 );
2107 let store = load(root);
2108 assert_eq!(store.resolved_total, 0);
2109 assert_eq!(store.suppressed_total, 0);
2110 }
2111
2112 #[test]
2113 fn cross_file_move_in_same_run_is_not_resolved() {
2114 let dir = tempfile::tempdir().unwrap();
2115 let root = dir.path();
2116 enable(root);
2117 let a = touch(root, "src/a.ts");
2118 let b = touch(root, "src/b.ts");
2119 run(
2120 root,
2121 &[&a],
2122 vec![fi(&a, "unused-export", "foo")],
2123 vec![],
2124 &[],
2125 "t0",
2126 );
2127 run(
2128 root,
2129 &[&a, &b],
2130 vec![fi(&b, "unused-export", "foo")],
2131 vec![],
2132 &[],
2133 "t1",
2134 );
2135 assert_eq!(
2136 load(root).resolved_total,
2137 0,
2138 "a cross-file move is not a resolution"
2139 );
2140 }
2141
2142 #[test]
2143 fn cross_run_move_uncredits_the_prior_resolution() {
2144 let dir = tempfile::tempdir().unwrap();
2145 let root = dir.path();
2146 enable(root);
2147 let a = touch(root, "src/a.ts");
2148 let b = touch(root, "src/b.ts");
2149 run(
2150 root,
2151 &[&a],
2152 vec![fi(&a, "unused-export", "foo")],
2153 vec![],
2154 &[],
2155 "t0",
2156 );
2157 run(root, &[&a], vec![], vec![], &[], "t1");
2158 assert_eq!(
2159 load(root).resolved_total,
2160 1,
2161 "source disappearance credited in run A"
2162 );
2163 run(
2164 root,
2165 &[&b],
2166 vec![fi(&b, "unused-export", "foo")],
2167 vec![],
2168 &[],
2169 "t2",
2170 );
2171 let store = load(root);
2172 assert_eq!(
2173 store.resolved_total, 0,
2174 "cross-run move must un-credit the phantom win"
2175 );
2176 assert!(
2177 store.recent_resolved.is_empty(),
2178 "the stale resolution event is dropped"
2179 );
2180 }
2181
2182 #[test]
2183 fn resolved_complexity_finding_and_suppressed_complexity() {
2184 let dir = tempfile::tempdir().unwrap();
2185 let root = dir.path();
2186 enable(root);
2187 let a = touch(root, "src/a.ts");
2188 run(
2189 root,
2190 &[&a],
2191 vec![fi(&a, "complexity", "bigFn")],
2192 vec![],
2193 &[],
2194 "t0",
2195 );
2196 run(root, &[&a], vec![], vec![], &[supp(&a, "complexity")], "t1");
2197 let store = load(root);
2198 assert_eq!(store.resolved_total, 0);
2199 assert_eq!(store.suppressed_total, 1);
2200
2201 let b = touch(root, "src/b.ts");
2202 run(
2203 root,
2204 &[&b],
2205 vec![fi(&b, "complexity", "huge")],
2206 vec![],
2207 &[],
2208 "t2",
2209 );
2210 run(root, &[&b], vec![], vec![], &[], "t3");
2211 assert_eq!(load(root).resolved_total, 1);
2212 }
2213
2214 #[test]
2215 fn resolved_duplication_clone_group() {
2216 let dir = tempfile::tempdir().unwrap();
2217 let root = dir.path();
2218 enable(root);
2219 let a = touch(root, "src/a.ts");
2220 let b = touch(root, "src/b.ts");
2221 let clone = CloneInput {
2222 fingerprint: "dup:abc12345".to_owned(),
2223 instance_paths: vec![a.clone(), b],
2224 };
2225 run(root, &[&a], vec![], vec![clone], &[], "t0");
2226 run(root, &[&a], vec![], vec![], &[], "t1");
2227 let store = load(root);
2228 assert_eq!(store.resolved_total, 1);
2229 assert_eq!(store.recent_resolved[0].kind, "code-duplication");
2230 }
2231
2232 #[test]
2233 fn blanket_suppression_covers_any_kind() {
2234 let dir = tempfile::tempdir().unwrap();
2235 let root = dir.path();
2236 enable(root);
2237 let a = touch(root, "src/a.ts");
2238 run(
2239 root,
2240 &[&a],
2241 vec![fi(&a, "unused-export", "foo")],
2242 vec![],
2243 &[],
2244 "t0",
2245 );
2246 let blanket = ActiveSuppression {
2247 path: a.clone(),
2248 kind: None,
2249 is_file_level: true,
2250 };
2251 run(root, &[&a], vec![], vec![], &[blanket], "t1");
2252 let store = load(root);
2253 assert_eq!(store.resolved_total, 0);
2254 assert_eq!(store.suppressed_total, 1);
2255 }
2256
2257 #[test]
2258 fn v1_store_loads_and_upgrades_to_v2() {
2259 let dir = tempfile::tempdir().unwrap();
2260 let root = dir.path();
2261 std::fs::create_dir_all(root.join(".fallow")).unwrap();
2262 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":[]}"#;
2263 std::fs::write(store_path(root), v1).unwrap();
2264 let store = load(root);
2265 assert_eq!(store.schema_version, 1);
2266 assert!(store.frontier.is_empty());
2267 assert_eq!(store.resolved_total, 0);
2268 let a = touch(root, "src/a.ts");
2269 run(
2270 root,
2271 &[&a],
2272 vec![fi(&a, "unused-export", "foo")],
2273 vec![],
2274 &[],
2275 "t1",
2276 );
2277 let store = load(root);
2278 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
2279 assert!(store.frontier.contains_key("src/a.ts"));
2280 }
2281
2282 #[test]
2283 fn recent_resolved_is_bounded() {
2284 let mut store = ImpactStore {
2285 enabled: true,
2286 ..Default::default()
2287 };
2288 for i in 0..(MAX_RECENT_RESOLVED + 25) {
2289 store.recent_resolved.push(ResolutionEvent {
2290 kind: "unused-export".into(),
2291 path: format!("src/f{i}.ts"),
2292 symbol: Some(format!("s{i}")),
2293 git_sha: None,
2294 timestamp: format!("t{i}"),
2295 });
2296 }
2297 bound_recent_resolved(&mut store);
2298 assert_eq!(store.recent_resolved.len(), MAX_RECENT_RESOLVED);
2299 assert_eq!(store.recent_resolved[0].path, "src/f25.ts");
2300 }
2301
2302 #[test]
2303 fn frontier_prunes_deleted_files() {
2304 let dir = tempfile::tempdir().unwrap();
2305 let root = dir.path();
2306 enable(root);
2307 let a = touch(root, "src/a.ts");
2308 run(
2309 root,
2310 &[&a],
2311 vec![fi(&a, "unused-export", "foo")],
2312 vec![],
2313 &[],
2314 "t0",
2315 );
2316 assert!(load(root).frontier.contains_key("src/a.ts"));
2317 std::fs::remove_file(&a).unwrap();
2318 let b = touch(root, "src/b.ts");
2319 run(root, &[&b], vec![], vec![], &[], "t1");
2320 assert!(!load(root).frontier.contains_key("src/a.ts"));
2321 }
2322
2323 #[test]
2324 fn honest_empty_state_before_attribution_baseline() {
2325 let store = ImpactStore {
2326 enabled: true,
2327 records: vec![ImpactRecord {
2328 timestamp: "t0".into(),
2329 version: "2.0.0".into(),
2330 git_sha: None,
2331 verdict: "warn".into(),
2332 gate: false,
2333 counts: ImpactCounts::default(),
2334 }],
2335 ..Default::default()
2336 };
2337 let report = build_report(&store);
2338 assert!(!report.attribution_active);
2339 let human = render_human(&report);
2340 assert!(human.contains("resolution tracking starts from your next gate run"));
2341 assert!(!human.contains("0 finding"));
2342 }
2343
2344 #[test]
2345 fn suppression_only_state_renders_under_a_resolved_header() {
2346 let report = ImpactReport {
2347 schema_version: ImpactReportSchemaVersion::V1,
2348 enabled: true,
2349 record_count: 2,
2350 first_recorded: Some("2026-05-29T10:00:00Z".into()),
2351 latest_git_sha: None,
2352 surfacing: Some(ImpactCounts::default()),
2353 trend: None,
2354 project_surfacing: None,
2355 project_trend: None,
2356 containment_count: 0,
2357 recent_containment: vec![],
2358 resolved_total: 0,
2359 suppressed_total: 2,
2360 recent_resolved: vec![],
2361 attribution_active: true,
2362 };
2363 let human = render_human(&report);
2364 let resolved_idx = human.find(" RESOLVED").expect("RESOLVED header present");
2365 let supp_idx = human
2366 .find("2 findings you marked intentional")
2367 .expect("suppression line present");
2368 assert!(
2369 resolved_idx < supp_idx,
2370 "suppression must render under RESOLVED"
2371 );
2372 assert!(human.contains("none yet"));
2373
2374 let md = render_markdown(&report);
2375 assert!(
2376 md.contains("- **Resolved:**"),
2377 "markdown always has a Resolved bullet"
2378 );
2379 assert!(md.contains("- **Marked intentional:** 2 finding"));
2380 }
2381
2382 fn clone_at(fingerprint: &str, paths: &[&Path]) -> CloneInput {
2384 CloneInput {
2385 fingerprint: fingerprint.to_owned(),
2386 instance_paths: paths.iter().map(|p| p.to_path_buf()).collect(),
2387 }
2388 }
2389
2390 fn run_wp(
2394 root: &Path,
2395 findings: Vec<FindingInput>,
2396 clones: Vec<CloneInput>,
2397 supps: &[ActiveSuppression],
2398 ts: &str,
2399 ) {
2400 let input = AttributionInput {
2401 root,
2402 scope: Scope::WholeProject,
2403 findings,
2404 clones,
2405 suppressions: supps,
2406 };
2407 record_combined_run(
2408 root,
2409 ImpactCounts::default(),
2410 Some("sha"),
2411 "2.0.0",
2412 ts,
2413 Some(&input),
2414 );
2415 }
2416
2417 #[test]
2423 fn whole_project_run_does_not_double_credit_after_audit() {
2424 let dir = tempfile::tempdir().unwrap();
2425 let root = dir.path();
2426 enable(root);
2427 let a = touch(root, "src/a.ts");
2428 let b = touch(root, "src/b.ts");
2429 run(
2431 root,
2432 &[&a, &b],
2433 vec![],
2434 vec![clone_at("dup:abc", &[&a, &b])],
2435 &[],
2436 "t1",
2437 );
2438 assert_eq!(load(root).clone_frontier.len(), 1);
2439
2440 run(root, &[&a, &b], vec![], vec![], &[], "t2");
2442 assert_eq!(load(root).resolved_total, 1);
2443 assert!(load(root).clone_frontier.is_empty());
2444
2445 run_wp(root, vec![], vec![], &[], "t3");
2447 assert_eq!(
2448 load(root).resolved_total,
2449 1,
2450 "whole-project run re-credited a resolution"
2451 );
2452 }
2453
2454 #[test]
2459 fn whole_project_run_credits_suppressed_not_resolved() {
2460 let dir = tempfile::tempdir().unwrap();
2461 let root = dir.path();
2462 enable(root);
2463 let util = touch(root, "src/util.ts");
2464 run(
2466 root,
2467 &[&util],
2468 vec![fi(&util, "unused-export", "dead")],
2469 vec![],
2470 &[],
2471 "t1",
2472 );
2473 assert_eq!(load(root).frontier.len(), 1);
2474
2475 run_wp(root, vec![], vec![], &[supp(&util, "unused-export")], "t2");
2478 let store = load(root);
2479 assert_eq!(
2480 store.suppressed_total, 1,
2481 "suppressed finding not counted suppressed"
2482 );
2483 assert_eq!(
2484 store.resolved_total, 0,
2485 "suppressed finding wrongly counted resolved"
2486 );
2487 }
2488
2489 #[test]
2494 fn clone_reshape_three_to_two_not_credited_as_resolved() {
2495 let dir = tempfile::tempdir().unwrap();
2496 let root = dir.path();
2497 enable(root);
2498 let a = touch(root, "src/a.ts");
2499 let b = touch(root, "src/b.ts");
2500 let c = touch(root, "src/c.ts");
2501 run(
2503 root,
2504 &[&a, &b, &c],
2505 vec![],
2506 vec![clone_at("dup:aaa", &[&a, &b, &c])],
2507 &[],
2508 "t1",
2509 );
2510 assert_eq!(load(root).clone_frontier.len(), 1);
2511
2512 run_wp(
2516 root,
2517 vec![],
2518 vec![clone_at("dup:bbb", &[&a, &b])],
2519 &[],
2520 "t2",
2521 );
2522 let store = load(root);
2523 assert_eq!(
2524 store.resolved_total, 0,
2525 "clone reshape miscredited as resolved"
2526 );
2527 assert!(store.clone_frontier.contains_key("dup:bbb"));
2528 assert!(!store.clone_frontier.contains_key("dup:aaa"));
2529 }
2530
2531 fn rcounts(total: usize, dead: usize, complexity: usize, dup: usize) -> ImpactCounts {
2538 ImpactCounts {
2539 total_issues: total,
2540 dead_code: dead,
2541 complexity,
2542 duplication: dup,
2543 }
2544 }
2545
2546 fn rtrend(prev: usize, cur: usize) -> TrendSummary {
2547 TrendSummary {
2548 direction: direction_for(cur as i64 - prev as i64),
2549 total_delta: cur as i64 - prev as i64,
2550 previous_total: prev,
2551 current_total: cur,
2552 }
2553 }
2554
2555 fn rreport(
2557 record_count: usize,
2558 first_recorded: Option<&str>,
2559 surfacing: Option<ImpactCounts>,
2560 trend: Option<TrendSummary>,
2561 project_surfacing: Option<ImpactCounts>,
2562 project_trend: Option<TrendSummary>,
2563 attribution_active: bool,
2564 ) -> ImpactReport {
2565 ImpactReport {
2566 schema_version: ImpactReportSchemaVersion::V1,
2567 enabled: true,
2568 record_count,
2569 first_recorded: first_recorded.map(ToOwned::to_owned),
2570 latest_git_sha: None,
2571 surfacing,
2572 trend,
2573 project_surfacing,
2574 project_trend,
2575 containment_count: 0,
2576 recent_containment: vec![],
2577 resolved_total: 0,
2578 suppressed_total: 0,
2579 recent_resolved: vec![],
2580 attribution_active,
2581 }
2582 }
2583
2584 #[test]
2588 fn render_human_project_only_store_shows_whole_project_not_empty_state() {
2589 let r = rreport(
2590 0,
2591 Some("2026-05-30T10:00:00Z"),
2592 None,
2593 None,
2594 Some(rcounts(1, 1, 0, 0)),
2595 None,
2596 true,
2597 );
2598 let human = render_human(&r);
2599 assert!(
2600 human.contains("WHOLE PROJECT (whole-repo context, not a to-do)"),
2601 "project-only must render the labeled section"
2602 );
2603 assert!(human.contains("1 issue across the whole project"));
2604 assert!(
2605 human.contains("project trend starts after your next full `fallow` run"),
2606 "single project record => no trend line, shows the next-run hint"
2607 );
2608 assert!(human.contains("Tracking since 2026-05-30"));
2609 assert!(
2610 !human.contains("No history yet"),
2611 "must not show the empty-state copy"
2612 );
2613 assert!(
2614 !human.contains("LATEST RUN"),
2615 "no changed-file track recorded"
2616 );
2617 assert!(
2618 !human.contains("recorded audit run"),
2619 "no audit runs => no changed-file footer"
2620 );
2621 }
2622
2623 #[test]
2626 fn render_human_both_tracks_label_actionable_vs_context() {
2627 let r = rreport(
2628 3,
2629 Some("2026-05-29T10:00:00Z"),
2630 Some(rcounts(4, 4, 0, 0)),
2631 Some(rtrend(6, 4)),
2632 Some(rcounts(40, 30, 5, 5)),
2633 Some(rtrend(45, 40)),
2634 true,
2635 );
2636 let human = render_human(&r);
2637 let latest = human
2638 .find("LATEST RUN (changed files, act on these now)")
2639 .expect("LATEST RUN labeled actionable");
2640 let whole = human
2641 .find("WHOLE PROJECT (whole-repo context, not a to-do)")
2642 .expect("WHOLE PROJECT labeled context");
2643 assert!(
2644 latest < whole,
2645 "changed-file section renders before whole-project"
2646 );
2647 assert!(human.contains("45 -> 40 (down) across your last two full runs"));
2648 assert!(human.contains("advances only on your local full `fallow` runs, not CI"));
2649 }
2650
2651 #[test]
2652 fn render_markdown_project_only_store_shows_whole_project_not_empty_state() {
2653 let r = rreport(
2654 0,
2655 Some("2026-05-30T10:00:00Z"),
2656 None,
2657 None,
2658 Some(rcounts(1, 1, 0, 0)),
2659 None,
2660 true,
2661 );
2662 let md = render_markdown(&r);
2663 assert!(
2664 md.contains(
2665 "- **Whole project (whole-repo context, last full `fallow` run):** 1 issue"
2666 ),
2667 "project-only md must render the labeled whole-project line"
2668 );
2669 assert!(
2670 !md.contains("No history yet"),
2671 "project-only md must not show empty state"
2672 );
2673 assert!(md.contains("Tracking since 2026-05-30"));
2674 }
2675}