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