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 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#[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
142pub 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
147fn 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
166fn 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#[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#[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
213fn 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
228fn 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
241fn 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
256fn 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
269fn 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#[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#[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#[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#[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#[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#[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 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 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 let steps = build_dead_code_next_steps(&results, &root, true, None);
704 assert!(steps.len() <= MAX_NEXT_STEPS);
705 }
706}