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