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