1use std::path::{Path, PathBuf};
4
5use fallow_types::envelope::Meta;
6use fallow_types::results::{ActiveSuppression, AnalysisResults};
7use rustc_hash::{FxHashMap, FxHashSet};
8use serde::{Deserialize, Serialize};
9
10use crate::audit::{AuditSummary, AuditVerdict};
11use crate::report::ci::fingerprint::fingerprint_hash;
12use crate::report::format_display_path;
13
14const STORE_SCHEMA_VERSION: u32 = 5;
15
16const MAX_RECORDS: usize = 200;
17
18const MAX_CONTAINMENT: usize = 200;
19
20const TREND_TOLERANCE: i64 = 0;
21
22const STORE_FILE: &str = "impact.json";
23
24const STORE_MAX_AGE_ENV: &str = "FALLOW_IMPACT_STORE_MAX_AGE_DAYS";
29
30const MAX_RECENT_RESOLVED: usize = 50;
31
32const ID_SEP: &str = "\u{1f}";
33
34const CODE_DUPLICATION_KIND: &str = "code-duplication";
35
36const BLANKET_SUPPRESSION: &str = "*";
37
38#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41pub struct ImpactCounts {
42 pub total_issues: usize,
43 pub dead_code: usize,
44 pub complexity: usize,
45 pub duplication: usize,
46}
47
48impl ImpactCounts {
49 fn from_summary(summary: &AuditSummary) -> Self {
50 Self {
51 total_issues: summary.dead_code_issues
52 + summary.complexity_findings
53 + summary.duplication_clone_groups,
54 dead_code: summary.dead_code_issues,
55 complexity: summary.complexity_findings,
56 duplication: summary.duplication_clone_groups,
57 }
58 }
59
60 pub(crate) fn from_combined(dead_code: usize, complexity: usize, duplication: usize) -> Self {
61 Self {
62 total_issues: dead_code + complexity + duplication,
63 dead_code,
64 complexity,
65 duplication,
66 }
67 }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ImpactRecord {
72 pub timestamp: String,
73 pub version: String,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub git_sha: Option<String>,
76 pub verdict: String,
77 #[serde(default)]
78 pub gate: bool,
79 pub counts: ImpactCounts,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct PendingContainment {
84 pub blocked_at: String,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub git_sha: Option<String>,
87 pub blocked_counts: ImpactCounts,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
92pub struct ContainmentEvent {
93 pub blocked_at: String,
94 pub cleared_at: String,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub git_sha: Option<String>,
97 pub blocked_counts: ImpactCounts,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct FrontierFinding {
102 pub id: String,
103 pub kind: String,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub symbol: Option<String>,
106}
107
108impl FrontierFinding {
109 fn move_key(&self) -> String {
110 match &self.symbol {
111 Some(symbol) => format!("{}{ID_SEP}{symbol}", self.kind),
112 None => self.id.clone(),
113 }
114 }
115}
116
117#[derive(Debug, Clone, Default, Serialize, Deserialize)]
118pub struct FileFrontier {
119 #[serde(default)]
120 pub findings: Vec<FrontierFinding>,
121 #[serde(default)]
122 pub suppressions: Vec<String>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
127pub struct ResolutionEvent {
128 pub kind: String,
129 pub path: String,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub symbol: Option<String>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub git_sha: Option<String>,
134 pub timestamp: String,
135}
136
137#[derive(Debug, Clone, Default, Serialize, Deserialize)]
138pub struct ImpactStore {
139 #[serde(default)]
140 pub schema_version: u32,
141 #[serde(default)]
142 pub enabled: bool,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub first_recorded: Option<String>,
145 #[serde(default)]
146 pub records: Vec<ImpactRecord>,
147 #[serde(default)]
148 pub project_records: Vec<ImpactRecord>,
149 #[serde(default)]
150 pub containment: Vec<ContainmentEvent>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub pending_containment: Option<PendingContainment>,
153 #[serde(default)]
157 pub frontier: FxHashMap<String, FxHashMap<String, FileFrontier>>,
158 #[serde(default)]
161 pub clone_frontier: FxHashMap<String, FxHashMap<String, Vec<String>>>,
162 #[serde(default)]
163 pub resolved_total: usize,
164 #[serde(default)]
165 pub suppressed_total: usize,
166 #[serde(default)]
167 pub recent_resolved: Vec<ResolutionEvent>,
168 #[serde(default)]
169 pub onboarding_declined: bool,
170 #[serde(default)]
174 pub explicit_decision: bool,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub last_digest_epoch: Option<u64>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub label: Option<String>,
187}
188
189#[derive(Debug, Default, Deserialize)]
194struct LegacyFlatStore {
195 #[serde(default)]
196 enabled: bool,
197 #[serde(default)]
198 first_recorded: Option<String>,
199 #[serde(default)]
200 records: Vec<ImpactRecord>,
201 #[serde(default)]
202 project_records: Vec<ImpactRecord>,
203 #[serde(default)]
204 containment: Vec<ContainmentEvent>,
205 #[serde(default)]
206 pending_containment: Option<PendingContainment>,
207 #[serde(default)]
208 frontier: FlatFrontier,
209 #[serde(default)]
210 clone_frontier: FlatCloneFrontier,
211 #[serde(default)]
212 resolved_total: usize,
213 #[serde(default)]
214 suppressed_total: usize,
215 #[serde(default)]
216 recent_resolved: Vec<ResolutionEvent>,
217 #[serde(default)]
218 onboarding_declined: bool,
219 #[serde(default)]
220 explicit_decision: bool,
221 #[serde(default)]
222 last_digest_epoch: Option<u64>,
223}
224
225impl LegacyFlatStore {
226 fn into_store(self, worktree_key: &str) -> ImpactStore {
229 let mut frontier: FxHashMap<String, FlatFrontier> = FxHashMap::default();
230 if !self.frontier.is_empty() {
231 frontier.insert(worktree_key.to_owned(), self.frontier);
232 }
233 let mut clone_frontier: FxHashMap<String, FlatCloneFrontier> = FxHashMap::default();
234 if !self.clone_frontier.is_empty() {
235 clone_frontier.insert(worktree_key.to_owned(), self.clone_frontier);
236 }
237 ImpactStore {
238 schema_version: STORE_SCHEMA_VERSION,
239 enabled: self.enabled,
240 first_recorded: self.first_recorded,
241 records: self.records,
242 project_records: self.project_records,
243 containment: self.containment,
244 pending_containment: self.pending_containment,
245 frontier,
246 clone_frontier,
247 resolved_total: self.resolved_total,
248 suppressed_total: self.suppressed_total,
249 recent_resolved: self.recent_resolved,
250 onboarding_declined: self.onboarding_declined,
251 explicit_decision: self.explicit_decision,
252 last_digest_epoch: self.last_digest_epoch,
253 label: None,
255 }
256 }
257}
258
259type ProjectIdentity = (String, String, Option<String>);
265
266static IDENTITY_CACHE: std::sync::OnceLock<std::sync::Mutex<FxHashMap<PathBuf, ProjectIdentity>>> =
267 std::sync::OnceLock::new();
268
269fn hash_path_identity(path: &Path) -> String {
274 let raw = path.to_string_lossy();
275 let normalized = if cfg!(any(target_os = "macos", target_os = "windows")) {
276 raw.to_lowercase()
277 } else {
278 raw.into_owned()
279 };
280 fingerprint_hash(&[normalized.as_str()])
281}
282
283fn resolve_or_root(resolved: Option<PathBuf>, root: &Path) -> PathBuf {
288 resolved
289 .or_else(|| dunce::canonicalize(root).ok())
290 .unwrap_or_else(|| root.to_path_buf())
291}
292
293fn repo_basename(common_or_dir: &Path) -> Option<String> {
298 let dir = if common_or_dir.file_name().is_some_and(|n| n == ".git") {
299 common_or_dir.parent()?
300 } else {
301 common_or_dir
302 };
303 dir.file_name().map(|n| n.to_string_lossy().into_owned())
304}
305
306fn project_identity(root: &Path) -> ProjectIdentity {
315 let cache = IDENTITY_CACHE.get_or_init(|| std::sync::Mutex::new(FxHashMap::default()));
316 if let Ok(map) = cache.lock()
317 && let Some(found) = map.get(root)
318 {
319 return found.clone();
320 }
321 let common = resolve_or_root(
322 fallow_core::changed_files::resolve_git_common_dir(root).ok(),
323 root,
324 );
325 let toplevel = resolve_or_root(
326 fallow_core::changed_files::resolve_git_toplevel(root).ok(),
327 root,
328 );
329 let identity = (
330 hash_path_identity(&common),
331 hash_path_identity(&toplevel),
332 repo_basename(&common),
333 );
334 if let Ok(mut map) = cache.lock() {
335 map.insert(root.to_path_buf(), identity.clone());
336 }
337 identity
338}
339
340#[cfg(test)]
341thread_local! {
342 static TEST_CONFIG_DIR: std::cell::RefCell<Option<PathBuf>> =
346 const { std::cell::RefCell::new(None) };
347
348 static TEST_FORCE_CI: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
356}
357
358fn impact_config_dir() -> Option<PathBuf> {
362 #[cfg(test)]
363 {
364 TEST_CONFIG_DIR.with(|c| c.borrow().clone())
365 }
366 #[cfg(not(test))]
367 {
368 crate::telemetry::config_dir()
369 }
370}
371
372fn record_gate_is_ci() -> bool {
379 #[cfg(test)]
380 {
381 TEST_FORCE_CI.with(std::cell::Cell::get)
382 }
383 #[cfg(not(test))]
384 {
385 crate::telemetry::is_ci()
386 }
387}
388
389fn store_path(root: &Path) -> Option<PathBuf> {
393 let (project_key, _, _) = project_identity(root);
394 Some(
395 impact_config_dir()?
396 .join("impact")
397 .join(format!("{project_key}.json")),
398 )
399}
400
401fn legacy_store_path(root: &Path) -> PathBuf {
404 root.join(".fallow").join(STORE_FILE)
405}
406
407pub fn load(root: &Path) -> ImpactStore {
410 let Some(path) = store_path(root) else {
411 return ImpactStore::default();
412 };
413 match std::fs::read_to_string(&path) {
414 Ok(content) => parse_store(&content, &path),
415 Err(_) => migrate_legacy_store(root),
418 }
419}
420
421fn parse_store(content: &str, path: &Path) -> ImpactStore {
422 match serde_json::from_str::<ImpactStore>(content) {
423 Ok(store) => {
424 if store.schema_version > STORE_SCHEMA_VERSION {
425 tracing::warn!(
426 "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.",
427 path.display(),
428 store.schema_version,
429 STORE_SCHEMA_VERSION,
430 );
431 }
432 store
433 }
434 Err(err) => {
435 tracing::warn!(
436 "fallow impact: ignoring unreadable store at {} ({err}); run `fallow impact enable` to reset it",
437 path.display()
438 );
439 ImpactStore::default()
440 }
441 }
442}
443
444fn save(store: &ImpactStore, root: &Path) {
447 let Some(path) = store_path(root) else {
448 return;
449 };
450 if let Some(parent) = path.parent()
451 && std::fs::create_dir_all(parent).is_err()
452 {
453 return;
454 }
455 if let Ok(json) = serde_json::to_string_pretty(store) {
456 let _ = fallow_config::atomic_write(&path, json.as_bytes());
457 }
458}
459
460fn lock_path_for(store: &Path) -> PathBuf {
462 let mut raw = store.as_os_str().to_owned();
463 raw.push(".lock");
464 PathBuf::from(raw)
465}
466
467struct ImpactStoreLock {
479 _file: std::fs::File,
480}
481
482impl ImpactStoreLock {
483 fn acquire(root: &Path) -> Option<Self> {
488 let lock_path = lock_path_for(&store_path(root)?);
489 if let Some(parent) = lock_path.parent()
490 && std::fs::create_dir_all(parent).is_err()
491 {
492 return None;
493 }
494 let file = std::fs::OpenOptions::new()
495 .create(true)
496 .truncate(false)
497 .write(true)
498 .open(&lock_path)
499 .ok()?;
500 match file.lock() {
501 Ok(()) => Some(Self { _file: file }),
502 Err(err) => {
503 tracing::debug!(error = %err, "could not acquire impact store lock");
504 None
505 }
506 }
507 }
508}
509
510fn resolve_store_max_age() -> Option<std::time::Duration> {
513 let raw = std::env::var(STORE_MAX_AGE_ENV).ok()?;
514 let days: u32 = raw.trim().parse().ok()?;
515 crate::base_worktree::days_to_duration(days)
516}
517
518fn sweep_old_stores(keep_key: &str, max_age: std::time::Duration) {
543 let Some(dir) = store_dir() else {
544 return;
545 };
546 let Ok(entries) = std::fs::read_dir(&dir) else {
547 return;
548 };
549 let now = std::time::SystemTime::now();
550 for entry in entries.flatten() {
551 let path = entry.path();
552 if path.extension().and_then(|e| e.to_str()) != Some("json") {
555 continue;
556 }
557 if path.file_stem().and_then(|s| s.to_str()) == Some(keep_key) {
558 continue;
559 }
560 let aged_out = std::fs::metadata(&path)
561 .and_then(|m| m.modified())
562 .ok()
563 .and_then(|mtime| now.duration_since(mtime).ok())
564 .is_some_and(|age| age >= max_age);
565 if aged_out {
566 let _ = std::fs::remove_file(&path);
567 }
568 }
569}
570
571fn migrate_legacy_store(root: &Path) -> ImpactStore {
579 let legacy_path = legacy_store_path(root);
580 let Ok(content) = std::fs::read_to_string(&legacy_path) else {
581 return ImpactStore::default();
582 };
583 let Ok(legacy) = serde_json::from_str::<LegacyFlatStore>(&content) else {
584 return ImpactStore::default();
585 };
586 let (_, worktree, display) = project_identity(root);
587 let mut store = legacy.into_store(&worktree);
588 store.label = display;
591 save(&store, root);
592 store
593}
594
595pub fn enable(root: &Path) -> bool {
599 let mut store = load(root);
600 let was_enabled = store.enabled;
601 store.enabled = true;
602 store.explicit_decision = true;
603 if store.schema_version == 0 {
604 store.schema_version = STORE_SCHEMA_VERSION;
605 }
606 save(&store, root);
607 !was_enabled
608}
609
610pub fn disable(root: &Path) -> bool {
615 let mut store = load(root);
616 let was_enabled = store.enabled;
617 store.enabled = false;
618 store.explicit_decision = true;
619 if store.schema_version == 0 {
620 store.schema_version = STORE_SCHEMA_VERSION;
621 }
622 save(&store, root);
623 was_enabled
624}
625
626#[derive(Debug, Clone, Copy)]
630pub struct ImpactDigest {
631 pub containment_count: usize,
632 pub resolved_total: usize,
633}
634
635const DIGEST_INTERVAL_SECS: u64 = 7 * 24 * 60 * 60;
637
638pub fn take_due_digest(root: &Path) -> Option<ImpactDigest> {
645 let mut store = load(root);
646 if !resolve_enabled(&store).0 {
647 return None;
648 }
649 let containment_count = store.containment.len();
650 if containment_count == 0 && store.resolved_total == 0 {
651 return None;
652 }
653 let now = std::time::SystemTime::now()
654 .duration_since(std::time::UNIX_EPOCH)
655 .ok()?
656 .as_secs();
657 if let Some(last) = store.last_digest_epoch
658 && now.saturating_sub(last) < DIGEST_INTERVAL_SECS
659 {
660 return None;
661 }
662 store.last_digest_epoch = Some(now);
663 save(&store, root);
664 Some(ImpactDigest {
665 containment_count,
666 resolved_total: store.resolved_total,
667 })
668}
669
670pub fn decline_onboarding(root: &Path) -> bool {
673 let mut store = load(root);
674 let was_declined = store.onboarding_declined;
675 store.onboarding_declined = true;
676 if store.schema_version == 0 {
677 store.schema_version = STORE_SCHEMA_VERSION;
678 }
679 save(&store, root);
680 !was_declined
681}
682
683#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
687#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
688#[serde(rename_all = "lowercase")]
689pub enum EnabledSource {
690 Project,
691 User,
692 Default,
693}
694
695#[derive(Debug, Default, Serialize, Deserialize)]
699struct GlobalImpactConfig {
700 #[serde(default)]
701 default_enabled: bool,
702}
703
704fn global_config_path() -> Option<PathBuf> {
705 Some(impact_config_dir()?.join(STORE_FILE))
706}
707
708fn load_global_default() -> bool {
710 let Some(path) = global_config_path() else {
711 return false;
712 };
713 std::fs::read_to_string(&path)
714 .ok()
715 .and_then(|c| serde_json::from_str::<GlobalImpactConfig>(&c).ok())
716 .is_some_and(|c| c.default_enabled)
717}
718
719pub fn set_global_default(on: bool) -> bool {
721 let was = load_global_default();
722 if let Some(path) = global_config_path() {
723 if let Some(parent) = path.parent()
724 && std::fs::create_dir_all(parent).is_err()
725 {
726 return false;
727 }
728 let config = GlobalImpactConfig {
729 default_enabled: on,
730 };
731 if let Ok(json) = serde_json::to_string_pretty(&config) {
732 let _ = fallow_config::atomic_write(&path, json.as_bytes());
733 }
734 }
735 was != on
736}
737
738fn resolve_enabled(store: &ImpactStore) -> (bool, EnabledSource) {
749 if store.enabled {
750 return (true, EnabledSource::Project);
751 }
752 if store.explicit_decision {
753 return (false, EnabledSource::Project);
754 }
755 if load_global_default() {
756 return (true, EnabledSource::User);
757 }
758 (false, EnabledSource::Default)
759}
760
761#[must_use]
764pub fn resolved_store_path(root: &Path) -> Option<PathBuf> {
765 store_path(root)
766}
767
768#[must_use]
770pub fn resolved_project_key(root: &Path) -> String {
771 project_identity(root).0
772}
773
774#[must_use]
777pub fn store_dir() -> Option<PathBuf> {
778 impact_config_dir().map(|d| d.join("impact"))
779}
780
781pub fn reset(root: &Path) -> bool {
783 store_path(root).is_some_and(|p| std::fs::remove_file(&p).is_ok())
784}
785
786pub fn reset_all() -> bool {
791 let Some(dir) = impact_config_dir().map(|d| d.join("impact")) else {
792 return false;
793 };
794 dir.is_dir() && std::fs::remove_dir_all(&dir).is_ok()
795}
796
797pub struct AuditRunRecord<'a> {
799 pub verdict: AuditVerdict,
800 pub gate: bool,
801 pub git_sha: Option<&'a str>,
802 pub version: &'a str,
803 pub timestamp: &'a str,
804 pub attribution: Option<&'a AttributionInput<'a>>,
805}
806
807pub fn record_audit_run(root: &Path, summary: &AuditSummary, record: &AuditRunRecord<'_>) {
808 let AuditRunRecord {
809 verdict,
810 gate,
811 git_sha,
812 version,
813 timestamp,
814 attribution,
815 } = record;
816 if record_gate_is_ci() {
821 return;
822 }
823 let _lock = ImpactStoreLock::acquire(root);
826 let mut store = load(root);
827 if !resolve_enabled(&store).0 {
828 return;
829 }
830 store.schema_version = STORE_SCHEMA_VERSION;
831 store.label = project_identity(root).2;
834
835 let counts = ImpactCounts::from_summary(summary);
836 let verdict_str = verdict_label(*verdict);
837
838 if store.first_recorded.is_none() {
839 store.first_recorded = Some((*timestamp).to_owned());
840 }
841
842 apply_containment(&mut store, *verdict, *gate, *git_sha, timestamp, &counts);
843
844 store.records.push(ImpactRecord {
845 timestamp: (*timestamp).to_owned(),
846 version: (*version).to_owned(),
847 git_sha: git_sha.map(ToOwned::to_owned),
848 verdict: verdict_str.to_owned(),
849 gate: *gate,
850 counts,
851 });
852 compact(&mut store);
853
854 if let Some(attribution) = attribution {
855 let (_, worktree, _) = project_identity(root);
856 apply_attribution(&mut store, attribution, &worktree, *git_sha, timestamp);
857 }
858
859 save(&store, root);
860 if let Some(max_age) = resolve_store_max_age() {
861 sweep_old_stores(&project_identity(root).0, max_age);
862 }
863}
864
865pub fn record_combined_run(
867 root: &Path,
868 counts: ImpactCounts,
869 git_sha: Option<&str>,
870 version: &str,
871 timestamp: &str,
872 attribution: Option<&AttributionInput<'_>>,
873) {
874 if record_gate_is_ci() {
875 return;
876 }
877 let _lock = ImpactStoreLock::acquire(root);
878 let mut store = load(root);
879 if !resolve_enabled(&store).0 {
880 return;
881 }
882 store.schema_version = STORE_SCHEMA_VERSION;
883 store.label = project_identity(root).2;
884
885 if store.first_recorded.is_none() {
886 store.first_recorded = Some(timestamp.to_owned());
887 }
888
889 let verdict_str = if counts.total_issues == 0 {
890 "pass"
891 } else {
892 "warn"
893 };
894 store.project_records.push(ImpactRecord {
895 timestamp: timestamp.to_owned(),
896 version: version.to_owned(),
897 git_sha: git_sha.map(ToOwned::to_owned),
898 verdict: verdict_str.to_owned(),
899 gate: false,
900 counts,
901 });
902 if store.project_records.len() > MAX_RECORDS {
903 let overflow = store.project_records.len() - MAX_RECORDS;
904 store.project_records.drain(0..overflow);
905 }
906
907 if let Some(attribution) = attribution {
908 let (_, worktree, _) = project_identity(root);
909 apply_attribution(&mut store, attribution, &worktree, git_sha, timestamp);
910 }
911
912 save(&store, root);
913 if let Some(max_age) = resolve_store_max_age() {
914 sweep_old_stores(&project_identity(root).0, max_age);
915 }
916}
917
918fn apply_containment(
920 store: &mut ImpactStore,
921 verdict: AuditVerdict,
922 gate: bool,
923 git_sha: Option<&str>,
924 timestamp: &str,
925 counts: &ImpactCounts,
926) {
927 if !gate {
928 return;
929 }
930 if verdict == AuditVerdict::Fail {
931 if store.pending_containment.is_none() {
932 store.pending_containment = Some(PendingContainment {
933 blocked_at: timestamp.to_owned(),
934 git_sha: git_sha.map(ToOwned::to_owned),
935 blocked_counts: counts.clone(),
936 });
937 }
938 } else if let Some(pending) = store.pending_containment.take() {
939 store.containment.push(ContainmentEvent {
940 blocked_at: pending.blocked_at,
941 cleared_at: timestamp.to_owned(),
942 git_sha: pending.git_sha,
943 blocked_counts: pending.blocked_counts,
944 });
945 if store.containment.len() > MAX_CONTAINMENT {
946 let overflow = store.containment.len() - MAX_CONTAINMENT;
947 store.containment.drain(0..overflow);
948 }
949 }
950}
951
952fn compact(store: &mut ImpactStore) {
953 if store.records.len() > MAX_RECORDS {
954 let overflow = store.records.len() - MAX_RECORDS;
955 store.records.drain(0..overflow);
956 }
957}
958
959#[derive(Debug, Clone)]
960pub struct FindingInput {
961 pub path: PathBuf,
962 pub kind: &'static str,
963 pub symbol: Option<String>,
964}
965
966#[derive(Debug, Clone)]
967pub struct CloneInput {
968 pub fingerprint: String,
969 pub instance_paths: Vec<PathBuf>,
970}
971
972pub enum Scope<'a> {
973 ChangedFiles(&'a [PathBuf]),
974 WholeProject,
975}
976
977pub struct AttributionInput<'a> {
978 pub root: &'a Path,
979 pub scope: Scope<'a>,
980 pub findings: Vec<FindingInput>,
981 pub clones: Vec<CloneInput>,
982 pub suppressions: &'a [ActiveSuppression],
983}
984
985fn finding_id(kind: &str, rel_path: &str, symbol: Option<&str>) -> String {
986 fingerprint_hash(&[kind, rel_path, symbol.unwrap_or("")])
987}
988
989fn covered_by(present: &FxHashSet<String>, kind: &str) -> bool {
990 present.contains(BLANKET_SUPPRESSION) || present.contains(kind)
991}
992
993type FlatFrontier = FxHashMap<String, FileFrontier>;
995type FlatCloneFrontier = FxHashMap<String, Vec<String>>;
997
998fn apply_attribution(
999 store: &mut ImpactStore,
1000 input: &AttributionInput<'_>,
1001 worktree_key: &str,
1002 git_sha: Option<&str>,
1003 timestamp: &str,
1004) {
1005 let root = input.root;
1006 let mut frontier: FlatFrontier = store.frontier.remove(worktree_key).unwrap_or_default();
1011 let mut clone_frontier: FlatCloneFrontier = store
1012 .clone_frontier
1013 .remove(worktree_key)
1014 .unwrap_or_default();
1015
1016 let changed: FxHashSet<String> = match input.scope {
1017 Scope::ChangedFiles(files) => files.iter().map(|p| format_display_path(p, root)).collect(),
1018 Scope::WholeProject => whole_project_scope(&frontier, &clone_frontier, input, root),
1019 };
1020
1021 let mut current_findings: FxHashMap<String, Vec<FrontierFinding>> = FxHashMap::default();
1022 for f in &input.findings {
1023 let rel = format_display_path(&f.path, root);
1024 if !changed.contains(&rel) {
1025 continue;
1026 }
1027 let id = finding_id(f.kind, &rel, f.symbol.as_deref());
1028 current_findings
1029 .entry(rel)
1030 .or_default()
1031 .push(FrontierFinding {
1032 id,
1033 kind: f.kind.to_owned(),
1034 symbol: f.symbol.clone(),
1035 });
1036 }
1037 let mut current_supps: FxHashMap<String, FxHashSet<String>> = FxHashMap::default();
1038 for s in input.suppressions {
1039 let rel = format_display_path(&s.path, root);
1040 if !changed.contains(&rel) {
1041 continue;
1042 }
1043 let key = s
1044 .kind
1045 .clone()
1046 .unwrap_or_else(|| BLANKET_SUPPRESSION.to_owned());
1047 current_supps.entry(rel).or_default().insert(key);
1048 }
1049
1050 let mut appeared_move_keys: FxHashSet<String> = FxHashSet::default();
1051 for (rel, findings) in ¤t_findings {
1052 let prior_ids: FxHashSet<&str> = frontier
1053 .get(rel)
1054 .map(|f| f.findings.iter().map(|x| x.id.as_str()).collect())
1055 .unwrap_or_default();
1056 for ff in findings {
1057 if !prior_ids.contains(ff.id.as_str()) {
1058 appeared_move_keys.insert(ff.move_key());
1059 }
1060 }
1061 }
1062
1063 uncredit_cross_run_moves(store, &appeared_move_keys);
1064
1065 let mut disappearance_input = FileDisappearancesInput {
1066 store,
1067 frontier: &frontier,
1068 changed: &changed,
1069 current_findings: ¤t_findings,
1070 current_supps: ¤t_supps,
1071 appeared_move_keys: &appeared_move_keys,
1072 git_sha,
1073 timestamp,
1074 };
1075 classify_file_disappearances(&mut disappearance_input);
1076 update_file_frontier(&mut frontier, &changed, current_findings, current_supps);
1077 classify_clone_disappearances(
1078 store,
1079 &frontier,
1080 &mut clone_frontier,
1081 input,
1082 &changed,
1083 git_sha,
1084 timestamp,
1085 );
1086 prune_frontier(&mut frontier, &mut clone_frontier, root);
1087 bound_recent_resolved(store);
1088
1089 if frontier.is_empty() {
1092 store.frontier.remove(worktree_key);
1093 } else {
1094 store.frontier.insert(worktree_key.to_owned(), frontier);
1095 }
1096 if clone_frontier.is_empty() {
1097 store.clone_frontier.remove(worktree_key);
1098 } else {
1099 store
1100 .clone_frontier
1101 .insert(worktree_key.to_owned(), clone_frontier);
1102 }
1103}
1104
1105fn whole_project_scope(
1106 frontier: &FlatFrontier,
1107 clone_frontier: &FlatCloneFrontier,
1108 input: &AttributionInput<'_>,
1109 root: &Path,
1110) -> FxHashSet<String> {
1111 let mut set: FxHashSet<String> = frontier.keys().cloned().collect();
1112 for paths in clone_frontier.values() {
1113 for p in paths {
1114 set.insert(p.clone());
1115 }
1116 }
1117 for f in &input.findings {
1118 set.insert(format_display_path(&f.path, root));
1119 }
1120 for c in &input.clones {
1121 for p in &c.instance_paths {
1122 set.insert(format_display_path(p, root));
1123 }
1124 }
1125 set
1126}
1127
1128struct FileDisappearancesInput<'a> {
1129 store: &'a mut ImpactStore,
1130 frontier: &'a FlatFrontier,
1131 changed: &'a FxHashSet<String>,
1132 current_findings: &'a FxHashMap<String, Vec<FrontierFinding>>,
1133 current_supps: &'a FxHashMap<String, FxHashSet<String>>,
1134 appeared_move_keys: &'a FxHashSet<String>,
1135 git_sha: Option<&'a str>,
1136 timestamp: &'a str,
1137}
1138
1139fn classify_file_disappearances(input: &mut FileDisappearancesInput<'_>) {
1140 let store = &mut *input.store;
1141 let frontier = input.frontier;
1142 let changed = input.changed;
1143 let current_findings = input.current_findings;
1144 let current_supps = input.current_supps;
1145 let appeared_move_keys = input.appeared_move_keys;
1146 let git_sha = input.git_sha;
1147 let timestamp = input.timestamp;
1148 let empty_supps = FxHashSet::default();
1149 for rel in changed {
1150 let Some(prior) = frontier.get(rel) else {
1151 continue;
1152 };
1153 let now_ids: FxHashSet<&str> = current_findings
1154 .get(rel)
1155 .map(|fs| fs.iter().map(|f| f.id.as_str()).collect())
1156 .unwrap_or_default();
1157 let now_supps = current_supps.get(rel).unwrap_or(&empty_supps);
1158 let prior_supps: FxHashSet<&str> = prior.suppressions.iter().map(String::as_str).collect();
1159 let new_supp_kinds: FxHashSet<String> = now_supps
1160 .iter()
1161 .filter(|k| !prior_supps.contains(k.as_str()))
1162 .cloned()
1163 .collect();
1164
1165 let mut resolved = Vec::new();
1166 let mut suppressed = 0usize;
1167 for pf in &prior.findings {
1168 if now_ids.contains(pf.id.as_str()) {
1169 continue; }
1171 if appeared_move_keys.contains(&pf.move_key()) {
1172 continue; }
1174 if covered_by(&new_supp_kinds, &pf.kind) {
1175 suppressed += 1; } else {
1177 resolved.push(pf.clone());
1178 }
1179 }
1180 store.suppressed_total += suppressed;
1181 for pf in resolved {
1182 store.resolved_total += 1;
1183 store.recent_resolved.push(ResolutionEvent {
1184 kind: pf.kind,
1185 path: rel.clone(),
1186 symbol: pf.symbol,
1187 git_sha: git_sha.map(ToOwned::to_owned),
1188 timestamp: timestamp.to_owned(),
1189 });
1190 }
1191 }
1192}
1193
1194fn update_file_frontier(
1195 frontier: &mut FlatFrontier,
1196 changed: &FxHashSet<String>,
1197 mut current_findings: FxHashMap<String, Vec<FrontierFinding>>,
1198 mut current_supps: FxHashMap<String, FxHashSet<String>>,
1199) {
1200 for rel in changed {
1201 let findings = current_findings.remove(rel).unwrap_or_default();
1202 let mut suppressions: Vec<String> = current_supps
1203 .remove(rel)
1204 .unwrap_or_default()
1205 .into_iter()
1206 .collect();
1207 suppressions.sort_unstable();
1208 if findings.is_empty() && suppressions.is_empty() {
1209 frontier.remove(rel);
1210 } else {
1211 frontier.insert(
1212 rel.clone(),
1213 FileFrontier {
1214 findings,
1215 suppressions,
1216 },
1217 );
1218 }
1219 }
1220}
1221
1222fn classify_clone_disappearances(
1223 store: &mut ImpactStore,
1224 frontier: &FlatFrontier,
1225 clone_frontier: &mut FlatCloneFrontier,
1226 input: &AttributionInput<'_>,
1227 changed: &FxHashSet<String>,
1228 git_sha: Option<&str>,
1229 timestamp: &str,
1230) {
1231 let root = input.root;
1232 let mut current: FxHashMap<String, Vec<String>> = FxHashMap::default();
1233 for c in &input.clones {
1234 let mut paths: Vec<String> = c
1235 .instance_paths
1236 .iter()
1237 .map(|p| format_display_path(p, root))
1238 .collect();
1239 paths.sort_unstable();
1240 paths.dedup();
1241 if paths.iter().any(|p| changed.contains(p)) {
1242 current.insert(c.fingerprint.clone(), paths);
1243 }
1244 }
1245
1246 let dup_suppressed = |paths: &[String]| -> bool {
1247 paths.iter().any(|p| {
1248 changed.contains(p)
1249 && frontier.get(p).is_some_and(|f| {
1250 f.suppressions
1251 .iter()
1252 .any(|k| k == CODE_DUPLICATION_KIND || k == BLANKET_SUPPRESSION)
1253 })
1254 })
1255 };
1256
1257 let still_duplicated: FxHashSet<&String> = current.values().flatten().collect();
1258
1259 let disappeared: Vec<(String, Vec<String>)> = clone_frontier
1260 .iter()
1261 .filter(|(fp, paths)| {
1262 paths.iter().any(|p| changed.contains(p)) && !current.contains_key(*fp)
1263 })
1264 .map(|(fp, paths)| (fp.clone(), paths.clone()))
1265 .collect();
1266
1267 for (fp, paths) in disappeared {
1268 clone_frontier.remove(&fp);
1269 if paths.iter().any(|p| still_duplicated.contains(p)) {
1270 continue;
1271 }
1272 if dup_suppressed(&paths) {
1273 store.suppressed_total += 1;
1274 } else {
1275 store.resolved_total += 1;
1276 let path = paths.first().cloned().unwrap_or_default();
1277 store.recent_resolved.push(ResolutionEvent {
1278 kind: CODE_DUPLICATION_KIND.to_owned(),
1279 path,
1280 symbol: None,
1281 git_sha: git_sha.map(ToOwned::to_owned),
1282 timestamp: timestamp.to_owned(),
1283 });
1284 }
1285 }
1286
1287 for (fp, paths) in current {
1288 clone_frontier.insert(fp, paths);
1289 }
1290}
1291
1292fn prune_frontier(
1293 frontier: &mut FlatFrontier,
1294 clone_frontier: &mut FlatCloneFrontier,
1295 root: &Path,
1296) {
1297 frontier.retain(|rel, _| root.join(rel).exists());
1298 clone_frontier.retain(|_, paths| paths.iter().any(|p| root.join(p).exists()));
1299}
1300
1301fn bound_recent_resolved(store: &mut ImpactStore) {
1302 if store.recent_resolved.len() > MAX_RECENT_RESOLVED {
1303 let overflow = store.recent_resolved.len() - MAX_RECENT_RESOLVED;
1304 store.recent_resolved.drain(0..overflow);
1305 }
1306}
1307
1308fn event_move_key(ev: &ResolutionEvent) -> Option<String> {
1309 ev.symbol
1310 .as_ref()
1311 .map(|symbol| format!("{}{ID_SEP}{symbol}", ev.kind))
1312}
1313
1314fn uncredit_cross_run_moves(store: &mut ImpactStore, appeared_move_keys: &FxHashSet<String>) {
1315 if appeared_move_keys.is_empty() {
1316 return;
1317 }
1318 let mut uncredited = 0usize;
1319 store.recent_resolved.retain(|ev| match event_move_key(ev) {
1320 Some(mk) if appeared_move_keys.contains(&mk) => {
1321 uncredited += 1;
1322 false
1323 }
1324 _ => true,
1325 });
1326 store.resolved_total = store.resolved_total.saturating_sub(uncredited);
1327}
1328
1329#[must_use]
1330pub fn collect_dead_code_findings(results: &AnalysisResults) -> Vec<FindingInput> {
1331 let mut out = Vec::new();
1332 let mut push = |path: &Path, kind: &'static str, symbol: Option<String>| {
1333 out.push(FindingInput {
1334 path: path.to_path_buf(),
1335 kind,
1336 symbol,
1337 });
1338 };
1339 collect_unused_symbol_findings(results, &mut push);
1340 collect_dependency_findings(results, &mut push);
1341 collect_catalog_findings(results, &mut push);
1342 out
1343}
1344
1345fn collect_unused_symbol_findings(
1346 results: &AnalysisResults,
1347 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1348) {
1349 for f in &results.unused_files {
1350 push(&f.file.path, "unused-file", None);
1351 }
1352 for f in &results.unused_exports {
1353 push(
1354 &f.export.path,
1355 "unused-export",
1356 Some(f.export.export_name.clone()),
1357 );
1358 }
1359 for f in &results.unused_types {
1360 push(
1361 &f.export.path,
1362 "unused-type",
1363 Some(f.export.export_name.clone()),
1364 );
1365 }
1366 for f in &results.private_type_leaks {
1367 push(
1368 &f.leak.path,
1369 "private-type-leak",
1370 Some(format!(
1371 "{}{ID_SEP}{}",
1372 f.leak.export_name, f.leak.type_name
1373 )),
1374 );
1375 }
1376 for f in &results.unused_enum_members {
1377 push(
1378 &f.member.path,
1379 "unused-enum-member",
1380 Some(format!(
1381 "{}{ID_SEP}{}",
1382 f.member.parent_name, f.member.member_name
1383 )),
1384 );
1385 }
1386 for f in &results.unused_class_members {
1387 push(
1388 &f.member.path,
1389 "unused-class-member",
1390 Some(format!(
1391 "{}{ID_SEP}{}",
1392 f.member.parent_name, f.member.member_name
1393 )),
1394 );
1395 }
1396 for f in &results.unused_store_members {
1397 push(
1398 &f.member.path,
1399 "unused-store-member",
1400 Some(format!(
1401 "{}{ID_SEP}{}",
1402 f.member.parent_name, f.member.member_name
1403 )),
1404 );
1405 }
1406 for f in &results.unprovided_injects {
1407 push(
1408 &f.inject.path,
1409 "unprovided-inject",
1410 Some(f.inject.key_name.clone()),
1411 );
1412 }
1413 for f in &results.unrendered_components {
1414 push(
1415 &f.component.path,
1416 "unrendered-component",
1417 Some(f.component.component_name.clone()),
1418 );
1419 }
1420 for f in &results.unused_component_props {
1421 push(
1422 &f.prop.path,
1423 "unused-component-prop",
1424 Some(f.prop.prop_name.clone()),
1425 );
1426 }
1427 for f in &results.unused_component_emits {
1428 push(
1429 &f.emit.path,
1430 "unused-component-emit",
1431 Some(f.emit.emit_name.clone()),
1432 );
1433 }
1434 for f in &results.unresolved_imports {
1435 push(
1436 &f.import.path,
1437 "unresolved-import",
1438 Some(f.import.specifier.clone()),
1439 );
1440 }
1441 for f in &results.boundary_violations {
1442 let to_path = f.violation.to_path.to_string_lossy().replace('\\', "/");
1443 push(
1444 &f.violation.from_path,
1445 "boundary-violation",
1446 Some(format!("{to_path}{ID_SEP}{}", f.violation.import_specifier)),
1447 );
1448 }
1449}
1450
1451fn collect_dependency_findings(
1452 results: &AnalysisResults,
1453 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1454) {
1455 for f in &results.unused_dependencies {
1456 push(
1457 &f.dep.path,
1458 "unused-dependency",
1459 Some(f.dep.package_name.clone()),
1460 );
1461 }
1462 for f in &results.unused_dev_dependencies {
1463 push(
1464 &f.dep.path,
1465 "unused-dev-dependency",
1466 Some(f.dep.package_name.clone()),
1467 );
1468 }
1469 for f in &results.unused_optional_dependencies {
1470 push(
1471 &f.dep.path,
1472 "unused-optional-dependency",
1473 Some(f.dep.package_name.clone()),
1474 );
1475 }
1476 for f in &results.type_only_dependencies {
1477 push(
1478 &f.dep.path,
1479 "type-only-dependency",
1480 Some(f.dep.package_name.clone()),
1481 );
1482 }
1483 for f in &results.test_only_dependencies {
1484 push(
1485 &f.dep.path,
1486 "test-only-dependency",
1487 Some(f.dep.package_name.clone()),
1488 );
1489 }
1490}
1491
1492fn collect_catalog_findings(
1493 results: &AnalysisResults,
1494 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1495) {
1496 for f in &results.unused_catalog_entries {
1497 push(
1498 &f.entry.path,
1499 "unused-catalog-entry",
1500 Some(format!(
1501 "{}{ID_SEP}{}",
1502 f.entry.catalog_name, f.entry.entry_name
1503 )),
1504 );
1505 }
1506 for f in &results.empty_catalog_groups {
1507 push(
1508 &f.group.path,
1509 "empty-catalog-group",
1510 Some(f.group.catalog_name.clone()),
1511 );
1512 }
1513 for f in &results.unresolved_catalog_references {
1514 push(
1515 &f.reference.path,
1516 "unresolved-catalog-reference",
1517 Some(format!(
1518 "{}{ID_SEP}{}",
1519 f.reference.catalog_name, f.reference.entry_name
1520 )),
1521 );
1522 }
1523 for f in &results.unused_dependency_overrides {
1524 push(
1525 &f.entry.path,
1526 "unused-dependency-override",
1527 Some(f.entry.raw_key.clone()),
1528 );
1529 }
1530 for f in &results.misconfigured_dependency_overrides {
1531 push(
1532 &f.entry.path,
1533 "misconfigured-dependency-override",
1534 Some(f.entry.raw_key.clone()),
1535 );
1536 }
1537}
1538
1539#[must_use]
1543pub fn collect_complexity_findings(
1544 report: &crate::health_types::HealthReport,
1545) -> Vec<FindingInput> {
1546 report
1547 .findings
1548 .iter()
1549 .map(|f| FindingInput {
1550 path: f.path.clone(),
1551 kind: "complexity",
1552 symbol: Some(f.name.clone()),
1553 })
1554 .collect()
1555}
1556
1557#[must_use]
1561pub fn collect_clone_findings(
1562 report: &fallow_core::duplicates::DuplicationReport,
1563) -> Vec<CloneInput> {
1564 report
1565 .clone_groups
1566 .iter()
1567 .map(|g| CloneInput {
1568 fingerprint: fallow_core::duplicates::clone_fingerprint(&g.instances),
1569 instance_paths: g.instances.iter().map(|i| i.file.clone()).collect(),
1570 })
1571 .collect()
1572}
1573
1574const fn verdict_label(verdict: AuditVerdict) -> &'static str {
1575 match verdict {
1576 AuditVerdict::Pass => "pass",
1577 AuditVerdict::Warn => "warn",
1578 AuditVerdict::Fail => "fail",
1579 }
1580}
1581
1582#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1584#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1585#[serde(rename_all = "snake_case")]
1586pub enum ImpactTrendDirection {
1587 Improving,
1589 Declining,
1591 Stable,
1593}
1594
1595#[derive(Debug, Clone, Serialize)]
1597#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1598pub struct TrendSummary {
1599 pub direction: ImpactTrendDirection,
1600 pub total_delta: i64,
1602 pub previous_total: usize,
1603 pub current_total: usize,
1604}
1605
1606fn direction_for(delta: i64) -> ImpactTrendDirection {
1607 if delta < -TREND_TOLERANCE {
1608 ImpactTrendDirection::Improving
1609 } else if delta > TREND_TOLERANCE {
1610 ImpactTrendDirection::Declining
1611 } else {
1612 ImpactTrendDirection::Stable
1613 }
1614}
1615
1616#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1623#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1624pub enum ImpactReportSchemaVersion {
1625 #[serde(rename = "1")]
1627 V1,
1628}
1629
1630#[derive(Debug, Clone, Serialize)]
1632#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1633#[cfg_attr(feature = "schema", schemars(title = "fallow impact --format json"))]
1634pub struct ImpactReport {
1635 pub schema_version: ImpactReportSchemaVersion,
1639 pub enabled: bool,
1640 pub enabled_source: EnabledSource,
1647 pub record_count: usize,
1648 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
1649 pub meta: Option<Meta>,
1650 #[serde(default, skip_serializing_if = "Option::is_none")]
1651 pub first_recorded: Option<String>,
1652 #[serde(default, skip_serializing_if = "Option::is_none")]
1659 pub latest_git_sha: Option<String>,
1660 #[serde(default, skip_serializing_if = "Option::is_none")]
1665 pub surfacing: Option<ImpactCounts>,
1666 #[serde(default, skip_serializing_if = "Option::is_none")]
1668 pub trend: Option<TrendSummary>,
1669 #[serde(default, skip_serializing_if = "Option::is_none")]
1674 pub project_surfacing: Option<ImpactCounts>,
1675 #[serde(default, skip_serializing_if = "Option::is_none")]
1679 pub project_trend: Option<TrendSummary>,
1680 pub containment_count: usize,
1681 pub recent_containment: Vec<ContainmentEvent>,
1683 pub resolved_total: usize,
1686 pub suppressed_total: usize,
1689 pub recent_resolved: Vec<ResolutionEvent>,
1691 pub attribution_active: bool,
1695 pub onboarding_declined: bool,
1699 pub explicit_decision: bool,
1704}
1705
1706fn trend_for(records: &[ImpactRecord]) -> Option<TrendSummary> {
1712 if records.len() < 2 {
1713 return None;
1714 }
1715 let current = &records[records.len() - 1];
1716 let previous = &records[records.len() - 2];
1717 let current_total = current.counts.total_issues;
1718 let previous_total = previous.counts.total_issues;
1719 let total_delta = current_total as i64 - previous_total as i64;
1720 Some(TrendSummary {
1721 direction: direction_for(total_delta),
1722 total_delta,
1723 previous_total,
1724 current_total,
1725 })
1726}
1727
1728pub fn build_report(store: &ImpactStore) -> ImpactReport {
1729 let surfacing = store.records.last().map(|r| r.counts.clone());
1730 let trend = trend_for(&store.records);
1731 let project_surfacing = store.project_records.last().map(|r| r.counts.clone());
1732 let project_trend = trend_for(&store.project_records);
1733
1734 let recent_containment = store
1735 .containment
1736 .iter()
1737 .rev()
1738 .take(5)
1739 .rev()
1740 .cloned()
1741 .collect();
1742
1743 let latest_git_sha = store.records.last().and_then(|r| r.git_sha.clone());
1744
1745 let recent_resolved = store
1746 .recent_resolved
1747 .iter()
1748 .rev()
1749 .take(5)
1750 .rev()
1751 .cloned()
1752 .collect();
1753 let attribution_active = !store.frontier.is_empty()
1754 || !store.clone_frontier.is_empty()
1755 || store.resolved_total > 0
1756 || store.suppressed_total > 0;
1757
1758 let (enabled, enabled_source) = resolve_enabled(store);
1759 ImpactReport {
1760 schema_version: ImpactReportSchemaVersion::V1,
1761 enabled,
1762 enabled_source,
1763 record_count: store.records.len(),
1764 meta: None,
1765 first_recorded: store.first_recorded.clone(),
1766 latest_git_sha,
1767 surfacing,
1768 trend,
1769 project_surfacing,
1770 project_trend,
1771 containment_count: store.containment.len(),
1772 recent_containment,
1773 resolved_total: store.resolved_total,
1774 suppressed_total: store.suppressed_total,
1775 recent_resolved,
1776 attribution_active,
1777 onboarding_declined: store.onboarding_declined,
1778 explicit_decision: store.explicit_decision,
1779 }
1780}
1781
1782#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1788#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1789pub enum CrossRepoImpactSchemaVersion {
1790 #[serde(rename = "1")]
1792 V1,
1793}
1794
1795#[derive(Debug, Clone, Default, Serialize)]
1798#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1799pub struct CrossRepoTotals {
1800 pub resolved_total: usize,
1801 pub suppressed_total: usize,
1802 pub containment_count: usize,
1803 pub project_wide_issues: usize,
1807 pub projects_with_baseline: usize,
1808}
1809
1810#[derive(Debug, Clone, Serialize)]
1812#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1813pub struct CrossRepoProjectEntry {
1814 pub project_key: String,
1817 #[serde(default, skip_serializing_if = "Option::is_none")]
1820 pub label: Option<String>,
1821 #[serde(default, skip_serializing_if = "Option::is_none")]
1824 pub last_recorded: Option<String>,
1825 pub report: ImpactReport,
1828}
1829
1830#[derive(Debug, Clone, Serialize)]
1832#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1833#[cfg_attr(
1834 feature = "schema",
1835 schemars(title = "fallow impact --all --format json")
1836)]
1837pub struct CrossRepoImpactReport {
1838 pub schema_version: CrossRepoImpactSchemaVersion,
1839 pub project_count: usize,
1842 pub tracked_count: usize,
1845 pub unreadable_count: usize,
1847 pub totals: CrossRepoTotals,
1848 pub projects: Vec<CrossRepoProjectEntry>,
1849}
1850
1851#[derive(Debug, Clone, Copy)]
1853pub enum CrossRepoSort {
1854 Recent,
1856 Resolved,
1858 Contained,
1860 Name,
1862}
1863
1864fn latest_activity(store: &ImpactStore) -> Option<String> {
1866 let a = store.records.last().map(|r| r.timestamp.clone());
1867 let b = store.project_records.last().map(|r| r.timestamp.clone());
1868 match (a, b) {
1869 (Some(x), Some(y)) => Some(if x >= y { x } else { y }),
1870 (x, y) => x.or(y),
1871 }
1872}
1873
1874#[must_use]
1880pub fn load_all() -> (Vec<(String, ImpactStore)>, usize) {
1881 let Some(dir) = impact_config_dir().map(|d| d.join("impact")) else {
1882 return (Vec::new(), 0);
1883 };
1884 let Ok(read) = std::fs::read_dir(&dir) else {
1885 return (Vec::new(), 0);
1886 };
1887 let mut stores = Vec::new();
1888 let mut unreadable = 0usize;
1889 for entry in read.flatten() {
1890 let path = entry.path();
1891 if path.extension().and_then(|e| e.to_str()) != Some("json") {
1892 continue;
1893 }
1894 let Some(key) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
1895 continue;
1896 };
1897 match std::fs::read_to_string(&path)
1898 .ok()
1899 .and_then(|c| serde_json::from_str::<ImpactStore>(&c).ok())
1900 {
1901 Some(store) => stores.push((key, store)),
1902 None => unreadable += 1,
1903 }
1904 }
1905 (stores, unreadable)
1906}
1907
1908#[must_use]
1912pub fn build_aggregate_report(
1913 stores: Vec<(String, ImpactStore)>,
1914 unreadable: usize,
1915 sort: CrossRepoSort,
1916) -> CrossRepoImpactReport {
1917 let project_count = stores.len();
1918 let mut totals = CrossRepoTotals::default();
1919 let mut projects = Vec::new();
1920 for (key, store) in stores {
1921 let report = build_report(&store);
1922 let has_history = report.record_count > 0
1923 || report.project_surfacing.is_some()
1924 || report.resolved_total > 0
1925 || report.containment_count > 0;
1926 if !has_history {
1927 continue;
1928 }
1929 totals.resolved_total += report.resolved_total;
1930 totals.suppressed_total += report.suppressed_total;
1931 totals.containment_count += report.containment_count;
1932 if let Some(ps) = &report.project_surfacing {
1933 totals.project_wide_issues += ps.total_issues;
1934 totals.projects_with_baseline += 1;
1935 }
1936 projects.push(CrossRepoProjectEntry {
1937 project_key: key,
1938 label: store.label.clone(),
1939 last_recorded: latest_activity(&store),
1940 report,
1941 });
1942 }
1943 sort_cross_repo(&mut projects, sort);
1944 CrossRepoImpactReport {
1945 schema_version: CrossRepoImpactSchemaVersion::V1,
1946 project_count,
1947 tracked_count: projects.len(),
1948 unreadable_count: unreadable,
1949 totals,
1950 projects,
1951 }
1952}
1953
1954fn sort_cross_repo(projects: &mut [CrossRepoProjectEntry], sort: CrossRepoSort) {
1955 match sort {
1956 CrossRepoSort::Recent => projects.sort_by(|a, b| {
1959 b.last_recorded
1960 .cmp(&a.last_recorded)
1961 .then_with(|| a.project_key.cmp(&b.project_key))
1962 }),
1963 CrossRepoSort::Resolved => projects.sort_by(|a, b| {
1964 b.report
1965 .resolved_total
1966 .cmp(&a.report.resolved_total)
1967 .then_with(|| a.project_key.cmp(&b.project_key))
1968 }),
1969 CrossRepoSort::Contained => projects.sort_by(|a, b| {
1970 b.report
1971 .containment_count
1972 .cmp(&a.report.containment_count)
1973 .then_with(|| a.project_key.cmp(&b.project_key))
1974 }),
1975 CrossRepoSort::Name => projects.sort_by(|a, b| {
1976 cross_repo_label(a)
1977 .cmp(&cross_repo_label(b))
1978 .then_with(|| a.project_key.cmp(&b.project_key))
1979 }),
1980 }
1981}
1982
1983fn cross_repo_label(entry: &CrossRepoProjectEntry) -> String {
1986 entry
1987 .label
1988 .clone()
1989 .unwrap_or_else(|| short_key(&entry.project_key))
1990}
1991
1992fn short_key(key: &str) -> String {
1994 key.chars().take(12).collect()
1995}
1996
1997#[must_use]
1999pub fn aggregate(sort: CrossRepoSort) -> CrossRepoImpactReport {
2000 let (stores, unreadable) = load_all();
2001 build_aggregate_report(stores, unreadable, sort)
2002}
2003
2004#[expect(
2010 clippy::format_push_string,
2011 reason = "small report renderer; readability over avoiding the extra allocation"
2012)]
2013fn render_project_section(out: &mut String, report: &ImpactReport) {
2014 let Some(s) = &report.project_surfacing else {
2015 return;
2016 };
2017 out.push_str(&format!(
2018 " WHOLE PROJECT (whole-repo context, not a to-do)\n {} issue{} across the whole project at your last full `fallow` run\n",
2019 s.total_issues,
2020 plural(s.total_issues),
2021 ));
2022 if let Some(t) = &report.project_trend {
2023 let arrow = trend_arrow(t.direction);
2024 out.push_str(&format!(
2025 " {} -> {} ({}) across your last two full runs (comparable over time)\n",
2026 t.previous_total, t.current_total, arrow,
2027 ));
2028 } else {
2029 out.push_str(" project trend starts after your next full `fallow` run\n");
2030 }
2031 out.push_str(" advances only on your local full `fallow` runs, not CI\n\n");
2032}
2033
2034#[expect(
2036 clippy::format_push_string,
2037 reason = "small report renderer; readability over avoiding the extra allocation"
2038)]
2039pub fn render_human(report: &ImpactReport) -> String {
2040 let mut out = String::new();
2041 out.push_str("FALLOW IMPACT\n\n");
2042
2043 if !report.enabled {
2044 out.push_str(
2045 "Impact tracking is off. Enable it with `fallow impact enable`, then\n\
2046 let your pre-commit gate run a few times to build history.\n",
2047 );
2048 return out;
2049 }
2050
2051 if report.enabled_source == EnabledSource::User {
2052 out.push_str(
2053 "Enabled by your user-global default (`fallow impact default on`). Run\n\
2054 `fallow impact disable` to opt this project out.\n\n",
2055 );
2056 }
2057
2058 if report.record_count == 0 && report.project_surfacing.is_none() {
2059 out.push_str(
2060 "Tracking enabled. No history yet: check back after your next few\n\
2061 commits (Impact records each `fallow audit` / pre-commit gate run,\n\
2062 and each full `fallow` run for the whole-project view).\n",
2063 );
2064 return out;
2065 }
2066
2067 if let Some(s) = &report.surfacing {
2068 out.push_str(&format!(
2069 " LATEST RUN (changed files, act on these now)\n {} issue{} flagged in your last `fallow audit` run\n",
2070 s.total_issues,
2071 plural(s.total_issues),
2072 ));
2073 out.push_str(&format!(
2074 " dead code {} · complexity {} · duplication {}\n\n",
2075 s.dead_code, s.complexity, s.duplication,
2076 ));
2077 }
2078
2079 if let Some(t) = &report.trend {
2080 let arrow = trend_arrow(t.direction);
2081 out.push_str(&format!(
2082 " 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",
2083 t.previous_total, t.current_total, arrow,
2084 ));
2085 }
2086
2087 render_project_section(&mut out, report);
2088
2089 out.push_str(&format!(
2090 " CONTAINED AT COMMIT\n {} time{} fallow blocked a commit until it was fixed\n",
2091 report.containment_count,
2092 plural(report.containment_count),
2093 ));
2094
2095 if report.resolved_total > 0 {
2096 out.push_str(&format!(
2097 "\n RESOLVED\n {} finding{} you cleared since fallow started tracking\n",
2098 report.resolved_total,
2099 plural(report.resolved_total),
2100 ));
2101 for ev in &report.recent_resolved {
2102 match &ev.symbol {
2103 Some(symbol) => {
2104 out.push_str(&format!(" {} {} in {}\n", ev.kind, symbol, ev.path));
2105 }
2106 None => out.push_str(&format!(" {} in {}\n", ev.kind, ev.path)),
2107 }
2108 }
2109 } else if report.attribution_active {
2110 out.push_str(
2111 "\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",
2112 );
2113 } else {
2114 out.push_str("\n RESOLVED\n resolution tracking starts from your next gate run\n");
2115 }
2116
2117 if report.suppressed_total > 0 {
2118 out.push_str(&format!(
2119 " {} finding{} you marked intentional (fallow-ignore), not counted as resolved\n",
2120 report.suppressed_total,
2121 plural(report.suppressed_total),
2122 ));
2123 }
2124
2125 out.push('\n');
2126 let since = report
2127 .first_recorded
2128 .as_deref()
2129 .map_or("the first run", date_only);
2130 if report.record_count > 0 {
2131 out.push_str(&format!(
2132 "Based on {} recorded audit run{} since {}. Local-only; never uploaded.\n\
2133 Changed-file scope: each audit run only sees files differing from your base.\n",
2134 report.record_count,
2135 plural(report.record_count),
2136 since,
2137 ));
2138 } else {
2139 out.push_str(&format!(
2140 "Tracking since {since}. Local-only; never uploaded.\n",
2141 ));
2142 }
2143 out.push_str(
2144 "Resolution tracking is a local-developer signal: it accrues on your\n\
2145 machine across runs, not in CI (fallow never records there).\n",
2146 );
2147 out
2148}
2149
2150pub fn render_json(report: &ImpactReport) -> String {
2152 let value = crate::output_envelope::serialize_root_output(
2153 crate::output_envelope::FallowOutput::Impact(report.clone()),
2154 )
2155 .unwrap_or_else(|_| serde_json::json!({"error":"failed to serialize impact report"}));
2156 serde_json::to_string_pretty(&value)
2157 .unwrap_or_else(|_| "{\"error\":\"failed to serialize impact report\"}".to_owned())
2158}
2159
2160#[expect(
2164 clippy::format_push_string,
2165 reason = "small report renderer; readability over avoiding the extra allocation"
2166)]
2167fn render_project_markdown(out: &mut String, report: &ImpactReport) {
2168 let Some(s) = &report.project_surfacing else {
2169 return;
2170 };
2171 out.push_str(&format!(
2172 "- **Whole project (whole-repo context, last full `fallow` run):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
2173 s.total_issues,
2174 plural(s.total_issues),
2175 s.dead_code,
2176 s.complexity,
2177 s.duplication,
2178 ));
2179 if let Some(t) = &report.project_trend {
2180 let arrow = trend_arrow(t.direction);
2181 out.push_str(&format!(
2182 "- **Project trend (whole project, last two full runs):** {} -> {} ({})\n",
2183 t.previous_total, t.current_total, arrow,
2184 ));
2185 }
2186}
2187
2188#[expect(
2190 clippy::format_push_string,
2191 reason = "small report renderer; readability over avoiding the extra allocation"
2192)]
2193pub fn render_markdown(report: &ImpactReport) -> String {
2194 let mut out = String::new();
2195 out.push_str("## Fallow impact\n\n");
2196
2197 if !report.enabled {
2198 out.push_str("Impact tracking is off. Run `fallow impact enable` to start.\n");
2199 return out;
2200 }
2201 if report.record_count == 0 && report.project_surfacing.is_none() {
2202 out.push_str("Tracking enabled. No history yet; check back after a few commits.\n");
2203 return out;
2204 }
2205
2206 if let Some(s) = &report.surfacing {
2207 out.push_str(&format!(
2208 "- **Latest run (changed files):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
2209 s.total_issues,
2210 plural(s.total_issues),
2211 s.dead_code,
2212 s.complexity,
2213 s.duplication,
2214 ));
2215 }
2216 if let Some(t) = &report.trend {
2217 out.push_str(&format!(
2218 "- **Trend (changed-file scope, last two runs):** {} -> {} ({})\n",
2219 t.previous_total,
2220 t.current_total,
2221 trend_arrow(t.direction),
2222 ));
2223 }
2224 render_project_markdown(&mut out, report);
2225 out.push_str(&format!(
2226 "- **Contained at commit:** {} time{}\n",
2227 report.containment_count,
2228 plural(report.containment_count),
2229 ));
2230 if report.resolved_total > 0 {
2231 out.push_str(&format!(
2232 "- **Resolved:** {} finding{} cleared since tracking started\n",
2233 report.resolved_total,
2234 plural(report.resolved_total),
2235 ));
2236 } else if report.attribution_active {
2237 out.push_str("- **Resolved:** none yet; tracking active\n");
2238 } else {
2239 out.push_str("- **Resolved:** resolution tracking starts from your next gate run\n");
2240 }
2241 if report.suppressed_total > 0 {
2242 out.push_str(&format!(
2243 "- **Marked intentional:** {} finding{} (`fallow-ignore`), not counted as resolved\n",
2244 report.suppressed_total,
2245 plural(report.suppressed_total),
2246 ));
2247 }
2248 let since = report
2249 .first_recorded
2250 .as_deref()
2251 .map_or("the first run", date_only);
2252 if report.record_count > 0 {
2253 out.push_str(&format!(
2254 "\n_Based on {} recorded audit run{} since {}. Local-only; resolution is a local-developer signal._\n",
2255 report.record_count,
2256 plural(report.record_count),
2257 since,
2258 ));
2259 } else {
2260 out.push_str(&format!(
2261 "\n_Tracking since {since}. Local-only; resolution is a local-developer signal._\n",
2262 ));
2263 }
2264 out
2265}
2266
2267#[must_use]
2269pub fn render_cross_repo_json(report: &CrossRepoImpactReport) -> String {
2270 let value = crate::output_envelope::serialize_root_output(
2271 crate::output_envelope::FallowOutput::ImpactCrossRepo(report.clone()),
2272 )
2273 .unwrap_or_else(
2274 |_| serde_json::json!({"error":"failed to serialize cross-repo impact report"}),
2275 );
2276 serde_json::to_string_pretty(&value).unwrap_or_else(|_| {
2277 "{\"error\":\"failed to serialize cross-repo impact report\"}".to_owned()
2278 })
2279}
2280
2281fn row_label(entry: &CrossRepoProjectEntry) -> String {
2284 cross_repo_label(entry)
2285}
2286
2287fn opt_count(c: Option<&ImpactCounts>) -> String {
2288 c.map_or_else(|| "-".to_owned(), |c| c.total_issues.to_string())
2289}
2290
2291fn row_trend(report: &ImpactReport) -> &'static str {
2292 report
2293 .project_trend
2294 .as_ref()
2295 .or(report.trend.as_ref())
2296 .map_or("-", |t| trend_arrow(t.direction))
2297}
2298
2299#[expect(
2303 clippy::format_push_string,
2304 reason = "small report renderer; readability over avoiding the extra allocation"
2305)]
2306#[must_use]
2307pub fn render_cross_repo_human(report: &CrossRepoImpactReport, limit: Option<usize>) -> String {
2308 let mut out = String::new();
2309 out.push_str("FALLOW IMPACT (ALL PROJECTS)\n\n");
2310
2311 if report.project_count == 0 {
2312 if report.unreadable_count > 0 {
2313 out.push_str(&format!(
2314 "No readable projects: skipped {} unreadable store{} (corrupt, or written by \
2315 a newer fallow). Upgrade fallow to read them.\n",
2316 report.unreadable_count,
2317 plural(report.unreadable_count),
2318 ));
2319 } else {
2320 out.push_str(
2321 "No projects tracked yet. Enable in a repo with `fallow impact enable`, or for \
2322 every project with `fallow impact default on`.\n",
2323 );
2324 }
2325 return out;
2326 }
2327
2328 out.push_str(&format!(
2329 "{} project{} tracked, {} with history\n\n",
2330 report.project_count,
2331 plural(report.project_count),
2332 report.tracked_count,
2333 ));
2334
2335 if !report.projects.is_empty() {
2336 out.push_str(&format!(
2337 "{:<24}{:>8}{:>10}{:>11}{:>10}{:>7} {}\n",
2338 "PROJECT", "LATEST", "REPO-WIDE", "CONTAINED", "RESOLVED", "TREND", "LAST RUN",
2339 ));
2340 let rows = limit.map_or(report.projects.len(), |n| n.min(report.projects.len()));
2341 for entry in report.projects.iter().take(rows) {
2342 let mut label = row_label(entry);
2343 if label.chars().count() > 22 {
2344 label = format!("{}...", label.chars().take(19).collect::<String>());
2345 }
2346 let last = entry
2347 .last_recorded
2348 .as_deref()
2349 .map_or("-", date_only)
2350 .to_owned();
2351 out.push_str(&format!(
2352 "{:<24}{:>8}{:>10}{:>11}{:>10}{:>7} {}\n",
2353 label,
2354 opt_count(entry.report.surfacing.as_ref()),
2355 opt_count(entry.report.project_surfacing.as_ref()),
2356 entry.report.containment_count,
2357 entry.report.resolved_total,
2358 row_trend(&entry.report),
2359 last,
2360 ));
2361 }
2362 if let Some(n) = limit
2363 && report.projects.len() > n
2364 {
2365 out.push_str(&format!(
2366 " ... and {} more (raise --limit to show)\n",
2367 report.projects.len() - n,
2368 ));
2369 }
2370 }
2371
2372 let no_history = report.project_count.saturating_sub(report.tracked_count);
2373 if no_history > 0 {
2374 out.push_str(&format!(
2375 "\n{no_history} tracked project{} with no history yet\n",
2376 plural(no_history),
2377 ));
2378 }
2379 if report.unreadable_count > 0 {
2380 out.push_str(&format!(
2381 "skipped {} unreadable store{}\n",
2382 report.unreadable_count,
2383 plural(report.unreadable_count),
2384 ));
2385 }
2386
2387 let t = &report.totals;
2388 out.push_str("\nGRAND TOTALS\n");
2389 out.push_str(&format!(
2390 " Across {} tracked project{}: {} finding{} resolved, {} commit{} contained, {} marked intentional\n",
2391 report.tracked_count,
2392 plural(report.tracked_count),
2393 t.resolved_total,
2394 plural(t.resolved_total),
2395 t.containment_count,
2396 plural(t.containment_count),
2397 t.suppressed_total,
2398 ));
2399 if t.projects_with_baseline > 0 {
2400 out.push_str(&format!(
2401 " {} issue{} project-wide across {} project{} with a full-run baseline (as of each project's last full run)\n",
2402 t.project_wide_issues,
2403 plural(t.project_wide_issues),
2404 t.projects_with_baseline,
2405 plural(t.projects_with_baseline),
2406 ));
2407 }
2408 out.push_str("\nLocal-only; never uploaded; accrues on this machine, not CI.\n");
2409 out
2410}
2411
2412#[expect(
2414 clippy::format_push_string,
2415 reason = "small report renderer; readability over avoiding the extra allocation"
2416)]
2417#[must_use]
2418pub fn render_cross_repo_markdown(report: &CrossRepoImpactReport) -> String {
2419 let mut out = String::new();
2420 out.push_str("## Fallow impact (all projects)\n\n");
2421 if report.project_count == 0 {
2422 if report.unreadable_count > 0 {
2423 out.push_str(&format!(
2424 "No readable projects: skipped {} unreadable store{}.\n",
2425 report.unreadable_count,
2426 plural(report.unreadable_count),
2427 ));
2428 } else {
2429 out.push_str("No projects tracked yet.\n");
2430 }
2431 return out;
2432 }
2433 out.push_str(&format!(
2434 "{} project{} tracked, {} with history.\n\n",
2435 report.project_count,
2436 plural(report.project_count),
2437 report.tracked_count,
2438 ));
2439 if !report.projects.is_empty() {
2440 out.push_str("| Project | Latest | Repo-wide | Contained | Resolved | Last run |\n");
2441 out.push_str("|:--------|-------:|----------:|----------:|---------:|:---------|\n");
2442 for entry in &report.projects {
2443 out.push_str(&format!(
2444 "| {} | {} | {} | {} | {} | {} |\n",
2445 row_label(entry),
2446 opt_count(entry.report.surfacing.as_ref()),
2447 opt_count(entry.report.project_surfacing.as_ref()),
2448 entry.report.containment_count,
2449 entry.report.resolved_total,
2450 entry.last_recorded.as_deref().map_or("-", date_only),
2451 ));
2452 }
2453 }
2454 let t = &report.totals;
2455 out.push_str(&format!(
2456 "\n**Grand totals:** {} resolved, {} contained, {} marked intentional across {} tracked project{}",
2457 t.resolved_total,
2458 t.containment_count,
2459 t.suppressed_total,
2460 report.tracked_count,
2461 plural(report.tracked_count),
2462 ));
2463 if t.projects_with_baseline > 0 {
2464 out.push_str(&format!(
2465 "; {} issue{} project-wide across {} project{} with a full-run baseline (as of each project's last full run)",
2466 t.project_wide_issues,
2467 plural(t.project_wide_issues),
2468 t.projects_with_baseline,
2469 plural(t.projects_with_baseline),
2470 ));
2471 }
2472 out.push_str(".\n\n_Local-only; never uploaded; accrues on this machine, not CI._\n");
2473 out
2474}
2475
2476const fn plural(n: usize) -> &'static str {
2477 if n == 1 { "" } else { "s" }
2478}
2479
2480fn date_only(ts: &str) -> &str {
2486 ts.split_once('T').map_or(ts, |(date, _)| date)
2487}
2488
2489const fn trend_arrow(direction: ImpactTrendDirection) -> &'static str {
2493 match direction {
2494 ImpactTrendDirection::Improving => "down",
2495 ImpactTrendDirection::Declining => "up",
2496 ImpactTrendDirection::Stable => "flat",
2497 }
2498}
2499
2500#[cfg(test)]
2501mod tests {
2502 use super::*;
2503
2504 fn test_env() -> (tempfile::TempDir, tempfile::TempDir) {
2510 let config = tempfile::tempdir().unwrap();
2511 TEST_CONFIG_DIR.with(|c| *c.borrow_mut() = Some(config.path().to_path_buf()));
2512 let root = tempfile::tempdir().unwrap();
2513 (config, root)
2514 }
2515
2516 fn frontier_paths(store: &ImpactStore) -> FxHashSet<String> {
2519 store
2520 .frontier
2521 .values()
2522 .flat_map(|m| m.keys().cloned())
2523 .collect()
2524 }
2525
2526 fn clone_fingerprints(store: &ImpactStore) -> FxHashSet<String> {
2528 store
2529 .clone_frontier
2530 .values()
2531 .flat_map(|m| m.keys().cloned())
2532 .collect()
2533 }
2534
2535 fn seed_store_raw(root: &Path, bytes: &[u8]) {
2538 let path = store_path(root).expect("test config dir set");
2539 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2540 std::fs::write(&path, bytes).unwrap();
2541 }
2542
2543 fn summary(dead: usize, complexity: usize, dupes: usize) -> AuditSummary {
2544 AuditSummary {
2545 dead_code_issues: dead,
2546 dead_code_has_errors: dead > 0,
2547 complexity_findings: complexity,
2548 max_cyclomatic: None,
2549 duplication_clone_groups: dupes,
2550 }
2551 }
2552
2553 fn record_v1(
2555 root: &Path,
2556 summary: &AuditSummary,
2557 verdict: AuditVerdict,
2558 gate: bool,
2559 git_sha: Option<&str>,
2560 version: &str,
2561 timestamp: &str,
2562 ) {
2563 record_audit_run(
2564 root,
2565 summary,
2566 &AuditRunRecord {
2567 verdict,
2568 gate,
2569 git_sha,
2570 version,
2571 timestamp,
2572 attribution: None,
2573 },
2574 );
2575 }
2576
2577 fn touch(root: &Path, rel: &str) -> PathBuf {
2580 let p = root.join(rel);
2581 if let Some(parent) = p.parent() {
2582 std::fs::create_dir_all(parent).unwrap();
2583 }
2584 std::fs::write(&p, b"x").unwrap();
2585 p
2586 }
2587
2588 fn fi(path: &Path, kind: &'static str, symbol: &str) -> FindingInput {
2589 FindingInput {
2590 path: path.to_path_buf(),
2591 kind,
2592 symbol: Some(symbol.to_owned()),
2593 }
2594 }
2595
2596 fn supp(path: &Path, kind: &str) -> ActiveSuppression {
2597 ActiveSuppression {
2598 path: path.to_path_buf(),
2599 kind: Some(kind.to_owned()),
2600 is_file_level: false,
2601 }
2602 }
2603
2604 fn run(
2606 root: &Path,
2607 changed: &[&Path],
2608 findings: Vec<FindingInput>,
2609 clones: Vec<CloneInput>,
2610 supps: &[ActiveSuppression],
2611 ts: &str,
2612 ) {
2613 let changed_files: Vec<PathBuf> = changed.iter().map(|p| p.to_path_buf()).collect();
2614 let input = AttributionInput {
2615 root,
2616 scope: Scope::ChangedFiles(&changed_files),
2617 findings,
2618 clones,
2619 suppressions: supps,
2620 };
2621 record_audit_run(
2622 root,
2623 &summary(0, 0, 0),
2624 &AuditRunRecord {
2625 verdict: AuditVerdict::Pass,
2626 gate: true,
2627 git_sha: Some("sha"),
2628 version: "2.0.0",
2629 timestamp: ts,
2630 attribution: Some(&input),
2631 },
2632 );
2633 }
2634
2635 #[test]
2636 fn disabled_store_does_not_record() {
2637 let (_config, dir) = test_env();
2638 let root = dir.path();
2639 record_v1(
2640 root,
2641 &summary(3, 1, 0),
2642 AuditVerdict::Fail,
2643 true,
2644 Some("abc1234"),
2645 "2.0.0",
2646 "2026-05-29T10:00:00Z",
2647 );
2648 let store = load(root);
2649 assert!(store.records.is_empty());
2650 assert!(!store.enabled);
2651 }
2652
2653 #[test]
2654 fn enable_and_disable_record_the_explicit_decision() {
2655 let (_config, dir) = test_env();
2656 let root = dir.path();
2657 assert!(!load(root).explicit_decision, "fresh store: never asked");
2658
2659 disable(root);
2661 let store = load(root);
2662 assert!(!store.enabled);
2663 assert!(store.explicit_decision);
2664 assert!(build_report(&store).explicit_decision);
2665 }
2666
2667 #[test]
2668 fn due_digest_stamps_and_respects_interval_and_gates() {
2669 let (_config, dir) = test_env();
2670 let root = dir.path();
2671
2672 assert!(take_due_digest(root).is_none());
2674 enable(root);
2675 assert!(take_due_digest(root).is_none(), "zero counters never nag");
2676
2677 let mut store = load(root);
2678 store.resolved_total = 3;
2679 store.containment.push(ContainmentEvent {
2680 blocked_at: "2026-06-11T00:00:00Z".to_string(),
2681 cleared_at: "2026-06-11T00:05:00Z".to_string(),
2682 git_sha: None,
2683 blocked_counts: ImpactCounts::default(),
2684 });
2685 save(&store, root);
2686
2687 let digest = take_due_digest(root).expect("first digest is due");
2688 assert_eq!(digest.containment_count, 1);
2689 assert_eq!(digest.resolved_total, 3);
2690 assert!(
2691 take_due_digest(root).is_none(),
2692 "stamped: not due again within the interval"
2693 );
2694
2695 let mut store = load(root);
2697 store.last_digest_epoch = Some(0);
2698 save(&store, root);
2699 assert!(take_due_digest(root).is_some());
2700 }
2701
2702 #[test]
2703 fn decline_onboarding_persists_in_existing_store() {
2704 let (_config, dir) = test_env();
2705 let root = dir.path();
2706
2707 assert!(decline_onboarding(root));
2708 assert!(!decline_onboarding(root));
2709
2710 let store = load(root);
2711 assert!(store.onboarding_declined);
2712 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
2713 assert!(!root.join(".gitignore").exists());
2715 let report = build_report(&store);
2716 assert!(report.onboarding_declined);
2717 }
2718
2719 #[test]
2720 fn enable_then_record_accrues_history() {
2721 let (_config, dir) = test_env();
2722 let root = dir.path();
2723 assert!(enable(root));
2724 assert!(!enable(root)); record_v1(
2726 root,
2727 &summary(2, 1, 0),
2728 AuditVerdict::Warn,
2729 false,
2730 None,
2731 "2.0.0",
2732 "2026-05-29T10:00:00Z",
2733 );
2734 let store = load(root);
2735 assert_eq!(store.records.len(), 1);
2736 assert_eq!(store.records[0].counts.total_issues, 3);
2737 assert_eq!(
2738 store.first_recorded.as_deref(),
2739 Some("2026-05-29T10:00:00Z")
2740 );
2741 }
2742
2743 #[test]
2744 fn record_is_a_noop_in_ci() {
2745 let (_config, dir) = test_env();
2751 let root = dir.path();
2752 assert!(enable(root));
2753 TEST_FORCE_CI.with(|c| c.set(true));
2754 record_v1(
2755 root,
2756 &summary(2, 1, 0),
2757 AuditVerdict::Warn,
2758 false,
2759 None,
2760 "2.0.0",
2761 "2026-05-29T10:00:00Z",
2762 );
2763 TEST_FORCE_CI.with(|c| c.set(false));
2764 let store = load(root);
2765 assert_eq!(store.records.len(), 0, "impact must not record while in CI");
2766 }
2767
2768 #[test]
2769 fn enable_writes_nothing_into_the_repo() {
2770 let (_config, dir) = test_env();
2771 let root = dir.path();
2772 enable(root);
2773 assert!(
2776 !root.join(".gitignore").exists(),
2777 "enable must not create or modify the repo's .gitignore"
2778 );
2779 assert!(
2780 !root.join(".fallow").exists(),
2781 "enable must not create an in-repo .fallow/ dir"
2782 );
2783 let store = load(root);
2785 assert!(store.enabled);
2786 assert!(store.explicit_decision);
2787 assert!(resolve_enabled(&store).0);
2788 }
2789
2790 #[test]
2791 fn single_record_yields_no_trend_no_spike() {
2792 let mut store = ImpactStore {
2793 enabled: true,
2794 ..Default::default()
2795 };
2796 store.records.push(ImpactRecord {
2797 timestamp: "t0".into(),
2798 version: "2.0.0".into(),
2799 git_sha: None,
2800 verdict: "warn".into(),
2801 gate: false,
2802 counts: ImpactCounts {
2803 total_issues: 5,
2804 dead_code: 5,
2805 complexity: 0,
2806 duplication: 0,
2807 },
2808 });
2809 let report = build_report(&store);
2810 assert!(report.trend.is_none());
2811 assert_eq!(report.surfacing.unwrap().total_issues, 5);
2812 }
2813
2814 #[test]
2815 fn empty_store_report_is_first_run() {
2816 let store = ImpactStore::default();
2817 let report = build_report(&store);
2818 assert_eq!(report.record_count, 0);
2819 assert!(report.trend.is_none());
2820 assert!(report.surfacing.is_none());
2821 let human = render_human(&report);
2822 assert!(human.contains("off")); }
2824
2825 #[test]
2826 fn enabled_empty_store_shows_check_back() {
2827 let store = ImpactStore {
2828 enabled: true,
2829 ..Default::default()
2830 };
2831 let report = build_report(&store);
2832 let human = render_human(&report);
2833 assert!(human.contains("No history yet"));
2834 assert!(!human.contains("0 issues"));
2835 }
2836
2837 #[test]
2838 fn trend_improving_when_issues_drop() {
2839 let mut store = ImpactStore {
2840 enabled: true,
2841 ..Default::default()
2842 };
2843 for total in [8usize, 3usize] {
2844 store.records.push(ImpactRecord {
2845 timestamp: format!("t{total}"),
2846 version: "2.0.0".into(),
2847 git_sha: None,
2848 verdict: "warn".into(),
2849 gate: false,
2850 counts: ImpactCounts {
2851 total_issues: total,
2852 dead_code: total,
2853 complexity: 0,
2854 duplication: 0,
2855 },
2856 });
2857 }
2858 let report = build_report(&store);
2859 let trend = report.trend.unwrap();
2860 assert_eq!(trend.direction, ImpactTrendDirection::Improving);
2861 assert_eq!(trend.total_delta, -5);
2862 }
2863
2864 #[test]
2865 fn containment_blocked_then_cleared_records_one_event() {
2866 let (_config, dir) = test_env();
2867 let root = dir.path();
2868 enable(root);
2869 record_v1(
2870 root,
2871 &summary(2, 0, 0),
2872 AuditVerdict::Fail,
2873 true,
2874 Some("sha1"),
2875 "2.0.0",
2876 "t0",
2877 );
2878 let store = load(root);
2879 assert!(store.pending_containment.is_some());
2880 assert!(store.containment.is_empty());
2881
2882 record_v1(
2883 root,
2884 &summary(0, 0, 0),
2885 AuditVerdict::Pass,
2886 true,
2887 Some("sha2"),
2888 "2.0.0",
2889 "t1",
2890 );
2891 let store = load(root);
2892 assert!(store.pending_containment.is_none());
2893 assert_eq!(store.containment.len(), 1);
2894 assert_eq!(store.containment[0].blocked_at, "t0");
2895 assert_eq!(store.containment[0].cleared_at, "t1");
2896 }
2897
2898 #[test]
2899 fn non_gate_run_never_creates_containment() {
2900 let (_config, dir) = test_env();
2901 let root = dir.path();
2902 enable(root);
2903 record_v1(
2904 root,
2905 &summary(2, 0, 0),
2906 AuditVerdict::Fail,
2907 false,
2908 None,
2909 "2.0.0",
2910 "t0",
2911 );
2912 let store = load(root);
2913 assert!(store.pending_containment.is_none());
2914 assert!(store.containment.is_empty());
2915 }
2916
2917 #[test]
2918 fn corrupt_store_loads_as_default_no_panic() {
2919 let (_config, dir) = test_env();
2920 let root = dir.path();
2921 seed_store_raw(root, b"{ not valid json ][");
2922 let store = load(root);
2923 assert!(!store.enabled);
2924 assert!(store.records.is_empty());
2925 record_v1(
2926 root,
2927 &summary(1, 0, 0),
2928 AuditVerdict::Fail,
2929 true,
2930 None,
2931 "2.0.0",
2932 "t0",
2933 );
2934 }
2935
2936 #[test]
2937 fn records_are_bounded() {
2938 let mut store = ImpactStore {
2939 enabled: true,
2940 ..Default::default()
2941 };
2942 for i in 0..(MAX_RECORDS + 50) {
2943 store.records.push(ImpactRecord {
2944 timestamp: format!("t{i}"),
2945 version: "2.0.0".into(),
2946 git_sha: None,
2947 verdict: "pass".into(),
2948 gate: false,
2949 counts: ImpactCounts::default(),
2950 });
2951 }
2952 compact(&mut store);
2953 assert_eq!(store.records.len(), MAX_RECORDS);
2954 assert_eq!(store.records[0].timestamp, "t50");
2955 }
2956
2957 #[test]
2958 fn report_always_carries_schema_version() {
2959 let empty = build_report(&ImpactStore::default());
2960 assert_eq!(empty.schema_version, ImpactReportSchemaVersion::V1);
2961 let json = render_json(&empty);
2962 assert!(
2963 json.contains("\"schema_version\": \"1\""),
2964 "schema_version must be present (as the \"1\" const) even when disabled: {json}"
2965 );
2966
2967 let mut store = ImpactStore {
2968 enabled: true,
2969 ..Default::default()
2970 };
2971 store.records.push(ImpactRecord {
2972 timestamp: "2026-05-29T10:00:00Z".into(),
2973 version: "2.0.0".into(),
2974 git_sha: None,
2975 verdict: "pass".into(),
2976 gate: false,
2977 counts: ImpactCounts::default(),
2978 });
2979 assert_eq!(
2980 build_report(&store).schema_version,
2981 ImpactReportSchemaVersion::V1
2982 );
2983 }
2984
2985 #[test]
2986 fn date_only_trims_iso_timestamp() {
2987 assert_eq!(date_only("2026-05-29T18:15:23Z"), "2026-05-29");
2988 assert_eq!(date_only("2026-05-29"), "2026-05-29");
2989 assert_eq!(date_only("the first run"), "the first run");
2990 }
2991
2992 #[test]
2993 fn human_footer_shows_date_only() {
2994 let mut store = ImpactStore {
2995 enabled: true,
2996 ..Default::default()
2997 };
2998 store.first_recorded = Some("2026-05-29T18:15:23Z".into());
2999 store.records.push(ImpactRecord {
3000 timestamp: "2026-05-29T18:15:23Z".into(),
3001 version: "2.0.0".into(),
3002 git_sha: None,
3003 verdict: "pass".into(),
3004 gate: false,
3005 counts: ImpactCounts::default(),
3006 });
3007 let report = build_report(&store);
3008 let human = render_human(&report);
3009 assert!(
3010 human.contains("since 2026-05-29.") && !human.contains("18:15:23"),
3011 "human footer must show date-only: {human}"
3012 );
3013 let md = render_markdown(&report);
3014 assert!(
3015 md.contains("since 2026-05-29.") && !md.contains("18:15:23"),
3016 "markdown footer must show date-only: {md}"
3017 );
3018 }
3019
3020 #[test]
3021 fn future_schema_version_store_loads_without_panic_or_loss() {
3022 let (_config, dir) = test_env();
3023 let root = dir.path();
3024 let future = format!(
3025 "{{\"schema_version\":{},\"enabled\":true,\"records\":[],\"containment\":[]}}",
3026 STORE_SCHEMA_VERSION + 1
3027 );
3028 seed_store_raw(root, future.as_bytes());
3029 let store = load(root);
3030 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION + 1);
3031 assert!(
3032 store.enabled,
3033 "future-version store must not degrade to default"
3034 );
3035 }
3036
3037 #[test]
3038 fn removed_finding_is_credited_as_resolved() {
3039 let (_config, dir) = test_env();
3040 let root = dir.path();
3041 enable(root);
3042 let a = touch(root, "src/a.ts");
3043 run(
3044 root,
3045 &[&a],
3046 vec![fi(&a, "unused-export", "foo")],
3047 vec![],
3048 &[],
3049 "t0",
3050 );
3051 assert_eq!(
3052 load(root).resolved_total,
3053 0,
3054 "first run only establishes a baseline"
3055 );
3056 run(root, &[&a], vec![], vec![], &[], "t1");
3057 let store = load(root);
3058 assert_eq!(store.resolved_total, 1);
3059 assert_eq!(store.suppressed_total, 0);
3060 assert_eq!(store.recent_resolved.len(), 1);
3061 assert_eq!(store.recent_resolved[0].kind, "unused-export");
3062 assert_eq!(store.recent_resolved[0].symbol.as_deref(), Some("foo"));
3063 assert_eq!(store.recent_resolved[0].path, "src/a.ts");
3064 }
3065
3066 #[test]
3067 fn suppressed_finding_is_not_a_win() {
3068 let (_config, dir) = test_env();
3069 let root = dir.path();
3070 enable(root);
3071 let a = touch(root, "src/a.ts");
3072 run(
3073 root,
3074 &[&a],
3075 vec![fi(&a, "unused-export", "foo")],
3076 vec![],
3077 &[],
3078 "t0",
3079 );
3080 run(
3081 root,
3082 &[&a],
3083 vec![],
3084 vec![],
3085 &[supp(&a, "unused-export")],
3086 "t1",
3087 );
3088 let store = load(root);
3089 assert_eq!(
3090 store.resolved_total, 0,
3091 "a suppression must never count as a win"
3092 );
3093 assert_eq!(store.suppressed_total, 1);
3094 }
3095
3096 #[test]
3097 fn fix_and_suppress_same_kind_credits_zero_resolved() {
3098 let (_config, dir) = test_env();
3099 let root = dir.path();
3100 enable(root);
3101 let a = touch(root, "src/a.ts");
3102 run(
3103 root,
3104 &[&a],
3105 vec![
3106 fi(&a, "unused-export", "foo"),
3107 fi(&a, "unused-export", "bar"),
3108 ],
3109 vec![],
3110 &[],
3111 "t0",
3112 );
3113 run(
3114 root,
3115 &[&a],
3116 vec![],
3117 vec![],
3118 &[supp(&a, "unused-export")],
3119 "t1",
3120 );
3121 let store = load(root);
3122 assert_eq!(store.resolved_total, 0);
3123 assert_eq!(store.suppressed_total, 2);
3124 }
3125
3126 #[test]
3127 fn within_file_move_is_not_resolved() {
3128 let (_config, dir) = test_env();
3129 let root = dir.path();
3130 enable(root);
3131 let a = touch(root, "src/a.ts");
3132 run(
3133 root,
3134 &[&a],
3135 vec![fi(&a, "unused-export", "foo")],
3136 vec![],
3137 &[],
3138 "t0",
3139 );
3140 run(
3141 root,
3142 &[&a],
3143 vec![fi(&a, "unused-export", "foo")],
3144 vec![],
3145 &[],
3146 "t1",
3147 );
3148 let store = load(root);
3149 assert_eq!(store.resolved_total, 0);
3150 assert_eq!(store.suppressed_total, 0);
3151 }
3152
3153 #[test]
3154 fn cross_file_move_in_same_run_is_not_resolved() {
3155 let (_config, dir) = test_env();
3156 let root = dir.path();
3157 enable(root);
3158 let a = touch(root, "src/a.ts");
3159 let b = touch(root, "src/b.ts");
3160 run(
3161 root,
3162 &[&a],
3163 vec![fi(&a, "unused-export", "foo")],
3164 vec![],
3165 &[],
3166 "t0",
3167 );
3168 run(
3169 root,
3170 &[&a, &b],
3171 vec![fi(&b, "unused-export", "foo")],
3172 vec![],
3173 &[],
3174 "t1",
3175 );
3176 assert_eq!(
3177 load(root).resolved_total,
3178 0,
3179 "a cross-file move is not a resolution"
3180 );
3181 }
3182
3183 #[test]
3184 fn cross_run_move_uncredits_the_prior_resolution() {
3185 let (_config, dir) = test_env();
3186 let root = dir.path();
3187 enable(root);
3188 let a = touch(root, "src/a.ts");
3189 let b = touch(root, "src/b.ts");
3190 run(
3191 root,
3192 &[&a],
3193 vec![fi(&a, "unused-export", "foo")],
3194 vec![],
3195 &[],
3196 "t0",
3197 );
3198 run(root, &[&a], vec![], vec![], &[], "t1");
3199 assert_eq!(
3200 load(root).resolved_total,
3201 1,
3202 "source disappearance credited in run A"
3203 );
3204 run(
3205 root,
3206 &[&b],
3207 vec![fi(&b, "unused-export", "foo")],
3208 vec![],
3209 &[],
3210 "t2",
3211 );
3212 let store = load(root);
3213 assert_eq!(
3214 store.resolved_total, 0,
3215 "cross-run move must un-credit the phantom win"
3216 );
3217 assert!(
3218 store.recent_resolved.is_empty(),
3219 "the stale resolution event is dropped"
3220 );
3221 }
3222
3223 #[test]
3224 fn resolved_complexity_finding_and_suppressed_complexity() {
3225 let (_config, dir) = test_env();
3226 let root = dir.path();
3227 enable(root);
3228 let a = touch(root, "src/a.ts");
3229 run(
3230 root,
3231 &[&a],
3232 vec![fi(&a, "complexity", "bigFn")],
3233 vec![],
3234 &[],
3235 "t0",
3236 );
3237 run(root, &[&a], vec![], vec![], &[supp(&a, "complexity")], "t1");
3238 let store = load(root);
3239 assert_eq!(store.resolved_total, 0);
3240 assert_eq!(store.suppressed_total, 1);
3241
3242 let b = touch(root, "src/b.ts");
3243 run(
3244 root,
3245 &[&b],
3246 vec![fi(&b, "complexity", "huge")],
3247 vec![],
3248 &[],
3249 "t2",
3250 );
3251 run(root, &[&b], vec![], vec![], &[], "t3");
3252 assert_eq!(load(root).resolved_total, 1);
3253 }
3254
3255 #[test]
3256 fn resolved_duplication_clone_group() {
3257 let (_config, dir) = test_env();
3258 let root = dir.path();
3259 enable(root);
3260 let a = touch(root, "src/a.ts");
3261 let b = touch(root, "src/b.ts");
3262 let clone = CloneInput {
3263 fingerprint: "dup:abc12345".to_owned(),
3264 instance_paths: vec![a.clone(), b],
3265 };
3266 run(root, &[&a], vec![], vec![clone], &[], "t0");
3267 run(root, &[&a], vec![], vec![], &[], "t1");
3268 let store = load(root);
3269 assert_eq!(store.resolved_total, 1);
3270 assert_eq!(store.recent_resolved[0].kind, "code-duplication");
3271 }
3272
3273 #[test]
3274 fn blanket_suppression_covers_any_kind() {
3275 let (_config, dir) = test_env();
3276 let root = dir.path();
3277 enable(root);
3278 let a = touch(root, "src/a.ts");
3279 run(
3280 root,
3281 &[&a],
3282 vec![fi(&a, "unused-export", "foo")],
3283 vec![],
3284 &[],
3285 "t0",
3286 );
3287 let blanket = ActiveSuppression {
3288 path: a.clone(),
3289 kind: None,
3290 is_file_level: true,
3291 };
3292 run(root, &[&a], vec![], vec![], &[blanket], "t1");
3293 let store = load(root);
3294 assert_eq!(store.resolved_total, 0);
3295 assert_eq!(store.suppressed_total, 1);
3296 }
3297
3298 #[test]
3299 fn v1_store_loads_and_upgrades_to_v2() {
3300 let (_config, dir) = test_env();
3301 let root = dir.path();
3302 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":[]}"#;
3303 seed_store_raw(root, v1.as_bytes());
3304 let store = load(root);
3305 assert_eq!(store.schema_version, 1);
3306 assert!(store.frontier.is_empty());
3307 assert_eq!(store.resolved_total, 0);
3308 let a = touch(root, "src/a.ts");
3309 run(
3310 root,
3311 &[&a],
3312 vec![fi(&a, "unused-export", "foo")],
3313 vec![],
3314 &[],
3315 "t1",
3316 );
3317 let store = load(root);
3318 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
3319 assert!(frontier_paths(&store).contains("src/a.ts"));
3320 }
3321
3322 #[test]
3323 fn recent_resolved_is_bounded() {
3324 let mut store = ImpactStore {
3325 enabled: true,
3326 ..Default::default()
3327 };
3328 for i in 0..(MAX_RECENT_RESOLVED + 25) {
3329 store.recent_resolved.push(ResolutionEvent {
3330 kind: "unused-export".into(),
3331 path: format!("src/f{i}.ts"),
3332 symbol: Some(format!("s{i}")),
3333 git_sha: None,
3334 timestamp: format!("t{i}"),
3335 });
3336 }
3337 bound_recent_resolved(&mut store);
3338 assert_eq!(store.recent_resolved.len(), MAX_RECENT_RESOLVED);
3339 assert_eq!(store.recent_resolved[0].path, "src/f25.ts");
3340 }
3341
3342 #[test]
3343 fn frontier_prunes_deleted_files() {
3344 let (_config, dir) = test_env();
3345 let root = dir.path();
3346 enable(root);
3347 let a = touch(root, "src/a.ts");
3348 run(
3349 root,
3350 &[&a],
3351 vec![fi(&a, "unused-export", "foo")],
3352 vec![],
3353 &[],
3354 "t0",
3355 );
3356 assert!(frontier_paths(&load(root)).contains("src/a.ts"));
3357 std::fs::remove_file(&a).unwrap();
3358 let b = touch(root, "src/b.ts");
3359 run(root, &[&b], vec![], vec![], &[], "t1");
3360 assert!(!frontier_paths(&load(root)).contains("src/a.ts"));
3361 }
3362
3363 #[test]
3364 fn honest_empty_state_before_attribution_baseline() {
3365 let store = ImpactStore {
3366 enabled: true,
3367 records: vec![ImpactRecord {
3368 timestamp: "t0".into(),
3369 version: "2.0.0".into(),
3370 git_sha: None,
3371 verdict: "warn".into(),
3372 gate: false,
3373 counts: ImpactCounts::default(),
3374 }],
3375 ..Default::default()
3376 };
3377 let report = build_report(&store);
3378 assert!(!report.attribution_active);
3379 let human = render_human(&report);
3380 assert!(human.contains("resolution tracking starts from your next gate run"));
3381 assert!(!human.contains("0 finding"));
3382 }
3383
3384 #[test]
3385 fn suppression_only_state_renders_under_a_resolved_header() {
3386 let report = ImpactReport {
3387 schema_version: ImpactReportSchemaVersion::V1,
3388 enabled: true,
3389 enabled_source: EnabledSource::Project,
3390 record_count: 2,
3391 meta: None,
3392 first_recorded: Some("2026-05-29T10:00:00Z".into()),
3393 latest_git_sha: None,
3394 surfacing: Some(ImpactCounts::default()),
3395 trend: None,
3396 project_surfacing: None,
3397 project_trend: None,
3398 containment_count: 0,
3399 recent_containment: vec![],
3400 resolved_total: 0,
3401 suppressed_total: 2,
3402 recent_resolved: vec![],
3403 attribution_active: true,
3404 onboarding_declined: false,
3405 explicit_decision: false,
3406 };
3407 let human = render_human(&report);
3408 let resolved_idx = human.find(" RESOLVED").expect("RESOLVED header present");
3409 let supp_idx = human
3410 .find("2 findings you marked intentional")
3411 .expect("suppression line present");
3412 assert!(
3413 resolved_idx < supp_idx,
3414 "suppression must render under RESOLVED"
3415 );
3416 assert!(human.contains("none yet"));
3417
3418 let md = render_markdown(&report);
3419 assert!(
3420 md.contains("- **Resolved:**"),
3421 "markdown always has a Resolved bullet"
3422 );
3423 assert!(md.contains("- **Marked intentional:** 2 finding"));
3424 }
3425
3426 fn clone_at(fingerprint: &str, paths: &[&Path]) -> CloneInput {
3428 CloneInput {
3429 fingerprint: fingerprint.to_owned(),
3430 instance_paths: paths.iter().map(|p| p.to_path_buf()).collect(),
3431 }
3432 }
3433
3434 fn run_wp(
3438 root: &Path,
3439 findings: Vec<FindingInput>,
3440 clones: Vec<CloneInput>,
3441 supps: &[ActiveSuppression],
3442 ts: &str,
3443 ) {
3444 let input = AttributionInput {
3445 root,
3446 scope: Scope::WholeProject,
3447 findings,
3448 clones,
3449 suppressions: supps,
3450 };
3451 record_combined_run(
3452 root,
3453 ImpactCounts::default(),
3454 Some("sha"),
3455 "2.0.0",
3456 ts,
3457 Some(&input),
3458 );
3459 }
3460
3461 #[test]
3462 fn whole_project_run_does_not_double_credit_after_audit() {
3463 let (_config, dir) = test_env();
3464 let root = dir.path();
3465 enable(root);
3466 let a = touch(root, "src/a.ts");
3467 let b = touch(root, "src/b.ts");
3468 run(
3469 root,
3470 &[&a, &b],
3471 vec![],
3472 vec![clone_at("dup:abc", &[&a, &b])],
3473 &[],
3474 "t1",
3475 );
3476 assert_eq!(clone_fingerprints(&load(root)).len(), 1);
3477
3478 run(root, &[&a, &b], vec![], vec![], &[], "t2");
3479 assert_eq!(load(root).resolved_total, 1);
3480 assert!(load(root).clone_frontier.is_empty());
3481
3482 run_wp(root, vec![], vec![], &[], "t3");
3483 assert_eq!(
3484 load(root).resolved_total,
3485 1,
3486 "whole-project run re-credited a resolution"
3487 );
3488 }
3489
3490 #[test]
3491 fn whole_project_run_credits_suppressed_not_resolved() {
3492 let (_config, dir) = test_env();
3493 let root = dir.path();
3494 enable(root);
3495 let util = touch(root, "src/util.ts");
3496 run(
3497 root,
3498 &[&util],
3499 vec![fi(&util, "unused-export", "dead")],
3500 vec![],
3501 &[],
3502 "t1",
3503 );
3504 assert_eq!(frontier_paths(&load(root)).len(), 1);
3505
3506 run_wp(root, vec![], vec![], &[supp(&util, "unused-export")], "t2");
3507 let store = load(root);
3508 assert_eq!(
3509 store.suppressed_total, 1,
3510 "suppressed finding not counted suppressed"
3511 );
3512 assert_eq!(
3513 store.resolved_total, 0,
3514 "suppressed finding wrongly counted resolved"
3515 );
3516 }
3517
3518 #[test]
3519 fn clone_reshape_three_to_two_not_credited_as_resolved() {
3520 let (_config, dir) = test_env();
3521 let root = dir.path();
3522 enable(root);
3523 let a = touch(root, "src/a.ts");
3524 let b = touch(root, "src/b.ts");
3525 let c = touch(root, "src/c.ts");
3526 run(
3527 root,
3528 &[&a, &b, &c],
3529 vec![],
3530 vec![clone_at("dup:aaa", &[&a, &b, &c])],
3531 &[],
3532 "t1",
3533 );
3534 assert_eq!(clone_fingerprints(&load(root)).len(), 1);
3535
3536 run_wp(
3537 root,
3538 vec![],
3539 vec![clone_at("dup:bbb", &[&a, &b])],
3540 &[],
3541 "t2",
3542 );
3543 let store = load(root);
3544 assert_eq!(
3545 store.resolved_total, 0,
3546 "clone reshape miscredited as resolved"
3547 );
3548 assert!(clone_fingerprints(&store).contains("dup:bbb"));
3549 assert!(!clone_fingerprints(&store).contains("dup:aaa"));
3550 }
3551
3552 fn rcounts(total: usize, dead: usize, complexity: usize, dup: usize) -> ImpactCounts {
3553 ImpactCounts {
3554 total_issues: total,
3555 dead_code: dead,
3556 complexity,
3557 duplication: dup,
3558 }
3559 }
3560
3561 fn rtrend(prev: usize, cur: usize) -> TrendSummary {
3562 TrendSummary {
3563 direction: direction_for(cur as i64 - prev as i64),
3564 total_delta: cur as i64 - prev as i64,
3565 previous_total: prev,
3566 current_total: cur,
3567 }
3568 }
3569
3570 fn rreport(
3572 record_count: usize,
3573 first_recorded: Option<&str>,
3574 surfacing: Option<ImpactCounts>,
3575 trend: Option<TrendSummary>,
3576 project_surfacing: Option<ImpactCounts>,
3577 project_trend: Option<TrendSummary>,
3578 attribution_active: bool,
3579 ) -> ImpactReport {
3580 ImpactReport {
3581 schema_version: ImpactReportSchemaVersion::V1,
3582 enabled: true,
3583 enabled_source: EnabledSource::Project,
3584 record_count,
3585 meta: None,
3586 first_recorded: first_recorded.map(ToOwned::to_owned),
3587 latest_git_sha: None,
3588 surfacing,
3589 trend,
3590 project_surfacing,
3591 project_trend,
3592 containment_count: 0,
3593 recent_containment: vec![],
3594 resolved_total: 0,
3595 suppressed_total: 0,
3596 recent_resolved: vec![],
3597 attribution_active,
3598 onboarding_declined: false,
3599 explicit_decision: false,
3600 }
3601 }
3602
3603 #[test]
3604 fn render_human_project_only_store_shows_whole_project_not_empty_state() {
3605 let r = rreport(
3606 0,
3607 Some("2026-05-30T10:00:00Z"),
3608 None,
3609 None,
3610 Some(rcounts(1, 1, 0, 0)),
3611 None,
3612 true,
3613 );
3614 let human = render_human(&r);
3615 assert!(
3616 human.contains("WHOLE PROJECT (whole-repo context, not a to-do)"),
3617 "project-only must render the labeled section"
3618 );
3619 assert!(human.contains("1 issue across the whole project"));
3620 assert!(
3621 human.contains("project trend starts after your next full `fallow` run"),
3622 "single project record => no trend line, shows the next-run hint"
3623 );
3624 assert!(human.contains("Tracking since 2026-05-30"));
3625 assert!(
3626 !human.contains("No history yet"),
3627 "must not show the empty-state copy"
3628 );
3629 assert!(
3630 !human.contains("LATEST RUN"),
3631 "no changed-file track recorded"
3632 );
3633 assert!(
3634 !human.contains("recorded audit run"),
3635 "no audit runs => no changed-file footer"
3636 );
3637 }
3638
3639 #[test]
3640 fn render_human_both_tracks_label_actionable_vs_context() {
3641 let r = rreport(
3642 3,
3643 Some("2026-05-29T10:00:00Z"),
3644 Some(rcounts(4, 4, 0, 0)),
3645 Some(rtrend(6, 4)),
3646 Some(rcounts(40, 30, 5, 5)),
3647 Some(rtrend(45, 40)),
3648 true,
3649 );
3650 let human = render_human(&r);
3651 let latest = human
3652 .find("LATEST RUN (changed files, act on these now)")
3653 .expect("LATEST RUN labeled actionable");
3654 let whole = human
3655 .find("WHOLE PROJECT (whole-repo context, not a to-do)")
3656 .expect("WHOLE PROJECT labeled context");
3657 assert!(
3658 latest < whole,
3659 "changed-file section renders before whole-project"
3660 );
3661 assert!(human.contains("45 -> 40 (down) across your last two full runs"));
3662 assert!(human.contains("advances only on your local full `fallow` runs, not CI"));
3663 }
3664
3665 #[test]
3666 fn render_markdown_project_only_store_shows_whole_project_not_empty_state() {
3667 let r = rreport(
3668 0,
3669 Some("2026-05-30T10:00:00Z"),
3670 None,
3671 None,
3672 Some(rcounts(1, 1, 0, 0)),
3673 None,
3674 true,
3675 );
3676 let md = render_markdown(&r);
3677 assert!(
3678 md.contains(
3679 "- **Whole project (whole-repo context, last full `fallow` run):** 1 issue"
3680 ),
3681 "project-only md must render the labeled whole-project line"
3682 );
3683 assert!(
3684 !md.contains("No history yet"),
3685 "project-only md must not show empty state"
3686 );
3687 assert!(md.contains("Tracking since 2026-05-30"));
3688 }
3689
3690 #[test]
3691 fn resolve_enabled_precedence_table() {
3692 let (_config, _dir) = test_env();
3693 let on = ImpactStore {
3695 enabled: true,
3696 ..Default::default()
3697 };
3698 assert_eq!(resolve_enabled(&on), (true, EnabledSource::Project));
3699
3700 let off_explicit = ImpactStore {
3702 enabled: false,
3703 explicit_decision: true,
3704 ..Default::default()
3705 };
3706 assert_eq!(
3707 resolve_enabled(&off_explicit),
3708 (false, EnabledSource::Project)
3709 );
3710
3711 let never = ImpactStore::default();
3713 assert_eq!(resolve_enabled(&never), (false, EnabledSource::Default));
3714
3715 assert!(set_global_default(true));
3717 assert_eq!(resolve_enabled(&never), (true, EnabledSource::User));
3718 assert_eq!(
3720 resolve_enabled(&off_explicit),
3721 (false, EnabledSource::Project)
3722 );
3723 }
3724
3725 #[test]
3726 fn human_report_explains_user_global_default() {
3727 let (_config, _dir) = test_env();
3728 set_global_default(true);
3729 let report = build_report(&ImpactStore::default());
3731 assert_eq!(report.enabled_source, EnabledSource::User);
3732 let human = render_human(&report);
3733 assert!(
3734 human.contains("Enabled by your user-global default"),
3735 "human report must explain a global-default enable: {human}"
3736 );
3737 let project = build_report(&ImpactStore {
3739 enabled: true,
3740 explicit_decision: true,
3741 ..Default::default()
3742 });
3743 assert_eq!(project.enabled_source, EnabledSource::Project);
3744 assert!(!render_human(&project).contains("user-global default"));
3745 }
3746
3747 #[test]
3748 fn global_default_round_trips() {
3749 let (_config, _dir) = test_env();
3750 assert!(!load_global_default());
3751 assert!(set_global_default(true));
3752 assert!(load_global_default());
3753 assert!(!set_global_default(true)); assert!(set_global_default(false));
3755 assert!(!load_global_default());
3756 }
3757
3758 #[test]
3759 fn global_default_records_without_per_repo_enable() {
3760 let (_config, dir) = test_env();
3761 let root = dir.path();
3762 set_global_default(true);
3763 record_v1(
3765 root,
3766 &summary(2, 0, 0),
3767 AuditVerdict::Warn,
3768 false,
3769 None,
3770 "2.0.0",
3771 "t0",
3772 );
3773 let report = build_report(&load(root));
3774 assert!(report.enabled);
3775 assert_eq!(report.enabled_source, EnabledSource::User);
3776 assert_eq!(report.record_count, 1);
3777 }
3778
3779 #[test]
3780 fn legacy_in_repo_store_is_migrated_on_first_load() {
3781 let (_config, dir) = test_env();
3782 let root = dir.path();
3783 let legacy = r#"{"schema_version":3,"enabled":true,"explicit_decision":true,
3785 "records":[{"timestamp":"t0","version":"2.0.0","verdict":"warn","gate":false,
3786 "counts":{"total_issues":1,"dead_code":1,"complexity":0,"duplication":0}}],
3787 "resolved_total":2,
3788 "frontier":{"src/a.ts":{"findings":[{"id":"x","kind":"unused-export","symbol":"foo"}],"suppressions":[]}},
3789 "containment":[]}"#;
3790 std::fs::create_dir_all(root.join(".fallow")).unwrap();
3791 std::fs::write(legacy_store_path(root), legacy).unwrap();
3792
3793 let store = load(root);
3794 assert!(store.enabled);
3795 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
3796 assert_eq!(store.records.len(), 1);
3797 assert_eq!(store.resolved_total, 2);
3798 assert!(frontier_paths(&store).contains("src/a.ts"));
3800 assert!(store_path(root).is_some_and(|p| p.exists()));
3803 let again = load(root);
3804 assert_eq!(again.records.len(), 1);
3805 }
3806
3807 #[test]
3808 fn reset_removes_only_this_project() {
3809 let (_config, dir) = test_env();
3810 let root = dir.path();
3811 enable(root);
3812 record_v1(
3813 root,
3814 &summary(1, 0, 0),
3815 AuditVerdict::Warn,
3816 false,
3817 None,
3818 "2.0.0",
3819 "t0",
3820 );
3821 assert_eq!(load(root).records.len(), 1);
3822 assert!(reset(root));
3823 assert!(load(root).records.is_empty());
3824 assert!(!reset(root)); }
3826
3827 #[test]
3828 fn reset_all_clears_dir_but_keeps_global_default() {
3829 let (_config, dir) = test_env();
3830 let root = dir.path();
3831 set_global_default(true);
3832 enable(root);
3833 assert!(load(root).enabled);
3834 assert!(reset_all());
3835 assert!(load_global_default());
3837 }
3838
3839 fn aggregate_env() -> tempfile::TempDir {
3843 let config = tempfile::tempdir().unwrap();
3844 TEST_CONFIG_DIR.with(|c| *c.borrow_mut() = Some(config.path().to_path_buf()));
3845 config
3846 }
3847
3848 fn seed_store(key: &str, store: &ImpactStore) {
3850 let dir = impact_config_dir().unwrap().join("impact");
3851 std::fs::create_dir_all(&dir).unwrap();
3852 std::fs::write(
3853 dir.join(format!("{key}.json")),
3854 serde_json::to_string_pretty(store).unwrap(),
3855 )
3856 .unwrap();
3857 }
3858
3859 fn store_with(
3860 label: &str,
3861 resolved: usize,
3862 contained: usize,
3863 latest_ts: &str,
3864 latest_issues: usize,
3865 ) -> ImpactStore {
3866 let mut s = ImpactStore {
3867 enabled: true,
3868 explicit_decision: true,
3869 resolved_total: resolved,
3870 label: Some(label.to_owned()),
3871 ..Default::default()
3872 };
3873 s.records.push(ImpactRecord {
3874 timestamp: latest_ts.to_owned(),
3875 version: "2.0.0".to_owned(),
3876 git_sha: None,
3877 verdict: "warn".to_owned(),
3878 gate: false,
3879 counts: ImpactCounts::from_combined(latest_issues, 0, 0),
3880 });
3881 for _ in 0..contained {
3882 s.containment.push(ContainmentEvent {
3883 blocked_at: "t0".to_owned(),
3884 cleared_at: "t1".to_owned(),
3885 git_sha: None,
3886 blocked_counts: ImpactCounts::default(),
3887 });
3888 }
3889 s
3890 }
3891
3892 #[test]
3893 fn repo_basename_returns_last_component_only() {
3894 assert_eq!(
3895 repo_basename(Path::new("/a/b/myrepo/.git")).as_deref(),
3896 Some("myrepo")
3897 );
3898 assert_eq!(
3899 repo_basename(Path::new("/a/b/proj")).as_deref(),
3900 Some("proj")
3901 );
3902 let name = repo_basename(Path::new("/x/y/z/.git")).unwrap();
3904 assert!(!name.contains('/') && !name.contains('\\'));
3905 }
3906
3907 #[test]
3908 fn aggregate_rolls_up_totals_and_excludes_empty() {
3909 let _cfg = aggregate_env();
3910 seed_store(
3911 "aaa",
3912 &store_with("alpha", 10, 2, "2026-06-10T00:00:00Z", 3),
3913 );
3914 seed_store("bbb", &store_with("beta", 5, 1, "2026-06-11T00:00:00Z", 0));
3915 seed_store(
3917 "ccc",
3918 &ImpactStore {
3919 enabled: true,
3920 explicit_decision: true,
3921 label: Some("gamma".into()),
3922 ..Default::default()
3923 },
3924 );
3925 let report = aggregate(CrossRepoSort::Recent);
3926 assert_eq!(report.project_count, 3, "all three stores enumerated");
3927 assert_eq!(report.tracked_count, 2, "empty store excluded from rows");
3928 assert_eq!(report.totals.resolved_total, 15);
3929 assert_eq!(report.totals.containment_count, 3);
3930 assert_eq!(report.unreadable_count, 0);
3931 }
3932
3933 #[test]
3934 fn aggregate_sort_recent_orders_by_last_activity() {
3935 let _cfg = aggregate_env();
3936 seed_store("old", &store_with("older", 1, 0, "2026-06-01T00:00:00Z", 1));
3937 seed_store("new", &store_with("newer", 1, 0, "2026-06-12T00:00:00Z", 1));
3938 let report = aggregate(CrossRepoSort::Recent);
3939 assert_eq!(report.projects[0].label.as_deref(), Some("newer"));
3940 assert_eq!(report.projects[1].label.as_deref(), Some("older"));
3941 }
3942
3943 #[test]
3944 fn cross_repo_json_carries_kind_and_leaks_no_path() {
3945 let _cfg = aggregate_env();
3946 seed_store("aaa", &store_with("alpha", 4, 1, "2026-06-10T00:00:00Z", 2));
3947 let report = aggregate(CrossRepoSort::Recent);
3948 let json = render_cross_repo_json(&report);
3949 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
3950 assert_eq!(value["kind"], "impact-cross-repo");
3951 for entry in value["projects"].as_array().unwrap() {
3953 if let Some(label) = entry["label"].as_str() {
3954 assert!(
3955 !label.contains('/') && !label.contains('\\'),
3956 "label must be a basename, got {label}"
3957 );
3958 }
3959 }
3960 assert!(
3961 !json.contains('/') || !json.contains("Users"),
3962 "json must not leak an absolute home path"
3963 );
3964 }
3965
3966 #[test]
3967 fn cross_repo_markdown_pluralizes_single_project() {
3968 let _cfg = aggregate_env();
3969 seed_store("solo", &store_with("solo", 3, 1, "2026-06-10T00:00:00Z", 2));
3970 let report = aggregate(CrossRepoSort::Recent);
3971 assert_eq!(report.project_count, 1);
3972 assert_eq!(report.tracked_count, 1);
3973 let md = render_cross_repo_markdown(&report);
3974 assert!(
3975 md.contains("1 project tracked"),
3976 "single project must read 'project', got:\n{md}"
3977 );
3978 assert!(
3979 !md.contains("1 projects tracked"),
3980 "must not pluralize a single project, got:\n{md}"
3981 );
3982 assert!(
3983 md.contains("across 1 tracked project"),
3984 "grand totals must read 'tracked project' (singular), got:\n{md}"
3985 );
3986 assert!(
3987 !md.contains("tracked projects"),
3988 "must not pluralize a single tracked project, got:\n{md}"
3989 );
3990 }
3991
3992 #[test]
3993 fn cross_repo_corrupt_file_is_skipped_and_counted() {
3994 let _cfg = aggregate_env();
3995 seed_store("good", &store_with("good", 3, 0, "2026-06-10T00:00:00Z", 1));
3996 let dir = impact_config_dir().unwrap().join("impact");
3997 std::fs::write(dir.join("bad.json"), b"{ not valid json ][").unwrap();
3998 let report = aggregate(CrossRepoSort::Recent);
3999 assert_eq!(report.tracked_count, 1, "good store still aggregated");
4000 assert_eq!(
4001 report.unreadable_count, 1,
4002 "corrupt file counted, not crashed"
4003 );
4004 }
4005
4006 #[test]
4007 fn cross_repo_empty_dir_is_first_run() {
4008 let _cfg = aggregate_env();
4009 let report = aggregate(CrossRepoSort::Recent);
4010 assert_eq!(report.project_count, 0);
4011 let human = render_cross_repo_human(&report, None);
4012 assert!(human.contains("No projects tracked yet"));
4013 }
4014
4015 #[test]
4016 fn cross_repo_all_corrupt_reports_unreadable_not_first_run() {
4017 let _cfg = aggregate_env();
4018 let dir = impact_config_dir().unwrap().join("impact");
4019 std::fs::create_dir_all(&dir).unwrap();
4020 std::fs::write(dir.join("bad.json"), b"{ broken ][").unwrap();
4021 let report = aggregate(CrossRepoSort::Recent);
4022 assert_eq!(report.project_count, 0);
4023 assert_eq!(report.unreadable_count, 1);
4024 let human = render_cross_repo_human(&report, None);
4025 assert!(
4026 human.contains("unreadable store") && !human.contains("No projects tracked yet"),
4027 "all-corrupt must report unreadable, not a misleading first-run hint: {human}"
4028 );
4029 }
4030
4031 #[test]
4032 fn record_audit_run_captures_basename_label() {
4033 let (_config, dir) = test_env();
4034 let root = dir.path();
4035 enable(root);
4036 record_v1(
4037 root,
4038 &summary(1, 0, 0),
4039 AuditVerdict::Warn,
4040 false,
4041 None,
4042 "2.0.0",
4043 "t0",
4044 );
4045 let label = load(root).label.expect("label captured on record");
4046 assert!(
4047 !label.contains('/') && !label.contains('\\'),
4048 "label must be a basename, got {label}"
4049 );
4050 }
4051
4052 #[test]
4055 fn lock_path_appends_lock_suffix() {
4056 assert_eq!(
4057 lock_path_for(Path::new("/c/fallow/impact/abc.json")),
4058 PathBuf::from("/c/fallow/impact/abc.json.lock")
4059 );
4060 }
4061
4062 #[test]
4063 fn store_lock_acquire_drop_then_record_roundtrips() {
4064 let (_config, dir) = test_env();
4065 let root = dir.path();
4066 enable(root);
4067 {
4070 let _lock = ImpactStoreLock::acquire(root).expect("lock acquires");
4071 }
4072 record_v1(
4073 root,
4074 &summary(1, 0, 0),
4075 AuditVerdict::Warn,
4076 false,
4077 None,
4078 "2.0.0",
4079 "t0",
4080 );
4081 assert_eq!(load(root).records.len(), 1, "record persisted under lock");
4082 let store = store_path(root).unwrap();
4084 assert!(lock_path_for(&store).exists(), "lock sidecar created");
4085 assert!(store.exists(), "store file is distinct from its lock");
4086 }
4087
4088 #[test]
4089 fn sweep_keeps_fresh_and_self_deletes_aged_out() {
4090 let _cfg = aggregate_env();
4091 seed_store("keepme", &store_with("keep", 1, 0, "t0", 1));
4092 seed_store("oldone", &store_with("old", 1, 0, "t0", 1));
4093 let lock = impact_config_dir()
4095 .unwrap()
4096 .join("impact")
4097 .join("oldone.json.lock");
4098 std::fs::write(&lock, b"").unwrap();
4099
4100 sweep_old_stores("keepme", std::time::Duration::ZERO);
4102
4103 let dir = impact_config_dir().unwrap().join("impact");
4104 assert!(dir.join("keepme.json").exists(), "kept store survives");
4105 assert!(
4106 !dir.join("oldone.json").exists(),
4107 "aged-out store reclaimed"
4108 );
4109 assert!(lock.exists(), "lock sidecar never deleted by the sweep");
4110 }
4111
4112 #[test]
4113 fn sweep_keeps_everything_under_a_large_window() {
4114 let _cfg = aggregate_env();
4115 seed_store("a", &store_with("a", 1, 0, "t0", 1));
4116 seed_store("b", &store_with("b", 1, 0, "t0", 1));
4117 sweep_old_stores("a", std::time::Duration::from_hours(10 * 365 * 24));
4119 let dir = impact_config_dir().unwrap().join("impact");
4120 assert!(dir.join("a.json").exists());
4121 assert!(dir.join("b.json").exists());
4122 }
4123}