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