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};
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    /// Legacy convenience override. `true` forces production mode; `false`
72    /// defers to config unless `production_override` is set.
73    pub production: bool,
74    /// Explicit production override from an embedder option. `None` means
75    /// use the project config for the current analysis.
76    pub production_override: Option<bool>,
77    pub changed_since: Option<String>,
78    pub workspace: Option<Vec<String>>,
79    pub changed_workspaces: Option<String>,
80    pub explain: bool,
81}
82
83/// Issue-type filters for the dead-code analysis.
84#[derive(Debug, Clone, Default)]
85pub struct DeadCodeFilters {
86    pub unused_files: bool,
87    pub unused_exports: bool,
88    pub unused_deps: bool,
89    pub unused_types: bool,
90    pub private_type_leaks: bool,
91    pub unused_enum_members: bool,
92    pub unused_class_members: bool,
93    pub unresolved_imports: bool,
94    pub unlisted_deps: bool,
95    pub duplicate_exports: bool,
96    pub circular_deps: bool,
97    pub re_export_cycles: bool,
98    pub boundary_violations: bool,
99    pub stale_suppressions: bool,
100    pub unused_catalog_entries: bool,
101    pub empty_catalog_groups: bool,
102    pub unresolved_catalog_references: bool,
103    pub unused_dependency_overrides: bool,
104    pub misconfigured_dependency_overrides: bool,
105}
106
107/// Options for dead-code-oriented analyses.
108#[derive(Debug, Clone, Default)]
109pub struct DeadCodeOptions {
110    pub analysis: AnalysisOptions,
111    pub filters: DeadCodeFilters,
112    pub files: Vec<PathBuf>,
113    pub include_entry_exports: bool,
114}
115
116/// Programmatic duplication mode selection.
117#[derive(Debug, Clone, Copy, Default)]
118pub enum DuplicationMode {
119    Strict,
120    #[default]
121    Mild,
122    Weak,
123    Semantic,
124}
125
126impl DuplicationMode {
127    const fn to_cli(self) -> DupesMode {
128        match self {
129            Self::Strict => DupesMode::Strict,
130            Self::Mild => DupesMode::Mild,
131            Self::Weak => DupesMode::Weak,
132            Self::Semantic => DupesMode::Semantic,
133        }
134    }
135}
136
137/// Options for duplication analysis.
138#[derive(Debug, Clone)]
139pub struct DuplicationOptions {
140    pub analysis: AnalysisOptions,
141    pub mode: DuplicationMode,
142    pub min_tokens: usize,
143    pub min_lines: usize,
144    /// Minimum number of occurrences (instances) before a clone group is
145    /// reported. Values below 2 are silently treated as 2 (a single
146    /// occurrence isn't a duplicate, so the engine no-ops). The CLI and
147    /// MCP surfaces hard-reject `< 2` at parse time; the programmatic
148    /// path is permissive because callers may construct this from
149    /// untyped configuration.
150    pub min_occurrences: usize,
151    pub threshold: f64,
152    pub skip_local: bool,
153    pub cross_language: bool,
154    pub ignore_imports: bool,
155    pub top: Option<usize>,
156}
157
158impl Default for DuplicationOptions {
159    fn default() -> Self {
160        Self {
161            analysis: AnalysisOptions::default(),
162            mode: DuplicationMode::Mild,
163            min_tokens: 50,
164            min_lines: 5,
165            min_occurrences: 2,
166            threshold: 0.0,
167            skip_local: false,
168            cross_language: false,
169            ignore_imports: false,
170            top: None,
171        }
172    }
173}
174
175/// Sort criteria for complexity findings.
176#[derive(Debug, Clone, Copy, Default)]
177pub enum ComplexitySort {
178    #[default]
179    Cyclomatic,
180    Cognitive,
181    Lines,
182    Severity,
183}
184
185impl ComplexitySort {
186    const fn to_cli(self) -> SortBy {
187        match self {
188            Self::Severity => SortBy::Severity,
189            Self::Cyclomatic => SortBy::Cyclomatic,
190            Self::Cognitive => SortBy::Cognitive,
191            Self::Lines => SortBy::Lines,
192        }
193    }
194}
195
196/// Privacy mode for ownership-aware hotspot output.
197#[derive(Debug, Clone, Copy, Default)]
198pub enum OwnershipEmailMode {
199    Raw,
200    #[default]
201    Handle,
202    Hash,
203}
204
205impl OwnershipEmailMode {
206    const fn to_config(self) -> EmailMode {
207        match self {
208            Self::Raw => EmailMode::Raw,
209            Self::Handle => EmailMode::Handle,
210            Self::Hash => EmailMode::Hash,
211        }
212    }
213}
214
215/// Effort filter for refactoring targets.
216#[derive(Debug, Clone, Copy)]
217pub enum TargetEffort {
218    Low,
219    Medium,
220    High,
221}
222
223impl TargetEffort {
224    const fn to_cli(self) -> EffortEstimate {
225        match self {
226            Self::Low => EffortEstimate::Low,
227            Self::Medium => EffortEstimate::Medium,
228            Self::High => EffortEstimate::High,
229        }
230    }
231}
232
233/// Options for complexity / health analysis.
234#[derive(Debug, Clone, Default)]
235pub struct ComplexityOptions {
236    pub analysis: AnalysisOptions,
237    pub max_cyclomatic: Option<u16>,
238    pub max_cognitive: Option<u16>,
239    pub max_crap: Option<f64>,
240    pub top: Option<usize>,
241    pub sort: ComplexitySort,
242    pub complexity: bool,
243    pub file_scores: bool,
244    pub coverage_gaps: bool,
245    pub hotspots: bool,
246    pub ownership: bool,
247    pub ownership_emails: Option<OwnershipEmailMode>,
248    pub targets: bool,
249    pub effort: Option<TargetEffort>,
250    pub score: bool,
251    pub since: Option<String>,
252    pub min_commits: Option<u32>,
253    pub coverage: Option<PathBuf>,
254    pub coverage_root: Option<PathBuf>,
255}
256
257#[derive(Debug, Clone)]
258struct ResolvedAnalysisOptions {
259    root: PathBuf,
260    config_path: Option<PathBuf>,
261    no_cache: bool,
262    threads: usize,
263    production_override: Option<bool>,
264    changed_since: Option<String>,
265    workspace: Option<Vec<String>>,
266    changed_workspaces: Option<String>,
267    explain: bool,
268}
269
270impl AnalysisOptions {
271    fn resolve(&self) -> ProgrammaticResult<ResolvedAnalysisOptions> {
272        if self.threads == Some(0) {
273            return Err(
274                ProgrammaticError::new("`threads` must be greater than 0", 2)
275                    .with_code("FALLOW_INVALID_THREADS")
276                    .with_context("analysis.threads"),
277            );
278        }
279        if self.workspace.is_some() && self.changed_workspaces.is_some() {
280            return Err(ProgrammaticError::new(
281                "`workspace` and `changed_workspaces` are mutually exclusive",
282                2,
283            )
284            .with_code("FALLOW_MUTUALLY_EXCLUSIVE_OPTIONS")
285            .with_context("analysis.workspace"));
286        }
287
288        let root = if let Some(root) = &self.root {
289            root.clone()
290        } else {
291            std::env::current_dir().map_err(|err| {
292                ProgrammaticError::new(
293                    format!("failed to resolve current working directory: {err}"),
294                    2,
295                )
296                .with_code("FALLOW_CWD_UNAVAILABLE")
297                .with_context("analysis.root")
298            })?
299        };
300
301        if !root.exists() {
302            return Err(ProgrammaticError::new(
303                format!("analysis root does not exist: {}", root.display()),
304                2,
305            )
306            .with_code("FALLOW_INVALID_ROOT")
307            .with_context("analysis.root"));
308        }
309        if !root.is_dir() {
310            return Err(ProgrammaticError::new(
311                format!("analysis root is not a directory: {}", root.display()),
312                2,
313            )
314            .with_code("FALLOW_INVALID_ROOT")
315            .with_context("analysis.root"));
316        }
317
318        if let Some(config_path) = &self.config_path
319            && !config_path.exists()
320        {
321            return Err(ProgrammaticError::new(
322                format!("config file does not exist: {}", config_path.display()),
323                2,
324            )
325            .with_code("FALLOW_INVALID_CONFIG_PATH")
326            .with_context("analysis.configPath"));
327        }
328
329        let threads = self.threads.unwrap_or_else(default_threads);
330        crate::rayon_pool::configure_global_pool(threads);
331        let production_override = self
332            .production_override
333            .or_else(|| self.production.then_some(true));
334
335        Ok(ResolvedAnalysisOptions {
336            root,
337            config_path: self.config_path.clone(),
338            no_cache: self.no_cache,
339            threads,
340            production_override,
341            changed_since: self.changed_since.clone(),
342            workspace: self.workspace.clone(),
343            changed_workspaces: self.changed_workspaces.clone(),
344            explain: self.explain,
345        })
346    }
347}
348
349fn default_threads() -> usize {
350    std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
351}
352
353fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
354    if let serde_json::Value::Object(map) = output {
355        map.insert("_meta".to_string(), meta);
356    }
357}
358
359fn build_dead_code_json(
360    results: &AnalysisResults,
361    root: &Path,
362    elapsed: std::time::Duration,
363    explain: bool,
364    config_fixable: bool,
365) -> ProgrammaticResult<serde_json::Value> {
366    let mut output =
367        crate::report::build_json_with_config_fixable(results, root, elapsed, config_fixable)
368            .map_err(|err| {
369                ProgrammaticError::new(format!("failed to serialize dead-code report: {err}"), 2)
370                    .with_code("FALLOW_SERIALIZE_DEAD_CODE_REPORT")
371                    .with_context("dead-code")
372            })?;
373    if explain {
374        insert_meta(&mut output, crate::explain::check_meta());
375    }
376    Ok(output)
377}
378
379fn to_issue_filters(filters: &DeadCodeFilters) -> IssueFilters {
380    IssueFilters {
381        unused_files: filters.unused_files,
382        unused_exports: filters.unused_exports,
383        unused_deps: filters.unused_deps,
384        unused_types: filters.unused_types,
385        private_type_leaks: filters.private_type_leaks,
386        unused_enum_members: filters.unused_enum_members,
387        unused_class_members: filters.unused_class_members,
388        unresolved_imports: filters.unresolved_imports,
389        unlisted_deps: filters.unlisted_deps,
390        duplicate_exports: filters.duplicate_exports,
391        circular_deps: filters.circular_deps,
392        re_export_cycles: filters.re_export_cycles,
393        boundary_violations: filters.boundary_violations,
394        stale_suppressions: filters.stale_suppressions,
395        unused_catalog_entries: filters.unused_catalog_entries,
396        empty_catalog_groups: filters.empty_catalog_groups,
397        unresolved_catalog_references: filters.unresolved_catalog_references,
398        unused_dependency_overrides: filters.unused_dependency_overrides,
399        misconfigured_dependency_overrides: filters.misconfigured_dependency_overrides,
400    }
401}
402
403fn generic_analysis_error(command: &str) -> ProgrammaticError {
404    let code = format!(
405        "FALLOW_{}_FAILED",
406        command.replace('-', "_").to_ascii_uppercase()
407    );
408    ProgrammaticError::new(format!("{command} failed"), 2)
409        .with_code(code)
410        .with_context(format!("fallow {command}"))
411        .with_help(format!(
412            "Re-run `fallow {command} --format json --quiet` in the target project for CLI diagnostics"
413        ))
414}
415
416fn build_check_options<'a>(
417    resolved: &'a ResolvedAnalysisOptions,
418    options: &'a DeadCodeOptions,
419    filters: &'a IssueFilters,
420    trace_opts: &'a TraceOptions,
421) -> CheckOptions<'a> {
422    CheckOptions {
423        root: &resolved.root,
424        config_path: &resolved.config_path,
425        output: OutputFormat::Human,
426        no_cache: resolved.no_cache,
427        threads: resolved.threads,
428        quiet: true,
429        fail_on_issues: false,
430        filters,
431        changed_since: resolved.changed_since.as_deref(),
432        baseline: None,
433        save_baseline: None,
434        sarif_file: None,
435        production: resolved.production_override.unwrap_or(false),
436        production_override: resolved.production_override,
437        workspace: resolved.workspace.as_deref(),
438        changed_workspaces: resolved.changed_workspaces.as_deref(),
439        group_by: None,
440        include_dupes: false,
441        trace_opts,
442        explain: resolved.explain,
443        top: None,
444        file: &options.files,
445        include_entry_exports: options.include_entry_exports,
446        summary: false,
447        regression_opts: crate::regression::RegressionOpts {
448            fail_on_regression: false,
449            tolerance: crate::regression::Tolerance::Absolute(0),
450            regression_baseline_file: None,
451            save_target: crate::regression::SaveRegressionTarget::None,
452            scoped: false,
453            quiet: true,
454            output: fallow_config::OutputFormat::Json,
455        },
456        retain_modules_for_health: false,
457        defer_performance: false,
458    }
459}
460
461fn filter_for_circular_dependencies(results: &AnalysisResults) -> AnalysisResults {
462    let mut filtered = results.clone();
463    filtered.unused_files.clear();
464    filtered.unused_exports.clear();
465    filtered.unused_types.clear();
466    filtered.private_type_leaks.clear();
467    filtered.unused_dependencies.clear();
468    filtered.unused_dev_dependencies.clear();
469    filtered.unused_optional_dependencies.clear();
470    filtered.unused_enum_members.clear();
471    filtered.unused_class_members.clear();
472    filtered.unresolved_imports.clear();
473    filtered.unlisted_dependencies.clear();
474    filtered.duplicate_exports.clear();
475    filtered.type_only_dependencies.clear();
476    filtered.test_only_dependencies.clear();
477    filtered.boundary_violations.clear();
478    filtered.stale_suppressions.clear();
479    filtered
480}
481
482fn filter_for_boundary_violations(results: &AnalysisResults) -> AnalysisResults {
483    let mut filtered = results.clone();
484    filtered.unused_files.clear();
485    filtered.unused_exports.clear();
486    filtered.unused_types.clear();
487    filtered.private_type_leaks.clear();
488    filtered.unused_dependencies.clear();
489    filtered.unused_dev_dependencies.clear();
490    filtered.unused_optional_dependencies.clear();
491    filtered.unused_enum_members.clear();
492    filtered.unused_class_members.clear();
493    filtered.unresolved_imports.clear();
494    filtered.unlisted_dependencies.clear();
495    filtered.duplicate_exports.clear();
496    filtered.type_only_dependencies.clear();
497    filtered.test_only_dependencies.clear();
498    filtered.circular_dependencies.clear();
499    filtered.stale_suppressions.clear();
500    filtered
501}
502
503/// Run the dead-code analysis and return the CLI JSON contract as a value.
504pub fn detect_dead_code(options: &DeadCodeOptions) -> ProgrammaticResult<serde_json::Value> {
505    let resolved = options.analysis.resolve()?;
506    let filters = to_issue_filters(&options.filters);
507    let trace_opts = TraceOptions {
508        trace_export: None,
509        trace_file: None,
510        trace_dependency: None,
511        performance: false,
512    };
513    let check_options = build_check_options(&resolved, options, &filters, &trace_opts);
514    let result = crate::check::execute_check(&check_options)
515        .map_err(|_| generic_analysis_error("dead-code"))?;
516    build_dead_code_json(
517        &result.results,
518        &result.config.root,
519        result.elapsed,
520        resolved.explain,
521        result.config_fixable,
522    )
523}
524
525/// Run the circular-dependency analysis and return the standard dead-code JSON envelope
526/// filtered down to the `circular_dependencies` category.
527pub fn detect_circular_dependencies(
528    options: &DeadCodeOptions,
529) -> ProgrammaticResult<serde_json::Value> {
530    let resolved = options.analysis.resolve()?;
531    let filters = to_issue_filters(&options.filters);
532    let trace_opts = TraceOptions {
533        trace_export: None,
534        trace_file: None,
535        trace_dependency: None,
536        performance: false,
537    };
538    let check_options = build_check_options(&resolved, options, &filters, &trace_opts);
539    let result = crate::check::execute_check(&check_options)
540        .map_err(|_| generic_analysis_error("dead-code"))?;
541    let filtered = filter_for_circular_dependencies(&result.results);
542    build_dead_code_json(
543        &filtered,
544        &result.config.root,
545        result.elapsed,
546        resolved.explain,
547        result.config_fixable,
548    )
549}
550
551/// Run the boundary-violation analysis and return the standard dead-code JSON envelope
552/// filtered down to the `boundary_violations` category.
553pub fn detect_boundary_violations(
554    options: &DeadCodeOptions,
555) -> ProgrammaticResult<serde_json::Value> {
556    let resolved = options.analysis.resolve()?;
557    let filters = to_issue_filters(&options.filters);
558    let trace_opts = TraceOptions {
559        trace_export: None,
560        trace_file: None,
561        trace_dependency: None,
562        performance: false,
563    };
564    let check_options = build_check_options(&resolved, options, &filters, &trace_opts);
565    let result = crate::check::execute_check(&check_options)
566        .map_err(|_| generic_analysis_error("dead-code"))?;
567    let filtered = filter_for_boundary_violations(&result.results);
568    build_dead_code_json(
569        &filtered,
570        &result.config.root,
571        result.elapsed,
572        resolved.explain,
573        result.config_fixable,
574    )
575}
576
577/// Run the duplication analysis and return the CLI JSON contract as a value.
578pub fn detect_duplication(options: &DuplicationOptions) -> ProgrammaticResult<serde_json::Value> {
579    let resolved = options.analysis.resolve()?;
580    let dupes_options = DupesOptions {
581        root: &resolved.root,
582        config_path: &resolved.config_path,
583        output: OutputFormat::Human,
584        no_cache: resolved.no_cache,
585        threads: resolved.threads,
586        quiet: true,
587        // The programmatic API requires callers to provide concrete values
588        // (the public `DuplicationOptions` has no Optional scalars), so we
589        // forward each as an explicit override.
590        mode: Some(options.mode.to_cli()),
591        min_tokens: Some(options.min_tokens),
592        min_lines: Some(options.min_lines),
593        min_occurrences: Some(options.min_occurrences),
594        threshold: Some(options.threshold),
595        skip_local: options.skip_local,
596        cross_language: options.cross_language,
597        ignore_imports: options.ignore_imports,
598        top: options.top,
599        baseline_path: None,
600        save_baseline_path: None,
601        production: resolved.production_override.unwrap_or(false),
602        production_override: resolved.production_override,
603        trace: None,
604        changed_since: resolved.changed_since.as_deref(),
605        changed_files: None,
606        workspace: resolved.workspace.as_deref(),
607        changed_workspaces: resolved.changed_workspaces.as_deref(),
608        explain: resolved.explain,
609        explain_skipped: false,
610        summary: false,
611        group_by: None,
612        // The programmatic API returns structured JSON; performance panels go
613        // to stderr in human mode and are not part of the public contract.
614        performance: false,
615    };
616    let result =
617        crate::dupes::execute_dupes(&dupes_options).map_err(|_| generic_analysis_error("dupes"))?;
618    build_duplication_json(
619        &result.report,
620        &result.config.root,
621        result.elapsed,
622        resolved.explain,
623    )
624    .map_err(|err| {
625        ProgrammaticError::new(format!("failed to serialize duplication report: {err}"), 2)
626            .with_code("FALLOW_SERIALIZE_DUPLICATION_REPORT")
627            .with_context("dupes")
628    })
629}
630
631fn build_complexity_options<'a>(
632    resolved: &'a ResolvedAnalysisOptions,
633    options: &'a ComplexityOptions,
634) -> HealthOptions<'a> {
635    let ownership = options.ownership || options.ownership_emails.is_some();
636    let hotspots = options.hotspots || ownership;
637    let targets = options.targets || options.effort.is_some();
638    let any_section = options.complexity
639        || options.file_scores
640        || options.coverage_gaps
641        || hotspots
642        || targets
643        || options.score;
644    let eff_score = if any_section { options.score } else { true };
645    let force_full = eff_score;
646    let score_only_output = options.score
647        && !options.complexity
648        && !options.file_scores
649        && !options.coverage_gaps
650        && !hotspots
651        && !targets;
652    let eff_file_scores = if any_section {
653        options.file_scores
654    } else {
655        true
656    } || force_full;
657    let eff_hotspots = if any_section { hotspots } else { true };
658    let eff_complexity = if any_section {
659        options.complexity
660    } else {
661        true
662    };
663    let eff_targets = if any_section { targets } else { true };
664    let eff_coverage_gaps = if any_section {
665        options.coverage_gaps
666    } else {
667        false
668    };
669
670    HealthOptions {
671        root: &resolved.root,
672        config_path: &resolved.config_path,
673        output: OutputFormat::Human,
674        no_cache: resolved.no_cache,
675        threads: resolved.threads,
676        quiet: true,
677        max_cyclomatic: options.max_cyclomatic,
678        max_cognitive: options.max_cognitive,
679        max_crap: options.max_crap,
680        top: options.top,
681        sort: options.sort.to_cli(),
682        production: resolved.production_override.unwrap_or(false),
683        production_override: resolved.production_override,
684        changed_since: resolved.changed_since.as_deref(),
685        workspace: resolved.workspace.as_deref(),
686        changed_workspaces: resolved.changed_workspaces.as_deref(),
687        baseline: None,
688        save_baseline: None,
689        complexity: eff_complexity,
690        file_scores: eff_file_scores,
691        coverage_gaps: eff_coverage_gaps,
692        config_activates_coverage_gaps: !any_section,
693        hotspots: eff_hotspots,
694        ownership: ownership && eff_hotspots,
695        ownership_emails: options.ownership_emails.map(OwnershipEmailMode::to_config),
696        targets: eff_targets,
697        force_full,
698        score_only_output,
699        enforce_coverage_gap_gate: true,
700        effort: options.effort.map(TargetEffort::to_cli),
701        score: eff_score,
702        min_score: None,
703        since: options.since.as_deref(),
704        min_commits: options.min_commits,
705        explain: resolved.explain,
706        summary: false,
707        save_snapshot: None,
708        trend: false,
709        group_by: None,
710        coverage: options.coverage.as_deref(),
711        coverage_root: options.coverage_root.as_deref(),
712        performance: false,
713        min_severity: None,
714        runtime_coverage: None,
715        // Programmatic API does not surface line-level PR scoping; callers
716        // that want it populate the process-wide diff cache via
717        // `crate::report::ci::diff_filter::init_shared_diff(...)` before
718        // calling `compute_complexity`.
719    }
720}
721
722/// Run the health / complexity analysis and return the CLI JSON contract as a value.
723pub fn compute_complexity(options: &ComplexityOptions) -> ProgrammaticResult<serde_json::Value> {
724    let resolved = options.analysis.resolve()?;
725    if let Some(path) = &options.coverage
726        && !path.exists()
727    {
728        return Err(ProgrammaticError::new(
729            format!("coverage path does not exist: {}", path.display()),
730            2,
731        )
732        .with_code("FALLOW_INVALID_COVERAGE_PATH")
733        .with_context("health.coverage"));
734    }
735    if let Err(message) =
736        crate::health::scoring::validate_coverage_root_absolute(options.coverage_root.as_deref())
737    {
738        return Err(ProgrammaticError::new(message, 2)
739            .with_code("FALLOW_INVALID_COVERAGE_ROOT")
740            .with_context("health.coverage_root"));
741    }
742
743    let health_options = build_complexity_options(&resolved, options);
744    let result = crate::health::execute_health(&health_options)
745        .map_err(|_| generic_analysis_error("health"))?;
746    build_health_json(
747        &result.report,
748        &result.config.root,
749        result.elapsed,
750        resolved.explain,
751    )
752    .map_err(|err| {
753        ProgrammaticError::new(format!("failed to serialize health report: {err}"), 2)
754            .with_code("FALLOW_SERIALIZE_HEALTH_REPORT")
755            .with_context("health")
756    })
757}
758
759/// Alias for `compute_complexity` with a more product-oriented name.
760pub fn compute_health(options: &ComplexityOptions) -> ProgrammaticResult<serde_json::Value> {
761    compute_complexity(options)
762}
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767    use crate::report::test_helpers::sample_results;
768
769    #[test]
770    fn circular_dependency_filter_clears_other_issue_types() {
771        let root = PathBuf::from("/project");
772        let results = sample_results(&root);
773        let filtered = filter_for_circular_dependencies(&results);
774        let json = build_dead_code_json(&filtered, &root, std::time::Duration::ZERO, false, false)
775            .expect("should serialize");
776
777        assert_eq!(json["circular_dependencies"].as_array().unwrap().len(), 1);
778        assert_eq!(json["boundary_violations"].as_array().unwrap().len(), 0);
779        assert_eq!(json["unused_files"].as_array().unwrap().len(), 0);
780        assert_eq!(json["summary"]["total_issues"], serde_json::Value::from(1));
781    }
782
783    #[test]
784    fn boundary_violation_filter_clears_other_issue_types() {
785        let root = PathBuf::from("/project");
786        let results = sample_results(&root);
787        let filtered = filter_for_boundary_violations(&results);
788        let json = build_dead_code_json(&filtered, &root, std::time::Duration::ZERO, false, false)
789            .expect("should serialize");
790
791        assert_eq!(json["boundary_violations"].as_array().unwrap().len(), 1);
792        assert_eq!(json["circular_dependencies"].as_array().unwrap().len(), 0);
793        assert_eq!(json["unused_exports"].as_array().unwrap().len(), 0);
794        assert_eq!(json["summary"]["total_issues"], serde_json::Value::from(1));
795    }
796
797    #[test]
798    fn dead_code_without_production_override_uses_per_analysis_config() {
799        let dir = tempfile::tempdir().expect("temp dir");
800        let root = dir.path();
801        std::fs::create_dir_all(root.join("src")).unwrap();
802        std::fs::write(
803            root.join("package.json"),
804            r#"{"name":"programmatic-production","main":"src/index.ts"}"#,
805        )
806        .unwrap();
807        std::fs::write(root.join("src/index.ts"), "export const ok = 1;\n").unwrap();
808        std::fs::write(root.join("src/utils.test.ts"), "export const dead = 1;\n").unwrap();
809        std::fs::write(
810            root.join(".fallowrc.json"),
811            r#"{"production":{"deadCode":true,"health":false,"dupes":false}}"#,
812        )
813        .unwrap();
814
815        let options = DeadCodeOptions {
816            analysis: AnalysisOptions {
817                root: Some(root.to_path_buf()),
818                ..AnalysisOptions::default()
819            },
820            ..DeadCodeOptions::default()
821        };
822        let json = detect_dead_code(&options).expect("analysis should succeed");
823        let paths = unused_file_paths(&json);
824
825        assert!(
826            !paths.iter().any(|path| path.ends_with("utils.test.ts")),
827            "omitted production option should defer to production.deadCode=true config: {paths:?}"
828        );
829    }
830
831    #[test]
832    fn dead_code_explicit_production_false_overrides_config() {
833        let dir = tempfile::tempdir().expect("temp dir");
834        let root = dir.path();
835        std::fs::create_dir_all(root.join("src")).unwrap();
836        std::fs::write(
837            root.join("package.json"),
838            r#"{"name":"programmatic-production","main":"src/index.ts"}"#,
839        )
840        .unwrap();
841        std::fs::write(root.join("src/index.ts"), "export const ok = 1;\n").unwrap();
842        std::fs::write(root.join("src/utils.test.ts"), "export const dead = 1;\n").unwrap();
843        std::fs::write(
844            root.join(".fallowrc.json"),
845            r#"{"production":{"deadCode":true,"health":false,"dupes":false}}"#,
846        )
847        .unwrap();
848
849        let options = DeadCodeOptions {
850            analysis: AnalysisOptions {
851                root: Some(root.to_path_buf()),
852                production_override: Some(false),
853                ..AnalysisOptions::default()
854            },
855            ..DeadCodeOptions::default()
856        };
857        let json = detect_dead_code(&options).expect("analysis should succeed");
858        let paths = unused_file_paths(&json);
859
860        assert!(
861            paths.iter().any(|path| path.ends_with("utils.test.ts")),
862            "explicit production=false should include test files despite config: {paths:?}"
863        );
864    }
865
866    fn unused_file_paths(json: &serde_json::Value) -> Vec<String> {
867        json["unused_files"]
868            .as_array()
869            .unwrap()
870            .iter()
871            .filter_map(|file| file["path"].as_str())
872            .map(str::to_owned)
873            .collect()
874    }
875}