1use std::path::Path;
8use std::process::Command;
9
10use fallow_api::DupesReportPayload;
11use fallow_output::{
12 CombinedNextStepsInput, DeadCodeNextStepsInput, DupesNextStepsInput, HealthNextStepsInput,
13 ImpactDigestCounts, build_combined_next_steps as build_combined_next_steps_contract,
14 build_dead_code_next_steps as build_dead_code_next_steps_contract,
15 build_dupes_next_steps as build_dupes_next_steps_contract, impact_digest_summary,
16 trace_unused_export_input,
17};
18use fallow_types::output::NextStep;
19use fallow_types::results::AnalysisResults;
20
21use fallow_output::HealthReport;
22
23#[must_use]
28pub fn suggestions_enabled() -> bool {
29 suggestions_enabled_from(std::env::var("FALLOW_SUGGESTIONS").ok().as_deref())
30}
31
32#[must_use]
35fn suggestions_enabled_from(value: Option<&str>) -> bool {
36 match value {
37 Some(raw) => !matches!(
38 raw.trim().to_ascii_lowercase().as_str(),
39 "off" | "0" | "false" | "no" | "disabled"
40 ),
41 None => true,
42 }
43}
44
45#[must_use]
50pub fn setup_pointer_applicable(root: &Path) -> bool {
51 root.exists()
52 && fallow_config::FallowConfig::find_config_path(root).is_none()
53 && !crate::telemetry::is_ci()
54 && !crate::impact::load(root).onboarding_declined
55}
56
57pub 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`).";
61
62pub fn impact_counts(digest: crate::impact::ImpactDigest) -> ImpactDigestCounts {
65 ImpactDigestCounts {
66 containment_count: digest.containment_count,
67 resolved_total: digest.resolved_total,
68 }
69}
70
71fn digest_summary(digest: crate::impact::ImpactDigest) -> String {
72 impact_digest_summary(impact_counts(digest))
73}
74
75#[must_use]
78pub fn impact_digest_line(digest: crate::impact::ImpactDigest) -> String {
79 format!(
80 "Impact: {} (details: `fallow impact`).",
81 digest_summary(digest)
82 )
83}
84
85#[must_use]
90pub fn due_impact_digest(root: &Path) -> Option<crate::impact::ImpactDigest> {
91 if !suggestions_enabled() || crate::telemetry::is_ci() {
92 return None;
93 }
94 crate::impact::take_due_digest(root)
95}
96
97fn default_workspace_ref_for_next_step(root: &Path) -> Option<String> {
98 if fallow_config::discover_workspaces(root).is_empty() {
99 return None;
100 }
101 resolve_default_workspace_ref(root)
102}
103
104pub fn audit_changed_applicable(root: &Path) -> bool {
107 fallow_engine::churn::is_git_repo(root)
108}
109
110fn resolve_default_workspace_ref(root: &Path) -> Option<String> {
119 if let Some(reference) = run_git(
120 root,
121 &[
122 "symbolic-ref",
123 "--quiet",
124 "--short",
125 "refs/remotes/origin/HEAD",
126 ],
127 ) {
128 let reference = reference.trim();
129 if !reference.is_empty() {
130 return Some(reference.to_string());
131 }
132 }
133 ["origin/main", "origin/master"]
134 .into_iter()
135 .find(|candidate| git_ref_exists(root, candidate))
136 .map(str::to_string)
137}
138
139fn git_ref_exists(root: &Path, reference: &str) -> bool {
140 Command::new("git")
141 .args(["-C"])
142 .arg(root)
143 .args(["rev-parse", "--verify", "--quiet", reference])
144 .output()
145 .is_ok_and(|output| output.status.success())
146}
147
148fn run_git(root: &Path, args: &[&str]) -> Option<String> {
149 let output = Command::new("git")
150 .args(["-C"])
151 .arg(root)
152 .args(args)
153 .output()
154 .ok()?;
155 if !output.status.success() {
156 return None;
157 }
158 String::from_utf8(output.stdout).ok()
159}
160
161#[must_use]
171pub fn build_dead_code_next_steps(
172 results: &AnalysisResults,
173 root: &Path,
174 offer_setup: bool,
175 digest: Option<crate::impact::ImpactDigest>,
176) -> Vec<NextStep> {
177 let workspace_ref = default_workspace_ref_for_next_step(root);
178 build_dead_code_next_steps_contract(DeadCodeNextStepsInput {
179 suggestions_enabled: suggestions_enabled(),
180 results,
181 root,
182 offer_setup,
183 impact_digest: digest.map(impact_counts),
184 workspace_ref: workspace_ref.as_deref(),
185 audit_changed: audit_changed_applicable(root),
186 })
187}
188
189#[must_use]
192pub fn health_next_steps_input(
193 report: &HealthReport,
194 root: &Path,
195 offer_setup: bool,
196 digest: Option<crate::impact::ImpactDigest>,
197) -> HealthNextStepsInput {
198 fallow_output::build_health_next_steps_input(
199 report,
200 suggestions_enabled(),
201 offer_setup,
202 digest.map(impact_counts),
203 audit_changed_applicable(root),
204 )
205}
206
207#[must_use]
210pub fn build_dupes_next_steps(
211 payload: &DupesReportPayload,
212 root: &Path,
213 offer_setup: bool,
214 digest: Option<crate::impact::ImpactDigest>,
215) -> Vec<NextStep> {
216 let clone_fingerprints = payload
217 .clone_groups
218 .iter()
219 .map(|group| group.fingerprint.as_str())
220 .collect::<Vec<_>>();
221 build_dupes_next_steps_contract(DupesNextStepsInput {
222 suggestions_enabled: suggestions_enabled(),
223 clone_fingerprints: &clone_fingerprints,
224 offer_setup,
225 impact_digest: digest.map(impact_counts),
226 audit_changed: audit_changed_applicable(root),
227 })
228}
229
230#[must_use]
237pub fn build_combined_next_steps(
238 results: Option<&AnalysisResults>,
239 dupes: Option<&DupesReportPayload>,
240 health: Option<&HealthReport>,
241 root: &Path,
242 offer_setup: bool,
243 digest: Option<crate::impact::ImpactDigest>,
244) -> Vec<NextStep> {
245 let workspace_ref = default_workspace_ref_for_next_step(root);
246 let clone_fingerprints = dupes
247 .map(|payload| {
248 payload
249 .clone_groups
250 .iter()
251 .map(|group| group.fingerprint.as_str())
252 .collect::<Vec<_>>()
253 })
254 .unwrap_or_default();
255 build_combined_next_steps_contract(&CombinedNextStepsInput {
256 suggestions_enabled: suggestions_enabled(),
257 has_dead_code_findings: results.is_some_and(|r| r.total_issues() > 0),
258 trace_unused_export: results.and_then(|r| trace_unused_export_input(r, root)),
259 workspace_ref: workspace_ref.as_deref(),
260 clone_fingerprints: &clone_fingerprints,
261 has_complexity_findings: health.is_some_and(|h| !h.findings.is_empty()),
262 offer_setup,
263 impact_digest: digest.map(impact_counts),
264 audit_changed: audit_changed_applicable(root),
265 })
266}
267
268#[must_use]
274#[cfg(test)]
275pub fn build_audit_next_steps(
276 check: Option<(&AnalysisResults, &Path)>,
277 complexity: Option<&HealthReport>,
278) -> Vec<NextStep> {
279 fallow_output::build_audit_next_steps(&fallow_output::build_audit_next_steps_input(
280 check,
281 complexity,
282 suggestions_enabled(),
283 ))
284}
285
286#[must_use]
293pub fn top_combined_next_step(
294 results: Option<&AnalysisResults>,
295 dupes: Option<&DupesReportPayload>,
296 health: Option<&HealthReport>,
297 root: &Path,
298) -> Option<NextStep> {
299 build_combined_next_steps(results, dupes, health, root, false, None)
300 .into_iter()
301 .next()
302}
303
304#[cfg(test)]
305mod tests {
306 use std::path::PathBuf;
307
308 use fallow_engine::duplicates::{
309 CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
310 };
311 use fallow_output::build_health_next_steps as build_health_next_steps_contract;
312 use fallow_output::{
313 ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding, HealthReport,
314 };
315 use fallow_types::output_dead_code::UnusedExportFinding;
316 use fallow_types::results::{AnalysisResults, UnusedExport};
317
318 use super::*;
319
320 fn unused_export(path: &str, name: &str) -> UnusedExportFinding {
321 UnusedExportFinding::with_actions(UnusedExport {
322 path: PathBuf::from(path),
323 export_name: name.to_string(),
324 is_type_only: false,
325 line: 1,
326 col: 0,
327 span_start: 0,
328 is_re_export: false,
329 })
330 }
331
332 fn results_with_exports(exports: Vec<UnusedExportFinding>) -> AnalysisResults {
333 AnalysisResults {
334 unused_exports: exports,
335 ..AnalysisResults::default()
336 }
337 }
338
339 fn clone_instance(path: &str, fragment: &str) -> CloneInstance {
340 CloneInstance {
341 file: PathBuf::from(path),
342 start_line: 1,
343 end_line: 8,
344 start_col: 0,
345 end_col: 0,
346 fragment: fragment.to_string(),
347 }
348 }
349
350 fn dupes_payload() -> DupesReportPayload {
351 let group = CloneGroup {
352 instances: vec![
353 clone_instance("/project/src/a.ts", "export const shared = 1;"),
354 clone_instance("/project/src/b.ts", "export const shared = 1;"),
355 ],
356 token_count: 20,
357 line_count: 8,
358 };
359 DupesReportPayload::from_report(&DuplicationReport {
360 clone_groups: vec![group],
361 clone_families: Vec::new(),
362 mirrored_directories: Vec::new(),
363 stats: DuplicationStats::default(),
364 })
365 }
366
367 fn health_report_with_finding() -> HealthReport {
368 HealthReport {
369 findings: vec![HealthFinding::from(ComplexityViolation {
370 path: PathBuf::from("/project/src/hot.ts"),
371 name: "hot".to_string(),
372 line: 1,
373 col: 0,
374 cyclomatic: 21,
375 cognitive: 16,
376 line_count: 42,
377 param_count: 0,
378 react_hook_count: 0,
379 react_jsx_max_depth: 0,
380 react_prop_count: 0,
381 react_hook_profile: None,
382 exceeded: ExceededThreshold::Both,
383 severity: FindingSeverity::High,
384 crap: None,
385 coverage_pct: None,
386 coverage_tier: None,
387 coverage_source: None,
388 inherited_from: None,
389 component_rollup: None,
390 contributions: Vec::new(),
391 effective_thresholds: None,
392 threshold_source: None,
393 })],
394 ..HealthReport::default()
395 }
396 }
397
398 fn assert_valid(step: &NextStep) {
399 const MUTATING_VERBS: [&str; 5] = ["fix", "init", "hooks", "migrate", "setup-hooks"];
400
401 assert!(
402 !step.command.contains('<') && !step.command.contains('>'),
403 "command must be placeholder-free: {}",
404 step.command
405 );
406 assert!(
407 !step
408 .command
409 .split_whitespace()
410 .any(|token| MUTATING_VERBS.contains(&token)),
411 "command must be read-only: {}",
412 step.command
413 );
414 }
415
416 #[test]
417 fn audit_next_steps_emit_runnable_relative_trace_command() {
418 let root = PathBuf::from("/project");
419 let results = results_with_exports(vec![unused_export("/project/src/util.ts", "foo")]);
420 let steps = build_audit_next_steps(Some((&results, &root)), None);
421
422 assert_eq!(steps.len(), 1);
423 assert_eq!(steps[0].id, "trace-unused-export");
424 assert_eq!(steps[0].command, "fallow dead-code --trace src/util.ts:foo");
425 assert_valid(&steps[0]);
426 }
427
428 #[test]
429 fn audit_next_steps_select_deterministic_trace_target() {
430 let root = PathBuf::from("/project");
431 let forward = results_with_exports(vec![
432 unused_export("/project/src/b.ts", "beta"),
433 unused_export("/project/src/a.ts", "alpha"),
434 ]);
435 let reverse = results_with_exports(vec![
436 unused_export("/project/src/a.ts", "alpha"),
437 unused_export("/project/src/b.ts", "beta"),
438 ]);
439 let a = build_audit_next_steps(Some((&forward, &root)), None);
440 let b = build_audit_next_steps(Some((&reverse, &root)), None);
441 assert_eq!(a[0].command, b[0].command);
442 assert_eq!(a[0].command, "fallow dead-code --trace src/a.ts:alpha");
443 }
444
445 #[test]
446 fn clean_run_emits_no_next_steps() {
447 let root = PathBuf::from("/project");
448 let results = AnalysisResults::default();
449 assert!(build_dead_code_next_steps(&results, &root, true, None).is_empty());
450 }
451
452 #[test]
453 fn setup_pointer_gate_ignores_nonexistent_roots() {
454 assert!(!setup_pointer_applicable(Path::new(
455 "/fallow-test-project-does-not-exist"
456 )));
457 }
458
459 #[test]
460 fn setup_pointer_leads_when_offered() {
461 let root = PathBuf::from("/project");
462 let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
463 let steps = build_dead_code_next_steps(&results, &root, true, None);
464 assert_eq!(steps.first().map(|s| s.id.as_str()), Some("setup"));
465 let steps = build_dead_code_next_steps(&results, &root, false, None);
466 assert!(steps.iter().all(|s| s.id != "setup"));
467 }
468
469 #[test]
470 fn human_top_step_never_surfaces_setup() {
471 let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
472 let top = top_combined_next_step(Some(&results), None, None, Path::new("/project"));
473 if let Some(step) = top {
474 assert_ne!(step.id, "setup");
475 }
476 }
477
478 fn digest(containment: usize, resolved: usize) -> crate::impact::ImpactDigest {
479 crate::impact::ImpactDigest {
480 containment_count: containment,
481 resolved_total: resolved,
482 }
483 }
484
485 #[test]
486 fn due_digest_rides_a_clean_run() {
487 let root = PathBuf::from("/project");
488 let results = AnalysisResults::default();
489 let steps = build_dead_code_next_steps(&results, &root, true, Some(digest(2, 0)));
490 assert_eq!(steps.len(), 1, "clean run carries ONLY the digest");
491 assert_eq!(steps[0].id, "impact-report");
492 }
493
494 #[test]
495 fn digest_follows_setup_on_dirty_runs() {
496 let root = PathBuf::from("/project");
497 let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
498 let steps = build_dead_code_next_steps(&results, &root, true, Some(digest(2, 3)));
499 let ids: Vec<&str> = steps.iter().map(|s| s.id.as_str()).collect();
500 assert_eq!(ids[0], "setup");
501 assert_eq!(ids[1], "impact-report");
502 }
503
504 #[test]
505 fn health_steps_keep_complexity_breakdown_from_output_contract() {
506 let report = health_report_with_finding();
507 let steps = build_health_next_steps_contract(health_next_steps_input(
508 &report,
509 Path::new("/project"),
510 false,
511 None,
512 ));
513 let ids: Vec<&str> = steps.iter().map(|s| s.id.as_str()).collect();
514
515 assert_eq!(ids, ["complexity-breakdown"]);
516 assert_valid(&steps[0]);
517 }
518
519 #[test]
520 fn health_next_steps_input_feeds_output_contract_builder() {
521 let report = health_report_with_finding();
522 let input =
523 health_next_steps_input(&report, Path::new("/project"), true, Some(digest(2, 1)));
524
525 assert!(input.suggestions_enabled);
526 assert!(input.has_findings);
527 assert!(input.offer_setup);
528 assert_eq!(
529 input.impact_digest,
530 Some(ImpactDigestCounts {
531 containment_count: 2,
532 resolved_total: 1,
533 })
534 );
535
536 let steps = build_health_next_steps_contract(input);
537 let ids: Vec<&str> = steps.iter().map(|s| s.id.as_str()).collect();
538 assert_eq!(ids, ["setup", "impact-report", "complexity-breakdown"]);
539 }
540
541 #[test]
542 fn dupes_next_steps_route_payload_fingerprints_to_output_contract() {
543 let payload = dupes_payload();
544
545 let steps = build_dupes_next_steps(&payload, Path::new("/project"), false, None);
546
547 assert_eq!(steps.len(), 1);
548 assert_eq!(steps[0].id, "trace-clone");
549 assert!(steps[0].command.starts_with("fallow dupes --trace dup:"));
550 assert_valid(&steps[0]);
551 }
552
553 #[test]
554 fn combined_next_steps_route_payload_facts_to_output_contract() {
555 let root = PathBuf::from("/project");
556 let results = results_with_exports(vec![
557 unused_export("/project/src/b.ts", "beta"),
558 unused_export("/project/src/a.ts", "alpha"),
559 ]);
560 let payload = dupes_payload();
561 let report = health_report_with_finding();
562
563 let steps = build_combined_next_steps(
564 Some(&results),
565 Some(&payload),
566 Some(&report),
567 &root,
568 true,
569 Some(digest(2, 1)),
570 );
571 let ids: Vec<&str> = steps.iter().map(|s| s.id.as_str()).collect();
572
573 assert_eq!(ids, ["setup", "impact-report", "trace-unused-export"]);
574 assert_eq!(steps[2].command, "fallow dead-code --trace src/a.ts:alpha");
575 for step in &steps {
576 assert_valid(step);
577 }
578 }
579
580 #[test]
581 fn audit_next_steps_route_payload_facts_to_output_contract() {
582 let root = PathBuf::from("/project");
583 let results = results_with_exports(vec![
584 unused_export("/project/src/b.ts", "beta"),
585 unused_export("/project/src/a.ts", "alpha"),
586 ]);
587 let report = health_report_with_finding();
588
589 let steps = build_audit_next_steps(Some((&results, &root)), Some(&report));
590 let ids: Vec<&str> = steps.iter().map(|s| s.id.as_str()).collect();
591
592 assert_eq!(ids, ["trace-unused-export", "complexity-breakdown"]);
593 assert_eq!(steps[0].command, "fallow dead-code --trace src/a.ts:alpha");
594 for step in &steps {
595 assert_valid(step);
596 }
597 }
598
599 #[test]
600 fn impact_digest_line_renders_counters() {
601 let line = impact_digest_line(digest(2, 1));
602 assert_eq!(
603 line,
604 "Impact: 2 commits contained at the gate, 1 finding resolved (details: `fallow impact`)."
605 );
606 }
607
608 #[test]
609 fn suggestions_enabled_parses_off_values() {
610 for off in ["off", "0", "false", "no", "disabled", "OFF", " Off "] {
611 assert!(!suggestions_enabled_from(Some(off)), "{off} should disable");
612 }
613 for on in ["on", "1", "true", "", "yes"] {
614 assert!(suggestions_enabled_from(Some(on)), "{on} should enable");
615 }
616 assert!(suggestions_enabled_from(None), "default is enabled");
617 }
618
619 #[test]
620 fn every_emitted_command_is_runnable_and_read_only() {
621 let root = PathBuf::from("/project");
623 let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
624 let payload = dupes_payload();
625 let report = health_report_with_finding();
626 let mut all = Vec::new();
627 all.extend(build_audit_next_steps(Some((&results, &root)), None));
628 all.extend(build_dead_code_next_steps(
629 &results,
630 &root,
631 true,
632 Some(digest(2, 1)),
633 ));
634 all.extend(build_dupes_next_steps(&payload, &root, false, None));
635 all.extend(build_health_next_steps_contract(health_next_steps_input(
636 &report, &root, false, None,
637 )));
638 all.extend(build_combined_next_steps(
639 Some(&results),
640 Some(&payload),
641 Some(&report),
642 &root,
643 true,
644 Some(digest(2, 1)),
645 ));
646 assert!(!all.is_empty());
647 for step in &all {
648 assert_valid(step);
649 }
650 }
651
652 #[test]
653 fn dead_code_steps_capped_at_three() {
654 let root = PathBuf::from("/project");
655 let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
656 let steps = build_dead_code_next_steps(&results, &root, true, None);
658 assert!(steps.len() <= 3);
659 }
660}