Skip to main content

fallow_cli/
impact.rs

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