1use 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
29const MAX_NEXT_STEPS: usize = 3;
32
33const MUTATING_VERBS: [&str; 5] = ["fix", "init", "hooks", "migrate", "setup-hooks"];
35
36#[must_use]
41pub fn suggestions_enabled() -> bool {
42 suggestions_enabled_from(std::env::var("FALLOW_SUGGESTIONS").ok().as_deref())
43}
44
45#[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
58fn 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
78fn 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
87fn 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
112fn trace_clone(payload: &DupesReportPayload) -> Option<NextStep> {
115 let fingerprint = payload
116 .clone_groups
117 .iter()
118 .map(|group| group.fingerprint.as_str())
119 .min()?;
120 Some(next_step(
121 "trace-clone",
122 format!("fallow dupes --trace {fingerprint}"),
123 "see sibling locations and an extract-function suggestion",
124 ))
125}
126
127fn complexity_breakdown(report: &HealthReport) -> Option<NextStep> {
130 if report.findings.is_empty() {
131 return None;
132 }
133 Some(next_step(
134 "complexity-breakdown",
135 "fallow health --complexity-breakdown".to_string(),
136 "see per-decision-point contributions for a hotspot",
137 ))
138}
139
140fn scope_workspaces(root: &Path) -> Option<NextStep> {
144 if fallow_config::discover_workspaces(root).is_empty() {
145 return None;
146 }
147 let reference = resolve_default_workspace_ref(root)?;
148 Some(next_step(
149 "scope-workspaces",
150 format!("fallow dead-code --changed-workspaces {reference}"),
151 "scope a monorepo run to the packages your branch touched",
152 ))
153}
154
155fn audit_changed(root: &Path) -> Option<NextStep> {
158 if !fallow_core::churn::is_git_repo(root) {
159 return None;
160 }
161 Some(next_step(
162 "audit-changed",
163 "fallow audit".to_string(),
164 "gate only the files your branch changed (auto-detects the base)",
165 ))
166}
167
168fn resolve_default_workspace_ref(root: &Path) -> Option<String> {
177 if let Some(reference) = run_git(
178 root,
179 &[
180 "symbolic-ref",
181 "--quiet",
182 "--short",
183 "refs/remotes/origin/HEAD",
184 ],
185 ) {
186 let reference = reference.trim();
187 if !reference.is_empty() {
188 return Some(reference.to_string());
189 }
190 }
191 ["origin/main", "origin/master"]
192 .into_iter()
193 .find(|candidate| git_ref_exists(root, candidate))
194 .map(str::to_string)
195}
196
197fn git_ref_exists(root: &Path, reference: &str) -> bool {
198 Command::new("git")
199 .args(["-C"])
200 .arg(root)
201 .args(["rev-parse", "--verify", "--quiet", reference])
202 .output()
203 .is_ok_and(|output| output.status.success())
204}
205
206fn run_git(root: &Path, args: &[&str]) -> Option<String> {
207 let output = Command::new("git")
208 .args(["-C"])
209 .arg(root)
210 .args(args)
211 .output()
212 .ok()?;
213 if !output.status.success() {
214 return None;
215 }
216 String::from_utf8(output.stdout).ok()
217}
218
219#[must_use]
226pub fn build_dead_code_next_steps(results: &AnalysisResults, root: &Path) -> Vec<NextStep> {
227 if !suggestions_enabled() || results.total_issues() == 0 {
228 return Vec::new();
229 }
230 let mut steps: Vec<NextStep> = [
231 trace_unused_export(results, root),
232 scope_workspaces(root),
233 audit_changed(root),
234 ]
235 .into_iter()
236 .flatten()
237 .collect();
238 steps.truncate(MAX_NEXT_STEPS);
239 steps
240}
241
242#[must_use]
244pub fn build_health_next_steps(report: &HealthReport, root: &Path) -> Vec<NextStep> {
245 if !suggestions_enabled() || report.findings.is_empty() {
246 return Vec::new();
247 }
248 let mut steps: Vec<NextStep> = [complexity_breakdown(report), audit_changed(root)]
249 .into_iter()
250 .flatten()
251 .collect();
252 steps.truncate(MAX_NEXT_STEPS);
253 steps
254}
255
256#[must_use]
258pub fn build_dupes_next_steps(payload: &DupesReportPayload, root: &Path) -> Vec<NextStep> {
259 if !suggestions_enabled() || payload.clone_groups.is_empty() {
260 return Vec::new();
261 }
262 let mut steps: Vec<NextStep> = [trace_clone(payload), audit_changed(root)]
263 .into_iter()
264 .flatten()
265 .collect();
266 steps.truncate(MAX_NEXT_STEPS);
267 steps
268}
269
270#[must_use]
277pub fn build_combined_next_steps(
278 results: Option<&AnalysisResults>,
279 dupes: Option<&DupesReportPayload>,
280 health: Option<&HealthReport>,
281 root: &Path,
282) -> Vec<NextStep> {
283 if !suggestions_enabled() {
284 return Vec::new();
285 }
286 let has_findings = results.is_some_and(|r| r.total_issues() > 0)
287 || dupes.is_some_and(|d| !d.clone_groups.is_empty())
288 || health.is_some_and(|h| !h.findings.is_empty());
289 if !has_findings {
290 return Vec::new();
291 }
292 let mut steps: Vec<NextStep> = [
293 results.and_then(|r| trace_unused_export(r, root)),
294 scope_workspaces(root),
295 dupes.and_then(trace_clone),
296 health.and_then(complexity_breakdown),
297 audit_changed(root),
298 ]
299 .into_iter()
300 .flatten()
301 .collect();
302 steps.truncate(MAX_NEXT_STEPS);
303 steps
304}
305
306#[must_use]
312pub fn build_audit_next_steps(
313 check: Option<(&AnalysisResults, &Path)>,
314 complexity: Option<&HealthReport>,
315) -> Vec<NextStep> {
316 if !suggestions_enabled() {
317 return Vec::new();
318 }
319 let mut steps: Vec<NextStep> = [
320 check.and_then(|(results, root)| trace_unused_export(results, root)),
321 complexity.and_then(complexity_breakdown),
322 ]
323 .into_iter()
324 .flatten()
325 .collect();
326 steps.truncate(MAX_NEXT_STEPS);
327 steps
328}
329
330#[must_use]
334pub fn top_combined_next_step(
335 results: Option<&AnalysisResults>,
336 dupes: Option<&DupesReportPayload>,
337 health: Option<&HealthReport>,
338 root: &Path,
339) -> Option<NextStep> {
340 build_combined_next_steps(results, dupes, health, root)
341 .into_iter()
342 .next()
343}
344
345#[cfg(test)]
346mod tests {
347 use std::path::PathBuf;
348
349 use fallow_types::output_dead_code::UnusedExportFinding;
350 use fallow_types::results::{AnalysisResults, UnusedExport};
351
352 use super::*;
353
354 fn unused_export(path: &str, name: &str) -> UnusedExportFinding {
355 UnusedExportFinding::with_actions(UnusedExport {
356 path: PathBuf::from(path),
357 export_name: name.to_string(),
358 is_type_only: false,
359 line: 1,
360 col: 0,
361 span_start: 0,
362 is_re_export: false,
363 })
364 }
365
366 fn results_with_exports(exports: Vec<UnusedExportFinding>) -> AnalysisResults {
367 AnalysisResults {
368 unused_exports: exports,
369 ..AnalysisResults::default()
370 }
371 }
372
373 fn assert_valid(step: &NextStep) {
374 assert!(
375 !step.command.contains('<') && !step.command.contains('>'),
376 "command must be placeholder-free: {}",
377 step.command
378 );
379 assert!(
380 !step
381 .command
382 .split_whitespace()
383 .any(|token| MUTATING_VERBS.contains(&token)),
384 "command must be read-only: {}",
385 step.command
386 );
387 }
388
389 #[test]
390 fn trace_unused_export_emits_runnable_relative_command() {
391 let root = PathBuf::from("/project");
392 let results = results_with_exports(vec![unused_export("/project/src/util.ts", "foo")]);
393 let step = trace_unused_export(&results, &root).expect("step");
394 assert_eq!(step.id, "trace-unused-export");
395 assert_eq!(step.command, "fallow dead-code --trace src/util.ts:foo");
396 assert_valid(&step);
397 }
398
399 #[test]
400 fn trace_unused_export_is_deterministic_regardless_of_vec_order() {
401 let root = PathBuf::from("/project");
402 let forward = results_with_exports(vec![
403 unused_export("/project/src/b.ts", "beta"),
404 unused_export("/project/src/a.ts", "alpha"),
405 ]);
406 let reverse = results_with_exports(vec![
407 unused_export("/project/src/a.ts", "alpha"),
408 unused_export("/project/src/b.ts", "beta"),
409 ]);
410 let a = trace_unused_export(&forward, &root).expect("step");
411 let b = trace_unused_export(&reverse, &root).expect("step");
412 assert_eq!(a.command, b.command);
413 assert_eq!(a.command, "fallow dead-code --trace src/a.ts:alpha");
414 }
415
416 #[test]
417 fn clean_run_emits_no_next_steps() {
418 let root = PathBuf::from("/project");
419 let results = AnalysisResults::default();
420 assert!(build_dead_code_next_steps(&results, &root).is_empty());
421 }
422
423 #[test]
424 fn suggestions_enabled_parses_off_values() {
425 for off in ["off", "0", "false", "no", "disabled", "OFF", " Off "] {
426 assert!(!suggestions_enabled_from(Some(off)), "{off} should disable");
427 }
428 for on in ["on", "1", "true", "", "yes"] {
429 assert!(suggestions_enabled_from(Some(on)), "{on} should enable");
430 }
431 assert!(suggestions_enabled_from(None), "default is enabled");
432 }
433
434 #[test]
435 fn every_emitted_command_is_runnable_and_read_only() {
436 let root = PathBuf::from("/project");
438 let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
439 let mut all = Vec::new();
440 all.extend(trace_unused_export(&results, &root));
441 all.push(next_step("audit-changed", "fallow audit".to_string(), "x"));
443 all.push(next_step(
444 "scope-workspaces",
445 "fallow dead-code --changed-workspaces origin/main".to_string(),
446 "x",
447 ));
448 all.push(next_step(
449 "complexity-breakdown",
450 "fallow health --complexity-breakdown".to_string(),
451 "x",
452 ));
453 all.push(next_step(
454 "trace-clone",
455 "fallow dupes --trace dup:abcd1234".to_string(),
456 "x",
457 ));
458 assert!(!all.is_empty());
459 for step in &all {
460 assert_valid(step);
461 }
462 }
463
464 #[test]
465 fn dead_code_steps_capped_at_three() {
466 let root = PathBuf::from("/project");
467 let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
468 let steps = build_dead_code_next_steps(&results, &root);
470 assert!(steps.len() <= MAX_NEXT_STEPS);
471 }
472}