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