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