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