Skip to main content

fallow_cli/
impact.rs

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