Skip to main content

fallow_cli/report/
suggestions.rs

1//! Runtime fact adapters for `next_steps[]` builders.
2//!
3//! The stable command strings, ordering, caps, and read-only contract live in
4//! `fallow-output`. This module keeps the CLI-specific probes: environment
5//! toggles, project setup state, git refs, and changed-branch applicability.
6
7use std::path::Path;
8
9use fallow_api::DupesReportPayload;
10use fallow_output::{
11    CombinedNextStepsInput, DeadCodeNextStepsInput, DupesNextStepsInput, HealthNextStepsInput,
12    ImpactDigestCounts, build_combined_next_steps as build_combined_next_steps_contract,
13    build_dead_code_next_steps as build_dead_code_next_steps_contract,
14    build_dupes_next_steps as build_dupes_next_steps_contract, impact_digest_summary,
15    trace_unused_export_input,
16};
17use fallow_types::output::NextStep;
18use fallow_types::results::AnalysisResults;
19
20use fallow_output::HealthReport;
21
22/// `FALLOW_SUGGESTIONS=off` (or `0`/`false`/`no`/`disabled`) disables next-steps
23/// entirely. Default on. This is the documented escape hatch for CI consumers
24/// that snapshot-diff raw `--format json` output; it reaches the spawned-CLI and
25/// MCP surfaces without a CLI flag.
26#[must_use]
27pub fn suggestions_enabled() -> bool {
28    suggestions_enabled_from(std::env::var("FALLOW_SUGGESTIONS").ok().as_deref())
29}
30
31/// Pure parse of the `FALLOW_SUGGESTIONS` value (kept separate so it is testable
32/// without mutating process env, which is `unsafe` under edition 2024).
33#[must_use]
34fn suggestions_enabled_from(value: Option<&str>) -> bool {
35    match value {
36        Some(raw) => !matches!(
37            raw.trim().to_ascii_lowercase().as_str(),
38            "off" | "0" | "false" | "no" | "disabled"
39        ),
40        None => true,
41    }
42}
43
44/// Shared first-contact gate for the `setup` next-step and the human setup hint
45/// on bare `fallow`: the project has no fallow config (searched up to the repo
46/// root, same as config loading), the run is not in CI, and onboarding has not
47/// been declined for this project (`fallow init --decline`).
48#[must_use]
49pub fn setup_pointer_applicable(root: &Path) -> bool {
50    root.exists()
51        && fallow_config::FallowConfig::find_config_path(root).is_none()
52        && !crate::telemetry::is_ci()
53        && !crate::impact::load(root).onboarding_declined
54}
55
56/// One-line human setup hint for bare `fallow` output: the prose counterpart of
57/// the `setup` next-step (agents get the JSON form, humans get this line).
58/// Worded as an offer, not a deficiency: zero-config is a supported happy path.
59pub 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`).";
60
61/// Real-counter summary fragment shared by the next-step reason and the human
62/// one-liner. The output crate owns the `impact-report` command contract.
63pub fn impact_counts(digest: crate::impact::ImpactDigest) -> ImpactDigestCounts {
64    ImpactDigestCounts {
65        containment_count: digest.containment_count,
66        resolved_total: digest.resolved_total,
67    }
68}
69
70fn digest_summary(digest: crate::impact::ImpactDigest) -> String {
71    impact_digest_summary(impact_counts(digest))
72}
73
74/// One-line human counterpart of the `impact-report` next-step, printed with
75/// the run summary on bare `fallow`.
76#[must_use]
77pub fn impact_digest_line(digest: crate::impact::ImpactDigest) -> String {
78    format!(
79        "Impact: {} (details: `fallow impact`).",
80        digest_summary(digest)
81    )
82}
83
84/// Read-and-stamp the due periodic impact digest for the envelope being built.
85/// Returns `None` in CI or when suggestions are disabled, WITHOUT consuming the
86/// cadence stamp, so the digest is never burned by a surface that will not
87/// show it.
88#[must_use]
89pub fn due_impact_digest(root: &Path) -> Option<crate::impact::ImpactDigest> {
90    if !suggestions_enabled() || crate::telemetry::is_ci() {
91        return None;
92    }
93    crate::impact::take_due_digest(root)
94}
95
96fn default_workspace_ref_for_next_step(root: &Path) -> Option<String> {
97    fallow_engine::repo_refs::default_workspace_ref(root)
98}
99
100/// `audit-changed`: gate only the files the current branch changed. `fallow
101/// audit` auto-detects its base, so no ref needs embedding.
102pub fn audit_changed_applicable(root: &Path) -> bool {
103    fallow_engine::churn::is_git_repo(root)
104}
105
106// ---------------------------------------------------------------------------
107// Public per-command builders. Each no-ops when suggestions are disabled or the
108// run is clean (no findings), so a clean run never emits `next_steps`, with one
109// documented exception: a due `impact-report` digest may ride a clean run.
110// ---------------------------------------------------------------------------
111
112/// Next-steps for standalone `fallow dead-code`. `offer_setup` is the caller's
113/// [`setup_pointer_applicable`] result (threaded as a parameter so the builders
114/// stay free of env/filesystem probes and deterministic under test).
115#[must_use]
116pub fn build_dead_code_next_steps(
117    results: &AnalysisResults,
118    root: &Path,
119    offer_setup: bool,
120    digest: Option<crate::impact::ImpactDigest>,
121) -> Vec<NextStep> {
122    let workspace_ref = default_workspace_ref_for_next_step(root);
123    build_dead_code_next_steps_contract(DeadCodeNextStepsInput {
124        suggestions_enabled: suggestions_enabled(),
125        results,
126        root,
127        offer_setup,
128        impact_digest: digest.map(impact_counts),
129        workspace_ref: workspace_ref.as_deref(),
130        audit_changed: audit_changed_applicable(root),
131        has_external_plugins: has_external_plugins(root),
132    })
133}
134
135/// Whether the project declares any auto-discovered external plugin
136/// (`fallow-plugin-*` files or `.fallow/plugins/`). A cheap readdir probe used
137/// to route the agent to `fallow plugin-check` when files are also unused.
138fn has_external_plugins(root: &Path) -> bool {
139    !fallow_config::discover_external_plugins(root, &[]).is_empty()
140}
141
142/// Next-steps for standalone `fallow health`. See [`build_dead_code_next_steps`]
143/// for the `offer_setup` parameter contract.
144#[must_use]
145pub fn health_next_steps_input(
146    report: &HealthReport,
147    root: &Path,
148    offer_setup: bool,
149    digest: Option<crate::impact::ImpactDigest>,
150) -> HealthNextStepsInput {
151    fallow_output::build_health_next_steps_input(
152        report,
153        suggestions_enabled(),
154        offer_setup,
155        digest.map(impact_counts),
156        audit_changed_applicable(root),
157    )
158}
159
160/// Next-steps for standalone `fallow dupes`. See [`build_dead_code_next_steps`]
161/// for the `offer_setup` parameter contract.
162#[must_use]
163pub fn build_dupes_next_steps(
164    payload: &DupesReportPayload,
165    root: &Path,
166    offer_setup: bool,
167    digest: Option<crate::impact::ImpactDigest>,
168) -> Vec<NextStep> {
169    let clone_fingerprints = payload
170        .clone_groups
171        .iter()
172        .map(|group| group.fingerprint.as_str())
173        .collect::<Vec<_>>();
174    build_dupes_next_steps_contract(DupesNextStepsInput {
175        suggestions_enabled: suggestions_enabled(),
176        clone_fingerprints: &clone_fingerprints,
177        offer_setup,
178        impact_digest: digest.map(impact_counts),
179        audit_changed: audit_changed_applicable(root),
180    })
181}
182
183/// Aggregated next-steps for bare `fallow` (combined). Candidates are pushed in
184/// priority order, then capped. `trace-unused-export` leads because it is the
185/// highest-value verification path; `scope-workspaces` is boosted above the
186/// trace-clone / complexity tier so big-repo runs that trigger everything still
187/// surface the rare monorepo-scoping capability instead of always dropping it
188/// under the cap. `audit-changed` is last (broadly applicable, least specific).
189#[must_use]
190pub fn build_combined_next_steps(
191    results: Option<&AnalysisResults>,
192    dupes: Option<&DupesReportPayload>,
193    health: Option<&HealthReport>,
194    root: &Path,
195    offer_setup: bool,
196    digest: Option<crate::impact::ImpactDigest>,
197) -> Vec<NextStep> {
198    let workspace_ref = default_workspace_ref_for_next_step(root);
199    let clone_fingerprints = dupes
200        .map(|payload| {
201            payload
202                .clone_groups
203                .iter()
204                .map(|group| group.fingerprint.as_str())
205                .collect::<Vec<_>>()
206        })
207        .unwrap_or_default();
208    build_combined_next_steps_contract(&CombinedNextStepsInput {
209        suggestions_enabled: suggestions_enabled(),
210        has_dead_code_findings: results.is_some_and(|r| r.total_issues() > 0),
211        trace_unused_export: results.and_then(|r| trace_unused_export_input(r, root)),
212        workspace_ref: workspace_ref.as_deref(),
213        clone_fingerprints: &clone_fingerprints,
214        has_complexity_findings: health.is_some_and(|h| !h.findings.is_empty()),
215        offer_setup,
216        impact_digest: digest.map(impact_counts),
217        audit_changed: audit_changed_applicable(root),
218        has_external_plugins: has_external_plugins(root),
219        has_unused_files: results.is_some_and(|r| !r.unused_files.is_empty()),
220    })
221}
222
223/// Next-steps for `fallow audit`. No `audit-changed` (audit IS the changed
224/// scope) and no `scope-workspaces` (audit already gates the change). The
225/// `check` tuple carries the changed-file analysis results plus the project root
226/// so the trace anchor is made root-relative the same way every other surface
227/// does it (in-memory finding paths are absolute; the wire form is relative).
228#[must_use]
229#[cfg(test)]
230pub fn build_audit_next_steps(
231    check: Option<(&AnalysisResults, &Path)>,
232    complexity: Option<&HealthReport>,
233) -> Vec<NextStep> {
234    fallow_output::build_audit_next_steps(&fallow_output::build_audit_next_steps_input(
235        check,
236        complexity,
237        suggestions_enabled(),
238    ))
239}
240
241/// The single highest-priority next-step for the human `Next:` line, computed
242/// from the same candidates and ordering as the combined JSON array so a human
243/// and an agent on the same run never see a contradictory top step. The `setup`
244/// pointer is deliberately excluded here (`offer_setup: false`): humans get the
245/// dedicated prose [`SETUP_HINT`] line instead, so the `Next:` slot always
246/// shows an analysis follow-up.
247#[must_use]
248pub fn top_combined_next_step(
249    results: Option<&AnalysisResults>,
250    dupes: Option<&DupesReportPayload>,
251    health: Option<&HealthReport>,
252    root: &Path,
253) -> Option<NextStep> {
254    build_combined_next_steps(results, dupes, health, root, false, None)
255        .into_iter()
256        .next()
257}
258
259#[cfg(test)]
260mod tests {
261    use std::path::PathBuf;
262
263    use fallow_output::build_health_next_steps as build_health_next_steps_contract;
264    use fallow_output::{
265        ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding, HealthReport,
266    };
267    use fallow_types::duplicates::{
268        CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
269    };
270    use fallow_types::output_dead_code::UnusedExportFinding;
271    use fallow_types::results::{AnalysisResults, UnusedExport};
272
273    use super::*;
274
275    fn unused_export(path: &str, name: &str) -> UnusedExportFinding {
276        UnusedExportFinding::with_actions(UnusedExport {
277            path: PathBuf::from(path),
278            export_name: name.to_string(),
279            is_type_only: false,
280            line: 1,
281            col: 0,
282            span_start: 0,
283            is_re_export: false,
284        })
285    }
286
287    fn results_with_exports(exports: Vec<UnusedExportFinding>) -> AnalysisResults {
288        AnalysisResults {
289            unused_exports: exports,
290            ..AnalysisResults::default()
291        }
292    }
293
294    fn clone_instance(path: &str, fragment: &str) -> CloneInstance {
295        CloneInstance {
296            file: PathBuf::from(path),
297            start_line: 1,
298            end_line: 8,
299            start_col: 0,
300            end_col: 0,
301            fragment: fragment.to_string(),
302        }
303    }
304
305    fn dupes_payload() -> DupesReportPayload {
306        let group = CloneGroup {
307            instances: vec![
308                clone_instance("/project/src/a.ts", "export const shared = 1;"),
309                clone_instance("/project/src/b.ts", "export const shared = 1;"),
310            ],
311            token_count: 20,
312            line_count: 8,
313        };
314        DupesReportPayload::from_report(&DuplicationReport {
315            clone_groups: vec![group],
316            clone_families: Vec::new(),
317            mirrored_directories: Vec::new(),
318            stats: DuplicationStats::default(),
319        })
320    }
321
322    fn health_report_with_finding() -> HealthReport {
323        HealthReport {
324            findings: vec![HealthFinding::from(ComplexityViolation {
325                path: PathBuf::from("/project/src/hot.ts"),
326                name: "hot".to_string(),
327                line: 1,
328                col: 0,
329                cyclomatic: 21,
330                cognitive: 16,
331                line_count: 42,
332                param_count: 0,
333                react_hook_count: 0,
334                react_jsx_max_depth: 0,
335                react_prop_count: 0,
336                react_hook_profile: None,
337                exceeded: ExceededThreshold::Both,
338                severity: FindingSeverity::High,
339                crap: None,
340                coverage_pct: None,
341                coverage_tier: None,
342                coverage_source: None,
343                inherited_from: None,
344                component_rollup: None,
345                contributions: Vec::new(),
346                effective_thresholds: None,
347                threshold_source: None,
348            })],
349            ..HealthReport::default()
350        }
351    }
352
353    fn assert_valid(step: &NextStep) {
354        const MUTATING_VERBS: [&str; 5] = ["fix", "init", "hooks", "migrate", "setup-hooks"];
355
356        assert!(
357            !step.command.contains('<') && !step.command.contains('>'),
358            "command must be placeholder-free: {}",
359            step.command
360        );
361        assert!(
362            !step
363                .command
364                .split_whitespace()
365                .any(|token| MUTATING_VERBS.contains(&token)),
366            "command must be read-only: {}",
367            step.command
368        );
369    }
370
371    #[test]
372    fn audit_next_steps_emit_runnable_relative_trace_command() {
373        let root = PathBuf::from("/project");
374        let results = results_with_exports(vec![unused_export("/project/src/util.ts", "foo")]);
375        let steps = build_audit_next_steps(Some((&results, &root)), None);
376
377        assert_eq!(steps.len(), 1);
378        assert_eq!(steps[0].id, "trace-unused-export");
379        assert_eq!(steps[0].command, "fallow dead-code --trace src/util.ts:foo");
380        assert_valid(&steps[0]);
381    }
382
383    #[test]
384    fn audit_next_steps_select_deterministic_trace_target() {
385        let root = PathBuf::from("/project");
386        let forward = results_with_exports(vec![
387            unused_export("/project/src/b.ts", "beta"),
388            unused_export("/project/src/a.ts", "alpha"),
389        ]);
390        let reverse = results_with_exports(vec![
391            unused_export("/project/src/a.ts", "alpha"),
392            unused_export("/project/src/b.ts", "beta"),
393        ]);
394        let a = build_audit_next_steps(Some((&forward, &root)), None);
395        let b = build_audit_next_steps(Some((&reverse, &root)), None);
396        assert_eq!(a[0].command, b[0].command);
397        assert_eq!(a[0].command, "fallow dead-code --trace src/a.ts:alpha");
398    }
399
400    #[test]
401    fn clean_run_emits_no_next_steps() {
402        let root = PathBuf::from("/project");
403        let results = AnalysisResults::default();
404        assert!(build_dead_code_next_steps(&results, &root, true, None).is_empty());
405    }
406
407    #[test]
408    fn setup_pointer_gate_ignores_nonexistent_roots() {
409        assert!(!setup_pointer_applicable(Path::new(
410            "/fallow-test-project-does-not-exist"
411        )));
412    }
413
414    #[test]
415    fn setup_pointer_leads_when_offered() {
416        let root = PathBuf::from("/project");
417        let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
418        let steps = build_dead_code_next_steps(&results, &root, true, None);
419        assert_eq!(steps.first().map(|s| s.id.as_str()), Some("setup"));
420        let steps = build_dead_code_next_steps(&results, &root, false, None);
421        assert!(steps.iter().all(|s| s.id != "setup"));
422    }
423
424    #[test]
425    fn human_top_step_never_surfaces_setup() {
426        let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
427        let top = top_combined_next_step(Some(&results), None, None, Path::new("/project"));
428        if let Some(step) = top {
429            assert_ne!(step.id, "setup");
430        }
431    }
432
433    fn digest(containment: usize, resolved: usize) -> crate::impact::ImpactDigest {
434        crate::impact::ImpactDigest {
435            containment_count: containment,
436            resolved_total: resolved,
437        }
438    }
439
440    #[test]
441    fn due_digest_rides_a_clean_run() {
442        let root = PathBuf::from("/project");
443        let results = AnalysisResults::default();
444        let steps = build_dead_code_next_steps(&results, &root, true, Some(digest(2, 0)));
445        assert_eq!(steps.len(), 1, "clean run carries ONLY the digest");
446        assert_eq!(steps[0].id, "impact-report");
447    }
448
449    #[test]
450    fn digest_follows_setup_on_dirty_runs() {
451        let root = PathBuf::from("/project");
452        let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
453        let steps = build_dead_code_next_steps(&results, &root, true, Some(digest(2, 3)));
454        let ids: Vec<&str> = steps.iter().map(|s| s.id.as_str()).collect();
455        assert_eq!(ids[0], "setup");
456        assert_eq!(ids[1], "impact-report");
457    }
458
459    #[test]
460    fn health_steps_keep_complexity_breakdown_from_output_contract() {
461        let report = health_report_with_finding();
462        let steps = build_health_next_steps_contract(health_next_steps_input(
463            &report,
464            Path::new("/project"),
465            false,
466            None,
467        ));
468        let ids: Vec<&str> = steps.iter().map(|s| s.id.as_str()).collect();
469
470        assert_eq!(ids, ["complexity-breakdown"]);
471        assert_valid(&steps[0]);
472    }
473
474    #[test]
475    fn health_next_steps_input_feeds_output_contract_builder() {
476        let report = health_report_with_finding();
477        let input =
478            health_next_steps_input(&report, Path::new("/project"), true, Some(digest(2, 1)));
479
480        assert!(input.suggestions_enabled);
481        assert!(input.has_findings);
482        assert!(input.offer_setup);
483        assert_eq!(
484            input.impact_digest,
485            Some(ImpactDigestCounts {
486                containment_count: 2,
487                resolved_total: 1,
488            })
489        );
490
491        let steps = build_health_next_steps_contract(input);
492        let ids: Vec<&str> = steps.iter().map(|s| s.id.as_str()).collect();
493        assert_eq!(ids, ["setup", "impact-report", "complexity-breakdown"]);
494    }
495
496    #[test]
497    fn dupes_next_steps_route_payload_fingerprints_to_output_contract() {
498        let payload = dupes_payload();
499
500        let steps = build_dupes_next_steps(&payload, Path::new("/project"), false, None);
501
502        assert_eq!(steps.len(), 1);
503        assert_eq!(steps[0].id, "trace-clone");
504        assert!(steps[0].command.starts_with("fallow dupes --trace dup:"));
505        assert_valid(&steps[0]);
506    }
507
508    #[test]
509    fn combined_next_steps_route_payload_facts_to_output_contract() {
510        let root = PathBuf::from("/project");
511        let results = results_with_exports(vec![
512            unused_export("/project/src/b.ts", "beta"),
513            unused_export("/project/src/a.ts", "alpha"),
514        ]);
515        let payload = dupes_payload();
516        let report = health_report_with_finding();
517
518        let steps = build_combined_next_steps(
519            Some(&results),
520            Some(&payload),
521            Some(&report),
522            &root,
523            true,
524            Some(digest(2, 1)),
525        );
526        let ids: Vec<&str> = steps.iter().map(|s| s.id.as_str()).collect();
527
528        assert_eq!(ids, ["setup", "impact-report", "trace-unused-export"]);
529        assert_eq!(steps[2].command, "fallow dead-code --trace src/a.ts:alpha");
530        for step in &steps {
531            assert_valid(step);
532        }
533    }
534
535    #[test]
536    fn audit_next_steps_route_payload_facts_to_output_contract() {
537        let root = PathBuf::from("/project");
538        let results = results_with_exports(vec![
539            unused_export("/project/src/b.ts", "beta"),
540            unused_export("/project/src/a.ts", "alpha"),
541        ]);
542        let report = health_report_with_finding();
543
544        let steps = build_audit_next_steps(Some((&results, &root)), Some(&report));
545        let ids: Vec<&str> = steps.iter().map(|s| s.id.as_str()).collect();
546
547        assert_eq!(ids, ["trace-unused-export", "complexity-breakdown"]);
548        assert_eq!(steps[0].command, "fallow dead-code --trace src/a.ts:alpha");
549        for step in &steps {
550            assert_valid(step);
551        }
552    }
553
554    #[test]
555    fn impact_digest_line_renders_counters() {
556        let line = impact_digest_line(digest(2, 1));
557        assert_eq!(
558            line,
559            "Impact: 2 commits contained at the gate, 1 finding resolved (details: `fallow impact`)."
560        );
561    }
562
563    #[test]
564    fn suggestions_enabled_parses_off_values() {
565        for off in ["off", "0", "false", "no", "disabled", "OFF", " Off "] {
566            assert!(!suggestions_enabled_from(Some(off)), "{off} should disable");
567        }
568        for on in ["on", "1", "true", "", "yes"] {
569            assert!(suggestions_enabled_from(Some(on)), "{on} should enable");
570        }
571        assert!(suggestions_enabled_from(None), "default is enabled");
572    }
573
574    #[test]
575    fn every_emitted_command_is_runnable_and_read_only() {
576        // Exercise CLI adapters and assert the output-owned command contracts.
577        let root = PathBuf::from("/project");
578        let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
579        let payload = dupes_payload();
580        let report = health_report_with_finding();
581        let mut all = Vec::new();
582        all.extend(build_audit_next_steps(Some((&results, &root)), None));
583        all.extend(build_dead_code_next_steps(
584            &results,
585            &root,
586            true,
587            Some(digest(2, 1)),
588        ));
589        all.extend(build_dupes_next_steps(&payload, &root, false, None));
590        all.extend(build_health_next_steps_contract(health_next_steps_input(
591            &report, &root, false, None,
592        )));
593        all.extend(build_combined_next_steps(
594            Some(&results),
595            Some(&payload),
596            Some(&report),
597            &root,
598            true,
599            Some(digest(2, 1)),
600        ));
601        assert!(!all.is_empty());
602        for step in &all {
603            assert_valid(step);
604        }
605    }
606
607    #[test]
608    fn dead_code_steps_capped_at_three() {
609        let root = PathBuf::from("/project");
610        let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
611        // Even if git/workspaces/setup add candidates, the cap holds.
612        let steps = build_dead_code_next_steps(&results, &root, true, None);
613        assert!(steps.len() <= 3);
614    }
615}