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