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::ci::diff_filter::{DiffIndex, LoadedDiff, MAX_DIFF_BYTES};
12use crate::report::{build_duplication_json, build_health_json};
13
14pub const COMMON_ANALYSIS_OPTION_FLAGS: &[&str] = &[
15    "root",
16    "config",
17    "no-cache",
18    "threads",
19    "changed-since",
20    "diff-file",
21    "production",
22    "workspace",
23    "changed-workspaces",
24    "explain",
25];
26
27/// Structured error surface for the programmatic API.
28#[derive(Debug, Clone, Serialize)]
29pub struct ProgrammaticError {
30    pub message: String,
31    pub exit_code: u8,
32    pub code: Option<String>,
33    pub help: Option<String>,
34    pub context: Option<String>,
35}
36
37impl ProgrammaticError {
38    #[must_use]
39    pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
40        Self {
41            message: message.into(),
42            exit_code,
43            code: None,
44            help: None,
45            context: None,
46        }
47    }
48
49    #[must_use]
50    pub fn with_help(mut self, help: impl Into<String>) -> Self {
51        self.help = Some(help.into());
52        self
53    }
54
55    #[must_use]
56    pub fn with_code(mut self, code: impl Into<String>) -> Self {
57        self.code = Some(code.into());
58        self
59    }
60
61    #[must_use]
62    pub fn with_context(mut self, context: impl Into<String>) -> Self {
63        self.context = Some(context.into());
64        self
65    }
66}
67
68impl std::fmt::Display for ProgrammaticError {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        write!(f, "{}", self.message)
71    }
72}
73
74impl std::error::Error for ProgrammaticError {}
75
76type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
77
78/// Shared options for all one-shot analyses.
79#[derive(Debug, Clone, Default)]
80pub struct AnalysisOptions {
81    pub root: Option<PathBuf>,
82    pub config_path: Option<PathBuf>,
83    pub no_cache: bool,
84    pub threads: Option<usize>,
85    pub diff_file: Option<PathBuf>,
86    /// Legacy convenience override. `true` forces production mode; `false`
87    /// defers to config unless `production_override` is set.
88    pub production: bool,
89    /// Explicit production override from an embedder option. `None` means
90    /// use the project config for the current analysis.
91    pub production_override: Option<bool>,
92    pub changed_since: Option<String>,
93    pub workspace: Option<Vec<String>>,
94    pub changed_workspaces: Option<String>,
95    pub explain: bool,
96}
97
98/// Issue-type filters for the dead-code analysis.
99#[derive(Debug, Clone, Default)]
100pub struct DeadCodeFilters {
101    pub unused_files: bool,
102    pub unused_exports: bool,
103    pub unused_deps: bool,
104    pub unused_types: bool,
105    pub private_type_leaks: bool,
106    pub unused_enum_members: bool,
107    pub unused_class_members: bool,
108    pub unresolved_imports: bool,
109    pub unlisted_deps: bool,
110    pub duplicate_exports: bool,
111    pub circular_deps: bool,
112    pub re_export_cycles: bool,
113    pub boundary_violations: bool,
114    pub stale_suppressions: bool,
115    pub unused_catalog_entries: bool,
116    pub empty_catalog_groups: bool,
117    pub unresolved_catalog_references: bool,
118    pub unused_dependency_overrides: bool,
119    pub misconfigured_dependency_overrides: bool,
120}
121
122/// Options for dead-code-oriented analyses.
123#[derive(Debug, Clone, Default)]
124pub struct DeadCodeOptions {
125    pub analysis: AnalysisOptions,
126    pub filters: DeadCodeFilters,
127    pub files: Vec<PathBuf>,
128    pub include_entry_exports: bool,
129}
130
131/// Programmatic duplication mode selection.
132#[derive(Debug, Clone, Copy, Default)]
133pub enum DuplicationMode {
134    Strict,
135    #[default]
136    Mild,
137    Weak,
138    Semantic,
139}
140
141impl DuplicationMode {
142    const fn to_cli(self) -> DupesMode {
143        match self {
144            Self::Strict => DupesMode::Strict,
145            Self::Mild => DupesMode::Mild,
146            Self::Weak => DupesMode::Weak,
147            Self::Semantic => DupesMode::Semantic,
148        }
149    }
150}
151
152/// Options for duplication analysis.
153#[derive(Debug, Clone)]
154pub struct DuplicationOptions {
155    pub analysis: AnalysisOptions,
156    pub mode: DuplicationMode,
157    pub min_tokens: usize,
158    pub min_lines: usize,
159    /// Minimum number of occurrences (instances) before a clone group is
160    /// reported. Values below 2 are silently treated as 2 (a single
161    /// occurrence isn't a duplicate, so the engine no-ops). The CLI and
162    /// MCP surfaces hard-reject `< 2` at parse time; the programmatic
163    /// path is permissive because callers may construct this from
164    /// untyped configuration.
165    pub min_occurrences: usize,
166    pub threshold: f64,
167    pub skip_local: bool,
168    pub cross_language: bool,
169    pub ignore_imports: bool,
170    pub top: Option<usize>,
171}
172
173impl Default for DuplicationOptions {
174    fn default() -> Self {
175        Self {
176            analysis: AnalysisOptions::default(),
177            mode: DuplicationMode::Mild,
178            min_tokens: 50,
179            min_lines: 5,
180            min_occurrences: 2,
181            threshold: 0.0,
182            skip_local: false,
183            cross_language: false,
184            ignore_imports: false,
185            top: None,
186        }
187    }
188}
189
190/// Sort criteria for complexity findings.
191#[derive(Debug, Clone, Copy, Default)]
192pub enum ComplexitySort {
193    #[default]
194    Cyclomatic,
195    Cognitive,
196    Lines,
197    Severity,
198}
199
200impl ComplexitySort {
201    const fn to_cli(self) -> SortBy {
202        match self {
203            Self::Severity => SortBy::Severity,
204            Self::Cyclomatic => SortBy::Cyclomatic,
205            Self::Cognitive => SortBy::Cognitive,
206            Self::Lines => SortBy::Lines,
207        }
208    }
209}
210
211/// Privacy mode for ownership-aware hotspot output.
212#[derive(Debug, Clone, Copy, Default)]
213pub enum OwnershipEmailMode {
214    Raw,
215    #[default]
216    Handle,
217    Anonymized,
218    /// Legacy spelling retained for embedders that already pass `hash`.
219    Hash,
220}
221
222impl OwnershipEmailMode {
223    const fn to_config(self) -> EmailMode {
224        match self {
225            Self::Raw => EmailMode::Raw,
226            Self::Handle => EmailMode::Handle,
227            Self::Anonymized => EmailMode::Anonymized,
228            Self::Hash => EmailMode::Hash,
229        }
230    }
231}
232
233/// Effort filter for refactoring targets.
234#[derive(Debug, Clone, Copy)]
235pub enum TargetEffort {
236    Low,
237    Medium,
238    High,
239}
240
241impl TargetEffort {
242    const fn to_cli(self) -> EffortEstimate {
243        match self {
244            Self::Low => EffortEstimate::Low,
245            Self::Medium => EffortEstimate::Medium,
246            Self::High => EffortEstimate::High,
247        }
248    }
249}
250
251/// Options for complexity / health analysis.
252#[derive(Debug, Clone, Default)]
253pub struct ComplexityOptions {
254    pub analysis: AnalysisOptions,
255    pub max_cyclomatic: Option<u16>,
256    pub max_cognitive: Option<u16>,
257    pub max_crap: Option<f64>,
258    pub top: Option<usize>,
259    pub sort: ComplexitySort,
260    pub complexity: bool,
261    pub file_scores: bool,
262    pub coverage_gaps: bool,
263    pub hotspots: bool,
264    pub ownership: bool,
265    pub ownership_emails: Option<OwnershipEmailMode>,
266    pub targets: bool,
267    pub effort: Option<TargetEffort>,
268    pub score: bool,
269    pub since: Option<String>,
270    pub min_commits: Option<u32>,
271    pub coverage: Option<PathBuf>,
272    pub coverage_root: Option<PathBuf>,
273}
274
275struct ResolvedAnalysisOptions {
276    root: PathBuf,
277    config_path: Option<PathBuf>,
278    no_cache: bool,
279    threads: usize,
280    pool: rayon::ThreadPool,
281    diff: Option<LoadedDiff>,
282    production_override: Option<bool>,
283    changed_since: Option<String>,
284    workspace: Option<Vec<String>>,
285    changed_workspaces: Option<String>,
286    explain: bool,
287}
288
289impl AnalysisOptions {
290    fn resolve(&self) -> ProgrammaticResult<ResolvedAnalysisOptions> {
291        if self.threads == Some(0) {
292            return Err(
293                ProgrammaticError::new("`threads` must be greater than 0", 2)
294                    .with_code("FALLOW_INVALID_THREADS")
295                    .with_context("analysis.threads"),
296            );
297        }
298        if self.workspace.is_some() && self.changed_workspaces.is_some() {
299            return Err(ProgrammaticError::new(
300                "`workspace` and `changed_workspaces` are mutually exclusive",
301                2,
302            )
303            .with_code("FALLOW_MUTUALLY_EXCLUSIVE_OPTIONS")
304            .with_context("analysis.workspace"));
305        }
306
307        let root = if let Some(root) = &self.root {
308            root.clone()
309        } else {
310            std::env::current_dir().map_err(|err| {
311                ProgrammaticError::new(
312                    format!("failed to resolve current working directory: {err}"),
313                    2,
314                )
315                .with_code("FALLOW_CWD_UNAVAILABLE")
316                .with_context("analysis.root")
317            })?
318        };
319
320        if !root.exists() {
321            return Err(ProgrammaticError::new(
322                format!("analysis root does not exist: {}", root.display()),
323                2,
324            )
325            .with_code("FALLOW_INVALID_ROOT")
326            .with_context("analysis.root"));
327        }
328        if !root.is_dir() {
329            return Err(ProgrammaticError::new(
330                format!("analysis root is not a directory: {}", root.display()),
331                2,
332            )
333            .with_code("FALLOW_INVALID_ROOT")
334            .with_context("analysis.root"));
335        }
336
337        if let Some(config_path) = &self.config_path
338            && !config_path.exists()
339        {
340            return Err(ProgrammaticError::new(
341                format!("config file does not exist: {}", config_path.display()),
342                2,
343            )
344            .with_code("FALLOW_INVALID_CONFIG_PATH")
345            .with_context("analysis.configPath"));
346        }
347
348        let threads = self.threads.unwrap_or_else(default_threads);
349        let pool = crate::rayon_pool::build_thread_pool(threads).map_err(|err| {
350            ProgrammaticError::new(format!("failed to build analysis thread pool: {err}"), 2)
351                .with_code("FALLOW_THREAD_POOL_INIT_FAILED")
352                .with_context("analysis.threads")
353        })?;
354        let diff = self
355            .diff_file
356            .as_deref()
357            .map(|path| load_explicit_diff_file(path, &root))
358            .transpose()?;
359        let production_override = self
360            .production_override
361            .or_else(|| self.production.then_some(true));
362
363        Ok(ResolvedAnalysisOptions {
364            root,
365            config_path: self.config_path.clone(),
366            no_cache: self.no_cache,
367            threads,
368            pool,
369            diff,
370            production_override,
371            changed_since: self.changed_since.clone(),
372            workspace: self.workspace.clone(),
373            changed_workspaces: self.changed_workspaces.clone(),
374            explain: self.explain,
375        })
376    }
377}
378
379impl ResolvedAnalysisOptions {
380    fn install<R: Send>(&self, f: impl FnOnce() -> R + Send) -> R {
381        self.pool.install(f)
382    }
383
384    fn diff_index(&self) -> Option<&DiffIndex> {
385        self.diff.as_ref().map(|loaded| &loaded.index)
386    }
387}
388
389fn default_threads() -> usize {
390    std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
391}
392
393fn load_explicit_diff_file(path: &Path, root: &Path) -> ProgrammaticResult<LoadedDiff> {
394    if path == Path::new("-") {
395        return Err(ProgrammaticError::new(
396            "`diff_file` does not support stdin; pass a file path",
397            2,
398        )
399        .with_code("FALLOW_INVALID_DIFF_FILE")
400        .with_context("analysis.diffFile"));
401    }
402
403    let abs = if crate::path_util::is_absolute_path_any_platform(path) {
404        path.to_path_buf()
405    } else {
406        root.join(path)
407    };
408
409    let meta = std::fs::metadata(&abs).map_err(|err| {
410        ProgrammaticError::new(
411            format!(
412                "diff file does not exist or cannot be read: {} ({err})",
413                abs.display()
414            ),
415            2,
416        )
417        .with_code("FALLOW_INVALID_DIFF_FILE")
418        .with_context("analysis.diffFile")
419    })?;
420    if !meta.is_file() {
421        return Err(ProgrammaticError::new(
422            format!("diff path is not a file: {}", abs.display()),
423            2,
424        )
425        .with_code("FALLOW_INVALID_DIFF_FILE")
426        .with_context("analysis.diffFile"));
427    }
428    if meta.len() > MAX_DIFF_BYTES {
429        return Err(ProgrammaticError::new(
430            format!(
431                "diff file is {} bytes, above the {MAX_DIFF_BYTES} byte limit: {}",
432                meta.len(),
433                abs.display()
434            ),
435            2,
436        )
437        .with_code("FALLOW_INVALID_DIFF_FILE")
438        .with_context("analysis.diffFile"));
439    }
440
441    let text = std::fs::read_to_string(&abs).map_err(|err| {
442        ProgrammaticError::new(
443            format!("failed to read diff file {}: {err}", abs.display()),
444            2,
445        )
446        .with_code("FALLOW_INVALID_DIFF_FILE")
447        .with_context("analysis.diffFile")
448    })?;
449
450    Ok(LoadedDiff {
451        index: DiffIndex::from_unified_diff(&text),
452    })
453}
454
455fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
456    if let serde_json::Value::Object(map) = output {
457        map.insert("_meta".to_string(), meta);
458    }
459}
460
461fn build_dead_code_json(
462    results: &AnalysisResults,
463    root: &Path,
464    elapsed: std::time::Duration,
465    explain: bool,
466    config_fixable: bool,
467) -> ProgrammaticResult<serde_json::Value> {
468    let mut output =
469        crate::report::build_json_with_config_fixable(results, root, elapsed, config_fixable)
470            .map_err(|err| {
471                ProgrammaticError::new(format!("failed to serialize dead-code report: {err}"), 2)
472                    .with_code("FALLOW_SERIALIZE_DEAD_CODE_REPORT")
473                    .with_context("dead-code")
474            })?;
475    if explain {
476        insert_meta(&mut output, crate::explain::check_meta());
477    }
478    Ok(output)
479}
480
481fn to_issue_filters(filters: &DeadCodeFilters) -> IssueFilters {
482    IssueFilters {
483        unused_files: filters.unused_files,
484        unused_exports: filters.unused_exports,
485        unused_deps: filters.unused_deps,
486        unused_types: filters.unused_types,
487        private_type_leaks: filters.private_type_leaks,
488        unused_enum_members: filters.unused_enum_members,
489        unused_class_members: filters.unused_class_members,
490        unresolved_imports: filters.unresolved_imports,
491        unlisted_deps: filters.unlisted_deps,
492        duplicate_exports: filters.duplicate_exports,
493        circular_deps: filters.circular_deps,
494        re_export_cycles: filters.re_export_cycles,
495        boundary_violations: filters.boundary_violations,
496        stale_suppressions: filters.stale_suppressions,
497        unused_catalog_entries: filters.unused_catalog_entries,
498        empty_catalog_groups: filters.empty_catalog_groups,
499        unresolved_catalog_references: filters.unresolved_catalog_references,
500        unused_dependency_overrides: filters.unused_dependency_overrides,
501        misconfigured_dependency_overrides: filters.misconfigured_dependency_overrides,
502    }
503}
504
505fn generic_analysis_error(command: &str) -> ProgrammaticError {
506    let code = format!(
507        "FALLOW_{}_FAILED",
508        command.replace('-', "_").to_ascii_uppercase()
509    );
510    ProgrammaticError::new(format!("{command} failed"), 2)
511        .with_code(code)
512        .with_context(format!("fallow {command}"))
513        .with_help(format!(
514            "Re-run `fallow {command} --format json --quiet` in the target project for CLI diagnostics"
515        ))
516}
517
518fn build_check_options<'a>(
519    resolved: &'a ResolvedAnalysisOptions,
520    options: &'a DeadCodeOptions,
521    filters: &'a IssueFilters,
522    trace_opts: &'a TraceOptions,
523) -> CheckOptions<'a> {
524    CheckOptions {
525        root: &resolved.root,
526        config_path: &resolved.config_path,
527        output: OutputFormat::Human,
528        no_cache: resolved.no_cache,
529        threads: resolved.threads,
530        quiet: true,
531        fail_on_issues: false,
532        filters,
533        changed_since: resolved.changed_since.as_deref(),
534        diff_index: resolved.diff_index(),
535        use_shared_diff_index: false,
536        baseline: None,
537        save_baseline: None,
538        sarif_file: None,
539        production: resolved.production_override.unwrap_or(false),
540        production_override: resolved.production_override,
541        workspace: resolved.workspace.as_deref(),
542        changed_workspaces: resolved.changed_workspaces.as_deref(),
543        group_by: None,
544        include_dupes: false,
545        trace_opts,
546        explain: resolved.explain,
547        top: None,
548        file: &options.files,
549        include_entry_exports: options.include_entry_exports,
550        summary: false,
551        regression_opts: crate::regression::RegressionOpts {
552            fail_on_regression: false,
553            tolerance: crate::regression::Tolerance::Absolute(0),
554            regression_baseline_file: None,
555            save_target: crate::regression::SaveRegressionTarget::None,
556            scoped: false,
557            quiet: true,
558            output: fallow_config::OutputFormat::Json,
559        },
560        retain_modules_for_health: false,
561        defer_performance: false,
562    }
563}
564
565fn filter_for_circular_dependencies(results: &AnalysisResults) -> AnalysisResults {
566    let mut filtered = results.clone();
567    filtered.unused_files.clear();
568    filtered.unused_exports.clear();
569    filtered.unused_types.clear();
570    filtered.private_type_leaks.clear();
571    filtered.unused_dependencies.clear();
572    filtered.unused_dev_dependencies.clear();
573    filtered.unused_optional_dependencies.clear();
574    filtered.unused_enum_members.clear();
575    filtered.unused_class_members.clear();
576    filtered.unresolved_imports.clear();
577    filtered.unlisted_dependencies.clear();
578    filtered.duplicate_exports.clear();
579    filtered.type_only_dependencies.clear();
580    filtered.test_only_dependencies.clear();
581    filtered.boundary_violations.clear();
582    filtered.stale_suppressions.clear();
583    filtered
584}
585
586fn filter_for_boundary_violations(results: &AnalysisResults) -> AnalysisResults {
587    let mut filtered = results.clone();
588    filtered.unused_files.clear();
589    filtered.unused_exports.clear();
590    filtered.unused_types.clear();
591    filtered.private_type_leaks.clear();
592    filtered.unused_dependencies.clear();
593    filtered.unused_dev_dependencies.clear();
594    filtered.unused_optional_dependencies.clear();
595    filtered.unused_enum_members.clear();
596    filtered.unused_class_members.clear();
597    filtered.unresolved_imports.clear();
598    filtered.unlisted_dependencies.clear();
599    filtered.duplicate_exports.clear();
600    filtered.type_only_dependencies.clear();
601    filtered.test_only_dependencies.clear();
602    filtered.circular_dependencies.clear();
603    filtered.stale_suppressions.clear();
604    filtered
605}
606
607/// Run the dead-code analysis and return the CLI JSON contract as a value.
608pub fn detect_dead_code(options: &DeadCodeOptions) -> ProgrammaticResult<serde_json::Value> {
609    let resolved = options.analysis.resolve()?;
610    resolved.install(|| {
611        let filters = to_issue_filters(&options.filters);
612        let trace_opts = TraceOptions {
613            trace_export: None,
614            trace_file: None,
615            trace_dependency: None,
616            performance: false,
617        };
618        let check_options = build_check_options(&resolved, options, &filters, &trace_opts);
619        let result = crate::check::execute_check(&check_options)
620            .map_err(|_| generic_analysis_error("dead-code"))?;
621        build_dead_code_json(
622            &result.results,
623            &result.config.root,
624            result.elapsed,
625            resolved.explain,
626            result.config_fixable,
627        )
628    })
629}
630
631/// Run the circular-dependency analysis and return the standard dead-code JSON envelope
632/// filtered down to the `circular_dependencies` category.
633pub fn detect_circular_dependencies(
634    options: &DeadCodeOptions,
635) -> ProgrammaticResult<serde_json::Value> {
636    let resolved = options.analysis.resolve()?;
637    resolved.install(|| {
638        let filters = to_issue_filters(&options.filters);
639        let trace_opts = TraceOptions {
640            trace_export: None,
641            trace_file: None,
642            trace_dependency: None,
643            performance: false,
644        };
645        let check_options = build_check_options(&resolved, options, &filters, &trace_opts);
646        let result = crate::check::execute_check(&check_options)
647            .map_err(|_| generic_analysis_error("dead-code"))?;
648        let filtered = filter_for_circular_dependencies(&result.results);
649        build_dead_code_json(
650            &filtered,
651            &result.config.root,
652            result.elapsed,
653            resolved.explain,
654            result.config_fixable,
655        )
656    })
657}
658
659/// Run the boundary-violation analysis and return the standard dead-code JSON envelope
660/// filtered down to the `boundary_violations` category.
661pub fn detect_boundary_violations(
662    options: &DeadCodeOptions,
663) -> ProgrammaticResult<serde_json::Value> {
664    let resolved = options.analysis.resolve()?;
665    resolved.install(|| {
666        let filters = to_issue_filters(&options.filters);
667        let trace_opts = TraceOptions {
668            trace_export: None,
669            trace_file: None,
670            trace_dependency: None,
671            performance: false,
672        };
673        let check_options = build_check_options(&resolved, options, &filters, &trace_opts);
674        let result = crate::check::execute_check(&check_options)
675            .map_err(|_| generic_analysis_error("dead-code"))?;
676        let filtered = filter_for_boundary_violations(&result.results);
677        build_dead_code_json(
678            &filtered,
679            &result.config.root,
680            result.elapsed,
681            resolved.explain,
682            result.config_fixable,
683        )
684    })
685}
686
687/// Run the duplication analysis and return the CLI JSON contract as a value.
688pub fn detect_duplication(options: &DuplicationOptions) -> ProgrammaticResult<serde_json::Value> {
689    let resolved = options.analysis.resolve()?;
690    resolved.install(|| {
691        let dupes_options = DupesOptions {
692            root: &resolved.root,
693            config_path: &resolved.config_path,
694            output: OutputFormat::Human,
695            no_cache: resolved.no_cache,
696            threads: resolved.threads,
697            quiet: true,
698            // The programmatic API requires callers to provide concrete values
699            // (the public `DuplicationOptions` has no Optional scalars), so we
700            // forward each as an explicit override.
701            mode: Some(options.mode.to_cli()),
702            min_tokens: Some(options.min_tokens),
703            min_lines: Some(options.min_lines),
704            min_occurrences: Some(options.min_occurrences),
705            threshold: Some(options.threshold),
706            skip_local: options.skip_local,
707            cross_language: options.cross_language,
708            ignore_imports: options.ignore_imports,
709            top: options.top,
710            baseline_path: None,
711            save_baseline_path: None,
712            production: resolved.production_override.unwrap_or(false),
713            production_override: resolved.production_override,
714            trace: None,
715            changed_since: resolved.changed_since.as_deref(),
716            diff_index: resolved.diff_index(),
717            use_shared_diff_index: false,
718            changed_files: None,
719            workspace: resolved.workspace.as_deref(),
720            changed_workspaces: resolved.changed_workspaces.as_deref(),
721            explain: resolved.explain,
722            explain_skipped: false,
723            summary: false,
724            group_by: None,
725            // The programmatic API returns structured JSON; performance panels go
726            // to stderr in human mode and are not part of the public contract.
727            performance: false,
728        };
729        let result = crate::dupes::execute_dupes(&dupes_options)
730            .map_err(|_| generic_analysis_error("dupes"))?;
731        build_duplication_json(
732            &result.report,
733            &result.config.root,
734            result.elapsed,
735            resolved.explain,
736        )
737        .map_err(|err| {
738            ProgrammaticError::new(format!("failed to serialize duplication report: {err}"), 2)
739                .with_code("FALLOW_SERIALIZE_DUPLICATION_REPORT")
740                .with_context("dupes")
741        })
742    })
743}
744
745fn build_complexity_options<'a>(
746    resolved: &'a ResolvedAnalysisOptions,
747    options: &'a ComplexityOptions,
748) -> HealthOptions<'a> {
749    let ownership = options.ownership || options.ownership_emails.is_some();
750    let hotspots = options.hotspots || ownership;
751    let targets = options.targets || options.effort.is_some();
752    let any_section = options.complexity
753        || options.file_scores
754        || options.coverage_gaps
755        || hotspots
756        || targets
757        || options.score;
758    let eff_score = if any_section { options.score } else { true };
759    let force_full = eff_score;
760    let score_only_output = options.score
761        && !options.complexity
762        && !options.file_scores
763        && !options.coverage_gaps
764        && !hotspots
765        && !targets;
766    let eff_file_scores = if any_section {
767        options.file_scores
768    } else {
769        true
770    } || force_full;
771    let eff_hotspots = if any_section { hotspots } else { true };
772    let eff_complexity = if any_section {
773        options.complexity
774    } else {
775        true
776    };
777    let eff_targets = if any_section { targets } else { true };
778    let eff_coverage_gaps = if any_section {
779        options.coverage_gaps
780    } else {
781        false
782    };
783
784    HealthOptions {
785        root: &resolved.root,
786        config_path: &resolved.config_path,
787        output: OutputFormat::Human,
788        no_cache: resolved.no_cache,
789        threads: resolved.threads,
790        quiet: true,
791        max_cyclomatic: options.max_cyclomatic,
792        max_cognitive: options.max_cognitive,
793        max_crap: options.max_crap,
794        top: options.top,
795        sort: options.sort.to_cli(),
796        production: resolved.production_override.unwrap_or(false),
797        production_override: resolved.production_override,
798        changed_since: resolved.changed_since.as_deref(),
799        diff_index: resolved.diff_index(),
800        use_shared_diff_index: false,
801        workspace: resolved.workspace.as_deref(),
802        changed_workspaces: resolved.changed_workspaces.as_deref(),
803        baseline: None,
804        save_baseline: None,
805        complexity: eff_complexity,
806        file_scores: eff_file_scores,
807        coverage_gaps: eff_coverage_gaps,
808        config_activates_coverage_gaps: !any_section,
809        hotspots: eff_hotspots,
810        ownership: ownership && eff_hotspots,
811        ownership_emails: options.ownership_emails.map(OwnershipEmailMode::to_config),
812        targets: eff_targets,
813        force_full,
814        score_only_output,
815        enforce_coverage_gap_gate: true,
816        effort: options.effort.map(TargetEffort::to_cli),
817        score: eff_score,
818        min_score: None,
819        since: options.since.as_deref(),
820        min_commits: options.min_commits,
821        explain: resolved.explain,
822        summary: false,
823        save_snapshot: None,
824        trend: false,
825        group_by: None,
826        coverage: options.coverage.as_deref(),
827        coverage_root: options.coverage_root.as_deref(),
828        performance: false,
829        min_severity: None,
830        runtime_coverage: None,
831    }
832}
833
834/// Run the health / complexity analysis and return the CLI JSON contract as a value.
835pub fn compute_complexity(options: &ComplexityOptions) -> ProgrammaticResult<serde_json::Value> {
836    let resolved = options.analysis.resolve()?;
837    if let Some(path) = &options.coverage
838        && !path.exists()
839    {
840        return Err(ProgrammaticError::new(
841            format!("coverage path does not exist: {}", path.display()),
842            2,
843        )
844        .with_code("FALLOW_INVALID_COVERAGE_PATH")
845        .with_context("health.coverage"));
846    }
847    if let Err(message) =
848        crate::health::scoring::validate_coverage_root_absolute(options.coverage_root.as_deref())
849    {
850        return Err(ProgrammaticError::new(message, 2)
851            .with_code("FALLOW_INVALID_COVERAGE_ROOT")
852            .with_context("health.coverage_root"));
853    }
854
855    resolved.install(|| {
856        let health_options = build_complexity_options(&resolved, options);
857        let result = crate::health::execute_health(&health_options)
858            .map_err(|_| generic_analysis_error("health"))?;
859        build_health_json(
860            &result.report,
861            &result.config.root,
862            result.elapsed,
863            resolved.explain,
864        )
865        .map_err(|err| {
866            ProgrammaticError::new(format!("failed to serialize health report: {err}"), 2)
867                .with_code("FALLOW_SERIALIZE_HEALTH_REPORT")
868                .with_context("health")
869        })
870    })
871}
872
873/// Alias for `compute_complexity` with a more product-oriented name.
874pub fn compute_health(options: &ComplexityOptions) -> ProgrammaticResult<serde_json::Value> {
875    compute_complexity(options)
876}
877
878#[cfg(test)]
879mod tests {
880    use super::*;
881    use crate::report::test_helpers::sample_results;
882    use std::process::Command;
883
884    const SHARED_DIFF_CHILD_ENV: &str = "FALLOW_PROGRAMMATIC_SHARED_DIFF_CHILD";
885    const SHARED_DIFF_CHILD_TEST: &str =
886        "programmatic::tests::programmatic_without_diff_file_ignores_shared_diff_cache";
887
888    #[test]
889    fn circular_dependency_filter_clears_other_issue_types() {
890        let root = PathBuf::from("/project");
891        let results = sample_results(&root);
892        let filtered = filter_for_circular_dependencies(&results);
893        let json = build_dead_code_json(&filtered, &root, std::time::Duration::ZERO, false, false)
894            .expect("should serialize");
895
896        assert_eq!(json["circular_dependencies"].as_array().unwrap().len(), 1);
897        assert_eq!(json["boundary_violations"].as_array().unwrap().len(), 0);
898        assert_eq!(json["unused_files"].as_array().unwrap().len(), 0);
899        assert_eq!(json["summary"]["total_issues"], serde_json::Value::from(1));
900    }
901
902    #[test]
903    fn boundary_violation_filter_clears_other_issue_types() {
904        let root = PathBuf::from("/project");
905        let results = sample_results(&root);
906        let filtered = filter_for_boundary_violations(&results);
907        let json = build_dead_code_json(&filtered, &root, std::time::Duration::ZERO, false, false)
908            .expect("should serialize");
909
910        assert_eq!(json["boundary_violations"].as_array().unwrap().len(), 1);
911        assert_eq!(json["circular_dependencies"].as_array().unwrap().len(), 0);
912        assert_eq!(json["unused_exports"].as_array().unwrap().len(), 0);
913        assert_eq!(json["summary"]["total_issues"], serde_json::Value::from(1));
914    }
915
916    #[test]
917    fn dead_code_without_production_override_uses_per_analysis_config() {
918        let dir = tempfile::tempdir().expect("temp dir");
919        let root = dir.path();
920        std::fs::create_dir_all(root.join("src")).unwrap();
921        std::fs::write(
922            root.join("package.json"),
923            r#"{"name":"programmatic-production","main":"src/index.ts"}"#,
924        )
925        .unwrap();
926        std::fs::write(root.join("src/index.ts"), "export const ok = 1;\n").unwrap();
927        std::fs::write(root.join("src/utils.test.ts"), "export const dead = 1;\n").unwrap();
928        std::fs::write(
929            root.join(".fallowrc.json"),
930            r#"{"production":{"deadCode":true,"health":false,"dupes":false}}"#,
931        )
932        .unwrap();
933
934        let options = DeadCodeOptions {
935            analysis: AnalysisOptions {
936                root: Some(root.to_path_buf()),
937                ..AnalysisOptions::default()
938            },
939            ..DeadCodeOptions::default()
940        };
941        let json = detect_dead_code(&options).expect("analysis should succeed");
942        let paths = unused_file_paths(&json);
943
944        assert!(
945            !paths.iter().any(|path| path.ends_with("utils.test.ts")),
946            "omitted production option should defer to production.deadCode=true config: {paths:?}"
947        );
948    }
949
950    #[test]
951    fn dead_code_explicit_production_false_overrides_config() {
952        let dir = tempfile::tempdir().expect("temp dir");
953        let root = dir.path();
954        std::fs::create_dir_all(root.join("src")).unwrap();
955        std::fs::write(
956            root.join("package.json"),
957            r#"{"name":"programmatic-production","main":"src/index.ts"}"#,
958        )
959        .unwrap();
960        std::fs::write(root.join("src/index.ts"), "export const ok = 1;\n").unwrap();
961        std::fs::write(root.join("src/utils.test.ts"), "export const dead = 1;\n").unwrap();
962        std::fs::write(
963            root.join(".fallowrc.json"),
964            r#"{"production":{"deadCode":true,"health":false,"dupes":false}}"#,
965        )
966        .unwrap();
967
968        let options = DeadCodeOptions {
969            analysis: AnalysisOptions {
970                root: Some(root.to_path_buf()),
971                production_override: Some(false),
972                ..AnalysisOptions::default()
973            },
974            ..DeadCodeOptions::default()
975        };
976        let json = detect_dead_code(&options).expect("analysis should succeed");
977        let paths = unused_file_paths(&json);
978
979        assert!(
980            paths.iter().any(|path| path.ends_with("utils.test.ts")),
981            "explicit production=false should include test files despite config: {paths:?}"
982        );
983    }
984
985    #[test]
986    fn analysis_resolve_uses_per_call_thread_pool() {
987        let dir = tempfile::tempdir().expect("temp dir");
988        let root = dir.path();
989
990        let one = AnalysisOptions {
991            root: Some(root.to_path_buf()),
992            threads: Some(1),
993            ..AnalysisOptions::default()
994        }
995        .resolve()
996        .expect("one-thread options should resolve");
997        let two = AnalysisOptions {
998            root: Some(root.to_path_buf()),
999            threads: Some(2),
1000            ..AnalysisOptions::default()
1001        }
1002        .resolve()
1003        .expect("two-thread options should resolve");
1004
1005        assert_eq!(one.install(rayon::current_num_threads), 1);
1006        assert_eq!(two.install(rayon::current_num_threads), 2);
1007    }
1008
1009    #[test]
1010    fn explicit_diff_file_scopes_dead_code_per_call() {
1011        let dir = tempfile::tempdir().expect("temp dir");
1012        let root = dir.path();
1013        std::fs::create_dir_all(root.join("src")).unwrap();
1014        std::fs::write(
1015            root.join("package.json"),
1016            r#"{"name":"programmatic-diff","main":"src/index.ts"}"#,
1017        )
1018        .unwrap();
1019        std::fs::write(
1020            root.join("src/index.ts"),
1021            "import { used } from './used';\nimport './a';\nimport './b';\nconsole.log(used);\n",
1022        )
1023        .unwrap();
1024        std::fs::write(root.join("src/used.ts"), "export const used = 1;\n").unwrap();
1025        std::fs::write(root.join("src/a.ts"), "export const deadA = 1;\n").unwrap();
1026        std::fs::write(root.join("src/b.ts"), "export const deadB = 1;\n").unwrap();
1027        std::fs::write(
1028            root.join("a.diff"),
1029            diff_for("src/a.ts", "export const deadA = 1;\n"),
1030        )
1031        .unwrap();
1032        std::fs::write(
1033            root.join("b.diff"),
1034            diff_for("src/b.ts", "export const deadB = 1;\n"),
1035        )
1036        .unwrap();
1037
1038        let filters = DeadCodeFilters {
1039            unused_exports: true,
1040            ..DeadCodeFilters::default()
1041        };
1042
1043        let a_json = detect_dead_code(&DeadCodeOptions {
1044            analysis: AnalysisOptions {
1045                root: Some(root.to_path_buf()),
1046                diff_file: Some(PathBuf::from("a.diff")),
1047                ..AnalysisOptions::default()
1048            },
1049            filters: filters.clone(),
1050            ..DeadCodeOptions::default()
1051        })
1052        .expect("a-scoped analysis should succeed");
1053        let b_json = detect_dead_code(&DeadCodeOptions {
1054            analysis: AnalysisOptions {
1055                root: Some(root.to_path_buf()),
1056                diff_file: Some(PathBuf::from("b.diff")),
1057                ..AnalysisOptions::default()
1058            },
1059            filters,
1060            ..DeadCodeOptions::default()
1061        })
1062        .expect("b-scoped analysis should succeed");
1063
1064        assert_eq!(unused_export_names(&a_json), vec!["deadA"]);
1065        assert_eq!(unused_export_names(&b_json), vec!["deadB"]);
1066    }
1067
1068    #[test]
1069    fn programmatic_without_diff_file_ignores_shared_diff_cache() {
1070        if std::env::var_os(SHARED_DIFF_CHILD_ENV).is_some() {
1071            run_programmatic_shared_diff_child();
1072            return;
1073        }
1074
1075        let current_exe = std::env::current_exe().expect("current test binary should be known");
1076        let output = Command::new(current_exe)
1077            .arg("--exact")
1078            .arg(SHARED_DIFF_CHILD_TEST)
1079            .arg("--nocapture")
1080            .env(SHARED_DIFF_CHILD_ENV, "1")
1081            .output()
1082            .expect("shared diff child should start");
1083
1084        assert!(
1085            output.status.success(),
1086            "shared diff child failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
1087            output.status.code(),
1088            String::from_utf8_lossy(&output.stdout),
1089            String::from_utf8_lossy(&output.stderr)
1090        );
1091    }
1092
1093    fn run_programmatic_shared_diff_child() {
1094        let dir = tempfile::tempdir().expect("temp dir");
1095        let root = dir.path();
1096        std::fs::create_dir_all(root.join("src")).unwrap();
1097        std::fs::write(
1098            root.join("package.json"),
1099            r#"{"name":"programmatic-shared-diff","main":"src/index.ts"}"#,
1100        )
1101        .unwrap();
1102        std::fs::write(
1103            root.join("src/index.ts"),
1104            "import { used } from './used';\nimport './a';\nimport './b';\nconsole.log(used);\n",
1105        )
1106        .unwrap();
1107        std::fs::write(root.join("src/used.ts"), "export const used = 1;\n").unwrap();
1108        std::fs::write(root.join("src/a.ts"), "export const deadA = 1;\n").unwrap();
1109        std::fs::write(root.join("src/b.ts"), "export const deadB = 1;\n").unwrap();
1110        std::fs::write(
1111            root.join("a.diff"),
1112            diff_for("src/a.ts", "export const deadA = 1;\n"),
1113        )
1114        .unwrap();
1115
1116        let source = crate::report::ci::diff_filter::DiffSource::Flag(root.join("a.diff"));
1117        let loaded = crate::report::ci::diff_filter::init_shared_diff(Some(&source), true);
1118        assert!(loaded.is_some(), "shared diff should load in child process");
1119
1120        let json = detect_dead_code(&DeadCodeOptions {
1121            analysis: AnalysisOptions {
1122                root: Some(root.to_path_buf()),
1123                ..AnalysisOptions::default()
1124            },
1125            filters: DeadCodeFilters {
1126                unused_exports: true,
1127                ..DeadCodeFilters::default()
1128            },
1129            ..DeadCodeOptions::default()
1130        })
1131        .expect("analysis without explicit diff should succeed");
1132
1133        assert_eq!(unused_export_names(&json), vec!["deadA", "deadB"]);
1134    }
1135
1136    #[test]
1137    fn explicit_diff_file_rejects_stdin_sentinel() {
1138        let dir = tempfile::tempdir().expect("temp dir");
1139        let Err(error) = AnalysisOptions {
1140            root: Some(dir.path().to_path_buf()),
1141            diff_file: Some(PathBuf::from("-")),
1142            ..AnalysisOptions::default()
1143        }
1144        .resolve() else {
1145            panic!("stdin sentinel is not part of the programmatic API");
1146        };
1147
1148        assert_eq!(error.code.as_deref(), Some("FALLOW_INVALID_DIFF_FILE"));
1149        assert_eq!(error.context.as_deref(), Some("analysis.diffFile"));
1150    }
1151
1152    fn unused_file_paths(json: &serde_json::Value) -> Vec<String> {
1153        json["unused_files"]
1154            .as_array()
1155            .unwrap()
1156            .iter()
1157            .filter_map(|file| file["path"].as_str())
1158            .map(str::to_owned)
1159            .collect()
1160    }
1161
1162    fn unused_export_names(json: &serde_json::Value) -> Vec<String> {
1163        let mut names: Vec<String> = json["unused_exports"]
1164            .as_array()
1165            .unwrap()
1166            .iter()
1167            .filter_map(|export| export["export_name"].as_str())
1168            .map(str::to_owned)
1169            .collect();
1170        names.sort();
1171        names
1172    }
1173
1174    fn diff_for(path: &str, line: &str) -> String {
1175        format!("diff --git a/{path} b/{path}\n--- /dev/null\n+++ b/{path}\n@@ -0,0 +1 @@\n+{line}")
1176    }
1177}