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