Skip to main content

fallow_cli/
impact.rs

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