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