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 = 4;
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
24const MAX_RECENT_RESOLVED: usize = 50;
25
26const ID_SEP: &str = "\u{1f}";
27
28const CODE_DUPLICATION_KIND: &str = "code-duplication";
29
30const BLANKET_SUPPRESSION: &str = "*";
31
32/// Per-category issue counts captured at a recorded run.
33#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35pub struct ImpactCounts {
36    pub total_issues: usize,
37    pub dead_code: usize,
38    pub complexity: usize,
39    pub duplication: usize,
40}
41
42impl ImpactCounts {
43    fn from_summary(summary: &AuditSummary) -> Self {
44        Self {
45            total_issues: summary.dead_code_issues
46                + summary.complexity_findings
47                + summary.duplication_clone_groups,
48            dead_code: summary.dead_code_issues,
49            complexity: summary.complexity_findings,
50            duplication: summary.duplication_clone_groups,
51        }
52    }
53
54    pub(crate) fn from_combined(dead_code: usize, complexity: usize, duplication: usize) -> Self {
55        Self {
56            total_issues: dead_code + complexity + duplication,
57            dead_code,
58            complexity,
59            duplication,
60        }
61    }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct ImpactRecord {
66    pub timestamp: String,
67    pub version: String,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub git_sha: Option<String>,
70    pub verdict: String,
71    #[serde(default)]
72    pub gate: bool,
73    pub counts: ImpactCounts,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct PendingContainment {
78    pub blocked_at: String,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub git_sha: Option<String>,
81    pub blocked_counts: ImpactCounts,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
86pub struct ContainmentEvent {
87    pub blocked_at: String,
88    pub cleared_at: String,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub git_sha: Option<String>,
91    pub blocked_counts: ImpactCounts,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct FrontierFinding {
96    pub id: String,
97    pub kind: String,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub symbol: Option<String>,
100}
101
102impl FrontierFinding {
103    fn move_key(&self) -> String {
104        match &self.symbol {
105            Some(symbol) => format!("{}{ID_SEP}{symbol}", self.kind),
106            None => self.id.clone(),
107        }
108    }
109}
110
111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
112pub struct FileFrontier {
113    #[serde(default)]
114    pub findings: Vec<FrontierFinding>,
115    #[serde(default)]
116    pub suppressions: Vec<String>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
121pub struct ResolutionEvent {
122    pub kind: String,
123    pub path: String,
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub symbol: Option<String>,
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub git_sha: Option<String>,
128    pub timestamp: String,
129}
130
131#[derive(Debug, Clone, Default, Serialize, Deserialize)]
132pub struct ImpactStore {
133    #[serde(default)]
134    pub schema_version: u32,
135    #[serde(default)]
136    pub enabled: bool,
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub first_recorded: Option<String>,
139    #[serde(default)]
140    pub records: Vec<ImpactRecord>,
141    #[serde(default)]
142    pub project_records: Vec<ImpactRecord>,
143    #[serde(default)]
144    pub containment: Vec<ContainmentEvent>,
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub pending_containment: Option<PendingContainment>,
147    /// Per-finding attribution baseline, namespaced by worktree key (schema v4)
148    /// so two worktrees of one repo (collapsed to a single store) do not prune
149    /// each other's per-file frontier. Inner map is rel-path -> findings.
150    #[serde(default)]
151    pub frontier: FxHashMap<String, FxHashMap<String, FileFrontier>>,
152    /// Clone-family attribution baseline, namespaced by worktree key (schema
153    /// v4). Inner map is clone fingerprint -> instance paths.
154    #[serde(default)]
155    pub clone_frontier: FxHashMap<String, FxHashMap<String, Vec<String>>>,
156    #[serde(default)]
157    pub resolved_total: usize,
158    #[serde(default)]
159    pub suppressed_total: usize,
160    #[serde(default)]
161    pub recent_resolved: Vec<ResolutionEvent>,
162    #[serde(default)]
163    pub onboarding_declined: bool,
164    /// Whether the user ever ran an explicit `impact enable` or `impact
165    /// disable`. Distinguishes "deliberately declined" from "never asked" so
166    /// the agent skill asks for the impact opt-in exactly once per project.
167    #[serde(default)]
168    pub explicit_decision: bool,
169    /// Unix epoch seconds when the periodic impact digest was last surfaced
170    /// (the `impact-report` next-step / human one-liner). Internal cadence
171    /// state, never exposed on the report.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub last_digest_epoch: Option<u64>,
174}
175
176/// Deserialize-only view of a pre-relocation in-repo store (schema <= 3), whose
177/// `frontier` / `clone_frontier` were FLAT (not worktree-namespaced). Used once
178/// during migration to import a legacy `.fallow/impact.json` into the user
179/// store. Every field carries `#[serde(default)]` so any of v1/v2/v3 reads.
180#[derive(Debug, Default, Deserialize)]
181struct LegacyFlatStore {
182    #[serde(default)]
183    enabled: bool,
184    #[serde(default)]
185    first_recorded: Option<String>,
186    #[serde(default)]
187    records: Vec<ImpactRecord>,
188    #[serde(default)]
189    project_records: Vec<ImpactRecord>,
190    #[serde(default)]
191    containment: Vec<ContainmentEvent>,
192    #[serde(default)]
193    pending_containment: Option<PendingContainment>,
194    #[serde(default)]
195    frontier: FlatFrontier,
196    #[serde(default)]
197    clone_frontier: FlatCloneFrontier,
198    #[serde(default)]
199    resolved_total: usize,
200    #[serde(default)]
201    suppressed_total: usize,
202    #[serde(default)]
203    recent_resolved: Vec<ResolutionEvent>,
204    #[serde(default)]
205    onboarding_declined: bool,
206    #[serde(default)]
207    explicit_decision: bool,
208    #[serde(default)]
209    last_digest_epoch: Option<u64>,
210}
211
212impl LegacyFlatStore {
213    /// Convert into the current (v4) store, wrapping the flat frontier under the
214    /// importing worktree's key.
215    fn into_store(self, worktree_key: &str) -> ImpactStore {
216        let mut frontier: FxHashMap<String, FlatFrontier> = FxHashMap::default();
217        if !self.frontier.is_empty() {
218            frontier.insert(worktree_key.to_owned(), self.frontier);
219        }
220        let mut clone_frontier: FxHashMap<String, FlatCloneFrontier> = FxHashMap::default();
221        if !self.clone_frontier.is_empty() {
222            clone_frontier.insert(worktree_key.to_owned(), self.clone_frontier);
223        }
224        ImpactStore {
225            schema_version: STORE_SCHEMA_VERSION,
226            enabled: self.enabled,
227            first_recorded: self.first_recorded,
228            records: self.records,
229            project_records: self.project_records,
230            containment: self.containment,
231            pending_containment: self.pending_containment,
232            frontier,
233            clone_frontier,
234            resolved_total: self.resolved_total,
235            suppressed_total: self.suppressed_total,
236            recent_resolved: self.recent_resolved,
237            onboarding_declined: self.onboarding_declined,
238            explicit_decision: self.explicit_decision,
239            last_digest_epoch: self.last_digest_epoch,
240        }
241    }
242}
243
244/// Process-global memo of `(project_key, worktree_key)` per analyzed root, so
245/// the git subprocesses that derive them run at most once per root per run
246/// (`fallow audit` is the perf-priority path and `load` is called several
247/// times per invocation).
248static IDENTITY_CACHE: std::sync::OnceLock<std::sync::Mutex<FxHashMap<PathBuf, (String, String)>>> =
249    std::sync::OnceLock::new();
250
251/// Hash a filesystem-path identity into a stable key. On case-insensitive
252/// filesystems (macOS APFS default, Windows) two spellings of one directory map
253/// to the same on-disk location, so fold case before hashing to keep the key
254/// stable across spellings. Linux paths are case-sensitive and left as-is.
255fn hash_path_identity(path: &Path) -> String {
256    let raw = path.to_string_lossy();
257    let normalized = if cfg!(any(target_os = "macos", target_os = "windows")) {
258        raw.to_lowercase()
259    } else {
260        raw.into_owned()
261    };
262    fingerprint_hash(&[normalized.as_str()])
263}
264
265/// The worktree-collapsed repo identity used to KEY the per-project store file.
266/// Every linked worktree of a repo shares one common dir, so they collapse onto
267/// one history. Falls back to the canonicalized root for non-git projects
268/// (predictable; a container mount path that changes between host and container
269/// produces a different key, documented as a known bound).
270fn impact_project_key(root: &Path) -> String {
271    let identity = fallow_core::changed_files::resolve_git_common_dir(root)
272        .ok()
273        .or_else(|| dunce::canonicalize(root).ok())
274        .unwrap_or_else(|| root.to_path_buf());
275    hash_path_identity(&identity)
276}
277
278/// Per-WORKTREE identity. Used only to namespace the attribution frontier
279/// inside the repo-collapsed store, so two worktrees of one repo do not prune
280/// each other's per-file baseline (the frontier is pruned to on-disk files
281/// every run). Falls back to the canonicalized root for non-git projects.
282fn worktree_key(root: &Path) -> String {
283    let identity = fallow_core::changed_files::resolve_git_toplevel(root)
284        .ok()
285        .or_else(|| dunce::canonicalize(root).ok())
286        .unwrap_or_else(|| root.to_path_buf());
287    hash_path_identity(&identity)
288}
289
290/// Resolve (and memoize) the `(project_key, worktree_key)` pair for `root`.
291fn project_identity(root: &Path) -> (String, String) {
292    let cache = IDENTITY_CACHE.get_or_init(|| std::sync::Mutex::new(FxHashMap::default()));
293    if let Ok(map) = cache.lock()
294        && let Some(found) = map.get(root)
295    {
296        return found.clone();
297    }
298    let identity = (impact_project_key(root), worktree_key(root));
299    if let Ok(mut map) = cache.lock() {
300        map.insert(root.to_path_buf(), identity.clone());
301    }
302    identity
303}
304
305#[cfg(test)]
306thread_local! {
307    /// Per-test override of the user config dir, so parallel tests get isolated
308    /// stores (the real config dir is process-global and would collide). Set via
309    /// [`with_test_config_dir`]; unset = fall back to the real config dir.
310    static TEST_CONFIG_DIR: std::cell::RefCell<Option<PathBuf>> =
311        const { std::cell::RefCell::new(None) };
312
313    /// Per-test CI signal for the record gate. Defaults to `false` so the unit
314    /// tests record into their isolated store EVEN when the suite itself runs on
315    /// CI (GitHub Actions sets `CI` / `GITHUB_ACTIONS`, which `telemetry::is_ci`
316    /// reads); without this, every record-dependent test fails on CI because the
317    /// real `is_ci()` short-circuits `record_*` before any store write. A test
318    /// can flip it true to exercise the CI no-op gate. Thread-local, so it is
319    /// parallel-safe and needs no unsafe env mutation.
320    static TEST_FORCE_CI: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
321}
322
323/// Fallow's per-user config dir. Under test it resolves ONLY the per-test
324/// override (or `None` when unset), so a test never reads or writes the real
325/// developer config dir and parallel tests stay isolated.
326fn impact_config_dir() -> Option<PathBuf> {
327    #[cfg(test)]
328    {
329        TEST_CONFIG_DIR.with(|c| c.borrow().clone())
330    }
331    #[cfg(not(test))]
332    {
333        crate::telemetry::config_dir()
334    }
335}
336
337/// Whether this run should be treated as CI for the Impact record gate. In
338/// production it is `telemetry::is_ci()`; under test it reads the per-test
339/// `TEST_FORCE_CI` override (default `false`) so the suite records into its
340/// isolated store regardless of the ambient CI env. The store path is ALWAYS
341/// the per-test temp dir under `#[cfg(test)]` (see [`impact_config_dir`]), so
342/// bypassing the CI gate in tests can never touch a real user store.
343fn record_gate_is_ci() -> bool {
344    #[cfg(test)]
345    {
346        TEST_FORCE_CI.with(std::cell::Cell::get)
347    }
348    #[cfg(not(test))]
349    {
350        crate::telemetry::is_ci()
351    }
352}
353
354/// Path to the per-project store file in the user's private config dir, or
355/// `None` when no config dir is resolvable (e.g. stripped CI env), in which
356/// case Impact is inert (no persistence). NEVER writes into the analyzed repo.
357fn store_path(root: &Path) -> Option<PathBuf> {
358    let (project_key, _) = project_identity(root);
359    Some(
360        impact_config_dir()?
361            .join("impact")
362            .join(format!("{project_key}.json")),
363    )
364}
365
366/// Path to a project's legacy in-repo store (`<root>/.fallow/impact.json`),
367/// read ONCE for migration into the user store, never written.
368fn legacy_store_path(root: &Path) -> PathBuf {
369    root.join(".fallow").join(STORE_FILE)
370}
371
372/// Load the store. Missing or unreadable files fall back to defaults; unreadable
373/// files are warned about rather than silently disabling tracking.
374pub fn load(root: &Path) -> ImpactStore {
375    let Some(path) = store_path(root) else {
376        return ImpactStore::default();
377    };
378    match std::fs::read_to_string(&path) {
379        Ok(content) => parse_store(&content, &path),
380        // No user-store file yet: attempt a one-time import of a legacy in-repo
381        // `.fallow/impact.json` (pre-relocation). Returns default if none.
382        Err(_) => migrate_legacy_store(root),
383    }
384}
385
386fn parse_store(content: &str, path: &Path) -> ImpactStore {
387    match serde_json::from_str::<ImpactStore>(content) {
388        Ok(store) => {
389            if store.schema_version > STORE_SCHEMA_VERSION {
390                tracing::warn!(
391                    "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.",
392                    path.display(),
393                    store.schema_version,
394                    STORE_SCHEMA_VERSION,
395                );
396            }
397            store
398        }
399        Err(err) => {
400            tracing::warn!(
401                "fallow impact: ignoring unreadable store at {} ({err}); run `fallow impact enable` to reset it",
402                path.display()
403            );
404            ImpactStore::default()
405        }
406    }
407}
408
409/// Persist the store best-effort using atomic replace. No-op when no config dir
410/// is resolvable (e.g. stripped CI env). NEVER writes into the analyzed repo.
411fn save(store: &ImpactStore, root: &Path) {
412    let Some(path) = store_path(root) else {
413        return;
414    };
415    if let Some(parent) = path.parent()
416        && std::fs::create_dir_all(parent).is_err()
417    {
418        return;
419    }
420    if let Ok(json) = serde_json::to_string_pretty(store) {
421        let _ = fallow_config::atomic_write(&path, json.as_bytes());
422    }
423}
424
425/// One-time import of a pre-relocation in-repo `.fallow/impact.json` into the
426/// user store. The legacy store had a FLAT frontier (schema <= 3); this reads
427/// it via [`LegacyFlatStore`] and wraps the flat frontier under the current
428/// worktree key. The legacy file is left byte-for-byte untouched (it is no
429/// longer read once the user store exists; re-running finds the user store and
430/// does not re-import). Monorepo note: N subdir stores share one repo key, so
431/// whichever subdir runs first wins (pick-first); the others are not merged.
432fn migrate_legacy_store(root: &Path) -> ImpactStore {
433    let legacy_path = legacy_store_path(root);
434    let Ok(content) = std::fs::read_to_string(&legacy_path) else {
435        return ImpactStore::default();
436    };
437    let Ok(legacy) = serde_json::from_str::<LegacyFlatStore>(&content) else {
438        return ImpactStore::default();
439    };
440    let (_, worktree) = project_identity(root);
441    let store = legacy.into_store(&worktree);
442    save(&store, root);
443    store
444}
445
446/// Enable Impact tracking for THIS project (an explicit per-repo decision that
447/// overrides the user-global default). Writes nothing into the repo: the store
448/// lives in the user config dir.
449pub fn enable(root: &Path) -> bool {
450    let mut store = load(root);
451    let was_enabled = store.enabled;
452    store.enabled = true;
453    store.explicit_decision = true;
454    if store.schema_version == 0 {
455        store.schema_version = STORE_SCHEMA_VERSION;
456    }
457    save(&store, root);
458    !was_enabled
459}
460
461/// Disable Impact tracking. Retains existing history. Returns whether it was
462/// newly disabled (false if already off). Also records the explicit decision,
463/// so declining the impact opt-in on a never-enabled project (`impact
464/// disable`) persists "asked and said no" for the agent skill.
465pub fn disable(root: &Path) -> bool {
466    let mut store = load(root);
467    let was_enabled = store.enabled;
468    store.enabled = false;
469    store.explicit_decision = true;
470    if store.schema_version == 0 {
471        store.schema_version = STORE_SCHEMA_VERSION;
472    }
473    save(&store, root);
474    was_enabled
475}
476
477/// A due periodic value digest: the headline counters for "what has fallow
478/// done for you here". Returned by [`take_due_digest`] at most once per
479/// `DIGEST_INTERVAL_SECS` per project.
480#[derive(Debug, Clone, Copy)]
481pub struct ImpactDigest {
482    pub containment_count: usize,
483    pub resolved_total: usize,
484}
485
486/// Minimum seconds between periodic digest surfacings (one week).
487const DIGEST_INTERVAL_SECS: u64 = 7 * 24 * 60 * 60;
488
489/// Return the periodic value digest when it is due, stamping the store so the
490/// next one is at least `DIGEST_INTERVAL_SECS` away. Due means: tracking is
491/// enabled, there is non-zero value to report (anti-nag: a zero digest never
492/// surfaces), and the previous digest is older than the interval (or never
493/// happened). Best-effort like the rest of the store: a clean run that drops
494/// the emitted step simply defers the digest to the next interval.
495pub fn take_due_digest(root: &Path) -> Option<ImpactDigest> {
496    let mut store = load(root);
497    if !resolve_enabled(&store).0 {
498        return None;
499    }
500    let containment_count = store.containment.len();
501    if containment_count == 0 && store.resolved_total == 0 {
502        return None;
503    }
504    let now = std::time::SystemTime::now()
505        .duration_since(std::time::UNIX_EPOCH)
506        .ok()?
507        .as_secs();
508    if let Some(last) = store.last_digest_epoch
509        && now.saturating_sub(last) < DIGEST_INTERVAL_SECS
510    {
511        return None;
512    }
513    store.last_digest_epoch = Some(now);
514    save(&store, root);
515    Some(ImpactDigest {
516        containment_count,
517        resolved_total: store.resolved_total,
518    })
519}
520
521/// Persist that the local user declined the agent onboarding prompt. Writes
522/// only to the user store; nothing is written into the repo.
523pub fn decline_onboarding(root: &Path) -> bool {
524    let mut store = load(root);
525    let was_declined = store.onboarding_declined;
526    store.onboarding_declined = true;
527    if store.schema_version == 0 {
528        store.schema_version = STORE_SCHEMA_VERSION;
529    }
530    save(&store, root);
531    !was_declined
532}
533
534/// Why Impact tracking is (or is not) active for a project. `Project` = an
535/// explicit per-repo `enable`; `User` = the user-global default with no per-repo
536/// decision; `Default` = off (no per-repo decision and no global default).
537#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
538#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
539#[serde(rename_all = "lowercase")]
540pub enum EnabledSource {
541    Project,
542    User,
543    Default,
544}
545
546/// The user-global Impact default, stored at `<config-dir>/fallow/impact.json`
547/// (sibling to `telemetry.json`). A single toggle: when on, new projects record
548/// without a per-repo `enable`. A per-repo explicit decision always wins.
549#[derive(Debug, Default, Serialize, Deserialize)]
550struct GlobalImpactConfig {
551    #[serde(default)]
552    default_enabled: bool,
553}
554
555fn global_config_path() -> Option<PathBuf> {
556    Some(impact_config_dir()?.join(STORE_FILE))
557}
558
559/// Whether the user-global default is on. False when unset or unreadable.
560fn load_global_default() -> bool {
561    let Some(path) = global_config_path() else {
562        return false;
563    };
564    std::fs::read_to_string(&path)
565        .ok()
566        .and_then(|c| serde_json::from_str::<GlobalImpactConfig>(&c).ok())
567        .is_some_and(|c| c.default_enabled)
568}
569
570/// Set the user-global default. Returns whether the value changed.
571pub fn set_global_default(on: bool) -> bool {
572    let was = load_global_default();
573    if let Some(path) = global_config_path() {
574        if let Some(parent) = path.parent()
575            && std::fs::create_dir_all(parent).is_err()
576        {
577            return false;
578        }
579        let config = GlobalImpactConfig {
580            default_enabled: on,
581        };
582        if let Ok(json) = serde_json::to_string_pretty(&config) {
583            let _ = fallow_config::atomic_write(&path, json.as_bytes());
584        }
585    }
586    was != on
587}
588
589/// Resolve whether Impact is active for this project and WHY. Precedence:
590/// per-repo decision (enable/disable) > user-global default > off.
591///
592/// `enabled == true` is itself an explicit project opt-in (somebody ran
593/// `enable` here), so it wins even when `explicit_decision` is unset, which is
594/// the case for stores written before the `explicit_decision` field existed. A
595/// store that is off-but-explicitly-decided (`!enabled && explicit_decision`)
596/// stays off as a Project decision (the user disabled it here). Only a truly
597/// never-asked store (`!enabled && !explicit_decision`) consults the global
598/// default.
599fn resolve_enabled(store: &ImpactStore) -> (bool, EnabledSource) {
600    if store.enabled {
601        return (true, EnabledSource::Project);
602    }
603    if store.explicit_decision {
604        return (false, EnabledSource::Project);
605    }
606    if load_global_default() {
607        return (true, EnabledSource::User);
608    }
609    (false, EnabledSource::Default)
610}
611
612/// The resolved per-project store-file path for `root`, for `status` display
613/// (so a wrong key is debuggable). `None` when no config dir is resolvable.
614#[must_use]
615pub fn resolved_store_path(root: &Path) -> Option<PathBuf> {
616    store_path(root)
617}
618
619/// The resolved (worktree-collapsed) project key for `root`, for display.
620#[must_use]
621pub fn resolved_project_key(root: &Path) -> String {
622    project_identity(root).0
623}
624
625/// Delete THIS project's store file. Returns whether a file was removed.
626pub fn reset(root: &Path) -> bool {
627    store_path(root).is_some_and(|p| std::fs::remove_file(&p).is_ok())
628}
629
630/// Delete the whole per-project impact dir (`<config-dir>/fallow/impact/`).
631/// Does NOT touch the global default toggle (`impact.json`): a data wipe should
632/// not silently re-disable an opt-in the user made. Returns whether the dir was
633/// present and removed.
634pub fn reset_all() -> bool {
635    let Some(dir) = impact_config_dir().map(|d| d.join("impact")) else {
636        return false;
637    };
638    dir.is_dir() && std::fs::remove_dir_all(&dir).is_ok()
639}
640
641/// Record an audit run into the rolling store.
642pub struct AuditRunRecord<'a> {
643    pub verdict: AuditVerdict,
644    pub gate: bool,
645    pub git_sha: Option<&'a str>,
646    pub version: &'a str,
647    pub timestamp: &'a str,
648    pub attribution: Option<&'a AttributionInput<'a>>,
649}
650
651pub fn record_audit_run(root: &Path, summary: &AuditSummary, record: &AuditRunRecord<'_>) {
652    let AuditRunRecord {
653        verdict,
654        gate,
655        git_sha,
656        version,
657        timestamp,
658        attribution,
659    } = record;
660    // Impact is a LOCAL-DEV signal. Never record in CI: a user-global default
661    // baked into a devcontainer/dotfiles image would otherwise start writing
662    // per-project files on every CI run (pre-relocation this was emergent from
663    // a fresh CI checkout having no in-repo store file; now it is explicit).
664    if record_gate_is_ci() {
665        return;
666    }
667    let mut store = load(root);
668    if !resolve_enabled(&store).0 {
669        return;
670    }
671    store.schema_version = STORE_SCHEMA_VERSION;
672
673    let counts = ImpactCounts::from_summary(summary);
674    let verdict_str = verdict_label(*verdict);
675
676    if store.first_recorded.is_none() {
677        store.first_recorded = Some((*timestamp).to_owned());
678    }
679
680    apply_containment(&mut store, *verdict, *gate, *git_sha, timestamp, &counts);
681
682    store.records.push(ImpactRecord {
683        timestamp: (*timestamp).to_owned(),
684        version: (*version).to_owned(),
685        git_sha: git_sha.map(ToOwned::to_owned),
686        verdict: verdict_str.to_owned(),
687        gate: *gate,
688        counts,
689    });
690    compact(&mut store);
691
692    if let Some(attribution) = attribution {
693        let (_, worktree) = project_identity(root);
694        apply_attribution(&mut store, attribution, &worktree, *git_sha, timestamp);
695    }
696
697    save(&store, root);
698}
699
700/// Record a whole-project combined run into the project track.
701pub fn record_combined_run(
702    root: &Path,
703    counts: ImpactCounts,
704    git_sha: Option<&str>,
705    version: &str,
706    timestamp: &str,
707    attribution: Option<&AttributionInput<'_>>,
708) {
709    if record_gate_is_ci() {
710        return;
711    }
712    let mut store = load(root);
713    if !resolve_enabled(&store).0 {
714        return;
715    }
716    store.schema_version = STORE_SCHEMA_VERSION;
717
718    if store.first_recorded.is_none() {
719        store.first_recorded = Some(timestamp.to_owned());
720    }
721
722    let verdict_str = if counts.total_issues == 0 {
723        "pass"
724    } else {
725        "warn"
726    };
727    store.project_records.push(ImpactRecord {
728        timestamp: timestamp.to_owned(),
729        version: version.to_owned(),
730        git_sha: git_sha.map(ToOwned::to_owned),
731        verdict: verdict_str.to_owned(),
732        gate: false,
733        counts,
734    });
735    if store.project_records.len() > MAX_RECORDS {
736        let overflow = store.project_records.len() - MAX_RECORDS;
737        store.project_records.drain(0..overflow);
738    }
739
740    if let Some(attribution) = attribution {
741        let (_, worktree) = project_identity(root);
742        apply_attribution(&mut store, attribution, &worktree, git_sha, timestamp);
743    }
744
745    save(&store, root);
746}
747
748/// Update pending/contained state from a gate run's verdict.
749fn apply_containment(
750    store: &mut ImpactStore,
751    verdict: AuditVerdict,
752    gate: bool,
753    git_sha: Option<&str>,
754    timestamp: &str,
755    counts: &ImpactCounts,
756) {
757    if !gate {
758        return;
759    }
760    if verdict == AuditVerdict::Fail {
761        if store.pending_containment.is_none() {
762            store.pending_containment = Some(PendingContainment {
763                blocked_at: timestamp.to_owned(),
764                git_sha: git_sha.map(ToOwned::to_owned),
765                blocked_counts: counts.clone(),
766            });
767        }
768    } else if let Some(pending) = store.pending_containment.take() {
769        store.containment.push(ContainmentEvent {
770            blocked_at: pending.blocked_at,
771            cleared_at: timestamp.to_owned(),
772            git_sha: pending.git_sha,
773            blocked_counts: pending.blocked_counts,
774        });
775        if store.containment.len() > MAX_CONTAINMENT {
776            let overflow = store.containment.len() - MAX_CONTAINMENT;
777            store.containment.drain(0..overflow);
778        }
779    }
780}
781
782fn compact(store: &mut ImpactStore) {
783    if store.records.len() > MAX_RECORDS {
784        let overflow = store.records.len() - MAX_RECORDS;
785        store.records.drain(0..overflow);
786    }
787}
788
789#[derive(Debug, Clone)]
790pub struct FindingInput {
791    pub path: PathBuf,
792    pub kind: &'static str,
793    pub symbol: Option<String>,
794}
795
796#[derive(Debug, Clone)]
797pub struct CloneInput {
798    pub fingerprint: String,
799    pub instance_paths: Vec<PathBuf>,
800}
801
802pub enum Scope<'a> {
803    ChangedFiles(&'a [PathBuf]),
804    WholeProject,
805}
806
807pub struct AttributionInput<'a> {
808    pub root: &'a Path,
809    pub scope: Scope<'a>,
810    pub findings: Vec<FindingInput>,
811    pub clones: Vec<CloneInput>,
812    pub suppressions: &'a [ActiveSuppression],
813}
814
815fn finding_id(kind: &str, rel_path: &str, symbol: Option<&str>) -> String {
816    fingerprint_hash(&[kind, rel_path, symbol.unwrap_or("")])
817}
818
819fn covered_by(present: &FxHashSet<String>, kind: &str) -> bool {
820    present.contains(BLANKET_SUPPRESSION) || present.contains(kind)
821}
822
823/// A single worktree's flat per-file attribution baseline (rel-path -> findings).
824type FlatFrontier = FxHashMap<String, FileFrontier>;
825/// A single worktree's flat clone baseline (fingerprint -> instance paths).
826type FlatCloneFrontier = FxHashMap<String, Vec<String>>;
827
828fn apply_attribution(
829    store: &mut ImpactStore,
830    input: &AttributionInput<'_>,
831    worktree_key: &str,
832    git_sha: Option<&str>,
833    timestamp: &str,
834) {
835    let root = input.root;
836    // Pull THIS worktree's baseline out of the (repo-collapsed) store into owned
837    // flat locals. The helpers mutate these locals plus the shared totals on
838    // `store`; because the locals are owned (not borrowed from `store`) there is
839    // no aliasing with the `store.resolved_total` / `recent_resolved` writes.
840    let mut frontier: FlatFrontier = store.frontier.remove(worktree_key).unwrap_or_default();
841    let mut clone_frontier: FlatCloneFrontier = store
842        .clone_frontier
843        .remove(worktree_key)
844        .unwrap_or_default();
845
846    let changed: FxHashSet<String> = match input.scope {
847        Scope::ChangedFiles(files) => files.iter().map(|p| format_display_path(p, root)).collect(),
848        Scope::WholeProject => whole_project_scope(&frontier, &clone_frontier, input, root),
849    };
850
851    let mut current_findings: FxHashMap<String, Vec<FrontierFinding>> = FxHashMap::default();
852    for f in &input.findings {
853        let rel = format_display_path(&f.path, root);
854        if !changed.contains(&rel) {
855            continue;
856        }
857        let id = finding_id(f.kind, &rel, f.symbol.as_deref());
858        current_findings
859            .entry(rel)
860            .or_default()
861            .push(FrontierFinding {
862                id,
863                kind: f.kind.to_owned(),
864                symbol: f.symbol.clone(),
865            });
866    }
867    let mut current_supps: FxHashMap<String, FxHashSet<String>> = FxHashMap::default();
868    for s in input.suppressions {
869        let rel = format_display_path(&s.path, root);
870        if !changed.contains(&rel) {
871            continue;
872        }
873        let key = s
874            .kind
875            .clone()
876            .unwrap_or_else(|| BLANKET_SUPPRESSION.to_owned());
877        current_supps.entry(rel).or_default().insert(key);
878    }
879
880    let mut appeared_move_keys: FxHashSet<String> = FxHashSet::default();
881    for (rel, findings) in &current_findings {
882        let prior_ids: FxHashSet<&str> = frontier
883            .get(rel)
884            .map(|f| f.findings.iter().map(|x| x.id.as_str()).collect())
885            .unwrap_or_default();
886        for ff in findings {
887            if !prior_ids.contains(ff.id.as_str()) {
888                appeared_move_keys.insert(ff.move_key());
889            }
890        }
891    }
892
893    uncredit_cross_run_moves(store, &appeared_move_keys);
894
895    let mut disappearance_input = FileDisappearancesInput {
896        store,
897        frontier: &frontier,
898        changed: &changed,
899        current_findings: &current_findings,
900        current_supps: &current_supps,
901        appeared_move_keys: &appeared_move_keys,
902        git_sha,
903        timestamp,
904    };
905    classify_file_disappearances(&mut disappearance_input);
906    update_file_frontier(&mut frontier, &changed, current_findings, current_supps);
907    classify_clone_disappearances(
908        store,
909        &frontier,
910        &mut clone_frontier,
911        input,
912        &changed,
913        git_sha,
914        timestamp,
915    );
916    prune_frontier(&mut frontier, &mut clone_frontier, root);
917    bound_recent_resolved(store);
918
919    // Store this worktree's baseline back; drop the worktree key entirely when
920    // empty so deleted/abandoned worktrees do not accumulate.
921    if frontier.is_empty() {
922        store.frontier.remove(worktree_key);
923    } else {
924        store.frontier.insert(worktree_key.to_owned(), frontier);
925    }
926    if clone_frontier.is_empty() {
927        store.clone_frontier.remove(worktree_key);
928    } else {
929        store
930            .clone_frontier
931            .insert(worktree_key.to_owned(), clone_frontier);
932    }
933}
934
935fn whole_project_scope(
936    frontier: &FlatFrontier,
937    clone_frontier: &FlatCloneFrontier,
938    input: &AttributionInput<'_>,
939    root: &Path,
940) -> FxHashSet<String> {
941    let mut set: FxHashSet<String> = frontier.keys().cloned().collect();
942    for paths in clone_frontier.values() {
943        for p in paths {
944            set.insert(p.clone());
945        }
946    }
947    for f in &input.findings {
948        set.insert(format_display_path(&f.path, root));
949    }
950    for c in &input.clones {
951        for p in &c.instance_paths {
952            set.insert(format_display_path(p, root));
953        }
954    }
955    set
956}
957
958struct FileDisappearancesInput<'a> {
959    store: &'a mut ImpactStore,
960    frontier: &'a FlatFrontier,
961    changed: &'a FxHashSet<String>,
962    current_findings: &'a FxHashMap<String, Vec<FrontierFinding>>,
963    current_supps: &'a FxHashMap<String, FxHashSet<String>>,
964    appeared_move_keys: &'a FxHashSet<String>,
965    git_sha: Option<&'a str>,
966    timestamp: &'a str,
967}
968
969fn classify_file_disappearances(input: &mut FileDisappearancesInput<'_>) {
970    let store = &mut *input.store;
971    let frontier = input.frontier;
972    let changed = input.changed;
973    let current_findings = input.current_findings;
974    let current_supps = input.current_supps;
975    let appeared_move_keys = input.appeared_move_keys;
976    let git_sha = input.git_sha;
977    let timestamp = input.timestamp;
978    let empty_supps = FxHashSet::default();
979    for rel in changed {
980        let Some(prior) = frontier.get(rel) else {
981            continue;
982        };
983        let now_ids: FxHashSet<&str> = current_findings
984            .get(rel)
985            .map(|fs| fs.iter().map(|f| f.id.as_str()).collect())
986            .unwrap_or_default();
987        let now_supps = current_supps.get(rel).unwrap_or(&empty_supps);
988        let prior_supps: FxHashSet<&str> = prior.suppressions.iter().map(String::as_str).collect();
989        let new_supp_kinds: FxHashSet<String> = now_supps
990            .iter()
991            .filter(|k| !prior_supps.contains(k.as_str()))
992            .cloned()
993            .collect();
994
995        let mut resolved = Vec::new();
996        let mut suppressed = 0usize;
997        for pf in &prior.findings {
998            if now_ids.contains(pf.id.as_str()) {
999                continue; // still present
1000            }
1001            if appeared_move_keys.contains(&pf.move_key()) {
1002                continue; // moved to another file this run
1003            }
1004            if covered_by(&new_supp_kinds, &pf.kind) {
1005                suppressed += 1; // conservative: a fresh fallow-ignore, never a win
1006            } else {
1007                resolved.push(pf.clone());
1008            }
1009        }
1010        store.suppressed_total += suppressed;
1011        for pf in resolved {
1012            store.resolved_total += 1;
1013            store.recent_resolved.push(ResolutionEvent {
1014                kind: pf.kind,
1015                path: rel.clone(),
1016                symbol: pf.symbol,
1017                git_sha: git_sha.map(ToOwned::to_owned),
1018                timestamp: timestamp.to_owned(),
1019            });
1020        }
1021    }
1022}
1023
1024fn update_file_frontier(
1025    frontier: &mut FlatFrontier,
1026    changed: &FxHashSet<String>,
1027    mut current_findings: FxHashMap<String, Vec<FrontierFinding>>,
1028    mut current_supps: FxHashMap<String, FxHashSet<String>>,
1029) {
1030    for rel in changed {
1031        let findings = current_findings.remove(rel).unwrap_or_default();
1032        let mut suppressions: Vec<String> = current_supps
1033            .remove(rel)
1034            .unwrap_or_default()
1035            .into_iter()
1036            .collect();
1037        suppressions.sort_unstable();
1038        if findings.is_empty() && suppressions.is_empty() {
1039            frontier.remove(rel);
1040        } else {
1041            frontier.insert(
1042                rel.clone(),
1043                FileFrontier {
1044                    findings,
1045                    suppressions,
1046                },
1047            );
1048        }
1049    }
1050}
1051
1052fn classify_clone_disappearances(
1053    store: &mut ImpactStore,
1054    frontier: &FlatFrontier,
1055    clone_frontier: &mut FlatCloneFrontier,
1056    input: &AttributionInput<'_>,
1057    changed: &FxHashSet<String>,
1058    git_sha: Option<&str>,
1059    timestamp: &str,
1060) {
1061    let root = input.root;
1062    let mut current: FxHashMap<String, Vec<String>> = FxHashMap::default();
1063    for c in &input.clones {
1064        let mut paths: Vec<String> = c
1065            .instance_paths
1066            .iter()
1067            .map(|p| format_display_path(p, root))
1068            .collect();
1069        paths.sort_unstable();
1070        paths.dedup();
1071        if paths.iter().any(|p| changed.contains(p)) {
1072            current.insert(c.fingerprint.clone(), paths);
1073        }
1074    }
1075
1076    let dup_suppressed = |paths: &[String]| -> bool {
1077        paths.iter().any(|p| {
1078            changed.contains(p)
1079                && frontier.get(p).is_some_and(|f| {
1080                    f.suppressions
1081                        .iter()
1082                        .any(|k| k == CODE_DUPLICATION_KIND || k == BLANKET_SUPPRESSION)
1083                })
1084        })
1085    };
1086
1087    let still_duplicated: FxHashSet<&String> = current.values().flatten().collect();
1088
1089    let disappeared: Vec<(String, Vec<String>)> = clone_frontier
1090        .iter()
1091        .filter(|(fp, paths)| {
1092            paths.iter().any(|p| changed.contains(p)) && !current.contains_key(*fp)
1093        })
1094        .map(|(fp, paths)| (fp.clone(), paths.clone()))
1095        .collect();
1096
1097    for (fp, paths) in disappeared {
1098        clone_frontier.remove(&fp);
1099        if paths.iter().any(|p| still_duplicated.contains(p)) {
1100            continue;
1101        }
1102        if dup_suppressed(&paths) {
1103            store.suppressed_total += 1;
1104        } else {
1105            store.resolved_total += 1;
1106            let path = paths.first().cloned().unwrap_or_default();
1107            store.recent_resolved.push(ResolutionEvent {
1108                kind: CODE_DUPLICATION_KIND.to_owned(),
1109                path,
1110                symbol: None,
1111                git_sha: git_sha.map(ToOwned::to_owned),
1112                timestamp: timestamp.to_owned(),
1113            });
1114        }
1115    }
1116
1117    for (fp, paths) in current {
1118        clone_frontier.insert(fp, paths);
1119    }
1120}
1121
1122fn prune_frontier(
1123    frontier: &mut FlatFrontier,
1124    clone_frontier: &mut FlatCloneFrontier,
1125    root: &Path,
1126) {
1127    frontier.retain(|rel, _| root.join(rel).exists());
1128    clone_frontier.retain(|_, paths| paths.iter().any(|p| root.join(p).exists()));
1129}
1130
1131fn bound_recent_resolved(store: &mut ImpactStore) {
1132    if store.recent_resolved.len() > MAX_RECENT_RESOLVED {
1133        let overflow = store.recent_resolved.len() - MAX_RECENT_RESOLVED;
1134        store.recent_resolved.drain(0..overflow);
1135    }
1136}
1137
1138fn event_move_key(ev: &ResolutionEvent) -> Option<String> {
1139    ev.symbol
1140        .as_ref()
1141        .map(|symbol| format!("{}{ID_SEP}{symbol}", ev.kind))
1142}
1143
1144fn uncredit_cross_run_moves(store: &mut ImpactStore, appeared_move_keys: &FxHashSet<String>) {
1145    if appeared_move_keys.is_empty() {
1146        return;
1147    }
1148    let mut uncredited = 0usize;
1149    store.recent_resolved.retain(|ev| match event_move_key(ev) {
1150        Some(mk) if appeared_move_keys.contains(&mk) => {
1151            uncredited += 1;
1152            false
1153        }
1154        _ => true,
1155    });
1156    store.resolved_total = store.resolved_total.saturating_sub(uncredited);
1157}
1158
1159#[must_use]
1160pub fn collect_dead_code_findings(results: &AnalysisResults) -> Vec<FindingInput> {
1161    let mut out = Vec::new();
1162    let mut push = |path: &Path, kind: &'static str, symbol: Option<String>| {
1163        out.push(FindingInput {
1164            path: path.to_path_buf(),
1165            kind,
1166            symbol,
1167        });
1168    };
1169    collect_unused_symbol_findings(results, &mut push);
1170    collect_dependency_findings(results, &mut push);
1171    collect_catalog_findings(results, &mut push);
1172    out
1173}
1174
1175fn collect_unused_symbol_findings(
1176    results: &AnalysisResults,
1177    push: &mut impl FnMut(&Path, &'static str, Option<String>),
1178) {
1179    for f in &results.unused_files {
1180        push(&f.file.path, "unused-file", None);
1181    }
1182    for f in &results.unused_exports {
1183        push(
1184            &f.export.path,
1185            "unused-export",
1186            Some(f.export.export_name.clone()),
1187        );
1188    }
1189    for f in &results.unused_types {
1190        push(
1191            &f.export.path,
1192            "unused-type",
1193            Some(f.export.export_name.clone()),
1194        );
1195    }
1196    for f in &results.private_type_leaks {
1197        push(
1198            &f.leak.path,
1199            "private-type-leak",
1200            Some(format!(
1201                "{}{ID_SEP}{}",
1202                f.leak.export_name, f.leak.type_name
1203            )),
1204        );
1205    }
1206    for f in &results.unused_enum_members {
1207        push(
1208            &f.member.path,
1209            "unused-enum-member",
1210            Some(format!(
1211                "{}{ID_SEP}{}",
1212                f.member.parent_name, f.member.member_name
1213            )),
1214        );
1215    }
1216    for f in &results.unused_class_members {
1217        push(
1218            &f.member.path,
1219            "unused-class-member",
1220            Some(format!(
1221                "{}{ID_SEP}{}",
1222                f.member.parent_name, f.member.member_name
1223            )),
1224        );
1225    }
1226    for f in &results.unresolved_imports {
1227        push(
1228            &f.import.path,
1229            "unresolved-import",
1230            Some(f.import.specifier.clone()),
1231        );
1232    }
1233    for f in &results.boundary_violations {
1234        let to_path = f.violation.to_path.to_string_lossy().replace('\\', "/");
1235        push(
1236            &f.violation.from_path,
1237            "boundary-violation",
1238            Some(format!("{to_path}{ID_SEP}{}", f.violation.import_specifier)),
1239        );
1240    }
1241}
1242
1243fn collect_dependency_findings(
1244    results: &AnalysisResults,
1245    push: &mut impl FnMut(&Path, &'static str, Option<String>),
1246) {
1247    for f in &results.unused_dependencies {
1248        push(
1249            &f.dep.path,
1250            "unused-dependency",
1251            Some(f.dep.package_name.clone()),
1252        );
1253    }
1254    for f in &results.unused_dev_dependencies {
1255        push(
1256            &f.dep.path,
1257            "unused-dev-dependency",
1258            Some(f.dep.package_name.clone()),
1259        );
1260    }
1261    for f in &results.unused_optional_dependencies {
1262        push(
1263            &f.dep.path,
1264            "unused-optional-dependency",
1265            Some(f.dep.package_name.clone()),
1266        );
1267    }
1268    for f in &results.type_only_dependencies {
1269        push(
1270            &f.dep.path,
1271            "type-only-dependency",
1272            Some(f.dep.package_name.clone()),
1273        );
1274    }
1275    for f in &results.test_only_dependencies {
1276        push(
1277            &f.dep.path,
1278            "test-only-dependency",
1279            Some(f.dep.package_name.clone()),
1280        );
1281    }
1282}
1283
1284fn collect_catalog_findings(
1285    results: &AnalysisResults,
1286    push: &mut impl FnMut(&Path, &'static str, Option<String>),
1287) {
1288    for f in &results.unused_catalog_entries {
1289        push(
1290            &f.entry.path,
1291            "unused-catalog-entry",
1292            Some(format!(
1293                "{}{ID_SEP}{}",
1294                f.entry.catalog_name, f.entry.entry_name
1295            )),
1296        );
1297    }
1298    for f in &results.empty_catalog_groups {
1299        push(
1300            &f.group.path,
1301            "empty-catalog-group",
1302            Some(f.group.catalog_name.clone()),
1303        );
1304    }
1305    for f in &results.unresolved_catalog_references {
1306        push(
1307            &f.reference.path,
1308            "unresolved-catalog-reference",
1309            Some(format!(
1310                "{}{ID_SEP}{}",
1311                f.reference.catalog_name, f.reference.entry_name
1312            )),
1313        );
1314    }
1315    for f in &results.unused_dependency_overrides {
1316        push(
1317            &f.entry.path,
1318            "unused-dependency-override",
1319            Some(f.entry.raw_key.clone()),
1320        );
1321    }
1322    for f in &results.misconfigured_dependency_overrides {
1323        push(
1324            &f.entry.path,
1325            "misconfigured-dependency-override",
1326            Some(f.entry.raw_key.clone()),
1327        );
1328    }
1329}
1330
1331/// Collect line-independent complexity finding identities `(path, function name)`
1332/// from a health report. The function name is line-independent, so a function
1333/// moving within its file keeps the same identity.
1334#[must_use]
1335pub fn collect_complexity_findings(
1336    report: &crate::health_types::HealthReport,
1337) -> Vec<FindingInput> {
1338    report
1339        .findings
1340        .iter()
1341        .map(|f| FindingInput {
1342            path: f.path.clone(),
1343            kind: "complexity",
1344            symbol: Some(f.name.clone()),
1345        })
1346        .collect()
1347}
1348
1349/// Collect clone-group identities `(fingerprint, instance paths)` from a
1350/// duplication report. The fingerprint is content-derived (`dup:<hash>`), so it
1351/// is stable across pure relocation.
1352#[must_use]
1353pub fn collect_clone_findings(
1354    report: &fallow_core::duplicates::DuplicationReport,
1355) -> Vec<CloneInput> {
1356    report
1357        .clone_groups
1358        .iter()
1359        .map(|g| CloneInput {
1360            fingerprint: fallow_core::duplicates::clone_fingerprint(&g.instances),
1361            instance_paths: g.instances.iter().map(|i| i.file.clone()).collect(),
1362        })
1363        .collect()
1364}
1365
1366const fn verdict_label(verdict: AuditVerdict) -> &'static str {
1367    match verdict {
1368        AuditVerdict::Pass => "pass",
1369        AuditVerdict::Warn => "warn",
1370        AuditVerdict::Fail => "fail",
1371    }
1372}
1373
1374/// Direction of a count trend between two recorded runs.
1375#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1376#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1377#[serde(rename_all = "snake_case")]
1378pub enum ImpactTrendDirection {
1379    /// Issue count went down (good).
1380    Improving,
1381    /// Issue count went up.
1382    Declining,
1383    /// Within tolerance.
1384    Stable,
1385}
1386
1387/// A computed trend between the two most recent records.
1388#[derive(Debug, Clone, Serialize)]
1389#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1390pub struct TrendSummary {
1391    pub direction: ImpactTrendDirection,
1392    /// Signed delta in total issues (current minus previous).
1393    pub total_delta: i64,
1394    pub previous_total: usize,
1395    pub current_total: usize,
1396}
1397
1398fn direction_for(delta: i64) -> ImpactTrendDirection {
1399    if delta < -TREND_TOLERANCE {
1400        ImpactTrendDirection::Improving
1401    } else if delta > TREND_TOLERANCE {
1402        ImpactTrendDirection::Declining
1403    } else {
1404        ImpactTrendDirection::Stable
1405    }
1406}
1407
1408/// Wire-version discriminator for [`ImpactReport`]. Independent from the global
1409/// `SchemaVersion` (the impact report versions on its own cadence) and from the
1410/// on-disk `STORE_SCHEMA_VERSION` (the persisted store shape versions
1411/// separately). Serializes as a string `const` so JSON consumers can switch on
1412/// it, matching the other independently-versioned envelopes (e.g.
1413/// `CoverageAnalyzeSchemaVersion`).
1414#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1415#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1416pub enum ImpactReportSchemaVersion {
1417    /// First release of the `fallow impact --format json` shape.
1418    #[serde(rename = "1")]
1419    V1,
1420}
1421
1422/// The rendered impact report, derived purely from the store (no analysis run).
1423#[derive(Debug, Clone, Serialize)]
1424#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1425#[cfg_attr(feature = "schema", schemars(title = "fallow impact --format json"))]
1426pub struct ImpactReport {
1427    /// Output-shape version for this report, so JSON consumers have a
1428    /// forward-compat signal independent of the on-disk store version. Always
1429    /// present; bumped only on a breaking change to this report's wire shape.
1430    pub schema_version: ImpactReportSchemaVersion,
1431    pub enabled: bool,
1432    /// WHY tracking is on or off: `project` (an explicit per-repo enable/disable
1433    /// decision), `user` (the user-global default with no per-repo decision), or
1434    /// `default` (off, no per-repo decision and no global default). Combine with
1435    /// `explicit_decision` to tell a never-asked off-state (`enabled:false`,
1436    /// `explicit_decision:false`, offer to enable) from a declined-here one
1437    /// (`enabled:false`, `explicit_decision:true`, do not nag).
1438    pub enabled_source: EnabledSource,
1439    pub record_count: usize,
1440    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
1441    pub meta: Option<Meta>,
1442    #[serde(default, skip_serializing_if = "Option::is_none")]
1443    pub first_recorded: Option<String>,
1444    /// Git SHA of the most recent recorded run, so a consumer can tell which
1445    /// commit the `surfacing` counts belong to. This is an ABBREVIATED SHA
1446    /// (`git rev-parse --short`), so it is for display/correlation only and will
1447    /// not match a full 40-character SHA from `$GITHUB_SHA` or the git API
1448    /// without expansion. None when the latest run had no SHA (not a git repo)
1449    /// or there are no records yet.
1450    #[serde(default, skip_serializing_if = "Option::is_none")]
1451    pub latest_git_sha: Option<String>,
1452    /// Counts from the most recent recorded run. These are CHANGED-FILE scoped
1453    /// (each record comes from a `fallow audit` run, whose default `new-only`
1454    /// gate counts only findings in the changed files of that run), NOT a
1455    /// whole-project total.
1456    #[serde(default, skip_serializing_if = "Option::is_none")]
1457    pub surfacing: Option<ImpactCounts>,
1458    /// Trend between the two most recent records. None until two records exist.
1459    #[serde(default, skip_serializing_if = "Option::is_none")]
1460    pub trend: Option<TrendSummary>,
1461    /// Counts from the most recent whole-project `fallow` run. WHOLE-PROJECT
1462    /// scope (not changed-file), so this is the current issue total across the
1463    /// whole repo, context next to the actionable changed-file `surfacing`
1464    /// count. None until a full `fallow` run has been recorded. v1.6.
1465    #[serde(default, skip_serializing_if = "Option::is_none")]
1466    pub project_surfacing: Option<ImpactCounts>,
1467    /// Trend between the two most recent whole-project records. Comparable over
1468    /// time (same whole-project denominator every run), unlike the changed-file
1469    /// `trend`. None until two full `fallow` runs exist. v1.6.
1470    #[serde(default, skip_serializing_if = "Option::is_none")]
1471    pub project_trend: Option<TrendSummary>,
1472    pub containment_count: usize,
1473    /// Most recent containment events (newest last), capped for display.
1474    pub recent_containment: Vec<ContainmentEvent>,
1475    /// Lifetime count of findings fallow credits as genuinely resolved (code
1476    /// removed or refactored, never a `fallow-ignore`). v1.5.
1477    pub resolved_total: usize,
1478    /// Lifetime count of findings silenced by a newly-added `fallow-ignore`.
1479    /// Reported as honest context, never as a win. v1.5.
1480    pub suppressed_total: usize,
1481    /// Most recent resolution events (newest last), capped for display. v1.5.
1482    pub recent_resolved: Vec<ResolutionEvent>,
1483    /// Whether per-finding attribution has a baseline yet. False on a freshly
1484    /// upgraded v1 store (no frontier captured), which the renderer uses to show
1485    /// "resolution tracking starts from your next run" instead of a bare zero.
1486    pub attribution_active: bool,
1487    /// Whether the local agent onboarding prompt has been explicitly declined.
1488    /// Stored in the user config dir (per project) so agents avoid cross-session
1489    /// nags without writing into the repo.
1490    pub onboarding_declined: bool,
1491    /// Whether the user ever made an explicit enable/disable decision for
1492    /// Impact tracking. `enabled: false` with `explicit_decision: false` means
1493    /// "never asked"; with `true` it means "asked and declined". Agents use
1494    /// this to offer the impact opt-in exactly once per project.
1495    pub explicit_decision: bool,
1496}
1497
1498/// Build a report from the store. Defensive: a single record (or none) yields
1499/// no trend rather than a spurious spike, and an empty store yields an empty
1500/// report flagged so the renderer can show the first-run message.
1501/// Trend between the two most recent records in a series. None until two records
1502/// exist; a missing prior record is "unknown" (no trend), never a spike.
1503fn trend_for(records: &[ImpactRecord]) -> Option<TrendSummary> {
1504    if records.len() < 2 {
1505        return None;
1506    }
1507    let current = &records[records.len() - 1];
1508    let previous = &records[records.len() - 2];
1509    let current_total = current.counts.total_issues;
1510    let previous_total = previous.counts.total_issues;
1511    let total_delta = current_total as i64 - previous_total as i64;
1512    Some(TrendSummary {
1513        direction: direction_for(total_delta),
1514        total_delta,
1515        previous_total,
1516        current_total,
1517    })
1518}
1519
1520pub fn build_report(store: &ImpactStore) -> ImpactReport {
1521    let surfacing = store.records.last().map(|r| r.counts.clone());
1522    let trend = trend_for(&store.records);
1523    let project_surfacing = store.project_records.last().map(|r| r.counts.clone());
1524    let project_trend = trend_for(&store.project_records);
1525
1526    let recent_containment = store
1527        .containment
1528        .iter()
1529        .rev()
1530        .take(5)
1531        .rev()
1532        .cloned()
1533        .collect();
1534
1535    let latest_git_sha = store.records.last().and_then(|r| r.git_sha.clone());
1536
1537    let recent_resolved = store
1538        .recent_resolved
1539        .iter()
1540        .rev()
1541        .take(5)
1542        .rev()
1543        .cloned()
1544        .collect();
1545    let attribution_active = !store.frontier.is_empty()
1546        || !store.clone_frontier.is_empty()
1547        || store.resolved_total > 0
1548        || store.suppressed_total > 0;
1549
1550    let (enabled, enabled_source) = resolve_enabled(store);
1551    ImpactReport {
1552        schema_version: ImpactReportSchemaVersion::V1,
1553        enabled,
1554        enabled_source,
1555        record_count: store.records.len(),
1556        meta: None,
1557        first_recorded: store.first_recorded.clone(),
1558        latest_git_sha,
1559        surfacing,
1560        trend,
1561        project_surfacing,
1562        project_trend,
1563        containment_count: store.containment.len(),
1564        recent_containment,
1565        resolved_total: store.resolved_total,
1566        suppressed_total: store.suppressed_total,
1567        recent_resolved,
1568        attribution_active,
1569        onboarding_declined: store.onboarding_declined,
1570        explicit_decision: store.explicit_decision,
1571    }
1572}
1573
1574/// Render the whole-project view for the human report. Deliberately understated
1575/// (one count line, one trend line, one caveat) rather than a co-equal header:
1576/// the project track advances only on local full `fallow` runs, not CI, so it is
1577/// context for the changed-file story above, not the headline. Renders nothing
1578/// when no full `fallow` run has been recorded yet.
1579#[expect(
1580    clippy::format_push_string,
1581    reason = "small report renderer; readability over avoiding the extra allocation"
1582)]
1583fn render_project_section(out: &mut String, report: &ImpactReport) {
1584    let Some(s) = &report.project_surfacing else {
1585        return;
1586    };
1587    out.push_str(&format!(
1588        "  WHOLE PROJECT (whole-repo context, not a to-do)\n    {} issue{} across the whole project at your last full `fallow` run\n",
1589        s.total_issues,
1590        plural(s.total_issues),
1591    ));
1592    if let Some(t) = &report.project_trend {
1593        let arrow = trend_arrow(t.direction);
1594        out.push_str(&format!(
1595            "    {} -> {} ({}) across your last two full runs (comparable over time)\n",
1596            t.previous_total, t.current_total, arrow,
1597        ));
1598    } else {
1599        out.push_str("    project trend starts after your next full `fallow` run\n");
1600    }
1601    out.push_str("      advances only on your local full `fallow` runs, not CI\n\n");
1602}
1603
1604/// Render the report as human-readable text.
1605#[expect(
1606    clippy::format_push_string,
1607    reason = "small report renderer; readability over avoiding the extra allocation"
1608)]
1609pub fn render_human(report: &ImpactReport) -> String {
1610    let mut out = String::new();
1611    out.push_str("FALLOW IMPACT\n\n");
1612
1613    if !report.enabled {
1614        out.push_str(
1615            "Impact tracking is off. Enable it with `fallow impact enable`, then\n\
1616             let your pre-commit gate run a few times to build history.\n",
1617        );
1618        return out;
1619    }
1620
1621    if report.enabled_source == EnabledSource::User {
1622        out.push_str(
1623            "Enabled by your user-global default (`fallow impact default on`). Run\n\
1624             `fallow impact disable` to opt this project out.\n\n",
1625        );
1626    }
1627
1628    if report.record_count == 0 && report.project_surfacing.is_none() {
1629        out.push_str(
1630            "Tracking enabled. No history yet: check back after your next few\n\
1631             commits (Impact records each `fallow audit` / pre-commit gate run,\n\
1632             and each full `fallow` run for the whole-project view).\n",
1633        );
1634        return out;
1635    }
1636
1637    if let Some(s) = &report.surfacing {
1638        out.push_str(&format!(
1639            "  LATEST RUN (changed files, act on these now)\n    {} issue{} flagged in your last `fallow audit` run\n",
1640            s.total_issues,
1641            plural(s.total_issues),
1642        ));
1643        out.push_str(&format!(
1644            "      dead code {}  ·  complexity {}  ·  duplication {}\n\n",
1645            s.dead_code, s.complexity, s.duplication,
1646        ));
1647    }
1648
1649    if let Some(t) = &report.trend {
1650        let arrow = trend_arrow(t.direction);
1651        out.push_str(&format!(
1652            "  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",
1653            t.previous_total, t.current_total, arrow,
1654        ));
1655    }
1656
1657    render_project_section(&mut out, report);
1658
1659    out.push_str(&format!(
1660        "  CONTAINED AT COMMIT\n    {} time{} fallow blocked a commit until it was fixed\n",
1661        report.containment_count,
1662        plural(report.containment_count),
1663    ));
1664
1665    if report.resolved_total > 0 {
1666        out.push_str(&format!(
1667            "\n  RESOLVED\n    {} finding{} you cleared since fallow started tracking\n",
1668            report.resolved_total,
1669            plural(report.resolved_total),
1670        ));
1671        for ev in &report.recent_resolved {
1672            match &ev.symbol {
1673                Some(symbol) => {
1674                    out.push_str(&format!("      {} {} in {}\n", ev.kind, symbol, ev.path));
1675                }
1676                None => out.push_str(&format!("      {} in {}\n", ev.kind, ev.path)),
1677            }
1678        }
1679    } else if report.attribution_active {
1680        out.push_str(
1681            "\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",
1682        );
1683    } else {
1684        out.push_str("\n  RESOLVED\n    resolution tracking starts from your next gate run\n");
1685    }
1686
1687    if report.suppressed_total > 0 {
1688        out.push_str(&format!(
1689            "      {} finding{} you marked intentional (fallow-ignore), not counted as resolved\n",
1690            report.suppressed_total,
1691            plural(report.suppressed_total),
1692        ));
1693    }
1694
1695    out.push('\n');
1696    let since = report
1697        .first_recorded
1698        .as_deref()
1699        .map_or("the first run", date_only);
1700    if report.record_count > 0 {
1701        out.push_str(&format!(
1702            "Based on {} recorded audit run{} since {}. Local-only; never uploaded.\n\
1703             Changed-file scope: each audit run only sees files differing from your base.\n",
1704            report.record_count,
1705            plural(report.record_count),
1706            since,
1707        ));
1708    } else {
1709        out.push_str(&format!(
1710            "Tracking since {since}. Local-only; never uploaded.\n",
1711        ));
1712    }
1713    out.push_str(
1714        "Resolution tracking is a local-developer signal: it accrues on your\n\
1715         machine across runs, not in CI (fallow never records there).\n",
1716    );
1717    out
1718}
1719
1720/// Render the report as JSON.
1721pub fn render_json(report: &ImpactReport) -> String {
1722    let value = crate::output_envelope::serialize_root_output(
1723        crate::output_envelope::FallowOutput::Impact(report.clone()),
1724    )
1725    .unwrap_or_else(|_| serde_json::json!({"error":"failed to serialize impact report"}));
1726    serde_json::to_string_pretty(&value)
1727        .unwrap_or_else(|_| "{\"error\":\"failed to serialize impact report\"}".to_owned())
1728}
1729
1730/// Render the whole-project view for the markdown report. One understated line
1731/// plus a trend line when available, matching the human renderer's framing.
1732/// Renders nothing when no full `fallow` run has been recorded yet.
1733#[expect(
1734    clippy::format_push_string,
1735    reason = "small report renderer; readability over avoiding the extra allocation"
1736)]
1737fn render_project_markdown(out: &mut String, report: &ImpactReport) {
1738    let Some(s) = &report.project_surfacing else {
1739        return;
1740    };
1741    out.push_str(&format!(
1742        "- **Whole project (whole-repo context, last full `fallow` run):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
1743        s.total_issues,
1744        plural(s.total_issues),
1745        s.dead_code,
1746        s.complexity,
1747        s.duplication,
1748    ));
1749    if let Some(t) = &report.project_trend {
1750        let arrow = trend_arrow(t.direction);
1751        out.push_str(&format!(
1752            "- **Project trend (whole project, last two full runs):** {} -> {} ({})\n",
1753            t.previous_total, t.current_total, arrow,
1754        ));
1755    }
1756}
1757
1758/// Render the report as Markdown (paste-ready for a PR description or standup).
1759#[expect(
1760    clippy::format_push_string,
1761    reason = "small report renderer; readability over avoiding the extra allocation"
1762)]
1763pub fn render_markdown(report: &ImpactReport) -> String {
1764    let mut out = String::new();
1765    out.push_str("## Fallow impact\n\n");
1766
1767    if !report.enabled {
1768        out.push_str("Impact tracking is off. Run `fallow impact enable` to start.\n");
1769        return out;
1770    }
1771    if report.record_count == 0 && report.project_surfacing.is_none() {
1772        out.push_str("Tracking enabled. No history yet; check back after a few commits.\n");
1773        return out;
1774    }
1775
1776    if let Some(s) = &report.surfacing {
1777        out.push_str(&format!(
1778            "- **Latest run (changed files):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
1779            s.total_issues,
1780            plural(s.total_issues),
1781            s.dead_code,
1782            s.complexity,
1783            s.duplication,
1784        ));
1785    }
1786    if let Some(t) = &report.trend {
1787        out.push_str(&format!(
1788            "- **Trend (changed-file scope, last two runs):** {} -> {} ({})\n",
1789            t.previous_total,
1790            t.current_total,
1791            trend_arrow(t.direction),
1792        ));
1793    }
1794    render_project_markdown(&mut out, report);
1795    out.push_str(&format!(
1796        "- **Contained at commit:** {} time{}\n",
1797        report.containment_count,
1798        plural(report.containment_count),
1799    ));
1800    if report.resolved_total > 0 {
1801        out.push_str(&format!(
1802            "- **Resolved:** {} finding{} cleared since tracking started\n",
1803            report.resolved_total,
1804            plural(report.resolved_total),
1805        ));
1806    } else if report.attribution_active {
1807        out.push_str("- **Resolved:** none yet; tracking active\n");
1808    } else {
1809        out.push_str("- **Resolved:** resolution tracking starts from your next gate run\n");
1810    }
1811    if report.suppressed_total > 0 {
1812        out.push_str(&format!(
1813            "- **Marked intentional:** {} finding{} (`fallow-ignore`), not counted as resolved\n",
1814            report.suppressed_total,
1815            plural(report.suppressed_total),
1816        ));
1817    }
1818    let since = report
1819        .first_recorded
1820        .as_deref()
1821        .map_or("the first run", date_only);
1822    if report.record_count > 0 {
1823        out.push_str(&format!(
1824            "\n_Based on {} recorded audit run{} since {}. Local-only; resolution is a local-developer signal._\n",
1825            report.record_count,
1826            plural(report.record_count),
1827            since,
1828        ));
1829    } else {
1830        out.push_str(&format!(
1831            "\n_Tracking since {since}. Local-only; resolution is a local-developer signal._\n",
1832        ));
1833    }
1834    out
1835}
1836
1837const fn plural(n: usize) -> &'static str {
1838    if n == 1 { "" } else { "s" }
1839}
1840
1841/// Trim a stored ISO-8601 timestamp (`2026-05-29T18:15:23Z`) to its date part
1842/// (`2026-05-29`) for human/markdown footers. The wall-clock time and `Z` add
1843/// noise without meaning when a reader just wants "tracking since when". JSON
1844/// keeps the full `first_recorded` timestamp. Returns the input unchanged if it
1845/// has no `T` separator.
1846fn date_only(ts: &str) -> &str {
1847    ts.split_once('T').map_or(ts, |(date, _)| date)
1848}
1849
1850/// Single human-facing trend vocabulary, shared by the text and markdown
1851/// renderers so the same concept does not read three different ways. The JSON
1852/// wire keeps the `improving`/`declining`/`stable` enum form for machines.
1853const fn trend_arrow(direction: ImpactTrendDirection) -> &'static str {
1854    match direction {
1855        ImpactTrendDirection::Improving => "down",
1856        ImpactTrendDirection::Declining => "up",
1857        ImpactTrendDirection::Stable => "flat",
1858    }
1859}
1860
1861#[cfg(test)]
1862mod tests {
1863    use super::*;
1864
1865    /// Per-test isolation: a fresh user-config dir (so the store never touches
1866    /// the real dir and parallel tests do not collide) plus a fresh project
1867    /// root. Bind BOTH returned `TempDir`s for the test's lifetime. The store
1868    /// for a non-git tempdir root keys on the canonical root, so each test's
1869    /// root is its own store.
1870    fn test_env() -> (tempfile::TempDir, tempfile::TempDir) {
1871        let config = tempfile::tempdir().unwrap();
1872        TEST_CONFIG_DIR.with(|c| *c.borrow_mut() = Some(config.path().to_path_buf()));
1873        let root = tempfile::tempdir().unwrap();
1874        (config, root)
1875    }
1876
1877    /// All frontier rel-paths across every worktree sub-map (tests use one
1878    /// root => one worktree key), for the v4 nested-frontier shape.
1879    fn frontier_paths(store: &ImpactStore) -> FxHashSet<String> {
1880        store
1881            .frontier
1882            .values()
1883            .flat_map(|m| m.keys().cloned())
1884            .collect()
1885    }
1886
1887    /// All clone fingerprints across every worktree sub-map.
1888    fn clone_fingerprints(store: &ImpactStore) -> FxHashSet<String> {
1889        store
1890            .clone_frontier
1891            .values()
1892            .flat_map(|m| m.keys().cloned())
1893            .collect()
1894    }
1895
1896    /// Seed raw bytes at the resolved (user-dir) store path, creating parent
1897    /// dirs, to exercise the load/parse path against hand-authored JSON.
1898    fn seed_store_raw(root: &Path, bytes: &[u8]) {
1899        let path = store_path(root).expect("test config dir set");
1900        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1901        std::fs::write(&path, bytes).unwrap();
1902    }
1903
1904    fn summary(dead: usize, complexity: usize, dupes: usize) -> AuditSummary {
1905        AuditSummary {
1906            dead_code_issues: dead,
1907            dead_code_has_errors: dead > 0,
1908            complexity_findings: complexity,
1909            max_cyclomatic: None,
1910            duplication_clone_groups: dupes,
1911        }
1912    }
1913
1914    /// Record a run with no per-finding attribution (v1 surfacing/trend/containment only).
1915    fn record_v1(
1916        root: &Path,
1917        summary: &AuditSummary,
1918        verdict: AuditVerdict,
1919        gate: bool,
1920        git_sha: Option<&str>,
1921        version: &str,
1922        timestamp: &str,
1923    ) {
1924        record_audit_run(
1925            root,
1926            summary,
1927            &AuditRunRecord {
1928                verdict,
1929                gate,
1930                git_sha,
1931                version,
1932                timestamp,
1933                attribution: None,
1934            },
1935        );
1936    }
1937
1938    /// Create a real file under `root` (attribution prunes frontier entries for
1939    /// files that no longer exist, so test files must exist on disk).
1940    fn touch(root: &Path, rel: &str) -> PathBuf {
1941        let p = root.join(rel);
1942        if let Some(parent) = p.parent() {
1943            std::fs::create_dir_all(parent).unwrap();
1944        }
1945        std::fs::write(&p, b"x").unwrap();
1946        p
1947    }
1948
1949    fn fi(path: &Path, kind: &'static str, symbol: &str) -> FindingInput {
1950        FindingInput {
1951            path: path.to_path_buf(),
1952            kind,
1953            symbol: Some(symbol.to_owned()),
1954        }
1955    }
1956
1957    fn supp(path: &Path, kind: &str) -> ActiveSuppression {
1958        ActiveSuppression {
1959            path: path.to_path_buf(),
1960            kind: Some(kind.to_owned()),
1961            is_file_level: false,
1962        }
1963    }
1964
1965    /// Record one attribution run against the store.
1966    fn run(
1967        root: &Path,
1968        changed: &[&Path],
1969        findings: Vec<FindingInput>,
1970        clones: Vec<CloneInput>,
1971        supps: &[ActiveSuppression],
1972        ts: &str,
1973    ) {
1974        let changed_files: Vec<PathBuf> = changed.iter().map(|p| p.to_path_buf()).collect();
1975        let input = AttributionInput {
1976            root,
1977            scope: Scope::ChangedFiles(&changed_files),
1978            findings,
1979            clones,
1980            suppressions: supps,
1981        };
1982        record_audit_run(
1983            root,
1984            &summary(0, 0, 0),
1985            &AuditRunRecord {
1986                verdict: AuditVerdict::Pass,
1987                gate: true,
1988                git_sha: Some("sha"),
1989                version: "2.0.0",
1990                timestamp: ts,
1991                attribution: Some(&input),
1992            },
1993        );
1994    }
1995
1996    #[test]
1997    fn disabled_store_does_not_record() {
1998        let (_config, dir) = test_env();
1999        let root = dir.path();
2000        record_v1(
2001            root,
2002            &summary(3, 1, 0),
2003            AuditVerdict::Fail,
2004            true,
2005            Some("abc1234"),
2006            "2.0.0",
2007            "2026-05-29T10:00:00Z",
2008        );
2009        let store = load(root);
2010        assert!(store.records.is_empty());
2011        assert!(!store.enabled);
2012    }
2013
2014    #[test]
2015    fn enable_and_disable_record_the_explicit_decision() {
2016        let (_config, dir) = test_env();
2017        let root = dir.path();
2018        assert!(!load(root).explicit_decision, "fresh store: never asked");
2019
2020        // Declining on a never-enabled project is an explicit decision too.
2021        disable(root);
2022        let store = load(root);
2023        assert!(!store.enabled);
2024        assert!(store.explicit_decision);
2025        assert!(build_report(&store).explicit_decision);
2026    }
2027
2028    #[test]
2029    fn due_digest_stamps_and_respects_interval_and_gates() {
2030        let (_config, dir) = test_env();
2031        let root = dir.path();
2032
2033        // Disabled, or enabled with zero value: never due.
2034        assert!(take_due_digest(root).is_none());
2035        enable(root);
2036        assert!(take_due_digest(root).is_none(), "zero counters never nag");
2037
2038        let mut store = load(root);
2039        store.resolved_total = 3;
2040        store.containment.push(ContainmentEvent {
2041            blocked_at: "2026-06-11T00:00:00Z".to_string(),
2042            cleared_at: "2026-06-11T00:05:00Z".to_string(),
2043            git_sha: None,
2044            blocked_counts: ImpactCounts::default(),
2045        });
2046        save(&store, root);
2047
2048        let digest = take_due_digest(root).expect("first digest is due");
2049        assert_eq!(digest.containment_count, 1);
2050        assert_eq!(digest.resolved_total, 3);
2051        assert!(
2052            take_due_digest(root).is_none(),
2053            "stamped: not due again within the interval"
2054        );
2055
2056        // An expired stamp makes it due again.
2057        let mut store = load(root);
2058        store.last_digest_epoch = Some(0);
2059        save(&store, root);
2060        assert!(take_due_digest(root).is_some());
2061    }
2062
2063    #[test]
2064    fn decline_onboarding_persists_in_existing_store() {
2065        let (_config, dir) = test_env();
2066        let root = dir.path();
2067
2068        assert!(decline_onboarding(root));
2069        assert!(!decline_onboarding(root));
2070
2071        let store = load(root);
2072        assert!(store.onboarding_declined);
2073        assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
2074        // Decline persists in the user store and writes nothing into the repo.
2075        assert!(!root.join(".gitignore").exists());
2076        let report = build_report(&store);
2077        assert!(report.onboarding_declined);
2078    }
2079
2080    #[test]
2081    fn enable_then_record_accrues_history() {
2082        let (_config, dir) = test_env();
2083        let root = dir.path();
2084        assert!(enable(root));
2085        assert!(!enable(root)); // second enable is a no-op-ish (already on)
2086        record_v1(
2087            root,
2088            &summary(2, 1, 0),
2089            AuditVerdict::Warn,
2090            false,
2091            None,
2092            "2.0.0",
2093            "2026-05-29T10:00:00Z",
2094        );
2095        let store = load(root);
2096        assert_eq!(store.records.len(), 1);
2097        assert_eq!(store.records[0].counts.total_issues, 3);
2098        assert_eq!(
2099            store.first_recorded.as_deref(),
2100            Some("2026-05-29T10:00:00Z")
2101        );
2102    }
2103
2104    #[test]
2105    fn record_is_a_noop_in_ci() {
2106        // Impact is local-dev-only: it must never record on CI. The suite itself
2107        // runs on CI (where `CI` / `GITHUB_ACTIONS` are set), so the gate uses a
2108        // per-test override instead of the ambient env; here we force it true to
2109        // prove the production no-op. Without the gate, an enabled project would
2110        // record on every CI run.
2111        let (_config, dir) = test_env();
2112        let root = dir.path();
2113        assert!(enable(root));
2114        TEST_FORCE_CI.with(|c| c.set(true));
2115        record_v1(
2116            root,
2117            &summary(2, 1, 0),
2118            AuditVerdict::Warn,
2119            false,
2120            None,
2121            "2.0.0",
2122            "2026-05-29T10:00:00Z",
2123        );
2124        TEST_FORCE_CI.with(|c| c.set(false));
2125        let store = load(root);
2126        assert_eq!(store.records.len(), 0, "impact must not record while in CI");
2127    }
2128
2129    #[test]
2130    fn enable_writes_nothing_into_the_repo() {
2131        let (_config, dir) = test_env();
2132        let root = dir.path();
2133        enable(root);
2134        // The user-store relocation means enable never touches the repo: no
2135        // .gitignore mutation and no in-repo .fallow/ dir.
2136        assert!(
2137            !root.join(".gitignore").exists(),
2138            "enable must not create or modify the repo's .gitignore"
2139        );
2140        assert!(
2141            !root.join(".fallow").exists(),
2142            "enable must not create an in-repo .fallow/ dir"
2143        );
2144        // The decision IS persisted, in the user store.
2145        let store = load(root);
2146        assert!(store.enabled);
2147        assert!(store.explicit_decision);
2148        assert!(resolve_enabled(&store).0);
2149    }
2150
2151    #[test]
2152    fn single_record_yields_no_trend_no_spike() {
2153        let mut store = ImpactStore {
2154            enabled: true,
2155            ..Default::default()
2156        };
2157        store.records.push(ImpactRecord {
2158            timestamp: "t0".into(),
2159            version: "2.0.0".into(),
2160            git_sha: None,
2161            verdict: "warn".into(),
2162            gate: false,
2163            counts: ImpactCounts {
2164                total_issues: 5,
2165                dead_code: 5,
2166                complexity: 0,
2167                duplication: 0,
2168            },
2169        });
2170        let report = build_report(&store);
2171        assert!(report.trend.is_none());
2172        assert_eq!(report.surfacing.unwrap().total_issues, 5);
2173    }
2174
2175    #[test]
2176    fn empty_store_report_is_first_run() {
2177        let store = ImpactStore::default();
2178        let report = build_report(&store);
2179        assert_eq!(report.record_count, 0);
2180        assert!(report.trend.is_none());
2181        assert!(report.surfacing.is_none());
2182        let human = render_human(&report);
2183        assert!(human.contains("off")); // default store is disabled
2184    }
2185
2186    #[test]
2187    fn enabled_empty_store_shows_check_back() {
2188        let store = ImpactStore {
2189            enabled: true,
2190            ..Default::default()
2191        };
2192        let report = build_report(&store);
2193        let human = render_human(&report);
2194        assert!(human.contains("No history yet"));
2195        assert!(!human.contains("0 issues"));
2196    }
2197
2198    #[test]
2199    fn trend_improving_when_issues_drop() {
2200        let mut store = ImpactStore {
2201            enabled: true,
2202            ..Default::default()
2203        };
2204        for total in [8usize, 3usize] {
2205            store.records.push(ImpactRecord {
2206                timestamp: format!("t{total}"),
2207                version: "2.0.0".into(),
2208                git_sha: None,
2209                verdict: "warn".into(),
2210                gate: false,
2211                counts: ImpactCounts {
2212                    total_issues: total,
2213                    dead_code: total,
2214                    complexity: 0,
2215                    duplication: 0,
2216                },
2217            });
2218        }
2219        let report = build_report(&store);
2220        let trend = report.trend.unwrap();
2221        assert_eq!(trend.direction, ImpactTrendDirection::Improving);
2222        assert_eq!(trend.total_delta, -5);
2223    }
2224
2225    #[test]
2226    fn containment_blocked_then_cleared_records_one_event() {
2227        let (_config, dir) = test_env();
2228        let root = dir.path();
2229        enable(root);
2230        record_v1(
2231            root,
2232            &summary(2, 0, 0),
2233            AuditVerdict::Fail,
2234            true,
2235            Some("sha1"),
2236            "2.0.0",
2237            "t0",
2238        );
2239        let store = load(root);
2240        assert!(store.pending_containment.is_some());
2241        assert!(store.containment.is_empty());
2242
2243        record_v1(
2244            root,
2245            &summary(0, 0, 0),
2246            AuditVerdict::Pass,
2247            true,
2248            Some("sha2"),
2249            "2.0.0",
2250            "t1",
2251        );
2252        let store = load(root);
2253        assert!(store.pending_containment.is_none());
2254        assert_eq!(store.containment.len(), 1);
2255        assert_eq!(store.containment[0].blocked_at, "t0");
2256        assert_eq!(store.containment[0].cleared_at, "t1");
2257    }
2258
2259    #[test]
2260    fn non_gate_run_never_creates_containment() {
2261        let (_config, dir) = test_env();
2262        let root = dir.path();
2263        enable(root);
2264        record_v1(
2265            root,
2266            &summary(2, 0, 0),
2267            AuditVerdict::Fail,
2268            false,
2269            None,
2270            "2.0.0",
2271            "t0",
2272        );
2273        let store = load(root);
2274        assert!(store.pending_containment.is_none());
2275        assert!(store.containment.is_empty());
2276    }
2277
2278    #[test]
2279    fn corrupt_store_loads_as_default_no_panic() {
2280        let (_config, dir) = test_env();
2281        let root = dir.path();
2282        seed_store_raw(root, b"{ not valid json ][");
2283        let store = load(root);
2284        assert!(!store.enabled);
2285        assert!(store.records.is_empty());
2286        record_v1(
2287            root,
2288            &summary(1, 0, 0),
2289            AuditVerdict::Fail,
2290            true,
2291            None,
2292            "2.0.0",
2293            "t0",
2294        );
2295    }
2296
2297    #[test]
2298    fn records_are_bounded() {
2299        let mut store = ImpactStore {
2300            enabled: true,
2301            ..Default::default()
2302        };
2303        for i in 0..(MAX_RECORDS + 50) {
2304            store.records.push(ImpactRecord {
2305                timestamp: format!("t{i}"),
2306                version: "2.0.0".into(),
2307                git_sha: None,
2308                verdict: "pass".into(),
2309                gate: false,
2310                counts: ImpactCounts::default(),
2311            });
2312        }
2313        compact(&mut store);
2314        assert_eq!(store.records.len(), MAX_RECORDS);
2315        assert_eq!(store.records[0].timestamp, "t50");
2316    }
2317
2318    #[test]
2319    fn report_always_carries_schema_version() {
2320        let empty = build_report(&ImpactStore::default());
2321        assert_eq!(empty.schema_version, ImpactReportSchemaVersion::V1);
2322        let json = render_json(&empty);
2323        assert!(
2324            json.contains("\"schema_version\": \"1\""),
2325            "schema_version must be present (as the \"1\" const) even when disabled: {json}"
2326        );
2327
2328        let mut store = ImpactStore {
2329            enabled: true,
2330            ..Default::default()
2331        };
2332        store.records.push(ImpactRecord {
2333            timestamp: "2026-05-29T10:00:00Z".into(),
2334            version: "2.0.0".into(),
2335            git_sha: None,
2336            verdict: "pass".into(),
2337            gate: false,
2338            counts: ImpactCounts::default(),
2339        });
2340        assert_eq!(
2341            build_report(&store).schema_version,
2342            ImpactReportSchemaVersion::V1
2343        );
2344    }
2345
2346    #[test]
2347    fn date_only_trims_iso_timestamp() {
2348        assert_eq!(date_only("2026-05-29T18:15:23Z"), "2026-05-29");
2349        assert_eq!(date_only("2026-05-29"), "2026-05-29");
2350        assert_eq!(date_only("the first run"), "the first run");
2351    }
2352
2353    #[test]
2354    fn human_footer_shows_date_only() {
2355        let mut store = ImpactStore {
2356            enabled: true,
2357            ..Default::default()
2358        };
2359        store.first_recorded = Some("2026-05-29T18:15:23Z".into());
2360        store.records.push(ImpactRecord {
2361            timestamp: "2026-05-29T18:15:23Z".into(),
2362            version: "2.0.0".into(),
2363            git_sha: None,
2364            verdict: "pass".into(),
2365            gate: false,
2366            counts: ImpactCounts::default(),
2367        });
2368        let report = build_report(&store);
2369        let human = render_human(&report);
2370        assert!(
2371            human.contains("since 2026-05-29.") && !human.contains("18:15:23"),
2372            "human footer must show date-only: {human}"
2373        );
2374        let md = render_markdown(&report);
2375        assert!(
2376            md.contains("since 2026-05-29.") && !md.contains("18:15:23"),
2377            "markdown footer must show date-only: {md}"
2378        );
2379    }
2380
2381    #[test]
2382    fn future_schema_version_store_loads_without_panic_or_loss() {
2383        let (_config, dir) = test_env();
2384        let root = dir.path();
2385        let future = format!(
2386            "{{\"schema_version\":{},\"enabled\":true,\"records\":[],\"containment\":[]}}",
2387            STORE_SCHEMA_VERSION + 1
2388        );
2389        seed_store_raw(root, future.as_bytes());
2390        let store = load(root);
2391        assert_eq!(store.schema_version, STORE_SCHEMA_VERSION + 1);
2392        assert!(
2393            store.enabled,
2394            "future-version store must not degrade to default"
2395        );
2396    }
2397
2398    #[test]
2399    fn removed_finding_is_credited_as_resolved() {
2400        let (_config, dir) = test_env();
2401        let root = dir.path();
2402        enable(root);
2403        let a = touch(root, "src/a.ts");
2404        run(
2405            root,
2406            &[&a],
2407            vec![fi(&a, "unused-export", "foo")],
2408            vec![],
2409            &[],
2410            "t0",
2411        );
2412        assert_eq!(
2413            load(root).resolved_total,
2414            0,
2415            "first run only establishes a baseline"
2416        );
2417        run(root, &[&a], vec![], vec![], &[], "t1");
2418        let store = load(root);
2419        assert_eq!(store.resolved_total, 1);
2420        assert_eq!(store.suppressed_total, 0);
2421        assert_eq!(store.recent_resolved.len(), 1);
2422        assert_eq!(store.recent_resolved[0].kind, "unused-export");
2423        assert_eq!(store.recent_resolved[0].symbol.as_deref(), Some("foo"));
2424        assert_eq!(store.recent_resolved[0].path, "src/a.ts");
2425    }
2426
2427    #[test]
2428    fn suppressed_finding_is_not_a_win() {
2429        let (_config, dir) = test_env();
2430        let root = dir.path();
2431        enable(root);
2432        let a = touch(root, "src/a.ts");
2433        run(
2434            root,
2435            &[&a],
2436            vec![fi(&a, "unused-export", "foo")],
2437            vec![],
2438            &[],
2439            "t0",
2440        );
2441        run(
2442            root,
2443            &[&a],
2444            vec![],
2445            vec![],
2446            &[supp(&a, "unused-export")],
2447            "t1",
2448        );
2449        let store = load(root);
2450        assert_eq!(
2451            store.resolved_total, 0,
2452            "a suppression must never count as a win"
2453        );
2454        assert_eq!(store.suppressed_total, 1);
2455    }
2456
2457    #[test]
2458    fn fix_and_suppress_same_kind_credits_zero_resolved() {
2459        let (_config, dir) = test_env();
2460        let root = dir.path();
2461        enable(root);
2462        let a = touch(root, "src/a.ts");
2463        run(
2464            root,
2465            &[&a],
2466            vec![
2467                fi(&a, "unused-export", "foo"),
2468                fi(&a, "unused-export", "bar"),
2469            ],
2470            vec![],
2471            &[],
2472            "t0",
2473        );
2474        run(
2475            root,
2476            &[&a],
2477            vec![],
2478            vec![],
2479            &[supp(&a, "unused-export")],
2480            "t1",
2481        );
2482        let store = load(root);
2483        assert_eq!(store.resolved_total, 0);
2484        assert_eq!(store.suppressed_total, 2);
2485    }
2486
2487    #[test]
2488    fn within_file_move_is_not_resolved() {
2489        let (_config, dir) = test_env();
2490        let root = dir.path();
2491        enable(root);
2492        let a = touch(root, "src/a.ts");
2493        run(
2494            root,
2495            &[&a],
2496            vec![fi(&a, "unused-export", "foo")],
2497            vec![],
2498            &[],
2499            "t0",
2500        );
2501        run(
2502            root,
2503            &[&a],
2504            vec![fi(&a, "unused-export", "foo")],
2505            vec![],
2506            &[],
2507            "t1",
2508        );
2509        let store = load(root);
2510        assert_eq!(store.resolved_total, 0);
2511        assert_eq!(store.suppressed_total, 0);
2512    }
2513
2514    #[test]
2515    fn cross_file_move_in_same_run_is_not_resolved() {
2516        let (_config, dir) = test_env();
2517        let root = dir.path();
2518        enable(root);
2519        let a = touch(root, "src/a.ts");
2520        let b = touch(root, "src/b.ts");
2521        run(
2522            root,
2523            &[&a],
2524            vec![fi(&a, "unused-export", "foo")],
2525            vec![],
2526            &[],
2527            "t0",
2528        );
2529        run(
2530            root,
2531            &[&a, &b],
2532            vec![fi(&b, "unused-export", "foo")],
2533            vec![],
2534            &[],
2535            "t1",
2536        );
2537        assert_eq!(
2538            load(root).resolved_total,
2539            0,
2540            "a cross-file move is not a resolution"
2541        );
2542    }
2543
2544    #[test]
2545    fn cross_run_move_uncredits_the_prior_resolution() {
2546        let (_config, dir) = test_env();
2547        let root = dir.path();
2548        enable(root);
2549        let a = touch(root, "src/a.ts");
2550        let b = touch(root, "src/b.ts");
2551        run(
2552            root,
2553            &[&a],
2554            vec![fi(&a, "unused-export", "foo")],
2555            vec![],
2556            &[],
2557            "t0",
2558        );
2559        run(root, &[&a], vec![], vec![], &[], "t1");
2560        assert_eq!(
2561            load(root).resolved_total,
2562            1,
2563            "source disappearance credited in run A"
2564        );
2565        run(
2566            root,
2567            &[&b],
2568            vec![fi(&b, "unused-export", "foo")],
2569            vec![],
2570            &[],
2571            "t2",
2572        );
2573        let store = load(root);
2574        assert_eq!(
2575            store.resolved_total, 0,
2576            "cross-run move must un-credit the phantom win"
2577        );
2578        assert!(
2579            store.recent_resolved.is_empty(),
2580            "the stale resolution event is dropped"
2581        );
2582    }
2583
2584    #[test]
2585    fn resolved_complexity_finding_and_suppressed_complexity() {
2586        let (_config, dir) = test_env();
2587        let root = dir.path();
2588        enable(root);
2589        let a = touch(root, "src/a.ts");
2590        run(
2591            root,
2592            &[&a],
2593            vec![fi(&a, "complexity", "bigFn")],
2594            vec![],
2595            &[],
2596            "t0",
2597        );
2598        run(root, &[&a], vec![], vec![], &[supp(&a, "complexity")], "t1");
2599        let store = load(root);
2600        assert_eq!(store.resolved_total, 0);
2601        assert_eq!(store.suppressed_total, 1);
2602
2603        let b = touch(root, "src/b.ts");
2604        run(
2605            root,
2606            &[&b],
2607            vec![fi(&b, "complexity", "huge")],
2608            vec![],
2609            &[],
2610            "t2",
2611        );
2612        run(root, &[&b], vec![], vec![], &[], "t3");
2613        assert_eq!(load(root).resolved_total, 1);
2614    }
2615
2616    #[test]
2617    fn resolved_duplication_clone_group() {
2618        let (_config, dir) = test_env();
2619        let root = dir.path();
2620        enable(root);
2621        let a = touch(root, "src/a.ts");
2622        let b = touch(root, "src/b.ts");
2623        let clone = CloneInput {
2624            fingerprint: "dup:abc12345".to_owned(),
2625            instance_paths: vec![a.clone(), b],
2626        };
2627        run(root, &[&a], vec![], vec![clone], &[], "t0");
2628        run(root, &[&a], vec![], vec![], &[], "t1");
2629        let store = load(root);
2630        assert_eq!(store.resolved_total, 1);
2631        assert_eq!(store.recent_resolved[0].kind, "code-duplication");
2632    }
2633
2634    #[test]
2635    fn blanket_suppression_covers_any_kind() {
2636        let (_config, dir) = test_env();
2637        let root = dir.path();
2638        enable(root);
2639        let a = touch(root, "src/a.ts");
2640        run(
2641            root,
2642            &[&a],
2643            vec![fi(&a, "unused-export", "foo")],
2644            vec![],
2645            &[],
2646            "t0",
2647        );
2648        let blanket = ActiveSuppression {
2649            path: a.clone(),
2650            kind: None,
2651            is_file_level: true,
2652        };
2653        run(root, &[&a], vec![], vec![], &[blanket], "t1");
2654        let store = load(root);
2655        assert_eq!(store.resolved_total, 0);
2656        assert_eq!(store.suppressed_total, 1);
2657    }
2658
2659    #[test]
2660    fn v1_store_loads_and_upgrades_to_v2() {
2661        let (_config, dir) = test_env();
2662        let root = dir.path();
2663        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":[]}"#;
2664        seed_store_raw(root, v1.as_bytes());
2665        let store = load(root);
2666        assert_eq!(store.schema_version, 1);
2667        assert!(store.frontier.is_empty());
2668        assert_eq!(store.resolved_total, 0);
2669        let a = touch(root, "src/a.ts");
2670        run(
2671            root,
2672            &[&a],
2673            vec![fi(&a, "unused-export", "foo")],
2674            vec![],
2675            &[],
2676            "t1",
2677        );
2678        let store = load(root);
2679        assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
2680        assert!(frontier_paths(&store).contains("src/a.ts"));
2681    }
2682
2683    #[test]
2684    fn recent_resolved_is_bounded() {
2685        let mut store = ImpactStore {
2686            enabled: true,
2687            ..Default::default()
2688        };
2689        for i in 0..(MAX_RECENT_RESOLVED + 25) {
2690            store.recent_resolved.push(ResolutionEvent {
2691                kind: "unused-export".into(),
2692                path: format!("src/f{i}.ts"),
2693                symbol: Some(format!("s{i}")),
2694                git_sha: None,
2695                timestamp: format!("t{i}"),
2696            });
2697        }
2698        bound_recent_resolved(&mut store);
2699        assert_eq!(store.recent_resolved.len(), MAX_RECENT_RESOLVED);
2700        assert_eq!(store.recent_resolved[0].path, "src/f25.ts");
2701    }
2702
2703    #[test]
2704    fn frontier_prunes_deleted_files() {
2705        let (_config, dir) = test_env();
2706        let root = dir.path();
2707        enable(root);
2708        let a = touch(root, "src/a.ts");
2709        run(
2710            root,
2711            &[&a],
2712            vec![fi(&a, "unused-export", "foo")],
2713            vec![],
2714            &[],
2715            "t0",
2716        );
2717        assert!(frontier_paths(&load(root)).contains("src/a.ts"));
2718        std::fs::remove_file(&a).unwrap();
2719        let b = touch(root, "src/b.ts");
2720        run(root, &[&b], vec![], vec![], &[], "t1");
2721        assert!(!frontier_paths(&load(root)).contains("src/a.ts"));
2722    }
2723
2724    #[test]
2725    fn honest_empty_state_before_attribution_baseline() {
2726        let store = ImpactStore {
2727            enabled: true,
2728            records: vec![ImpactRecord {
2729                timestamp: "t0".into(),
2730                version: "2.0.0".into(),
2731                git_sha: None,
2732                verdict: "warn".into(),
2733                gate: false,
2734                counts: ImpactCounts::default(),
2735            }],
2736            ..Default::default()
2737        };
2738        let report = build_report(&store);
2739        assert!(!report.attribution_active);
2740        let human = render_human(&report);
2741        assert!(human.contains("resolution tracking starts from your next gate run"));
2742        assert!(!human.contains("0 finding"));
2743    }
2744
2745    #[test]
2746    fn suppression_only_state_renders_under_a_resolved_header() {
2747        let report = ImpactReport {
2748            schema_version: ImpactReportSchemaVersion::V1,
2749            enabled: true,
2750            enabled_source: EnabledSource::Project,
2751            record_count: 2,
2752            meta: None,
2753            first_recorded: Some("2026-05-29T10:00:00Z".into()),
2754            latest_git_sha: None,
2755            surfacing: Some(ImpactCounts::default()),
2756            trend: None,
2757            project_surfacing: None,
2758            project_trend: None,
2759            containment_count: 0,
2760            recent_containment: vec![],
2761            resolved_total: 0,
2762            suppressed_total: 2,
2763            recent_resolved: vec![],
2764            attribution_active: true,
2765            onboarding_declined: false,
2766            explicit_decision: false,
2767        };
2768        let human = render_human(&report);
2769        let resolved_idx = human.find("  RESOLVED").expect("RESOLVED header present");
2770        let supp_idx = human
2771            .find("2 findings you marked intentional")
2772            .expect("suppression line present");
2773        assert!(
2774            resolved_idx < supp_idx,
2775            "suppression must render under RESOLVED"
2776        );
2777        assert!(human.contains("none yet"));
2778
2779        let md = render_markdown(&report);
2780        assert!(
2781            md.contains("- **Resolved:**"),
2782            "markdown always has a Resolved bullet"
2783        );
2784        assert!(md.contains("- **Marked intentional:** 2 finding"));
2785    }
2786
2787    /// Build a `CloneInput` over real absolute paths (built from `root`).
2788    fn clone_at(fingerprint: &str, paths: &[&Path]) -> CloneInput {
2789        CloneInput {
2790            fingerprint: fingerprint.to_owned(),
2791            instance_paths: paths.iter().map(|p| p.to_path_buf()).collect(),
2792        }
2793    }
2794
2795    /// Record a WHOLE-PROJECT run via the real combined-track recorder
2796    /// (`record_combined_run` with `Scope::WholeProject`), exercising the same
2797    /// path `combined.rs` uses on a full `fallow` run.
2798    fn run_wp(
2799        root: &Path,
2800        findings: Vec<FindingInput>,
2801        clones: Vec<CloneInput>,
2802        supps: &[ActiveSuppression],
2803        ts: &str,
2804    ) {
2805        let input = AttributionInput {
2806            root,
2807            scope: Scope::WholeProject,
2808            findings,
2809            clones,
2810            suppressions: supps,
2811        };
2812        record_combined_run(
2813            root,
2814            ImpactCounts::default(),
2815            Some("sha"),
2816            "2.0.0",
2817            ts,
2818            Some(&input),
2819        );
2820    }
2821
2822    #[test]
2823    fn whole_project_run_does_not_double_credit_after_audit() {
2824        let (_config, dir) = test_env();
2825        let root = dir.path();
2826        enable(root);
2827        let a = touch(root, "src/a.ts");
2828        let b = touch(root, "src/b.ts");
2829        run(
2830            root,
2831            &[&a, &b],
2832            vec![],
2833            vec![clone_at("dup:abc", &[&a, &b])],
2834            &[],
2835            "t1",
2836        );
2837        assert_eq!(clone_fingerprints(&load(root)).len(), 1);
2838
2839        run(root, &[&a, &b], vec![], vec![], &[], "t2");
2840        assert_eq!(load(root).resolved_total, 1);
2841        assert!(load(root).clone_frontier.is_empty());
2842
2843        run_wp(root, vec![], vec![], &[], "t3");
2844        assert_eq!(
2845            load(root).resolved_total,
2846            1,
2847            "whole-project run re-credited a resolution"
2848        );
2849    }
2850
2851    #[test]
2852    fn whole_project_run_credits_suppressed_not_resolved() {
2853        let (_config, dir) = test_env();
2854        let root = dir.path();
2855        enable(root);
2856        let util = touch(root, "src/util.ts");
2857        run(
2858            root,
2859            &[&util],
2860            vec![fi(&util, "unused-export", "dead")],
2861            vec![],
2862            &[],
2863            "t1",
2864        );
2865        assert_eq!(frontier_paths(&load(root)).len(), 1);
2866
2867        run_wp(root, vec![], vec![], &[supp(&util, "unused-export")], "t2");
2868        let store = load(root);
2869        assert_eq!(
2870            store.suppressed_total, 1,
2871            "suppressed finding not counted suppressed"
2872        );
2873        assert_eq!(
2874            store.resolved_total, 0,
2875            "suppressed finding wrongly counted resolved"
2876        );
2877    }
2878
2879    #[test]
2880    fn clone_reshape_three_to_two_not_credited_as_resolved() {
2881        let (_config, dir) = test_env();
2882        let root = dir.path();
2883        enable(root);
2884        let a = touch(root, "src/a.ts");
2885        let b = touch(root, "src/b.ts");
2886        let c = touch(root, "src/c.ts");
2887        run(
2888            root,
2889            &[&a, &b, &c],
2890            vec![],
2891            vec![clone_at("dup:aaa", &[&a, &b, &c])],
2892            &[],
2893            "t1",
2894        );
2895        assert_eq!(clone_fingerprints(&load(root)).len(), 1);
2896
2897        run_wp(
2898            root,
2899            vec![],
2900            vec![clone_at("dup:bbb", &[&a, &b])],
2901            &[],
2902            "t2",
2903        );
2904        let store = load(root);
2905        assert_eq!(
2906            store.resolved_total, 0,
2907            "clone reshape miscredited as resolved"
2908        );
2909        assert!(clone_fingerprints(&store).contains("dup:bbb"));
2910        assert!(!clone_fingerprints(&store).contains("dup:aaa"));
2911    }
2912
2913    fn rcounts(total: usize, dead: usize, complexity: usize, dup: usize) -> ImpactCounts {
2914        ImpactCounts {
2915            total_issues: total,
2916            dead_code: dead,
2917            complexity,
2918            duplication: dup,
2919        }
2920    }
2921
2922    fn rtrend(prev: usize, cur: usize) -> TrendSummary {
2923        TrendSummary {
2924            direction: direction_for(cur as i64 - prev as i64),
2925            total_delta: cur as i64 - prev as i64,
2926            previous_total: prev,
2927            current_total: cur,
2928        }
2929    }
2930
2931    /// Build a report literal for render-state tests.
2932    fn rreport(
2933        record_count: usize,
2934        first_recorded: Option<&str>,
2935        surfacing: Option<ImpactCounts>,
2936        trend: Option<TrendSummary>,
2937        project_surfacing: Option<ImpactCounts>,
2938        project_trend: Option<TrendSummary>,
2939        attribution_active: bool,
2940    ) -> ImpactReport {
2941        ImpactReport {
2942            schema_version: ImpactReportSchemaVersion::V1,
2943            enabled: true,
2944            enabled_source: EnabledSource::Project,
2945            record_count,
2946            meta: None,
2947            first_recorded: first_recorded.map(ToOwned::to_owned),
2948            latest_git_sha: None,
2949            surfacing,
2950            trend,
2951            project_surfacing,
2952            project_trend,
2953            containment_count: 0,
2954            recent_containment: vec![],
2955            resolved_total: 0,
2956            suppressed_total: 0,
2957            recent_resolved: vec![],
2958            attribution_active,
2959            onboarding_declined: false,
2960            explicit_decision: false,
2961        }
2962    }
2963
2964    #[test]
2965    fn render_human_project_only_store_shows_whole_project_not_empty_state() {
2966        let r = rreport(
2967            0,
2968            Some("2026-05-30T10:00:00Z"),
2969            None,
2970            None,
2971            Some(rcounts(1, 1, 0, 0)),
2972            None,
2973            true,
2974        );
2975        let human = render_human(&r);
2976        assert!(
2977            human.contains("WHOLE PROJECT (whole-repo context, not a to-do)"),
2978            "project-only must render the labeled section"
2979        );
2980        assert!(human.contains("1 issue across the whole project"));
2981        assert!(
2982            human.contains("project trend starts after your next full `fallow` run"),
2983            "single project record => no trend line, shows the next-run hint"
2984        );
2985        assert!(human.contains("Tracking since 2026-05-30"));
2986        assert!(
2987            !human.contains("No history yet"),
2988            "must not show the empty-state copy"
2989        );
2990        assert!(
2991            !human.contains("LATEST RUN"),
2992            "no changed-file track recorded"
2993        );
2994        assert!(
2995            !human.contains("recorded audit run"),
2996            "no audit runs => no changed-file footer"
2997        );
2998    }
2999
3000    #[test]
3001    fn render_human_both_tracks_label_actionable_vs_context() {
3002        let r = rreport(
3003            3,
3004            Some("2026-05-29T10:00:00Z"),
3005            Some(rcounts(4, 4, 0, 0)),
3006            Some(rtrend(6, 4)),
3007            Some(rcounts(40, 30, 5, 5)),
3008            Some(rtrend(45, 40)),
3009            true,
3010        );
3011        let human = render_human(&r);
3012        let latest = human
3013            .find("LATEST RUN (changed files, act on these now)")
3014            .expect("LATEST RUN labeled actionable");
3015        let whole = human
3016            .find("WHOLE PROJECT (whole-repo context, not a to-do)")
3017            .expect("WHOLE PROJECT labeled context");
3018        assert!(
3019            latest < whole,
3020            "changed-file section renders before whole-project"
3021        );
3022        assert!(human.contains("45 -> 40 (down) across your last two full runs"));
3023        assert!(human.contains("advances only on your local full `fallow` runs, not CI"));
3024    }
3025
3026    #[test]
3027    fn render_markdown_project_only_store_shows_whole_project_not_empty_state() {
3028        let r = rreport(
3029            0,
3030            Some("2026-05-30T10:00:00Z"),
3031            None,
3032            None,
3033            Some(rcounts(1, 1, 0, 0)),
3034            None,
3035            true,
3036        );
3037        let md = render_markdown(&r);
3038        assert!(
3039            md.contains(
3040                "- **Whole project (whole-repo context, last full `fallow` run):** 1 issue"
3041            ),
3042            "project-only md must render the labeled whole-project line"
3043        );
3044        assert!(
3045            !md.contains("No history yet"),
3046            "project-only md must not show empty state"
3047        );
3048        assert!(md.contains("Tracking since 2026-05-30"));
3049    }
3050
3051    #[test]
3052    fn resolve_enabled_precedence_table() {
3053        let (_config, _dir) = test_env();
3054        // enabled-true is an explicit project opt-in regardless of the flag.
3055        let on = ImpactStore {
3056            enabled: true,
3057            ..Default::default()
3058        };
3059        assert_eq!(resolve_enabled(&on), (true, EnabledSource::Project));
3060
3061        // explicitly disabled here stays off as a Project decision.
3062        let off_explicit = ImpactStore {
3063            enabled: false,
3064            explicit_decision: true,
3065            ..Default::default()
3066        };
3067        assert_eq!(
3068            resolve_enabled(&off_explicit),
3069            (false, EnabledSource::Project)
3070        );
3071
3072        // never-asked + no global default => off (Default).
3073        let never = ImpactStore::default();
3074        assert_eq!(resolve_enabled(&never), (false, EnabledSource::Default));
3075
3076        // never-asked + global default on => on (User).
3077        assert!(set_global_default(true));
3078        assert_eq!(resolve_enabled(&never), (true, EnabledSource::User));
3079        // a per-repo disable still wins over the global default.
3080        assert_eq!(
3081            resolve_enabled(&off_explicit),
3082            (false, EnabledSource::Project)
3083        );
3084    }
3085
3086    #[test]
3087    fn human_report_explains_user_global_default() {
3088        let (_config, _dir) = test_env();
3089        set_global_default(true);
3090        // A never-asked store resolved on a project: enabled via the global default.
3091        let report = build_report(&ImpactStore::default());
3092        assert_eq!(report.enabled_source, EnabledSource::User);
3093        let human = render_human(&report);
3094        assert!(
3095            human.contains("Enabled by your user-global default"),
3096            "human report must explain a global-default enable: {human}"
3097        );
3098        // A project-enabled report does NOT show the global-default note.
3099        let project = build_report(&ImpactStore {
3100            enabled: true,
3101            explicit_decision: true,
3102            ..Default::default()
3103        });
3104        assert_eq!(project.enabled_source, EnabledSource::Project);
3105        assert!(!render_human(&project).contains("user-global default"));
3106    }
3107
3108    #[test]
3109    fn global_default_round_trips() {
3110        let (_config, _dir) = test_env();
3111        assert!(!load_global_default());
3112        assert!(set_global_default(true));
3113        assert!(load_global_default());
3114        assert!(!set_global_default(true)); // unchanged
3115        assert!(set_global_default(false));
3116        assert!(!load_global_default());
3117    }
3118
3119    #[test]
3120    fn global_default_records_without_per_repo_enable() {
3121        let (_config, dir) = test_env();
3122        let root = dir.path();
3123        set_global_default(true);
3124        // No `enable(root)` call: the global default alone should activate.
3125        record_v1(
3126            root,
3127            &summary(2, 0, 0),
3128            AuditVerdict::Warn,
3129            false,
3130            None,
3131            "2.0.0",
3132            "t0",
3133        );
3134        let report = build_report(&load(root));
3135        assert!(report.enabled);
3136        assert_eq!(report.enabled_source, EnabledSource::User);
3137        assert_eq!(report.record_count, 1);
3138    }
3139
3140    #[test]
3141    fn legacy_in_repo_store_is_migrated_on_first_load() {
3142        let (_config, dir) = test_env();
3143        let root = dir.path();
3144        // Seed a pre-relocation v3 store with a FLAT frontier in the repo.
3145        let legacy = r#"{"schema_version":3,"enabled":true,"explicit_decision":true,
3146            "records":[{"timestamp":"t0","version":"2.0.0","verdict":"warn","gate":false,
3147            "counts":{"total_issues":1,"dead_code":1,"complexity":0,"duplication":0}}],
3148            "resolved_total":2,
3149            "frontier":{"src/a.ts":{"findings":[{"id":"x","kind":"unused-export","symbol":"foo"}],"suppressions":[]}},
3150            "containment":[]}"#;
3151        std::fs::create_dir_all(root.join(".fallow")).unwrap();
3152        std::fs::write(legacy_store_path(root), legacy).unwrap();
3153
3154        let store = load(root);
3155        assert!(store.enabled);
3156        assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
3157        assert_eq!(store.records.len(), 1);
3158        assert_eq!(store.resolved_total, 2);
3159        // The flat frontier was wrapped under the worktree key (nested v4 shape).
3160        assert!(frontier_paths(&store).contains("src/a.ts"));
3161        // The user store now exists, so a second load does NOT re-import (it
3162        // reads the user store directly).
3163        assert!(store_path(root).is_some_and(|p| p.exists()));
3164        let again = load(root);
3165        assert_eq!(again.records.len(), 1);
3166    }
3167
3168    #[test]
3169    fn reset_removes_only_this_project() {
3170        let (_config, dir) = test_env();
3171        let root = dir.path();
3172        enable(root);
3173        record_v1(
3174            root,
3175            &summary(1, 0, 0),
3176            AuditVerdict::Warn,
3177            false,
3178            None,
3179            "2.0.0",
3180            "t0",
3181        );
3182        assert_eq!(load(root).records.len(), 1);
3183        assert!(reset(root));
3184        assert!(load(root).records.is_empty());
3185        assert!(!reset(root)); // already gone
3186    }
3187
3188    #[test]
3189    fn reset_all_clears_dir_but_keeps_global_default() {
3190        let (_config, dir) = test_env();
3191        let root = dir.path();
3192        set_global_default(true);
3193        enable(root);
3194        assert!(load(root).enabled);
3195        assert!(reset_all());
3196        // The global default toggle survives a data wipe.
3197        assert!(load_global_default());
3198    }
3199}