Skip to main content

fallow_cli/
impact.rs

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