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            comment_line: 3,
2575        }
2576    }
2577
2578    /// Record one attribution run against the store.
2579    fn run(
2580        root: &Path,
2581        changed: &[&Path],
2582        findings: Vec<FindingInput>,
2583        clones: Vec<CloneInput>,
2584        supps: &[ActiveSuppression],
2585        ts: &str,
2586    ) {
2587        let changed_files: Vec<PathBuf> = changed.iter().map(|p| p.to_path_buf()).collect();
2588        let input = AttributionInput {
2589            root,
2590            scope: Scope::ChangedFiles(&changed_files),
2591            findings,
2592            clones,
2593            suppressions: supps,
2594        };
2595        record_audit_run(
2596            root,
2597            &summary(0, 0, 0),
2598            &AuditRunRecord {
2599                verdict: AuditVerdict::Pass,
2600                gate: true,
2601                git_sha: Some("sha"),
2602                version: "2.0.0",
2603                timestamp: ts,
2604                attribution: Some(&input),
2605            },
2606        );
2607    }
2608
2609    #[test]
2610    fn disabled_store_does_not_record() {
2611        let (_config, dir) = test_env();
2612        let root = dir.path();
2613        record_v1(
2614            root,
2615            &summary(3, 1, 0),
2616            AuditVerdict::Fail,
2617            true,
2618            Some("abc1234"),
2619            "2.0.0",
2620            "2026-05-29T10:00:00Z",
2621        );
2622        let store = load(root);
2623        assert!(store.records.is_empty());
2624        assert!(!store.enabled);
2625    }
2626
2627    #[test]
2628    fn enable_and_disable_record_the_explicit_decision() {
2629        let (_config, dir) = test_env();
2630        let root = dir.path();
2631        assert!(!load(root).explicit_decision, "fresh store: never asked");
2632
2633        // Declining on a never-enabled project is an explicit decision too.
2634        disable(root);
2635        let store = load(root);
2636        assert!(!store.enabled);
2637        assert!(store.explicit_decision);
2638        assert!(build_report(&store).explicit_decision);
2639    }
2640
2641    #[test]
2642    fn due_digest_stamps_and_respects_interval_and_gates() {
2643        let (_config, dir) = test_env();
2644        let root = dir.path();
2645
2646        // Disabled, or enabled with zero value: never due.
2647        assert!(take_due_digest(root).is_none());
2648        enable(root);
2649        assert!(take_due_digest(root).is_none(), "zero counters never nag");
2650
2651        let mut store = load(root);
2652        store.resolved_total = 3;
2653        store.containment.push(ContainmentEvent {
2654            blocked_at: "2026-06-11T00:00:00Z".to_string(),
2655            cleared_at: "2026-06-11T00:05:00Z".to_string(),
2656            git_sha: None,
2657            blocked_counts: ImpactCounts::default(),
2658        });
2659        save(&store, root);
2660
2661        let digest = take_due_digest(root).expect("first digest is due");
2662        assert_eq!(digest.containment_count, 1);
2663        assert_eq!(digest.resolved_total, 3);
2664        assert!(
2665            take_due_digest(root).is_none(),
2666            "stamped: not due again within the interval"
2667        );
2668
2669        // An expired stamp makes it due again.
2670        let mut store = load(root);
2671        store.last_digest_epoch = Some(0);
2672        save(&store, root);
2673        assert!(take_due_digest(root).is_some());
2674    }
2675
2676    #[test]
2677    fn decline_onboarding_persists_in_existing_store() {
2678        let (_config, dir) = test_env();
2679        let root = dir.path();
2680
2681        assert!(decline_onboarding(root));
2682        assert!(!decline_onboarding(root));
2683
2684        let store = load(root);
2685        assert!(store.onboarding_declined);
2686        assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
2687        // Decline persists in the user store and writes nothing into the repo.
2688        assert!(!root.join(".gitignore").exists());
2689        let report = build_report(&store);
2690        assert!(report.onboarding_declined);
2691    }
2692
2693    #[test]
2694    fn enable_then_record_accrues_history() {
2695        let (_config, dir) = test_env();
2696        let root = dir.path();
2697        assert!(enable(root));
2698        assert!(!enable(root)); // second enable is a no-op-ish (already on)
2699        record_v1(
2700            root,
2701            &summary(2, 1, 0),
2702            AuditVerdict::Warn,
2703            false,
2704            None,
2705            "2.0.0",
2706            "2026-05-29T10:00:00Z",
2707        );
2708        let store = load(root);
2709        assert_eq!(store.records.len(), 1);
2710        assert_eq!(store.records[0].counts.total_issues, 3);
2711        assert_eq!(
2712            store.first_recorded.as_deref(),
2713            Some("2026-05-29T10:00:00Z")
2714        );
2715    }
2716
2717    #[test]
2718    fn record_is_a_noop_in_ci() {
2719        // Impact is local-dev-only: it must never record on CI. The suite itself
2720        // runs on CI (where `CI` / `GITHUB_ACTIONS` are set), so the gate uses a
2721        // per-test override instead of the ambient env; here we force it true to
2722        // prove the production no-op. Without the gate, an enabled project would
2723        // record on every CI run.
2724        let (_config, dir) = test_env();
2725        let root = dir.path();
2726        assert!(enable(root));
2727        TEST_FORCE_CI.with(|c| c.set(true));
2728        record_v1(
2729            root,
2730            &summary(2, 1, 0),
2731            AuditVerdict::Warn,
2732            false,
2733            None,
2734            "2.0.0",
2735            "2026-05-29T10:00:00Z",
2736        );
2737        TEST_FORCE_CI.with(|c| c.set(false));
2738        let store = load(root);
2739        assert_eq!(store.records.len(), 0, "impact must not record while in CI");
2740    }
2741
2742    #[test]
2743    fn enable_writes_nothing_into_the_repo() {
2744        let (_config, dir) = test_env();
2745        let root = dir.path();
2746        enable(root);
2747        // The user-store relocation means enable never touches the repo: no
2748        // .gitignore mutation and no in-repo .fallow/ dir.
2749        assert!(
2750            !root.join(".gitignore").exists(),
2751            "enable must not create or modify the repo's .gitignore"
2752        );
2753        assert!(
2754            !root.join(".fallow").exists(),
2755            "enable must not create an in-repo .fallow/ dir"
2756        );
2757        // The decision IS persisted, in the user store.
2758        let store = load(root);
2759        assert!(store.enabled);
2760        assert!(store.explicit_decision);
2761        assert!(resolve_enabled(&store).0);
2762    }
2763
2764    #[test]
2765    fn single_record_yields_no_trend_no_spike() {
2766        let mut store = ImpactStore {
2767            enabled: true,
2768            ..Default::default()
2769        };
2770        store.records.push(ImpactRecord {
2771            timestamp: "t0".into(),
2772            version: "2.0.0".into(),
2773            git_sha: None,
2774            verdict: "warn".into(),
2775            gate: false,
2776            counts: ImpactCounts {
2777                total_issues: 5,
2778                dead_code: 5,
2779                complexity: 0,
2780                duplication: 0,
2781            },
2782        });
2783        let report = build_report(&store);
2784        assert!(report.trend.is_none());
2785        assert_eq!(report.surfacing.unwrap().total_issues, 5);
2786    }
2787
2788    #[test]
2789    fn empty_store_report_is_first_run() {
2790        let store = ImpactStore::default();
2791        let report = build_report(&store);
2792        assert_eq!(report.record_count, 0);
2793        assert!(report.trend.is_none());
2794        assert!(report.surfacing.is_none());
2795        let human = render_human(&report);
2796        assert!(human.contains("off")); // default store is disabled
2797    }
2798
2799    #[test]
2800    fn enabled_empty_store_shows_check_back() {
2801        let store = ImpactStore {
2802            enabled: true,
2803            ..Default::default()
2804        };
2805        let report = build_report(&store);
2806        let human = render_human(&report);
2807        assert!(human.contains("No history yet"));
2808        assert!(!human.contains("0 issues"));
2809    }
2810
2811    #[test]
2812    fn trend_improving_when_issues_drop() {
2813        let mut store = ImpactStore {
2814            enabled: true,
2815            ..Default::default()
2816        };
2817        for total in [8usize, 3usize] {
2818            store.records.push(ImpactRecord {
2819                timestamp: format!("t{total}"),
2820                version: "2.0.0".into(),
2821                git_sha: None,
2822                verdict: "warn".into(),
2823                gate: false,
2824                counts: ImpactCounts {
2825                    total_issues: total,
2826                    dead_code: total,
2827                    complexity: 0,
2828                    duplication: 0,
2829                },
2830            });
2831        }
2832        let report = build_report(&store);
2833        let trend = report.trend.unwrap();
2834        assert_eq!(trend.direction, ImpactTrendDirection::Improving);
2835        assert_eq!(trend.total_delta, -5);
2836    }
2837
2838    #[test]
2839    fn containment_blocked_then_cleared_records_one_event() {
2840        let (_config, dir) = test_env();
2841        let root = dir.path();
2842        enable(root);
2843        record_v1(
2844            root,
2845            &summary(2, 0, 0),
2846            AuditVerdict::Fail,
2847            true,
2848            Some("sha1"),
2849            "2.0.0",
2850            "t0",
2851        );
2852        let store = load(root);
2853        assert!(store.pending_containment.is_some());
2854        assert!(store.containment.is_empty());
2855
2856        record_v1(
2857            root,
2858            &summary(0, 0, 0),
2859            AuditVerdict::Pass,
2860            true,
2861            Some("sha2"),
2862            "2.0.0",
2863            "t1",
2864        );
2865        let store = load(root);
2866        assert!(store.pending_containment.is_none());
2867        assert_eq!(store.containment.len(), 1);
2868        assert_eq!(store.containment[0].blocked_at, "t0");
2869        assert_eq!(store.containment[0].cleared_at, "t1");
2870    }
2871
2872    #[test]
2873    fn non_gate_run_never_creates_containment() {
2874        let (_config, dir) = test_env();
2875        let root = dir.path();
2876        enable(root);
2877        record_v1(
2878            root,
2879            &summary(2, 0, 0),
2880            AuditVerdict::Fail,
2881            false,
2882            None,
2883            "2.0.0",
2884            "t0",
2885        );
2886        let store = load(root);
2887        assert!(store.pending_containment.is_none());
2888        assert!(store.containment.is_empty());
2889    }
2890
2891    #[test]
2892    fn corrupt_store_loads_as_default_no_panic() {
2893        let (_config, dir) = test_env();
2894        let root = dir.path();
2895        seed_store_raw(root, b"{ not valid json ][");
2896        let store = load(root);
2897        assert!(!store.enabled);
2898        assert!(store.records.is_empty());
2899        record_v1(
2900            root,
2901            &summary(1, 0, 0),
2902            AuditVerdict::Fail,
2903            true,
2904            None,
2905            "2.0.0",
2906            "t0",
2907        );
2908    }
2909
2910    #[test]
2911    fn records_are_bounded() {
2912        let mut store = ImpactStore {
2913            enabled: true,
2914            ..Default::default()
2915        };
2916        for i in 0..(MAX_RECORDS + 50) {
2917            store.records.push(ImpactRecord {
2918                timestamp: format!("t{i}"),
2919                version: "2.0.0".into(),
2920                git_sha: None,
2921                verdict: "pass".into(),
2922                gate: false,
2923                counts: ImpactCounts::default(),
2924            });
2925        }
2926        compact(&mut store);
2927        assert_eq!(store.records.len(), MAX_RECORDS);
2928        assert_eq!(store.records[0].timestamp, "t50");
2929    }
2930
2931    #[test]
2932    fn report_always_carries_schema_version() {
2933        let empty = build_report(&ImpactStore::default());
2934        assert_eq!(empty.schema_version, ImpactReportSchemaVersion::V1);
2935        let json = render_json(&empty);
2936        assert!(
2937            json.contains("\"schema_version\": \"1\""),
2938            "schema_version must be present (as the \"1\" const) even when disabled: {json}"
2939        );
2940
2941        let mut store = ImpactStore {
2942            enabled: true,
2943            ..Default::default()
2944        };
2945        store.records.push(ImpactRecord {
2946            timestamp: "2026-05-29T10:00:00Z".into(),
2947            version: "2.0.0".into(),
2948            git_sha: None,
2949            verdict: "pass".into(),
2950            gate: false,
2951            counts: ImpactCounts::default(),
2952        });
2953        assert_eq!(
2954            build_report(&store).schema_version,
2955            ImpactReportSchemaVersion::V1
2956        );
2957    }
2958
2959    #[test]
2960    fn date_only_trims_iso_timestamp() {
2961        assert_eq!(date_only("2026-05-29T18:15:23Z"), "2026-05-29");
2962        assert_eq!(date_only("2026-05-29"), "2026-05-29");
2963        assert_eq!(date_only("the first run"), "the first run");
2964    }
2965
2966    #[test]
2967    fn human_footer_shows_date_only() {
2968        let mut store = ImpactStore {
2969            enabled: true,
2970            ..Default::default()
2971        };
2972        store.first_recorded = Some("2026-05-29T18:15:23Z".into());
2973        store.records.push(ImpactRecord {
2974            timestamp: "2026-05-29T18:15:23Z".into(),
2975            version: "2.0.0".into(),
2976            git_sha: None,
2977            verdict: "pass".into(),
2978            gate: false,
2979            counts: ImpactCounts::default(),
2980        });
2981        let report = build_report(&store);
2982        let human = render_human(&report);
2983        assert!(
2984            human.contains("since 2026-05-29.") && !human.contains("18:15:23"),
2985            "human footer must show date-only: {human}"
2986        );
2987        let md = render_markdown(&report);
2988        assert!(
2989            md.contains("since 2026-05-29.") && !md.contains("18:15:23"),
2990            "markdown footer must show date-only: {md}"
2991        );
2992    }
2993
2994    #[test]
2995    fn future_schema_version_store_loads_without_panic_or_loss() {
2996        let (_config, dir) = test_env();
2997        let root = dir.path();
2998        let future = format!(
2999            "{{\"schema_version\":{},\"enabled\":true,\"records\":[],\"containment\":[]}}",
3000            STORE_SCHEMA_VERSION + 1
3001        );
3002        seed_store_raw(root, future.as_bytes());
3003        let store = load(root);
3004        assert_eq!(store.schema_version, STORE_SCHEMA_VERSION + 1);
3005        assert!(
3006            store.enabled,
3007            "future-version store must not degrade to default"
3008        );
3009    }
3010
3011    #[test]
3012    fn removed_finding_is_credited_as_resolved() {
3013        let (_config, dir) = test_env();
3014        let root = dir.path();
3015        enable(root);
3016        let a = touch(root, "src/a.ts");
3017        run(
3018            root,
3019            &[&a],
3020            vec![fi(&a, "unused-export", "foo")],
3021            vec![],
3022            &[],
3023            "t0",
3024        );
3025        assert_eq!(
3026            load(root).resolved_total,
3027            0,
3028            "first run only establishes a baseline"
3029        );
3030        run(root, &[&a], vec![], vec![], &[], "t1");
3031        let store = load(root);
3032        assert_eq!(store.resolved_total, 1);
3033        assert_eq!(store.suppressed_total, 0);
3034        assert_eq!(store.recent_resolved.len(), 1);
3035        assert_eq!(store.recent_resolved[0].kind, "unused-export");
3036        assert_eq!(store.recent_resolved[0].symbol.as_deref(), Some("foo"));
3037        assert_eq!(store.recent_resolved[0].path, "src/a.ts");
3038    }
3039
3040    #[test]
3041    fn suppressed_finding_is_not_a_win() {
3042        let (_config, dir) = test_env();
3043        let root = dir.path();
3044        enable(root);
3045        let a = touch(root, "src/a.ts");
3046        run(
3047            root,
3048            &[&a],
3049            vec![fi(&a, "unused-export", "foo")],
3050            vec![],
3051            &[],
3052            "t0",
3053        );
3054        run(
3055            root,
3056            &[&a],
3057            vec![],
3058            vec![],
3059            &[supp(&a, "unused-export")],
3060            "t1",
3061        );
3062        let store = load(root);
3063        assert_eq!(
3064            store.resolved_total, 0,
3065            "a suppression must never count as a win"
3066        );
3067        assert_eq!(store.suppressed_total, 1);
3068    }
3069
3070    #[test]
3071    fn fix_and_suppress_same_kind_credits_zero_resolved() {
3072        let (_config, dir) = test_env();
3073        let root = dir.path();
3074        enable(root);
3075        let a = touch(root, "src/a.ts");
3076        run(
3077            root,
3078            &[&a],
3079            vec![
3080                fi(&a, "unused-export", "foo"),
3081                fi(&a, "unused-export", "bar"),
3082            ],
3083            vec![],
3084            &[],
3085            "t0",
3086        );
3087        run(
3088            root,
3089            &[&a],
3090            vec![],
3091            vec![],
3092            &[supp(&a, "unused-export")],
3093            "t1",
3094        );
3095        let store = load(root);
3096        assert_eq!(store.resolved_total, 0);
3097        assert_eq!(store.suppressed_total, 2);
3098    }
3099
3100    #[test]
3101    fn within_file_move_is_not_resolved() {
3102        let (_config, dir) = test_env();
3103        let root = dir.path();
3104        enable(root);
3105        let a = touch(root, "src/a.ts");
3106        run(
3107            root,
3108            &[&a],
3109            vec![fi(&a, "unused-export", "foo")],
3110            vec![],
3111            &[],
3112            "t0",
3113        );
3114        run(
3115            root,
3116            &[&a],
3117            vec![fi(&a, "unused-export", "foo")],
3118            vec![],
3119            &[],
3120            "t1",
3121        );
3122        let store = load(root);
3123        assert_eq!(store.resolved_total, 0);
3124        assert_eq!(store.suppressed_total, 0);
3125    }
3126
3127    #[test]
3128    fn cross_file_move_in_same_run_is_not_resolved() {
3129        let (_config, dir) = test_env();
3130        let root = dir.path();
3131        enable(root);
3132        let a = touch(root, "src/a.ts");
3133        let b = touch(root, "src/b.ts");
3134        run(
3135            root,
3136            &[&a],
3137            vec![fi(&a, "unused-export", "foo")],
3138            vec![],
3139            &[],
3140            "t0",
3141        );
3142        run(
3143            root,
3144            &[&a, &b],
3145            vec![fi(&b, "unused-export", "foo")],
3146            vec![],
3147            &[],
3148            "t1",
3149        );
3150        assert_eq!(
3151            load(root).resolved_total,
3152            0,
3153            "a cross-file move is not a resolution"
3154        );
3155    }
3156
3157    #[test]
3158    fn cross_run_move_uncredits_the_prior_resolution() {
3159        let (_config, dir) = test_env();
3160        let root = dir.path();
3161        enable(root);
3162        let a = touch(root, "src/a.ts");
3163        let b = touch(root, "src/b.ts");
3164        run(
3165            root,
3166            &[&a],
3167            vec![fi(&a, "unused-export", "foo")],
3168            vec![],
3169            &[],
3170            "t0",
3171        );
3172        run(root, &[&a], vec![], vec![], &[], "t1");
3173        assert_eq!(
3174            load(root).resolved_total,
3175            1,
3176            "source disappearance credited in run A"
3177        );
3178        run(
3179            root,
3180            &[&b],
3181            vec![fi(&b, "unused-export", "foo")],
3182            vec![],
3183            &[],
3184            "t2",
3185        );
3186        let store = load(root);
3187        assert_eq!(
3188            store.resolved_total, 0,
3189            "cross-run move must un-credit the phantom win"
3190        );
3191        assert!(
3192            store.recent_resolved.is_empty(),
3193            "the stale resolution event is dropped"
3194        );
3195    }
3196
3197    #[test]
3198    fn resolved_complexity_finding_and_suppressed_complexity() {
3199        let (_config, dir) = test_env();
3200        let root = dir.path();
3201        enable(root);
3202        let a = touch(root, "src/a.ts");
3203        run(
3204            root,
3205            &[&a],
3206            vec![fi(&a, "complexity", "bigFn")],
3207            vec![],
3208            &[],
3209            "t0",
3210        );
3211        run(root, &[&a], vec![], vec![], &[supp(&a, "complexity")], "t1");
3212        let store = load(root);
3213        assert_eq!(store.resolved_total, 0);
3214        assert_eq!(store.suppressed_total, 1);
3215
3216        let b = touch(root, "src/b.ts");
3217        run(
3218            root,
3219            &[&b],
3220            vec![fi(&b, "complexity", "huge")],
3221            vec![],
3222            &[],
3223            "t2",
3224        );
3225        run(root, &[&b], vec![], vec![], &[], "t3");
3226        assert_eq!(load(root).resolved_total, 1);
3227    }
3228
3229    #[test]
3230    fn resolved_duplication_clone_group() {
3231        let (_config, dir) = test_env();
3232        let root = dir.path();
3233        enable(root);
3234        let a = touch(root, "src/a.ts");
3235        let b = touch(root, "src/b.ts");
3236        let clone = CloneInput {
3237            fingerprint: "dup:abc12345".to_owned(),
3238            instance_paths: vec![a.clone(), b],
3239        };
3240        run(root, &[&a], vec![], vec![clone], &[], "t0");
3241        run(root, &[&a], vec![], vec![], &[], "t1");
3242        let store = load(root);
3243        assert_eq!(store.resolved_total, 1);
3244        assert_eq!(store.recent_resolved[0].kind, "code-duplication");
3245    }
3246
3247    #[test]
3248    fn blanket_suppression_covers_any_kind() {
3249        let (_config, dir) = test_env();
3250        let root = dir.path();
3251        enable(root);
3252        let a = touch(root, "src/a.ts");
3253        run(
3254            root,
3255            &[&a],
3256            vec![fi(&a, "unused-export", "foo")],
3257            vec![],
3258            &[],
3259            "t0",
3260        );
3261        let blanket = ActiveSuppression {
3262            path: a.clone(),
3263            kind: None,
3264            is_file_level: true,
3265            reason: None,
3266            comment_line: 1,
3267        };
3268        run(root, &[&a], vec![], vec![], &[blanket], "t1");
3269        let store = load(root);
3270        assert_eq!(store.resolved_total, 0);
3271        assert_eq!(store.suppressed_total, 1);
3272    }
3273
3274    #[test]
3275    fn v1_store_loads_and_upgrades_to_v2() {
3276        let (_config, dir) = test_env();
3277        let root = dir.path();
3278        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":[]}"#;
3279        seed_store_raw(root, v1.as_bytes());
3280        let store = load(root);
3281        assert_eq!(store.schema_version, 1);
3282        assert!(store.frontier.is_empty());
3283        assert_eq!(store.resolved_total, 0);
3284        let a = touch(root, "src/a.ts");
3285        run(
3286            root,
3287            &[&a],
3288            vec![fi(&a, "unused-export", "foo")],
3289            vec![],
3290            &[],
3291            "t1",
3292        );
3293        let store = load(root);
3294        assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
3295        assert!(frontier_paths(&store).contains("src/a.ts"));
3296    }
3297
3298    #[test]
3299    fn recent_resolved_is_bounded() {
3300        let mut store = ImpactStore {
3301            enabled: true,
3302            ..Default::default()
3303        };
3304        for i in 0..(MAX_RECENT_RESOLVED + 25) {
3305            store.recent_resolved.push(ResolutionEvent {
3306                kind: "unused-export".into(),
3307                path: format!("src/f{i}.ts"),
3308                symbol: Some(format!("s{i}")),
3309                git_sha: None,
3310                timestamp: format!("t{i}"),
3311            });
3312        }
3313        bound_recent_resolved(&mut store);
3314        assert_eq!(store.recent_resolved.len(), MAX_RECENT_RESOLVED);
3315        assert_eq!(store.recent_resolved[0].path, "src/f25.ts");
3316    }
3317
3318    #[test]
3319    fn frontier_prunes_deleted_files() {
3320        let (_config, dir) = test_env();
3321        let root = dir.path();
3322        enable(root);
3323        let a = touch(root, "src/a.ts");
3324        run(
3325            root,
3326            &[&a],
3327            vec![fi(&a, "unused-export", "foo")],
3328            vec![],
3329            &[],
3330            "t0",
3331        );
3332        assert!(frontier_paths(&load(root)).contains("src/a.ts"));
3333        std::fs::remove_file(&a).unwrap();
3334        let b = touch(root, "src/b.ts");
3335        run(root, &[&b], vec![], vec![], &[], "t1");
3336        assert!(!frontier_paths(&load(root)).contains("src/a.ts"));
3337    }
3338
3339    #[test]
3340    fn honest_empty_state_before_attribution_baseline() {
3341        let store = ImpactStore {
3342            enabled: true,
3343            records: vec![ImpactRecord {
3344                timestamp: "t0".into(),
3345                version: "2.0.0".into(),
3346                git_sha: None,
3347                verdict: "warn".into(),
3348                gate: false,
3349                counts: ImpactCounts::default(),
3350            }],
3351            ..Default::default()
3352        };
3353        let report = build_report(&store);
3354        assert!(!report.attribution_active);
3355        let human = render_human(&report);
3356        assert!(human.contains("resolution tracking starts from your next gate run"));
3357        assert!(!human.contains("0 finding"));
3358    }
3359
3360    #[test]
3361    fn suppression_only_state_renders_under_a_resolved_header() {
3362        let report = ImpactReport {
3363            schema_version: ImpactReportSchemaVersion::V1,
3364            enabled: true,
3365            enabled_source: EnabledSource::Project,
3366            record_count: 2,
3367            meta: None,
3368            first_recorded: Some("2026-05-29T10:00:00Z".into()),
3369            latest_git_sha: None,
3370            surfacing: Some(ImpactCounts::default()),
3371            trend: None,
3372            project_surfacing: None,
3373            project_trend: None,
3374            containment_count: 0,
3375            recent_containment: vec![],
3376            resolved_total: 0,
3377            suppressed_total: 2,
3378            recent_resolved: vec![],
3379            attribution_active: true,
3380            onboarding_declined: false,
3381            explicit_decision: false,
3382        };
3383        let human = render_human(&report);
3384        let resolved_idx = human.find("  RESOLVED").expect("RESOLVED header present");
3385        let supp_idx = human
3386            .find("2 findings you marked intentional")
3387            .expect("suppression line present");
3388        assert!(
3389            resolved_idx < supp_idx,
3390            "suppression must render under RESOLVED"
3391        );
3392        assert!(human.contains("none yet"));
3393
3394        let md = render_markdown(&report);
3395        assert!(
3396            md.contains("- **Resolved:**"),
3397            "markdown always has a Resolved bullet"
3398        );
3399        assert!(md.contains("- **Marked intentional:** 2 finding"));
3400    }
3401
3402    /// Build a `CloneInput` over real absolute paths (built from `root`).
3403    fn clone_at(fingerprint: &str, paths: &[&Path]) -> CloneInput {
3404        CloneInput {
3405            fingerprint: fingerprint.to_owned(),
3406            instance_paths: paths.iter().map(|p| p.to_path_buf()).collect(),
3407        }
3408    }
3409
3410    /// Record a WHOLE-PROJECT run via the real combined-track recorder
3411    /// (`record_combined_run` with `Scope::WholeProject`), exercising the same
3412    /// path `combined.rs` uses on a full `fallow` run.
3413    fn run_wp(
3414        root: &Path,
3415        findings: Vec<FindingInput>,
3416        clones: Vec<CloneInput>,
3417        supps: &[ActiveSuppression],
3418        ts: &str,
3419    ) {
3420        let input = AttributionInput {
3421            root,
3422            scope: Scope::WholeProject,
3423            findings,
3424            clones,
3425            suppressions: supps,
3426        };
3427        record_combined_run(
3428            root,
3429            ImpactCounts::default(),
3430            Some("sha"),
3431            "2.0.0",
3432            ts,
3433            Some(&input),
3434        );
3435    }
3436
3437    #[test]
3438    fn whole_project_run_does_not_double_credit_after_audit() {
3439        let (_config, dir) = test_env();
3440        let root = dir.path();
3441        enable(root);
3442        let a = touch(root, "src/a.ts");
3443        let b = touch(root, "src/b.ts");
3444        run(
3445            root,
3446            &[&a, &b],
3447            vec![],
3448            vec![clone_at("dup:abc", &[&a, &b])],
3449            &[],
3450            "t1",
3451        );
3452        assert_eq!(clone_fingerprints(&load(root)).len(), 1);
3453
3454        run(root, &[&a, &b], vec![], vec![], &[], "t2");
3455        assert_eq!(load(root).resolved_total, 1);
3456        assert!(load(root).clone_frontier.is_empty());
3457
3458        run_wp(root, vec![], vec![], &[], "t3");
3459        assert_eq!(
3460            load(root).resolved_total,
3461            1,
3462            "whole-project run re-credited a resolution"
3463        );
3464    }
3465
3466    #[test]
3467    fn whole_project_run_credits_suppressed_not_resolved() {
3468        let (_config, dir) = test_env();
3469        let root = dir.path();
3470        enable(root);
3471        let util = touch(root, "src/util.ts");
3472        run(
3473            root,
3474            &[&util],
3475            vec![fi(&util, "unused-export", "dead")],
3476            vec![],
3477            &[],
3478            "t1",
3479        );
3480        assert_eq!(frontier_paths(&load(root)).len(), 1);
3481
3482        run_wp(root, vec![], vec![], &[supp(&util, "unused-export")], "t2");
3483        let store = load(root);
3484        assert_eq!(
3485            store.suppressed_total, 1,
3486            "suppressed finding not counted suppressed"
3487        );
3488        assert_eq!(
3489            store.resolved_total, 0,
3490            "suppressed finding wrongly counted resolved"
3491        );
3492    }
3493
3494    #[test]
3495    fn clone_reshape_three_to_two_not_credited_as_resolved() {
3496        let (_config, dir) = test_env();
3497        let root = dir.path();
3498        enable(root);
3499        let a = touch(root, "src/a.ts");
3500        let b = touch(root, "src/b.ts");
3501        let c = touch(root, "src/c.ts");
3502        run(
3503            root,
3504            &[&a, &b, &c],
3505            vec![],
3506            vec![clone_at("dup:aaa", &[&a, &b, &c])],
3507            &[],
3508            "t1",
3509        );
3510        assert_eq!(clone_fingerprints(&load(root)).len(), 1);
3511
3512        run_wp(
3513            root,
3514            vec![],
3515            vec![clone_at("dup:bbb", &[&a, &b])],
3516            &[],
3517            "t2",
3518        );
3519        let store = load(root);
3520        assert_eq!(
3521            store.resolved_total, 0,
3522            "clone reshape miscredited as resolved"
3523        );
3524        assert!(clone_fingerprints(&store).contains("dup:bbb"));
3525        assert!(!clone_fingerprints(&store).contains("dup:aaa"));
3526    }
3527
3528    fn rcounts(total: usize, dead: usize, complexity: usize, dup: usize) -> ImpactCounts {
3529        ImpactCounts {
3530            total_issues: total,
3531            dead_code: dead,
3532            complexity,
3533            duplication: dup,
3534        }
3535    }
3536
3537    fn rtrend(prev: usize, cur: usize) -> TrendSummary {
3538        TrendSummary {
3539            direction: direction_for(cur as i64 - prev as i64),
3540            total_delta: cur as i64 - prev as i64,
3541            previous_total: prev,
3542            current_total: cur,
3543        }
3544    }
3545
3546    /// Build a report literal for render-state tests.
3547    #[expect(
3548        clippy::too_many_arguments,
3549        reason = "test scaffold; positional ImpactReport builder, bundling adds churn with no production value"
3550    )]
3551    fn rreport(
3552        record_count: usize,
3553        first_recorded: Option<&str>,
3554        surfacing: Option<ImpactCounts>,
3555        trend: Option<TrendSummary>,
3556        project_surfacing: Option<ImpactCounts>,
3557        project_trend: Option<TrendSummary>,
3558        attribution_active: bool,
3559    ) -> ImpactReport {
3560        ImpactReport {
3561            schema_version: ImpactReportSchemaVersion::V1,
3562            enabled: true,
3563            enabled_source: EnabledSource::Project,
3564            record_count,
3565            meta: None,
3566            first_recorded: first_recorded.map(ToOwned::to_owned),
3567            latest_git_sha: None,
3568            surfacing,
3569            trend,
3570            project_surfacing,
3571            project_trend,
3572            containment_count: 0,
3573            recent_containment: vec![],
3574            resolved_total: 0,
3575            suppressed_total: 0,
3576            recent_resolved: vec![],
3577            attribution_active,
3578            onboarding_declined: false,
3579            explicit_decision: false,
3580        }
3581    }
3582
3583    #[test]
3584    fn render_human_project_only_store_shows_whole_project_not_empty_state() {
3585        let r = rreport(
3586            0,
3587            Some("2026-05-30T10:00:00Z"),
3588            None,
3589            None,
3590            Some(rcounts(1, 1, 0, 0)),
3591            None,
3592            true,
3593        );
3594        let human = render_human(&r);
3595        assert!(
3596            human.contains("WHOLE PROJECT (whole-repo context, not a to-do)"),
3597            "project-only must render the labeled section"
3598        );
3599        assert!(human.contains("1 issue across the whole project"));
3600        assert!(
3601            human.contains("project trend starts after your next full `fallow` run"),
3602            "single project record => no trend line, shows the next-run hint"
3603        );
3604        assert!(human.contains("Tracking since 2026-05-30"));
3605        assert!(
3606            !human.contains("No history yet"),
3607            "must not show the empty-state copy"
3608        );
3609        assert!(
3610            !human.contains("LATEST RUN"),
3611            "no changed-file track recorded"
3612        );
3613        assert!(
3614            !human.contains("recorded audit run"),
3615            "no audit runs => no changed-file footer"
3616        );
3617    }
3618
3619    #[test]
3620    fn render_human_both_tracks_label_actionable_vs_context() {
3621        let r = rreport(
3622            3,
3623            Some("2026-05-29T10:00:00Z"),
3624            Some(rcounts(4, 4, 0, 0)),
3625            Some(rtrend(6, 4)),
3626            Some(rcounts(40, 30, 5, 5)),
3627            Some(rtrend(45, 40)),
3628            true,
3629        );
3630        let human = render_human(&r);
3631        let latest = human
3632            .find("LATEST RUN (changed files, act on these now)")
3633            .expect("LATEST RUN labeled actionable");
3634        let whole = human
3635            .find("WHOLE PROJECT (whole-repo context, not a to-do)")
3636            .expect("WHOLE PROJECT labeled context");
3637        assert!(
3638            latest < whole,
3639            "changed-file section renders before whole-project"
3640        );
3641        assert!(human.contains("45 -> 40 (down) across your last two full runs"));
3642        assert!(human.contains("advances only on your local full `fallow` runs, not CI"));
3643    }
3644
3645    #[test]
3646    fn render_markdown_project_only_store_shows_whole_project_not_empty_state() {
3647        let r = rreport(
3648            0,
3649            Some("2026-05-30T10:00:00Z"),
3650            None,
3651            None,
3652            Some(rcounts(1, 1, 0, 0)),
3653            None,
3654            true,
3655        );
3656        let md = render_markdown(&r);
3657        assert!(
3658            md.contains(
3659                "- **Whole project (whole-repo context, last full `fallow` run):** 1 issue"
3660            ),
3661            "project-only md must render the labeled whole-project line"
3662        );
3663        assert!(
3664            !md.contains("No history yet"),
3665            "project-only md must not show empty state"
3666        );
3667        assert!(md.contains("Tracking since 2026-05-30"));
3668    }
3669
3670    #[test]
3671    fn resolve_enabled_precedence_table() {
3672        let (_config, _dir) = test_env();
3673        // enabled-true is an explicit project opt-in regardless of the flag.
3674        let on = ImpactStore {
3675            enabled: true,
3676            ..Default::default()
3677        };
3678        assert_eq!(resolve_enabled(&on), (true, EnabledSource::Project));
3679
3680        // explicitly disabled here stays off as a Project decision.
3681        let off_explicit = ImpactStore {
3682            enabled: false,
3683            explicit_decision: true,
3684            ..Default::default()
3685        };
3686        assert_eq!(
3687            resolve_enabled(&off_explicit),
3688            (false, EnabledSource::Project)
3689        );
3690
3691        // never-asked + no global default => off (Default).
3692        let never = ImpactStore::default();
3693        assert_eq!(resolve_enabled(&never), (false, EnabledSource::Default));
3694
3695        // never-asked + global default on => on (User).
3696        assert!(set_global_default(true));
3697        assert_eq!(resolve_enabled(&never), (true, EnabledSource::User));
3698        // a per-repo disable still wins over the global default.
3699        assert_eq!(
3700            resolve_enabled(&off_explicit),
3701            (false, EnabledSource::Project)
3702        );
3703    }
3704
3705    #[test]
3706    fn human_report_explains_user_global_default() {
3707        let (_config, _dir) = test_env();
3708        set_global_default(true);
3709        // A never-asked store resolved on a project: enabled via the global default.
3710        let report = build_report(&ImpactStore::default());
3711        assert_eq!(report.enabled_source, EnabledSource::User);
3712        let human = render_human(&report);
3713        assert!(
3714            human.contains("Enabled by your user-global default"),
3715            "human report must explain a global-default enable: {human}"
3716        );
3717        // A project-enabled report does NOT show the global-default note.
3718        let project = build_report(&ImpactStore {
3719            enabled: true,
3720            explicit_decision: true,
3721            ..Default::default()
3722        });
3723        assert_eq!(project.enabled_source, EnabledSource::Project);
3724        assert!(!render_human(&project).contains("user-global default"));
3725    }
3726
3727    #[test]
3728    fn global_default_round_trips() {
3729        let (_config, _dir) = test_env();
3730        assert!(!load_global_default());
3731        assert!(set_global_default(true));
3732        assert!(load_global_default());
3733        assert!(!set_global_default(true)); // unchanged
3734        assert!(set_global_default(false));
3735        assert!(!load_global_default());
3736    }
3737
3738    #[test]
3739    fn global_default_records_without_per_repo_enable() {
3740        let (_config, dir) = test_env();
3741        let root = dir.path();
3742        set_global_default(true);
3743        // No `enable(root)` call: the global default alone should activate.
3744        record_v1(
3745            root,
3746            &summary(2, 0, 0),
3747            AuditVerdict::Warn,
3748            false,
3749            None,
3750            "2.0.0",
3751            "t0",
3752        );
3753        let report = build_report(&load(root));
3754        assert!(report.enabled);
3755        assert_eq!(report.enabled_source, EnabledSource::User);
3756        assert_eq!(report.record_count, 1);
3757    }
3758
3759    #[test]
3760    fn legacy_in_repo_store_is_migrated_on_first_load() {
3761        let (_config, dir) = test_env();
3762        let root = dir.path();
3763        // Seed a pre-relocation v3 store with a FLAT frontier in the repo.
3764        let legacy = r#"{"schema_version":3,"enabled":true,"explicit_decision":true,
3765            "records":[{"timestamp":"t0","version":"2.0.0","verdict":"warn","gate":false,
3766            "counts":{"total_issues":1,"dead_code":1,"complexity":0,"duplication":0}}],
3767            "resolved_total":2,
3768            "frontier":{"src/a.ts":{"findings":[{"id":"x","kind":"unused-export","symbol":"foo"}],"suppressions":[]}},
3769            "containment":[]}"#;
3770        std::fs::create_dir_all(root.join(".fallow")).unwrap();
3771        std::fs::write(legacy_store_path(root), legacy).unwrap();
3772
3773        let store = load(root);
3774        assert!(store.enabled);
3775        assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
3776        assert_eq!(store.records.len(), 1);
3777        assert_eq!(store.resolved_total, 2);
3778        // The flat frontier was wrapped under the worktree key (nested v4 shape).
3779        assert!(frontier_paths(&store).contains("src/a.ts"));
3780        // The user store now exists, so a second load does NOT re-import (it
3781        // reads the user store directly).
3782        assert!(store_path(root).is_some_and(|p| p.exists()));
3783        let again = load(root);
3784        assert_eq!(again.records.len(), 1);
3785    }
3786
3787    #[test]
3788    fn reset_removes_only_this_project() {
3789        let (_config, dir) = test_env();
3790        let root = dir.path();
3791        enable(root);
3792        record_v1(
3793            root,
3794            &summary(1, 0, 0),
3795            AuditVerdict::Warn,
3796            false,
3797            None,
3798            "2.0.0",
3799            "t0",
3800        );
3801        assert_eq!(load(root).records.len(), 1);
3802        assert!(reset(root));
3803        assert!(load(root).records.is_empty());
3804        assert!(!reset(root)); // already gone
3805    }
3806
3807    #[test]
3808    fn reset_all_clears_dir_but_keeps_global_default() {
3809        let (_config, dir) = test_env();
3810        let root = dir.path();
3811        set_global_default(true);
3812        enable(root);
3813        assert!(load(root).enabled);
3814        assert!(reset_all());
3815        // The global default toggle survives a data wipe.
3816        assert!(load_global_default());
3817    }
3818
3819    // ----- cross-repo aggregate (`impact --all`) tests --------------------
3820
3821    /// Set an isolated config dir (no project root needed) and return its guard.
3822    fn aggregate_env() -> tempfile::TempDir {
3823        let config = tempfile::tempdir().unwrap();
3824        TEST_CONFIG_DIR.with(|c| *c.borrow_mut() = Some(config.path().to_path_buf()));
3825        config
3826    }
3827
3828    /// Write a store file directly under `<config>/impact/<key>.json`.
3829    fn seed_store(key: &str, store: &ImpactStore) {
3830        let dir = impact_config_dir().unwrap().join("impact");
3831        std::fs::create_dir_all(&dir).unwrap();
3832        std::fs::write(
3833            dir.join(format!("{key}.json")),
3834            serde_json::to_string_pretty(store).unwrap(),
3835        )
3836        .unwrap();
3837    }
3838
3839    fn store_with(
3840        label: &str,
3841        resolved: usize,
3842        contained: usize,
3843        latest_ts: &str,
3844        latest_issues: usize,
3845    ) -> ImpactStore {
3846        let mut s = ImpactStore {
3847            enabled: true,
3848            explicit_decision: true,
3849            resolved_total: resolved,
3850            label: Some(label.to_owned()),
3851            ..Default::default()
3852        };
3853        s.records.push(ImpactRecord {
3854            timestamp: latest_ts.to_owned(),
3855            version: "2.0.0".to_owned(),
3856            git_sha: None,
3857            verdict: "warn".to_owned(),
3858            gate: false,
3859            counts: ImpactCounts::from_combined(latest_issues, 0, 0),
3860        });
3861        for _ in 0..contained {
3862            s.containment.push(ContainmentEvent {
3863                blocked_at: "t0".to_owned(),
3864                cleared_at: "t1".to_owned(),
3865                git_sha: None,
3866                blocked_counts: ImpactCounts::default(),
3867            });
3868        }
3869        s
3870    }
3871
3872    #[test]
3873    fn repo_basename_returns_last_component_only() {
3874        assert_eq!(
3875            repo_basename(Path::new("/a/b/myrepo/.git")).as_deref(),
3876            Some("myrepo")
3877        );
3878        assert_eq!(
3879            repo_basename(Path::new("/a/b/proj")).as_deref(),
3880            Some("proj")
3881        );
3882        // Never a separator in the result.
3883        let name = repo_basename(Path::new("/x/y/z/.git")).unwrap();
3884        assert!(!name.contains('/') && !name.contains('\\'));
3885    }
3886
3887    #[test]
3888    fn aggregate_rolls_up_totals_and_excludes_empty() {
3889        let _cfg = aggregate_env();
3890        seed_store(
3891            "aaa",
3892            &store_with("alpha", 10, 2, "2026-06-10T00:00:00Z", 3),
3893        );
3894        seed_store("bbb", &store_with("beta", 5, 1, "2026-06-11T00:00:00Z", 0));
3895        // enabled-but-empty: no records, no resolved, no containment.
3896        seed_store(
3897            "ccc",
3898            &ImpactStore {
3899                enabled: true,
3900                explicit_decision: true,
3901                label: Some("gamma".into()),
3902                ..Default::default()
3903            },
3904        );
3905        let report = aggregate(CrossRepoSort::Recent);
3906        assert_eq!(report.project_count, 3, "all three stores enumerated");
3907        assert_eq!(report.tracked_count, 2, "empty store excluded from rows");
3908        assert_eq!(report.totals.resolved_total, 15);
3909        assert_eq!(report.totals.containment_count, 3);
3910        assert_eq!(report.unreadable_count, 0);
3911    }
3912
3913    #[test]
3914    fn aggregate_sort_recent_orders_by_last_activity() {
3915        let _cfg = aggregate_env();
3916        seed_store("old", &store_with("older", 1, 0, "2026-06-01T00:00:00Z", 1));
3917        seed_store("new", &store_with("newer", 1, 0, "2026-06-12T00:00:00Z", 1));
3918        let report = aggregate(CrossRepoSort::Recent);
3919        assert_eq!(report.projects[0].label.as_deref(), Some("newer"));
3920        assert_eq!(report.projects[1].label.as_deref(), Some("older"));
3921    }
3922
3923    #[test]
3924    fn cross_repo_json_carries_kind_and_leaks_no_path() {
3925        let _cfg = aggregate_env();
3926        seed_store("aaa", &store_with("alpha", 4, 1, "2026-06-10T00:00:00Z", 2));
3927        let report = aggregate(CrossRepoSort::Recent);
3928        let json = render_cross_repo_json(&report);
3929        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
3930        assert_eq!(value["kind"], "impact-cross-repo");
3931        // No label (or any string) may contain a path separator.
3932        for entry in value["projects"].as_array().unwrap() {
3933            if let Some(label) = entry["label"].as_str() {
3934                assert!(
3935                    !label.contains('/') && !label.contains('\\'),
3936                    "label must be a basename, got {label}"
3937                );
3938            }
3939        }
3940        assert!(
3941            !json.contains('/') || !json.contains("Users"),
3942            "json must not leak an absolute home path"
3943        );
3944    }
3945
3946    #[test]
3947    fn cross_repo_markdown_pluralizes_single_project() {
3948        let _cfg = aggregate_env();
3949        seed_store("solo", &store_with("solo", 3, 1, "2026-06-10T00:00:00Z", 2));
3950        let report = aggregate(CrossRepoSort::Recent);
3951        assert_eq!(report.project_count, 1);
3952        assert_eq!(report.tracked_count, 1);
3953        let md = render_cross_repo_markdown(&report);
3954        assert!(
3955            md.contains("1 project tracked"),
3956            "single project must read 'project', got:\n{md}"
3957        );
3958        assert!(
3959            !md.contains("1 projects tracked"),
3960            "must not pluralize a single project, got:\n{md}"
3961        );
3962        assert!(
3963            md.contains("across 1 tracked project"),
3964            "grand totals must read 'tracked project' (singular), got:\n{md}"
3965        );
3966        assert!(
3967            !md.contains("tracked projects"),
3968            "must not pluralize a single tracked project, got:\n{md}"
3969        );
3970    }
3971
3972    #[test]
3973    fn cross_repo_corrupt_file_is_skipped_and_counted() {
3974        let _cfg = aggregate_env();
3975        seed_store("good", &store_with("good", 3, 0, "2026-06-10T00:00:00Z", 1));
3976        let dir = impact_config_dir().unwrap().join("impact");
3977        std::fs::write(dir.join("bad.json"), b"{ not valid json ][").unwrap();
3978        let report = aggregate(CrossRepoSort::Recent);
3979        assert_eq!(report.tracked_count, 1, "good store still aggregated");
3980        assert_eq!(
3981            report.unreadable_count, 1,
3982            "corrupt file counted, not crashed"
3983        );
3984    }
3985
3986    #[test]
3987    fn cross_repo_empty_dir_is_first_run() {
3988        let _cfg = aggregate_env();
3989        let report = aggregate(CrossRepoSort::Recent);
3990        assert_eq!(report.project_count, 0);
3991        let human = render_cross_repo_human(&report, None);
3992        assert!(human.contains("No projects tracked yet"));
3993    }
3994
3995    #[test]
3996    fn cross_repo_all_corrupt_reports_unreadable_not_first_run() {
3997        let _cfg = aggregate_env();
3998        let dir = impact_config_dir().unwrap().join("impact");
3999        std::fs::create_dir_all(&dir).unwrap();
4000        std::fs::write(dir.join("bad.json"), b"{ broken ][").unwrap();
4001        let report = aggregate(CrossRepoSort::Recent);
4002        assert_eq!(report.project_count, 0);
4003        assert_eq!(report.unreadable_count, 1);
4004        let human = render_cross_repo_human(&report, None);
4005        assert!(
4006            human.contains("unreadable store") && !human.contains("No projects tracked yet"),
4007            "all-corrupt must report unreadable, not a misleading first-run hint: {human}"
4008        );
4009    }
4010
4011    #[test]
4012    fn record_audit_run_captures_basename_label() {
4013        let (_config, dir) = test_env();
4014        let root = dir.path();
4015        enable(root);
4016        record_v1(
4017            root,
4018            &summary(1, 0, 0),
4019            AuditVerdict::Warn,
4020            false,
4021            None,
4022            "2.0.0",
4023            "t0",
4024        );
4025        let label = load(root).label.expect("label captured on record");
4026        assert!(
4027            !label.contains('/') && !label.contains('\\'),
4028            "label must be a basename, got {label}"
4029        );
4030    }
4031
4032    // ----- store advisory lock + age-based GC --------------------------------
4033
4034    #[test]
4035    fn lock_path_appends_lock_suffix() {
4036        assert_eq!(
4037            lock_path_for(Path::new("/c/fallow/impact/abc.json")),
4038            PathBuf::from("/c/fallow/impact/abc.json.lock")
4039        );
4040    }
4041
4042    #[test]
4043    fn store_lock_acquire_drop_then_record_roundtrips() {
4044        let (_config, dir) = test_env();
4045        let root = dir.path();
4046        enable(root);
4047        // Acquiring + dropping the lock around a record must not deadlock and
4048        // the record must persist.
4049        {
4050            let _lock = ImpactStoreLock::acquire(root).expect("lock acquires");
4051        }
4052        record_v1(
4053            root,
4054            &summary(1, 0, 0),
4055            AuditVerdict::Warn,
4056            false,
4057            None,
4058            "2.0.0",
4059            "t0",
4060        );
4061        assert_eq!(load(root).records.len(), 1, "record persisted under lock");
4062        // The lock sidecar lives next to the store and is never the store itself.
4063        let store = store_path(root).unwrap();
4064        assert!(lock_path_for(&store).exists(), "lock sidecar created");
4065        assert!(store.exists(), "store file is distinct from its lock");
4066    }
4067
4068    #[test]
4069    fn sweep_keeps_fresh_and_self_deletes_aged_out() {
4070        let _cfg = aggregate_env();
4071        seed_store("keepme", &store_with("keep", 1, 0, "t0", 1));
4072        seed_store("oldone", &store_with("old", 1, 0, "t0", 1));
4073        // A `.lock` sidecar must survive the sweep (lock-lifecycle invariant).
4074        let lock = impact_config_dir()
4075            .unwrap()
4076            .join("impact")
4077            .join("oldone.json.lock");
4078        std::fs::write(&lock, b"").unwrap();
4079
4080        // max_age = 0 ages out every non-kept store regardless of mtime.
4081        sweep_old_stores("keepme", std::time::Duration::ZERO);
4082
4083        let dir = impact_config_dir().unwrap().join("impact");
4084        assert!(dir.join("keepme.json").exists(), "kept store survives");
4085        assert!(
4086            !dir.join("oldone.json").exists(),
4087            "aged-out store reclaimed"
4088        );
4089        assert!(lock.exists(), "lock sidecar never deleted by the sweep");
4090    }
4091
4092    #[test]
4093    fn sweep_keeps_everything_under_a_large_window() {
4094        let _cfg = aggregate_env();
4095        seed_store("a", &store_with("a", 1, 0, "t0", 1));
4096        seed_store("b", &store_with("b", 1, 0, "t0", 1));
4097        // 10-year window: freshly-written stores are never aged out.
4098        sweep_old_stores("a", std::time::Duration::from_hours(10 * 365 * 24));
4099        let dir = impact_config_dir().unwrap().join("impact");
4100        assert!(dir.join("a.json").exists());
4101        assert!(dir.join("b.json").exists());
4102    }
4103
4104    // ----- LegacyFlatStore::into_store with empty frontiers -----------------
4105
4106    #[test]
4107    #[cfg_attr(miri, ignore)]
4108    fn legacy_into_store_with_empty_frontiers_does_not_insert_worktree_key() {
4109        // When a legacy store has no frontier or clone_frontier entries, the
4110        // resulting v4 store must not insert empty sub-maps for the worktree key.
4111        let legacy = LegacyFlatStore {
4112            enabled: true,
4113            explicit_decision: true,
4114            first_recorded: Some("t0".to_owned()),
4115            records: vec![],
4116            project_records: vec![],
4117            containment: vec![],
4118            pending_containment: None,
4119            frontier: FxHashMap::default(),
4120            clone_frontier: FxHashMap::default(),
4121            resolved_total: 0,
4122            suppressed_total: 0,
4123            recent_resolved: vec![],
4124            onboarding_declined: false,
4125            last_digest_epoch: None,
4126        };
4127        let store = legacy.into_store("wt-key");
4128        assert!(
4129            store.frontier.is_empty(),
4130            "empty legacy frontier must not insert a worktree key"
4131        );
4132        assert!(
4133            store.clone_frontier.is_empty(),
4134            "empty legacy clone_frontier must not insert a worktree key"
4135        );
4136        assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
4137        assert!(store.label.is_none(), "label is always None on migration");
4138    }
4139
4140    // ----- repo_basename edge case: path with no parent ---------------------
4141
4142    #[test]
4143    fn repo_basename_non_git_path_returns_last_component() {
4144        // A non-.git directory name returns its own basename.
4145        assert_eq!(
4146            repo_basename(Path::new("/a/b/myproject")).as_deref(),
4147            Some("myproject")
4148        );
4149    }
4150
4151    #[test]
4152    fn repo_basename_root_path_returns_none() {
4153        // A root-only path has no file_name; must return None, not panic.
4154        assert!(repo_basename(Path::new("/")).is_none());
4155    }
4156
4157    // ----- direction_for: stable and declining branches ---------------------
4158
4159    #[test]
4160    fn direction_for_declining_when_delta_positive() {
4161        assert_eq!(direction_for(1), ImpactTrendDirection::Declining);
4162        assert_eq!(direction_for(100), ImpactTrendDirection::Declining);
4163    }
4164
4165    #[test]
4166    fn direction_for_stable_when_delta_zero() {
4167        assert_eq!(direction_for(0), ImpactTrendDirection::Stable);
4168    }
4169
4170    #[test]
4171    fn direction_for_improving_when_delta_negative() {
4172        assert_eq!(direction_for(-1), ImpactTrendDirection::Improving);
4173    }
4174
4175    // ----- trend_arrow covers all three directions -------------------------
4176
4177    #[test]
4178    fn trend_arrow_all_directions() {
4179        assert_eq!(trend_arrow(ImpactTrendDirection::Improving), "down");
4180        assert_eq!(trend_arrow(ImpactTrendDirection::Declining), "up");
4181        assert_eq!(trend_arrow(ImpactTrendDirection::Stable), "flat");
4182    }
4183
4184    // ----- build_report project_records trend is populated -----------------
4185
4186    #[test]
4187    fn build_report_project_trend_populated_from_project_records() {
4188        let mut store = ImpactStore {
4189            enabled: true,
4190            ..Default::default()
4191        };
4192        for total in [20usize, 15usize] {
4193            store.project_records.push(ImpactRecord {
4194                timestamp: format!("t{total}"),
4195                version: "2.0.0".into(),
4196                git_sha: None,
4197                verdict: "warn".into(),
4198                gate: false,
4199                counts: ImpactCounts {
4200                    total_issues: total,
4201                    dead_code: total,
4202                    complexity: 0,
4203                    duplication: 0,
4204                },
4205            });
4206        }
4207        let report = build_report(&store);
4208        let pt = report
4209            .project_trend
4210            .expect("two project records yield a trend");
4211        assert_eq!(pt.direction, ImpactTrendDirection::Improving);
4212        assert_eq!(pt.previous_total, 20);
4213        assert_eq!(pt.current_total, 15);
4214        assert_eq!(pt.total_delta, -5);
4215    }
4216
4217    // ----- latest_activity selects the max timestamp across both series -----
4218
4219    #[test]
4220    fn latest_activity_both_series_returns_newer() {
4221        let mut store = ImpactStore::default();
4222        store.records.push(ImpactRecord {
4223            timestamp: "2026-01-01T00:00:00Z".to_owned(),
4224            version: "2.0.0".to_owned(),
4225            git_sha: None,
4226            verdict: "warn".to_owned(),
4227            gate: false,
4228            counts: ImpactCounts::default(),
4229        });
4230        store.project_records.push(ImpactRecord {
4231            timestamp: "2026-06-15T00:00:00Z".to_owned(),
4232            version: "2.0.0".to_owned(),
4233            git_sha: None,
4234            verdict: "warn".to_owned(),
4235            gate: false,
4236            counts: ImpactCounts::default(),
4237        });
4238        assert_eq!(
4239            latest_activity(&store).as_deref(),
4240            Some("2026-06-15T00:00:00Z")
4241        );
4242    }
4243
4244    #[test]
4245    fn latest_activity_only_project_records() {
4246        let mut store = ImpactStore::default();
4247        store.project_records.push(ImpactRecord {
4248            timestamp: "2026-05-01T00:00:00Z".to_owned(),
4249            version: "2.0.0".to_owned(),
4250            git_sha: None,
4251            verdict: "pass".to_owned(),
4252            gate: false,
4253            counts: ImpactCounts::default(),
4254        });
4255        assert_eq!(
4256            latest_activity(&store).as_deref(),
4257            Some("2026-05-01T00:00:00Z")
4258        );
4259    }
4260
4261    #[test]
4262    fn latest_activity_empty_store_returns_none() {
4263        assert!(latest_activity(&ImpactStore::default()).is_none());
4264    }
4265
4266    // ----- apply_containment: containment overflow capped at MAX_CONTAINMENT --
4267
4268    #[test]
4269    #[cfg_attr(miri, ignore)]
4270    fn containment_events_are_bounded_at_max_containment() {
4271        let (_config, dir) = test_env();
4272        let root = dir.path();
4273        enable(root);
4274        // Directly pre-fill the store with MAX_CONTAINMENT events.
4275        let mut store = load(root);
4276        for i in 0..MAX_CONTAINMENT {
4277            store.containment.push(ContainmentEvent {
4278                blocked_at: format!("block{i}"),
4279                cleared_at: format!("clear{i}"),
4280                git_sha: None,
4281                blocked_counts: ImpactCounts::default(),
4282            });
4283        }
4284        save(&store, root);
4285
4286        // Trigger one more containment cycle (fail then pass).
4287        record_v1(
4288            root,
4289            &summary(1, 0, 0),
4290            AuditVerdict::Fail,
4291            true,
4292            None,
4293            "2.0.0",
4294            "overflow_block",
4295        );
4296        record_v1(
4297            root,
4298            &summary(0, 0, 0),
4299            AuditVerdict::Pass,
4300            true,
4301            None,
4302            "2.0.0",
4303            "overflow_clear",
4304        );
4305        let store = load(root);
4306        assert_eq!(
4307            store.containment.len(),
4308            MAX_CONTAINMENT,
4309            "containment must be capped at MAX_CONTAINMENT"
4310        );
4311        // The most recent event is at the end.
4312        assert_eq!(
4313            store.containment.last().unwrap().blocked_at,
4314            "overflow_block"
4315        );
4316    }
4317
4318    // ----- disable from enabled state sets schema_version ------------------
4319
4320    #[test]
4321    #[cfg_attr(miri, ignore)]
4322    fn disable_from_enabled_state_sets_schema_version() {
4323        let (_config, dir) = test_env();
4324        let root = dir.path();
4325        enable(root);
4326        let was_newly_disabled = disable(root);
4327        assert!(
4328            was_newly_disabled,
4329            "disable returns true when previously enabled"
4330        );
4331        let store = load(root);
4332        assert!(!store.enabled);
4333        assert!(store.explicit_decision);
4334        assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
4335
4336        // A second disable is a no-op (already off).
4337        let again = disable(root);
4338        assert!(!again, "disable returns false when already off");
4339    }
4340
4341    // ----- record_combined_run: CI gate and project_records bounded ---------
4342
4343    #[test]
4344    #[cfg_attr(miri, ignore)]
4345    fn record_combined_run_is_noop_in_ci() {
4346        let (_config, dir) = test_env();
4347        let root = dir.path();
4348        enable(root);
4349        TEST_FORCE_CI.with(|c| c.set(true));
4350        record_combined_run(
4351            root,
4352            ImpactCounts::from_combined(5, 0, 0),
4353            None,
4354            "2.0.0",
4355            "t0",
4356            None,
4357        );
4358        TEST_FORCE_CI.with(|c| c.set(false));
4359        assert!(
4360            load(root).project_records.is_empty(),
4361            "combined run must not record on CI"
4362        );
4363    }
4364
4365    #[test]
4366    #[cfg_attr(miri, ignore)]
4367    fn record_combined_run_is_noop_when_disabled() {
4368        let (_config, dir) = test_env();
4369        let root = dir.path();
4370        // store is disabled by default; no enable() call.
4371        record_combined_run(
4372            root,
4373            ImpactCounts::from_combined(3, 0, 0),
4374            None,
4375            "2.0.0",
4376            "t0",
4377            None,
4378        );
4379        assert!(
4380            load(root).project_records.is_empty(),
4381            "combined run must not record when disabled"
4382        );
4383    }
4384
4385    #[test]
4386    #[cfg_attr(miri, ignore)]
4387    fn record_combined_run_project_records_bounded_at_max() {
4388        let (_config, dir) = test_env();
4389        let root = dir.path();
4390        enable(root);
4391        // Pre-fill to MAX_RECORDS.
4392        let mut store = load(root);
4393        for i in 0..MAX_RECORDS {
4394            store.project_records.push(ImpactRecord {
4395                timestamp: format!("t{i}"),
4396                version: "2.0.0".to_owned(),
4397                git_sha: None,
4398                verdict: "warn".to_owned(),
4399                gate: false,
4400                counts: ImpactCounts::default(),
4401            });
4402        }
4403        save(&store, root);
4404
4405        record_combined_run(
4406            root,
4407            ImpactCounts::from_combined(1, 0, 0),
4408            None,
4409            "2.0.0",
4410            "overflow",
4411            None,
4412        );
4413        let store = load(root);
4414        assert_eq!(
4415            store.project_records.len(),
4416            MAX_RECORDS,
4417            "project_records must be capped at MAX_RECORDS"
4418        );
4419        assert_eq!(
4420            store.project_records.last().unwrap().timestamp,
4421            "overflow",
4422            "newest record must be at the tail"
4423        );
4424    }
4425
4426    // ----- clone_dup_suppressed: clone disappearance with suppression -------
4427
4428    #[test]
4429    #[cfg_attr(miri, ignore)]
4430    fn suppressed_clone_disappearance_credits_suppressed_not_resolved() {
4431        let (_config, dir) = test_env();
4432        let root = dir.path();
4433        enable(root);
4434        let a = touch(root, "src/a.ts");
4435        let b = touch(root, "src/b.ts");
4436        let clone = CloneInput {
4437            fingerprint: "dup:suppressed".to_owned(),
4438            instance_paths: vec![a.clone(), b.clone()],
4439        };
4440        run(root, &[&a, &b], vec![], vec![clone], &[], "t0");
4441        // Second run: clone disappears AND a blanket suppression appears on `a`.
4442        let blanket = ActiveSuppression {
4443            path: a.clone(),
4444            kind: None,
4445            is_file_level: true,
4446            reason: None,
4447            comment_line: 1,
4448        };
4449        run(root, &[&a, &b], vec![], vec![], &[blanket], "t1");
4450        let store = load(root);
4451        assert_eq!(
4452            store.suppressed_total, 1,
4453            "suppressed clone disappearance must count as suppressed"
4454        );
4455        assert_eq!(
4456            store.resolved_total, 0,
4457            "suppressed clone must not count as resolved"
4458        );
4459    }
4460
4461    // ----- load_all skips .lock and non-.json files -------------------------
4462
4463    #[test]
4464    #[cfg_attr(miri, ignore)]
4465    fn load_all_skips_non_json_and_lock_files() {
4466        let _cfg = aggregate_env();
4467        seed_store("real", &store_with("real", 2, 0, "2026-06-10T00:00:00Z", 1));
4468        let dir = impact_config_dir().unwrap().join("impact");
4469        // Write a .lock sidecar and a non-json file that should both be ignored.
4470        std::fs::write(dir.join("real.json.lock"), b"").unwrap();
4471        std::fs::write(dir.join("notes.txt"), b"ignored").unwrap();
4472        let (stores, unreadable) = load_all();
4473        assert_eq!(stores.len(), 1, "only the .json store is loaded");
4474        assert_eq!(
4475            unreadable, 0,
4476            "lock and txt files are not counted unreadable"
4477        );
4478    }
4479
4480    // ----- build_aggregate_report: project_wide_issues totalling ------------
4481
4482    #[test]
4483    fn aggregate_totals_project_wide_issues_from_project_surfacing() {
4484        // Build stores directly (no fs involvement for this pure test).
4485        let mut s1 = ImpactStore {
4486            enabled: true,
4487            explicit_decision: true,
4488            resolved_total: 3,
4489            ..Default::default()
4490        };
4491        s1.records.push(ImpactRecord {
4492            timestamp: "t1".to_owned(),
4493            version: "2.0.0".to_owned(),
4494            git_sha: None,
4495            verdict: "warn".to_owned(),
4496            gate: false,
4497            counts: ImpactCounts::from_combined(5, 0, 0),
4498        });
4499        // Add a project_records entry so project_surfacing is non-None.
4500        s1.project_records.push(ImpactRecord {
4501            timestamp: "pt1".to_owned(),
4502            version: "2.0.0".to_owned(),
4503            git_sha: None,
4504            verdict: "warn".to_owned(),
4505            gate: false,
4506            counts: ImpactCounts::from_combined(20, 0, 0),
4507        });
4508
4509        let mut s2 = ImpactStore {
4510            enabled: true,
4511            explicit_decision: true,
4512            resolved_total: 7,
4513            ..Default::default()
4514        };
4515        s2.records.push(ImpactRecord {
4516            timestamp: "t2".to_owned(),
4517            version: "2.0.0".to_owned(),
4518            git_sha: None,
4519            verdict: "warn".to_owned(),
4520            gate: false,
4521            counts: ImpactCounts::from_combined(2, 0, 0),
4522        });
4523        s2.project_records.push(ImpactRecord {
4524            timestamp: "pt2".to_owned(),
4525            version: "2.0.0".to_owned(),
4526            git_sha: None,
4527            verdict: "warn".to_owned(),
4528            gate: false,
4529            counts: ImpactCounts::from_combined(30, 0, 0),
4530        });
4531
4532        let report = build_aggregate_report(
4533            vec![("k1".to_owned(), s1), ("k2".to_owned(), s2)],
4534            0,
4535            CrossRepoSort::Recent,
4536        );
4537        assert_eq!(report.totals.resolved_total, 10);
4538        assert_eq!(report.totals.project_wide_issues, 50);
4539        assert_eq!(report.totals.projects_with_baseline, 2);
4540    }
4541
4542    // ----- sort_cross_repo: all sort variants --------------------------------
4543
4544    #[test]
4545    fn aggregate_sort_resolved_orders_by_resolved_total() {
4546        let _cfg = aggregate_env();
4547        seed_store("low", &store_with("low", 1, 0, "2026-06-10T00:00:00Z", 1));
4548        seed_store("high", &store_with("high", 9, 0, "2026-06-09T00:00:00Z", 1));
4549        let report = aggregate(CrossRepoSort::Resolved);
4550        assert_eq!(report.projects[0].label.as_deref(), Some("high"));
4551        assert_eq!(report.projects[1].label.as_deref(), Some("low"));
4552    }
4553
4554    #[test]
4555    fn aggregate_sort_contained_orders_by_containment_count() {
4556        let _cfg = aggregate_env();
4557        seed_store("none", &store_with("none", 1, 0, "2026-06-10T00:00:00Z", 1));
4558        seed_store("many", &store_with("many", 1, 3, "2026-06-09T00:00:00Z", 1));
4559        let report = aggregate(CrossRepoSort::Contained);
4560        assert_eq!(report.projects[0].label.as_deref(), Some("many"));
4561        assert_eq!(report.projects[1].label.as_deref(), Some("none"));
4562    }
4563
4564    #[test]
4565    fn aggregate_sort_name_orders_alphabetically_by_label() {
4566        let _cfg = aggregate_env();
4567        seed_store("zzz", &store_with("zulu", 1, 0, "2026-06-10T00:00:00Z", 1));
4568        seed_store("aaa", &store_with("alpha", 1, 0, "2026-06-09T00:00:00Z", 1));
4569        let report = aggregate(CrossRepoSort::Name);
4570        assert_eq!(report.projects[0].label.as_deref(), Some("alpha"));
4571        assert_eq!(report.projects[1].label.as_deref(), Some("zulu"));
4572    }
4573
4574    // ----- render_cross_repo_human: limit and overflow line -----------------
4575
4576    #[test]
4577    fn render_cross_repo_human_limit_shows_overflow_line() {
4578        let _cfg = aggregate_env();
4579        seed_store("a", &store_with("alpha", 1, 0, "2026-06-10T00:00:00Z", 1));
4580        seed_store("b", &store_with("beta", 2, 0, "2026-06-09T00:00:00Z", 1));
4581        seed_store("c", &store_with("gamma", 3, 0, "2026-06-08T00:00:00Z", 1));
4582        let report = aggregate(CrossRepoSort::Recent);
4583        let human = render_cross_repo_human(&report, Some(2));
4584        assert!(
4585            human.contains("and 1 more"),
4586            "overflow line missing when limit < tracked_count: {human}"
4587        );
4588    }
4589
4590    #[test]
4591    fn render_cross_repo_human_no_limit_shows_all_rows() {
4592        let _cfg = aggregate_env();
4593        seed_store("a", &store_with("alpha", 1, 0, "2026-06-10T00:00:00Z", 1));
4594        seed_store("b", &store_with("beta", 2, 0, "2026-06-09T00:00:00Z", 1));
4595        let report = aggregate(CrossRepoSort::Recent);
4596        let human = render_cross_repo_human(&report, None);
4597        assert!(
4598            !human.contains("more (raise --limit"),
4599            "no limit => no overflow line: {human}"
4600        );
4601        assert!(human.contains("alpha"));
4602        assert!(human.contains("beta"));
4603    }
4604
4605    // ----- render_cross_repo_human: skipped (no-history) and unreadable ----
4606
4607    #[test]
4608    fn render_cross_repo_human_shows_no_history_count() {
4609        let _cfg = aggregate_env();
4610        seed_store(
4611            "empty",
4612            &ImpactStore {
4613                enabled: true,
4614                explicit_decision: true,
4615                ..Default::default()
4616            },
4617        );
4618        seed_store("full", &store_with("full", 1, 0, "t0", 1));
4619        let report = aggregate(CrossRepoSort::Recent);
4620        let human = render_cross_repo_human(&report, None);
4621        assert!(
4622            human.contains("tracked project") && human.contains("no history yet"),
4623            "must report the no-history count: {human}"
4624        );
4625    }
4626
4627    #[test]
4628    fn render_cross_repo_human_shows_skipped_unreadable() {
4629        let _cfg = aggregate_env();
4630        seed_store("good", &store_with("good", 1, 0, "t0", 1));
4631        let dir = impact_config_dir().unwrap().join("impact");
4632        std::fs::write(dir.join("corrupt.json"), b"}{broken").unwrap();
4633        let report = aggregate(CrossRepoSort::Recent);
4634        let human = render_cross_repo_human(&report, None);
4635        assert!(
4636            human.contains("skipped") && human.contains("unreadable store"),
4637            "must show the skipped unreadable count: {human}"
4638        );
4639    }
4640
4641    // ----- render_cross_repo_totals: project_wide line ----------------------
4642
4643    #[test]
4644    fn render_cross_repo_human_grand_totals_shows_project_wide_when_present() {
4645        let _cfg = aggregate_env();
4646        let mut s = store_with("proj", 5, 1, "2026-06-10T00:00:00Z", 4);
4647        // Add a project_records entry so project_surfacing is populated.
4648        s.project_records.push(ImpactRecord {
4649            timestamp: "pt".to_owned(),
4650            version: "2.0.0".to_owned(),
4651            git_sha: None,
4652            verdict: "warn".to_owned(),
4653            gate: false,
4654            counts: ImpactCounts::from_combined(42, 0, 0),
4655        });
4656        seed_store("proj", &s);
4657        let report = aggregate(CrossRepoSort::Recent);
4658        let human = render_cross_repo_human(&report, None);
4659        assert!(
4660            human.contains("42 issue") && human.contains("project-wide"),
4661            "grand totals must include the project-wide line: {human}"
4662        );
4663    }
4664
4665    // ----- render_human_resolved_section: resolved events without symbol ----
4666
4667    #[test]
4668    fn render_human_resolved_event_without_symbol_omits_symbol() {
4669        let report = ImpactReport {
4670            schema_version: ImpactReportSchemaVersion::V1,
4671            enabled: true,
4672            enabled_source: EnabledSource::Project,
4673            record_count: 2,
4674            meta: None,
4675            first_recorded: Some("2026-05-01T00:00:00Z".into()),
4676            latest_git_sha: None,
4677            surfacing: Some(ImpactCounts::default()),
4678            trend: None,
4679            project_surfacing: None,
4680            project_trend: None,
4681            containment_count: 0,
4682            recent_containment: vec![],
4683            resolved_total: 1,
4684            suppressed_total: 0,
4685            recent_resolved: vec![ResolutionEvent {
4686                kind: "unused-file".to_owned(),
4687                path: "src/dead.ts".to_owned(),
4688                symbol: None,
4689                git_sha: None,
4690                timestamp: "t1".to_owned(),
4691            }],
4692            attribution_active: true,
4693            onboarding_declined: false,
4694            explicit_decision: true,
4695        };
4696        let human = render_human(&report);
4697        // Resolved event without a symbol prints "kind in path", never "None".
4698        assert!(
4699            human.contains("unused-file in src/dead.ts"),
4700            "no-symbol event must show 'kind in path': {human}"
4701        );
4702        assert!(!human.contains("None"), "must not stringify None: {human}");
4703    }
4704
4705    // ----- render_markdown: disabled state and enabled with trend -----------
4706
4707    #[test]
4708    fn render_markdown_disabled_shows_enable_hint() {
4709        let report = ImpactReport {
4710            schema_version: ImpactReportSchemaVersion::V1,
4711            enabled: false,
4712            enabled_source: EnabledSource::Default,
4713            record_count: 0,
4714            meta: None,
4715            first_recorded: None,
4716            latest_git_sha: None,
4717            surfacing: None,
4718            trend: None,
4719            project_surfacing: None,
4720            project_trend: None,
4721            containment_count: 0,
4722            recent_containment: vec![],
4723            resolved_total: 0,
4724            suppressed_total: 0,
4725            recent_resolved: vec![],
4726            attribution_active: false,
4727            onboarding_declined: false,
4728            explicit_decision: false,
4729        };
4730        let md = render_markdown(&report);
4731        assert!(
4732            md.contains("Impact tracking is off"),
4733            "disabled markdown must show enable hint: {md}"
4734        );
4735        assert!(md.contains("fallow impact enable"));
4736    }
4737
4738    #[test]
4739    fn render_markdown_with_trend_shows_trend_line() {
4740        let report = ImpactReport {
4741            schema_version: ImpactReportSchemaVersion::V1,
4742            enabled: true,
4743            enabled_source: EnabledSource::Project,
4744            record_count: 2,
4745            meta: None,
4746            first_recorded: Some("2026-05-01T00:00:00Z".into()),
4747            latest_git_sha: None,
4748            surfacing: Some(ImpactCounts::from_combined(3, 3, 0)),
4749            trend: Some(TrendSummary {
4750                direction: ImpactTrendDirection::Declining,
4751                total_delta: 2,
4752                previous_total: 1,
4753                current_total: 3,
4754            }),
4755            project_surfacing: None,
4756            project_trend: None,
4757            containment_count: 0,
4758            recent_containment: vec![],
4759            resolved_total: 0,
4760            suppressed_total: 0,
4761            recent_resolved: vec![],
4762            attribution_active: false,
4763            onboarding_declined: false,
4764            explicit_decision: true,
4765        };
4766        let md = render_markdown(&report);
4767        assert!(
4768            md.contains("Trend (changed-file scope"),
4769            "markdown with trend must show trend line: {md}"
4770        );
4771        assert!(md.contains("1 -> 3 (up)"), "trend values present: {md}");
4772    }
4773
4774    #[test]
4775    fn render_markdown_with_project_trend_shows_project_trend_line() {
4776        let report = ImpactReport {
4777            schema_version: ImpactReportSchemaVersion::V1,
4778            enabled: true,
4779            enabled_source: EnabledSource::Project,
4780            record_count: 1,
4781            meta: None,
4782            first_recorded: Some("2026-05-01T00:00:00Z".into()),
4783            latest_git_sha: None,
4784            surfacing: None,
4785            trend: None,
4786            project_surfacing: Some(ImpactCounts::from_combined(10, 5, 3)),
4787            project_trend: Some(TrendSummary {
4788                direction: ImpactTrendDirection::Stable,
4789                total_delta: 0,
4790                previous_total: 10,
4791                current_total: 10,
4792            }),
4793            containment_count: 0,
4794            recent_containment: vec![],
4795            resolved_total: 0,
4796            suppressed_total: 0,
4797            recent_resolved: vec![],
4798            attribution_active: false,
4799            onboarding_declined: false,
4800            explicit_decision: true,
4801        };
4802        let md = render_markdown(&report);
4803        assert!(
4804            md.contains("Project trend (whole project"),
4805            "project trend line must appear in markdown: {md}"
4806        );
4807        assert!(
4808            md.contains("10 -> 10 (flat)"),
4809            "stable trend must read 'flat': {md}"
4810        );
4811    }
4812
4813    #[test]
4814    fn render_markdown_enabled_no_history_shows_check_back() {
4815        let report = ImpactReport {
4816            schema_version: ImpactReportSchemaVersion::V1,
4817            enabled: true,
4818            enabled_source: EnabledSource::Project,
4819            record_count: 0,
4820            meta: None,
4821            first_recorded: None,
4822            latest_git_sha: None,
4823            surfacing: None,
4824            trend: None,
4825            project_surfacing: None,
4826            project_trend: None,
4827            containment_count: 0,
4828            recent_containment: vec![],
4829            resolved_total: 0,
4830            suppressed_total: 0,
4831            recent_resolved: vec![],
4832            attribution_active: false,
4833            onboarding_declined: false,
4834            explicit_decision: true,
4835        };
4836        let md = render_markdown(&report);
4837        assert!(
4838            md.contains("No history yet"),
4839            "enabled but empty markdown must show 'No history yet': {md}"
4840        );
4841    }
4842
4843    #[test]
4844    fn render_markdown_resolved_shows_count_when_positive() {
4845        let report = ImpactReport {
4846            schema_version: ImpactReportSchemaVersion::V1,
4847            enabled: true,
4848            enabled_source: EnabledSource::Project,
4849            record_count: 3,
4850            meta: None,
4851            first_recorded: Some("2026-05-01T00:00:00Z".into()),
4852            latest_git_sha: None,
4853            surfacing: Some(ImpactCounts::default()),
4854            trend: None,
4855            project_surfacing: None,
4856            project_trend: None,
4857            containment_count: 0,
4858            recent_containment: vec![],
4859            resolved_total: 4,
4860            suppressed_total: 1,
4861            recent_resolved: vec![],
4862            attribution_active: true,
4863            onboarding_declined: false,
4864            explicit_decision: true,
4865        };
4866        let md = render_markdown(&report);
4867        assert!(
4868            md.contains("**Resolved:** 4 finding"),
4869            "markdown must show resolved count: {md}"
4870        );
4871        assert!(
4872            md.contains("**Marked intentional:** 1 finding"),
4873            "markdown must show suppressed count: {md}"
4874        );
4875    }
4876
4877    // ----- render_markdown_footer: project-only (no audit records) ----------
4878
4879    #[test]
4880    fn render_markdown_footer_project_only_no_audit_records() {
4881        // When record_count is 0 but project_surfacing exists, the footer
4882        // must show the "Tracking since" form, not "recorded audit runs".
4883        let report = ImpactReport {
4884            schema_version: ImpactReportSchemaVersion::V1,
4885            enabled: true,
4886            enabled_source: EnabledSource::Project,
4887            record_count: 0,
4888            meta: None,
4889            first_recorded: Some("2026-05-10T00:00:00Z".into()),
4890            latest_git_sha: None,
4891            surfacing: None,
4892            trend: None,
4893            project_surfacing: Some(ImpactCounts::from_combined(3, 2, 1)),
4894            project_trend: None,
4895            containment_count: 0,
4896            recent_containment: vec![],
4897            resolved_total: 0,
4898            suppressed_total: 0,
4899            recent_resolved: vec![],
4900            attribution_active: false,
4901            onboarding_declined: false,
4902            explicit_decision: true,
4903        };
4904        let md = render_markdown(&report);
4905        assert!(
4906            md.contains("Tracking since 2026-05-10"),
4907            "project-only footer must say 'Tracking since': {md}"
4908        );
4909        assert!(
4910            !md.contains("recorded audit run"),
4911            "project-only footer must not mention 'recorded audit run': {md}"
4912        );
4913    }
4914
4915    // ----- cross_repo_markdown: project_wide totals line --------------------
4916
4917    #[test]
4918    fn render_cross_repo_markdown_includes_project_wide_totals_when_present() {
4919        let _cfg = aggregate_env();
4920        let mut s = store_with("proj", 2, 0, "t0", 1);
4921        s.project_records.push(ImpactRecord {
4922            timestamp: "pt".to_owned(),
4923            version: "2.0.0".to_owned(),
4924            git_sha: None,
4925            verdict: "warn".to_owned(),
4926            gate: false,
4927            counts: ImpactCounts::from_combined(99, 0, 0),
4928        });
4929        seed_store("proj", &s);
4930        let report = aggregate(CrossRepoSort::Recent);
4931        let md = render_cross_repo_markdown(&report);
4932        assert!(
4933            md.contains("99 issue") && md.contains("project-wide"),
4934            "cross-repo markdown must show project-wide totals: {md}"
4935        );
4936    }
4937
4938    // ----- migrate_legacy_store: corrupt legacy file falls back to default ---
4939
4940    #[test]
4941    #[cfg_attr(miri, ignore)]
4942    fn migrate_legacy_store_corrupt_file_returns_default() {
4943        let (_config, dir) = test_env();
4944        let root = dir.path();
4945        // Write a corrupt legacy in-repo store.
4946        std::fs::create_dir_all(root.join(".fallow")).unwrap();
4947        std::fs::write(legacy_store_path(root), b"{ corrupted json ][").unwrap();
4948        // load() tries the user store (missing) then migrate_legacy_store.
4949        let store = load(root);
4950        // A corrupt legacy file must return a default store without panicking.
4951        assert!(!store.enabled);
4952        assert!(store.records.is_empty());
4953    }
4954
4955    // ----- resolve_enabled: user source propagates to report ----------------
4956
4957    #[test]
4958    fn resolve_enabled_user_source_appears_in_report() {
4959        let (_config, _dir) = test_env();
4960        set_global_default(true);
4961        let never_asked = ImpactStore::default();
4962        let (enabled, source) = resolve_enabled(&never_asked);
4963        assert!(enabled);
4964        assert_eq!(source, EnabledSource::User);
4965        let report = build_report(&never_asked);
4966        assert_eq!(report.enabled_source, EnabledSource::User);
4967    }
4968
4969    // ----- render_cross_repo_human: long label truncated --------------------
4970
4971    #[test]
4972    fn render_cross_repo_human_long_label_is_truncated_in_table() {
4973        let _cfg = aggregate_env();
4974        let very_long_name = "this_is_a_very_long_project_name_that_exceeds_the_column_width";
4975        let s = store_with(very_long_name, 1, 0, "2026-06-10T00:00:00Z", 1);
4976        seed_store("longkey", &s);
4977        let report = aggregate(CrossRepoSort::Recent);
4978        let human = render_cross_repo_human(&report, None);
4979        // Must contain the truncation marker and never the full long name inline.
4980        assert!(
4981            human.contains("..."),
4982            "long label must be truncated with '...': {human}"
4983        );
4984    }
4985
4986    // ----- aggregate_sort_name when label is absent: falls back to short key -
4987
4988    #[test]
4989    fn aggregate_sort_name_falls_back_to_short_key_when_no_label() {
4990        let _cfg = aggregate_env();
4991        // Store with no label; cross_repo_label falls back to the key prefix.
4992        let mut s = ImpactStore {
4993            enabled: true,
4994            explicit_decision: true,
4995            resolved_total: 1,
4996            label: None,
4997            ..Default::default()
4998        };
4999        s.records.push(ImpactRecord {
5000            timestamp: "t0".to_owned(),
5001            version: "2.0.0".to_owned(),
5002            git_sha: None,
5003            verdict: "warn".to_owned(),
5004            gate: false,
5005            counts: ImpactCounts::from_combined(1, 0, 0),
5006        });
5007        seed_store("abcdefghijklmnop", &s);
5008        let report = aggregate(CrossRepoSort::Name);
5009        // The row is present even without a label.
5010        assert_eq!(report.tracked_count, 1);
5011        // The short_key used is the first 12 chars of the key.
5012        assert_eq!(report.projects[0].project_key, "abcdefghijklmnop");
5013    }
5014
5015    // ----- row_trend fallback to changed-file trend when no project trend ---
5016
5017    #[test]
5018    fn row_trend_falls_back_to_changed_file_trend_when_no_project_trend() {
5019        let report = ImpactReport {
5020            schema_version: ImpactReportSchemaVersion::V1,
5021            enabled: true,
5022            enabled_source: EnabledSource::Project,
5023            record_count: 2,
5024            meta: None,
5025            first_recorded: None,
5026            latest_git_sha: None,
5027            surfacing: Some(ImpactCounts::default()),
5028            trend: Some(TrendSummary {
5029                direction: ImpactTrendDirection::Improving,
5030                total_delta: -3,
5031                previous_total: 8,
5032                current_total: 5,
5033            }),
5034            project_surfacing: None,
5035            project_trend: None,
5036            containment_count: 0,
5037            recent_containment: vec![],
5038            resolved_total: 0,
5039            suppressed_total: 0,
5040            recent_resolved: vec![],
5041            attribution_active: false,
5042            onboarding_declined: false,
5043            explicit_decision: true,
5044        };
5045        assert_eq!(row_trend(&report), "down");
5046    }
5047
5048    #[test]
5049    fn row_trend_returns_dash_when_no_trend_at_all() {
5050        let report = ImpactReport {
5051            schema_version: ImpactReportSchemaVersion::V1,
5052            enabled: true,
5053            enabled_source: EnabledSource::Project,
5054            record_count: 1,
5055            meta: None,
5056            first_recorded: None,
5057            latest_git_sha: None,
5058            surfacing: Some(ImpactCounts::default()),
5059            trend: None,
5060            project_surfacing: None,
5061            project_trend: None,
5062            containment_count: 0,
5063            recent_containment: vec![],
5064            resolved_total: 0,
5065            suppressed_total: 0,
5066            recent_resolved: vec![],
5067            attribution_active: false,
5068            onboarding_declined: false,
5069            explicit_decision: true,
5070        };
5071        assert_eq!(row_trend(&report), "-");
5072    }
5073
5074    // ----- opt_count returns "-" for None and the total for Some ------------
5075
5076    #[test]
5077    fn opt_count_returns_dash_for_none_and_total_for_some() {
5078        assert_eq!(opt_count(None), "-");
5079        // from_combined(dead=4, complexity=2, dup=1) => total=7
5080        assert_eq!(opt_count(Some(&ImpactCounts::from_combined(4, 2, 1))), "7");
5081    }
5082
5083    // ----- project_key is stable (no separator) for non-git dirs -----------
5084
5085    #[test]
5086    #[cfg_attr(miri, ignore)]
5087    fn impact_project_key_is_a_hex_string_with_no_separator() {
5088        let (_config, dir) = test_env();
5089        let root = dir.path();
5090        let key = project_identity(root).0;
5091        assert!(
5092            !key.contains('/') && !key.contains('\\'),
5093            "project key must not contain a path separator: {key}"
5094        );
5095        assert!(!key.is_empty(), "project key must not be empty");
5096    }
5097
5098    // ----- ImpactCounts::from_combined wires through correctly --------------
5099
5100    #[test]
5101    fn impact_counts_from_combined_sums_to_total() {
5102        let c = ImpactCounts::from_combined(3, 2, 1);
5103        assert_eq!(c.total_issues, 6);
5104        assert_eq!(c.dead_code, 3);
5105        assert_eq!(c.complexity, 2);
5106        assert_eq!(c.duplication, 1);
5107    }
5108
5109    // ----- cross_repo_markdown: no history case (project_count > 0, tracked_count 0) --
5110
5111    #[test]
5112    fn render_cross_repo_markdown_all_empty_projects_tracked_count_zero() {
5113        // All stores are enabled-but-empty => tracked_count 0, project_count > 0.
5114        let _cfg = aggregate_env();
5115        seed_store(
5116            "empty1",
5117            &ImpactStore {
5118                enabled: true,
5119                explicit_decision: true,
5120                ..Default::default()
5121            },
5122        );
5123        let report = aggregate(CrossRepoSort::Recent);
5124        assert_eq!(report.project_count, 1);
5125        assert_eq!(report.tracked_count, 0);
5126        let md = render_cross_repo_markdown(&report);
5127        // Must show project_count but NOT an empty table (projects vec is empty).
5128        assert!(
5129            md.contains("1 project tracked, 0 with history"),
5130            "must show counts: {md}"
5131        );
5132        assert!(
5133            !md.contains("| Project |"),
5134            "no table when tracked_count is 0: {md}"
5135        );
5136    }
5137
5138    // ----- render_cross_repo_markdown: project_count == 0, unreadable > 0 --
5139
5140    #[test]
5141    fn render_cross_repo_markdown_zero_projects_with_unreadable() {
5142        let _cfg = aggregate_env();
5143        let dir = impact_config_dir().unwrap().join("impact");
5144        std::fs::create_dir_all(&dir).unwrap();
5145        std::fs::write(dir.join("bad.json"), b"}{bad").unwrap();
5146        let report = aggregate(CrossRepoSort::Recent);
5147        assert_eq!(report.project_count, 0);
5148        assert_eq!(report.unreadable_count, 1);
5149        let md = render_cross_repo_markdown(&report);
5150        assert!(
5151            md.contains("skipped") && md.contains("unreadable store"),
5152            "must report corrupt stores: {md}"
5153        );
5154    }
5155}