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}
1562
1563fn collect_catalog_findings(
1564 results: &AnalysisResults,
1565 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1566) {
1567 for f in &results.unused_catalog_entries {
1568 push(
1569 &f.entry.path,
1570 "unused-catalog-entry",
1571 Some(format!(
1572 "{}{ID_SEP}{}",
1573 f.entry.catalog_name, f.entry.entry_name
1574 )),
1575 );
1576 }
1577 for f in &results.empty_catalog_groups {
1578 push(
1579 &f.group.path,
1580 "empty-catalog-group",
1581 Some(f.group.catalog_name.clone()),
1582 );
1583 }
1584 for f in &results.unresolved_catalog_references {
1585 push(
1586 &f.reference.path,
1587 "unresolved-catalog-reference",
1588 Some(format!(
1589 "{}{ID_SEP}{}",
1590 f.reference.catalog_name, f.reference.entry_name
1591 )),
1592 );
1593 }
1594 for f in &results.unused_dependency_overrides {
1595 push(
1596 &f.entry.path,
1597 "unused-dependency-override",
1598 Some(f.entry.raw_key.clone()),
1599 );
1600 }
1601 for f in &results.misconfigured_dependency_overrides {
1602 push(
1603 &f.entry.path,
1604 "misconfigured-dependency-override",
1605 Some(f.entry.raw_key.clone()),
1606 );
1607 }
1608}
1609
1610#[must_use]
1614pub fn collect_complexity_findings(report: &fallow_output::HealthReport) -> Vec<FindingInput> {
1615 report
1616 .findings
1617 .iter()
1618 .map(|f| FindingInput {
1619 path: f.path.clone(),
1620 kind: "complexity",
1621 symbol: Some(f.name.clone()),
1622 })
1623 .collect()
1624}
1625
1626#[must_use]
1630pub fn collect_clone_findings(
1631 report: &fallow_types::duplicates::DuplicationReport,
1632) -> Vec<CloneInput> {
1633 report
1634 .clone_groups
1635 .iter()
1636 .map(|g| CloneInput {
1637 fingerprint: fallow_engine::duplicates::clone_fingerprint(&g.instances),
1638 instance_paths: g.instances.iter().map(|i| i.file.clone()).collect(),
1639 })
1640 .collect()
1641}
1642
1643const fn verdict_label(verdict: AuditVerdict) -> &'static str {
1644 match verdict {
1645 AuditVerdict::Pass => "pass",
1646 AuditVerdict::Warn => "warn",
1647 AuditVerdict::Fail => "fail",
1648 }
1649}
1650
1651fn direction_for(delta: i64) -> ImpactTrendDirection {
1652 if delta < -TREND_TOLERANCE {
1653 ImpactTrendDirection::Improving
1654 } else if delta > TREND_TOLERANCE {
1655 ImpactTrendDirection::Declining
1656 } else {
1657 ImpactTrendDirection::Stable
1658 }
1659}
1660
1661fn trend_for(records: &[ImpactRecord]) -> Option<TrendSummary> {
1667 if records.len() < 2 {
1668 return None;
1669 }
1670 let current = &records[records.len() - 1];
1671 let previous = &records[records.len() - 2];
1672 let current_total = current.counts.total_issues;
1673 let previous_total = previous.counts.total_issues;
1674 let total_delta = current_total as i64 - previous_total as i64;
1675 Some(TrendSummary {
1676 direction: direction_for(total_delta),
1677 total_delta,
1678 previous_total,
1679 current_total,
1680 })
1681}
1682
1683pub fn build_report(store: &ImpactStore) -> ImpactReport {
1684 let surfacing = store.records.last().map(|r| r.counts.clone());
1685 let trend = trend_for(&store.records);
1686 let project_surfacing = store.project_records.last().map(|r| r.counts.clone());
1687 let project_trend = trend_for(&store.project_records);
1688
1689 let recent_containment = store
1690 .containment
1691 .iter()
1692 .rev()
1693 .take(5)
1694 .rev()
1695 .cloned()
1696 .collect();
1697
1698 let latest_git_sha = store.records.last().and_then(|r| r.git_sha.clone());
1699
1700 let recent_resolved = store
1701 .recent_resolved
1702 .iter()
1703 .rev()
1704 .take(5)
1705 .rev()
1706 .cloned()
1707 .collect();
1708 let attribution_active = !store.frontier.is_empty()
1709 || !store.clone_frontier.is_empty()
1710 || store.resolved_total > 0
1711 || store.suppressed_total > 0;
1712
1713 let (enabled, enabled_source) = resolve_enabled(store);
1714 ImpactReport {
1715 schema_version: ImpactReportSchemaVersion::V1,
1716 enabled,
1717 enabled_source,
1718 record_count: store.records.len(),
1719 meta: None,
1720 first_recorded: store.first_recorded.clone(),
1721 latest_git_sha,
1722 surfacing,
1723 trend,
1724 project_surfacing,
1725 project_trend,
1726 containment_count: store.containment.len(),
1727 recent_containment,
1728 resolved_total: store.resolved_total,
1729 suppressed_total: store.suppressed_total,
1730 recent_resolved,
1731 attribution_active,
1732 onboarding_declined: store.onboarding_declined,
1733 explicit_decision: store.explicit_decision,
1734 }
1735}
1736
1737#[derive(Debug, Clone, Copy)]
1739pub enum CrossRepoSort {
1740 Recent,
1742 Resolved,
1744 Contained,
1746 Name,
1748}
1749
1750fn latest_activity(store: &ImpactStore) -> Option<String> {
1752 let a = store.records.last().map(|r| r.timestamp.clone());
1753 let b = store.project_records.last().map(|r| r.timestamp.clone());
1754 match (a, b) {
1755 (Some(x), Some(y)) => Some(if x >= y { x } else { y }),
1756 (x, y) => x.or(y),
1757 }
1758}
1759
1760#[must_use]
1766pub fn load_all() -> (Vec<(String, ImpactStore)>, usize) {
1767 let Some(dir) = impact_config_dir().map(|d| d.join("impact")) else {
1768 return (Vec::new(), 0);
1769 };
1770 let Ok(read) = std::fs::read_dir(&dir) else {
1771 return (Vec::new(), 0);
1772 };
1773 let mut stores = Vec::new();
1774 let mut unreadable = 0usize;
1775 for entry in read.flatten() {
1776 let path = entry.path();
1777 if path.extension().and_then(|e| e.to_str()) != Some("json") {
1778 continue;
1779 }
1780 let Some(key) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
1781 continue;
1782 };
1783 match std::fs::read_to_string(&path)
1784 .ok()
1785 .and_then(|c| serde_json::from_str::<ImpactStore>(&c).ok())
1786 {
1787 Some(store) => stores.push((key, store)),
1788 None => unreadable += 1,
1789 }
1790 }
1791 (stores, unreadable)
1792}
1793
1794#[must_use]
1798pub fn build_aggregate_report(
1799 stores: Vec<(String, ImpactStore)>,
1800 unreadable: usize,
1801 sort: CrossRepoSort,
1802) -> CrossRepoImpactReport {
1803 let project_count = stores.len();
1804 let mut totals = CrossRepoTotals::default();
1805 let mut projects = Vec::new();
1806 for (key, store) in stores {
1807 let report = build_report(&store);
1808 let has_history = report.record_count > 0
1809 || report.project_surfacing.is_some()
1810 || report.resolved_total > 0
1811 || report.containment_count > 0;
1812 if !has_history {
1813 continue;
1814 }
1815 totals.resolved_total += report.resolved_total;
1816 totals.suppressed_total += report.suppressed_total;
1817 totals.containment_count += report.containment_count;
1818 if let Some(ps) = &report.project_surfacing {
1819 totals.project_wide_issues += ps.total_issues;
1820 totals.projects_with_baseline += 1;
1821 }
1822 projects.push(CrossRepoProjectEntry {
1823 project_key: key,
1824 label: store.label.clone(),
1825 last_recorded: latest_activity(&store),
1826 report,
1827 });
1828 }
1829 sort_cross_repo(&mut projects, sort);
1830 CrossRepoImpactReport {
1831 schema_version: CrossRepoImpactSchemaVersion::V1,
1832 project_count,
1833 tracked_count: projects.len(),
1834 unreadable_count: unreadable,
1835 totals,
1836 projects,
1837 }
1838}
1839
1840fn sort_cross_repo(projects: &mut [CrossRepoProjectEntry], sort: CrossRepoSort) {
1841 match sort {
1842 CrossRepoSort::Recent => projects.sort_by(|a, b| {
1845 b.last_recorded
1846 .cmp(&a.last_recorded)
1847 .then_with(|| a.project_key.cmp(&b.project_key))
1848 }),
1849 CrossRepoSort::Resolved => projects.sort_by(|a, b| {
1850 b.report
1851 .resolved_total
1852 .cmp(&a.report.resolved_total)
1853 .then_with(|| a.project_key.cmp(&b.project_key))
1854 }),
1855 CrossRepoSort::Contained => projects.sort_by(|a, b| {
1856 b.report
1857 .containment_count
1858 .cmp(&a.report.containment_count)
1859 .then_with(|| a.project_key.cmp(&b.project_key))
1860 }),
1861 CrossRepoSort::Name => projects.sort_by(|a, b| {
1862 cross_repo_label(a)
1863 .cmp(&cross_repo_label(b))
1864 .then_with(|| a.project_key.cmp(&b.project_key))
1865 }),
1866 }
1867}
1868
1869fn cross_repo_label(entry: &CrossRepoProjectEntry) -> String {
1872 entry
1873 .label
1874 .clone()
1875 .unwrap_or_else(|| short_key(&entry.project_key))
1876}
1877
1878fn short_key(key: &str) -> String {
1880 key.chars().take(12).collect()
1881}
1882
1883#[must_use]
1885pub fn aggregate(sort: CrossRepoSort) -> CrossRepoImpactReport {
1886 let (stores, unreadable) = load_all();
1887 build_aggregate_report(stores, unreadable, sort)
1888}
1889
1890#[expect(
1896 clippy::format_push_string,
1897 reason = "small report renderer; readability over avoiding the extra allocation"
1898)]
1899fn render_project_section(out: &mut String, report: &ImpactReport) {
1900 let Some(s) = &report.project_surfacing else {
1901 return;
1902 };
1903 out.push_str(&format!(
1904 " WHOLE PROJECT (whole-repo context, not a to-do)\n {} issue{} across the whole project at your last full `fallow` run\n",
1905 s.total_issues,
1906 plural(s.total_issues),
1907 ));
1908 if let Some(t) = &report.project_trend {
1909 let arrow = trend_arrow(t.direction);
1910 out.push_str(&format!(
1911 " {} -> {} ({}) across your last two full runs (comparable over time)\n",
1912 t.previous_total, t.current_total, arrow,
1913 ));
1914 } else {
1915 out.push_str(" project trend starts after your next full `fallow` run\n");
1916 }
1917 out.push_str(" advances only on your local full `fallow` runs, not CI\n\n");
1918}
1919
1920#[expect(
1922 clippy::format_push_string,
1923 reason = "small report renderer; readability over avoiding the extra allocation"
1924)]
1925pub fn render_human(report: &ImpactReport) -> String {
1926 let mut out = String::new();
1927 out.push_str("FALLOW IMPACT\n\n");
1928
1929 if !report.enabled {
1930 out.push_str(
1931 "Impact tracking is off. Enable it with `fallow impact enable`, then\n\
1932 let your pre-commit gate run a few times to build history.\n",
1933 );
1934 return out;
1935 }
1936
1937 if report.enabled_source == EnabledSource::User {
1938 out.push_str(
1939 "Enabled by your user-global default (`fallow impact default on`). Run\n\
1940 `fallow impact disable` to opt this project out.\n\n",
1941 );
1942 }
1943
1944 if report.record_count == 0 && report.project_surfacing.is_none() {
1945 out.push_str(
1946 "Tracking enabled. No history yet: check back after your next few\n\
1947 commits (Impact records each `fallow audit` / pre-commit gate run,\n\
1948 and each full `fallow` run for the whole-project view).\n",
1949 );
1950 return out;
1951 }
1952
1953 render_human_changed_section(&mut out, report);
1954
1955 render_project_section(&mut out, report);
1956
1957 out.push_str(&format!(
1958 " CONTAINED AT COMMIT\n {} time{} fallow blocked a commit until it was fixed\n",
1959 report.containment_count,
1960 plural(report.containment_count),
1961 ));
1962
1963 render_human_resolved_section(&mut out, report);
1964
1965 render_human_footer(&mut out, report);
1966 out
1967}
1968
1969#[expect(
1971 clippy::format_push_string,
1972 reason = "small report renderer; readability over avoiding the extra allocation"
1973)]
1974fn render_human_changed_section(out: &mut String, report: &ImpactReport) {
1975 if let Some(s) = &report.surfacing {
1976 out.push_str(&format!(
1977 " LATEST RUN (changed files, act on these now)\n {} issue{} flagged in your last `fallow audit` run\n",
1978 s.total_issues,
1979 plural(s.total_issues),
1980 ));
1981 out.push_str(&format!(
1982 " dead code {} · complexity {} · duplication {}\n\n",
1983 s.dead_code, s.complexity, s.duplication,
1984 ));
1985 }
1986
1987 if let Some(t) = &report.trend {
1988 let arrow = trend_arrow(t.direction);
1989 out.push_str(&format!(
1990 " 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",
1991 t.previous_total, t.current_total, arrow,
1992 ));
1993 }
1994}
1995
1996#[expect(
1998 clippy::format_push_string,
1999 reason = "small report renderer; readability over avoiding the extra allocation"
2000)]
2001fn render_human_resolved_section(out: &mut String, report: &ImpactReport) {
2002 if report.resolved_total > 0 {
2003 out.push_str(&format!(
2004 "\n RESOLVED\n {} finding{} you cleared since fallow started tracking\n",
2005 report.resolved_total,
2006 plural(report.resolved_total),
2007 ));
2008 for ev in &report.recent_resolved {
2009 match &ev.symbol {
2010 Some(symbol) => {
2011 out.push_str(&format!(" {} {} in {}\n", ev.kind, symbol, ev.path));
2012 }
2013 None => out.push_str(&format!(" {} in {}\n", ev.kind, ev.path)),
2014 }
2015 }
2016 } else if report.attribution_active {
2017 out.push_str(
2018 "\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",
2019 );
2020 } else {
2021 out.push_str("\n RESOLVED\n resolution tracking starts from your next gate run\n");
2022 }
2023
2024 if report.suppressed_total > 0 {
2025 out.push_str(&format!(
2026 " {} finding{} you marked intentional (fallow-ignore), not counted as resolved\n",
2027 report.suppressed_total,
2028 plural(report.suppressed_total),
2029 ));
2030 }
2031}
2032
2033#[expect(
2035 clippy::format_push_string,
2036 reason = "small report renderer; readability over avoiding the extra allocation"
2037)]
2038fn render_human_footer(out: &mut String, report: &ImpactReport) {
2039 out.push('\n');
2040 let since = report
2041 .first_recorded
2042 .as_deref()
2043 .map_or("the first run", date_only);
2044 if report.record_count > 0 {
2045 out.push_str(&format!(
2046 "Based on {} recorded audit run{} since {}. Local-only; never uploaded.\n\
2047 Changed-file scope: each audit run only sees files differing from your base.\n",
2048 report.record_count,
2049 plural(report.record_count),
2050 since,
2051 ));
2052 } else {
2053 out.push_str(&format!(
2054 "Tracking since {since}. Local-only; never uploaded.\n",
2055 ));
2056 }
2057 out.push_str(
2058 "Resolution tracking is a local-developer signal: it accrues on your\n\
2059 machine across runs, not in CI (fallow never records there).\n",
2060 );
2061}
2062
2063pub fn render_json(report: &ImpactReport) -> String {
2065 let value = fallow_output::serialize_impact_json_output(
2066 report.clone(),
2067 crate::output_runtime::current_root_envelope_mode(),
2068 crate::output_runtime::telemetry_analysis_run_id().as_deref(),
2069 )
2070 .unwrap_or_else(|_| serde_json::json!({"error":"failed to serialize impact report"}));
2071 serde_json::to_string_pretty(&value)
2072 .unwrap_or_else(|_| "{\"error\":\"failed to serialize impact report\"}".to_owned())
2073}
2074
2075#[expect(
2079 clippy::format_push_string,
2080 reason = "small report renderer; readability over avoiding the extra allocation"
2081)]
2082fn render_project_markdown(out: &mut String, report: &ImpactReport) {
2083 let Some(s) = &report.project_surfacing else {
2084 return;
2085 };
2086 out.push_str(&format!(
2087 "- **Whole project (whole-repo context, last full `fallow` run):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
2088 s.total_issues,
2089 plural(s.total_issues),
2090 s.dead_code,
2091 s.complexity,
2092 s.duplication,
2093 ));
2094 if let Some(t) = &report.project_trend {
2095 let arrow = trend_arrow(t.direction);
2096 out.push_str(&format!(
2097 "- **Project trend (whole project, last two full runs):** {} -> {} ({})\n",
2098 t.previous_total, t.current_total, arrow,
2099 ));
2100 }
2101}
2102
2103#[expect(
2105 clippy::format_push_string,
2106 reason = "small report renderer; readability over avoiding the extra allocation"
2107)]
2108pub fn render_markdown(report: &ImpactReport) -> String {
2109 let mut out = String::new();
2110 out.push_str("## Fallow impact\n\n");
2111
2112 if !report.enabled {
2113 out.push_str("Impact tracking is off. Run `fallow impact enable` to start.\n");
2114 return out;
2115 }
2116 if report.record_count == 0 && report.project_surfacing.is_none() {
2117 out.push_str("Tracking enabled. No history yet; check back after a few commits.\n");
2118 return out;
2119 }
2120
2121 if let Some(s) = &report.surfacing {
2122 out.push_str(&format!(
2123 "- **Latest run (changed files):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
2124 s.total_issues,
2125 plural(s.total_issues),
2126 s.dead_code,
2127 s.complexity,
2128 s.duplication,
2129 ));
2130 }
2131 if let Some(t) = &report.trend {
2132 out.push_str(&format!(
2133 "- **Trend (changed-file scope, last two runs):** {} -> {} ({})\n",
2134 t.previous_total,
2135 t.current_total,
2136 trend_arrow(t.direction),
2137 ));
2138 }
2139 render_project_markdown(&mut out, report);
2140 out.push_str(&format!(
2141 "- **Contained at commit:** {} time{}\n",
2142 report.containment_count,
2143 plural(report.containment_count),
2144 ));
2145 render_markdown_resolved_section(&mut out, report);
2146 render_markdown_footer(&mut out, report);
2147 out
2148}
2149
2150#[expect(
2152 clippy::format_push_string,
2153 reason = "small report renderer; readability over avoiding the extra allocation"
2154)]
2155fn render_markdown_resolved_section(out: &mut String, report: &ImpactReport) {
2156 if report.resolved_total > 0 {
2157 out.push_str(&format!(
2158 "- **Resolved:** {} finding{} cleared since tracking started\n",
2159 report.resolved_total,
2160 plural(report.resolved_total),
2161 ));
2162 } else if report.attribution_active {
2163 out.push_str("- **Resolved:** none yet; tracking active\n");
2164 } else {
2165 out.push_str("- **Resolved:** resolution tracking starts from your next gate run\n");
2166 }
2167 if report.suppressed_total > 0 {
2168 out.push_str(&format!(
2169 "- **Marked intentional:** {} finding{} (`fallow-ignore`), not counted as resolved\n",
2170 report.suppressed_total,
2171 plural(report.suppressed_total),
2172 ));
2173 }
2174}
2175
2176#[expect(
2178 clippy::format_push_string,
2179 reason = "small report renderer; readability over avoiding the extra allocation"
2180)]
2181fn render_markdown_footer(out: &mut String, report: &ImpactReport) {
2182 let since = report
2183 .first_recorded
2184 .as_deref()
2185 .map_or("the first run", date_only);
2186 if report.record_count > 0 {
2187 out.push_str(&format!(
2188 "\n_Based on {} recorded audit run{} since {}. Local-only; resolution is a local-developer signal._\n",
2189 report.record_count,
2190 plural(report.record_count),
2191 since,
2192 ));
2193 } else {
2194 out.push_str(&format!(
2195 "\n_Tracking since {since}. Local-only; resolution is a local-developer signal._\n",
2196 ));
2197 }
2198}
2199
2200#[must_use]
2202pub fn render_cross_repo_json(report: &CrossRepoImpactReport) -> String {
2203 let value = fallow_output::serialize_cross_repo_impact_json_output(
2204 report.clone(),
2205 crate::output_runtime::current_root_envelope_mode(),
2206 crate::output_runtime::telemetry_analysis_run_id().as_deref(),
2207 )
2208 .unwrap_or_else(
2209 |_| serde_json::json!({"error":"failed to serialize cross-repo impact report"}),
2210 );
2211 serde_json::to_string_pretty(&value).unwrap_or_else(|_| {
2212 "{\"error\":\"failed to serialize cross-repo impact report\"}".to_owned()
2213 })
2214}
2215
2216fn row_label(entry: &CrossRepoProjectEntry) -> String {
2219 cross_repo_label(entry)
2220}
2221
2222fn opt_count(c: Option<&ImpactCounts>) -> String {
2223 c.map_or_else(|| "-".to_owned(), |c| c.total_issues.to_string())
2224}
2225
2226fn row_trend(report: &ImpactReport) -> &'static str {
2227 report
2228 .project_trend
2229 .as_ref()
2230 .or(report.trend.as_ref())
2231 .map_or("-", |t| trend_arrow(t.direction))
2232}
2233
2234#[expect(
2238 clippy::format_push_string,
2239 reason = "small report renderer; readability over avoiding the extra allocation"
2240)]
2241#[must_use]
2242pub fn render_cross_repo_human(report: &CrossRepoImpactReport, limit: Option<usize>) -> String {
2243 let mut out = String::new();
2244 out.push_str("FALLOW IMPACT (ALL PROJECTS)\n\n");
2245
2246 if report.project_count == 0 {
2247 if report.unreadable_count > 0 {
2248 out.push_str(&format!(
2249 "No readable projects: skipped {} unreadable store{} (corrupt, or written by \
2250 a newer fallow). Upgrade fallow to read them.\n",
2251 report.unreadable_count,
2252 plural(report.unreadable_count),
2253 ));
2254 } else {
2255 out.push_str(
2256 "No projects tracked yet. Enable in a repo with `fallow impact enable`, or for \
2257 every project with `fallow impact default on`.\n",
2258 );
2259 }
2260 return out;
2261 }
2262
2263 out.push_str(&format!(
2264 "{} project{} tracked, {} with history\n\n",
2265 report.project_count,
2266 plural(report.project_count),
2267 report.tracked_count,
2268 ));
2269
2270 render_cross_repo_table(&mut out, report, limit);
2271 render_cross_repo_skipped(&mut out, report);
2272 render_cross_repo_totals(&mut out, report);
2273 out.push_str("\nLocal-only; never uploaded; accrues on this machine, not CI.\n");
2274 out
2275}
2276
2277#[expect(
2279 clippy::format_push_string,
2280 reason = "small report renderer; readability over avoiding the extra allocation"
2281)]
2282fn render_cross_repo_table(out: &mut String, report: &CrossRepoImpactReport, limit: Option<usize>) {
2283 if report.projects.is_empty() {
2284 return;
2285 }
2286 out.push_str(&format!(
2287 "{:<24}{:>8}{:>10}{:>11}{:>10}{:>7} {}\n",
2288 "PROJECT", "LATEST", "REPO-WIDE", "CONTAINED", "RESOLVED", "TREND", "LAST RUN",
2289 ));
2290 let rows = limit.map_or(report.projects.len(), |n| n.min(report.projects.len()));
2291 for entry in report.projects.iter().take(rows) {
2292 let mut label = row_label(entry);
2293 if label.chars().count() > 22 {
2294 label = format!("{}...", label.chars().take(19).collect::<String>());
2295 }
2296 let last = entry
2297 .last_recorded
2298 .as_deref()
2299 .map_or("-", date_only)
2300 .to_owned();
2301 out.push_str(&format!(
2302 "{:<24}{:>8}{:>10}{:>11}{:>10}{:>7} {}\n",
2303 label,
2304 opt_count(entry.report.surfacing.as_ref()),
2305 opt_count(entry.report.project_surfacing.as_ref()),
2306 entry.report.containment_count,
2307 entry.report.resolved_total,
2308 row_trend(&entry.report),
2309 last,
2310 ));
2311 }
2312 if let Some(n) = limit
2313 && report.projects.len() > n
2314 {
2315 out.push_str(&format!(
2316 " ... and {} more (raise --limit to show)\n",
2317 report.projects.len() - n,
2318 ));
2319 }
2320}
2321
2322#[expect(
2324 clippy::format_push_string,
2325 reason = "small report renderer; readability over avoiding the extra allocation"
2326)]
2327fn render_cross_repo_skipped(out: &mut String, report: &CrossRepoImpactReport) {
2328 let no_history = report.project_count.saturating_sub(report.tracked_count);
2329 if no_history > 0 {
2330 out.push_str(&format!(
2331 "\n{no_history} tracked project{} with no history yet\n",
2332 plural(no_history),
2333 ));
2334 }
2335 if report.unreadable_count > 0 {
2336 out.push_str(&format!(
2337 "skipped {} unreadable store{}\n",
2338 report.unreadable_count,
2339 plural(report.unreadable_count),
2340 ));
2341 }
2342}
2343
2344#[expect(
2346 clippy::format_push_string,
2347 reason = "small report renderer; readability over avoiding the extra allocation"
2348)]
2349fn render_cross_repo_totals(out: &mut String, report: &CrossRepoImpactReport) {
2350 let t = &report.totals;
2351 out.push_str("\nGRAND TOTALS\n");
2352 out.push_str(&format!(
2353 " Across {} tracked project{}: {} finding{} resolved, {} commit{} contained, {} marked intentional\n",
2354 report.tracked_count,
2355 plural(report.tracked_count),
2356 t.resolved_total,
2357 plural(t.resolved_total),
2358 t.containment_count,
2359 plural(t.containment_count),
2360 t.suppressed_total,
2361 ));
2362 if t.projects_with_baseline > 0 {
2363 out.push_str(&format!(
2364 " {} issue{} project-wide across {} project{} with a full-run baseline (as of each project's last full run)\n",
2365 t.project_wide_issues,
2366 plural(t.project_wide_issues),
2367 t.projects_with_baseline,
2368 plural(t.projects_with_baseline),
2369 ));
2370 }
2371}
2372
2373#[expect(
2375 clippy::format_push_string,
2376 reason = "small report renderer; readability over avoiding the extra allocation"
2377)]
2378#[must_use]
2379pub fn render_cross_repo_markdown(report: &CrossRepoImpactReport) -> String {
2380 let mut out = String::new();
2381 out.push_str("## Fallow impact (all projects)\n\n");
2382 if report.project_count == 0 {
2383 if report.unreadable_count > 0 {
2384 out.push_str(&format!(
2385 "No readable projects: skipped {} unreadable store{}.\n",
2386 report.unreadable_count,
2387 plural(report.unreadable_count),
2388 ));
2389 } else {
2390 out.push_str("No projects tracked yet.\n");
2391 }
2392 return out;
2393 }
2394 out.push_str(&format!(
2395 "{} project{} tracked, {} with history.\n\n",
2396 report.project_count,
2397 plural(report.project_count),
2398 report.tracked_count,
2399 ));
2400 if !report.projects.is_empty() {
2401 out.push_str("| Project | Latest | Repo-wide | Contained | Resolved | Last run |\n");
2402 out.push_str("|:--------|-------:|----------:|----------:|---------:|:---------|\n");
2403 for entry in &report.projects {
2404 out.push_str(&format!(
2405 "| {} | {} | {} | {} | {} | {} |\n",
2406 row_label(entry),
2407 opt_count(entry.report.surfacing.as_ref()),
2408 opt_count(entry.report.project_surfacing.as_ref()),
2409 entry.report.containment_count,
2410 entry.report.resolved_total,
2411 entry.last_recorded.as_deref().map_or("-", date_only),
2412 ));
2413 }
2414 }
2415 let t = &report.totals;
2416 out.push_str(&format!(
2417 "\n**Grand totals:** {} resolved, {} contained, {} marked intentional across {} tracked project{}",
2418 t.resolved_total,
2419 t.containment_count,
2420 t.suppressed_total,
2421 report.tracked_count,
2422 plural(report.tracked_count),
2423 ));
2424 if t.projects_with_baseline > 0 {
2425 out.push_str(&format!(
2426 "; {} issue{} project-wide across {} project{} with a full-run baseline (as of each project's last full run)",
2427 t.project_wide_issues,
2428 plural(t.project_wide_issues),
2429 t.projects_with_baseline,
2430 plural(t.projects_with_baseline),
2431 ));
2432 }
2433 out.push_str(".\n\n_Local-only; never uploaded; accrues on this machine, not CI._\n");
2434 out
2435}
2436
2437const fn plural(n: usize) -> &'static str {
2438 if n == 1 { "" } else { "s" }
2439}
2440
2441fn date_only(ts: &str) -> &str {
2447 ts.split_once('T').map_or(ts, |(date, _)| date)
2448}
2449
2450const fn trend_arrow(direction: ImpactTrendDirection) -> &'static str {
2454 match direction {
2455 ImpactTrendDirection::Improving => "down",
2456 ImpactTrendDirection::Declining => "up",
2457 ImpactTrendDirection::Stable => "flat",
2458 }
2459}
2460
2461#[cfg(test)]
2462mod tests {
2463 use super::*;
2464
2465 fn test_env() -> (tempfile::TempDir, tempfile::TempDir) {
2471 let config = tempfile::tempdir().unwrap();
2472 TEST_CONFIG_DIR.with(|c| *c.borrow_mut() = Some(config.path().to_path_buf()));
2473 let root = tempfile::tempdir().unwrap();
2474 (config, root)
2475 }
2476
2477 fn frontier_paths(store: &ImpactStore) -> FxHashSet<String> {
2480 store
2481 .frontier
2482 .values()
2483 .flat_map(|m| m.keys().cloned())
2484 .collect()
2485 }
2486
2487 fn clone_fingerprints(store: &ImpactStore) -> FxHashSet<String> {
2489 store
2490 .clone_frontier
2491 .values()
2492 .flat_map(|m| m.keys().cloned())
2493 .collect()
2494 }
2495
2496 fn seed_store_raw(root: &Path, bytes: &[u8]) {
2499 let path = store_path(root).expect("test config dir set");
2500 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2501 std::fs::write(&path, bytes).unwrap();
2502 }
2503
2504 fn summary(dead: usize, complexity: usize, dupes: usize) -> AuditSummary {
2505 AuditSummary {
2506 dead_code_issues: dead,
2507 dead_code_has_errors: dead > 0,
2508 complexity_findings: complexity,
2509 max_cyclomatic: None,
2510 duplication_clone_groups: dupes,
2511 }
2512 }
2513
2514 #[expect(
2516 clippy::too_many_arguments,
2517 reason = "test scaffold; positional record builder mirrors the AuditRunRecord fields, bundling adds churn with no production value"
2518 )]
2519 fn record_v1(
2520 root: &Path,
2521 summary: &AuditSummary,
2522 verdict: AuditVerdict,
2523 gate: bool,
2524 git_sha: Option<&str>,
2525 version: &str,
2526 timestamp: &str,
2527 ) {
2528 record_audit_run(
2529 root,
2530 summary,
2531 &AuditRunRecord {
2532 verdict,
2533 gate,
2534 git_sha,
2535 version,
2536 timestamp,
2537 attribution: None,
2538 },
2539 );
2540 }
2541
2542 fn touch(root: &Path, rel: &str) -> PathBuf {
2545 let p = root.join(rel);
2546 if let Some(parent) = p.parent() {
2547 std::fs::create_dir_all(parent).unwrap();
2548 }
2549 std::fs::write(&p, b"x").unwrap();
2550 p
2551 }
2552
2553 fn fi(path: &Path, kind: &'static str, symbol: &str) -> FindingInput {
2554 FindingInput {
2555 path: path.to_path_buf(),
2556 kind,
2557 symbol: Some(symbol.to_owned()),
2558 }
2559 }
2560
2561 fn supp(path: &Path, kind: &str) -> ActiveSuppression {
2562 ActiveSuppression {
2563 path: path.to_path_buf(),
2564 kind: Some(kind.to_owned()),
2565 is_file_level: false,
2566 reason: None,
2567 }
2568 }
2569
2570 fn run(
2572 root: &Path,
2573 changed: &[&Path],
2574 findings: Vec<FindingInput>,
2575 clones: Vec<CloneInput>,
2576 supps: &[ActiveSuppression],
2577 ts: &str,
2578 ) {
2579 let changed_files: Vec<PathBuf> = changed.iter().map(|p| p.to_path_buf()).collect();
2580 let input = AttributionInput {
2581 root,
2582 scope: Scope::ChangedFiles(&changed_files),
2583 findings,
2584 clones,
2585 suppressions: supps,
2586 };
2587 record_audit_run(
2588 root,
2589 &summary(0, 0, 0),
2590 &AuditRunRecord {
2591 verdict: AuditVerdict::Pass,
2592 gate: true,
2593 git_sha: Some("sha"),
2594 version: "2.0.0",
2595 timestamp: ts,
2596 attribution: Some(&input),
2597 },
2598 );
2599 }
2600
2601 #[test]
2602 fn disabled_store_does_not_record() {
2603 let (_config, dir) = test_env();
2604 let root = dir.path();
2605 record_v1(
2606 root,
2607 &summary(3, 1, 0),
2608 AuditVerdict::Fail,
2609 true,
2610 Some("abc1234"),
2611 "2.0.0",
2612 "2026-05-29T10:00:00Z",
2613 );
2614 let store = load(root);
2615 assert!(store.records.is_empty());
2616 assert!(!store.enabled);
2617 }
2618
2619 #[test]
2620 fn enable_and_disable_record_the_explicit_decision() {
2621 let (_config, dir) = test_env();
2622 let root = dir.path();
2623 assert!(!load(root).explicit_decision, "fresh store: never asked");
2624
2625 disable(root);
2627 let store = load(root);
2628 assert!(!store.enabled);
2629 assert!(store.explicit_decision);
2630 assert!(build_report(&store).explicit_decision);
2631 }
2632
2633 #[test]
2634 fn due_digest_stamps_and_respects_interval_and_gates() {
2635 let (_config, dir) = test_env();
2636 let root = dir.path();
2637
2638 assert!(take_due_digest(root).is_none());
2640 enable(root);
2641 assert!(take_due_digest(root).is_none(), "zero counters never nag");
2642
2643 let mut store = load(root);
2644 store.resolved_total = 3;
2645 store.containment.push(ContainmentEvent {
2646 blocked_at: "2026-06-11T00:00:00Z".to_string(),
2647 cleared_at: "2026-06-11T00:05:00Z".to_string(),
2648 git_sha: None,
2649 blocked_counts: ImpactCounts::default(),
2650 });
2651 save(&store, root);
2652
2653 let digest = take_due_digest(root).expect("first digest is due");
2654 assert_eq!(digest.containment_count, 1);
2655 assert_eq!(digest.resolved_total, 3);
2656 assert!(
2657 take_due_digest(root).is_none(),
2658 "stamped: not due again within the interval"
2659 );
2660
2661 let mut store = load(root);
2663 store.last_digest_epoch = Some(0);
2664 save(&store, root);
2665 assert!(take_due_digest(root).is_some());
2666 }
2667
2668 #[test]
2669 fn decline_onboarding_persists_in_existing_store() {
2670 let (_config, dir) = test_env();
2671 let root = dir.path();
2672
2673 assert!(decline_onboarding(root));
2674 assert!(!decline_onboarding(root));
2675
2676 let store = load(root);
2677 assert!(store.onboarding_declined);
2678 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
2679 assert!(!root.join(".gitignore").exists());
2681 let report = build_report(&store);
2682 assert!(report.onboarding_declined);
2683 }
2684
2685 #[test]
2686 fn enable_then_record_accrues_history() {
2687 let (_config, dir) = test_env();
2688 let root = dir.path();
2689 assert!(enable(root));
2690 assert!(!enable(root)); record_v1(
2692 root,
2693 &summary(2, 1, 0),
2694 AuditVerdict::Warn,
2695 false,
2696 None,
2697 "2.0.0",
2698 "2026-05-29T10:00:00Z",
2699 );
2700 let store = load(root);
2701 assert_eq!(store.records.len(), 1);
2702 assert_eq!(store.records[0].counts.total_issues, 3);
2703 assert_eq!(
2704 store.first_recorded.as_deref(),
2705 Some("2026-05-29T10:00:00Z")
2706 );
2707 }
2708
2709 #[test]
2710 fn record_is_a_noop_in_ci() {
2711 let (_config, dir) = test_env();
2717 let root = dir.path();
2718 assert!(enable(root));
2719 TEST_FORCE_CI.with(|c| c.set(true));
2720 record_v1(
2721 root,
2722 &summary(2, 1, 0),
2723 AuditVerdict::Warn,
2724 false,
2725 None,
2726 "2.0.0",
2727 "2026-05-29T10:00:00Z",
2728 );
2729 TEST_FORCE_CI.with(|c| c.set(false));
2730 let store = load(root);
2731 assert_eq!(store.records.len(), 0, "impact must not record while in CI");
2732 }
2733
2734 #[test]
2735 fn enable_writes_nothing_into_the_repo() {
2736 let (_config, dir) = test_env();
2737 let root = dir.path();
2738 enable(root);
2739 assert!(
2742 !root.join(".gitignore").exists(),
2743 "enable must not create or modify the repo's .gitignore"
2744 );
2745 assert!(
2746 !root.join(".fallow").exists(),
2747 "enable must not create an in-repo .fallow/ dir"
2748 );
2749 let store = load(root);
2751 assert!(store.enabled);
2752 assert!(store.explicit_decision);
2753 assert!(resolve_enabled(&store).0);
2754 }
2755
2756 #[test]
2757 fn single_record_yields_no_trend_no_spike() {
2758 let mut store = ImpactStore {
2759 enabled: true,
2760 ..Default::default()
2761 };
2762 store.records.push(ImpactRecord {
2763 timestamp: "t0".into(),
2764 version: "2.0.0".into(),
2765 git_sha: None,
2766 verdict: "warn".into(),
2767 gate: false,
2768 counts: ImpactCounts {
2769 total_issues: 5,
2770 dead_code: 5,
2771 complexity: 0,
2772 duplication: 0,
2773 },
2774 });
2775 let report = build_report(&store);
2776 assert!(report.trend.is_none());
2777 assert_eq!(report.surfacing.unwrap().total_issues, 5);
2778 }
2779
2780 #[test]
2781 fn empty_store_report_is_first_run() {
2782 let store = ImpactStore::default();
2783 let report = build_report(&store);
2784 assert_eq!(report.record_count, 0);
2785 assert!(report.trend.is_none());
2786 assert!(report.surfacing.is_none());
2787 let human = render_human(&report);
2788 assert!(human.contains("off")); }
2790
2791 #[test]
2792 fn enabled_empty_store_shows_check_back() {
2793 let store = ImpactStore {
2794 enabled: true,
2795 ..Default::default()
2796 };
2797 let report = build_report(&store);
2798 let human = render_human(&report);
2799 assert!(human.contains("No history yet"));
2800 assert!(!human.contains("0 issues"));
2801 }
2802
2803 #[test]
2804 fn trend_improving_when_issues_drop() {
2805 let mut store = ImpactStore {
2806 enabled: true,
2807 ..Default::default()
2808 };
2809 for total in [8usize, 3usize] {
2810 store.records.push(ImpactRecord {
2811 timestamp: format!("t{total}"),
2812 version: "2.0.0".into(),
2813 git_sha: None,
2814 verdict: "warn".into(),
2815 gate: false,
2816 counts: ImpactCounts {
2817 total_issues: total,
2818 dead_code: total,
2819 complexity: 0,
2820 duplication: 0,
2821 },
2822 });
2823 }
2824 let report = build_report(&store);
2825 let trend = report.trend.unwrap();
2826 assert_eq!(trend.direction, ImpactTrendDirection::Improving);
2827 assert_eq!(trend.total_delta, -5);
2828 }
2829
2830 #[test]
2831 fn containment_blocked_then_cleared_records_one_event() {
2832 let (_config, dir) = test_env();
2833 let root = dir.path();
2834 enable(root);
2835 record_v1(
2836 root,
2837 &summary(2, 0, 0),
2838 AuditVerdict::Fail,
2839 true,
2840 Some("sha1"),
2841 "2.0.0",
2842 "t0",
2843 );
2844 let store = load(root);
2845 assert!(store.pending_containment.is_some());
2846 assert!(store.containment.is_empty());
2847
2848 record_v1(
2849 root,
2850 &summary(0, 0, 0),
2851 AuditVerdict::Pass,
2852 true,
2853 Some("sha2"),
2854 "2.0.0",
2855 "t1",
2856 );
2857 let store = load(root);
2858 assert!(store.pending_containment.is_none());
2859 assert_eq!(store.containment.len(), 1);
2860 assert_eq!(store.containment[0].blocked_at, "t0");
2861 assert_eq!(store.containment[0].cleared_at, "t1");
2862 }
2863
2864 #[test]
2865 fn non_gate_run_never_creates_containment() {
2866 let (_config, dir) = test_env();
2867 let root = dir.path();
2868 enable(root);
2869 record_v1(
2870 root,
2871 &summary(2, 0, 0),
2872 AuditVerdict::Fail,
2873 false,
2874 None,
2875 "2.0.0",
2876 "t0",
2877 );
2878 let store = load(root);
2879 assert!(store.pending_containment.is_none());
2880 assert!(store.containment.is_empty());
2881 }
2882
2883 #[test]
2884 fn corrupt_store_loads_as_default_no_panic() {
2885 let (_config, dir) = test_env();
2886 let root = dir.path();
2887 seed_store_raw(root, b"{ not valid json ][");
2888 let store = load(root);
2889 assert!(!store.enabled);
2890 assert!(store.records.is_empty());
2891 record_v1(
2892 root,
2893 &summary(1, 0, 0),
2894 AuditVerdict::Fail,
2895 true,
2896 None,
2897 "2.0.0",
2898 "t0",
2899 );
2900 }
2901
2902 #[test]
2903 fn records_are_bounded() {
2904 let mut store = ImpactStore {
2905 enabled: true,
2906 ..Default::default()
2907 };
2908 for i in 0..(MAX_RECORDS + 50) {
2909 store.records.push(ImpactRecord {
2910 timestamp: format!("t{i}"),
2911 version: "2.0.0".into(),
2912 git_sha: None,
2913 verdict: "pass".into(),
2914 gate: false,
2915 counts: ImpactCounts::default(),
2916 });
2917 }
2918 compact(&mut store);
2919 assert_eq!(store.records.len(), MAX_RECORDS);
2920 assert_eq!(store.records[0].timestamp, "t50");
2921 }
2922
2923 #[test]
2924 fn report_always_carries_schema_version() {
2925 let empty = build_report(&ImpactStore::default());
2926 assert_eq!(empty.schema_version, ImpactReportSchemaVersion::V1);
2927 let json = render_json(&empty);
2928 assert!(
2929 json.contains("\"schema_version\": \"1\""),
2930 "schema_version must be present (as the \"1\" const) even when disabled: {json}"
2931 );
2932
2933 let mut store = ImpactStore {
2934 enabled: true,
2935 ..Default::default()
2936 };
2937 store.records.push(ImpactRecord {
2938 timestamp: "2026-05-29T10:00:00Z".into(),
2939 version: "2.0.0".into(),
2940 git_sha: None,
2941 verdict: "pass".into(),
2942 gate: false,
2943 counts: ImpactCounts::default(),
2944 });
2945 assert_eq!(
2946 build_report(&store).schema_version,
2947 ImpactReportSchemaVersion::V1
2948 );
2949 }
2950
2951 #[test]
2952 fn date_only_trims_iso_timestamp() {
2953 assert_eq!(date_only("2026-05-29T18:15:23Z"), "2026-05-29");
2954 assert_eq!(date_only("2026-05-29"), "2026-05-29");
2955 assert_eq!(date_only("the first run"), "the first run");
2956 }
2957
2958 #[test]
2959 fn human_footer_shows_date_only() {
2960 let mut store = ImpactStore {
2961 enabled: true,
2962 ..Default::default()
2963 };
2964 store.first_recorded = Some("2026-05-29T18:15:23Z".into());
2965 store.records.push(ImpactRecord {
2966 timestamp: "2026-05-29T18:15:23Z".into(),
2967 version: "2.0.0".into(),
2968 git_sha: None,
2969 verdict: "pass".into(),
2970 gate: false,
2971 counts: ImpactCounts::default(),
2972 });
2973 let report = build_report(&store);
2974 let human = render_human(&report);
2975 assert!(
2976 human.contains("since 2026-05-29.") && !human.contains("18:15:23"),
2977 "human footer must show date-only: {human}"
2978 );
2979 let md = render_markdown(&report);
2980 assert!(
2981 md.contains("since 2026-05-29.") && !md.contains("18:15:23"),
2982 "markdown footer must show date-only: {md}"
2983 );
2984 }
2985
2986 #[test]
2987 fn future_schema_version_store_loads_without_panic_or_loss() {
2988 let (_config, dir) = test_env();
2989 let root = dir.path();
2990 let future = format!(
2991 "{{\"schema_version\":{},\"enabled\":true,\"records\":[],\"containment\":[]}}",
2992 STORE_SCHEMA_VERSION + 1
2993 );
2994 seed_store_raw(root, future.as_bytes());
2995 let store = load(root);
2996 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION + 1);
2997 assert!(
2998 store.enabled,
2999 "future-version store must not degrade to default"
3000 );
3001 }
3002
3003 #[test]
3004 fn removed_finding_is_credited_as_resolved() {
3005 let (_config, dir) = test_env();
3006 let root = dir.path();
3007 enable(root);
3008 let a = touch(root, "src/a.ts");
3009 run(
3010 root,
3011 &[&a],
3012 vec![fi(&a, "unused-export", "foo")],
3013 vec![],
3014 &[],
3015 "t0",
3016 );
3017 assert_eq!(
3018 load(root).resolved_total,
3019 0,
3020 "first run only establishes a baseline"
3021 );
3022 run(root, &[&a], vec![], vec![], &[], "t1");
3023 let store = load(root);
3024 assert_eq!(store.resolved_total, 1);
3025 assert_eq!(store.suppressed_total, 0);
3026 assert_eq!(store.recent_resolved.len(), 1);
3027 assert_eq!(store.recent_resolved[0].kind, "unused-export");
3028 assert_eq!(store.recent_resolved[0].symbol.as_deref(), Some("foo"));
3029 assert_eq!(store.recent_resolved[0].path, "src/a.ts");
3030 }
3031
3032 #[test]
3033 fn suppressed_finding_is_not_a_win() {
3034 let (_config, dir) = test_env();
3035 let root = dir.path();
3036 enable(root);
3037 let a = touch(root, "src/a.ts");
3038 run(
3039 root,
3040 &[&a],
3041 vec![fi(&a, "unused-export", "foo")],
3042 vec![],
3043 &[],
3044 "t0",
3045 );
3046 run(
3047 root,
3048 &[&a],
3049 vec![],
3050 vec![],
3051 &[supp(&a, "unused-export")],
3052 "t1",
3053 );
3054 let store = load(root);
3055 assert_eq!(
3056 store.resolved_total, 0,
3057 "a suppression must never count as a win"
3058 );
3059 assert_eq!(store.suppressed_total, 1);
3060 }
3061
3062 #[test]
3063 fn fix_and_suppress_same_kind_credits_zero_resolved() {
3064 let (_config, dir) = test_env();
3065 let root = dir.path();
3066 enable(root);
3067 let a = touch(root, "src/a.ts");
3068 run(
3069 root,
3070 &[&a],
3071 vec![
3072 fi(&a, "unused-export", "foo"),
3073 fi(&a, "unused-export", "bar"),
3074 ],
3075 vec![],
3076 &[],
3077 "t0",
3078 );
3079 run(
3080 root,
3081 &[&a],
3082 vec![],
3083 vec![],
3084 &[supp(&a, "unused-export")],
3085 "t1",
3086 );
3087 let store = load(root);
3088 assert_eq!(store.resolved_total, 0);
3089 assert_eq!(store.suppressed_total, 2);
3090 }
3091
3092 #[test]
3093 fn within_file_move_is_not_resolved() {
3094 let (_config, dir) = test_env();
3095 let root = dir.path();
3096 enable(root);
3097 let a = touch(root, "src/a.ts");
3098 run(
3099 root,
3100 &[&a],
3101 vec![fi(&a, "unused-export", "foo")],
3102 vec![],
3103 &[],
3104 "t0",
3105 );
3106 run(
3107 root,
3108 &[&a],
3109 vec![fi(&a, "unused-export", "foo")],
3110 vec![],
3111 &[],
3112 "t1",
3113 );
3114 let store = load(root);
3115 assert_eq!(store.resolved_total, 0);
3116 assert_eq!(store.suppressed_total, 0);
3117 }
3118
3119 #[test]
3120 fn cross_file_move_in_same_run_is_not_resolved() {
3121 let (_config, dir) = test_env();
3122 let root = dir.path();
3123 enable(root);
3124 let a = touch(root, "src/a.ts");
3125 let b = touch(root, "src/b.ts");
3126 run(
3127 root,
3128 &[&a],
3129 vec![fi(&a, "unused-export", "foo")],
3130 vec![],
3131 &[],
3132 "t0",
3133 );
3134 run(
3135 root,
3136 &[&a, &b],
3137 vec![fi(&b, "unused-export", "foo")],
3138 vec![],
3139 &[],
3140 "t1",
3141 );
3142 assert_eq!(
3143 load(root).resolved_total,
3144 0,
3145 "a cross-file move is not a resolution"
3146 );
3147 }
3148
3149 #[test]
3150 fn cross_run_move_uncredits_the_prior_resolution() {
3151 let (_config, dir) = test_env();
3152 let root = dir.path();
3153 enable(root);
3154 let a = touch(root, "src/a.ts");
3155 let b = touch(root, "src/b.ts");
3156 run(
3157 root,
3158 &[&a],
3159 vec![fi(&a, "unused-export", "foo")],
3160 vec![],
3161 &[],
3162 "t0",
3163 );
3164 run(root, &[&a], vec![], vec![], &[], "t1");
3165 assert_eq!(
3166 load(root).resolved_total,
3167 1,
3168 "source disappearance credited in run A"
3169 );
3170 run(
3171 root,
3172 &[&b],
3173 vec![fi(&b, "unused-export", "foo")],
3174 vec![],
3175 &[],
3176 "t2",
3177 );
3178 let store = load(root);
3179 assert_eq!(
3180 store.resolved_total, 0,
3181 "cross-run move must un-credit the phantom win"
3182 );
3183 assert!(
3184 store.recent_resolved.is_empty(),
3185 "the stale resolution event is dropped"
3186 );
3187 }
3188
3189 #[test]
3190 fn resolved_complexity_finding_and_suppressed_complexity() {
3191 let (_config, dir) = test_env();
3192 let root = dir.path();
3193 enable(root);
3194 let a = touch(root, "src/a.ts");
3195 run(
3196 root,
3197 &[&a],
3198 vec![fi(&a, "complexity", "bigFn")],
3199 vec![],
3200 &[],
3201 "t0",
3202 );
3203 run(root, &[&a], vec![], vec![], &[supp(&a, "complexity")], "t1");
3204 let store = load(root);
3205 assert_eq!(store.resolved_total, 0);
3206 assert_eq!(store.suppressed_total, 1);
3207
3208 let b = touch(root, "src/b.ts");
3209 run(
3210 root,
3211 &[&b],
3212 vec![fi(&b, "complexity", "huge")],
3213 vec![],
3214 &[],
3215 "t2",
3216 );
3217 run(root, &[&b], vec![], vec![], &[], "t3");
3218 assert_eq!(load(root).resolved_total, 1);
3219 }
3220
3221 #[test]
3222 fn resolved_duplication_clone_group() {
3223 let (_config, dir) = test_env();
3224 let root = dir.path();
3225 enable(root);
3226 let a = touch(root, "src/a.ts");
3227 let b = touch(root, "src/b.ts");
3228 let clone = CloneInput {
3229 fingerprint: "dup:abc12345".to_owned(),
3230 instance_paths: vec![a.clone(), b],
3231 };
3232 run(root, &[&a], vec![], vec![clone], &[], "t0");
3233 run(root, &[&a], vec![], vec![], &[], "t1");
3234 let store = load(root);
3235 assert_eq!(store.resolved_total, 1);
3236 assert_eq!(store.recent_resolved[0].kind, "code-duplication");
3237 }
3238
3239 #[test]
3240 fn blanket_suppression_covers_any_kind() {
3241 let (_config, dir) = test_env();
3242 let root = dir.path();
3243 enable(root);
3244 let a = touch(root, "src/a.ts");
3245 run(
3246 root,
3247 &[&a],
3248 vec![fi(&a, "unused-export", "foo")],
3249 vec![],
3250 &[],
3251 "t0",
3252 );
3253 let blanket = ActiveSuppression {
3254 path: a.clone(),
3255 kind: None,
3256 is_file_level: true,
3257 reason: None,
3258 };
3259 run(root, &[&a], vec![], vec![], &[blanket], "t1");
3260 let store = load(root);
3261 assert_eq!(store.resolved_total, 0);
3262 assert_eq!(store.suppressed_total, 1);
3263 }
3264
3265 #[test]
3266 fn v1_store_loads_and_upgrades_to_v2() {
3267 let (_config, dir) = test_env();
3268 let root = dir.path();
3269 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":[]}"#;
3270 seed_store_raw(root, v1.as_bytes());
3271 let store = load(root);
3272 assert_eq!(store.schema_version, 1);
3273 assert!(store.frontier.is_empty());
3274 assert_eq!(store.resolved_total, 0);
3275 let a = touch(root, "src/a.ts");
3276 run(
3277 root,
3278 &[&a],
3279 vec![fi(&a, "unused-export", "foo")],
3280 vec![],
3281 &[],
3282 "t1",
3283 );
3284 let store = load(root);
3285 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
3286 assert!(frontier_paths(&store).contains("src/a.ts"));
3287 }
3288
3289 #[test]
3290 fn recent_resolved_is_bounded() {
3291 let mut store = ImpactStore {
3292 enabled: true,
3293 ..Default::default()
3294 };
3295 for i in 0..(MAX_RECENT_RESOLVED + 25) {
3296 store.recent_resolved.push(ResolutionEvent {
3297 kind: "unused-export".into(),
3298 path: format!("src/f{i}.ts"),
3299 symbol: Some(format!("s{i}")),
3300 git_sha: None,
3301 timestamp: format!("t{i}"),
3302 });
3303 }
3304 bound_recent_resolved(&mut store);
3305 assert_eq!(store.recent_resolved.len(), MAX_RECENT_RESOLVED);
3306 assert_eq!(store.recent_resolved[0].path, "src/f25.ts");
3307 }
3308
3309 #[test]
3310 fn frontier_prunes_deleted_files() {
3311 let (_config, dir) = test_env();
3312 let root = dir.path();
3313 enable(root);
3314 let a = touch(root, "src/a.ts");
3315 run(
3316 root,
3317 &[&a],
3318 vec![fi(&a, "unused-export", "foo")],
3319 vec![],
3320 &[],
3321 "t0",
3322 );
3323 assert!(frontier_paths(&load(root)).contains("src/a.ts"));
3324 std::fs::remove_file(&a).unwrap();
3325 let b = touch(root, "src/b.ts");
3326 run(root, &[&b], vec![], vec![], &[], "t1");
3327 assert!(!frontier_paths(&load(root)).contains("src/a.ts"));
3328 }
3329
3330 #[test]
3331 fn honest_empty_state_before_attribution_baseline() {
3332 let store = ImpactStore {
3333 enabled: true,
3334 records: vec![ImpactRecord {
3335 timestamp: "t0".into(),
3336 version: "2.0.0".into(),
3337 git_sha: None,
3338 verdict: "warn".into(),
3339 gate: false,
3340 counts: ImpactCounts::default(),
3341 }],
3342 ..Default::default()
3343 };
3344 let report = build_report(&store);
3345 assert!(!report.attribution_active);
3346 let human = render_human(&report);
3347 assert!(human.contains("resolution tracking starts from your next gate run"));
3348 assert!(!human.contains("0 finding"));
3349 }
3350
3351 #[test]
3352 fn suppression_only_state_renders_under_a_resolved_header() {
3353 let report = ImpactReport {
3354 schema_version: ImpactReportSchemaVersion::V1,
3355 enabled: true,
3356 enabled_source: EnabledSource::Project,
3357 record_count: 2,
3358 meta: None,
3359 first_recorded: Some("2026-05-29T10:00:00Z".into()),
3360 latest_git_sha: None,
3361 surfacing: Some(ImpactCounts::default()),
3362 trend: None,
3363 project_surfacing: None,
3364 project_trend: None,
3365 containment_count: 0,
3366 recent_containment: vec![],
3367 resolved_total: 0,
3368 suppressed_total: 2,
3369 recent_resolved: vec![],
3370 attribution_active: true,
3371 onboarding_declined: false,
3372 explicit_decision: false,
3373 };
3374 let human = render_human(&report);
3375 let resolved_idx = human.find(" RESOLVED").expect("RESOLVED header present");
3376 let supp_idx = human
3377 .find("2 findings you marked intentional")
3378 .expect("suppression line present");
3379 assert!(
3380 resolved_idx < supp_idx,
3381 "suppression must render under RESOLVED"
3382 );
3383 assert!(human.contains("none yet"));
3384
3385 let md = render_markdown(&report);
3386 assert!(
3387 md.contains("- **Resolved:**"),
3388 "markdown always has a Resolved bullet"
3389 );
3390 assert!(md.contains("- **Marked intentional:** 2 finding"));
3391 }
3392
3393 fn clone_at(fingerprint: &str, paths: &[&Path]) -> CloneInput {
3395 CloneInput {
3396 fingerprint: fingerprint.to_owned(),
3397 instance_paths: paths.iter().map(|p| p.to_path_buf()).collect(),
3398 }
3399 }
3400
3401 fn run_wp(
3405 root: &Path,
3406 findings: Vec<FindingInput>,
3407 clones: Vec<CloneInput>,
3408 supps: &[ActiveSuppression],
3409 ts: &str,
3410 ) {
3411 let input = AttributionInput {
3412 root,
3413 scope: Scope::WholeProject,
3414 findings,
3415 clones,
3416 suppressions: supps,
3417 };
3418 record_combined_run(
3419 root,
3420 ImpactCounts::default(),
3421 Some("sha"),
3422 "2.0.0",
3423 ts,
3424 Some(&input),
3425 );
3426 }
3427
3428 #[test]
3429 fn whole_project_run_does_not_double_credit_after_audit() {
3430 let (_config, dir) = test_env();
3431 let root = dir.path();
3432 enable(root);
3433 let a = touch(root, "src/a.ts");
3434 let b = touch(root, "src/b.ts");
3435 run(
3436 root,
3437 &[&a, &b],
3438 vec![],
3439 vec![clone_at("dup:abc", &[&a, &b])],
3440 &[],
3441 "t1",
3442 );
3443 assert_eq!(clone_fingerprints(&load(root)).len(), 1);
3444
3445 run(root, &[&a, &b], vec![], vec![], &[], "t2");
3446 assert_eq!(load(root).resolved_total, 1);
3447 assert!(load(root).clone_frontier.is_empty());
3448
3449 run_wp(root, vec![], vec![], &[], "t3");
3450 assert_eq!(
3451 load(root).resolved_total,
3452 1,
3453 "whole-project run re-credited a resolution"
3454 );
3455 }
3456
3457 #[test]
3458 fn whole_project_run_credits_suppressed_not_resolved() {
3459 let (_config, dir) = test_env();
3460 let root = dir.path();
3461 enable(root);
3462 let util = touch(root, "src/util.ts");
3463 run(
3464 root,
3465 &[&util],
3466 vec![fi(&util, "unused-export", "dead")],
3467 vec![],
3468 &[],
3469 "t1",
3470 );
3471 assert_eq!(frontier_paths(&load(root)).len(), 1);
3472
3473 run_wp(root, vec![], vec![], &[supp(&util, "unused-export")], "t2");
3474 let store = load(root);
3475 assert_eq!(
3476 store.suppressed_total, 1,
3477 "suppressed finding not counted suppressed"
3478 );
3479 assert_eq!(
3480 store.resolved_total, 0,
3481 "suppressed finding wrongly counted resolved"
3482 );
3483 }
3484
3485 #[test]
3486 fn clone_reshape_three_to_two_not_credited_as_resolved() {
3487 let (_config, dir) = test_env();
3488 let root = dir.path();
3489 enable(root);
3490 let a = touch(root, "src/a.ts");
3491 let b = touch(root, "src/b.ts");
3492 let c = touch(root, "src/c.ts");
3493 run(
3494 root,
3495 &[&a, &b, &c],
3496 vec![],
3497 vec![clone_at("dup:aaa", &[&a, &b, &c])],
3498 &[],
3499 "t1",
3500 );
3501 assert_eq!(clone_fingerprints(&load(root)).len(), 1);
3502
3503 run_wp(
3504 root,
3505 vec![],
3506 vec![clone_at("dup:bbb", &[&a, &b])],
3507 &[],
3508 "t2",
3509 );
3510 let store = load(root);
3511 assert_eq!(
3512 store.resolved_total, 0,
3513 "clone reshape miscredited as resolved"
3514 );
3515 assert!(clone_fingerprints(&store).contains("dup:bbb"));
3516 assert!(!clone_fingerprints(&store).contains("dup:aaa"));
3517 }
3518
3519 fn rcounts(total: usize, dead: usize, complexity: usize, dup: usize) -> ImpactCounts {
3520 ImpactCounts {
3521 total_issues: total,
3522 dead_code: dead,
3523 complexity,
3524 duplication: dup,
3525 }
3526 }
3527
3528 fn rtrend(prev: usize, cur: usize) -> TrendSummary {
3529 TrendSummary {
3530 direction: direction_for(cur as i64 - prev as i64),
3531 total_delta: cur as i64 - prev as i64,
3532 previous_total: prev,
3533 current_total: cur,
3534 }
3535 }
3536
3537 #[expect(
3539 clippy::too_many_arguments,
3540 reason = "test scaffold; positional ImpactReport builder, bundling adds churn with no production value"
3541 )]
3542 fn rreport(
3543 record_count: usize,
3544 first_recorded: Option<&str>,
3545 surfacing: Option<ImpactCounts>,
3546 trend: Option<TrendSummary>,
3547 project_surfacing: Option<ImpactCounts>,
3548 project_trend: Option<TrendSummary>,
3549 attribution_active: bool,
3550 ) -> ImpactReport {
3551 ImpactReport {
3552 schema_version: ImpactReportSchemaVersion::V1,
3553 enabled: true,
3554 enabled_source: EnabledSource::Project,
3555 record_count,
3556 meta: None,
3557 first_recorded: first_recorded.map(ToOwned::to_owned),
3558 latest_git_sha: None,
3559 surfacing,
3560 trend,
3561 project_surfacing,
3562 project_trend,
3563 containment_count: 0,
3564 recent_containment: vec![],
3565 resolved_total: 0,
3566 suppressed_total: 0,
3567 recent_resolved: vec![],
3568 attribution_active,
3569 onboarding_declined: false,
3570 explicit_decision: false,
3571 }
3572 }
3573
3574 #[test]
3575 fn render_human_project_only_store_shows_whole_project_not_empty_state() {
3576 let r = rreport(
3577 0,
3578 Some("2026-05-30T10:00:00Z"),
3579 None,
3580 None,
3581 Some(rcounts(1, 1, 0, 0)),
3582 None,
3583 true,
3584 );
3585 let human = render_human(&r);
3586 assert!(
3587 human.contains("WHOLE PROJECT (whole-repo context, not a to-do)"),
3588 "project-only must render the labeled section"
3589 );
3590 assert!(human.contains("1 issue across the whole project"));
3591 assert!(
3592 human.contains("project trend starts after your next full `fallow` run"),
3593 "single project record => no trend line, shows the next-run hint"
3594 );
3595 assert!(human.contains("Tracking since 2026-05-30"));
3596 assert!(
3597 !human.contains("No history yet"),
3598 "must not show the empty-state copy"
3599 );
3600 assert!(
3601 !human.contains("LATEST RUN"),
3602 "no changed-file track recorded"
3603 );
3604 assert!(
3605 !human.contains("recorded audit run"),
3606 "no audit runs => no changed-file footer"
3607 );
3608 }
3609
3610 #[test]
3611 fn render_human_both_tracks_label_actionable_vs_context() {
3612 let r = rreport(
3613 3,
3614 Some("2026-05-29T10:00:00Z"),
3615 Some(rcounts(4, 4, 0, 0)),
3616 Some(rtrend(6, 4)),
3617 Some(rcounts(40, 30, 5, 5)),
3618 Some(rtrend(45, 40)),
3619 true,
3620 );
3621 let human = render_human(&r);
3622 let latest = human
3623 .find("LATEST RUN (changed files, act on these now)")
3624 .expect("LATEST RUN labeled actionable");
3625 let whole = human
3626 .find("WHOLE PROJECT (whole-repo context, not a to-do)")
3627 .expect("WHOLE PROJECT labeled context");
3628 assert!(
3629 latest < whole,
3630 "changed-file section renders before whole-project"
3631 );
3632 assert!(human.contains("45 -> 40 (down) across your last two full runs"));
3633 assert!(human.contains("advances only on your local full `fallow` runs, not CI"));
3634 }
3635
3636 #[test]
3637 fn render_markdown_project_only_store_shows_whole_project_not_empty_state() {
3638 let r = rreport(
3639 0,
3640 Some("2026-05-30T10:00:00Z"),
3641 None,
3642 None,
3643 Some(rcounts(1, 1, 0, 0)),
3644 None,
3645 true,
3646 );
3647 let md = render_markdown(&r);
3648 assert!(
3649 md.contains(
3650 "- **Whole project (whole-repo context, last full `fallow` run):** 1 issue"
3651 ),
3652 "project-only md must render the labeled whole-project line"
3653 );
3654 assert!(
3655 !md.contains("No history yet"),
3656 "project-only md must not show empty state"
3657 );
3658 assert!(md.contains("Tracking since 2026-05-30"));
3659 }
3660
3661 #[test]
3662 fn resolve_enabled_precedence_table() {
3663 let (_config, _dir) = test_env();
3664 let on = ImpactStore {
3666 enabled: true,
3667 ..Default::default()
3668 };
3669 assert_eq!(resolve_enabled(&on), (true, EnabledSource::Project));
3670
3671 let off_explicit = ImpactStore {
3673 enabled: false,
3674 explicit_decision: true,
3675 ..Default::default()
3676 };
3677 assert_eq!(
3678 resolve_enabled(&off_explicit),
3679 (false, EnabledSource::Project)
3680 );
3681
3682 let never = ImpactStore::default();
3684 assert_eq!(resolve_enabled(&never), (false, EnabledSource::Default));
3685
3686 assert!(set_global_default(true));
3688 assert_eq!(resolve_enabled(&never), (true, EnabledSource::User));
3689 assert_eq!(
3691 resolve_enabled(&off_explicit),
3692 (false, EnabledSource::Project)
3693 );
3694 }
3695
3696 #[test]
3697 fn human_report_explains_user_global_default() {
3698 let (_config, _dir) = test_env();
3699 set_global_default(true);
3700 let report = build_report(&ImpactStore::default());
3702 assert_eq!(report.enabled_source, EnabledSource::User);
3703 let human = render_human(&report);
3704 assert!(
3705 human.contains("Enabled by your user-global default"),
3706 "human report must explain a global-default enable: {human}"
3707 );
3708 let project = build_report(&ImpactStore {
3710 enabled: true,
3711 explicit_decision: true,
3712 ..Default::default()
3713 });
3714 assert_eq!(project.enabled_source, EnabledSource::Project);
3715 assert!(!render_human(&project).contains("user-global default"));
3716 }
3717
3718 #[test]
3719 fn global_default_round_trips() {
3720 let (_config, _dir) = test_env();
3721 assert!(!load_global_default());
3722 assert!(set_global_default(true));
3723 assert!(load_global_default());
3724 assert!(!set_global_default(true)); assert!(set_global_default(false));
3726 assert!(!load_global_default());
3727 }
3728
3729 #[test]
3730 fn global_default_records_without_per_repo_enable() {
3731 let (_config, dir) = test_env();
3732 let root = dir.path();
3733 set_global_default(true);
3734 record_v1(
3736 root,
3737 &summary(2, 0, 0),
3738 AuditVerdict::Warn,
3739 false,
3740 None,
3741 "2.0.0",
3742 "t0",
3743 );
3744 let report = build_report(&load(root));
3745 assert!(report.enabled);
3746 assert_eq!(report.enabled_source, EnabledSource::User);
3747 assert_eq!(report.record_count, 1);
3748 }
3749
3750 #[test]
3751 fn legacy_in_repo_store_is_migrated_on_first_load() {
3752 let (_config, dir) = test_env();
3753 let root = dir.path();
3754 let legacy = r#"{"schema_version":3,"enabled":true,"explicit_decision":true,
3756 "records":[{"timestamp":"t0","version":"2.0.0","verdict":"warn","gate":false,
3757 "counts":{"total_issues":1,"dead_code":1,"complexity":0,"duplication":0}}],
3758 "resolved_total":2,
3759 "frontier":{"src/a.ts":{"findings":[{"id":"x","kind":"unused-export","symbol":"foo"}],"suppressions":[]}},
3760 "containment":[]}"#;
3761 std::fs::create_dir_all(root.join(".fallow")).unwrap();
3762 std::fs::write(legacy_store_path(root), legacy).unwrap();
3763
3764 let store = load(root);
3765 assert!(store.enabled);
3766 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
3767 assert_eq!(store.records.len(), 1);
3768 assert_eq!(store.resolved_total, 2);
3769 assert!(frontier_paths(&store).contains("src/a.ts"));
3771 assert!(store_path(root).is_some_and(|p| p.exists()));
3774 let again = load(root);
3775 assert_eq!(again.records.len(), 1);
3776 }
3777
3778 #[test]
3779 fn reset_removes_only_this_project() {
3780 let (_config, dir) = test_env();
3781 let root = dir.path();
3782 enable(root);
3783 record_v1(
3784 root,
3785 &summary(1, 0, 0),
3786 AuditVerdict::Warn,
3787 false,
3788 None,
3789 "2.0.0",
3790 "t0",
3791 );
3792 assert_eq!(load(root).records.len(), 1);
3793 assert!(reset(root));
3794 assert!(load(root).records.is_empty());
3795 assert!(!reset(root)); }
3797
3798 #[test]
3799 fn reset_all_clears_dir_but_keeps_global_default() {
3800 let (_config, dir) = test_env();
3801 let root = dir.path();
3802 set_global_default(true);
3803 enable(root);
3804 assert!(load(root).enabled);
3805 assert!(reset_all());
3806 assert!(load_global_default());
3808 }
3809
3810 fn aggregate_env() -> tempfile::TempDir {
3814 let config = tempfile::tempdir().unwrap();
3815 TEST_CONFIG_DIR.with(|c| *c.borrow_mut() = Some(config.path().to_path_buf()));
3816 config
3817 }
3818
3819 fn seed_store(key: &str, store: &ImpactStore) {
3821 let dir = impact_config_dir().unwrap().join("impact");
3822 std::fs::create_dir_all(&dir).unwrap();
3823 std::fs::write(
3824 dir.join(format!("{key}.json")),
3825 serde_json::to_string_pretty(store).unwrap(),
3826 )
3827 .unwrap();
3828 }
3829
3830 fn store_with(
3831 label: &str,
3832 resolved: usize,
3833 contained: usize,
3834 latest_ts: &str,
3835 latest_issues: usize,
3836 ) -> ImpactStore {
3837 let mut s = ImpactStore {
3838 enabled: true,
3839 explicit_decision: true,
3840 resolved_total: resolved,
3841 label: Some(label.to_owned()),
3842 ..Default::default()
3843 };
3844 s.records.push(ImpactRecord {
3845 timestamp: latest_ts.to_owned(),
3846 version: "2.0.0".to_owned(),
3847 git_sha: None,
3848 verdict: "warn".to_owned(),
3849 gate: false,
3850 counts: ImpactCounts::from_combined(latest_issues, 0, 0),
3851 });
3852 for _ in 0..contained {
3853 s.containment.push(ContainmentEvent {
3854 blocked_at: "t0".to_owned(),
3855 cleared_at: "t1".to_owned(),
3856 git_sha: None,
3857 blocked_counts: ImpactCounts::default(),
3858 });
3859 }
3860 s
3861 }
3862
3863 #[test]
3864 fn repo_basename_returns_last_component_only() {
3865 assert_eq!(
3866 repo_basename(Path::new("/a/b/myrepo/.git")).as_deref(),
3867 Some("myrepo")
3868 );
3869 assert_eq!(
3870 repo_basename(Path::new("/a/b/proj")).as_deref(),
3871 Some("proj")
3872 );
3873 let name = repo_basename(Path::new("/x/y/z/.git")).unwrap();
3875 assert!(!name.contains('/') && !name.contains('\\'));
3876 }
3877
3878 #[test]
3879 fn aggregate_rolls_up_totals_and_excludes_empty() {
3880 let _cfg = aggregate_env();
3881 seed_store(
3882 "aaa",
3883 &store_with("alpha", 10, 2, "2026-06-10T00:00:00Z", 3),
3884 );
3885 seed_store("bbb", &store_with("beta", 5, 1, "2026-06-11T00:00:00Z", 0));
3886 seed_store(
3888 "ccc",
3889 &ImpactStore {
3890 enabled: true,
3891 explicit_decision: true,
3892 label: Some("gamma".into()),
3893 ..Default::default()
3894 },
3895 );
3896 let report = aggregate(CrossRepoSort::Recent);
3897 assert_eq!(report.project_count, 3, "all three stores enumerated");
3898 assert_eq!(report.tracked_count, 2, "empty store excluded from rows");
3899 assert_eq!(report.totals.resolved_total, 15);
3900 assert_eq!(report.totals.containment_count, 3);
3901 assert_eq!(report.unreadable_count, 0);
3902 }
3903
3904 #[test]
3905 fn aggregate_sort_recent_orders_by_last_activity() {
3906 let _cfg = aggregate_env();
3907 seed_store("old", &store_with("older", 1, 0, "2026-06-01T00:00:00Z", 1));
3908 seed_store("new", &store_with("newer", 1, 0, "2026-06-12T00:00:00Z", 1));
3909 let report = aggregate(CrossRepoSort::Recent);
3910 assert_eq!(report.projects[0].label.as_deref(), Some("newer"));
3911 assert_eq!(report.projects[1].label.as_deref(), Some("older"));
3912 }
3913
3914 #[test]
3915 fn cross_repo_json_carries_kind_and_leaks_no_path() {
3916 let _cfg = aggregate_env();
3917 seed_store("aaa", &store_with("alpha", 4, 1, "2026-06-10T00:00:00Z", 2));
3918 let report = aggregate(CrossRepoSort::Recent);
3919 let json = render_cross_repo_json(&report);
3920 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
3921 assert_eq!(value["kind"], "impact-cross-repo");
3922 for entry in value["projects"].as_array().unwrap() {
3924 if let Some(label) = entry["label"].as_str() {
3925 assert!(
3926 !label.contains('/') && !label.contains('\\'),
3927 "label must be a basename, got {label}"
3928 );
3929 }
3930 }
3931 assert!(
3932 !json.contains('/') || !json.contains("Users"),
3933 "json must not leak an absolute home path"
3934 );
3935 }
3936
3937 #[test]
3938 fn cross_repo_markdown_pluralizes_single_project() {
3939 let _cfg = aggregate_env();
3940 seed_store("solo", &store_with("solo", 3, 1, "2026-06-10T00:00:00Z", 2));
3941 let report = aggregate(CrossRepoSort::Recent);
3942 assert_eq!(report.project_count, 1);
3943 assert_eq!(report.tracked_count, 1);
3944 let md = render_cross_repo_markdown(&report);
3945 assert!(
3946 md.contains("1 project tracked"),
3947 "single project must read 'project', got:\n{md}"
3948 );
3949 assert!(
3950 !md.contains("1 projects tracked"),
3951 "must not pluralize a single project, got:\n{md}"
3952 );
3953 assert!(
3954 md.contains("across 1 tracked project"),
3955 "grand totals must read 'tracked project' (singular), got:\n{md}"
3956 );
3957 assert!(
3958 !md.contains("tracked projects"),
3959 "must not pluralize a single tracked project, got:\n{md}"
3960 );
3961 }
3962
3963 #[test]
3964 fn cross_repo_corrupt_file_is_skipped_and_counted() {
3965 let _cfg = aggregate_env();
3966 seed_store("good", &store_with("good", 3, 0, "2026-06-10T00:00:00Z", 1));
3967 let dir = impact_config_dir().unwrap().join("impact");
3968 std::fs::write(dir.join("bad.json"), b"{ not valid json ][").unwrap();
3969 let report = aggregate(CrossRepoSort::Recent);
3970 assert_eq!(report.tracked_count, 1, "good store still aggregated");
3971 assert_eq!(
3972 report.unreadable_count, 1,
3973 "corrupt file counted, not crashed"
3974 );
3975 }
3976
3977 #[test]
3978 fn cross_repo_empty_dir_is_first_run() {
3979 let _cfg = aggregate_env();
3980 let report = aggregate(CrossRepoSort::Recent);
3981 assert_eq!(report.project_count, 0);
3982 let human = render_cross_repo_human(&report, None);
3983 assert!(human.contains("No projects tracked yet"));
3984 }
3985
3986 #[test]
3987 fn cross_repo_all_corrupt_reports_unreadable_not_first_run() {
3988 let _cfg = aggregate_env();
3989 let dir = impact_config_dir().unwrap().join("impact");
3990 std::fs::create_dir_all(&dir).unwrap();
3991 std::fs::write(dir.join("bad.json"), b"{ broken ][").unwrap();
3992 let report = aggregate(CrossRepoSort::Recent);
3993 assert_eq!(report.project_count, 0);
3994 assert_eq!(report.unreadable_count, 1);
3995 let human = render_cross_repo_human(&report, None);
3996 assert!(
3997 human.contains("unreadable store") && !human.contains("No projects tracked yet"),
3998 "all-corrupt must report unreadable, not a misleading first-run hint: {human}"
3999 );
4000 }
4001
4002 #[test]
4003 fn record_audit_run_captures_basename_label() {
4004 let (_config, dir) = test_env();
4005 let root = dir.path();
4006 enable(root);
4007 record_v1(
4008 root,
4009 &summary(1, 0, 0),
4010 AuditVerdict::Warn,
4011 false,
4012 None,
4013 "2.0.0",
4014 "t0",
4015 );
4016 let label = load(root).label.expect("label captured on record");
4017 assert!(
4018 !label.contains('/') && !label.contains('\\'),
4019 "label must be a basename, got {label}"
4020 );
4021 }
4022
4023 #[test]
4026 fn lock_path_appends_lock_suffix() {
4027 assert_eq!(
4028 lock_path_for(Path::new("/c/fallow/impact/abc.json")),
4029 PathBuf::from("/c/fallow/impact/abc.json.lock")
4030 );
4031 }
4032
4033 #[test]
4034 fn store_lock_acquire_drop_then_record_roundtrips() {
4035 let (_config, dir) = test_env();
4036 let root = dir.path();
4037 enable(root);
4038 {
4041 let _lock = ImpactStoreLock::acquire(root).expect("lock acquires");
4042 }
4043 record_v1(
4044 root,
4045 &summary(1, 0, 0),
4046 AuditVerdict::Warn,
4047 false,
4048 None,
4049 "2.0.0",
4050 "t0",
4051 );
4052 assert_eq!(load(root).records.len(), 1, "record persisted under lock");
4053 let store = store_path(root).unwrap();
4055 assert!(lock_path_for(&store).exists(), "lock sidecar created");
4056 assert!(store.exists(), "store file is distinct from its lock");
4057 }
4058
4059 #[test]
4060 fn sweep_keeps_fresh_and_self_deletes_aged_out() {
4061 let _cfg = aggregate_env();
4062 seed_store("keepme", &store_with("keep", 1, 0, "t0", 1));
4063 seed_store("oldone", &store_with("old", 1, 0, "t0", 1));
4064 let lock = impact_config_dir()
4066 .unwrap()
4067 .join("impact")
4068 .join("oldone.json.lock");
4069 std::fs::write(&lock, b"").unwrap();
4070
4071 sweep_old_stores("keepme", std::time::Duration::ZERO);
4073
4074 let dir = impact_config_dir().unwrap().join("impact");
4075 assert!(dir.join("keepme.json").exists(), "kept store survives");
4076 assert!(
4077 !dir.join("oldone.json").exists(),
4078 "aged-out store reclaimed"
4079 );
4080 assert!(lock.exists(), "lock sidecar never deleted by the sweep");
4081 }
4082
4083 #[test]
4084 fn sweep_keeps_everything_under_a_large_window() {
4085 let _cfg = aggregate_env();
4086 seed_store("a", &store_with("a", 1, 0, "t0", 1));
4087 seed_store("b", &store_with("b", 1, 0, "t0", 1));
4088 sweep_old_stores("a", std::time::Duration::from_hours(10 * 365 * 24));
4090 let dir = impact_config_dir().unwrap().join("impact");
4091 assert!(dir.join("a.json").exists());
4092 assert!(dir.join("b.json").exists());
4093 }
4094
4095 #[test]
4098 #[cfg_attr(miri, ignore)]
4099 fn legacy_into_store_with_empty_frontiers_does_not_insert_worktree_key() {
4100 let legacy = LegacyFlatStore {
4103 enabled: true,
4104 explicit_decision: true,
4105 first_recorded: Some("t0".to_owned()),
4106 records: vec![],
4107 project_records: vec![],
4108 containment: vec![],
4109 pending_containment: None,
4110 frontier: FxHashMap::default(),
4111 clone_frontier: FxHashMap::default(),
4112 resolved_total: 0,
4113 suppressed_total: 0,
4114 recent_resolved: vec![],
4115 onboarding_declined: false,
4116 last_digest_epoch: None,
4117 };
4118 let store = legacy.into_store("wt-key");
4119 assert!(
4120 store.frontier.is_empty(),
4121 "empty legacy frontier must not insert a worktree key"
4122 );
4123 assert!(
4124 store.clone_frontier.is_empty(),
4125 "empty legacy clone_frontier must not insert a worktree key"
4126 );
4127 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
4128 assert!(store.label.is_none(), "label is always None on migration");
4129 }
4130
4131 #[test]
4134 fn repo_basename_non_git_path_returns_last_component() {
4135 assert_eq!(
4137 repo_basename(Path::new("/a/b/myproject")).as_deref(),
4138 Some("myproject")
4139 );
4140 }
4141
4142 #[test]
4143 fn repo_basename_root_path_returns_none() {
4144 assert!(repo_basename(Path::new("/")).is_none());
4146 }
4147
4148 #[test]
4151 fn direction_for_declining_when_delta_positive() {
4152 assert_eq!(direction_for(1), ImpactTrendDirection::Declining);
4153 assert_eq!(direction_for(100), ImpactTrendDirection::Declining);
4154 }
4155
4156 #[test]
4157 fn direction_for_stable_when_delta_zero() {
4158 assert_eq!(direction_for(0), ImpactTrendDirection::Stable);
4159 }
4160
4161 #[test]
4162 fn direction_for_improving_when_delta_negative() {
4163 assert_eq!(direction_for(-1), ImpactTrendDirection::Improving);
4164 }
4165
4166 #[test]
4169 fn trend_arrow_all_directions() {
4170 assert_eq!(trend_arrow(ImpactTrendDirection::Improving), "down");
4171 assert_eq!(trend_arrow(ImpactTrendDirection::Declining), "up");
4172 assert_eq!(trend_arrow(ImpactTrendDirection::Stable), "flat");
4173 }
4174
4175 #[test]
4178 fn build_report_project_trend_populated_from_project_records() {
4179 let mut store = ImpactStore {
4180 enabled: true,
4181 ..Default::default()
4182 };
4183 for total in [20usize, 15usize] {
4184 store.project_records.push(ImpactRecord {
4185 timestamp: format!("t{total}"),
4186 version: "2.0.0".into(),
4187 git_sha: None,
4188 verdict: "warn".into(),
4189 gate: false,
4190 counts: ImpactCounts {
4191 total_issues: total,
4192 dead_code: total,
4193 complexity: 0,
4194 duplication: 0,
4195 },
4196 });
4197 }
4198 let report = build_report(&store);
4199 let pt = report
4200 .project_trend
4201 .expect("two project records yield a trend");
4202 assert_eq!(pt.direction, ImpactTrendDirection::Improving);
4203 assert_eq!(pt.previous_total, 20);
4204 assert_eq!(pt.current_total, 15);
4205 assert_eq!(pt.total_delta, -5);
4206 }
4207
4208 #[test]
4211 fn latest_activity_both_series_returns_newer() {
4212 let mut store = ImpactStore::default();
4213 store.records.push(ImpactRecord {
4214 timestamp: "2026-01-01T00:00:00Z".to_owned(),
4215 version: "2.0.0".to_owned(),
4216 git_sha: None,
4217 verdict: "warn".to_owned(),
4218 gate: false,
4219 counts: ImpactCounts::default(),
4220 });
4221 store.project_records.push(ImpactRecord {
4222 timestamp: "2026-06-15T00:00:00Z".to_owned(),
4223 version: "2.0.0".to_owned(),
4224 git_sha: None,
4225 verdict: "warn".to_owned(),
4226 gate: false,
4227 counts: ImpactCounts::default(),
4228 });
4229 assert_eq!(
4230 latest_activity(&store).as_deref(),
4231 Some("2026-06-15T00:00:00Z")
4232 );
4233 }
4234
4235 #[test]
4236 fn latest_activity_only_project_records() {
4237 let mut store = ImpactStore::default();
4238 store.project_records.push(ImpactRecord {
4239 timestamp: "2026-05-01T00:00:00Z".to_owned(),
4240 version: "2.0.0".to_owned(),
4241 git_sha: None,
4242 verdict: "pass".to_owned(),
4243 gate: false,
4244 counts: ImpactCounts::default(),
4245 });
4246 assert_eq!(
4247 latest_activity(&store).as_deref(),
4248 Some("2026-05-01T00:00:00Z")
4249 );
4250 }
4251
4252 #[test]
4253 fn latest_activity_empty_store_returns_none() {
4254 assert!(latest_activity(&ImpactStore::default()).is_none());
4255 }
4256
4257 #[test]
4260 #[cfg_attr(miri, ignore)]
4261 fn containment_events_are_bounded_at_max_containment() {
4262 let (_config, dir) = test_env();
4263 let root = dir.path();
4264 enable(root);
4265 let mut store = load(root);
4267 for i in 0..MAX_CONTAINMENT {
4268 store.containment.push(ContainmentEvent {
4269 blocked_at: format!("block{i}"),
4270 cleared_at: format!("clear{i}"),
4271 git_sha: None,
4272 blocked_counts: ImpactCounts::default(),
4273 });
4274 }
4275 save(&store, root);
4276
4277 record_v1(
4279 root,
4280 &summary(1, 0, 0),
4281 AuditVerdict::Fail,
4282 true,
4283 None,
4284 "2.0.0",
4285 "overflow_block",
4286 );
4287 record_v1(
4288 root,
4289 &summary(0, 0, 0),
4290 AuditVerdict::Pass,
4291 true,
4292 None,
4293 "2.0.0",
4294 "overflow_clear",
4295 );
4296 let store = load(root);
4297 assert_eq!(
4298 store.containment.len(),
4299 MAX_CONTAINMENT,
4300 "containment must be capped at MAX_CONTAINMENT"
4301 );
4302 assert_eq!(
4304 store.containment.last().unwrap().blocked_at,
4305 "overflow_block"
4306 );
4307 }
4308
4309 #[test]
4312 #[cfg_attr(miri, ignore)]
4313 fn disable_from_enabled_state_sets_schema_version() {
4314 let (_config, dir) = test_env();
4315 let root = dir.path();
4316 enable(root);
4317 let was_newly_disabled = disable(root);
4318 assert!(
4319 was_newly_disabled,
4320 "disable returns true when previously enabled"
4321 );
4322 let store = load(root);
4323 assert!(!store.enabled);
4324 assert!(store.explicit_decision);
4325 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
4326
4327 let again = disable(root);
4329 assert!(!again, "disable returns false when already off");
4330 }
4331
4332 #[test]
4335 #[cfg_attr(miri, ignore)]
4336 fn record_combined_run_is_noop_in_ci() {
4337 let (_config, dir) = test_env();
4338 let root = dir.path();
4339 enable(root);
4340 TEST_FORCE_CI.with(|c| c.set(true));
4341 record_combined_run(
4342 root,
4343 ImpactCounts::from_combined(5, 0, 0),
4344 None,
4345 "2.0.0",
4346 "t0",
4347 None,
4348 );
4349 TEST_FORCE_CI.with(|c| c.set(false));
4350 assert!(
4351 load(root).project_records.is_empty(),
4352 "combined run must not record on CI"
4353 );
4354 }
4355
4356 #[test]
4357 #[cfg_attr(miri, ignore)]
4358 fn record_combined_run_is_noop_when_disabled() {
4359 let (_config, dir) = test_env();
4360 let root = dir.path();
4361 record_combined_run(
4363 root,
4364 ImpactCounts::from_combined(3, 0, 0),
4365 None,
4366 "2.0.0",
4367 "t0",
4368 None,
4369 );
4370 assert!(
4371 load(root).project_records.is_empty(),
4372 "combined run must not record when disabled"
4373 );
4374 }
4375
4376 #[test]
4377 #[cfg_attr(miri, ignore)]
4378 fn record_combined_run_project_records_bounded_at_max() {
4379 let (_config, dir) = test_env();
4380 let root = dir.path();
4381 enable(root);
4382 let mut store = load(root);
4384 for i in 0..MAX_RECORDS {
4385 store.project_records.push(ImpactRecord {
4386 timestamp: format!("t{i}"),
4387 version: "2.0.0".to_owned(),
4388 git_sha: None,
4389 verdict: "warn".to_owned(),
4390 gate: false,
4391 counts: ImpactCounts::default(),
4392 });
4393 }
4394 save(&store, root);
4395
4396 record_combined_run(
4397 root,
4398 ImpactCounts::from_combined(1, 0, 0),
4399 None,
4400 "2.0.0",
4401 "overflow",
4402 None,
4403 );
4404 let store = load(root);
4405 assert_eq!(
4406 store.project_records.len(),
4407 MAX_RECORDS,
4408 "project_records must be capped at MAX_RECORDS"
4409 );
4410 assert_eq!(
4411 store.project_records.last().unwrap().timestamp,
4412 "overflow",
4413 "newest record must be at the tail"
4414 );
4415 }
4416
4417 #[test]
4420 #[cfg_attr(miri, ignore)]
4421 fn suppressed_clone_disappearance_credits_suppressed_not_resolved() {
4422 let (_config, dir) = test_env();
4423 let root = dir.path();
4424 enable(root);
4425 let a = touch(root, "src/a.ts");
4426 let b = touch(root, "src/b.ts");
4427 let clone = CloneInput {
4428 fingerprint: "dup:suppressed".to_owned(),
4429 instance_paths: vec![a.clone(), b.clone()],
4430 };
4431 run(root, &[&a, &b], vec![], vec![clone], &[], "t0");
4432 let blanket = ActiveSuppression {
4434 path: a.clone(),
4435 kind: None,
4436 is_file_level: true,
4437 reason: None,
4438 };
4439 run(root, &[&a, &b], vec![], vec![], &[blanket], "t1");
4440 let store = load(root);
4441 assert_eq!(
4442 store.suppressed_total, 1,
4443 "suppressed clone disappearance must count as suppressed"
4444 );
4445 assert_eq!(
4446 store.resolved_total, 0,
4447 "suppressed clone must not count as resolved"
4448 );
4449 }
4450
4451 #[test]
4454 #[cfg_attr(miri, ignore)]
4455 fn load_all_skips_non_json_and_lock_files() {
4456 let _cfg = aggregate_env();
4457 seed_store("real", &store_with("real", 2, 0, "2026-06-10T00:00:00Z", 1));
4458 let dir = impact_config_dir().unwrap().join("impact");
4459 std::fs::write(dir.join("real.json.lock"), b"").unwrap();
4461 std::fs::write(dir.join("notes.txt"), b"ignored").unwrap();
4462 let (stores, unreadable) = load_all();
4463 assert_eq!(stores.len(), 1, "only the .json store is loaded");
4464 assert_eq!(
4465 unreadable, 0,
4466 "lock and txt files are not counted unreadable"
4467 );
4468 }
4469
4470 #[test]
4473 fn aggregate_totals_project_wide_issues_from_project_surfacing() {
4474 let mut s1 = ImpactStore {
4476 enabled: true,
4477 explicit_decision: true,
4478 resolved_total: 3,
4479 ..Default::default()
4480 };
4481 s1.records.push(ImpactRecord {
4482 timestamp: "t1".to_owned(),
4483 version: "2.0.0".to_owned(),
4484 git_sha: None,
4485 verdict: "warn".to_owned(),
4486 gate: false,
4487 counts: ImpactCounts::from_combined(5, 0, 0),
4488 });
4489 s1.project_records.push(ImpactRecord {
4491 timestamp: "pt1".to_owned(),
4492 version: "2.0.0".to_owned(),
4493 git_sha: None,
4494 verdict: "warn".to_owned(),
4495 gate: false,
4496 counts: ImpactCounts::from_combined(20, 0, 0),
4497 });
4498
4499 let mut s2 = ImpactStore {
4500 enabled: true,
4501 explicit_decision: true,
4502 resolved_total: 7,
4503 ..Default::default()
4504 };
4505 s2.records.push(ImpactRecord {
4506 timestamp: "t2".to_owned(),
4507 version: "2.0.0".to_owned(),
4508 git_sha: None,
4509 verdict: "warn".to_owned(),
4510 gate: false,
4511 counts: ImpactCounts::from_combined(2, 0, 0),
4512 });
4513 s2.project_records.push(ImpactRecord {
4514 timestamp: "pt2".to_owned(),
4515 version: "2.0.0".to_owned(),
4516 git_sha: None,
4517 verdict: "warn".to_owned(),
4518 gate: false,
4519 counts: ImpactCounts::from_combined(30, 0, 0),
4520 });
4521
4522 let report = build_aggregate_report(
4523 vec![("k1".to_owned(), s1), ("k2".to_owned(), s2)],
4524 0,
4525 CrossRepoSort::Recent,
4526 );
4527 assert_eq!(report.totals.resolved_total, 10);
4528 assert_eq!(report.totals.project_wide_issues, 50);
4529 assert_eq!(report.totals.projects_with_baseline, 2);
4530 }
4531
4532 #[test]
4535 fn aggregate_sort_resolved_orders_by_resolved_total() {
4536 let _cfg = aggregate_env();
4537 seed_store("low", &store_with("low", 1, 0, "2026-06-10T00:00:00Z", 1));
4538 seed_store("high", &store_with("high", 9, 0, "2026-06-09T00:00:00Z", 1));
4539 let report = aggregate(CrossRepoSort::Resolved);
4540 assert_eq!(report.projects[0].label.as_deref(), Some("high"));
4541 assert_eq!(report.projects[1].label.as_deref(), Some("low"));
4542 }
4543
4544 #[test]
4545 fn aggregate_sort_contained_orders_by_containment_count() {
4546 let _cfg = aggregate_env();
4547 seed_store("none", &store_with("none", 1, 0, "2026-06-10T00:00:00Z", 1));
4548 seed_store("many", &store_with("many", 1, 3, "2026-06-09T00:00:00Z", 1));
4549 let report = aggregate(CrossRepoSort::Contained);
4550 assert_eq!(report.projects[0].label.as_deref(), Some("many"));
4551 assert_eq!(report.projects[1].label.as_deref(), Some("none"));
4552 }
4553
4554 #[test]
4555 fn aggregate_sort_name_orders_alphabetically_by_label() {
4556 let _cfg = aggregate_env();
4557 seed_store("zzz", &store_with("zulu", 1, 0, "2026-06-10T00:00:00Z", 1));
4558 seed_store("aaa", &store_with("alpha", 1, 0, "2026-06-09T00:00:00Z", 1));
4559 let report = aggregate(CrossRepoSort::Name);
4560 assert_eq!(report.projects[0].label.as_deref(), Some("alpha"));
4561 assert_eq!(report.projects[1].label.as_deref(), Some("zulu"));
4562 }
4563
4564 #[test]
4567 fn render_cross_repo_human_limit_shows_overflow_line() {
4568 let _cfg = aggregate_env();
4569 seed_store("a", &store_with("alpha", 1, 0, "2026-06-10T00:00:00Z", 1));
4570 seed_store("b", &store_with("beta", 2, 0, "2026-06-09T00:00:00Z", 1));
4571 seed_store("c", &store_with("gamma", 3, 0, "2026-06-08T00:00:00Z", 1));
4572 let report = aggregate(CrossRepoSort::Recent);
4573 let human = render_cross_repo_human(&report, Some(2));
4574 assert!(
4575 human.contains("and 1 more"),
4576 "overflow line missing when limit < tracked_count: {human}"
4577 );
4578 }
4579
4580 #[test]
4581 fn render_cross_repo_human_no_limit_shows_all_rows() {
4582 let _cfg = aggregate_env();
4583 seed_store("a", &store_with("alpha", 1, 0, "2026-06-10T00:00:00Z", 1));
4584 seed_store("b", &store_with("beta", 2, 0, "2026-06-09T00:00:00Z", 1));
4585 let report = aggregate(CrossRepoSort::Recent);
4586 let human = render_cross_repo_human(&report, None);
4587 assert!(
4588 !human.contains("more (raise --limit"),
4589 "no limit => no overflow line: {human}"
4590 );
4591 assert!(human.contains("alpha"));
4592 assert!(human.contains("beta"));
4593 }
4594
4595 #[test]
4598 fn render_cross_repo_human_shows_no_history_count() {
4599 let _cfg = aggregate_env();
4600 seed_store(
4601 "empty",
4602 &ImpactStore {
4603 enabled: true,
4604 explicit_decision: true,
4605 ..Default::default()
4606 },
4607 );
4608 seed_store("full", &store_with("full", 1, 0, "t0", 1));
4609 let report = aggregate(CrossRepoSort::Recent);
4610 let human = render_cross_repo_human(&report, None);
4611 assert!(
4612 human.contains("tracked project") && human.contains("no history yet"),
4613 "must report the no-history count: {human}"
4614 );
4615 }
4616
4617 #[test]
4618 fn render_cross_repo_human_shows_skipped_unreadable() {
4619 let _cfg = aggregate_env();
4620 seed_store("good", &store_with("good", 1, 0, "t0", 1));
4621 let dir = impact_config_dir().unwrap().join("impact");
4622 std::fs::write(dir.join("corrupt.json"), b"}{broken").unwrap();
4623 let report = aggregate(CrossRepoSort::Recent);
4624 let human = render_cross_repo_human(&report, None);
4625 assert!(
4626 human.contains("skipped") && human.contains("unreadable store"),
4627 "must show the skipped unreadable count: {human}"
4628 );
4629 }
4630
4631 #[test]
4634 fn render_cross_repo_human_grand_totals_shows_project_wide_when_present() {
4635 let _cfg = aggregate_env();
4636 let mut s = store_with("proj", 5, 1, "2026-06-10T00:00:00Z", 4);
4637 s.project_records.push(ImpactRecord {
4639 timestamp: "pt".to_owned(),
4640 version: "2.0.0".to_owned(),
4641 git_sha: None,
4642 verdict: "warn".to_owned(),
4643 gate: false,
4644 counts: ImpactCounts::from_combined(42, 0, 0),
4645 });
4646 seed_store("proj", &s);
4647 let report = aggregate(CrossRepoSort::Recent);
4648 let human = render_cross_repo_human(&report, None);
4649 assert!(
4650 human.contains("42 issue") && human.contains("project-wide"),
4651 "grand totals must include the project-wide line: {human}"
4652 );
4653 }
4654
4655 #[test]
4658 fn render_human_resolved_event_without_symbol_omits_symbol() {
4659 let report = ImpactReport {
4660 schema_version: ImpactReportSchemaVersion::V1,
4661 enabled: true,
4662 enabled_source: EnabledSource::Project,
4663 record_count: 2,
4664 meta: None,
4665 first_recorded: Some("2026-05-01T00:00:00Z".into()),
4666 latest_git_sha: None,
4667 surfacing: Some(ImpactCounts::default()),
4668 trend: None,
4669 project_surfacing: None,
4670 project_trend: None,
4671 containment_count: 0,
4672 recent_containment: vec![],
4673 resolved_total: 1,
4674 suppressed_total: 0,
4675 recent_resolved: vec![ResolutionEvent {
4676 kind: "unused-file".to_owned(),
4677 path: "src/dead.ts".to_owned(),
4678 symbol: None,
4679 git_sha: None,
4680 timestamp: "t1".to_owned(),
4681 }],
4682 attribution_active: true,
4683 onboarding_declined: false,
4684 explicit_decision: true,
4685 };
4686 let human = render_human(&report);
4687 assert!(
4689 human.contains("unused-file in src/dead.ts"),
4690 "no-symbol event must show 'kind in path': {human}"
4691 );
4692 assert!(!human.contains("None"), "must not stringify None: {human}");
4693 }
4694
4695 #[test]
4698 fn render_markdown_disabled_shows_enable_hint() {
4699 let report = ImpactReport {
4700 schema_version: ImpactReportSchemaVersion::V1,
4701 enabled: false,
4702 enabled_source: EnabledSource::Default,
4703 record_count: 0,
4704 meta: None,
4705 first_recorded: None,
4706 latest_git_sha: None,
4707 surfacing: None,
4708 trend: None,
4709 project_surfacing: None,
4710 project_trend: None,
4711 containment_count: 0,
4712 recent_containment: vec![],
4713 resolved_total: 0,
4714 suppressed_total: 0,
4715 recent_resolved: vec![],
4716 attribution_active: false,
4717 onboarding_declined: false,
4718 explicit_decision: false,
4719 };
4720 let md = render_markdown(&report);
4721 assert!(
4722 md.contains("Impact tracking is off"),
4723 "disabled markdown must show enable hint: {md}"
4724 );
4725 assert!(md.contains("fallow impact enable"));
4726 }
4727
4728 #[test]
4729 fn render_markdown_with_trend_shows_trend_line() {
4730 let report = ImpactReport {
4731 schema_version: ImpactReportSchemaVersion::V1,
4732 enabled: true,
4733 enabled_source: EnabledSource::Project,
4734 record_count: 2,
4735 meta: None,
4736 first_recorded: Some("2026-05-01T00:00:00Z".into()),
4737 latest_git_sha: None,
4738 surfacing: Some(ImpactCounts::from_combined(3, 3, 0)),
4739 trend: Some(TrendSummary {
4740 direction: ImpactTrendDirection::Declining,
4741 total_delta: 2,
4742 previous_total: 1,
4743 current_total: 3,
4744 }),
4745 project_surfacing: None,
4746 project_trend: None,
4747 containment_count: 0,
4748 recent_containment: vec![],
4749 resolved_total: 0,
4750 suppressed_total: 0,
4751 recent_resolved: vec![],
4752 attribution_active: false,
4753 onboarding_declined: false,
4754 explicit_decision: true,
4755 };
4756 let md = render_markdown(&report);
4757 assert!(
4758 md.contains("Trend (changed-file scope"),
4759 "markdown with trend must show trend line: {md}"
4760 );
4761 assert!(md.contains("1 -> 3 (up)"), "trend values present: {md}");
4762 }
4763
4764 #[test]
4765 fn render_markdown_with_project_trend_shows_project_trend_line() {
4766 let report = ImpactReport {
4767 schema_version: ImpactReportSchemaVersion::V1,
4768 enabled: true,
4769 enabled_source: EnabledSource::Project,
4770 record_count: 1,
4771 meta: None,
4772 first_recorded: Some("2026-05-01T00:00:00Z".into()),
4773 latest_git_sha: None,
4774 surfacing: None,
4775 trend: None,
4776 project_surfacing: Some(ImpactCounts::from_combined(10, 5, 3)),
4777 project_trend: Some(TrendSummary {
4778 direction: ImpactTrendDirection::Stable,
4779 total_delta: 0,
4780 previous_total: 10,
4781 current_total: 10,
4782 }),
4783 containment_count: 0,
4784 recent_containment: vec![],
4785 resolved_total: 0,
4786 suppressed_total: 0,
4787 recent_resolved: vec![],
4788 attribution_active: false,
4789 onboarding_declined: false,
4790 explicit_decision: true,
4791 };
4792 let md = render_markdown(&report);
4793 assert!(
4794 md.contains("Project trend (whole project"),
4795 "project trend line must appear in markdown: {md}"
4796 );
4797 assert!(
4798 md.contains("10 -> 10 (flat)"),
4799 "stable trend must read 'flat': {md}"
4800 );
4801 }
4802
4803 #[test]
4804 fn render_markdown_enabled_no_history_shows_check_back() {
4805 let report = ImpactReport {
4806 schema_version: ImpactReportSchemaVersion::V1,
4807 enabled: true,
4808 enabled_source: EnabledSource::Project,
4809 record_count: 0,
4810 meta: None,
4811 first_recorded: None,
4812 latest_git_sha: None,
4813 surfacing: None,
4814 trend: None,
4815 project_surfacing: None,
4816 project_trend: None,
4817 containment_count: 0,
4818 recent_containment: vec![],
4819 resolved_total: 0,
4820 suppressed_total: 0,
4821 recent_resolved: vec![],
4822 attribution_active: false,
4823 onboarding_declined: false,
4824 explicit_decision: true,
4825 };
4826 let md = render_markdown(&report);
4827 assert!(
4828 md.contains("No history yet"),
4829 "enabled but empty markdown must show 'No history yet': {md}"
4830 );
4831 }
4832
4833 #[test]
4834 fn render_markdown_resolved_shows_count_when_positive() {
4835 let report = ImpactReport {
4836 schema_version: ImpactReportSchemaVersion::V1,
4837 enabled: true,
4838 enabled_source: EnabledSource::Project,
4839 record_count: 3,
4840 meta: None,
4841 first_recorded: Some("2026-05-01T00:00:00Z".into()),
4842 latest_git_sha: None,
4843 surfacing: Some(ImpactCounts::default()),
4844 trend: None,
4845 project_surfacing: None,
4846 project_trend: None,
4847 containment_count: 0,
4848 recent_containment: vec![],
4849 resolved_total: 4,
4850 suppressed_total: 1,
4851 recent_resolved: vec![],
4852 attribution_active: true,
4853 onboarding_declined: false,
4854 explicit_decision: true,
4855 };
4856 let md = render_markdown(&report);
4857 assert!(
4858 md.contains("**Resolved:** 4 finding"),
4859 "markdown must show resolved count: {md}"
4860 );
4861 assert!(
4862 md.contains("**Marked intentional:** 1 finding"),
4863 "markdown must show suppressed count: {md}"
4864 );
4865 }
4866
4867 #[test]
4870 fn render_markdown_footer_project_only_no_audit_records() {
4871 let report = ImpactReport {
4874 schema_version: ImpactReportSchemaVersion::V1,
4875 enabled: true,
4876 enabled_source: EnabledSource::Project,
4877 record_count: 0,
4878 meta: None,
4879 first_recorded: Some("2026-05-10T00:00:00Z".into()),
4880 latest_git_sha: None,
4881 surfacing: None,
4882 trend: None,
4883 project_surfacing: Some(ImpactCounts::from_combined(3, 2, 1)),
4884 project_trend: None,
4885 containment_count: 0,
4886 recent_containment: vec![],
4887 resolved_total: 0,
4888 suppressed_total: 0,
4889 recent_resolved: vec![],
4890 attribution_active: false,
4891 onboarding_declined: false,
4892 explicit_decision: true,
4893 };
4894 let md = render_markdown(&report);
4895 assert!(
4896 md.contains("Tracking since 2026-05-10"),
4897 "project-only footer must say 'Tracking since': {md}"
4898 );
4899 assert!(
4900 !md.contains("recorded audit run"),
4901 "project-only footer must not mention 'recorded audit run': {md}"
4902 );
4903 }
4904
4905 #[test]
4908 fn render_cross_repo_markdown_includes_project_wide_totals_when_present() {
4909 let _cfg = aggregate_env();
4910 let mut s = store_with("proj", 2, 0, "t0", 1);
4911 s.project_records.push(ImpactRecord {
4912 timestamp: "pt".to_owned(),
4913 version: "2.0.0".to_owned(),
4914 git_sha: None,
4915 verdict: "warn".to_owned(),
4916 gate: false,
4917 counts: ImpactCounts::from_combined(99, 0, 0),
4918 });
4919 seed_store("proj", &s);
4920 let report = aggregate(CrossRepoSort::Recent);
4921 let md = render_cross_repo_markdown(&report);
4922 assert!(
4923 md.contains("99 issue") && md.contains("project-wide"),
4924 "cross-repo markdown must show project-wide totals: {md}"
4925 );
4926 }
4927
4928 #[test]
4931 #[cfg_attr(miri, ignore)]
4932 fn migrate_legacy_store_corrupt_file_returns_default() {
4933 let (_config, dir) = test_env();
4934 let root = dir.path();
4935 std::fs::create_dir_all(root.join(".fallow")).unwrap();
4937 std::fs::write(legacy_store_path(root), b"{ corrupted json ][").unwrap();
4938 let store = load(root);
4940 assert!(!store.enabled);
4942 assert!(store.records.is_empty());
4943 }
4944
4945 #[test]
4948 fn resolve_enabled_user_source_appears_in_report() {
4949 let (_config, _dir) = test_env();
4950 set_global_default(true);
4951 let never_asked = ImpactStore::default();
4952 let (enabled, source) = resolve_enabled(&never_asked);
4953 assert!(enabled);
4954 assert_eq!(source, EnabledSource::User);
4955 let report = build_report(&never_asked);
4956 assert_eq!(report.enabled_source, EnabledSource::User);
4957 }
4958
4959 #[test]
4962 fn render_cross_repo_human_long_label_is_truncated_in_table() {
4963 let _cfg = aggregate_env();
4964 let very_long_name = "this_is_a_very_long_project_name_that_exceeds_the_column_width";
4965 let s = store_with(very_long_name, 1, 0, "2026-06-10T00:00:00Z", 1);
4966 seed_store("longkey", &s);
4967 let report = aggregate(CrossRepoSort::Recent);
4968 let human = render_cross_repo_human(&report, None);
4969 assert!(
4971 human.contains("..."),
4972 "long label must be truncated with '...': {human}"
4973 );
4974 }
4975
4976 #[test]
4979 fn aggregate_sort_name_falls_back_to_short_key_when_no_label() {
4980 let _cfg = aggregate_env();
4981 let mut s = ImpactStore {
4983 enabled: true,
4984 explicit_decision: true,
4985 resolved_total: 1,
4986 label: None,
4987 ..Default::default()
4988 };
4989 s.records.push(ImpactRecord {
4990 timestamp: "t0".to_owned(),
4991 version: "2.0.0".to_owned(),
4992 git_sha: None,
4993 verdict: "warn".to_owned(),
4994 gate: false,
4995 counts: ImpactCounts::from_combined(1, 0, 0),
4996 });
4997 seed_store("abcdefghijklmnop", &s);
4998 let report = aggregate(CrossRepoSort::Name);
4999 assert_eq!(report.tracked_count, 1);
5001 assert_eq!(report.projects[0].project_key, "abcdefghijklmnop");
5003 }
5004
5005 #[test]
5008 fn row_trend_falls_back_to_changed_file_trend_when_no_project_trend() {
5009 let report = ImpactReport {
5010 schema_version: ImpactReportSchemaVersion::V1,
5011 enabled: true,
5012 enabled_source: EnabledSource::Project,
5013 record_count: 2,
5014 meta: None,
5015 first_recorded: None,
5016 latest_git_sha: None,
5017 surfacing: Some(ImpactCounts::default()),
5018 trend: Some(TrendSummary {
5019 direction: ImpactTrendDirection::Improving,
5020 total_delta: -3,
5021 previous_total: 8,
5022 current_total: 5,
5023 }),
5024 project_surfacing: None,
5025 project_trend: None,
5026 containment_count: 0,
5027 recent_containment: vec![],
5028 resolved_total: 0,
5029 suppressed_total: 0,
5030 recent_resolved: vec![],
5031 attribution_active: false,
5032 onboarding_declined: false,
5033 explicit_decision: true,
5034 };
5035 assert_eq!(row_trend(&report), "down");
5036 }
5037
5038 #[test]
5039 fn row_trend_returns_dash_when_no_trend_at_all() {
5040 let report = ImpactReport {
5041 schema_version: ImpactReportSchemaVersion::V1,
5042 enabled: true,
5043 enabled_source: EnabledSource::Project,
5044 record_count: 1,
5045 meta: None,
5046 first_recorded: None,
5047 latest_git_sha: None,
5048 surfacing: Some(ImpactCounts::default()),
5049 trend: None,
5050 project_surfacing: None,
5051 project_trend: None,
5052 containment_count: 0,
5053 recent_containment: vec![],
5054 resolved_total: 0,
5055 suppressed_total: 0,
5056 recent_resolved: vec![],
5057 attribution_active: false,
5058 onboarding_declined: false,
5059 explicit_decision: true,
5060 };
5061 assert_eq!(row_trend(&report), "-");
5062 }
5063
5064 #[test]
5067 fn opt_count_returns_dash_for_none_and_total_for_some() {
5068 assert_eq!(opt_count(None), "-");
5069 assert_eq!(opt_count(Some(&ImpactCounts::from_combined(4, 2, 1))), "7");
5071 }
5072
5073 #[test]
5076 #[cfg_attr(miri, ignore)]
5077 fn impact_project_key_is_a_hex_string_with_no_separator() {
5078 let (_config, dir) = test_env();
5079 let root = dir.path();
5080 let key = project_identity(root).0;
5081 assert!(
5082 !key.contains('/') && !key.contains('\\'),
5083 "project key must not contain a path separator: {key}"
5084 );
5085 assert!(!key.is_empty(), "project key must not be empty");
5086 }
5087
5088 #[test]
5091 fn impact_counts_from_combined_sums_to_total() {
5092 let c = ImpactCounts::from_combined(3, 2, 1);
5093 assert_eq!(c.total_issues, 6);
5094 assert_eq!(c.dead_code, 3);
5095 assert_eq!(c.complexity, 2);
5096 assert_eq!(c.duplication, 1);
5097 }
5098
5099 #[test]
5102 fn render_cross_repo_markdown_all_empty_projects_tracked_count_zero() {
5103 let _cfg = aggregate_env();
5105 seed_store(
5106 "empty1",
5107 &ImpactStore {
5108 enabled: true,
5109 explicit_decision: true,
5110 ..Default::default()
5111 },
5112 );
5113 let report = aggregate(CrossRepoSort::Recent);
5114 assert_eq!(report.project_count, 1);
5115 assert_eq!(report.tracked_count, 0);
5116 let md = render_cross_repo_markdown(&report);
5117 assert!(
5119 md.contains("1 project tracked, 0 with history"),
5120 "must show counts: {md}"
5121 );
5122 assert!(
5123 !md.contains("| Project |"),
5124 "no table when tracked_count is 0: {md}"
5125 );
5126 }
5127
5128 #[test]
5131 fn render_cross_repo_markdown_zero_projects_with_unreadable() {
5132 let _cfg = aggregate_env();
5133 let dir = impact_config_dir().unwrap().join("impact");
5134 std::fs::create_dir_all(&dir).unwrap();
5135 std::fs::write(dir.join("bad.json"), b"}{bad").unwrap();
5136 let report = aggregate(CrossRepoSort::Recent);
5137 assert_eq!(report.project_count, 0);
5138 assert_eq!(report.unreadable_count, 1);
5139 let md = render_cross_repo_markdown(&report);
5140 assert!(
5141 md.contains("skipped") && md.contains("unreadable store"),
5142 "must report corrupt stores: {md}"
5143 );
5144 }
5145}