Skip to main content

fallow_cli/report/
suggestions.rs

1//! Command-level `next_steps[]` builder.
2//!
3//! Computes a small list of read-only, runnable follow-up commands from a run's
4//! findings, surfaced at the JSON root (and as a one-line human `Next:` hint).
5//! The purpose is to point agents and humans sideways to fallow's adjacent
6//! verification capabilities (trace, complexity breakdown, audit, workspace
7//! scoping) that telemetry shows agents rarely discover, because they act on the
8//! output in front of them rather than on reference docs.
9//!
10//! Two hard contracts, both enforced by the tests in this module and by the
11//! `next_step` constructor's debug assertions:
12//!
13//! 1. **Read-only.** A step NEVER suggests `fallow fix` or any mutating command.
14//! 2. **Runnable, placeholder-free.** Every `command` runs as-is; it never
15//!    contains an angle-bracket placeholder. Finding-derived values come from a
16//!    real, deterministically-selected finding; values that cannot be made
17//!    concrete (a coverage path) are dropped from v1 rather than shipped as a
18//!    placeholder, and an unresolvable git ref omits its step entirely.
19
20use std::path::Path;
21use std::process::Command;
22
23use fallow_core::results::AnalysisResults;
24use fallow_types::output::NextStep;
25
26use crate::health_types::HealthReport;
27use crate::output_dupes::DupesReportPayload;
28
29/// Maximum number of next-steps emitted per envelope. Keeps the array a glance,
30/// not a wall; the priority order decides which survive the cap.
31const MAX_NEXT_STEPS: usize = 3;
32
33/// Mutating verbs a next-step must never suggest (the read-only contract).
34const MUTATING_VERBS: [&str; 5] = ["fix", "init", "hooks", "migrate", "setup-hooks"];
35
36/// `FALLOW_SUGGESTIONS=off` (or `0`/`false`/`no`/`disabled`) disables next-steps
37/// entirely. Default on. This is the documented escape hatch for CI consumers
38/// that snapshot-diff raw `--format json` output; it reaches the spawned-CLI and
39/// MCP surfaces without a CLI flag.
40#[must_use]
41pub fn suggestions_enabled() -> bool {
42    suggestions_enabled_from(std::env::var("FALLOW_SUGGESTIONS").ok().as_deref())
43}
44
45/// Pure parse of the `FALLOW_SUGGESTIONS` value (kept separate so it is testable
46/// without mutating process env, which is `unsafe` under edition 2024).
47#[must_use]
48fn suggestions_enabled_from(value: Option<&str>) -> bool {
49    match value {
50        Some(raw) => !matches!(
51            raw.trim().to_ascii_lowercase().as_str(),
52            "off" | "0" | "false" | "no" | "disabled"
53        ),
54        None => true,
55    }
56}
57
58/// Construct a next-step, asserting the two contracts in debug builds so a new
59/// trigger that violates them trips the test suite rather than shipping.
60fn next_step(id: &str, command: String, reason: &str) -> NextStep {
61    debug_assert!(
62        !command.contains('<') && !command.contains('>'),
63        "next-step command must be runnable (no placeholder): {command}"
64    );
65    debug_assert!(
66        !command
67            .split_whitespace()
68            .any(|token| MUTATING_VERBS.contains(&token)),
69        "next-step command must be read-only (no mutating verb): {command}"
70    );
71    NextStep {
72        id: id.to_string(),
73        command,
74        reason: reason.to_string(),
75    }
76}
77
78/// Project-root-relative, forward-slash path for embedding in a command string,
79/// matching the wire form of finding paths.
80fn relative_command_path(path: &Path, root: &Path) -> String {
81    path.strip_prefix(root)
82        .unwrap_or(path)
83        .to_string_lossy()
84        .replace('\\', "/")
85}
86
87// ---------------------------------------------------------------------------
88// Individual triggers. Each returns `Some(step)` only when its evidence exists.
89// ---------------------------------------------------------------------------
90
91/// `trace-unused-export`: verify an export is truly unused before deleting.
92/// Uses the lexicographically smallest `(path, name)` finding so the embedded
93/// command is deterministic across runs (independent of internal vec order).
94fn trace_unused_export(results: &AnalysisResults, root: &Path) -> Option<NextStep> {
95    let target = results
96        .unused_exports
97        .iter()
98        .map(|finding| {
99            (
100                relative_command_path(&finding.export.path, root),
101                finding.export.export_name.clone(),
102            )
103        })
104        .min()?;
105    Some(next_step(
106        "trace-unused-export",
107        format!("fallow dead-code --trace {}:{}", target.0, target.1),
108        "verify an export is truly unused before deleting",
109    ))
110}
111
112/// `trace-clone`: see sibling locations and an extract-function suggestion for a
113/// duplicated block. Uses the smallest fingerprint for run-to-run determinism.
114fn trace_clone(payload: &DupesReportPayload) -> Option<NextStep> {
115    let fingerprint = payload
116        .clone_groups
117        .iter()
118        .map(|group| group.fingerprint.as_str())
119        .min()?;
120    Some(next_step(
121        "trace-clone",
122        format!("fallow dupes --trace {fingerprint}"),
123        "see sibling locations and an extract-function suggestion",
124    ))
125}
126
127/// `complexity-breakdown`: see the per-decision-point contributions behind a
128/// high-complexity finding.
129fn complexity_breakdown(report: &HealthReport) -> Option<NextStep> {
130    if report.findings.is_empty() {
131        return None;
132    }
133    Some(next_step(
134        "complexity-breakdown",
135        "fallow health --complexity-breakdown".to_string(),
136        "see per-decision-point contributions for a hotspot",
137    ))
138}
139
140/// `scope-workspaces`: scope a monorepo run to the packages touched since the
141/// default branch. Emitted only when the project is a monorepo AND a concrete
142/// default ref resolves, so the embedded ref is real (never a placeholder).
143fn scope_workspaces(root: &Path) -> Option<NextStep> {
144    if fallow_config::discover_workspaces(root).is_empty() {
145        return None;
146    }
147    let reference = resolve_default_workspace_ref(root)?;
148    Some(next_step(
149        "scope-workspaces",
150        format!("fallow dead-code --changed-workspaces {reference}"),
151        "scope a monorepo run to the packages your branch touched",
152    ))
153}
154
155/// `audit-changed`: gate only the files the current branch changed. `fallow
156/// audit` auto-detects its base, so no ref needs embedding.
157fn audit_changed(root: &Path) -> Option<NextStep> {
158    if !fallow_core::churn::is_git_repo(root) {
159        return None;
160    }
161    Some(next_step(
162        "audit-changed",
163        "fallow audit".to_string(),
164        "gate only the files your branch changed (auto-detects the base)",
165    ))
166}
167
168// ---------------------------------------------------------------------------
169// Git ref resolution (self-contained; keeps `scope-workspaces` placeholder-free)
170// ---------------------------------------------------------------------------
171
172/// Resolve a concrete, human-readable default ref for `--changed-workspaces`.
173/// Tries `origin/HEAD` then verifies `origin/main` / `origin/master`. Returns
174/// the first that resolves, or `None` (in which case `scope-workspaces` is
175/// omitted rather than shipping an unrunnable `origin/main` guess).
176fn resolve_default_workspace_ref(root: &Path) -> Option<String> {
177    if let Some(reference) = run_git(
178        root,
179        &[
180            "symbolic-ref",
181            "--quiet",
182            "--short",
183            "refs/remotes/origin/HEAD",
184        ],
185    ) {
186        let reference = reference.trim();
187        if !reference.is_empty() {
188            return Some(reference.to_string());
189        }
190    }
191    ["origin/main", "origin/master"]
192        .into_iter()
193        .find(|candidate| git_ref_exists(root, candidate))
194        .map(str::to_string)
195}
196
197fn git_ref_exists(root: &Path, reference: &str) -> bool {
198    Command::new("git")
199        .args(["-C"])
200        .arg(root)
201        .args(["rev-parse", "--verify", "--quiet", reference])
202        .output()
203        .is_ok_and(|output| output.status.success())
204}
205
206fn run_git(root: &Path, args: &[&str]) -> Option<String> {
207    let output = Command::new("git")
208        .args(["-C"])
209        .arg(root)
210        .args(args)
211        .output()
212        .ok()?;
213    if !output.status.success() {
214        return None;
215    }
216    String::from_utf8(output.stdout).ok()
217}
218
219// ---------------------------------------------------------------------------
220// Public per-command builders. Each no-ops when suggestions are disabled or the
221// run is clean (no findings), so a clean run never emits `next_steps`.
222// ---------------------------------------------------------------------------
223
224/// Next-steps for standalone `fallow dead-code`.
225#[must_use]
226pub fn build_dead_code_next_steps(results: &AnalysisResults, root: &Path) -> Vec<NextStep> {
227    if !suggestions_enabled() || results.total_issues() == 0 {
228        return Vec::new();
229    }
230    let mut steps: Vec<NextStep> = [
231        trace_unused_export(results, root),
232        scope_workspaces(root),
233        audit_changed(root),
234    ]
235    .into_iter()
236    .flatten()
237    .collect();
238    steps.truncate(MAX_NEXT_STEPS);
239    steps
240}
241
242/// Next-steps for standalone `fallow health`.
243#[must_use]
244pub fn build_health_next_steps(report: &HealthReport, root: &Path) -> Vec<NextStep> {
245    if !suggestions_enabled() || report.findings.is_empty() {
246        return Vec::new();
247    }
248    let mut steps: Vec<NextStep> = [complexity_breakdown(report), audit_changed(root)]
249        .into_iter()
250        .flatten()
251        .collect();
252    steps.truncate(MAX_NEXT_STEPS);
253    steps
254}
255
256/// Next-steps for standalone `fallow dupes`.
257#[must_use]
258pub fn build_dupes_next_steps(payload: &DupesReportPayload, root: &Path) -> Vec<NextStep> {
259    if !suggestions_enabled() || payload.clone_groups.is_empty() {
260        return Vec::new();
261    }
262    let mut steps: Vec<NextStep> = [trace_clone(payload), audit_changed(root)]
263        .into_iter()
264        .flatten()
265        .collect();
266    steps.truncate(MAX_NEXT_STEPS);
267    steps
268}
269
270/// Aggregated next-steps for bare `fallow` (combined). Candidates are pushed in
271/// priority order, then capped. `trace-unused-export` leads because it is the
272/// highest-value verification path; `scope-workspaces` is boosted above the
273/// trace-clone / complexity tier so big-repo runs that trigger everything still
274/// surface the rare monorepo-scoping capability instead of always dropping it
275/// under the cap. `audit-changed` is last (broadly applicable, least specific).
276#[must_use]
277pub fn build_combined_next_steps(
278    results: Option<&AnalysisResults>,
279    dupes: Option<&DupesReportPayload>,
280    health: Option<&HealthReport>,
281    root: &Path,
282) -> Vec<NextStep> {
283    if !suggestions_enabled() {
284        return Vec::new();
285    }
286    let has_findings = results.is_some_and(|r| r.total_issues() > 0)
287        || dupes.is_some_and(|d| !d.clone_groups.is_empty())
288        || health.is_some_and(|h| !h.findings.is_empty());
289    if !has_findings {
290        return Vec::new();
291    }
292    let mut steps: Vec<NextStep> = [
293        results.and_then(|r| trace_unused_export(r, root)),
294        scope_workspaces(root),
295        dupes.and_then(trace_clone),
296        health.and_then(complexity_breakdown),
297        audit_changed(root),
298    ]
299    .into_iter()
300    .flatten()
301    .collect();
302    steps.truncate(MAX_NEXT_STEPS);
303    steps
304}
305
306/// Next-steps for `fallow audit`. No `audit-changed` (audit IS the changed
307/// scope) and no `scope-workspaces` (audit already gates the change). The
308/// `check` tuple carries the changed-file analysis results plus the project root
309/// so the trace anchor is made root-relative the same way every other surface
310/// does it (in-memory finding paths are absolute; the wire form is relative).
311#[must_use]
312pub fn build_audit_next_steps(
313    check: Option<(&AnalysisResults, &Path)>,
314    complexity: Option<&HealthReport>,
315) -> Vec<NextStep> {
316    if !suggestions_enabled() {
317        return Vec::new();
318    }
319    let mut steps: Vec<NextStep> = [
320        check.and_then(|(results, root)| trace_unused_export(results, root)),
321        complexity.and_then(complexity_breakdown),
322    ]
323    .into_iter()
324    .flatten()
325    .collect();
326    steps.truncate(MAX_NEXT_STEPS);
327    steps
328}
329
330/// The single highest-priority next-step for the human `Next:` line, computed
331/// from the same candidates and ordering as the combined JSON array so a human
332/// and an agent on the same run never see a contradictory top step.
333#[must_use]
334pub fn top_combined_next_step(
335    results: Option<&AnalysisResults>,
336    dupes: Option<&DupesReportPayload>,
337    health: Option<&HealthReport>,
338    root: &Path,
339) -> Option<NextStep> {
340    build_combined_next_steps(results, dupes, health, root)
341        .into_iter()
342        .next()
343}
344
345#[cfg(test)]
346mod tests {
347    use std::path::PathBuf;
348
349    use fallow_types::output_dead_code::UnusedExportFinding;
350    use fallow_types::results::{AnalysisResults, UnusedExport};
351
352    use super::*;
353
354    fn unused_export(path: &str, name: &str) -> UnusedExportFinding {
355        UnusedExportFinding::with_actions(UnusedExport {
356            path: PathBuf::from(path),
357            export_name: name.to_string(),
358            is_type_only: false,
359            line: 1,
360            col: 0,
361            span_start: 0,
362            is_re_export: false,
363        })
364    }
365
366    fn results_with_exports(exports: Vec<UnusedExportFinding>) -> AnalysisResults {
367        AnalysisResults {
368            unused_exports: exports,
369            ..AnalysisResults::default()
370        }
371    }
372
373    fn assert_valid(step: &NextStep) {
374        assert!(
375            !step.command.contains('<') && !step.command.contains('>'),
376            "command must be placeholder-free: {}",
377            step.command
378        );
379        assert!(
380            !step
381                .command
382                .split_whitespace()
383                .any(|token| MUTATING_VERBS.contains(&token)),
384            "command must be read-only: {}",
385            step.command
386        );
387    }
388
389    #[test]
390    fn trace_unused_export_emits_runnable_relative_command() {
391        let root = PathBuf::from("/project");
392        let results = results_with_exports(vec![unused_export("/project/src/util.ts", "foo")]);
393        let step = trace_unused_export(&results, &root).expect("step");
394        assert_eq!(step.id, "trace-unused-export");
395        assert_eq!(step.command, "fallow dead-code --trace src/util.ts:foo");
396        assert_valid(&step);
397    }
398
399    #[test]
400    fn trace_unused_export_is_deterministic_regardless_of_vec_order() {
401        let root = PathBuf::from("/project");
402        let forward = results_with_exports(vec![
403            unused_export("/project/src/b.ts", "beta"),
404            unused_export("/project/src/a.ts", "alpha"),
405        ]);
406        let reverse = results_with_exports(vec![
407            unused_export("/project/src/a.ts", "alpha"),
408            unused_export("/project/src/b.ts", "beta"),
409        ]);
410        let a = trace_unused_export(&forward, &root).expect("step");
411        let b = trace_unused_export(&reverse, &root).expect("step");
412        assert_eq!(a.command, b.command);
413        assert_eq!(a.command, "fallow dead-code --trace src/a.ts:alpha");
414    }
415
416    #[test]
417    fn clean_run_emits_no_next_steps() {
418        let root = PathBuf::from("/project");
419        let results = AnalysisResults::default();
420        assert!(build_dead_code_next_steps(&results, &root).is_empty());
421    }
422
423    #[test]
424    fn suggestions_enabled_parses_off_values() {
425        for off in ["off", "0", "false", "no", "disabled", "OFF", " Off "] {
426            assert!(!suggestions_enabled_from(Some(off)), "{off} should disable");
427        }
428        for on in ["on", "1", "true", "", "yes"] {
429            assert!(suggestions_enabled_from(Some(on)), "{on} should enable");
430        }
431        assert!(suggestions_enabled_from(None), "default is enabled");
432    }
433
434    #[test]
435    fn every_emitted_command_is_runnable_and_read_only() {
436        // Exercise every data-driven trigger and assert both contracts.
437        let root = PathBuf::from("/project");
438        let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
439        let mut all = Vec::new();
440        all.extend(trace_unused_export(&results, &root));
441        // Static-command triggers (no findings needed to inspect the string).
442        all.push(next_step("audit-changed", "fallow audit".to_string(), "x"));
443        all.push(next_step(
444            "scope-workspaces",
445            "fallow dead-code --changed-workspaces origin/main".to_string(),
446            "x",
447        ));
448        all.push(next_step(
449            "complexity-breakdown",
450            "fallow health --complexity-breakdown".to_string(),
451            "x",
452        ));
453        all.push(next_step(
454            "trace-clone",
455            "fallow dupes --trace dup:abcd1234".to_string(),
456            "x",
457        ));
458        assert!(!all.is_empty());
459        for step in &all {
460            assert_valid(step);
461        }
462    }
463
464    #[test]
465    fn dead_code_steps_capped_at_three() {
466        let root = PathBuf::from("/project");
467        let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
468        // Even if git/workspaces add candidates, the cap holds.
469        let steps = build_dead_code_next_steps(&results, &root);
470        assert!(steps.len() <= MAX_NEXT_STEPS);
471    }
472}