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>>;
997type CurrentState = (
999 FxHashMap<String, Vec<FrontierFinding>>,
1000 FxHashMap<String, FxHashSet<String>>,
1001);
1002
1003fn apply_attribution(
1004 store: &mut ImpactStore,
1005 input: &AttributionInput<'_>,
1006 worktree_key: &str,
1007 git_sha: Option<&str>,
1008 timestamp: &str,
1009) {
1010 let root = input.root;
1011 let mut frontier: FlatFrontier = store.frontier.remove(worktree_key).unwrap_or_default();
1016 let mut clone_frontier: FlatCloneFrontier = store
1017 .clone_frontier
1018 .remove(worktree_key)
1019 .unwrap_or_default();
1020
1021 let changed: FxHashSet<String> = match input.scope {
1022 Scope::ChangedFiles(files) => files.iter().map(|p| format_display_path(p, root)).collect(),
1023 Scope::WholeProject => whole_project_scope(&frontier, &clone_frontier, input, root),
1024 };
1025
1026 let (current_findings, current_supps) = collect_current_state(input, &changed, root);
1027
1028 let appeared_move_keys = compute_appeared_move_keys(&frontier, ¤t_findings);
1029
1030 uncredit_cross_run_moves(store, &appeared_move_keys);
1031
1032 let mut disappearance_input = FileDisappearancesInput {
1033 store,
1034 frontier: &frontier,
1035 changed: &changed,
1036 current_findings: ¤t_findings,
1037 current_supps: ¤t_supps,
1038 appeared_move_keys: &appeared_move_keys,
1039 git_sha,
1040 timestamp,
1041 };
1042 classify_file_disappearances(&mut disappearance_input);
1043 update_file_frontier(&mut frontier, &changed, current_findings, current_supps);
1044 classify_clone_disappearances(
1045 store,
1046 &frontier,
1047 &mut clone_frontier,
1048 input,
1049 &changed,
1050 git_sha,
1051 timestamp,
1052 );
1053 prune_frontier(&mut frontier, &mut clone_frontier, root);
1054 bound_recent_resolved(store);
1055
1056 store_worktree_baseline(store, worktree_key, frontier, clone_frontier);
1057}
1058
1059fn compute_appeared_move_keys(
1062 frontier: &FlatFrontier,
1063 current_findings: &FxHashMap<String, Vec<FrontierFinding>>,
1064) -> FxHashSet<String> {
1065 let mut appeared_move_keys: FxHashSet<String> = FxHashSet::default();
1066 for (rel, findings) in current_findings {
1067 let prior_ids: FxHashSet<&str> = frontier
1068 .get(rel)
1069 .map(|f| f.findings.iter().map(|x| x.id.as_str()).collect())
1070 .unwrap_or_default();
1071 for ff in findings {
1072 if !prior_ids.contains(ff.id.as_str()) {
1073 appeared_move_keys.insert(ff.move_key());
1074 }
1075 }
1076 }
1077 appeared_move_keys
1078}
1079
1080fn collect_current_state(
1084 input: &AttributionInput<'_>,
1085 changed: &FxHashSet<String>,
1086 root: &Path,
1087) -> CurrentState {
1088 let mut current_findings: FxHashMap<String, Vec<FrontierFinding>> = FxHashMap::default();
1089 for f in &input.findings {
1090 let rel = format_display_path(&f.path, root);
1091 if !changed.contains(&rel) {
1092 continue;
1093 }
1094 let id = finding_id(f.kind, &rel, f.symbol.as_deref());
1095 current_findings
1096 .entry(rel)
1097 .or_default()
1098 .push(FrontierFinding {
1099 id,
1100 kind: f.kind.to_owned(),
1101 symbol: f.symbol.clone(),
1102 });
1103 }
1104 let mut current_supps: FxHashMap<String, FxHashSet<String>> = FxHashMap::default();
1105 for s in input.suppressions {
1106 let rel = format_display_path(&s.path, root);
1107 if !changed.contains(&rel) {
1108 continue;
1109 }
1110 let key = s
1111 .kind
1112 .clone()
1113 .unwrap_or_else(|| BLANKET_SUPPRESSION.to_owned());
1114 current_supps.entry(rel).or_default().insert(key);
1115 }
1116 (current_findings, current_supps)
1117}
1118
1119fn store_worktree_baseline(
1122 store: &mut ImpactStore,
1123 worktree_key: &str,
1124 frontier: FlatFrontier,
1125 clone_frontier: FlatCloneFrontier,
1126) {
1127 if frontier.is_empty() {
1128 store.frontier.remove(worktree_key);
1129 } else {
1130 store.frontier.insert(worktree_key.to_owned(), frontier);
1131 }
1132 if clone_frontier.is_empty() {
1133 store.clone_frontier.remove(worktree_key);
1134 } else {
1135 store
1136 .clone_frontier
1137 .insert(worktree_key.to_owned(), clone_frontier);
1138 }
1139}
1140
1141fn whole_project_scope(
1142 frontier: &FlatFrontier,
1143 clone_frontier: &FlatCloneFrontier,
1144 input: &AttributionInput<'_>,
1145 root: &Path,
1146) -> FxHashSet<String> {
1147 let mut set: FxHashSet<String> = frontier.keys().cloned().collect();
1148 for paths in clone_frontier.values() {
1149 for p in paths {
1150 set.insert(p.clone());
1151 }
1152 }
1153 for f in &input.findings {
1154 set.insert(format_display_path(&f.path, root));
1155 }
1156 for c in &input.clones {
1157 for p in &c.instance_paths {
1158 set.insert(format_display_path(p, root));
1159 }
1160 }
1161 set
1162}
1163
1164struct FileDisappearancesInput<'a> {
1165 store: &'a mut ImpactStore,
1166 frontier: &'a FlatFrontier,
1167 changed: &'a FxHashSet<String>,
1168 current_findings: &'a FxHashMap<String, Vec<FrontierFinding>>,
1169 current_supps: &'a FxHashMap<String, FxHashSet<String>>,
1170 appeared_move_keys: &'a FxHashSet<String>,
1171 git_sha: Option<&'a str>,
1172 timestamp: &'a str,
1173}
1174
1175fn classify_file_disappearances(input: &mut FileDisappearancesInput<'_>) {
1176 let store = &mut *input.store;
1177 let frontier = input.frontier;
1178 let changed = input.changed;
1179 let current_findings = input.current_findings;
1180 let current_supps = input.current_supps;
1181 let appeared_move_keys = input.appeared_move_keys;
1182 let git_sha = input.git_sha;
1183 let timestamp = input.timestamp;
1184 let empty_supps = FxHashSet::default();
1185 for rel in changed {
1186 let Some(prior) = frontier.get(rel) else {
1187 continue;
1188 };
1189 let now_ids: FxHashSet<&str> = current_findings
1190 .get(rel)
1191 .map(|fs| fs.iter().map(|f| f.id.as_str()).collect())
1192 .unwrap_or_default();
1193 let now_supps = current_supps.get(rel).unwrap_or(&empty_supps);
1194 let prior_supps: FxHashSet<&str> = prior.suppressions.iter().map(String::as_str).collect();
1195 let new_supp_kinds: FxHashSet<String> = now_supps
1196 .iter()
1197 .filter(|k| !prior_supps.contains(k.as_str()))
1198 .cloned()
1199 .collect();
1200
1201 let mut resolved = Vec::new();
1202 let mut suppressed = 0usize;
1203 for pf in &prior.findings {
1204 if now_ids.contains(pf.id.as_str()) {
1205 continue; }
1207 if appeared_move_keys.contains(&pf.move_key()) {
1208 continue; }
1210 if covered_by(&new_supp_kinds, &pf.kind) {
1211 suppressed += 1; } else {
1213 resolved.push(pf.clone());
1214 }
1215 }
1216 store.suppressed_total += suppressed;
1217 for pf in resolved {
1218 store.resolved_total += 1;
1219 store.recent_resolved.push(ResolutionEvent {
1220 kind: pf.kind,
1221 path: rel.clone(),
1222 symbol: pf.symbol,
1223 git_sha: git_sha.map(ToOwned::to_owned),
1224 timestamp: timestamp.to_owned(),
1225 });
1226 }
1227 }
1228}
1229
1230fn update_file_frontier(
1231 frontier: &mut FlatFrontier,
1232 changed: &FxHashSet<String>,
1233 mut current_findings: FxHashMap<String, Vec<FrontierFinding>>,
1234 mut current_supps: FxHashMap<String, FxHashSet<String>>,
1235) {
1236 for rel in changed {
1237 let findings = current_findings.remove(rel).unwrap_or_default();
1238 let mut suppressions: Vec<String> = current_supps
1239 .remove(rel)
1240 .unwrap_or_default()
1241 .into_iter()
1242 .collect();
1243 suppressions.sort_unstable();
1244 if findings.is_empty() && suppressions.is_empty() {
1245 frontier.remove(rel);
1246 } else {
1247 frontier.insert(
1248 rel.clone(),
1249 FileFrontier {
1250 findings,
1251 suppressions,
1252 },
1253 );
1254 }
1255 }
1256}
1257
1258fn classify_clone_disappearances(
1259 store: &mut ImpactStore,
1260 frontier: &FlatFrontier,
1261 clone_frontier: &mut FlatCloneFrontier,
1262 input: &AttributionInput<'_>,
1263 changed: &FxHashSet<String>,
1264 git_sha: Option<&str>,
1265 timestamp: &str,
1266) {
1267 let current = collect_changed_clone_groups(input, changed);
1268
1269 let still_duplicated: FxHashSet<&String> = current.values().flatten().collect();
1270
1271 let disappeared: Vec<(String, Vec<String>)> = clone_frontier
1272 .iter()
1273 .filter(|(fp, paths)| {
1274 paths.iter().any(|p| changed.contains(p)) && !current.contains_key(*fp)
1275 })
1276 .map(|(fp, paths)| (fp.clone(), paths.clone()))
1277 .collect();
1278
1279 for (fp, paths) in disappeared {
1280 clone_frontier.remove(&fp);
1281 if paths.iter().any(|p| still_duplicated.contains(p)) {
1282 continue;
1283 }
1284 credit_clone_disappearance(store, frontier, changed, &paths, git_sha, timestamp);
1285 }
1286
1287 for (fp, paths) in current {
1288 clone_frontier.insert(fp, paths);
1289 }
1290}
1291
1292fn collect_changed_clone_groups(
1295 input: &AttributionInput<'_>,
1296 changed: &FxHashSet<String>,
1297) -> FxHashMap<String, Vec<String>> {
1298 let root = input.root;
1299 let mut current: FxHashMap<String, Vec<String>> = FxHashMap::default();
1300 for c in &input.clones {
1301 let mut paths: Vec<String> = c
1302 .instance_paths
1303 .iter()
1304 .map(|p| format_display_path(p, root))
1305 .collect();
1306 paths.sort_unstable();
1307 paths.dedup();
1308 if paths.iter().any(|p| changed.contains(p)) {
1309 current.insert(c.fingerprint.clone(), paths);
1310 }
1311 }
1312 current
1313}
1314
1315fn clone_dup_suppressed(
1318 frontier: &FlatFrontier,
1319 changed: &FxHashSet<String>,
1320 paths: &[String],
1321) -> bool {
1322 paths.iter().any(|p| {
1323 changed.contains(p)
1324 && frontier.get(p).is_some_and(|f| {
1325 f.suppressions
1326 .iter()
1327 .any(|k| k == CODE_DUPLICATION_KIND || k == BLANKET_SUPPRESSION)
1328 })
1329 })
1330}
1331
1332fn credit_clone_disappearance(
1334 store: &mut ImpactStore,
1335 frontier: &FlatFrontier,
1336 changed: &FxHashSet<String>,
1337 paths: &[String],
1338 git_sha: Option<&str>,
1339 timestamp: &str,
1340) {
1341 if clone_dup_suppressed(frontier, changed, paths) {
1342 store.suppressed_total += 1;
1343 } else {
1344 store.resolved_total += 1;
1345 let path = paths.first().cloned().unwrap_or_default();
1346 store.recent_resolved.push(ResolutionEvent {
1347 kind: CODE_DUPLICATION_KIND.to_owned(),
1348 path,
1349 symbol: None,
1350 git_sha: git_sha.map(ToOwned::to_owned),
1351 timestamp: timestamp.to_owned(),
1352 });
1353 }
1354}
1355
1356fn prune_frontier(
1357 frontier: &mut FlatFrontier,
1358 clone_frontier: &mut FlatCloneFrontier,
1359 root: &Path,
1360) {
1361 frontier.retain(|rel, _| root.join(rel).exists());
1362 clone_frontier.retain(|_, paths| paths.iter().any(|p| root.join(p).exists()));
1363}
1364
1365fn bound_recent_resolved(store: &mut ImpactStore) {
1366 if store.recent_resolved.len() > MAX_RECENT_RESOLVED {
1367 let overflow = store.recent_resolved.len() - MAX_RECENT_RESOLVED;
1368 store.recent_resolved.drain(0..overflow);
1369 }
1370}
1371
1372fn event_move_key(ev: &ResolutionEvent) -> Option<String> {
1373 ev.symbol
1374 .as_ref()
1375 .map(|symbol| format!("{}{ID_SEP}{symbol}", ev.kind))
1376}
1377
1378fn uncredit_cross_run_moves(store: &mut ImpactStore, appeared_move_keys: &FxHashSet<String>) {
1379 if appeared_move_keys.is_empty() {
1380 return;
1381 }
1382 let mut uncredited = 0usize;
1383 store.recent_resolved.retain(|ev| match event_move_key(ev) {
1384 Some(mk) if appeared_move_keys.contains(&mk) => {
1385 uncredited += 1;
1386 false
1387 }
1388 _ => true,
1389 });
1390 store.resolved_total = store.resolved_total.saturating_sub(uncredited);
1391}
1392
1393#[must_use]
1394pub fn collect_dead_code_findings(results: &AnalysisResults) -> Vec<FindingInput> {
1395 let mut out = Vec::new();
1396 let mut push = |path: &Path, kind: &'static str, symbol: Option<String>| {
1397 out.push(FindingInput {
1398 path: path.to_path_buf(),
1399 kind,
1400 symbol,
1401 });
1402 };
1403 collect_unused_symbol_findings(results, &mut push);
1404 collect_dependency_findings(results, &mut push);
1405 collect_catalog_findings(results, &mut push);
1406 out
1407}
1408
1409fn collect_unused_symbol_findings(
1410 results: &AnalysisResults,
1411 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1412) {
1413 collect_file_and_export_findings(results, push);
1414 collect_member_findings(results, push);
1415 collect_component_findings(results, push);
1416 collect_import_boundary_findings(results, push);
1417}
1418
1419fn collect_file_and_export_findings(
1421 results: &AnalysisResults,
1422 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1423) {
1424 for f in &results.unused_files {
1425 push(&f.file.path, "unused-file", None);
1426 }
1427 for f in &results.unused_exports {
1428 push(
1429 &f.export.path,
1430 "unused-export",
1431 Some(f.export.export_name.clone()),
1432 );
1433 }
1434 for f in &results.unused_types {
1435 push(
1436 &f.export.path,
1437 "unused-type",
1438 Some(f.export.export_name.clone()),
1439 );
1440 }
1441 for f in &results.private_type_leaks {
1442 push(
1443 &f.leak.path,
1444 "private-type-leak",
1445 Some(format!(
1446 "{}{ID_SEP}{}",
1447 f.leak.export_name, f.leak.type_name
1448 )),
1449 );
1450 }
1451}
1452
1453fn collect_member_findings(
1455 results: &AnalysisResults,
1456 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1457) {
1458 for f in &results.unused_enum_members {
1459 push(
1460 &f.member.path,
1461 "unused-enum-member",
1462 Some(format!(
1463 "{}{ID_SEP}{}",
1464 f.member.parent_name, f.member.member_name
1465 )),
1466 );
1467 }
1468 for f in &results.unused_class_members {
1469 push(
1470 &f.member.path,
1471 "unused-class-member",
1472 Some(format!(
1473 "{}{ID_SEP}{}",
1474 f.member.parent_name, f.member.member_name
1475 )),
1476 );
1477 }
1478 for f in &results.unused_store_members {
1479 push(
1480 &f.member.path,
1481 "unused-store-member",
1482 Some(format!(
1483 "{}{ID_SEP}{}",
1484 f.member.parent_name, f.member.member_name
1485 )),
1486 );
1487 }
1488 for f in &results.unprovided_injects {
1489 push(
1490 &f.inject.path,
1491 "unprovided-inject",
1492 Some(f.inject.key_name.clone()),
1493 );
1494 }
1495}
1496
1497fn collect_component_findings(
1499 results: &AnalysisResults,
1500 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1501) {
1502 for f in &results.unrendered_components {
1503 push(
1504 &f.component.path,
1505 "unrendered-component",
1506 Some(f.component.component_name.clone()),
1507 );
1508 }
1509 for f in &results.unused_component_props {
1510 push(
1511 &f.prop.path,
1512 "unused-component-prop",
1513 Some(f.prop.prop_name.clone()),
1514 );
1515 }
1516 for f in &results.unused_component_emits {
1517 push(
1518 &f.emit.path,
1519 "unused-component-emit",
1520 Some(f.emit.emit_name.clone()),
1521 );
1522 }
1523 for f in &results.unused_component_inputs {
1524 push(
1525 &f.input.path,
1526 "unused-component-input",
1527 Some(f.input.input_name.clone()),
1528 );
1529 }
1530 for f in &results.unused_component_outputs {
1531 push(
1532 &f.output.path,
1533 "unused-component-output",
1534 Some(f.output.output_name.clone()),
1535 );
1536 }
1537}
1538
1539fn collect_import_boundary_findings(
1541 results: &AnalysisResults,
1542 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1543) {
1544 for f in &results.unresolved_imports {
1545 push(
1546 &f.import.path,
1547 "unresolved-import",
1548 Some(f.import.specifier.clone()),
1549 );
1550 }
1551 for f in &results.boundary_violations {
1552 let to_path = f.violation.to_path.to_string_lossy().replace('\\', "/");
1553 push(
1554 &f.violation.from_path,
1555 "boundary-violation",
1556 Some(format!("{to_path}{ID_SEP}{}", f.violation.import_specifier)),
1557 );
1558 }
1559}
1560
1561fn collect_dependency_findings(
1562 results: &AnalysisResults,
1563 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1564) {
1565 for f in &results.unused_dependencies {
1566 push(
1567 &f.dep.path,
1568 "unused-dependency",
1569 Some(f.dep.package_name.clone()),
1570 );
1571 }
1572 for f in &results.unused_dev_dependencies {
1573 push(
1574 &f.dep.path,
1575 "unused-dev-dependency",
1576 Some(f.dep.package_name.clone()),
1577 );
1578 }
1579 for f in &results.unused_optional_dependencies {
1580 push(
1581 &f.dep.path,
1582 "unused-optional-dependency",
1583 Some(f.dep.package_name.clone()),
1584 );
1585 }
1586 for f in &results.type_only_dependencies {
1587 push(
1588 &f.dep.path,
1589 "type-only-dependency",
1590 Some(f.dep.package_name.clone()),
1591 );
1592 }
1593 for f in &results.test_only_dependencies {
1594 push(
1595 &f.dep.path,
1596 "test-only-dependency",
1597 Some(f.dep.package_name.clone()),
1598 );
1599 }
1600}
1601
1602fn collect_catalog_findings(
1603 results: &AnalysisResults,
1604 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1605) {
1606 for f in &results.unused_catalog_entries {
1607 push(
1608 &f.entry.path,
1609 "unused-catalog-entry",
1610 Some(format!(
1611 "{}{ID_SEP}{}",
1612 f.entry.catalog_name, f.entry.entry_name
1613 )),
1614 );
1615 }
1616 for f in &results.empty_catalog_groups {
1617 push(
1618 &f.group.path,
1619 "empty-catalog-group",
1620 Some(f.group.catalog_name.clone()),
1621 );
1622 }
1623 for f in &results.unresolved_catalog_references {
1624 push(
1625 &f.reference.path,
1626 "unresolved-catalog-reference",
1627 Some(format!(
1628 "{}{ID_SEP}{}",
1629 f.reference.catalog_name, f.reference.entry_name
1630 )),
1631 );
1632 }
1633 for f in &results.unused_dependency_overrides {
1634 push(
1635 &f.entry.path,
1636 "unused-dependency-override",
1637 Some(f.entry.raw_key.clone()),
1638 );
1639 }
1640 for f in &results.misconfigured_dependency_overrides {
1641 push(
1642 &f.entry.path,
1643 "misconfigured-dependency-override",
1644 Some(f.entry.raw_key.clone()),
1645 );
1646 }
1647}
1648
1649#[must_use]
1653pub fn collect_complexity_findings(
1654 report: &crate::health_types::HealthReport,
1655) -> Vec<FindingInput> {
1656 report
1657 .findings
1658 .iter()
1659 .map(|f| FindingInput {
1660 path: f.path.clone(),
1661 kind: "complexity",
1662 symbol: Some(f.name.clone()),
1663 })
1664 .collect()
1665}
1666
1667#[must_use]
1671pub fn collect_clone_findings(
1672 report: &fallow_core::duplicates::DuplicationReport,
1673) -> Vec<CloneInput> {
1674 report
1675 .clone_groups
1676 .iter()
1677 .map(|g| CloneInput {
1678 fingerprint: fallow_core::duplicates::clone_fingerprint(&g.instances),
1679 instance_paths: g.instances.iter().map(|i| i.file.clone()).collect(),
1680 })
1681 .collect()
1682}
1683
1684const fn verdict_label(verdict: AuditVerdict) -> &'static str {
1685 match verdict {
1686 AuditVerdict::Pass => "pass",
1687 AuditVerdict::Warn => "warn",
1688 AuditVerdict::Fail => "fail",
1689 }
1690}
1691
1692#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1694#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1695#[serde(rename_all = "snake_case")]
1696pub enum ImpactTrendDirection {
1697 Improving,
1699 Declining,
1701 Stable,
1703}
1704
1705#[derive(Debug, Clone, Serialize)]
1707#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1708pub struct TrendSummary {
1709 pub direction: ImpactTrendDirection,
1710 pub total_delta: i64,
1712 pub previous_total: usize,
1713 pub current_total: usize,
1714}
1715
1716fn direction_for(delta: i64) -> ImpactTrendDirection {
1717 if delta < -TREND_TOLERANCE {
1718 ImpactTrendDirection::Improving
1719 } else if delta > TREND_TOLERANCE {
1720 ImpactTrendDirection::Declining
1721 } else {
1722 ImpactTrendDirection::Stable
1723 }
1724}
1725
1726#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1733#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1734pub enum ImpactReportSchemaVersion {
1735 #[serde(rename = "1")]
1737 V1,
1738}
1739
1740#[derive(Debug, Clone, Serialize)]
1742#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1743#[cfg_attr(feature = "schema", schemars(title = "fallow impact --format json"))]
1744pub struct ImpactReport {
1745 pub schema_version: ImpactReportSchemaVersion,
1749 pub enabled: bool,
1750 pub enabled_source: EnabledSource,
1757 pub record_count: usize,
1758 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
1759 pub meta: Option<Meta>,
1760 #[serde(default, skip_serializing_if = "Option::is_none")]
1761 pub first_recorded: Option<String>,
1762 #[serde(default, skip_serializing_if = "Option::is_none")]
1769 pub latest_git_sha: Option<String>,
1770 #[serde(default, skip_serializing_if = "Option::is_none")]
1775 pub surfacing: Option<ImpactCounts>,
1776 #[serde(default, skip_serializing_if = "Option::is_none")]
1778 pub trend: Option<TrendSummary>,
1779 #[serde(default, skip_serializing_if = "Option::is_none")]
1784 pub project_surfacing: Option<ImpactCounts>,
1785 #[serde(default, skip_serializing_if = "Option::is_none")]
1789 pub project_trend: Option<TrendSummary>,
1790 pub containment_count: usize,
1791 pub recent_containment: Vec<ContainmentEvent>,
1793 pub resolved_total: usize,
1796 pub suppressed_total: usize,
1799 pub recent_resolved: Vec<ResolutionEvent>,
1801 pub attribution_active: bool,
1805 pub onboarding_declined: bool,
1809 pub explicit_decision: bool,
1814}
1815
1816fn trend_for(records: &[ImpactRecord]) -> Option<TrendSummary> {
1822 if records.len() < 2 {
1823 return None;
1824 }
1825 let current = &records[records.len() - 1];
1826 let previous = &records[records.len() - 2];
1827 let current_total = current.counts.total_issues;
1828 let previous_total = previous.counts.total_issues;
1829 let total_delta = current_total as i64 - previous_total as i64;
1830 Some(TrendSummary {
1831 direction: direction_for(total_delta),
1832 total_delta,
1833 previous_total,
1834 current_total,
1835 })
1836}
1837
1838pub fn build_report(store: &ImpactStore) -> ImpactReport {
1839 let surfacing = store.records.last().map(|r| r.counts.clone());
1840 let trend = trend_for(&store.records);
1841 let project_surfacing = store.project_records.last().map(|r| r.counts.clone());
1842 let project_trend = trend_for(&store.project_records);
1843
1844 let recent_containment = store
1845 .containment
1846 .iter()
1847 .rev()
1848 .take(5)
1849 .rev()
1850 .cloned()
1851 .collect();
1852
1853 let latest_git_sha = store.records.last().and_then(|r| r.git_sha.clone());
1854
1855 let recent_resolved = store
1856 .recent_resolved
1857 .iter()
1858 .rev()
1859 .take(5)
1860 .rev()
1861 .cloned()
1862 .collect();
1863 let attribution_active = !store.frontier.is_empty()
1864 || !store.clone_frontier.is_empty()
1865 || store.resolved_total > 0
1866 || store.suppressed_total > 0;
1867
1868 let (enabled, enabled_source) = resolve_enabled(store);
1869 ImpactReport {
1870 schema_version: ImpactReportSchemaVersion::V1,
1871 enabled,
1872 enabled_source,
1873 record_count: store.records.len(),
1874 meta: None,
1875 first_recorded: store.first_recorded.clone(),
1876 latest_git_sha,
1877 surfacing,
1878 trend,
1879 project_surfacing,
1880 project_trend,
1881 containment_count: store.containment.len(),
1882 recent_containment,
1883 resolved_total: store.resolved_total,
1884 suppressed_total: store.suppressed_total,
1885 recent_resolved,
1886 attribution_active,
1887 onboarding_declined: store.onboarding_declined,
1888 explicit_decision: store.explicit_decision,
1889 }
1890}
1891
1892#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1898#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1899pub enum CrossRepoImpactSchemaVersion {
1900 #[serde(rename = "1")]
1902 V1,
1903}
1904
1905#[derive(Debug, Clone, Default, Serialize)]
1908#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1909pub struct CrossRepoTotals {
1910 pub resolved_total: usize,
1911 pub suppressed_total: usize,
1912 pub containment_count: usize,
1913 pub project_wide_issues: usize,
1917 pub projects_with_baseline: usize,
1918}
1919
1920#[derive(Debug, Clone, Serialize)]
1922#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1923pub struct CrossRepoProjectEntry {
1924 pub project_key: String,
1927 #[serde(default, skip_serializing_if = "Option::is_none")]
1930 pub label: Option<String>,
1931 #[serde(default, skip_serializing_if = "Option::is_none")]
1934 pub last_recorded: Option<String>,
1935 pub report: ImpactReport,
1938}
1939
1940#[derive(Debug, Clone, Serialize)]
1942#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1943#[cfg_attr(
1944 feature = "schema",
1945 schemars(title = "fallow impact --all --format json")
1946)]
1947pub struct CrossRepoImpactReport {
1948 pub schema_version: CrossRepoImpactSchemaVersion,
1949 pub project_count: usize,
1952 pub tracked_count: usize,
1955 pub unreadable_count: usize,
1957 pub totals: CrossRepoTotals,
1958 pub projects: Vec<CrossRepoProjectEntry>,
1959}
1960
1961#[derive(Debug, Clone, Copy)]
1963pub enum CrossRepoSort {
1964 Recent,
1966 Resolved,
1968 Contained,
1970 Name,
1972}
1973
1974fn latest_activity(store: &ImpactStore) -> Option<String> {
1976 let a = store.records.last().map(|r| r.timestamp.clone());
1977 let b = store.project_records.last().map(|r| r.timestamp.clone());
1978 match (a, b) {
1979 (Some(x), Some(y)) => Some(if x >= y { x } else { y }),
1980 (x, y) => x.or(y),
1981 }
1982}
1983
1984#[must_use]
1990pub fn load_all() -> (Vec<(String, ImpactStore)>, usize) {
1991 let Some(dir) = impact_config_dir().map(|d| d.join("impact")) else {
1992 return (Vec::new(), 0);
1993 };
1994 let Ok(read) = std::fs::read_dir(&dir) else {
1995 return (Vec::new(), 0);
1996 };
1997 let mut stores = Vec::new();
1998 let mut unreadable = 0usize;
1999 for entry in read.flatten() {
2000 let path = entry.path();
2001 if path.extension().and_then(|e| e.to_str()) != Some("json") {
2002 continue;
2003 }
2004 let Some(key) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
2005 continue;
2006 };
2007 match std::fs::read_to_string(&path)
2008 .ok()
2009 .and_then(|c| serde_json::from_str::<ImpactStore>(&c).ok())
2010 {
2011 Some(store) => stores.push((key, store)),
2012 None => unreadable += 1,
2013 }
2014 }
2015 (stores, unreadable)
2016}
2017
2018#[must_use]
2022pub fn build_aggregate_report(
2023 stores: Vec<(String, ImpactStore)>,
2024 unreadable: usize,
2025 sort: CrossRepoSort,
2026) -> CrossRepoImpactReport {
2027 let project_count = stores.len();
2028 let mut totals = CrossRepoTotals::default();
2029 let mut projects = Vec::new();
2030 for (key, store) in stores {
2031 let report = build_report(&store);
2032 let has_history = report.record_count > 0
2033 || report.project_surfacing.is_some()
2034 || report.resolved_total > 0
2035 || report.containment_count > 0;
2036 if !has_history {
2037 continue;
2038 }
2039 totals.resolved_total += report.resolved_total;
2040 totals.suppressed_total += report.suppressed_total;
2041 totals.containment_count += report.containment_count;
2042 if let Some(ps) = &report.project_surfacing {
2043 totals.project_wide_issues += ps.total_issues;
2044 totals.projects_with_baseline += 1;
2045 }
2046 projects.push(CrossRepoProjectEntry {
2047 project_key: key,
2048 label: store.label.clone(),
2049 last_recorded: latest_activity(&store),
2050 report,
2051 });
2052 }
2053 sort_cross_repo(&mut projects, sort);
2054 CrossRepoImpactReport {
2055 schema_version: CrossRepoImpactSchemaVersion::V1,
2056 project_count,
2057 tracked_count: projects.len(),
2058 unreadable_count: unreadable,
2059 totals,
2060 projects,
2061 }
2062}
2063
2064fn sort_cross_repo(projects: &mut [CrossRepoProjectEntry], sort: CrossRepoSort) {
2065 match sort {
2066 CrossRepoSort::Recent => projects.sort_by(|a, b| {
2069 b.last_recorded
2070 .cmp(&a.last_recorded)
2071 .then_with(|| a.project_key.cmp(&b.project_key))
2072 }),
2073 CrossRepoSort::Resolved => projects.sort_by(|a, b| {
2074 b.report
2075 .resolved_total
2076 .cmp(&a.report.resolved_total)
2077 .then_with(|| a.project_key.cmp(&b.project_key))
2078 }),
2079 CrossRepoSort::Contained => projects.sort_by(|a, b| {
2080 b.report
2081 .containment_count
2082 .cmp(&a.report.containment_count)
2083 .then_with(|| a.project_key.cmp(&b.project_key))
2084 }),
2085 CrossRepoSort::Name => projects.sort_by(|a, b| {
2086 cross_repo_label(a)
2087 .cmp(&cross_repo_label(b))
2088 .then_with(|| a.project_key.cmp(&b.project_key))
2089 }),
2090 }
2091}
2092
2093fn cross_repo_label(entry: &CrossRepoProjectEntry) -> String {
2096 entry
2097 .label
2098 .clone()
2099 .unwrap_or_else(|| short_key(&entry.project_key))
2100}
2101
2102fn short_key(key: &str) -> String {
2104 key.chars().take(12).collect()
2105}
2106
2107#[must_use]
2109pub fn aggregate(sort: CrossRepoSort) -> CrossRepoImpactReport {
2110 let (stores, unreadable) = load_all();
2111 build_aggregate_report(stores, unreadable, sort)
2112}
2113
2114#[expect(
2120 clippy::format_push_string,
2121 reason = "small report renderer; readability over avoiding the extra allocation"
2122)]
2123fn render_project_section(out: &mut String, report: &ImpactReport) {
2124 let Some(s) = &report.project_surfacing else {
2125 return;
2126 };
2127 out.push_str(&format!(
2128 " WHOLE PROJECT (whole-repo context, not a to-do)\n {} issue{} across the whole project at your last full `fallow` run\n",
2129 s.total_issues,
2130 plural(s.total_issues),
2131 ));
2132 if let Some(t) = &report.project_trend {
2133 let arrow = trend_arrow(t.direction);
2134 out.push_str(&format!(
2135 " {} -> {} ({}) across your last two full runs (comparable over time)\n",
2136 t.previous_total, t.current_total, arrow,
2137 ));
2138 } else {
2139 out.push_str(" project trend starts after your next full `fallow` run\n");
2140 }
2141 out.push_str(" advances only on your local full `fallow` runs, not CI\n\n");
2142}
2143
2144#[expect(
2146 clippy::format_push_string,
2147 reason = "small report renderer; readability over avoiding the extra allocation"
2148)]
2149pub fn render_human(report: &ImpactReport) -> String {
2150 let mut out = String::new();
2151 out.push_str("FALLOW IMPACT\n\n");
2152
2153 if !report.enabled {
2154 out.push_str(
2155 "Impact tracking is off. Enable it with `fallow impact enable`, then\n\
2156 let your pre-commit gate run a few times to build history.\n",
2157 );
2158 return out;
2159 }
2160
2161 if report.enabled_source == EnabledSource::User {
2162 out.push_str(
2163 "Enabled by your user-global default (`fallow impact default on`). Run\n\
2164 `fallow impact disable` to opt this project out.\n\n",
2165 );
2166 }
2167
2168 if report.record_count == 0 && report.project_surfacing.is_none() {
2169 out.push_str(
2170 "Tracking enabled. No history yet: check back after your next few\n\
2171 commits (Impact records each `fallow audit` / pre-commit gate run,\n\
2172 and each full `fallow` run for the whole-project view).\n",
2173 );
2174 return out;
2175 }
2176
2177 render_human_changed_section(&mut out, report);
2178
2179 render_project_section(&mut out, report);
2180
2181 out.push_str(&format!(
2182 " CONTAINED AT COMMIT\n {} time{} fallow blocked a commit until it was fixed\n",
2183 report.containment_count,
2184 plural(report.containment_count),
2185 ));
2186
2187 render_human_resolved_section(&mut out, report);
2188
2189 render_human_footer(&mut out, report);
2190 out
2191}
2192
2193#[expect(
2195 clippy::format_push_string,
2196 reason = "small report renderer; readability over avoiding the extra allocation"
2197)]
2198fn render_human_changed_section(out: &mut String, report: &ImpactReport) {
2199 if let Some(s) = &report.surfacing {
2200 out.push_str(&format!(
2201 " LATEST RUN (changed files, act on these now)\n {} issue{} flagged in your last `fallow audit` run\n",
2202 s.total_issues,
2203 plural(s.total_issues),
2204 ));
2205 out.push_str(&format!(
2206 " dead code {} · complexity {} · duplication {}\n\n",
2207 s.dead_code, s.complexity, s.duplication,
2208 ));
2209 }
2210
2211 if let Some(t) = &report.trend {
2212 let arrow = trend_arrow(t.direction);
2213 out.push_str(&format!(
2214 " 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",
2215 t.previous_total, t.current_total, arrow,
2216 ));
2217 }
2218}
2219
2220#[expect(
2222 clippy::format_push_string,
2223 reason = "small report renderer; readability over avoiding the extra allocation"
2224)]
2225fn render_human_resolved_section(out: &mut String, report: &ImpactReport) {
2226 if report.resolved_total > 0 {
2227 out.push_str(&format!(
2228 "\n RESOLVED\n {} finding{} you cleared since fallow started tracking\n",
2229 report.resolved_total,
2230 plural(report.resolved_total),
2231 ));
2232 for ev in &report.recent_resolved {
2233 match &ev.symbol {
2234 Some(symbol) => {
2235 out.push_str(&format!(" {} {} in {}\n", ev.kind, symbol, ev.path));
2236 }
2237 None => out.push_str(&format!(" {} in {}\n", ev.kind, ev.path)),
2238 }
2239 }
2240 } else if report.attribution_active {
2241 out.push_str(
2242 "\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",
2243 );
2244 } else {
2245 out.push_str("\n RESOLVED\n resolution tracking starts from your next gate run\n");
2246 }
2247
2248 if report.suppressed_total > 0 {
2249 out.push_str(&format!(
2250 " {} finding{} you marked intentional (fallow-ignore), not counted as resolved\n",
2251 report.suppressed_total,
2252 plural(report.suppressed_total),
2253 ));
2254 }
2255}
2256
2257#[expect(
2259 clippy::format_push_string,
2260 reason = "small report renderer; readability over avoiding the extra allocation"
2261)]
2262fn render_human_footer(out: &mut String, report: &ImpactReport) {
2263 out.push('\n');
2264 let since = report
2265 .first_recorded
2266 .as_deref()
2267 .map_or("the first run", date_only);
2268 if report.record_count > 0 {
2269 out.push_str(&format!(
2270 "Based on {} recorded audit run{} since {}. Local-only; never uploaded.\n\
2271 Changed-file scope: each audit run only sees files differing from your base.\n",
2272 report.record_count,
2273 plural(report.record_count),
2274 since,
2275 ));
2276 } else {
2277 out.push_str(&format!(
2278 "Tracking since {since}. Local-only; never uploaded.\n",
2279 ));
2280 }
2281 out.push_str(
2282 "Resolution tracking is a local-developer signal: it accrues on your\n\
2283 machine across runs, not in CI (fallow never records there).\n",
2284 );
2285}
2286
2287pub fn render_json(report: &ImpactReport) -> String {
2289 let value = crate::output_envelope::serialize_root_output(
2290 crate::output_envelope::FallowOutput::Impact(report.clone()),
2291 )
2292 .unwrap_or_else(|_| serde_json::json!({"error":"failed to serialize impact report"}));
2293 serde_json::to_string_pretty(&value)
2294 .unwrap_or_else(|_| "{\"error\":\"failed to serialize impact report\"}".to_owned())
2295}
2296
2297#[expect(
2301 clippy::format_push_string,
2302 reason = "small report renderer; readability over avoiding the extra allocation"
2303)]
2304fn render_project_markdown(out: &mut String, report: &ImpactReport) {
2305 let Some(s) = &report.project_surfacing else {
2306 return;
2307 };
2308 out.push_str(&format!(
2309 "- **Whole project (whole-repo context, last full `fallow` run):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
2310 s.total_issues,
2311 plural(s.total_issues),
2312 s.dead_code,
2313 s.complexity,
2314 s.duplication,
2315 ));
2316 if let Some(t) = &report.project_trend {
2317 let arrow = trend_arrow(t.direction);
2318 out.push_str(&format!(
2319 "- **Project trend (whole project, last two full runs):** {} -> {} ({})\n",
2320 t.previous_total, t.current_total, arrow,
2321 ));
2322 }
2323}
2324
2325#[expect(
2327 clippy::format_push_string,
2328 reason = "small report renderer; readability over avoiding the extra allocation"
2329)]
2330pub fn render_markdown(report: &ImpactReport) -> String {
2331 let mut out = String::new();
2332 out.push_str("## Fallow impact\n\n");
2333
2334 if !report.enabled {
2335 out.push_str("Impact tracking is off. Run `fallow impact enable` to start.\n");
2336 return out;
2337 }
2338 if report.record_count == 0 && report.project_surfacing.is_none() {
2339 out.push_str("Tracking enabled. No history yet; check back after a few commits.\n");
2340 return out;
2341 }
2342
2343 if let Some(s) = &report.surfacing {
2344 out.push_str(&format!(
2345 "- **Latest run (changed files):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
2346 s.total_issues,
2347 plural(s.total_issues),
2348 s.dead_code,
2349 s.complexity,
2350 s.duplication,
2351 ));
2352 }
2353 if let Some(t) = &report.trend {
2354 out.push_str(&format!(
2355 "- **Trend (changed-file scope, last two runs):** {} -> {} ({})\n",
2356 t.previous_total,
2357 t.current_total,
2358 trend_arrow(t.direction),
2359 ));
2360 }
2361 render_project_markdown(&mut out, report);
2362 out.push_str(&format!(
2363 "- **Contained at commit:** {} time{}\n",
2364 report.containment_count,
2365 plural(report.containment_count),
2366 ));
2367 render_markdown_resolved_section(&mut out, report);
2368 render_markdown_footer(&mut out, report);
2369 out
2370}
2371
2372#[expect(
2374 clippy::format_push_string,
2375 reason = "small report renderer; readability over avoiding the extra allocation"
2376)]
2377fn render_markdown_resolved_section(out: &mut String, report: &ImpactReport) {
2378 if report.resolved_total > 0 {
2379 out.push_str(&format!(
2380 "- **Resolved:** {} finding{} cleared since tracking started\n",
2381 report.resolved_total,
2382 plural(report.resolved_total),
2383 ));
2384 } else if report.attribution_active {
2385 out.push_str("- **Resolved:** none yet; tracking active\n");
2386 } else {
2387 out.push_str("- **Resolved:** resolution tracking starts from your next gate run\n");
2388 }
2389 if report.suppressed_total > 0 {
2390 out.push_str(&format!(
2391 "- **Marked intentional:** {} finding{} (`fallow-ignore`), not counted as resolved\n",
2392 report.suppressed_total,
2393 plural(report.suppressed_total),
2394 ));
2395 }
2396}
2397
2398#[expect(
2400 clippy::format_push_string,
2401 reason = "small report renderer; readability over avoiding the extra allocation"
2402)]
2403fn render_markdown_footer(out: &mut String, report: &ImpactReport) {
2404 let since = report
2405 .first_recorded
2406 .as_deref()
2407 .map_or("the first run", date_only);
2408 if report.record_count > 0 {
2409 out.push_str(&format!(
2410 "\n_Based on {} recorded audit run{} since {}. Local-only; resolution is a local-developer signal._\n",
2411 report.record_count,
2412 plural(report.record_count),
2413 since,
2414 ));
2415 } else {
2416 out.push_str(&format!(
2417 "\n_Tracking since {since}. Local-only; resolution is a local-developer signal._\n",
2418 ));
2419 }
2420}
2421
2422#[must_use]
2424pub fn render_cross_repo_json(report: &CrossRepoImpactReport) -> String {
2425 let value = crate::output_envelope::serialize_root_output(
2426 crate::output_envelope::FallowOutput::ImpactCrossRepo(report.clone()),
2427 )
2428 .unwrap_or_else(
2429 |_| serde_json::json!({"error":"failed to serialize cross-repo impact report"}),
2430 );
2431 serde_json::to_string_pretty(&value).unwrap_or_else(|_| {
2432 "{\"error\":\"failed to serialize cross-repo impact report\"}".to_owned()
2433 })
2434}
2435
2436fn row_label(entry: &CrossRepoProjectEntry) -> String {
2439 cross_repo_label(entry)
2440}
2441
2442fn opt_count(c: Option<&ImpactCounts>) -> String {
2443 c.map_or_else(|| "-".to_owned(), |c| c.total_issues.to_string())
2444}
2445
2446fn row_trend(report: &ImpactReport) -> &'static str {
2447 report
2448 .project_trend
2449 .as_ref()
2450 .or(report.trend.as_ref())
2451 .map_or("-", |t| trend_arrow(t.direction))
2452}
2453
2454#[expect(
2458 clippy::format_push_string,
2459 reason = "small report renderer; readability over avoiding the extra allocation"
2460)]
2461#[must_use]
2462pub fn render_cross_repo_human(report: &CrossRepoImpactReport, limit: Option<usize>) -> String {
2463 let mut out = String::new();
2464 out.push_str("FALLOW IMPACT (ALL PROJECTS)\n\n");
2465
2466 if report.project_count == 0 {
2467 if report.unreadable_count > 0 {
2468 out.push_str(&format!(
2469 "No readable projects: skipped {} unreadable store{} (corrupt, or written by \
2470 a newer fallow). Upgrade fallow to read them.\n",
2471 report.unreadable_count,
2472 plural(report.unreadable_count),
2473 ));
2474 } else {
2475 out.push_str(
2476 "No projects tracked yet. Enable in a repo with `fallow impact enable`, or for \
2477 every project with `fallow impact default on`.\n",
2478 );
2479 }
2480 return out;
2481 }
2482
2483 out.push_str(&format!(
2484 "{} project{} tracked, {} with history\n\n",
2485 report.project_count,
2486 plural(report.project_count),
2487 report.tracked_count,
2488 ));
2489
2490 render_cross_repo_table(&mut out, report, limit);
2491 render_cross_repo_skipped(&mut out, report);
2492 render_cross_repo_totals(&mut out, report);
2493 out.push_str("\nLocal-only; never uploaded; accrues on this machine, not CI.\n");
2494 out
2495}
2496
2497#[expect(
2499 clippy::format_push_string,
2500 reason = "small report renderer; readability over avoiding the extra allocation"
2501)]
2502fn render_cross_repo_table(out: &mut String, report: &CrossRepoImpactReport, limit: Option<usize>) {
2503 if report.projects.is_empty() {
2504 return;
2505 }
2506 out.push_str(&format!(
2507 "{:<24}{:>8}{:>10}{:>11}{:>10}{:>7} {}\n",
2508 "PROJECT", "LATEST", "REPO-WIDE", "CONTAINED", "RESOLVED", "TREND", "LAST RUN",
2509 ));
2510 let rows = limit.map_or(report.projects.len(), |n| n.min(report.projects.len()));
2511 for entry in report.projects.iter().take(rows) {
2512 let mut label = row_label(entry);
2513 if label.chars().count() > 22 {
2514 label = format!("{}...", label.chars().take(19).collect::<String>());
2515 }
2516 let last = entry
2517 .last_recorded
2518 .as_deref()
2519 .map_or("-", date_only)
2520 .to_owned();
2521 out.push_str(&format!(
2522 "{:<24}{:>8}{:>10}{:>11}{:>10}{:>7} {}\n",
2523 label,
2524 opt_count(entry.report.surfacing.as_ref()),
2525 opt_count(entry.report.project_surfacing.as_ref()),
2526 entry.report.containment_count,
2527 entry.report.resolved_total,
2528 row_trend(&entry.report),
2529 last,
2530 ));
2531 }
2532 if let Some(n) = limit
2533 && report.projects.len() > n
2534 {
2535 out.push_str(&format!(
2536 " ... and {} more (raise --limit to show)\n",
2537 report.projects.len() - n,
2538 ));
2539 }
2540}
2541
2542#[expect(
2544 clippy::format_push_string,
2545 reason = "small report renderer; readability over avoiding the extra allocation"
2546)]
2547fn render_cross_repo_skipped(out: &mut String, report: &CrossRepoImpactReport) {
2548 let no_history = report.project_count.saturating_sub(report.tracked_count);
2549 if no_history > 0 {
2550 out.push_str(&format!(
2551 "\n{no_history} tracked project{} with no history yet\n",
2552 plural(no_history),
2553 ));
2554 }
2555 if report.unreadable_count > 0 {
2556 out.push_str(&format!(
2557 "skipped {} unreadable store{}\n",
2558 report.unreadable_count,
2559 plural(report.unreadable_count),
2560 ));
2561 }
2562}
2563
2564#[expect(
2566 clippy::format_push_string,
2567 reason = "small report renderer; readability over avoiding the extra allocation"
2568)]
2569fn render_cross_repo_totals(out: &mut String, report: &CrossRepoImpactReport) {
2570 let t = &report.totals;
2571 out.push_str("\nGRAND TOTALS\n");
2572 out.push_str(&format!(
2573 " Across {} tracked project{}: {} finding{} resolved, {} commit{} contained, {} marked intentional\n",
2574 report.tracked_count,
2575 plural(report.tracked_count),
2576 t.resolved_total,
2577 plural(t.resolved_total),
2578 t.containment_count,
2579 plural(t.containment_count),
2580 t.suppressed_total,
2581 ));
2582 if t.projects_with_baseline > 0 {
2583 out.push_str(&format!(
2584 " {} issue{} project-wide across {} project{} with a full-run baseline (as of each project's last full run)\n",
2585 t.project_wide_issues,
2586 plural(t.project_wide_issues),
2587 t.projects_with_baseline,
2588 plural(t.projects_with_baseline),
2589 ));
2590 }
2591}
2592
2593#[expect(
2595 clippy::format_push_string,
2596 reason = "small report renderer; readability over avoiding the extra allocation"
2597)]
2598#[must_use]
2599pub fn render_cross_repo_markdown(report: &CrossRepoImpactReport) -> String {
2600 let mut out = String::new();
2601 out.push_str("## Fallow impact (all projects)\n\n");
2602 if report.project_count == 0 {
2603 if report.unreadable_count > 0 {
2604 out.push_str(&format!(
2605 "No readable projects: skipped {} unreadable store{}.\n",
2606 report.unreadable_count,
2607 plural(report.unreadable_count),
2608 ));
2609 } else {
2610 out.push_str("No projects tracked yet.\n");
2611 }
2612 return out;
2613 }
2614 out.push_str(&format!(
2615 "{} project{} tracked, {} with history.\n\n",
2616 report.project_count,
2617 plural(report.project_count),
2618 report.tracked_count,
2619 ));
2620 if !report.projects.is_empty() {
2621 out.push_str("| Project | Latest | Repo-wide | Contained | Resolved | Last run |\n");
2622 out.push_str("|:--------|-------:|----------:|----------:|---------:|:---------|\n");
2623 for entry in &report.projects {
2624 out.push_str(&format!(
2625 "| {} | {} | {} | {} | {} | {} |\n",
2626 row_label(entry),
2627 opt_count(entry.report.surfacing.as_ref()),
2628 opt_count(entry.report.project_surfacing.as_ref()),
2629 entry.report.containment_count,
2630 entry.report.resolved_total,
2631 entry.last_recorded.as_deref().map_or("-", date_only),
2632 ));
2633 }
2634 }
2635 let t = &report.totals;
2636 out.push_str(&format!(
2637 "\n**Grand totals:** {} resolved, {} contained, {} marked intentional across {} tracked project{}",
2638 t.resolved_total,
2639 t.containment_count,
2640 t.suppressed_total,
2641 report.tracked_count,
2642 plural(report.tracked_count),
2643 ));
2644 if t.projects_with_baseline > 0 {
2645 out.push_str(&format!(
2646 "; {} issue{} project-wide across {} project{} with a full-run baseline (as of each project's last full run)",
2647 t.project_wide_issues,
2648 plural(t.project_wide_issues),
2649 t.projects_with_baseline,
2650 plural(t.projects_with_baseline),
2651 ));
2652 }
2653 out.push_str(".\n\n_Local-only; never uploaded; accrues on this machine, not CI._\n");
2654 out
2655}
2656
2657const fn plural(n: usize) -> &'static str {
2658 if n == 1 { "" } else { "s" }
2659}
2660
2661fn date_only(ts: &str) -> &str {
2667 ts.split_once('T').map_or(ts, |(date, _)| date)
2668}
2669
2670const fn trend_arrow(direction: ImpactTrendDirection) -> &'static str {
2674 match direction {
2675 ImpactTrendDirection::Improving => "down",
2676 ImpactTrendDirection::Declining => "up",
2677 ImpactTrendDirection::Stable => "flat",
2678 }
2679}
2680
2681#[cfg(test)]
2682mod tests {
2683 use super::*;
2684
2685 fn test_env() -> (tempfile::TempDir, tempfile::TempDir) {
2691 let config = tempfile::tempdir().unwrap();
2692 TEST_CONFIG_DIR.with(|c| *c.borrow_mut() = Some(config.path().to_path_buf()));
2693 let root = tempfile::tempdir().unwrap();
2694 (config, root)
2695 }
2696
2697 fn frontier_paths(store: &ImpactStore) -> FxHashSet<String> {
2700 store
2701 .frontier
2702 .values()
2703 .flat_map(|m| m.keys().cloned())
2704 .collect()
2705 }
2706
2707 fn clone_fingerprints(store: &ImpactStore) -> FxHashSet<String> {
2709 store
2710 .clone_frontier
2711 .values()
2712 .flat_map(|m| m.keys().cloned())
2713 .collect()
2714 }
2715
2716 fn seed_store_raw(root: &Path, bytes: &[u8]) {
2719 let path = store_path(root).expect("test config dir set");
2720 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2721 std::fs::write(&path, bytes).unwrap();
2722 }
2723
2724 fn summary(dead: usize, complexity: usize, dupes: usize) -> AuditSummary {
2725 AuditSummary {
2726 dead_code_issues: dead,
2727 dead_code_has_errors: dead > 0,
2728 complexity_findings: complexity,
2729 max_cyclomatic: None,
2730 duplication_clone_groups: dupes,
2731 }
2732 }
2733
2734 fn record_v1(
2736 root: &Path,
2737 summary: &AuditSummary,
2738 verdict: AuditVerdict,
2739 gate: bool,
2740 git_sha: Option<&str>,
2741 version: &str,
2742 timestamp: &str,
2743 ) {
2744 record_audit_run(
2745 root,
2746 summary,
2747 &AuditRunRecord {
2748 verdict,
2749 gate,
2750 git_sha,
2751 version,
2752 timestamp,
2753 attribution: None,
2754 },
2755 );
2756 }
2757
2758 fn touch(root: &Path, rel: &str) -> PathBuf {
2761 let p = root.join(rel);
2762 if let Some(parent) = p.parent() {
2763 std::fs::create_dir_all(parent).unwrap();
2764 }
2765 std::fs::write(&p, b"x").unwrap();
2766 p
2767 }
2768
2769 fn fi(path: &Path, kind: &'static str, symbol: &str) -> FindingInput {
2770 FindingInput {
2771 path: path.to_path_buf(),
2772 kind,
2773 symbol: Some(symbol.to_owned()),
2774 }
2775 }
2776
2777 fn supp(path: &Path, kind: &str) -> ActiveSuppression {
2778 ActiveSuppression {
2779 path: path.to_path_buf(),
2780 kind: Some(kind.to_owned()),
2781 is_file_level: false,
2782 reason: None,
2783 }
2784 }
2785
2786 fn run(
2788 root: &Path,
2789 changed: &[&Path],
2790 findings: Vec<FindingInput>,
2791 clones: Vec<CloneInput>,
2792 supps: &[ActiveSuppression],
2793 ts: &str,
2794 ) {
2795 let changed_files: Vec<PathBuf> = changed.iter().map(|p| p.to_path_buf()).collect();
2796 let input = AttributionInput {
2797 root,
2798 scope: Scope::ChangedFiles(&changed_files),
2799 findings,
2800 clones,
2801 suppressions: supps,
2802 };
2803 record_audit_run(
2804 root,
2805 &summary(0, 0, 0),
2806 &AuditRunRecord {
2807 verdict: AuditVerdict::Pass,
2808 gate: true,
2809 git_sha: Some("sha"),
2810 version: "2.0.0",
2811 timestamp: ts,
2812 attribution: Some(&input),
2813 },
2814 );
2815 }
2816
2817 #[test]
2818 fn disabled_store_does_not_record() {
2819 let (_config, dir) = test_env();
2820 let root = dir.path();
2821 record_v1(
2822 root,
2823 &summary(3, 1, 0),
2824 AuditVerdict::Fail,
2825 true,
2826 Some("abc1234"),
2827 "2.0.0",
2828 "2026-05-29T10:00:00Z",
2829 );
2830 let store = load(root);
2831 assert!(store.records.is_empty());
2832 assert!(!store.enabled);
2833 }
2834
2835 #[test]
2836 fn enable_and_disable_record_the_explicit_decision() {
2837 let (_config, dir) = test_env();
2838 let root = dir.path();
2839 assert!(!load(root).explicit_decision, "fresh store: never asked");
2840
2841 disable(root);
2843 let store = load(root);
2844 assert!(!store.enabled);
2845 assert!(store.explicit_decision);
2846 assert!(build_report(&store).explicit_decision);
2847 }
2848
2849 #[test]
2850 fn due_digest_stamps_and_respects_interval_and_gates() {
2851 let (_config, dir) = test_env();
2852 let root = dir.path();
2853
2854 assert!(take_due_digest(root).is_none());
2856 enable(root);
2857 assert!(take_due_digest(root).is_none(), "zero counters never nag");
2858
2859 let mut store = load(root);
2860 store.resolved_total = 3;
2861 store.containment.push(ContainmentEvent {
2862 blocked_at: "2026-06-11T00:00:00Z".to_string(),
2863 cleared_at: "2026-06-11T00:05:00Z".to_string(),
2864 git_sha: None,
2865 blocked_counts: ImpactCounts::default(),
2866 });
2867 save(&store, root);
2868
2869 let digest = take_due_digest(root).expect("first digest is due");
2870 assert_eq!(digest.containment_count, 1);
2871 assert_eq!(digest.resolved_total, 3);
2872 assert!(
2873 take_due_digest(root).is_none(),
2874 "stamped: not due again within the interval"
2875 );
2876
2877 let mut store = load(root);
2879 store.last_digest_epoch = Some(0);
2880 save(&store, root);
2881 assert!(take_due_digest(root).is_some());
2882 }
2883
2884 #[test]
2885 fn decline_onboarding_persists_in_existing_store() {
2886 let (_config, dir) = test_env();
2887 let root = dir.path();
2888
2889 assert!(decline_onboarding(root));
2890 assert!(!decline_onboarding(root));
2891
2892 let store = load(root);
2893 assert!(store.onboarding_declined);
2894 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
2895 assert!(!root.join(".gitignore").exists());
2897 let report = build_report(&store);
2898 assert!(report.onboarding_declined);
2899 }
2900
2901 #[test]
2902 fn enable_then_record_accrues_history() {
2903 let (_config, dir) = test_env();
2904 let root = dir.path();
2905 assert!(enable(root));
2906 assert!(!enable(root)); record_v1(
2908 root,
2909 &summary(2, 1, 0),
2910 AuditVerdict::Warn,
2911 false,
2912 None,
2913 "2.0.0",
2914 "2026-05-29T10:00:00Z",
2915 );
2916 let store = load(root);
2917 assert_eq!(store.records.len(), 1);
2918 assert_eq!(store.records[0].counts.total_issues, 3);
2919 assert_eq!(
2920 store.first_recorded.as_deref(),
2921 Some("2026-05-29T10:00:00Z")
2922 );
2923 }
2924
2925 #[test]
2926 fn record_is_a_noop_in_ci() {
2927 let (_config, dir) = test_env();
2933 let root = dir.path();
2934 assert!(enable(root));
2935 TEST_FORCE_CI.with(|c| c.set(true));
2936 record_v1(
2937 root,
2938 &summary(2, 1, 0),
2939 AuditVerdict::Warn,
2940 false,
2941 None,
2942 "2.0.0",
2943 "2026-05-29T10:00:00Z",
2944 );
2945 TEST_FORCE_CI.with(|c| c.set(false));
2946 let store = load(root);
2947 assert_eq!(store.records.len(), 0, "impact must not record while in CI");
2948 }
2949
2950 #[test]
2951 fn enable_writes_nothing_into_the_repo() {
2952 let (_config, dir) = test_env();
2953 let root = dir.path();
2954 enable(root);
2955 assert!(
2958 !root.join(".gitignore").exists(),
2959 "enable must not create or modify the repo's .gitignore"
2960 );
2961 assert!(
2962 !root.join(".fallow").exists(),
2963 "enable must not create an in-repo .fallow/ dir"
2964 );
2965 let store = load(root);
2967 assert!(store.enabled);
2968 assert!(store.explicit_decision);
2969 assert!(resolve_enabled(&store).0);
2970 }
2971
2972 #[test]
2973 fn single_record_yields_no_trend_no_spike() {
2974 let mut store = ImpactStore {
2975 enabled: true,
2976 ..Default::default()
2977 };
2978 store.records.push(ImpactRecord {
2979 timestamp: "t0".into(),
2980 version: "2.0.0".into(),
2981 git_sha: None,
2982 verdict: "warn".into(),
2983 gate: false,
2984 counts: ImpactCounts {
2985 total_issues: 5,
2986 dead_code: 5,
2987 complexity: 0,
2988 duplication: 0,
2989 },
2990 });
2991 let report = build_report(&store);
2992 assert!(report.trend.is_none());
2993 assert_eq!(report.surfacing.unwrap().total_issues, 5);
2994 }
2995
2996 #[test]
2997 fn empty_store_report_is_first_run() {
2998 let store = ImpactStore::default();
2999 let report = build_report(&store);
3000 assert_eq!(report.record_count, 0);
3001 assert!(report.trend.is_none());
3002 assert!(report.surfacing.is_none());
3003 let human = render_human(&report);
3004 assert!(human.contains("off")); }
3006
3007 #[test]
3008 fn enabled_empty_store_shows_check_back() {
3009 let store = ImpactStore {
3010 enabled: true,
3011 ..Default::default()
3012 };
3013 let report = build_report(&store);
3014 let human = render_human(&report);
3015 assert!(human.contains("No history yet"));
3016 assert!(!human.contains("0 issues"));
3017 }
3018
3019 #[test]
3020 fn trend_improving_when_issues_drop() {
3021 let mut store = ImpactStore {
3022 enabled: true,
3023 ..Default::default()
3024 };
3025 for total in [8usize, 3usize] {
3026 store.records.push(ImpactRecord {
3027 timestamp: format!("t{total}"),
3028 version: "2.0.0".into(),
3029 git_sha: None,
3030 verdict: "warn".into(),
3031 gate: false,
3032 counts: ImpactCounts {
3033 total_issues: total,
3034 dead_code: total,
3035 complexity: 0,
3036 duplication: 0,
3037 },
3038 });
3039 }
3040 let report = build_report(&store);
3041 let trend = report.trend.unwrap();
3042 assert_eq!(trend.direction, ImpactTrendDirection::Improving);
3043 assert_eq!(trend.total_delta, -5);
3044 }
3045
3046 #[test]
3047 fn containment_blocked_then_cleared_records_one_event() {
3048 let (_config, dir) = test_env();
3049 let root = dir.path();
3050 enable(root);
3051 record_v1(
3052 root,
3053 &summary(2, 0, 0),
3054 AuditVerdict::Fail,
3055 true,
3056 Some("sha1"),
3057 "2.0.0",
3058 "t0",
3059 );
3060 let store = load(root);
3061 assert!(store.pending_containment.is_some());
3062 assert!(store.containment.is_empty());
3063
3064 record_v1(
3065 root,
3066 &summary(0, 0, 0),
3067 AuditVerdict::Pass,
3068 true,
3069 Some("sha2"),
3070 "2.0.0",
3071 "t1",
3072 );
3073 let store = load(root);
3074 assert!(store.pending_containment.is_none());
3075 assert_eq!(store.containment.len(), 1);
3076 assert_eq!(store.containment[0].blocked_at, "t0");
3077 assert_eq!(store.containment[0].cleared_at, "t1");
3078 }
3079
3080 #[test]
3081 fn non_gate_run_never_creates_containment() {
3082 let (_config, dir) = test_env();
3083 let root = dir.path();
3084 enable(root);
3085 record_v1(
3086 root,
3087 &summary(2, 0, 0),
3088 AuditVerdict::Fail,
3089 false,
3090 None,
3091 "2.0.0",
3092 "t0",
3093 );
3094 let store = load(root);
3095 assert!(store.pending_containment.is_none());
3096 assert!(store.containment.is_empty());
3097 }
3098
3099 #[test]
3100 fn corrupt_store_loads_as_default_no_panic() {
3101 let (_config, dir) = test_env();
3102 let root = dir.path();
3103 seed_store_raw(root, b"{ not valid json ][");
3104 let store = load(root);
3105 assert!(!store.enabled);
3106 assert!(store.records.is_empty());
3107 record_v1(
3108 root,
3109 &summary(1, 0, 0),
3110 AuditVerdict::Fail,
3111 true,
3112 None,
3113 "2.0.0",
3114 "t0",
3115 );
3116 }
3117
3118 #[test]
3119 fn records_are_bounded() {
3120 let mut store = ImpactStore {
3121 enabled: true,
3122 ..Default::default()
3123 };
3124 for i in 0..(MAX_RECORDS + 50) {
3125 store.records.push(ImpactRecord {
3126 timestamp: format!("t{i}"),
3127 version: "2.0.0".into(),
3128 git_sha: None,
3129 verdict: "pass".into(),
3130 gate: false,
3131 counts: ImpactCounts::default(),
3132 });
3133 }
3134 compact(&mut store);
3135 assert_eq!(store.records.len(), MAX_RECORDS);
3136 assert_eq!(store.records[0].timestamp, "t50");
3137 }
3138
3139 #[test]
3140 fn report_always_carries_schema_version() {
3141 let empty = build_report(&ImpactStore::default());
3142 assert_eq!(empty.schema_version, ImpactReportSchemaVersion::V1);
3143 let json = render_json(&empty);
3144 assert!(
3145 json.contains("\"schema_version\": \"1\""),
3146 "schema_version must be present (as the \"1\" const) even when disabled: {json}"
3147 );
3148
3149 let mut store = ImpactStore {
3150 enabled: true,
3151 ..Default::default()
3152 };
3153 store.records.push(ImpactRecord {
3154 timestamp: "2026-05-29T10:00:00Z".into(),
3155 version: "2.0.0".into(),
3156 git_sha: None,
3157 verdict: "pass".into(),
3158 gate: false,
3159 counts: ImpactCounts::default(),
3160 });
3161 assert_eq!(
3162 build_report(&store).schema_version,
3163 ImpactReportSchemaVersion::V1
3164 );
3165 }
3166
3167 #[test]
3168 fn date_only_trims_iso_timestamp() {
3169 assert_eq!(date_only("2026-05-29T18:15:23Z"), "2026-05-29");
3170 assert_eq!(date_only("2026-05-29"), "2026-05-29");
3171 assert_eq!(date_only("the first run"), "the first run");
3172 }
3173
3174 #[test]
3175 fn human_footer_shows_date_only() {
3176 let mut store = ImpactStore {
3177 enabled: true,
3178 ..Default::default()
3179 };
3180 store.first_recorded = Some("2026-05-29T18:15:23Z".into());
3181 store.records.push(ImpactRecord {
3182 timestamp: "2026-05-29T18:15:23Z".into(),
3183 version: "2.0.0".into(),
3184 git_sha: None,
3185 verdict: "pass".into(),
3186 gate: false,
3187 counts: ImpactCounts::default(),
3188 });
3189 let report = build_report(&store);
3190 let human = render_human(&report);
3191 assert!(
3192 human.contains("since 2026-05-29.") && !human.contains("18:15:23"),
3193 "human footer must show date-only: {human}"
3194 );
3195 let md = render_markdown(&report);
3196 assert!(
3197 md.contains("since 2026-05-29.") && !md.contains("18:15:23"),
3198 "markdown footer must show date-only: {md}"
3199 );
3200 }
3201
3202 #[test]
3203 fn future_schema_version_store_loads_without_panic_or_loss() {
3204 let (_config, dir) = test_env();
3205 let root = dir.path();
3206 let future = format!(
3207 "{{\"schema_version\":{},\"enabled\":true,\"records\":[],\"containment\":[]}}",
3208 STORE_SCHEMA_VERSION + 1
3209 );
3210 seed_store_raw(root, future.as_bytes());
3211 let store = load(root);
3212 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION + 1);
3213 assert!(
3214 store.enabled,
3215 "future-version store must not degrade to default"
3216 );
3217 }
3218
3219 #[test]
3220 fn removed_finding_is_credited_as_resolved() {
3221 let (_config, dir) = test_env();
3222 let root = dir.path();
3223 enable(root);
3224 let a = touch(root, "src/a.ts");
3225 run(
3226 root,
3227 &[&a],
3228 vec![fi(&a, "unused-export", "foo")],
3229 vec![],
3230 &[],
3231 "t0",
3232 );
3233 assert_eq!(
3234 load(root).resolved_total,
3235 0,
3236 "first run only establishes a baseline"
3237 );
3238 run(root, &[&a], vec![], vec![], &[], "t1");
3239 let store = load(root);
3240 assert_eq!(store.resolved_total, 1);
3241 assert_eq!(store.suppressed_total, 0);
3242 assert_eq!(store.recent_resolved.len(), 1);
3243 assert_eq!(store.recent_resolved[0].kind, "unused-export");
3244 assert_eq!(store.recent_resolved[0].symbol.as_deref(), Some("foo"));
3245 assert_eq!(store.recent_resolved[0].path, "src/a.ts");
3246 }
3247
3248 #[test]
3249 fn suppressed_finding_is_not_a_win() {
3250 let (_config, dir) = test_env();
3251 let root = dir.path();
3252 enable(root);
3253 let a = touch(root, "src/a.ts");
3254 run(
3255 root,
3256 &[&a],
3257 vec![fi(&a, "unused-export", "foo")],
3258 vec![],
3259 &[],
3260 "t0",
3261 );
3262 run(
3263 root,
3264 &[&a],
3265 vec![],
3266 vec![],
3267 &[supp(&a, "unused-export")],
3268 "t1",
3269 );
3270 let store = load(root);
3271 assert_eq!(
3272 store.resolved_total, 0,
3273 "a suppression must never count as a win"
3274 );
3275 assert_eq!(store.suppressed_total, 1);
3276 }
3277
3278 #[test]
3279 fn fix_and_suppress_same_kind_credits_zero_resolved() {
3280 let (_config, dir) = test_env();
3281 let root = dir.path();
3282 enable(root);
3283 let a = touch(root, "src/a.ts");
3284 run(
3285 root,
3286 &[&a],
3287 vec![
3288 fi(&a, "unused-export", "foo"),
3289 fi(&a, "unused-export", "bar"),
3290 ],
3291 vec![],
3292 &[],
3293 "t0",
3294 );
3295 run(
3296 root,
3297 &[&a],
3298 vec![],
3299 vec![],
3300 &[supp(&a, "unused-export")],
3301 "t1",
3302 );
3303 let store = load(root);
3304 assert_eq!(store.resolved_total, 0);
3305 assert_eq!(store.suppressed_total, 2);
3306 }
3307
3308 #[test]
3309 fn within_file_move_is_not_resolved() {
3310 let (_config, dir) = test_env();
3311 let root = dir.path();
3312 enable(root);
3313 let a = touch(root, "src/a.ts");
3314 run(
3315 root,
3316 &[&a],
3317 vec![fi(&a, "unused-export", "foo")],
3318 vec![],
3319 &[],
3320 "t0",
3321 );
3322 run(
3323 root,
3324 &[&a],
3325 vec![fi(&a, "unused-export", "foo")],
3326 vec![],
3327 &[],
3328 "t1",
3329 );
3330 let store = load(root);
3331 assert_eq!(store.resolved_total, 0);
3332 assert_eq!(store.suppressed_total, 0);
3333 }
3334
3335 #[test]
3336 fn cross_file_move_in_same_run_is_not_resolved() {
3337 let (_config, dir) = test_env();
3338 let root = dir.path();
3339 enable(root);
3340 let a = touch(root, "src/a.ts");
3341 let b = touch(root, "src/b.ts");
3342 run(
3343 root,
3344 &[&a],
3345 vec![fi(&a, "unused-export", "foo")],
3346 vec![],
3347 &[],
3348 "t0",
3349 );
3350 run(
3351 root,
3352 &[&a, &b],
3353 vec![fi(&b, "unused-export", "foo")],
3354 vec![],
3355 &[],
3356 "t1",
3357 );
3358 assert_eq!(
3359 load(root).resolved_total,
3360 0,
3361 "a cross-file move is not a resolution"
3362 );
3363 }
3364
3365 #[test]
3366 fn cross_run_move_uncredits_the_prior_resolution() {
3367 let (_config, dir) = test_env();
3368 let root = dir.path();
3369 enable(root);
3370 let a = touch(root, "src/a.ts");
3371 let b = touch(root, "src/b.ts");
3372 run(
3373 root,
3374 &[&a],
3375 vec![fi(&a, "unused-export", "foo")],
3376 vec![],
3377 &[],
3378 "t0",
3379 );
3380 run(root, &[&a], vec![], vec![], &[], "t1");
3381 assert_eq!(
3382 load(root).resolved_total,
3383 1,
3384 "source disappearance credited in run A"
3385 );
3386 run(
3387 root,
3388 &[&b],
3389 vec![fi(&b, "unused-export", "foo")],
3390 vec![],
3391 &[],
3392 "t2",
3393 );
3394 let store = load(root);
3395 assert_eq!(
3396 store.resolved_total, 0,
3397 "cross-run move must un-credit the phantom win"
3398 );
3399 assert!(
3400 store.recent_resolved.is_empty(),
3401 "the stale resolution event is dropped"
3402 );
3403 }
3404
3405 #[test]
3406 fn resolved_complexity_finding_and_suppressed_complexity() {
3407 let (_config, dir) = test_env();
3408 let root = dir.path();
3409 enable(root);
3410 let a = touch(root, "src/a.ts");
3411 run(
3412 root,
3413 &[&a],
3414 vec![fi(&a, "complexity", "bigFn")],
3415 vec![],
3416 &[],
3417 "t0",
3418 );
3419 run(root, &[&a], vec![], vec![], &[supp(&a, "complexity")], "t1");
3420 let store = load(root);
3421 assert_eq!(store.resolved_total, 0);
3422 assert_eq!(store.suppressed_total, 1);
3423
3424 let b = touch(root, "src/b.ts");
3425 run(
3426 root,
3427 &[&b],
3428 vec![fi(&b, "complexity", "huge")],
3429 vec![],
3430 &[],
3431 "t2",
3432 );
3433 run(root, &[&b], vec![], vec![], &[], "t3");
3434 assert_eq!(load(root).resolved_total, 1);
3435 }
3436
3437 #[test]
3438 fn resolved_duplication_clone_group() {
3439 let (_config, dir) = test_env();
3440 let root = dir.path();
3441 enable(root);
3442 let a = touch(root, "src/a.ts");
3443 let b = touch(root, "src/b.ts");
3444 let clone = CloneInput {
3445 fingerprint: "dup:abc12345".to_owned(),
3446 instance_paths: vec![a.clone(), b],
3447 };
3448 run(root, &[&a], vec![], vec![clone], &[], "t0");
3449 run(root, &[&a], vec![], vec![], &[], "t1");
3450 let store = load(root);
3451 assert_eq!(store.resolved_total, 1);
3452 assert_eq!(store.recent_resolved[0].kind, "code-duplication");
3453 }
3454
3455 #[test]
3456 fn blanket_suppression_covers_any_kind() {
3457 let (_config, dir) = test_env();
3458 let root = dir.path();
3459 enable(root);
3460 let a = touch(root, "src/a.ts");
3461 run(
3462 root,
3463 &[&a],
3464 vec![fi(&a, "unused-export", "foo")],
3465 vec![],
3466 &[],
3467 "t0",
3468 );
3469 let blanket = ActiveSuppression {
3470 path: a.clone(),
3471 kind: None,
3472 is_file_level: true,
3473 reason: None,
3474 };
3475 run(root, &[&a], vec![], vec![], &[blanket], "t1");
3476 let store = load(root);
3477 assert_eq!(store.resolved_total, 0);
3478 assert_eq!(store.suppressed_total, 1);
3479 }
3480
3481 #[test]
3482 fn v1_store_loads_and_upgrades_to_v2() {
3483 let (_config, dir) = test_env();
3484 let root = dir.path();
3485 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":[]}"#;
3486 seed_store_raw(root, v1.as_bytes());
3487 let store = load(root);
3488 assert_eq!(store.schema_version, 1);
3489 assert!(store.frontier.is_empty());
3490 assert_eq!(store.resolved_total, 0);
3491 let a = touch(root, "src/a.ts");
3492 run(
3493 root,
3494 &[&a],
3495 vec![fi(&a, "unused-export", "foo")],
3496 vec![],
3497 &[],
3498 "t1",
3499 );
3500 let store = load(root);
3501 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
3502 assert!(frontier_paths(&store).contains("src/a.ts"));
3503 }
3504
3505 #[test]
3506 fn recent_resolved_is_bounded() {
3507 let mut store = ImpactStore {
3508 enabled: true,
3509 ..Default::default()
3510 };
3511 for i in 0..(MAX_RECENT_RESOLVED + 25) {
3512 store.recent_resolved.push(ResolutionEvent {
3513 kind: "unused-export".into(),
3514 path: format!("src/f{i}.ts"),
3515 symbol: Some(format!("s{i}")),
3516 git_sha: None,
3517 timestamp: format!("t{i}"),
3518 });
3519 }
3520 bound_recent_resolved(&mut store);
3521 assert_eq!(store.recent_resolved.len(), MAX_RECENT_RESOLVED);
3522 assert_eq!(store.recent_resolved[0].path, "src/f25.ts");
3523 }
3524
3525 #[test]
3526 fn frontier_prunes_deleted_files() {
3527 let (_config, dir) = test_env();
3528 let root = dir.path();
3529 enable(root);
3530 let a = touch(root, "src/a.ts");
3531 run(
3532 root,
3533 &[&a],
3534 vec![fi(&a, "unused-export", "foo")],
3535 vec![],
3536 &[],
3537 "t0",
3538 );
3539 assert!(frontier_paths(&load(root)).contains("src/a.ts"));
3540 std::fs::remove_file(&a).unwrap();
3541 let b = touch(root, "src/b.ts");
3542 run(root, &[&b], vec![], vec![], &[], "t1");
3543 assert!(!frontier_paths(&load(root)).contains("src/a.ts"));
3544 }
3545
3546 #[test]
3547 fn honest_empty_state_before_attribution_baseline() {
3548 let store = ImpactStore {
3549 enabled: true,
3550 records: vec![ImpactRecord {
3551 timestamp: "t0".into(),
3552 version: "2.0.0".into(),
3553 git_sha: None,
3554 verdict: "warn".into(),
3555 gate: false,
3556 counts: ImpactCounts::default(),
3557 }],
3558 ..Default::default()
3559 };
3560 let report = build_report(&store);
3561 assert!(!report.attribution_active);
3562 let human = render_human(&report);
3563 assert!(human.contains("resolution tracking starts from your next gate run"));
3564 assert!(!human.contains("0 finding"));
3565 }
3566
3567 #[test]
3568 fn suppression_only_state_renders_under_a_resolved_header() {
3569 let report = ImpactReport {
3570 schema_version: ImpactReportSchemaVersion::V1,
3571 enabled: true,
3572 enabled_source: EnabledSource::Project,
3573 record_count: 2,
3574 meta: None,
3575 first_recorded: Some("2026-05-29T10:00:00Z".into()),
3576 latest_git_sha: None,
3577 surfacing: Some(ImpactCounts::default()),
3578 trend: None,
3579 project_surfacing: None,
3580 project_trend: None,
3581 containment_count: 0,
3582 recent_containment: vec![],
3583 resolved_total: 0,
3584 suppressed_total: 2,
3585 recent_resolved: vec![],
3586 attribution_active: true,
3587 onboarding_declined: false,
3588 explicit_decision: false,
3589 };
3590 let human = render_human(&report);
3591 let resolved_idx = human.find(" RESOLVED").expect("RESOLVED header present");
3592 let supp_idx = human
3593 .find("2 findings you marked intentional")
3594 .expect("suppression line present");
3595 assert!(
3596 resolved_idx < supp_idx,
3597 "suppression must render under RESOLVED"
3598 );
3599 assert!(human.contains("none yet"));
3600
3601 let md = render_markdown(&report);
3602 assert!(
3603 md.contains("- **Resolved:**"),
3604 "markdown always has a Resolved bullet"
3605 );
3606 assert!(md.contains("- **Marked intentional:** 2 finding"));
3607 }
3608
3609 fn clone_at(fingerprint: &str, paths: &[&Path]) -> CloneInput {
3611 CloneInput {
3612 fingerprint: fingerprint.to_owned(),
3613 instance_paths: paths.iter().map(|p| p.to_path_buf()).collect(),
3614 }
3615 }
3616
3617 fn run_wp(
3621 root: &Path,
3622 findings: Vec<FindingInput>,
3623 clones: Vec<CloneInput>,
3624 supps: &[ActiveSuppression],
3625 ts: &str,
3626 ) {
3627 let input = AttributionInput {
3628 root,
3629 scope: Scope::WholeProject,
3630 findings,
3631 clones,
3632 suppressions: supps,
3633 };
3634 record_combined_run(
3635 root,
3636 ImpactCounts::default(),
3637 Some("sha"),
3638 "2.0.0",
3639 ts,
3640 Some(&input),
3641 );
3642 }
3643
3644 #[test]
3645 fn whole_project_run_does_not_double_credit_after_audit() {
3646 let (_config, dir) = test_env();
3647 let root = dir.path();
3648 enable(root);
3649 let a = touch(root, "src/a.ts");
3650 let b = touch(root, "src/b.ts");
3651 run(
3652 root,
3653 &[&a, &b],
3654 vec![],
3655 vec![clone_at("dup:abc", &[&a, &b])],
3656 &[],
3657 "t1",
3658 );
3659 assert_eq!(clone_fingerprints(&load(root)).len(), 1);
3660
3661 run(root, &[&a, &b], vec![], vec![], &[], "t2");
3662 assert_eq!(load(root).resolved_total, 1);
3663 assert!(load(root).clone_frontier.is_empty());
3664
3665 run_wp(root, vec![], vec![], &[], "t3");
3666 assert_eq!(
3667 load(root).resolved_total,
3668 1,
3669 "whole-project run re-credited a resolution"
3670 );
3671 }
3672
3673 #[test]
3674 fn whole_project_run_credits_suppressed_not_resolved() {
3675 let (_config, dir) = test_env();
3676 let root = dir.path();
3677 enable(root);
3678 let util = touch(root, "src/util.ts");
3679 run(
3680 root,
3681 &[&util],
3682 vec![fi(&util, "unused-export", "dead")],
3683 vec![],
3684 &[],
3685 "t1",
3686 );
3687 assert_eq!(frontier_paths(&load(root)).len(), 1);
3688
3689 run_wp(root, vec![], vec![], &[supp(&util, "unused-export")], "t2");
3690 let store = load(root);
3691 assert_eq!(
3692 store.suppressed_total, 1,
3693 "suppressed finding not counted suppressed"
3694 );
3695 assert_eq!(
3696 store.resolved_total, 0,
3697 "suppressed finding wrongly counted resolved"
3698 );
3699 }
3700
3701 #[test]
3702 fn clone_reshape_three_to_two_not_credited_as_resolved() {
3703 let (_config, dir) = test_env();
3704 let root = dir.path();
3705 enable(root);
3706 let a = touch(root, "src/a.ts");
3707 let b = touch(root, "src/b.ts");
3708 let c = touch(root, "src/c.ts");
3709 run(
3710 root,
3711 &[&a, &b, &c],
3712 vec![],
3713 vec![clone_at("dup:aaa", &[&a, &b, &c])],
3714 &[],
3715 "t1",
3716 );
3717 assert_eq!(clone_fingerprints(&load(root)).len(), 1);
3718
3719 run_wp(
3720 root,
3721 vec![],
3722 vec![clone_at("dup:bbb", &[&a, &b])],
3723 &[],
3724 "t2",
3725 );
3726 let store = load(root);
3727 assert_eq!(
3728 store.resolved_total, 0,
3729 "clone reshape miscredited as resolved"
3730 );
3731 assert!(clone_fingerprints(&store).contains("dup:bbb"));
3732 assert!(!clone_fingerprints(&store).contains("dup:aaa"));
3733 }
3734
3735 fn rcounts(total: usize, dead: usize, complexity: usize, dup: usize) -> ImpactCounts {
3736 ImpactCounts {
3737 total_issues: total,
3738 dead_code: dead,
3739 complexity,
3740 duplication: dup,
3741 }
3742 }
3743
3744 fn rtrend(prev: usize, cur: usize) -> TrendSummary {
3745 TrendSummary {
3746 direction: direction_for(cur as i64 - prev as i64),
3747 total_delta: cur as i64 - prev as i64,
3748 previous_total: prev,
3749 current_total: cur,
3750 }
3751 }
3752
3753 fn rreport(
3755 record_count: usize,
3756 first_recorded: Option<&str>,
3757 surfacing: Option<ImpactCounts>,
3758 trend: Option<TrendSummary>,
3759 project_surfacing: Option<ImpactCounts>,
3760 project_trend: Option<TrendSummary>,
3761 attribution_active: bool,
3762 ) -> ImpactReport {
3763 ImpactReport {
3764 schema_version: ImpactReportSchemaVersion::V1,
3765 enabled: true,
3766 enabled_source: EnabledSource::Project,
3767 record_count,
3768 meta: None,
3769 first_recorded: first_recorded.map(ToOwned::to_owned),
3770 latest_git_sha: None,
3771 surfacing,
3772 trend,
3773 project_surfacing,
3774 project_trend,
3775 containment_count: 0,
3776 recent_containment: vec![],
3777 resolved_total: 0,
3778 suppressed_total: 0,
3779 recent_resolved: vec![],
3780 attribution_active,
3781 onboarding_declined: false,
3782 explicit_decision: false,
3783 }
3784 }
3785
3786 #[test]
3787 fn render_human_project_only_store_shows_whole_project_not_empty_state() {
3788 let r = rreport(
3789 0,
3790 Some("2026-05-30T10:00:00Z"),
3791 None,
3792 None,
3793 Some(rcounts(1, 1, 0, 0)),
3794 None,
3795 true,
3796 );
3797 let human = render_human(&r);
3798 assert!(
3799 human.contains("WHOLE PROJECT (whole-repo context, not a to-do)"),
3800 "project-only must render the labeled section"
3801 );
3802 assert!(human.contains("1 issue across the whole project"));
3803 assert!(
3804 human.contains("project trend starts after your next full `fallow` run"),
3805 "single project record => no trend line, shows the next-run hint"
3806 );
3807 assert!(human.contains("Tracking since 2026-05-30"));
3808 assert!(
3809 !human.contains("No history yet"),
3810 "must not show the empty-state copy"
3811 );
3812 assert!(
3813 !human.contains("LATEST RUN"),
3814 "no changed-file track recorded"
3815 );
3816 assert!(
3817 !human.contains("recorded audit run"),
3818 "no audit runs => no changed-file footer"
3819 );
3820 }
3821
3822 #[test]
3823 fn render_human_both_tracks_label_actionable_vs_context() {
3824 let r = rreport(
3825 3,
3826 Some("2026-05-29T10:00:00Z"),
3827 Some(rcounts(4, 4, 0, 0)),
3828 Some(rtrend(6, 4)),
3829 Some(rcounts(40, 30, 5, 5)),
3830 Some(rtrend(45, 40)),
3831 true,
3832 );
3833 let human = render_human(&r);
3834 let latest = human
3835 .find("LATEST RUN (changed files, act on these now)")
3836 .expect("LATEST RUN labeled actionable");
3837 let whole = human
3838 .find("WHOLE PROJECT (whole-repo context, not a to-do)")
3839 .expect("WHOLE PROJECT labeled context");
3840 assert!(
3841 latest < whole,
3842 "changed-file section renders before whole-project"
3843 );
3844 assert!(human.contains("45 -> 40 (down) across your last two full runs"));
3845 assert!(human.contains("advances only on your local full `fallow` runs, not CI"));
3846 }
3847
3848 #[test]
3849 fn render_markdown_project_only_store_shows_whole_project_not_empty_state() {
3850 let r = rreport(
3851 0,
3852 Some("2026-05-30T10:00:00Z"),
3853 None,
3854 None,
3855 Some(rcounts(1, 1, 0, 0)),
3856 None,
3857 true,
3858 );
3859 let md = render_markdown(&r);
3860 assert!(
3861 md.contains(
3862 "- **Whole project (whole-repo context, last full `fallow` run):** 1 issue"
3863 ),
3864 "project-only md must render the labeled whole-project line"
3865 );
3866 assert!(
3867 !md.contains("No history yet"),
3868 "project-only md must not show empty state"
3869 );
3870 assert!(md.contains("Tracking since 2026-05-30"));
3871 }
3872
3873 #[test]
3874 fn resolve_enabled_precedence_table() {
3875 let (_config, _dir) = test_env();
3876 let on = ImpactStore {
3878 enabled: true,
3879 ..Default::default()
3880 };
3881 assert_eq!(resolve_enabled(&on), (true, EnabledSource::Project));
3882
3883 let off_explicit = ImpactStore {
3885 enabled: false,
3886 explicit_decision: true,
3887 ..Default::default()
3888 };
3889 assert_eq!(
3890 resolve_enabled(&off_explicit),
3891 (false, EnabledSource::Project)
3892 );
3893
3894 let never = ImpactStore::default();
3896 assert_eq!(resolve_enabled(&never), (false, EnabledSource::Default));
3897
3898 assert!(set_global_default(true));
3900 assert_eq!(resolve_enabled(&never), (true, EnabledSource::User));
3901 assert_eq!(
3903 resolve_enabled(&off_explicit),
3904 (false, EnabledSource::Project)
3905 );
3906 }
3907
3908 #[test]
3909 fn human_report_explains_user_global_default() {
3910 let (_config, _dir) = test_env();
3911 set_global_default(true);
3912 let report = build_report(&ImpactStore::default());
3914 assert_eq!(report.enabled_source, EnabledSource::User);
3915 let human = render_human(&report);
3916 assert!(
3917 human.contains("Enabled by your user-global default"),
3918 "human report must explain a global-default enable: {human}"
3919 );
3920 let project = build_report(&ImpactStore {
3922 enabled: true,
3923 explicit_decision: true,
3924 ..Default::default()
3925 });
3926 assert_eq!(project.enabled_source, EnabledSource::Project);
3927 assert!(!render_human(&project).contains("user-global default"));
3928 }
3929
3930 #[test]
3931 fn global_default_round_trips() {
3932 let (_config, _dir) = test_env();
3933 assert!(!load_global_default());
3934 assert!(set_global_default(true));
3935 assert!(load_global_default());
3936 assert!(!set_global_default(true)); assert!(set_global_default(false));
3938 assert!(!load_global_default());
3939 }
3940
3941 #[test]
3942 fn global_default_records_without_per_repo_enable() {
3943 let (_config, dir) = test_env();
3944 let root = dir.path();
3945 set_global_default(true);
3946 record_v1(
3948 root,
3949 &summary(2, 0, 0),
3950 AuditVerdict::Warn,
3951 false,
3952 None,
3953 "2.0.0",
3954 "t0",
3955 );
3956 let report = build_report(&load(root));
3957 assert!(report.enabled);
3958 assert_eq!(report.enabled_source, EnabledSource::User);
3959 assert_eq!(report.record_count, 1);
3960 }
3961
3962 #[test]
3963 fn legacy_in_repo_store_is_migrated_on_first_load() {
3964 let (_config, dir) = test_env();
3965 let root = dir.path();
3966 let legacy = r#"{"schema_version":3,"enabled":true,"explicit_decision":true,
3968 "records":[{"timestamp":"t0","version":"2.0.0","verdict":"warn","gate":false,
3969 "counts":{"total_issues":1,"dead_code":1,"complexity":0,"duplication":0}}],
3970 "resolved_total":2,
3971 "frontier":{"src/a.ts":{"findings":[{"id":"x","kind":"unused-export","symbol":"foo"}],"suppressions":[]}},
3972 "containment":[]}"#;
3973 std::fs::create_dir_all(root.join(".fallow")).unwrap();
3974 std::fs::write(legacy_store_path(root), legacy).unwrap();
3975
3976 let store = load(root);
3977 assert!(store.enabled);
3978 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
3979 assert_eq!(store.records.len(), 1);
3980 assert_eq!(store.resolved_total, 2);
3981 assert!(frontier_paths(&store).contains("src/a.ts"));
3983 assert!(store_path(root).is_some_and(|p| p.exists()));
3986 let again = load(root);
3987 assert_eq!(again.records.len(), 1);
3988 }
3989
3990 #[test]
3991 fn reset_removes_only_this_project() {
3992 let (_config, dir) = test_env();
3993 let root = dir.path();
3994 enable(root);
3995 record_v1(
3996 root,
3997 &summary(1, 0, 0),
3998 AuditVerdict::Warn,
3999 false,
4000 None,
4001 "2.0.0",
4002 "t0",
4003 );
4004 assert_eq!(load(root).records.len(), 1);
4005 assert!(reset(root));
4006 assert!(load(root).records.is_empty());
4007 assert!(!reset(root)); }
4009
4010 #[test]
4011 fn reset_all_clears_dir_but_keeps_global_default() {
4012 let (_config, dir) = test_env();
4013 let root = dir.path();
4014 set_global_default(true);
4015 enable(root);
4016 assert!(load(root).enabled);
4017 assert!(reset_all());
4018 assert!(load_global_default());
4020 }
4021
4022 fn aggregate_env() -> tempfile::TempDir {
4026 let config = tempfile::tempdir().unwrap();
4027 TEST_CONFIG_DIR.with(|c| *c.borrow_mut() = Some(config.path().to_path_buf()));
4028 config
4029 }
4030
4031 fn seed_store(key: &str, store: &ImpactStore) {
4033 let dir = impact_config_dir().unwrap().join("impact");
4034 std::fs::create_dir_all(&dir).unwrap();
4035 std::fs::write(
4036 dir.join(format!("{key}.json")),
4037 serde_json::to_string_pretty(store).unwrap(),
4038 )
4039 .unwrap();
4040 }
4041
4042 fn store_with(
4043 label: &str,
4044 resolved: usize,
4045 contained: usize,
4046 latest_ts: &str,
4047 latest_issues: usize,
4048 ) -> ImpactStore {
4049 let mut s = ImpactStore {
4050 enabled: true,
4051 explicit_decision: true,
4052 resolved_total: resolved,
4053 label: Some(label.to_owned()),
4054 ..Default::default()
4055 };
4056 s.records.push(ImpactRecord {
4057 timestamp: latest_ts.to_owned(),
4058 version: "2.0.0".to_owned(),
4059 git_sha: None,
4060 verdict: "warn".to_owned(),
4061 gate: false,
4062 counts: ImpactCounts::from_combined(latest_issues, 0, 0),
4063 });
4064 for _ in 0..contained {
4065 s.containment.push(ContainmentEvent {
4066 blocked_at: "t0".to_owned(),
4067 cleared_at: "t1".to_owned(),
4068 git_sha: None,
4069 blocked_counts: ImpactCounts::default(),
4070 });
4071 }
4072 s
4073 }
4074
4075 #[test]
4076 fn repo_basename_returns_last_component_only() {
4077 assert_eq!(
4078 repo_basename(Path::new("/a/b/myrepo/.git")).as_deref(),
4079 Some("myrepo")
4080 );
4081 assert_eq!(
4082 repo_basename(Path::new("/a/b/proj")).as_deref(),
4083 Some("proj")
4084 );
4085 let name = repo_basename(Path::new("/x/y/z/.git")).unwrap();
4087 assert!(!name.contains('/') && !name.contains('\\'));
4088 }
4089
4090 #[test]
4091 fn aggregate_rolls_up_totals_and_excludes_empty() {
4092 let _cfg = aggregate_env();
4093 seed_store(
4094 "aaa",
4095 &store_with("alpha", 10, 2, "2026-06-10T00:00:00Z", 3),
4096 );
4097 seed_store("bbb", &store_with("beta", 5, 1, "2026-06-11T00:00:00Z", 0));
4098 seed_store(
4100 "ccc",
4101 &ImpactStore {
4102 enabled: true,
4103 explicit_decision: true,
4104 label: Some("gamma".into()),
4105 ..Default::default()
4106 },
4107 );
4108 let report = aggregate(CrossRepoSort::Recent);
4109 assert_eq!(report.project_count, 3, "all three stores enumerated");
4110 assert_eq!(report.tracked_count, 2, "empty store excluded from rows");
4111 assert_eq!(report.totals.resolved_total, 15);
4112 assert_eq!(report.totals.containment_count, 3);
4113 assert_eq!(report.unreadable_count, 0);
4114 }
4115
4116 #[test]
4117 fn aggregate_sort_recent_orders_by_last_activity() {
4118 let _cfg = aggregate_env();
4119 seed_store("old", &store_with("older", 1, 0, "2026-06-01T00:00:00Z", 1));
4120 seed_store("new", &store_with("newer", 1, 0, "2026-06-12T00:00:00Z", 1));
4121 let report = aggregate(CrossRepoSort::Recent);
4122 assert_eq!(report.projects[0].label.as_deref(), Some("newer"));
4123 assert_eq!(report.projects[1].label.as_deref(), Some("older"));
4124 }
4125
4126 #[test]
4127 fn cross_repo_json_carries_kind_and_leaks_no_path() {
4128 let _cfg = aggregate_env();
4129 seed_store("aaa", &store_with("alpha", 4, 1, "2026-06-10T00:00:00Z", 2));
4130 let report = aggregate(CrossRepoSort::Recent);
4131 let json = render_cross_repo_json(&report);
4132 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
4133 assert_eq!(value["kind"], "impact-cross-repo");
4134 for entry in value["projects"].as_array().unwrap() {
4136 if let Some(label) = entry["label"].as_str() {
4137 assert!(
4138 !label.contains('/') && !label.contains('\\'),
4139 "label must be a basename, got {label}"
4140 );
4141 }
4142 }
4143 assert!(
4144 !json.contains('/') || !json.contains("Users"),
4145 "json must not leak an absolute home path"
4146 );
4147 }
4148
4149 #[test]
4150 fn cross_repo_markdown_pluralizes_single_project() {
4151 let _cfg = aggregate_env();
4152 seed_store("solo", &store_with("solo", 3, 1, "2026-06-10T00:00:00Z", 2));
4153 let report = aggregate(CrossRepoSort::Recent);
4154 assert_eq!(report.project_count, 1);
4155 assert_eq!(report.tracked_count, 1);
4156 let md = render_cross_repo_markdown(&report);
4157 assert!(
4158 md.contains("1 project tracked"),
4159 "single project must read 'project', got:\n{md}"
4160 );
4161 assert!(
4162 !md.contains("1 projects tracked"),
4163 "must not pluralize a single project, got:\n{md}"
4164 );
4165 assert!(
4166 md.contains("across 1 tracked project"),
4167 "grand totals must read 'tracked project' (singular), got:\n{md}"
4168 );
4169 assert!(
4170 !md.contains("tracked projects"),
4171 "must not pluralize a single tracked project, got:\n{md}"
4172 );
4173 }
4174
4175 #[test]
4176 fn cross_repo_corrupt_file_is_skipped_and_counted() {
4177 let _cfg = aggregate_env();
4178 seed_store("good", &store_with("good", 3, 0, "2026-06-10T00:00:00Z", 1));
4179 let dir = impact_config_dir().unwrap().join("impact");
4180 std::fs::write(dir.join("bad.json"), b"{ not valid json ][").unwrap();
4181 let report = aggregate(CrossRepoSort::Recent);
4182 assert_eq!(report.tracked_count, 1, "good store still aggregated");
4183 assert_eq!(
4184 report.unreadable_count, 1,
4185 "corrupt file counted, not crashed"
4186 );
4187 }
4188
4189 #[test]
4190 fn cross_repo_empty_dir_is_first_run() {
4191 let _cfg = aggregate_env();
4192 let report = aggregate(CrossRepoSort::Recent);
4193 assert_eq!(report.project_count, 0);
4194 let human = render_cross_repo_human(&report, None);
4195 assert!(human.contains("No projects tracked yet"));
4196 }
4197
4198 #[test]
4199 fn cross_repo_all_corrupt_reports_unreadable_not_first_run() {
4200 let _cfg = aggregate_env();
4201 let dir = impact_config_dir().unwrap().join("impact");
4202 std::fs::create_dir_all(&dir).unwrap();
4203 std::fs::write(dir.join("bad.json"), b"{ broken ][").unwrap();
4204 let report = aggregate(CrossRepoSort::Recent);
4205 assert_eq!(report.project_count, 0);
4206 assert_eq!(report.unreadable_count, 1);
4207 let human = render_cross_repo_human(&report, None);
4208 assert!(
4209 human.contains("unreadable store") && !human.contains("No projects tracked yet"),
4210 "all-corrupt must report unreadable, not a misleading first-run hint: {human}"
4211 );
4212 }
4213
4214 #[test]
4215 fn record_audit_run_captures_basename_label() {
4216 let (_config, dir) = test_env();
4217 let root = dir.path();
4218 enable(root);
4219 record_v1(
4220 root,
4221 &summary(1, 0, 0),
4222 AuditVerdict::Warn,
4223 false,
4224 None,
4225 "2.0.0",
4226 "t0",
4227 );
4228 let label = load(root).label.expect("label captured on record");
4229 assert!(
4230 !label.contains('/') && !label.contains('\\'),
4231 "label must be a basename, got {label}"
4232 );
4233 }
4234
4235 #[test]
4238 fn lock_path_appends_lock_suffix() {
4239 assert_eq!(
4240 lock_path_for(Path::new("/c/fallow/impact/abc.json")),
4241 PathBuf::from("/c/fallow/impact/abc.json.lock")
4242 );
4243 }
4244
4245 #[test]
4246 fn store_lock_acquire_drop_then_record_roundtrips() {
4247 let (_config, dir) = test_env();
4248 let root = dir.path();
4249 enable(root);
4250 {
4253 let _lock = ImpactStoreLock::acquire(root).expect("lock acquires");
4254 }
4255 record_v1(
4256 root,
4257 &summary(1, 0, 0),
4258 AuditVerdict::Warn,
4259 false,
4260 None,
4261 "2.0.0",
4262 "t0",
4263 );
4264 assert_eq!(load(root).records.len(), 1, "record persisted under lock");
4265 let store = store_path(root).unwrap();
4267 assert!(lock_path_for(&store).exists(), "lock sidecar created");
4268 assert!(store.exists(), "store file is distinct from its lock");
4269 }
4270
4271 #[test]
4272 fn sweep_keeps_fresh_and_self_deletes_aged_out() {
4273 let _cfg = aggregate_env();
4274 seed_store("keepme", &store_with("keep", 1, 0, "t0", 1));
4275 seed_store("oldone", &store_with("old", 1, 0, "t0", 1));
4276 let lock = impact_config_dir()
4278 .unwrap()
4279 .join("impact")
4280 .join("oldone.json.lock");
4281 std::fs::write(&lock, b"").unwrap();
4282
4283 sweep_old_stores("keepme", std::time::Duration::ZERO);
4285
4286 let dir = impact_config_dir().unwrap().join("impact");
4287 assert!(dir.join("keepme.json").exists(), "kept store survives");
4288 assert!(
4289 !dir.join("oldone.json").exists(),
4290 "aged-out store reclaimed"
4291 );
4292 assert!(lock.exists(), "lock sidecar never deleted by the sweep");
4293 }
4294
4295 #[test]
4296 fn sweep_keeps_everything_under_a_large_window() {
4297 let _cfg = aggregate_env();
4298 seed_store("a", &store_with("a", 1, 0, "t0", 1));
4299 seed_store("b", &store_with("b", 1, 0, "t0", 1));
4300 sweep_old_stores("a", std::time::Duration::from_hours(10 * 365 * 24));
4302 let dir = impact_config_dir().unwrap().join("impact");
4303 assert!(dir.join("a.json").exists());
4304 assert!(dir.join("b.json").exists());
4305 }
4306}