use std::path::Path;
use std::process::Command;
use fallow_core::results::AnalysisResults;
use fallow_types::output::NextStep;
use crate::health_types::HealthReport;
use crate::output_dupes::DupesReportPayload;
const MAX_NEXT_STEPS: usize = 3;
const MUTATING_VERBS: [&str; 5] = ["fix", "init", "hooks", "migrate", "setup-hooks"];
#[must_use]
pub fn suggestions_enabled() -> bool {
suggestions_enabled_from(std::env::var("FALLOW_SUGGESTIONS").ok().as_deref())
}
#[must_use]
fn suggestions_enabled_from(value: Option<&str>) -> bool {
match value {
Some(raw) => !matches!(
raw.trim().to_ascii_lowercase().as_str(),
"off" | "0" | "false" | "no" | "disabled"
),
None => true,
}
}
fn next_step(id: &str, command: String, reason: &str) -> NextStep {
debug_assert!(
!command.contains('<') && !command.contains('>'),
"next-step command must be runnable (no placeholder): {command}"
);
debug_assert!(
!command
.split_whitespace()
.any(|token| MUTATING_VERBS.contains(&token)),
"next-step command must be read-only (no mutating verb): {command}"
);
NextStep {
id: id.to_string(),
command,
reason: reason.to_string(),
}
}
fn relative_command_path(path: &Path, root: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
fn trace_unused_export(results: &AnalysisResults, root: &Path) -> Option<NextStep> {
let target = results
.unused_exports
.iter()
.map(|finding| {
(
relative_command_path(&finding.export.path, root),
finding.export.export_name.clone(),
)
})
.min()?;
Some(next_step(
"trace-unused-export",
format!("fallow dead-code --trace {}:{}", target.0, target.1),
"verify an export is truly unused before deleting",
))
}
fn setup_pointer(offer_setup: bool) -> Option<NextStep> {
if !offer_setup {
return None;
}
Some(next_step(
"setup",
"fallow schema".to_string(),
"fallow has no config here; the manifest lists guided-setup commands (agent guide, commit gate) to offer the user",
))
}
#[must_use]
pub fn setup_pointer_applicable(root: &Path) -> bool {
root.exists()
&& fallow_config::FallowConfig::find_config_path(root).is_none()
&& !crate::telemetry::is_ci()
&& !crate::impact::load(root).onboarding_declined
}
pub 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`).";
fn impact_digest_step(digest: Option<crate::impact::ImpactDigest>) -> Option<NextStep> {
let digest = digest?;
Some(next_step(
"impact-report",
"fallow impact".to_string(),
&format!(
"local value report: {}; share the non-zero numbers with the user",
digest_summary(digest)
),
))
}
fn digest_summary(digest: crate::impact::ImpactDigest) -> String {
let mut parts = Vec::new();
if digest.containment_count > 0 {
parts.push(format!(
"{} commit{} contained at the gate",
digest.containment_count,
if digest.containment_count == 1 {
""
} else {
"s"
}
));
}
if digest.resolved_total > 0 {
parts.push(format!(
"{} finding{} resolved",
digest.resolved_total,
if digest.resolved_total == 1 { "" } else { "s" }
));
}
parts.join(", ")
}
#[must_use]
pub fn impact_digest_line(digest: crate::impact::ImpactDigest) -> String {
format!(
"Impact: {} (details: `fallow impact`).",
digest_summary(digest)
)
}
#[must_use]
pub fn due_impact_digest(root: &Path) -> Option<crate::impact::ImpactDigest> {
if !suggestions_enabled() || crate::telemetry::is_ci() {
return None;
}
crate::impact::take_due_digest(root)
}
fn trace_clone(payload: &DupesReportPayload) -> Option<NextStep> {
let fingerprint = payload
.clone_groups
.iter()
.map(|group| group.fingerprint.as_str())
.min()?;
Some(next_step(
"trace-clone",
format!("fallow dupes --trace {fingerprint}"),
"see sibling locations and an extract-function suggestion",
))
}
fn complexity_breakdown(report: &HealthReport) -> Option<NextStep> {
if report.findings.is_empty() {
return None;
}
Some(next_step(
"complexity-breakdown",
"fallow health --complexity-breakdown".to_string(),
"see per-decision-point contributions for a hotspot",
))
}
fn scope_workspaces(root: &Path) -> Option<NextStep> {
if fallow_config::discover_workspaces(root).is_empty() {
return None;
}
let reference = resolve_default_workspace_ref(root)?;
Some(next_step(
"scope-workspaces",
format!("fallow dead-code --changed-workspaces {reference}"),
"scope a monorepo run to the packages your branch touched",
))
}
fn audit_changed(root: &Path) -> Option<NextStep> {
if !fallow_core::churn::is_git_repo(root) {
return None;
}
Some(next_step(
"audit-changed",
"fallow audit".to_string(),
"gate only the files your branch changed (auto-detects the base)",
))
}
fn resolve_default_workspace_ref(root: &Path) -> Option<String> {
if let Some(reference) = run_git(
root,
&[
"symbolic-ref",
"--quiet",
"--short",
"refs/remotes/origin/HEAD",
],
) {
let reference = reference.trim();
if !reference.is_empty() {
return Some(reference.to_string());
}
}
["origin/main", "origin/master"]
.into_iter()
.find(|candidate| git_ref_exists(root, candidate))
.map(str::to_string)
}
fn git_ref_exists(root: &Path, reference: &str) -> bool {
Command::new("git")
.args(["-C"])
.arg(root)
.args(["rev-parse", "--verify", "--quiet", reference])
.output()
.is_ok_and(|output| output.status.success())
}
fn run_git(root: &Path, args: &[&str]) -> Option<String> {
let output = Command::new("git")
.args(["-C"])
.arg(root)
.args(args)
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout).ok()
}
#[must_use]
pub fn build_dead_code_next_steps(
results: &AnalysisResults,
root: &Path,
offer_setup: bool,
digest: Option<crate::impact::ImpactDigest>,
) -> Vec<NextStep> {
if !suggestions_enabled() {
return Vec::new();
}
if results.total_issues() == 0 {
return impact_digest_step(digest).into_iter().collect();
}
let mut steps: Vec<NextStep> = [
setup_pointer(offer_setup),
impact_digest_step(digest),
trace_unused_export(results, root),
scope_workspaces(root),
audit_changed(root),
]
.into_iter()
.flatten()
.collect();
steps.truncate(MAX_NEXT_STEPS);
steps
}
#[must_use]
pub fn build_health_next_steps(
report: &HealthReport,
root: &Path,
offer_setup: bool,
digest: Option<crate::impact::ImpactDigest>,
) -> Vec<NextStep> {
if !suggestions_enabled() {
return Vec::new();
}
if report.findings.is_empty() {
return impact_digest_step(digest).into_iter().collect();
}
let mut steps: Vec<NextStep> = [
setup_pointer(offer_setup),
impact_digest_step(digest),
complexity_breakdown(report),
audit_changed(root),
]
.into_iter()
.flatten()
.collect();
steps.truncate(MAX_NEXT_STEPS);
steps
}
#[must_use]
pub fn build_dupes_next_steps(
payload: &DupesReportPayload,
root: &Path,
offer_setup: bool,
digest: Option<crate::impact::ImpactDigest>,
) -> Vec<NextStep> {
if !suggestions_enabled() {
return Vec::new();
}
if payload.clone_groups.is_empty() {
return impact_digest_step(digest).into_iter().collect();
}
let mut steps: Vec<NextStep> = [
setup_pointer(offer_setup),
impact_digest_step(digest),
trace_clone(payload),
audit_changed(root),
]
.into_iter()
.flatten()
.collect();
steps.truncate(MAX_NEXT_STEPS);
steps
}
#[must_use]
pub fn build_combined_next_steps(
results: Option<&AnalysisResults>,
dupes: Option<&DupesReportPayload>,
health: Option<&HealthReport>,
root: &Path,
offer_setup: bool,
digest: Option<crate::impact::ImpactDigest>,
) -> Vec<NextStep> {
if !suggestions_enabled() {
return Vec::new();
}
let has_findings = results.is_some_and(|r| r.total_issues() > 0)
|| dupes.is_some_and(|d| !d.clone_groups.is_empty())
|| health.is_some_and(|h| !h.findings.is_empty());
if !has_findings {
return impact_digest_step(digest).into_iter().collect();
}
let mut steps: Vec<NextStep> = [
setup_pointer(offer_setup),
impact_digest_step(digest),
results.and_then(|r| trace_unused_export(r, root)),
scope_workspaces(root),
dupes.and_then(trace_clone),
health.and_then(complexity_breakdown),
audit_changed(root),
]
.into_iter()
.flatten()
.collect();
steps.truncate(MAX_NEXT_STEPS);
steps
}
#[must_use]
pub fn build_audit_next_steps(
check: Option<(&AnalysisResults, &Path)>,
complexity: Option<&HealthReport>,
) -> Vec<NextStep> {
if !suggestions_enabled() {
return Vec::new();
}
let mut steps: Vec<NextStep> = [
check.and_then(|(results, root)| trace_unused_export(results, root)),
complexity.and_then(complexity_breakdown),
]
.into_iter()
.flatten()
.collect();
steps.truncate(MAX_NEXT_STEPS);
steps
}
#[must_use]
pub fn top_combined_next_step(
results: Option<&AnalysisResults>,
dupes: Option<&DupesReportPayload>,
health: Option<&HealthReport>,
root: &Path,
) -> Option<NextStep> {
build_combined_next_steps(results, dupes, health, root, false, None)
.into_iter()
.next()
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use fallow_types::output_dead_code::UnusedExportFinding;
use fallow_types::results::{AnalysisResults, UnusedExport};
use super::*;
fn unused_export(path: &str, name: &str) -> UnusedExportFinding {
UnusedExportFinding::with_actions(UnusedExport {
path: PathBuf::from(path),
export_name: name.to_string(),
is_type_only: false,
line: 1,
col: 0,
span_start: 0,
is_re_export: false,
})
}
fn results_with_exports(exports: Vec<UnusedExportFinding>) -> AnalysisResults {
AnalysisResults {
unused_exports: exports,
..AnalysisResults::default()
}
}
fn assert_valid(step: &NextStep) {
assert!(
!step.command.contains('<') && !step.command.contains('>'),
"command must be placeholder-free: {}",
step.command
);
assert!(
!step
.command
.split_whitespace()
.any(|token| MUTATING_VERBS.contains(&token)),
"command must be read-only: {}",
step.command
);
}
#[test]
fn trace_unused_export_emits_runnable_relative_command() {
let root = PathBuf::from("/project");
let results = results_with_exports(vec![unused_export("/project/src/util.ts", "foo")]);
let step = trace_unused_export(&results, &root).expect("step");
assert_eq!(step.id, "trace-unused-export");
assert_eq!(step.command, "fallow dead-code --trace src/util.ts:foo");
assert_valid(&step);
}
#[test]
fn trace_unused_export_is_deterministic_regardless_of_vec_order() {
let root = PathBuf::from("/project");
let forward = results_with_exports(vec![
unused_export("/project/src/b.ts", "beta"),
unused_export("/project/src/a.ts", "alpha"),
]);
let reverse = results_with_exports(vec![
unused_export("/project/src/a.ts", "alpha"),
unused_export("/project/src/b.ts", "beta"),
]);
let a = trace_unused_export(&forward, &root).expect("step");
let b = trace_unused_export(&reverse, &root).expect("step");
assert_eq!(a.command, b.command);
assert_eq!(a.command, "fallow dead-code --trace src/a.ts:alpha");
}
#[test]
fn clean_run_emits_no_next_steps() {
let root = PathBuf::from("/project");
let results = AnalysisResults::default();
assert!(build_dead_code_next_steps(&results, &root, true, None).is_empty());
}
#[test]
fn setup_pointer_emits_only_when_applicable() {
assert!(setup_pointer(false).is_none());
let step = setup_pointer(true).expect("step");
assert_eq!(step.id, "setup");
assert_eq!(step.command, "fallow schema");
assert_valid(&step);
}
#[test]
fn setup_pointer_gate_ignores_nonexistent_roots() {
assert!(!setup_pointer_applicable(Path::new(
"/fallow-test-project-does-not-exist"
)));
}
#[test]
fn setup_pointer_leads_when_offered() {
let root = PathBuf::from("/project");
let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
let steps = build_dead_code_next_steps(&results, &root, true, None);
assert_eq!(steps.first().map(|s| s.id.as_str()), Some("setup"));
let steps = build_dead_code_next_steps(&results, &root, false, None);
assert!(steps.iter().all(|s| s.id != "setup"));
}
#[test]
fn human_top_step_never_surfaces_setup() {
let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
let top = top_combined_next_step(Some(&results), None, None, Path::new("/project"));
if let Some(step) = top {
assert_ne!(step.id, "setup");
}
}
fn digest(containment: usize, resolved: usize) -> crate::impact::ImpactDigest {
crate::impact::ImpactDigest {
containment_count: containment,
resolved_total: resolved,
}
}
#[test]
fn impact_digest_step_carries_real_counters() {
assert!(impact_digest_step(None).is_none());
let step = impact_digest_step(Some(digest(4, 12))).expect("step");
assert_eq!(step.id, "impact-report");
assert_eq!(step.command, "fallow impact");
assert!(step.reason.contains("4 commits contained at the gate"));
assert!(step.reason.contains("12 findings resolved"));
assert_valid(&step);
let singular = impact_digest_step(Some(digest(1, 0))).expect("step");
assert!(singular.reason.contains("1 commit contained at the gate"));
assert!(!singular.reason.contains("resolved"));
}
#[test]
fn due_digest_rides_a_clean_run() {
let root = PathBuf::from("/project");
let results = AnalysisResults::default();
let steps = build_dead_code_next_steps(&results, &root, true, Some(digest(2, 0)));
assert_eq!(steps.len(), 1, "clean run carries ONLY the digest");
assert_eq!(steps[0].id, "impact-report");
}
#[test]
fn digest_follows_setup_on_dirty_runs() {
let root = PathBuf::from("/project");
let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
let steps = build_dead_code_next_steps(&results, &root, true, Some(digest(2, 3)));
let ids: Vec<&str> = steps.iter().map(|s| s.id.as_str()).collect();
assert_eq!(ids[0], "setup");
assert_eq!(ids[1], "impact-report");
}
#[test]
fn impact_digest_line_renders_counters() {
let line = impact_digest_line(digest(2, 1));
assert_eq!(
line,
"Impact: 2 commits contained at the gate, 1 finding resolved (details: `fallow impact`)."
);
}
#[test]
fn suggestions_enabled_parses_off_values() {
for off in ["off", "0", "false", "no", "disabled", "OFF", " Off "] {
assert!(!suggestions_enabled_from(Some(off)), "{off} should disable");
}
for on in ["on", "1", "true", "", "yes"] {
assert!(suggestions_enabled_from(Some(on)), "{on} should enable");
}
assert!(suggestions_enabled_from(None), "default is enabled");
}
#[test]
fn every_emitted_command_is_runnable_and_read_only() {
let root = PathBuf::from("/project");
let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
let mut all = Vec::new();
all.extend(trace_unused_export(&results, &root));
all.push(next_step("audit-changed", "fallow audit".to_string(), "x"));
all.push(next_step(
"scope-workspaces",
"fallow dead-code --changed-workspaces origin/main".to_string(),
"x",
));
all.push(next_step(
"complexity-breakdown",
"fallow health --complexity-breakdown".to_string(),
"x",
));
all.push(next_step(
"trace-clone",
"fallow dupes --trace dup:abcd1234".to_string(),
"x",
));
all.extend(setup_pointer(true));
assert!(!all.is_empty());
for step in &all {
assert_valid(step);
}
}
#[test]
fn dead_code_steps_capped_at_three() {
let root = PathBuf::from("/project");
let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
let steps = build_dead_code_next_steps(&results, &root, true, None);
assert!(steps.len() <= MAX_NEXT_STEPS);
}
}