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/// `setup`: first-contact pointer for unconfigured projects. The command is the
113/// read-only capability manifest (`fallow schema`), whose `task_matrix` and
114/// commands list name the guided-setup surface (`init --agents`, the hooks
115/// installer); the mutating commands themselves are never embedded here (the
116/// read-only contract), the agent offers them to the user instead. Callers gate
117/// this via [`setup_pointer_applicable`] so CI runs, configured projects, and
118/// projects that declined onboarding never see it.
119fn setup_pointer(offer_setup: bool) -> Option<NextStep> {
120    if !offer_setup {
121        return None;
122    }
123    Some(next_step(
124        "setup",
125        "fallow schema".to_string(),
126        "fallow has no config here; the manifest lists guided-setup commands (agent guide, commit gate) to offer the user",
127    ))
128}
129
130/// Shared first-contact gate for the `setup` next-step and the human setup hint
131/// on bare `fallow`: the project has no fallow config (searched up to the repo
132/// root, same as config loading), the run is not in CI, and onboarding has not
133/// been declined for this project (`fallow init --decline`).
134#[must_use]
135pub fn setup_pointer_applicable(root: &Path) -> bool {
136    root.exists()
137        && fallow_config::FallowConfig::find_config_path(root).is_none()
138        && !crate::telemetry::is_ci()
139        && !crate::impact::load(root).onboarding_declined
140}
141
142/// One-line human setup hint for bare `fallow` output: the prose counterpart of
143/// the `setup` next-step (agents get the JSON form, humans get this line).
144/// Worded as an offer, not a deficiency: zero-config is a supported happy path.
145pub const SETUP_HINT: &str = "Setup: `fallow init --agents` writes an agent guide; `fallow hooks install --target agent` adds a commit gate (hide this hint: `fallow init --decline`).";
146
147/// `impact-report`: the periodic local value digest. Emitted at most once per
148/// week per project (the cadence stamp lives in the impact store, not the
149/// agent, so it is consistent across agents and sessions), only when impact
150/// tracking is enabled and has non-zero value to report, never in CI. Unlike
151/// every other trigger this one may surface on a CLEAN run: a clean project
152/// after a period of gate containment is exactly the moment the value report
153/// is informative.
154fn impact_digest_step(digest: Option<crate::impact::ImpactDigest>) -> Option<NextStep> {
155    let digest = digest?;
156    Some(next_step(
157        "impact-report",
158        "fallow impact".to_string(),
159        &format!(
160            "local value report: {}; share the non-zero numbers with the user",
161            digest_summary(digest)
162        ),
163    ))
164}
165
166/// Real-counter summary fragment shared by the next-step reason and the human
167/// one-liner (the placeholder-free contract: numbers come from the store).
168fn digest_summary(digest: crate::impact::ImpactDigest) -> String {
169    let mut parts = Vec::new();
170    if digest.containment_count > 0 {
171        parts.push(format!(
172            "{} commit{} contained at the gate",
173            digest.containment_count,
174            if digest.containment_count == 1 {
175                ""
176            } else {
177                "s"
178            }
179        ));
180    }
181    if digest.resolved_total > 0 {
182        parts.push(format!(
183            "{} finding{} resolved",
184            digest.resolved_total,
185            if digest.resolved_total == 1 { "" } else { "s" }
186        ));
187    }
188    parts.join(", ")
189}
190
191/// One-line human counterpart of the `impact-report` next-step, printed with
192/// the run summary on bare `fallow`.
193#[must_use]
194pub fn impact_digest_line(digest: crate::impact::ImpactDigest) -> String {
195    format!(
196        "Impact: {} (details: `fallow impact`).",
197        digest_summary(digest)
198    )
199}
200
201/// Read-and-stamp the due periodic impact digest for the envelope being built.
202/// Returns `None` in CI or when suggestions are disabled, WITHOUT consuming the
203/// cadence stamp, so the digest is never burned by a surface that will not
204/// show it.
205#[must_use]
206pub fn due_impact_digest(root: &Path) -> Option<crate::impact::ImpactDigest> {
207    if !suggestions_enabled() || crate::telemetry::is_ci() {
208        return None;
209    }
210    crate::impact::take_due_digest(root)
211}
212
213/// `trace-clone`: see sibling locations and an extract-function suggestion for a
214/// duplicated block. Uses the smallest fingerprint for run-to-run determinism.
215fn trace_clone(payload: &DupesReportPayload) -> Option<NextStep> {
216    let fingerprint = payload
217        .clone_groups
218        .iter()
219        .map(|group| group.fingerprint.as_str())
220        .min()?;
221    Some(next_step(
222        "trace-clone",
223        format!("fallow dupes --trace {fingerprint}"),
224        "see sibling locations and an extract-function suggestion",
225    ))
226}
227
228/// `complexity-breakdown`: see the per-decision-point contributions behind a
229/// high-complexity finding.
230fn complexity_breakdown(report: &HealthReport) -> Option<NextStep> {
231    if report.findings.is_empty() {
232        return None;
233    }
234    Some(next_step(
235        "complexity-breakdown",
236        "fallow health --complexity-breakdown".to_string(),
237        "see per-decision-point contributions for a hotspot",
238    ))
239}
240
241/// `scope-workspaces`: scope a monorepo run to the packages touched since the
242/// default branch. Emitted only when the project is a monorepo AND a concrete
243/// default ref resolves, so the embedded ref is real (never a placeholder).
244fn scope_workspaces(root: &Path) -> Option<NextStep> {
245    if fallow_config::discover_workspaces(root).is_empty() {
246        return None;
247    }
248    let reference = resolve_default_workspace_ref(root)?;
249    Some(next_step(
250        "scope-workspaces",
251        format!("fallow dead-code --changed-workspaces {reference}"),
252        "scope a monorepo run to the packages your branch touched",
253    ))
254}
255
256/// `audit-changed`: gate only the files the current branch changed. `fallow
257/// audit` auto-detects its base, so no ref needs embedding.
258fn audit_changed(root: &Path) -> Option<NextStep> {
259    if !fallow_core::churn::is_git_repo(root) {
260        return None;
261    }
262    Some(next_step(
263        "audit-changed",
264        "fallow audit".to_string(),
265        "gate only the files your branch changed (auto-detects the base)",
266    ))
267}
268
269// ---------------------------------------------------------------------------
270// Git ref resolution (self-contained; keeps `scope-workspaces` placeholder-free)
271// ---------------------------------------------------------------------------
272
273/// Resolve a concrete, human-readable default ref for `--changed-workspaces`.
274/// Tries `origin/HEAD` then verifies `origin/main` / `origin/master`. Returns
275/// the first that resolves, or `None` (in which case `scope-workspaces` is
276/// omitted rather than shipping an unrunnable `origin/main` guess).
277fn resolve_default_workspace_ref(root: &Path) -> Option<String> {
278    if let Some(reference) = run_git(
279        root,
280        &[
281            "symbolic-ref",
282            "--quiet",
283            "--short",
284            "refs/remotes/origin/HEAD",
285        ],
286    ) {
287        let reference = reference.trim();
288        if !reference.is_empty() {
289            return Some(reference.to_string());
290        }
291    }
292    ["origin/main", "origin/master"]
293        .into_iter()
294        .find(|candidate| git_ref_exists(root, candidate))
295        .map(str::to_string)
296}
297
298fn git_ref_exists(root: &Path, reference: &str) -> bool {
299    Command::new("git")
300        .args(["-C"])
301        .arg(root)
302        .args(["rev-parse", "--verify", "--quiet", reference])
303        .output()
304        .is_ok_and(|output| output.status.success())
305}
306
307fn run_git(root: &Path, args: &[&str]) -> Option<String> {
308    let output = Command::new("git")
309        .args(["-C"])
310        .arg(root)
311        .args(args)
312        .output()
313        .ok()?;
314    if !output.status.success() {
315        return None;
316    }
317    String::from_utf8(output.stdout).ok()
318}
319
320// ---------------------------------------------------------------------------
321// Public per-command builders. Each no-ops when suggestions are disabled or the
322// run is clean (no findings), so a clean run never emits `next_steps`, with one
323// documented exception: a due `impact-report` digest may ride a clean run.
324// ---------------------------------------------------------------------------
325
326/// Next-steps for standalone `fallow dead-code`. `offer_setup` is the caller's
327/// [`setup_pointer_applicable`] result (threaded as a parameter so the builders
328/// stay free of env/filesystem probes and deterministic under test).
329#[must_use]
330pub fn build_dead_code_next_steps(
331    results: &AnalysisResults,
332    root: &Path,
333    offer_setup: bool,
334    digest: Option<crate::impact::ImpactDigest>,
335) -> Vec<NextStep> {
336    if !suggestions_enabled() {
337        return Vec::new();
338    }
339    if results.total_issues() == 0 {
340        return impact_digest_step(digest).into_iter().collect();
341    }
342    let mut steps: Vec<NextStep> = [
343        setup_pointer(offer_setup),
344        impact_digest_step(digest),
345        trace_unused_export(results, root),
346        scope_workspaces(root),
347        audit_changed(root),
348    ]
349    .into_iter()
350    .flatten()
351    .collect();
352    steps.truncate(MAX_NEXT_STEPS);
353    steps
354}
355
356/// Next-steps for standalone `fallow health`. See [`build_dead_code_next_steps`]
357/// for the `offer_setup` parameter contract.
358#[must_use]
359pub fn build_health_next_steps(
360    report: &HealthReport,
361    root: &Path,
362    offer_setup: bool,
363    digest: Option<crate::impact::ImpactDigest>,
364) -> Vec<NextStep> {
365    if !suggestions_enabled() {
366        return Vec::new();
367    }
368    if report.findings.is_empty() {
369        return impact_digest_step(digest).into_iter().collect();
370    }
371    let mut steps: Vec<NextStep> = [
372        setup_pointer(offer_setup),
373        impact_digest_step(digest),
374        complexity_breakdown(report),
375        audit_changed(root),
376    ]
377    .into_iter()
378    .flatten()
379    .collect();
380    steps.truncate(MAX_NEXT_STEPS);
381    steps
382}
383
384/// Next-steps for standalone `fallow dupes`. See [`build_dead_code_next_steps`]
385/// for the `offer_setup` parameter contract.
386#[must_use]
387pub fn build_dupes_next_steps(
388    payload: &DupesReportPayload,
389    root: &Path,
390    offer_setup: bool,
391    digest: Option<crate::impact::ImpactDigest>,
392) -> Vec<NextStep> {
393    if !suggestions_enabled() {
394        return Vec::new();
395    }
396    if payload.clone_groups.is_empty() {
397        return impact_digest_step(digest).into_iter().collect();
398    }
399    let mut steps: Vec<NextStep> = [
400        setup_pointer(offer_setup),
401        impact_digest_step(digest),
402        trace_clone(payload),
403        audit_changed(root),
404    ]
405    .into_iter()
406    .flatten()
407    .collect();
408    steps.truncate(MAX_NEXT_STEPS);
409    steps
410}
411
412/// Aggregated next-steps for bare `fallow` (combined). Candidates are pushed in
413/// priority order, then capped. `trace-unused-export` leads because it is the
414/// highest-value verification path; `scope-workspaces` is boosted above the
415/// trace-clone / complexity tier so big-repo runs that trigger everything still
416/// surface the rare monorepo-scoping capability instead of always dropping it
417/// under the cap. `audit-changed` is last (broadly applicable, least specific).
418#[must_use]
419pub fn build_combined_next_steps(
420    results: Option<&AnalysisResults>,
421    dupes: Option<&DupesReportPayload>,
422    health: Option<&HealthReport>,
423    root: &Path,
424    offer_setup: bool,
425    digest: Option<crate::impact::ImpactDigest>,
426) -> Vec<NextStep> {
427    if !suggestions_enabled() {
428        return Vec::new();
429    }
430    let has_findings = results.is_some_and(|r| r.total_issues() > 0)
431        || dupes.is_some_and(|d| !d.clone_groups.is_empty())
432        || health.is_some_and(|h| !h.findings.is_empty());
433    if !has_findings {
434        return impact_digest_step(digest).into_iter().collect();
435    }
436    let mut steps: Vec<NextStep> = [
437        setup_pointer(offer_setup),
438        impact_digest_step(digest),
439        results.and_then(|r| trace_unused_export(r, root)),
440        scope_workspaces(root),
441        dupes.and_then(trace_clone),
442        health.and_then(complexity_breakdown),
443        audit_changed(root),
444    ]
445    .into_iter()
446    .flatten()
447    .collect();
448    steps.truncate(MAX_NEXT_STEPS);
449    steps
450}
451
452/// Next-steps for `fallow audit`. No `audit-changed` (audit IS the changed
453/// scope) and no `scope-workspaces` (audit already gates the change). The
454/// `check` tuple carries the changed-file analysis results plus the project root
455/// so the trace anchor is made root-relative the same way every other surface
456/// does it (in-memory finding paths are absolute; the wire form is relative).
457#[must_use]
458pub fn build_audit_next_steps(
459    check: Option<(&AnalysisResults, &Path)>,
460    complexity: Option<&HealthReport>,
461) -> Vec<NextStep> {
462    if !suggestions_enabled() {
463        return Vec::new();
464    }
465    let mut steps: Vec<NextStep> = [
466        check.and_then(|(results, root)| trace_unused_export(results, root)),
467        complexity.and_then(complexity_breakdown),
468    ]
469    .into_iter()
470    .flatten()
471    .collect();
472    steps.truncate(MAX_NEXT_STEPS);
473    steps
474}
475
476/// The single highest-priority next-step for the human `Next:` line, computed
477/// from the same candidates and ordering as the combined JSON array so a human
478/// and an agent on the same run never see a contradictory top step. The `setup`
479/// pointer is deliberately excluded here (`offer_setup: false`): humans get the
480/// dedicated prose [`SETUP_HINT`] line instead, so the `Next:` slot always
481/// shows an analysis follow-up.
482#[must_use]
483pub fn top_combined_next_step(
484    results: Option<&AnalysisResults>,
485    dupes: Option<&DupesReportPayload>,
486    health: Option<&HealthReport>,
487    root: &Path,
488) -> Option<NextStep> {
489    build_combined_next_steps(results, dupes, health, root, false, None)
490        .into_iter()
491        .next()
492}
493
494#[cfg(test)]
495mod tests {
496    use std::path::PathBuf;
497
498    use fallow_types::output_dead_code::UnusedExportFinding;
499    use fallow_types::results::{AnalysisResults, UnusedExport};
500
501    use super::*;
502
503    fn unused_export(path: &str, name: &str) -> UnusedExportFinding {
504        UnusedExportFinding::with_actions(UnusedExport {
505            path: PathBuf::from(path),
506            export_name: name.to_string(),
507            is_type_only: false,
508            line: 1,
509            col: 0,
510            span_start: 0,
511            is_re_export: false,
512        })
513    }
514
515    fn results_with_exports(exports: Vec<UnusedExportFinding>) -> AnalysisResults {
516        AnalysisResults {
517            unused_exports: exports,
518            ..AnalysisResults::default()
519        }
520    }
521
522    fn assert_valid(step: &NextStep) {
523        assert!(
524            !step.command.contains('<') && !step.command.contains('>'),
525            "command must be placeholder-free: {}",
526            step.command
527        );
528        assert!(
529            !step
530                .command
531                .split_whitespace()
532                .any(|token| MUTATING_VERBS.contains(&token)),
533            "command must be read-only: {}",
534            step.command
535        );
536    }
537
538    #[test]
539    fn trace_unused_export_emits_runnable_relative_command() {
540        let root = PathBuf::from("/project");
541        let results = results_with_exports(vec![unused_export("/project/src/util.ts", "foo")]);
542        let step = trace_unused_export(&results, &root).expect("step");
543        assert_eq!(step.id, "trace-unused-export");
544        assert_eq!(step.command, "fallow dead-code --trace src/util.ts:foo");
545        assert_valid(&step);
546    }
547
548    #[test]
549    fn trace_unused_export_is_deterministic_regardless_of_vec_order() {
550        let root = PathBuf::from("/project");
551        let forward = results_with_exports(vec![
552            unused_export("/project/src/b.ts", "beta"),
553            unused_export("/project/src/a.ts", "alpha"),
554        ]);
555        let reverse = results_with_exports(vec![
556            unused_export("/project/src/a.ts", "alpha"),
557            unused_export("/project/src/b.ts", "beta"),
558        ]);
559        let a = trace_unused_export(&forward, &root).expect("step");
560        let b = trace_unused_export(&reverse, &root).expect("step");
561        assert_eq!(a.command, b.command);
562        assert_eq!(a.command, "fallow dead-code --trace src/a.ts:alpha");
563    }
564
565    #[test]
566    fn clean_run_emits_no_next_steps() {
567        let root = PathBuf::from("/project");
568        let results = AnalysisResults::default();
569        assert!(build_dead_code_next_steps(&results, &root, true, None).is_empty());
570    }
571
572    #[test]
573    fn setup_pointer_emits_only_when_applicable() {
574        assert!(setup_pointer(false).is_none());
575        let step = setup_pointer(true).expect("step");
576        assert_eq!(step.id, "setup");
577        assert_eq!(step.command, "fallow schema");
578        assert_valid(&step);
579    }
580
581    #[test]
582    fn setup_pointer_gate_ignores_nonexistent_roots() {
583        assert!(!setup_pointer_applicable(Path::new(
584            "/fallow-test-project-does-not-exist"
585        )));
586    }
587
588    #[test]
589    fn setup_pointer_leads_when_offered() {
590        let root = PathBuf::from("/project");
591        let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
592        let steps = build_dead_code_next_steps(&results, &root, true, None);
593        assert_eq!(steps.first().map(|s| s.id.as_str()), Some("setup"));
594        let steps = build_dead_code_next_steps(&results, &root, false, None);
595        assert!(steps.iter().all(|s| s.id != "setup"));
596    }
597
598    #[test]
599    fn human_top_step_never_surfaces_setup() {
600        let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
601        let top = top_combined_next_step(Some(&results), None, None, Path::new("/project"));
602        if let Some(step) = top {
603            assert_ne!(step.id, "setup");
604        }
605    }
606
607    fn digest(containment: usize, resolved: usize) -> crate::impact::ImpactDigest {
608        crate::impact::ImpactDigest {
609            containment_count: containment,
610            resolved_total: resolved,
611        }
612    }
613
614    #[test]
615    fn impact_digest_step_carries_real_counters() {
616        assert!(impact_digest_step(None).is_none());
617        let step = impact_digest_step(Some(digest(4, 12))).expect("step");
618        assert_eq!(step.id, "impact-report");
619        assert_eq!(step.command, "fallow impact");
620        assert!(step.reason.contains("4 commits contained at the gate"));
621        assert!(step.reason.contains("12 findings resolved"));
622        assert_valid(&step);
623        let singular = impact_digest_step(Some(digest(1, 0))).expect("step");
624        assert!(singular.reason.contains("1 commit contained at the gate"));
625        assert!(!singular.reason.contains("resolved"));
626    }
627
628    #[test]
629    fn due_digest_rides_a_clean_run() {
630        let root = PathBuf::from("/project");
631        let results = AnalysisResults::default();
632        let steps = build_dead_code_next_steps(&results, &root, true, Some(digest(2, 0)));
633        assert_eq!(steps.len(), 1, "clean run carries ONLY the digest");
634        assert_eq!(steps[0].id, "impact-report");
635    }
636
637    #[test]
638    fn digest_follows_setup_on_dirty_runs() {
639        let root = PathBuf::from("/project");
640        let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
641        let steps = build_dead_code_next_steps(&results, &root, true, Some(digest(2, 3)));
642        let ids: Vec<&str> = steps.iter().map(|s| s.id.as_str()).collect();
643        assert_eq!(ids[0], "setup");
644        assert_eq!(ids[1], "impact-report");
645    }
646
647    #[test]
648    fn impact_digest_line_renders_counters() {
649        let line = impact_digest_line(digest(2, 1));
650        assert_eq!(
651            line,
652            "Impact: 2 commits contained at the gate, 1 finding resolved (details: `fallow impact`)."
653        );
654    }
655
656    #[test]
657    fn suggestions_enabled_parses_off_values() {
658        for off in ["off", "0", "false", "no", "disabled", "OFF", " Off "] {
659            assert!(!suggestions_enabled_from(Some(off)), "{off} should disable");
660        }
661        for on in ["on", "1", "true", "", "yes"] {
662            assert!(suggestions_enabled_from(Some(on)), "{on} should enable");
663        }
664        assert!(suggestions_enabled_from(None), "default is enabled");
665    }
666
667    #[test]
668    fn every_emitted_command_is_runnable_and_read_only() {
669        // Exercise every data-driven trigger and assert both contracts.
670        let root = PathBuf::from("/project");
671        let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
672        let mut all = Vec::new();
673        all.extend(trace_unused_export(&results, &root));
674        // Static-command triggers (no findings needed to inspect the string).
675        all.push(next_step("audit-changed", "fallow audit".to_string(), "x"));
676        all.push(next_step(
677            "scope-workspaces",
678            "fallow dead-code --changed-workspaces origin/main".to_string(),
679            "x",
680        ));
681        all.push(next_step(
682            "complexity-breakdown",
683            "fallow health --complexity-breakdown".to_string(),
684            "x",
685        ));
686        all.push(next_step(
687            "trace-clone",
688            "fallow dupes --trace dup:abcd1234".to_string(),
689            "x",
690        ));
691        all.extend(setup_pointer(true));
692        assert!(!all.is_empty());
693        for step in &all {
694            assert_valid(step);
695        }
696    }
697
698    #[test]
699    fn dead_code_steps_capped_at_three() {
700        let root = PathBuf::from("/project");
701        let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
702        // Even if git/workspaces/setup add candidates, the cap holds.
703        let steps = build_dead_code_next_steps(&results, &root, true, None);
704        assert!(steps.len() <= MAX_NEXT_STEPS);
705    }
706}