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