Skip to main content

fallow_cli/
impact.rs

1//! Fallow Impact: a local, opt-in value report.
2//!
3//! Impact answers "what did fallow do for you?" rather than "what is wrong now?".
4//! v1 is deliberately thin and honest. It renders three things:
5//!
6//! 1. Surfacing: how many issues fallow is currently showing you.
7//! 2. Trend: whether the issue count is moving the right way between recorded runs.
8//! 3. Containment: how many times a pre-commit gate run blocked then cleared.
9//!
10//! Everything lives locally in a single rolling file at `.fallow/impact.json`
11//! (gitignored). Writes are best-effort and NEVER affect the exit code of any
12//! command: a corrupt or unwritable store degrades to "no history", never an
13//! error.
14//!
15//! v1.5 adds per-finding attribution on top: it credits genuinely RESOLVED
16//! findings (code removed or refactored) and never counts a `fallow-ignore`
17//! suppression as a win. It tells the two apart by capturing the present
18//! suppression state each run and diffing a per-file frontier against the files
19//! audit re-analyzed; a finding that merely moved (within a file, or to another
20//! file even across separate commits) is not counted as resolved. Attribution is
21//! a local-developer signal: it accrues where `.fallow/impact.json` persists
22//! across runs, not in ephemeral CI runners.
23
24use std::path::{Path, PathBuf};
25
26use fallow_types::results::{ActiveSuppression, AnalysisResults};
27use rustc_hash::{FxHashMap, FxHashSet};
28use serde::{Deserialize, Serialize};
29
30use crate::audit::{AuditSummary, AuditVerdict};
31use crate::report::ci::fingerprint::fingerprint_hash;
32use crate::report::format_display_path;
33
34/// On-disk schema version for the rolling impact store. Distinct from the JSON
35/// report's wire version ([`ImpactReportSchemaVersion`]): the store's persisted
36/// shape and the `--format json` report's shape evolve independently. v2 added
37/// the per-finding attribution surface (file frontier, clone frontier,
38/// resolved/suppressed counters, recent resolutions). A v1 store reads forward
39/// cleanly (new fields default empty; attribution accrues from the next run). A
40/// v2 store is also safe to READ on an older v1 binary (unknown keys ignored);
41/// the only caveat is DOWNGRADE: an older v1 binary that records a run rewrites
42/// the store with only the v1 fields, silently dropping the frontier and the
43/// lifetime counters, after which the next v2 run re-seeds from empty.
44/// Attribution restarts, it does not corrupt.
45const STORE_SCHEMA_VERSION: u32 = 2;
46
47/// Upper bound on retained per-run records. The store is a single compacted file,
48/// so this only bounds memory/disk, not file count. Oldest records are dropped first.
49const MAX_RECORDS: usize = 200;
50
51/// Upper bound on retained containment events (oldest dropped first).
52const MAX_CONTAINMENT: usize = 200;
53
54/// Tolerance (in absolute issue count) at or below which a trend is "stable"
55/// rather than improving/declining. Zero means any nonzero delta (even a single
56/// finding) registers as a direction; raise it to suppress single-finding noise.
57const TREND_TOLERANCE: i64 = 0;
58
59/// File name of the rolling impact store inside `.fallow/`.
60const STORE_FILE: &str = "impact.json";
61
62/// Upper bound on retained recent-resolution events (oldest dropped first).
63/// Bounds the one growing list the v1.5 surface adds; the lifetime totals are
64/// scalar counters and the frontier maps are pruned to on-disk files each run.
65const MAX_RECENT_RESOLVED: usize = 50;
66
67/// Field separator for composing a stable, line-independent finding identity
68/// out of `(kind, path, symbol)` parts before hashing. ASCII unit separator so
69/// it cannot collide with any path, symbol, or kind character.
70const ID_SEP: &str = "\u{1f}";
71
72/// The kebab-case kind string for duplication findings, used both as the
73/// clone-frontier finding kind and as the suppression kind that silences them.
74const CODE_DUPLICATION_KIND: &str = "code-duplication";
75
76/// Sentinel a blanket suppression (`// fallow-ignore-*` with no kind) is stored
77/// under, since it covers every kind on its target.
78const BLANKET_SUPPRESSION: &str = "*";
79
80/// Per-category issue counts captured at a recorded run.
81#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
82#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
83pub struct ImpactCounts {
84    pub total_issues: usize,
85    pub dead_code: usize,
86    pub complexity: usize,
87    pub duplication: usize,
88}
89
90impl ImpactCounts {
91    fn from_summary(summary: &AuditSummary) -> Self {
92        Self {
93            total_issues: summary.dead_code_issues
94                + summary.complexity_findings
95                + summary.duplication_clone_groups,
96            dead_code: summary.dead_code_issues,
97            complexity: summary.complexity_findings,
98            duplication: summary.duplication_clone_groups,
99        }
100    }
101
102    /// Build counts from a whole-project combined run's per-analysis totals.
103    /// Unlike [`from_summary`](Self::from_summary) (changed-file scope), these
104    /// are whole-project totals.
105    pub(crate) fn from_combined(dead_code: usize, complexity: usize, duplication: usize) -> Self {
106        Self {
107            total_issues: dead_code + complexity + duplication,
108            dead_code,
109            complexity,
110            duplication,
111        }
112    }
113}
114
115/// One recorded audit run.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct ImpactRecord {
118    pub timestamp: String,
119    pub version: String,
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub git_sha: Option<String>,
122    /// "pass" | "warn" | "fail".
123    pub verdict: String,
124    /// Whether this run was the pre-commit gate (carried the gate marker).
125    #[serde(default)]
126    pub gate: bool,
127    pub counts: ImpactCounts,
128}
129
130/// A pre-commit gate run that blocked (verdict fail) and is awaiting a clean run.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct PendingContainment {
133    pub blocked_at: String,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub git_sha: Option<String>,
136    pub blocked_counts: ImpactCounts,
137}
138
139/// A blocked-then-cleared containment: fallow stopped a commit until it was fixed.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
142pub struct ContainmentEvent {
143    pub blocked_at: String,
144    pub cleared_at: String,
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub git_sha: Option<String>,
147    pub blocked_counts: ImpactCounts,
148}
149
150/// One recorded finding's line-independent identity inside a [`FileFrontier`].
151///
152/// The `id` is a stable hash of `(kind, path, symbol)` so a finding that moves
153/// up or down within its file keeps the same identity (line is excluded). The
154/// `kind` and `symbol` are retained so the cross-file move-key can be recomputed
155/// and a resolution event can name the finding.
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct FrontierFinding {
158    pub id: String,
159    pub kind: String,
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub symbol: Option<String>,
162}
163
164impl FrontierFinding {
165    /// Path-independent key used to cancel within-run moves: a finding that
166    /// disappears from one file and reappears (same kind + symbol) in another
167    /// file analyzed the same run is a move, not a resolution. Symbol-less
168    /// findings (e.g. `unused-file`) fall back to the full `id`, so they never
169    /// spuriously cancel across files.
170    fn move_key(&self) -> String {
171        match &self.symbol {
172            Some(symbol) => format!("{}{ID_SEP}{symbol}", self.kind),
173            None => self.id.clone(),
174        }
175    }
176}
177
178/// The last-known per-finding state of one file: the findings it carried and the
179/// suppression kinds present in it, captured the last time audit re-analyzed it.
180#[derive(Debug, Clone, Default, Serialize, Deserialize)]
181pub struct FileFrontier {
182    #[serde(default)]
183    pub findings: Vec<FrontierFinding>,
184    /// Suppression kinds present in the file (kebab-case, or `"*"` for a blanket
185    /// marker). Used to detect a `fallow-ignore` that newly appeared covering a
186    /// disappeared finding's kind.
187    #[serde(default)]
188    pub suppressions: Vec<String>,
189}
190
191/// A genuinely-resolved finding, recorded for the recent-resolutions display.
192#[derive(Debug, Clone, Serialize, Deserialize)]
193#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
194pub struct ResolutionEvent {
195    /// The resolved finding's kind, kebab-case (e.g. `"unused-export"`).
196    pub kind: String,
197    /// Workspace-relative, forward-slash path of the file the finding was in.
198    pub path: String,
199    /// The finding's symbol (export / member / dependency name), when it has
200    /// one. `None` for file-level and content-hash-keyed findings (duplication).
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub symbol: Option<String>,
203    /// Short git SHA of the run that recorded the resolution, when in a git repo.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub git_sha: Option<String>,
206    /// ISO-8601 timestamp of the recording run.
207    pub timestamp: String,
208}
209
210/// The rolling impact store, persisted to `.fallow/impact.json`.
211#[derive(Debug, Clone, Default, Serialize, Deserialize)]
212pub struct ImpactStore {
213    #[serde(default)]
214    pub schema_version: u32,
215    /// Whether the user has opted in via `fallow impact enable`.
216    #[serde(default)]
217    pub enabled: bool,
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub first_recorded: Option<String>,
220    #[serde(default)]
221    pub records: Vec<ImpactRecord>,
222    /// Whole-project records appended by a true full `fallow` run (dead code,
223    /// duplication, and complexity together, with no scope narrowing). Kept
224    /// separate from `records` so the changed-file (audit) trend and the
225    /// whole-project trend never share a series. v1.6.
226    #[serde(default)]
227    pub project_records: Vec<ImpactRecord>,
228    #[serde(default)]
229    pub containment: Vec<ContainmentEvent>,
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub pending_containment: Option<PendingContainment>,
232    /// Per-file last-known finding + suppression state (dead-code and complexity
233    /// findings). Diffed each run for the files audit re-analyzed; entries for
234    /// files no longer on disk are pruned. v1.5.
235    #[serde(default)]
236    pub frontier: FxHashMap<String, FileFrontier>,
237    /// Clone-group state keyed by content fingerprint (`dup:<hash>`), value is
238    /// the workspace-relative instance paths. Duplication is multi-file, so it
239    /// needs a fingerprint-keyed frontier rather than the per-file one. v1.5.
240    #[serde(default)]
241    pub clone_frontier: FxHashMap<String, Vec<String>>,
242    /// Lifetime count of findings fallow credits as genuinely resolved. v1.5.
243    #[serde(default)]
244    pub resolved_total: usize,
245    /// Lifetime count of findings silenced by a newly-added `fallow-ignore`
246    /// (never counted as resolved). v1.5.
247    #[serde(default)]
248    pub suppressed_total: usize,
249    /// Most recent resolution events (newest last), bounded by the
250    /// `MAX_RECENT_RESOLVED` cap. v1.5.
251    #[serde(default)]
252    pub recent_resolved: Vec<ResolutionEvent>,
253}
254
255/// Path to the rolling store for a project root.
256fn store_path(root: &Path) -> PathBuf {
257    root.join(".fallow").join(STORE_FILE)
258}
259
260/// Load the store. A missing file is the normal "not enabled yet" case and
261/// returns a default silently. A present-but-unparsable file is surfaced with
262/// a one-line warning (rather than silently disabling tracking) and then
263/// degrades to a default; the corrupt file is left on disk untouched, and
264/// because [`record_audit_run`] no-ops on a disabled store it is never
265/// overwritten, so re-running `fallow impact enable` is a deliberate reset.
266pub fn load(root: &Path) -> ImpactStore {
267    let path = store_path(root);
268    let Ok(content) = std::fs::read_to_string(&path) else {
269        return ImpactStore::default();
270    };
271    match serde_json::from_str::<ImpactStore>(&content) {
272        Ok(store) => {
273            if store.schema_version > STORE_SCHEMA_VERSION {
274                tracing::warn!(
275                    "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.",
276                    path.display(),
277                    store.schema_version,
278                    STORE_SCHEMA_VERSION,
279                );
280            }
281            store
282        }
283        Err(err) => {
284            tracing::warn!(
285                "fallow impact: ignoring unreadable store at {} ({err}); run `fallow impact enable` to reset it",
286                path.display()
287            );
288            ImpactStore::default()
289        }
290    }
291}
292
293/// Persist the store, best-effort. Uses `atomic_write` (tempfile + rename) so a
294/// crash or a concurrent writer can never leave a torn, half-written file that
295/// the next `load` would treat as corrupt and silently disable. Errors are
296/// swallowed: Impact must never affect the exit code or output of the command
297/// that triggered the write. Concurrent writers still race (last-write-wins can
298/// drop a record), but each write lands as whole, valid JSON.
299fn save(store: &ImpactStore, root: &Path) {
300    let path = store_path(root);
301    if let Some(parent) = path.parent()
302        && std::fs::create_dir_all(parent).is_err()
303    {
304        return;
305    }
306    if let Ok(json) = serde_json::to_string_pretty(store) {
307        let _ = fallow_config::atomic_write(&path, json.as_bytes());
308    }
309}
310
311/// Enable Impact tracking. Returns whether it was newly enabled (false if already on).
312///
313/// Also ensures `.fallow/` is gitignored so the store is not accidentally
314/// committed: the store is the feature's local-only promise, and `enable` is the
315/// moment it is first created, so it is the right place to make
316/// "gitignored, never uploaded" true even when the user never ran `fallow init`.
317/// Best-effort: a gitignore write failure must never fail enabling.
318pub fn enable(root: &Path) -> bool {
319    let mut store = load(root);
320    let was_enabled = store.enabled;
321    store.enabled = true;
322    if store.schema_version == 0 {
323        store.schema_version = STORE_SCHEMA_VERSION;
324    }
325    save(&store, root);
326    ensure_fallow_gitignored(root);
327    !was_enabled
328}
329
330/// Best-effort: append `.fallow/` to the project's `.gitignore` if no line
331/// already ignores it. Idempotent, and a no-op when `fallow init` (which writes
332/// the same entry) already added it. Any IO error is swallowed: enabling Impact
333/// must never fail on a gitignore write. `impact` lives in the library crate
334/// while `setup_hooks::ensure_gitignore_entry` is binary-only, so this small
335/// helper is intentionally self-contained rather than shared.
336fn ensure_fallow_gitignored(root: &Path) {
337    let path = root.join(".gitignore");
338    let existing = std::fs::read_to_string(&path).unwrap_or_default();
339    let already = existing
340        .lines()
341        .any(|line| matches!(line.trim(), ".fallow" | ".fallow/"));
342    if already {
343        return;
344    }
345    let mut contents = existing;
346    if !contents.is_empty() && !contents.ends_with('\n') {
347        contents.push('\n');
348    }
349    contents.push_str(".fallow/\n");
350    // atomic_write (tempfile + rename) so a crash mid-write cannot truncate the
351    // project's .gitignore, matching save()'s store-write durability.
352    let _ = fallow_config::atomic_write(&path, contents.as_bytes());
353}
354
355/// Disable Impact tracking. Retains existing history. Returns whether it was
356/// newly disabled (false if already off).
357pub fn disable(root: &Path) -> bool {
358    let mut store = load(root);
359    let was_enabled = store.enabled;
360    store.enabled = false;
361    save(&store, root);
362    was_enabled
363}
364
365/// Record an audit run into the rolling store. No-op when tracking is disabled
366/// or the store cannot be read. Best-effort throughout; never returns an error.
367///
368/// `gate` indicates the run carried the pre-commit gate marker. Containment
369/// events are only derived from gate runs: a `fail` gate run sets a pending
370/// containment; a later non-`fail` gate run clears it into a containment event.
371///
372/// `attribution`, when present, carries the per-finding state for this run and
373/// drives v1.5 resolved/suppressed attribution against the per-file frontier.
374/// Pass `None` to record only the v1 surfacing/trend/containment data.
375#[expect(
376    clippy::too_many_arguments,
377    reason = "best-effort recorder threading the v1 record fields plus the v1.5 attribution input; a params struct would not improve the single call site"
378)]
379pub fn record_audit_run(
380    root: &Path,
381    summary: &AuditSummary,
382    verdict: AuditVerdict,
383    gate: bool,
384    git_sha: Option<&str>,
385    version: &str,
386    timestamp: &str,
387    attribution: Option<&AttributionInput<'_>>,
388) {
389    let mut store = load(root);
390    if !store.enabled {
391        return;
392    }
393    // Bump a forward-read v1 store to the current schema once we write it.
394    store.schema_version = STORE_SCHEMA_VERSION;
395
396    let counts = ImpactCounts::from_summary(summary);
397    let verdict_str = verdict_label(verdict);
398
399    if store.first_recorded.is_none() {
400        store.first_recorded = Some(timestamp.to_owned());
401    }
402
403    apply_containment(&mut store, verdict, gate, git_sha, timestamp, &counts);
404
405    store.records.push(ImpactRecord {
406        timestamp: timestamp.to_owned(),
407        version: version.to_owned(),
408        git_sha: git_sha.map(ToOwned::to_owned),
409        verdict: verdict_str.to_owned(),
410        gate,
411        counts,
412    });
413    compact(&mut store);
414
415    if let Some(attribution) = attribution {
416        apply_attribution(&mut store, attribution, git_sha, timestamp);
417    }
418
419    save(&store, root);
420}
421
422/// Record a whole-project combined run into the project track. No-op when
423/// tracking is disabled or the store cannot be read. Best-effort throughout;
424/// never returns an error and never affects the command's exit code or output.
425///
426/// Unlike [`record_audit_run`] this appends to `project_records` (not `records`)
427/// and derives no containment (the pre-commit gate is audit-only). `attribution`
428/// drives v1.5 resolved/suppressed credit with [`Scope::WholeProject`], so a
429/// duplication or whole-repo cleanup verified outside a changed-file audit is
430/// credited on the next full `fallow` run.
431pub fn record_combined_run(
432    root: &Path,
433    counts: ImpactCounts,
434    git_sha: Option<&str>,
435    version: &str,
436    timestamp: &str,
437    attribution: Option<&AttributionInput<'_>>,
438) {
439    let mut store = load(root);
440    if !store.enabled {
441        return;
442    }
443    store.schema_version = STORE_SCHEMA_VERSION;
444
445    if store.first_recorded.is_none() {
446        store.first_recorded = Some(timestamp.to_owned());
447    }
448
449    let verdict_str = if counts.total_issues == 0 {
450        "pass"
451    } else {
452        "warn"
453    };
454    store.project_records.push(ImpactRecord {
455        timestamp: timestamp.to_owned(),
456        version: version.to_owned(),
457        git_sha: git_sha.map(ToOwned::to_owned),
458        verdict: verdict_str.to_owned(),
459        gate: false,
460        counts,
461    });
462    if store.project_records.len() > MAX_RECORDS {
463        let overflow = store.project_records.len() - MAX_RECORDS;
464        store.project_records.drain(0..overflow);
465    }
466
467    if let Some(attribution) = attribution {
468        apply_attribution(&mut store, attribution, git_sha, timestamp);
469    }
470
471    save(&store, root);
472}
473
474/// Update pending/contained state from a gate run's verdict.
475fn apply_containment(
476    store: &mut ImpactStore,
477    verdict: AuditVerdict,
478    gate: bool,
479    git_sha: Option<&str>,
480    timestamp: &str,
481    counts: &ImpactCounts,
482) {
483    if !gate {
484        return;
485    }
486    if verdict == AuditVerdict::Fail {
487        // Blocked. Record (or keep) a pending containment with the blocking counts.
488        if store.pending_containment.is_none() {
489            store.pending_containment = Some(PendingContainment {
490                blocked_at: timestamp.to_owned(),
491                git_sha: git_sha.map(ToOwned::to_owned),
492                blocked_counts: counts.clone(),
493            });
494        }
495    } else if let Some(pending) = store.pending_containment.take() {
496        // Cleared. A previously-blocked commit now passes the gate.
497        store.containment.push(ContainmentEvent {
498            blocked_at: pending.blocked_at,
499            cleared_at: timestamp.to_owned(),
500            git_sha: pending.git_sha,
501            blocked_counts: pending.blocked_counts,
502        });
503        if store.containment.len() > MAX_CONTAINMENT {
504            let overflow = store.containment.len() - MAX_CONTAINMENT;
505            store.containment.drain(0..overflow);
506        }
507    }
508}
509
510/// Drop oldest records beyond the retention bound.
511fn compact(store: &mut ImpactStore) {
512    if store.records.len() > MAX_RECORDS {
513        let overflow = store.records.len() - MAX_RECORDS;
514        store.records.drain(0..overflow);
515    }
516}
517
518/// One finding's identity inputs for a run, in absolute-path form. Built by the
519/// [`collect_dead_code_findings`] / [`collect_complexity_findings`] helpers from
520/// the typed audit results, or by tests directly.
521#[derive(Debug, Clone)]
522pub struct FindingInput {
523    pub path: PathBuf,
524    pub kind: &'static str,
525    pub symbol: Option<String>,
526}
527
528/// One clone group's identity for a run: its content fingerprint plus the
529/// absolute paths of its instances. Built by [`collect_clone_findings`].
530#[derive(Debug, Clone)]
531pub struct CloneInput {
532    pub fingerprint: String,
533    pub instance_paths: Vec<PathBuf>,
534}
535
536/// Everything the per-finding attribution pass needs for one recorded run.
537///
538/// All paths are absolute (relativized internally against `root`). `findings`
539/// holds dead-code and complexity findings; `clones` holds duplication groups;
540/// `suppressions` is the present-suppression snapshot from
541/// [`AnalysisResults::active_suppressions`].
542/// The set of files an attribution pass may reason about when diffing a
543/// disappearance against the stored frontier. Audit passes its git-diff changed
544/// set; a whole-project `fallow` run passes [`Scope::WholeProject`], which
545/// scopes off the frontier keys themselves (every file fallow previously
546/// reported a finding for was, by definition, just re-analyzed on a full run).
547pub enum Scope<'a> {
548    /// Only the listed files were re-analyzed (audit's git-diff changed set).
549    ChangedFiles(&'a [PathBuf]),
550    /// The whole project was re-analyzed (a full `fallow` run).
551    WholeProject,
552}
553
554pub struct AttributionInput<'a> {
555    pub root: &'a Path,
556    pub scope: Scope<'a>,
557    pub findings: Vec<FindingInput>,
558    pub clones: Vec<CloneInput>,
559    pub suppressions: &'a [ActiveSuppression],
560}
561
562/// Compute a finding's stable, line-independent identity hash from its
563/// workspace-relative path, kind, and optional symbol.
564fn finding_id(kind: &str, rel_path: &str, symbol: Option<&str>) -> String {
565    fingerprint_hash(&[kind, rel_path, symbol.unwrap_or("")])
566}
567
568/// Whether a finding of `kind` is covered by a set of suppression kinds present
569/// in the file. A blanket marker (`"*"`) covers everything.
570fn covered_by(present: &FxHashSet<String>, kind: &str) -> bool {
571    present.contains(BLANKET_SUPPRESSION) || present.contains(kind)
572}
573
574/// Drive v1.5 attribution: diff this run's per-file findings against the stored
575/// frontier for the files audit re-analyzed, classify each disappearance as
576/// resolved / suppressed / moved, update the frontier, and prune dead entries.
577fn apply_attribution(
578    store: &mut ImpactStore,
579    input: &AttributionInput<'_>,
580    git_sha: Option<&str>,
581    timestamp: &str,
582) {
583    let root = input.root;
584    let changed: FxHashSet<String> = match input.scope {
585        Scope::ChangedFiles(files) => files.iter().map(|p| format_display_path(p, root)).collect(),
586        Scope::WholeProject => whole_project_scope(store, input, root),
587    };
588
589    // Current findings and suppressions for the changed (re-analyzed) files only.
590    let mut current_findings: FxHashMap<String, Vec<FrontierFinding>> = FxHashMap::default();
591    for f in &input.findings {
592        let rel = format_display_path(&f.path, root);
593        if !changed.contains(&rel) {
594            continue;
595        }
596        let id = finding_id(f.kind, &rel, f.symbol.as_deref());
597        current_findings
598            .entry(rel)
599            .or_default()
600            .push(FrontierFinding {
601                id,
602                kind: f.kind.to_owned(),
603                symbol: f.symbol.clone(),
604            });
605    }
606    let mut current_supps: FxHashMap<String, FxHashSet<String>> = FxHashMap::default();
607    for s in input.suppressions {
608        let rel = format_display_path(&s.path, root);
609        if !changed.contains(&rel) {
610            continue;
611        }
612        let key = s
613            .kind
614            .clone()
615            .unwrap_or_else(|| BLANKET_SUPPRESSION.to_owned());
616        current_supps.entry(rel).or_default().insert(key);
617    }
618
619    // Move keys that newly appeared this run (present now, absent from that file's
620    // prior frontier). A disappearance whose move key is in this set is a
621    // cross-file move, not a resolution.
622    let mut appeared_move_keys: FxHashSet<String> = FxHashSet::default();
623    for (rel, findings) in &current_findings {
624        let prior_ids: FxHashSet<&str> = store
625            .frontier
626            .get(rel)
627            .map(|f| f.findings.iter().map(|x| x.id.as_str()).collect())
628            .unwrap_or_default();
629        for ff in findings {
630            if !prior_ids.contains(ff.id.as_str()) {
631                appeared_move_keys.insert(ff.move_key());
632            }
633        }
634    }
635
636    // Cross-RUN move correction: a finding credited resolved in a PRIOR run whose
637    // move-key reappears as a new finding this run was a move across runs (e.g. a
638    // dead export relocated between barrels in separate commits), not a
639    // resolution. Un-credit it so a move never counts as a win. Safe direction:
640    // the (kind, symbol) move-key is path-independent, so a rare unrelated finding
641    // of the same kind+name under-counts here, never over-counts. Runs BEFORE this
642    // run's own classification adds events, so it only touches prior resolutions
643    // (within-run moves are handled by `appeared_move_keys`).
644    uncredit_cross_run_moves(store, &appeared_move_keys);
645
646    classify_file_disappearances(
647        store,
648        &changed,
649        &current_findings,
650        &current_supps,
651        &appeared_move_keys,
652        git_sha,
653        timestamp,
654    );
655    update_file_frontier(store, &changed, current_findings, current_supps);
656    classify_clone_disappearances(store, input, &changed, git_sha, timestamp);
657    prune_frontier(store, root);
658    bound_recent_resolved(store);
659}
660
661/// Build the in-scope file set for a whole-project run: every file the frontier
662/// or clone-frontier already tracks (all re-analyzed by definition on a full
663/// run) plus every file carrying a finding or clone instance this run. This lets
664/// a resolution anywhere in the repo be credited without a git-diff changed set,
665/// while staying safe against double-credit: a finding leaves the frontier
666/// exactly once (whichever run first re-analyzes its file and sees it gone), and
667/// once gone a wider scope cannot re-find it.
668fn whole_project_scope(
669    store: &ImpactStore,
670    input: &AttributionInput<'_>,
671    root: &Path,
672) -> FxHashSet<String> {
673    let mut set: FxHashSet<String> = store.frontier.keys().cloned().collect();
674    for paths in store.clone_frontier.values() {
675        for p in paths {
676            set.insert(p.clone());
677        }
678    }
679    for f in &input.findings {
680        set.insert(format_display_path(&f.path, root));
681    }
682    for c in &input.clones {
683        for p in &c.instance_paths {
684            set.insert(format_display_path(p, root));
685        }
686    }
687    set
688}
689
690/// Classify each finding that left a changed file's frontier since the last run.
691fn classify_file_disappearances(
692    store: &mut ImpactStore,
693    changed: &FxHashSet<String>,
694    current_findings: &FxHashMap<String, Vec<FrontierFinding>>,
695    current_supps: &FxHashMap<String, FxHashSet<String>>,
696    appeared_move_keys: &FxHashSet<String>,
697    git_sha: Option<&str>,
698    timestamp: &str,
699) {
700    let empty_supps = FxHashSet::default();
701    for rel in changed {
702        let Some(prior) = store.frontier.get(rel) else {
703            continue;
704        };
705        let now_ids: FxHashSet<&str> = current_findings
706            .get(rel)
707            .map(|fs| fs.iter().map(|f| f.id.as_str()).collect())
708            .unwrap_or_default();
709        let now_supps = current_supps.get(rel).unwrap_or(&empty_supps);
710        let prior_supps: FxHashSet<&str> = prior.suppressions.iter().map(String::as_str).collect();
711        // Suppression kinds that newly appeared in this file this run.
712        let new_supp_kinds: FxHashSet<String> = now_supps
713            .iter()
714            .filter(|k| !prior_supps.contains(k.as_str()))
715            .cloned()
716            .collect();
717
718        let mut resolved = Vec::new();
719        let mut suppressed = 0usize;
720        for pf in &prior.findings {
721            if now_ids.contains(pf.id.as_str()) {
722                continue; // still present
723            }
724            if appeared_move_keys.contains(&pf.move_key()) {
725                continue; // moved to another file this run
726            }
727            if covered_by(&new_supp_kinds, &pf.kind) {
728                suppressed += 1; // conservative: a fresh fallow-ignore, never a win
729            } else {
730                resolved.push(pf.clone());
731            }
732        }
733        store.suppressed_total += suppressed;
734        for pf in resolved {
735            store.resolved_total += 1;
736            store.recent_resolved.push(ResolutionEvent {
737                kind: pf.kind,
738                path: rel.clone(),
739                symbol: pf.symbol,
740                git_sha: git_sha.map(ToOwned::to_owned),
741                timestamp: timestamp.to_owned(),
742            });
743        }
744    }
745}
746
747/// Overwrite the frontier entry for each changed file with its current state,
748/// removing entries that now hold neither findings nor suppressions.
749fn update_file_frontier(
750    store: &mut ImpactStore,
751    changed: &FxHashSet<String>,
752    mut current_findings: FxHashMap<String, Vec<FrontierFinding>>,
753    mut current_supps: FxHashMap<String, FxHashSet<String>>,
754) {
755    for rel in changed {
756        let findings = current_findings.remove(rel).unwrap_or_default();
757        let mut suppressions: Vec<String> = current_supps
758            .remove(rel)
759            .unwrap_or_default()
760            .into_iter()
761            .collect();
762        suppressions.sort_unstable();
763        if findings.is_empty() && suppressions.is_empty() {
764            store.frontier.remove(rel);
765        } else {
766            store.frontier.insert(
767                rel.clone(),
768                FileFrontier {
769                    findings,
770                    suppressions,
771                },
772            );
773        }
774    }
775}
776
777/// Classify duplication clone groups that left the clone frontier for a changed
778/// file. Clone fingerprints are content-derived, so a relocated identical clone
779/// keeps its fingerprint and is never counted as resolved (move handled for
780/// free). A clone is suppressed when a `code-duplication` suppression is present
781/// in any of its instance files this run.
782fn classify_clone_disappearances(
783    store: &mut ImpactStore,
784    input: &AttributionInput<'_>,
785    changed: &FxHashSet<String>,
786    git_sha: Option<&str>,
787    timestamp: &str,
788) {
789    let root = input.root;
790    // Current clone fingerprints touching a changed file, with relative paths.
791    let mut current: FxHashMap<String, Vec<String>> = FxHashMap::default();
792    for c in &input.clones {
793        let mut paths: Vec<String> = c
794            .instance_paths
795            .iter()
796            .map(|p| format_display_path(p, root))
797            .collect();
798        paths.sort_unstable();
799        paths.dedup();
800        if paths.iter().any(|p| changed.contains(p)) {
801            current.insert(c.fingerprint.clone(), paths);
802        }
803    }
804
805    // A clone is suppressed when an instance file currently carries a
806    // code-duplication (or blanket) suppression. Reads the just-updated frontier;
807    // since a reported clone was not suppressed in the prior run, a suppression
808    // present now is necessarily newly-appeared, so this is the conservative
809    // (never-over-credit) read.
810    let dup_suppressed = |paths: &[String]| -> bool {
811        paths.iter().any(|p| {
812            changed.contains(p)
813                && store.frontier.get(p).is_some_and(|f| {
814                    f.suppressions
815                        .iter()
816                        .any(|k| k == CODE_DUPLICATION_KIND || k == BLANKET_SUPPRESSION)
817                })
818        })
819    };
820
821    // Files still participating in SOME current clone group this run. A
822    // disappeared fingerprint whose instance files are still duplicated (under a
823    // different, reshaped fingerprint) is NOT a full resolution: removing one of
824    // three identical instances changes the content fingerprint but leaves the
825    // remaining files duplicated. Crediting that as resolved would over-count, so
826    // a reshape is silently re-tracked under the new fingerprint, never credited.
827    // Conservative direction, matching v1.5 (never over-credit a win).
828    let still_duplicated: FxHashSet<&String> = current.values().flatten().collect();
829
830    // Disappeared clones: in the stored frontier, intersecting a changed file,
831    // not present in the current run.
832    let disappeared: Vec<(String, Vec<String>)> = store
833        .clone_frontier
834        .iter()
835        .filter(|(fp, paths)| {
836            paths.iter().any(|p| changed.contains(p)) && !current.contains_key(*fp)
837        })
838        .map(|(fp, paths)| (fp.clone(), paths.clone()))
839        .collect();
840
841    for (fp, paths) in disappeared {
842        store.clone_frontier.remove(&fp);
843        if paths.iter().any(|p| still_duplicated.contains(p)) {
844            // Reshape, not a resolution: duplication persists at these files
845            // under a new fingerprint (re-tracked below). Neither resolved nor
846            // suppressed.
847            continue;
848        }
849        if dup_suppressed(&paths) {
850            store.suppressed_total += 1;
851        } else {
852            store.resolved_total += 1;
853            let path = paths.first().cloned().unwrap_or_default();
854            store.recent_resolved.push(ResolutionEvent {
855                kind: CODE_DUPLICATION_KIND.to_owned(),
856                path,
857                symbol: None,
858                git_sha: git_sha.map(ToOwned::to_owned),
859                timestamp: timestamp.to_owned(),
860            });
861        }
862    }
863
864    // Record the current clones for the next run.
865    for (fp, paths) in current {
866        store.clone_frontier.insert(fp, paths);
867    }
868}
869
870/// Drop frontier and clone-frontier entries whose files no longer exist on disk,
871/// bounding both maps to the live working tree.
872fn prune_frontier(store: &mut ImpactStore, root: &Path) {
873    store.frontier.retain(|rel, _| root.join(rel).exists());
874    store
875        .clone_frontier
876        .retain(|_, paths| paths.iter().any(|p| root.join(p).exists()));
877}
878
879/// Bound the recent-resolutions list, dropping the oldest entries.
880fn bound_recent_resolved(store: &mut ImpactStore) {
881    if store.recent_resolved.len() > MAX_RECENT_RESOLVED {
882        let overflow = store.recent_resolved.len() - MAX_RECENT_RESOLVED;
883        store.recent_resolved.drain(0..overflow);
884    }
885}
886
887/// Path-independent move-key for a recorded resolution, for cross-run move
888/// detection. Mirrors [`FrontierFinding::move_key`]'s symbol branch. `None` for
889/// symbol-less resolutions (file-level, duplication), which are not move-tracked
890/// across runs (file moves are delete+create; clone fingerprints are content-
891/// derived and already move-stable within a run).
892fn event_move_key(ev: &ResolutionEvent) -> Option<String> {
893    ev.symbol
894        .as_ref()
895        .map(|symbol| format!("{}{ID_SEP}{symbol}", ev.kind))
896}
897
898/// Retroactively un-credit prior-run resolutions revealed as cross-run moves:
899/// a `(kind, symbol)` that newly appeared this run and was already recorded as
900/// resolved in an earlier run was relocated, not removed. Drops the stale event
901/// and decrements the lifetime tally. Bounded by `recent_resolved`'s cap.
902fn uncredit_cross_run_moves(store: &mut ImpactStore, appeared_move_keys: &FxHashSet<String>) {
903    if appeared_move_keys.is_empty() {
904        return;
905    }
906    let mut uncredited = 0usize;
907    store.recent_resolved.retain(|ev| match event_move_key(ev) {
908        Some(mk) if appeared_move_keys.contains(&mk) => {
909            uncredited += 1;
910            false
911        }
912        _ => true,
913    });
914    store.resolved_total = store.resolved_total.saturating_sub(uncredited);
915}
916
917/// Collect line-independent dead-code finding identities from an analysis result.
918///
919/// Covers the single-file-anchored dead-code kinds. Multi-file kinds (circular
920/// dependencies, re-export cycles, duplicate exports, unlisted dependencies) are
921/// intentionally not attributed in v1.5: they surface and trend, but their
922/// multi-file nature does not fit the per-file frontier (duplication has its own
923/// fingerprint frontier). Boundary violations are anchored at the importing file.
924#[must_use]
925pub fn collect_dead_code_findings(results: &AnalysisResults) -> Vec<FindingInput> {
926    let mut out = Vec::new();
927    let mut push = |path: &Path, kind: &'static str, symbol: Option<String>| {
928        out.push(FindingInput {
929            path: path.to_path_buf(),
930            kind,
931            symbol,
932        });
933    };
934    for f in &results.unused_files {
935        push(&f.file.path, "unused-file", None);
936    }
937    for f in &results.unused_exports {
938        push(
939            &f.export.path,
940            "unused-export",
941            Some(f.export.export_name.clone()),
942        );
943    }
944    for f in &results.unused_types {
945        push(
946            &f.export.path,
947            "unused-type",
948            Some(f.export.export_name.clone()),
949        );
950    }
951    for f in &results.private_type_leaks {
952        push(
953            &f.leak.path,
954            "private-type-leak",
955            Some(format!(
956                "{}{ID_SEP}{}",
957                f.leak.export_name, f.leak.type_name
958            )),
959        );
960    }
961    for f in &results.unused_enum_members {
962        push(
963            &f.member.path,
964            "unused-enum-member",
965            Some(format!(
966                "{}{ID_SEP}{}",
967                f.member.parent_name, f.member.member_name
968            )),
969        );
970    }
971    for f in &results.unused_class_members {
972        push(
973            &f.member.path,
974            "unused-class-member",
975            Some(format!(
976                "{}{ID_SEP}{}",
977                f.member.parent_name, f.member.member_name
978            )),
979        );
980    }
981    for f in &results.unresolved_imports {
982        push(
983            &f.import.path,
984            "unresolved-import",
985            Some(f.import.specifier.clone()),
986        );
987    }
988    for f in &results.boundary_violations {
989        // Forward-slash normalize the target path so the finding identity hashes
990        // identically across platforms (the symbol feeds finding_id).
991        let to_path = f.violation.to_path.to_string_lossy().replace('\\', "/");
992        push(
993            &f.violation.from_path,
994            "boundary-violation",
995            Some(format!("{to_path}{ID_SEP}{}", f.violation.import_specifier)),
996        );
997    }
998    for f in &results.unused_dependencies {
999        push(
1000            &f.dep.path,
1001            "unused-dependency",
1002            Some(f.dep.package_name.clone()),
1003        );
1004    }
1005    for f in &results.unused_dev_dependencies {
1006        push(
1007            &f.dep.path,
1008            "unused-dev-dependency",
1009            Some(f.dep.package_name.clone()),
1010        );
1011    }
1012    for f in &results.unused_optional_dependencies {
1013        push(
1014            &f.dep.path,
1015            "unused-optional-dependency",
1016            Some(f.dep.package_name.clone()),
1017        );
1018    }
1019    for f in &results.type_only_dependencies {
1020        push(
1021            &f.dep.path,
1022            "type-only-dependency",
1023            Some(f.dep.package_name.clone()),
1024        );
1025    }
1026    for f in &results.test_only_dependencies {
1027        push(
1028            &f.dep.path,
1029            "test-only-dependency",
1030            Some(f.dep.package_name.clone()),
1031        );
1032    }
1033    for f in &results.unused_catalog_entries {
1034        push(
1035            &f.entry.path,
1036            "unused-catalog-entry",
1037            Some(format!(
1038                "{}{ID_SEP}{}",
1039                f.entry.catalog_name, f.entry.entry_name
1040            )),
1041        );
1042    }
1043    for f in &results.empty_catalog_groups {
1044        push(
1045            &f.group.path,
1046            "empty-catalog-group",
1047            Some(f.group.catalog_name.clone()),
1048        );
1049    }
1050    for f in &results.unresolved_catalog_references {
1051        push(
1052            &f.reference.path,
1053            "unresolved-catalog-reference",
1054            Some(format!(
1055                "{}{ID_SEP}{}",
1056                f.reference.catalog_name, f.reference.entry_name
1057            )),
1058        );
1059    }
1060    for f in &results.unused_dependency_overrides {
1061        push(
1062            &f.entry.path,
1063            "unused-dependency-override",
1064            Some(f.entry.raw_key.clone()),
1065        );
1066    }
1067    for f in &results.misconfigured_dependency_overrides {
1068        push(
1069            &f.entry.path,
1070            "misconfigured-dependency-override",
1071            Some(f.entry.raw_key.clone()),
1072        );
1073    }
1074    out
1075}
1076
1077/// Collect line-independent complexity finding identities `(path, function name)`
1078/// from a health report. The function name is line-independent, so a function
1079/// moving within its file keeps the same identity.
1080#[must_use]
1081pub fn collect_complexity_findings(
1082    report: &crate::health_types::HealthReport,
1083) -> Vec<FindingInput> {
1084    report
1085        .findings
1086        .iter()
1087        .map(|f| FindingInput {
1088            path: f.path.clone(),
1089            kind: "complexity",
1090            symbol: Some(f.name.clone()),
1091        })
1092        .collect()
1093}
1094
1095/// Collect clone-group identities `(fingerprint, instance paths)` from a
1096/// duplication report. The fingerprint is content-derived (`dup:<hash>`), so it
1097/// is stable across pure relocation.
1098#[must_use]
1099pub fn collect_clone_findings(
1100    report: &fallow_core::duplicates::DuplicationReport,
1101) -> Vec<CloneInput> {
1102    report
1103        .clone_groups
1104        .iter()
1105        .map(|g| CloneInput {
1106            fingerprint: fallow_core::duplicates::clone_fingerprint(&g.instances),
1107            instance_paths: g.instances.iter().map(|i| i.file.clone()).collect(),
1108        })
1109        .collect()
1110}
1111
1112const fn verdict_label(verdict: AuditVerdict) -> &'static str {
1113    match verdict {
1114        AuditVerdict::Pass => "pass",
1115        AuditVerdict::Warn => "warn",
1116        AuditVerdict::Fail => "fail",
1117    }
1118}
1119
1120/// Direction of a count trend between two recorded runs.
1121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1123#[serde(rename_all = "snake_case")]
1124pub enum ImpactTrendDirection {
1125    /// Issue count went down (good).
1126    Improving,
1127    /// Issue count went up.
1128    Declining,
1129    /// Within tolerance.
1130    Stable,
1131}
1132
1133/// A computed trend between the two most recent records.
1134#[derive(Debug, Clone, Serialize)]
1135#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1136pub struct TrendSummary {
1137    pub direction: ImpactTrendDirection,
1138    /// Signed delta in total issues (current minus previous).
1139    pub total_delta: i64,
1140    pub previous_total: usize,
1141    pub current_total: usize,
1142}
1143
1144fn direction_for(delta: i64) -> ImpactTrendDirection {
1145    if delta < -TREND_TOLERANCE {
1146        ImpactTrendDirection::Improving
1147    } else if delta > TREND_TOLERANCE {
1148        ImpactTrendDirection::Declining
1149    } else {
1150        ImpactTrendDirection::Stable
1151    }
1152}
1153
1154/// Wire-version discriminator for [`ImpactReport`]. Independent from the global
1155/// `SchemaVersion` (the impact report versions on its own cadence) and from the
1156/// on-disk `STORE_SCHEMA_VERSION` (the persisted store shape versions
1157/// separately). Serializes as a string `const` so JSON consumers can switch on
1158/// it, matching the other independently-versioned envelopes (e.g.
1159/// `CoverageAnalyzeSchemaVersion`).
1160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1161#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1162pub enum ImpactReportSchemaVersion {
1163    /// First release of the `fallow impact --format json` shape.
1164    #[serde(rename = "1")]
1165    V1,
1166}
1167
1168/// The rendered impact report, derived purely from the store (no analysis run).
1169#[derive(Debug, Clone, Serialize)]
1170#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1171#[cfg_attr(feature = "schema", schemars(title = "fallow impact --format json"))]
1172pub struct ImpactReport {
1173    /// Output-shape version for this report, so JSON consumers have a
1174    /// forward-compat signal independent of the on-disk store version. Always
1175    /// present; bumped only on a breaking change to this report's wire shape.
1176    pub schema_version: ImpactReportSchemaVersion,
1177    pub enabled: bool,
1178    pub record_count: usize,
1179    #[serde(default, skip_serializing_if = "Option::is_none")]
1180    pub first_recorded: Option<String>,
1181    /// Git SHA of the most recent recorded run, so a consumer can tell which
1182    /// commit the `surfacing` counts belong to. This is an ABBREVIATED SHA
1183    /// (`git rev-parse --short`), so it is for display/correlation only and will
1184    /// not match a full 40-character SHA from `$GITHUB_SHA` or the git API
1185    /// without expansion. None when the latest run had no SHA (not a git repo)
1186    /// or there are no records yet.
1187    #[serde(default, skip_serializing_if = "Option::is_none")]
1188    pub latest_git_sha: Option<String>,
1189    /// Counts from the most recent recorded run. These are CHANGED-FILE scoped
1190    /// (each record comes from a `fallow audit` run, whose default `new-only`
1191    /// gate counts only findings in the changed files of that run), NOT a
1192    /// whole-project total.
1193    #[serde(default, skip_serializing_if = "Option::is_none")]
1194    pub surfacing: Option<ImpactCounts>,
1195    /// Trend between the two most recent records. None until two records exist.
1196    #[serde(default, skip_serializing_if = "Option::is_none")]
1197    pub trend: Option<TrendSummary>,
1198    /// Counts from the most recent whole-project `fallow` run. WHOLE-PROJECT
1199    /// scope (not changed-file), so this is the current issue total across the
1200    /// whole repo, context next to the actionable changed-file `surfacing`
1201    /// count. None until a full `fallow` run has been recorded. v1.6.
1202    #[serde(default, skip_serializing_if = "Option::is_none")]
1203    pub project_surfacing: Option<ImpactCounts>,
1204    /// Trend between the two most recent whole-project records. Comparable over
1205    /// time (same whole-project denominator every run), unlike the changed-file
1206    /// `trend`. None until two full `fallow` runs exist. v1.6.
1207    #[serde(default, skip_serializing_if = "Option::is_none")]
1208    pub project_trend: Option<TrendSummary>,
1209    pub containment_count: usize,
1210    /// Most recent containment events (newest last), capped for display.
1211    pub recent_containment: Vec<ContainmentEvent>,
1212    /// Lifetime count of findings fallow credits as genuinely resolved (code
1213    /// removed or refactored, never a `fallow-ignore`). v1.5.
1214    pub resolved_total: usize,
1215    /// Lifetime count of findings silenced by a newly-added `fallow-ignore`.
1216    /// Reported as honest context, never as a win. v1.5.
1217    pub suppressed_total: usize,
1218    /// Most recent resolution events (newest last), capped for display. v1.5.
1219    pub recent_resolved: Vec<ResolutionEvent>,
1220    /// Whether per-finding attribution has a baseline yet. False on a freshly
1221    /// upgraded v1 store (no frontier captured), which the renderer uses to show
1222    /// "resolution tracking starts from your next run" instead of a bare zero.
1223    pub attribution_active: bool,
1224}
1225
1226/// Build a report from the store. Defensive: a single record (or none) yields
1227/// no trend rather than a spurious spike, and an empty store yields an empty
1228/// report flagged so the renderer can show the first-run message.
1229/// Trend between the two most recent records in a series. None until two records
1230/// exist; a missing prior record is "unknown" (no trend), never a spike.
1231fn trend_for(records: &[ImpactRecord]) -> Option<TrendSummary> {
1232    if records.len() < 2 {
1233        return None;
1234    }
1235    let current = &records[records.len() - 1];
1236    let previous = &records[records.len() - 2];
1237    let current_total = current.counts.total_issues;
1238    let previous_total = previous.counts.total_issues;
1239    let total_delta = current_total as i64 - previous_total as i64;
1240    Some(TrendSummary {
1241        direction: direction_for(total_delta),
1242        total_delta,
1243        previous_total,
1244        current_total,
1245    })
1246}
1247
1248pub fn build_report(store: &ImpactStore) -> ImpactReport {
1249    let surfacing = store.records.last().map(|r| r.counts.clone());
1250    let trend = trend_for(&store.records);
1251    let project_surfacing = store.project_records.last().map(|r| r.counts.clone());
1252    let project_trend = trend_for(&store.project_records);
1253
1254    let recent_containment = store
1255        .containment
1256        .iter()
1257        .rev()
1258        .take(5)
1259        .rev()
1260        .cloned()
1261        .collect();
1262
1263    let latest_git_sha = store.records.last().and_then(|r| r.git_sha.clone());
1264
1265    let recent_resolved = store
1266        .recent_resolved
1267        .iter()
1268        .rev()
1269        .take(5)
1270        .rev()
1271        .cloned()
1272        .collect();
1273    // Attribution has a baseline once any file frontier, clone frontier, or
1274    // lifetime counter exists.
1275    let attribution_active = !store.frontier.is_empty()
1276        || !store.clone_frontier.is_empty()
1277        || store.resolved_total > 0
1278        || store.suppressed_total > 0;
1279
1280    ImpactReport {
1281        schema_version: ImpactReportSchemaVersion::V1,
1282        enabled: store.enabled,
1283        record_count: store.records.len(),
1284        first_recorded: store.first_recorded.clone(),
1285        latest_git_sha,
1286        surfacing,
1287        trend,
1288        project_surfacing,
1289        project_trend,
1290        containment_count: store.containment.len(),
1291        recent_containment,
1292        resolved_total: store.resolved_total,
1293        suppressed_total: store.suppressed_total,
1294        recent_resolved,
1295        attribution_active,
1296    }
1297}
1298
1299/// Render the whole-project view for the human report. Deliberately understated
1300/// (one count line, one trend line, one caveat) rather than a co-equal header:
1301/// the project track advances only on local full `fallow` runs, not CI, so it is
1302/// context for the changed-file story above, not the headline. Renders nothing
1303/// when no full `fallow` run has been recorded yet.
1304#[expect(
1305    clippy::format_push_string,
1306    reason = "small report renderer; readability over avoiding the extra allocation"
1307)]
1308fn render_project_section(out: &mut String, report: &ImpactReport) {
1309    let Some(s) = &report.project_surfacing else {
1310        return;
1311    };
1312    out.push_str(&format!(
1313        "  WHOLE PROJECT (whole-repo context, not a to-do)\n    {} issue{} across the whole project at your last full `fallow` run\n",
1314        s.total_issues,
1315        plural(s.total_issues),
1316    ));
1317    if let Some(t) = &report.project_trend {
1318        let arrow = trend_arrow(t.direction);
1319        out.push_str(&format!(
1320            "    {} -> {} ({}) across your last two full runs (comparable over time)\n",
1321            t.previous_total, t.current_total, arrow,
1322        ));
1323    } else {
1324        out.push_str("    project trend starts after your next full `fallow` run\n");
1325    }
1326    out.push_str("      advances only on your local full `fallow` runs, not CI\n\n");
1327}
1328
1329/// Render the report as human-readable text.
1330#[expect(
1331    clippy::format_push_string,
1332    reason = "small report renderer; readability over avoiding the extra allocation"
1333)]
1334pub fn render_human(report: &ImpactReport) -> String {
1335    let mut out = String::new();
1336    out.push_str("FALLOW IMPACT\n\n");
1337
1338    if !report.enabled {
1339        out.push_str(
1340            "Impact tracking is off. Enable it with `fallow impact enable`, then\n\
1341             let your pre-commit gate run a few times to build history.\n",
1342        );
1343        return out;
1344    }
1345
1346    if report.record_count == 0 && report.project_surfacing.is_none() {
1347        out.push_str(
1348            "Tracking enabled. No history yet: check back after your next few\n\
1349             commits (Impact records each `fallow audit` / pre-commit gate run,\n\
1350             and each full `fallow` run for the whole-project view).\n",
1351        );
1352        return out;
1353    }
1354
1355    if let Some(s) = &report.surfacing {
1356        out.push_str(&format!(
1357            "  LATEST RUN (changed files, act on these now)\n    {} issue{} flagged in your last `fallow audit` run\n",
1358            s.total_issues,
1359            plural(s.total_issues),
1360        ));
1361        out.push_str(&format!(
1362            "      dead code {}  ·  complexity {}  ·  duplication {}\n\n",
1363            s.dead_code, s.complexity, s.duplication,
1364        ));
1365    }
1366
1367    if let Some(t) = &report.trend {
1368        let arrow = trend_arrow(t.direction);
1369        out.push_str(&format!(
1370            "  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",
1371            t.previous_total, t.current_total, arrow,
1372        ));
1373    }
1374
1375    render_project_section(&mut out, report);
1376
1377    out.push_str(&format!(
1378        "  CONTAINED AT COMMIT\n    {} time{} fallow blocked a commit until it was fixed\n",
1379        report.containment_count,
1380        plural(report.containment_count),
1381    ));
1382
1383    // RESOLVED always renders a header so the suppression line below always has
1384    // a section to belong to (the three states are exhaustive).
1385    if report.resolved_total > 0 {
1386        out.push_str(&format!(
1387            "\n  RESOLVED\n    {} finding{} you cleared since fallow started tracking\n",
1388            report.resolved_total,
1389            plural(report.resolved_total),
1390        ));
1391        for ev in &report.recent_resolved {
1392            match &ev.symbol {
1393                Some(symbol) => {
1394                    out.push_str(&format!("      {} {} in {}\n", ev.kind, symbol, ev.path));
1395                }
1396                None => out.push_str(&format!("      {} in {}\n", ev.kind, ev.path)),
1397            }
1398        }
1399    } else if report.attribution_active {
1400        out.push_str(
1401            "\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",
1402        );
1403    } else {
1404        out.push_str("\n  RESOLVED\n    resolution tracking starts from your next gate run\n");
1405    }
1406
1407    // Suppression is honest context, indented under RESOLVED, never a scoreboard.
1408    if report.suppressed_total > 0 {
1409        out.push_str(&format!(
1410            "      {} finding{} you marked intentional (fallow-ignore), not counted as resolved\n",
1411            report.suppressed_total,
1412            plural(report.suppressed_total),
1413        ));
1414    }
1415
1416    out.push('\n');
1417    let since = report
1418        .first_recorded
1419        .as_deref()
1420        .map_or("the first run", date_only);
1421    if report.record_count > 0 {
1422        out.push_str(&format!(
1423            "Based on {} recorded audit run{} since {}. Local-only; never uploaded.\n\
1424             Changed-file scope: each audit run only sees files differing from your base.\n",
1425            report.record_count,
1426            plural(report.record_count),
1427            since,
1428        ));
1429    } else {
1430        out.push_str(&format!(
1431            "Tracking since {since}. Local-only; never uploaded.\n",
1432        ));
1433    }
1434    out.push_str(
1435        "Resolution tracking is a local-developer signal: it accrues where\n\
1436         .fallow/impact.json persists across runs, not in ephemeral CI runners.\n",
1437    );
1438    out
1439}
1440
1441/// Render the report as JSON.
1442pub fn render_json(report: &ImpactReport) -> String {
1443    serde_json::to_string_pretty(report)
1444        .unwrap_or_else(|_| "{\"error\":\"failed to serialize impact report\"}".to_owned())
1445}
1446
1447/// Render the whole-project view for the markdown report. One understated line
1448/// plus a trend line when available, matching the human renderer's framing.
1449/// Renders nothing when no full `fallow` run has been recorded yet.
1450#[expect(
1451    clippy::format_push_string,
1452    reason = "small report renderer; readability over avoiding the extra allocation"
1453)]
1454fn render_project_markdown(out: &mut String, report: &ImpactReport) {
1455    let Some(s) = &report.project_surfacing else {
1456        return;
1457    };
1458    out.push_str(&format!(
1459        "- **Whole project (whole-repo context, last full `fallow` run):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
1460        s.total_issues,
1461        plural(s.total_issues),
1462        s.dead_code,
1463        s.complexity,
1464        s.duplication,
1465    ));
1466    if let Some(t) = &report.project_trend {
1467        let arrow = trend_arrow(t.direction);
1468        out.push_str(&format!(
1469            "- **Project trend (whole project, last two full runs):** {} -> {} ({})\n",
1470            t.previous_total, t.current_total, arrow,
1471        ));
1472    }
1473}
1474
1475/// Render the report as Markdown (paste-ready for a PR description or standup).
1476#[expect(
1477    clippy::format_push_string,
1478    reason = "small report renderer; readability over avoiding the extra allocation"
1479)]
1480pub fn render_markdown(report: &ImpactReport) -> String {
1481    let mut out = String::new();
1482    out.push_str("## Fallow impact\n\n");
1483
1484    if !report.enabled {
1485        out.push_str("Impact tracking is off. Run `fallow impact enable` to start.\n");
1486        return out;
1487    }
1488    if report.record_count == 0 && report.project_surfacing.is_none() {
1489        out.push_str("Tracking enabled. No history yet; check back after a few commits.\n");
1490        return out;
1491    }
1492
1493    if let Some(s) = &report.surfacing {
1494        out.push_str(&format!(
1495            "- **Latest run (changed files):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
1496            s.total_issues,
1497            plural(s.total_issues),
1498            s.dead_code,
1499            s.complexity,
1500            s.duplication,
1501        ));
1502    }
1503    if let Some(t) = &report.trend {
1504        out.push_str(&format!(
1505            "- **Trend (changed-file scope, last two runs):** {} -> {} ({})\n",
1506            t.previous_total,
1507            t.current_total,
1508            trend_arrow(t.direction),
1509        ));
1510    }
1511    render_project_markdown(&mut out, report);
1512    out.push_str(&format!(
1513        "- **Contained at commit:** {} time{}\n",
1514        report.containment_count,
1515        plural(report.containment_count),
1516    ));
1517    // Always emit a Resolved bullet (three exhaustive states) so the Marked-
1518    // intentional bullet never appears without it.
1519    if report.resolved_total > 0 {
1520        out.push_str(&format!(
1521            "- **Resolved:** {} finding{} cleared since tracking started\n",
1522            report.resolved_total,
1523            plural(report.resolved_total),
1524        ));
1525    } else if report.attribution_active {
1526        out.push_str("- **Resolved:** none yet; tracking active\n");
1527    } else {
1528        out.push_str("- **Resolved:** resolution tracking starts from your next gate run\n");
1529    }
1530    if report.suppressed_total > 0 {
1531        out.push_str(&format!(
1532            "- **Marked intentional:** {} finding{} (`fallow-ignore`), not counted as resolved\n",
1533            report.suppressed_total,
1534            plural(report.suppressed_total),
1535        ));
1536    }
1537    let since = report
1538        .first_recorded
1539        .as_deref()
1540        .map_or("the first run", date_only);
1541    if report.record_count > 0 {
1542        out.push_str(&format!(
1543            "\n_Based on {} recorded audit run{} since {}. Local-only; resolution is a local-developer signal._\n",
1544            report.record_count,
1545            plural(report.record_count),
1546            since,
1547        ));
1548    } else {
1549        out.push_str(&format!(
1550            "\n_Tracking since {since}. Local-only; resolution is a local-developer signal._\n",
1551        ));
1552    }
1553    out
1554}
1555
1556const fn plural(n: usize) -> &'static str {
1557    if n == 1 { "" } else { "s" }
1558}
1559
1560/// Trim a stored ISO-8601 timestamp (`2026-05-29T18:15:23Z`) to its date part
1561/// (`2026-05-29`) for human/markdown footers. The wall-clock time and `Z` add
1562/// noise without meaning when a reader just wants "tracking since when". JSON
1563/// keeps the full `first_recorded` timestamp. Returns the input unchanged if it
1564/// has no `T` separator.
1565fn date_only(ts: &str) -> &str {
1566    ts.split_once('T').map_or(ts, |(date, _)| date)
1567}
1568
1569/// Single human-facing trend vocabulary, shared by the text and markdown
1570/// renderers so the same concept does not read three different ways. The JSON
1571/// wire keeps the `improving`/`declining`/`stable` enum form for machines.
1572const fn trend_arrow(direction: ImpactTrendDirection) -> &'static str {
1573    match direction {
1574        ImpactTrendDirection::Improving => "down",
1575        ImpactTrendDirection::Declining => "up",
1576        ImpactTrendDirection::Stable => "flat",
1577    }
1578}
1579
1580#[cfg(test)]
1581mod tests {
1582    use super::*;
1583
1584    fn summary(dead: usize, complexity: usize, dupes: usize) -> AuditSummary {
1585        AuditSummary {
1586            dead_code_issues: dead,
1587            dead_code_has_errors: dead > 0,
1588            complexity_findings: complexity,
1589            max_cyclomatic: None,
1590            duplication_clone_groups: dupes,
1591        }
1592    }
1593
1594    /// Record a run with no per-finding attribution (v1 surfacing/trend/containment only).
1595    fn record_v1(
1596        root: &Path,
1597        summary: &AuditSummary,
1598        verdict: AuditVerdict,
1599        gate: bool,
1600        git_sha: Option<&str>,
1601        version: &str,
1602        timestamp: &str,
1603    ) {
1604        record_audit_run(
1605            root, summary, verdict, gate, git_sha, version, timestamp, None,
1606        );
1607    }
1608
1609    // ---- v1.5 per-finding attribution helpers ----
1610
1611    /// Create a real file under `root` (attribution prunes frontier entries for
1612    /// files that no longer exist, so test files must exist on disk).
1613    fn touch(root: &Path, rel: &str) -> PathBuf {
1614        let p = root.join(rel);
1615        if let Some(parent) = p.parent() {
1616            std::fs::create_dir_all(parent).unwrap();
1617        }
1618        std::fs::write(&p, b"x").unwrap();
1619        p
1620    }
1621
1622    fn fi(path: &Path, kind: &'static str, symbol: &str) -> FindingInput {
1623        FindingInput {
1624            path: path.to_path_buf(),
1625            kind,
1626            symbol: Some(symbol.to_owned()),
1627        }
1628    }
1629
1630    fn supp(path: &Path, kind: &str) -> ActiveSuppression {
1631        ActiveSuppression {
1632            path: path.to_path_buf(),
1633            kind: Some(kind.to_owned()),
1634            is_file_level: false,
1635        }
1636    }
1637
1638    /// Record one attribution run against the store.
1639    fn run(
1640        root: &Path,
1641        changed: &[&Path],
1642        findings: Vec<FindingInput>,
1643        clones: Vec<CloneInput>,
1644        supps: &[ActiveSuppression],
1645        ts: &str,
1646    ) {
1647        let changed_files: Vec<PathBuf> = changed.iter().map(|p| p.to_path_buf()).collect();
1648        let input = AttributionInput {
1649            root,
1650            scope: Scope::ChangedFiles(&changed_files),
1651            findings,
1652            clones,
1653            suppressions: supps,
1654        };
1655        record_audit_run(
1656            root,
1657            &summary(0, 0, 0),
1658            AuditVerdict::Pass,
1659            true,
1660            Some("sha"),
1661            "2.0.0",
1662            ts,
1663            Some(&input),
1664        );
1665    }
1666
1667    #[test]
1668    fn disabled_store_does_not_record() {
1669        let dir = tempfile::tempdir().unwrap();
1670        let root = dir.path();
1671        // Not enabled: recording is a no-op.
1672        record_v1(
1673            root,
1674            &summary(3, 1, 0),
1675            AuditVerdict::Fail,
1676            true,
1677            Some("abc1234"),
1678            "2.0.0",
1679            "2026-05-29T10:00:00Z",
1680        );
1681        let store = load(root);
1682        assert!(store.records.is_empty());
1683        assert!(!store.enabled);
1684    }
1685
1686    #[test]
1687    fn enable_then_record_accrues_history() {
1688        let dir = tempfile::tempdir().unwrap();
1689        let root = dir.path();
1690        assert!(enable(root));
1691        assert!(!enable(root)); // second enable is a no-op-ish (already on)
1692        record_v1(
1693            root,
1694            &summary(2, 1, 0),
1695            AuditVerdict::Warn,
1696            false,
1697            None,
1698            "2.0.0",
1699            "2026-05-29T10:00:00Z",
1700        );
1701        let store = load(root);
1702        assert_eq!(store.records.len(), 1);
1703        assert_eq!(store.records[0].counts.total_issues, 3);
1704        assert_eq!(
1705            store.first_recorded.as_deref(),
1706            Some("2026-05-29T10:00:00Z")
1707        );
1708    }
1709
1710    #[test]
1711    fn enable_gitignores_the_store() {
1712        let dir = tempfile::tempdir().unwrap();
1713        let root = dir.path();
1714        enable(root);
1715        let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1716        assert!(
1717            gitignore.lines().any(|l| l.trim() == ".fallow/"),
1718            "enable must gitignore .fallow/, got: {gitignore:?}"
1719        );
1720        // Idempotent: a second enable does not duplicate the entry, and an
1721        // existing entry (e.g. from `fallow init`) is left alone.
1722        enable(root);
1723        let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1724        assert_eq!(
1725            gitignore.lines().filter(|l| l.trim() == ".fallow/").count(),
1726            1,
1727            "re-enabling must not duplicate the .fallow/ entry"
1728        );
1729    }
1730
1731    #[test]
1732    fn single_record_yields_no_trend_no_spike() {
1733        let mut store = ImpactStore {
1734            enabled: true,
1735            ..Default::default()
1736        };
1737        store.records.push(ImpactRecord {
1738            timestamp: "t0".into(),
1739            version: "2.0.0".into(),
1740            git_sha: None,
1741            verdict: "warn".into(),
1742            gate: false,
1743            counts: ImpactCounts {
1744                total_issues: 5,
1745                dead_code: 5,
1746                complexity: 0,
1747                duplication: 0,
1748            },
1749        });
1750        let report = build_report(&store);
1751        // A single record must NOT produce a trend (which would read as a spike
1752        // from zero on the first run after enabling).
1753        assert!(report.trend.is_none());
1754        assert_eq!(report.surfacing.unwrap().total_issues, 5);
1755    }
1756
1757    #[test]
1758    fn empty_store_report_is_first_run() {
1759        let store = ImpactStore::default();
1760        let report = build_report(&store);
1761        assert_eq!(report.record_count, 0);
1762        assert!(report.trend.is_none());
1763        assert!(report.surfacing.is_none());
1764        let human = render_human(&report);
1765        assert!(human.contains("off")); // default store is disabled
1766    }
1767
1768    #[test]
1769    fn enabled_empty_store_shows_check_back() {
1770        let store = ImpactStore {
1771            enabled: true,
1772            ..Default::default()
1773        };
1774        let report = build_report(&store);
1775        let human = render_human(&report);
1776        assert!(human.contains("No history yet"));
1777        // Never a fabricated zero presented as a value claim.
1778        assert!(!human.contains("0 issues"));
1779    }
1780
1781    #[test]
1782    fn trend_improving_when_issues_drop() {
1783        let mut store = ImpactStore {
1784            enabled: true,
1785            ..Default::default()
1786        };
1787        for total in [8usize, 3usize] {
1788            store.records.push(ImpactRecord {
1789                timestamp: format!("t{total}"),
1790                version: "2.0.0".into(),
1791                git_sha: None,
1792                verdict: "warn".into(),
1793                gate: false,
1794                counts: ImpactCounts {
1795                    total_issues: total,
1796                    dead_code: total,
1797                    complexity: 0,
1798                    duplication: 0,
1799                },
1800            });
1801        }
1802        let report = build_report(&store);
1803        let trend = report.trend.unwrap();
1804        assert_eq!(trend.direction, ImpactTrendDirection::Improving);
1805        assert_eq!(trend.total_delta, -5);
1806    }
1807
1808    #[test]
1809    fn containment_blocked_then_cleared_records_one_event() {
1810        let dir = tempfile::tempdir().unwrap();
1811        let root = dir.path();
1812        enable(root);
1813        // Gate run fails: blocked.
1814        record_v1(
1815            root,
1816            &summary(2, 0, 0),
1817            AuditVerdict::Fail,
1818            true,
1819            Some("sha1"),
1820            "2.0.0",
1821            "t0",
1822        );
1823        let store = load(root);
1824        assert!(store.pending_containment.is_some());
1825        assert!(store.containment.is_empty());
1826
1827        // Gate run passes: cleared -> one containment event.
1828        record_v1(
1829            root,
1830            &summary(0, 0, 0),
1831            AuditVerdict::Pass,
1832            true,
1833            Some("sha2"),
1834            "2.0.0",
1835            "t1",
1836        );
1837        let store = load(root);
1838        assert!(store.pending_containment.is_none());
1839        assert_eq!(store.containment.len(), 1);
1840        assert_eq!(store.containment[0].blocked_at, "t0");
1841        assert_eq!(store.containment[0].cleared_at, "t1");
1842    }
1843
1844    #[test]
1845    fn non_gate_run_never_creates_containment() {
1846        let dir = tempfile::tempdir().unwrap();
1847        let root = dir.path();
1848        enable(root);
1849        // Fail but NOT a gate run: no pending containment.
1850        record_v1(
1851            root,
1852            &summary(2, 0, 0),
1853            AuditVerdict::Fail,
1854            false,
1855            None,
1856            "2.0.0",
1857            "t0",
1858        );
1859        let store = load(root);
1860        assert!(store.pending_containment.is_none());
1861        assert!(store.containment.is_empty());
1862    }
1863
1864    #[test]
1865    fn corrupt_store_loads_as_default_no_panic() {
1866        let dir = tempfile::tempdir().unwrap();
1867        let root = dir.path();
1868        std::fs::create_dir_all(root.join(".fallow")).unwrap();
1869        std::fs::write(store_path(root), b"{ not valid json ][").unwrap();
1870        // Must not panic; degrades to a default (disabled) store.
1871        let store = load(root);
1872        assert!(!store.enabled);
1873        assert!(store.records.is_empty());
1874        // Recording against a corrupt store is a no-op (disabled), never an error.
1875        record_v1(
1876            root,
1877            &summary(1, 0, 0),
1878            AuditVerdict::Fail,
1879            true,
1880            None,
1881            "2.0.0",
1882            "t0",
1883        );
1884    }
1885
1886    #[test]
1887    fn records_are_bounded() {
1888        let mut store = ImpactStore {
1889            enabled: true,
1890            ..Default::default()
1891        };
1892        for i in 0..(MAX_RECORDS + 50) {
1893            store.records.push(ImpactRecord {
1894                timestamp: format!("t{i}"),
1895                version: "2.0.0".into(),
1896                git_sha: None,
1897                verdict: "pass".into(),
1898                gate: false,
1899                counts: ImpactCounts::default(),
1900            });
1901        }
1902        compact(&mut store);
1903        assert_eq!(store.records.len(), MAX_RECORDS);
1904        // Oldest dropped: the surviving first record is t50.
1905        assert_eq!(store.records[0].timestamp, "t50");
1906    }
1907
1908    #[test]
1909    fn report_always_carries_schema_version() {
1910        // Disabled / empty store still emits the schema version so a machine
1911        // consumer has a forward-compat signal regardless of state.
1912        let empty = build_report(&ImpactStore::default());
1913        assert_eq!(empty.schema_version, ImpactReportSchemaVersion::V1);
1914        let json = render_json(&empty);
1915        assert!(
1916            json.contains("\"schema_version\": \"1\""),
1917            "schema_version must be present (as the \"1\" const) even when disabled: {json}"
1918        );
1919
1920        let mut store = ImpactStore {
1921            enabled: true,
1922            ..Default::default()
1923        };
1924        store.records.push(ImpactRecord {
1925            timestamp: "2026-05-29T10:00:00Z".into(),
1926            version: "2.0.0".into(),
1927            git_sha: None,
1928            verdict: "pass".into(),
1929            gate: false,
1930            counts: ImpactCounts::default(),
1931        });
1932        assert_eq!(
1933            build_report(&store).schema_version,
1934            ImpactReportSchemaVersion::V1
1935        );
1936    }
1937
1938    #[test]
1939    fn date_only_trims_iso_timestamp() {
1940        assert_eq!(date_only("2026-05-29T18:15:23Z"), "2026-05-29");
1941        // No `T` separator: returned unchanged.
1942        assert_eq!(date_only("2026-05-29"), "2026-05-29");
1943        assert_eq!(date_only("the first run"), "the first run");
1944    }
1945
1946    #[test]
1947    fn human_footer_shows_date_only() {
1948        let mut store = ImpactStore {
1949            enabled: true,
1950            ..Default::default()
1951        };
1952        store.first_recorded = Some("2026-05-29T18:15:23Z".into());
1953        store.records.push(ImpactRecord {
1954            timestamp: "2026-05-29T18:15:23Z".into(),
1955            version: "2.0.0".into(),
1956            git_sha: None,
1957            verdict: "pass".into(),
1958            gate: false,
1959            counts: ImpactCounts::default(),
1960        });
1961        let report = build_report(&store);
1962        let human = render_human(&report);
1963        assert!(
1964            human.contains("since 2026-05-29.") && !human.contains("18:15:23"),
1965            "human footer must show date-only: {human}"
1966        );
1967        let md = render_markdown(&report);
1968        assert!(
1969            md.contains("since 2026-05-29.") && !md.contains("18:15:23"),
1970            "markdown footer must show date-only: {md}"
1971        );
1972    }
1973
1974    #[test]
1975    fn future_schema_version_store_loads_without_panic_or_loss() {
1976        let dir = tempfile::tempdir().unwrap();
1977        let root = dir.path();
1978        std::fs::create_dir_all(root.join(".fallow")).unwrap();
1979        // A store written by a hypothetical future fallow (schema_version 2)
1980        // must still load (best-effort) rather than be discarded as corrupt.
1981        let future = format!(
1982            "{{\"schema_version\":{},\"enabled\":true,\"records\":[],\"containment\":[]}}",
1983            STORE_SCHEMA_VERSION + 1
1984        );
1985        std::fs::write(store_path(root), future).unwrap();
1986        let store = load(root);
1987        assert_eq!(store.schema_version, STORE_SCHEMA_VERSION + 1);
1988        assert!(
1989            store.enabled,
1990            "future-version store must not degrade to default"
1991        );
1992    }
1993
1994    // ---- v1.5 per-finding attribution ----
1995
1996    #[test]
1997    fn removed_finding_is_credited_as_resolved() {
1998        let dir = tempfile::tempdir().unwrap();
1999        let root = dir.path();
2000        enable(root);
2001        let a = touch(root, "src/a.ts");
2002        run(
2003            root,
2004            &[&a],
2005            vec![fi(&a, "unused-export", "foo")],
2006            vec![],
2007            &[],
2008            "t0",
2009        );
2010        assert_eq!(
2011            load(root).resolved_total,
2012            0,
2013            "first run only establishes a baseline"
2014        );
2015        run(root, &[&a], vec![], vec![], &[], "t1");
2016        let store = load(root);
2017        assert_eq!(store.resolved_total, 1);
2018        assert_eq!(store.suppressed_total, 0);
2019        assert_eq!(store.recent_resolved.len(), 1);
2020        assert_eq!(store.recent_resolved[0].kind, "unused-export");
2021        assert_eq!(store.recent_resolved[0].symbol.as_deref(), Some("foo"));
2022        assert_eq!(store.recent_resolved[0].path, "src/a.ts");
2023    }
2024
2025    #[test]
2026    fn suppressed_finding_is_not_a_win() {
2027        let dir = tempfile::tempdir().unwrap();
2028        let root = dir.path();
2029        enable(root);
2030        let a = touch(root, "src/a.ts");
2031        run(
2032            root,
2033            &[&a],
2034            vec![fi(&a, "unused-export", "foo")],
2035            vec![],
2036            &[],
2037            "t0",
2038        );
2039        run(
2040            root,
2041            &[&a],
2042            vec![],
2043            vec![],
2044            &[supp(&a, "unused-export")],
2045            "t1",
2046        );
2047        let store = load(root);
2048        assert_eq!(
2049            store.resolved_total, 0,
2050            "a suppression must never count as a win"
2051        );
2052        assert_eq!(store.suppressed_total, 1);
2053    }
2054
2055    #[test]
2056    fn fix_and_suppress_same_kind_credits_zero_resolved() {
2057        let dir = tempfile::tempdir().unwrap();
2058        let root = dir.path();
2059        enable(root);
2060        let a = touch(root, "src/a.ts");
2061        run(
2062            root,
2063            &[&a],
2064            vec![
2065                fi(&a, "unused-export", "foo"),
2066                fi(&a, "unused-export", "bar"),
2067            ],
2068            vec![],
2069            &[],
2070            "t0",
2071        );
2072        run(
2073            root,
2074            &[&a],
2075            vec![],
2076            vec![],
2077            &[supp(&a, "unused-export")],
2078            "t1",
2079        );
2080        let store = load(root);
2081        assert_eq!(store.resolved_total, 0);
2082        assert_eq!(store.suppressed_total, 2);
2083    }
2084
2085    #[test]
2086    fn within_file_move_is_not_resolved() {
2087        let dir = tempfile::tempdir().unwrap();
2088        let root = dir.path();
2089        enable(root);
2090        let a = touch(root, "src/a.ts");
2091        run(
2092            root,
2093            &[&a],
2094            vec![fi(&a, "unused-export", "foo")],
2095            vec![],
2096            &[],
2097            "t0",
2098        );
2099        run(
2100            root,
2101            &[&a],
2102            vec![fi(&a, "unused-export", "foo")],
2103            vec![],
2104            &[],
2105            "t1",
2106        );
2107        let store = load(root);
2108        assert_eq!(store.resolved_total, 0);
2109        assert_eq!(store.suppressed_total, 0);
2110    }
2111
2112    #[test]
2113    fn cross_file_move_in_same_run_is_not_resolved() {
2114        let dir = tempfile::tempdir().unwrap();
2115        let root = dir.path();
2116        enable(root);
2117        let a = touch(root, "src/a.ts");
2118        let b = touch(root, "src/b.ts");
2119        run(
2120            root,
2121            &[&a],
2122            vec![fi(&a, "unused-export", "foo")],
2123            vec![],
2124            &[],
2125            "t0",
2126        );
2127        run(
2128            root,
2129            &[&a, &b],
2130            vec![fi(&b, "unused-export", "foo")],
2131            vec![],
2132            &[],
2133            "t1",
2134        );
2135        assert_eq!(
2136            load(root).resolved_total,
2137            0,
2138            "a cross-file move is not a resolution"
2139        );
2140    }
2141
2142    #[test]
2143    fn cross_run_move_uncredits_the_prior_resolution() {
2144        let dir = tempfile::tempdir().unwrap();
2145        let root = dir.path();
2146        enable(root);
2147        let a = touch(root, "src/a.ts");
2148        let b = touch(root, "src/b.ts");
2149        run(
2150            root,
2151            &[&a],
2152            vec![fi(&a, "unused-export", "foo")],
2153            vec![],
2154            &[],
2155            "t0",
2156        );
2157        run(root, &[&a], vec![], vec![], &[], "t1");
2158        assert_eq!(
2159            load(root).resolved_total,
2160            1,
2161            "source disappearance credited in run A"
2162        );
2163        run(
2164            root,
2165            &[&b],
2166            vec![fi(&b, "unused-export", "foo")],
2167            vec![],
2168            &[],
2169            "t2",
2170        );
2171        let store = load(root);
2172        assert_eq!(
2173            store.resolved_total, 0,
2174            "cross-run move must un-credit the phantom win"
2175        );
2176        assert!(
2177            store.recent_resolved.is_empty(),
2178            "the stale resolution event is dropped"
2179        );
2180    }
2181
2182    #[test]
2183    fn resolved_complexity_finding_and_suppressed_complexity() {
2184        let dir = tempfile::tempdir().unwrap();
2185        let root = dir.path();
2186        enable(root);
2187        let a = touch(root, "src/a.ts");
2188        run(
2189            root,
2190            &[&a],
2191            vec![fi(&a, "complexity", "bigFn")],
2192            vec![],
2193            &[],
2194            "t0",
2195        );
2196        run(root, &[&a], vec![], vec![], &[supp(&a, "complexity")], "t1");
2197        let store = load(root);
2198        assert_eq!(store.resolved_total, 0);
2199        assert_eq!(store.suppressed_total, 1);
2200
2201        let b = touch(root, "src/b.ts");
2202        run(
2203            root,
2204            &[&b],
2205            vec![fi(&b, "complexity", "huge")],
2206            vec![],
2207            &[],
2208            "t2",
2209        );
2210        run(root, &[&b], vec![], vec![], &[], "t3");
2211        assert_eq!(load(root).resolved_total, 1);
2212    }
2213
2214    #[test]
2215    fn resolved_duplication_clone_group() {
2216        let dir = tempfile::tempdir().unwrap();
2217        let root = dir.path();
2218        enable(root);
2219        let a = touch(root, "src/a.ts");
2220        let b = touch(root, "src/b.ts");
2221        let clone = CloneInput {
2222            fingerprint: "dup:abc12345".to_owned(),
2223            instance_paths: vec![a.clone(), b],
2224        };
2225        run(root, &[&a], vec![], vec![clone], &[], "t0");
2226        run(root, &[&a], vec![], vec![], &[], "t1");
2227        let store = load(root);
2228        assert_eq!(store.resolved_total, 1);
2229        assert_eq!(store.recent_resolved[0].kind, "code-duplication");
2230    }
2231
2232    #[test]
2233    fn blanket_suppression_covers_any_kind() {
2234        let dir = tempfile::tempdir().unwrap();
2235        let root = dir.path();
2236        enable(root);
2237        let a = touch(root, "src/a.ts");
2238        run(
2239            root,
2240            &[&a],
2241            vec![fi(&a, "unused-export", "foo")],
2242            vec![],
2243            &[],
2244            "t0",
2245        );
2246        let blanket = ActiveSuppression {
2247            path: a.clone(),
2248            kind: None,
2249            is_file_level: true,
2250        };
2251        run(root, &[&a], vec![], vec![], &[blanket], "t1");
2252        let store = load(root);
2253        assert_eq!(store.resolved_total, 0);
2254        assert_eq!(store.suppressed_total, 1);
2255    }
2256
2257    #[test]
2258    fn v1_store_loads_and_upgrades_to_v2() {
2259        let dir = tempfile::tempdir().unwrap();
2260        let root = dir.path();
2261        std::fs::create_dir_all(root.join(".fallow")).unwrap();
2262        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":[]}"#;
2263        std::fs::write(store_path(root), v1).unwrap();
2264        let store = load(root);
2265        assert_eq!(store.schema_version, 1);
2266        assert!(store.frontier.is_empty());
2267        assert_eq!(store.resolved_total, 0);
2268        let a = touch(root, "src/a.ts");
2269        run(
2270            root,
2271            &[&a],
2272            vec![fi(&a, "unused-export", "foo")],
2273            vec![],
2274            &[],
2275            "t1",
2276        );
2277        let store = load(root);
2278        assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
2279        assert!(store.frontier.contains_key("src/a.ts"));
2280    }
2281
2282    #[test]
2283    fn recent_resolved_is_bounded() {
2284        let mut store = ImpactStore {
2285            enabled: true,
2286            ..Default::default()
2287        };
2288        for i in 0..(MAX_RECENT_RESOLVED + 25) {
2289            store.recent_resolved.push(ResolutionEvent {
2290                kind: "unused-export".into(),
2291                path: format!("src/f{i}.ts"),
2292                symbol: Some(format!("s{i}")),
2293                git_sha: None,
2294                timestamp: format!("t{i}"),
2295            });
2296        }
2297        bound_recent_resolved(&mut store);
2298        assert_eq!(store.recent_resolved.len(), MAX_RECENT_RESOLVED);
2299        assert_eq!(store.recent_resolved[0].path, "src/f25.ts");
2300    }
2301
2302    #[test]
2303    fn frontier_prunes_deleted_files() {
2304        let dir = tempfile::tempdir().unwrap();
2305        let root = dir.path();
2306        enable(root);
2307        let a = touch(root, "src/a.ts");
2308        run(
2309            root,
2310            &[&a],
2311            vec![fi(&a, "unused-export", "foo")],
2312            vec![],
2313            &[],
2314            "t0",
2315        );
2316        assert!(load(root).frontier.contains_key("src/a.ts"));
2317        std::fs::remove_file(&a).unwrap();
2318        let b = touch(root, "src/b.ts");
2319        run(root, &[&b], vec![], vec![], &[], "t1");
2320        assert!(!load(root).frontier.contains_key("src/a.ts"));
2321    }
2322
2323    #[test]
2324    fn honest_empty_state_before_attribution_baseline() {
2325        let store = ImpactStore {
2326            enabled: true,
2327            records: vec![ImpactRecord {
2328                timestamp: "t0".into(),
2329                version: "2.0.0".into(),
2330                git_sha: None,
2331                verdict: "warn".into(),
2332                gate: false,
2333                counts: ImpactCounts::default(),
2334            }],
2335            ..Default::default()
2336        };
2337        let report = build_report(&store);
2338        assert!(!report.attribution_active);
2339        let human = render_human(&report);
2340        assert!(human.contains("resolution tracking starts from your next gate run"));
2341        assert!(!human.contains("0 finding"));
2342    }
2343
2344    #[test]
2345    fn suppression_only_state_renders_under_a_resolved_header() {
2346        let report = ImpactReport {
2347            schema_version: ImpactReportSchemaVersion::V1,
2348            enabled: true,
2349            record_count: 2,
2350            first_recorded: Some("2026-05-29T10:00:00Z".into()),
2351            latest_git_sha: None,
2352            surfacing: Some(ImpactCounts::default()),
2353            trend: None,
2354            project_surfacing: None,
2355            project_trend: None,
2356            containment_count: 0,
2357            recent_containment: vec![],
2358            resolved_total: 0,
2359            suppressed_total: 2,
2360            recent_resolved: vec![],
2361            attribution_active: true,
2362        };
2363        let human = render_human(&report);
2364        let resolved_idx = human.find("  RESOLVED").expect("RESOLVED header present");
2365        let supp_idx = human
2366            .find("2 findings you marked intentional")
2367            .expect("suppression line present");
2368        assert!(
2369            resolved_idx < supp_idx,
2370            "suppression must render under RESOLVED"
2371        );
2372        assert!(human.contains("none yet"));
2373
2374        let md = render_markdown(&report);
2375        assert!(
2376            md.contains("- **Resolved:**"),
2377            "markdown always has a Resolved bullet"
2378        );
2379        assert!(md.contains("- **Marked intentional:** 2 finding"));
2380    }
2381
2382    /// Build a `CloneInput` over real absolute paths (built from `root`).
2383    fn clone_at(fingerprint: &str, paths: &[&Path]) -> CloneInput {
2384        CloneInput {
2385            fingerprint: fingerprint.to_owned(),
2386            instance_paths: paths.iter().map(|p| p.to_path_buf()).collect(),
2387        }
2388    }
2389
2390    /// Record a WHOLE-PROJECT run via the real combined-track recorder
2391    /// (`record_combined_run` with `Scope::WholeProject`), exercising the same
2392    /// path `combined.rs` uses on a full `fallow` run.
2393    fn run_wp(
2394        root: &Path,
2395        findings: Vec<FindingInput>,
2396        clones: Vec<CloneInput>,
2397        supps: &[ActiveSuppression],
2398        ts: &str,
2399    ) {
2400        let input = AttributionInput {
2401            root,
2402            scope: Scope::WholeProject,
2403            findings,
2404            clones,
2405            suppressions: supps,
2406        };
2407        record_combined_run(
2408            root,
2409            ImpactCounts::default(),
2410            Some("sha"),
2411            "2.0.0",
2412            ts,
2413            Some(&input),
2414        );
2415    }
2416
2417    // FIX-FIRST safety test 1: a clone credited resolved by an audit run is NOT
2418    // re-credited by a later whole-project run. The frontier is shared across
2419    // both tracks, and a disappearance leaves the frontier exactly once, so a
2420    // wider scope cannot re-find it. Uses real temp files because
2421    // `prune_frontier` drops entries whose files are gone from disk.
2422    #[test]
2423    fn whole_project_run_does_not_double_credit_after_audit() {
2424        let dir = tempfile::tempdir().unwrap();
2425        let root = dir.path();
2426        enable(root);
2427        let a = touch(root, "src/a.ts");
2428        let b = touch(root, "src/b.ts");
2429        // Audit run 1: clone present, enters the clone frontier.
2430        run(
2431            root,
2432            &[&a, &b],
2433            vec![],
2434            vec![clone_at("dup:abc", &[&a, &b])],
2435            &[],
2436            "t1",
2437        );
2438        assert_eq!(load(root).clone_frontier.len(), 1);
2439
2440        // Audit run 2: clone gone, credited resolved once and removed.
2441        run(root, &[&a, &b], vec![], vec![], &[], "t2");
2442        assert_eq!(load(root).resolved_total, 1);
2443        assert!(load(root).clone_frontier.is_empty());
2444
2445        // Whole-project run: clone still gone. Must NOT re-credit.
2446        run_wp(root, vec![], vec![], &[], "t3");
2447        assert_eq!(
2448            load(root).resolved_total,
2449            1,
2450            "whole-project run re-credited a resolution"
2451        );
2452    }
2453
2454    // FIX-FIRST safety test 2: on a whole-project run, a finding gone from an
2455    // UNCHANGED file because a fresh fallow-ignore now covers it is credited
2456    // suppressed, never resolved (the v1.5 false-win guard through the wider
2457    // WholeProject scope rather than a git changed set).
2458    #[test]
2459    fn whole_project_run_credits_suppressed_not_resolved() {
2460        let dir = tempfile::tempdir().unwrap();
2461        let root = dir.path();
2462        enable(root);
2463        let util = touch(root, "src/util.ts");
2464        // Audit run records the finding into the frontier.
2465        run(
2466            root,
2467            &[&util],
2468            vec![fi(&util, "unused-export", "dead")],
2469            vec![],
2470            &[],
2471            "t1",
2472        );
2473        assert_eq!(load(root).frontier.len(), 1);
2474
2475        // Whole-project run: finding gone, a fresh fallow-ignore covers it. The
2476        // file is in scope because it is a frontier key (no git changed set).
2477        run_wp(root, vec![], vec![], &[supp(&util, "unused-export")], "t2");
2478        let store = load(root);
2479        assert_eq!(
2480            store.suppressed_total, 1,
2481            "suppressed finding not counted suppressed"
2482        );
2483        assert_eq!(
2484            store.resolved_total, 0,
2485            "suppressed finding wrongly counted resolved"
2486        );
2487    }
2488
2489    // FIX-FIRST safety test 3: a clone reshaped from three instances to two
2490    // (still duplicated, new content fingerprint) is NOT credited as fully
2491    // resolved. Duplication persists at the surviving files, so the disappeared
2492    // fingerprint is re-tracked under the new one, never counted as a win.
2493    #[test]
2494    fn clone_reshape_three_to_two_not_credited_as_resolved() {
2495        let dir = tempfile::tempdir().unwrap();
2496        let root = dir.path();
2497        enable(root);
2498        let a = touch(root, "src/a.ts");
2499        let b = touch(root, "src/b.ts");
2500        let c = touch(root, "src/c.ts");
2501        // Run 1: a 3-instance clone.
2502        run(
2503            root,
2504            &[&a, &b, &c],
2505            vec![],
2506            vec![clone_at("dup:aaa", &[&a, &b, &c])],
2507            &[],
2508            "t1",
2509        );
2510        assert_eq!(load(root).clone_frontier.len(), 1);
2511
2512        // Run 2 (whole project): one instance removed, the remaining two still
2513        // duplicate under a new fingerprint. The old fingerprint disappears but
2514        // its files are still duplicated, so it is a reshape, not a resolution.
2515        run_wp(
2516            root,
2517            vec![],
2518            vec![clone_at("dup:bbb", &[&a, &b])],
2519            &[],
2520            "t2",
2521        );
2522        let store = load(root);
2523        assert_eq!(
2524            store.resolved_total, 0,
2525            "clone reshape miscredited as resolved"
2526        );
2527        assert!(store.clone_frontier.contains_key("dup:bbb"));
2528        assert!(!store.clone_frontier.contains_key("dup:aaa"));
2529    }
2530
2531    // ---- v1.6 whole-project render-state tests ----
2532    // Lock the exact human/markdown strings for the project-only and both-tracks
2533    // states. Every other render test sets `project_surfacing: None`, so without
2534    // these, a regression (section dropped, empty-state guard misfiring, or an
2535    // unlabeled count) would pass CI unnoticed.
2536
2537    fn rcounts(total: usize, dead: usize, complexity: usize, dup: usize) -> ImpactCounts {
2538        ImpactCounts {
2539            total_issues: total,
2540            dead_code: dead,
2541            complexity,
2542            duplication: dup,
2543        }
2544    }
2545
2546    fn rtrend(prev: usize, cur: usize) -> TrendSummary {
2547        TrendSummary {
2548            direction: direction_for(cur as i64 - prev as i64),
2549            total_delta: cur as i64 - prev as i64,
2550            previous_total: prev,
2551            current_total: cur,
2552        }
2553    }
2554
2555    /// Build a report literal for render-state tests.
2556    fn rreport(
2557        record_count: usize,
2558        first_recorded: Option<&str>,
2559        surfacing: Option<ImpactCounts>,
2560        trend: Option<TrendSummary>,
2561        project_surfacing: Option<ImpactCounts>,
2562        project_trend: Option<TrendSummary>,
2563        attribution_active: bool,
2564    ) -> ImpactReport {
2565        ImpactReport {
2566            schema_version: ImpactReportSchemaVersion::V1,
2567            enabled: true,
2568            record_count,
2569            first_recorded: first_recorded.map(ToOwned::to_owned),
2570            latest_git_sha: None,
2571            surfacing,
2572            trend,
2573            project_surfacing,
2574            project_trend,
2575            containment_count: 0,
2576            recent_containment: vec![],
2577            resolved_total: 0,
2578            suppressed_total: 0,
2579            recent_resolved: vec![],
2580            attribution_active,
2581        }
2582    }
2583
2584    // A project-only store (full `fallow` runs, never `audit`): record_count 0
2585    // but project_surfacing present. Must render the WHOLE PROJECT section and a
2586    // "Tracking since" footer, NOT "No history yet" / a changed-file caveat.
2587    #[test]
2588    fn render_human_project_only_store_shows_whole_project_not_empty_state() {
2589        let r = rreport(
2590            0,
2591            Some("2026-05-30T10:00:00Z"),
2592            None,
2593            None,
2594            Some(rcounts(1, 1, 0, 0)),
2595            None,
2596            true,
2597        );
2598        let human = render_human(&r);
2599        assert!(
2600            human.contains("WHOLE PROJECT (whole-repo context, not a to-do)"),
2601            "project-only must render the labeled section"
2602        );
2603        assert!(human.contains("1 issue across the whole project"));
2604        assert!(
2605            human.contains("project trend starts after your next full `fallow` run"),
2606            "single project record => no trend line, shows the next-run hint"
2607        );
2608        assert!(human.contains("Tracking since 2026-05-30"));
2609        assert!(
2610            !human.contains("No history yet"),
2611            "must not show the empty-state copy"
2612        );
2613        assert!(
2614            !human.contains("LATEST RUN"),
2615            "no changed-file track recorded"
2616        );
2617        assert!(
2618            !human.contains("recorded audit run"),
2619            "no audit runs => no changed-file footer"
2620        );
2621    }
2622
2623    // Both tracks present: the two counts must be labeled so a human knows which
2624    // is actionable, and LATEST RUN must render before WHOLE PROJECT.
2625    #[test]
2626    fn render_human_both_tracks_label_actionable_vs_context() {
2627        let r = rreport(
2628            3,
2629            Some("2026-05-29T10:00:00Z"),
2630            Some(rcounts(4, 4, 0, 0)),
2631            Some(rtrend(6, 4)),
2632            Some(rcounts(40, 30, 5, 5)),
2633            Some(rtrend(45, 40)),
2634            true,
2635        );
2636        let human = render_human(&r);
2637        let latest = human
2638            .find("LATEST RUN (changed files, act on these now)")
2639            .expect("LATEST RUN labeled actionable");
2640        let whole = human
2641            .find("WHOLE PROJECT (whole-repo context, not a to-do)")
2642            .expect("WHOLE PROJECT labeled context");
2643        assert!(
2644            latest < whole,
2645            "changed-file section renders before whole-project"
2646        );
2647        assert!(human.contains("45 -> 40 (down) across your last two full runs"));
2648        assert!(human.contains("advances only on your local full `fallow` runs, not CI"));
2649    }
2650
2651    #[test]
2652    fn render_markdown_project_only_store_shows_whole_project_not_empty_state() {
2653        let r = rreport(
2654            0,
2655            Some("2026-05-30T10:00:00Z"),
2656            None,
2657            None,
2658            Some(rcounts(1, 1, 0, 0)),
2659            None,
2660            true,
2661        );
2662        let md = render_markdown(&r);
2663        assert!(
2664            md.contains(
2665                "- **Whole project (whole-repo context, last full `fallow` run):** 1 issue"
2666            ),
2667            "project-only md must render the labeled whole-project line"
2668        );
2669        assert!(
2670            !md.contains("No history yet"),
2671            "project-only md must not show empty state"
2672        );
2673        assert!(md.contains("Tracking since 2026-05-30"));
2674    }
2675}