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