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(&mut CloneDisappearancesInput {
1045 store,
1046 frontier: &frontier,
1047 clone_frontier: &mut clone_frontier,
1048 input,
1049 changed: &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
1258struct CloneDisappearancesInput<'a> {
1262 store: &'a mut ImpactStore,
1263 frontier: &'a FlatFrontier,
1264 clone_frontier: &'a mut FlatCloneFrontier,
1265 input: &'a AttributionInput<'a>,
1266 changed: &'a FxHashSet<String>,
1267 git_sha: Option<&'a str>,
1268 timestamp: &'a str,
1269}
1270
1271fn classify_clone_disappearances(args: &mut CloneDisappearancesInput<'_>) {
1272 let store = &mut *args.store;
1273 let frontier = args.frontier;
1274 let clone_frontier = &mut *args.clone_frontier;
1275 let input = args.input;
1276 let changed = args.changed;
1277 let git_sha = args.git_sha;
1278 let timestamp = args.timestamp;
1279 let current = collect_changed_clone_groups(input, changed);
1280
1281 let still_duplicated: FxHashSet<&String> = current.values().flatten().collect();
1282
1283 let disappeared: Vec<(String, Vec<String>)> = clone_frontier
1284 .iter()
1285 .filter(|(fp, paths)| {
1286 paths.iter().any(|p| changed.contains(p)) && !current.contains_key(*fp)
1287 })
1288 .map(|(fp, paths)| (fp.clone(), paths.clone()))
1289 .collect();
1290
1291 for (fp, paths) in disappeared {
1292 clone_frontier.remove(&fp);
1293 if paths.iter().any(|p| still_duplicated.contains(p)) {
1294 continue;
1295 }
1296 credit_clone_disappearance(store, frontier, changed, &paths, git_sha, timestamp);
1297 }
1298
1299 for (fp, paths) in current {
1300 clone_frontier.insert(fp, paths);
1301 }
1302}
1303
1304fn collect_changed_clone_groups(
1307 input: &AttributionInput<'_>,
1308 changed: &FxHashSet<String>,
1309) -> FxHashMap<String, Vec<String>> {
1310 let root = input.root;
1311 let mut current: FxHashMap<String, Vec<String>> = FxHashMap::default();
1312 for c in &input.clones {
1313 let mut paths: Vec<String> = c
1314 .instance_paths
1315 .iter()
1316 .map(|p| format_display_path(p, root))
1317 .collect();
1318 paths.sort_unstable();
1319 paths.dedup();
1320 if paths.iter().any(|p| changed.contains(p)) {
1321 current.insert(c.fingerprint.clone(), paths);
1322 }
1323 }
1324 current
1325}
1326
1327fn clone_dup_suppressed(
1330 frontier: &FlatFrontier,
1331 changed: &FxHashSet<String>,
1332 paths: &[String],
1333) -> bool {
1334 paths.iter().any(|p| {
1335 changed.contains(p)
1336 && frontier.get(p).is_some_and(|f| {
1337 f.suppressions
1338 .iter()
1339 .any(|k| k == CODE_DUPLICATION_KIND || k == BLANKET_SUPPRESSION)
1340 })
1341 })
1342}
1343
1344fn credit_clone_disappearance(
1346 store: &mut ImpactStore,
1347 frontier: &FlatFrontier,
1348 changed: &FxHashSet<String>,
1349 paths: &[String],
1350 git_sha: Option<&str>,
1351 timestamp: &str,
1352) {
1353 if clone_dup_suppressed(frontier, changed, paths) {
1354 store.suppressed_total += 1;
1355 } else {
1356 store.resolved_total += 1;
1357 let path = paths.first().cloned().unwrap_or_default();
1358 store.recent_resolved.push(ResolutionEvent {
1359 kind: CODE_DUPLICATION_KIND.to_owned(),
1360 path,
1361 symbol: None,
1362 git_sha: git_sha.map(ToOwned::to_owned),
1363 timestamp: timestamp.to_owned(),
1364 });
1365 }
1366}
1367
1368fn prune_frontier(
1369 frontier: &mut FlatFrontier,
1370 clone_frontier: &mut FlatCloneFrontier,
1371 root: &Path,
1372) {
1373 frontier.retain(|rel, _| root.join(rel).exists());
1374 clone_frontier.retain(|_, paths| paths.iter().any(|p| root.join(p).exists()));
1375}
1376
1377fn bound_recent_resolved(store: &mut ImpactStore) {
1378 if store.recent_resolved.len() > MAX_RECENT_RESOLVED {
1379 let overflow = store.recent_resolved.len() - MAX_RECENT_RESOLVED;
1380 store.recent_resolved.drain(0..overflow);
1381 }
1382}
1383
1384fn event_move_key(ev: &ResolutionEvent) -> Option<String> {
1385 ev.symbol
1386 .as_ref()
1387 .map(|symbol| format!("{}{ID_SEP}{symbol}", ev.kind))
1388}
1389
1390fn uncredit_cross_run_moves(store: &mut ImpactStore, appeared_move_keys: &FxHashSet<String>) {
1391 if appeared_move_keys.is_empty() {
1392 return;
1393 }
1394 let mut uncredited = 0usize;
1395 store.recent_resolved.retain(|ev| match event_move_key(ev) {
1396 Some(mk) if appeared_move_keys.contains(&mk) => {
1397 uncredited += 1;
1398 false
1399 }
1400 _ => true,
1401 });
1402 store.resolved_total = store.resolved_total.saturating_sub(uncredited);
1403}
1404
1405#[must_use]
1406pub fn collect_dead_code_findings(results: &AnalysisResults) -> Vec<FindingInput> {
1407 let mut out = Vec::new();
1408 let mut push = |path: &Path, kind: &'static str, symbol: Option<String>| {
1409 out.push(FindingInput {
1410 path: path.to_path_buf(),
1411 kind,
1412 symbol,
1413 });
1414 };
1415 collect_unused_symbol_findings(results, &mut push);
1416 collect_dependency_findings(results, &mut push);
1417 collect_catalog_findings(results, &mut push);
1418 out
1419}
1420
1421fn collect_unused_symbol_findings(
1422 results: &AnalysisResults,
1423 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1424) {
1425 collect_file_and_export_findings(results, push);
1426 collect_member_findings(results, push);
1427 collect_component_findings(results, push);
1428 collect_import_boundary_findings(results, push);
1429}
1430
1431fn collect_file_and_export_findings(
1433 results: &AnalysisResults,
1434 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1435) {
1436 for f in &results.unused_files {
1437 push(&f.file.path, "unused-file", None);
1438 }
1439 for f in &results.unused_exports {
1440 push(
1441 &f.export.path,
1442 "unused-export",
1443 Some(f.export.export_name.clone()),
1444 );
1445 }
1446 for f in &results.unused_types {
1447 push(
1448 &f.export.path,
1449 "unused-type",
1450 Some(f.export.export_name.clone()),
1451 );
1452 }
1453 for f in &results.private_type_leaks {
1454 push(
1455 &f.leak.path,
1456 "private-type-leak",
1457 Some(format!(
1458 "{}{ID_SEP}{}",
1459 f.leak.export_name, f.leak.type_name
1460 )),
1461 );
1462 }
1463}
1464
1465fn collect_member_findings(
1467 results: &AnalysisResults,
1468 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1469) {
1470 for f in &results.unused_enum_members {
1471 push(
1472 &f.member.path,
1473 "unused-enum-member",
1474 Some(format!(
1475 "{}{ID_SEP}{}",
1476 f.member.parent_name, f.member.member_name
1477 )),
1478 );
1479 }
1480 for f in &results.unused_class_members {
1481 push(
1482 &f.member.path,
1483 "unused-class-member",
1484 Some(format!(
1485 "{}{ID_SEP}{}",
1486 f.member.parent_name, f.member.member_name
1487 )),
1488 );
1489 }
1490 for f in &results.unused_store_members {
1491 push(
1492 &f.member.path,
1493 "unused-store-member",
1494 Some(format!(
1495 "{}{ID_SEP}{}",
1496 f.member.parent_name, f.member.member_name
1497 )),
1498 );
1499 }
1500 for f in &results.unprovided_injects {
1501 push(
1502 &f.inject.path,
1503 "unprovided-inject",
1504 Some(f.inject.key_name.clone()),
1505 );
1506 }
1507}
1508
1509fn collect_component_findings(
1511 results: &AnalysisResults,
1512 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1513) {
1514 for f in &results.unrendered_components {
1515 push(
1516 &f.component.path,
1517 "unrendered-component",
1518 Some(f.component.component_name.clone()),
1519 );
1520 }
1521 for f in &results.unused_component_props {
1522 push(
1523 &f.prop.path,
1524 "unused-component-prop",
1525 Some(f.prop.prop_name.clone()),
1526 );
1527 }
1528 for f in &results.unused_component_emits {
1529 push(
1530 &f.emit.path,
1531 "unused-component-emit",
1532 Some(f.emit.emit_name.clone()),
1533 );
1534 }
1535 for f in &results.unused_component_inputs {
1536 push(
1537 &f.input.path,
1538 "unused-component-input",
1539 Some(f.input.input_name.clone()),
1540 );
1541 }
1542 for f in &results.unused_component_outputs {
1543 push(
1544 &f.output.path,
1545 "unused-component-output",
1546 Some(f.output.output_name.clone()),
1547 );
1548 }
1549}
1550
1551fn collect_import_boundary_findings(
1553 results: &AnalysisResults,
1554 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1555) {
1556 for f in &results.unresolved_imports {
1557 push(
1558 &f.import.path,
1559 "unresolved-import",
1560 Some(f.import.specifier.clone()),
1561 );
1562 }
1563 for f in &results.boundary_violations {
1564 let to_path = f.violation.to_path.to_string_lossy().replace('\\', "/");
1565 push(
1566 &f.violation.from_path,
1567 "boundary-violation",
1568 Some(format!("{to_path}{ID_SEP}{}", f.violation.import_specifier)),
1569 );
1570 }
1571}
1572
1573fn collect_dependency_findings(
1574 results: &AnalysisResults,
1575 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1576) {
1577 for f in &results.unused_dependencies {
1578 push(
1579 &f.dep.path,
1580 "unused-dependency",
1581 Some(f.dep.package_name.clone()),
1582 );
1583 }
1584 for f in &results.unused_dev_dependencies {
1585 push(
1586 &f.dep.path,
1587 "unused-dev-dependency",
1588 Some(f.dep.package_name.clone()),
1589 );
1590 }
1591 for f in &results.unused_optional_dependencies {
1592 push(
1593 &f.dep.path,
1594 "unused-optional-dependency",
1595 Some(f.dep.package_name.clone()),
1596 );
1597 }
1598 for f in &results.type_only_dependencies {
1599 push(
1600 &f.dep.path,
1601 "type-only-dependency",
1602 Some(f.dep.package_name.clone()),
1603 );
1604 }
1605 for f in &results.test_only_dependencies {
1606 push(
1607 &f.dep.path,
1608 "test-only-dependency",
1609 Some(f.dep.package_name.clone()),
1610 );
1611 }
1612}
1613
1614fn collect_catalog_findings(
1615 results: &AnalysisResults,
1616 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1617) {
1618 for f in &results.unused_catalog_entries {
1619 push(
1620 &f.entry.path,
1621 "unused-catalog-entry",
1622 Some(format!(
1623 "{}{ID_SEP}{}",
1624 f.entry.catalog_name, f.entry.entry_name
1625 )),
1626 );
1627 }
1628 for f in &results.empty_catalog_groups {
1629 push(
1630 &f.group.path,
1631 "empty-catalog-group",
1632 Some(f.group.catalog_name.clone()),
1633 );
1634 }
1635 for f in &results.unresolved_catalog_references {
1636 push(
1637 &f.reference.path,
1638 "unresolved-catalog-reference",
1639 Some(format!(
1640 "{}{ID_SEP}{}",
1641 f.reference.catalog_name, f.reference.entry_name
1642 )),
1643 );
1644 }
1645 for f in &results.unused_dependency_overrides {
1646 push(
1647 &f.entry.path,
1648 "unused-dependency-override",
1649 Some(f.entry.raw_key.clone()),
1650 );
1651 }
1652 for f in &results.misconfigured_dependency_overrides {
1653 push(
1654 &f.entry.path,
1655 "misconfigured-dependency-override",
1656 Some(f.entry.raw_key.clone()),
1657 );
1658 }
1659}
1660
1661#[must_use]
1665pub fn collect_complexity_findings(
1666 report: &crate::health_types::HealthReport,
1667) -> Vec<FindingInput> {
1668 report
1669 .findings
1670 .iter()
1671 .map(|f| FindingInput {
1672 path: f.path.clone(),
1673 kind: "complexity",
1674 symbol: Some(f.name.clone()),
1675 })
1676 .collect()
1677}
1678
1679#[must_use]
1683pub fn collect_clone_findings(
1684 report: &fallow_core::duplicates::DuplicationReport,
1685) -> Vec<CloneInput> {
1686 report
1687 .clone_groups
1688 .iter()
1689 .map(|g| CloneInput {
1690 fingerprint: fallow_core::duplicates::clone_fingerprint(&g.instances),
1691 instance_paths: g.instances.iter().map(|i| i.file.clone()).collect(),
1692 })
1693 .collect()
1694}
1695
1696const fn verdict_label(verdict: AuditVerdict) -> &'static str {
1697 match verdict {
1698 AuditVerdict::Pass => "pass",
1699 AuditVerdict::Warn => "warn",
1700 AuditVerdict::Fail => "fail",
1701 }
1702}
1703
1704#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1706#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1707#[serde(rename_all = "snake_case")]
1708pub enum ImpactTrendDirection {
1709 Improving,
1711 Declining,
1713 Stable,
1715}
1716
1717#[derive(Debug, Clone, Serialize)]
1719#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1720pub struct TrendSummary {
1721 pub direction: ImpactTrendDirection,
1722 pub total_delta: i64,
1724 pub previous_total: usize,
1725 pub current_total: usize,
1726}
1727
1728fn direction_for(delta: i64) -> ImpactTrendDirection {
1729 if delta < -TREND_TOLERANCE {
1730 ImpactTrendDirection::Improving
1731 } else if delta > TREND_TOLERANCE {
1732 ImpactTrendDirection::Declining
1733 } else {
1734 ImpactTrendDirection::Stable
1735 }
1736}
1737
1738#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1745#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1746pub enum ImpactReportSchemaVersion {
1747 #[serde(rename = "1")]
1749 V1,
1750}
1751
1752#[derive(Debug, Clone, Serialize)]
1754#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1755#[cfg_attr(feature = "schema", schemars(title = "fallow impact --format json"))]
1756pub struct ImpactReport {
1757 pub schema_version: ImpactReportSchemaVersion,
1761 pub enabled: bool,
1762 pub enabled_source: EnabledSource,
1769 pub record_count: usize,
1770 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
1771 pub meta: Option<Meta>,
1772 #[serde(default, skip_serializing_if = "Option::is_none")]
1773 pub first_recorded: Option<String>,
1774 #[serde(default, skip_serializing_if = "Option::is_none")]
1781 pub latest_git_sha: Option<String>,
1782 #[serde(default, skip_serializing_if = "Option::is_none")]
1787 pub surfacing: Option<ImpactCounts>,
1788 #[serde(default, skip_serializing_if = "Option::is_none")]
1790 pub trend: Option<TrendSummary>,
1791 #[serde(default, skip_serializing_if = "Option::is_none")]
1796 pub project_surfacing: Option<ImpactCounts>,
1797 #[serde(default, skip_serializing_if = "Option::is_none")]
1801 pub project_trend: Option<TrendSummary>,
1802 pub containment_count: usize,
1803 pub recent_containment: Vec<ContainmentEvent>,
1805 pub resolved_total: usize,
1808 pub suppressed_total: usize,
1811 pub recent_resolved: Vec<ResolutionEvent>,
1813 pub attribution_active: bool,
1817 pub onboarding_declined: bool,
1821 pub explicit_decision: bool,
1826}
1827
1828fn trend_for(records: &[ImpactRecord]) -> Option<TrendSummary> {
1834 if records.len() < 2 {
1835 return None;
1836 }
1837 let current = &records[records.len() - 1];
1838 let previous = &records[records.len() - 2];
1839 let current_total = current.counts.total_issues;
1840 let previous_total = previous.counts.total_issues;
1841 let total_delta = current_total as i64 - previous_total as i64;
1842 Some(TrendSummary {
1843 direction: direction_for(total_delta),
1844 total_delta,
1845 previous_total,
1846 current_total,
1847 })
1848}
1849
1850pub fn build_report(store: &ImpactStore) -> ImpactReport {
1851 let surfacing = store.records.last().map(|r| r.counts.clone());
1852 let trend = trend_for(&store.records);
1853 let project_surfacing = store.project_records.last().map(|r| r.counts.clone());
1854 let project_trend = trend_for(&store.project_records);
1855
1856 let recent_containment = store
1857 .containment
1858 .iter()
1859 .rev()
1860 .take(5)
1861 .rev()
1862 .cloned()
1863 .collect();
1864
1865 let latest_git_sha = store.records.last().and_then(|r| r.git_sha.clone());
1866
1867 let recent_resolved = store
1868 .recent_resolved
1869 .iter()
1870 .rev()
1871 .take(5)
1872 .rev()
1873 .cloned()
1874 .collect();
1875 let attribution_active = !store.frontier.is_empty()
1876 || !store.clone_frontier.is_empty()
1877 || store.resolved_total > 0
1878 || store.suppressed_total > 0;
1879
1880 let (enabled, enabled_source) = resolve_enabled(store);
1881 ImpactReport {
1882 schema_version: ImpactReportSchemaVersion::V1,
1883 enabled,
1884 enabled_source,
1885 record_count: store.records.len(),
1886 meta: None,
1887 first_recorded: store.first_recorded.clone(),
1888 latest_git_sha,
1889 surfacing,
1890 trend,
1891 project_surfacing,
1892 project_trend,
1893 containment_count: store.containment.len(),
1894 recent_containment,
1895 resolved_total: store.resolved_total,
1896 suppressed_total: store.suppressed_total,
1897 recent_resolved,
1898 attribution_active,
1899 onboarding_declined: store.onboarding_declined,
1900 explicit_decision: store.explicit_decision,
1901 }
1902}
1903
1904#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1910#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1911pub enum CrossRepoImpactSchemaVersion {
1912 #[serde(rename = "1")]
1914 V1,
1915}
1916
1917#[derive(Debug, Clone, Default, Serialize)]
1920#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1921pub struct CrossRepoTotals {
1922 pub resolved_total: usize,
1923 pub suppressed_total: usize,
1924 pub containment_count: usize,
1925 pub project_wide_issues: usize,
1929 pub projects_with_baseline: usize,
1930}
1931
1932#[derive(Debug, Clone, Serialize)]
1934#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1935pub struct CrossRepoProjectEntry {
1936 pub project_key: String,
1939 #[serde(default, skip_serializing_if = "Option::is_none")]
1942 pub label: Option<String>,
1943 #[serde(default, skip_serializing_if = "Option::is_none")]
1946 pub last_recorded: Option<String>,
1947 pub report: ImpactReport,
1950}
1951
1952#[derive(Debug, Clone, Serialize)]
1954#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1955#[cfg_attr(
1956 feature = "schema",
1957 schemars(title = "fallow impact --all --format json")
1958)]
1959pub struct CrossRepoImpactReport {
1960 pub schema_version: CrossRepoImpactSchemaVersion,
1961 pub project_count: usize,
1964 pub tracked_count: usize,
1967 pub unreadable_count: usize,
1969 pub totals: CrossRepoTotals,
1970 pub projects: Vec<CrossRepoProjectEntry>,
1971}
1972
1973#[derive(Debug, Clone, Copy)]
1975pub enum CrossRepoSort {
1976 Recent,
1978 Resolved,
1980 Contained,
1982 Name,
1984}
1985
1986fn latest_activity(store: &ImpactStore) -> Option<String> {
1988 let a = store.records.last().map(|r| r.timestamp.clone());
1989 let b = store.project_records.last().map(|r| r.timestamp.clone());
1990 match (a, b) {
1991 (Some(x), Some(y)) => Some(if x >= y { x } else { y }),
1992 (x, y) => x.or(y),
1993 }
1994}
1995
1996#[must_use]
2002pub fn load_all() -> (Vec<(String, ImpactStore)>, usize) {
2003 let Some(dir) = impact_config_dir().map(|d| d.join("impact")) else {
2004 return (Vec::new(), 0);
2005 };
2006 let Ok(read) = std::fs::read_dir(&dir) else {
2007 return (Vec::new(), 0);
2008 };
2009 let mut stores = Vec::new();
2010 let mut unreadable = 0usize;
2011 for entry in read.flatten() {
2012 let path = entry.path();
2013 if path.extension().and_then(|e| e.to_str()) != Some("json") {
2014 continue;
2015 }
2016 let Some(key) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
2017 continue;
2018 };
2019 match std::fs::read_to_string(&path)
2020 .ok()
2021 .and_then(|c| serde_json::from_str::<ImpactStore>(&c).ok())
2022 {
2023 Some(store) => stores.push((key, store)),
2024 None => unreadable += 1,
2025 }
2026 }
2027 (stores, unreadable)
2028}
2029
2030#[must_use]
2034pub fn build_aggregate_report(
2035 stores: Vec<(String, ImpactStore)>,
2036 unreadable: usize,
2037 sort: CrossRepoSort,
2038) -> CrossRepoImpactReport {
2039 let project_count = stores.len();
2040 let mut totals = CrossRepoTotals::default();
2041 let mut projects = Vec::new();
2042 for (key, store) in stores {
2043 let report = build_report(&store);
2044 let has_history = report.record_count > 0
2045 || report.project_surfacing.is_some()
2046 || report.resolved_total > 0
2047 || report.containment_count > 0;
2048 if !has_history {
2049 continue;
2050 }
2051 totals.resolved_total += report.resolved_total;
2052 totals.suppressed_total += report.suppressed_total;
2053 totals.containment_count += report.containment_count;
2054 if let Some(ps) = &report.project_surfacing {
2055 totals.project_wide_issues += ps.total_issues;
2056 totals.projects_with_baseline += 1;
2057 }
2058 projects.push(CrossRepoProjectEntry {
2059 project_key: key,
2060 label: store.label.clone(),
2061 last_recorded: latest_activity(&store),
2062 report,
2063 });
2064 }
2065 sort_cross_repo(&mut projects, sort);
2066 CrossRepoImpactReport {
2067 schema_version: CrossRepoImpactSchemaVersion::V1,
2068 project_count,
2069 tracked_count: projects.len(),
2070 unreadable_count: unreadable,
2071 totals,
2072 projects,
2073 }
2074}
2075
2076fn sort_cross_repo(projects: &mut [CrossRepoProjectEntry], sort: CrossRepoSort) {
2077 match sort {
2078 CrossRepoSort::Recent => projects.sort_by(|a, b| {
2081 b.last_recorded
2082 .cmp(&a.last_recorded)
2083 .then_with(|| a.project_key.cmp(&b.project_key))
2084 }),
2085 CrossRepoSort::Resolved => projects.sort_by(|a, b| {
2086 b.report
2087 .resolved_total
2088 .cmp(&a.report.resolved_total)
2089 .then_with(|| a.project_key.cmp(&b.project_key))
2090 }),
2091 CrossRepoSort::Contained => projects.sort_by(|a, b| {
2092 b.report
2093 .containment_count
2094 .cmp(&a.report.containment_count)
2095 .then_with(|| a.project_key.cmp(&b.project_key))
2096 }),
2097 CrossRepoSort::Name => projects.sort_by(|a, b| {
2098 cross_repo_label(a)
2099 .cmp(&cross_repo_label(b))
2100 .then_with(|| a.project_key.cmp(&b.project_key))
2101 }),
2102 }
2103}
2104
2105fn cross_repo_label(entry: &CrossRepoProjectEntry) -> String {
2108 entry
2109 .label
2110 .clone()
2111 .unwrap_or_else(|| short_key(&entry.project_key))
2112}
2113
2114fn short_key(key: &str) -> String {
2116 key.chars().take(12).collect()
2117}
2118
2119#[must_use]
2121pub fn aggregate(sort: CrossRepoSort) -> CrossRepoImpactReport {
2122 let (stores, unreadable) = load_all();
2123 build_aggregate_report(stores, unreadable, sort)
2124}
2125
2126#[expect(
2132 clippy::format_push_string,
2133 reason = "small report renderer; readability over avoiding the extra allocation"
2134)]
2135fn render_project_section(out: &mut String, report: &ImpactReport) {
2136 let Some(s) = &report.project_surfacing else {
2137 return;
2138 };
2139 out.push_str(&format!(
2140 " WHOLE PROJECT (whole-repo context, not a to-do)\n {} issue{} across the whole project at your last full `fallow` run\n",
2141 s.total_issues,
2142 plural(s.total_issues),
2143 ));
2144 if let Some(t) = &report.project_trend {
2145 let arrow = trend_arrow(t.direction);
2146 out.push_str(&format!(
2147 " {} -> {} ({}) across your last two full runs (comparable over time)\n",
2148 t.previous_total, t.current_total, arrow,
2149 ));
2150 } else {
2151 out.push_str(" project trend starts after your next full `fallow` run\n");
2152 }
2153 out.push_str(" advances only on your local full `fallow` runs, not CI\n\n");
2154}
2155
2156#[expect(
2158 clippy::format_push_string,
2159 reason = "small report renderer; readability over avoiding the extra allocation"
2160)]
2161pub fn render_human(report: &ImpactReport) -> String {
2162 let mut out = String::new();
2163 out.push_str("FALLOW IMPACT\n\n");
2164
2165 if !report.enabled {
2166 out.push_str(
2167 "Impact tracking is off. Enable it with `fallow impact enable`, then\n\
2168 let your pre-commit gate run a few times to build history.\n",
2169 );
2170 return out;
2171 }
2172
2173 if report.enabled_source == EnabledSource::User {
2174 out.push_str(
2175 "Enabled by your user-global default (`fallow impact default on`). Run\n\
2176 `fallow impact disable` to opt this project out.\n\n",
2177 );
2178 }
2179
2180 if report.record_count == 0 && report.project_surfacing.is_none() {
2181 out.push_str(
2182 "Tracking enabled. No history yet: check back after your next few\n\
2183 commits (Impact records each `fallow audit` / pre-commit gate run,\n\
2184 and each full `fallow` run for the whole-project view).\n",
2185 );
2186 return out;
2187 }
2188
2189 render_human_changed_section(&mut out, report);
2190
2191 render_project_section(&mut out, report);
2192
2193 out.push_str(&format!(
2194 " CONTAINED AT COMMIT\n {} time{} fallow blocked a commit until it was fixed\n",
2195 report.containment_count,
2196 plural(report.containment_count),
2197 ));
2198
2199 render_human_resolved_section(&mut out, report);
2200
2201 render_human_footer(&mut out, report);
2202 out
2203}
2204
2205#[expect(
2207 clippy::format_push_string,
2208 reason = "small report renderer; readability over avoiding the extra allocation"
2209)]
2210fn render_human_changed_section(out: &mut String, report: &ImpactReport) {
2211 if let Some(s) = &report.surfacing {
2212 out.push_str(&format!(
2213 " LATEST RUN (changed files, act on these now)\n {} issue{} flagged in your last `fallow audit` run\n",
2214 s.total_issues,
2215 plural(s.total_issues),
2216 ));
2217 out.push_str(&format!(
2218 " dead code {} · complexity {} · duplication {}\n\n",
2219 s.dead_code, s.complexity, s.duplication,
2220 ));
2221 }
2222
2223 if let Some(t) = &report.trend {
2224 let arrow = trend_arrow(t.direction);
2225 out.push_str(&format!(
2226 " 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",
2227 t.previous_total, t.current_total, arrow,
2228 ));
2229 }
2230}
2231
2232#[expect(
2234 clippy::format_push_string,
2235 reason = "small report renderer; readability over avoiding the extra allocation"
2236)]
2237fn render_human_resolved_section(out: &mut String, report: &ImpactReport) {
2238 if report.resolved_total > 0 {
2239 out.push_str(&format!(
2240 "\n RESOLVED\n {} finding{} you cleared since fallow started tracking\n",
2241 report.resolved_total,
2242 plural(report.resolved_total),
2243 ));
2244 for ev in &report.recent_resolved {
2245 match &ev.symbol {
2246 Some(symbol) => {
2247 out.push_str(&format!(" {} {} in {}\n", ev.kind, symbol, ev.path));
2248 }
2249 None => out.push_str(&format!(" {} in {}\n", ev.kind, ev.path)),
2250 }
2251 }
2252 } else if report.attribution_active {
2253 out.push_str(
2254 "\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",
2255 );
2256 } else {
2257 out.push_str("\n RESOLVED\n resolution tracking starts from your next gate run\n");
2258 }
2259
2260 if report.suppressed_total > 0 {
2261 out.push_str(&format!(
2262 " {} finding{} you marked intentional (fallow-ignore), not counted as resolved\n",
2263 report.suppressed_total,
2264 plural(report.suppressed_total),
2265 ));
2266 }
2267}
2268
2269#[expect(
2271 clippy::format_push_string,
2272 reason = "small report renderer; readability over avoiding the extra allocation"
2273)]
2274fn render_human_footer(out: &mut String, report: &ImpactReport) {
2275 out.push('\n');
2276 let since = report
2277 .first_recorded
2278 .as_deref()
2279 .map_or("the first run", date_only);
2280 if report.record_count > 0 {
2281 out.push_str(&format!(
2282 "Based on {} recorded audit run{} since {}. Local-only; never uploaded.\n\
2283 Changed-file scope: each audit run only sees files differing from your base.\n",
2284 report.record_count,
2285 plural(report.record_count),
2286 since,
2287 ));
2288 } else {
2289 out.push_str(&format!(
2290 "Tracking since {since}. Local-only; never uploaded.\n",
2291 ));
2292 }
2293 out.push_str(
2294 "Resolution tracking is a local-developer signal: it accrues on your\n\
2295 machine across runs, not in CI (fallow never records there).\n",
2296 );
2297}
2298
2299pub fn render_json(report: &ImpactReport) -> String {
2301 let value = crate::output_envelope::serialize_root_output(
2302 crate::output_envelope::FallowOutput::Impact(report.clone()),
2303 )
2304 .unwrap_or_else(|_| serde_json::json!({"error":"failed to serialize impact report"}));
2305 serde_json::to_string_pretty(&value)
2306 .unwrap_or_else(|_| "{\"error\":\"failed to serialize impact report\"}".to_owned())
2307}
2308
2309#[expect(
2313 clippy::format_push_string,
2314 reason = "small report renderer; readability over avoiding the extra allocation"
2315)]
2316fn render_project_markdown(out: &mut String, report: &ImpactReport) {
2317 let Some(s) = &report.project_surfacing else {
2318 return;
2319 };
2320 out.push_str(&format!(
2321 "- **Whole project (whole-repo context, last full `fallow` run):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
2322 s.total_issues,
2323 plural(s.total_issues),
2324 s.dead_code,
2325 s.complexity,
2326 s.duplication,
2327 ));
2328 if let Some(t) = &report.project_trend {
2329 let arrow = trend_arrow(t.direction);
2330 out.push_str(&format!(
2331 "- **Project trend (whole project, last two full runs):** {} -> {} ({})\n",
2332 t.previous_total, t.current_total, arrow,
2333 ));
2334 }
2335}
2336
2337#[expect(
2339 clippy::format_push_string,
2340 reason = "small report renderer; readability over avoiding the extra allocation"
2341)]
2342pub fn render_markdown(report: &ImpactReport) -> String {
2343 let mut out = String::new();
2344 out.push_str("## Fallow impact\n\n");
2345
2346 if !report.enabled {
2347 out.push_str("Impact tracking is off. Run `fallow impact enable` to start.\n");
2348 return out;
2349 }
2350 if report.record_count == 0 && report.project_surfacing.is_none() {
2351 out.push_str("Tracking enabled. No history yet; check back after a few commits.\n");
2352 return out;
2353 }
2354
2355 if let Some(s) = &report.surfacing {
2356 out.push_str(&format!(
2357 "- **Latest run (changed files):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
2358 s.total_issues,
2359 plural(s.total_issues),
2360 s.dead_code,
2361 s.complexity,
2362 s.duplication,
2363 ));
2364 }
2365 if let Some(t) = &report.trend {
2366 out.push_str(&format!(
2367 "- **Trend (changed-file scope, last two runs):** {} -> {} ({})\n",
2368 t.previous_total,
2369 t.current_total,
2370 trend_arrow(t.direction),
2371 ));
2372 }
2373 render_project_markdown(&mut out, report);
2374 out.push_str(&format!(
2375 "- **Contained at commit:** {} time{}\n",
2376 report.containment_count,
2377 plural(report.containment_count),
2378 ));
2379 render_markdown_resolved_section(&mut out, report);
2380 render_markdown_footer(&mut out, report);
2381 out
2382}
2383
2384#[expect(
2386 clippy::format_push_string,
2387 reason = "small report renderer; readability over avoiding the extra allocation"
2388)]
2389fn render_markdown_resolved_section(out: &mut String, report: &ImpactReport) {
2390 if report.resolved_total > 0 {
2391 out.push_str(&format!(
2392 "- **Resolved:** {} finding{} cleared since tracking started\n",
2393 report.resolved_total,
2394 plural(report.resolved_total),
2395 ));
2396 } else if report.attribution_active {
2397 out.push_str("- **Resolved:** none yet; tracking active\n");
2398 } else {
2399 out.push_str("- **Resolved:** resolution tracking starts from your next gate run\n");
2400 }
2401 if report.suppressed_total > 0 {
2402 out.push_str(&format!(
2403 "- **Marked intentional:** {} finding{} (`fallow-ignore`), not counted as resolved\n",
2404 report.suppressed_total,
2405 plural(report.suppressed_total),
2406 ));
2407 }
2408}
2409
2410#[expect(
2412 clippy::format_push_string,
2413 reason = "small report renderer; readability over avoiding the extra allocation"
2414)]
2415fn render_markdown_footer(out: &mut String, report: &ImpactReport) {
2416 let since = report
2417 .first_recorded
2418 .as_deref()
2419 .map_or("the first run", date_only);
2420 if report.record_count > 0 {
2421 out.push_str(&format!(
2422 "\n_Based on {} recorded audit run{} since {}. Local-only; resolution is a local-developer signal._\n",
2423 report.record_count,
2424 plural(report.record_count),
2425 since,
2426 ));
2427 } else {
2428 out.push_str(&format!(
2429 "\n_Tracking since {since}. Local-only; resolution is a local-developer signal._\n",
2430 ));
2431 }
2432}
2433
2434#[must_use]
2436pub fn render_cross_repo_json(report: &CrossRepoImpactReport) -> String {
2437 let value = crate::output_envelope::serialize_root_output(
2438 crate::output_envelope::FallowOutput::ImpactCrossRepo(report.clone()),
2439 )
2440 .unwrap_or_else(
2441 |_| serde_json::json!({"error":"failed to serialize cross-repo impact report"}),
2442 );
2443 serde_json::to_string_pretty(&value).unwrap_or_else(|_| {
2444 "{\"error\":\"failed to serialize cross-repo impact report\"}".to_owned()
2445 })
2446}
2447
2448fn row_label(entry: &CrossRepoProjectEntry) -> String {
2451 cross_repo_label(entry)
2452}
2453
2454fn opt_count(c: Option<&ImpactCounts>) -> String {
2455 c.map_or_else(|| "-".to_owned(), |c| c.total_issues.to_string())
2456}
2457
2458fn row_trend(report: &ImpactReport) -> &'static str {
2459 report
2460 .project_trend
2461 .as_ref()
2462 .or(report.trend.as_ref())
2463 .map_or("-", |t| trend_arrow(t.direction))
2464}
2465
2466#[expect(
2470 clippy::format_push_string,
2471 reason = "small report renderer; readability over avoiding the extra allocation"
2472)]
2473#[must_use]
2474pub fn render_cross_repo_human(report: &CrossRepoImpactReport, limit: Option<usize>) -> String {
2475 let mut out = String::new();
2476 out.push_str("FALLOW IMPACT (ALL PROJECTS)\n\n");
2477
2478 if report.project_count == 0 {
2479 if report.unreadable_count > 0 {
2480 out.push_str(&format!(
2481 "No readable projects: skipped {} unreadable store{} (corrupt, or written by \
2482 a newer fallow). Upgrade fallow to read them.\n",
2483 report.unreadable_count,
2484 plural(report.unreadable_count),
2485 ));
2486 } else {
2487 out.push_str(
2488 "No projects tracked yet. Enable in a repo with `fallow impact enable`, or for \
2489 every project with `fallow impact default on`.\n",
2490 );
2491 }
2492 return out;
2493 }
2494
2495 out.push_str(&format!(
2496 "{} project{} tracked, {} with history\n\n",
2497 report.project_count,
2498 plural(report.project_count),
2499 report.tracked_count,
2500 ));
2501
2502 render_cross_repo_table(&mut out, report, limit);
2503 render_cross_repo_skipped(&mut out, report);
2504 render_cross_repo_totals(&mut out, report);
2505 out.push_str("\nLocal-only; never uploaded; accrues on this machine, not CI.\n");
2506 out
2507}
2508
2509#[expect(
2511 clippy::format_push_string,
2512 reason = "small report renderer; readability over avoiding the extra allocation"
2513)]
2514fn render_cross_repo_table(out: &mut String, report: &CrossRepoImpactReport, limit: Option<usize>) {
2515 if report.projects.is_empty() {
2516 return;
2517 }
2518 out.push_str(&format!(
2519 "{:<24}{:>8}{:>10}{:>11}{:>10}{:>7} {}\n",
2520 "PROJECT", "LATEST", "REPO-WIDE", "CONTAINED", "RESOLVED", "TREND", "LAST RUN",
2521 ));
2522 let rows = limit.map_or(report.projects.len(), |n| n.min(report.projects.len()));
2523 for entry in report.projects.iter().take(rows) {
2524 let mut label = row_label(entry);
2525 if label.chars().count() > 22 {
2526 label = format!("{}...", label.chars().take(19).collect::<String>());
2527 }
2528 let last = entry
2529 .last_recorded
2530 .as_deref()
2531 .map_or("-", date_only)
2532 .to_owned();
2533 out.push_str(&format!(
2534 "{:<24}{:>8}{:>10}{:>11}{:>10}{:>7} {}\n",
2535 label,
2536 opt_count(entry.report.surfacing.as_ref()),
2537 opt_count(entry.report.project_surfacing.as_ref()),
2538 entry.report.containment_count,
2539 entry.report.resolved_total,
2540 row_trend(&entry.report),
2541 last,
2542 ));
2543 }
2544 if let Some(n) = limit
2545 && report.projects.len() > n
2546 {
2547 out.push_str(&format!(
2548 " ... and {} more (raise --limit to show)\n",
2549 report.projects.len() - n,
2550 ));
2551 }
2552}
2553
2554#[expect(
2556 clippy::format_push_string,
2557 reason = "small report renderer; readability over avoiding the extra allocation"
2558)]
2559fn render_cross_repo_skipped(out: &mut String, report: &CrossRepoImpactReport) {
2560 let no_history = report.project_count.saturating_sub(report.tracked_count);
2561 if no_history > 0 {
2562 out.push_str(&format!(
2563 "\n{no_history} tracked project{} with no history yet\n",
2564 plural(no_history),
2565 ));
2566 }
2567 if report.unreadable_count > 0 {
2568 out.push_str(&format!(
2569 "skipped {} unreadable store{}\n",
2570 report.unreadable_count,
2571 plural(report.unreadable_count),
2572 ));
2573 }
2574}
2575
2576#[expect(
2578 clippy::format_push_string,
2579 reason = "small report renderer; readability over avoiding the extra allocation"
2580)]
2581fn render_cross_repo_totals(out: &mut String, report: &CrossRepoImpactReport) {
2582 let t = &report.totals;
2583 out.push_str("\nGRAND TOTALS\n");
2584 out.push_str(&format!(
2585 " Across {} tracked project{}: {} finding{} resolved, {} commit{} contained, {} marked intentional\n",
2586 report.tracked_count,
2587 plural(report.tracked_count),
2588 t.resolved_total,
2589 plural(t.resolved_total),
2590 t.containment_count,
2591 plural(t.containment_count),
2592 t.suppressed_total,
2593 ));
2594 if t.projects_with_baseline > 0 {
2595 out.push_str(&format!(
2596 " {} issue{} project-wide across {} project{} with a full-run baseline (as of each project's last full run)\n",
2597 t.project_wide_issues,
2598 plural(t.project_wide_issues),
2599 t.projects_with_baseline,
2600 plural(t.projects_with_baseline),
2601 ));
2602 }
2603}
2604
2605#[expect(
2607 clippy::format_push_string,
2608 reason = "small report renderer; readability over avoiding the extra allocation"
2609)]
2610#[must_use]
2611pub fn render_cross_repo_markdown(report: &CrossRepoImpactReport) -> String {
2612 let mut out = String::new();
2613 out.push_str("## Fallow impact (all projects)\n\n");
2614 if report.project_count == 0 {
2615 if report.unreadable_count > 0 {
2616 out.push_str(&format!(
2617 "No readable projects: skipped {} unreadable store{}.\n",
2618 report.unreadable_count,
2619 plural(report.unreadable_count),
2620 ));
2621 } else {
2622 out.push_str("No projects tracked yet.\n");
2623 }
2624 return out;
2625 }
2626 out.push_str(&format!(
2627 "{} project{} tracked, {} with history.\n\n",
2628 report.project_count,
2629 plural(report.project_count),
2630 report.tracked_count,
2631 ));
2632 if !report.projects.is_empty() {
2633 out.push_str("| Project | Latest | Repo-wide | Contained | Resolved | Last run |\n");
2634 out.push_str("|:--------|-------:|----------:|----------:|---------:|:---------|\n");
2635 for entry in &report.projects {
2636 out.push_str(&format!(
2637 "| {} | {} | {} | {} | {} | {} |\n",
2638 row_label(entry),
2639 opt_count(entry.report.surfacing.as_ref()),
2640 opt_count(entry.report.project_surfacing.as_ref()),
2641 entry.report.containment_count,
2642 entry.report.resolved_total,
2643 entry.last_recorded.as_deref().map_or("-", date_only),
2644 ));
2645 }
2646 }
2647 let t = &report.totals;
2648 out.push_str(&format!(
2649 "\n**Grand totals:** {} resolved, {} contained, {} marked intentional across {} tracked project{}",
2650 t.resolved_total,
2651 t.containment_count,
2652 t.suppressed_total,
2653 report.tracked_count,
2654 plural(report.tracked_count),
2655 ));
2656 if t.projects_with_baseline > 0 {
2657 out.push_str(&format!(
2658 "; {} issue{} project-wide across {} project{} with a full-run baseline (as of each project's last full run)",
2659 t.project_wide_issues,
2660 plural(t.project_wide_issues),
2661 t.projects_with_baseline,
2662 plural(t.projects_with_baseline),
2663 ));
2664 }
2665 out.push_str(".\n\n_Local-only; never uploaded; accrues on this machine, not CI._\n");
2666 out
2667}
2668
2669const fn plural(n: usize) -> &'static str {
2670 if n == 1 { "" } else { "s" }
2671}
2672
2673fn date_only(ts: &str) -> &str {
2679 ts.split_once('T').map_or(ts, |(date, _)| date)
2680}
2681
2682const fn trend_arrow(direction: ImpactTrendDirection) -> &'static str {
2686 match direction {
2687 ImpactTrendDirection::Improving => "down",
2688 ImpactTrendDirection::Declining => "up",
2689 ImpactTrendDirection::Stable => "flat",
2690 }
2691}
2692
2693#[cfg(test)]
2694mod tests {
2695 use super::*;
2696
2697 fn test_env() -> (tempfile::TempDir, tempfile::TempDir) {
2703 let config = tempfile::tempdir().unwrap();
2704 TEST_CONFIG_DIR.with(|c| *c.borrow_mut() = Some(config.path().to_path_buf()));
2705 let root = tempfile::tempdir().unwrap();
2706 (config, root)
2707 }
2708
2709 fn frontier_paths(store: &ImpactStore) -> FxHashSet<String> {
2712 store
2713 .frontier
2714 .values()
2715 .flat_map(|m| m.keys().cloned())
2716 .collect()
2717 }
2718
2719 fn clone_fingerprints(store: &ImpactStore) -> FxHashSet<String> {
2721 store
2722 .clone_frontier
2723 .values()
2724 .flat_map(|m| m.keys().cloned())
2725 .collect()
2726 }
2727
2728 fn seed_store_raw(root: &Path, bytes: &[u8]) {
2731 let path = store_path(root).expect("test config dir set");
2732 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2733 std::fs::write(&path, bytes).unwrap();
2734 }
2735
2736 fn summary(dead: usize, complexity: usize, dupes: usize) -> AuditSummary {
2737 AuditSummary {
2738 dead_code_issues: dead,
2739 dead_code_has_errors: dead > 0,
2740 complexity_findings: complexity,
2741 max_cyclomatic: None,
2742 duplication_clone_groups: dupes,
2743 }
2744 }
2745
2746 #[expect(
2748 clippy::too_many_arguments,
2749 reason = "test scaffold; positional record builder mirrors the AuditRunRecord fields, bundling adds churn with no production value"
2750 )]
2751 fn record_v1(
2752 root: &Path,
2753 summary: &AuditSummary,
2754 verdict: AuditVerdict,
2755 gate: bool,
2756 git_sha: Option<&str>,
2757 version: &str,
2758 timestamp: &str,
2759 ) {
2760 record_audit_run(
2761 root,
2762 summary,
2763 &AuditRunRecord {
2764 verdict,
2765 gate,
2766 git_sha,
2767 version,
2768 timestamp,
2769 attribution: None,
2770 },
2771 );
2772 }
2773
2774 fn touch(root: &Path, rel: &str) -> PathBuf {
2777 let p = root.join(rel);
2778 if let Some(parent) = p.parent() {
2779 std::fs::create_dir_all(parent).unwrap();
2780 }
2781 std::fs::write(&p, b"x").unwrap();
2782 p
2783 }
2784
2785 fn fi(path: &Path, kind: &'static str, symbol: &str) -> FindingInput {
2786 FindingInput {
2787 path: path.to_path_buf(),
2788 kind,
2789 symbol: Some(symbol.to_owned()),
2790 }
2791 }
2792
2793 fn supp(path: &Path, kind: &str) -> ActiveSuppression {
2794 ActiveSuppression {
2795 path: path.to_path_buf(),
2796 kind: Some(kind.to_owned()),
2797 is_file_level: false,
2798 reason: None,
2799 }
2800 }
2801
2802 fn run(
2804 root: &Path,
2805 changed: &[&Path],
2806 findings: Vec<FindingInput>,
2807 clones: Vec<CloneInput>,
2808 supps: &[ActiveSuppression],
2809 ts: &str,
2810 ) {
2811 let changed_files: Vec<PathBuf> = changed.iter().map(|p| p.to_path_buf()).collect();
2812 let input = AttributionInput {
2813 root,
2814 scope: Scope::ChangedFiles(&changed_files),
2815 findings,
2816 clones,
2817 suppressions: supps,
2818 };
2819 record_audit_run(
2820 root,
2821 &summary(0, 0, 0),
2822 &AuditRunRecord {
2823 verdict: AuditVerdict::Pass,
2824 gate: true,
2825 git_sha: Some("sha"),
2826 version: "2.0.0",
2827 timestamp: ts,
2828 attribution: Some(&input),
2829 },
2830 );
2831 }
2832
2833 #[test]
2834 fn disabled_store_does_not_record() {
2835 let (_config, dir) = test_env();
2836 let root = dir.path();
2837 record_v1(
2838 root,
2839 &summary(3, 1, 0),
2840 AuditVerdict::Fail,
2841 true,
2842 Some("abc1234"),
2843 "2.0.0",
2844 "2026-05-29T10:00:00Z",
2845 );
2846 let store = load(root);
2847 assert!(store.records.is_empty());
2848 assert!(!store.enabled);
2849 }
2850
2851 #[test]
2852 fn enable_and_disable_record_the_explicit_decision() {
2853 let (_config, dir) = test_env();
2854 let root = dir.path();
2855 assert!(!load(root).explicit_decision, "fresh store: never asked");
2856
2857 disable(root);
2859 let store = load(root);
2860 assert!(!store.enabled);
2861 assert!(store.explicit_decision);
2862 assert!(build_report(&store).explicit_decision);
2863 }
2864
2865 #[test]
2866 fn due_digest_stamps_and_respects_interval_and_gates() {
2867 let (_config, dir) = test_env();
2868 let root = dir.path();
2869
2870 assert!(take_due_digest(root).is_none());
2872 enable(root);
2873 assert!(take_due_digest(root).is_none(), "zero counters never nag");
2874
2875 let mut store = load(root);
2876 store.resolved_total = 3;
2877 store.containment.push(ContainmentEvent {
2878 blocked_at: "2026-06-11T00:00:00Z".to_string(),
2879 cleared_at: "2026-06-11T00:05:00Z".to_string(),
2880 git_sha: None,
2881 blocked_counts: ImpactCounts::default(),
2882 });
2883 save(&store, root);
2884
2885 let digest = take_due_digest(root).expect("first digest is due");
2886 assert_eq!(digest.containment_count, 1);
2887 assert_eq!(digest.resolved_total, 3);
2888 assert!(
2889 take_due_digest(root).is_none(),
2890 "stamped: not due again within the interval"
2891 );
2892
2893 let mut store = load(root);
2895 store.last_digest_epoch = Some(0);
2896 save(&store, root);
2897 assert!(take_due_digest(root).is_some());
2898 }
2899
2900 #[test]
2901 fn decline_onboarding_persists_in_existing_store() {
2902 let (_config, dir) = test_env();
2903 let root = dir.path();
2904
2905 assert!(decline_onboarding(root));
2906 assert!(!decline_onboarding(root));
2907
2908 let store = load(root);
2909 assert!(store.onboarding_declined);
2910 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
2911 assert!(!root.join(".gitignore").exists());
2913 let report = build_report(&store);
2914 assert!(report.onboarding_declined);
2915 }
2916
2917 #[test]
2918 fn enable_then_record_accrues_history() {
2919 let (_config, dir) = test_env();
2920 let root = dir.path();
2921 assert!(enable(root));
2922 assert!(!enable(root)); record_v1(
2924 root,
2925 &summary(2, 1, 0),
2926 AuditVerdict::Warn,
2927 false,
2928 None,
2929 "2.0.0",
2930 "2026-05-29T10:00:00Z",
2931 );
2932 let store = load(root);
2933 assert_eq!(store.records.len(), 1);
2934 assert_eq!(store.records[0].counts.total_issues, 3);
2935 assert_eq!(
2936 store.first_recorded.as_deref(),
2937 Some("2026-05-29T10:00:00Z")
2938 );
2939 }
2940
2941 #[test]
2942 fn record_is_a_noop_in_ci() {
2943 let (_config, dir) = test_env();
2949 let root = dir.path();
2950 assert!(enable(root));
2951 TEST_FORCE_CI.with(|c| c.set(true));
2952 record_v1(
2953 root,
2954 &summary(2, 1, 0),
2955 AuditVerdict::Warn,
2956 false,
2957 None,
2958 "2.0.0",
2959 "2026-05-29T10:00:00Z",
2960 );
2961 TEST_FORCE_CI.with(|c| c.set(false));
2962 let store = load(root);
2963 assert_eq!(store.records.len(), 0, "impact must not record while in CI");
2964 }
2965
2966 #[test]
2967 fn enable_writes_nothing_into_the_repo() {
2968 let (_config, dir) = test_env();
2969 let root = dir.path();
2970 enable(root);
2971 assert!(
2974 !root.join(".gitignore").exists(),
2975 "enable must not create or modify the repo's .gitignore"
2976 );
2977 assert!(
2978 !root.join(".fallow").exists(),
2979 "enable must not create an in-repo .fallow/ dir"
2980 );
2981 let store = load(root);
2983 assert!(store.enabled);
2984 assert!(store.explicit_decision);
2985 assert!(resolve_enabled(&store).0);
2986 }
2987
2988 #[test]
2989 fn single_record_yields_no_trend_no_spike() {
2990 let mut store = ImpactStore {
2991 enabled: true,
2992 ..Default::default()
2993 };
2994 store.records.push(ImpactRecord {
2995 timestamp: "t0".into(),
2996 version: "2.0.0".into(),
2997 git_sha: None,
2998 verdict: "warn".into(),
2999 gate: false,
3000 counts: ImpactCounts {
3001 total_issues: 5,
3002 dead_code: 5,
3003 complexity: 0,
3004 duplication: 0,
3005 },
3006 });
3007 let report = build_report(&store);
3008 assert!(report.trend.is_none());
3009 assert_eq!(report.surfacing.unwrap().total_issues, 5);
3010 }
3011
3012 #[test]
3013 fn empty_store_report_is_first_run() {
3014 let store = ImpactStore::default();
3015 let report = build_report(&store);
3016 assert_eq!(report.record_count, 0);
3017 assert!(report.trend.is_none());
3018 assert!(report.surfacing.is_none());
3019 let human = render_human(&report);
3020 assert!(human.contains("off")); }
3022
3023 #[test]
3024 fn enabled_empty_store_shows_check_back() {
3025 let store = ImpactStore {
3026 enabled: true,
3027 ..Default::default()
3028 };
3029 let report = build_report(&store);
3030 let human = render_human(&report);
3031 assert!(human.contains("No history yet"));
3032 assert!(!human.contains("0 issues"));
3033 }
3034
3035 #[test]
3036 fn trend_improving_when_issues_drop() {
3037 let mut store = ImpactStore {
3038 enabled: true,
3039 ..Default::default()
3040 };
3041 for total in [8usize, 3usize] {
3042 store.records.push(ImpactRecord {
3043 timestamp: format!("t{total}"),
3044 version: "2.0.0".into(),
3045 git_sha: None,
3046 verdict: "warn".into(),
3047 gate: false,
3048 counts: ImpactCounts {
3049 total_issues: total,
3050 dead_code: total,
3051 complexity: 0,
3052 duplication: 0,
3053 },
3054 });
3055 }
3056 let report = build_report(&store);
3057 let trend = report.trend.unwrap();
3058 assert_eq!(trend.direction, ImpactTrendDirection::Improving);
3059 assert_eq!(trend.total_delta, -5);
3060 }
3061
3062 #[test]
3063 fn containment_blocked_then_cleared_records_one_event() {
3064 let (_config, dir) = test_env();
3065 let root = dir.path();
3066 enable(root);
3067 record_v1(
3068 root,
3069 &summary(2, 0, 0),
3070 AuditVerdict::Fail,
3071 true,
3072 Some("sha1"),
3073 "2.0.0",
3074 "t0",
3075 );
3076 let store = load(root);
3077 assert!(store.pending_containment.is_some());
3078 assert!(store.containment.is_empty());
3079
3080 record_v1(
3081 root,
3082 &summary(0, 0, 0),
3083 AuditVerdict::Pass,
3084 true,
3085 Some("sha2"),
3086 "2.0.0",
3087 "t1",
3088 );
3089 let store = load(root);
3090 assert!(store.pending_containment.is_none());
3091 assert_eq!(store.containment.len(), 1);
3092 assert_eq!(store.containment[0].blocked_at, "t0");
3093 assert_eq!(store.containment[0].cleared_at, "t1");
3094 }
3095
3096 #[test]
3097 fn non_gate_run_never_creates_containment() {
3098 let (_config, dir) = test_env();
3099 let root = dir.path();
3100 enable(root);
3101 record_v1(
3102 root,
3103 &summary(2, 0, 0),
3104 AuditVerdict::Fail,
3105 false,
3106 None,
3107 "2.0.0",
3108 "t0",
3109 );
3110 let store = load(root);
3111 assert!(store.pending_containment.is_none());
3112 assert!(store.containment.is_empty());
3113 }
3114
3115 #[test]
3116 fn corrupt_store_loads_as_default_no_panic() {
3117 let (_config, dir) = test_env();
3118 let root = dir.path();
3119 seed_store_raw(root, b"{ not valid json ][");
3120 let store = load(root);
3121 assert!(!store.enabled);
3122 assert!(store.records.is_empty());
3123 record_v1(
3124 root,
3125 &summary(1, 0, 0),
3126 AuditVerdict::Fail,
3127 true,
3128 None,
3129 "2.0.0",
3130 "t0",
3131 );
3132 }
3133
3134 #[test]
3135 fn records_are_bounded() {
3136 let mut store = ImpactStore {
3137 enabled: true,
3138 ..Default::default()
3139 };
3140 for i in 0..(MAX_RECORDS + 50) {
3141 store.records.push(ImpactRecord {
3142 timestamp: format!("t{i}"),
3143 version: "2.0.0".into(),
3144 git_sha: None,
3145 verdict: "pass".into(),
3146 gate: false,
3147 counts: ImpactCounts::default(),
3148 });
3149 }
3150 compact(&mut store);
3151 assert_eq!(store.records.len(), MAX_RECORDS);
3152 assert_eq!(store.records[0].timestamp, "t50");
3153 }
3154
3155 #[test]
3156 fn report_always_carries_schema_version() {
3157 let empty = build_report(&ImpactStore::default());
3158 assert_eq!(empty.schema_version, ImpactReportSchemaVersion::V1);
3159 let json = render_json(&empty);
3160 assert!(
3161 json.contains("\"schema_version\": \"1\""),
3162 "schema_version must be present (as the \"1\" const) even when disabled: {json}"
3163 );
3164
3165 let mut store = ImpactStore {
3166 enabled: true,
3167 ..Default::default()
3168 };
3169 store.records.push(ImpactRecord {
3170 timestamp: "2026-05-29T10:00:00Z".into(),
3171 version: "2.0.0".into(),
3172 git_sha: None,
3173 verdict: "pass".into(),
3174 gate: false,
3175 counts: ImpactCounts::default(),
3176 });
3177 assert_eq!(
3178 build_report(&store).schema_version,
3179 ImpactReportSchemaVersion::V1
3180 );
3181 }
3182
3183 #[test]
3184 fn date_only_trims_iso_timestamp() {
3185 assert_eq!(date_only("2026-05-29T18:15:23Z"), "2026-05-29");
3186 assert_eq!(date_only("2026-05-29"), "2026-05-29");
3187 assert_eq!(date_only("the first run"), "the first run");
3188 }
3189
3190 #[test]
3191 fn human_footer_shows_date_only() {
3192 let mut store = ImpactStore {
3193 enabled: true,
3194 ..Default::default()
3195 };
3196 store.first_recorded = Some("2026-05-29T18:15:23Z".into());
3197 store.records.push(ImpactRecord {
3198 timestamp: "2026-05-29T18:15:23Z".into(),
3199 version: "2.0.0".into(),
3200 git_sha: None,
3201 verdict: "pass".into(),
3202 gate: false,
3203 counts: ImpactCounts::default(),
3204 });
3205 let report = build_report(&store);
3206 let human = render_human(&report);
3207 assert!(
3208 human.contains("since 2026-05-29.") && !human.contains("18:15:23"),
3209 "human footer must show date-only: {human}"
3210 );
3211 let md = render_markdown(&report);
3212 assert!(
3213 md.contains("since 2026-05-29.") && !md.contains("18:15:23"),
3214 "markdown footer must show date-only: {md}"
3215 );
3216 }
3217
3218 #[test]
3219 fn future_schema_version_store_loads_without_panic_or_loss() {
3220 let (_config, dir) = test_env();
3221 let root = dir.path();
3222 let future = format!(
3223 "{{\"schema_version\":{},\"enabled\":true,\"records\":[],\"containment\":[]}}",
3224 STORE_SCHEMA_VERSION + 1
3225 );
3226 seed_store_raw(root, future.as_bytes());
3227 let store = load(root);
3228 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION + 1);
3229 assert!(
3230 store.enabled,
3231 "future-version store must not degrade to default"
3232 );
3233 }
3234
3235 #[test]
3236 fn removed_finding_is_credited_as_resolved() {
3237 let (_config, dir) = test_env();
3238 let root = dir.path();
3239 enable(root);
3240 let a = touch(root, "src/a.ts");
3241 run(
3242 root,
3243 &[&a],
3244 vec![fi(&a, "unused-export", "foo")],
3245 vec![],
3246 &[],
3247 "t0",
3248 );
3249 assert_eq!(
3250 load(root).resolved_total,
3251 0,
3252 "first run only establishes a baseline"
3253 );
3254 run(root, &[&a], vec![], vec![], &[], "t1");
3255 let store = load(root);
3256 assert_eq!(store.resolved_total, 1);
3257 assert_eq!(store.suppressed_total, 0);
3258 assert_eq!(store.recent_resolved.len(), 1);
3259 assert_eq!(store.recent_resolved[0].kind, "unused-export");
3260 assert_eq!(store.recent_resolved[0].symbol.as_deref(), Some("foo"));
3261 assert_eq!(store.recent_resolved[0].path, "src/a.ts");
3262 }
3263
3264 #[test]
3265 fn suppressed_finding_is_not_a_win() {
3266 let (_config, dir) = test_env();
3267 let root = dir.path();
3268 enable(root);
3269 let a = touch(root, "src/a.ts");
3270 run(
3271 root,
3272 &[&a],
3273 vec![fi(&a, "unused-export", "foo")],
3274 vec![],
3275 &[],
3276 "t0",
3277 );
3278 run(
3279 root,
3280 &[&a],
3281 vec![],
3282 vec![],
3283 &[supp(&a, "unused-export")],
3284 "t1",
3285 );
3286 let store = load(root);
3287 assert_eq!(
3288 store.resolved_total, 0,
3289 "a suppression must never count as a win"
3290 );
3291 assert_eq!(store.suppressed_total, 1);
3292 }
3293
3294 #[test]
3295 fn fix_and_suppress_same_kind_credits_zero_resolved() {
3296 let (_config, dir) = test_env();
3297 let root = dir.path();
3298 enable(root);
3299 let a = touch(root, "src/a.ts");
3300 run(
3301 root,
3302 &[&a],
3303 vec![
3304 fi(&a, "unused-export", "foo"),
3305 fi(&a, "unused-export", "bar"),
3306 ],
3307 vec![],
3308 &[],
3309 "t0",
3310 );
3311 run(
3312 root,
3313 &[&a],
3314 vec![],
3315 vec![],
3316 &[supp(&a, "unused-export")],
3317 "t1",
3318 );
3319 let store = load(root);
3320 assert_eq!(store.resolved_total, 0);
3321 assert_eq!(store.suppressed_total, 2);
3322 }
3323
3324 #[test]
3325 fn within_file_move_is_not_resolved() {
3326 let (_config, dir) = test_env();
3327 let root = dir.path();
3328 enable(root);
3329 let a = touch(root, "src/a.ts");
3330 run(
3331 root,
3332 &[&a],
3333 vec![fi(&a, "unused-export", "foo")],
3334 vec![],
3335 &[],
3336 "t0",
3337 );
3338 run(
3339 root,
3340 &[&a],
3341 vec![fi(&a, "unused-export", "foo")],
3342 vec![],
3343 &[],
3344 "t1",
3345 );
3346 let store = load(root);
3347 assert_eq!(store.resolved_total, 0);
3348 assert_eq!(store.suppressed_total, 0);
3349 }
3350
3351 #[test]
3352 fn cross_file_move_in_same_run_is_not_resolved() {
3353 let (_config, dir) = test_env();
3354 let root = dir.path();
3355 enable(root);
3356 let a = touch(root, "src/a.ts");
3357 let b = touch(root, "src/b.ts");
3358 run(
3359 root,
3360 &[&a],
3361 vec![fi(&a, "unused-export", "foo")],
3362 vec![],
3363 &[],
3364 "t0",
3365 );
3366 run(
3367 root,
3368 &[&a, &b],
3369 vec![fi(&b, "unused-export", "foo")],
3370 vec![],
3371 &[],
3372 "t1",
3373 );
3374 assert_eq!(
3375 load(root).resolved_total,
3376 0,
3377 "a cross-file move is not a resolution"
3378 );
3379 }
3380
3381 #[test]
3382 fn cross_run_move_uncredits_the_prior_resolution() {
3383 let (_config, dir) = test_env();
3384 let root = dir.path();
3385 enable(root);
3386 let a = touch(root, "src/a.ts");
3387 let b = touch(root, "src/b.ts");
3388 run(
3389 root,
3390 &[&a],
3391 vec![fi(&a, "unused-export", "foo")],
3392 vec![],
3393 &[],
3394 "t0",
3395 );
3396 run(root, &[&a], vec![], vec![], &[], "t1");
3397 assert_eq!(
3398 load(root).resolved_total,
3399 1,
3400 "source disappearance credited in run A"
3401 );
3402 run(
3403 root,
3404 &[&b],
3405 vec![fi(&b, "unused-export", "foo")],
3406 vec![],
3407 &[],
3408 "t2",
3409 );
3410 let store = load(root);
3411 assert_eq!(
3412 store.resolved_total, 0,
3413 "cross-run move must un-credit the phantom win"
3414 );
3415 assert!(
3416 store.recent_resolved.is_empty(),
3417 "the stale resolution event is dropped"
3418 );
3419 }
3420
3421 #[test]
3422 fn resolved_complexity_finding_and_suppressed_complexity() {
3423 let (_config, dir) = test_env();
3424 let root = dir.path();
3425 enable(root);
3426 let a = touch(root, "src/a.ts");
3427 run(
3428 root,
3429 &[&a],
3430 vec![fi(&a, "complexity", "bigFn")],
3431 vec![],
3432 &[],
3433 "t0",
3434 );
3435 run(root, &[&a], vec![], vec![], &[supp(&a, "complexity")], "t1");
3436 let store = load(root);
3437 assert_eq!(store.resolved_total, 0);
3438 assert_eq!(store.suppressed_total, 1);
3439
3440 let b = touch(root, "src/b.ts");
3441 run(
3442 root,
3443 &[&b],
3444 vec![fi(&b, "complexity", "huge")],
3445 vec![],
3446 &[],
3447 "t2",
3448 );
3449 run(root, &[&b], vec![], vec![], &[], "t3");
3450 assert_eq!(load(root).resolved_total, 1);
3451 }
3452
3453 #[test]
3454 fn resolved_duplication_clone_group() {
3455 let (_config, dir) = test_env();
3456 let root = dir.path();
3457 enable(root);
3458 let a = touch(root, "src/a.ts");
3459 let b = touch(root, "src/b.ts");
3460 let clone = CloneInput {
3461 fingerprint: "dup:abc12345".to_owned(),
3462 instance_paths: vec![a.clone(), b],
3463 };
3464 run(root, &[&a], vec![], vec![clone], &[], "t0");
3465 run(root, &[&a], vec![], vec![], &[], "t1");
3466 let store = load(root);
3467 assert_eq!(store.resolved_total, 1);
3468 assert_eq!(store.recent_resolved[0].kind, "code-duplication");
3469 }
3470
3471 #[test]
3472 fn blanket_suppression_covers_any_kind() {
3473 let (_config, dir) = test_env();
3474 let root = dir.path();
3475 enable(root);
3476 let a = touch(root, "src/a.ts");
3477 run(
3478 root,
3479 &[&a],
3480 vec![fi(&a, "unused-export", "foo")],
3481 vec![],
3482 &[],
3483 "t0",
3484 );
3485 let blanket = ActiveSuppression {
3486 path: a.clone(),
3487 kind: None,
3488 is_file_level: true,
3489 reason: None,
3490 };
3491 run(root, &[&a], vec![], vec![], &[blanket], "t1");
3492 let store = load(root);
3493 assert_eq!(store.resolved_total, 0);
3494 assert_eq!(store.suppressed_total, 1);
3495 }
3496
3497 #[test]
3498 fn v1_store_loads_and_upgrades_to_v2() {
3499 let (_config, dir) = test_env();
3500 let root = dir.path();
3501 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":[]}"#;
3502 seed_store_raw(root, v1.as_bytes());
3503 let store = load(root);
3504 assert_eq!(store.schema_version, 1);
3505 assert!(store.frontier.is_empty());
3506 assert_eq!(store.resolved_total, 0);
3507 let a = touch(root, "src/a.ts");
3508 run(
3509 root,
3510 &[&a],
3511 vec![fi(&a, "unused-export", "foo")],
3512 vec![],
3513 &[],
3514 "t1",
3515 );
3516 let store = load(root);
3517 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
3518 assert!(frontier_paths(&store).contains("src/a.ts"));
3519 }
3520
3521 #[test]
3522 fn recent_resolved_is_bounded() {
3523 let mut store = ImpactStore {
3524 enabled: true,
3525 ..Default::default()
3526 };
3527 for i in 0..(MAX_RECENT_RESOLVED + 25) {
3528 store.recent_resolved.push(ResolutionEvent {
3529 kind: "unused-export".into(),
3530 path: format!("src/f{i}.ts"),
3531 symbol: Some(format!("s{i}")),
3532 git_sha: None,
3533 timestamp: format!("t{i}"),
3534 });
3535 }
3536 bound_recent_resolved(&mut store);
3537 assert_eq!(store.recent_resolved.len(), MAX_RECENT_RESOLVED);
3538 assert_eq!(store.recent_resolved[0].path, "src/f25.ts");
3539 }
3540
3541 #[test]
3542 fn frontier_prunes_deleted_files() {
3543 let (_config, dir) = test_env();
3544 let root = dir.path();
3545 enable(root);
3546 let a = touch(root, "src/a.ts");
3547 run(
3548 root,
3549 &[&a],
3550 vec![fi(&a, "unused-export", "foo")],
3551 vec![],
3552 &[],
3553 "t0",
3554 );
3555 assert!(frontier_paths(&load(root)).contains("src/a.ts"));
3556 std::fs::remove_file(&a).unwrap();
3557 let b = touch(root, "src/b.ts");
3558 run(root, &[&b], vec![], vec![], &[], "t1");
3559 assert!(!frontier_paths(&load(root)).contains("src/a.ts"));
3560 }
3561
3562 #[test]
3563 fn honest_empty_state_before_attribution_baseline() {
3564 let store = ImpactStore {
3565 enabled: true,
3566 records: vec![ImpactRecord {
3567 timestamp: "t0".into(),
3568 version: "2.0.0".into(),
3569 git_sha: None,
3570 verdict: "warn".into(),
3571 gate: false,
3572 counts: ImpactCounts::default(),
3573 }],
3574 ..Default::default()
3575 };
3576 let report = build_report(&store);
3577 assert!(!report.attribution_active);
3578 let human = render_human(&report);
3579 assert!(human.contains("resolution tracking starts from your next gate run"));
3580 assert!(!human.contains("0 finding"));
3581 }
3582
3583 #[test]
3584 fn suppression_only_state_renders_under_a_resolved_header() {
3585 let report = ImpactReport {
3586 schema_version: ImpactReportSchemaVersion::V1,
3587 enabled: true,
3588 enabled_source: EnabledSource::Project,
3589 record_count: 2,
3590 meta: None,
3591 first_recorded: Some("2026-05-29T10:00:00Z".into()),
3592 latest_git_sha: None,
3593 surfacing: Some(ImpactCounts::default()),
3594 trend: None,
3595 project_surfacing: None,
3596 project_trend: None,
3597 containment_count: 0,
3598 recent_containment: vec![],
3599 resolved_total: 0,
3600 suppressed_total: 2,
3601 recent_resolved: vec![],
3602 attribution_active: true,
3603 onboarding_declined: false,
3604 explicit_decision: false,
3605 };
3606 let human = render_human(&report);
3607 let resolved_idx = human.find(" RESOLVED").expect("RESOLVED header present");
3608 let supp_idx = human
3609 .find("2 findings you marked intentional")
3610 .expect("suppression line present");
3611 assert!(
3612 resolved_idx < supp_idx,
3613 "suppression must render under RESOLVED"
3614 );
3615 assert!(human.contains("none yet"));
3616
3617 let md = render_markdown(&report);
3618 assert!(
3619 md.contains("- **Resolved:**"),
3620 "markdown always has a Resolved bullet"
3621 );
3622 assert!(md.contains("- **Marked intentional:** 2 finding"));
3623 }
3624
3625 fn clone_at(fingerprint: &str, paths: &[&Path]) -> CloneInput {
3627 CloneInput {
3628 fingerprint: fingerprint.to_owned(),
3629 instance_paths: paths.iter().map(|p| p.to_path_buf()).collect(),
3630 }
3631 }
3632
3633 fn run_wp(
3637 root: &Path,
3638 findings: Vec<FindingInput>,
3639 clones: Vec<CloneInput>,
3640 supps: &[ActiveSuppression],
3641 ts: &str,
3642 ) {
3643 let input = AttributionInput {
3644 root,
3645 scope: Scope::WholeProject,
3646 findings,
3647 clones,
3648 suppressions: supps,
3649 };
3650 record_combined_run(
3651 root,
3652 ImpactCounts::default(),
3653 Some("sha"),
3654 "2.0.0",
3655 ts,
3656 Some(&input),
3657 );
3658 }
3659
3660 #[test]
3661 fn whole_project_run_does_not_double_credit_after_audit() {
3662 let (_config, dir) = test_env();
3663 let root = dir.path();
3664 enable(root);
3665 let a = touch(root, "src/a.ts");
3666 let b = touch(root, "src/b.ts");
3667 run(
3668 root,
3669 &[&a, &b],
3670 vec![],
3671 vec![clone_at("dup:abc", &[&a, &b])],
3672 &[],
3673 "t1",
3674 );
3675 assert_eq!(clone_fingerprints(&load(root)).len(), 1);
3676
3677 run(root, &[&a, &b], vec![], vec![], &[], "t2");
3678 assert_eq!(load(root).resolved_total, 1);
3679 assert!(load(root).clone_frontier.is_empty());
3680
3681 run_wp(root, vec![], vec![], &[], "t3");
3682 assert_eq!(
3683 load(root).resolved_total,
3684 1,
3685 "whole-project run re-credited a resolution"
3686 );
3687 }
3688
3689 #[test]
3690 fn whole_project_run_credits_suppressed_not_resolved() {
3691 let (_config, dir) = test_env();
3692 let root = dir.path();
3693 enable(root);
3694 let util = touch(root, "src/util.ts");
3695 run(
3696 root,
3697 &[&util],
3698 vec![fi(&util, "unused-export", "dead")],
3699 vec![],
3700 &[],
3701 "t1",
3702 );
3703 assert_eq!(frontier_paths(&load(root)).len(), 1);
3704
3705 run_wp(root, vec![], vec![], &[supp(&util, "unused-export")], "t2");
3706 let store = load(root);
3707 assert_eq!(
3708 store.suppressed_total, 1,
3709 "suppressed finding not counted suppressed"
3710 );
3711 assert_eq!(
3712 store.resolved_total, 0,
3713 "suppressed finding wrongly counted resolved"
3714 );
3715 }
3716
3717 #[test]
3718 fn clone_reshape_three_to_two_not_credited_as_resolved() {
3719 let (_config, dir) = test_env();
3720 let root = dir.path();
3721 enable(root);
3722 let a = touch(root, "src/a.ts");
3723 let b = touch(root, "src/b.ts");
3724 let c = touch(root, "src/c.ts");
3725 run(
3726 root,
3727 &[&a, &b, &c],
3728 vec![],
3729 vec![clone_at("dup:aaa", &[&a, &b, &c])],
3730 &[],
3731 "t1",
3732 );
3733 assert_eq!(clone_fingerprints(&load(root)).len(), 1);
3734
3735 run_wp(
3736 root,
3737 vec![],
3738 vec![clone_at("dup:bbb", &[&a, &b])],
3739 &[],
3740 "t2",
3741 );
3742 let store = load(root);
3743 assert_eq!(
3744 store.resolved_total, 0,
3745 "clone reshape miscredited as resolved"
3746 );
3747 assert!(clone_fingerprints(&store).contains("dup:bbb"));
3748 assert!(!clone_fingerprints(&store).contains("dup:aaa"));
3749 }
3750
3751 fn rcounts(total: usize, dead: usize, complexity: usize, dup: usize) -> ImpactCounts {
3752 ImpactCounts {
3753 total_issues: total,
3754 dead_code: dead,
3755 complexity,
3756 duplication: dup,
3757 }
3758 }
3759
3760 fn rtrend(prev: usize, cur: usize) -> TrendSummary {
3761 TrendSummary {
3762 direction: direction_for(cur as i64 - prev as i64),
3763 total_delta: cur as i64 - prev as i64,
3764 previous_total: prev,
3765 current_total: cur,
3766 }
3767 }
3768
3769 #[expect(
3771 clippy::too_many_arguments,
3772 reason = "test scaffold; positional ImpactReport builder, bundling adds churn with no production value"
3773 )]
3774 fn rreport(
3775 record_count: usize,
3776 first_recorded: Option<&str>,
3777 surfacing: Option<ImpactCounts>,
3778 trend: Option<TrendSummary>,
3779 project_surfacing: Option<ImpactCounts>,
3780 project_trend: Option<TrendSummary>,
3781 attribution_active: bool,
3782 ) -> ImpactReport {
3783 ImpactReport {
3784 schema_version: ImpactReportSchemaVersion::V1,
3785 enabled: true,
3786 enabled_source: EnabledSource::Project,
3787 record_count,
3788 meta: None,
3789 first_recorded: first_recorded.map(ToOwned::to_owned),
3790 latest_git_sha: None,
3791 surfacing,
3792 trend,
3793 project_surfacing,
3794 project_trend,
3795 containment_count: 0,
3796 recent_containment: vec![],
3797 resolved_total: 0,
3798 suppressed_total: 0,
3799 recent_resolved: vec![],
3800 attribution_active,
3801 onboarding_declined: false,
3802 explicit_decision: false,
3803 }
3804 }
3805
3806 #[test]
3807 fn render_human_project_only_store_shows_whole_project_not_empty_state() {
3808 let r = rreport(
3809 0,
3810 Some("2026-05-30T10:00:00Z"),
3811 None,
3812 None,
3813 Some(rcounts(1, 1, 0, 0)),
3814 None,
3815 true,
3816 );
3817 let human = render_human(&r);
3818 assert!(
3819 human.contains("WHOLE PROJECT (whole-repo context, not a to-do)"),
3820 "project-only must render the labeled section"
3821 );
3822 assert!(human.contains("1 issue across the whole project"));
3823 assert!(
3824 human.contains("project trend starts after your next full `fallow` run"),
3825 "single project record => no trend line, shows the next-run hint"
3826 );
3827 assert!(human.contains("Tracking since 2026-05-30"));
3828 assert!(
3829 !human.contains("No history yet"),
3830 "must not show the empty-state copy"
3831 );
3832 assert!(
3833 !human.contains("LATEST RUN"),
3834 "no changed-file track recorded"
3835 );
3836 assert!(
3837 !human.contains("recorded audit run"),
3838 "no audit runs => no changed-file footer"
3839 );
3840 }
3841
3842 #[test]
3843 fn render_human_both_tracks_label_actionable_vs_context() {
3844 let r = rreport(
3845 3,
3846 Some("2026-05-29T10:00:00Z"),
3847 Some(rcounts(4, 4, 0, 0)),
3848 Some(rtrend(6, 4)),
3849 Some(rcounts(40, 30, 5, 5)),
3850 Some(rtrend(45, 40)),
3851 true,
3852 );
3853 let human = render_human(&r);
3854 let latest = human
3855 .find("LATEST RUN (changed files, act on these now)")
3856 .expect("LATEST RUN labeled actionable");
3857 let whole = human
3858 .find("WHOLE PROJECT (whole-repo context, not a to-do)")
3859 .expect("WHOLE PROJECT labeled context");
3860 assert!(
3861 latest < whole,
3862 "changed-file section renders before whole-project"
3863 );
3864 assert!(human.contains("45 -> 40 (down) across your last two full runs"));
3865 assert!(human.contains("advances only on your local full `fallow` runs, not CI"));
3866 }
3867
3868 #[test]
3869 fn render_markdown_project_only_store_shows_whole_project_not_empty_state() {
3870 let r = rreport(
3871 0,
3872 Some("2026-05-30T10:00:00Z"),
3873 None,
3874 None,
3875 Some(rcounts(1, 1, 0, 0)),
3876 None,
3877 true,
3878 );
3879 let md = render_markdown(&r);
3880 assert!(
3881 md.contains(
3882 "- **Whole project (whole-repo context, last full `fallow` run):** 1 issue"
3883 ),
3884 "project-only md must render the labeled whole-project line"
3885 );
3886 assert!(
3887 !md.contains("No history yet"),
3888 "project-only md must not show empty state"
3889 );
3890 assert!(md.contains("Tracking since 2026-05-30"));
3891 }
3892
3893 #[test]
3894 fn resolve_enabled_precedence_table() {
3895 let (_config, _dir) = test_env();
3896 let on = ImpactStore {
3898 enabled: true,
3899 ..Default::default()
3900 };
3901 assert_eq!(resolve_enabled(&on), (true, EnabledSource::Project));
3902
3903 let off_explicit = ImpactStore {
3905 enabled: false,
3906 explicit_decision: true,
3907 ..Default::default()
3908 };
3909 assert_eq!(
3910 resolve_enabled(&off_explicit),
3911 (false, EnabledSource::Project)
3912 );
3913
3914 let never = ImpactStore::default();
3916 assert_eq!(resolve_enabled(&never), (false, EnabledSource::Default));
3917
3918 assert!(set_global_default(true));
3920 assert_eq!(resolve_enabled(&never), (true, EnabledSource::User));
3921 assert_eq!(
3923 resolve_enabled(&off_explicit),
3924 (false, EnabledSource::Project)
3925 );
3926 }
3927
3928 #[test]
3929 fn human_report_explains_user_global_default() {
3930 let (_config, _dir) = test_env();
3931 set_global_default(true);
3932 let report = build_report(&ImpactStore::default());
3934 assert_eq!(report.enabled_source, EnabledSource::User);
3935 let human = render_human(&report);
3936 assert!(
3937 human.contains("Enabled by your user-global default"),
3938 "human report must explain a global-default enable: {human}"
3939 );
3940 let project = build_report(&ImpactStore {
3942 enabled: true,
3943 explicit_decision: true,
3944 ..Default::default()
3945 });
3946 assert_eq!(project.enabled_source, EnabledSource::Project);
3947 assert!(!render_human(&project).contains("user-global default"));
3948 }
3949
3950 #[test]
3951 fn global_default_round_trips() {
3952 let (_config, _dir) = test_env();
3953 assert!(!load_global_default());
3954 assert!(set_global_default(true));
3955 assert!(load_global_default());
3956 assert!(!set_global_default(true)); assert!(set_global_default(false));
3958 assert!(!load_global_default());
3959 }
3960
3961 #[test]
3962 fn global_default_records_without_per_repo_enable() {
3963 let (_config, dir) = test_env();
3964 let root = dir.path();
3965 set_global_default(true);
3966 record_v1(
3968 root,
3969 &summary(2, 0, 0),
3970 AuditVerdict::Warn,
3971 false,
3972 None,
3973 "2.0.0",
3974 "t0",
3975 );
3976 let report = build_report(&load(root));
3977 assert!(report.enabled);
3978 assert_eq!(report.enabled_source, EnabledSource::User);
3979 assert_eq!(report.record_count, 1);
3980 }
3981
3982 #[test]
3983 fn legacy_in_repo_store_is_migrated_on_first_load() {
3984 let (_config, dir) = test_env();
3985 let root = dir.path();
3986 let legacy = r#"{"schema_version":3,"enabled":true,"explicit_decision":true,
3988 "records":[{"timestamp":"t0","version":"2.0.0","verdict":"warn","gate":false,
3989 "counts":{"total_issues":1,"dead_code":1,"complexity":0,"duplication":0}}],
3990 "resolved_total":2,
3991 "frontier":{"src/a.ts":{"findings":[{"id":"x","kind":"unused-export","symbol":"foo"}],"suppressions":[]}},
3992 "containment":[]}"#;
3993 std::fs::create_dir_all(root.join(".fallow")).unwrap();
3994 std::fs::write(legacy_store_path(root), legacy).unwrap();
3995
3996 let store = load(root);
3997 assert!(store.enabled);
3998 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
3999 assert_eq!(store.records.len(), 1);
4000 assert_eq!(store.resolved_total, 2);
4001 assert!(frontier_paths(&store).contains("src/a.ts"));
4003 assert!(store_path(root).is_some_and(|p| p.exists()));
4006 let again = load(root);
4007 assert_eq!(again.records.len(), 1);
4008 }
4009
4010 #[test]
4011 fn reset_removes_only_this_project() {
4012 let (_config, dir) = test_env();
4013 let root = dir.path();
4014 enable(root);
4015 record_v1(
4016 root,
4017 &summary(1, 0, 0),
4018 AuditVerdict::Warn,
4019 false,
4020 None,
4021 "2.0.0",
4022 "t0",
4023 );
4024 assert_eq!(load(root).records.len(), 1);
4025 assert!(reset(root));
4026 assert!(load(root).records.is_empty());
4027 assert!(!reset(root)); }
4029
4030 #[test]
4031 fn reset_all_clears_dir_but_keeps_global_default() {
4032 let (_config, dir) = test_env();
4033 let root = dir.path();
4034 set_global_default(true);
4035 enable(root);
4036 assert!(load(root).enabled);
4037 assert!(reset_all());
4038 assert!(load_global_default());
4040 }
4041
4042 fn aggregate_env() -> tempfile::TempDir {
4046 let config = tempfile::tempdir().unwrap();
4047 TEST_CONFIG_DIR.with(|c| *c.borrow_mut() = Some(config.path().to_path_buf()));
4048 config
4049 }
4050
4051 fn seed_store(key: &str, store: &ImpactStore) {
4053 let dir = impact_config_dir().unwrap().join("impact");
4054 std::fs::create_dir_all(&dir).unwrap();
4055 std::fs::write(
4056 dir.join(format!("{key}.json")),
4057 serde_json::to_string_pretty(store).unwrap(),
4058 )
4059 .unwrap();
4060 }
4061
4062 fn store_with(
4063 label: &str,
4064 resolved: usize,
4065 contained: usize,
4066 latest_ts: &str,
4067 latest_issues: usize,
4068 ) -> ImpactStore {
4069 let mut s = ImpactStore {
4070 enabled: true,
4071 explicit_decision: true,
4072 resolved_total: resolved,
4073 label: Some(label.to_owned()),
4074 ..Default::default()
4075 };
4076 s.records.push(ImpactRecord {
4077 timestamp: latest_ts.to_owned(),
4078 version: "2.0.0".to_owned(),
4079 git_sha: None,
4080 verdict: "warn".to_owned(),
4081 gate: false,
4082 counts: ImpactCounts::from_combined(latest_issues, 0, 0),
4083 });
4084 for _ in 0..contained {
4085 s.containment.push(ContainmentEvent {
4086 blocked_at: "t0".to_owned(),
4087 cleared_at: "t1".to_owned(),
4088 git_sha: None,
4089 blocked_counts: ImpactCounts::default(),
4090 });
4091 }
4092 s
4093 }
4094
4095 #[test]
4096 fn repo_basename_returns_last_component_only() {
4097 assert_eq!(
4098 repo_basename(Path::new("/a/b/myrepo/.git")).as_deref(),
4099 Some("myrepo")
4100 );
4101 assert_eq!(
4102 repo_basename(Path::new("/a/b/proj")).as_deref(),
4103 Some("proj")
4104 );
4105 let name = repo_basename(Path::new("/x/y/z/.git")).unwrap();
4107 assert!(!name.contains('/') && !name.contains('\\'));
4108 }
4109
4110 #[test]
4111 fn aggregate_rolls_up_totals_and_excludes_empty() {
4112 let _cfg = aggregate_env();
4113 seed_store(
4114 "aaa",
4115 &store_with("alpha", 10, 2, "2026-06-10T00:00:00Z", 3),
4116 );
4117 seed_store("bbb", &store_with("beta", 5, 1, "2026-06-11T00:00:00Z", 0));
4118 seed_store(
4120 "ccc",
4121 &ImpactStore {
4122 enabled: true,
4123 explicit_decision: true,
4124 label: Some("gamma".into()),
4125 ..Default::default()
4126 },
4127 );
4128 let report = aggregate(CrossRepoSort::Recent);
4129 assert_eq!(report.project_count, 3, "all three stores enumerated");
4130 assert_eq!(report.tracked_count, 2, "empty store excluded from rows");
4131 assert_eq!(report.totals.resolved_total, 15);
4132 assert_eq!(report.totals.containment_count, 3);
4133 assert_eq!(report.unreadable_count, 0);
4134 }
4135
4136 #[test]
4137 fn aggregate_sort_recent_orders_by_last_activity() {
4138 let _cfg = aggregate_env();
4139 seed_store("old", &store_with("older", 1, 0, "2026-06-01T00:00:00Z", 1));
4140 seed_store("new", &store_with("newer", 1, 0, "2026-06-12T00:00:00Z", 1));
4141 let report = aggregate(CrossRepoSort::Recent);
4142 assert_eq!(report.projects[0].label.as_deref(), Some("newer"));
4143 assert_eq!(report.projects[1].label.as_deref(), Some("older"));
4144 }
4145
4146 #[test]
4147 fn cross_repo_json_carries_kind_and_leaks_no_path() {
4148 let _cfg = aggregate_env();
4149 seed_store("aaa", &store_with("alpha", 4, 1, "2026-06-10T00:00:00Z", 2));
4150 let report = aggregate(CrossRepoSort::Recent);
4151 let json = render_cross_repo_json(&report);
4152 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
4153 assert_eq!(value["kind"], "impact-cross-repo");
4154 for entry in value["projects"].as_array().unwrap() {
4156 if let Some(label) = entry["label"].as_str() {
4157 assert!(
4158 !label.contains('/') && !label.contains('\\'),
4159 "label must be a basename, got {label}"
4160 );
4161 }
4162 }
4163 assert!(
4164 !json.contains('/') || !json.contains("Users"),
4165 "json must not leak an absolute home path"
4166 );
4167 }
4168
4169 #[test]
4170 fn cross_repo_markdown_pluralizes_single_project() {
4171 let _cfg = aggregate_env();
4172 seed_store("solo", &store_with("solo", 3, 1, "2026-06-10T00:00:00Z", 2));
4173 let report = aggregate(CrossRepoSort::Recent);
4174 assert_eq!(report.project_count, 1);
4175 assert_eq!(report.tracked_count, 1);
4176 let md = render_cross_repo_markdown(&report);
4177 assert!(
4178 md.contains("1 project tracked"),
4179 "single project must read 'project', got:\n{md}"
4180 );
4181 assert!(
4182 !md.contains("1 projects tracked"),
4183 "must not pluralize a single project, got:\n{md}"
4184 );
4185 assert!(
4186 md.contains("across 1 tracked project"),
4187 "grand totals must read 'tracked project' (singular), got:\n{md}"
4188 );
4189 assert!(
4190 !md.contains("tracked projects"),
4191 "must not pluralize a single tracked project, got:\n{md}"
4192 );
4193 }
4194
4195 #[test]
4196 fn cross_repo_corrupt_file_is_skipped_and_counted() {
4197 let _cfg = aggregate_env();
4198 seed_store("good", &store_with("good", 3, 0, "2026-06-10T00:00:00Z", 1));
4199 let dir = impact_config_dir().unwrap().join("impact");
4200 std::fs::write(dir.join("bad.json"), b"{ not valid json ][").unwrap();
4201 let report = aggregate(CrossRepoSort::Recent);
4202 assert_eq!(report.tracked_count, 1, "good store still aggregated");
4203 assert_eq!(
4204 report.unreadable_count, 1,
4205 "corrupt file counted, not crashed"
4206 );
4207 }
4208
4209 #[test]
4210 fn cross_repo_empty_dir_is_first_run() {
4211 let _cfg = aggregate_env();
4212 let report = aggregate(CrossRepoSort::Recent);
4213 assert_eq!(report.project_count, 0);
4214 let human = render_cross_repo_human(&report, None);
4215 assert!(human.contains("No projects tracked yet"));
4216 }
4217
4218 #[test]
4219 fn cross_repo_all_corrupt_reports_unreadable_not_first_run() {
4220 let _cfg = aggregate_env();
4221 let dir = impact_config_dir().unwrap().join("impact");
4222 std::fs::create_dir_all(&dir).unwrap();
4223 std::fs::write(dir.join("bad.json"), b"{ broken ][").unwrap();
4224 let report = aggregate(CrossRepoSort::Recent);
4225 assert_eq!(report.project_count, 0);
4226 assert_eq!(report.unreadable_count, 1);
4227 let human = render_cross_repo_human(&report, None);
4228 assert!(
4229 human.contains("unreadable store") && !human.contains("No projects tracked yet"),
4230 "all-corrupt must report unreadable, not a misleading first-run hint: {human}"
4231 );
4232 }
4233
4234 #[test]
4235 fn record_audit_run_captures_basename_label() {
4236 let (_config, dir) = test_env();
4237 let root = dir.path();
4238 enable(root);
4239 record_v1(
4240 root,
4241 &summary(1, 0, 0),
4242 AuditVerdict::Warn,
4243 false,
4244 None,
4245 "2.0.0",
4246 "t0",
4247 );
4248 let label = load(root).label.expect("label captured on record");
4249 assert!(
4250 !label.contains('/') && !label.contains('\\'),
4251 "label must be a basename, got {label}"
4252 );
4253 }
4254
4255 #[test]
4258 fn lock_path_appends_lock_suffix() {
4259 assert_eq!(
4260 lock_path_for(Path::new("/c/fallow/impact/abc.json")),
4261 PathBuf::from("/c/fallow/impact/abc.json.lock")
4262 );
4263 }
4264
4265 #[test]
4266 fn store_lock_acquire_drop_then_record_roundtrips() {
4267 let (_config, dir) = test_env();
4268 let root = dir.path();
4269 enable(root);
4270 {
4273 let _lock = ImpactStoreLock::acquire(root).expect("lock acquires");
4274 }
4275 record_v1(
4276 root,
4277 &summary(1, 0, 0),
4278 AuditVerdict::Warn,
4279 false,
4280 None,
4281 "2.0.0",
4282 "t0",
4283 );
4284 assert_eq!(load(root).records.len(), 1, "record persisted under lock");
4285 let store = store_path(root).unwrap();
4287 assert!(lock_path_for(&store).exists(), "lock sidecar created");
4288 assert!(store.exists(), "store file is distinct from its lock");
4289 }
4290
4291 #[test]
4292 fn sweep_keeps_fresh_and_self_deletes_aged_out() {
4293 let _cfg = aggregate_env();
4294 seed_store("keepme", &store_with("keep", 1, 0, "t0", 1));
4295 seed_store("oldone", &store_with("old", 1, 0, "t0", 1));
4296 let lock = impact_config_dir()
4298 .unwrap()
4299 .join("impact")
4300 .join("oldone.json.lock");
4301 std::fs::write(&lock, b"").unwrap();
4302
4303 sweep_old_stores("keepme", std::time::Duration::ZERO);
4305
4306 let dir = impact_config_dir().unwrap().join("impact");
4307 assert!(dir.join("keepme.json").exists(), "kept store survives");
4308 assert!(
4309 !dir.join("oldone.json").exists(),
4310 "aged-out store reclaimed"
4311 );
4312 assert!(lock.exists(), "lock sidecar never deleted by the sweep");
4313 }
4314
4315 #[test]
4316 fn sweep_keeps_everything_under_a_large_window() {
4317 let _cfg = aggregate_env();
4318 seed_store("a", &store_with("a", 1, 0, "t0", 1));
4319 seed_store("b", &store_with("b", 1, 0, "t0", 1));
4320 sweep_old_stores("a", std::time::Duration::from_hours(10 * 365 * 24));
4322 let dir = impact_config_dir().unwrap().join("impact");
4323 assert!(dir.join("a.json").exists());
4324 assert!(dir.join("b.json").exists());
4325 }
4326
4327 #[test]
4330 #[cfg_attr(miri, ignore)]
4331 fn legacy_into_store_with_empty_frontiers_does_not_insert_worktree_key() {
4332 let legacy = LegacyFlatStore {
4335 enabled: true,
4336 explicit_decision: true,
4337 first_recorded: Some("t0".to_owned()),
4338 records: vec![],
4339 project_records: vec![],
4340 containment: vec![],
4341 pending_containment: None,
4342 frontier: FxHashMap::default(),
4343 clone_frontier: FxHashMap::default(),
4344 resolved_total: 0,
4345 suppressed_total: 0,
4346 recent_resolved: vec![],
4347 onboarding_declined: false,
4348 last_digest_epoch: None,
4349 };
4350 let store = legacy.into_store("wt-key");
4351 assert!(
4352 store.frontier.is_empty(),
4353 "empty legacy frontier must not insert a worktree key"
4354 );
4355 assert!(
4356 store.clone_frontier.is_empty(),
4357 "empty legacy clone_frontier must not insert a worktree key"
4358 );
4359 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
4360 assert!(store.label.is_none(), "label is always None on migration");
4361 }
4362
4363 #[test]
4366 fn repo_basename_non_git_path_returns_last_component() {
4367 assert_eq!(
4369 repo_basename(Path::new("/a/b/myproject")).as_deref(),
4370 Some("myproject")
4371 );
4372 }
4373
4374 #[test]
4375 fn repo_basename_root_path_returns_none() {
4376 assert!(repo_basename(Path::new("/")).is_none());
4378 }
4379
4380 #[test]
4383 fn direction_for_declining_when_delta_positive() {
4384 assert_eq!(direction_for(1), ImpactTrendDirection::Declining);
4385 assert_eq!(direction_for(100), ImpactTrendDirection::Declining);
4386 }
4387
4388 #[test]
4389 fn direction_for_stable_when_delta_zero() {
4390 assert_eq!(direction_for(0), ImpactTrendDirection::Stable);
4391 }
4392
4393 #[test]
4394 fn direction_for_improving_when_delta_negative() {
4395 assert_eq!(direction_for(-1), ImpactTrendDirection::Improving);
4396 }
4397
4398 #[test]
4401 fn trend_arrow_all_directions() {
4402 assert_eq!(trend_arrow(ImpactTrendDirection::Improving), "down");
4403 assert_eq!(trend_arrow(ImpactTrendDirection::Declining), "up");
4404 assert_eq!(trend_arrow(ImpactTrendDirection::Stable), "flat");
4405 }
4406
4407 #[test]
4410 fn build_report_project_trend_populated_from_project_records() {
4411 let mut store = ImpactStore {
4412 enabled: true,
4413 ..Default::default()
4414 };
4415 for total in [20usize, 15usize] {
4416 store.project_records.push(ImpactRecord {
4417 timestamp: format!("t{total}"),
4418 version: "2.0.0".into(),
4419 git_sha: None,
4420 verdict: "warn".into(),
4421 gate: false,
4422 counts: ImpactCounts {
4423 total_issues: total,
4424 dead_code: total,
4425 complexity: 0,
4426 duplication: 0,
4427 },
4428 });
4429 }
4430 let report = build_report(&store);
4431 let pt = report
4432 .project_trend
4433 .expect("two project records yield a trend");
4434 assert_eq!(pt.direction, ImpactTrendDirection::Improving);
4435 assert_eq!(pt.previous_total, 20);
4436 assert_eq!(pt.current_total, 15);
4437 assert_eq!(pt.total_delta, -5);
4438 }
4439
4440 #[test]
4443 fn latest_activity_both_series_returns_newer() {
4444 let mut store = ImpactStore::default();
4445 store.records.push(ImpactRecord {
4446 timestamp: "2026-01-01T00:00:00Z".to_owned(),
4447 version: "2.0.0".to_owned(),
4448 git_sha: None,
4449 verdict: "warn".to_owned(),
4450 gate: false,
4451 counts: ImpactCounts::default(),
4452 });
4453 store.project_records.push(ImpactRecord {
4454 timestamp: "2026-06-15T00:00:00Z".to_owned(),
4455 version: "2.0.0".to_owned(),
4456 git_sha: None,
4457 verdict: "warn".to_owned(),
4458 gate: false,
4459 counts: ImpactCounts::default(),
4460 });
4461 assert_eq!(
4462 latest_activity(&store).as_deref(),
4463 Some("2026-06-15T00:00:00Z")
4464 );
4465 }
4466
4467 #[test]
4468 fn latest_activity_only_project_records() {
4469 let mut store = ImpactStore::default();
4470 store.project_records.push(ImpactRecord {
4471 timestamp: "2026-05-01T00:00:00Z".to_owned(),
4472 version: "2.0.0".to_owned(),
4473 git_sha: None,
4474 verdict: "pass".to_owned(),
4475 gate: false,
4476 counts: ImpactCounts::default(),
4477 });
4478 assert_eq!(
4479 latest_activity(&store).as_deref(),
4480 Some("2026-05-01T00:00:00Z")
4481 );
4482 }
4483
4484 #[test]
4485 fn latest_activity_empty_store_returns_none() {
4486 assert!(latest_activity(&ImpactStore::default()).is_none());
4487 }
4488
4489 #[test]
4492 #[cfg_attr(miri, ignore)]
4493 fn containment_events_are_bounded_at_max_containment() {
4494 let (_config, dir) = test_env();
4495 let root = dir.path();
4496 enable(root);
4497 let mut store = load(root);
4499 for i in 0..MAX_CONTAINMENT {
4500 store.containment.push(ContainmentEvent {
4501 blocked_at: format!("block{i}"),
4502 cleared_at: format!("clear{i}"),
4503 git_sha: None,
4504 blocked_counts: ImpactCounts::default(),
4505 });
4506 }
4507 save(&store, root);
4508
4509 record_v1(
4511 root,
4512 &summary(1, 0, 0),
4513 AuditVerdict::Fail,
4514 true,
4515 None,
4516 "2.0.0",
4517 "overflow_block",
4518 );
4519 record_v1(
4520 root,
4521 &summary(0, 0, 0),
4522 AuditVerdict::Pass,
4523 true,
4524 None,
4525 "2.0.0",
4526 "overflow_clear",
4527 );
4528 let store = load(root);
4529 assert_eq!(
4530 store.containment.len(),
4531 MAX_CONTAINMENT,
4532 "containment must be capped at MAX_CONTAINMENT"
4533 );
4534 assert_eq!(
4536 store.containment.last().unwrap().blocked_at,
4537 "overflow_block"
4538 );
4539 }
4540
4541 #[test]
4544 #[cfg_attr(miri, ignore)]
4545 fn disable_from_enabled_state_sets_schema_version() {
4546 let (_config, dir) = test_env();
4547 let root = dir.path();
4548 enable(root);
4549 let was_newly_disabled = disable(root);
4550 assert!(
4551 was_newly_disabled,
4552 "disable returns true when previously enabled"
4553 );
4554 let store = load(root);
4555 assert!(!store.enabled);
4556 assert!(store.explicit_decision);
4557 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
4558
4559 let again = disable(root);
4561 assert!(!again, "disable returns false when already off");
4562 }
4563
4564 #[test]
4567 #[cfg_attr(miri, ignore)]
4568 fn record_combined_run_is_noop_in_ci() {
4569 let (_config, dir) = test_env();
4570 let root = dir.path();
4571 enable(root);
4572 TEST_FORCE_CI.with(|c| c.set(true));
4573 record_combined_run(
4574 root,
4575 ImpactCounts::from_combined(5, 0, 0),
4576 None,
4577 "2.0.0",
4578 "t0",
4579 None,
4580 );
4581 TEST_FORCE_CI.with(|c| c.set(false));
4582 assert!(
4583 load(root).project_records.is_empty(),
4584 "combined run must not record on CI"
4585 );
4586 }
4587
4588 #[test]
4589 #[cfg_attr(miri, ignore)]
4590 fn record_combined_run_is_noop_when_disabled() {
4591 let (_config, dir) = test_env();
4592 let root = dir.path();
4593 record_combined_run(
4595 root,
4596 ImpactCounts::from_combined(3, 0, 0),
4597 None,
4598 "2.0.0",
4599 "t0",
4600 None,
4601 );
4602 assert!(
4603 load(root).project_records.is_empty(),
4604 "combined run must not record when disabled"
4605 );
4606 }
4607
4608 #[test]
4609 #[cfg_attr(miri, ignore)]
4610 fn record_combined_run_project_records_bounded_at_max() {
4611 let (_config, dir) = test_env();
4612 let root = dir.path();
4613 enable(root);
4614 let mut store = load(root);
4616 for i in 0..MAX_RECORDS {
4617 store.project_records.push(ImpactRecord {
4618 timestamp: format!("t{i}"),
4619 version: "2.0.0".to_owned(),
4620 git_sha: None,
4621 verdict: "warn".to_owned(),
4622 gate: false,
4623 counts: ImpactCounts::default(),
4624 });
4625 }
4626 save(&store, root);
4627
4628 record_combined_run(
4629 root,
4630 ImpactCounts::from_combined(1, 0, 0),
4631 None,
4632 "2.0.0",
4633 "overflow",
4634 None,
4635 );
4636 let store = load(root);
4637 assert_eq!(
4638 store.project_records.len(),
4639 MAX_RECORDS,
4640 "project_records must be capped at MAX_RECORDS"
4641 );
4642 assert_eq!(
4643 store.project_records.last().unwrap().timestamp,
4644 "overflow",
4645 "newest record must be at the tail"
4646 );
4647 }
4648
4649 #[test]
4652 #[cfg_attr(miri, ignore)]
4653 fn suppressed_clone_disappearance_credits_suppressed_not_resolved() {
4654 let (_config, dir) = test_env();
4655 let root = dir.path();
4656 enable(root);
4657 let a = touch(root, "src/a.ts");
4658 let b = touch(root, "src/b.ts");
4659 let clone = CloneInput {
4660 fingerprint: "dup:suppressed".to_owned(),
4661 instance_paths: vec![a.clone(), b.clone()],
4662 };
4663 run(root, &[&a, &b], vec![], vec![clone], &[], "t0");
4664 let blanket = ActiveSuppression {
4666 path: a.clone(),
4667 kind: None,
4668 is_file_level: true,
4669 reason: None,
4670 };
4671 run(root, &[&a, &b], vec![], vec![], &[blanket], "t1");
4672 let store = load(root);
4673 assert_eq!(
4674 store.suppressed_total, 1,
4675 "suppressed clone disappearance must count as suppressed"
4676 );
4677 assert_eq!(
4678 store.resolved_total, 0,
4679 "suppressed clone must not count as resolved"
4680 );
4681 }
4682
4683 #[test]
4686 #[cfg_attr(miri, ignore)]
4687 fn load_all_skips_non_json_and_lock_files() {
4688 let _cfg = aggregate_env();
4689 seed_store("real", &store_with("real", 2, 0, "2026-06-10T00:00:00Z", 1));
4690 let dir = impact_config_dir().unwrap().join("impact");
4691 std::fs::write(dir.join("real.json.lock"), b"").unwrap();
4693 std::fs::write(dir.join("notes.txt"), b"ignored").unwrap();
4694 let (stores, unreadable) = load_all();
4695 assert_eq!(stores.len(), 1, "only the .json store is loaded");
4696 assert_eq!(
4697 unreadable, 0,
4698 "lock and txt files are not counted unreadable"
4699 );
4700 }
4701
4702 #[test]
4705 fn aggregate_totals_project_wide_issues_from_project_surfacing() {
4706 let mut s1 = ImpactStore {
4708 enabled: true,
4709 explicit_decision: true,
4710 resolved_total: 3,
4711 ..Default::default()
4712 };
4713 s1.records.push(ImpactRecord {
4714 timestamp: "t1".to_owned(),
4715 version: "2.0.0".to_owned(),
4716 git_sha: None,
4717 verdict: "warn".to_owned(),
4718 gate: false,
4719 counts: ImpactCounts::from_combined(5, 0, 0),
4720 });
4721 s1.project_records.push(ImpactRecord {
4723 timestamp: "pt1".to_owned(),
4724 version: "2.0.0".to_owned(),
4725 git_sha: None,
4726 verdict: "warn".to_owned(),
4727 gate: false,
4728 counts: ImpactCounts::from_combined(20, 0, 0),
4729 });
4730
4731 let mut s2 = ImpactStore {
4732 enabled: true,
4733 explicit_decision: true,
4734 resolved_total: 7,
4735 ..Default::default()
4736 };
4737 s2.records.push(ImpactRecord {
4738 timestamp: "t2".to_owned(),
4739 version: "2.0.0".to_owned(),
4740 git_sha: None,
4741 verdict: "warn".to_owned(),
4742 gate: false,
4743 counts: ImpactCounts::from_combined(2, 0, 0),
4744 });
4745 s2.project_records.push(ImpactRecord {
4746 timestamp: "pt2".to_owned(),
4747 version: "2.0.0".to_owned(),
4748 git_sha: None,
4749 verdict: "warn".to_owned(),
4750 gate: false,
4751 counts: ImpactCounts::from_combined(30, 0, 0),
4752 });
4753
4754 let report = build_aggregate_report(
4755 vec![("k1".to_owned(), s1), ("k2".to_owned(), s2)],
4756 0,
4757 CrossRepoSort::Recent,
4758 );
4759 assert_eq!(report.totals.resolved_total, 10);
4760 assert_eq!(report.totals.project_wide_issues, 50);
4761 assert_eq!(report.totals.projects_with_baseline, 2);
4762 }
4763
4764 #[test]
4767 fn aggregate_sort_resolved_orders_by_resolved_total() {
4768 let _cfg = aggregate_env();
4769 seed_store("low", &store_with("low", 1, 0, "2026-06-10T00:00:00Z", 1));
4770 seed_store("high", &store_with("high", 9, 0, "2026-06-09T00:00:00Z", 1));
4771 let report = aggregate(CrossRepoSort::Resolved);
4772 assert_eq!(report.projects[0].label.as_deref(), Some("high"));
4773 assert_eq!(report.projects[1].label.as_deref(), Some("low"));
4774 }
4775
4776 #[test]
4777 fn aggregate_sort_contained_orders_by_containment_count() {
4778 let _cfg = aggregate_env();
4779 seed_store("none", &store_with("none", 1, 0, "2026-06-10T00:00:00Z", 1));
4780 seed_store("many", &store_with("many", 1, 3, "2026-06-09T00:00:00Z", 1));
4781 let report = aggregate(CrossRepoSort::Contained);
4782 assert_eq!(report.projects[0].label.as_deref(), Some("many"));
4783 assert_eq!(report.projects[1].label.as_deref(), Some("none"));
4784 }
4785
4786 #[test]
4787 fn aggregate_sort_name_orders_alphabetically_by_label() {
4788 let _cfg = aggregate_env();
4789 seed_store("zzz", &store_with("zulu", 1, 0, "2026-06-10T00:00:00Z", 1));
4790 seed_store("aaa", &store_with("alpha", 1, 0, "2026-06-09T00:00:00Z", 1));
4791 let report = aggregate(CrossRepoSort::Name);
4792 assert_eq!(report.projects[0].label.as_deref(), Some("alpha"));
4793 assert_eq!(report.projects[1].label.as_deref(), Some("zulu"));
4794 }
4795
4796 #[test]
4799 fn render_cross_repo_human_limit_shows_overflow_line() {
4800 let _cfg = aggregate_env();
4801 seed_store("a", &store_with("alpha", 1, 0, "2026-06-10T00:00:00Z", 1));
4802 seed_store("b", &store_with("beta", 2, 0, "2026-06-09T00:00:00Z", 1));
4803 seed_store("c", &store_with("gamma", 3, 0, "2026-06-08T00:00:00Z", 1));
4804 let report = aggregate(CrossRepoSort::Recent);
4805 let human = render_cross_repo_human(&report, Some(2));
4806 assert!(
4807 human.contains("and 1 more"),
4808 "overflow line missing when limit < tracked_count: {human}"
4809 );
4810 }
4811
4812 #[test]
4813 fn render_cross_repo_human_no_limit_shows_all_rows() {
4814 let _cfg = aggregate_env();
4815 seed_store("a", &store_with("alpha", 1, 0, "2026-06-10T00:00:00Z", 1));
4816 seed_store("b", &store_with("beta", 2, 0, "2026-06-09T00:00:00Z", 1));
4817 let report = aggregate(CrossRepoSort::Recent);
4818 let human = render_cross_repo_human(&report, None);
4819 assert!(
4820 !human.contains("more (raise --limit"),
4821 "no limit => no overflow line: {human}"
4822 );
4823 assert!(human.contains("alpha"));
4824 assert!(human.contains("beta"));
4825 }
4826
4827 #[test]
4830 fn render_cross_repo_human_shows_no_history_count() {
4831 let _cfg = aggregate_env();
4832 seed_store(
4833 "empty",
4834 &ImpactStore {
4835 enabled: true,
4836 explicit_decision: true,
4837 ..Default::default()
4838 },
4839 );
4840 seed_store("full", &store_with("full", 1, 0, "t0", 1));
4841 let report = aggregate(CrossRepoSort::Recent);
4842 let human = render_cross_repo_human(&report, None);
4843 assert!(
4844 human.contains("tracked project") && human.contains("no history yet"),
4845 "must report the no-history count: {human}"
4846 );
4847 }
4848
4849 #[test]
4850 fn render_cross_repo_human_shows_skipped_unreadable() {
4851 let _cfg = aggregate_env();
4852 seed_store("good", &store_with("good", 1, 0, "t0", 1));
4853 let dir = impact_config_dir().unwrap().join("impact");
4854 std::fs::write(dir.join("corrupt.json"), b"}{broken").unwrap();
4855 let report = aggregate(CrossRepoSort::Recent);
4856 let human = render_cross_repo_human(&report, None);
4857 assert!(
4858 human.contains("skipped") && human.contains("unreadable store"),
4859 "must show the skipped unreadable count: {human}"
4860 );
4861 }
4862
4863 #[test]
4866 fn render_cross_repo_human_grand_totals_shows_project_wide_when_present() {
4867 let _cfg = aggregate_env();
4868 let mut s = store_with("proj", 5, 1, "2026-06-10T00:00:00Z", 4);
4869 s.project_records.push(ImpactRecord {
4871 timestamp: "pt".to_owned(),
4872 version: "2.0.0".to_owned(),
4873 git_sha: None,
4874 verdict: "warn".to_owned(),
4875 gate: false,
4876 counts: ImpactCounts::from_combined(42, 0, 0),
4877 });
4878 seed_store("proj", &s);
4879 let report = aggregate(CrossRepoSort::Recent);
4880 let human = render_cross_repo_human(&report, None);
4881 assert!(
4882 human.contains("42 issue") && human.contains("project-wide"),
4883 "grand totals must include the project-wide line: {human}"
4884 );
4885 }
4886
4887 #[test]
4890 fn render_human_resolved_event_without_symbol_omits_symbol() {
4891 let report = ImpactReport {
4892 schema_version: ImpactReportSchemaVersion::V1,
4893 enabled: true,
4894 enabled_source: EnabledSource::Project,
4895 record_count: 2,
4896 meta: None,
4897 first_recorded: Some("2026-05-01T00:00:00Z".into()),
4898 latest_git_sha: None,
4899 surfacing: Some(ImpactCounts::default()),
4900 trend: None,
4901 project_surfacing: None,
4902 project_trend: None,
4903 containment_count: 0,
4904 recent_containment: vec![],
4905 resolved_total: 1,
4906 suppressed_total: 0,
4907 recent_resolved: vec![ResolutionEvent {
4908 kind: "unused-file".to_owned(),
4909 path: "src/dead.ts".to_owned(),
4910 symbol: None,
4911 git_sha: None,
4912 timestamp: "t1".to_owned(),
4913 }],
4914 attribution_active: true,
4915 onboarding_declined: false,
4916 explicit_decision: true,
4917 };
4918 let human = render_human(&report);
4919 assert!(
4921 human.contains("unused-file in src/dead.ts"),
4922 "no-symbol event must show 'kind in path': {human}"
4923 );
4924 assert!(!human.contains("None"), "must not stringify None: {human}");
4925 }
4926
4927 #[test]
4930 fn render_markdown_disabled_shows_enable_hint() {
4931 let report = ImpactReport {
4932 schema_version: ImpactReportSchemaVersion::V1,
4933 enabled: false,
4934 enabled_source: EnabledSource::Default,
4935 record_count: 0,
4936 meta: None,
4937 first_recorded: None,
4938 latest_git_sha: None,
4939 surfacing: None,
4940 trend: None,
4941 project_surfacing: None,
4942 project_trend: None,
4943 containment_count: 0,
4944 recent_containment: vec![],
4945 resolved_total: 0,
4946 suppressed_total: 0,
4947 recent_resolved: vec![],
4948 attribution_active: false,
4949 onboarding_declined: false,
4950 explicit_decision: false,
4951 };
4952 let md = render_markdown(&report);
4953 assert!(
4954 md.contains("Impact tracking is off"),
4955 "disabled markdown must show enable hint: {md}"
4956 );
4957 assert!(md.contains("fallow impact enable"));
4958 }
4959
4960 #[test]
4961 fn render_markdown_with_trend_shows_trend_line() {
4962 let report = ImpactReport {
4963 schema_version: ImpactReportSchemaVersion::V1,
4964 enabled: true,
4965 enabled_source: EnabledSource::Project,
4966 record_count: 2,
4967 meta: None,
4968 first_recorded: Some("2026-05-01T00:00:00Z".into()),
4969 latest_git_sha: None,
4970 surfacing: Some(ImpactCounts::from_combined(3, 3, 0)),
4971 trend: Some(TrendSummary {
4972 direction: ImpactTrendDirection::Declining,
4973 total_delta: 2,
4974 previous_total: 1,
4975 current_total: 3,
4976 }),
4977 project_surfacing: None,
4978 project_trend: None,
4979 containment_count: 0,
4980 recent_containment: vec![],
4981 resolved_total: 0,
4982 suppressed_total: 0,
4983 recent_resolved: vec![],
4984 attribution_active: false,
4985 onboarding_declined: false,
4986 explicit_decision: true,
4987 };
4988 let md = render_markdown(&report);
4989 assert!(
4990 md.contains("Trend (changed-file scope"),
4991 "markdown with trend must show trend line: {md}"
4992 );
4993 assert!(md.contains("1 -> 3 (up)"), "trend values present: {md}");
4994 }
4995
4996 #[test]
4997 fn render_markdown_with_project_trend_shows_project_trend_line() {
4998 let report = ImpactReport {
4999 schema_version: ImpactReportSchemaVersion::V1,
5000 enabled: true,
5001 enabled_source: EnabledSource::Project,
5002 record_count: 1,
5003 meta: None,
5004 first_recorded: Some("2026-05-01T00:00:00Z".into()),
5005 latest_git_sha: None,
5006 surfacing: None,
5007 trend: None,
5008 project_surfacing: Some(ImpactCounts::from_combined(10, 5, 3)),
5009 project_trend: Some(TrendSummary {
5010 direction: ImpactTrendDirection::Stable,
5011 total_delta: 0,
5012 previous_total: 10,
5013 current_total: 10,
5014 }),
5015 containment_count: 0,
5016 recent_containment: vec![],
5017 resolved_total: 0,
5018 suppressed_total: 0,
5019 recent_resolved: vec![],
5020 attribution_active: false,
5021 onboarding_declined: false,
5022 explicit_decision: true,
5023 };
5024 let md = render_markdown(&report);
5025 assert!(
5026 md.contains("Project trend (whole project"),
5027 "project trend line must appear in markdown: {md}"
5028 );
5029 assert!(
5030 md.contains("10 -> 10 (flat)"),
5031 "stable trend must read 'flat': {md}"
5032 );
5033 }
5034
5035 #[test]
5036 fn render_markdown_enabled_no_history_shows_check_back() {
5037 let report = ImpactReport {
5038 schema_version: ImpactReportSchemaVersion::V1,
5039 enabled: true,
5040 enabled_source: EnabledSource::Project,
5041 record_count: 0,
5042 meta: None,
5043 first_recorded: None,
5044 latest_git_sha: None,
5045 surfacing: None,
5046 trend: None,
5047 project_surfacing: None,
5048 project_trend: None,
5049 containment_count: 0,
5050 recent_containment: vec![],
5051 resolved_total: 0,
5052 suppressed_total: 0,
5053 recent_resolved: vec![],
5054 attribution_active: false,
5055 onboarding_declined: false,
5056 explicit_decision: true,
5057 };
5058 let md = render_markdown(&report);
5059 assert!(
5060 md.contains("No history yet"),
5061 "enabled but empty markdown must show 'No history yet': {md}"
5062 );
5063 }
5064
5065 #[test]
5066 fn render_markdown_resolved_shows_count_when_positive() {
5067 let report = ImpactReport {
5068 schema_version: ImpactReportSchemaVersion::V1,
5069 enabled: true,
5070 enabled_source: EnabledSource::Project,
5071 record_count: 3,
5072 meta: None,
5073 first_recorded: Some("2026-05-01T00:00:00Z".into()),
5074 latest_git_sha: None,
5075 surfacing: Some(ImpactCounts::default()),
5076 trend: None,
5077 project_surfacing: None,
5078 project_trend: None,
5079 containment_count: 0,
5080 recent_containment: vec![],
5081 resolved_total: 4,
5082 suppressed_total: 1,
5083 recent_resolved: vec![],
5084 attribution_active: true,
5085 onboarding_declined: false,
5086 explicit_decision: true,
5087 };
5088 let md = render_markdown(&report);
5089 assert!(
5090 md.contains("**Resolved:** 4 finding"),
5091 "markdown must show resolved count: {md}"
5092 );
5093 assert!(
5094 md.contains("**Marked intentional:** 1 finding"),
5095 "markdown must show suppressed count: {md}"
5096 );
5097 }
5098
5099 #[test]
5102 fn render_markdown_footer_project_only_no_audit_records() {
5103 let report = ImpactReport {
5106 schema_version: ImpactReportSchemaVersion::V1,
5107 enabled: true,
5108 enabled_source: EnabledSource::Project,
5109 record_count: 0,
5110 meta: None,
5111 first_recorded: Some("2026-05-10T00:00:00Z".into()),
5112 latest_git_sha: None,
5113 surfacing: None,
5114 trend: None,
5115 project_surfacing: Some(ImpactCounts::from_combined(3, 2, 1)),
5116 project_trend: None,
5117 containment_count: 0,
5118 recent_containment: vec![],
5119 resolved_total: 0,
5120 suppressed_total: 0,
5121 recent_resolved: vec![],
5122 attribution_active: false,
5123 onboarding_declined: false,
5124 explicit_decision: true,
5125 };
5126 let md = render_markdown(&report);
5127 assert!(
5128 md.contains("Tracking since 2026-05-10"),
5129 "project-only footer must say 'Tracking since': {md}"
5130 );
5131 assert!(
5132 !md.contains("recorded audit run"),
5133 "project-only footer must not mention 'recorded audit run': {md}"
5134 );
5135 }
5136
5137 #[test]
5140 fn render_cross_repo_markdown_includes_project_wide_totals_when_present() {
5141 let _cfg = aggregate_env();
5142 let mut s = store_with("proj", 2, 0, "t0", 1);
5143 s.project_records.push(ImpactRecord {
5144 timestamp: "pt".to_owned(),
5145 version: "2.0.0".to_owned(),
5146 git_sha: None,
5147 verdict: "warn".to_owned(),
5148 gate: false,
5149 counts: ImpactCounts::from_combined(99, 0, 0),
5150 });
5151 seed_store("proj", &s);
5152 let report = aggregate(CrossRepoSort::Recent);
5153 let md = render_cross_repo_markdown(&report);
5154 assert!(
5155 md.contains("99 issue") && md.contains("project-wide"),
5156 "cross-repo markdown must show project-wide totals: {md}"
5157 );
5158 }
5159
5160 #[test]
5163 #[cfg_attr(miri, ignore)]
5164 fn migrate_legacy_store_corrupt_file_returns_default() {
5165 let (_config, dir) = test_env();
5166 let root = dir.path();
5167 std::fs::create_dir_all(root.join(".fallow")).unwrap();
5169 std::fs::write(legacy_store_path(root), b"{ corrupted json ][").unwrap();
5170 let store = load(root);
5172 assert!(!store.enabled);
5174 assert!(store.records.is_empty());
5175 }
5176
5177 #[test]
5180 fn resolve_enabled_user_source_appears_in_report() {
5181 let (_config, _dir) = test_env();
5182 set_global_default(true);
5183 let never_asked = ImpactStore::default();
5184 let (enabled, source) = resolve_enabled(&never_asked);
5185 assert!(enabled);
5186 assert_eq!(source, EnabledSource::User);
5187 let report = build_report(&never_asked);
5188 assert_eq!(report.enabled_source, EnabledSource::User);
5189 }
5190
5191 #[test]
5194 fn render_cross_repo_human_long_label_is_truncated_in_table() {
5195 let _cfg = aggregate_env();
5196 let very_long_name = "this_is_a_very_long_project_name_that_exceeds_the_column_width";
5197 let s = store_with(very_long_name, 1, 0, "2026-06-10T00:00:00Z", 1);
5198 seed_store("longkey", &s);
5199 let report = aggregate(CrossRepoSort::Recent);
5200 let human = render_cross_repo_human(&report, None);
5201 assert!(
5203 human.contains("..."),
5204 "long label must be truncated with '...': {human}"
5205 );
5206 }
5207
5208 #[test]
5211 fn aggregate_sort_name_falls_back_to_short_key_when_no_label() {
5212 let _cfg = aggregate_env();
5213 let mut s = ImpactStore {
5215 enabled: true,
5216 explicit_decision: true,
5217 resolved_total: 1,
5218 label: None,
5219 ..Default::default()
5220 };
5221 s.records.push(ImpactRecord {
5222 timestamp: "t0".to_owned(),
5223 version: "2.0.0".to_owned(),
5224 git_sha: None,
5225 verdict: "warn".to_owned(),
5226 gate: false,
5227 counts: ImpactCounts::from_combined(1, 0, 0),
5228 });
5229 seed_store("abcdefghijklmnop", &s);
5230 let report = aggregate(CrossRepoSort::Name);
5231 assert_eq!(report.tracked_count, 1);
5233 assert_eq!(report.projects[0].project_key, "abcdefghijklmnop");
5235 }
5236
5237 #[test]
5240 fn row_trend_falls_back_to_changed_file_trend_when_no_project_trend() {
5241 let report = ImpactReport {
5242 schema_version: ImpactReportSchemaVersion::V1,
5243 enabled: true,
5244 enabled_source: EnabledSource::Project,
5245 record_count: 2,
5246 meta: None,
5247 first_recorded: None,
5248 latest_git_sha: None,
5249 surfacing: Some(ImpactCounts::default()),
5250 trend: Some(TrendSummary {
5251 direction: ImpactTrendDirection::Improving,
5252 total_delta: -3,
5253 previous_total: 8,
5254 current_total: 5,
5255 }),
5256 project_surfacing: None,
5257 project_trend: None,
5258 containment_count: 0,
5259 recent_containment: vec![],
5260 resolved_total: 0,
5261 suppressed_total: 0,
5262 recent_resolved: vec![],
5263 attribution_active: false,
5264 onboarding_declined: false,
5265 explicit_decision: true,
5266 };
5267 assert_eq!(row_trend(&report), "down");
5268 }
5269
5270 #[test]
5271 fn row_trend_returns_dash_when_no_trend_at_all() {
5272 let report = ImpactReport {
5273 schema_version: ImpactReportSchemaVersion::V1,
5274 enabled: true,
5275 enabled_source: EnabledSource::Project,
5276 record_count: 1,
5277 meta: None,
5278 first_recorded: None,
5279 latest_git_sha: None,
5280 surfacing: Some(ImpactCounts::default()),
5281 trend: None,
5282 project_surfacing: None,
5283 project_trend: None,
5284 containment_count: 0,
5285 recent_containment: vec![],
5286 resolved_total: 0,
5287 suppressed_total: 0,
5288 recent_resolved: vec![],
5289 attribution_active: false,
5290 onboarding_declined: false,
5291 explicit_decision: true,
5292 };
5293 assert_eq!(row_trend(&report), "-");
5294 }
5295
5296 #[test]
5299 fn opt_count_returns_dash_for_none_and_total_for_some() {
5300 assert_eq!(opt_count(None), "-");
5301 assert_eq!(opt_count(Some(&ImpactCounts::from_combined(4, 2, 1))), "7");
5303 }
5304
5305 #[test]
5308 #[cfg_attr(miri, ignore)]
5309 fn impact_project_key_is_a_hex_string_with_no_separator() {
5310 let (_config, dir) = test_env();
5311 let root = dir.path();
5312 let key = project_identity(root).0;
5313 assert!(
5314 !key.contains('/') && !key.contains('\\'),
5315 "project key must not contain a path separator: {key}"
5316 );
5317 assert!(!key.is_empty(), "project key must not be empty");
5318 }
5319
5320 #[test]
5323 fn impact_counts_from_combined_sums_to_total() {
5324 let c = ImpactCounts::from_combined(3, 2, 1);
5325 assert_eq!(c.total_issues, 6);
5326 assert_eq!(c.dead_code, 3);
5327 assert_eq!(c.complexity, 2);
5328 assert_eq!(c.duplication, 1);
5329 }
5330
5331 #[test]
5334 fn render_cross_repo_markdown_all_empty_projects_tracked_count_zero() {
5335 let _cfg = aggregate_env();
5337 seed_store(
5338 "empty1",
5339 &ImpactStore {
5340 enabled: true,
5341 explicit_decision: true,
5342 ..Default::default()
5343 },
5344 );
5345 let report = aggregate(CrossRepoSort::Recent);
5346 assert_eq!(report.project_count, 1);
5347 assert_eq!(report.tracked_count, 0);
5348 let md = render_cross_repo_markdown(&report);
5349 assert!(
5351 md.contains("1 project tracked, 0 with history"),
5352 "must show counts: {md}"
5353 );
5354 assert!(
5355 !md.contains("| Project |"),
5356 "no table when tracked_count is 0: {md}"
5357 );
5358 }
5359
5360 #[test]
5363 fn render_cross_repo_markdown_zero_projects_with_unreadable() {
5364 let _cfg = aggregate_env();
5365 let dir = impact_config_dir().unwrap().join("impact");
5366 std::fs::create_dir_all(&dir).unwrap();
5367 std::fs::write(dir.join("bad.json"), b"}{bad").unwrap();
5368 let report = aggregate(CrossRepoSort::Recent);
5369 assert_eq!(report.project_count, 0);
5370 assert_eq!(report.unreadable_count, 1);
5371 let md = render_cross_repo_markdown(&report);
5372 assert!(
5373 md.contains("skipped") && md.contains("unreadable store"),
5374 "must report corrupt stores: {md}"
5375 );
5376 }
5377}