1use std::path::{Path, PathBuf};
4
5pub use fallow_output::{
6 ContainmentEvent, CrossRepoImpactReport, CrossRepoImpactSchemaVersion, CrossRepoProjectEntry,
7 CrossRepoTotals, EnabledSource, ImpactCounts, ImpactReport, ImpactReportSchemaVersion,
8 ImpactTrendDirection, ResolutionEvent, TrendSummary,
9};
10use fallow_types::results::{ActiveSuppression, AnalysisResults};
11use rustc_hash::{FxHashMap, FxHashSet};
12use serde::{Deserialize, Serialize};
13
14use crate::audit::{AuditSummary, AuditVerdict};
15use crate::report::ci::fingerprint::fingerprint_hash;
16use crate::report::format_display_path;
17
18const STORE_SCHEMA_VERSION: u32 = 5;
19
20const MAX_RECORDS: usize = 200;
21
22const MAX_CONTAINMENT: usize = 200;
23
24const TREND_TOLERANCE: i64 = 0;
25
26const STORE_FILE: &str = "impact.json";
27
28const STORE_MAX_AGE_ENV: &str = "FALLOW_IMPACT_STORE_MAX_AGE_DAYS";
33
34const MAX_RECENT_RESOLVED: usize = 50;
35
36const ID_SEP: &str = "\u{1f}";
37
38const CODE_DUPLICATION_KIND: &str = "code-duplication";
39
40const BLANKET_SUPPRESSION: &str = "*";
41
42fn impact_counts_from_summary(summary: &AuditSummary) -> ImpactCounts {
43 ImpactCounts {
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#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct ImpactRecord {
55 pub timestamp: String,
56 pub version: String,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub git_sha: Option<String>,
59 pub verdict: String,
60 #[serde(default)]
61 pub gate: bool,
62 pub counts: ImpactCounts,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct PendingContainment {
67 pub blocked_at: String,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub git_sha: Option<String>,
70 pub blocked_counts: ImpactCounts,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct FrontierFinding {
75 pub id: String,
76 pub kind: String,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub symbol: Option<String>,
79}
80
81impl FrontierFinding {
82 fn move_key(&self) -> String {
83 match &self.symbol {
84 Some(symbol) => format!("{}{ID_SEP}{symbol}", self.kind),
85 None => self.id.clone(),
86 }
87 }
88}
89
90#[derive(Debug, Clone, Default, Serialize, Deserialize)]
91pub struct FileFrontier {
92 #[serde(default)]
93 pub findings: Vec<FrontierFinding>,
94 #[serde(default)]
95 pub suppressions: Vec<String>,
96}
97
98#[derive(Debug, Clone, Default, Serialize, Deserialize)]
99pub struct ImpactStore {
100 #[serde(default)]
101 pub schema_version: u32,
102 #[serde(default)]
103 pub enabled: bool,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub first_recorded: Option<String>,
106 #[serde(default)]
107 pub records: Vec<ImpactRecord>,
108 #[serde(default)]
109 pub project_records: Vec<ImpactRecord>,
110 #[serde(default)]
111 pub containment: Vec<ContainmentEvent>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub pending_containment: Option<PendingContainment>,
114 #[serde(default)]
118 pub frontier: FxHashMap<String, FxHashMap<String, FileFrontier>>,
119 #[serde(default)]
122 pub clone_frontier: FxHashMap<String, FxHashMap<String, Vec<String>>>,
123 #[serde(default)]
124 pub resolved_total: usize,
125 #[serde(default)]
126 pub suppressed_total: usize,
127 #[serde(default)]
128 pub recent_resolved: Vec<ResolutionEvent>,
129 #[serde(default)]
130 pub onboarding_declined: bool,
131 #[serde(default)]
135 pub explicit_decision: bool,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub last_digest_epoch: Option<u64>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub label: Option<String>,
148}
149
150#[derive(Debug, Default, Deserialize)]
155struct LegacyFlatStore {
156 #[serde(default)]
157 enabled: bool,
158 #[serde(default)]
159 first_recorded: Option<String>,
160 #[serde(default)]
161 records: Vec<ImpactRecord>,
162 #[serde(default)]
163 project_records: Vec<ImpactRecord>,
164 #[serde(default)]
165 containment: Vec<ContainmentEvent>,
166 #[serde(default)]
167 pending_containment: Option<PendingContainment>,
168 #[serde(default)]
169 frontier: FlatFrontier,
170 #[serde(default)]
171 clone_frontier: FlatCloneFrontier,
172 #[serde(default)]
173 resolved_total: usize,
174 #[serde(default)]
175 suppressed_total: usize,
176 #[serde(default)]
177 recent_resolved: Vec<ResolutionEvent>,
178 #[serde(default)]
179 onboarding_declined: bool,
180 #[serde(default)]
181 explicit_decision: bool,
182 #[serde(default)]
183 last_digest_epoch: Option<u64>,
184}
185
186impl LegacyFlatStore {
187 fn into_store(self, worktree_key: &str) -> ImpactStore {
190 let mut frontier: FxHashMap<String, FlatFrontier> = FxHashMap::default();
191 if !self.frontier.is_empty() {
192 frontier.insert(worktree_key.to_owned(), self.frontier);
193 }
194 let mut clone_frontier: FxHashMap<String, FlatCloneFrontier> = FxHashMap::default();
195 if !self.clone_frontier.is_empty() {
196 clone_frontier.insert(worktree_key.to_owned(), self.clone_frontier);
197 }
198 ImpactStore {
199 schema_version: STORE_SCHEMA_VERSION,
200 enabled: self.enabled,
201 first_recorded: self.first_recorded,
202 records: self.records,
203 project_records: self.project_records,
204 containment: self.containment,
205 pending_containment: self.pending_containment,
206 frontier,
207 clone_frontier,
208 resolved_total: self.resolved_total,
209 suppressed_total: self.suppressed_total,
210 recent_resolved: self.recent_resolved,
211 onboarding_declined: self.onboarding_declined,
212 explicit_decision: self.explicit_decision,
213 last_digest_epoch: self.last_digest_epoch,
214 label: None,
216 }
217 }
218}
219
220type ProjectIdentity = (String, String, Option<String>);
226
227static IDENTITY_CACHE: std::sync::OnceLock<std::sync::Mutex<FxHashMap<PathBuf, ProjectIdentity>>> =
228 std::sync::OnceLock::new();
229
230fn hash_path_identity(path: &Path) -> String {
235 let raw = path.to_string_lossy();
236 let normalized = if cfg!(any(target_os = "macos", target_os = "windows")) {
237 raw.to_lowercase()
238 } else {
239 raw.into_owned()
240 };
241 fingerprint_hash(&[normalized.as_str()])
242}
243
244fn resolve_or_root(resolved: Option<PathBuf>, root: &Path) -> PathBuf {
249 resolved
250 .or_else(|| dunce::canonicalize(root).ok())
251 .unwrap_or_else(|| root.to_path_buf())
252}
253
254fn repo_basename(common_or_dir: &Path) -> Option<String> {
259 let dir = if common_or_dir.file_name().is_some_and(|n| n == ".git") {
260 common_or_dir.parent()?
261 } else {
262 common_or_dir
263 };
264 dir.file_name().map(|n| n.to_string_lossy().into_owned())
265}
266
267fn project_identity(root: &Path) -> ProjectIdentity {
276 let cache = IDENTITY_CACHE.get_or_init(|| std::sync::Mutex::new(FxHashMap::default()));
277 if let Ok(map) = cache.lock()
278 && let Some(found) = map.get(root)
279 {
280 return found.clone();
281 }
282 let common = resolve_or_root(
283 fallow_engine::changed_files::resolve_git_common_dir(root).ok(),
284 root,
285 );
286 let toplevel = resolve_or_root(
287 fallow_engine::changed_files::resolve_git_toplevel(root).ok(),
288 root,
289 );
290 let identity = (
291 hash_path_identity(&common),
292 hash_path_identity(&toplevel),
293 repo_basename(&common),
294 );
295 if let Ok(mut map) = cache.lock() {
296 map.insert(root.to_path_buf(), identity.clone());
297 }
298 identity
299}
300
301#[cfg(test)]
302thread_local! {
303 static TEST_CONFIG_DIR: std::cell::RefCell<Option<PathBuf>> =
307 const { std::cell::RefCell::new(None) };
308
309 static TEST_FORCE_CI: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
317}
318
319fn impact_config_dir() -> Option<PathBuf> {
323 #[cfg(test)]
324 {
325 TEST_CONFIG_DIR.with(|c| c.borrow().clone())
326 }
327 #[cfg(not(test))]
328 {
329 crate::telemetry::config_dir()
330 }
331}
332
333fn record_gate_is_ci() -> bool {
340 #[cfg(test)]
341 {
342 TEST_FORCE_CI.with(std::cell::Cell::get)
343 }
344 #[cfg(not(test))]
345 {
346 crate::telemetry::is_ci()
347 }
348}
349
350fn store_path(root: &Path) -> Option<PathBuf> {
354 let (project_key, _, _) = project_identity(root);
355 Some(
356 impact_config_dir()?
357 .join("impact")
358 .join(format!("{project_key}.json")),
359 )
360}
361
362fn legacy_store_path(root: &Path) -> PathBuf {
365 root.join(".fallow").join(STORE_FILE)
366}
367
368pub fn load(root: &Path) -> ImpactStore {
371 let Some(path) = store_path(root) else {
372 return ImpactStore::default();
373 };
374 match std::fs::read_to_string(&path) {
375 Ok(content) => parse_store(&content, &path),
376 Err(_) => migrate_legacy_store(root),
379 }
380}
381
382fn parse_store(content: &str, path: &Path) -> ImpactStore {
383 match serde_json::from_str::<ImpactStore>(content) {
384 Ok(store) => {
385 if store.schema_version > STORE_SCHEMA_VERSION {
386 tracing::warn!(
387 "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.",
388 path.display(),
389 store.schema_version,
390 STORE_SCHEMA_VERSION,
391 );
392 }
393 store
394 }
395 Err(err) => {
396 tracing::warn!(
397 "fallow impact: ignoring unreadable store at {} ({err}); run `fallow impact enable` to reset it",
398 path.display()
399 );
400 ImpactStore::default()
401 }
402 }
403}
404
405fn save(store: &ImpactStore, root: &Path) {
408 let Some(path) = store_path(root) else {
409 return;
410 };
411 if let Some(parent) = path.parent()
412 && std::fs::create_dir_all(parent).is_err()
413 {
414 return;
415 }
416 if let Ok(json) = serde_json::to_string_pretty(store) {
417 let _ = fallow_config::atomic_write(&path, json.as_bytes());
418 }
419}
420
421fn lock_path_for(store: &Path) -> PathBuf {
423 let mut raw = store.as_os_str().to_owned();
424 raw.push(".lock");
425 PathBuf::from(raw)
426}
427
428struct ImpactStoreLock {
440 _file: std::fs::File,
441}
442
443impl ImpactStoreLock {
444 fn acquire(root: &Path) -> Option<Self> {
449 let lock_path = lock_path_for(&store_path(root)?);
450 if let Some(parent) = lock_path.parent()
451 && std::fs::create_dir_all(parent).is_err()
452 {
453 return None;
454 }
455 let file = std::fs::OpenOptions::new()
456 .create(true)
457 .truncate(false)
458 .write(true)
459 .open(&lock_path)
460 .ok()?;
461 match file.lock() {
462 Ok(()) => Some(Self { _file: file }),
463 Err(err) => {
464 tracing::debug!(error = %err, "could not acquire impact store lock");
465 None
466 }
467 }
468 }
469}
470
471fn resolve_store_max_age() -> Option<std::time::Duration> {
474 let raw = std::env::var(STORE_MAX_AGE_ENV).ok()?;
475 let days: u32 = raw.trim().parse().ok()?;
476 crate::base_worktree::days_to_duration(days)
477}
478
479fn sweep_old_stores(keep_key: &str, max_age: std::time::Duration) {
504 let Some(dir) = store_dir() else {
505 return;
506 };
507 let Ok(entries) = std::fs::read_dir(&dir) else {
508 return;
509 };
510 let now = std::time::SystemTime::now();
511 for entry in entries.flatten() {
512 let path = entry.path();
513 if path.extension().and_then(|e| e.to_str()) != Some("json") {
516 continue;
517 }
518 if path.file_stem().and_then(|s| s.to_str()) == Some(keep_key) {
519 continue;
520 }
521 let aged_out = std::fs::metadata(&path)
522 .and_then(|m| m.modified())
523 .ok()
524 .and_then(|mtime| now.duration_since(mtime).ok())
525 .is_some_and(|age| age >= max_age);
526 if aged_out {
527 let _ = std::fs::remove_file(&path);
528 }
529 }
530}
531
532fn migrate_legacy_store(root: &Path) -> ImpactStore {
540 let legacy_path = legacy_store_path(root);
541 let Ok(content) = std::fs::read_to_string(&legacy_path) else {
542 return ImpactStore::default();
543 };
544 let Ok(legacy) = serde_json::from_str::<LegacyFlatStore>(&content) else {
545 return ImpactStore::default();
546 };
547 let (_, worktree, display) = project_identity(root);
548 let mut store = legacy.into_store(&worktree);
549 store.label = display;
552 save(&store, root);
553 store
554}
555
556pub fn enable(root: &Path) -> bool {
560 let mut store = load(root);
561 let was_enabled = store.enabled;
562 store.enabled = true;
563 store.explicit_decision = true;
564 if store.schema_version == 0 {
565 store.schema_version = STORE_SCHEMA_VERSION;
566 }
567 save(&store, root);
568 !was_enabled
569}
570
571pub fn disable(root: &Path) -> bool {
576 let mut store = load(root);
577 let was_enabled = store.enabled;
578 store.enabled = false;
579 store.explicit_decision = true;
580 if store.schema_version == 0 {
581 store.schema_version = STORE_SCHEMA_VERSION;
582 }
583 save(&store, root);
584 was_enabled
585}
586
587#[derive(Debug, Clone, Copy)]
591pub struct ImpactDigest {
592 pub containment_count: usize,
593 pub resolved_total: usize,
594}
595
596const DIGEST_INTERVAL_SECS: u64 = 7 * 24 * 60 * 60;
598
599pub fn take_due_digest(root: &Path) -> Option<ImpactDigest> {
606 let mut store = load(root);
607 if !resolve_enabled(&store).0 {
608 return None;
609 }
610 let containment_count = store.containment.len();
611 if containment_count == 0 && store.resolved_total == 0 {
612 return None;
613 }
614 let now = std::time::SystemTime::now()
615 .duration_since(std::time::UNIX_EPOCH)
616 .ok()?
617 .as_secs();
618 if let Some(last) = store.last_digest_epoch
619 && now.saturating_sub(last) < DIGEST_INTERVAL_SECS
620 {
621 return None;
622 }
623 store.last_digest_epoch = Some(now);
624 save(&store, root);
625 Some(ImpactDigest {
626 containment_count,
627 resolved_total: store.resolved_total,
628 })
629}
630
631pub fn decline_onboarding(root: &Path) -> bool {
634 let mut store = load(root);
635 let was_declined = store.onboarding_declined;
636 store.onboarding_declined = true;
637 if store.schema_version == 0 {
638 store.schema_version = STORE_SCHEMA_VERSION;
639 }
640 save(&store, root);
641 !was_declined
642}
643
644#[derive(Debug, Default, Serialize, Deserialize)]
648struct GlobalImpactConfig {
649 #[serde(default)]
650 default_enabled: bool,
651}
652
653fn global_config_path() -> Option<PathBuf> {
654 Some(impact_config_dir()?.join(STORE_FILE))
655}
656
657fn load_global_default() -> bool {
659 let Some(path) = global_config_path() else {
660 return false;
661 };
662 std::fs::read_to_string(&path)
663 .ok()
664 .and_then(|c| serde_json::from_str::<GlobalImpactConfig>(&c).ok())
665 .is_some_and(|c| c.default_enabled)
666}
667
668pub fn set_global_default(on: bool) -> bool {
670 let was = load_global_default();
671 if let Some(path) = global_config_path() {
672 if let Some(parent) = path.parent()
673 && std::fs::create_dir_all(parent).is_err()
674 {
675 return false;
676 }
677 let config = GlobalImpactConfig {
678 default_enabled: on,
679 };
680 if let Ok(json) = serde_json::to_string_pretty(&config) {
681 let _ = fallow_config::atomic_write(&path, json.as_bytes());
682 }
683 }
684 was != on
685}
686
687fn resolve_enabled(store: &ImpactStore) -> (bool, EnabledSource) {
698 if store.enabled {
699 return (true, EnabledSource::Project);
700 }
701 if store.explicit_decision {
702 return (false, EnabledSource::Project);
703 }
704 if load_global_default() {
705 return (true, EnabledSource::User);
706 }
707 (false, EnabledSource::Default)
708}
709
710#[must_use]
713pub fn resolved_store_path(root: &Path) -> Option<PathBuf> {
714 store_path(root)
715}
716
717#[must_use]
719pub fn resolved_project_key(root: &Path) -> String {
720 project_identity(root).0
721}
722
723#[must_use]
726pub fn store_dir() -> Option<PathBuf> {
727 impact_config_dir().map(|d| d.join("impact"))
728}
729
730pub fn reset(root: &Path) -> bool {
732 store_path(root).is_some_and(|p| std::fs::remove_file(&p).is_ok())
733}
734
735pub fn reset_all() -> bool {
740 let Some(dir) = impact_config_dir().map(|d| d.join("impact")) else {
741 return false;
742 };
743 dir.is_dir() && std::fs::remove_dir_all(&dir).is_ok()
744}
745
746pub struct AuditRunRecord<'a> {
748 pub verdict: AuditVerdict,
749 pub gate: bool,
750 pub git_sha: Option<&'a str>,
751 pub version: &'a str,
752 pub timestamp: &'a str,
753 pub attribution: Option<&'a AttributionInput<'a>>,
754}
755
756pub fn record_audit_run(root: &Path, summary: &AuditSummary, record: &AuditRunRecord<'_>) {
757 let AuditRunRecord {
758 verdict,
759 gate,
760 git_sha,
761 version,
762 timestamp,
763 attribution,
764 } = record;
765 if record_gate_is_ci() {
770 return;
771 }
772 let _lock = ImpactStoreLock::acquire(root);
775 let mut store = load(root);
776 if !resolve_enabled(&store).0 {
777 return;
778 }
779 store.schema_version = STORE_SCHEMA_VERSION;
780 store.label = project_identity(root).2;
783
784 let counts = impact_counts_from_summary(summary);
785 let verdict_str = verdict_label(*verdict);
786
787 if store.first_recorded.is_none() {
788 store.first_recorded = Some((*timestamp).to_owned());
789 }
790
791 apply_containment(&mut store, *verdict, *gate, *git_sha, timestamp, &counts);
792
793 store.records.push(ImpactRecord {
794 timestamp: (*timestamp).to_owned(),
795 version: (*version).to_owned(),
796 git_sha: git_sha.map(ToOwned::to_owned),
797 verdict: verdict_str.to_owned(),
798 gate: *gate,
799 counts,
800 });
801 compact(&mut store);
802
803 if let Some(attribution) = attribution {
804 let (_, worktree, _) = project_identity(root);
805 apply_attribution(&mut store, attribution, &worktree, *git_sha, timestamp);
806 }
807
808 save(&store, root);
809 if let Some(max_age) = resolve_store_max_age() {
810 sweep_old_stores(&project_identity(root).0, max_age);
811 }
812}
813
814pub fn record_combined_run(
816 root: &Path,
817 counts: ImpactCounts,
818 git_sha: Option<&str>,
819 version: &str,
820 timestamp: &str,
821 attribution: Option<&AttributionInput<'_>>,
822) {
823 if record_gate_is_ci() {
824 return;
825 }
826 let _lock = ImpactStoreLock::acquire(root);
827 let mut store = load(root);
828 if !resolve_enabled(&store).0 {
829 return;
830 }
831 store.schema_version = STORE_SCHEMA_VERSION;
832 store.label = project_identity(root).2;
833
834 if store.first_recorded.is_none() {
835 store.first_recorded = Some(timestamp.to_owned());
836 }
837
838 let verdict_str = if counts.total_issues == 0 {
839 "pass"
840 } else {
841 "warn"
842 };
843 store.project_records.push(ImpactRecord {
844 timestamp: timestamp.to_owned(),
845 version: version.to_owned(),
846 git_sha: git_sha.map(ToOwned::to_owned),
847 verdict: verdict_str.to_owned(),
848 gate: false,
849 counts,
850 });
851 if store.project_records.len() > MAX_RECORDS {
852 let overflow = store.project_records.len() - MAX_RECORDS;
853 store.project_records.drain(0..overflow);
854 }
855
856 if let Some(attribution) = attribution {
857 let (_, worktree, _) = project_identity(root);
858 apply_attribution(&mut store, attribution, &worktree, git_sha, timestamp);
859 }
860
861 save(&store, root);
862 if let Some(max_age) = resolve_store_max_age() {
863 sweep_old_stores(&project_identity(root).0, max_age);
864 }
865}
866
867fn apply_containment(
869 store: &mut ImpactStore,
870 verdict: AuditVerdict,
871 gate: bool,
872 git_sha: Option<&str>,
873 timestamp: &str,
874 counts: &ImpactCounts,
875) {
876 if !gate {
877 return;
878 }
879 if verdict == AuditVerdict::Fail {
880 if store.pending_containment.is_none() {
881 store.pending_containment = Some(PendingContainment {
882 blocked_at: timestamp.to_owned(),
883 git_sha: git_sha.map(ToOwned::to_owned),
884 blocked_counts: counts.clone(),
885 });
886 }
887 } else if let Some(pending) = store.pending_containment.take() {
888 store.containment.push(ContainmentEvent {
889 blocked_at: pending.blocked_at,
890 cleared_at: timestamp.to_owned(),
891 git_sha: pending.git_sha,
892 blocked_counts: pending.blocked_counts,
893 });
894 if store.containment.len() > MAX_CONTAINMENT {
895 let overflow = store.containment.len() - MAX_CONTAINMENT;
896 store.containment.drain(0..overflow);
897 }
898 }
899}
900
901fn compact(store: &mut ImpactStore) {
902 if store.records.len() > MAX_RECORDS {
903 let overflow = store.records.len() - MAX_RECORDS;
904 store.records.drain(0..overflow);
905 }
906}
907
908#[derive(Debug, Clone)]
909pub struct FindingInput {
910 pub path: PathBuf,
911 pub kind: &'static str,
912 pub symbol: Option<String>,
913}
914
915#[derive(Debug, Clone)]
916pub struct CloneInput {
917 pub fingerprint: String,
918 pub instance_paths: Vec<PathBuf>,
919}
920
921pub enum Scope<'a> {
922 ChangedFiles(&'a [PathBuf]),
923 WholeProject,
924}
925
926pub struct AttributionInput<'a> {
927 pub root: &'a Path,
928 pub scope: Scope<'a>,
929 pub findings: Vec<FindingInput>,
930 pub clones: Vec<CloneInput>,
931 pub suppressions: &'a [ActiveSuppression],
932}
933
934fn finding_id(kind: &str, rel_path: &str, symbol: Option<&str>) -> String {
935 fingerprint_hash(&[kind, rel_path, symbol.unwrap_or("")])
936}
937
938fn covered_by(present: &FxHashSet<String>, kind: &str) -> bool {
939 present.contains(BLANKET_SUPPRESSION) || present.contains(kind)
940}
941
942type FlatFrontier = FxHashMap<String, FileFrontier>;
944type FlatCloneFrontier = FxHashMap<String, Vec<String>>;
946type CurrentState = (
948 FxHashMap<String, Vec<FrontierFinding>>,
949 FxHashMap<String, FxHashSet<String>>,
950);
951
952fn apply_attribution(
953 store: &mut ImpactStore,
954 input: &AttributionInput<'_>,
955 worktree_key: &str,
956 git_sha: Option<&str>,
957 timestamp: &str,
958) {
959 let root = input.root;
960 let mut frontier: FlatFrontier = store.frontier.remove(worktree_key).unwrap_or_default();
965 let mut clone_frontier: FlatCloneFrontier = store
966 .clone_frontier
967 .remove(worktree_key)
968 .unwrap_or_default();
969
970 let changed: FxHashSet<String> = match input.scope {
971 Scope::ChangedFiles(files) => files.iter().map(|p| format_display_path(p, root)).collect(),
972 Scope::WholeProject => whole_project_scope(&frontier, &clone_frontier, input, root),
973 };
974
975 let (current_findings, current_supps) = collect_current_state(input, &changed, root);
976
977 let appeared_move_keys = compute_appeared_move_keys(&frontier, ¤t_findings);
978
979 uncredit_cross_run_moves(store, &appeared_move_keys);
980
981 let mut disappearance_input = FileDisappearancesInput {
982 store,
983 frontier: &frontier,
984 changed: &changed,
985 current_findings: ¤t_findings,
986 current_supps: ¤t_supps,
987 appeared_move_keys: &appeared_move_keys,
988 git_sha,
989 timestamp,
990 };
991 classify_file_disappearances(&mut disappearance_input);
992 update_file_frontier(&mut frontier, &changed, current_findings, current_supps);
993 classify_clone_disappearances(&mut CloneDisappearancesInput {
994 store,
995 frontier: &frontier,
996 clone_frontier: &mut clone_frontier,
997 input,
998 changed: &changed,
999 git_sha,
1000 timestamp,
1001 });
1002 prune_frontier(&mut frontier, &mut clone_frontier, root);
1003 bound_recent_resolved(store);
1004
1005 store_worktree_baseline(store, worktree_key, frontier, clone_frontier);
1006}
1007
1008fn compute_appeared_move_keys(
1011 frontier: &FlatFrontier,
1012 current_findings: &FxHashMap<String, Vec<FrontierFinding>>,
1013) -> FxHashSet<String> {
1014 let mut appeared_move_keys: FxHashSet<String> = FxHashSet::default();
1015 for (rel, findings) in current_findings {
1016 let prior_ids: FxHashSet<&str> = frontier
1017 .get(rel)
1018 .map(|f| f.findings.iter().map(|x| x.id.as_str()).collect())
1019 .unwrap_or_default();
1020 for ff in findings {
1021 if !prior_ids.contains(ff.id.as_str()) {
1022 appeared_move_keys.insert(ff.move_key());
1023 }
1024 }
1025 }
1026 appeared_move_keys
1027}
1028
1029fn collect_current_state(
1033 input: &AttributionInput<'_>,
1034 changed: &FxHashSet<String>,
1035 root: &Path,
1036) -> CurrentState {
1037 let mut current_findings: FxHashMap<String, Vec<FrontierFinding>> = FxHashMap::default();
1038 for f in &input.findings {
1039 let rel = format_display_path(&f.path, root);
1040 if !changed.contains(&rel) {
1041 continue;
1042 }
1043 let id = finding_id(f.kind, &rel, f.symbol.as_deref());
1044 current_findings
1045 .entry(rel)
1046 .or_default()
1047 .push(FrontierFinding {
1048 id,
1049 kind: f.kind.to_owned(),
1050 symbol: f.symbol.clone(),
1051 });
1052 }
1053 let mut current_supps: FxHashMap<String, FxHashSet<String>> = FxHashMap::default();
1054 for s in input.suppressions {
1055 let rel = format_display_path(&s.path, root);
1056 if !changed.contains(&rel) {
1057 continue;
1058 }
1059 let key = s
1060 .kind
1061 .clone()
1062 .unwrap_or_else(|| BLANKET_SUPPRESSION.to_owned());
1063 current_supps.entry(rel).or_default().insert(key);
1064 }
1065 (current_findings, current_supps)
1066}
1067
1068fn store_worktree_baseline(
1071 store: &mut ImpactStore,
1072 worktree_key: &str,
1073 frontier: FlatFrontier,
1074 clone_frontier: FlatCloneFrontier,
1075) {
1076 if frontier.is_empty() {
1077 store.frontier.remove(worktree_key);
1078 } else {
1079 store.frontier.insert(worktree_key.to_owned(), frontier);
1080 }
1081 if clone_frontier.is_empty() {
1082 store.clone_frontier.remove(worktree_key);
1083 } else {
1084 store
1085 .clone_frontier
1086 .insert(worktree_key.to_owned(), clone_frontier);
1087 }
1088}
1089
1090fn whole_project_scope(
1091 frontier: &FlatFrontier,
1092 clone_frontier: &FlatCloneFrontier,
1093 input: &AttributionInput<'_>,
1094 root: &Path,
1095) -> FxHashSet<String> {
1096 let mut set: FxHashSet<String> = frontier.keys().cloned().collect();
1097 for paths in clone_frontier.values() {
1098 for p in paths {
1099 set.insert(p.clone());
1100 }
1101 }
1102 for f in &input.findings {
1103 set.insert(format_display_path(&f.path, root));
1104 }
1105 for c in &input.clones {
1106 for p in &c.instance_paths {
1107 set.insert(format_display_path(p, root));
1108 }
1109 }
1110 set
1111}
1112
1113struct FileDisappearancesInput<'a> {
1114 store: &'a mut ImpactStore,
1115 frontier: &'a FlatFrontier,
1116 changed: &'a FxHashSet<String>,
1117 current_findings: &'a FxHashMap<String, Vec<FrontierFinding>>,
1118 current_supps: &'a FxHashMap<String, FxHashSet<String>>,
1119 appeared_move_keys: &'a FxHashSet<String>,
1120 git_sha: Option<&'a str>,
1121 timestamp: &'a str,
1122}
1123
1124fn classify_file_disappearances(input: &mut FileDisappearancesInput<'_>) {
1125 let store = &mut *input.store;
1126 let frontier = input.frontier;
1127 let changed = input.changed;
1128 let current_findings = input.current_findings;
1129 let current_supps = input.current_supps;
1130 let appeared_move_keys = input.appeared_move_keys;
1131 let git_sha = input.git_sha;
1132 let timestamp = input.timestamp;
1133 let empty_supps = FxHashSet::default();
1134 for rel in changed {
1135 let Some(prior) = frontier.get(rel) else {
1136 continue;
1137 };
1138 let now_ids: FxHashSet<&str> = current_findings
1139 .get(rel)
1140 .map(|fs| fs.iter().map(|f| f.id.as_str()).collect())
1141 .unwrap_or_default();
1142 let now_supps = current_supps.get(rel).unwrap_or(&empty_supps);
1143 let prior_supps: FxHashSet<&str> = prior.suppressions.iter().map(String::as_str).collect();
1144 let new_supp_kinds: FxHashSet<String> = now_supps
1145 .iter()
1146 .filter(|k| !prior_supps.contains(k.as_str()))
1147 .cloned()
1148 .collect();
1149
1150 let mut resolved = Vec::new();
1151 let mut suppressed = 0usize;
1152 for pf in &prior.findings {
1153 if now_ids.contains(pf.id.as_str()) {
1154 continue; }
1156 if appeared_move_keys.contains(&pf.move_key()) {
1157 continue; }
1159 if covered_by(&new_supp_kinds, &pf.kind) {
1160 suppressed += 1; } else {
1162 resolved.push(pf.clone());
1163 }
1164 }
1165 store.suppressed_total += suppressed;
1166 for pf in resolved {
1167 store.resolved_total += 1;
1168 store.recent_resolved.push(ResolutionEvent {
1169 kind: pf.kind,
1170 path: rel.clone(),
1171 symbol: pf.symbol,
1172 git_sha: git_sha.map(ToOwned::to_owned),
1173 timestamp: timestamp.to_owned(),
1174 });
1175 }
1176 }
1177}
1178
1179fn update_file_frontier(
1180 frontier: &mut FlatFrontier,
1181 changed: &FxHashSet<String>,
1182 mut current_findings: FxHashMap<String, Vec<FrontierFinding>>,
1183 mut current_supps: FxHashMap<String, FxHashSet<String>>,
1184) {
1185 for rel in changed {
1186 let findings = current_findings.remove(rel).unwrap_or_default();
1187 let mut suppressions: Vec<String> = current_supps
1188 .remove(rel)
1189 .unwrap_or_default()
1190 .into_iter()
1191 .collect();
1192 suppressions.sort_unstable();
1193 if findings.is_empty() && suppressions.is_empty() {
1194 frontier.remove(rel);
1195 } else {
1196 frontier.insert(
1197 rel.clone(),
1198 FileFrontier {
1199 findings,
1200 suppressions,
1201 },
1202 );
1203 }
1204 }
1205}
1206
1207struct CloneDisappearancesInput<'a> {
1211 store: &'a mut ImpactStore,
1212 frontier: &'a FlatFrontier,
1213 clone_frontier: &'a mut FlatCloneFrontier,
1214 input: &'a AttributionInput<'a>,
1215 changed: &'a FxHashSet<String>,
1216 git_sha: Option<&'a str>,
1217 timestamp: &'a str,
1218}
1219
1220fn classify_clone_disappearances(args: &mut CloneDisappearancesInput<'_>) {
1221 let store = &mut *args.store;
1222 let frontier = args.frontier;
1223 let clone_frontier = &mut *args.clone_frontier;
1224 let input = args.input;
1225 let changed = args.changed;
1226 let git_sha = args.git_sha;
1227 let timestamp = args.timestamp;
1228 let current = collect_changed_clone_groups(input, changed);
1229
1230 let still_duplicated: FxHashSet<&String> = current.values().flatten().collect();
1231
1232 let disappeared: Vec<(String, Vec<String>)> = clone_frontier
1233 .iter()
1234 .filter(|(fp, paths)| {
1235 paths.iter().any(|p| changed.contains(p)) && !current.contains_key(*fp)
1236 })
1237 .map(|(fp, paths)| (fp.clone(), paths.clone()))
1238 .collect();
1239
1240 for (fp, paths) in disappeared {
1241 clone_frontier.remove(&fp);
1242 if paths.iter().any(|p| still_duplicated.contains(p)) {
1243 continue;
1244 }
1245 credit_clone_disappearance(store, frontier, changed, &paths, git_sha, timestamp);
1246 }
1247
1248 for (fp, paths) in current {
1249 clone_frontier.insert(fp, paths);
1250 }
1251}
1252
1253fn collect_changed_clone_groups(
1256 input: &AttributionInput<'_>,
1257 changed: &FxHashSet<String>,
1258) -> FxHashMap<String, Vec<String>> {
1259 let root = input.root;
1260 let mut current: FxHashMap<String, Vec<String>> = FxHashMap::default();
1261 for c in &input.clones {
1262 let mut paths: Vec<String> = c
1263 .instance_paths
1264 .iter()
1265 .map(|p| format_display_path(p, root))
1266 .collect();
1267 paths.sort_unstable();
1268 paths.dedup();
1269 if paths.iter().any(|p| changed.contains(p)) {
1270 current.insert(c.fingerprint.clone(), paths);
1271 }
1272 }
1273 current
1274}
1275
1276fn clone_dup_suppressed(
1279 frontier: &FlatFrontier,
1280 changed: &FxHashSet<String>,
1281 paths: &[String],
1282) -> bool {
1283 paths.iter().any(|p| {
1284 changed.contains(p)
1285 && frontier.get(p).is_some_and(|f| {
1286 f.suppressions
1287 .iter()
1288 .any(|k| k == CODE_DUPLICATION_KIND || k == BLANKET_SUPPRESSION)
1289 })
1290 })
1291}
1292
1293fn credit_clone_disappearance(
1295 store: &mut ImpactStore,
1296 frontier: &FlatFrontier,
1297 changed: &FxHashSet<String>,
1298 paths: &[String],
1299 git_sha: Option<&str>,
1300 timestamp: &str,
1301) {
1302 if clone_dup_suppressed(frontier, changed, paths) {
1303 store.suppressed_total += 1;
1304 } else {
1305 store.resolved_total += 1;
1306 let path = paths.first().cloned().unwrap_or_default();
1307 store.recent_resolved.push(ResolutionEvent {
1308 kind: CODE_DUPLICATION_KIND.to_owned(),
1309 path,
1310 symbol: None,
1311 git_sha: git_sha.map(ToOwned::to_owned),
1312 timestamp: timestamp.to_owned(),
1313 });
1314 }
1315}
1316
1317fn prune_frontier(
1318 frontier: &mut FlatFrontier,
1319 clone_frontier: &mut FlatCloneFrontier,
1320 root: &Path,
1321) {
1322 frontier.retain(|rel, _| root.join(rel).exists());
1323 clone_frontier.retain(|_, paths| paths.iter().any(|p| root.join(p).exists()));
1324}
1325
1326fn bound_recent_resolved(store: &mut ImpactStore) {
1327 if store.recent_resolved.len() > MAX_RECENT_RESOLVED {
1328 let overflow = store.recent_resolved.len() - MAX_RECENT_RESOLVED;
1329 store.recent_resolved.drain(0..overflow);
1330 }
1331}
1332
1333fn event_move_key(ev: &ResolutionEvent) -> Option<String> {
1334 ev.symbol
1335 .as_ref()
1336 .map(|symbol| format!("{}{ID_SEP}{symbol}", ev.kind))
1337}
1338
1339fn uncredit_cross_run_moves(store: &mut ImpactStore, appeared_move_keys: &FxHashSet<String>) {
1340 if appeared_move_keys.is_empty() {
1341 return;
1342 }
1343 let mut uncredited = 0usize;
1344 store.recent_resolved.retain(|ev| match event_move_key(ev) {
1345 Some(mk) if appeared_move_keys.contains(&mk) => {
1346 uncredited += 1;
1347 false
1348 }
1349 _ => true,
1350 });
1351 store.resolved_total = store.resolved_total.saturating_sub(uncredited);
1352}
1353
1354#[must_use]
1355pub fn collect_dead_code_findings(results: &AnalysisResults) -> Vec<FindingInput> {
1356 let mut out = Vec::new();
1357 let mut push = |path: &Path, kind: &'static str, symbol: Option<String>| {
1358 out.push(FindingInput {
1359 path: path.to_path_buf(),
1360 kind,
1361 symbol,
1362 });
1363 };
1364 collect_unused_symbol_findings(results, &mut push);
1365 collect_dependency_findings(results, &mut push);
1366 collect_catalog_findings(results, &mut push);
1367 out
1368}
1369
1370fn collect_unused_symbol_findings(
1371 results: &AnalysisResults,
1372 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1373) {
1374 collect_file_and_export_findings(results, push);
1375 collect_member_findings(results, push);
1376 collect_component_findings(results, push);
1377 collect_import_boundary_findings(results, push);
1378}
1379
1380fn collect_file_and_export_findings(
1382 results: &AnalysisResults,
1383 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1384) {
1385 for f in &results.unused_files {
1386 push(&f.file.path, "unused-file", None);
1387 }
1388 for f in &results.unused_exports {
1389 push(
1390 &f.export.path,
1391 "unused-export",
1392 Some(f.export.export_name.clone()),
1393 );
1394 }
1395 for f in &results.unused_types {
1396 push(
1397 &f.export.path,
1398 "unused-type",
1399 Some(f.export.export_name.clone()),
1400 );
1401 }
1402 for f in &results.private_type_leaks {
1403 push(
1404 &f.leak.path,
1405 "private-type-leak",
1406 Some(format!(
1407 "{}{ID_SEP}{}",
1408 f.leak.export_name, f.leak.type_name
1409 )),
1410 );
1411 }
1412}
1413
1414fn collect_member_findings(
1416 results: &AnalysisResults,
1417 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1418) {
1419 for f in &results.unused_enum_members {
1420 push(
1421 &f.member.path,
1422 "unused-enum-member",
1423 Some(format!(
1424 "{}{ID_SEP}{}",
1425 f.member.parent_name, f.member.member_name
1426 )),
1427 );
1428 }
1429 for f in &results.unused_class_members {
1430 push(
1431 &f.member.path,
1432 "unused-class-member",
1433 Some(format!(
1434 "{}{ID_SEP}{}",
1435 f.member.parent_name, f.member.member_name
1436 )),
1437 );
1438 }
1439 for f in &results.unused_store_members {
1440 push(
1441 &f.member.path,
1442 "unused-store-member",
1443 Some(format!(
1444 "{}{ID_SEP}{}",
1445 f.member.parent_name, f.member.member_name
1446 )),
1447 );
1448 }
1449 for f in &results.unprovided_injects {
1450 push(
1451 &f.inject.path,
1452 "unprovided-inject",
1453 Some(f.inject.key_name.clone()),
1454 );
1455 }
1456}
1457
1458fn collect_component_findings(
1460 results: &AnalysisResults,
1461 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1462) {
1463 for f in &results.unrendered_components {
1464 push(
1465 &f.component.path,
1466 "unrendered-component",
1467 Some(f.component.component_name.clone()),
1468 );
1469 }
1470 for f in &results.unused_component_props {
1471 push(
1472 &f.prop.path,
1473 "unused-component-prop",
1474 Some(f.prop.prop_name.clone()),
1475 );
1476 }
1477 for f in &results.unused_component_emits {
1478 push(
1479 &f.emit.path,
1480 "unused-component-emit",
1481 Some(f.emit.emit_name.clone()),
1482 );
1483 }
1484 for f in &results.unused_component_inputs {
1485 push(
1486 &f.input.path,
1487 "unused-component-input",
1488 Some(f.input.input_name.clone()),
1489 );
1490 }
1491 for f in &results.unused_component_outputs {
1492 push(
1493 &f.output.path,
1494 "unused-component-output",
1495 Some(f.output.output_name.clone()),
1496 );
1497 }
1498}
1499
1500fn collect_import_boundary_findings(
1502 results: &AnalysisResults,
1503 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1504) {
1505 for f in &results.unresolved_imports {
1506 push(
1507 &f.import.path,
1508 "unresolved-import",
1509 Some(f.import.specifier.clone()),
1510 );
1511 }
1512 for f in &results.boundary_violations {
1513 let to_path = f.violation.to_path.to_string_lossy().replace('\\', "/");
1514 push(
1515 &f.violation.from_path,
1516 "boundary-violation",
1517 Some(format!("{to_path}{ID_SEP}{}", f.violation.import_specifier)),
1518 );
1519 }
1520}
1521
1522fn collect_dependency_findings(
1523 results: &AnalysisResults,
1524 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1525) {
1526 for f in &results.unused_dependencies {
1527 push(
1528 &f.dep.path,
1529 "unused-dependency",
1530 Some(f.dep.package_name.clone()),
1531 );
1532 }
1533 for f in &results.unused_dev_dependencies {
1534 push(
1535 &f.dep.path,
1536 "unused-dev-dependency",
1537 Some(f.dep.package_name.clone()),
1538 );
1539 }
1540 for f in &results.unused_optional_dependencies {
1541 push(
1542 &f.dep.path,
1543 "unused-optional-dependency",
1544 Some(f.dep.package_name.clone()),
1545 );
1546 }
1547 for f in &results.type_only_dependencies {
1548 push(
1549 &f.dep.path,
1550 "type-only-dependency",
1551 Some(f.dep.package_name.clone()),
1552 );
1553 }
1554 for f in &results.test_only_dependencies {
1555 push(
1556 &f.dep.path,
1557 "test-only-dependency",
1558 Some(f.dep.package_name.clone()),
1559 );
1560 }
1561 for f in &results.dev_dependencies_in_production {
1562 push(
1563 &f.dep.path,
1564 "dev-dependency-in-production",
1565 Some(f.dep.package_name.clone()),
1566 );
1567 }
1568}
1569
1570fn collect_catalog_findings(
1571 results: &AnalysisResults,
1572 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1573) {
1574 for f in &results.unused_catalog_entries {
1575 push(
1576 &f.entry.path,
1577 "unused-catalog-entry",
1578 Some(format!(
1579 "{}{ID_SEP}{}",
1580 f.entry.catalog_name, f.entry.entry_name
1581 )),
1582 );
1583 }
1584 for f in &results.empty_catalog_groups {
1585 push(
1586 &f.group.path,
1587 "empty-catalog-group",
1588 Some(f.group.catalog_name.clone()),
1589 );
1590 }
1591 for f in &results.unresolved_catalog_references {
1592 push(
1593 &f.reference.path,
1594 "unresolved-catalog-reference",
1595 Some(format!(
1596 "{}{ID_SEP}{}",
1597 f.reference.catalog_name, f.reference.entry_name
1598 )),
1599 );
1600 }
1601 for f in &results.unused_dependency_overrides {
1602 push(
1603 &f.entry.path,
1604 "unused-dependency-override",
1605 Some(f.entry.raw_key.clone()),
1606 );
1607 }
1608 for f in &results.misconfigured_dependency_overrides {
1609 push(
1610 &f.entry.path,
1611 "misconfigured-dependency-override",
1612 Some(f.entry.raw_key.clone()),
1613 );
1614 }
1615}
1616
1617#[must_use]
1621pub fn collect_complexity_findings(report: &fallow_output::HealthReport) -> Vec<FindingInput> {
1622 report
1623 .findings
1624 .iter()
1625 .map(|f| FindingInput {
1626 path: f.path.clone(),
1627 kind: "complexity",
1628 symbol: Some(f.name.clone()),
1629 })
1630 .collect()
1631}
1632
1633#[must_use]
1637pub fn collect_clone_findings(
1638 report: &fallow_types::duplicates::DuplicationReport,
1639) -> Vec<CloneInput> {
1640 report
1641 .clone_groups
1642 .iter()
1643 .map(|g| CloneInput {
1644 fingerprint: fallow_engine::duplicates::clone_fingerprint(&g.instances),
1645 instance_paths: g.instances.iter().map(|i| i.file.clone()).collect(),
1646 })
1647 .collect()
1648}
1649
1650const fn verdict_label(verdict: AuditVerdict) -> &'static str {
1651 match verdict {
1652 AuditVerdict::Pass => "pass",
1653 AuditVerdict::Warn => "warn",
1654 AuditVerdict::Fail => "fail",
1655 }
1656}
1657
1658fn direction_for(delta: i64) -> ImpactTrendDirection {
1659 if delta < -TREND_TOLERANCE {
1660 ImpactTrendDirection::Improving
1661 } else if delta > TREND_TOLERANCE {
1662 ImpactTrendDirection::Declining
1663 } else {
1664 ImpactTrendDirection::Stable
1665 }
1666}
1667
1668fn trend_for(records: &[ImpactRecord]) -> Option<TrendSummary> {
1674 if records.len() < 2 {
1675 return None;
1676 }
1677 let current = &records[records.len() - 1];
1678 let previous = &records[records.len() - 2];
1679 let current_total = current.counts.total_issues;
1680 let previous_total = previous.counts.total_issues;
1681 let total_delta = current_total as i64 - previous_total as i64;
1682 Some(TrendSummary {
1683 direction: direction_for(total_delta),
1684 total_delta,
1685 previous_total,
1686 current_total,
1687 })
1688}
1689
1690pub fn build_report(store: &ImpactStore) -> ImpactReport {
1691 let surfacing = store.records.last().map(|r| r.counts.clone());
1692 let trend = trend_for(&store.records);
1693 let project_surfacing = store.project_records.last().map(|r| r.counts.clone());
1694 let project_trend = trend_for(&store.project_records);
1695
1696 let recent_containment = store
1697 .containment
1698 .iter()
1699 .rev()
1700 .take(5)
1701 .rev()
1702 .cloned()
1703 .collect();
1704
1705 let latest_git_sha = store.records.last().and_then(|r| r.git_sha.clone());
1706
1707 let recent_resolved = store
1708 .recent_resolved
1709 .iter()
1710 .rev()
1711 .take(5)
1712 .rev()
1713 .cloned()
1714 .collect();
1715 let attribution_active = !store.frontier.is_empty()
1716 || !store.clone_frontier.is_empty()
1717 || store.resolved_total > 0
1718 || store.suppressed_total > 0;
1719
1720 let (enabled, enabled_source) = resolve_enabled(store);
1721 ImpactReport {
1722 schema_version: ImpactReportSchemaVersion::V1,
1723 enabled,
1724 enabled_source,
1725 record_count: store.records.len(),
1726 meta: None,
1727 first_recorded: store.first_recorded.clone(),
1728 latest_git_sha,
1729 surfacing,
1730 trend,
1731 project_surfacing,
1732 project_trend,
1733 containment_count: store.containment.len(),
1734 recent_containment,
1735 resolved_total: store.resolved_total,
1736 suppressed_total: store.suppressed_total,
1737 recent_resolved,
1738 attribution_active,
1739 onboarding_declined: store.onboarding_declined,
1740 explicit_decision: store.explicit_decision,
1741 }
1742}
1743
1744#[derive(Debug, Clone, Copy)]
1746pub enum CrossRepoSort {
1747 Recent,
1749 Resolved,
1751 Contained,
1753 Name,
1755}
1756
1757fn latest_activity(store: &ImpactStore) -> Option<String> {
1759 let a = store.records.last().map(|r| r.timestamp.clone());
1760 let b = store.project_records.last().map(|r| r.timestamp.clone());
1761 match (a, b) {
1762 (Some(x), Some(y)) => Some(if x >= y { x } else { y }),
1763 (x, y) => x.or(y),
1764 }
1765}
1766
1767#[must_use]
1773pub fn load_all() -> (Vec<(String, ImpactStore)>, usize) {
1774 let Some(dir) = impact_config_dir().map(|d| d.join("impact")) else {
1775 return (Vec::new(), 0);
1776 };
1777 let Ok(read) = std::fs::read_dir(&dir) else {
1778 return (Vec::new(), 0);
1779 };
1780 let mut stores = Vec::new();
1781 let mut unreadable = 0usize;
1782 for entry in read.flatten() {
1783 let path = entry.path();
1784 if path.extension().and_then(|e| e.to_str()) != Some("json") {
1785 continue;
1786 }
1787 let Some(key) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
1788 continue;
1789 };
1790 match std::fs::read_to_string(&path)
1791 .ok()
1792 .and_then(|c| serde_json::from_str::<ImpactStore>(&c).ok())
1793 {
1794 Some(store) => stores.push((key, store)),
1795 None => unreadable += 1,
1796 }
1797 }
1798 (stores, unreadable)
1799}
1800
1801#[must_use]
1805pub fn build_aggregate_report(
1806 stores: Vec<(String, ImpactStore)>,
1807 unreadable: usize,
1808 sort: CrossRepoSort,
1809) -> CrossRepoImpactReport {
1810 let project_count = stores.len();
1811 let mut totals = CrossRepoTotals::default();
1812 let mut projects = Vec::new();
1813 for (key, store) in stores {
1814 let report = build_report(&store);
1815 let has_history = report.record_count > 0
1816 || report.project_surfacing.is_some()
1817 || report.resolved_total > 0
1818 || report.containment_count > 0;
1819 if !has_history {
1820 continue;
1821 }
1822 totals.resolved_total += report.resolved_total;
1823 totals.suppressed_total += report.suppressed_total;
1824 totals.containment_count += report.containment_count;
1825 if let Some(ps) = &report.project_surfacing {
1826 totals.project_wide_issues += ps.total_issues;
1827 totals.projects_with_baseline += 1;
1828 }
1829 projects.push(CrossRepoProjectEntry {
1830 project_key: key,
1831 label: store.label.clone(),
1832 last_recorded: latest_activity(&store),
1833 report,
1834 });
1835 }
1836 sort_cross_repo(&mut projects, sort);
1837 CrossRepoImpactReport {
1838 schema_version: CrossRepoImpactSchemaVersion::V1,
1839 project_count,
1840 tracked_count: projects.len(),
1841 unreadable_count: unreadable,
1842 totals,
1843 projects,
1844 }
1845}
1846
1847fn sort_cross_repo(projects: &mut [CrossRepoProjectEntry], sort: CrossRepoSort) {
1848 match sort {
1849 CrossRepoSort::Recent => projects.sort_by(|a, b| {
1852 b.last_recorded
1853 .cmp(&a.last_recorded)
1854 .then_with(|| a.project_key.cmp(&b.project_key))
1855 }),
1856 CrossRepoSort::Resolved => projects.sort_by(|a, b| {
1857 b.report
1858 .resolved_total
1859 .cmp(&a.report.resolved_total)
1860 .then_with(|| a.project_key.cmp(&b.project_key))
1861 }),
1862 CrossRepoSort::Contained => projects.sort_by(|a, b| {
1863 b.report
1864 .containment_count
1865 .cmp(&a.report.containment_count)
1866 .then_with(|| a.project_key.cmp(&b.project_key))
1867 }),
1868 CrossRepoSort::Name => projects.sort_by(|a, b| {
1869 cross_repo_label(a)
1870 .cmp(&cross_repo_label(b))
1871 .then_with(|| a.project_key.cmp(&b.project_key))
1872 }),
1873 }
1874}
1875
1876fn cross_repo_label(entry: &CrossRepoProjectEntry) -> String {
1879 entry
1880 .label
1881 .clone()
1882 .unwrap_or_else(|| short_key(&entry.project_key))
1883}
1884
1885fn short_key(key: &str) -> String {
1887 key.chars().take(12).collect()
1888}
1889
1890#[must_use]
1892pub fn aggregate(sort: CrossRepoSort) -> CrossRepoImpactReport {
1893 let (stores, unreadable) = load_all();
1894 build_aggregate_report(stores, unreadable, sort)
1895}
1896
1897#[expect(
1903 clippy::format_push_string,
1904 reason = "small report renderer; readability over avoiding the extra allocation"
1905)]
1906fn render_project_section(out: &mut String, report: &ImpactReport) {
1907 let Some(s) = &report.project_surfacing else {
1908 return;
1909 };
1910 out.push_str(&format!(
1911 " WHOLE PROJECT (whole-repo context, not a to-do)\n {} issue{} across the whole project at your last full `fallow` run\n",
1912 s.total_issues,
1913 plural(s.total_issues),
1914 ));
1915 if let Some(t) = &report.project_trend {
1916 let arrow = trend_arrow(t.direction);
1917 out.push_str(&format!(
1918 " {} -> {} ({}) across your last two full runs (comparable over time)\n",
1919 t.previous_total, t.current_total, arrow,
1920 ));
1921 } else {
1922 out.push_str(" project trend starts after your next full `fallow` run\n");
1923 }
1924 out.push_str(" advances only on your local full `fallow` runs, not CI\n\n");
1925}
1926
1927#[expect(
1929 clippy::format_push_string,
1930 reason = "small report renderer; readability over avoiding the extra allocation"
1931)]
1932pub fn render_human(report: &ImpactReport) -> String {
1933 let mut out = String::new();
1934 out.push_str("FALLOW IMPACT\n\n");
1935
1936 if !report.enabled {
1937 out.push_str(
1938 "Impact tracking is off. Enable it with `fallow impact enable`, then\n\
1939 let your pre-commit gate run a few times to build history.\n",
1940 );
1941 return out;
1942 }
1943
1944 if report.enabled_source == EnabledSource::User {
1945 out.push_str(
1946 "Enabled by your user-global default (`fallow impact default on`). Run\n\
1947 `fallow impact disable` to opt this project out.\n\n",
1948 );
1949 }
1950
1951 if report.record_count == 0 && report.project_surfacing.is_none() {
1952 out.push_str(
1953 "Tracking enabled. No history yet: check back after your next few\n\
1954 commits (Impact records each `fallow audit` / pre-commit gate run,\n\
1955 and each full `fallow` run for the whole-project view).\n",
1956 );
1957 return out;
1958 }
1959
1960 render_human_changed_section(&mut out, report);
1961
1962 render_project_section(&mut out, report);
1963
1964 out.push_str(&format!(
1965 " CONTAINED AT COMMIT\n {} time{} fallow blocked a commit until it was fixed\n",
1966 report.containment_count,
1967 plural(report.containment_count),
1968 ));
1969
1970 render_human_resolved_section(&mut out, report);
1971
1972 render_human_footer(&mut out, report);
1973 out
1974}
1975
1976#[expect(
1978 clippy::format_push_string,
1979 reason = "small report renderer; readability over avoiding the extra allocation"
1980)]
1981fn render_human_changed_section(out: &mut String, report: &ImpactReport) {
1982 if let Some(s) = &report.surfacing {
1983 out.push_str(&format!(
1984 " LATEST RUN (changed files, act on these now)\n {} issue{} flagged in your last `fallow audit` run\n",
1985 s.total_issues,
1986 plural(s.total_issues),
1987 ));
1988 out.push_str(&format!(
1989 " dead code {} · complexity {} · duplication {}\n\n",
1990 s.dead_code, s.complexity, s.duplication,
1991 ));
1992 }
1993
1994 if let Some(t) = &report.trend {
1995 let arrow = trend_arrow(t.direction);
1996 out.push_str(&format!(
1997 " 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",
1998 t.previous_total, t.current_total, arrow,
1999 ));
2000 }
2001}
2002
2003#[expect(
2005 clippy::format_push_string,
2006 reason = "small report renderer; readability over avoiding the extra allocation"
2007)]
2008fn render_human_resolved_section(out: &mut String, report: &ImpactReport) {
2009 if report.resolved_total > 0 {
2010 out.push_str(&format!(
2011 "\n RESOLVED\n {} finding{} you cleared since fallow started tracking\n",
2012 report.resolved_total,
2013 plural(report.resolved_total),
2014 ));
2015 for ev in &report.recent_resolved {
2016 match &ev.symbol {
2017 Some(symbol) => {
2018 out.push_str(&format!(" {} {} in {}\n", ev.kind, symbol, ev.path));
2019 }
2020 None => out.push_str(&format!(" {} in {}\n", ev.kind, ev.path)),
2021 }
2022 }
2023 } else if report.attribution_active {
2024 out.push_str(
2025 "\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",
2026 );
2027 } else {
2028 out.push_str("\n RESOLVED\n resolution tracking starts from your next gate run\n");
2029 }
2030
2031 if report.suppressed_total > 0 {
2032 out.push_str(&format!(
2033 " {} finding{} you marked intentional (fallow-ignore), not counted as resolved\n",
2034 report.suppressed_total,
2035 plural(report.suppressed_total),
2036 ));
2037 }
2038}
2039
2040#[expect(
2042 clippy::format_push_string,
2043 reason = "small report renderer; readability over avoiding the extra allocation"
2044)]
2045fn render_human_footer(out: &mut String, report: &ImpactReport) {
2046 out.push('\n');
2047 let since = report
2048 .first_recorded
2049 .as_deref()
2050 .map_or("the first run", date_only);
2051 if report.record_count > 0 {
2052 out.push_str(&format!(
2053 "Based on {} recorded audit run{} since {}. Local-only; never uploaded.\n\
2054 Changed-file scope: each audit run only sees files differing from your base.\n",
2055 report.record_count,
2056 plural(report.record_count),
2057 since,
2058 ));
2059 } else {
2060 out.push_str(&format!(
2061 "Tracking since {since}. Local-only; never uploaded.\n",
2062 ));
2063 }
2064 out.push_str(
2065 "Resolution tracking is a local-developer signal: it accrues on your\n\
2066 machine across runs, not in CI (fallow never records there).\n",
2067 );
2068}
2069
2070pub fn render_json(report: &ImpactReport) -> String {
2072 let value = fallow_output::serialize_impact_json_output(
2073 report.clone(),
2074 crate::output_runtime::current_root_envelope_mode(),
2075 crate::output_runtime::telemetry_analysis_run_id().as_deref(),
2076 )
2077 .unwrap_or_else(|_| serde_json::json!({"error":"failed to serialize impact report"}));
2078 serde_json::to_string_pretty(&value)
2079 .unwrap_or_else(|_| "{\"error\":\"failed to serialize impact report\"}".to_owned())
2080}
2081
2082#[expect(
2086 clippy::format_push_string,
2087 reason = "small report renderer; readability over avoiding the extra allocation"
2088)]
2089fn render_project_markdown(out: &mut String, report: &ImpactReport) {
2090 let Some(s) = &report.project_surfacing else {
2091 return;
2092 };
2093 out.push_str(&format!(
2094 "- **Whole project (whole-repo context, last full `fallow` run):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
2095 s.total_issues,
2096 plural(s.total_issues),
2097 s.dead_code,
2098 s.complexity,
2099 s.duplication,
2100 ));
2101 if let Some(t) = &report.project_trend {
2102 let arrow = trend_arrow(t.direction);
2103 out.push_str(&format!(
2104 "- **Project trend (whole project, last two full runs):** {} -> {} ({})\n",
2105 t.previous_total, t.current_total, arrow,
2106 ));
2107 }
2108}
2109
2110#[expect(
2112 clippy::format_push_string,
2113 reason = "small report renderer; readability over avoiding the extra allocation"
2114)]
2115pub fn render_markdown(report: &ImpactReport) -> String {
2116 let mut out = String::new();
2117 out.push_str("## Fallow impact\n\n");
2118
2119 if !report.enabled {
2120 out.push_str("Impact tracking is off. Run `fallow impact enable` to start.\n");
2121 return out;
2122 }
2123 if report.record_count == 0 && report.project_surfacing.is_none() {
2124 out.push_str("Tracking enabled. No history yet; check back after a few commits.\n");
2125 return out;
2126 }
2127
2128 if let Some(s) = &report.surfacing {
2129 out.push_str(&format!(
2130 "- **Latest run (changed files):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
2131 s.total_issues,
2132 plural(s.total_issues),
2133 s.dead_code,
2134 s.complexity,
2135 s.duplication,
2136 ));
2137 }
2138 if let Some(t) = &report.trend {
2139 out.push_str(&format!(
2140 "- **Trend (changed-file scope, last two runs):** {} -> {} ({})\n",
2141 t.previous_total,
2142 t.current_total,
2143 trend_arrow(t.direction),
2144 ));
2145 }
2146 render_project_markdown(&mut out, report);
2147 out.push_str(&format!(
2148 "- **Contained at commit:** {} time{}\n",
2149 report.containment_count,
2150 plural(report.containment_count),
2151 ));
2152 render_markdown_resolved_section(&mut out, report);
2153 render_markdown_footer(&mut out, report);
2154 out
2155}
2156
2157#[expect(
2159 clippy::format_push_string,
2160 reason = "small report renderer; readability over avoiding the extra allocation"
2161)]
2162fn render_markdown_resolved_section(out: &mut String, report: &ImpactReport) {
2163 if report.resolved_total > 0 {
2164 out.push_str(&format!(
2165 "- **Resolved:** {} finding{} cleared since tracking started\n",
2166 report.resolved_total,
2167 plural(report.resolved_total),
2168 ));
2169 } else if report.attribution_active {
2170 out.push_str("- **Resolved:** none yet; tracking active\n");
2171 } else {
2172 out.push_str("- **Resolved:** resolution tracking starts from your next gate run\n");
2173 }
2174 if report.suppressed_total > 0 {
2175 out.push_str(&format!(
2176 "- **Marked intentional:** {} finding{} (`fallow-ignore`), not counted as resolved\n",
2177 report.suppressed_total,
2178 plural(report.suppressed_total),
2179 ));
2180 }
2181}
2182
2183#[expect(
2185 clippy::format_push_string,
2186 reason = "small report renderer; readability over avoiding the extra allocation"
2187)]
2188fn render_markdown_footer(out: &mut String, report: &ImpactReport) {
2189 let since = report
2190 .first_recorded
2191 .as_deref()
2192 .map_or("the first run", date_only);
2193 if report.record_count > 0 {
2194 out.push_str(&format!(
2195 "\n_Based on {} recorded audit run{} since {}. Local-only; resolution is a local-developer signal._\n",
2196 report.record_count,
2197 plural(report.record_count),
2198 since,
2199 ));
2200 } else {
2201 out.push_str(&format!(
2202 "\n_Tracking since {since}. Local-only; resolution is a local-developer signal._\n",
2203 ));
2204 }
2205}
2206
2207#[must_use]
2209pub fn render_cross_repo_json(report: &CrossRepoImpactReport) -> String {
2210 let value = fallow_output::serialize_cross_repo_impact_json_output(
2211 report.clone(),
2212 crate::output_runtime::current_root_envelope_mode(),
2213 crate::output_runtime::telemetry_analysis_run_id().as_deref(),
2214 )
2215 .unwrap_or_else(
2216 |_| serde_json::json!({"error":"failed to serialize cross-repo impact report"}),
2217 );
2218 serde_json::to_string_pretty(&value).unwrap_or_else(|_| {
2219 "{\"error\":\"failed to serialize cross-repo impact report\"}".to_owned()
2220 })
2221}
2222
2223fn row_label(entry: &CrossRepoProjectEntry) -> String {
2226 cross_repo_label(entry)
2227}
2228
2229fn opt_count(c: Option<&ImpactCounts>) -> String {
2230 c.map_or_else(|| "-".to_owned(), |c| c.total_issues.to_string())
2231}
2232
2233fn row_trend(report: &ImpactReport) -> &'static str {
2234 report
2235 .project_trend
2236 .as_ref()
2237 .or(report.trend.as_ref())
2238 .map_or("-", |t| trend_arrow(t.direction))
2239}
2240
2241#[expect(
2245 clippy::format_push_string,
2246 reason = "small report renderer; readability over avoiding the extra allocation"
2247)]
2248#[must_use]
2249pub fn render_cross_repo_human(report: &CrossRepoImpactReport, limit: Option<usize>) -> String {
2250 let mut out = String::new();
2251 out.push_str("FALLOW IMPACT (ALL PROJECTS)\n\n");
2252
2253 if report.project_count == 0 {
2254 if report.unreadable_count > 0 {
2255 out.push_str(&format!(
2256 "No readable projects: skipped {} unreadable store{} (corrupt, or written by \
2257 a newer fallow). Upgrade fallow to read them.\n",
2258 report.unreadable_count,
2259 plural(report.unreadable_count),
2260 ));
2261 } else {
2262 out.push_str(
2263 "No projects tracked yet. Enable in a repo with `fallow impact enable`, or for \
2264 every project with `fallow impact default on`.\n",
2265 );
2266 }
2267 return out;
2268 }
2269
2270 out.push_str(&format!(
2271 "{} project{} tracked, {} with history\n\n",
2272 report.project_count,
2273 plural(report.project_count),
2274 report.tracked_count,
2275 ));
2276
2277 render_cross_repo_table(&mut out, report, limit);
2278 render_cross_repo_skipped(&mut out, report);
2279 render_cross_repo_totals(&mut out, report);
2280 out.push_str("\nLocal-only; never uploaded; accrues on this machine, not CI.\n");
2281 out
2282}
2283
2284#[expect(
2286 clippy::format_push_string,
2287 reason = "small report renderer; readability over avoiding the extra allocation"
2288)]
2289fn render_cross_repo_table(out: &mut String, report: &CrossRepoImpactReport, limit: Option<usize>) {
2290 if report.projects.is_empty() {
2291 return;
2292 }
2293 out.push_str(&format!(
2294 "{:<24}{:>8}{:>10}{:>11}{:>10}{:>7} {}\n",
2295 "PROJECT", "LATEST", "REPO-WIDE", "CONTAINED", "RESOLVED", "TREND", "LAST RUN",
2296 ));
2297 let rows = limit.map_or(report.projects.len(), |n| n.min(report.projects.len()));
2298 for entry in report.projects.iter().take(rows) {
2299 let mut label = row_label(entry);
2300 if label.chars().count() > 22 {
2301 label = format!("{}...", label.chars().take(19).collect::<String>());
2302 }
2303 let last = entry
2304 .last_recorded
2305 .as_deref()
2306 .map_or("-", date_only)
2307 .to_owned();
2308 out.push_str(&format!(
2309 "{:<24}{:>8}{:>10}{:>11}{:>10}{:>7} {}\n",
2310 label,
2311 opt_count(entry.report.surfacing.as_ref()),
2312 opt_count(entry.report.project_surfacing.as_ref()),
2313 entry.report.containment_count,
2314 entry.report.resolved_total,
2315 row_trend(&entry.report),
2316 last,
2317 ));
2318 }
2319 if let Some(n) = limit
2320 && report.projects.len() > n
2321 {
2322 out.push_str(&format!(
2323 " ... and {} more (raise --limit to show)\n",
2324 report.projects.len() - n,
2325 ));
2326 }
2327}
2328
2329#[expect(
2331 clippy::format_push_string,
2332 reason = "small report renderer; readability over avoiding the extra allocation"
2333)]
2334fn render_cross_repo_skipped(out: &mut String, report: &CrossRepoImpactReport) {
2335 let no_history = report.project_count.saturating_sub(report.tracked_count);
2336 if no_history > 0 {
2337 out.push_str(&format!(
2338 "\n{no_history} tracked project{} with no history yet\n",
2339 plural(no_history),
2340 ));
2341 }
2342 if report.unreadable_count > 0 {
2343 out.push_str(&format!(
2344 "skipped {} unreadable store{}\n",
2345 report.unreadable_count,
2346 plural(report.unreadable_count),
2347 ));
2348 }
2349}
2350
2351#[expect(
2353 clippy::format_push_string,
2354 reason = "small report renderer; readability over avoiding the extra allocation"
2355)]
2356fn render_cross_repo_totals(out: &mut String, report: &CrossRepoImpactReport) {
2357 let t = &report.totals;
2358 out.push_str("\nGRAND TOTALS\n");
2359 out.push_str(&format!(
2360 " Across {} tracked project{}: {} finding{} resolved, {} commit{} contained, {} marked intentional\n",
2361 report.tracked_count,
2362 plural(report.tracked_count),
2363 t.resolved_total,
2364 plural(t.resolved_total),
2365 t.containment_count,
2366 plural(t.containment_count),
2367 t.suppressed_total,
2368 ));
2369 if t.projects_with_baseline > 0 {
2370 out.push_str(&format!(
2371 " {} issue{} project-wide across {} project{} with a full-run baseline (as of each project's last full run)\n",
2372 t.project_wide_issues,
2373 plural(t.project_wide_issues),
2374 t.projects_with_baseline,
2375 plural(t.projects_with_baseline),
2376 ));
2377 }
2378}
2379
2380#[expect(
2382 clippy::format_push_string,
2383 reason = "small report renderer; readability over avoiding the extra allocation"
2384)]
2385#[must_use]
2386pub fn render_cross_repo_markdown(report: &CrossRepoImpactReport) -> String {
2387 let mut out = String::new();
2388 out.push_str("## Fallow impact (all projects)\n\n");
2389 if report.project_count == 0 {
2390 if report.unreadable_count > 0 {
2391 out.push_str(&format!(
2392 "No readable projects: skipped {} unreadable store{}.\n",
2393 report.unreadable_count,
2394 plural(report.unreadable_count),
2395 ));
2396 } else {
2397 out.push_str("No projects tracked yet.\n");
2398 }
2399 return out;
2400 }
2401 out.push_str(&format!(
2402 "{} project{} tracked, {} with history.\n\n",
2403 report.project_count,
2404 plural(report.project_count),
2405 report.tracked_count,
2406 ));
2407 if !report.projects.is_empty() {
2408 out.push_str("| Project | Latest | Repo-wide | Contained | Resolved | Last run |\n");
2409 out.push_str("|:--------|-------:|----------:|----------:|---------:|:---------|\n");
2410 for entry in &report.projects {
2411 out.push_str(&format!(
2412 "| {} | {} | {} | {} | {} | {} |\n",
2413 row_label(entry),
2414 opt_count(entry.report.surfacing.as_ref()),
2415 opt_count(entry.report.project_surfacing.as_ref()),
2416 entry.report.containment_count,
2417 entry.report.resolved_total,
2418 entry.last_recorded.as_deref().map_or("-", date_only),
2419 ));
2420 }
2421 }
2422 let t = &report.totals;
2423 out.push_str(&format!(
2424 "\n**Grand totals:** {} resolved, {} contained, {} marked intentional across {} tracked project{}",
2425 t.resolved_total,
2426 t.containment_count,
2427 t.suppressed_total,
2428 report.tracked_count,
2429 plural(report.tracked_count),
2430 ));
2431 if t.projects_with_baseline > 0 {
2432 out.push_str(&format!(
2433 "; {} issue{} project-wide across {} project{} with a full-run baseline (as of each project's last full run)",
2434 t.project_wide_issues,
2435 plural(t.project_wide_issues),
2436 t.projects_with_baseline,
2437 plural(t.projects_with_baseline),
2438 ));
2439 }
2440 out.push_str(".\n\n_Local-only; never uploaded; accrues on this machine, not CI._\n");
2441 out
2442}
2443
2444const fn plural(n: usize) -> &'static str {
2445 if n == 1 { "" } else { "s" }
2446}
2447
2448fn date_only(ts: &str) -> &str {
2454 ts.split_once('T').map_or(ts, |(date, _)| date)
2455}
2456
2457const fn trend_arrow(direction: ImpactTrendDirection) -> &'static str {
2461 match direction {
2462 ImpactTrendDirection::Improving => "down",
2463 ImpactTrendDirection::Declining => "up",
2464 ImpactTrendDirection::Stable => "flat",
2465 }
2466}
2467
2468#[cfg(test)]
2469mod tests {
2470 use super::*;
2471
2472 fn test_env() -> (tempfile::TempDir, tempfile::TempDir) {
2478 let config = tempfile::tempdir().unwrap();
2479 TEST_CONFIG_DIR.with(|c| *c.borrow_mut() = Some(config.path().to_path_buf()));
2480 let root = tempfile::tempdir().unwrap();
2481 (config, root)
2482 }
2483
2484 fn frontier_paths(store: &ImpactStore) -> FxHashSet<String> {
2487 store
2488 .frontier
2489 .values()
2490 .flat_map(|m| m.keys().cloned())
2491 .collect()
2492 }
2493
2494 fn clone_fingerprints(store: &ImpactStore) -> FxHashSet<String> {
2496 store
2497 .clone_frontier
2498 .values()
2499 .flat_map(|m| m.keys().cloned())
2500 .collect()
2501 }
2502
2503 fn seed_store_raw(root: &Path, bytes: &[u8]) {
2506 let path = store_path(root).expect("test config dir set");
2507 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2508 std::fs::write(&path, bytes).unwrap();
2509 }
2510
2511 fn summary(dead: usize, complexity: usize, dupes: usize) -> AuditSummary {
2512 AuditSummary {
2513 dead_code_issues: dead,
2514 dead_code_has_errors: dead > 0,
2515 complexity_findings: complexity,
2516 max_cyclomatic: None,
2517 duplication_clone_groups: dupes,
2518 }
2519 }
2520
2521 #[expect(
2523 clippy::too_many_arguments,
2524 reason = "test scaffold; positional record builder mirrors the AuditRunRecord fields, bundling adds churn with no production value"
2525 )]
2526 fn record_v1(
2527 root: &Path,
2528 summary: &AuditSummary,
2529 verdict: AuditVerdict,
2530 gate: bool,
2531 git_sha: Option<&str>,
2532 version: &str,
2533 timestamp: &str,
2534 ) {
2535 record_audit_run(
2536 root,
2537 summary,
2538 &AuditRunRecord {
2539 verdict,
2540 gate,
2541 git_sha,
2542 version,
2543 timestamp,
2544 attribution: None,
2545 },
2546 );
2547 }
2548
2549 fn touch(root: &Path, rel: &str) -> PathBuf {
2552 let p = root.join(rel);
2553 if let Some(parent) = p.parent() {
2554 std::fs::create_dir_all(parent).unwrap();
2555 }
2556 std::fs::write(&p, b"x").unwrap();
2557 p
2558 }
2559
2560 fn fi(path: &Path, kind: &'static str, symbol: &str) -> FindingInput {
2561 FindingInput {
2562 path: path.to_path_buf(),
2563 kind,
2564 symbol: Some(symbol.to_owned()),
2565 }
2566 }
2567
2568 fn supp(path: &Path, kind: &str) -> ActiveSuppression {
2569 ActiveSuppression {
2570 path: path.to_path_buf(),
2571 kind: Some(kind.to_owned()),
2572 is_file_level: false,
2573 reason: None,
2574 comment_line: 3,
2575 }
2576 }
2577
2578 fn run(
2580 root: &Path,
2581 changed: &[&Path],
2582 findings: Vec<FindingInput>,
2583 clones: Vec<CloneInput>,
2584 supps: &[ActiveSuppression],
2585 ts: &str,
2586 ) {
2587 let changed_files: Vec<PathBuf> = changed.iter().map(|p| p.to_path_buf()).collect();
2588 let input = AttributionInput {
2589 root,
2590 scope: Scope::ChangedFiles(&changed_files),
2591 findings,
2592 clones,
2593 suppressions: supps,
2594 };
2595 record_audit_run(
2596 root,
2597 &summary(0, 0, 0),
2598 &AuditRunRecord {
2599 verdict: AuditVerdict::Pass,
2600 gate: true,
2601 git_sha: Some("sha"),
2602 version: "2.0.0",
2603 timestamp: ts,
2604 attribution: Some(&input),
2605 },
2606 );
2607 }
2608
2609 #[test]
2610 fn disabled_store_does_not_record() {
2611 let (_config, dir) = test_env();
2612 let root = dir.path();
2613 record_v1(
2614 root,
2615 &summary(3, 1, 0),
2616 AuditVerdict::Fail,
2617 true,
2618 Some("abc1234"),
2619 "2.0.0",
2620 "2026-05-29T10:00:00Z",
2621 );
2622 let store = load(root);
2623 assert!(store.records.is_empty());
2624 assert!(!store.enabled);
2625 }
2626
2627 #[test]
2628 fn enable_and_disable_record_the_explicit_decision() {
2629 let (_config, dir) = test_env();
2630 let root = dir.path();
2631 assert!(!load(root).explicit_decision, "fresh store: never asked");
2632
2633 disable(root);
2635 let store = load(root);
2636 assert!(!store.enabled);
2637 assert!(store.explicit_decision);
2638 assert!(build_report(&store).explicit_decision);
2639 }
2640
2641 #[test]
2642 fn due_digest_stamps_and_respects_interval_and_gates() {
2643 let (_config, dir) = test_env();
2644 let root = dir.path();
2645
2646 assert!(take_due_digest(root).is_none());
2648 enable(root);
2649 assert!(take_due_digest(root).is_none(), "zero counters never nag");
2650
2651 let mut store = load(root);
2652 store.resolved_total = 3;
2653 store.containment.push(ContainmentEvent {
2654 blocked_at: "2026-06-11T00:00:00Z".to_string(),
2655 cleared_at: "2026-06-11T00:05:00Z".to_string(),
2656 git_sha: None,
2657 blocked_counts: ImpactCounts::default(),
2658 });
2659 save(&store, root);
2660
2661 let digest = take_due_digest(root).expect("first digest is due");
2662 assert_eq!(digest.containment_count, 1);
2663 assert_eq!(digest.resolved_total, 3);
2664 assert!(
2665 take_due_digest(root).is_none(),
2666 "stamped: not due again within the interval"
2667 );
2668
2669 let mut store = load(root);
2671 store.last_digest_epoch = Some(0);
2672 save(&store, root);
2673 assert!(take_due_digest(root).is_some());
2674 }
2675
2676 #[test]
2677 fn decline_onboarding_persists_in_existing_store() {
2678 let (_config, dir) = test_env();
2679 let root = dir.path();
2680
2681 assert!(decline_onboarding(root));
2682 assert!(!decline_onboarding(root));
2683
2684 let store = load(root);
2685 assert!(store.onboarding_declined);
2686 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
2687 assert!(!root.join(".gitignore").exists());
2689 let report = build_report(&store);
2690 assert!(report.onboarding_declined);
2691 }
2692
2693 #[test]
2694 fn enable_then_record_accrues_history() {
2695 let (_config, dir) = test_env();
2696 let root = dir.path();
2697 assert!(enable(root));
2698 assert!(!enable(root)); record_v1(
2700 root,
2701 &summary(2, 1, 0),
2702 AuditVerdict::Warn,
2703 false,
2704 None,
2705 "2.0.0",
2706 "2026-05-29T10:00:00Z",
2707 );
2708 let store = load(root);
2709 assert_eq!(store.records.len(), 1);
2710 assert_eq!(store.records[0].counts.total_issues, 3);
2711 assert_eq!(
2712 store.first_recorded.as_deref(),
2713 Some("2026-05-29T10:00:00Z")
2714 );
2715 }
2716
2717 #[test]
2718 fn record_is_a_noop_in_ci() {
2719 let (_config, dir) = test_env();
2725 let root = dir.path();
2726 assert!(enable(root));
2727 TEST_FORCE_CI.with(|c| c.set(true));
2728 record_v1(
2729 root,
2730 &summary(2, 1, 0),
2731 AuditVerdict::Warn,
2732 false,
2733 None,
2734 "2.0.0",
2735 "2026-05-29T10:00:00Z",
2736 );
2737 TEST_FORCE_CI.with(|c| c.set(false));
2738 let store = load(root);
2739 assert_eq!(store.records.len(), 0, "impact must not record while in CI");
2740 }
2741
2742 #[test]
2743 fn enable_writes_nothing_into_the_repo() {
2744 let (_config, dir) = test_env();
2745 let root = dir.path();
2746 enable(root);
2747 assert!(
2750 !root.join(".gitignore").exists(),
2751 "enable must not create or modify the repo's .gitignore"
2752 );
2753 assert!(
2754 !root.join(".fallow").exists(),
2755 "enable must not create an in-repo .fallow/ dir"
2756 );
2757 let store = load(root);
2759 assert!(store.enabled);
2760 assert!(store.explicit_decision);
2761 assert!(resolve_enabled(&store).0);
2762 }
2763
2764 #[test]
2765 fn single_record_yields_no_trend_no_spike() {
2766 let mut store = ImpactStore {
2767 enabled: true,
2768 ..Default::default()
2769 };
2770 store.records.push(ImpactRecord {
2771 timestamp: "t0".into(),
2772 version: "2.0.0".into(),
2773 git_sha: None,
2774 verdict: "warn".into(),
2775 gate: false,
2776 counts: ImpactCounts {
2777 total_issues: 5,
2778 dead_code: 5,
2779 complexity: 0,
2780 duplication: 0,
2781 },
2782 });
2783 let report = build_report(&store);
2784 assert!(report.trend.is_none());
2785 assert_eq!(report.surfacing.unwrap().total_issues, 5);
2786 }
2787
2788 #[test]
2789 fn empty_store_report_is_first_run() {
2790 let store = ImpactStore::default();
2791 let report = build_report(&store);
2792 assert_eq!(report.record_count, 0);
2793 assert!(report.trend.is_none());
2794 assert!(report.surfacing.is_none());
2795 let human = render_human(&report);
2796 assert!(human.contains("off")); }
2798
2799 #[test]
2800 fn enabled_empty_store_shows_check_back() {
2801 let store = ImpactStore {
2802 enabled: true,
2803 ..Default::default()
2804 };
2805 let report = build_report(&store);
2806 let human = render_human(&report);
2807 assert!(human.contains("No history yet"));
2808 assert!(!human.contains("0 issues"));
2809 }
2810
2811 #[test]
2812 fn trend_improving_when_issues_drop() {
2813 let mut store = ImpactStore {
2814 enabled: true,
2815 ..Default::default()
2816 };
2817 for total in [8usize, 3usize] {
2818 store.records.push(ImpactRecord {
2819 timestamp: format!("t{total}"),
2820 version: "2.0.0".into(),
2821 git_sha: None,
2822 verdict: "warn".into(),
2823 gate: false,
2824 counts: ImpactCounts {
2825 total_issues: total,
2826 dead_code: total,
2827 complexity: 0,
2828 duplication: 0,
2829 },
2830 });
2831 }
2832 let report = build_report(&store);
2833 let trend = report.trend.unwrap();
2834 assert_eq!(trend.direction, ImpactTrendDirection::Improving);
2835 assert_eq!(trend.total_delta, -5);
2836 }
2837
2838 #[test]
2839 fn containment_blocked_then_cleared_records_one_event() {
2840 let (_config, dir) = test_env();
2841 let root = dir.path();
2842 enable(root);
2843 record_v1(
2844 root,
2845 &summary(2, 0, 0),
2846 AuditVerdict::Fail,
2847 true,
2848 Some("sha1"),
2849 "2.0.0",
2850 "t0",
2851 );
2852 let store = load(root);
2853 assert!(store.pending_containment.is_some());
2854 assert!(store.containment.is_empty());
2855
2856 record_v1(
2857 root,
2858 &summary(0, 0, 0),
2859 AuditVerdict::Pass,
2860 true,
2861 Some("sha2"),
2862 "2.0.0",
2863 "t1",
2864 );
2865 let store = load(root);
2866 assert!(store.pending_containment.is_none());
2867 assert_eq!(store.containment.len(), 1);
2868 assert_eq!(store.containment[0].blocked_at, "t0");
2869 assert_eq!(store.containment[0].cleared_at, "t1");
2870 }
2871
2872 #[test]
2873 fn non_gate_run_never_creates_containment() {
2874 let (_config, dir) = test_env();
2875 let root = dir.path();
2876 enable(root);
2877 record_v1(
2878 root,
2879 &summary(2, 0, 0),
2880 AuditVerdict::Fail,
2881 false,
2882 None,
2883 "2.0.0",
2884 "t0",
2885 );
2886 let store = load(root);
2887 assert!(store.pending_containment.is_none());
2888 assert!(store.containment.is_empty());
2889 }
2890
2891 #[test]
2892 fn corrupt_store_loads_as_default_no_panic() {
2893 let (_config, dir) = test_env();
2894 let root = dir.path();
2895 seed_store_raw(root, b"{ not valid json ][");
2896 let store = load(root);
2897 assert!(!store.enabled);
2898 assert!(store.records.is_empty());
2899 record_v1(
2900 root,
2901 &summary(1, 0, 0),
2902 AuditVerdict::Fail,
2903 true,
2904 None,
2905 "2.0.0",
2906 "t0",
2907 );
2908 }
2909
2910 #[test]
2911 fn records_are_bounded() {
2912 let mut store = ImpactStore {
2913 enabled: true,
2914 ..Default::default()
2915 };
2916 for i in 0..(MAX_RECORDS + 50) {
2917 store.records.push(ImpactRecord {
2918 timestamp: format!("t{i}"),
2919 version: "2.0.0".into(),
2920 git_sha: None,
2921 verdict: "pass".into(),
2922 gate: false,
2923 counts: ImpactCounts::default(),
2924 });
2925 }
2926 compact(&mut store);
2927 assert_eq!(store.records.len(), MAX_RECORDS);
2928 assert_eq!(store.records[0].timestamp, "t50");
2929 }
2930
2931 #[test]
2932 fn report_always_carries_schema_version() {
2933 let empty = build_report(&ImpactStore::default());
2934 assert_eq!(empty.schema_version, ImpactReportSchemaVersion::V1);
2935 let json = render_json(&empty);
2936 assert!(
2937 json.contains("\"schema_version\": \"1\""),
2938 "schema_version must be present (as the \"1\" const) even when disabled: {json}"
2939 );
2940
2941 let mut store = ImpactStore {
2942 enabled: true,
2943 ..Default::default()
2944 };
2945 store.records.push(ImpactRecord {
2946 timestamp: "2026-05-29T10:00:00Z".into(),
2947 version: "2.0.0".into(),
2948 git_sha: None,
2949 verdict: "pass".into(),
2950 gate: false,
2951 counts: ImpactCounts::default(),
2952 });
2953 assert_eq!(
2954 build_report(&store).schema_version,
2955 ImpactReportSchemaVersion::V1
2956 );
2957 }
2958
2959 #[test]
2960 fn date_only_trims_iso_timestamp() {
2961 assert_eq!(date_only("2026-05-29T18:15:23Z"), "2026-05-29");
2962 assert_eq!(date_only("2026-05-29"), "2026-05-29");
2963 assert_eq!(date_only("the first run"), "the first run");
2964 }
2965
2966 #[test]
2967 fn human_footer_shows_date_only() {
2968 let mut store = ImpactStore {
2969 enabled: true,
2970 ..Default::default()
2971 };
2972 store.first_recorded = Some("2026-05-29T18:15:23Z".into());
2973 store.records.push(ImpactRecord {
2974 timestamp: "2026-05-29T18:15:23Z".into(),
2975 version: "2.0.0".into(),
2976 git_sha: None,
2977 verdict: "pass".into(),
2978 gate: false,
2979 counts: ImpactCounts::default(),
2980 });
2981 let report = build_report(&store);
2982 let human = render_human(&report);
2983 assert!(
2984 human.contains("since 2026-05-29.") && !human.contains("18:15:23"),
2985 "human footer must show date-only: {human}"
2986 );
2987 let md = render_markdown(&report);
2988 assert!(
2989 md.contains("since 2026-05-29.") && !md.contains("18:15:23"),
2990 "markdown footer must show date-only: {md}"
2991 );
2992 }
2993
2994 #[test]
2995 fn future_schema_version_store_loads_without_panic_or_loss() {
2996 let (_config, dir) = test_env();
2997 let root = dir.path();
2998 let future = format!(
2999 "{{\"schema_version\":{},\"enabled\":true,\"records\":[],\"containment\":[]}}",
3000 STORE_SCHEMA_VERSION + 1
3001 );
3002 seed_store_raw(root, future.as_bytes());
3003 let store = load(root);
3004 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION + 1);
3005 assert!(
3006 store.enabled,
3007 "future-version store must not degrade to default"
3008 );
3009 }
3010
3011 #[test]
3012 fn removed_finding_is_credited_as_resolved() {
3013 let (_config, dir) = test_env();
3014 let root = dir.path();
3015 enable(root);
3016 let a = touch(root, "src/a.ts");
3017 run(
3018 root,
3019 &[&a],
3020 vec![fi(&a, "unused-export", "foo")],
3021 vec![],
3022 &[],
3023 "t0",
3024 );
3025 assert_eq!(
3026 load(root).resolved_total,
3027 0,
3028 "first run only establishes a baseline"
3029 );
3030 run(root, &[&a], vec![], vec![], &[], "t1");
3031 let store = load(root);
3032 assert_eq!(store.resolved_total, 1);
3033 assert_eq!(store.suppressed_total, 0);
3034 assert_eq!(store.recent_resolved.len(), 1);
3035 assert_eq!(store.recent_resolved[0].kind, "unused-export");
3036 assert_eq!(store.recent_resolved[0].symbol.as_deref(), Some("foo"));
3037 assert_eq!(store.recent_resolved[0].path, "src/a.ts");
3038 }
3039
3040 #[test]
3041 fn suppressed_finding_is_not_a_win() {
3042 let (_config, dir) = test_env();
3043 let root = dir.path();
3044 enable(root);
3045 let a = touch(root, "src/a.ts");
3046 run(
3047 root,
3048 &[&a],
3049 vec![fi(&a, "unused-export", "foo")],
3050 vec![],
3051 &[],
3052 "t0",
3053 );
3054 run(
3055 root,
3056 &[&a],
3057 vec![],
3058 vec![],
3059 &[supp(&a, "unused-export")],
3060 "t1",
3061 );
3062 let store = load(root);
3063 assert_eq!(
3064 store.resolved_total, 0,
3065 "a suppression must never count as a win"
3066 );
3067 assert_eq!(store.suppressed_total, 1);
3068 }
3069
3070 #[test]
3071 fn fix_and_suppress_same_kind_credits_zero_resolved() {
3072 let (_config, dir) = test_env();
3073 let root = dir.path();
3074 enable(root);
3075 let a = touch(root, "src/a.ts");
3076 run(
3077 root,
3078 &[&a],
3079 vec![
3080 fi(&a, "unused-export", "foo"),
3081 fi(&a, "unused-export", "bar"),
3082 ],
3083 vec![],
3084 &[],
3085 "t0",
3086 );
3087 run(
3088 root,
3089 &[&a],
3090 vec![],
3091 vec![],
3092 &[supp(&a, "unused-export")],
3093 "t1",
3094 );
3095 let store = load(root);
3096 assert_eq!(store.resolved_total, 0);
3097 assert_eq!(store.suppressed_total, 2);
3098 }
3099
3100 #[test]
3101 fn within_file_move_is_not_resolved() {
3102 let (_config, dir) = test_env();
3103 let root = dir.path();
3104 enable(root);
3105 let a = touch(root, "src/a.ts");
3106 run(
3107 root,
3108 &[&a],
3109 vec![fi(&a, "unused-export", "foo")],
3110 vec![],
3111 &[],
3112 "t0",
3113 );
3114 run(
3115 root,
3116 &[&a],
3117 vec![fi(&a, "unused-export", "foo")],
3118 vec![],
3119 &[],
3120 "t1",
3121 );
3122 let store = load(root);
3123 assert_eq!(store.resolved_total, 0);
3124 assert_eq!(store.suppressed_total, 0);
3125 }
3126
3127 #[test]
3128 fn cross_file_move_in_same_run_is_not_resolved() {
3129 let (_config, dir) = test_env();
3130 let root = dir.path();
3131 enable(root);
3132 let a = touch(root, "src/a.ts");
3133 let b = touch(root, "src/b.ts");
3134 run(
3135 root,
3136 &[&a],
3137 vec![fi(&a, "unused-export", "foo")],
3138 vec![],
3139 &[],
3140 "t0",
3141 );
3142 run(
3143 root,
3144 &[&a, &b],
3145 vec![fi(&b, "unused-export", "foo")],
3146 vec![],
3147 &[],
3148 "t1",
3149 );
3150 assert_eq!(
3151 load(root).resolved_total,
3152 0,
3153 "a cross-file move is not a resolution"
3154 );
3155 }
3156
3157 #[test]
3158 fn cross_run_move_uncredits_the_prior_resolution() {
3159 let (_config, dir) = test_env();
3160 let root = dir.path();
3161 enable(root);
3162 let a = touch(root, "src/a.ts");
3163 let b = touch(root, "src/b.ts");
3164 run(
3165 root,
3166 &[&a],
3167 vec![fi(&a, "unused-export", "foo")],
3168 vec![],
3169 &[],
3170 "t0",
3171 );
3172 run(root, &[&a], vec![], vec![], &[], "t1");
3173 assert_eq!(
3174 load(root).resolved_total,
3175 1,
3176 "source disappearance credited in run A"
3177 );
3178 run(
3179 root,
3180 &[&b],
3181 vec![fi(&b, "unused-export", "foo")],
3182 vec![],
3183 &[],
3184 "t2",
3185 );
3186 let store = load(root);
3187 assert_eq!(
3188 store.resolved_total, 0,
3189 "cross-run move must un-credit the phantom win"
3190 );
3191 assert!(
3192 store.recent_resolved.is_empty(),
3193 "the stale resolution event is dropped"
3194 );
3195 }
3196
3197 #[test]
3198 fn resolved_complexity_finding_and_suppressed_complexity() {
3199 let (_config, dir) = test_env();
3200 let root = dir.path();
3201 enable(root);
3202 let a = touch(root, "src/a.ts");
3203 run(
3204 root,
3205 &[&a],
3206 vec![fi(&a, "complexity", "bigFn")],
3207 vec![],
3208 &[],
3209 "t0",
3210 );
3211 run(root, &[&a], vec![], vec![], &[supp(&a, "complexity")], "t1");
3212 let store = load(root);
3213 assert_eq!(store.resolved_total, 0);
3214 assert_eq!(store.suppressed_total, 1);
3215
3216 let b = touch(root, "src/b.ts");
3217 run(
3218 root,
3219 &[&b],
3220 vec![fi(&b, "complexity", "huge")],
3221 vec![],
3222 &[],
3223 "t2",
3224 );
3225 run(root, &[&b], vec![], vec![], &[], "t3");
3226 assert_eq!(load(root).resolved_total, 1);
3227 }
3228
3229 #[test]
3230 fn resolved_duplication_clone_group() {
3231 let (_config, dir) = test_env();
3232 let root = dir.path();
3233 enable(root);
3234 let a = touch(root, "src/a.ts");
3235 let b = touch(root, "src/b.ts");
3236 let clone = CloneInput {
3237 fingerprint: "dup:abc12345".to_owned(),
3238 instance_paths: vec![a.clone(), b],
3239 };
3240 run(root, &[&a], vec![], vec![clone], &[], "t0");
3241 run(root, &[&a], vec![], vec![], &[], "t1");
3242 let store = load(root);
3243 assert_eq!(store.resolved_total, 1);
3244 assert_eq!(store.recent_resolved[0].kind, "code-duplication");
3245 }
3246
3247 #[test]
3248 fn blanket_suppression_covers_any_kind() {
3249 let (_config, dir) = test_env();
3250 let root = dir.path();
3251 enable(root);
3252 let a = touch(root, "src/a.ts");
3253 run(
3254 root,
3255 &[&a],
3256 vec![fi(&a, "unused-export", "foo")],
3257 vec![],
3258 &[],
3259 "t0",
3260 );
3261 let blanket = ActiveSuppression {
3262 path: a.clone(),
3263 kind: None,
3264 is_file_level: true,
3265 reason: None,
3266 comment_line: 1,
3267 };
3268 run(root, &[&a], vec![], vec![], &[blanket], "t1");
3269 let store = load(root);
3270 assert_eq!(store.resolved_total, 0);
3271 assert_eq!(store.suppressed_total, 1);
3272 }
3273
3274 #[test]
3275 fn v1_store_loads_and_upgrades_to_v2() {
3276 let (_config, dir) = test_env();
3277 let root = dir.path();
3278 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":[]}"#;
3279 seed_store_raw(root, v1.as_bytes());
3280 let store = load(root);
3281 assert_eq!(store.schema_version, 1);
3282 assert!(store.frontier.is_empty());
3283 assert_eq!(store.resolved_total, 0);
3284 let a = touch(root, "src/a.ts");
3285 run(
3286 root,
3287 &[&a],
3288 vec![fi(&a, "unused-export", "foo")],
3289 vec![],
3290 &[],
3291 "t1",
3292 );
3293 let store = load(root);
3294 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
3295 assert!(frontier_paths(&store).contains("src/a.ts"));
3296 }
3297
3298 #[test]
3299 fn recent_resolved_is_bounded() {
3300 let mut store = ImpactStore {
3301 enabled: true,
3302 ..Default::default()
3303 };
3304 for i in 0..(MAX_RECENT_RESOLVED + 25) {
3305 store.recent_resolved.push(ResolutionEvent {
3306 kind: "unused-export".into(),
3307 path: format!("src/f{i}.ts"),
3308 symbol: Some(format!("s{i}")),
3309 git_sha: None,
3310 timestamp: format!("t{i}"),
3311 });
3312 }
3313 bound_recent_resolved(&mut store);
3314 assert_eq!(store.recent_resolved.len(), MAX_RECENT_RESOLVED);
3315 assert_eq!(store.recent_resolved[0].path, "src/f25.ts");
3316 }
3317
3318 #[test]
3319 fn frontier_prunes_deleted_files() {
3320 let (_config, dir) = test_env();
3321 let root = dir.path();
3322 enable(root);
3323 let a = touch(root, "src/a.ts");
3324 run(
3325 root,
3326 &[&a],
3327 vec![fi(&a, "unused-export", "foo")],
3328 vec![],
3329 &[],
3330 "t0",
3331 );
3332 assert!(frontier_paths(&load(root)).contains("src/a.ts"));
3333 std::fs::remove_file(&a).unwrap();
3334 let b = touch(root, "src/b.ts");
3335 run(root, &[&b], vec![], vec![], &[], "t1");
3336 assert!(!frontier_paths(&load(root)).contains("src/a.ts"));
3337 }
3338
3339 #[test]
3340 fn honest_empty_state_before_attribution_baseline() {
3341 let store = ImpactStore {
3342 enabled: true,
3343 records: vec![ImpactRecord {
3344 timestamp: "t0".into(),
3345 version: "2.0.0".into(),
3346 git_sha: None,
3347 verdict: "warn".into(),
3348 gate: false,
3349 counts: ImpactCounts::default(),
3350 }],
3351 ..Default::default()
3352 };
3353 let report = build_report(&store);
3354 assert!(!report.attribution_active);
3355 let human = render_human(&report);
3356 assert!(human.contains("resolution tracking starts from your next gate run"));
3357 assert!(!human.contains("0 finding"));
3358 }
3359
3360 #[test]
3361 fn suppression_only_state_renders_under_a_resolved_header() {
3362 let report = ImpactReport {
3363 schema_version: ImpactReportSchemaVersion::V1,
3364 enabled: true,
3365 enabled_source: EnabledSource::Project,
3366 record_count: 2,
3367 meta: None,
3368 first_recorded: Some("2026-05-29T10:00:00Z".into()),
3369 latest_git_sha: None,
3370 surfacing: Some(ImpactCounts::default()),
3371 trend: None,
3372 project_surfacing: None,
3373 project_trend: None,
3374 containment_count: 0,
3375 recent_containment: vec![],
3376 resolved_total: 0,
3377 suppressed_total: 2,
3378 recent_resolved: vec![],
3379 attribution_active: true,
3380 onboarding_declined: false,
3381 explicit_decision: false,
3382 };
3383 let human = render_human(&report);
3384 let resolved_idx = human.find(" RESOLVED").expect("RESOLVED header present");
3385 let supp_idx = human
3386 .find("2 findings you marked intentional")
3387 .expect("suppression line present");
3388 assert!(
3389 resolved_idx < supp_idx,
3390 "suppression must render under RESOLVED"
3391 );
3392 assert!(human.contains("none yet"));
3393
3394 let md = render_markdown(&report);
3395 assert!(
3396 md.contains("- **Resolved:**"),
3397 "markdown always has a Resolved bullet"
3398 );
3399 assert!(md.contains("- **Marked intentional:** 2 finding"));
3400 }
3401
3402 fn clone_at(fingerprint: &str, paths: &[&Path]) -> CloneInput {
3404 CloneInput {
3405 fingerprint: fingerprint.to_owned(),
3406 instance_paths: paths.iter().map(|p| p.to_path_buf()).collect(),
3407 }
3408 }
3409
3410 fn run_wp(
3414 root: &Path,
3415 findings: Vec<FindingInput>,
3416 clones: Vec<CloneInput>,
3417 supps: &[ActiveSuppression],
3418 ts: &str,
3419 ) {
3420 let input = AttributionInput {
3421 root,
3422 scope: Scope::WholeProject,
3423 findings,
3424 clones,
3425 suppressions: supps,
3426 };
3427 record_combined_run(
3428 root,
3429 ImpactCounts::default(),
3430 Some("sha"),
3431 "2.0.0",
3432 ts,
3433 Some(&input),
3434 );
3435 }
3436
3437 #[test]
3438 fn whole_project_run_does_not_double_credit_after_audit() {
3439 let (_config, dir) = test_env();
3440 let root = dir.path();
3441 enable(root);
3442 let a = touch(root, "src/a.ts");
3443 let b = touch(root, "src/b.ts");
3444 run(
3445 root,
3446 &[&a, &b],
3447 vec![],
3448 vec![clone_at("dup:abc", &[&a, &b])],
3449 &[],
3450 "t1",
3451 );
3452 assert_eq!(clone_fingerprints(&load(root)).len(), 1);
3453
3454 run(root, &[&a, &b], vec![], vec![], &[], "t2");
3455 assert_eq!(load(root).resolved_total, 1);
3456 assert!(load(root).clone_frontier.is_empty());
3457
3458 run_wp(root, vec![], vec![], &[], "t3");
3459 assert_eq!(
3460 load(root).resolved_total,
3461 1,
3462 "whole-project run re-credited a resolution"
3463 );
3464 }
3465
3466 #[test]
3467 fn whole_project_run_credits_suppressed_not_resolved() {
3468 let (_config, dir) = test_env();
3469 let root = dir.path();
3470 enable(root);
3471 let util = touch(root, "src/util.ts");
3472 run(
3473 root,
3474 &[&util],
3475 vec![fi(&util, "unused-export", "dead")],
3476 vec![],
3477 &[],
3478 "t1",
3479 );
3480 assert_eq!(frontier_paths(&load(root)).len(), 1);
3481
3482 run_wp(root, vec![], vec![], &[supp(&util, "unused-export")], "t2");
3483 let store = load(root);
3484 assert_eq!(
3485 store.suppressed_total, 1,
3486 "suppressed finding not counted suppressed"
3487 );
3488 assert_eq!(
3489 store.resolved_total, 0,
3490 "suppressed finding wrongly counted resolved"
3491 );
3492 }
3493
3494 #[test]
3495 fn clone_reshape_three_to_two_not_credited_as_resolved() {
3496 let (_config, dir) = test_env();
3497 let root = dir.path();
3498 enable(root);
3499 let a = touch(root, "src/a.ts");
3500 let b = touch(root, "src/b.ts");
3501 let c = touch(root, "src/c.ts");
3502 run(
3503 root,
3504 &[&a, &b, &c],
3505 vec![],
3506 vec![clone_at("dup:aaa", &[&a, &b, &c])],
3507 &[],
3508 "t1",
3509 );
3510 assert_eq!(clone_fingerprints(&load(root)).len(), 1);
3511
3512 run_wp(
3513 root,
3514 vec![],
3515 vec![clone_at("dup:bbb", &[&a, &b])],
3516 &[],
3517 "t2",
3518 );
3519 let store = load(root);
3520 assert_eq!(
3521 store.resolved_total, 0,
3522 "clone reshape miscredited as resolved"
3523 );
3524 assert!(clone_fingerprints(&store).contains("dup:bbb"));
3525 assert!(!clone_fingerprints(&store).contains("dup:aaa"));
3526 }
3527
3528 fn rcounts(total: usize, dead: usize, complexity: usize, dup: usize) -> ImpactCounts {
3529 ImpactCounts {
3530 total_issues: total,
3531 dead_code: dead,
3532 complexity,
3533 duplication: dup,
3534 }
3535 }
3536
3537 fn rtrend(prev: usize, cur: usize) -> TrendSummary {
3538 TrendSummary {
3539 direction: direction_for(cur as i64 - prev as i64),
3540 total_delta: cur as i64 - prev as i64,
3541 previous_total: prev,
3542 current_total: cur,
3543 }
3544 }
3545
3546 #[expect(
3548 clippy::too_many_arguments,
3549 reason = "test scaffold; positional ImpactReport builder, bundling adds churn with no production value"
3550 )]
3551 fn rreport(
3552 record_count: usize,
3553 first_recorded: Option<&str>,
3554 surfacing: Option<ImpactCounts>,
3555 trend: Option<TrendSummary>,
3556 project_surfacing: Option<ImpactCounts>,
3557 project_trend: Option<TrendSummary>,
3558 attribution_active: bool,
3559 ) -> ImpactReport {
3560 ImpactReport {
3561 schema_version: ImpactReportSchemaVersion::V1,
3562 enabled: true,
3563 enabled_source: EnabledSource::Project,
3564 record_count,
3565 meta: None,
3566 first_recorded: first_recorded.map(ToOwned::to_owned),
3567 latest_git_sha: None,
3568 surfacing,
3569 trend,
3570 project_surfacing,
3571 project_trend,
3572 containment_count: 0,
3573 recent_containment: vec![],
3574 resolved_total: 0,
3575 suppressed_total: 0,
3576 recent_resolved: vec![],
3577 attribution_active,
3578 onboarding_declined: false,
3579 explicit_decision: false,
3580 }
3581 }
3582
3583 #[test]
3584 fn render_human_project_only_store_shows_whole_project_not_empty_state() {
3585 let r = rreport(
3586 0,
3587 Some("2026-05-30T10:00:00Z"),
3588 None,
3589 None,
3590 Some(rcounts(1, 1, 0, 0)),
3591 None,
3592 true,
3593 );
3594 let human = render_human(&r);
3595 assert!(
3596 human.contains("WHOLE PROJECT (whole-repo context, not a to-do)"),
3597 "project-only must render the labeled section"
3598 );
3599 assert!(human.contains("1 issue across the whole project"));
3600 assert!(
3601 human.contains("project trend starts after your next full `fallow` run"),
3602 "single project record => no trend line, shows the next-run hint"
3603 );
3604 assert!(human.contains("Tracking since 2026-05-30"));
3605 assert!(
3606 !human.contains("No history yet"),
3607 "must not show the empty-state copy"
3608 );
3609 assert!(
3610 !human.contains("LATEST RUN"),
3611 "no changed-file track recorded"
3612 );
3613 assert!(
3614 !human.contains("recorded audit run"),
3615 "no audit runs => no changed-file footer"
3616 );
3617 }
3618
3619 #[test]
3620 fn render_human_both_tracks_label_actionable_vs_context() {
3621 let r = rreport(
3622 3,
3623 Some("2026-05-29T10:00:00Z"),
3624 Some(rcounts(4, 4, 0, 0)),
3625 Some(rtrend(6, 4)),
3626 Some(rcounts(40, 30, 5, 5)),
3627 Some(rtrend(45, 40)),
3628 true,
3629 );
3630 let human = render_human(&r);
3631 let latest = human
3632 .find("LATEST RUN (changed files, act on these now)")
3633 .expect("LATEST RUN labeled actionable");
3634 let whole = human
3635 .find("WHOLE PROJECT (whole-repo context, not a to-do)")
3636 .expect("WHOLE PROJECT labeled context");
3637 assert!(
3638 latest < whole,
3639 "changed-file section renders before whole-project"
3640 );
3641 assert!(human.contains("45 -> 40 (down) across your last two full runs"));
3642 assert!(human.contains("advances only on your local full `fallow` runs, not CI"));
3643 }
3644
3645 #[test]
3646 fn render_markdown_project_only_store_shows_whole_project_not_empty_state() {
3647 let r = rreport(
3648 0,
3649 Some("2026-05-30T10:00:00Z"),
3650 None,
3651 None,
3652 Some(rcounts(1, 1, 0, 0)),
3653 None,
3654 true,
3655 );
3656 let md = render_markdown(&r);
3657 assert!(
3658 md.contains(
3659 "- **Whole project (whole-repo context, last full `fallow` run):** 1 issue"
3660 ),
3661 "project-only md must render the labeled whole-project line"
3662 );
3663 assert!(
3664 !md.contains("No history yet"),
3665 "project-only md must not show empty state"
3666 );
3667 assert!(md.contains("Tracking since 2026-05-30"));
3668 }
3669
3670 #[test]
3671 fn resolve_enabled_precedence_table() {
3672 let (_config, _dir) = test_env();
3673 let on = ImpactStore {
3675 enabled: true,
3676 ..Default::default()
3677 };
3678 assert_eq!(resolve_enabled(&on), (true, EnabledSource::Project));
3679
3680 let off_explicit = ImpactStore {
3682 enabled: false,
3683 explicit_decision: true,
3684 ..Default::default()
3685 };
3686 assert_eq!(
3687 resolve_enabled(&off_explicit),
3688 (false, EnabledSource::Project)
3689 );
3690
3691 let never = ImpactStore::default();
3693 assert_eq!(resolve_enabled(&never), (false, EnabledSource::Default));
3694
3695 assert!(set_global_default(true));
3697 assert_eq!(resolve_enabled(&never), (true, EnabledSource::User));
3698 assert_eq!(
3700 resolve_enabled(&off_explicit),
3701 (false, EnabledSource::Project)
3702 );
3703 }
3704
3705 #[test]
3706 fn human_report_explains_user_global_default() {
3707 let (_config, _dir) = test_env();
3708 set_global_default(true);
3709 let report = build_report(&ImpactStore::default());
3711 assert_eq!(report.enabled_source, EnabledSource::User);
3712 let human = render_human(&report);
3713 assert!(
3714 human.contains("Enabled by your user-global default"),
3715 "human report must explain a global-default enable: {human}"
3716 );
3717 let project = build_report(&ImpactStore {
3719 enabled: true,
3720 explicit_decision: true,
3721 ..Default::default()
3722 });
3723 assert_eq!(project.enabled_source, EnabledSource::Project);
3724 assert!(!render_human(&project).contains("user-global default"));
3725 }
3726
3727 #[test]
3728 fn global_default_round_trips() {
3729 let (_config, _dir) = test_env();
3730 assert!(!load_global_default());
3731 assert!(set_global_default(true));
3732 assert!(load_global_default());
3733 assert!(!set_global_default(true)); assert!(set_global_default(false));
3735 assert!(!load_global_default());
3736 }
3737
3738 #[test]
3739 fn global_default_records_without_per_repo_enable() {
3740 let (_config, dir) = test_env();
3741 let root = dir.path();
3742 set_global_default(true);
3743 record_v1(
3745 root,
3746 &summary(2, 0, 0),
3747 AuditVerdict::Warn,
3748 false,
3749 None,
3750 "2.0.0",
3751 "t0",
3752 );
3753 let report = build_report(&load(root));
3754 assert!(report.enabled);
3755 assert_eq!(report.enabled_source, EnabledSource::User);
3756 assert_eq!(report.record_count, 1);
3757 }
3758
3759 #[test]
3760 fn legacy_in_repo_store_is_migrated_on_first_load() {
3761 let (_config, dir) = test_env();
3762 let root = dir.path();
3763 let legacy = r#"{"schema_version":3,"enabled":true,"explicit_decision":true,
3765 "records":[{"timestamp":"t0","version":"2.0.0","verdict":"warn","gate":false,
3766 "counts":{"total_issues":1,"dead_code":1,"complexity":0,"duplication":0}}],
3767 "resolved_total":2,
3768 "frontier":{"src/a.ts":{"findings":[{"id":"x","kind":"unused-export","symbol":"foo"}],"suppressions":[]}},
3769 "containment":[]}"#;
3770 std::fs::create_dir_all(root.join(".fallow")).unwrap();
3771 std::fs::write(legacy_store_path(root), legacy).unwrap();
3772
3773 let store = load(root);
3774 assert!(store.enabled);
3775 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
3776 assert_eq!(store.records.len(), 1);
3777 assert_eq!(store.resolved_total, 2);
3778 assert!(frontier_paths(&store).contains("src/a.ts"));
3780 assert!(store_path(root).is_some_and(|p| p.exists()));
3783 let again = load(root);
3784 assert_eq!(again.records.len(), 1);
3785 }
3786
3787 #[test]
3788 fn reset_removes_only_this_project() {
3789 let (_config, dir) = test_env();
3790 let root = dir.path();
3791 enable(root);
3792 record_v1(
3793 root,
3794 &summary(1, 0, 0),
3795 AuditVerdict::Warn,
3796 false,
3797 None,
3798 "2.0.0",
3799 "t0",
3800 );
3801 assert_eq!(load(root).records.len(), 1);
3802 assert!(reset(root));
3803 assert!(load(root).records.is_empty());
3804 assert!(!reset(root)); }
3806
3807 #[test]
3808 fn reset_all_clears_dir_but_keeps_global_default() {
3809 let (_config, dir) = test_env();
3810 let root = dir.path();
3811 set_global_default(true);
3812 enable(root);
3813 assert!(load(root).enabled);
3814 assert!(reset_all());
3815 assert!(load_global_default());
3817 }
3818
3819 fn aggregate_env() -> tempfile::TempDir {
3823 let config = tempfile::tempdir().unwrap();
3824 TEST_CONFIG_DIR.with(|c| *c.borrow_mut() = Some(config.path().to_path_buf()));
3825 config
3826 }
3827
3828 fn seed_store(key: &str, store: &ImpactStore) {
3830 let dir = impact_config_dir().unwrap().join("impact");
3831 std::fs::create_dir_all(&dir).unwrap();
3832 std::fs::write(
3833 dir.join(format!("{key}.json")),
3834 serde_json::to_string_pretty(store).unwrap(),
3835 )
3836 .unwrap();
3837 }
3838
3839 fn store_with(
3840 label: &str,
3841 resolved: usize,
3842 contained: usize,
3843 latest_ts: &str,
3844 latest_issues: usize,
3845 ) -> ImpactStore {
3846 let mut s = ImpactStore {
3847 enabled: true,
3848 explicit_decision: true,
3849 resolved_total: resolved,
3850 label: Some(label.to_owned()),
3851 ..Default::default()
3852 };
3853 s.records.push(ImpactRecord {
3854 timestamp: latest_ts.to_owned(),
3855 version: "2.0.0".to_owned(),
3856 git_sha: None,
3857 verdict: "warn".to_owned(),
3858 gate: false,
3859 counts: ImpactCounts::from_combined(latest_issues, 0, 0),
3860 });
3861 for _ in 0..contained {
3862 s.containment.push(ContainmentEvent {
3863 blocked_at: "t0".to_owned(),
3864 cleared_at: "t1".to_owned(),
3865 git_sha: None,
3866 blocked_counts: ImpactCounts::default(),
3867 });
3868 }
3869 s
3870 }
3871
3872 #[test]
3873 fn repo_basename_returns_last_component_only() {
3874 assert_eq!(
3875 repo_basename(Path::new("/a/b/myrepo/.git")).as_deref(),
3876 Some("myrepo")
3877 );
3878 assert_eq!(
3879 repo_basename(Path::new("/a/b/proj")).as_deref(),
3880 Some("proj")
3881 );
3882 let name = repo_basename(Path::new("/x/y/z/.git")).unwrap();
3884 assert!(!name.contains('/') && !name.contains('\\'));
3885 }
3886
3887 #[test]
3888 fn aggregate_rolls_up_totals_and_excludes_empty() {
3889 let _cfg = aggregate_env();
3890 seed_store(
3891 "aaa",
3892 &store_with("alpha", 10, 2, "2026-06-10T00:00:00Z", 3),
3893 );
3894 seed_store("bbb", &store_with("beta", 5, 1, "2026-06-11T00:00:00Z", 0));
3895 seed_store(
3897 "ccc",
3898 &ImpactStore {
3899 enabled: true,
3900 explicit_decision: true,
3901 label: Some("gamma".into()),
3902 ..Default::default()
3903 },
3904 );
3905 let report = aggregate(CrossRepoSort::Recent);
3906 assert_eq!(report.project_count, 3, "all three stores enumerated");
3907 assert_eq!(report.tracked_count, 2, "empty store excluded from rows");
3908 assert_eq!(report.totals.resolved_total, 15);
3909 assert_eq!(report.totals.containment_count, 3);
3910 assert_eq!(report.unreadable_count, 0);
3911 }
3912
3913 #[test]
3914 fn aggregate_sort_recent_orders_by_last_activity() {
3915 let _cfg = aggregate_env();
3916 seed_store("old", &store_with("older", 1, 0, "2026-06-01T00:00:00Z", 1));
3917 seed_store("new", &store_with("newer", 1, 0, "2026-06-12T00:00:00Z", 1));
3918 let report = aggregate(CrossRepoSort::Recent);
3919 assert_eq!(report.projects[0].label.as_deref(), Some("newer"));
3920 assert_eq!(report.projects[1].label.as_deref(), Some("older"));
3921 }
3922
3923 #[test]
3924 fn cross_repo_json_carries_kind_and_leaks_no_path() {
3925 let _cfg = aggregate_env();
3926 seed_store("aaa", &store_with("alpha", 4, 1, "2026-06-10T00:00:00Z", 2));
3927 let report = aggregate(CrossRepoSort::Recent);
3928 let json = render_cross_repo_json(&report);
3929 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
3930 assert_eq!(value["kind"], "impact-cross-repo");
3931 for entry in value["projects"].as_array().unwrap() {
3933 if let Some(label) = entry["label"].as_str() {
3934 assert!(
3935 !label.contains('/') && !label.contains('\\'),
3936 "label must be a basename, got {label}"
3937 );
3938 }
3939 }
3940 assert!(
3941 !json.contains('/') || !json.contains("Users"),
3942 "json must not leak an absolute home path"
3943 );
3944 }
3945
3946 #[test]
3947 fn cross_repo_markdown_pluralizes_single_project() {
3948 let _cfg = aggregate_env();
3949 seed_store("solo", &store_with("solo", 3, 1, "2026-06-10T00:00:00Z", 2));
3950 let report = aggregate(CrossRepoSort::Recent);
3951 assert_eq!(report.project_count, 1);
3952 assert_eq!(report.tracked_count, 1);
3953 let md = render_cross_repo_markdown(&report);
3954 assert!(
3955 md.contains("1 project tracked"),
3956 "single project must read 'project', got:\n{md}"
3957 );
3958 assert!(
3959 !md.contains("1 projects tracked"),
3960 "must not pluralize a single project, got:\n{md}"
3961 );
3962 assert!(
3963 md.contains("across 1 tracked project"),
3964 "grand totals must read 'tracked project' (singular), got:\n{md}"
3965 );
3966 assert!(
3967 !md.contains("tracked projects"),
3968 "must not pluralize a single tracked project, got:\n{md}"
3969 );
3970 }
3971
3972 #[test]
3973 fn cross_repo_corrupt_file_is_skipped_and_counted() {
3974 let _cfg = aggregate_env();
3975 seed_store("good", &store_with("good", 3, 0, "2026-06-10T00:00:00Z", 1));
3976 let dir = impact_config_dir().unwrap().join("impact");
3977 std::fs::write(dir.join("bad.json"), b"{ not valid json ][").unwrap();
3978 let report = aggregate(CrossRepoSort::Recent);
3979 assert_eq!(report.tracked_count, 1, "good store still aggregated");
3980 assert_eq!(
3981 report.unreadable_count, 1,
3982 "corrupt file counted, not crashed"
3983 );
3984 }
3985
3986 #[test]
3987 fn cross_repo_empty_dir_is_first_run() {
3988 let _cfg = aggregate_env();
3989 let report = aggregate(CrossRepoSort::Recent);
3990 assert_eq!(report.project_count, 0);
3991 let human = render_cross_repo_human(&report, None);
3992 assert!(human.contains("No projects tracked yet"));
3993 }
3994
3995 #[test]
3996 fn cross_repo_all_corrupt_reports_unreadable_not_first_run() {
3997 let _cfg = aggregate_env();
3998 let dir = impact_config_dir().unwrap().join("impact");
3999 std::fs::create_dir_all(&dir).unwrap();
4000 std::fs::write(dir.join("bad.json"), b"{ broken ][").unwrap();
4001 let report = aggregate(CrossRepoSort::Recent);
4002 assert_eq!(report.project_count, 0);
4003 assert_eq!(report.unreadable_count, 1);
4004 let human = render_cross_repo_human(&report, None);
4005 assert!(
4006 human.contains("unreadable store") && !human.contains("No projects tracked yet"),
4007 "all-corrupt must report unreadable, not a misleading first-run hint: {human}"
4008 );
4009 }
4010
4011 #[test]
4012 fn record_audit_run_captures_basename_label() {
4013 let (_config, dir) = test_env();
4014 let root = dir.path();
4015 enable(root);
4016 record_v1(
4017 root,
4018 &summary(1, 0, 0),
4019 AuditVerdict::Warn,
4020 false,
4021 None,
4022 "2.0.0",
4023 "t0",
4024 );
4025 let label = load(root).label.expect("label captured on record");
4026 assert!(
4027 !label.contains('/') && !label.contains('\\'),
4028 "label must be a basename, got {label}"
4029 );
4030 }
4031
4032 #[test]
4035 fn lock_path_appends_lock_suffix() {
4036 assert_eq!(
4037 lock_path_for(Path::new("/c/fallow/impact/abc.json")),
4038 PathBuf::from("/c/fallow/impact/abc.json.lock")
4039 );
4040 }
4041
4042 #[test]
4043 fn store_lock_acquire_drop_then_record_roundtrips() {
4044 let (_config, dir) = test_env();
4045 let root = dir.path();
4046 enable(root);
4047 {
4050 let _lock = ImpactStoreLock::acquire(root).expect("lock acquires");
4051 }
4052 record_v1(
4053 root,
4054 &summary(1, 0, 0),
4055 AuditVerdict::Warn,
4056 false,
4057 None,
4058 "2.0.0",
4059 "t0",
4060 );
4061 assert_eq!(load(root).records.len(), 1, "record persisted under lock");
4062 let store = store_path(root).unwrap();
4064 assert!(lock_path_for(&store).exists(), "lock sidecar created");
4065 assert!(store.exists(), "store file is distinct from its lock");
4066 }
4067
4068 #[test]
4069 fn sweep_keeps_fresh_and_self_deletes_aged_out() {
4070 let _cfg = aggregate_env();
4071 seed_store("keepme", &store_with("keep", 1, 0, "t0", 1));
4072 seed_store("oldone", &store_with("old", 1, 0, "t0", 1));
4073 let lock = impact_config_dir()
4075 .unwrap()
4076 .join("impact")
4077 .join("oldone.json.lock");
4078 std::fs::write(&lock, b"").unwrap();
4079
4080 sweep_old_stores("keepme", std::time::Duration::ZERO);
4082
4083 let dir = impact_config_dir().unwrap().join("impact");
4084 assert!(dir.join("keepme.json").exists(), "kept store survives");
4085 assert!(
4086 !dir.join("oldone.json").exists(),
4087 "aged-out store reclaimed"
4088 );
4089 assert!(lock.exists(), "lock sidecar never deleted by the sweep");
4090 }
4091
4092 #[test]
4093 fn sweep_keeps_everything_under_a_large_window() {
4094 let _cfg = aggregate_env();
4095 seed_store("a", &store_with("a", 1, 0, "t0", 1));
4096 seed_store("b", &store_with("b", 1, 0, "t0", 1));
4097 sweep_old_stores("a", std::time::Duration::from_hours(10 * 365 * 24));
4099 let dir = impact_config_dir().unwrap().join("impact");
4100 assert!(dir.join("a.json").exists());
4101 assert!(dir.join("b.json").exists());
4102 }
4103
4104 #[test]
4107 #[cfg_attr(miri, ignore)]
4108 fn legacy_into_store_with_empty_frontiers_does_not_insert_worktree_key() {
4109 let legacy = LegacyFlatStore {
4112 enabled: true,
4113 explicit_decision: true,
4114 first_recorded: Some("t0".to_owned()),
4115 records: vec![],
4116 project_records: vec![],
4117 containment: vec![],
4118 pending_containment: None,
4119 frontier: FxHashMap::default(),
4120 clone_frontier: FxHashMap::default(),
4121 resolved_total: 0,
4122 suppressed_total: 0,
4123 recent_resolved: vec![],
4124 onboarding_declined: false,
4125 last_digest_epoch: None,
4126 };
4127 let store = legacy.into_store("wt-key");
4128 assert!(
4129 store.frontier.is_empty(),
4130 "empty legacy frontier must not insert a worktree key"
4131 );
4132 assert!(
4133 store.clone_frontier.is_empty(),
4134 "empty legacy clone_frontier must not insert a worktree key"
4135 );
4136 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
4137 assert!(store.label.is_none(), "label is always None on migration");
4138 }
4139
4140 #[test]
4143 fn repo_basename_non_git_path_returns_last_component() {
4144 assert_eq!(
4146 repo_basename(Path::new("/a/b/myproject")).as_deref(),
4147 Some("myproject")
4148 );
4149 }
4150
4151 #[test]
4152 fn repo_basename_root_path_returns_none() {
4153 assert!(repo_basename(Path::new("/")).is_none());
4155 }
4156
4157 #[test]
4160 fn direction_for_declining_when_delta_positive() {
4161 assert_eq!(direction_for(1), ImpactTrendDirection::Declining);
4162 assert_eq!(direction_for(100), ImpactTrendDirection::Declining);
4163 }
4164
4165 #[test]
4166 fn direction_for_stable_when_delta_zero() {
4167 assert_eq!(direction_for(0), ImpactTrendDirection::Stable);
4168 }
4169
4170 #[test]
4171 fn direction_for_improving_when_delta_negative() {
4172 assert_eq!(direction_for(-1), ImpactTrendDirection::Improving);
4173 }
4174
4175 #[test]
4178 fn trend_arrow_all_directions() {
4179 assert_eq!(trend_arrow(ImpactTrendDirection::Improving), "down");
4180 assert_eq!(trend_arrow(ImpactTrendDirection::Declining), "up");
4181 assert_eq!(trend_arrow(ImpactTrendDirection::Stable), "flat");
4182 }
4183
4184 #[test]
4187 fn build_report_project_trend_populated_from_project_records() {
4188 let mut store = ImpactStore {
4189 enabled: true,
4190 ..Default::default()
4191 };
4192 for total in [20usize, 15usize] {
4193 store.project_records.push(ImpactRecord {
4194 timestamp: format!("t{total}"),
4195 version: "2.0.0".into(),
4196 git_sha: None,
4197 verdict: "warn".into(),
4198 gate: false,
4199 counts: ImpactCounts {
4200 total_issues: total,
4201 dead_code: total,
4202 complexity: 0,
4203 duplication: 0,
4204 },
4205 });
4206 }
4207 let report = build_report(&store);
4208 let pt = report
4209 .project_trend
4210 .expect("two project records yield a trend");
4211 assert_eq!(pt.direction, ImpactTrendDirection::Improving);
4212 assert_eq!(pt.previous_total, 20);
4213 assert_eq!(pt.current_total, 15);
4214 assert_eq!(pt.total_delta, -5);
4215 }
4216
4217 #[test]
4220 fn latest_activity_both_series_returns_newer() {
4221 let mut store = ImpactStore::default();
4222 store.records.push(ImpactRecord {
4223 timestamp: "2026-01-01T00:00:00Z".to_owned(),
4224 version: "2.0.0".to_owned(),
4225 git_sha: None,
4226 verdict: "warn".to_owned(),
4227 gate: false,
4228 counts: ImpactCounts::default(),
4229 });
4230 store.project_records.push(ImpactRecord {
4231 timestamp: "2026-06-15T00:00:00Z".to_owned(),
4232 version: "2.0.0".to_owned(),
4233 git_sha: None,
4234 verdict: "warn".to_owned(),
4235 gate: false,
4236 counts: ImpactCounts::default(),
4237 });
4238 assert_eq!(
4239 latest_activity(&store).as_deref(),
4240 Some("2026-06-15T00:00:00Z")
4241 );
4242 }
4243
4244 #[test]
4245 fn latest_activity_only_project_records() {
4246 let mut store = ImpactStore::default();
4247 store.project_records.push(ImpactRecord {
4248 timestamp: "2026-05-01T00:00:00Z".to_owned(),
4249 version: "2.0.0".to_owned(),
4250 git_sha: None,
4251 verdict: "pass".to_owned(),
4252 gate: false,
4253 counts: ImpactCounts::default(),
4254 });
4255 assert_eq!(
4256 latest_activity(&store).as_deref(),
4257 Some("2026-05-01T00:00:00Z")
4258 );
4259 }
4260
4261 #[test]
4262 fn latest_activity_empty_store_returns_none() {
4263 assert!(latest_activity(&ImpactStore::default()).is_none());
4264 }
4265
4266 #[test]
4269 #[cfg_attr(miri, ignore)]
4270 fn containment_events_are_bounded_at_max_containment() {
4271 let (_config, dir) = test_env();
4272 let root = dir.path();
4273 enable(root);
4274 let mut store = load(root);
4276 for i in 0..MAX_CONTAINMENT {
4277 store.containment.push(ContainmentEvent {
4278 blocked_at: format!("block{i}"),
4279 cleared_at: format!("clear{i}"),
4280 git_sha: None,
4281 blocked_counts: ImpactCounts::default(),
4282 });
4283 }
4284 save(&store, root);
4285
4286 record_v1(
4288 root,
4289 &summary(1, 0, 0),
4290 AuditVerdict::Fail,
4291 true,
4292 None,
4293 "2.0.0",
4294 "overflow_block",
4295 );
4296 record_v1(
4297 root,
4298 &summary(0, 0, 0),
4299 AuditVerdict::Pass,
4300 true,
4301 None,
4302 "2.0.0",
4303 "overflow_clear",
4304 );
4305 let store = load(root);
4306 assert_eq!(
4307 store.containment.len(),
4308 MAX_CONTAINMENT,
4309 "containment must be capped at MAX_CONTAINMENT"
4310 );
4311 assert_eq!(
4313 store.containment.last().unwrap().blocked_at,
4314 "overflow_block"
4315 );
4316 }
4317
4318 #[test]
4321 #[cfg_attr(miri, ignore)]
4322 fn disable_from_enabled_state_sets_schema_version() {
4323 let (_config, dir) = test_env();
4324 let root = dir.path();
4325 enable(root);
4326 let was_newly_disabled = disable(root);
4327 assert!(
4328 was_newly_disabled,
4329 "disable returns true when previously enabled"
4330 );
4331 let store = load(root);
4332 assert!(!store.enabled);
4333 assert!(store.explicit_decision);
4334 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
4335
4336 let again = disable(root);
4338 assert!(!again, "disable returns false when already off");
4339 }
4340
4341 #[test]
4344 #[cfg_attr(miri, ignore)]
4345 fn record_combined_run_is_noop_in_ci() {
4346 let (_config, dir) = test_env();
4347 let root = dir.path();
4348 enable(root);
4349 TEST_FORCE_CI.with(|c| c.set(true));
4350 record_combined_run(
4351 root,
4352 ImpactCounts::from_combined(5, 0, 0),
4353 None,
4354 "2.0.0",
4355 "t0",
4356 None,
4357 );
4358 TEST_FORCE_CI.with(|c| c.set(false));
4359 assert!(
4360 load(root).project_records.is_empty(),
4361 "combined run must not record on CI"
4362 );
4363 }
4364
4365 #[test]
4366 #[cfg_attr(miri, ignore)]
4367 fn record_combined_run_is_noop_when_disabled() {
4368 let (_config, dir) = test_env();
4369 let root = dir.path();
4370 record_combined_run(
4372 root,
4373 ImpactCounts::from_combined(3, 0, 0),
4374 None,
4375 "2.0.0",
4376 "t0",
4377 None,
4378 );
4379 assert!(
4380 load(root).project_records.is_empty(),
4381 "combined run must not record when disabled"
4382 );
4383 }
4384
4385 #[test]
4386 #[cfg_attr(miri, ignore)]
4387 fn record_combined_run_project_records_bounded_at_max() {
4388 let (_config, dir) = test_env();
4389 let root = dir.path();
4390 enable(root);
4391 let mut store = load(root);
4393 for i in 0..MAX_RECORDS {
4394 store.project_records.push(ImpactRecord {
4395 timestamp: format!("t{i}"),
4396 version: "2.0.0".to_owned(),
4397 git_sha: None,
4398 verdict: "warn".to_owned(),
4399 gate: false,
4400 counts: ImpactCounts::default(),
4401 });
4402 }
4403 save(&store, root);
4404
4405 record_combined_run(
4406 root,
4407 ImpactCounts::from_combined(1, 0, 0),
4408 None,
4409 "2.0.0",
4410 "overflow",
4411 None,
4412 );
4413 let store = load(root);
4414 assert_eq!(
4415 store.project_records.len(),
4416 MAX_RECORDS,
4417 "project_records must be capped at MAX_RECORDS"
4418 );
4419 assert_eq!(
4420 store.project_records.last().unwrap().timestamp,
4421 "overflow",
4422 "newest record must be at the tail"
4423 );
4424 }
4425
4426 #[test]
4429 #[cfg_attr(miri, ignore)]
4430 fn suppressed_clone_disappearance_credits_suppressed_not_resolved() {
4431 let (_config, dir) = test_env();
4432 let root = dir.path();
4433 enable(root);
4434 let a = touch(root, "src/a.ts");
4435 let b = touch(root, "src/b.ts");
4436 let clone = CloneInput {
4437 fingerprint: "dup:suppressed".to_owned(),
4438 instance_paths: vec![a.clone(), b.clone()],
4439 };
4440 run(root, &[&a, &b], vec![], vec![clone], &[], "t0");
4441 let blanket = ActiveSuppression {
4443 path: a.clone(),
4444 kind: None,
4445 is_file_level: true,
4446 reason: None,
4447 comment_line: 1,
4448 };
4449 run(root, &[&a, &b], vec![], vec![], &[blanket], "t1");
4450 let store = load(root);
4451 assert_eq!(
4452 store.suppressed_total, 1,
4453 "suppressed clone disappearance must count as suppressed"
4454 );
4455 assert_eq!(
4456 store.resolved_total, 0,
4457 "suppressed clone must not count as resolved"
4458 );
4459 }
4460
4461 #[test]
4464 #[cfg_attr(miri, ignore)]
4465 fn load_all_skips_non_json_and_lock_files() {
4466 let _cfg = aggregate_env();
4467 seed_store("real", &store_with("real", 2, 0, "2026-06-10T00:00:00Z", 1));
4468 let dir = impact_config_dir().unwrap().join("impact");
4469 std::fs::write(dir.join("real.json.lock"), b"").unwrap();
4471 std::fs::write(dir.join("notes.txt"), b"ignored").unwrap();
4472 let (stores, unreadable) = load_all();
4473 assert_eq!(stores.len(), 1, "only the .json store is loaded");
4474 assert_eq!(
4475 unreadable, 0,
4476 "lock and txt files are not counted unreadable"
4477 );
4478 }
4479
4480 #[test]
4483 fn aggregate_totals_project_wide_issues_from_project_surfacing() {
4484 let mut s1 = ImpactStore {
4486 enabled: true,
4487 explicit_decision: true,
4488 resolved_total: 3,
4489 ..Default::default()
4490 };
4491 s1.records.push(ImpactRecord {
4492 timestamp: "t1".to_owned(),
4493 version: "2.0.0".to_owned(),
4494 git_sha: None,
4495 verdict: "warn".to_owned(),
4496 gate: false,
4497 counts: ImpactCounts::from_combined(5, 0, 0),
4498 });
4499 s1.project_records.push(ImpactRecord {
4501 timestamp: "pt1".to_owned(),
4502 version: "2.0.0".to_owned(),
4503 git_sha: None,
4504 verdict: "warn".to_owned(),
4505 gate: false,
4506 counts: ImpactCounts::from_combined(20, 0, 0),
4507 });
4508
4509 let mut s2 = ImpactStore {
4510 enabled: true,
4511 explicit_decision: true,
4512 resolved_total: 7,
4513 ..Default::default()
4514 };
4515 s2.records.push(ImpactRecord {
4516 timestamp: "t2".to_owned(),
4517 version: "2.0.0".to_owned(),
4518 git_sha: None,
4519 verdict: "warn".to_owned(),
4520 gate: false,
4521 counts: ImpactCounts::from_combined(2, 0, 0),
4522 });
4523 s2.project_records.push(ImpactRecord {
4524 timestamp: "pt2".to_owned(),
4525 version: "2.0.0".to_owned(),
4526 git_sha: None,
4527 verdict: "warn".to_owned(),
4528 gate: false,
4529 counts: ImpactCounts::from_combined(30, 0, 0),
4530 });
4531
4532 let report = build_aggregate_report(
4533 vec![("k1".to_owned(), s1), ("k2".to_owned(), s2)],
4534 0,
4535 CrossRepoSort::Recent,
4536 );
4537 assert_eq!(report.totals.resolved_total, 10);
4538 assert_eq!(report.totals.project_wide_issues, 50);
4539 assert_eq!(report.totals.projects_with_baseline, 2);
4540 }
4541
4542 #[test]
4545 fn aggregate_sort_resolved_orders_by_resolved_total() {
4546 let _cfg = aggregate_env();
4547 seed_store("low", &store_with("low", 1, 0, "2026-06-10T00:00:00Z", 1));
4548 seed_store("high", &store_with("high", 9, 0, "2026-06-09T00:00:00Z", 1));
4549 let report = aggregate(CrossRepoSort::Resolved);
4550 assert_eq!(report.projects[0].label.as_deref(), Some("high"));
4551 assert_eq!(report.projects[1].label.as_deref(), Some("low"));
4552 }
4553
4554 #[test]
4555 fn aggregate_sort_contained_orders_by_containment_count() {
4556 let _cfg = aggregate_env();
4557 seed_store("none", &store_with("none", 1, 0, "2026-06-10T00:00:00Z", 1));
4558 seed_store("many", &store_with("many", 1, 3, "2026-06-09T00:00:00Z", 1));
4559 let report = aggregate(CrossRepoSort::Contained);
4560 assert_eq!(report.projects[0].label.as_deref(), Some("many"));
4561 assert_eq!(report.projects[1].label.as_deref(), Some("none"));
4562 }
4563
4564 #[test]
4565 fn aggregate_sort_name_orders_alphabetically_by_label() {
4566 let _cfg = aggregate_env();
4567 seed_store("zzz", &store_with("zulu", 1, 0, "2026-06-10T00:00:00Z", 1));
4568 seed_store("aaa", &store_with("alpha", 1, 0, "2026-06-09T00:00:00Z", 1));
4569 let report = aggregate(CrossRepoSort::Name);
4570 assert_eq!(report.projects[0].label.as_deref(), Some("alpha"));
4571 assert_eq!(report.projects[1].label.as_deref(), Some("zulu"));
4572 }
4573
4574 #[test]
4577 fn render_cross_repo_human_limit_shows_overflow_line() {
4578 let _cfg = aggregate_env();
4579 seed_store("a", &store_with("alpha", 1, 0, "2026-06-10T00:00:00Z", 1));
4580 seed_store("b", &store_with("beta", 2, 0, "2026-06-09T00:00:00Z", 1));
4581 seed_store("c", &store_with("gamma", 3, 0, "2026-06-08T00:00:00Z", 1));
4582 let report = aggregate(CrossRepoSort::Recent);
4583 let human = render_cross_repo_human(&report, Some(2));
4584 assert!(
4585 human.contains("and 1 more"),
4586 "overflow line missing when limit < tracked_count: {human}"
4587 );
4588 }
4589
4590 #[test]
4591 fn render_cross_repo_human_no_limit_shows_all_rows() {
4592 let _cfg = aggregate_env();
4593 seed_store("a", &store_with("alpha", 1, 0, "2026-06-10T00:00:00Z", 1));
4594 seed_store("b", &store_with("beta", 2, 0, "2026-06-09T00:00:00Z", 1));
4595 let report = aggregate(CrossRepoSort::Recent);
4596 let human = render_cross_repo_human(&report, None);
4597 assert!(
4598 !human.contains("more (raise --limit"),
4599 "no limit => no overflow line: {human}"
4600 );
4601 assert!(human.contains("alpha"));
4602 assert!(human.contains("beta"));
4603 }
4604
4605 #[test]
4608 fn render_cross_repo_human_shows_no_history_count() {
4609 let _cfg = aggregate_env();
4610 seed_store(
4611 "empty",
4612 &ImpactStore {
4613 enabled: true,
4614 explicit_decision: true,
4615 ..Default::default()
4616 },
4617 );
4618 seed_store("full", &store_with("full", 1, 0, "t0", 1));
4619 let report = aggregate(CrossRepoSort::Recent);
4620 let human = render_cross_repo_human(&report, None);
4621 assert!(
4622 human.contains("tracked project") && human.contains("no history yet"),
4623 "must report the no-history count: {human}"
4624 );
4625 }
4626
4627 #[test]
4628 fn render_cross_repo_human_shows_skipped_unreadable() {
4629 let _cfg = aggregate_env();
4630 seed_store("good", &store_with("good", 1, 0, "t0", 1));
4631 let dir = impact_config_dir().unwrap().join("impact");
4632 std::fs::write(dir.join("corrupt.json"), b"}{broken").unwrap();
4633 let report = aggregate(CrossRepoSort::Recent);
4634 let human = render_cross_repo_human(&report, None);
4635 assert!(
4636 human.contains("skipped") && human.contains("unreadable store"),
4637 "must show the skipped unreadable count: {human}"
4638 );
4639 }
4640
4641 #[test]
4644 fn render_cross_repo_human_grand_totals_shows_project_wide_when_present() {
4645 let _cfg = aggregate_env();
4646 let mut s = store_with("proj", 5, 1, "2026-06-10T00:00:00Z", 4);
4647 s.project_records.push(ImpactRecord {
4649 timestamp: "pt".to_owned(),
4650 version: "2.0.0".to_owned(),
4651 git_sha: None,
4652 verdict: "warn".to_owned(),
4653 gate: false,
4654 counts: ImpactCounts::from_combined(42, 0, 0),
4655 });
4656 seed_store("proj", &s);
4657 let report = aggregate(CrossRepoSort::Recent);
4658 let human = render_cross_repo_human(&report, None);
4659 assert!(
4660 human.contains("42 issue") && human.contains("project-wide"),
4661 "grand totals must include the project-wide line: {human}"
4662 );
4663 }
4664
4665 #[test]
4668 fn render_human_resolved_event_without_symbol_omits_symbol() {
4669 let report = ImpactReport {
4670 schema_version: ImpactReportSchemaVersion::V1,
4671 enabled: true,
4672 enabled_source: EnabledSource::Project,
4673 record_count: 2,
4674 meta: None,
4675 first_recorded: Some("2026-05-01T00:00:00Z".into()),
4676 latest_git_sha: None,
4677 surfacing: Some(ImpactCounts::default()),
4678 trend: None,
4679 project_surfacing: None,
4680 project_trend: None,
4681 containment_count: 0,
4682 recent_containment: vec![],
4683 resolved_total: 1,
4684 suppressed_total: 0,
4685 recent_resolved: vec![ResolutionEvent {
4686 kind: "unused-file".to_owned(),
4687 path: "src/dead.ts".to_owned(),
4688 symbol: None,
4689 git_sha: None,
4690 timestamp: "t1".to_owned(),
4691 }],
4692 attribution_active: true,
4693 onboarding_declined: false,
4694 explicit_decision: true,
4695 };
4696 let human = render_human(&report);
4697 assert!(
4699 human.contains("unused-file in src/dead.ts"),
4700 "no-symbol event must show 'kind in path': {human}"
4701 );
4702 assert!(!human.contains("None"), "must not stringify None: {human}");
4703 }
4704
4705 #[test]
4708 fn render_markdown_disabled_shows_enable_hint() {
4709 let report = ImpactReport {
4710 schema_version: ImpactReportSchemaVersion::V1,
4711 enabled: false,
4712 enabled_source: EnabledSource::Default,
4713 record_count: 0,
4714 meta: None,
4715 first_recorded: None,
4716 latest_git_sha: None,
4717 surfacing: None,
4718 trend: None,
4719 project_surfacing: None,
4720 project_trend: None,
4721 containment_count: 0,
4722 recent_containment: vec![],
4723 resolved_total: 0,
4724 suppressed_total: 0,
4725 recent_resolved: vec![],
4726 attribution_active: false,
4727 onboarding_declined: false,
4728 explicit_decision: false,
4729 };
4730 let md = render_markdown(&report);
4731 assert!(
4732 md.contains("Impact tracking is off"),
4733 "disabled markdown must show enable hint: {md}"
4734 );
4735 assert!(md.contains("fallow impact enable"));
4736 }
4737
4738 #[test]
4739 fn render_markdown_with_trend_shows_trend_line() {
4740 let report = ImpactReport {
4741 schema_version: ImpactReportSchemaVersion::V1,
4742 enabled: true,
4743 enabled_source: EnabledSource::Project,
4744 record_count: 2,
4745 meta: None,
4746 first_recorded: Some("2026-05-01T00:00:00Z".into()),
4747 latest_git_sha: None,
4748 surfacing: Some(ImpactCounts::from_combined(3, 3, 0)),
4749 trend: Some(TrendSummary {
4750 direction: ImpactTrendDirection::Declining,
4751 total_delta: 2,
4752 previous_total: 1,
4753 current_total: 3,
4754 }),
4755 project_surfacing: None,
4756 project_trend: None,
4757 containment_count: 0,
4758 recent_containment: vec![],
4759 resolved_total: 0,
4760 suppressed_total: 0,
4761 recent_resolved: vec![],
4762 attribution_active: false,
4763 onboarding_declined: false,
4764 explicit_decision: true,
4765 };
4766 let md = render_markdown(&report);
4767 assert!(
4768 md.contains("Trend (changed-file scope"),
4769 "markdown with trend must show trend line: {md}"
4770 );
4771 assert!(md.contains("1 -> 3 (up)"), "trend values present: {md}");
4772 }
4773
4774 #[test]
4775 fn render_markdown_with_project_trend_shows_project_trend_line() {
4776 let report = ImpactReport {
4777 schema_version: ImpactReportSchemaVersion::V1,
4778 enabled: true,
4779 enabled_source: EnabledSource::Project,
4780 record_count: 1,
4781 meta: None,
4782 first_recorded: Some("2026-05-01T00:00:00Z".into()),
4783 latest_git_sha: None,
4784 surfacing: None,
4785 trend: None,
4786 project_surfacing: Some(ImpactCounts::from_combined(10, 5, 3)),
4787 project_trend: Some(TrendSummary {
4788 direction: ImpactTrendDirection::Stable,
4789 total_delta: 0,
4790 previous_total: 10,
4791 current_total: 10,
4792 }),
4793 containment_count: 0,
4794 recent_containment: vec![],
4795 resolved_total: 0,
4796 suppressed_total: 0,
4797 recent_resolved: vec![],
4798 attribution_active: false,
4799 onboarding_declined: false,
4800 explicit_decision: true,
4801 };
4802 let md = render_markdown(&report);
4803 assert!(
4804 md.contains("Project trend (whole project"),
4805 "project trend line must appear in markdown: {md}"
4806 );
4807 assert!(
4808 md.contains("10 -> 10 (flat)"),
4809 "stable trend must read 'flat': {md}"
4810 );
4811 }
4812
4813 #[test]
4814 fn render_markdown_enabled_no_history_shows_check_back() {
4815 let report = ImpactReport {
4816 schema_version: ImpactReportSchemaVersion::V1,
4817 enabled: true,
4818 enabled_source: EnabledSource::Project,
4819 record_count: 0,
4820 meta: None,
4821 first_recorded: None,
4822 latest_git_sha: None,
4823 surfacing: None,
4824 trend: None,
4825 project_surfacing: None,
4826 project_trend: None,
4827 containment_count: 0,
4828 recent_containment: vec![],
4829 resolved_total: 0,
4830 suppressed_total: 0,
4831 recent_resolved: vec![],
4832 attribution_active: false,
4833 onboarding_declined: false,
4834 explicit_decision: true,
4835 };
4836 let md = render_markdown(&report);
4837 assert!(
4838 md.contains("No history yet"),
4839 "enabled but empty markdown must show 'No history yet': {md}"
4840 );
4841 }
4842
4843 #[test]
4844 fn render_markdown_resolved_shows_count_when_positive() {
4845 let report = ImpactReport {
4846 schema_version: ImpactReportSchemaVersion::V1,
4847 enabled: true,
4848 enabled_source: EnabledSource::Project,
4849 record_count: 3,
4850 meta: None,
4851 first_recorded: Some("2026-05-01T00:00:00Z".into()),
4852 latest_git_sha: None,
4853 surfacing: Some(ImpactCounts::default()),
4854 trend: None,
4855 project_surfacing: None,
4856 project_trend: None,
4857 containment_count: 0,
4858 recent_containment: vec![],
4859 resolved_total: 4,
4860 suppressed_total: 1,
4861 recent_resolved: vec![],
4862 attribution_active: true,
4863 onboarding_declined: false,
4864 explicit_decision: true,
4865 };
4866 let md = render_markdown(&report);
4867 assert!(
4868 md.contains("**Resolved:** 4 finding"),
4869 "markdown must show resolved count: {md}"
4870 );
4871 assert!(
4872 md.contains("**Marked intentional:** 1 finding"),
4873 "markdown must show suppressed count: {md}"
4874 );
4875 }
4876
4877 #[test]
4880 fn render_markdown_footer_project_only_no_audit_records() {
4881 let report = ImpactReport {
4884 schema_version: ImpactReportSchemaVersion::V1,
4885 enabled: true,
4886 enabled_source: EnabledSource::Project,
4887 record_count: 0,
4888 meta: None,
4889 first_recorded: Some("2026-05-10T00:00:00Z".into()),
4890 latest_git_sha: None,
4891 surfacing: None,
4892 trend: None,
4893 project_surfacing: Some(ImpactCounts::from_combined(3, 2, 1)),
4894 project_trend: None,
4895 containment_count: 0,
4896 recent_containment: vec![],
4897 resolved_total: 0,
4898 suppressed_total: 0,
4899 recent_resolved: vec![],
4900 attribution_active: false,
4901 onboarding_declined: false,
4902 explicit_decision: true,
4903 };
4904 let md = render_markdown(&report);
4905 assert!(
4906 md.contains("Tracking since 2026-05-10"),
4907 "project-only footer must say 'Tracking since': {md}"
4908 );
4909 assert!(
4910 !md.contains("recorded audit run"),
4911 "project-only footer must not mention 'recorded audit run': {md}"
4912 );
4913 }
4914
4915 #[test]
4918 fn render_cross_repo_markdown_includes_project_wide_totals_when_present() {
4919 let _cfg = aggregate_env();
4920 let mut s = store_with("proj", 2, 0, "t0", 1);
4921 s.project_records.push(ImpactRecord {
4922 timestamp: "pt".to_owned(),
4923 version: "2.0.0".to_owned(),
4924 git_sha: None,
4925 verdict: "warn".to_owned(),
4926 gate: false,
4927 counts: ImpactCounts::from_combined(99, 0, 0),
4928 });
4929 seed_store("proj", &s);
4930 let report = aggregate(CrossRepoSort::Recent);
4931 let md = render_cross_repo_markdown(&report);
4932 assert!(
4933 md.contains("99 issue") && md.contains("project-wide"),
4934 "cross-repo markdown must show project-wide totals: {md}"
4935 );
4936 }
4937
4938 #[test]
4941 #[cfg_attr(miri, ignore)]
4942 fn migrate_legacy_store_corrupt_file_returns_default() {
4943 let (_config, dir) = test_env();
4944 let root = dir.path();
4945 std::fs::create_dir_all(root.join(".fallow")).unwrap();
4947 std::fs::write(legacy_store_path(root), b"{ corrupted json ][").unwrap();
4948 let store = load(root);
4950 assert!(!store.enabled);
4952 assert!(store.records.is_empty());
4953 }
4954
4955 #[test]
4958 fn resolve_enabled_user_source_appears_in_report() {
4959 let (_config, _dir) = test_env();
4960 set_global_default(true);
4961 let never_asked = ImpactStore::default();
4962 let (enabled, source) = resolve_enabled(&never_asked);
4963 assert!(enabled);
4964 assert_eq!(source, EnabledSource::User);
4965 let report = build_report(&never_asked);
4966 assert_eq!(report.enabled_source, EnabledSource::User);
4967 }
4968
4969 #[test]
4972 fn render_cross_repo_human_long_label_is_truncated_in_table() {
4973 let _cfg = aggregate_env();
4974 let very_long_name = "this_is_a_very_long_project_name_that_exceeds_the_column_width";
4975 let s = store_with(very_long_name, 1, 0, "2026-06-10T00:00:00Z", 1);
4976 seed_store("longkey", &s);
4977 let report = aggregate(CrossRepoSort::Recent);
4978 let human = render_cross_repo_human(&report, None);
4979 assert!(
4981 human.contains("..."),
4982 "long label must be truncated with '...': {human}"
4983 );
4984 }
4985
4986 #[test]
4989 fn aggregate_sort_name_falls_back_to_short_key_when_no_label() {
4990 let _cfg = aggregate_env();
4991 let mut s = ImpactStore {
4993 enabled: true,
4994 explicit_decision: true,
4995 resolved_total: 1,
4996 label: None,
4997 ..Default::default()
4998 };
4999 s.records.push(ImpactRecord {
5000 timestamp: "t0".to_owned(),
5001 version: "2.0.0".to_owned(),
5002 git_sha: None,
5003 verdict: "warn".to_owned(),
5004 gate: false,
5005 counts: ImpactCounts::from_combined(1, 0, 0),
5006 });
5007 seed_store("abcdefghijklmnop", &s);
5008 let report = aggregate(CrossRepoSort::Name);
5009 assert_eq!(report.tracked_count, 1);
5011 assert_eq!(report.projects[0].project_key, "abcdefghijklmnop");
5013 }
5014
5015 #[test]
5018 fn row_trend_falls_back_to_changed_file_trend_when_no_project_trend() {
5019 let report = ImpactReport {
5020 schema_version: ImpactReportSchemaVersion::V1,
5021 enabled: true,
5022 enabled_source: EnabledSource::Project,
5023 record_count: 2,
5024 meta: None,
5025 first_recorded: None,
5026 latest_git_sha: None,
5027 surfacing: Some(ImpactCounts::default()),
5028 trend: Some(TrendSummary {
5029 direction: ImpactTrendDirection::Improving,
5030 total_delta: -3,
5031 previous_total: 8,
5032 current_total: 5,
5033 }),
5034 project_surfacing: None,
5035 project_trend: None,
5036 containment_count: 0,
5037 recent_containment: vec![],
5038 resolved_total: 0,
5039 suppressed_total: 0,
5040 recent_resolved: vec![],
5041 attribution_active: false,
5042 onboarding_declined: false,
5043 explicit_decision: true,
5044 };
5045 assert_eq!(row_trend(&report), "down");
5046 }
5047
5048 #[test]
5049 fn row_trend_returns_dash_when_no_trend_at_all() {
5050 let report = ImpactReport {
5051 schema_version: ImpactReportSchemaVersion::V1,
5052 enabled: true,
5053 enabled_source: EnabledSource::Project,
5054 record_count: 1,
5055 meta: None,
5056 first_recorded: None,
5057 latest_git_sha: None,
5058 surfacing: Some(ImpactCounts::default()),
5059 trend: None,
5060 project_surfacing: None,
5061 project_trend: None,
5062 containment_count: 0,
5063 recent_containment: vec![],
5064 resolved_total: 0,
5065 suppressed_total: 0,
5066 recent_resolved: vec![],
5067 attribution_active: false,
5068 onboarding_declined: false,
5069 explicit_decision: true,
5070 };
5071 assert_eq!(row_trend(&report), "-");
5072 }
5073
5074 #[test]
5077 fn opt_count_returns_dash_for_none_and_total_for_some() {
5078 assert_eq!(opt_count(None), "-");
5079 assert_eq!(opt_count(Some(&ImpactCounts::from_combined(4, 2, 1))), "7");
5081 }
5082
5083 #[test]
5086 #[cfg_attr(miri, ignore)]
5087 fn impact_project_key_is_a_hex_string_with_no_separator() {
5088 let (_config, dir) = test_env();
5089 let root = dir.path();
5090 let key = project_identity(root).0;
5091 assert!(
5092 !key.contains('/') && !key.contains('\\'),
5093 "project key must not contain a path separator: {key}"
5094 );
5095 assert!(!key.is_empty(), "project key must not be empty");
5096 }
5097
5098 #[test]
5101 fn impact_counts_from_combined_sums_to_total() {
5102 let c = ImpactCounts::from_combined(3, 2, 1);
5103 assert_eq!(c.total_issues, 6);
5104 assert_eq!(c.dead_code, 3);
5105 assert_eq!(c.complexity, 2);
5106 assert_eq!(c.duplication, 1);
5107 }
5108
5109 #[test]
5112 fn render_cross_repo_markdown_all_empty_projects_tracked_count_zero() {
5113 let _cfg = aggregate_env();
5115 seed_store(
5116 "empty1",
5117 &ImpactStore {
5118 enabled: true,
5119 explicit_decision: true,
5120 ..Default::default()
5121 },
5122 );
5123 let report = aggregate(CrossRepoSort::Recent);
5124 assert_eq!(report.project_count, 1);
5125 assert_eq!(report.tracked_count, 0);
5126 let md = render_cross_repo_markdown(&report);
5127 assert!(
5129 md.contains("1 project tracked, 0 with history"),
5130 "must show counts: {md}"
5131 );
5132 assert!(
5133 !md.contains("| Project |"),
5134 "no table when tracked_count is 0: {md}"
5135 );
5136 }
5137
5138 #[test]
5141 fn render_cross_repo_markdown_zero_projects_with_unreadable() {
5142 let _cfg = aggregate_env();
5143 let dir = impact_config_dir().unwrap().join("impact");
5144 std::fs::create_dir_all(&dir).unwrap();
5145 std::fs::write(dir.join("bad.json"), b"}{bad").unwrap();
5146 let report = aggregate(CrossRepoSort::Recent);
5147 assert_eq!(report.project_count, 0);
5148 assert_eq!(report.unreadable_count, 1);
5149 let md = render_cross_repo_markdown(&report);
5150 assert!(
5151 md.contains("skipped") && md.contains("unreadable store"),
5152 "must report corrupt stores: {md}"
5153 );
5154 }
5155}