1use std::path::{Path, PathBuf};
2
3use fallow_config::{EmailMode, OutputFormat};
4use fallow_core::results::AnalysisResults;
5use serde::Serialize;
6
7use crate::check::{CheckOptions, IssueFilters, TraceOptions};
8use crate::dupes::{DupesMode, DupesOptions};
9use crate::health::{HealthOptions, SortBy};
10use crate::health_types::EffortEstimate;
11use crate::report::{build_duplication_json, build_health_json, build_json};
12
13#[derive(Debug, Clone, Serialize)]
15pub struct ProgrammaticError {
16 pub message: String,
17 pub exit_code: u8,
18 pub code: Option<String>,
19 pub help: Option<String>,
20 pub context: Option<String>,
21}
22
23impl ProgrammaticError {
24 #[must_use]
25 pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
26 Self {
27 message: message.into(),
28 exit_code,
29 code: None,
30 help: None,
31 context: None,
32 }
33 }
34
35 #[must_use]
36 pub fn with_help(mut self, help: impl Into<String>) -> Self {
37 self.help = Some(help.into());
38 self
39 }
40
41 #[must_use]
42 pub fn with_code(mut self, code: impl Into<String>) -> Self {
43 self.code = Some(code.into());
44 self
45 }
46
47 #[must_use]
48 pub fn with_context(mut self, context: impl Into<String>) -> Self {
49 self.context = Some(context.into());
50 self
51 }
52}
53
54impl std::fmt::Display for ProgrammaticError {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 write!(f, "{}", self.message)
57 }
58}
59
60impl std::error::Error for ProgrammaticError {}
61
62type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
63
64#[derive(Debug, Clone, Default)]
66pub struct AnalysisOptions {
67 pub root: Option<PathBuf>,
68 pub config_path: Option<PathBuf>,
69 pub no_cache: bool,
70 pub threads: Option<usize>,
71 pub production: bool,
72 pub changed_since: Option<String>,
73 pub workspace: Option<Vec<String>>,
74 pub changed_workspaces: Option<String>,
75 pub explain: bool,
76}
77
78#[derive(Debug, Clone, Default)]
80pub struct DeadCodeFilters {
81 pub unused_files: bool,
82 pub unused_exports: bool,
83 pub unused_deps: bool,
84 pub unused_types: bool,
85 pub unused_enum_members: bool,
86 pub unused_class_members: bool,
87 pub unresolved_imports: bool,
88 pub unlisted_deps: bool,
89 pub duplicate_exports: bool,
90 pub circular_deps: bool,
91 pub boundary_violations: bool,
92 pub stale_suppressions: bool,
93}
94
95#[derive(Debug, Clone, Default)]
97pub struct DeadCodeOptions {
98 pub analysis: AnalysisOptions,
99 pub filters: DeadCodeFilters,
100 pub files: Vec<PathBuf>,
101 pub include_entry_exports: bool,
102}
103
104#[derive(Debug, Clone, Copy, Default)]
106pub enum DuplicationMode {
107 Strict,
108 #[default]
109 Mild,
110 Weak,
111 Semantic,
112}
113
114impl DuplicationMode {
115 const fn to_cli(self) -> DupesMode {
116 match self {
117 Self::Strict => DupesMode::Strict,
118 Self::Mild => DupesMode::Mild,
119 Self::Weak => DupesMode::Weak,
120 Self::Semantic => DupesMode::Semantic,
121 }
122 }
123}
124
125#[derive(Debug, Clone)]
127pub struct DuplicationOptions {
128 pub analysis: AnalysisOptions,
129 pub mode: DuplicationMode,
130 pub min_tokens: usize,
131 pub min_lines: usize,
132 pub threshold: f64,
133 pub skip_local: bool,
134 pub cross_language: bool,
135 pub ignore_imports: bool,
136 pub top: Option<usize>,
137}
138
139impl Default for DuplicationOptions {
140 fn default() -> Self {
141 Self {
142 analysis: AnalysisOptions::default(),
143 mode: DuplicationMode::Mild,
144 min_tokens: 50,
145 min_lines: 5,
146 threshold: 0.0,
147 skip_local: false,
148 cross_language: false,
149 ignore_imports: false,
150 top: None,
151 }
152 }
153}
154
155#[derive(Debug, Clone, Copy, Default)]
157pub enum ComplexitySort {
158 #[default]
159 Cyclomatic,
160 Cognitive,
161 Lines,
162}
163
164impl ComplexitySort {
165 const fn to_cli(self) -> SortBy {
166 match self {
167 Self::Cyclomatic => SortBy::Cyclomatic,
168 Self::Cognitive => SortBy::Cognitive,
169 Self::Lines => SortBy::Lines,
170 }
171 }
172}
173
174#[derive(Debug, Clone, Copy, Default)]
176pub enum OwnershipEmailMode {
177 Raw,
178 #[default]
179 Handle,
180 Hash,
181}
182
183impl OwnershipEmailMode {
184 const fn to_config(self) -> EmailMode {
185 match self {
186 Self::Raw => EmailMode::Raw,
187 Self::Handle => EmailMode::Handle,
188 Self::Hash => EmailMode::Hash,
189 }
190 }
191}
192
193#[derive(Debug, Clone, Copy)]
195pub enum TargetEffort {
196 Low,
197 Medium,
198 High,
199}
200
201impl TargetEffort {
202 const fn to_cli(self) -> EffortEstimate {
203 match self {
204 Self::Low => EffortEstimate::Low,
205 Self::Medium => EffortEstimate::Medium,
206 Self::High => EffortEstimate::High,
207 }
208 }
209}
210
211#[derive(Debug, Clone, Default)]
213pub struct ComplexityOptions {
214 pub analysis: AnalysisOptions,
215 pub max_cyclomatic: Option<u16>,
216 pub max_cognitive: Option<u16>,
217 pub max_crap: Option<f64>,
218 pub top: Option<usize>,
219 pub sort: ComplexitySort,
220 pub complexity: bool,
221 pub file_scores: bool,
222 pub coverage_gaps: bool,
223 pub hotspots: bool,
224 pub ownership: bool,
225 pub ownership_emails: Option<OwnershipEmailMode>,
226 pub targets: bool,
227 pub effort: Option<TargetEffort>,
228 pub score: bool,
229 pub since: Option<String>,
230 pub min_commits: Option<u32>,
231 pub coverage: Option<PathBuf>,
232 pub coverage_root: Option<PathBuf>,
233}
234
235#[derive(Debug, Clone)]
236struct ResolvedAnalysisOptions {
237 root: PathBuf,
238 config_path: Option<PathBuf>,
239 no_cache: bool,
240 threads: usize,
241 production: bool,
242 changed_since: Option<String>,
243 workspace: Option<Vec<String>>,
244 changed_workspaces: Option<String>,
245 explain: bool,
246}
247
248impl AnalysisOptions {
249 fn resolve(&self) -> ProgrammaticResult<ResolvedAnalysisOptions> {
250 if self.threads == Some(0) {
251 return Err(
252 ProgrammaticError::new("`threads` must be greater than 0", 2)
253 .with_code("FALLOW_INVALID_THREADS")
254 .with_context("analysis.threads"),
255 );
256 }
257 if self.workspace.is_some() && self.changed_workspaces.is_some() {
258 return Err(ProgrammaticError::new(
259 "`workspace` and `changed_workspaces` are mutually exclusive",
260 2,
261 )
262 .with_code("FALLOW_MUTUALLY_EXCLUSIVE_OPTIONS")
263 .with_context("analysis.workspace"));
264 }
265
266 let root = if let Some(root) = &self.root {
267 root.clone()
268 } else {
269 std::env::current_dir().map_err(|err| {
270 ProgrammaticError::new(
271 format!("failed to resolve current working directory: {err}"),
272 2,
273 )
274 .with_code("FALLOW_CWD_UNAVAILABLE")
275 .with_context("analysis.root")
276 })?
277 };
278
279 if !root.exists() {
280 return Err(ProgrammaticError::new(
281 format!("analysis root does not exist: {}", root.display()),
282 2,
283 )
284 .with_code("FALLOW_INVALID_ROOT")
285 .with_context("analysis.root"));
286 }
287 if !root.is_dir() {
288 return Err(ProgrammaticError::new(
289 format!("analysis root is not a directory: {}", root.display()),
290 2,
291 )
292 .with_code("FALLOW_INVALID_ROOT")
293 .with_context("analysis.root"));
294 }
295
296 if let Some(config_path) = &self.config_path
297 && !config_path.exists()
298 {
299 return Err(ProgrammaticError::new(
300 format!("config file does not exist: {}", config_path.display()),
301 2,
302 )
303 .with_code("FALLOW_INVALID_CONFIG_PATH")
304 .with_context("analysis.configPath"));
305 }
306
307 let threads = self.threads.unwrap_or_else(default_threads);
308
309 Ok(ResolvedAnalysisOptions {
310 root,
311 config_path: self.config_path.clone(),
312 no_cache: self.no_cache,
313 threads,
314 production: self.production,
315 changed_since: self.changed_since.clone(),
316 workspace: self.workspace.clone(),
317 changed_workspaces: self.changed_workspaces.clone(),
318 explain: self.explain,
319 })
320 }
321}
322
323fn default_threads() -> usize {
324 std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
325}
326
327fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
328 if let serde_json::Value::Object(map) = output {
329 map.insert("_meta".to_string(), meta);
330 }
331}
332
333fn build_dead_code_json(
334 results: &AnalysisResults,
335 root: &Path,
336 elapsed: std::time::Duration,
337 explain: bool,
338) -> ProgrammaticResult<serde_json::Value> {
339 let mut output = build_json(results, root, elapsed).map_err(|err| {
340 ProgrammaticError::new(format!("failed to serialize dead-code report: {err}"), 2)
341 .with_code("FALLOW_SERIALIZE_DEAD_CODE_REPORT")
342 .with_context("dead-code")
343 })?;
344 if explain {
345 insert_meta(&mut output, crate::explain::check_meta());
346 }
347 Ok(output)
348}
349
350fn to_issue_filters(filters: &DeadCodeFilters) -> IssueFilters {
351 IssueFilters {
352 unused_files: filters.unused_files,
353 unused_exports: filters.unused_exports,
354 unused_deps: filters.unused_deps,
355 unused_types: filters.unused_types,
356 unused_enum_members: filters.unused_enum_members,
357 unused_class_members: filters.unused_class_members,
358 unresolved_imports: filters.unresolved_imports,
359 unlisted_deps: filters.unlisted_deps,
360 duplicate_exports: filters.duplicate_exports,
361 circular_deps: filters.circular_deps,
362 boundary_violations: filters.boundary_violations,
363 stale_suppressions: filters.stale_suppressions,
364 }
365}
366
367fn generic_analysis_error(command: &str) -> ProgrammaticError {
368 let code = format!(
369 "FALLOW_{}_FAILED",
370 command.replace('-', "_").to_ascii_uppercase()
371 );
372 ProgrammaticError::new(format!("{command} failed"), 2)
373 .with_code(code)
374 .with_context(format!("fallow {command}"))
375 .with_help(format!(
376 "Re-run `fallow {command} --format json --quiet` in the target project for CLI diagnostics"
377 ))
378}
379
380fn build_check_options<'a>(
381 resolved: &'a ResolvedAnalysisOptions,
382 options: &'a DeadCodeOptions,
383 filters: &'a IssueFilters,
384 trace_opts: &'a TraceOptions,
385) -> CheckOptions<'a> {
386 CheckOptions {
387 root: &resolved.root,
388 config_path: &resolved.config_path,
389 output: OutputFormat::Human,
390 no_cache: resolved.no_cache,
391 threads: resolved.threads,
392 quiet: true,
393 fail_on_issues: false,
394 filters,
395 changed_since: resolved.changed_since.as_deref(),
396 baseline: None,
397 save_baseline: None,
398 sarif_file: None,
399 production: resolved.production,
400 workspace: resolved.workspace.as_deref(),
401 changed_workspaces: resolved.changed_workspaces.as_deref(),
402 group_by: None,
403 include_dupes: false,
404 trace_opts,
405 explain: resolved.explain,
406 top: None,
407 file: &options.files,
408 include_entry_exports: options.include_entry_exports,
409 summary: false,
410 regression_opts: crate::regression::RegressionOpts {
411 fail_on_regression: false,
412 tolerance: crate::regression::Tolerance::Absolute(0),
413 regression_baseline_file: None,
414 save_target: crate::regression::SaveRegressionTarget::None,
415 scoped: false,
416 quiet: true,
417 },
418 retain_modules_for_health: false,
419 }
420}
421
422fn filter_for_circular_dependencies(results: &AnalysisResults) -> AnalysisResults {
423 let mut filtered = results.clone();
424 filtered.unused_files.clear();
425 filtered.unused_exports.clear();
426 filtered.unused_types.clear();
427 filtered.unused_dependencies.clear();
428 filtered.unused_dev_dependencies.clear();
429 filtered.unused_optional_dependencies.clear();
430 filtered.unused_enum_members.clear();
431 filtered.unused_class_members.clear();
432 filtered.unresolved_imports.clear();
433 filtered.unlisted_dependencies.clear();
434 filtered.duplicate_exports.clear();
435 filtered.type_only_dependencies.clear();
436 filtered.test_only_dependencies.clear();
437 filtered.boundary_violations.clear();
438 filtered.stale_suppressions.clear();
439 filtered
440}
441
442fn filter_for_boundary_violations(results: &AnalysisResults) -> AnalysisResults {
443 let mut filtered = results.clone();
444 filtered.unused_files.clear();
445 filtered.unused_exports.clear();
446 filtered.unused_types.clear();
447 filtered.unused_dependencies.clear();
448 filtered.unused_dev_dependencies.clear();
449 filtered.unused_optional_dependencies.clear();
450 filtered.unused_enum_members.clear();
451 filtered.unused_class_members.clear();
452 filtered.unresolved_imports.clear();
453 filtered.unlisted_dependencies.clear();
454 filtered.duplicate_exports.clear();
455 filtered.type_only_dependencies.clear();
456 filtered.test_only_dependencies.clear();
457 filtered.circular_dependencies.clear();
458 filtered.stale_suppressions.clear();
459 filtered
460}
461
462pub fn detect_dead_code(options: &DeadCodeOptions) -> ProgrammaticResult<serde_json::Value> {
464 let resolved = options.analysis.resolve()?;
465 let filters = to_issue_filters(&options.filters);
466 let trace_opts = TraceOptions {
467 trace_export: None,
468 trace_file: None,
469 trace_dependency: None,
470 performance: false,
471 };
472 let check_options = build_check_options(&resolved, options, &filters, &trace_opts);
473 let result = crate::check::execute_check(&check_options)
474 .map_err(|_| generic_analysis_error("dead-code"))?;
475 build_dead_code_json(
476 &result.results,
477 &result.config.root,
478 result.elapsed,
479 resolved.explain,
480 )
481}
482
483pub fn detect_circular_dependencies(
486 options: &DeadCodeOptions,
487) -> ProgrammaticResult<serde_json::Value> {
488 let resolved = options.analysis.resolve()?;
489 let filters = to_issue_filters(&options.filters);
490 let trace_opts = TraceOptions {
491 trace_export: None,
492 trace_file: None,
493 trace_dependency: None,
494 performance: false,
495 };
496 let check_options = build_check_options(&resolved, options, &filters, &trace_opts);
497 let result = crate::check::execute_check(&check_options)
498 .map_err(|_| generic_analysis_error("dead-code"))?;
499 let filtered = filter_for_circular_dependencies(&result.results);
500 build_dead_code_json(
501 &filtered,
502 &result.config.root,
503 result.elapsed,
504 resolved.explain,
505 )
506}
507
508pub fn detect_boundary_violations(
511 options: &DeadCodeOptions,
512) -> ProgrammaticResult<serde_json::Value> {
513 let resolved = options.analysis.resolve()?;
514 let filters = to_issue_filters(&options.filters);
515 let trace_opts = TraceOptions {
516 trace_export: None,
517 trace_file: None,
518 trace_dependency: None,
519 performance: false,
520 };
521 let check_options = build_check_options(&resolved, options, &filters, &trace_opts);
522 let result = crate::check::execute_check(&check_options)
523 .map_err(|_| generic_analysis_error("dead-code"))?;
524 let filtered = filter_for_boundary_violations(&result.results);
525 build_dead_code_json(
526 &filtered,
527 &result.config.root,
528 result.elapsed,
529 resolved.explain,
530 )
531}
532
533pub fn detect_duplication(options: &DuplicationOptions) -> ProgrammaticResult<serde_json::Value> {
535 let resolved = options.analysis.resolve()?;
536 let dupes_options = DupesOptions {
537 root: &resolved.root,
538 config_path: &resolved.config_path,
539 output: OutputFormat::Human,
540 no_cache: resolved.no_cache,
541 threads: resolved.threads,
542 quiet: true,
543 mode: options.mode.to_cli(),
544 min_tokens: options.min_tokens,
545 min_lines: options.min_lines,
546 threshold: options.threshold,
547 skip_local: options.skip_local,
548 cross_language: options.cross_language,
549 ignore_imports: options.ignore_imports,
550 top: options.top,
551 baseline_path: None,
552 save_baseline_path: None,
553 production: resolved.production,
554 trace: None,
555 changed_since: resolved.changed_since.as_deref(),
556 workspace: resolved.workspace.as_deref(),
557 changed_workspaces: resolved.changed_workspaces.as_deref(),
558 explain: resolved.explain,
559 summary: false,
560 group_by: None,
561 };
562 let result =
563 crate::dupes::execute_dupes(&dupes_options).map_err(|_| generic_analysis_error("dupes"))?;
564 build_duplication_json(
565 &result.report,
566 &result.config.root,
567 result.elapsed,
568 resolved.explain,
569 )
570 .map_err(|err| {
571 ProgrammaticError::new(format!("failed to serialize duplication report: {err}"), 2)
572 .with_code("FALLOW_SERIALIZE_DUPLICATION_REPORT")
573 .with_context("dupes")
574 })
575}
576
577fn build_complexity_options<'a>(
578 resolved: &'a ResolvedAnalysisOptions,
579 options: &'a ComplexityOptions,
580) -> HealthOptions<'a> {
581 let ownership = options.ownership || options.ownership_emails.is_some();
582 let hotspots = options.hotspots || ownership;
583 let targets = options.targets || options.effort.is_some();
584 let any_section = options.complexity
585 || options.file_scores
586 || options.coverage_gaps
587 || hotspots
588 || targets
589 || options.score;
590 let eff_score = if any_section { options.score } else { true };
591 let force_full = eff_score;
592 let score_only_output = options.score
593 && !options.complexity
594 && !options.file_scores
595 && !options.coverage_gaps
596 && !hotspots
597 && !targets;
598 let eff_file_scores = if any_section {
599 options.file_scores
600 } else {
601 true
602 } || force_full;
603 let eff_hotspots = if any_section { hotspots } else { true } || force_full;
604 let eff_complexity = if any_section {
605 options.complexity
606 } else {
607 true
608 };
609 let eff_targets = if any_section { targets } else { true };
610 let eff_coverage_gaps = if any_section {
611 options.coverage_gaps
612 } else {
613 false
614 };
615
616 HealthOptions {
617 root: &resolved.root,
618 config_path: &resolved.config_path,
619 output: OutputFormat::Human,
620 no_cache: resolved.no_cache,
621 threads: resolved.threads,
622 quiet: true,
623 max_cyclomatic: options.max_cyclomatic,
624 max_cognitive: options.max_cognitive,
625 max_crap: options.max_crap,
626 top: options.top,
627 sort: options.sort.to_cli(),
628 production: resolved.production,
629 changed_since: resolved.changed_since.as_deref(),
630 workspace: resolved.workspace.as_deref(),
631 changed_workspaces: resolved.changed_workspaces.as_deref(),
632 baseline: None,
633 save_baseline: None,
634 complexity: eff_complexity,
635 file_scores: eff_file_scores,
636 coverage_gaps: eff_coverage_gaps,
637 config_activates_coverage_gaps: !any_section,
638 hotspots: eff_hotspots,
639 ownership: ownership && eff_hotspots,
640 ownership_emails: options.ownership_emails.map(OwnershipEmailMode::to_config),
641 targets: eff_targets,
642 force_full,
643 score_only_output,
644 enforce_coverage_gap_gate: true,
645 effort: options.effort.map(TargetEffort::to_cli),
646 score: eff_score,
647 min_score: None,
648 since: options.since.as_deref(),
649 min_commits: options.min_commits,
650 explain: resolved.explain,
651 summary: false,
652 save_snapshot: None,
653 trend: false,
654 group_by: None,
655 coverage: options.coverage.as_deref(),
656 coverage_root: options.coverage_root.as_deref(),
657 performance: false,
658 min_severity: None,
659 production_coverage: None,
660 }
661}
662
663pub fn compute_complexity(options: &ComplexityOptions) -> ProgrammaticResult<serde_json::Value> {
665 let resolved = options.analysis.resolve()?;
666 if let Some(path) = &options.coverage
667 && !path.exists()
668 {
669 return Err(ProgrammaticError::new(
670 format!("coverage path does not exist: {}", path.display()),
671 2,
672 )
673 .with_code("FALLOW_INVALID_COVERAGE_PATH")
674 .with_context("health.coverage"));
675 }
676
677 let health_options = build_complexity_options(&resolved, options);
678 let result = crate::health::execute_health(&health_options)
679 .map_err(|_| generic_analysis_error("health"))?;
680 build_health_json(
681 &result.report,
682 &result.config.root,
683 result.elapsed,
684 resolved.explain,
685 )
686 .map_err(|err| {
687 ProgrammaticError::new(format!("failed to serialize health report: {err}"), 2)
688 .with_code("FALLOW_SERIALIZE_HEALTH_REPORT")
689 .with_context("health")
690 })
691}
692
693pub fn compute_health(options: &ComplexityOptions) -> ProgrammaticResult<serde_json::Value> {
695 compute_complexity(options)
696}
697
698#[cfg(test)]
699mod tests {
700 use super::*;
701 use crate::report::test_helpers::sample_results;
702
703 #[test]
704 fn circular_dependency_filter_clears_other_issue_types() {
705 let root = PathBuf::from("/project");
706 let results = sample_results(&root);
707 let filtered = filter_for_circular_dependencies(&results);
708 let json = build_dead_code_json(&filtered, &root, std::time::Duration::ZERO, false)
709 .expect("should serialize");
710
711 assert_eq!(json["circular_dependencies"].as_array().unwrap().len(), 1);
712 assert_eq!(json["boundary_violations"].as_array().unwrap().len(), 0);
713 assert_eq!(json["unused_files"].as_array().unwrap().len(), 0);
714 assert_eq!(json["summary"]["total_issues"], serde_json::Value::from(1));
715 }
716
717 #[test]
718 fn boundary_violation_filter_clears_other_issue_types() {
719 let root = PathBuf::from("/project");
720 let results = sample_results(&root);
721 let filtered = filter_for_boundary_violations(&results);
722 let json = build_dead_code_json(&filtered, &root, std::time::Duration::ZERO, false)
723 .expect("should serialize");
724
725 assert_eq!(json["boundary_violations"].as_array().unwrap().len(), 1);
726 assert_eq!(json["circular_dependencies"].as_array().unwrap().len(), 0);
727 assert_eq!(json["unused_exports"].as_array().unwrap().len(), 0);
728 assert_eq!(json["summary"]["total_issues"], serde_json::Value::from(1));
729 }
730}