1use fallow_types::output::NextStep;
8use fallow_types::results::AnalysisResults;
9use std::path::Path;
10
11use crate::HealthReport;
12
13const MAX_NEXT_STEPS: usize = 3;
14const MUTATING_VERBS: [&str; 5] = ["fix", "init", "hooks", "migrate", "setup-hooks"];
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct ImpactDigestCounts {
19 pub containment_count: usize,
20 pub resolved_total: usize,
21}
22
23#[derive(Debug, Clone, Copy)]
25pub struct DeadCodeNextStepsInput<'a> {
26 pub suggestions_enabled: bool,
27 pub results: &'a AnalysisResults,
28 pub root: &'a Path,
29 pub offer_setup: bool,
30 pub impact_digest: Option<ImpactDigestCounts>,
31 pub workspace_ref: Option<&'a str>,
32 pub audit_changed: bool,
33}
34
35#[derive(Debug, Clone, Copy)]
37pub struct DupesNextStepsInput<'a> {
38 pub suggestions_enabled: bool,
39 pub clone_fingerprints: &'a [&'a str],
40 pub offer_setup: bool,
41 pub impact_digest: Option<ImpactDigestCounts>,
42 pub audit_changed: bool,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct TraceUnusedExportInput {
48 pub path: String,
49 pub export_name: String,
50}
51
52#[derive(Debug, Clone)]
54pub struct CombinedNextStepsInput<'a> {
55 pub suggestions_enabled: bool,
56 pub has_dead_code_findings: bool,
57 pub trace_unused_export: Option<TraceUnusedExportInput>,
58 pub workspace_ref: Option<&'a str>,
59 pub clone_fingerprints: &'a [&'a str],
60 pub has_complexity_findings: bool,
61 pub offer_setup: bool,
62 pub impact_digest: Option<ImpactDigestCounts>,
63 pub audit_changed: bool,
64}
65
66#[derive(Debug, Clone)]
68pub struct AuditNextStepsInput {
69 pub suggestions_enabled: bool,
70 pub trace_unused_export: Option<TraceUnusedExportInput>,
71 pub has_complexity_findings: bool,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub struct HealthNextStepsInput {
77 pub suggestions_enabled: bool,
78 pub has_findings: bool,
79 pub offer_setup: bool,
80 pub impact_digest: Option<ImpactDigestCounts>,
81 pub audit_changed: bool,
82}
83
84#[must_use]
87pub fn build_health_next_steps_input(
88 report: &HealthReport,
89 suggestions_enabled: bool,
90 offer_setup: bool,
91 impact_digest: Option<ImpactDigestCounts>,
92 audit_changed: bool,
93) -> HealthNextStepsInput {
94 HealthNextStepsInput {
95 suggestions_enabled,
96 has_findings: !report.findings.is_empty(),
97 offer_setup,
98 impact_digest,
99 audit_changed,
100 }
101}
102
103#[must_use]
106pub fn impact_digest_summary(digest: ImpactDigestCounts) -> String {
107 let mut parts = Vec::new();
108 if digest.containment_count > 0 {
109 parts.push(format!(
110 "{} commit{} contained at the gate",
111 digest.containment_count,
112 if digest.containment_count == 1 {
113 ""
114 } else {
115 "s"
116 }
117 ));
118 }
119 if digest.resolved_total > 0 {
120 parts.push(format!(
121 "{} finding{} resolved",
122 digest.resolved_total,
123 if digest.resolved_total == 1 { "" } else { "s" }
124 ));
125 }
126 parts.join(", ")
127}
128
129#[must_use]
131pub fn build_health_next_steps(input: HealthNextStepsInput) -> Vec<NextStep> {
132 if !input.suggestions_enabled {
133 return Vec::new();
134 }
135 if !input.has_findings {
136 return impact_digest_step(input.impact_digest)
137 .into_iter()
138 .collect();
139 }
140
141 let mut steps: Vec<NextStep> = [
142 setup_pointer(input.offer_setup),
143 impact_digest_step(input.impact_digest),
144 complexity_breakdown(input.has_findings),
145 audit_changed(input.audit_changed),
146 ]
147 .into_iter()
148 .flatten()
149 .collect();
150 steps.truncate(MAX_NEXT_STEPS);
151 steps
152}
153
154#[must_use]
156pub fn build_dead_code_next_steps(input: DeadCodeNextStepsInput<'_>) -> Vec<NextStep> {
157 if !input.suggestions_enabled {
158 return Vec::new();
159 }
160 if input.results.total_issues() == 0 {
161 return impact_digest_step(input.impact_digest)
162 .into_iter()
163 .collect();
164 }
165
166 let mut steps: Vec<NextStep> = [
167 setup_pointer(input.offer_setup),
168 impact_digest_step(input.impact_digest),
169 trace_unused_export(input.results, input.root),
170 scope_workspaces(input.workspace_ref),
171 audit_changed(input.audit_changed),
172 ]
173 .into_iter()
174 .flatten()
175 .collect();
176 steps.truncate(MAX_NEXT_STEPS);
177 steps
178}
179
180#[must_use]
182pub fn build_dupes_next_steps(input: DupesNextStepsInput<'_>) -> Vec<NextStep> {
183 if !input.suggestions_enabled {
184 return Vec::new();
185 }
186 if input.clone_fingerprints.is_empty() {
187 return impact_digest_step(input.impact_digest)
188 .into_iter()
189 .collect();
190 }
191
192 let mut steps: Vec<NextStep> = [
193 setup_pointer(input.offer_setup),
194 impact_digest_step(input.impact_digest),
195 trace_clone(input.clone_fingerprints),
196 audit_changed(input.audit_changed),
197 ]
198 .into_iter()
199 .flatten()
200 .collect();
201 steps.truncate(MAX_NEXT_STEPS);
202 steps
203}
204
205#[must_use]
207pub fn build_combined_next_steps(input: &CombinedNextStepsInput<'_>) -> Vec<NextStep> {
208 if !input.suggestions_enabled {
209 return Vec::new();
210 }
211 let has_findings = input.has_dead_code_findings
212 || !input.clone_fingerprints.is_empty()
213 || input.has_complexity_findings;
214 if !has_findings {
215 return impact_digest_step(input.impact_digest)
216 .into_iter()
217 .collect();
218 }
219
220 let mut steps: Vec<NextStep> = [
221 setup_pointer(input.offer_setup),
222 impact_digest_step(input.impact_digest),
223 trace_unused_export_from_input(input.trace_unused_export.as_ref()),
224 scope_workspaces(input.workspace_ref),
225 trace_clone(input.clone_fingerprints),
226 complexity_breakdown(input.has_complexity_findings),
227 audit_changed(input.audit_changed),
228 ]
229 .into_iter()
230 .flatten()
231 .collect();
232 steps.truncate(MAX_NEXT_STEPS);
233 steps
234}
235
236#[must_use]
238pub fn build_audit_next_steps(input: &AuditNextStepsInput) -> Vec<NextStep> {
239 if !input.suggestions_enabled {
240 return Vec::new();
241 }
242
243 let mut steps: Vec<NextStep> = [
244 trace_unused_export_from_input(input.trace_unused_export.as_ref()),
245 complexity_breakdown(input.has_complexity_findings),
246 ]
247 .into_iter()
248 .flatten()
249 .collect();
250 steps.truncate(MAX_NEXT_STEPS);
251 steps
252}
253
254#[must_use]
257pub fn build_audit_next_steps_input(
258 check: Option<(&AnalysisResults, &Path)>,
259 complexity: Option<&HealthReport>,
260 suggestions_enabled: bool,
261) -> AuditNextStepsInput {
262 AuditNextStepsInput {
263 suggestions_enabled,
264 trace_unused_export: check
265 .and_then(|(results, root)| trace_unused_export_input(results, root)),
266 has_complexity_findings: complexity.is_some_and(|report| !report.findings.is_empty()),
267 }
268}
269
270fn relative_command_path(path: &Path, root: &Path) -> String {
271 path.strip_prefix(root)
272 .unwrap_or(path)
273 .to_string_lossy()
274 .replace('\\', "/")
275}
276
277#[must_use]
280pub fn trace_unused_export_input(
281 results: &AnalysisResults,
282 root: &Path,
283) -> Option<TraceUnusedExportInput> {
284 let target = results
285 .unused_exports
286 .iter()
287 .map(|finding| {
288 (
289 relative_command_path(&finding.export.path, root),
290 finding.export.export_name.clone(),
291 )
292 })
293 .min()?;
294 Some(TraceUnusedExportInput {
295 path: target.0,
296 export_name: target.1,
297 })
298}
299
300fn trace_unused_export(results: &AnalysisResults, root: &Path) -> Option<NextStep> {
301 trace_unused_export_from_input(trace_unused_export_input(results, root).as_ref())
302}
303
304fn trace_unused_export_from_input(target: Option<&TraceUnusedExportInput>) -> Option<NextStep> {
305 let target = target?;
306 Some(next_step(
307 "trace-unused-export",
308 format!(
309 "fallow dead-code --trace {}:{}",
310 target.path, target.export_name
311 ),
312 "verify an export is truly unused before deleting",
313 ))
314}
315
316fn trace_clone(fingerprints: &[&str]) -> Option<NextStep> {
317 let fingerprint = fingerprints.iter().copied().min()?;
318 Some(next_step(
319 "trace-clone",
320 format!("fallow dupes --trace {fingerprint}"),
321 "see sibling locations and an extract-function suggestion",
322 ))
323}
324
325fn next_step(id: &str, command: String, reason: &str) -> NextStep {
326 debug_assert!(
327 !command.contains('<') && !command.contains('>'),
328 "next-step command must be runnable (no placeholder): {command}"
329 );
330 debug_assert!(
331 !command
332 .split_whitespace()
333 .any(|token| MUTATING_VERBS.contains(&token)),
334 "next-step command must be read-only (no mutating verb): {command}"
335 );
336 NextStep {
337 id: id.to_string(),
338 command,
339 reason: reason.to_string(),
340 }
341}
342
343fn setup_pointer(offer_setup: bool) -> Option<NextStep> {
344 if !offer_setup {
345 return None;
346 }
347 Some(next_step(
348 "setup",
349 "fallow schema".to_string(),
350 "fallow has no config here; the manifest lists guided-setup commands (agent guide, commit gate) to offer the user",
351 ))
352}
353
354fn impact_digest_step(digest: Option<ImpactDigestCounts>) -> Option<NextStep> {
355 let digest = digest?;
356 Some(next_step(
357 "impact-report",
358 "fallow impact".to_string(),
359 &format!(
360 "local value report: {}; share the non-zero numbers with the user",
361 impact_digest_summary(digest)
362 ),
363 ))
364}
365
366fn complexity_breakdown(has_findings: bool) -> Option<NextStep> {
367 if !has_findings {
368 return None;
369 }
370 Some(next_step(
371 "complexity-breakdown",
372 "fallow health --complexity-breakdown".to_string(),
373 "see per-decision-point contributions for a hotspot",
374 ))
375}
376
377fn audit_changed(applicable: bool) -> Option<NextStep> {
378 if !applicable {
379 return None;
380 }
381 Some(next_step(
382 "audit-changed",
383 "fallow audit".to_string(),
384 "gate only the files your branch changed (auto-detects the base)",
385 ))
386}
387
388fn scope_workspaces(workspace_ref: Option<&str>) -> Option<NextStep> {
389 let reference = workspace_ref?;
390 Some(next_step(
391 "scope-workspaces",
392 format!("fallow dead-code --changed-workspaces {reference}"),
393 "scope a monorepo run to the packages your branch touched",
394 ))
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400 use crate::{ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding};
401 use fallow_types::output_dead_code::UnusedExportFinding;
402 use fallow_types::results::UnusedExport;
403
404 fn digest(containment_count: usize, resolved_total: usize) -> ImpactDigestCounts {
405 ImpactDigestCounts {
406 containment_count,
407 resolved_total,
408 }
409 }
410
411 fn dirty_input() -> HealthNextStepsInput {
412 HealthNextStepsInput {
413 suggestions_enabled: true,
414 has_findings: true,
415 offer_setup: false,
416 impact_digest: None,
417 audit_changed: false,
418 }
419 }
420
421 fn dirty_report() -> HealthReport {
422 HealthReport {
423 findings: vec![HealthFinding::from(ComplexityViolation {
424 path: "/project/src/hot.ts".into(),
425 name: "hot".to_string(),
426 line: 1,
427 col: 0,
428 cyclomatic: 21,
429 cognitive: 16,
430 line_count: 42,
431 param_count: 0,
432 react_hook_count: 0,
433 react_jsx_max_depth: 0,
434 react_prop_count: 0,
435 react_hook_profile: None,
436 exceeded: ExceededThreshold::Both,
437 severity: FindingSeverity::High,
438 crap: None,
439 coverage_pct: None,
440 coverage_tier: None,
441 coverage_source: None,
442 inherited_from: None,
443 component_rollup: None,
444 contributions: Vec::new(),
445 effective_thresholds: None,
446 threshold_source: None,
447 })],
448 ..HealthReport::default()
449 }
450 }
451
452 fn unused_export(path: &str, name: &str) -> UnusedExportFinding {
453 UnusedExportFinding::with_actions(UnusedExport {
454 path: path.into(),
455 export_name: name.to_string(),
456 is_type_only: false,
457 line: 1,
458 col: 0,
459 span_start: 0,
460 is_re_export: false,
461 })
462 }
463
464 fn dead_code_input(results: &AnalysisResults) -> DeadCodeNextStepsInput<'_> {
465 DeadCodeNextStepsInput {
466 suggestions_enabled: true,
467 results,
468 root: Path::new("/project"),
469 offer_setup: false,
470 impact_digest: None,
471 workspace_ref: None,
472 audit_changed: false,
473 }
474 }
475
476 fn dupes_input<'a>(clone_fingerprints: &'a [&'a str]) -> DupesNextStepsInput<'a> {
477 DupesNextStepsInput {
478 suggestions_enabled: true,
479 clone_fingerprints,
480 offer_setup: false,
481 impact_digest: None,
482 audit_changed: false,
483 }
484 }
485
486 fn combined_input<'a>(clone_fingerprints: &'a [&'a str]) -> CombinedNextStepsInput<'a> {
487 CombinedNextStepsInput {
488 suggestions_enabled: true,
489 has_dead_code_findings: false,
490 trace_unused_export: None,
491 workspace_ref: None,
492 clone_fingerprints,
493 has_complexity_findings: false,
494 offer_setup: false,
495 impact_digest: None,
496 audit_changed: false,
497 }
498 }
499
500 fn audit_input() -> AuditNextStepsInput {
501 AuditNextStepsInput {
502 suggestions_enabled: true,
503 trace_unused_export: None,
504 has_complexity_findings: false,
505 }
506 }
507
508 fn assert_valid(step: &NextStep) {
509 assert!(
510 !step.command.contains('<') && !step.command.contains('>'),
511 "command must be placeholder-free: {}",
512 step.command
513 );
514 assert!(
515 !step
516 .command
517 .split_whitespace()
518 .any(|token| MUTATING_VERBS.contains(&token)),
519 "command must be read-only: {}",
520 step.command
521 );
522 }
523
524 #[test]
525 fn audit_steps_are_empty_when_suggestions_are_disabled() {
526 let steps = build_audit_next_steps(&AuditNextStepsInput {
527 suggestions_enabled: false,
528 trace_unused_export: Some(TraceUnusedExportInput {
529 path: "src/a.ts".to_string(),
530 export_name: "alpha".to_string(),
531 }),
532 has_complexity_findings: true,
533 });
534
535 assert!(steps.is_empty());
536 }
537
538 #[test]
539 fn audit_input_builder_derives_trace_and_complexity_facts() {
540 let results = AnalysisResults {
541 unused_exports: vec![
542 unused_export("/project/src/b.ts", "beta"),
543 unused_export("/project/src/a.ts", "alpha"),
544 ],
545 ..AnalysisResults::default()
546 };
547 let report = dirty_report();
548
549 let input = build_audit_next_steps_input(
550 Some((&results, Path::new("/project"))),
551 Some(&report),
552 true,
553 );
554
555 assert_eq!(
556 input.trace_unused_export,
557 Some(TraceUnusedExportInput {
558 path: "src/a.ts".to_string(),
559 export_name: "alpha".to_string(),
560 })
561 );
562 assert!(input.has_complexity_findings);
563 assert!(input.suggestions_enabled);
564 }
565
566 #[test]
567 fn audit_steps_order_trace_before_complexity() {
568 let steps = build_audit_next_steps(&AuditNextStepsInput {
569 trace_unused_export: Some(TraceUnusedExportInput {
570 path: "src/a.ts".to_string(),
571 export_name: "alpha".to_string(),
572 }),
573 has_complexity_findings: true,
574 ..audit_input()
575 });
576 let ids = steps
577 .iter()
578 .map(|step| step.id.as_str())
579 .collect::<Vec<_>>();
580
581 assert_eq!(ids, ["trace-unused-export", "complexity-breakdown"]);
582 assert_eq!(steps[0].command, "fallow dead-code --trace src/a.ts:alpha");
583 for step in &steps {
584 assert_valid(step);
585 }
586 }
587
588 #[test]
589 fn audit_steps_emit_complexity_without_trace_target() {
590 let steps = build_audit_next_steps(&AuditNextStepsInput {
591 has_complexity_findings: true,
592 ..audit_input()
593 });
594
595 assert_eq!(steps.len(), 1);
596 assert_eq!(steps[0].id, "complexity-breakdown");
597 }
598
599 #[test]
600 fn health_steps_are_empty_when_suggestions_are_disabled() {
601 let steps = build_health_next_steps(HealthNextStepsInput {
602 suggestions_enabled: false,
603 has_findings: true,
604 offer_setup: true,
605 impact_digest: Some(digest(2, 1)),
606 audit_changed: true,
607 });
608
609 assert!(steps.is_empty());
610 }
611
612 #[test]
613 fn health_input_builder_derives_findings_from_report() {
614 let clean = build_health_next_steps_input(
615 &HealthReport::default(),
616 true,
617 true,
618 Some(digest(2, 1)),
619 true,
620 );
621 assert_eq!(
622 clean,
623 HealthNextStepsInput {
624 suggestions_enabled: true,
625 has_findings: false,
626 offer_setup: true,
627 impact_digest: Some(digest(2, 1)),
628 audit_changed: true,
629 }
630 );
631
632 let dirty = build_health_next_steps_input(&dirty_report(), true, false, None, false);
633 assert!(dirty.has_findings);
634 }
635
636 #[test]
637 fn dead_code_steps_trace_smallest_unused_export() {
638 let results = AnalysisResults {
639 unused_exports: vec![
640 unused_export("/project/src/b.ts", "beta"),
641 unused_export("/project/src/a.ts", "alpha"),
642 ],
643 ..AnalysisResults::default()
644 };
645
646 let steps = build_dead_code_next_steps(dead_code_input(&results));
647
648 assert_eq!(steps[0].id, "trace-unused-export");
649 assert_eq!(steps[0].command, "fallow dead-code --trace src/a.ts:alpha");
650 assert_valid(&steps[0]);
651 }
652
653 #[test]
654 fn dead_code_steps_order_setup_impact_trace_workspace_then_audit() {
655 let results = AnalysisResults {
656 unused_exports: vec![unused_export("/project/src/a.ts", "alpha")],
657 ..AnalysisResults::default()
658 };
659 let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
660 offer_setup: true,
661 impact_digest: Some(digest(2, 1)),
662 workspace_ref: Some("origin/main"),
663 audit_changed: true,
664 ..dead_code_input(&results)
665 });
666 let ids = steps
667 .iter()
668 .map(|step| step.id.as_str())
669 .collect::<Vec<_>>();
670
671 assert_eq!(ids, ["setup", "impact-report", "trace-unused-export"]);
672 for step in &steps {
673 assert_valid(step);
674 }
675 }
676
677 #[test]
678 fn clean_dead_code_run_emits_only_due_impact_digest() {
679 let results = AnalysisResults::default();
680 let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
681 impact_digest: Some(digest(2, 1)),
682 audit_changed: true,
683 ..dead_code_input(&results)
684 });
685
686 assert_eq!(steps.len(), 1);
687 assert_eq!(steps[0].id, "impact-report");
688 }
689
690 #[test]
691 fn dupes_steps_trace_smallest_clone_fingerprint() {
692 let fingerprints = ["dup:bbbbbbbb", "dup:aaaaaaaa"];
693
694 let steps = build_dupes_next_steps(dupes_input(&fingerprints));
695
696 assert_eq!(steps[0].id, "trace-clone");
697 assert_eq!(steps[0].command, "fallow dupes --trace dup:aaaaaaaa");
698 assert_valid(&steps[0]);
699 }
700
701 #[test]
702 fn dupes_steps_order_setup_impact_trace_then_audit() {
703 let fingerprints = ["dup:aaaaaaaa"];
704 let steps = build_dupes_next_steps(DupesNextStepsInput {
705 offer_setup: true,
706 impact_digest: Some(digest(2, 1)),
707 audit_changed: true,
708 ..dupes_input(&fingerprints)
709 });
710 let ids = steps
711 .iter()
712 .map(|step| step.id.as_str())
713 .collect::<Vec<_>>();
714
715 assert_eq!(ids, ["setup", "impact-report", "trace-clone"]);
716 for step in &steps {
717 assert_valid(step);
718 }
719 }
720
721 #[test]
722 fn clean_dupes_run_emits_only_due_impact_digest() {
723 let steps = build_dupes_next_steps(DupesNextStepsInput {
724 impact_digest: Some(digest(2, 1)),
725 audit_changed: true,
726 ..dupes_input(&[])
727 });
728
729 assert_eq!(steps.len(), 1);
730 assert_eq!(steps[0].id, "impact-report");
731 }
732
733 #[test]
734 fn combined_steps_are_empty_when_suggestions_are_disabled() {
735 let fingerprints = ["dup:aaaaaaaa"];
736 let steps = build_combined_next_steps(&CombinedNextStepsInput {
737 suggestions_enabled: false,
738 has_dead_code_findings: true,
739 trace_unused_export: Some(TraceUnusedExportInput {
740 path: "src/a.ts".to_string(),
741 export_name: "alpha".to_string(),
742 }),
743 workspace_ref: Some("origin/main"),
744 clone_fingerprints: &fingerprints,
745 has_complexity_findings: true,
746 offer_setup: true,
747 impact_digest: Some(digest(2, 1)),
748 audit_changed: true,
749 });
750
751 assert!(steps.is_empty());
752 }
753
754 #[test]
755 fn clean_combined_run_emits_only_due_impact_digest() {
756 let steps = build_combined_next_steps(&CombinedNextStepsInput {
757 impact_digest: Some(digest(2, 1)),
758 audit_changed: true,
759 ..combined_input(&[])
760 });
761
762 assert_eq!(steps.len(), 1);
763 assert_eq!(steps[0].id, "impact-report");
764 }
765
766 #[test]
767 fn combined_steps_order_and_cap_all_signals() {
768 let fingerprints = ["dup:bbbbbbbb", "dup:aaaaaaaa"];
769 let steps = build_combined_next_steps(&CombinedNextStepsInput {
770 has_dead_code_findings: true,
771 trace_unused_export: Some(TraceUnusedExportInput {
772 path: "src/a.ts".to_string(),
773 export_name: "alpha".to_string(),
774 }),
775 workspace_ref: Some("origin/main"),
776 has_complexity_findings: true,
777 offer_setup: true,
778 impact_digest: Some(digest(2, 1)),
779 audit_changed: true,
780 ..combined_input(&fingerprints)
781 });
782 let ids = steps
783 .iter()
784 .map(|step| step.id.as_str())
785 .collect::<Vec<_>>();
786
787 assert_eq!(ids, ["setup", "impact-report", "trace-unused-export"]);
788 for step in &steps {
789 assert_valid(step);
790 }
791 }
792
793 #[test]
794 fn combined_steps_keep_workspace_before_clone_and_complexity() {
795 let fingerprints = ["dup:aaaaaaaa"];
796 let steps = build_combined_next_steps(&CombinedNextStepsInput {
797 has_dead_code_findings: true,
798 workspace_ref: Some("origin/main"),
799 has_complexity_findings: true,
800 audit_changed: true,
801 ..combined_input(&fingerprints)
802 });
803 let ids = steps
804 .iter()
805 .map(|step| step.id.as_str())
806 .collect::<Vec<_>>();
807
808 assert_eq!(
809 ids,
810 ["scope-workspaces", "trace-clone", "complexity-breakdown"]
811 );
812 }
813
814 #[test]
815 fn clean_health_run_emits_only_due_impact_digest() {
816 let steps = build_health_next_steps(HealthNextStepsInput {
817 suggestions_enabled: true,
818 has_findings: false,
819 offer_setup: true,
820 impact_digest: Some(digest(2, 1)),
821 audit_changed: true,
822 });
823
824 assert_eq!(steps.len(), 1);
825 assert_eq!(steps[0].id, "impact-report");
826 assert_valid(&steps[0]);
827 }
828
829 #[test]
830 fn dirty_health_run_orders_setup_impact_complexity_then_audit() {
831 let steps = build_health_next_steps(HealthNextStepsInput {
832 offer_setup: true,
833 impact_digest: Some(digest(2, 1)),
834 audit_changed: true,
835 ..dirty_input()
836 });
837 let ids = steps
838 .iter()
839 .map(|step| step.id.as_str())
840 .collect::<Vec<_>>();
841
842 assert_eq!(ids, ["setup", "impact-report", "complexity-breakdown"]);
843 for step in &steps {
844 assert_valid(step);
845 }
846 }
847
848 #[test]
849 fn dirty_health_run_uses_complexity_when_setup_and_impact_are_absent() {
850 let steps = build_health_next_steps(HealthNextStepsInput {
851 audit_changed: true,
852 ..dirty_input()
853 });
854 let ids = steps
855 .iter()
856 .map(|step| step.id.as_str())
857 .collect::<Vec<_>>();
858
859 assert_eq!(ids, ["complexity-breakdown", "audit-changed"]);
860 }
861
862 #[test]
863 fn impact_digest_summary_pluralizes_real_counters() {
864 assert_eq!(
865 impact_digest_summary(digest(1, 1)),
866 "1 commit contained at the gate, 1 finding resolved"
867 );
868 assert_eq!(
869 impact_digest_summary(digest(2, 3)),
870 "2 commits contained at the gate, 3 findings resolved"
871 );
872 }
873}