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