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