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    })
132}
133
134/// Next-steps for standalone `fallow health`. See [`build_dead_code_next_steps`]
135/// for the `offer_setup` parameter contract.
136#[must_use]
137pub fn health_next_steps_input(
138    report: &HealthReport,
139    root: &Path,
140    offer_setup: bool,
141    digest: Option<crate::impact::ImpactDigest>,
142) -> HealthNextStepsInput {
143    fallow_output::build_health_next_steps_input(
144        report,
145        suggestions_enabled(),
146        offer_setup,
147        digest.map(impact_counts),
148        audit_changed_applicable(root),
149    )
150}
151
152/// Next-steps for standalone `fallow dupes`. See [`build_dead_code_next_steps`]
153/// for the `offer_setup` parameter contract.
154#[must_use]
155pub fn build_dupes_next_steps(
156    payload: &DupesReportPayload,
157    root: &Path,
158    offer_setup: bool,
159    digest: Option<crate::impact::ImpactDigest>,
160) -> Vec<NextStep> {
161    let clone_fingerprints = payload
162        .clone_groups
163        .iter()
164        .map(|group| group.fingerprint.as_str())
165        .collect::<Vec<_>>();
166    build_dupes_next_steps_contract(DupesNextStepsInput {
167        suggestions_enabled: suggestions_enabled(),
168        clone_fingerprints: &clone_fingerprints,
169        offer_setup,
170        impact_digest: digest.map(impact_counts),
171        audit_changed: audit_changed_applicable(root),
172    })
173}
174
175/// Aggregated next-steps for bare `fallow` (combined). Candidates are pushed in
176/// priority order, then capped. `trace-unused-export` leads because it is the
177/// highest-value verification path; `scope-workspaces` is boosted above the
178/// trace-clone / complexity tier so big-repo runs that trigger everything still
179/// surface the rare monorepo-scoping capability instead of always dropping it
180/// under the cap. `audit-changed` is last (broadly applicable, least specific).
181#[must_use]
182pub fn build_combined_next_steps(
183    results: Option<&AnalysisResults>,
184    dupes: Option<&DupesReportPayload>,
185    health: Option<&HealthReport>,
186    root: &Path,
187    offer_setup: bool,
188    digest: Option<crate::impact::ImpactDigest>,
189) -> Vec<NextStep> {
190    let workspace_ref = default_workspace_ref_for_next_step(root);
191    let clone_fingerprints = dupes
192        .map(|payload| {
193            payload
194                .clone_groups
195                .iter()
196                .map(|group| group.fingerprint.as_str())
197                .collect::<Vec<_>>()
198        })
199        .unwrap_or_default();
200    build_combined_next_steps_contract(&CombinedNextStepsInput {
201        suggestions_enabled: suggestions_enabled(),
202        has_dead_code_findings: results.is_some_and(|r| r.total_issues() > 0),
203        trace_unused_export: results.and_then(|r| trace_unused_export_input(r, root)),
204        workspace_ref: workspace_ref.as_deref(),
205        clone_fingerprints: &clone_fingerprints,
206        has_complexity_findings: health.is_some_and(|h| !h.findings.is_empty()),
207        offer_setup,
208        impact_digest: digest.map(impact_counts),
209        audit_changed: audit_changed_applicable(root),
210    })
211}
212
213/// Next-steps for `fallow audit`. No `audit-changed` (audit IS the changed
214/// scope) and no `scope-workspaces` (audit already gates the change). The
215/// `check` tuple carries the changed-file analysis results plus the project root
216/// so the trace anchor is made root-relative the same way every other surface
217/// does it (in-memory finding paths are absolute; the wire form is relative).
218#[must_use]
219#[cfg(test)]
220pub fn build_audit_next_steps(
221    check: Option<(&AnalysisResults, &Path)>,
222    complexity: Option<&HealthReport>,
223) -> Vec<NextStep> {
224    fallow_output::build_audit_next_steps(&fallow_output::build_audit_next_steps_input(
225        check,
226        complexity,
227        suggestions_enabled(),
228    ))
229}
230
231/// The single highest-priority next-step for the human `Next:` line, computed
232/// from the same candidates and ordering as the combined JSON array so a human
233/// and an agent on the same run never see a contradictory top step. The `setup`
234/// pointer is deliberately excluded here (`offer_setup: false`): humans get the
235/// dedicated prose [`SETUP_HINT`] line instead, so the `Next:` slot always
236/// shows an analysis follow-up.
237#[must_use]
238pub fn top_combined_next_step(
239    results: Option<&AnalysisResults>,
240    dupes: Option<&DupesReportPayload>,
241    health: Option<&HealthReport>,
242    root: &Path,
243) -> Option<NextStep> {
244    build_combined_next_steps(results, dupes, health, root, false, None)
245        .into_iter()
246        .next()
247}
248
249#[cfg(test)]
250mod tests {
251    use std::path::PathBuf;
252
253    use fallow_output::build_health_next_steps as build_health_next_steps_contract;
254    use fallow_output::{
255        ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding, HealthReport,
256    };
257    use fallow_types::duplicates::{
258        CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
259    };
260    use fallow_types::output_dead_code::UnusedExportFinding;
261    use fallow_types::results::{AnalysisResults, UnusedExport};
262
263    use super::*;
264
265    fn unused_export(path: &str, name: &str) -> UnusedExportFinding {
266        UnusedExportFinding::with_actions(UnusedExport {
267            path: PathBuf::from(path),
268            export_name: name.to_string(),
269            is_type_only: false,
270            line: 1,
271            col: 0,
272            span_start: 0,
273            is_re_export: false,
274        })
275    }
276
277    fn results_with_exports(exports: Vec<UnusedExportFinding>) -> AnalysisResults {
278        AnalysisResults {
279            unused_exports: exports,
280            ..AnalysisResults::default()
281        }
282    }
283
284    fn clone_instance(path: &str, fragment: &str) -> CloneInstance {
285        CloneInstance {
286            file: PathBuf::from(path),
287            start_line: 1,
288            end_line: 8,
289            start_col: 0,
290            end_col: 0,
291            fragment: fragment.to_string(),
292        }
293    }
294
295    fn dupes_payload() -> DupesReportPayload {
296        let group = CloneGroup {
297            instances: vec![
298                clone_instance("/project/src/a.ts", "export const shared = 1;"),
299                clone_instance("/project/src/b.ts", "export const shared = 1;"),
300            ],
301            token_count: 20,
302            line_count: 8,
303        };
304        DupesReportPayload::from_report(&DuplicationReport {
305            clone_groups: vec![group],
306            clone_families: Vec::new(),
307            mirrored_directories: Vec::new(),
308            stats: DuplicationStats::default(),
309        })
310    }
311
312    fn health_report_with_finding() -> HealthReport {
313        HealthReport {
314            findings: vec![HealthFinding::from(ComplexityViolation {
315                path: PathBuf::from("/project/src/hot.ts"),
316                name: "hot".to_string(),
317                line: 1,
318                col: 0,
319                cyclomatic: 21,
320                cognitive: 16,
321                line_count: 42,
322                param_count: 0,
323                react_hook_count: 0,
324                react_jsx_max_depth: 0,
325                react_prop_count: 0,
326                react_hook_profile: None,
327                exceeded: ExceededThreshold::Both,
328                severity: FindingSeverity::High,
329                crap: None,
330                coverage_pct: None,
331                coverage_tier: None,
332                coverage_source: None,
333                inherited_from: None,
334                component_rollup: None,
335                contributions: Vec::new(),
336                effective_thresholds: None,
337                threshold_source: None,
338            })],
339            ..HealthReport::default()
340        }
341    }
342
343    fn assert_valid(step: &NextStep) {
344        const MUTATING_VERBS: [&str; 5] = ["fix", "init", "hooks", "migrate", "setup-hooks"];
345
346        assert!(
347            !step.command.contains('<') && !step.command.contains('>'),
348            "command must be placeholder-free: {}",
349            step.command
350        );
351        assert!(
352            !step
353                .command
354                .split_whitespace()
355                .any(|token| MUTATING_VERBS.contains(&token)),
356            "command must be read-only: {}",
357            step.command
358        );
359    }
360
361    #[test]
362    fn audit_next_steps_emit_runnable_relative_trace_command() {
363        let root = PathBuf::from("/project");
364        let results = results_with_exports(vec![unused_export("/project/src/util.ts", "foo")]);
365        let steps = build_audit_next_steps(Some((&results, &root)), None);
366
367        assert_eq!(steps.len(), 1);
368        assert_eq!(steps[0].id, "trace-unused-export");
369        assert_eq!(steps[0].command, "fallow dead-code --trace src/util.ts:foo");
370        assert_valid(&steps[0]);
371    }
372
373    #[test]
374    fn audit_next_steps_select_deterministic_trace_target() {
375        let root = PathBuf::from("/project");
376        let forward = results_with_exports(vec![
377            unused_export("/project/src/b.ts", "beta"),
378            unused_export("/project/src/a.ts", "alpha"),
379        ]);
380        let reverse = results_with_exports(vec![
381            unused_export("/project/src/a.ts", "alpha"),
382            unused_export("/project/src/b.ts", "beta"),
383        ]);
384        let a = build_audit_next_steps(Some((&forward, &root)), None);
385        let b = build_audit_next_steps(Some((&reverse, &root)), None);
386        assert_eq!(a[0].command, b[0].command);
387        assert_eq!(a[0].command, "fallow dead-code --trace src/a.ts:alpha");
388    }
389
390    #[test]
391    fn clean_run_emits_no_next_steps() {
392        let root = PathBuf::from("/project");
393        let results = AnalysisResults::default();
394        assert!(build_dead_code_next_steps(&results, &root, true, None).is_empty());
395    }
396
397    #[test]
398    fn setup_pointer_gate_ignores_nonexistent_roots() {
399        assert!(!setup_pointer_applicable(Path::new(
400            "/fallow-test-project-does-not-exist"
401        )));
402    }
403
404    #[test]
405    fn setup_pointer_leads_when_offered() {
406        let root = PathBuf::from("/project");
407        let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
408        let steps = build_dead_code_next_steps(&results, &root, true, None);
409        assert_eq!(steps.first().map(|s| s.id.as_str()), Some("setup"));
410        let steps = build_dead_code_next_steps(&results, &root, false, None);
411        assert!(steps.iter().all(|s| s.id != "setup"));
412    }
413
414    #[test]
415    fn human_top_step_never_surfaces_setup() {
416        let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
417        let top = top_combined_next_step(Some(&results), None, None, Path::new("/project"));
418        if let Some(step) = top {
419            assert_ne!(step.id, "setup");
420        }
421    }
422
423    fn digest(containment: usize, resolved: usize) -> crate::impact::ImpactDigest {
424        crate::impact::ImpactDigest {
425            containment_count: containment,
426            resolved_total: resolved,
427        }
428    }
429
430    #[test]
431    fn due_digest_rides_a_clean_run() {
432        let root = PathBuf::from("/project");
433        let results = AnalysisResults::default();
434        let steps = build_dead_code_next_steps(&results, &root, true, Some(digest(2, 0)));
435        assert_eq!(steps.len(), 1, "clean run carries ONLY the digest");
436        assert_eq!(steps[0].id, "impact-report");
437    }
438
439    #[test]
440    fn digest_follows_setup_on_dirty_runs() {
441        let root = PathBuf::from("/project");
442        let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
443        let steps = build_dead_code_next_steps(&results, &root, true, Some(digest(2, 3)));
444        let ids: Vec<&str> = steps.iter().map(|s| s.id.as_str()).collect();
445        assert_eq!(ids[0], "setup");
446        assert_eq!(ids[1], "impact-report");
447    }
448
449    #[test]
450    fn health_steps_keep_complexity_breakdown_from_output_contract() {
451        let report = health_report_with_finding();
452        let steps = build_health_next_steps_contract(health_next_steps_input(
453            &report,
454            Path::new("/project"),
455            false,
456            None,
457        ));
458        let ids: Vec<&str> = steps.iter().map(|s| s.id.as_str()).collect();
459
460        assert_eq!(ids, ["complexity-breakdown"]);
461        assert_valid(&steps[0]);
462    }
463
464    #[test]
465    fn health_next_steps_input_feeds_output_contract_builder() {
466        let report = health_report_with_finding();
467        let input =
468            health_next_steps_input(&report, Path::new("/project"), true, Some(digest(2, 1)));
469
470        assert!(input.suggestions_enabled);
471        assert!(input.has_findings);
472        assert!(input.offer_setup);
473        assert_eq!(
474            input.impact_digest,
475            Some(ImpactDigestCounts {
476                containment_count: 2,
477                resolved_total: 1,
478            })
479        );
480
481        let steps = build_health_next_steps_contract(input);
482        let ids: Vec<&str> = steps.iter().map(|s| s.id.as_str()).collect();
483        assert_eq!(ids, ["setup", "impact-report", "complexity-breakdown"]);
484    }
485
486    #[test]
487    fn dupes_next_steps_route_payload_fingerprints_to_output_contract() {
488        let payload = dupes_payload();
489
490        let steps = build_dupes_next_steps(&payload, Path::new("/project"), false, None);
491
492        assert_eq!(steps.len(), 1);
493        assert_eq!(steps[0].id, "trace-clone");
494        assert!(steps[0].command.starts_with("fallow dupes --trace dup:"));
495        assert_valid(&steps[0]);
496    }
497
498    #[test]
499    fn combined_next_steps_route_payload_facts_to_output_contract() {
500        let root = PathBuf::from("/project");
501        let results = results_with_exports(vec![
502            unused_export("/project/src/b.ts", "beta"),
503            unused_export("/project/src/a.ts", "alpha"),
504        ]);
505        let payload = dupes_payload();
506        let report = health_report_with_finding();
507
508        let steps = build_combined_next_steps(
509            Some(&results),
510            Some(&payload),
511            Some(&report),
512            &root,
513            true,
514            Some(digest(2, 1)),
515        );
516        let ids: Vec<&str> = steps.iter().map(|s| s.id.as_str()).collect();
517
518        assert_eq!(ids, ["setup", "impact-report", "trace-unused-export"]);
519        assert_eq!(steps[2].command, "fallow dead-code --trace src/a.ts:alpha");
520        for step in &steps {
521            assert_valid(step);
522        }
523    }
524
525    #[test]
526    fn audit_next_steps_route_payload_facts_to_output_contract() {
527        let root = PathBuf::from("/project");
528        let results = results_with_exports(vec![
529            unused_export("/project/src/b.ts", "beta"),
530            unused_export("/project/src/a.ts", "alpha"),
531        ]);
532        let report = health_report_with_finding();
533
534        let steps = build_audit_next_steps(Some((&results, &root)), Some(&report));
535        let ids: Vec<&str> = steps.iter().map(|s| s.id.as_str()).collect();
536
537        assert_eq!(ids, ["trace-unused-export", "complexity-breakdown"]);
538        assert_eq!(steps[0].command, "fallow dead-code --trace src/a.ts:alpha");
539        for step in &steps {
540            assert_valid(step);
541        }
542    }
543
544    #[test]
545    fn impact_digest_line_renders_counters() {
546        let line = impact_digest_line(digest(2, 1));
547        assert_eq!(
548            line,
549            "Impact: 2 commits contained at the gate, 1 finding resolved (details: `fallow impact`)."
550        );
551    }
552
553    #[test]
554    fn suggestions_enabled_parses_off_values() {
555        for off in ["off", "0", "false", "no", "disabled", "OFF", " Off "] {
556            assert!(!suggestions_enabled_from(Some(off)), "{off} should disable");
557        }
558        for on in ["on", "1", "true", "", "yes"] {
559            assert!(suggestions_enabled_from(Some(on)), "{on} should enable");
560        }
561        assert!(suggestions_enabled_from(None), "default is enabled");
562    }
563
564    #[test]
565    fn every_emitted_command_is_runnable_and_read_only() {
566        // Exercise CLI adapters and assert the output-owned command contracts.
567        let root = PathBuf::from("/project");
568        let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
569        let payload = dupes_payload();
570        let report = health_report_with_finding();
571        let mut all = Vec::new();
572        all.extend(build_audit_next_steps(Some((&results, &root)), None));
573        all.extend(build_dead_code_next_steps(
574            &results,
575            &root,
576            true,
577            Some(digest(2, 1)),
578        ));
579        all.extend(build_dupes_next_steps(&payload, &root, false, None));
580        all.extend(build_health_next_steps_contract(health_next_steps_input(
581            &report, &root, false, None,
582        )));
583        all.extend(build_combined_next_steps(
584            Some(&results),
585            Some(&payload),
586            Some(&report),
587            &root,
588            true,
589            Some(digest(2, 1)),
590        ));
591        assert!(!all.is_empty());
592        for step in &all {
593            assert_valid(step);
594        }
595    }
596
597    #[test]
598    fn dead_code_steps_capped_at_three() {
599        let root = PathBuf::from("/project");
600        let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
601        // Even if git/workspaces/setup add candidates, the cap holds.
602        let steps = build_dead_code_next_steps(&results, &root, true, None);
603        assert!(steps.len() <= 3);
604    }
605}