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.unused_component_inputs {
1435 push(
1436 &f.input.path,
1437 "unused-component-input",
1438 Some(f.input.input_name.clone()),
1439 );
1440 }
1441 for f in &results.unused_component_outputs {
1442 push(
1443 &f.output.path,
1444 "unused-component-output",
1445 Some(f.output.output_name.clone()),
1446 );
1447 }
1448 for f in &results.unresolved_imports {
1449 push(
1450 &f.import.path,
1451 "unresolved-import",
1452 Some(f.import.specifier.clone()),
1453 );
1454 }
1455 for f in &results.boundary_violations {
1456 let to_path = f.violation.to_path.to_string_lossy().replace('\\', "/");
1457 push(
1458 &f.violation.from_path,
1459 "boundary-violation",
1460 Some(format!("{to_path}{ID_SEP}{}", f.violation.import_specifier)),
1461 );
1462 }
1463}
1464
1465fn collect_dependency_findings(
1466 results: &AnalysisResults,
1467 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1468) {
1469 for f in &results.unused_dependencies {
1470 push(
1471 &f.dep.path,
1472 "unused-dependency",
1473 Some(f.dep.package_name.clone()),
1474 );
1475 }
1476 for f in &results.unused_dev_dependencies {
1477 push(
1478 &f.dep.path,
1479 "unused-dev-dependency",
1480 Some(f.dep.package_name.clone()),
1481 );
1482 }
1483 for f in &results.unused_optional_dependencies {
1484 push(
1485 &f.dep.path,
1486 "unused-optional-dependency",
1487 Some(f.dep.package_name.clone()),
1488 );
1489 }
1490 for f in &results.type_only_dependencies {
1491 push(
1492 &f.dep.path,
1493 "type-only-dependency",
1494 Some(f.dep.package_name.clone()),
1495 );
1496 }
1497 for f in &results.test_only_dependencies {
1498 push(
1499 &f.dep.path,
1500 "test-only-dependency",
1501 Some(f.dep.package_name.clone()),
1502 );
1503 }
1504}
1505
1506fn collect_catalog_findings(
1507 results: &AnalysisResults,
1508 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1509) {
1510 for f in &results.unused_catalog_entries {
1511 push(
1512 &f.entry.path,
1513 "unused-catalog-entry",
1514 Some(format!(
1515 "{}{ID_SEP}{}",
1516 f.entry.catalog_name, f.entry.entry_name
1517 )),
1518 );
1519 }
1520 for f in &results.empty_catalog_groups {
1521 push(
1522 &f.group.path,
1523 "empty-catalog-group",
1524 Some(f.group.catalog_name.clone()),
1525 );
1526 }
1527 for f in &results.unresolved_catalog_references {
1528 push(
1529 &f.reference.path,
1530 "unresolved-catalog-reference",
1531 Some(format!(
1532 "{}{ID_SEP}{}",
1533 f.reference.catalog_name, f.reference.entry_name
1534 )),
1535 );
1536 }
1537 for f in &results.unused_dependency_overrides {
1538 push(
1539 &f.entry.path,
1540 "unused-dependency-override",
1541 Some(f.entry.raw_key.clone()),
1542 );
1543 }
1544 for f in &results.misconfigured_dependency_overrides {
1545 push(
1546 &f.entry.path,
1547 "misconfigured-dependency-override",
1548 Some(f.entry.raw_key.clone()),
1549 );
1550 }
1551}
1552
1553#[must_use]
1557pub fn collect_complexity_findings(
1558 report: &crate::health_types::HealthReport,
1559) -> Vec<FindingInput> {
1560 report
1561 .findings
1562 .iter()
1563 .map(|f| FindingInput {
1564 path: f.path.clone(),
1565 kind: "complexity",
1566 symbol: Some(f.name.clone()),
1567 })
1568 .collect()
1569}
1570
1571#[must_use]
1575pub fn collect_clone_findings(
1576 report: &fallow_core::duplicates::DuplicationReport,
1577) -> Vec<CloneInput> {
1578 report
1579 .clone_groups
1580 .iter()
1581 .map(|g| CloneInput {
1582 fingerprint: fallow_core::duplicates::clone_fingerprint(&g.instances),
1583 instance_paths: g.instances.iter().map(|i| i.file.clone()).collect(),
1584 })
1585 .collect()
1586}
1587
1588const fn verdict_label(verdict: AuditVerdict) -> &'static str {
1589 match verdict {
1590 AuditVerdict::Pass => "pass",
1591 AuditVerdict::Warn => "warn",
1592 AuditVerdict::Fail => "fail",
1593 }
1594}
1595
1596#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1598#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1599#[serde(rename_all = "snake_case")]
1600pub enum ImpactTrendDirection {
1601 Improving,
1603 Declining,
1605 Stable,
1607}
1608
1609#[derive(Debug, Clone, Serialize)]
1611#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1612pub struct TrendSummary {
1613 pub direction: ImpactTrendDirection,
1614 pub total_delta: i64,
1616 pub previous_total: usize,
1617 pub current_total: usize,
1618}
1619
1620fn direction_for(delta: i64) -> ImpactTrendDirection {
1621 if delta < -TREND_TOLERANCE {
1622 ImpactTrendDirection::Improving
1623 } else if delta > TREND_TOLERANCE {
1624 ImpactTrendDirection::Declining
1625 } else {
1626 ImpactTrendDirection::Stable
1627 }
1628}
1629
1630#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1637#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1638pub enum ImpactReportSchemaVersion {
1639 #[serde(rename = "1")]
1641 V1,
1642}
1643
1644#[derive(Debug, Clone, Serialize)]
1646#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1647#[cfg_attr(feature = "schema", schemars(title = "fallow impact --format json"))]
1648pub struct ImpactReport {
1649 pub schema_version: ImpactReportSchemaVersion,
1653 pub enabled: bool,
1654 pub enabled_source: EnabledSource,
1661 pub record_count: usize,
1662 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
1663 pub meta: Option<Meta>,
1664 #[serde(default, skip_serializing_if = "Option::is_none")]
1665 pub first_recorded: Option<String>,
1666 #[serde(default, skip_serializing_if = "Option::is_none")]
1673 pub latest_git_sha: Option<String>,
1674 #[serde(default, skip_serializing_if = "Option::is_none")]
1679 pub surfacing: Option<ImpactCounts>,
1680 #[serde(default, skip_serializing_if = "Option::is_none")]
1682 pub trend: Option<TrendSummary>,
1683 #[serde(default, skip_serializing_if = "Option::is_none")]
1688 pub project_surfacing: Option<ImpactCounts>,
1689 #[serde(default, skip_serializing_if = "Option::is_none")]
1693 pub project_trend: Option<TrendSummary>,
1694 pub containment_count: usize,
1695 pub recent_containment: Vec<ContainmentEvent>,
1697 pub resolved_total: usize,
1700 pub suppressed_total: usize,
1703 pub recent_resolved: Vec<ResolutionEvent>,
1705 pub attribution_active: bool,
1709 pub onboarding_declined: bool,
1713 pub explicit_decision: bool,
1718}
1719
1720fn trend_for(records: &[ImpactRecord]) -> Option<TrendSummary> {
1726 if records.len() < 2 {
1727 return None;
1728 }
1729 let current = &records[records.len() - 1];
1730 let previous = &records[records.len() - 2];
1731 let current_total = current.counts.total_issues;
1732 let previous_total = previous.counts.total_issues;
1733 let total_delta = current_total as i64 - previous_total as i64;
1734 Some(TrendSummary {
1735 direction: direction_for(total_delta),
1736 total_delta,
1737 previous_total,
1738 current_total,
1739 })
1740}
1741
1742pub fn build_report(store: &ImpactStore) -> ImpactReport {
1743 let surfacing = store.records.last().map(|r| r.counts.clone());
1744 let trend = trend_for(&store.records);
1745 let project_surfacing = store.project_records.last().map(|r| r.counts.clone());
1746 let project_trend = trend_for(&store.project_records);
1747
1748 let recent_containment = store
1749 .containment
1750 .iter()
1751 .rev()
1752 .take(5)
1753 .rev()
1754 .cloned()
1755 .collect();
1756
1757 let latest_git_sha = store.records.last().and_then(|r| r.git_sha.clone());
1758
1759 let recent_resolved = store
1760 .recent_resolved
1761 .iter()
1762 .rev()
1763 .take(5)
1764 .rev()
1765 .cloned()
1766 .collect();
1767 let attribution_active = !store.frontier.is_empty()
1768 || !store.clone_frontier.is_empty()
1769 || store.resolved_total > 0
1770 || store.suppressed_total > 0;
1771
1772 let (enabled, enabled_source) = resolve_enabled(store);
1773 ImpactReport {
1774 schema_version: ImpactReportSchemaVersion::V1,
1775 enabled,
1776 enabled_source,
1777 record_count: store.records.len(),
1778 meta: None,
1779 first_recorded: store.first_recorded.clone(),
1780 latest_git_sha,
1781 surfacing,
1782 trend,
1783 project_surfacing,
1784 project_trend,
1785 containment_count: store.containment.len(),
1786 recent_containment,
1787 resolved_total: store.resolved_total,
1788 suppressed_total: store.suppressed_total,
1789 recent_resolved,
1790 attribution_active,
1791 onboarding_declined: store.onboarding_declined,
1792 explicit_decision: store.explicit_decision,
1793 }
1794}
1795
1796#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1802#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1803pub enum CrossRepoImpactSchemaVersion {
1804 #[serde(rename = "1")]
1806 V1,
1807}
1808
1809#[derive(Debug, Clone, Default, Serialize)]
1812#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1813pub struct CrossRepoTotals {
1814 pub resolved_total: usize,
1815 pub suppressed_total: usize,
1816 pub containment_count: usize,
1817 pub project_wide_issues: usize,
1821 pub projects_with_baseline: usize,
1822}
1823
1824#[derive(Debug, Clone, Serialize)]
1826#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1827pub struct CrossRepoProjectEntry {
1828 pub project_key: String,
1831 #[serde(default, skip_serializing_if = "Option::is_none")]
1834 pub label: Option<String>,
1835 #[serde(default, skip_serializing_if = "Option::is_none")]
1838 pub last_recorded: Option<String>,
1839 pub report: ImpactReport,
1842}
1843
1844#[derive(Debug, Clone, Serialize)]
1846#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1847#[cfg_attr(
1848 feature = "schema",
1849 schemars(title = "fallow impact --all --format json")
1850)]
1851pub struct CrossRepoImpactReport {
1852 pub schema_version: CrossRepoImpactSchemaVersion,
1853 pub project_count: usize,
1856 pub tracked_count: usize,
1859 pub unreadable_count: usize,
1861 pub totals: CrossRepoTotals,
1862 pub projects: Vec<CrossRepoProjectEntry>,
1863}
1864
1865#[derive(Debug, Clone, Copy)]
1867pub enum CrossRepoSort {
1868 Recent,
1870 Resolved,
1872 Contained,
1874 Name,
1876}
1877
1878fn latest_activity(store: &ImpactStore) -> Option<String> {
1880 let a = store.records.last().map(|r| r.timestamp.clone());
1881 let b = store.project_records.last().map(|r| r.timestamp.clone());
1882 match (a, b) {
1883 (Some(x), Some(y)) => Some(if x >= y { x } else { y }),
1884 (x, y) => x.or(y),
1885 }
1886}
1887
1888#[must_use]
1894pub fn load_all() -> (Vec<(String, ImpactStore)>, usize) {
1895 let Some(dir) = impact_config_dir().map(|d| d.join("impact")) else {
1896 return (Vec::new(), 0);
1897 };
1898 let Ok(read) = std::fs::read_dir(&dir) else {
1899 return (Vec::new(), 0);
1900 };
1901 let mut stores = Vec::new();
1902 let mut unreadable = 0usize;
1903 for entry in read.flatten() {
1904 let path = entry.path();
1905 if path.extension().and_then(|e| e.to_str()) != Some("json") {
1906 continue;
1907 }
1908 let Some(key) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
1909 continue;
1910 };
1911 match std::fs::read_to_string(&path)
1912 .ok()
1913 .and_then(|c| serde_json::from_str::<ImpactStore>(&c).ok())
1914 {
1915 Some(store) => stores.push((key, store)),
1916 None => unreadable += 1,
1917 }
1918 }
1919 (stores, unreadable)
1920}
1921
1922#[must_use]
1926pub fn build_aggregate_report(
1927 stores: Vec<(String, ImpactStore)>,
1928 unreadable: usize,
1929 sort: CrossRepoSort,
1930) -> CrossRepoImpactReport {
1931 let project_count = stores.len();
1932 let mut totals = CrossRepoTotals::default();
1933 let mut projects = Vec::new();
1934 for (key, store) in stores {
1935 let report = build_report(&store);
1936 let has_history = report.record_count > 0
1937 || report.project_surfacing.is_some()
1938 || report.resolved_total > 0
1939 || report.containment_count > 0;
1940 if !has_history {
1941 continue;
1942 }
1943 totals.resolved_total += report.resolved_total;
1944 totals.suppressed_total += report.suppressed_total;
1945 totals.containment_count += report.containment_count;
1946 if let Some(ps) = &report.project_surfacing {
1947 totals.project_wide_issues += ps.total_issues;
1948 totals.projects_with_baseline += 1;
1949 }
1950 projects.push(CrossRepoProjectEntry {
1951 project_key: key,
1952 label: store.label.clone(),
1953 last_recorded: latest_activity(&store),
1954 report,
1955 });
1956 }
1957 sort_cross_repo(&mut projects, sort);
1958 CrossRepoImpactReport {
1959 schema_version: CrossRepoImpactSchemaVersion::V1,
1960 project_count,
1961 tracked_count: projects.len(),
1962 unreadable_count: unreadable,
1963 totals,
1964 projects,
1965 }
1966}
1967
1968fn sort_cross_repo(projects: &mut [CrossRepoProjectEntry], sort: CrossRepoSort) {
1969 match sort {
1970 CrossRepoSort::Recent => projects.sort_by(|a, b| {
1973 b.last_recorded
1974 .cmp(&a.last_recorded)
1975 .then_with(|| a.project_key.cmp(&b.project_key))
1976 }),
1977 CrossRepoSort::Resolved => projects.sort_by(|a, b| {
1978 b.report
1979 .resolved_total
1980 .cmp(&a.report.resolved_total)
1981 .then_with(|| a.project_key.cmp(&b.project_key))
1982 }),
1983 CrossRepoSort::Contained => projects.sort_by(|a, b| {
1984 b.report
1985 .containment_count
1986 .cmp(&a.report.containment_count)
1987 .then_with(|| a.project_key.cmp(&b.project_key))
1988 }),
1989 CrossRepoSort::Name => projects.sort_by(|a, b| {
1990 cross_repo_label(a)
1991 .cmp(&cross_repo_label(b))
1992 .then_with(|| a.project_key.cmp(&b.project_key))
1993 }),
1994 }
1995}
1996
1997fn cross_repo_label(entry: &CrossRepoProjectEntry) -> String {
2000 entry
2001 .label
2002 .clone()
2003 .unwrap_or_else(|| short_key(&entry.project_key))
2004}
2005
2006fn short_key(key: &str) -> String {
2008 key.chars().take(12).collect()
2009}
2010
2011#[must_use]
2013pub fn aggregate(sort: CrossRepoSort) -> CrossRepoImpactReport {
2014 let (stores, unreadable) = load_all();
2015 build_aggregate_report(stores, unreadable, sort)
2016}
2017
2018#[expect(
2024 clippy::format_push_string,
2025 reason = "small report renderer; readability over avoiding the extra allocation"
2026)]
2027fn render_project_section(out: &mut String, report: &ImpactReport) {
2028 let Some(s) = &report.project_surfacing else {
2029 return;
2030 };
2031 out.push_str(&format!(
2032 " WHOLE PROJECT (whole-repo context, not a to-do)\n {} issue{} across the whole project at your last full `fallow` run\n",
2033 s.total_issues,
2034 plural(s.total_issues),
2035 ));
2036 if let Some(t) = &report.project_trend {
2037 let arrow = trend_arrow(t.direction);
2038 out.push_str(&format!(
2039 " {} -> {} ({}) across your last two full runs (comparable over time)\n",
2040 t.previous_total, t.current_total, arrow,
2041 ));
2042 } else {
2043 out.push_str(" project trend starts after your next full `fallow` run\n");
2044 }
2045 out.push_str(" advances only on your local full `fallow` runs, not CI\n\n");
2046}
2047
2048#[expect(
2050 clippy::format_push_string,
2051 reason = "small report renderer; readability over avoiding the extra allocation"
2052)]
2053pub fn render_human(report: &ImpactReport) -> String {
2054 let mut out = String::new();
2055 out.push_str("FALLOW IMPACT\n\n");
2056
2057 if !report.enabled {
2058 out.push_str(
2059 "Impact tracking is off. Enable it with `fallow impact enable`, then\n\
2060 let your pre-commit gate run a few times to build history.\n",
2061 );
2062 return out;
2063 }
2064
2065 if report.enabled_source == EnabledSource::User {
2066 out.push_str(
2067 "Enabled by your user-global default (`fallow impact default on`). Run\n\
2068 `fallow impact disable` to opt this project out.\n\n",
2069 );
2070 }
2071
2072 if report.record_count == 0 && report.project_surfacing.is_none() {
2073 out.push_str(
2074 "Tracking enabled. No history yet: check back after your next few\n\
2075 commits (Impact records each `fallow audit` / pre-commit gate run,\n\
2076 and each full `fallow` run for the whole-project view).\n",
2077 );
2078 return out;
2079 }
2080
2081 if let Some(s) = &report.surfacing {
2082 out.push_str(&format!(
2083 " LATEST RUN (changed files, act on these now)\n {} issue{} flagged in your last `fallow audit` run\n",
2084 s.total_issues,
2085 plural(s.total_issues),
2086 ));
2087 out.push_str(&format!(
2088 " dead code {} · complexity {} · duplication {}\n\n",
2089 s.dead_code, s.complexity, s.duplication,
2090 ));
2091 }
2092
2093 if let Some(t) = &report.trend {
2094 let arrow = trend_arrow(t.direction);
2095 out.push_str(&format!(
2096 " 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",
2097 t.previous_total, t.current_total, arrow,
2098 ));
2099 }
2100
2101 render_project_section(&mut out, report);
2102
2103 out.push_str(&format!(
2104 " CONTAINED AT COMMIT\n {} time{} fallow blocked a commit until it was fixed\n",
2105 report.containment_count,
2106 plural(report.containment_count),
2107 ));
2108
2109 if report.resolved_total > 0 {
2110 out.push_str(&format!(
2111 "\n RESOLVED\n {} finding{} you cleared since fallow started tracking\n",
2112 report.resolved_total,
2113 plural(report.resolved_total),
2114 ));
2115 for ev in &report.recent_resolved {
2116 match &ev.symbol {
2117 Some(symbol) => {
2118 out.push_str(&format!(" {} {} in {}\n", ev.kind, symbol, ev.path));
2119 }
2120 None => out.push_str(&format!(" {} in {}\n", ev.kind, ev.path)),
2121 }
2122 }
2123 } else if report.attribution_active {
2124 out.push_str(
2125 "\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",
2126 );
2127 } else {
2128 out.push_str("\n RESOLVED\n resolution tracking starts from your next gate run\n");
2129 }
2130
2131 if report.suppressed_total > 0 {
2132 out.push_str(&format!(
2133 " {} finding{} you marked intentional (fallow-ignore), not counted as resolved\n",
2134 report.suppressed_total,
2135 plural(report.suppressed_total),
2136 ));
2137 }
2138
2139 out.push('\n');
2140 let since = report
2141 .first_recorded
2142 .as_deref()
2143 .map_or("the first run", date_only);
2144 if report.record_count > 0 {
2145 out.push_str(&format!(
2146 "Based on {} recorded audit run{} since {}. Local-only; never uploaded.\n\
2147 Changed-file scope: each audit run only sees files differing from your base.\n",
2148 report.record_count,
2149 plural(report.record_count),
2150 since,
2151 ));
2152 } else {
2153 out.push_str(&format!(
2154 "Tracking since {since}. Local-only; never uploaded.\n",
2155 ));
2156 }
2157 out.push_str(
2158 "Resolution tracking is a local-developer signal: it accrues on your\n\
2159 machine across runs, not in CI (fallow never records there).\n",
2160 );
2161 out
2162}
2163
2164pub fn render_json(report: &ImpactReport) -> String {
2166 let value = crate::output_envelope::serialize_root_output(
2167 crate::output_envelope::FallowOutput::Impact(report.clone()),
2168 )
2169 .unwrap_or_else(|_| serde_json::json!({"error":"failed to serialize impact report"}));
2170 serde_json::to_string_pretty(&value)
2171 .unwrap_or_else(|_| "{\"error\":\"failed to serialize impact report\"}".to_owned())
2172}
2173
2174#[expect(
2178 clippy::format_push_string,
2179 reason = "small report renderer; readability over avoiding the extra allocation"
2180)]
2181fn render_project_markdown(out: &mut String, report: &ImpactReport) {
2182 let Some(s) = &report.project_surfacing else {
2183 return;
2184 };
2185 out.push_str(&format!(
2186 "- **Whole project (whole-repo context, last full `fallow` run):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
2187 s.total_issues,
2188 plural(s.total_issues),
2189 s.dead_code,
2190 s.complexity,
2191 s.duplication,
2192 ));
2193 if let Some(t) = &report.project_trend {
2194 let arrow = trend_arrow(t.direction);
2195 out.push_str(&format!(
2196 "- **Project trend (whole project, last two full runs):** {} -> {} ({})\n",
2197 t.previous_total, t.current_total, arrow,
2198 ));
2199 }
2200}
2201
2202#[expect(
2204 clippy::format_push_string,
2205 reason = "small report renderer; readability over avoiding the extra allocation"
2206)]
2207pub fn render_markdown(report: &ImpactReport) -> String {
2208 let mut out = String::new();
2209 out.push_str("## Fallow impact\n\n");
2210
2211 if !report.enabled {
2212 out.push_str("Impact tracking is off. Run `fallow impact enable` to start.\n");
2213 return out;
2214 }
2215 if report.record_count == 0 && report.project_surfacing.is_none() {
2216 out.push_str("Tracking enabled. No history yet; check back after a few commits.\n");
2217 return out;
2218 }
2219
2220 if let Some(s) = &report.surfacing {
2221 out.push_str(&format!(
2222 "- **Latest run (changed files):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
2223 s.total_issues,
2224 plural(s.total_issues),
2225 s.dead_code,
2226 s.complexity,
2227 s.duplication,
2228 ));
2229 }
2230 if let Some(t) = &report.trend {
2231 out.push_str(&format!(
2232 "- **Trend (changed-file scope, last two runs):** {} -> {} ({})\n",
2233 t.previous_total,
2234 t.current_total,
2235 trend_arrow(t.direction),
2236 ));
2237 }
2238 render_project_markdown(&mut out, report);
2239 out.push_str(&format!(
2240 "- **Contained at commit:** {} time{}\n",
2241 report.containment_count,
2242 plural(report.containment_count),
2243 ));
2244 if report.resolved_total > 0 {
2245 out.push_str(&format!(
2246 "- **Resolved:** {} finding{} cleared since tracking started\n",
2247 report.resolved_total,
2248 plural(report.resolved_total),
2249 ));
2250 } else if report.attribution_active {
2251 out.push_str("- **Resolved:** none yet; tracking active\n");
2252 } else {
2253 out.push_str("- **Resolved:** resolution tracking starts from your next gate run\n");
2254 }
2255 if report.suppressed_total > 0 {
2256 out.push_str(&format!(
2257 "- **Marked intentional:** {} finding{} (`fallow-ignore`), not counted as resolved\n",
2258 report.suppressed_total,
2259 plural(report.suppressed_total),
2260 ));
2261 }
2262 let since = report
2263 .first_recorded
2264 .as_deref()
2265 .map_or("the first run", date_only);
2266 if report.record_count > 0 {
2267 out.push_str(&format!(
2268 "\n_Based on {} recorded audit run{} since {}. Local-only; resolution is a local-developer signal._\n",
2269 report.record_count,
2270 plural(report.record_count),
2271 since,
2272 ));
2273 } else {
2274 out.push_str(&format!(
2275 "\n_Tracking since {since}. Local-only; resolution is a local-developer signal._\n",
2276 ));
2277 }
2278 out
2279}
2280
2281#[must_use]
2283pub fn render_cross_repo_json(report: &CrossRepoImpactReport) -> String {
2284 let value = crate::output_envelope::serialize_root_output(
2285 crate::output_envelope::FallowOutput::ImpactCrossRepo(report.clone()),
2286 )
2287 .unwrap_or_else(
2288 |_| serde_json::json!({"error":"failed to serialize cross-repo impact report"}),
2289 );
2290 serde_json::to_string_pretty(&value).unwrap_or_else(|_| {
2291 "{\"error\":\"failed to serialize cross-repo impact report\"}".to_owned()
2292 })
2293}
2294
2295fn row_label(entry: &CrossRepoProjectEntry) -> String {
2298 cross_repo_label(entry)
2299}
2300
2301fn opt_count(c: Option<&ImpactCounts>) -> String {
2302 c.map_or_else(|| "-".to_owned(), |c| c.total_issues.to_string())
2303}
2304
2305fn row_trend(report: &ImpactReport) -> &'static str {
2306 report
2307 .project_trend
2308 .as_ref()
2309 .or(report.trend.as_ref())
2310 .map_or("-", |t| trend_arrow(t.direction))
2311}
2312
2313#[expect(
2317 clippy::format_push_string,
2318 reason = "small report renderer; readability over avoiding the extra allocation"
2319)]
2320#[must_use]
2321pub fn render_cross_repo_human(report: &CrossRepoImpactReport, limit: Option<usize>) -> String {
2322 let mut out = String::new();
2323 out.push_str("FALLOW IMPACT (ALL PROJECTS)\n\n");
2324
2325 if report.project_count == 0 {
2326 if report.unreadable_count > 0 {
2327 out.push_str(&format!(
2328 "No readable projects: skipped {} unreadable store{} (corrupt, or written by \
2329 a newer fallow). Upgrade fallow to read them.\n",
2330 report.unreadable_count,
2331 plural(report.unreadable_count),
2332 ));
2333 } else {
2334 out.push_str(
2335 "No projects tracked yet. Enable in a repo with `fallow impact enable`, or for \
2336 every project with `fallow impact default on`.\n",
2337 );
2338 }
2339 return out;
2340 }
2341
2342 out.push_str(&format!(
2343 "{} project{} tracked, {} with history\n\n",
2344 report.project_count,
2345 plural(report.project_count),
2346 report.tracked_count,
2347 ));
2348
2349 if !report.projects.is_empty() {
2350 out.push_str(&format!(
2351 "{:<24}{:>8}{:>10}{:>11}{:>10}{:>7} {}\n",
2352 "PROJECT", "LATEST", "REPO-WIDE", "CONTAINED", "RESOLVED", "TREND", "LAST RUN",
2353 ));
2354 let rows = limit.map_or(report.projects.len(), |n| n.min(report.projects.len()));
2355 for entry in report.projects.iter().take(rows) {
2356 let mut label = row_label(entry);
2357 if label.chars().count() > 22 {
2358 label = format!("{}...", label.chars().take(19).collect::<String>());
2359 }
2360 let last = entry
2361 .last_recorded
2362 .as_deref()
2363 .map_or("-", date_only)
2364 .to_owned();
2365 out.push_str(&format!(
2366 "{:<24}{:>8}{:>10}{:>11}{:>10}{:>7} {}\n",
2367 label,
2368 opt_count(entry.report.surfacing.as_ref()),
2369 opt_count(entry.report.project_surfacing.as_ref()),
2370 entry.report.containment_count,
2371 entry.report.resolved_total,
2372 row_trend(&entry.report),
2373 last,
2374 ));
2375 }
2376 if let Some(n) = limit
2377 && report.projects.len() > n
2378 {
2379 out.push_str(&format!(
2380 " ... and {} more (raise --limit to show)\n",
2381 report.projects.len() - n,
2382 ));
2383 }
2384 }
2385
2386 let no_history = report.project_count.saturating_sub(report.tracked_count);
2387 if no_history > 0 {
2388 out.push_str(&format!(
2389 "\n{no_history} tracked project{} with no history yet\n",
2390 plural(no_history),
2391 ));
2392 }
2393 if report.unreadable_count > 0 {
2394 out.push_str(&format!(
2395 "skipped {} unreadable store{}\n",
2396 report.unreadable_count,
2397 plural(report.unreadable_count),
2398 ));
2399 }
2400
2401 let t = &report.totals;
2402 out.push_str("\nGRAND TOTALS\n");
2403 out.push_str(&format!(
2404 " Across {} tracked project{}: {} finding{} resolved, {} commit{} contained, {} marked intentional\n",
2405 report.tracked_count,
2406 plural(report.tracked_count),
2407 t.resolved_total,
2408 plural(t.resolved_total),
2409 t.containment_count,
2410 plural(t.containment_count),
2411 t.suppressed_total,
2412 ));
2413 if t.projects_with_baseline > 0 {
2414 out.push_str(&format!(
2415 " {} issue{} project-wide across {} project{} with a full-run baseline (as of each project's last full run)\n",
2416 t.project_wide_issues,
2417 plural(t.project_wide_issues),
2418 t.projects_with_baseline,
2419 plural(t.projects_with_baseline),
2420 ));
2421 }
2422 out.push_str("\nLocal-only; never uploaded; accrues on this machine, not CI.\n");
2423 out
2424}
2425
2426#[expect(
2428 clippy::format_push_string,
2429 reason = "small report renderer; readability over avoiding the extra allocation"
2430)]
2431#[must_use]
2432pub fn render_cross_repo_markdown(report: &CrossRepoImpactReport) -> String {
2433 let mut out = String::new();
2434 out.push_str("## Fallow impact (all projects)\n\n");
2435 if report.project_count == 0 {
2436 if report.unreadable_count > 0 {
2437 out.push_str(&format!(
2438 "No readable projects: skipped {} unreadable store{}.\n",
2439 report.unreadable_count,
2440 plural(report.unreadable_count),
2441 ));
2442 } else {
2443 out.push_str("No projects tracked yet.\n");
2444 }
2445 return out;
2446 }
2447 out.push_str(&format!(
2448 "{} project{} tracked, {} with history.\n\n",
2449 report.project_count,
2450 plural(report.project_count),
2451 report.tracked_count,
2452 ));
2453 if !report.projects.is_empty() {
2454 out.push_str("| Project | Latest | Repo-wide | Contained | Resolved | Last run |\n");
2455 out.push_str("|:--------|-------:|----------:|----------:|---------:|:---------|\n");
2456 for entry in &report.projects {
2457 out.push_str(&format!(
2458 "| {} | {} | {} | {} | {} | {} |\n",
2459 row_label(entry),
2460 opt_count(entry.report.surfacing.as_ref()),
2461 opt_count(entry.report.project_surfacing.as_ref()),
2462 entry.report.containment_count,
2463 entry.report.resolved_total,
2464 entry.last_recorded.as_deref().map_or("-", date_only),
2465 ));
2466 }
2467 }
2468 let t = &report.totals;
2469 out.push_str(&format!(
2470 "\n**Grand totals:** {} resolved, {} contained, {} marked intentional across {} tracked project{}",
2471 t.resolved_total,
2472 t.containment_count,
2473 t.suppressed_total,
2474 report.tracked_count,
2475 plural(report.tracked_count),
2476 ));
2477 if t.projects_with_baseline > 0 {
2478 out.push_str(&format!(
2479 "; {} issue{} project-wide across {} project{} with a full-run baseline (as of each project's last full run)",
2480 t.project_wide_issues,
2481 plural(t.project_wide_issues),
2482 t.projects_with_baseline,
2483 plural(t.projects_with_baseline),
2484 ));
2485 }
2486 out.push_str(".\n\n_Local-only; never uploaded; accrues on this machine, not CI._\n");
2487 out
2488}
2489
2490const fn plural(n: usize) -> &'static str {
2491 if n == 1 { "" } else { "s" }
2492}
2493
2494fn date_only(ts: &str) -> &str {
2500 ts.split_once('T').map_or(ts, |(date, _)| date)
2501}
2502
2503const fn trend_arrow(direction: ImpactTrendDirection) -> &'static str {
2507 match direction {
2508 ImpactTrendDirection::Improving => "down",
2509 ImpactTrendDirection::Declining => "up",
2510 ImpactTrendDirection::Stable => "flat",
2511 }
2512}
2513
2514#[cfg(test)]
2515mod tests {
2516 use super::*;
2517
2518 fn test_env() -> (tempfile::TempDir, tempfile::TempDir) {
2524 let config = tempfile::tempdir().unwrap();
2525 TEST_CONFIG_DIR.with(|c| *c.borrow_mut() = Some(config.path().to_path_buf()));
2526 let root = tempfile::tempdir().unwrap();
2527 (config, root)
2528 }
2529
2530 fn frontier_paths(store: &ImpactStore) -> FxHashSet<String> {
2533 store
2534 .frontier
2535 .values()
2536 .flat_map(|m| m.keys().cloned())
2537 .collect()
2538 }
2539
2540 fn clone_fingerprints(store: &ImpactStore) -> FxHashSet<String> {
2542 store
2543 .clone_frontier
2544 .values()
2545 .flat_map(|m| m.keys().cloned())
2546 .collect()
2547 }
2548
2549 fn seed_store_raw(root: &Path, bytes: &[u8]) {
2552 let path = store_path(root).expect("test config dir set");
2553 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2554 std::fs::write(&path, bytes).unwrap();
2555 }
2556
2557 fn summary(dead: usize, complexity: usize, dupes: usize) -> AuditSummary {
2558 AuditSummary {
2559 dead_code_issues: dead,
2560 dead_code_has_errors: dead > 0,
2561 complexity_findings: complexity,
2562 max_cyclomatic: None,
2563 duplication_clone_groups: dupes,
2564 }
2565 }
2566
2567 fn record_v1(
2569 root: &Path,
2570 summary: &AuditSummary,
2571 verdict: AuditVerdict,
2572 gate: bool,
2573 git_sha: Option<&str>,
2574 version: &str,
2575 timestamp: &str,
2576 ) {
2577 record_audit_run(
2578 root,
2579 summary,
2580 &AuditRunRecord {
2581 verdict,
2582 gate,
2583 git_sha,
2584 version,
2585 timestamp,
2586 attribution: None,
2587 },
2588 );
2589 }
2590
2591 fn touch(root: &Path, rel: &str) -> PathBuf {
2594 let p = root.join(rel);
2595 if let Some(parent) = p.parent() {
2596 std::fs::create_dir_all(parent).unwrap();
2597 }
2598 std::fs::write(&p, b"x").unwrap();
2599 p
2600 }
2601
2602 fn fi(path: &Path, kind: &'static str, symbol: &str) -> FindingInput {
2603 FindingInput {
2604 path: path.to_path_buf(),
2605 kind,
2606 symbol: Some(symbol.to_owned()),
2607 }
2608 }
2609
2610 fn supp(path: &Path, kind: &str) -> ActiveSuppression {
2611 ActiveSuppression {
2612 path: path.to_path_buf(),
2613 kind: Some(kind.to_owned()),
2614 is_file_level: false,
2615 reason: None,
2616 }
2617 }
2618
2619 fn run(
2621 root: &Path,
2622 changed: &[&Path],
2623 findings: Vec<FindingInput>,
2624 clones: Vec<CloneInput>,
2625 supps: &[ActiveSuppression],
2626 ts: &str,
2627 ) {
2628 let changed_files: Vec<PathBuf> = changed.iter().map(|p| p.to_path_buf()).collect();
2629 let input = AttributionInput {
2630 root,
2631 scope: Scope::ChangedFiles(&changed_files),
2632 findings,
2633 clones,
2634 suppressions: supps,
2635 };
2636 record_audit_run(
2637 root,
2638 &summary(0, 0, 0),
2639 &AuditRunRecord {
2640 verdict: AuditVerdict::Pass,
2641 gate: true,
2642 git_sha: Some("sha"),
2643 version: "2.0.0",
2644 timestamp: ts,
2645 attribution: Some(&input),
2646 },
2647 );
2648 }
2649
2650 #[test]
2651 fn disabled_store_does_not_record() {
2652 let (_config, dir) = test_env();
2653 let root = dir.path();
2654 record_v1(
2655 root,
2656 &summary(3, 1, 0),
2657 AuditVerdict::Fail,
2658 true,
2659 Some("abc1234"),
2660 "2.0.0",
2661 "2026-05-29T10:00:00Z",
2662 );
2663 let store = load(root);
2664 assert!(store.records.is_empty());
2665 assert!(!store.enabled);
2666 }
2667
2668 #[test]
2669 fn enable_and_disable_record_the_explicit_decision() {
2670 let (_config, dir) = test_env();
2671 let root = dir.path();
2672 assert!(!load(root).explicit_decision, "fresh store: never asked");
2673
2674 disable(root);
2676 let store = load(root);
2677 assert!(!store.enabled);
2678 assert!(store.explicit_decision);
2679 assert!(build_report(&store).explicit_decision);
2680 }
2681
2682 #[test]
2683 fn due_digest_stamps_and_respects_interval_and_gates() {
2684 let (_config, dir) = test_env();
2685 let root = dir.path();
2686
2687 assert!(take_due_digest(root).is_none());
2689 enable(root);
2690 assert!(take_due_digest(root).is_none(), "zero counters never nag");
2691
2692 let mut store = load(root);
2693 store.resolved_total = 3;
2694 store.containment.push(ContainmentEvent {
2695 blocked_at: "2026-06-11T00:00:00Z".to_string(),
2696 cleared_at: "2026-06-11T00:05:00Z".to_string(),
2697 git_sha: None,
2698 blocked_counts: ImpactCounts::default(),
2699 });
2700 save(&store, root);
2701
2702 let digest = take_due_digest(root).expect("first digest is due");
2703 assert_eq!(digest.containment_count, 1);
2704 assert_eq!(digest.resolved_total, 3);
2705 assert!(
2706 take_due_digest(root).is_none(),
2707 "stamped: not due again within the interval"
2708 );
2709
2710 let mut store = load(root);
2712 store.last_digest_epoch = Some(0);
2713 save(&store, root);
2714 assert!(take_due_digest(root).is_some());
2715 }
2716
2717 #[test]
2718 fn decline_onboarding_persists_in_existing_store() {
2719 let (_config, dir) = test_env();
2720 let root = dir.path();
2721
2722 assert!(decline_onboarding(root));
2723 assert!(!decline_onboarding(root));
2724
2725 let store = load(root);
2726 assert!(store.onboarding_declined);
2727 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
2728 assert!(!root.join(".gitignore").exists());
2730 let report = build_report(&store);
2731 assert!(report.onboarding_declined);
2732 }
2733
2734 #[test]
2735 fn enable_then_record_accrues_history() {
2736 let (_config, dir) = test_env();
2737 let root = dir.path();
2738 assert!(enable(root));
2739 assert!(!enable(root)); record_v1(
2741 root,
2742 &summary(2, 1, 0),
2743 AuditVerdict::Warn,
2744 false,
2745 None,
2746 "2.0.0",
2747 "2026-05-29T10:00:00Z",
2748 );
2749 let store = load(root);
2750 assert_eq!(store.records.len(), 1);
2751 assert_eq!(store.records[0].counts.total_issues, 3);
2752 assert_eq!(
2753 store.first_recorded.as_deref(),
2754 Some("2026-05-29T10:00:00Z")
2755 );
2756 }
2757
2758 #[test]
2759 fn record_is_a_noop_in_ci() {
2760 let (_config, dir) = test_env();
2766 let root = dir.path();
2767 assert!(enable(root));
2768 TEST_FORCE_CI.with(|c| c.set(true));
2769 record_v1(
2770 root,
2771 &summary(2, 1, 0),
2772 AuditVerdict::Warn,
2773 false,
2774 None,
2775 "2.0.0",
2776 "2026-05-29T10:00:00Z",
2777 );
2778 TEST_FORCE_CI.with(|c| c.set(false));
2779 let store = load(root);
2780 assert_eq!(store.records.len(), 0, "impact must not record while in CI");
2781 }
2782
2783 #[test]
2784 fn enable_writes_nothing_into_the_repo() {
2785 let (_config, dir) = test_env();
2786 let root = dir.path();
2787 enable(root);
2788 assert!(
2791 !root.join(".gitignore").exists(),
2792 "enable must not create or modify the repo's .gitignore"
2793 );
2794 assert!(
2795 !root.join(".fallow").exists(),
2796 "enable must not create an in-repo .fallow/ dir"
2797 );
2798 let store = load(root);
2800 assert!(store.enabled);
2801 assert!(store.explicit_decision);
2802 assert!(resolve_enabled(&store).0);
2803 }
2804
2805 #[test]
2806 fn single_record_yields_no_trend_no_spike() {
2807 let mut store = ImpactStore {
2808 enabled: true,
2809 ..Default::default()
2810 };
2811 store.records.push(ImpactRecord {
2812 timestamp: "t0".into(),
2813 version: "2.0.0".into(),
2814 git_sha: None,
2815 verdict: "warn".into(),
2816 gate: false,
2817 counts: ImpactCounts {
2818 total_issues: 5,
2819 dead_code: 5,
2820 complexity: 0,
2821 duplication: 0,
2822 },
2823 });
2824 let report = build_report(&store);
2825 assert!(report.trend.is_none());
2826 assert_eq!(report.surfacing.unwrap().total_issues, 5);
2827 }
2828
2829 #[test]
2830 fn empty_store_report_is_first_run() {
2831 let store = ImpactStore::default();
2832 let report = build_report(&store);
2833 assert_eq!(report.record_count, 0);
2834 assert!(report.trend.is_none());
2835 assert!(report.surfacing.is_none());
2836 let human = render_human(&report);
2837 assert!(human.contains("off")); }
2839
2840 #[test]
2841 fn enabled_empty_store_shows_check_back() {
2842 let store = ImpactStore {
2843 enabled: true,
2844 ..Default::default()
2845 };
2846 let report = build_report(&store);
2847 let human = render_human(&report);
2848 assert!(human.contains("No history yet"));
2849 assert!(!human.contains("0 issues"));
2850 }
2851
2852 #[test]
2853 fn trend_improving_when_issues_drop() {
2854 let mut store = ImpactStore {
2855 enabled: true,
2856 ..Default::default()
2857 };
2858 for total in [8usize, 3usize] {
2859 store.records.push(ImpactRecord {
2860 timestamp: format!("t{total}"),
2861 version: "2.0.0".into(),
2862 git_sha: None,
2863 verdict: "warn".into(),
2864 gate: false,
2865 counts: ImpactCounts {
2866 total_issues: total,
2867 dead_code: total,
2868 complexity: 0,
2869 duplication: 0,
2870 },
2871 });
2872 }
2873 let report = build_report(&store);
2874 let trend = report.trend.unwrap();
2875 assert_eq!(trend.direction, ImpactTrendDirection::Improving);
2876 assert_eq!(trend.total_delta, -5);
2877 }
2878
2879 #[test]
2880 fn containment_blocked_then_cleared_records_one_event() {
2881 let (_config, dir) = test_env();
2882 let root = dir.path();
2883 enable(root);
2884 record_v1(
2885 root,
2886 &summary(2, 0, 0),
2887 AuditVerdict::Fail,
2888 true,
2889 Some("sha1"),
2890 "2.0.0",
2891 "t0",
2892 );
2893 let store = load(root);
2894 assert!(store.pending_containment.is_some());
2895 assert!(store.containment.is_empty());
2896
2897 record_v1(
2898 root,
2899 &summary(0, 0, 0),
2900 AuditVerdict::Pass,
2901 true,
2902 Some("sha2"),
2903 "2.0.0",
2904 "t1",
2905 );
2906 let store = load(root);
2907 assert!(store.pending_containment.is_none());
2908 assert_eq!(store.containment.len(), 1);
2909 assert_eq!(store.containment[0].blocked_at, "t0");
2910 assert_eq!(store.containment[0].cleared_at, "t1");
2911 }
2912
2913 #[test]
2914 fn non_gate_run_never_creates_containment() {
2915 let (_config, dir) = test_env();
2916 let root = dir.path();
2917 enable(root);
2918 record_v1(
2919 root,
2920 &summary(2, 0, 0),
2921 AuditVerdict::Fail,
2922 false,
2923 None,
2924 "2.0.0",
2925 "t0",
2926 );
2927 let store = load(root);
2928 assert!(store.pending_containment.is_none());
2929 assert!(store.containment.is_empty());
2930 }
2931
2932 #[test]
2933 fn corrupt_store_loads_as_default_no_panic() {
2934 let (_config, dir) = test_env();
2935 let root = dir.path();
2936 seed_store_raw(root, b"{ not valid json ][");
2937 let store = load(root);
2938 assert!(!store.enabled);
2939 assert!(store.records.is_empty());
2940 record_v1(
2941 root,
2942 &summary(1, 0, 0),
2943 AuditVerdict::Fail,
2944 true,
2945 None,
2946 "2.0.0",
2947 "t0",
2948 );
2949 }
2950
2951 #[test]
2952 fn records_are_bounded() {
2953 let mut store = ImpactStore {
2954 enabled: true,
2955 ..Default::default()
2956 };
2957 for i in 0..(MAX_RECORDS + 50) {
2958 store.records.push(ImpactRecord {
2959 timestamp: format!("t{i}"),
2960 version: "2.0.0".into(),
2961 git_sha: None,
2962 verdict: "pass".into(),
2963 gate: false,
2964 counts: ImpactCounts::default(),
2965 });
2966 }
2967 compact(&mut store);
2968 assert_eq!(store.records.len(), MAX_RECORDS);
2969 assert_eq!(store.records[0].timestamp, "t50");
2970 }
2971
2972 #[test]
2973 fn report_always_carries_schema_version() {
2974 let empty = build_report(&ImpactStore::default());
2975 assert_eq!(empty.schema_version, ImpactReportSchemaVersion::V1);
2976 let json = render_json(&empty);
2977 assert!(
2978 json.contains("\"schema_version\": \"1\""),
2979 "schema_version must be present (as the \"1\" const) even when disabled: {json}"
2980 );
2981
2982 let mut store = ImpactStore {
2983 enabled: true,
2984 ..Default::default()
2985 };
2986 store.records.push(ImpactRecord {
2987 timestamp: "2026-05-29T10:00:00Z".into(),
2988 version: "2.0.0".into(),
2989 git_sha: None,
2990 verdict: "pass".into(),
2991 gate: false,
2992 counts: ImpactCounts::default(),
2993 });
2994 assert_eq!(
2995 build_report(&store).schema_version,
2996 ImpactReportSchemaVersion::V1
2997 );
2998 }
2999
3000 #[test]
3001 fn date_only_trims_iso_timestamp() {
3002 assert_eq!(date_only("2026-05-29T18:15:23Z"), "2026-05-29");
3003 assert_eq!(date_only("2026-05-29"), "2026-05-29");
3004 assert_eq!(date_only("the first run"), "the first run");
3005 }
3006
3007 #[test]
3008 fn human_footer_shows_date_only() {
3009 let mut store = ImpactStore {
3010 enabled: true,
3011 ..Default::default()
3012 };
3013 store.first_recorded = Some("2026-05-29T18:15:23Z".into());
3014 store.records.push(ImpactRecord {
3015 timestamp: "2026-05-29T18:15:23Z".into(),
3016 version: "2.0.0".into(),
3017 git_sha: None,
3018 verdict: "pass".into(),
3019 gate: false,
3020 counts: ImpactCounts::default(),
3021 });
3022 let report = build_report(&store);
3023 let human = render_human(&report);
3024 assert!(
3025 human.contains("since 2026-05-29.") && !human.contains("18:15:23"),
3026 "human footer must show date-only: {human}"
3027 );
3028 let md = render_markdown(&report);
3029 assert!(
3030 md.contains("since 2026-05-29.") && !md.contains("18:15:23"),
3031 "markdown footer must show date-only: {md}"
3032 );
3033 }
3034
3035 #[test]
3036 fn future_schema_version_store_loads_without_panic_or_loss() {
3037 let (_config, dir) = test_env();
3038 let root = dir.path();
3039 let future = format!(
3040 "{{\"schema_version\":{},\"enabled\":true,\"records\":[],\"containment\":[]}}",
3041 STORE_SCHEMA_VERSION + 1
3042 );
3043 seed_store_raw(root, future.as_bytes());
3044 let store = load(root);
3045 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION + 1);
3046 assert!(
3047 store.enabled,
3048 "future-version store must not degrade to default"
3049 );
3050 }
3051
3052 #[test]
3053 fn removed_finding_is_credited_as_resolved() {
3054 let (_config, dir) = test_env();
3055 let root = dir.path();
3056 enable(root);
3057 let a = touch(root, "src/a.ts");
3058 run(
3059 root,
3060 &[&a],
3061 vec![fi(&a, "unused-export", "foo")],
3062 vec![],
3063 &[],
3064 "t0",
3065 );
3066 assert_eq!(
3067 load(root).resolved_total,
3068 0,
3069 "first run only establishes a baseline"
3070 );
3071 run(root, &[&a], vec![], vec![], &[], "t1");
3072 let store = load(root);
3073 assert_eq!(store.resolved_total, 1);
3074 assert_eq!(store.suppressed_total, 0);
3075 assert_eq!(store.recent_resolved.len(), 1);
3076 assert_eq!(store.recent_resolved[0].kind, "unused-export");
3077 assert_eq!(store.recent_resolved[0].symbol.as_deref(), Some("foo"));
3078 assert_eq!(store.recent_resolved[0].path, "src/a.ts");
3079 }
3080
3081 #[test]
3082 fn suppressed_finding_is_not_a_win() {
3083 let (_config, dir) = test_env();
3084 let root = dir.path();
3085 enable(root);
3086 let a = touch(root, "src/a.ts");
3087 run(
3088 root,
3089 &[&a],
3090 vec![fi(&a, "unused-export", "foo")],
3091 vec![],
3092 &[],
3093 "t0",
3094 );
3095 run(
3096 root,
3097 &[&a],
3098 vec![],
3099 vec![],
3100 &[supp(&a, "unused-export")],
3101 "t1",
3102 );
3103 let store = load(root);
3104 assert_eq!(
3105 store.resolved_total, 0,
3106 "a suppression must never count as a win"
3107 );
3108 assert_eq!(store.suppressed_total, 1);
3109 }
3110
3111 #[test]
3112 fn fix_and_suppress_same_kind_credits_zero_resolved() {
3113 let (_config, dir) = test_env();
3114 let root = dir.path();
3115 enable(root);
3116 let a = touch(root, "src/a.ts");
3117 run(
3118 root,
3119 &[&a],
3120 vec![
3121 fi(&a, "unused-export", "foo"),
3122 fi(&a, "unused-export", "bar"),
3123 ],
3124 vec![],
3125 &[],
3126 "t0",
3127 );
3128 run(
3129 root,
3130 &[&a],
3131 vec![],
3132 vec![],
3133 &[supp(&a, "unused-export")],
3134 "t1",
3135 );
3136 let store = load(root);
3137 assert_eq!(store.resolved_total, 0);
3138 assert_eq!(store.suppressed_total, 2);
3139 }
3140
3141 #[test]
3142 fn within_file_move_is_not_resolved() {
3143 let (_config, dir) = test_env();
3144 let root = dir.path();
3145 enable(root);
3146 let a = touch(root, "src/a.ts");
3147 run(
3148 root,
3149 &[&a],
3150 vec![fi(&a, "unused-export", "foo")],
3151 vec![],
3152 &[],
3153 "t0",
3154 );
3155 run(
3156 root,
3157 &[&a],
3158 vec![fi(&a, "unused-export", "foo")],
3159 vec![],
3160 &[],
3161 "t1",
3162 );
3163 let store = load(root);
3164 assert_eq!(store.resolved_total, 0);
3165 assert_eq!(store.suppressed_total, 0);
3166 }
3167
3168 #[test]
3169 fn cross_file_move_in_same_run_is_not_resolved() {
3170 let (_config, dir) = test_env();
3171 let root = dir.path();
3172 enable(root);
3173 let a = touch(root, "src/a.ts");
3174 let b = touch(root, "src/b.ts");
3175 run(
3176 root,
3177 &[&a],
3178 vec![fi(&a, "unused-export", "foo")],
3179 vec![],
3180 &[],
3181 "t0",
3182 );
3183 run(
3184 root,
3185 &[&a, &b],
3186 vec![fi(&b, "unused-export", "foo")],
3187 vec![],
3188 &[],
3189 "t1",
3190 );
3191 assert_eq!(
3192 load(root).resolved_total,
3193 0,
3194 "a cross-file move is not a resolution"
3195 );
3196 }
3197
3198 #[test]
3199 fn cross_run_move_uncredits_the_prior_resolution() {
3200 let (_config, dir) = test_env();
3201 let root = dir.path();
3202 enable(root);
3203 let a = touch(root, "src/a.ts");
3204 let b = touch(root, "src/b.ts");
3205 run(
3206 root,
3207 &[&a],
3208 vec![fi(&a, "unused-export", "foo")],
3209 vec![],
3210 &[],
3211 "t0",
3212 );
3213 run(root, &[&a], vec![], vec![], &[], "t1");
3214 assert_eq!(
3215 load(root).resolved_total,
3216 1,
3217 "source disappearance credited in run A"
3218 );
3219 run(
3220 root,
3221 &[&b],
3222 vec![fi(&b, "unused-export", "foo")],
3223 vec![],
3224 &[],
3225 "t2",
3226 );
3227 let store = load(root);
3228 assert_eq!(
3229 store.resolved_total, 0,
3230 "cross-run move must un-credit the phantom win"
3231 );
3232 assert!(
3233 store.recent_resolved.is_empty(),
3234 "the stale resolution event is dropped"
3235 );
3236 }
3237
3238 #[test]
3239 fn resolved_complexity_finding_and_suppressed_complexity() {
3240 let (_config, dir) = test_env();
3241 let root = dir.path();
3242 enable(root);
3243 let a = touch(root, "src/a.ts");
3244 run(
3245 root,
3246 &[&a],
3247 vec![fi(&a, "complexity", "bigFn")],
3248 vec![],
3249 &[],
3250 "t0",
3251 );
3252 run(root, &[&a], vec![], vec![], &[supp(&a, "complexity")], "t1");
3253 let store = load(root);
3254 assert_eq!(store.resolved_total, 0);
3255 assert_eq!(store.suppressed_total, 1);
3256
3257 let b = touch(root, "src/b.ts");
3258 run(
3259 root,
3260 &[&b],
3261 vec![fi(&b, "complexity", "huge")],
3262 vec![],
3263 &[],
3264 "t2",
3265 );
3266 run(root, &[&b], vec![], vec![], &[], "t3");
3267 assert_eq!(load(root).resolved_total, 1);
3268 }
3269
3270 #[test]
3271 fn resolved_duplication_clone_group() {
3272 let (_config, dir) = test_env();
3273 let root = dir.path();
3274 enable(root);
3275 let a = touch(root, "src/a.ts");
3276 let b = touch(root, "src/b.ts");
3277 let clone = CloneInput {
3278 fingerprint: "dup:abc12345".to_owned(),
3279 instance_paths: vec![a.clone(), b],
3280 };
3281 run(root, &[&a], vec![], vec![clone], &[], "t0");
3282 run(root, &[&a], vec![], vec![], &[], "t1");
3283 let store = load(root);
3284 assert_eq!(store.resolved_total, 1);
3285 assert_eq!(store.recent_resolved[0].kind, "code-duplication");
3286 }
3287
3288 #[test]
3289 fn blanket_suppression_covers_any_kind() {
3290 let (_config, dir) = test_env();
3291 let root = dir.path();
3292 enable(root);
3293 let a = touch(root, "src/a.ts");
3294 run(
3295 root,
3296 &[&a],
3297 vec![fi(&a, "unused-export", "foo")],
3298 vec![],
3299 &[],
3300 "t0",
3301 );
3302 let blanket = ActiveSuppression {
3303 path: a.clone(),
3304 kind: None,
3305 is_file_level: true,
3306 reason: None,
3307 };
3308 run(root, &[&a], vec![], vec![], &[blanket], "t1");
3309 let store = load(root);
3310 assert_eq!(store.resolved_total, 0);
3311 assert_eq!(store.suppressed_total, 1);
3312 }
3313
3314 #[test]
3315 fn v1_store_loads_and_upgrades_to_v2() {
3316 let (_config, dir) = test_env();
3317 let root = dir.path();
3318 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":[]}"#;
3319 seed_store_raw(root, v1.as_bytes());
3320 let store = load(root);
3321 assert_eq!(store.schema_version, 1);
3322 assert!(store.frontier.is_empty());
3323 assert_eq!(store.resolved_total, 0);
3324 let a = touch(root, "src/a.ts");
3325 run(
3326 root,
3327 &[&a],
3328 vec![fi(&a, "unused-export", "foo")],
3329 vec![],
3330 &[],
3331 "t1",
3332 );
3333 let store = load(root);
3334 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
3335 assert!(frontier_paths(&store).contains("src/a.ts"));
3336 }
3337
3338 #[test]
3339 fn recent_resolved_is_bounded() {
3340 let mut store = ImpactStore {
3341 enabled: true,
3342 ..Default::default()
3343 };
3344 for i in 0..(MAX_RECENT_RESOLVED + 25) {
3345 store.recent_resolved.push(ResolutionEvent {
3346 kind: "unused-export".into(),
3347 path: format!("src/f{i}.ts"),
3348 symbol: Some(format!("s{i}")),
3349 git_sha: None,
3350 timestamp: format!("t{i}"),
3351 });
3352 }
3353 bound_recent_resolved(&mut store);
3354 assert_eq!(store.recent_resolved.len(), MAX_RECENT_RESOLVED);
3355 assert_eq!(store.recent_resolved[0].path, "src/f25.ts");
3356 }
3357
3358 #[test]
3359 fn frontier_prunes_deleted_files() {
3360 let (_config, dir) = test_env();
3361 let root = dir.path();
3362 enable(root);
3363 let a = touch(root, "src/a.ts");
3364 run(
3365 root,
3366 &[&a],
3367 vec![fi(&a, "unused-export", "foo")],
3368 vec![],
3369 &[],
3370 "t0",
3371 );
3372 assert!(frontier_paths(&load(root)).contains("src/a.ts"));
3373 std::fs::remove_file(&a).unwrap();
3374 let b = touch(root, "src/b.ts");
3375 run(root, &[&b], vec![], vec![], &[], "t1");
3376 assert!(!frontier_paths(&load(root)).contains("src/a.ts"));
3377 }
3378
3379 #[test]
3380 fn honest_empty_state_before_attribution_baseline() {
3381 let store = ImpactStore {
3382 enabled: true,
3383 records: vec![ImpactRecord {
3384 timestamp: "t0".into(),
3385 version: "2.0.0".into(),
3386 git_sha: None,
3387 verdict: "warn".into(),
3388 gate: false,
3389 counts: ImpactCounts::default(),
3390 }],
3391 ..Default::default()
3392 };
3393 let report = build_report(&store);
3394 assert!(!report.attribution_active);
3395 let human = render_human(&report);
3396 assert!(human.contains("resolution tracking starts from your next gate run"));
3397 assert!(!human.contains("0 finding"));
3398 }
3399
3400 #[test]
3401 fn suppression_only_state_renders_under_a_resolved_header() {
3402 let report = ImpactReport {
3403 schema_version: ImpactReportSchemaVersion::V1,
3404 enabled: true,
3405 enabled_source: EnabledSource::Project,
3406 record_count: 2,
3407 meta: None,
3408 first_recorded: Some("2026-05-29T10:00:00Z".into()),
3409 latest_git_sha: None,
3410 surfacing: Some(ImpactCounts::default()),
3411 trend: None,
3412 project_surfacing: None,
3413 project_trend: None,
3414 containment_count: 0,
3415 recent_containment: vec![],
3416 resolved_total: 0,
3417 suppressed_total: 2,
3418 recent_resolved: vec![],
3419 attribution_active: true,
3420 onboarding_declined: false,
3421 explicit_decision: false,
3422 };
3423 let human = render_human(&report);
3424 let resolved_idx = human.find(" RESOLVED").expect("RESOLVED header present");
3425 let supp_idx = human
3426 .find("2 findings you marked intentional")
3427 .expect("suppression line present");
3428 assert!(
3429 resolved_idx < supp_idx,
3430 "suppression must render under RESOLVED"
3431 );
3432 assert!(human.contains("none yet"));
3433
3434 let md = render_markdown(&report);
3435 assert!(
3436 md.contains("- **Resolved:**"),
3437 "markdown always has a Resolved bullet"
3438 );
3439 assert!(md.contains("- **Marked intentional:** 2 finding"));
3440 }
3441
3442 fn clone_at(fingerprint: &str, paths: &[&Path]) -> CloneInput {
3444 CloneInput {
3445 fingerprint: fingerprint.to_owned(),
3446 instance_paths: paths.iter().map(|p| p.to_path_buf()).collect(),
3447 }
3448 }
3449
3450 fn run_wp(
3454 root: &Path,
3455 findings: Vec<FindingInput>,
3456 clones: Vec<CloneInput>,
3457 supps: &[ActiveSuppression],
3458 ts: &str,
3459 ) {
3460 let input = AttributionInput {
3461 root,
3462 scope: Scope::WholeProject,
3463 findings,
3464 clones,
3465 suppressions: supps,
3466 };
3467 record_combined_run(
3468 root,
3469 ImpactCounts::default(),
3470 Some("sha"),
3471 "2.0.0",
3472 ts,
3473 Some(&input),
3474 );
3475 }
3476
3477 #[test]
3478 fn whole_project_run_does_not_double_credit_after_audit() {
3479 let (_config, dir) = test_env();
3480 let root = dir.path();
3481 enable(root);
3482 let a = touch(root, "src/a.ts");
3483 let b = touch(root, "src/b.ts");
3484 run(
3485 root,
3486 &[&a, &b],
3487 vec![],
3488 vec![clone_at("dup:abc", &[&a, &b])],
3489 &[],
3490 "t1",
3491 );
3492 assert_eq!(clone_fingerprints(&load(root)).len(), 1);
3493
3494 run(root, &[&a, &b], vec![], vec![], &[], "t2");
3495 assert_eq!(load(root).resolved_total, 1);
3496 assert!(load(root).clone_frontier.is_empty());
3497
3498 run_wp(root, vec![], vec![], &[], "t3");
3499 assert_eq!(
3500 load(root).resolved_total,
3501 1,
3502 "whole-project run re-credited a resolution"
3503 );
3504 }
3505
3506 #[test]
3507 fn whole_project_run_credits_suppressed_not_resolved() {
3508 let (_config, dir) = test_env();
3509 let root = dir.path();
3510 enable(root);
3511 let util = touch(root, "src/util.ts");
3512 run(
3513 root,
3514 &[&util],
3515 vec![fi(&util, "unused-export", "dead")],
3516 vec![],
3517 &[],
3518 "t1",
3519 );
3520 assert_eq!(frontier_paths(&load(root)).len(), 1);
3521
3522 run_wp(root, vec![], vec![], &[supp(&util, "unused-export")], "t2");
3523 let store = load(root);
3524 assert_eq!(
3525 store.suppressed_total, 1,
3526 "suppressed finding not counted suppressed"
3527 );
3528 assert_eq!(
3529 store.resolved_total, 0,
3530 "suppressed finding wrongly counted resolved"
3531 );
3532 }
3533
3534 #[test]
3535 fn clone_reshape_three_to_two_not_credited_as_resolved() {
3536 let (_config, dir) = test_env();
3537 let root = dir.path();
3538 enable(root);
3539 let a = touch(root, "src/a.ts");
3540 let b = touch(root, "src/b.ts");
3541 let c = touch(root, "src/c.ts");
3542 run(
3543 root,
3544 &[&a, &b, &c],
3545 vec![],
3546 vec![clone_at("dup:aaa", &[&a, &b, &c])],
3547 &[],
3548 "t1",
3549 );
3550 assert_eq!(clone_fingerprints(&load(root)).len(), 1);
3551
3552 run_wp(
3553 root,
3554 vec![],
3555 vec![clone_at("dup:bbb", &[&a, &b])],
3556 &[],
3557 "t2",
3558 );
3559 let store = load(root);
3560 assert_eq!(
3561 store.resolved_total, 0,
3562 "clone reshape miscredited as resolved"
3563 );
3564 assert!(clone_fingerprints(&store).contains("dup:bbb"));
3565 assert!(!clone_fingerprints(&store).contains("dup:aaa"));
3566 }
3567
3568 fn rcounts(total: usize, dead: usize, complexity: usize, dup: usize) -> ImpactCounts {
3569 ImpactCounts {
3570 total_issues: total,
3571 dead_code: dead,
3572 complexity,
3573 duplication: dup,
3574 }
3575 }
3576
3577 fn rtrend(prev: usize, cur: usize) -> TrendSummary {
3578 TrendSummary {
3579 direction: direction_for(cur as i64 - prev as i64),
3580 total_delta: cur as i64 - prev as i64,
3581 previous_total: prev,
3582 current_total: cur,
3583 }
3584 }
3585
3586 fn rreport(
3588 record_count: usize,
3589 first_recorded: Option<&str>,
3590 surfacing: Option<ImpactCounts>,
3591 trend: Option<TrendSummary>,
3592 project_surfacing: Option<ImpactCounts>,
3593 project_trend: Option<TrendSummary>,
3594 attribution_active: bool,
3595 ) -> ImpactReport {
3596 ImpactReport {
3597 schema_version: ImpactReportSchemaVersion::V1,
3598 enabled: true,
3599 enabled_source: EnabledSource::Project,
3600 record_count,
3601 meta: None,
3602 first_recorded: first_recorded.map(ToOwned::to_owned),
3603 latest_git_sha: None,
3604 surfacing,
3605 trend,
3606 project_surfacing,
3607 project_trend,
3608 containment_count: 0,
3609 recent_containment: vec![],
3610 resolved_total: 0,
3611 suppressed_total: 0,
3612 recent_resolved: vec![],
3613 attribution_active,
3614 onboarding_declined: false,
3615 explicit_decision: false,
3616 }
3617 }
3618
3619 #[test]
3620 fn render_human_project_only_store_shows_whole_project_not_empty_state() {
3621 let r = rreport(
3622 0,
3623 Some("2026-05-30T10:00:00Z"),
3624 None,
3625 None,
3626 Some(rcounts(1, 1, 0, 0)),
3627 None,
3628 true,
3629 );
3630 let human = render_human(&r);
3631 assert!(
3632 human.contains("WHOLE PROJECT (whole-repo context, not a to-do)"),
3633 "project-only must render the labeled section"
3634 );
3635 assert!(human.contains("1 issue across the whole project"));
3636 assert!(
3637 human.contains("project trend starts after your next full `fallow` run"),
3638 "single project record => no trend line, shows the next-run hint"
3639 );
3640 assert!(human.contains("Tracking since 2026-05-30"));
3641 assert!(
3642 !human.contains("No history yet"),
3643 "must not show the empty-state copy"
3644 );
3645 assert!(
3646 !human.contains("LATEST RUN"),
3647 "no changed-file track recorded"
3648 );
3649 assert!(
3650 !human.contains("recorded audit run"),
3651 "no audit runs => no changed-file footer"
3652 );
3653 }
3654
3655 #[test]
3656 fn render_human_both_tracks_label_actionable_vs_context() {
3657 let r = rreport(
3658 3,
3659 Some("2026-05-29T10:00:00Z"),
3660 Some(rcounts(4, 4, 0, 0)),
3661 Some(rtrend(6, 4)),
3662 Some(rcounts(40, 30, 5, 5)),
3663 Some(rtrend(45, 40)),
3664 true,
3665 );
3666 let human = render_human(&r);
3667 let latest = human
3668 .find("LATEST RUN (changed files, act on these now)")
3669 .expect("LATEST RUN labeled actionable");
3670 let whole = human
3671 .find("WHOLE PROJECT (whole-repo context, not a to-do)")
3672 .expect("WHOLE PROJECT labeled context");
3673 assert!(
3674 latest < whole,
3675 "changed-file section renders before whole-project"
3676 );
3677 assert!(human.contains("45 -> 40 (down) across your last two full runs"));
3678 assert!(human.contains("advances only on your local full `fallow` runs, not CI"));
3679 }
3680
3681 #[test]
3682 fn render_markdown_project_only_store_shows_whole_project_not_empty_state() {
3683 let r = rreport(
3684 0,
3685 Some("2026-05-30T10:00:00Z"),
3686 None,
3687 None,
3688 Some(rcounts(1, 1, 0, 0)),
3689 None,
3690 true,
3691 );
3692 let md = render_markdown(&r);
3693 assert!(
3694 md.contains(
3695 "- **Whole project (whole-repo context, last full `fallow` run):** 1 issue"
3696 ),
3697 "project-only md must render the labeled whole-project line"
3698 );
3699 assert!(
3700 !md.contains("No history yet"),
3701 "project-only md must not show empty state"
3702 );
3703 assert!(md.contains("Tracking since 2026-05-30"));
3704 }
3705
3706 #[test]
3707 fn resolve_enabled_precedence_table() {
3708 let (_config, _dir) = test_env();
3709 let on = ImpactStore {
3711 enabled: true,
3712 ..Default::default()
3713 };
3714 assert_eq!(resolve_enabled(&on), (true, EnabledSource::Project));
3715
3716 let off_explicit = ImpactStore {
3718 enabled: false,
3719 explicit_decision: true,
3720 ..Default::default()
3721 };
3722 assert_eq!(
3723 resolve_enabled(&off_explicit),
3724 (false, EnabledSource::Project)
3725 );
3726
3727 let never = ImpactStore::default();
3729 assert_eq!(resolve_enabled(&never), (false, EnabledSource::Default));
3730
3731 assert!(set_global_default(true));
3733 assert_eq!(resolve_enabled(&never), (true, EnabledSource::User));
3734 assert_eq!(
3736 resolve_enabled(&off_explicit),
3737 (false, EnabledSource::Project)
3738 );
3739 }
3740
3741 #[test]
3742 fn human_report_explains_user_global_default() {
3743 let (_config, _dir) = test_env();
3744 set_global_default(true);
3745 let report = build_report(&ImpactStore::default());
3747 assert_eq!(report.enabled_source, EnabledSource::User);
3748 let human = render_human(&report);
3749 assert!(
3750 human.contains("Enabled by your user-global default"),
3751 "human report must explain a global-default enable: {human}"
3752 );
3753 let project = build_report(&ImpactStore {
3755 enabled: true,
3756 explicit_decision: true,
3757 ..Default::default()
3758 });
3759 assert_eq!(project.enabled_source, EnabledSource::Project);
3760 assert!(!render_human(&project).contains("user-global default"));
3761 }
3762
3763 #[test]
3764 fn global_default_round_trips() {
3765 let (_config, _dir) = test_env();
3766 assert!(!load_global_default());
3767 assert!(set_global_default(true));
3768 assert!(load_global_default());
3769 assert!(!set_global_default(true)); assert!(set_global_default(false));
3771 assert!(!load_global_default());
3772 }
3773
3774 #[test]
3775 fn global_default_records_without_per_repo_enable() {
3776 let (_config, dir) = test_env();
3777 let root = dir.path();
3778 set_global_default(true);
3779 record_v1(
3781 root,
3782 &summary(2, 0, 0),
3783 AuditVerdict::Warn,
3784 false,
3785 None,
3786 "2.0.0",
3787 "t0",
3788 );
3789 let report = build_report(&load(root));
3790 assert!(report.enabled);
3791 assert_eq!(report.enabled_source, EnabledSource::User);
3792 assert_eq!(report.record_count, 1);
3793 }
3794
3795 #[test]
3796 fn legacy_in_repo_store_is_migrated_on_first_load() {
3797 let (_config, dir) = test_env();
3798 let root = dir.path();
3799 let legacy = r#"{"schema_version":3,"enabled":true,"explicit_decision":true,
3801 "records":[{"timestamp":"t0","version":"2.0.0","verdict":"warn","gate":false,
3802 "counts":{"total_issues":1,"dead_code":1,"complexity":0,"duplication":0}}],
3803 "resolved_total":2,
3804 "frontier":{"src/a.ts":{"findings":[{"id":"x","kind":"unused-export","symbol":"foo"}],"suppressions":[]}},
3805 "containment":[]}"#;
3806 std::fs::create_dir_all(root.join(".fallow")).unwrap();
3807 std::fs::write(legacy_store_path(root), legacy).unwrap();
3808
3809 let store = load(root);
3810 assert!(store.enabled);
3811 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
3812 assert_eq!(store.records.len(), 1);
3813 assert_eq!(store.resolved_total, 2);
3814 assert!(frontier_paths(&store).contains("src/a.ts"));
3816 assert!(store_path(root).is_some_and(|p| p.exists()));
3819 let again = load(root);
3820 assert_eq!(again.records.len(), 1);
3821 }
3822
3823 #[test]
3824 fn reset_removes_only_this_project() {
3825 let (_config, dir) = test_env();
3826 let root = dir.path();
3827 enable(root);
3828 record_v1(
3829 root,
3830 &summary(1, 0, 0),
3831 AuditVerdict::Warn,
3832 false,
3833 None,
3834 "2.0.0",
3835 "t0",
3836 );
3837 assert_eq!(load(root).records.len(), 1);
3838 assert!(reset(root));
3839 assert!(load(root).records.is_empty());
3840 assert!(!reset(root)); }
3842
3843 #[test]
3844 fn reset_all_clears_dir_but_keeps_global_default() {
3845 let (_config, dir) = test_env();
3846 let root = dir.path();
3847 set_global_default(true);
3848 enable(root);
3849 assert!(load(root).enabled);
3850 assert!(reset_all());
3851 assert!(load_global_default());
3853 }
3854
3855 fn aggregate_env() -> tempfile::TempDir {
3859 let config = tempfile::tempdir().unwrap();
3860 TEST_CONFIG_DIR.with(|c| *c.borrow_mut() = Some(config.path().to_path_buf()));
3861 config
3862 }
3863
3864 fn seed_store(key: &str, store: &ImpactStore) {
3866 let dir = impact_config_dir().unwrap().join("impact");
3867 std::fs::create_dir_all(&dir).unwrap();
3868 std::fs::write(
3869 dir.join(format!("{key}.json")),
3870 serde_json::to_string_pretty(store).unwrap(),
3871 )
3872 .unwrap();
3873 }
3874
3875 fn store_with(
3876 label: &str,
3877 resolved: usize,
3878 contained: usize,
3879 latest_ts: &str,
3880 latest_issues: usize,
3881 ) -> ImpactStore {
3882 let mut s = ImpactStore {
3883 enabled: true,
3884 explicit_decision: true,
3885 resolved_total: resolved,
3886 label: Some(label.to_owned()),
3887 ..Default::default()
3888 };
3889 s.records.push(ImpactRecord {
3890 timestamp: latest_ts.to_owned(),
3891 version: "2.0.0".to_owned(),
3892 git_sha: None,
3893 verdict: "warn".to_owned(),
3894 gate: false,
3895 counts: ImpactCounts::from_combined(latest_issues, 0, 0),
3896 });
3897 for _ in 0..contained {
3898 s.containment.push(ContainmentEvent {
3899 blocked_at: "t0".to_owned(),
3900 cleared_at: "t1".to_owned(),
3901 git_sha: None,
3902 blocked_counts: ImpactCounts::default(),
3903 });
3904 }
3905 s
3906 }
3907
3908 #[test]
3909 fn repo_basename_returns_last_component_only() {
3910 assert_eq!(
3911 repo_basename(Path::new("/a/b/myrepo/.git")).as_deref(),
3912 Some("myrepo")
3913 );
3914 assert_eq!(
3915 repo_basename(Path::new("/a/b/proj")).as_deref(),
3916 Some("proj")
3917 );
3918 let name = repo_basename(Path::new("/x/y/z/.git")).unwrap();
3920 assert!(!name.contains('/') && !name.contains('\\'));
3921 }
3922
3923 #[test]
3924 fn aggregate_rolls_up_totals_and_excludes_empty() {
3925 let _cfg = aggregate_env();
3926 seed_store(
3927 "aaa",
3928 &store_with("alpha", 10, 2, "2026-06-10T00:00:00Z", 3),
3929 );
3930 seed_store("bbb", &store_with("beta", 5, 1, "2026-06-11T00:00:00Z", 0));
3931 seed_store(
3933 "ccc",
3934 &ImpactStore {
3935 enabled: true,
3936 explicit_decision: true,
3937 label: Some("gamma".into()),
3938 ..Default::default()
3939 },
3940 );
3941 let report = aggregate(CrossRepoSort::Recent);
3942 assert_eq!(report.project_count, 3, "all three stores enumerated");
3943 assert_eq!(report.tracked_count, 2, "empty store excluded from rows");
3944 assert_eq!(report.totals.resolved_total, 15);
3945 assert_eq!(report.totals.containment_count, 3);
3946 assert_eq!(report.unreadable_count, 0);
3947 }
3948
3949 #[test]
3950 fn aggregate_sort_recent_orders_by_last_activity() {
3951 let _cfg = aggregate_env();
3952 seed_store("old", &store_with("older", 1, 0, "2026-06-01T00:00:00Z", 1));
3953 seed_store("new", &store_with("newer", 1, 0, "2026-06-12T00:00:00Z", 1));
3954 let report = aggregate(CrossRepoSort::Recent);
3955 assert_eq!(report.projects[0].label.as_deref(), Some("newer"));
3956 assert_eq!(report.projects[1].label.as_deref(), Some("older"));
3957 }
3958
3959 #[test]
3960 fn cross_repo_json_carries_kind_and_leaks_no_path() {
3961 let _cfg = aggregate_env();
3962 seed_store("aaa", &store_with("alpha", 4, 1, "2026-06-10T00:00:00Z", 2));
3963 let report = aggregate(CrossRepoSort::Recent);
3964 let json = render_cross_repo_json(&report);
3965 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
3966 assert_eq!(value["kind"], "impact-cross-repo");
3967 for entry in value["projects"].as_array().unwrap() {
3969 if let Some(label) = entry["label"].as_str() {
3970 assert!(
3971 !label.contains('/') && !label.contains('\\'),
3972 "label must be a basename, got {label}"
3973 );
3974 }
3975 }
3976 assert!(
3977 !json.contains('/') || !json.contains("Users"),
3978 "json must not leak an absolute home path"
3979 );
3980 }
3981
3982 #[test]
3983 fn cross_repo_markdown_pluralizes_single_project() {
3984 let _cfg = aggregate_env();
3985 seed_store("solo", &store_with("solo", 3, 1, "2026-06-10T00:00:00Z", 2));
3986 let report = aggregate(CrossRepoSort::Recent);
3987 assert_eq!(report.project_count, 1);
3988 assert_eq!(report.tracked_count, 1);
3989 let md = render_cross_repo_markdown(&report);
3990 assert!(
3991 md.contains("1 project tracked"),
3992 "single project must read 'project', got:\n{md}"
3993 );
3994 assert!(
3995 !md.contains("1 projects tracked"),
3996 "must not pluralize a single project, got:\n{md}"
3997 );
3998 assert!(
3999 md.contains("across 1 tracked project"),
4000 "grand totals must read 'tracked project' (singular), got:\n{md}"
4001 );
4002 assert!(
4003 !md.contains("tracked projects"),
4004 "must not pluralize a single tracked project, got:\n{md}"
4005 );
4006 }
4007
4008 #[test]
4009 fn cross_repo_corrupt_file_is_skipped_and_counted() {
4010 let _cfg = aggregate_env();
4011 seed_store("good", &store_with("good", 3, 0, "2026-06-10T00:00:00Z", 1));
4012 let dir = impact_config_dir().unwrap().join("impact");
4013 std::fs::write(dir.join("bad.json"), b"{ not valid json ][").unwrap();
4014 let report = aggregate(CrossRepoSort::Recent);
4015 assert_eq!(report.tracked_count, 1, "good store still aggregated");
4016 assert_eq!(
4017 report.unreadable_count, 1,
4018 "corrupt file counted, not crashed"
4019 );
4020 }
4021
4022 #[test]
4023 fn cross_repo_empty_dir_is_first_run() {
4024 let _cfg = aggregate_env();
4025 let report = aggregate(CrossRepoSort::Recent);
4026 assert_eq!(report.project_count, 0);
4027 let human = render_cross_repo_human(&report, None);
4028 assert!(human.contains("No projects tracked yet"));
4029 }
4030
4031 #[test]
4032 fn cross_repo_all_corrupt_reports_unreadable_not_first_run() {
4033 let _cfg = aggregate_env();
4034 let dir = impact_config_dir().unwrap().join("impact");
4035 std::fs::create_dir_all(&dir).unwrap();
4036 std::fs::write(dir.join("bad.json"), b"{ broken ][").unwrap();
4037 let report = aggregate(CrossRepoSort::Recent);
4038 assert_eq!(report.project_count, 0);
4039 assert_eq!(report.unreadable_count, 1);
4040 let human = render_cross_repo_human(&report, None);
4041 assert!(
4042 human.contains("unreadable store") && !human.contains("No projects tracked yet"),
4043 "all-corrupt must report unreadable, not a misleading first-run hint: {human}"
4044 );
4045 }
4046
4047 #[test]
4048 fn record_audit_run_captures_basename_label() {
4049 let (_config, dir) = test_env();
4050 let root = dir.path();
4051 enable(root);
4052 record_v1(
4053 root,
4054 &summary(1, 0, 0),
4055 AuditVerdict::Warn,
4056 false,
4057 None,
4058 "2.0.0",
4059 "t0",
4060 );
4061 let label = load(root).label.expect("label captured on record");
4062 assert!(
4063 !label.contains('/') && !label.contains('\\'),
4064 "label must be a basename, got {label}"
4065 );
4066 }
4067
4068 #[test]
4071 fn lock_path_appends_lock_suffix() {
4072 assert_eq!(
4073 lock_path_for(Path::new("/c/fallow/impact/abc.json")),
4074 PathBuf::from("/c/fallow/impact/abc.json.lock")
4075 );
4076 }
4077
4078 #[test]
4079 fn store_lock_acquire_drop_then_record_roundtrips() {
4080 let (_config, dir) = test_env();
4081 let root = dir.path();
4082 enable(root);
4083 {
4086 let _lock = ImpactStoreLock::acquire(root).expect("lock acquires");
4087 }
4088 record_v1(
4089 root,
4090 &summary(1, 0, 0),
4091 AuditVerdict::Warn,
4092 false,
4093 None,
4094 "2.0.0",
4095 "t0",
4096 );
4097 assert_eq!(load(root).records.len(), 1, "record persisted under lock");
4098 let store = store_path(root).unwrap();
4100 assert!(lock_path_for(&store).exists(), "lock sidecar created");
4101 assert!(store.exists(), "store file is distinct from its lock");
4102 }
4103
4104 #[test]
4105 fn sweep_keeps_fresh_and_self_deletes_aged_out() {
4106 let _cfg = aggregate_env();
4107 seed_store("keepme", &store_with("keep", 1, 0, "t0", 1));
4108 seed_store("oldone", &store_with("old", 1, 0, "t0", 1));
4109 let lock = impact_config_dir()
4111 .unwrap()
4112 .join("impact")
4113 .join("oldone.json.lock");
4114 std::fs::write(&lock, b"").unwrap();
4115
4116 sweep_old_stores("keepme", std::time::Duration::ZERO);
4118
4119 let dir = impact_config_dir().unwrap().join("impact");
4120 assert!(dir.join("keepme.json").exists(), "kept store survives");
4121 assert!(
4122 !dir.join("oldone.json").exists(),
4123 "aged-out store reclaimed"
4124 );
4125 assert!(lock.exists(), "lock sidecar never deleted by the sweep");
4126 }
4127
4128 #[test]
4129 fn sweep_keeps_everything_under_a_large_window() {
4130 let _cfg = aggregate_env();
4131 seed_store("a", &store_with("a", 1, 0, "t0", 1));
4132 seed_store("b", &store_with("b", 1, 0, "t0", 1));
4133 sweep_old_stores("a", std::time::Duration::from_hours(10 * 365 * 24));
4135 let dir = impact_config_dir().unwrap().join("impact");
4136 assert!(dir.join("a.json").exists());
4137 assert!(dir.join("b.json").exists());
4138 }
4139}