Skip to main content

fallow_cli/
impact.rs

1//! Fallow Impact: local, opt-in value reporting.
2
3use std::path::{Path, PathBuf};
4
5use fallow_types::envelope::Meta;
6use fallow_types::results::{ActiveSuppression, AnalysisResults};
7use rustc_hash::{FxHashMap, FxHashSet};
8use serde::{Deserialize, Serialize};
9
10use crate::audit::{AuditSummary, AuditVerdict};
11use crate::report::ci::fingerprint::fingerprint_hash;
12use crate::report::format_display_path;
13
14const STORE_SCHEMA_VERSION: u32 = 5;
15
16const MAX_RECORDS: usize = 200;
17
18const MAX_CONTAINMENT: usize = 200;
19
20const TREND_TOLERANCE: i64 = 0;
21
22const STORE_FILE: &str = "impact.json";
23
24/// Env var: when set to a positive integer N, a recorded run opportunistically
25/// removes per-project store files whose file mtime is older than N days,
26/// reclaiming stores left behind by deleted repos. Unset / `0` / unparseable
27/// disables the sweep (default: keep every store forever).
28const STORE_MAX_AGE_ENV: &str = "FALLOW_IMPACT_STORE_MAX_AGE_DAYS";
29
30const MAX_RECENT_RESOLVED: usize = 50;
31
32const ID_SEP: &str = "\u{1f}";
33
34const CODE_DUPLICATION_KIND: &str = "code-duplication";
35
36const BLANKET_SUPPRESSION: &str = "*";
37
38/// Per-category issue counts captured at a recorded run.
39#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41pub struct ImpactCounts {
42    pub total_issues: usize,
43    pub dead_code: usize,
44    pub complexity: usize,
45    pub duplication: usize,
46}
47
48impl ImpactCounts {
49    fn from_summary(summary: &AuditSummary) -> Self {
50        Self {
51            total_issues: summary.dead_code_issues
52                + summary.complexity_findings
53                + summary.duplication_clone_groups,
54            dead_code: summary.dead_code_issues,
55            complexity: summary.complexity_findings,
56            duplication: summary.duplication_clone_groups,
57        }
58    }
59
60    pub(crate) fn from_combined(dead_code: usize, complexity: usize, duplication: usize) -> Self {
61        Self {
62            total_issues: dead_code + complexity + duplication,
63            dead_code,
64            complexity,
65            duplication,
66        }
67    }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ImpactRecord {
72    pub timestamp: String,
73    pub version: String,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub git_sha: Option<String>,
76    pub verdict: String,
77    #[serde(default)]
78    pub gate: bool,
79    pub counts: ImpactCounts,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct PendingContainment {
84    pub blocked_at: String,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub git_sha: Option<String>,
87    pub blocked_counts: ImpactCounts,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
92pub struct ContainmentEvent {
93    pub blocked_at: String,
94    pub cleared_at: String,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub git_sha: Option<String>,
97    pub blocked_counts: ImpactCounts,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct FrontierFinding {
102    pub id: String,
103    pub kind: String,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub symbol: Option<String>,
106}
107
108impl FrontierFinding {
109    fn move_key(&self) -> String {
110        match &self.symbol {
111            Some(symbol) => format!("{}{ID_SEP}{symbol}", self.kind),
112            None => self.id.clone(),
113        }
114    }
115}
116
117#[derive(Debug, Clone, Default, Serialize, Deserialize)]
118pub struct FileFrontier {
119    #[serde(default)]
120    pub findings: Vec<FrontierFinding>,
121    #[serde(default)]
122    pub suppressions: Vec<String>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
127pub struct ResolutionEvent {
128    pub kind: String,
129    pub path: String,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub symbol: Option<String>,
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub git_sha: Option<String>,
134    pub timestamp: String,
135}
136
137#[derive(Debug, Clone, Default, Serialize, Deserialize)]
138pub struct ImpactStore {
139    #[serde(default)]
140    pub schema_version: u32,
141    #[serde(default)]
142    pub enabled: bool,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub first_recorded: Option<String>,
145    #[serde(default)]
146    pub records: Vec<ImpactRecord>,
147    #[serde(default)]
148    pub project_records: Vec<ImpactRecord>,
149    #[serde(default)]
150    pub containment: Vec<ContainmentEvent>,
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub pending_containment: Option<PendingContainment>,
153    /// Per-finding attribution baseline, namespaced by worktree key (schema v4)
154    /// so two worktrees of one repo (collapsed to a single store) do not prune
155    /// each other's per-file frontier. Inner map is rel-path -> findings.
156    #[serde(default)]
157    pub frontier: FxHashMap<String, FxHashMap<String, FileFrontier>>,
158    /// Clone-family attribution baseline, namespaced by worktree key (schema
159    /// v4). Inner map is clone fingerprint -> instance paths.
160    #[serde(default)]
161    pub clone_frontier: FxHashMap<String, FxHashMap<String, Vec<String>>>,
162    #[serde(default)]
163    pub resolved_total: usize,
164    #[serde(default)]
165    pub suppressed_total: usize,
166    #[serde(default)]
167    pub recent_resolved: Vec<ResolutionEvent>,
168    #[serde(default)]
169    pub onboarding_declined: bool,
170    /// Whether the user ever ran an explicit `impact enable` or `impact
171    /// disable`. Distinguishes "deliberately declined" from "never asked" so
172    /// the agent skill asks for the impact opt-in exactly once per project.
173    #[serde(default)]
174    pub explicit_decision: bool,
175    /// Unix epoch seconds when the periodic impact digest was last surfaced
176    /// (the `impact-report` next-step / human one-liner). Internal cadence
177    /// state, never exposed on the report.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub last_digest_epoch: Option<u64>,
180    /// Repo display name (the git-toplevel BASENAME only, never a full path),
181    /// captured at record time so the cross-repo `fallow impact --all` view can
182    /// label rows legibly without reversing the opaque project-key hash. Absent
183    /// on pre-v5 stores (rows fall back to the short key) and on stores written
184    /// by older builds. v5.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub label: Option<String>,
187}
188
189/// Deserialize-only view of a pre-relocation in-repo store (schema <= 3), whose
190/// `frontier` / `clone_frontier` were FLAT (not worktree-namespaced). Used once
191/// during migration to import a legacy `.fallow/impact.json` into the user
192/// store. Every field carries `#[serde(default)]` so any of v1/v2/v3 reads.
193#[derive(Debug, Default, Deserialize)]
194struct LegacyFlatStore {
195    #[serde(default)]
196    enabled: bool,
197    #[serde(default)]
198    first_recorded: Option<String>,
199    #[serde(default)]
200    records: Vec<ImpactRecord>,
201    #[serde(default)]
202    project_records: Vec<ImpactRecord>,
203    #[serde(default)]
204    containment: Vec<ContainmentEvent>,
205    #[serde(default)]
206    pending_containment: Option<PendingContainment>,
207    #[serde(default)]
208    frontier: FlatFrontier,
209    #[serde(default)]
210    clone_frontier: FlatCloneFrontier,
211    #[serde(default)]
212    resolved_total: usize,
213    #[serde(default)]
214    suppressed_total: usize,
215    #[serde(default)]
216    recent_resolved: Vec<ResolutionEvent>,
217    #[serde(default)]
218    onboarding_declined: bool,
219    #[serde(default)]
220    explicit_decision: bool,
221    #[serde(default)]
222    last_digest_epoch: Option<u64>,
223}
224
225impl LegacyFlatStore {
226    /// Convert into the current (v4) store, wrapping the flat frontier under the
227    /// importing worktree's key.
228    fn into_store(self, worktree_key: &str) -> ImpactStore {
229        let mut frontier: FxHashMap<String, FlatFrontier> = FxHashMap::default();
230        if !self.frontier.is_empty() {
231            frontier.insert(worktree_key.to_owned(), self.frontier);
232        }
233        let mut clone_frontier: FxHashMap<String, FlatCloneFrontier> = FxHashMap::default();
234        if !self.clone_frontier.is_empty() {
235            clone_frontier.insert(worktree_key.to_owned(), self.clone_frontier);
236        }
237        ImpactStore {
238            schema_version: STORE_SCHEMA_VERSION,
239            enabled: self.enabled,
240            first_recorded: self.first_recorded,
241            records: self.records,
242            project_records: self.project_records,
243            containment: self.containment,
244            pending_containment: self.pending_containment,
245            frontier,
246            clone_frontier,
247            resolved_total: self.resolved_total,
248            suppressed_total: self.suppressed_total,
249            recent_resolved: self.recent_resolved,
250            onboarding_declined: self.onboarding_declined,
251            explicit_decision: self.explicit_decision,
252            last_digest_epoch: self.last_digest_epoch,
253            // Legacy stores carry no label; the next recorded run backfills it.
254            label: None,
255        }
256    }
257}
258
259/// Process-global memo of `(project_key, worktree_key)` per analyzed root, so
260/// the git subprocesses that derive them run at most once per root per run
261/// (`fallow audit` is the perf-priority path and `load` is called several
262/// times per invocation).
263/// `(project_key, worktree_key, display_name)` for a root.
264type ProjectIdentity = (String, String, Option<String>);
265
266static IDENTITY_CACHE: std::sync::OnceLock<std::sync::Mutex<FxHashMap<PathBuf, ProjectIdentity>>> =
267    std::sync::OnceLock::new();
268
269/// Hash a filesystem-path identity into a stable key. On case-insensitive
270/// filesystems (macOS APFS default, Windows) two spellings of one directory map
271/// to the same on-disk location, so fold case before hashing to keep the key
272/// stable across spellings. Linux paths are case-sensitive and left as-is.
273fn hash_path_identity(path: &Path) -> String {
274    let raw = path.to_string_lossy();
275    let normalized = if cfg!(any(target_os = "macos", target_os = "windows")) {
276        raw.to_lowercase()
277    } else {
278        raw.into_owned()
279    };
280    fingerprint_hash(&[normalized.as_str()])
281}
282
283/// Resolve `resolved` to an existing absolute path, falling back to the
284/// canonicalized `root` and finally the raw `root`. Shared by the project key
285/// (git common dir) and the worktree key (git toplevel) so both keep identical
286/// non-git fallback behavior.
287fn resolve_or_root(resolved: Option<PathBuf>, root: &Path) -> PathBuf {
288    resolved
289        .or_else(|| dunce::canonicalize(root).ok())
290        .unwrap_or_else(|| root.to_path_buf())
291}
292
293/// The repo's display name for cross-repo rows: the folder that owns the shared
294/// `.git` (stable across worktrees), else the directory's own basename. This is
295/// a BASENAME only (e.g. `fallow`), never a full path, so persisting it does not
296/// reintroduce the absolute path the store relocation deliberately dropped.
297fn repo_basename(common_or_dir: &Path) -> Option<String> {
298    let dir = if common_or_dir.file_name().is_some_and(|n| n == ".git") {
299        common_or_dir.parent()?
300    } else {
301        common_or_dir
302    };
303    dir.file_name().map(|n| n.to_string_lossy().into_owned())
304}
305
306/// Resolve (and memoize) the `(project_key, worktree_key, display_name)` for
307/// `root`. `project_key` collapses all worktrees of a repo onto one identity via
308/// the git common dir (falling back to the canonicalized root for non-git);
309/// `worktree_key` is the per-tree toplevel (namespaces the attribution frontier
310/// so concurrent worktrees do not prune each other's baseline); `display_name`
311/// is the repo's basename for legible cross-repo rows. All three derive from a
312/// single common-dir + toplevel resolution so the git subprocesses run at most
313/// once per root per run (`fallow audit` is the perf-priority path).
314fn project_identity(root: &Path) -> ProjectIdentity {
315    let cache = IDENTITY_CACHE.get_or_init(|| std::sync::Mutex::new(FxHashMap::default()));
316    if let Ok(map) = cache.lock()
317        && let Some(found) = map.get(root)
318    {
319        return found.clone();
320    }
321    let common = resolve_or_root(
322        fallow_core::changed_files::resolve_git_common_dir(root).ok(),
323        root,
324    );
325    let toplevel = resolve_or_root(
326        fallow_core::changed_files::resolve_git_toplevel(root).ok(),
327        root,
328    );
329    let identity = (
330        hash_path_identity(&common),
331        hash_path_identity(&toplevel),
332        repo_basename(&common),
333    );
334    if let Ok(mut map) = cache.lock() {
335        map.insert(root.to_path_buf(), identity.clone());
336    }
337    identity
338}
339
340#[cfg(test)]
341thread_local! {
342    /// Per-test override of the user config dir, so parallel tests get isolated
343    /// stores (the real config dir is process-global and would collide). Set via
344    /// [`with_test_config_dir`]; unset = fall back to the real config dir.
345    static TEST_CONFIG_DIR: std::cell::RefCell<Option<PathBuf>> =
346        const { std::cell::RefCell::new(None) };
347
348    /// Per-test CI signal for the record gate. Defaults to `false` so the unit
349    /// tests record into their isolated store EVEN when the suite itself runs on
350    /// CI (GitHub Actions sets `CI` / `GITHUB_ACTIONS`, which `telemetry::is_ci`
351    /// reads); without this, every record-dependent test fails on CI because the
352    /// real `is_ci()` short-circuits `record_*` before any store write. A test
353    /// can flip it true to exercise the CI no-op gate. Thread-local, so it is
354    /// parallel-safe and needs no unsafe env mutation.
355    static TEST_FORCE_CI: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
356}
357
358/// Fallow's per-user config dir. Under test it resolves ONLY the per-test
359/// override (or `None` when unset), so a test never reads or writes the real
360/// developer config dir and parallel tests stay isolated.
361fn impact_config_dir() -> Option<PathBuf> {
362    #[cfg(test)]
363    {
364        TEST_CONFIG_DIR.with(|c| c.borrow().clone())
365    }
366    #[cfg(not(test))]
367    {
368        crate::telemetry::config_dir()
369    }
370}
371
372/// Whether this run should be treated as CI for the Impact record gate. In
373/// production it is `telemetry::is_ci()`; under test it reads the per-test
374/// `TEST_FORCE_CI` override (default `false`) so the suite records into its
375/// isolated store regardless of the ambient CI env. The store path is ALWAYS
376/// the per-test temp dir under `#[cfg(test)]` (see [`impact_config_dir`]), so
377/// bypassing the CI gate in tests can never touch a real user store.
378fn record_gate_is_ci() -> bool {
379    #[cfg(test)]
380    {
381        TEST_FORCE_CI.with(std::cell::Cell::get)
382    }
383    #[cfg(not(test))]
384    {
385        crate::telemetry::is_ci()
386    }
387}
388
389/// Path to the per-project store file in the user's private config dir, or
390/// `None` when no config dir is resolvable (e.g. stripped CI env), in which
391/// case Impact is inert (no persistence). NEVER writes into the analyzed repo.
392fn store_path(root: &Path) -> Option<PathBuf> {
393    let (project_key, _, _) = project_identity(root);
394    Some(
395        impact_config_dir()?
396            .join("impact")
397            .join(format!("{project_key}.json")),
398    )
399}
400
401/// Path to a project's legacy in-repo store (`<root>/.fallow/impact.json`),
402/// read ONCE for migration into the user store, never written.
403fn legacy_store_path(root: &Path) -> PathBuf {
404    root.join(".fallow").join(STORE_FILE)
405}
406
407/// Load the store. Missing or unreadable files fall back to defaults; unreadable
408/// files are warned about rather than silently disabling tracking.
409pub fn load(root: &Path) -> ImpactStore {
410    let Some(path) = store_path(root) else {
411        return ImpactStore::default();
412    };
413    match std::fs::read_to_string(&path) {
414        Ok(content) => parse_store(&content, &path),
415        // No user-store file yet: attempt a one-time import of a legacy in-repo
416        // `.fallow/impact.json` (pre-relocation). Returns default if none.
417        Err(_) => migrate_legacy_store(root),
418    }
419}
420
421fn parse_store(content: &str, path: &Path) -> ImpactStore {
422    match serde_json::from_str::<ImpactStore>(content) {
423        Ok(store) => {
424            if store.schema_version > STORE_SCHEMA_VERSION {
425                tracing::warn!(
426                    "fallow impact: store at {} has schema_version {} but this build understands up to {}; reading it as best-effort, fields this build does not know are dropped on the next write. Upgrade fallow to read it fully.",
427                    path.display(),
428                    store.schema_version,
429                    STORE_SCHEMA_VERSION,
430                );
431            }
432            store
433        }
434        Err(err) => {
435            tracing::warn!(
436                "fallow impact: ignoring unreadable store at {} ({err}); run `fallow impact enable` to reset it",
437                path.display()
438            );
439            ImpactStore::default()
440        }
441    }
442}
443
444/// Persist the store best-effort using atomic replace. No-op when no config dir
445/// is resolvable (e.g. stripped CI env). NEVER writes into the analyzed repo.
446fn save(store: &ImpactStore, root: &Path) {
447    let Some(path) = store_path(root) else {
448        return;
449    };
450    if let Some(parent) = path.parent()
451        && std::fs::create_dir_all(parent).is_err()
452    {
453        return;
454    }
455    if let Ok(json) = serde_json::to_string_pretty(store) {
456        let _ = fallow_config::atomic_write(&path, json.as_bytes());
457    }
458}
459
460/// The advisory-lock sidecar path for a store file (`<store>.json.lock`).
461fn lock_path_for(store: &Path) -> PathBuf {
462    let mut raw = store.as_os_str().to_owned();
463    raw.push(".lock");
464    PathBuf::from(raw)
465}
466
467/// Advisory lock serialising the load -> mutate -> save critical section of an
468/// Impact record across concurrent `fallow` processes.
469///
470/// Two worktrees of the same repo collapse to the SAME store key (and SAME
471/// store path), so a pre-commit gate firing in both at once would otherwise
472/// lost-update each other (A loads, B loads, A saves, B saves => A's record is
473/// dropped). Records BLOCK on this lock so a contended run is serialised rather
474/// than dropped. The `.lock` sidecar is intentionally never deleted (an
475/// unlinked-but-locked inode plus a racer's `O_CREAT` would split the lock
476/// across two inodes); the kernel releases the lock when the handle drops,
477/// including at process exit, so an abandoned record never wedges the next run.
478struct ImpactStoreLock {
479    _file: std::fs::File,
480}
481
482impl ImpactStoreLock {
483    /// Block until the per-project store lock for `root` is held. Best-effort:
484    /// returns `None` (proceed unlocked) when no store path resolves or the lock
485    /// file cannot be opened/locked, so a lock-layer failure never drops a
486    /// record (it only loses the cross-worktree serialisation guarantee).
487    fn acquire(root: &Path) -> Option<Self> {
488        let lock_path = lock_path_for(&store_path(root)?);
489        if let Some(parent) = lock_path.parent()
490            && std::fs::create_dir_all(parent).is_err()
491        {
492            return None;
493        }
494        let file = std::fs::OpenOptions::new()
495            .create(true)
496            .truncate(false)
497            .write(true)
498            .open(&lock_path)
499            .ok()?;
500        match file.lock() {
501            Ok(()) => Some(Self { _file: file }),
502            Err(err) => {
503                tracing::debug!(error = %err, "could not acquire impact store lock");
504                None
505            }
506        }
507    }
508}
509
510/// Resolve the store-GC window from [`STORE_MAX_AGE_ENV`]. `None` (no sweep)
511/// when unset, `0`, or unparseable.
512fn resolve_store_max_age() -> Option<std::time::Duration> {
513    let raw = std::env::var(STORE_MAX_AGE_ENV).ok()?;
514    let days: u32 = raw.trim().parse().ok()?;
515    crate::base_worktree::days_to_duration(days)
516}
517
518/// Remove per-project store files older than `max_age`, reclaiming stores left
519/// behind by deleted repos. Age is the store FILE's mtime (any recorded run
520/// rewrites the file via atomic replace, refreshing the mtime), so an
521/// actively-tracked repo never ages out. Best-effort and opportunistic (called
522/// after a successful record), gated entirely on [`STORE_MAX_AGE_ENV`]. Never
523/// deletes `.lock` sidecars (the lock-lifecycle invariant) and never the global
524/// `impact.json` toggle (it is a sibling FILE one level up, not inside the
525/// `impact/` dir). Skips `keep_key`'s own store so the just-written file is
526/// never reclaimed by a stale-mtime race in the same run.
527///
528/// Cross-project GC race: this sweep does NOT take the per-store
529/// [`ImpactStoreLock`] of the OTHER projects it inspects, so in principle it
530/// could delete a store another process is mid-writing. This is a bounded,
531/// best-effort limitation rather than a corruption bug. A store becomes a
532/// deletion candidate only when its file mtime is already older than
533/// `max_age`, and any record refreshes the mtime via an atomic replace, so a
534/// repo with even occasional activity never ages out. The one genuinely lossy
535/// window is sub-millisecond: a concurrent writer that completes its atomic
536/// replace AFTER this fn's `metadata().modified()` read but BEFORE the
537/// `remove_file` would have its just-written (fresh) record deleted. That
538/// window is vanishingly small, the deletion is opt-in (gated on
539/// [`STORE_MAX_AGE_ENV`]), and the store is reconstructed on the project's
540/// next recorded run, so the worst case is the loss of a single just-written
541/// record for an otherwise-dormant project, never partial/corrupt state.
542fn sweep_old_stores(keep_key: &str, max_age: std::time::Duration) {
543    let Some(dir) = store_dir() else {
544        return;
545    };
546    let Ok(entries) = std::fs::read_dir(&dir) else {
547        return;
548    };
549    let now = std::time::SystemTime::now();
550    for entry in entries.flatten() {
551        let path = entry.path();
552        // Only `<key>.json` store files; `.lock` sidecars have a `lock`
553        // extension and are skipped (never deleted).
554        if path.extension().and_then(|e| e.to_str()) != Some("json") {
555            continue;
556        }
557        if path.file_stem().and_then(|s| s.to_str()) == Some(keep_key) {
558            continue;
559        }
560        let aged_out = std::fs::metadata(&path)
561            .and_then(|m| m.modified())
562            .ok()
563            .and_then(|mtime| now.duration_since(mtime).ok())
564            .is_some_and(|age| age >= max_age);
565        if aged_out {
566            let _ = std::fs::remove_file(&path);
567        }
568    }
569}
570
571/// One-time import of a pre-relocation in-repo `.fallow/impact.json` into the
572/// user store. The legacy store had a FLAT frontier (schema <= 3); this reads
573/// it via [`LegacyFlatStore`] and wraps the flat frontier under the current
574/// worktree key. The legacy file is left byte-for-byte untouched (it is no
575/// longer read once the user store exists; re-running finds the user store and
576/// does not re-import). Monorepo note: N subdir stores share one repo key, so
577/// whichever subdir runs first wins (pick-first); the others are not merged.
578fn migrate_legacy_store(root: &Path) -> ImpactStore {
579    let legacy_path = legacy_store_path(root);
580    let Ok(content) = std::fs::read_to_string(&legacy_path) else {
581        return ImpactStore::default();
582    };
583    let Ok(legacy) = serde_json::from_str::<LegacyFlatStore>(&content) else {
584        return ImpactStore::default();
585    };
586    let (_, worktree, display) = project_identity(root);
587    let mut store = legacy.into_store(&worktree);
588    // Backfill the cross-repo display label on migration so the imported repo
589    // is legible in `impact --all` without waiting for its next recorded run.
590    store.label = display;
591    save(&store, root);
592    store
593}
594
595/// Enable Impact tracking for THIS project (an explicit per-repo decision that
596/// overrides the user-global default). Writes nothing into the repo: the store
597/// lives in the user config dir.
598pub fn enable(root: &Path) -> bool {
599    let mut store = load(root);
600    let was_enabled = store.enabled;
601    store.enabled = true;
602    store.explicit_decision = true;
603    if store.schema_version == 0 {
604        store.schema_version = STORE_SCHEMA_VERSION;
605    }
606    save(&store, root);
607    !was_enabled
608}
609
610/// Disable Impact tracking. Retains existing history. Returns whether it was
611/// newly disabled (false if already off). Also records the explicit decision,
612/// so declining the impact opt-in on a never-enabled project (`impact
613/// disable`) persists "asked and said no" for the agent skill.
614pub fn disable(root: &Path) -> bool {
615    let mut store = load(root);
616    let was_enabled = store.enabled;
617    store.enabled = false;
618    store.explicit_decision = true;
619    if store.schema_version == 0 {
620        store.schema_version = STORE_SCHEMA_VERSION;
621    }
622    save(&store, root);
623    was_enabled
624}
625
626/// A due periodic value digest: the headline counters for "what has fallow
627/// done for you here". Returned by [`take_due_digest`] at most once per
628/// `DIGEST_INTERVAL_SECS` per project.
629#[derive(Debug, Clone, Copy)]
630pub struct ImpactDigest {
631    pub containment_count: usize,
632    pub resolved_total: usize,
633}
634
635/// Minimum seconds between periodic digest surfacings (one week).
636const DIGEST_INTERVAL_SECS: u64 = 7 * 24 * 60 * 60;
637
638/// Return the periodic value digest when it is due, stamping the store so the
639/// next one is at least `DIGEST_INTERVAL_SECS` away. Due means: tracking is
640/// enabled, there is non-zero value to report (anti-nag: a zero digest never
641/// surfaces), and the previous digest is older than the interval (or never
642/// happened). Best-effort like the rest of the store: a clean run that drops
643/// the emitted step simply defers the digest to the next interval.
644pub fn take_due_digest(root: &Path) -> Option<ImpactDigest> {
645    let mut store = load(root);
646    if !resolve_enabled(&store).0 {
647        return None;
648    }
649    let containment_count = store.containment.len();
650    if containment_count == 0 && store.resolved_total == 0 {
651        return None;
652    }
653    let now = std::time::SystemTime::now()
654        .duration_since(std::time::UNIX_EPOCH)
655        .ok()?
656        .as_secs();
657    if let Some(last) = store.last_digest_epoch
658        && now.saturating_sub(last) < DIGEST_INTERVAL_SECS
659    {
660        return None;
661    }
662    store.last_digest_epoch = Some(now);
663    save(&store, root);
664    Some(ImpactDigest {
665        containment_count,
666        resolved_total: store.resolved_total,
667    })
668}
669
670/// Persist that the local user declined the agent onboarding prompt. Writes
671/// only to the user store; nothing is written into the repo.
672pub fn decline_onboarding(root: &Path) -> bool {
673    let mut store = load(root);
674    let was_declined = store.onboarding_declined;
675    store.onboarding_declined = true;
676    if store.schema_version == 0 {
677        store.schema_version = STORE_SCHEMA_VERSION;
678    }
679    save(&store, root);
680    !was_declined
681}
682
683/// Why Impact tracking is (or is not) active for a project. `Project` = an
684/// explicit per-repo `enable`; `User` = the user-global default with no per-repo
685/// decision; `Default` = off (no per-repo decision and no global default).
686#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
687#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
688#[serde(rename_all = "lowercase")]
689pub enum EnabledSource {
690    Project,
691    User,
692    Default,
693}
694
695/// The user-global Impact default, stored at `<config-dir>/fallow/impact.json`
696/// (sibling to `telemetry.json`). A single toggle: when on, new projects record
697/// without a per-repo `enable`. A per-repo explicit decision always wins.
698#[derive(Debug, Default, Serialize, Deserialize)]
699struct GlobalImpactConfig {
700    #[serde(default)]
701    default_enabled: bool,
702}
703
704fn global_config_path() -> Option<PathBuf> {
705    Some(impact_config_dir()?.join(STORE_FILE))
706}
707
708/// Whether the user-global default is on. False when unset or unreadable.
709fn load_global_default() -> bool {
710    let Some(path) = global_config_path() else {
711        return false;
712    };
713    std::fs::read_to_string(&path)
714        .ok()
715        .and_then(|c| serde_json::from_str::<GlobalImpactConfig>(&c).ok())
716        .is_some_and(|c| c.default_enabled)
717}
718
719/// Set the user-global default. Returns whether the value changed.
720pub fn set_global_default(on: bool) -> bool {
721    let was = load_global_default();
722    if let Some(path) = global_config_path() {
723        if let Some(parent) = path.parent()
724            && std::fs::create_dir_all(parent).is_err()
725        {
726            return false;
727        }
728        let config = GlobalImpactConfig {
729            default_enabled: on,
730        };
731        if let Ok(json) = serde_json::to_string_pretty(&config) {
732            let _ = fallow_config::atomic_write(&path, json.as_bytes());
733        }
734    }
735    was != on
736}
737
738/// Resolve whether Impact is active for this project and WHY. Precedence:
739/// per-repo decision (enable/disable) > user-global default > off.
740///
741/// `enabled == true` is itself an explicit project opt-in (somebody ran
742/// `enable` here), so it wins even when `explicit_decision` is unset, which is
743/// the case for stores written before the `explicit_decision` field existed. A
744/// store that is off-but-explicitly-decided (`!enabled && explicit_decision`)
745/// stays off as a Project decision (the user disabled it here). Only a truly
746/// never-asked store (`!enabled && !explicit_decision`) consults the global
747/// default.
748fn resolve_enabled(store: &ImpactStore) -> (bool, EnabledSource) {
749    if store.enabled {
750        return (true, EnabledSource::Project);
751    }
752    if store.explicit_decision {
753        return (false, EnabledSource::Project);
754    }
755    if load_global_default() {
756        return (true, EnabledSource::User);
757    }
758    (false, EnabledSource::Default)
759}
760
761/// The resolved per-project store-file path for `root`, for `status` display
762/// (so a wrong key is debuggable). `None` when no config dir is resolvable.
763#[must_use]
764pub fn resolved_store_path(root: &Path) -> Option<PathBuf> {
765    store_path(root)
766}
767
768/// The resolved (worktree-collapsed) project key for `root`, for display.
769#[must_use]
770pub fn resolved_project_key(root: &Path) -> String {
771    project_identity(root).0
772}
773
774/// The per-project store directory (`<config-dir>/fallow/impact/`), for the
775/// `impact --all` human discoverability footer. `None` when no config dir.
776#[must_use]
777pub fn store_dir() -> Option<PathBuf> {
778    impact_config_dir().map(|d| d.join("impact"))
779}
780
781/// Delete THIS project's store file. Returns whether a file was removed.
782pub fn reset(root: &Path) -> bool {
783    store_path(root).is_some_and(|p| std::fs::remove_file(&p).is_ok())
784}
785
786/// Delete the whole per-project impact dir (`<config-dir>/fallow/impact/`).
787/// Does NOT touch the global default toggle (`impact.json`): a data wipe should
788/// not silently re-disable an opt-in the user made. Returns whether the dir was
789/// present and removed.
790pub fn reset_all() -> bool {
791    let Some(dir) = impact_config_dir().map(|d| d.join("impact")) else {
792        return false;
793    };
794    dir.is_dir() && std::fs::remove_dir_all(&dir).is_ok()
795}
796
797/// Record an audit run into the rolling store.
798pub struct AuditRunRecord<'a> {
799    pub verdict: AuditVerdict,
800    pub gate: bool,
801    pub git_sha: Option<&'a str>,
802    pub version: &'a str,
803    pub timestamp: &'a str,
804    pub attribution: Option<&'a AttributionInput<'a>>,
805}
806
807pub fn record_audit_run(root: &Path, summary: &AuditSummary, record: &AuditRunRecord<'_>) {
808    let AuditRunRecord {
809        verdict,
810        gate,
811        git_sha,
812        version,
813        timestamp,
814        attribution,
815    } = record;
816    // Impact is a LOCAL-DEV signal. Never record in CI: a user-global default
817    // baked into a devcontainer/dotfiles image would otherwise start writing
818    // per-project files on every CI run (pre-relocation this was emergent from
819    // a fresh CI checkout having no in-repo store file; now it is explicit).
820    if record_gate_is_ci() {
821        return;
822    }
823    // Serialise the load -> mutate -> save window so two worktrees of the same
824    // repo (same store key) cannot lost-update each other's record.
825    let _lock = ImpactStoreLock::acquire(root);
826    let mut store = load(root);
827    if !resolve_enabled(&store).0 {
828        return;
829    }
830    store.schema_version = STORE_SCHEMA_VERSION;
831    // Capture the repo basename for the cross-repo view (memoized; no extra git
832    // probe). Refreshed each run so a renamed repo folder updates its label.
833    store.label = project_identity(root).2;
834
835    let counts = ImpactCounts::from_summary(summary);
836    let verdict_str = verdict_label(*verdict);
837
838    if store.first_recorded.is_none() {
839        store.first_recorded = Some((*timestamp).to_owned());
840    }
841
842    apply_containment(&mut store, *verdict, *gate, *git_sha, timestamp, &counts);
843
844    store.records.push(ImpactRecord {
845        timestamp: (*timestamp).to_owned(),
846        version: (*version).to_owned(),
847        git_sha: git_sha.map(ToOwned::to_owned),
848        verdict: verdict_str.to_owned(),
849        gate: *gate,
850        counts,
851    });
852    compact(&mut store);
853
854    if let Some(attribution) = attribution {
855        let (_, worktree, _) = project_identity(root);
856        apply_attribution(&mut store, attribution, &worktree, *git_sha, timestamp);
857    }
858
859    save(&store, root);
860    if let Some(max_age) = resolve_store_max_age() {
861        sweep_old_stores(&project_identity(root).0, max_age);
862    }
863}
864
865/// Record a whole-project combined run into the project track.
866pub fn record_combined_run(
867    root: &Path,
868    counts: ImpactCounts,
869    git_sha: Option<&str>,
870    version: &str,
871    timestamp: &str,
872    attribution: Option<&AttributionInput<'_>>,
873) {
874    if record_gate_is_ci() {
875        return;
876    }
877    let _lock = ImpactStoreLock::acquire(root);
878    let mut store = load(root);
879    if !resolve_enabled(&store).0 {
880        return;
881    }
882    store.schema_version = STORE_SCHEMA_VERSION;
883    store.label = project_identity(root).2;
884
885    if store.first_recorded.is_none() {
886        store.first_recorded = Some(timestamp.to_owned());
887    }
888
889    let verdict_str = if counts.total_issues == 0 {
890        "pass"
891    } else {
892        "warn"
893    };
894    store.project_records.push(ImpactRecord {
895        timestamp: timestamp.to_owned(),
896        version: version.to_owned(),
897        git_sha: git_sha.map(ToOwned::to_owned),
898        verdict: verdict_str.to_owned(),
899        gate: false,
900        counts,
901    });
902    if store.project_records.len() > MAX_RECORDS {
903        let overflow = store.project_records.len() - MAX_RECORDS;
904        store.project_records.drain(0..overflow);
905    }
906
907    if let Some(attribution) = attribution {
908        let (_, worktree, _) = project_identity(root);
909        apply_attribution(&mut store, attribution, &worktree, git_sha, timestamp);
910    }
911
912    save(&store, root);
913    if let Some(max_age) = resolve_store_max_age() {
914        sweep_old_stores(&project_identity(root).0, max_age);
915    }
916}
917
918/// Update pending/contained state from a gate run's verdict.
919fn apply_containment(
920    store: &mut ImpactStore,
921    verdict: AuditVerdict,
922    gate: bool,
923    git_sha: Option<&str>,
924    timestamp: &str,
925    counts: &ImpactCounts,
926) {
927    if !gate {
928        return;
929    }
930    if verdict == AuditVerdict::Fail {
931        if store.pending_containment.is_none() {
932            store.pending_containment = Some(PendingContainment {
933                blocked_at: timestamp.to_owned(),
934                git_sha: git_sha.map(ToOwned::to_owned),
935                blocked_counts: counts.clone(),
936            });
937        }
938    } else if let Some(pending) = store.pending_containment.take() {
939        store.containment.push(ContainmentEvent {
940            blocked_at: pending.blocked_at,
941            cleared_at: timestamp.to_owned(),
942            git_sha: pending.git_sha,
943            blocked_counts: pending.blocked_counts,
944        });
945        if store.containment.len() > MAX_CONTAINMENT {
946            let overflow = store.containment.len() - MAX_CONTAINMENT;
947            store.containment.drain(0..overflow);
948        }
949    }
950}
951
952fn compact(store: &mut ImpactStore) {
953    if store.records.len() > MAX_RECORDS {
954        let overflow = store.records.len() - MAX_RECORDS;
955        store.records.drain(0..overflow);
956    }
957}
958
959#[derive(Debug, Clone)]
960pub struct FindingInput {
961    pub path: PathBuf,
962    pub kind: &'static str,
963    pub symbol: Option<String>,
964}
965
966#[derive(Debug, Clone)]
967pub struct CloneInput {
968    pub fingerprint: String,
969    pub instance_paths: Vec<PathBuf>,
970}
971
972pub enum Scope<'a> {
973    ChangedFiles(&'a [PathBuf]),
974    WholeProject,
975}
976
977pub struct AttributionInput<'a> {
978    pub root: &'a Path,
979    pub scope: Scope<'a>,
980    pub findings: Vec<FindingInput>,
981    pub clones: Vec<CloneInput>,
982    pub suppressions: &'a [ActiveSuppression],
983}
984
985fn finding_id(kind: &str, rel_path: &str, symbol: Option<&str>) -> String {
986    fingerprint_hash(&[kind, rel_path, symbol.unwrap_or("")])
987}
988
989fn covered_by(present: &FxHashSet<String>, kind: &str) -> bool {
990    present.contains(BLANKET_SUPPRESSION) || present.contains(kind)
991}
992
993/// A single worktree's flat per-file attribution baseline (rel-path -> findings).
994type FlatFrontier = FxHashMap<String, FileFrontier>;
995/// A single worktree's flat clone baseline (fingerprint -> instance paths).
996type FlatCloneFrontier = FxHashMap<String, Vec<String>>;
997/// This run's per-file findings and present-suppression kinds, scoped to changed files.
998type CurrentState = (
999    FxHashMap<String, Vec<FrontierFinding>>,
1000    FxHashMap<String, FxHashSet<String>>,
1001);
1002
1003fn apply_attribution(
1004    store: &mut ImpactStore,
1005    input: &AttributionInput<'_>,
1006    worktree_key: &str,
1007    git_sha: Option<&str>,
1008    timestamp: &str,
1009) {
1010    let root = input.root;
1011    // Pull THIS worktree's baseline out of the (repo-collapsed) store into owned
1012    // flat locals. The helpers mutate these locals plus the shared totals on
1013    // `store`; because the locals are owned (not borrowed from `store`) there is
1014    // no aliasing with the `store.resolved_total` / `recent_resolved` writes.
1015    let mut frontier: FlatFrontier = store.frontier.remove(worktree_key).unwrap_or_default();
1016    let mut clone_frontier: FlatCloneFrontier = store
1017        .clone_frontier
1018        .remove(worktree_key)
1019        .unwrap_or_default();
1020
1021    let changed: FxHashSet<String> = match input.scope {
1022        Scope::ChangedFiles(files) => files.iter().map(|p| format_display_path(p, root)).collect(),
1023        Scope::WholeProject => whole_project_scope(&frontier, &clone_frontier, input, root),
1024    };
1025
1026    let (current_findings, current_supps) = collect_current_state(input, &changed, root);
1027
1028    let appeared_move_keys = compute_appeared_move_keys(&frontier, &current_findings);
1029
1030    uncredit_cross_run_moves(store, &appeared_move_keys);
1031
1032    let mut disappearance_input = FileDisappearancesInput {
1033        store,
1034        frontier: &frontier,
1035        changed: &changed,
1036        current_findings: &current_findings,
1037        current_supps: &current_supps,
1038        appeared_move_keys: &appeared_move_keys,
1039        git_sha,
1040        timestamp,
1041    };
1042    classify_file_disappearances(&mut disappearance_input);
1043    update_file_frontier(&mut frontier, &changed, current_findings, current_supps);
1044    classify_clone_disappearances(
1045        store,
1046        &frontier,
1047        &mut clone_frontier,
1048        input,
1049        &changed,
1050        git_sha,
1051        timestamp,
1052    );
1053    prune_frontier(&mut frontier, &mut clone_frontier, root);
1054    bound_recent_resolved(store);
1055
1056    store_worktree_baseline(store, worktree_key, frontier, clone_frontier);
1057}
1058
1059/// Collect the move-keys of findings that newly appeared this run (no prior ID in
1060/// the same file), so cross-file moves can be cancelled against disappearances.
1061fn compute_appeared_move_keys(
1062    frontier: &FlatFrontier,
1063    current_findings: &FxHashMap<String, Vec<FrontierFinding>>,
1064) -> FxHashSet<String> {
1065    let mut appeared_move_keys: FxHashSet<String> = FxHashSet::default();
1066    for (rel, findings) in current_findings {
1067        let prior_ids: FxHashSet<&str> = frontier
1068            .get(rel)
1069            .map(|f| f.findings.iter().map(|x| x.id.as_str()).collect())
1070            .unwrap_or_default();
1071        for ff in findings {
1072            if !prior_ids.contains(ff.id.as_str()) {
1073                appeared_move_keys.insert(ff.move_key());
1074            }
1075        }
1076    }
1077    appeared_move_keys
1078}
1079
1080/// Build this run's per-file findings + present-suppression maps, scoped to
1081/// changed files. Findings carry a line-independent ID; suppressions collapse to
1082/// kind keys (blanket when no kind is given).
1083fn collect_current_state(
1084    input: &AttributionInput<'_>,
1085    changed: &FxHashSet<String>,
1086    root: &Path,
1087) -> CurrentState {
1088    let mut current_findings: FxHashMap<String, Vec<FrontierFinding>> = FxHashMap::default();
1089    for f in &input.findings {
1090        let rel = format_display_path(&f.path, root);
1091        if !changed.contains(&rel) {
1092            continue;
1093        }
1094        let id = finding_id(f.kind, &rel, f.symbol.as_deref());
1095        current_findings
1096            .entry(rel)
1097            .or_default()
1098            .push(FrontierFinding {
1099                id,
1100                kind: f.kind.to_owned(),
1101                symbol: f.symbol.clone(),
1102            });
1103    }
1104    let mut current_supps: FxHashMap<String, FxHashSet<String>> = FxHashMap::default();
1105    for s in input.suppressions {
1106        let rel = format_display_path(&s.path, root);
1107        if !changed.contains(&rel) {
1108            continue;
1109        }
1110        let key = s
1111            .kind
1112            .clone()
1113            .unwrap_or_else(|| BLANKET_SUPPRESSION.to_owned());
1114        current_supps.entry(rel).or_default().insert(key);
1115    }
1116    (current_findings, current_supps)
1117}
1118
1119/// Store this worktree's baseline back; drop the worktree key entirely when empty
1120/// so deleted/abandoned worktrees do not accumulate.
1121fn store_worktree_baseline(
1122    store: &mut ImpactStore,
1123    worktree_key: &str,
1124    frontier: FlatFrontier,
1125    clone_frontier: FlatCloneFrontier,
1126) {
1127    if frontier.is_empty() {
1128        store.frontier.remove(worktree_key);
1129    } else {
1130        store.frontier.insert(worktree_key.to_owned(), frontier);
1131    }
1132    if clone_frontier.is_empty() {
1133        store.clone_frontier.remove(worktree_key);
1134    } else {
1135        store
1136            .clone_frontier
1137            .insert(worktree_key.to_owned(), clone_frontier);
1138    }
1139}
1140
1141fn whole_project_scope(
1142    frontier: &FlatFrontier,
1143    clone_frontier: &FlatCloneFrontier,
1144    input: &AttributionInput<'_>,
1145    root: &Path,
1146) -> FxHashSet<String> {
1147    let mut set: FxHashSet<String> = frontier.keys().cloned().collect();
1148    for paths in clone_frontier.values() {
1149        for p in paths {
1150            set.insert(p.clone());
1151        }
1152    }
1153    for f in &input.findings {
1154        set.insert(format_display_path(&f.path, root));
1155    }
1156    for c in &input.clones {
1157        for p in &c.instance_paths {
1158            set.insert(format_display_path(p, root));
1159        }
1160    }
1161    set
1162}
1163
1164struct FileDisappearancesInput<'a> {
1165    store: &'a mut ImpactStore,
1166    frontier: &'a FlatFrontier,
1167    changed: &'a FxHashSet<String>,
1168    current_findings: &'a FxHashMap<String, Vec<FrontierFinding>>,
1169    current_supps: &'a FxHashMap<String, FxHashSet<String>>,
1170    appeared_move_keys: &'a FxHashSet<String>,
1171    git_sha: Option<&'a str>,
1172    timestamp: &'a str,
1173}
1174
1175fn classify_file_disappearances(input: &mut FileDisappearancesInput<'_>) {
1176    let store = &mut *input.store;
1177    let frontier = input.frontier;
1178    let changed = input.changed;
1179    let current_findings = input.current_findings;
1180    let current_supps = input.current_supps;
1181    let appeared_move_keys = input.appeared_move_keys;
1182    let git_sha = input.git_sha;
1183    let timestamp = input.timestamp;
1184    let empty_supps = FxHashSet::default();
1185    for rel in changed {
1186        let Some(prior) = frontier.get(rel) else {
1187            continue;
1188        };
1189        let now_ids: FxHashSet<&str> = current_findings
1190            .get(rel)
1191            .map(|fs| fs.iter().map(|f| f.id.as_str()).collect())
1192            .unwrap_or_default();
1193        let now_supps = current_supps.get(rel).unwrap_or(&empty_supps);
1194        let prior_supps: FxHashSet<&str> = prior.suppressions.iter().map(String::as_str).collect();
1195        let new_supp_kinds: FxHashSet<String> = now_supps
1196            .iter()
1197            .filter(|k| !prior_supps.contains(k.as_str()))
1198            .cloned()
1199            .collect();
1200
1201        let mut resolved = Vec::new();
1202        let mut suppressed = 0usize;
1203        for pf in &prior.findings {
1204            if now_ids.contains(pf.id.as_str()) {
1205                continue; // still present
1206            }
1207            if appeared_move_keys.contains(&pf.move_key()) {
1208                continue; // moved to another file this run
1209            }
1210            if covered_by(&new_supp_kinds, &pf.kind) {
1211                suppressed += 1; // conservative: a fresh fallow-ignore, never a win
1212            } else {
1213                resolved.push(pf.clone());
1214            }
1215        }
1216        store.suppressed_total += suppressed;
1217        for pf in resolved {
1218            store.resolved_total += 1;
1219            store.recent_resolved.push(ResolutionEvent {
1220                kind: pf.kind,
1221                path: rel.clone(),
1222                symbol: pf.symbol,
1223                git_sha: git_sha.map(ToOwned::to_owned),
1224                timestamp: timestamp.to_owned(),
1225            });
1226        }
1227    }
1228}
1229
1230fn update_file_frontier(
1231    frontier: &mut FlatFrontier,
1232    changed: &FxHashSet<String>,
1233    mut current_findings: FxHashMap<String, Vec<FrontierFinding>>,
1234    mut current_supps: FxHashMap<String, FxHashSet<String>>,
1235) {
1236    for rel in changed {
1237        let findings = current_findings.remove(rel).unwrap_or_default();
1238        let mut suppressions: Vec<String> = current_supps
1239            .remove(rel)
1240            .unwrap_or_default()
1241            .into_iter()
1242            .collect();
1243        suppressions.sort_unstable();
1244        if findings.is_empty() && suppressions.is_empty() {
1245            frontier.remove(rel);
1246        } else {
1247            frontier.insert(
1248                rel.clone(),
1249                FileFrontier {
1250                    findings,
1251                    suppressions,
1252                },
1253            );
1254        }
1255    }
1256}
1257
1258fn classify_clone_disappearances(
1259    store: &mut ImpactStore,
1260    frontier: &FlatFrontier,
1261    clone_frontier: &mut FlatCloneFrontier,
1262    input: &AttributionInput<'_>,
1263    changed: &FxHashSet<String>,
1264    git_sha: Option<&str>,
1265    timestamp: &str,
1266) {
1267    let current = collect_changed_clone_groups(input, changed);
1268
1269    let still_duplicated: FxHashSet<&String> = current.values().flatten().collect();
1270
1271    let disappeared: Vec<(String, Vec<String>)> = clone_frontier
1272        .iter()
1273        .filter(|(fp, paths)| {
1274            paths.iter().any(|p| changed.contains(p)) && !current.contains_key(*fp)
1275        })
1276        .map(|(fp, paths)| (fp.clone(), paths.clone()))
1277        .collect();
1278
1279    for (fp, paths) in disappeared {
1280        clone_frontier.remove(&fp);
1281        if paths.iter().any(|p| still_duplicated.contains(p)) {
1282            continue;
1283        }
1284        credit_clone_disappearance(store, frontier, changed, &paths, git_sha, timestamp);
1285    }
1286
1287    for (fp, paths) in current {
1288        clone_frontier.insert(fp, paths);
1289    }
1290}
1291
1292/// Build this run's changed-touching clone groups (fingerprint -> sorted/deduped
1293/// display paths), keeping only groups with at least one changed instance path.
1294fn collect_changed_clone_groups(
1295    input: &AttributionInput<'_>,
1296    changed: &FxHashSet<String>,
1297) -> FxHashMap<String, Vec<String>> {
1298    let root = input.root;
1299    let mut current: FxHashMap<String, Vec<String>> = FxHashMap::default();
1300    for c in &input.clones {
1301        let mut paths: Vec<String> = c
1302            .instance_paths
1303            .iter()
1304            .map(|p| format_display_path(p, root))
1305            .collect();
1306        paths.sort_unstable();
1307        paths.dedup();
1308        if paths.iter().any(|p| changed.contains(p)) {
1309            current.insert(c.fingerprint.clone(), paths);
1310        }
1311    }
1312    current
1313}
1314
1315/// True when a disappeared clone group's changed paths carry a fresh duplication
1316/// or blanket suppression in the frontier (conservative: counts as suppressed).
1317fn clone_dup_suppressed(
1318    frontier: &FlatFrontier,
1319    changed: &FxHashSet<String>,
1320    paths: &[String],
1321) -> bool {
1322    paths.iter().any(|p| {
1323        changed.contains(p)
1324            && frontier.get(p).is_some_and(|f| {
1325                f.suppressions
1326                    .iter()
1327                    .any(|k| k == CODE_DUPLICATION_KIND || k == BLANKET_SUPPRESSION)
1328            })
1329    })
1330}
1331
1332/// Credit a fully-disappeared clone group as resolved or suppressed on `store`.
1333fn credit_clone_disappearance(
1334    store: &mut ImpactStore,
1335    frontier: &FlatFrontier,
1336    changed: &FxHashSet<String>,
1337    paths: &[String],
1338    git_sha: Option<&str>,
1339    timestamp: &str,
1340) {
1341    if clone_dup_suppressed(frontier, changed, paths) {
1342        store.suppressed_total += 1;
1343    } else {
1344        store.resolved_total += 1;
1345        let path = paths.first().cloned().unwrap_or_default();
1346        store.recent_resolved.push(ResolutionEvent {
1347            kind: CODE_DUPLICATION_KIND.to_owned(),
1348            path,
1349            symbol: None,
1350            git_sha: git_sha.map(ToOwned::to_owned),
1351            timestamp: timestamp.to_owned(),
1352        });
1353    }
1354}
1355
1356fn prune_frontier(
1357    frontier: &mut FlatFrontier,
1358    clone_frontier: &mut FlatCloneFrontier,
1359    root: &Path,
1360) {
1361    frontier.retain(|rel, _| root.join(rel).exists());
1362    clone_frontier.retain(|_, paths| paths.iter().any(|p| root.join(p).exists()));
1363}
1364
1365fn bound_recent_resolved(store: &mut ImpactStore) {
1366    if store.recent_resolved.len() > MAX_RECENT_RESOLVED {
1367        let overflow = store.recent_resolved.len() - MAX_RECENT_RESOLVED;
1368        store.recent_resolved.drain(0..overflow);
1369    }
1370}
1371
1372fn event_move_key(ev: &ResolutionEvent) -> Option<String> {
1373    ev.symbol
1374        .as_ref()
1375        .map(|symbol| format!("{}{ID_SEP}{symbol}", ev.kind))
1376}
1377
1378fn uncredit_cross_run_moves(store: &mut ImpactStore, appeared_move_keys: &FxHashSet<String>) {
1379    if appeared_move_keys.is_empty() {
1380        return;
1381    }
1382    let mut uncredited = 0usize;
1383    store.recent_resolved.retain(|ev| match event_move_key(ev) {
1384        Some(mk) if appeared_move_keys.contains(&mk) => {
1385            uncredited += 1;
1386            false
1387        }
1388        _ => true,
1389    });
1390    store.resolved_total = store.resolved_total.saturating_sub(uncredited);
1391}
1392
1393#[must_use]
1394pub fn collect_dead_code_findings(results: &AnalysisResults) -> Vec<FindingInput> {
1395    let mut out = Vec::new();
1396    let mut push = |path: &Path, kind: &'static str, symbol: Option<String>| {
1397        out.push(FindingInput {
1398            path: path.to_path_buf(),
1399            kind,
1400            symbol,
1401        });
1402    };
1403    collect_unused_symbol_findings(results, &mut push);
1404    collect_dependency_findings(results, &mut push);
1405    collect_catalog_findings(results, &mut push);
1406    out
1407}
1408
1409fn collect_unused_symbol_findings(
1410    results: &AnalysisResults,
1411    push: &mut impl FnMut(&Path, &'static str, Option<String>),
1412) {
1413    collect_file_and_export_findings(results, push);
1414    collect_member_findings(results, push);
1415    collect_component_findings(results, push);
1416    collect_import_boundary_findings(results, push);
1417}
1418
1419/// Push unused-file, export, type, and private-type-leak findings.
1420fn collect_file_and_export_findings(
1421    results: &AnalysisResults,
1422    push: &mut impl FnMut(&Path, &'static str, Option<String>),
1423) {
1424    for f in &results.unused_files {
1425        push(&f.file.path, "unused-file", None);
1426    }
1427    for f in &results.unused_exports {
1428        push(
1429            &f.export.path,
1430            "unused-export",
1431            Some(f.export.export_name.clone()),
1432        );
1433    }
1434    for f in &results.unused_types {
1435        push(
1436            &f.export.path,
1437            "unused-type",
1438            Some(f.export.export_name.clone()),
1439        );
1440    }
1441    for f in &results.private_type_leaks {
1442        push(
1443            &f.leak.path,
1444            "private-type-leak",
1445            Some(format!(
1446                "{}{ID_SEP}{}",
1447                f.leak.export_name, f.leak.type_name
1448            )),
1449        );
1450    }
1451}
1452
1453/// Push unused enum/class/store member and unprovided-inject findings.
1454fn collect_member_findings(
1455    results: &AnalysisResults,
1456    push: &mut impl FnMut(&Path, &'static str, Option<String>),
1457) {
1458    for f in &results.unused_enum_members {
1459        push(
1460            &f.member.path,
1461            "unused-enum-member",
1462            Some(format!(
1463                "{}{ID_SEP}{}",
1464                f.member.parent_name, f.member.member_name
1465            )),
1466        );
1467    }
1468    for f in &results.unused_class_members {
1469        push(
1470            &f.member.path,
1471            "unused-class-member",
1472            Some(format!(
1473                "{}{ID_SEP}{}",
1474                f.member.parent_name, f.member.member_name
1475            )),
1476        );
1477    }
1478    for f in &results.unused_store_members {
1479        push(
1480            &f.member.path,
1481            "unused-store-member",
1482            Some(format!(
1483                "{}{ID_SEP}{}",
1484                f.member.parent_name, f.member.member_name
1485            )),
1486        );
1487    }
1488    for f in &results.unprovided_injects {
1489        push(
1490            &f.inject.path,
1491            "unprovided-inject",
1492            Some(f.inject.key_name.clone()),
1493        );
1494    }
1495}
1496
1497/// Push unrendered-component and component prop/emit/input/output findings.
1498fn collect_component_findings(
1499    results: &AnalysisResults,
1500    push: &mut impl FnMut(&Path, &'static str, Option<String>),
1501) {
1502    for f in &results.unrendered_components {
1503        push(
1504            &f.component.path,
1505            "unrendered-component",
1506            Some(f.component.component_name.clone()),
1507        );
1508    }
1509    for f in &results.unused_component_props {
1510        push(
1511            &f.prop.path,
1512            "unused-component-prop",
1513            Some(f.prop.prop_name.clone()),
1514        );
1515    }
1516    for f in &results.unused_component_emits {
1517        push(
1518            &f.emit.path,
1519            "unused-component-emit",
1520            Some(f.emit.emit_name.clone()),
1521        );
1522    }
1523    for f in &results.unused_component_inputs {
1524        push(
1525            &f.input.path,
1526            "unused-component-input",
1527            Some(f.input.input_name.clone()),
1528        );
1529    }
1530    for f in &results.unused_component_outputs {
1531        push(
1532            &f.output.path,
1533            "unused-component-output",
1534            Some(f.output.output_name.clone()),
1535        );
1536    }
1537}
1538
1539/// Push unresolved-import and boundary-violation findings.
1540fn collect_import_boundary_findings(
1541    results: &AnalysisResults,
1542    push: &mut impl FnMut(&Path, &'static str, Option<String>),
1543) {
1544    for f in &results.unresolved_imports {
1545        push(
1546            &f.import.path,
1547            "unresolved-import",
1548            Some(f.import.specifier.clone()),
1549        );
1550    }
1551    for f in &results.boundary_violations {
1552        let to_path = f.violation.to_path.to_string_lossy().replace('\\', "/");
1553        push(
1554            &f.violation.from_path,
1555            "boundary-violation",
1556            Some(format!("{to_path}{ID_SEP}{}", f.violation.import_specifier)),
1557        );
1558    }
1559}
1560
1561fn collect_dependency_findings(
1562    results: &AnalysisResults,
1563    push: &mut impl FnMut(&Path, &'static str, Option<String>),
1564) {
1565    for f in &results.unused_dependencies {
1566        push(
1567            &f.dep.path,
1568            "unused-dependency",
1569            Some(f.dep.package_name.clone()),
1570        );
1571    }
1572    for f in &results.unused_dev_dependencies {
1573        push(
1574            &f.dep.path,
1575            "unused-dev-dependency",
1576            Some(f.dep.package_name.clone()),
1577        );
1578    }
1579    for f in &results.unused_optional_dependencies {
1580        push(
1581            &f.dep.path,
1582            "unused-optional-dependency",
1583            Some(f.dep.package_name.clone()),
1584        );
1585    }
1586    for f in &results.type_only_dependencies {
1587        push(
1588            &f.dep.path,
1589            "type-only-dependency",
1590            Some(f.dep.package_name.clone()),
1591        );
1592    }
1593    for f in &results.test_only_dependencies {
1594        push(
1595            &f.dep.path,
1596            "test-only-dependency",
1597            Some(f.dep.package_name.clone()),
1598        );
1599    }
1600}
1601
1602fn collect_catalog_findings(
1603    results: &AnalysisResults,
1604    push: &mut impl FnMut(&Path, &'static str, Option<String>),
1605) {
1606    for f in &results.unused_catalog_entries {
1607        push(
1608            &f.entry.path,
1609            "unused-catalog-entry",
1610            Some(format!(
1611                "{}{ID_SEP}{}",
1612                f.entry.catalog_name, f.entry.entry_name
1613            )),
1614        );
1615    }
1616    for f in &results.empty_catalog_groups {
1617        push(
1618            &f.group.path,
1619            "empty-catalog-group",
1620            Some(f.group.catalog_name.clone()),
1621        );
1622    }
1623    for f in &results.unresolved_catalog_references {
1624        push(
1625            &f.reference.path,
1626            "unresolved-catalog-reference",
1627            Some(format!(
1628                "{}{ID_SEP}{}",
1629                f.reference.catalog_name, f.reference.entry_name
1630            )),
1631        );
1632    }
1633    for f in &results.unused_dependency_overrides {
1634        push(
1635            &f.entry.path,
1636            "unused-dependency-override",
1637            Some(f.entry.raw_key.clone()),
1638        );
1639    }
1640    for f in &results.misconfigured_dependency_overrides {
1641        push(
1642            &f.entry.path,
1643            "misconfigured-dependency-override",
1644            Some(f.entry.raw_key.clone()),
1645        );
1646    }
1647}
1648
1649/// Collect line-independent complexity finding identities `(path, function name)`
1650/// from a health report. The function name is line-independent, so a function
1651/// moving within its file keeps the same identity.
1652#[must_use]
1653pub fn collect_complexity_findings(
1654    report: &crate::health_types::HealthReport,
1655) -> Vec<FindingInput> {
1656    report
1657        .findings
1658        .iter()
1659        .map(|f| FindingInput {
1660            path: f.path.clone(),
1661            kind: "complexity",
1662            symbol: Some(f.name.clone()),
1663        })
1664        .collect()
1665}
1666
1667/// Collect clone-group identities `(fingerprint, instance paths)` from a
1668/// duplication report. The fingerprint is content-derived (`dup:<hash>`), so it
1669/// is stable across pure relocation.
1670#[must_use]
1671pub fn collect_clone_findings(
1672    report: &fallow_core::duplicates::DuplicationReport,
1673) -> Vec<CloneInput> {
1674    report
1675        .clone_groups
1676        .iter()
1677        .map(|g| CloneInput {
1678            fingerprint: fallow_core::duplicates::clone_fingerprint(&g.instances),
1679            instance_paths: g.instances.iter().map(|i| i.file.clone()).collect(),
1680        })
1681        .collect()
1682}
1683
1684const fn verdict_label(verdict: AuditVerdict) -> &'static str {
1685    match verdict {
1686        AuditVerdict::Pass => "pass",
1687        AuditVerdict::Warn => "warn",
1688        AuditVerdict::Fail => "fail",
1689    }
1690}
1691
1692/// Direction of a count trend between two recorded runs.
1693#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1694#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1695#[serde(rename_all = "snake_case")]
1696pub enum ImpactTrendDirection {
1697    /// Issue count went down (good).
1698    Improving,
1699    /// Issue count went up.
1700    Declining,
1701    /// Within tolerance.
1702    Stable,
1703}
1704
1705/// A computed trend between the two most recent records.
1706#[derive(Debug, Clone, Serialize)]
1707#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1708pub struct TrendSummary {
1709    pub direction: ImpactTrendDirection,
1710    /// Signed delta in total issues (current minus previous).
1711    pub total_delta: i64,
1712    pub previous_total: usize,
1713    pub current_total: usize,
1714}
1715
1716fn direction_for(delta: i64) -> ImpactTrendDirection {
1717    if delta < -TREND_TOLERANCE {
1718        ImpactTrendDirection::Improving
1719    } else if delta > TREND_TOLERANCE {
1720        ImpactTrendDirection::Declining
1721    } else {
1722        ImpactTrendDirection::Stable
1723    }
1724}
1725
1726/// Wire-version discriminator for [`ImpactReport`]. Independent from the global
1727/// `SchemaVersion` (the impact report versions on its own cadence) and from the
1728/// on-disk `STORE_SCHEMA_VERSION` (the persisted store shape versions
1729/// separately). Serializes as a string `const` so JSON consumers can switch on
1730/// it, matching the other independently-versioned envelopes (e.g.
1731/// `CoverageAnalyzeSchemaVersion`).
1732#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1733#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1734pub enum ImpactReportSchemaVersion {
1735    /// First release of the `fallow impact --format json` shape.
1736    #[serde(rename = "1")]
1737    V1,
1738}
1739
1740/// The rendered impact report, derived purely from the store (no analysis run).
1741#[derive(Debug, Clone, Serialize)]
1742#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1743#[cfg_attr(feature = "schema", schemars(title = "fallow impact --format json"))]
1744pub struct ImpactReport {
1745    /// Output-shape version for this report, so JSON consumers have a
1746    /// forward-compat signal independent of the on-disk store version. Always
1747    /// present; bumped only on a breaking change to this report's wire shape.
1748    pub schema_version: ImpactReportSchemaVersion,
1749    pub enabled: bool,
1750    /// WHY tracking is on or off: `project` (an explicit per-repo enable/disable
1751    /// decision), `user` (the user-global default with no per-repo decision), or
1752    /// `default` (off, no per-repo decision and no global default). Combine with
1753    /// `explicit_decision` to tell a never-asked off-state (`enabled:false`,
1754    /// `explicit_decision:false`, offer to enable) from a declined-here one
1755    /// (`enabled:false`, `explicit_decision:true`, do not nag).
1756    pub enabled_source: EnabledSource,
1757    pub record_count: usize,
1758    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
1759    pub meta: Option<Meta>,
1760    #[serde(default, skip_serializing_if = "Option::is_none")]
1761    pub first_recorded: Option<String>,
1762    /// Git SHA of the most recent recorded run, so a consumer can tell which
1763    /// commit the `surfacing` counts belong to. This is an ABBREVIATED SHA
1764    /// (`git rev-parse --short`), so it is for display/correlation only and will
1765    /// not match a full 40-character SHA from `$GITHUB_SHA` or the git API
1766    /// without expansion. None when the latest run had no SHA (not a git repo)
1767    /// or there are no records yet.
1768    #[serde(default, skip_serializing_if = "Option::is_none")]
1769    pub latest_git_sha: Option<String>,
1770    /// Counts from the most recent recorded run. These are CHANGED-FILE scoped
1771    /// (each record comes from a `fallow audit` run, whose default `new-only`
1772    /// gate counts only findings in the changed files of that run), NOT a
1773    /// whole-project total.
1774    #[serde(default, skip_serializing_if = "Option::is_none")]
1775    pub surfacing: Option<ImpactCounts>,
1776    /// Trend between the two most recent records. None until two records exist.
1777    #[serde(default, skip_serializing_if = "Option::is_none")]
1778    pub trend: Option<TrendSummary>,
1779    /// Counts from the most recent whole-project `fallow` run. WHOLE-PROJECT
1780    /// scope (not changed-file), so this is the current issue total across the
1781    /// whole repo, context next to the actionable changed-file `surfacing`
1782    /// count. None until a full `fallow` run has been recorded. v1.6.
1783    #[serde(default, skip_serializing_if = "Option::is_none")]
1784    pub project_surfacing: Option<ImpactCounts>,
1785    /// Trend between the two most recent whole-project records. Comparable over
1786    /// time (same whole-project denominator every run), unlike the changed-file
1787    /// `trend`. None until two full `fallow` runs exist. v1.6.
1788    #[serde(default, skip_serializing_if = "Option::is_none")]
1789    pub project_trend: Option<TrendSummary>,
1790    pub containment_count: usize,
1791    /// Most recent containment events (newest last), capped for display.
1792    pub recent_containment: Vec<ContainmentEvent>,
1793    /// Lifetime count of findings fallow credits as genuinely resolved (code
1794    /// removed or refactored, never a `fallow-ignore`). v1.5.
1795    pub resolved_total: usize,
1796    /// Lifetime count of findings silenced by a newly-added `fallow-ignore`.
1797    /// Reported as honest context, never as a win. v1.5.
1798    pub suppressed_total: usize,
1799    /// Most recent resolution events (newest last), capped for display. v1.5.
1800    pub recent_resolved: Vec<ResolutionEvent>,
1801    /// Whether per-finding attribution has a baseline yet. False on a freshly
1802    /// upgraded v1 store (no frontier captured), which the renderer uses to show
1803    /// "resolution tracking starts from your next run" instead of a bare zero.
1804    pub attribution_active: bool,
1805    /// Whether the local agent onboarding prompt has been explicitly declined.
1806    /// Stored in the user config dir (per project) so agents avoid cross-session
1807    /// nags without writing into the repo.
1808    pub onboarding_declined: bool,
1809    /// Whether the user ever made an explicit enable/disable decision for
1810    /// Impact tracking. `enabled: false` with `explicit_decision: false` means
1811    /// "never asked"; with `true` it means "asked and declined". Agents use
1812    /// this to offer the impact opt-in exactly once per project.
1813    pub explicit_decision: bool,
1814}
1815
1816/// Build a report from the store. Defensive: a single record (or none) yields
1817/// no trend rather than a spurious spike, and an empty store yields an empty
1818/// report flagged so the renderer can show the first-run message.
1819/// Trend between the two most recent records in a series. None until two records
1820/// exist; a missing prior record is "unknown" (no trend), never a spike.
1821fn trend_for(records: &[ImpactRecord]) -> Option<TrendSummary> {
1822    if records.len() < 2 {
1823        return None;
1824    }
1825    let current = &records[records.len() - 1];
1826    let previous = &records[records.len() - 2];
1827    let current_total = current.counts.total_issues;
1828    let previous_total = previous.counts.total_issues;
1829    let total_delta = current_total as i64 - previous_total as i64;
1830    Some(TrendSummary {
1831        direction: direction_for(total_delta),
1832        total_delta,
1833        previous_total,
1834        current_total,
1835    })
1836}
1837
1838pub fn build_report(store: &ImpactStore) -> ImpactReport {
1839    let surfacing = store.records.last().map(|r| r.counts.clone());
1840    let trend = trend_for(&store.records);
1841    let project_surfacing = store.project_records.last().map(|r| r.counts.clone());
1842    let project_trend = trend_for(&store.project_records);
1843
1844    let recent_containment = store
1845        .containment
1846        .iter()
1847        .rev()
1848        .take(5)
1849        .rev()
1850        .cloned()
1851        .collect();
1852
1853    let latest_git_sha = store.records.last().and_then(|r| r.git_sha.clone());
1854
1855    let recent_resolved = store
1856        .recent_resolved
1857        .iter()
1858        .rev()
1859        .take(5)
1860        .rev()
1861        .cloned()
1862        .collect();
1863    let attribution_active = !store.frontier.is_empty()
1864        || !store.clone_frontier.is_empty()
1865        || store.resolved_total > 0
1866        || store.suppressed_total > 0;
1867
1868    let (enabled, enabled_source) = resolve_enabled(store);
1869    ImpactReport {
1870        schema_version: ImpactReportSchemaVersion::V1,
1871        enabled,
1872        enabled_source,
1873        record_count: store.records.len(),
1874        meta: None,
1875        first_recorded: store.first_recorded.clone(),
1876        latest_git_sha,
1877        surfacing,
1878        trend,
1879        project_surfacing,
1880        project_trend,
1881        containment_count: store.containment.len(),
1882        recent_containment,
1883        resolved_total: store.resolved_total,
1884        suppressed_total: store.suppressed_total,
1885        recent_resolved,
1886        attribution_active,
1887        onboarding_declined: store.onboarding_declined,
1888        explicit_decision: store.explicit_decision,
1889    }
1890}
1891
1892// ----- Cross-repo aggregate view (`fallow impact --all`) -------------------
1893
1894/// Independent wire-version for the cross-repo report, on its own cadence (it
1895/// versions separately from the per-project `ImpactReportSchemaVersion` and the
1896/// on-disk `STORE_SCHEMA_VERSION`).
1897#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1898#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1899pub enum CrossRepoImpactSchemaVersion {
1900    /// First release of the `fallow impact --all --format json` shape.
1901    #[serde(rename = "1")]
1902    V1,
1903}
1904
1905/// Grand totals across every tracked project (including repos whose directory no
1906/// longer exists on disk: their past wins still count toward lifetime impact).
1907#[derive(Debug, Clone, Default, Serialize)]
1908#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1909pub struct CrossRepoTotals {
1910    pub resolved_total: usize,
1911    pub suppressed_total: usize,
1912    pub containment_count: usize,
1913    /// Sum of whole-project issue totals across projects that have a full-run
1914    /// baseline, as of EACH project's last full `fallow` run (not a simultaneous
1915    /// snapshot).
1916    pub project_wide_issues: usize,
1917    pub projects_with_baseline: usize,
1918}
1919
1920/// One project's row in the cross-repo roll-up.
1921#[derive(Debug, Clone, Serialize)]
1922#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1923pub struct CrossRepoProjectEntry {
1924    /// Stable, non-reversible project key (the store filename stem); the
1925    /// cross-tool/cross-run JOIN key. NEVER a path.
1926    pub project_key: String,
1927    /// Repo basename for display (never a full path). Absent on pre-v5 stores
1928    /// (the row falls back to the short key).
1929    #[serde(default, skip_serializing_if = "Option::is_none")]
1930    pub label: Option<String>,
1931    /// Timestamp of the project's most recent recorded run (changed-file or
1932    /// whole-project), for the LAST RUN column and the default `recent` sort.
1933    #[serde(default, skip_serializing_if = "Option::is_none")]
1934    pub last_recorded: Option<String>,
1935    /// The full per-project report (identical shape to `fallow impact --format
1936    /// json`), reused verbatim so the per-project wire contract is the sub-shape.
1937    pub report: ImpactReport,
1938}
1939
1940/// The cross-repo aggregate report (`fallow impact --all --format json`).
1941#[derive(Debug, Clone, Serialize)]
1942#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1943#[cfg_attr(
1944    feature = "schema",
1945    schemars(title = "fallow impact --all --format json")
1946)]
1947pub struct CrossRepoImpactReport {
1948    pub schema_version: CrossRepoImpactSchemaVersion,
1949    /// Per-project stores successfully parsed (add `unreadable_count` for the
1950    /// total number of store files found in the user config dir).
1951    pub project_count: usize,
1952    /// Stores with recorded history (the rows in `projects`); excludes
1953    /// enabled-but-empty stores, which are still counted in `project_count`.
1954    pub tracked_count: usize,
1955    /// Stores that failed to parse and were skipped (corrupt or newer-schema).
1956    pub unreadable_count: usize,
1957    pub totals: CrossRepoTotals,
1958    pub projects: Vec<CrossRepoProjectEntry>,
1959}
1960
1961/// Ranking for the cross-repo rows.
1962#[derive(Debug, Clone, Copy)]
1963pub enum CrossRepoSort {
1964    /// Most recently recorded first (the default: active repos float up).
1965    Recent,
1966    /// Most findings resolved first.
1967    Resolved,
1968    /// Most commits contained first.
1969    Contained,
1970    /// Alphabetical by label/key.
1971    Name,
1972}
1973
1974/// The newest record timestamp across the changed-file and whole-project series.
1975fn latest_activity(store: &ImpactStore) -> Option<String> {
1976    let a = store.records.last().map(|r| r.timestamp.clone());
1977    let b = store.project_records.last().map(|r| r.timestamp.clone());
1978    match (a, b) {
1979        (Some(x), Some(y)) => Some(if x >= y { x } else { y }),
1980        (x, y) => x.or(y),
1981    }
1982}
1983
1984/// Enumerate every per-project store in `<config-dir>/fallow/impact/`, returning
1985/// `(project_key, store)` pairs plus the count of files that failed to parse.
1986/// Read-only; never writes. The global `impact.json` toggle is a sibling FILE of
1987/// this dir (one level up), so it is naturally excluded. Corrupt/newer-schema
1988/// files are skipped and counted, never substituted with a default store.
1989#[must_use]
1990pub fn load_all() -> (Vec<(String, ImpactStore)>, usize) {
1991    let Some(dir) = impact_config_dir().map(|d| d.join("impact")) else {
1992        return (Vec::new(), 0);
1993    };
1994    let Ok(read) = std::fs::read_dir(&dir) else {
1995        return (Vec::new(), 0);
1996    };
1997    let mut stores = Vec::new();
1998    let mut unreadable = 0usize;
1999    for entry in read.flatten() {
2000        let path = entry.path();
2001        if path.extension().and_then(|e| e.to_str()) != Some("json") {
2002            continue;
2003        }
2004        let Some(key) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
2005            continue;
2006        };
2007        match std::fs::read_to_string(&path)
2008            .ok()
2009            .and_then(|c| serde_json::from_str::<ImpactStore>(&c).ok())
2010        {
2011            Some(store) => stores.push((key, store)),
2012            None => unreadable += 1,
2013        }
2014    }
2015    (stores, unreadable)
2016}
2017
2018/// Build the cross-repo aggregate from enumerated stores. Excludes
2019/// enabled-but-empty projects from the rows (counted in `project_count`), sums
2020/// totals over every tracked project, and sorts the rows by `sort`.
2021#[must_use]
2022pub fn build_aggregate_report(
2023    stores: Vec<(String, ImpactStore)>,
2024    unreadable: usize,
2025    sort: CrossRepoSort,
2026) -> CrossRepoImpactReport {
2027    let project_count = stores.len();
2028    let mut totals = CrossRepoTotals::default();
2029    let mut projects = Vec::new();
2030    for (key, store) in stores {
2031        let report = build_report(&store);
2032        let has_history = report.record_count > 0
2033            || report.project_surfacing.is_some()
2034            || report.resolved_total > 0
2035            || report.containment_count > 0;
2036        if !has_history {
2037            continue;
2038        }
2039        totals.resolved_total += report.resolved_total;
2040        totals.suppressed_total += report.suppressed_total;
2041        totals.containment_count += report.containment_count;
2042        if let Some(ps) = &report.project_surfacing {
2043            totals.project_wide_issues += ps.total_issues;
2044            totals.projects_with_baseline += 1;
2045        }
2046        projects.push(CrossRepoProjectEntry {
2047            project_key: key,
2048            label: store.label.clone(),
2049            last_recorded: latest_activity(&store),
2050            report,
2051        });
2052    }
2053    sort_cross_repo(&mut projects, sort);
2054    CrossRepoImpactReport {
2055        schema_version: CrossRepoImpactSchemaVersion::V1,
2056        project_count,
2057        tracked_count: projects.len(),
2058        unreadable_count: unreadable,
2059        totals,
2060        projects,
2061    }
2062}
2063
2064fn sort_cross_repo(projects: &mut [CrossRepoProjectEntry], sort: CrossRepoSort) {
2065    match sort {
2066        // Newest activity first; missing timestamps sort last. project_key
2067        // tiebreak keeps the order deterministic.
2068        CrossRepoSort::Recent => projects.sort_by(|a, b| {
2069            b.last_recorded
2070                .cmp(&a.last_recorded)
2071                .then_with(|| a.project_key.cmp(&b.project_key))
2072        }),
2073        CrossRepoSort::Resolved => projects.sort_by(|a, b| {
2074            b.report
2075                .resolved_total
2076                .cmp(&a.report.resolved_total)
2077                .then_with(|| a.project_key.cmp(&b.project_key))
2078        }),
2079        CrossRepoSort::Contained => projects.sort_by(|a, b| {
2080            b.report
2081                .containment_count
2082                .cmp(&a.report.containment_count)
2083                .then_with(|| a.project_key.cmp(&b.project_key))
2084        }),
2085        CrossRepoSort::Name => projects.sort_by(|a, b| {
2086            cross_repo_label(a)
2087                .cmp(&cross_repo_label(b))
2088                .then_with(|| a.project_key.cmp(&b.project_key))
2089        }),
2090    }
2091}
2092
2093/// The display label for a row: the basename when present, else the short key.
2094/// Pure (no path access), so JSON/markdown using it can never leak a path.
2095fn cross_repo_label(entry: &CrossRepoProjectEntry) -> String {
2096    entry
2097        .label
2098        .clone()
2099        .unwrap_or_else(|| short_key(&entry.project_key))
2100}
2101
2102/// First 12 hex of a project key, for opaque-but-stable row labels.
2103fn short_key(key: &str) -> String {
2104    key.chars().take(12).collect()
2105}
2106
2107/// Build the cross-repo report by enumerating the config dir.
2108#[must_use]
2109pub fn aggregate(sort: CrossRepoSort) -> CrossRepoImpactReport {
2110    let (stores, unreadable) = load_all();
2111    build_aggregate_report(stores, unreadable, sort)
2112}
2113
2114/// Render the whole-project view for the human report. Deliberately understated
2115/// (one count line, one trend line, one caveat) rather than a co-equal header:
2116/// the project track advances only on local full `fallow` runs, not CI, so it is
2117/// context for the changed-file story above, not the headline. Renders nothing
2118/// when no full `fallow` run has been recorded yet.
2119#[expect(
2120    clippy::format_push_string,
2121    reason = "small report renderer; readability over avoiding the extra allocation"
2122)]
2123fn render_project_section(out: &mut String, report: &ImpactReport) {
2124    let Some(s) = &report.project_surfacing else {
2125        return;
2126    };
2127    out.push_str(&format!(
2128        "  WHOLE PROJECT (whole-repo context, not a to-do)\n    {} issue{} across the whole project at your last full `fallow` run\n",
2129        s.total_issues,
2130        plural(s.total_issues),
2131    ));
2132    if let Some(t) = &report.project_trend {
2133        let arrow = trend_arrow(t.direction);
2134        out.push_str(&format!(
2135            "    {} -> {} ({}) across your last two full runs (comparable over time)\n",
2136            t.previous_total, t.current_total, arrow,
2137        ));
2138    } else {
2139        out.push_str("    project trend starts after your next full `fallow` run\n");
2140    }
2141    out.push_str("      advances only on your local full `fallow` runs, not CI\n\n");
2142}
2143
2144/// Render the report as human-readable text.
2145#[expect(
2146    clippy::format_push_string,
2147    reason = "small report renderer; readability over avoiding the extra allocation"
2148)]
2149pub fn render_human(report: &ImpactReport) -> String {
2150    let mut out = String::new();
2151    out.push_str("FALLOW IMPACT\n\n");
2152
2153    if !report.enabled {
2154        out.push_str(
2155            "Impact tracking is off. Enable it with `fallow impact enable`, then\n\
2156             let your pre-commit gate run a few times to build history.\n",
2157        );
2158        return out;
2159    }
2160
2161    if report.enabled_source == EnabledSource::User {
2162        out.push_str(
2163            "Enabled by your user-global default (`fallow impact default on`). Run\n\
2164             `fallow impact disable` to opt this project out.\n\n",
2165        );
2166    }
2167
2168    if report.record_count == 0 && report.project_surfacing.is_none() {
2169        out.push_str(
2170            "Tracking enabled. No history yet: check back after your next few\n\
2171             commits (Impact records each `fallow audit` / pre-commit gate run,\n\
2172             and each full `fallow` run for the whole-project view).\n",
2173        );
2174        return out;
2175    }
2176
2177    render_human_changed_section(&mut out, report);
2178
2179    render_project_section(&mut out, report);
2180
2181    out.push_str(&format!(
2182        "  CONTAINED AT COMMIT\n    {} time{} fallow blocked a commit until it was fixed\n",
2183        report.containment_count,
2184        plural(report.containment_count),
2185    ));
2186
2187    render_human_resolved_section(&mut out, report);
2188
2189    render_human_footer(&mut out, report);
2190    out
2191}
2192
2193/// Render the changed-file LATEST RUN and TREND sections of the human report.
2194#[expect(
2195    clippy::format_push_string,
2196    reason = "small report renderer; readability over avoiding the extra allocation"
2197)]
2198fn render_human_changed_section(out: &mut String, report: &ImpactReport) {
2199    if let Some(s) = &report.surfacing {
2200        out.push_str(&format!(
2201            "  LATEST RUN (changed files, act on these now)\n    {} issue{} flagged in your last `fallow audit` run\n",
2202            s.total_issues,
2203            plural(s.total_issues),
2204        ));
2205        out.push_str(&format!(
2206            "      dead code {}  ·  complexity {}  ·  duplication {}\n\n",
2207            s.dead_code, s.complexity, s.duplication,
2208        ));
2209    }
2210
2211    if let Some(t) = &report.trend {
2212        let arrow = trend_arrow(t.direction);
2213        out.push_str(&format!(
2214            "  TREND\n    {} -> {} issues ({}) across your last two recorded runs\n      each run is changed-file scope, so consecutive runs may cover different changes\n\n",
2215            t.previous_total, t.current_total, arrow,
2216        ));
2217    }
2218}
2219
2220/// Render the RESOLVED and marked-intentional sections of the human report.
2221#[expect(
2222    clippy::format_push_string,
2223    reason = "small report renderer; readability over avoiding the extra allocation"
2224)]
2225fn render_human_resolved_section(out: &mut String, report: &ImpactReport) {
2226    if report.resolved_total > 0 {
2227        out.push_str(&format!(
2228            "\n  RESOLVED\n    {} finding{} you cleared since fallow started tracking\n",
2229            report.resolved_total,
2230            plural(report.resolved_total),
2231        ));
2232        for ev in &report.recent_resolved {
2233            match &ev.symbol {
2234                Some(symbol) => {
2235                    out.push_str(&format!("      {} {} in {}\n", ev.kind, symbol, ev.path));
2236                }
2237                None => out.push_str(&format!("      {} in {}\n", ev.kind, ev.path)),
2238            }
2239        }
2240    } else if report.attribution_active {
2241        out.push_str(
2242            "\n  RESOLVED\n    none yet; a finding is credited when fallow re-analyzes the\n      file it left (a fix that reverts a file to its base state\n      may not be individually credited)\n",
2243        );
2244    } else {
2245        out.push_str("\n  RESOLVED\n    resolution tracking starts from your next gate run\n");
2246    }
2247
2248    if report.suppressed_total > 0 {
2249        out.push_str(&format!(
2250            "      {} finding{} you marked intentional (fallow-ignore), not counted as resolved\n",
2251            report.suppressed_total,
2252            plural(report.suppressed_total),
2253        ));
2254    }
2255}
2256
2257/// Render the trailing provenance/footer lines of the human report.
2258#[expect(
2259    clippy::format_push_string,
2260    reason = "small report renderer; readability over avoiding the extra allocation"
2261)]
2262fn render_human_footer(out: &mut String, report: &ImpactReport) {
2263    out.push('\n');
2264    let since = report
2265        .first_recorded
2266        .as_deref()
2267        .map_or("the first run", date_only);
2268    if report.record_count > 0 {
2269        out.push_str(&format!(
2270            "Based on {} recorded audit run{} since {}. Local-only; never uploaded.\n\
2271             Changed-file scope: each audit run only sees files differing from your base.\n",
2272            report.record_count,
2273            plural(report.record_count),
2274            since,
2275        ));
2276    } else {
2277        out.push_str(&format!(
2278            "Tracking since {since}. Local-only; never uploaded.\n",
2279        ));
2280    }
2281    out.push_str(
2282        "Resolution tracking is a local-developer signal: it accrues on your\n\
2283         machine across runs, not in CI (fallow never records there).\n",
2284    );
2285}
2286
2287/// Render the report as JSON.
2288pub fn render_json(report: &ImpactReport) -> String {
2289    let value = crate::output_envelope::serialize_root_output(
2290        crate::output_envelope::FallowOutput::Impact(report.clone()),
2291    )
2292    .unwrap_or_else(|_| serde_json::json!({"error":"failed to serialize impact report"}));
2293    serde_json::to_string_pretty(&value)
2294        .unwrap_or_else(|_| "{\"error\":\"failed to serialize impact report\"}".to_owned())
2295}
2296
2297/// Render the whole-project view for the markdown report. One understated line
2298/// plus a trend line when available, matching the human renderer's framing.
2299/// Renders nothing when no full `fallow` run has been recorded yet.
2300#[expect(
2301    clippy::format_push_string,
2302    reason = "small report renderer; readability over avoiding the extra allocation"
2303)]
2304fn render_project_markdown(out: &mut String, report: &ImpactReport) {
2305    let Some(s) = &report.project_surfacing else {
2306        return;
2307    };
2308    out.push_str(&format!(
2309        "- **Whole project (whole-repo context, last full `fallow` run):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
2310        s.total_issues,
2311        plural(s.total_issues),
2312        s.dead_code,
2313        s.complexity,
2314        s.duplication,
2315    ));
2316    if let Some(t) = &report.project_trend {
2317        let arrow = trend_arrow(t.direction);
2318        out.push_str(&format!(
2319            "- **Project trend (whole project, last two full runs):** {} -> {} ({})\n",
2320            t.previous_total, t.current_total, arrow,
2321        ));
2322    }
2323}
2324
2325/// Render the report as Markdown (paste-ready for a PR description or standup).
2326#[expect(
2327    clippy::format_push_string,
2328    reason = "small report renderer; readability over avoiding the extra allocation"
2329)]
2330pub fn render_markdown(report: &ImpactReport) -> String {
2331    let mut out = String::new();
2332    out.push_str("## Fallow impact\n\n");
2333
2334    if !report.enabled {
2335        out.push_str("Impact tracking is off. Run `fallow impact enable` to start.\n");
2336        return out;
2337    }
2338    if report.record_count == 0 && report.project_surfacing.is_none() {
2339        out.push_str("Tracking enabled. No history yet; check back after a few commits.\n");
2340        return out;
2341    }
2342
2343    if let Some(s) = &report.surfacing {
2344        out.push_str(&format!(
2345            "- **Latest run (changed files):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
2346            s.total_issues,
2347            plural(s.total_issues),
2348            s.dead_code,
2349            s.complexity,
2350            s.duplication,
2351        ));
2352    }
2353    if let Some(t) = &report.trend {
2354        out.push_str(&format!(
2355            "- **Trend (changed-file scope, last two runs):** {} -> {} ({})\n",
2356            t.previous_total,
2357            t.current_total,
2358            trend_arrow(t.direction),
2359        ));
2360    }
2361    render_project_markdown(&mut out, report);
2362    out.push_str(&format!(
2363        "- **Contained at commit:** {} time{}\n",
2364        report.containment_count,
2365        plural(report.containment_count),
2366    ));
2367    render_markdown_resolved_section(&mut out, report);
2368    render_markdown_footer(&mut out, report);
2369    out
2370}
2371
2372/// Render the Resolved and marked-intentional bullets of the markdown report.
2373#[expect(
2374    clippy::format_push_string,
2375    reason = "small report renderer; readability over avoiding the extra allocation"
2376)]
2377fn render_markdown_resolved_section(out: &mut String, report: &ImpactReport) {
2378    if report.resolved_total > 0 {
2379        out.push_str(&format!(
2380            "- **Resolved:** {} finding{} cleared since tracking started\n",
2381            report.resolved_total,
2382            plural(report.resolved_total),
2383        ));
2384    } else if report.attribution_active {
2385        out.push_str("- **Resolved:** none yet; tracking active\n");
2386    } else {
2387        out.push_str("- **Resolved:** resolution tracking starts from your next gate run\n");
2388    }
2389    if report.suppressed_total > 0 {
2390        out.push_str(&format!(
2391            "- **Marked intentional:** {} finding{} (`fallow-ignore`), not counted as resolved\n",
2392            report.suppressed_total,
2393            plural(report.suppressed_total),
2394        ));
2395    }
2396}
2397
2398/// Render the trailing provenance line of the markdown report.
2399#[expect(
2400    clippy::format_push_string,
2401    reason = "small report renderer; readability over avoiding the extra allocation"
2402)]
2403fn render_markdown_footer(out: &mut String, report: &ImpactReport) {
2404    let since = report
2405        .first_recorded
2406        .as_deref()
2407        .map_or("the first run", date_only);
2408    if report.record_count > 0 {
2409        out.push_str(&format!(
2410            "\n_Based on {} recorded audit run{} since {}. Local-only; resolution is a local-developer signal._\n",
2411            report.record_count,
2412            plural(report.record_count),
2413            since,
2414        ));
2415    } else {
2416        out.push_str(&format!(
2417            "\n_Tracking since {since}. Local-only; resolution is a local-developer signal._\n",
2418        ));
2419    }
2420}
2421
2422/// Render the cross-repo report as JSON via the typed `ImpactCrossRepo` envelope.
2423#[must_use]
2424pub fn render_cross_repo_json(report: &CrossRepoImpactReport) -> String {
2425    let value = crate::output_envelope::serialize_root_output(
2426        crate::output_envelope::FallowOutput::ImpactCrossRepo(report.clone()),
2427    )
2428    .unwrap_or_else(
2429        |_| serde_json::json!({"error":"failed to serialize cross-repo impact report"}),
2430    );
2431    serde_json::to_string_pretty(&value).unwrap_or_else(|_| {
2432        "{\"error\":\"failed to serialize cross-repo impact report\"}".to_owned()
2433    })
2434}
2435
2436/// A single row's display label (basename when present, else short key). Pure:
2437/// never touches the filesystem, so it can never leak a path.
2438fn row_label(entry: &CrossRepoProjectEntry) -> String {
2439    cross_repo_label(entry)
2440}
2441
2442fn opt_count(c: Option<&ImpactCounts>) -> String {
2443    c.map_or_else(|| "-".to_owned(), |c| c.total_issues.to_string())
2444}
2445
2446fn row_trend(report: &ImpactReport) -> &'static str {
2447    report
2448        .project_trend
2449        .as_ref()
2450        .or(report.trend.as_ref())
2451        .map_or("-", |t| trend_arrow(t.direction))
2452}
2453
2454/// Render the cross-repo roll-up as human-readable text. `limit` caps the
2455/// printed rows (grand totals always reflect every tracked project). Path-free:
2456/// the CLI adds the single store-dir discoverability line, gated on `!quiet`.
2457#[expect(
2458    clippy::format_push_string,
2459    reason = "small report renderer; readability over avoiding the extra allocation"
2460)]
2461#[must_use]
2462pub fn render_cross_repo_human(report: &CrossRepoImpactReport, limit: Option<usize>) -> String {
2463    let mut out = String::new();
2464    out.push_str("FALLOW IMPACT (ALL PROJECTS)\n\n");
2465
2466    if report.project_count == 0 {
2467        if report.unreadable_count > 0 {
2468            out.push_str(&format!(
2469                "No readable projects: skipped {} unreadable store{} (corrupt, or written by \
2470                 a newer fallow). Upgrade fallow to read them.\n",
2471                report.unreadable_count,
2472                plural(report.unreadable_count),
2473            ));
2474        } else {
2475            out.push_str(
2476                "No projects tracked yet. Enable in a repo with `fallow impact enable`, or for \
2477                 every project with `fallow impact default on`.\n",
2478            );
2479        }
2480        return out;
2481    }
2482
2483    out.push_str(&format!(
2484        "{} project{} tracked, {} with history\n\n",
2485        report.project_count,
2486        plural(report.project_count),
2487        report.tracked_count,
2488    ));
2489
2490    render_cross_repo_table(&mut out, report, limit);
2491    render_cross_repo_skipped(&mut out, report);
2492    render_cross_repo_totals(&mut out, report);
2493    out.push_str("\nLocal-only; never uploaded; accrues on this machine, not CI.\n");
2494    out
2495}
2496
2497/// Render the per-project table (header, rows capped at `limit`, overflow line).
2498#[expect(
2499    clippy::format_push_string,
2500    reason = "small report renderer; readability over avoiding the extra allocation"
2501)]
2502fn render_cross_repo_table(out: &mut String, report: &CrossRepoImpactReport, limit: Option<usize>) {
2503    if report.projects.is_empty() {
2504        return;
2505    }
2506    out.push_str(&format!(
2507        "{:<24}{:>8}{:>10}{:>11}{:>10}{:>7}  {}\n",
2508        "PROJECT", "LATEST", "REPO-WIDE", "CONTAINED", "RESOLVED", "TREND", "LAST RUN",
2509    ));
2510    let rows = limit.map_or(report.projects.len(), |n| n.min(report.projects.len()));
2511    for entry in report.projects.iter().take(rows) {
2512        let mut label = row_label(entry);
2513        if label.chars().count() > 22 {
2514            label = format!("{}...", label.chars().take(19).collect::<String>());
2515        }
2516        let last = entry
2517            .last_recorded
2518            .as_deref()
2519            .map_or("-", date_only)
2520            .to_owned();
2521        out.push_str(&format!(
2522            "{:<24}{:>8}{:>10}{:>11}{:>10}{:>7}  {}\n",
2523            label,
2524            opt_count(entry.report.surfacing.as_ref()),
2525            opt_count(entry.report.project_surfacing.as_ref()),
2526            entry.report.containment_count,
2527            entry.report.resolved_total,
2528            row_trend(&entry.report),
2529            last,
2530        ));
2531    }
2532    if let Some(n) = limit
2533        && report.projects.len() > n
2534    {
2535        out.push_str(&format!(
2536            "  ... and {} more (raise --limit to show)\n",
2537            report.projects.len() - n,
2538        ));
2539    }
2540}
2541
2542/// Render the no-history and skipped-unreadable summary lines.
2543#[expect(
2544    clippy::format_push_string,
2545    reason = "small report renderer; readability over avoiding the extra allocation"
2546)]
2547fn render_cross_repo_skipped(out: &mut String, report: &CrossRepoImpactReport) {
2548    let no_history = report.project_count.saturating_sub(report.tracked_count);
2549    if no_history > 0 {
2550        out.push_str(&format!(
2551            "\n{no_history} tracked project{} with no history yet\n",
2552            plural(no_history),
2553        ));
2554    }
2555    if report.unreadable_count > 0 {
2556        out.push_str(&format!(
2557            "skipped {} unreadable store{}\n",
2558            report.unreadable_count,
2559            plural(report.unreadable_count),
2560        ));
2561    }
2562}
2563
2564/// Render the GRAND TOTALS block (resolved/contained/intentional + baseline line).
2565#[expect(
2566    clippy::format_push_string,
2567    reason = "small report renderer; readability over avoiding the extra allocation"
2568)]
2569fn render_cross_repo_totals(out: &mut String, report: &CrossRepoImpactReport) {
2570    let t = &report.totals;
2571    out.push_str("\nGRAND TOTALS\n");
2572    out.push_str(&format!(
2573        "  Across {} tracked project{}: {} finding{} resolved, {} commit{} contained, {} marked intentional\n",
2574        report.tracked_count,
2575        plural(report.tracked_count),
2576        t.resolved_total,
2577        plural(t.resolved_total),
2578        t.containment_count,
2579        plural(t.containment_count),
2580        t.suppressed_total,
2581    ));
2582    if t.projects_with_baseline > 0 {
2583        out.push_str(&format!(
2584            "  {} issue{} project-wide across {} project{} with a full-run baseline (as of each project's last full run)\n",
2585            t.project_wide_issues,
2586            plural(t.project_wide_issues),
2587            t.projects_with_baseline,
2588            plural(t.projects_with_baseline),
2589        ));
2590    }
2591}
2592
2593/// Render the cross-repo roll-up as Markdown (paste-ready, path-free).
2594#[expect(
2595    clippy::format_push_string,
2596    reason = "small report renderer; readability over avoiding the extra allocation"
2597)]
2598#[must_use]
2599pub fn render_cross_repo_markdown(report: &CrossRepoImpactReport) -> String {
2600    let mut out = String::new();
2601    out.push_str("## Fallow impact (all projects)\n\n");
2602    if report.project_count == 0 {
2603        if report.unreadable_count > 0 {
2604            out.push_str(&format!(
2605                "No readable projects: skipped {} unreadable store{}.\n",
2606                report.unreadable_count,
2607                plural(report.unreadable_count),
2608            ));
2609        } else {
2610            out.push_str("No projects tracked yet.\n");
2611        }
2612        return out;
2613    }
2614    out.push_str(&format!(
2615        "{} project{} tracked, {} with history.\n\n",
2616        report.project_count,
2617        plural(report.project_count),
2618        report.tracked_count,
2619    ));
2620    if !report.projects.is_empty() {
2621        out.push_str("| Project | Latest | Repo-wide | Contained | Resolved | Last run |\n");
2622        out.push_str("|:--------|-------:|----------:|----------:|---------:|:---------|\n");
2623        for entry in &report.projects {
2624            out.push_str(&format!(
2625                "| {} | {} | {} | {} | {} | {} |\n",
2626                row_label(entry),
2627                opt_count(entry.report.surfacing.as_ref()),
2628                opt_count(entry.report.project_surfacing.as_ref()),
2629                entry.report.containment_count,
2630                entry.report.resolved_total,
2631                entry.last_recorded.as_deref().map_or("-", date_only),
2632            ));
2633        }
2634    }
2635    let t = &report.totals;
2636    out.push_str(&format!(
2637        "\n**Grand totals:** {} resolved, {} contained, {} marked intentional across {} tracked project{}",
2638        t.resolved_total,
2639        t.containment_count,
2640        t.suppressed_total,
2641        report.tracked_count,
2642        plural(report.tracked_count),
2643    ));
2644    if t.projects_with_baseline > 0 {
2645        out.push_str(&format!(
2646            "; {} issue{} project-wide across {} project{} with a full-run baseline (as of each project's last full run)",
2647            t.project_wide_issues,
2648            plural(t.project_wide_issues),
2649            t.projects_with_baseline,
2650            plural(t.projects_with_baseline),
2651        ));
2652    }
2653    out.push_str(".\n\n_Local-only; never uploaded; accrues on this machine, not CI._\n");
2654    out
2655}
2656
2657const fn plural(n: usize) -> &'static str {
2658    if n == 1 { "" } else { "s" }
2659}
2660
2661/// Trim a stored ISO-8601 timestamp (`2026-05-29T18:15:23Z`) to its date part
2662/// (`2026-05-29`) for human/markdown footers. The wall-clock time and `Z` add
2663/// noise without meaning when a reader just wants "tracking since when". JSON
2664/// keeps the full `first_recorded` timestamp. Returns the input unchanged if it
2665/// has no `T` separator.
2666fn date_only(ts: &str) -> &str {
2667    ts.split_once('T').map_or(ts, |(date, _)| date)
2668}
2669
2670/// Single human-facing trend vocabulary, shared by the text and markdown
2671/// renderers so the same concept does not read three different ways. The JSON
2672/// wire keeps the `improving`/`declining`/`stable` enum form for machines.
2673const fn trend_arrow(direction: ImpactTrendDirection) -> &'static str {
2674    match direction {
2675        ImpactTrendDirection::Improving => "down",
2676        ImpactTrendDirection::Declining => "up",
2677        ImpactTrendDirection::Stable => "flat",
2678    }
2679}
2680
2681#[cfg(test)]
2682mod tests {
2683    use super::*;
2684
2685    /// Per-test isolation: a fresh user-config dir (so the store never touches
2686    /// the real dir and parallel tests do not collide) plus a fresh project
2687    /// root. Bind BOTH returned `TempDir`s for the test's lifetime. The store
2688    /// for a non-git tempdir root keys on the canonical root, so each test's
2689    /// root is its own store.
2690    fn test_env() -> (tempfile::TempDir, tempfile::TempDir) {
2691        let config = tempfile::tempdir().unwrap();
2692        TEST_CONFIG_DIR.with(|c| *c.borrow_mut() = Some(config.path().to_path_buf()));
2693        let root = tempfile::tempdir().unwrap();
2694        (config, root)
2695    }
2696
2697    /// All frontier rel-paths across every worktree sub-map (tests use one
2698    /// root => one worktree key), for the v4 nested-frontier shape.
2699    fn frontier_paths(store: &ImpactStore) -> FxHashSet<String> {
2700        store
2701            .frontier
2702            .values()
2703            .flat_map(|m| m.keys().cloned())
2704            .collect()
2705    }
2706
2707    /// All clone fingerprints across every worktree sub-map.
2708    fn clone_fingerprints(store: &ImpactStore) -> FxHashSet<String> {
2709        store
2710            .clone_frontier
2711            .values()
2712            .flat_map(|m| m.keys().cloned())
2713            .collect()
2714    }
2715
2716    /// Seed raw bytes at the resolved (user-dir) store path, creating parent
2717    /// dirs, to exercise the load/parse path against hand-authored JSON.
2718    fn seed_store_raw(root: &Path, bytes: &[u8]) {
2719        let path = store_path(root).expect("test config dir set");
2720        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2721        std::fs::write(&path, bytes).unwrap();
2722    }
2723
2724    fn summary(dead: usize, complexity: usize, dupes: usize) -> AuditSummary {
2725        AuditSummary {
2726            dead_code_issues: dead,
2727            dead_code_has_errors: dead > 0,
2728            complexity_findings: complexity,
2729            max_cyclomatic: None,
2730            duplication_clone_groups: dupes,
2731        }
2732    }
2733
2734    /// Record a run with no per-finding attribution (v1 surfacing/trend/containment only).
2735    fn record_v1(
2736        root: &Path,
2737        summary: &AuditSummary,
2738        verdict: AuditVerdict,
2739        gate: bool,
2740        git_sha: Option<&str>,
2741        version: &str,
2742        timestamp: &str,
2743    ) {
2744        record_audit_run(
2745            root,
2746            summary,
2747            &AuditRunRecord {
2748                verdict,
2749                gate,
2750                git_sha,
2751                version,
2752                timestamp,
2753                attribution: None,
2754            },
2755        );
2756    }
2757
2758    /// Create a real file under `root` (attribution prunes frontier entries for
2759    /// files that no longer exist, so test files must exist on disk).
2760    fn touch(root: &Path, rel: &str) -> PathBuf {
2761        let p = root.join(rel);
2762        if let Some(parent) = p.parent() {
2763            std::fs::create_dir_all(parent).unwrap();
2764        }
2765        std::fs::write(&p, b"x").unwrap();
2766        p
2767    }
2768
2769    fn fi(path: &Path, kind: &'static str, symbol: &str) -> FindingInput {
2770        FindingInput {
2771            path: path.to_path_buf(),
2772            kind,
2773            symbol: Some(symbol.to_owned()),
2774        }
2775    }
2776
2777    fn supp(path: &Path, kind: &str) -> ActiveSuppression {
2778        ActiveSuppression {
2779            path: path.to_path_buf(),
2780            kind: Some(kind.to_owned()),
2781            is_file_level: false,
2782            reason: None,
2783        }
2784    }
2785
2786    /// Record one attribution run against the store.
2787    fn run(
2788        root: &Path,
2789        changed: &[&Path],
2790        findings: Vec<FindingInput>,
2791        clones: Vec<CloneInput>,
2792        supps: &[ActiveSuppression],
2793        ts: &str,
2794    ) {
2795        let changed_files: Vec<PathBuf> = changed.iter().map(|p| p.to_path_buf()).collect();
2796        let input = AttributionInput {
2797            root,
2798            scope: Scope::ChangedFiles(&changed_files),
2799            findings,
2800            clones,
2801            suppressions: supps,
2802        };
2803        record_audit_run(
2804            root,
2805            &summary(0, 0, 0),
2806            &AuditRunRecord {
2807                verdict: AuditVerdict::Pass,
2808                gate: true,
2809                git_sha: Some("sha"),
2810                version: "2.0.0",
2811                timestamp: ts,
2812                attribution: Some(&input),
2813            },
2814        );
2815    }
2816
2817    #[test]
2818    fn disabled_store_does_not_record() {
2819        let (_config, dir) = test_env();
2820        let root = dir.path();
2821        record_v1(
2822            root,
2823            &summary(3, 1, 0),
2824            AuditVerdict::Fail,
2825            true,
2826            Some("abc1234"),
2827            "2.0.0",
2828            "2026-05-29T10:00:00Z",
2829        );
2830        let store = load(root);
2831        assert!(store.records.is_empty());
2832        assert!(!store.enabled);
2833    }
2834
2835    #[test]
2836    fn enable_and_disable_record_the_explicit_decision() {
2837        let (_config, dir) = test_env();
2838        let root = dir.path();
2839        assert!(!load(root).explicit_decision, "fresh store: never asked");
2840
2841        // Declining on a never-enabled project is an explicit decision too.
2842        disable(root);
2843        let store = load(root);
2844        assert!(!store.enabled);
2845        assert!(store.explicit_decision);
2846        assert!(build_report(&store).explicit_decision);
2847    }
2848
2849    #[test]
2850    fn due_digest_stamps_and_respects_interval_and_gates() {
2851        let (_config, dir) = test_env();
2852        let root = dir.path();
2853
2854        // Disabled, or enabled with zero value: never due.
2855        assert!(take_due_digest(root).is_none());
2856        enable(root);
2857        assert!(take_due_digest(root).is_none(), "zero counters never nag");
2858
2859        let mut store = load(root);
2860        store.resolved_total = 3;
2861        store.containment.push(ContainmentEvent {
2862            blocked_at: "2026-06-11T00:00:00Z".to_string(),
2863            cleared_at: "2026-06-11T00:05:00Z".to_string(),
2864            git_sha: None,
2865            blocked_counts: ImpactCounts::default(),
2866        });
2867        save(&store, root);
2868
2869        let digest = take_due_digest(root).expect("first digest is due");
2870        assert_eq!(digest.containment_count, 1);
2871        assert_eq!(digest.resolved_total, 3);
2872        assert!(
2873            take_due_digest(root).is_none(),
2874            "stamped: not due again within the interval"
2875        );
2876
2877        // An expired stamp makes it due again.
2878        let mut store = load(root);
2879        store.last_digest_epoch = Some(0);
2880        save(&store, root);
2881        assert!(take_due_digest(root).is_some());
2882    }
2883
2884    #[test]
2885    fn decline_onboarding_persists_in_existing_store() {
2886        let (_config, dir) = test_env();
2887        let root = dir.path();
2888
2889        assert!(decline_onboarding(root));
2890        assert!(!decline_onboarding(root));
2891
2892        let store = load(root);
2893        assert!(store.onboarding_declined);
2894        assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
2895        // Decline persists in the user store and writes nothing into the repo.
2896        assert!(!root.join(".gitignore").exists());
2897        let report = build_report(&store);
2898        assert!(report.onboarding_declined);
2899    }
2900
2901    #[test]
2902    fn enable_then_record_accrues_history() {
2903        let (_config, dir) = test_env();
2904        let root = dir.path();
2905        assert!(enable(root));
2906        assert!(!enable(root)); // second enable is a no-op-ish (already on)
2907        record_v1(
2908            root,
2909            &summary(2, 1, 0),
2910            AuditVerdict::Warn,
2911            false,
2912            None,
2913            "2.0.0",
2914            "2026-05-29T10:00:00Z",
2915        );
2916        let store = load(root);
2917        assert_eq!(store.records.len(), 1);
2918        assert_eq!(store.records[0].counts.total_issues, 3);
2919        assert_eq!(
2920            store.first_recorded.as_deref(),
2921            Some("2026-05-29T10:00:00Z")
2922        );
2923    }
2924
2925    #[test]
2926    fn record_is_a_noop_in_ci() {
2927        // Impact is local-dev-only: it must never record on CI. The suite itself
2928        // runs on CI (where `CI` / `GITHUB_ACTIONS` are set), so the gate uses a
2929        // per-test override instead of the ambient env; here we force it true to
2930        // prove the production no-op. Without the gate, an enabled project would
2931        // record on every CI run.
2932        let (_config, dir) = test_env();
2933        let root = dir.path();
2934        assert!(enable(root));
2935        TEST_FORCE_CI.with(|c| c.set(true));
2936        record_v1(
2937            root,
2938            &summary(2, 1, 0),
2939            AuditVerdict::Warn,
2940            false,
2941            None,
2942            "2.0.0",
2943            "2026-05-29T10:00:00Z",
2944        );
2945        TEST_FORCE_CI.with(|c| c.set(false));
2946        let store = load(root);
2947        assert_eq!(store.records.len(), 0, "impact must not record while in CI");
2948    }
2949
2950    #[test]
2951    fn enable_writes_nothing_into_the_repo() {
2952        let (_config, dir) = test_env();
2953        let root = dir.path();
2954        enable(root);
2955        // The user-store relocation means enable never touches the repo: no
2956        // .gitignore mutation and no in-repo .fallow/ dir.
2957        assert!(
2958            !root.join(".gitignore").exists(),
2959            "enable must not create or modify the repo's .gitignore"
2960        );
2961        assert!(
2962            !root.join(".fallow").exists(),
2963            "enable must not create an in-repo .fallow/ dir"
2964        );
2965        // The decision IS persisted, in the user store.
2966        let store = load(root);
2967        assert!(store.enabled);
2968        assert!(store.explicit_decision);
2969        assert!(resolve_enabled(&store).0);
2970    }
2971
2972    #[test]
2973    fn single_record_yields_no_trend_no_spike() {
2974        let mut store = ImpactStore {
2975            enabled: true,
2976            ..Default::default()
2977        };
2978        store.records.push(ImpactRecord {
2979            timestamp: "t0".into(),
2980            version: "2.0.0".into(),
2981            git_sha: None,
2982            verdict: "warn".into(),
2983            gate: false,
2984            counts: ImpactCounts {
2985                total_issues: 5,
2986                dead_code: 5,
2987                complexity: 0,
2988                duplication: 0,
2989            },
2990        });
2991        let report = build_report(&store);
2992        assert!(report.trend.is_none());
2993        assert_eq!(report.surfacing.unwrap().total_issues, 5);
2994    }
2995
2996    #[test]
2997    fn empty_store_report_is_first_run() {
2998        let store = ImpactStore::default();
2999        let report = build_report(&store);
3000        assert_eq!(report.record_count, 0);
3001        assert!(report.trend.is_none());
3002        assert!(report.surfacing.is_none());
3003        let human = render_human(&report);
3004        assert!(human.contains("off")); // default store is disabled
3005    }
3006
3007    #[test]
3008    fn enabled_empty_store_shows_check_back() {
3009        let store = ImpactStore {
3010            enabled: true,
3011            ..Default::default()
3012        };
3013        let report = build_report(&store);
3014        let human = render_human(&report);
3015        assert!(human.contains("No history yet"));
3016        assert!(!human.contains("0 issues"));
3017    }
3018
3019    #[test]
3020    fn trend_improving_when_issues_drop() {
3021        let mut store = ImpactStore {
3022            enabled: true,
3023            ..Default::default()
3024        };
3025        for total in [8usize, 3usize] {
3026            store.records.push(ImpactRecord {
3027                timestamp: format!("t{total}"),
3028                version: "2.0.0".into(),
3029                git_sha: None,
3030                verdict: "warn".into(),
3031                gate: false,
3032                counts: ImpactCounts {
3033                    total_issues: total,
3034                    dead_code: total,
3035                    complexity: 0,
3036                    duplication: 0,
3037                },
3038            });
3039        }
3040        let report = build_report(&store);
3041        let trend = report.trend.unwrap();
3042        assert_eq!(trend.direction, ImpactTrendDirection::Improving);
3043        assert_eq!(trend.total_delta, -5);
3044    }
3045
3046    #[test]
3047    fn containment_blocked_then_cleared_records_one_event() {
3048        let (_config, dir) = test_env();
3049        let root = dir.path();
3050        enable(root);
3051        record_v1(
3052            root,
3053            &summary(2, 0, 0),
3054            AuditVerdict::Fail,
3055            true,
3056            Some("sha1"),
3057            "2.0.0",
3058            "t0",
3059        );
3060        let store = load(root);
3061        assert!(store.pending_containment.is_some());
3062        assert!(store.containment.is_empty());
3063
3064        record_v1(
3065            root,
3066            &summary(0, 0, 0),
3067            AuditVerdict::Pass,
3068            true,
3069            Some("sha2"),
3070            "2.0.0",
3071            "t1",
3072        );
3073        let store = load(root);
3074        assert!(store.pending_containment.is_none());
3075        assert_eq!(store.containment.len(), 1);
3076        assert_eq!(store.containment[0].blocked_at, "t0");
3077        assert_eq!(store.containment[0].cleared_at, "t1");
3078    }
3079
3080    #[test]
3081    fn non_gate_run_never_creates_containment() {
3082        let (_config, dir) = test_env();
3083        let root = dir.path();
3084        enable(root);
3085        record_v1(
3086            root,
3087            &summary(2, 0, 0),
3088            AuditVerdict::Fail,
3089            false,
3090            None,
3091            "2.0.0",
3092            "t0",
3093        );
3094        let store = load(root);
3095        assert!(store.pending_containment.is_none());
3096        assert!(store.containment.is_empty());
3097    }
3098
3099    #[test]
3100    fn corrupt_store_loads_as_default_no_panic() {
3101        let (_config, dir) = test_env();
3102        let root = dir.path();
3103        seed_store_raw(root, b"{ not valid json ][");
3104        let store = load(root);
3105        assert!(!store.enabled);
3106        assert!(store.records.is_empty());
3107        record_v1(
3108            root,
3109            &summary(1, 0, 0),
3110            AuditVerdict::Fail,
3111            true,
3112            None,
3113            "2.0.0",
3114            "t0",
3115        );
3116    }
3117
3118    #[test]
3119    fn records_are_bounded() {
3120        let mut store = ImpactStore {
3121            enabled: true,
3122            ..Default::default()
3123        };
3124        for i in 0..(MAX_RECORDS + 50) {
3125            store.records.push(ImpactRecord {
3126                timestamp: format!("t{i}"),
3127                version: "2.0.0".into(),
3128                git_sha: None,
3129                verdict: "pass".into(),
3130                gate: false,
3131                counts: ImpactCounts::default(),
3132            });
3133        }
3134        compact(&mut store);
3135        assert_eq!(store.records.len(), MAX_RECORDS);
3136        assert_eq!(store.records[0].timestamp, "t50");
3137    }
3138
3139    #[test]
3140    fn report_always_carries_schema_version() {
3141        let empty = build_report(&ImpactStore::default());
3142        assert_eq!(empty.schema_version, ImpactReportSchemaVersion::V1);
3143        let json = render_json(&empty);
3144        assert!(
3145            json.contains("\"schema_version\": \"1\""),
3146            "schema_version must be present (as the \"1\" const) even when disabled: {json}"
3147        );
3148
3149        let mut store = ImpactStore {
3150            enabled: true,
3151            ..Default::default()
3152        };
3153        store.records.push(ImpactRecord {
3154            timestamp: "2026-05-29T10:00:00Z".into(),
3155            version: "2.0.0".into(),
3156            git_sha: None,
3157            verdict: "pass".into(),
3158            gate: false,
3159            counts: ImpactCounts::default(),
3160        });
3161        assert_eq!(
3162            build_report(&store).schema_version,
3163            ImpactReportSchemaVersion::V1
3164        );
3165    }
3166
3167    #[test]
3168    fn date_only_trims_iso_timestamp() {
3169        assert_eq!(date_only("2026-05-29T18:15:23Z"), "2026-05-29");
3170        assert_eq!(date_only("2026-05-29"), "2026-05-29");
3171        assert_eq!(date_only("the first run"), "the first run");
3172    }
3173
3174    #[test]
3175    fn human_footer_shows_date_only() {
3176        let mut store = ImpactStore {
3177            enabled: true,
3178            ..Default::default()
3179        };
3180        store.first_recorded = Some("2026-05-29T18:15:23Z".into());
3181        store.records.push(ImpactRecord {
3182            timestamp: "2026-05-29T18:15:23Z".into(),
3183            version: "2.0.0".into(),
3184            git_sha: None,
3185            verdict: "pass".into(),
3186            gate: false,
3187            counts: ImpactCounts::default(),
3188        });
3189        let report = build_report(&store);
3190        let human = render_human(&report);
3191        assert!(
3192            human.contains("since 2026-05-29.") && !human.contains("18:15:23"),
3193            "human footer must show date-only: {human}"
3194        );
3195        let md = render_markdown(&report);
3196        assert!(
3197            md.contains("since 2026-05-29.") && !md.contains("18:15:23"),
3198            "markdown footer must show date-only: {md}"
3199        );
3200    }
3201
3202    #[test]
3203    fn future_schema_version_store_loads_without_panic_or_loss() {
3204        let (_config, dir) = test_env();
3205        let root = dir.path();
3206        let future = format!(
3207            "{{\"schema_version\":{},\"enabled\":true,\"records\":[],\"containment\":[]}}",
3208            STORE_SCHEMA_VERSION + 1
3209        );
3210        seed_store_raw(root, future.as_bytes());
3211        let store = load(root);
3212        assert_eq!(store.schema_version, STORE_SCHEMA_VERSION + 1);
3213        assert!(
3214            store.enabled,
3215            "future-version store must not degrade to default"
3216        );
3217    }
3218
3219    #[test]
3220    fn removed_finding_is_credited_as_resolved() {
3221        let (_config, dir) = test_env();
3222        let root = dir.path();
3223        enable(root);
3224        let a = touch(root, "src/a.ts");
3225        run(
3226            root,
3227            &[&a],
3228            vec![fi(&a, "unused-export", "foo")],
3229            vec![],
3230            &[],
3231            "t0",
3232        );
3233        assert_eq!(
3234            load(root).resolved_total,
3235            0,
3236            "first run only establishes a baseline"
3237        );
3238        run(root, &[&a], vec![], vec![], &[], "t1");
3239        let store = load(root);
3240        assert_eq!(store.resolved_total, 1);
3241        assert_eq!(store.suppressed_total, 0);
3242        assert_eq!(store.recent_resolved.len(), 1);
3243        assert_eq!(store.recent_resolved[0].kind, "unused-export");
3244        assert_eq!(store.recent_resolved[0].symbol.as_deref(), Some("foo"));
3245        assert_eq!(store.recent_resolved[0].path, "src/a.ts");
3246    }
3247
3248    #[test]
3249    fn suppressed_finding_is_not_a_win() {
3250        let (_config, dir) = test_env();
3251        let root = dir.path();
3252        enable(root);
3253        let a = touch(root, "src/a.ts");
3254        run(
3255            root,
3256            &[&a],
3257            vec![fi(&a, "unused-export", "foo")],
3258            vec![],
3259            &[],
3260            "t0",
3261        );
3262        run(
3263            root,
3264            &[&a],
3265            vec![],
3266            vec![],
3267            &[supp(&a, "unused-export")],
3268            "t1",
3269        );
3270        let store = load(root);
3271        assert_eq!(
3272            store.resolved_total, 0,
3273            "a suppression must never count as a win"
3274        );
3275        assert_eq!(store.suppressed_total, 1);
3276    }
3277
3278    #[test]
3279    fn fix_and_suppress_same_kind_credits_zero_resolved() {
3280        let (_config, dir) = test_env();
3281        let root = dir.path();
3282        enable(root);
3283        let a = touch(root, "src/a.ts");
3284        run(
3285            root,
3286            &[&a],
3287            vec![
3288                fi(&a, "unused-export", "foo"),
3289                fi(&a, "unused-export", "bar"),
3290            ],
3291            vec![],
3292            &[],
3293            "t0",
3294        );
3295        run(
3296            root,
3297            &[&a],
3298            vec![],
3299            vec![],
3300            &[supp(&a, "unused-export")],
3301            "t1",
3302        );
3303        let store = load(root);
3304        assert_eq!(store.resolved_total, 0);
3305        assert_eq!(store.suppressed_total, 2);
3306    }
3307
3308    #[test]
3309    fn within_file_move_is_not_resolved() {
3310        let (_config, dir) = test_env();
3311        let root = dir.path();
3312        enable(root);
3313        let a = touch(root, "src/a.ts");
3314        run(
3315            root,
3316            &[&a],
3317            vec![fi(&a, "unused-export", "foo")],
3318            vec![],
3319            &[],
3320            "t0",
3321        );
3322        run(
3323            root,
3324            &[&a],
3325            vec![fi(&a, "unused-export", "foo")],
3326            vec![],
3327            &[],
3328            "t1",
3329        );
3330        let store = load(root);
3331        assert_eq!(store.resolved_total, 0);
3332        assert_eq!(store.suppressed_total, 0);
3333    }
3334
3335    #[test]
3336    fn cross_file_move_in_same_run_is_not_resolved() {
3337        let (_config, dir) = test_env();
3338        let root = dir.path();
3339        enable(root);
3340        let a = touch(root, "src/a.ts");
3341        let b = touch(root, "src/b.ts");
3342        run(
3343            root,
3344            &[&a],
3345            vec![fi(&a, "unused-export", "foo")],
3346            vec![],
3347            &[],
3348            "t0",
3349        );
3350        run(
3351            root,
3352            &[&a, &b],
3353            vec![fi(&b, "unused-export", "foo")],
3354            vec![],
3355            &[],
3356            "t1",
3357        );
3358        assert_eq!(
3359            load(root).resolved_total,
3360            0,
3361            "a cross-file move is not a resolution"
3362        );
3363    }
3364
3365    #[test]
3366    fn cross_run_move_uncredits_the_prior_resolution() {
3367        let (_config, dir) = test_env();
3368        let root = dir.path();
3369        enable(root);
3370        let a = touch(root, "src/a.ts");
3371        let b = touch(root, "src/b.ts");
3372        run(
3373            root,
3374            &[&a],
3375            vec![fi(&a, "unused-export", "foo")],
3376            vec![],
3377            &[],
3378            "t0",
3379        );
3380        run(root, &[&a], vec![], vec![], &[], "t1");
3381        assert_eq!(
3382            load(root).resolved_total,
3383            1,
3384            "source disappearance credited in run A"
3385        );
3386        run(
3387            root,
3388            &[&b],
3389            vec![fi(&b, "unused-export", "foo")],
3390            vec![],
3391            &[],
3392            "t2",
3393        );
3394        let store = load(root);
3395        assert_eq!(
3396            store.resolved_total, 0,
3397            "cross-run move must un-credit the phantom win"
3398        );
3399        assert!(
3400            store.recent_resolved.is_empty(),
3401            "the stale resolution event is dropped"
3402        );
3403    }
3404
3405    #[test]
3406    fn resolved_complexity_finding_and_suppressed_complexity() {
3407        let (_config, dir) = test_env();
3408        let root = dir.path();
3409        enable(root);
3410        let a = touch(root, "src/a.ts");
3411        run(
3412            root,
3413            &[&a],
3414            vec![fi(&a, "complexity", "bigFn")],
3415            vec![],
3416            &[],
3417            "t0",
3418        );
3419        run(root, &[&a], vec![], vec![], &[supp(&a, "complexity")], "t1");
3420        let store = load(root);
3421        assert_eq!(store.resolved_total, 0);
3422        assert_eq!(store.suppressed_total, 1);
3423
3424        let b = touch(root, "src/b.ts");
3425        run(
3426            root,
3427            &[&b],
3428            vec![fi(&b, "complexity", "huge")],
3429            vec![],
3430            &[],
3431            "t2",
3432        );
3433        run(root, &[&b], vec![], vec![], &[], "t3");
3434        assert_eq!(load(root).resolved_total, 1);
3435    }
3436
3437    #[test]
3438    fn resolved_duplication_clone_group() {
3439        let (_config, dir) = test_env();
3440        let root = dir.path();
3441        enable(root);
3442        let a = touch(root, "src/a.ts");
3443        let b = touch(root, "src/b.ts");
3444        let clone = CloneInput {
3445            fingerprint: "dup:abc12345".to_owned(),
3446            instance_paths: vec![a.clone(), b],
3447        };
3448        run(root, &[&a], vec![], vec![clone], &[], "t0");
3449        run(root, &[&a], vec![], vec![], &[], "t1");
3450        let store = load(root);
3451        assert_eq!(store.resolved_total, 1);
3452        assert_eq!(store.recent_resolved[0].kind, "code-duplication");
3453    }
3454
3455    #[test]
3456    fn blanket_suppression_covers_any_kind() {
3457        let (_config, dir) = test_env();
3458        let root = dir.path();
3459        enable(root);
3460        let a = touch(root, "src/a.ts");
3461        run(
3462            root,
3463            &[&a],
3464            vec![fi(&a, "unused-export", "foo")],
3465            vec![],
3466            &[],
3467            "t0",
3468        );
3469        let blanket = ActiveSuppression {
3470            path: a.clone(),
3471            kind: None,
3472            is_file_level: true,
3473            reason: None,
3474        };
3475        run(root, &[&a], vec![], vec![], &[blanket], "t1");
3476        let store = load(root);
3477        assert_eq!(store.resolved_total, 0);
3478        assert_eq!(store.suppressed_total, 1);
3479    }
3480
3481    #[test]
3482    fn v1_store_loads_and_upgrades_to_v2() {
3483        let (_config, dir) = test_env();
3484        let root = dir.path();
3485        let v1 = r#"{"schema_version":1,"enabled":true,"first_recorded":"t0","records":[{"timestamp":"t0","version":"2.0.0","verdict":"warn","gate":false,"counts":{"total_issues":1,"dead_code":1,"complexity":0,"duplication":0}}],"containment":[]}"#;
3486        seed_store_raw(root, v1.as_bytes());
3487        let store = load(root);
3488        assert_eq!(store.schema_version, 1);
3489        assert!(store.frontier.is_empty());
3490        assert_eq!(store.resolved_total, 0);
3491        let a = touch(root, "src/a.ts");
3492        run(
3493            root,
3494            &[&a],
3495            vec![fi(&a, "unused-export", "foo")],
3496            vec![],
3497            &[],
3498            "t1",
3499        );
3500        let store = load(root);
3501        assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
3502        assert!(frontier_paths(&store).contains("src/a.ts"));
3503    }
3504
3505    #[test]
3506    fn recent_resolved_is_bounded() {
3507        let mut store = ImpactStore {
3508            enabled: true,
3509            ..Default::default()
3510        };
3511        for i in 0..(MAX_RECENT_RESOLVED + 25) {
3512            store.recent_resolved.push(ResolutionEvent {
3513                kind: "unused-export".into(),
3514                path: format!("src/f{i}.ts"),
3515                symbol: Some(format!("s{i}")),
3516                git_sha: None,
3517                timestamp: format!("t{i}"),
3518            });
3519        }
3520        bound_recent_resolved(&mut store);
3521        assert_eq!(store.recent_resolved.len(), MAX_RECENT_RESOLVED);
3522        assert_eq!(store.recent_resolved[0].path, "src/f25.ts");
3523    }
3524
3525    #[test]
3526    fn frontier_prunes_deleted_files() {
3527        let (_config, dir) = test_env();
3528        let root = dir.path();
3529        enable(root);
3530        let a = touch(root, "src/a.ts");
3531        run(
3532            root,
3533            &[&a],
3534            vec![fi(&a, "unused-export", "foo")],
3535            vec![],
3536            &[],
3537            "t0",
3538        );
3539        assert!(frontier_paths(&load(root)).contains("src/a.ts"));
3540        std::fs::remove_file(&a).unwrap();
3541        let b = touch(root, "src/b.ts");
3542        run(root, &[&b], vec![], vec![], &[], "t1");
3543        assert!(!frontier_paths(&load(root)).contains("src/a.ts"));
3544    }
3545
3546    #[test]
3547    fn honest_empty_state_before_attribution_baseline() {
3548        let store = ImpactStore {
3549            enabled: true,
3550            records: vec![ImpactRecord {
3551                timestamp: "t0".into(),
3552                version: "2.0.0".into(),
3553                git_sha: None,
3554                verdict: "warn".into(),
3555                gate: false,
3556                counts: ImpactCounts::default(),
3557            }],
3558            ..Default::default()
3559        };
3560        let report = build_report(&store);
3561        assert!(!report.attribution_active);
3562        let human = render_human(&report);
3563        assert!(human.contains("resolution tracking starts from your next gate run"));
3564        assert!(!human.contains("0 finding"));
3565    }
3566
3567    #[test]
3568    fn suppression_only_state_renders_under_a_resolved_header() {
3569        let report = ImpactReport {
3570            schema_version: ImpactReportSchemaVersion::V1,
3571            enabled: true,
3572            enabled_source: EnabledSource::Project,
3573            record_count: 2,
3574            meta: None,
3575            first_recorded: Some("2026-05-29T10:00:00Z".into()),
3576            latest_git_sha: None,
3577            surfacing: Some(ImpactCounts::default()),
3578            trend: None,
3579            project_surfacing: None,
3580            project_trend: None,
3581            containment_count: 0,
3582            recent_containment: vec![],
3583            resolved_total: 0,
3584            suppressed_total: 2,
3585            recent_resolved: vec![],
3586            attribution_active: true,
3587            onboarding_declined: false,
3588            explicit_decision: false,
3589        };
3590        let human = render_human(&report);
3591        let resolved_idx = human.find("  RESOLVED").expect("RESOLVED header present");
3592        let supp_idx = human
3593            .find("2 findings you marked intentional")
3594            .expect("suppression line present");
3595        assert!(
3596            resolved_idx < supp_idx,
3597            "suppression must render under RESOLVED"
3598        );
3599        assert!(human.contains("none yet"));
3600
3601        let md = render_markdown(&report);
3602        assert!(
3603            md.contains("- **Resolved:**"),
3604            "markdown always has a Resolved bullet"
3605        );
3606        assert!(md.contains("- **Marked intentional:** 2 finding"));
3607    }
3608
3609    /// Build a `CloneInput` over real absolute paths (built from `root`).
3610    fn clone_at(fingerprint: &str, paths: &[&Path]) -> CloneInput {
3611        CloneInput {
3612            fingerprint: fingerprint.to_owned(),
3613            instance_paths: paths.iter().map(|p| p.to_path_buf()).collect(),
3614        }
3615    }
3616
3617    /// Record a WHOLE-PROJECT run via the real combined-track recorder
3618    /// (`record_combined_run` with `Scope::WholeProject`), exercising the same
3619    /// path `combined.rs` uses on a full `fallow` run.
3620    fn run_wp(
3621        root: &Path,
3622        findings: Vec<FindingInput>,
3623        clones: Vec<CloneInput>,
3624        supps: &[ActiveSuppression],
3625        ts: &str,
3626    ) {
3627        let input = AttributionInput {
3628            root,
3629            scope: Scope::WholeProject,
3630            findings,
3631            clones,
3632            suppressions: supps,
3633        };
3634        record_combined_run(
3635            root,
3636            ImpactCounts::default(),
3637            Some("sha"),
3638            "2.0.0",
3639            ts,
3640            Some(&input),
3641        );
3642    }
3643
3644    #[test]
3645    fn whole_project_run_does_not_double_credit_after_audit() {
3646        let (_config, dir) = test_env();
3647        let root = dir.path();
3648        enable(root);
3649        let a = touch(root, "src/a.ts");
3650        let b = touch(root, "src/b.ts");
3651        run(
3652            root,
3653            &[&a, &b],
3654            vec![],
3655            vec![clone_at("dup:abc", &[&a, &b])],
3656            &[],
3657            "t1",
3658        );
3659        assert_eq!(clone_fingerprints(&load(root)).len(), 1);
3660
3661        run(root, &[&a, &b], vec![], vec![], &[], "t2");
3662        assert_eq!(load(root).resolved_total, 1);
3663        assert!(load(root).clone_frontier.is_empty());
3664
3665        run_wp(root, vec![], vec![], &[], "t3");
3666        assert_eq!(
3667            load(root).resolved_total,
3668            1,
3669            "whole-project run re-credited a resolution"
3670        );
3671    }
3672
3673    #[test]
3674    fn whole_project_run_credits_suppressed_not_resolved() {
3675        let (_config, dir) = test_env();
3676        let root = dir.path();
3677        enable(root);
3678        let util = touch(root, "src/util.ts");
3679        run(
3680            root,
3681            &[&util],
3682            vec![fi(&util, "unused-export", "dead")],
3683            vec![],
3684            &[],
3685            "t1",
3686        );
3687        assert_eq!(frontier_paths(&load(root)).len(), 1);
3688
3689        run_wp(root, vec![], vec![], &[supp(&util, "unused-export")], "t2");
3690        let store = load(root);
3691        assert_eq!(
3692            store.suppressed_total, 1,
3693            "suppressed finding not counted suppressed"
3694        );
3695        assert_eq!(
3696            store.resolved_total, 0,
3697            "suppressed finding wrongly counted resolved"
3698        );
3699    }
3700
3701    #[test]
3702    fn clone_reshape_three_to_two_not_credited_as_resolved() {
3703        let (_config, dir) = test_env();
3704        let root = dir.path();
3705        enable(root);
3706        let a = touch(root, "src/a.ts");
3707        let b = touch(root, "src/b.ts");
3708        let c = touch(root, "src/c.ts");
3709        run(
3710            root,
3711            &[&a, &b, &c],
3712            vec![],
3713            vec![clone_at("dup:aaa", &[&a, &b, &c])],
3714            &[],
3715            "t1",
3716        );
3717        assert_eq!(clone_fingerprints(&load(root)).len(), 1);
3718
3719        run_wp(
3720            root,
3721            vec![],
3722            vec![clone_at("dup:bbb", &[&a, &b])],
3723            &[],
3724            "t2",
3725        );
3726        let store = load(root);
3727        assert_eq!(
3728            store.resolved_total, 0,
3729            "clone reshape miscredited as resolved"
3730        );
3731        assert!(clone_fingerprints(&store).contains("dup:bbb"));
3732        assert!(!clone_fingerprints(&store).contains("dup:aaa"));
3733    }
3734
3735    fn rcounts(total: usize, dead: usize, complexity: usize, dup: usize) -> ImpactCounts {
3736        ImpactCounts {
3737            total_issues: total,
3738            dead_code: dead,
3739            complexity,
3740            duplication: dup,
3741        }
3742    }
3743
3744    fn rtrend(prev: usize, cur: usize) -> TrendSummary {
3745        TrendSummary {
3746            direction: direction_for(cur as i64 - prev as i64),
3747            total_delta: cur as i64 - prev as i64,
3748            previous_total: prev,
3749            current_total: cur,
3750        }
3751    }
3752
3753    /// Build a report literal for render-state tests.
3754    fn rreport(
3755        record_count: usize,
3756        first_recorded: Option<&str>,
3757        surfacing: Option<ImpactCounts>,
3758        trend: Option<TrendSummary>,
3759        project_surfacing: Option<ImpactCounts>,
3760        project_trend: Option<TrendSummary>,
3761        attribution_active: bool,
3762    ) -> ImpactReport {
3763        ImpactReport {
3764            schema_version: ImpactReportSchemaVersion::V1,
3765            enabled: true,
3766            enabled_source: EnabledSource::Project,
3767            record_count,
3768            meta: None,
3769            first_recorded: first_recorded.map(ToOwned::to_owned),
3770            latest_git_sha: None,
3771            surfacing,
3772            trend,
3773            project_surfacing,
3774            project_trend,
3775            containment_count: 0,
3776            recent_containment: vec![],
3777            resolved_total: 0,
3778            suppressed_total: 0,
3779            recent_resolved: vec![],
3780            attribution_active,
3781            onboarding_declined: false,
3782            explicit_decision: false,
3783        }
3784    }
3785
3786    #[test]
3787    fn render_human_project_only_store_shows_whole_project_not_empty_state() {
3788        let r = rreport(
3789            0,
3790            Some("2026-05-30T10:00:00Z"),
3791            None,
3792            None,
3793            Some(rcounts(1, 1, 0, 0)),
3794            None,
3795            true,
3796        );
3797        let human = render_human(&r);
3798        assert!(
3799            human.contains("WHOLE PROJECT (whole-repo context, not a to-do)"),
3800            "project-only must render the labeled section"
3801        );
3802        assert!(human.contains("1 issue across the whole project"));
3803        assert!(
3804            human.contains("project trend starts after your next full `fallow` run"),
3805            "single project record => no trend line, shows the next-run hint"
3806        );
3807        assert!(human.contains("Tracking since 2026-05-30"));
3808        assert!(
3809            !human.contains("No history yet"),
3810            "must not show the empty-state copy"
3811        );
3812        assert!(
3813            !human.contains("LATEST RUN"),
3814            "no changed-file track recorded"
3815        );
3816        assert!(
3817            !human.contains("recorded audit run"),
3818            "no audit runs => no changed-file footer"
3819        );
3820    }
3821
3822    #[test]
3823    fn render_human_both_tracks_label_actionable_vs_context() {
3824        let r = rreport(
3825            3,
3826            Some("2026-05-29T10:00:00Z"),
3827            Some(rcounts(4, 4, 0, 0)),
3828            Some(rtrend(6, 4)),
3829            Some(rcounts(40, 30, 5, 5)),
3830            Some(rtrend(45, 40)),
3831            true,
3832        );
3833        let human = render_human(&r);
3834        let latest = human
3835            .find("LATEST RUN (changed files, act on these now)")
3836            .expect("LATEST RUN labeled actionable");
3837        let whole = human
3838            .find("WHOLE PROJECT (whole-repo context, not a to-do)")
3839            .expect("WHOLE PROJECT labeled context");
3840        assert!(
3841            latest < whole,
3842            "changed-file section renders before whole-project"
3843        );
3844        assert!(human.contains("45 -> 40 (down) across your last two full runs"));
3845        assert!(human.contains("advances only on your local full `fallow` runs, not CI"));
3846    }
3847
3848    #[test]
3849    fn render_markdown_project_only_store_shows_whole_project_not_empty_state() {
3850        let r = rreport(
3851            0,
3852            Some("2026-05-30T10:00:00Z"),
3853            None,
3854            None,
3855            Some(rcounts(1, 1, 0, 0)),
3856            None,
3857            true,
3858        );
3859        let md = render_markdown(&r);
3860        assert!(
3861            md.contains(
3862                "- **Whole project (whole-repo context, last full `fallow` run):** 1 issue"
3863            ),
3864            "project-only md must render the labeled whole-project line"
3865        );
3866        assert!(
3867            !md.contains("No history yet"),
3868            "project-only md must not show empty state"
3869        );
3870        assert!(md.contains("Tracking since 2026-05-30"));
3871    }
3872
3873    #[test]
3874    fn resolve_enabled_precedence_table() {
3875        let (_config, _dir) = test_env();
3876        // enabled-true is an explicit project opt-in regardless of the flag.
3877        let on = ImpactStore {
3878            enabled: true,
3879            ..Default::default()
3880        };
3881        assert_eq!(resolve_enabled(&on), (true, EnabledSource::Project));
3882
3883        // explicitly disabled here stays off as a Project decision.
3884        let off_explicit = ImpactStore {
3885            enabled: false,
3886            explicit_decision: true,
3887            ..Default::default()
3888        };
3889        assert_eq!(
3890            resolve_enabled(&off_explicit),
3891            (false, EnabledSource::Project)
3892        );
3893
3894        // never-asked + no global default => off (Default).
3895        let never = ImpactStore::default();
3896        assert_eq!(resolve_enabled(&never), (false, EnabledSource::Default));
3897
3898        // never-asked + global default on => on (User).
3899        assert!(set_global_default(true));
3900        assert_eq!(resolve_enabled(&never), (true, EnabledSource::User));
3901        // a per-repo disable still wins over the global default.
3902        assert_eq!(
3903            resolve_enabled(&off_explicit),
3904            (false, EnabledSource::Project)
3905        );
3906    }
3907
3908    #[test]
3909    fn human_report_explains_user_global_default() {
3910        let (_config, _dir) = test_env();
3911        set_global_default(true);
3912        // A never-asked store resolved on a project: enabled via the global default.
3913        let report = build_report(&ImpactStore::default());
3914        assert_eq!(report.enabled_source, EnabledSource::User);
3915        let human = render_human(&report);
3916        assert!(
3917            human.contains("Enabled by your user-global default"),
3918            "human report must explain a global-default enable: {human}"
3919        );
3920        // A project-enabled report does NOT show the global-default note.
3921        let project = build_report(&ImpactStore {
3922            enabled: true,
3923            explicit_decision: true,
3924            ..Default::default()
3925        });
3926        assert_eq!(project.enabled_source, EnabledSource::Project);
3927        assert!(!render_human(&project).contains("user-global default"));
3928    }
3929
3930    #[test]
3931    fn global_default_round_trips() {
3932        let (_config, _dir) = test_env();
3933        assert!(!load_global_default());
3934        assert!(set_global_default(true));
3935        assert!(load_global_default());
3936        assert!(!set_global_default(true)); // unchanged
3937        assert!(set_global_default(false));
3938        assert!(!load_global_default());
3939    }
3940
3941    #[test]
3942    fn global_default_records_without_per_repo_enable() {
3943        let (_config, dir) = test_env();
3944        let root = dir.path();
3945        set_global_default(true);
3946        // No `enable(root)` call: the global default alone should activate.
3947        record_v1(
3948            root,
3949            &summary(2, 0, 0),
3950            AuditVerdict::Warn,
3951            false,
3952            None,
3953            "2.0.0",
3954            "t0",
3955        );
3956        let report = build_report(&load(root));
3957        assert!(report.enabled);
3958        assert_eq!(report.enabled_source, EnabledSource::User);
3959        assert_eq!(report.record_count, 1);
3960    }
3961
3962    #[test]
3963    fn legacy_in_repo_store_is_migrated_on_first_load() {
3964        let (_config, dir) = test_env();
3965        let root = dir.path();
3966        // Seed a pre-relocation v3 store with a FLAT frontier in the repo.
3967        let legacy = r#"{"schema_version":3,"enabled":true,"explicit_decision":true,
3968            "records":[{"timestamp":"t0","version":"2.0.0","verdict":"warn","gate":false,
3969            "counts":{"total_issues":1,"dead_code":1,"complexity":0,"duplication":0}}],
3970            "resolved_total":2,
3971            "frontier":{"src/a.ts":{"findings":[{"id":"x","kind":"unused-export","symbol":"foo"}],"suppressions":[]}},
3972            "containment":[]}"#;
3973        std::fs::create_dir_all(root.join(".fallow")).unwrap();
3974        std::fs::write(legacy_store_path(root), legacy).unwrap();
3975
3976        let store = load(root);
3977        assert!(store.enabled);
3978        assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
3979        assert_eq!(store.records.len(), 1);
3980        assert_eq!(store.resolved_total, 2);
3981        // The flat frontier was wrapped under the worktree key (nested v4 shape).
3982        assert!(frontier_paths(&store).contains("src/a.ts"));
3983        // The user store now exists, so a second load does NOT re-import (it
3984        // reads the user store directly).
3985        assert!(store_path(root).is_some_and(|p| p.exists()));
3986        let again = load(root);
3987        assert_eq!(again.records.len(), 1);
3988    }
3989
3990    #[test]
3991    fn reset_removes_only_this_project() {
3992        let (_config, dir) = test_env();
3993        let root = dir.path();
3994        enable(root);
3995        record_v1(
3996            root,
3997            &summary(1, 0, 0),
3998            AuditVerdict::Warn,
3999            false,
4000            None,
4001            "2.0.0",
4002            "t0",
4003        );
4004        assert_eq!(load(root).records.len(), 1);
4005        assert!(reset(root));
4006        assert!(load(root).records.is_empty());
4007        assert!(!reset(root)); // already gone
4008    }
4009
4010    #[test]
4011    fn reset_all_clears_dir_but_keeps_global_default() {
4012        let (_config, dir) = test_env();
4013        let root = dir.path();
4014        set_global_default(true);
4015        enable(root);
4016        assert!(load(root).enabled);
4017        assert!(reset_all());
4018        // The global default toggle survives a data wipe.
4019        assert!(load_global_default());
4020    }
4021
4022    // ----- cross-repo aggregate (`impact --all`) tests --------------------
4023
4024    /// Set an isolated config dir (no project root needed) and return its guard.
4025    fn aggregate_env() -> tempfile::TempDir {
4026        let config = tempfile::tempdir().unwrap();
4027        TEST_CONFIG_DIR.with(|c| *c.borrow_mut() = Some(config.path().to_path_buf()));
4028        config
4029    }
4030
4031    /// Write a store file directly under `<config>/impact/<key>.json`.
4032    fn seed_store(key: &str, store: &ImpactStore) {
4033        let dir = impact_config_dir().unwrap().join("impact");
4034        std::fs::create_dir_all(&dir).unwrap();
4035        std::fs::write(
4036            dir.join(format!("{key}.json")),
4037            serde_json::to_string_pretty(store).unwrap(),
4038        )
4039        .unwrap();
4040    }
4041
4042    fn store_with(
4043        label: &str,
4044        resolved: usize,
4045        contained: usize,
4046        latest_ts: &str,
4047        latest_issues: usize,
4048    ) -> ImpactStore {
4049        let mut s = ImpactStore {
4050            enabled: true,
4051            explicit_decision: true,
4052            resolved_total: resolved,
4053            label: Some(label.to_owned()),
4054            ..Default::default()
4055        };
4056        s.records.push(ImpactRecord {
4057            timestamp: latest_ts.to_owned(),
4058            version: "2.0.0".to_owned(),
4059            git_sha: None,
4060            verdict: "warn".to_owned(),
4061            gate: false,
4062            counts: ImpactCounts::from_combined(latest_issues, 0, 0),
4063        });
4064        for _ in 0..contained {
4065            s.containment.push(ContainmentEvent {
4066                blocked_at: "t0".to_owned(),
4067                cleared_at: "t1".to_owned(),
4068                git_sha: None,
4069                blocked_counts: ImpactCounts::default(),
4070            });
4071        }
4072        s
4073    }
4074
4075    #[test]
4076    fn repo_basename_returns_last_component_only() {
4077        assert_eq!(
4078            repo_basename(Path::new("/a/b/myrepo/.git")).as_deref(),
4079            Some("myrepo")
4080        );
4081        assert_eq!(
4082            repo_basename(Path::new("/a/b/proj")).as_deref(),
4083            Some("proj")
4084        );
4085        // Never a separator in the result.
4086        let name = repo_basename(Path::new("/x/y/z/.git")).unwrap();
4087        assert!(!name.contains('/') && !name.contains('\\'));
4088    }
4089
4090    #[test]
4091    fn aggregate_rolls_up_totals_and_excludes_empty() {
4092        let _cfg = aggregate_env();
4093        seed_store(
4094            "aaa",
4095            &store_with("alpha", 10, 2, "2026-06-10T00:00:00Z", 3),
4096        );
4097        seed_store("bbb", &store_with("beta", 5, 1, "2026-06-11T00:00:00Z", 0));
4098        // enabled-but-empty: no records, no resolved, no containment.
4099        seed_store(
4100            "ccc",
4101            &ImpactStore {
4102                enabled: true,
4103                explicit_decision: true,
4104                label: Some("gamma".into()),
4105                ..Default::default()
4106            },
4107        );
4108        let report = aggregate(CrossRepoSort::Recent);
4109        assert_eq!(report.project_count, 3, "all three stores enumerated");
4110        assert_eq!(report.tracked_count, 2, "empty store excluded from rows");
4111        assert_eq!(report.totals.resolved_total, 15);
4112        assert_eq!(report.totals.containment_count, 3);
4113        assert_eq!(report.unreadable_count, 0);
4114    }
4115
4116    #[test]
4117    fn aggregate_sort_recent_orders_by_last_activity() {
4118        let _cfg = aggregate_env();
4119        seed_store("old", &store_with("older", 1, 0, "2026-06-01T00:00:00Z", 1));
4120        seed_store("new", &store_with("newer", 1, 0, "2026-06-12T00:00:00Z", 1));
4121        let report = aggregate(CrossRepoSort::Recent);
4122        assert_eq!(report.projects[0].label.as_deref(), Some("newer"));
4123        assert_eq!(report.projects[1].label.as_deref(), Some("older"));
4124    }
4125
4126    #[test]
4127    fn cross_repo_json_carries_kind_and_leaks_no_path() {
4128        let _cfg = aggregate_env();
4129        seed_store("aaa", &store_with("alpha", 4, 1, "2026-06-10T00:00:00Z", 2));
4130        let report = aggregate(CrossRepoSort::Recent);
4131        let json = render_cross_repo_json(&report);
4132        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
4133        assert_eq!(value["kind"], "impact-cross-repo");
4134        // No label (or any string) may contain a path separator.
4135        for entry in value["projects"].as_array().unwrap() {
4136            if let Some(label) = entry["label"].as_str() {
4137                assert!(
4138                    !label.contains('/') && !label.contains('\\'),
4139                    "label must be a basename, got {label}"
4140                );
4141            }
4142        }
4143        assert!(
4144            !json.contains('/') || !json.contains("Users"),
4145            "json must not leak an absolute home path"
4146        );
4147    }
4148
4149    #[test]
4150    fn cross_repo_markdown_pluralizes_single_project() {
4151        let _cfg = aggregate_env();
4152        seed_store("solo", &store_with("solo", 3, 1, "2026-06-10T00:00:00Z", 2));
4153        let report = aggregate(CrossRepoSort::Recent);
4154        assert_eq!(report.project_count, 1);
4155        assert_eq!(report.tracked_count, 1);
4156        let md = render_cross_repo_markdown(&report);
4157        assert!(
4158            md.contains("1 project tracked"),
4159            "single project must read 'project', got:\n{md}"
4160        );
4161        assert!(
4162            !md.contains("1 projects tracked"),
4163            "must not pluralize a single project, got:\n{md}"
4164        );
4165        assert!(
4166            md.contains("across 1 tracked project"),
4167            "grand totals must read 'tracked project' (singular), got:\n{md}"
4168        );
4169        assert!(
4170            !md.contains("tracked projects"),
4171            "must not pluralize a single tracked project, got:\n{md}"
4172        );
4173    }
4174
4175    #[test]
4176    fn cross_repo_corrupt_file_is_skipped_and_counted() {
4177        let _cfg = aggregate_env();
4178        seed_store("good", &store_with("good", 3, 0, "2026-06-10T00:00:00Z", 1));
4179        let dir = impact_config_dir().unwrap().join("impact");
4180        std::fs::write(dir.join("bad.json"), b"{ not valid json ][").unwrap();
4181        let report = aggregate(CrossRepoSort::Recent);
4182        assert_eq!(report.tracked_count, 1, "good store still aggregated");
4183        assert_eq!(
4184            report.unreadable_count, 1,
4185            "corrupt file counted, not crashed"
4186        );
4187    }
4188
4189    #[test]
4190    fn cross_repo_empty_dir_is_first_run() {
4191        let _cfg = aggregate_env();
4192        let report = aggregate(CrossRepoSort::Recent);
4193        assert_eq!(report.project_count, 0);
4194        let human = render_cross_repo_human(&report, None);
4195        assert!(human.contains("No projects tracked yet"));
4196    }
4197
4198    #[test]
4199    fn cross_repo_all_corrupt_reports_unreadable_not_first_run() {
4200        let _cfg = aggregate_env();
4201        let dir = impact_config_dir().unwrap().join("impact");
4202        std::fs::create_dir_all(&dir).unwrap();
4203        std::fs::write(dir.join("bad.json"), b"{ broken ][").unwrap();
4204        let report = aggregate(CrossRepoSort::Recent);
4205        assert_eq!(report.project_count, 0);
4206        assert_eq!(report.unreadable_count, 1);
4207        let human = render_cross_repo_human(&report, None);
4208        assert!(
4209            human.contains("unreadable store") && !human.contains("No projects tracked yet"),
4210            "all-corrupt must report unreadable, not a misleading first-run hint: {human}"
4211        );
4212    }
4213
4214    #[test]
4215    fn record_audit_run_captures_basename_label() {
4216        let (_config, dir) = test_env();
4217        let root = dir.path();
4218        enable(root);
4219        record_v1(
4220            root,
4221            &summary(1, 0, 0),
4222            AuditVerdict::Warn,
4223            false,
4224            None,
4225            "2.0.0",
4226            "t0",
4227        );
4228        let label = load(root).label.expect("label captured on record");
4229        assert!(
4230            !label.contains('/') && !label.contains('\\'),
4231            "label must be a basename, got {label}"
4232        );
4233    }
4234
4235    // ----- store advisory lock + age-based GC --------------------------------
4236
4237    #[test]
4238    fn lock_path_appends_lock_suffix() {
4239        assert_eq!(
4240            lock_path_for(Path::new("/c/fallow/impact/abc.json")),
4241            PathBuf::from("/c/fallow/impact/abc.json.lock")
4242        );
4243    }
4244
4245    #[test]
4246    fn store_lock_acquire_drop_then_record_roundtrips() {
4247        let (_config, dir) = test_env();
4248        let root = dir.path();
4249        enable(root);
4250        // Acquiring + dropping the lock around a record must not deadlock and
4251        // the record must persist.
4252        {
4253            let _lock = ImpactStoreLock::acquire(root).expect("lock acquires");
4254        }
4255        record_v1(
4256            root,
4257            &summary(1, 0, 0),
4258            AuditVerdict::Warn,
4259            false,
4260            None,
4261            "2.0.0",
4262            "t0",
4263        );
4264        assert_eq!(load(root).records.len(), 1, "record persisted under lock");
4265        // The lock sidecar lives next to the store and is never the store itself.
4266        let store = store_path(root).unwrap();
4267        assert!(lock_path_for(&store).exists(), "lock sidecar created");
4268        assert!(store.exists(), "store file is distinct from its lock");
4269    }
4270
4271    #[test]
4272    fn sweep_keeps_fresh_and_self_deletes_aged_out() {
4273        let _cfg = aggregate_env();
4274        seed_store("keepme", &store_with("keep", 1, 0, "t0", 1));
4275        seed_store("oldone", &store_with("old", 1, 0, "t0", 1));
4276        // A `.lock` sidecar must survive the sweep (lock-lifecycle invariant).
4277        let lock = impact_config_dir()
4278            .unwrap()
4279            .join("impact")
4280            .join("oldone.json.lock");
4281        std::fs::write(&lock, b"").unwrap();
4282
4283        // max_age = 0 ages out every non-kept store regardless of mtime.
4284        sweep_old_stores("keepme", std::time::Duration::ZERO);
4285
4286        let dir = impact_config_dir().unwrap().join("impact");
4287        assert!(dir.join("keepme.json").exists(), "kept store survives");
4288        assert!(
4289            !dir.join("oldone.json").exists(),
4290            "aged-out store reclaimed"
4291        );
4292        assert!(lock.exists(), "lock sidecar never deleted by the sweep");
4293    }
4294
4295    #[test]
4296    fn sweep_keeps_everything_under_a_large_window() {
4297        let _cfg = aggregate_env();
4298        seed_store("a", &store_with("a", 1, 0, "t0", 1));
4299        seed_store("b", &store_with("b", 1, 0, "t0", 1));
4300        // 10-year window: freshly-written stores are never aged out.
4301        sweep_old_stores("a", std::time::Duration::from_hours(10 * 365 * 24));
4302        let dir = impact_config_dir().unwrap().join("impact");
4303        assert!(dir.join("a.json").exists());
4304        assert!(dir.join("b.json").exists());
4305    }
4306}