Skip to main content

fallow_cli/
impact.rs

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