Skip to main content

fallow_cli/
programmatic.rs

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/// Structured error surface for the programmatic API.
14#[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/// Shared options for all one-shot analyses.
65#[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/// Issue-type filters for the dead-code analysis.
79#[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/// Options for dead-code-oriented analyses.
96#[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/// Programmatic duplication mode selection.
105#[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/// Options for duplication analysis.
126#[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/// Sort criteria for complexity findings.
156#[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/// Privacy mode for ownership-aware hotspot output.
175#[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/// Effort filter for refactoring targets.
194#[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/// Options for complexity / health analysis.
212#[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
462/// Run the dead-code analysis and return the CLI JSON contract as a value.
463pub 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
483/// Run the circular-dependency analysis and return the standard dead-code JSON envelope
484/// filtered down to the `circular_dependencies` category.
485pub 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
508/// Run the boundary-violation analysis and return the standard dead-code JSON envelope
509/// filtered down to the `boundary_violations` category.
510pub 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
533/// Run the duplication analysis and return the CLI JSON contract as a value.
534pub 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
663/// Run the health / complexity analysis and return the CLI JSON contract as a value.
664pub 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
693/// Alias for `compute_complexity` with a more product-oriented name.
694pub 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}