Skip to main content

fallow_cli/
programmatic.rs

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