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