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