Skip to main content

fallow_output/
next_steps.rs

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