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::ci::diff_filter::{DiffIndex, LoadedDiff, MAX_DIFF_BYTES};
12use crate::report::{build_duplication_json, build_health_json};
13
14pub const COMMON_ANALYSIS_OPTION_FLAGS: &[&str] = &[
15    "root",
16    "config",
17    "no-cache",
18    "threads",
19    "changed-since",
20    "diff-file",
21    "production",
22    "workspace",
23    "changed-workspaces",
24    "explain",
25    "legacy-envelope",
26];
27
28/// Structured error surface for the programmatic API.
29#[derive(Debug, Clone, Serialize)]
30pub struct ProgrammaticError {
31    pub message: String,
32    pub exit_code: u8,
33    pub code: Option<String>,
34    pub help: Option<String>,
35    pub context: Option<String>,
36}
37
38impl ProgrammaticError {
39    #[must_use]
40    pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
41        Self {
42            message: message.into(),
43            exit_code,
44            code: None,
45            help: None,
46            context: None,
47        }
48    }
49
50    #[must_use]
51    pub fn with_help(mut self, help: impl Into<String>) -> Self {
52        self.help = Some(help.into());
53        self
54    }
55
56    #[must_use]
57    pub fn with_code(mut self, code: impl Into<String>) -> Self {
58        self.code = Some(code.into());
59        self
60    }
61
62    #[must_use]
63    pub fn with_context(mut self, context: impl Into<String>) -> Self {
64        self.context = Some(context.into());
65        self
66    }
67}
68
69impl std::fmt::Display for ProgrammaticError {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        write!(f, "{}", self.message)
72    }
73}
74
75impl std::error::Error for ProgrammaticError {}
76
77type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
78
79/// Shared options for all one-shot analyses.
80#[derive(Debug, Clone, Default)]
81pub struct AnalysisOptions {
82    pub root: Option<PathBuf>,
83    pub config_path: Option<PathBuf>,
84    pub no_cache: bool,
85    pub threads: Option<usize>,
86    pub diff_file: Option<PathBuf>,
87    /// Legacy convenience override. `true` forces production mode; `false`
88    /// defers to config unless `production_override` is set.
89    pub production: bool,
90    /// Explicit production override from an embedder option. `None` means
91    /// use the project config for the current analysis.
92    pub production_override: Option<bool>,
93    pub changed_since: Option<String>,
94    pub workspace: Option<Vec<String>>,
95    pub changed_workspaces: Option<String>,
96    pub explain: bool,
97    /// Return the one-cycle legacy root envelope without top-level `kind`.
98    pub legacy_envelope: bool,
99}
100
101/// Issue-type filters for the dead-code analysis.
102#[derive(Debug, Clone, Default)]
103pub struct DeadCodeFilters {
104    pub unused_files: bool,
105    pub unused_exports: bool,
106    pub unused_deps: bool,
107    pub unused_types: bool,
108    pub private_type_leaks: bool,
109    pub unused_enum_members: bool,
110    pub unused_class_members: bool,
111    pub unused_store_members: bool,
112    pub unprovided_injects: bool,
113    pub unrendered_components: bool,
114    pub unused_component_props: bool,
115    pub unused_component_emits: bool,
116    pub unused_component_inputs: bool,
117    pub unused_component_outputs: bool,
118    pub unused_svelte_events: bool,
119    pub unused_server_actions: bool,
120    pub unused_load_data_keys: bool,
121    pub unresolved_imports: bool,
122    pub unlisted_deps: bool,
123    pub duplicate_exports: bool,
124    pub circular_deps: bool,
125    pub re_export_cycles: bool,
126    pub boundary_violations: bool,
127    pub policy_violations: bool,
128    pub stale_suppressions: bool,
129    pub unused_catalog_entries: bool,
130    pub empty_catalog_groups: bool,
131    pub unresolved_catalog_references: bool,
132    pub unused_dependency_overrides: bool,
133    pub misconfigured_dependency_overrides: bool,
134}
135
136/// Options for dead-code-oriented analyses.
137#[derive(Debug, Clone, Default)]
138pub struct DeadCodeOptions {
139    pub analysis: AnalysisOptions,
140    pub filters: DeadCodeFilters,
141    pub files: Vec<PathBuf>,
142    pub include_entry_exports: bool,
143}
144
145/// Programmatic duplication mode selection.
146#[derive(Debug, Clone, Copy, Default)]
147pub enum DuplicationMode {
148    Strict,
149    #[default]
150    Mild,
151    Weak,
152    Semantic,
153}
154
155impl DuplicationMode {
156    const fn to_cli(self) -> DupesMode {
157        match self {
158            Self::Strict => DupesMode::Strict,
159            Self::Mild => DupesMode::Mild,
160            Self::Weak => DupesMode::Weak,
161            Self::Semantic => DupesMode::Semantic,
162        }
163    }
164}
165
166/// Options for duplication analysis.
167#[derive(Debug, Clone)]
168pub struct DuplicationOptions {
169    pub analysis: AnalysisOptions,
170    pub mode: DuplicationMode,
171    pub min_tokens: usize,
172    pub min_lines: usize,
173    /// Minimum number of occurrences (instances) before a clone group is
174    /// reported. Values below 2 are silently treated as 2 (a single
175    /// occurrence isn't a duplicate, so the engine no-ops). The CLI and
176    /// MCP surfaces hard-reject `< 2` at parse time; the programmatic
177    /// path is permissive because callers may construct this from
178    /// untyped configuration.
179    pub min_occurrences: usize,
180    pub threshold: f64,
181    pub skip_local: bool,
182    pub cross_language: bool,
183    /// Exclude module wiring from clone detection. `None` defers to the project
184    /// config (which defaults to `true` since #1224); `Some(false)` forces
185    /// module wiring to be counted again.
186    pub ignore_imports: Option<bool>,
187    pub top: Option<usize>,
188}
189
190impl Default for DuplicationOptions {
191    fn default() -> Self {
192        Self {
193            analysis: AnalysisOptions::default(),
194            mode: DuplicationMode::Mild,
195            min_tokens: 50,
196            min_lines: 5,
197            min_occurrences: 2,
198            threshold: 0.0,
199            skip_local: false,
200            cross_language: false,
201            ignore_imports: None,
202            top: None,
203        }
204    }
205}
206
207/// Sort criteria for complexity findings.
208#[derive(Debug, Clone, Copy, Default)]
209pub enum ComplexitySort {
210    #[default]
211    Cyclomatic,
212    Cognitive,
213    Lines,
214    Severity,
215}
216
217impl ComplexitySort {
218    const fn to_cli(self) -> SortBy {
219        match self {
220            Self::Severity => SortBy::Severity,
221            Self::Cyclomatic => SortBy::Cyclomatic,
222            Self::Cognitive => SortBy::Cognitive,
223            Self::Lines => SortBy::Lines,
224        }
225    }
226}
227
228/// Privacy mode for ownership-aware hotspot output.
229#[derive(Debug, Clone, Copy, Default)]
230pub enum OwnershipEmailMode {
231    Raw,
232    #[default]
233    Handle,
234    Anonymized,
235    /// Legacy spelling retained for embedders that already pass `hash`.
236    Hash,
237}
238
239impl OwnershipEmailMode {
240    const fn to_config(self) -> EmailMode {
241        match self {
242            Self::Raw => EmailMode::Raw,
243            Self::Handle => EmailMode::Handle,
244            Self::Anonymized => EmailMode::Anonymized,
245            Self::Hash => EmailMode::Hash,
246        }
247    }
248}
249
250/// Effort filter for refactoring targets.
251#[derive(Debug, Clone, Copy)]
252pub enum TargetEffort {
253    Low,
254    Medium,
255    High,
256}
257
258impl TargetEffort {
259    const fn to_cli(self) -> EffortEstimate {
260        match self {
261            Self::Low => EffortEstimate::Low,
262            Self::Medium => EffortEstimate::Medium,
263            Self::High => EffortEstimate::High,
264        }
265    }
266}
267
268/// Options for complexity / health analysis.
269#[derive(Debug, Clone, Default)]
270pub struct ComplexityOptions {
271    pub analysis: AnalysisOptions,
272    pub max_cyclomatic: Option<u16>,
273    pub max_cognitive: Option<u16>,
274    pub max_crap: Option<f64>,
275    pub top: Option<usize>,
276    pub sort: ComplexitySort,
277    pub complexity: bool,
278    pub file_scores: bool,
279    pub coverage_gaps: bool,
280    pub hotspots: bool,
281    pub ownership: bool,
282    pub ownership_emails: Option<OwnershipEmailMode>,
283    pub targets: bool,
284    pub effort: Option<TargetEffort>,
285    pub score: bool,
286    pub since: Option<String>,
287    pub min_commits: Option<u32>,
288    pub coverage: Option<PathBuf>,
289    pub coverage_root: Option<PathBuf>,
290}
291
292struct ResolvedAnalysisOptions {
293    root: PathBuf,
294    config_path: Option<PathBuf>,
295    no_cache: bool,
296    threads: usize,
297    pool: rayon::ThreadPool,
298    diff: Option<LoadedDiff>,
299    production_override: Option<bool>,
300    changed_since: Option<String>,
301    workspace: Option<Vec<String>>,
302    changed_workspaces: Option<String>,
303    explain: bool,
304    legacy_envelope: bool,
305}
306
307impl AnalysisOptions {
308    fn resolve(&self) -> ProgrammaticResult<ResolvedAnalysisOptions> {
309        if self.threads == Some(0) {
310            return Err(
311                ProgrammaticError::new("`threads` must be greater than 0", 2)
312                    .with_code("FALLOW_INVALID_THREADS")
313                    .with_context("analysis.threads"),
314            );
315        }
316        if self.workspace.is_some() && self.changed_workspaces.is_some() {
317            return Err(ProgrammaticError::new(
318                "`workspace` and `changed_workspaces` are mutually exclusive",
319                2,
320            )
321            .with_code("FALLOW_MUTUALLY_EXCLUSIVE_OPTIONS")
322            .with_context("analysis.workspace"));
323        }
324
325        let root = if let Some(root) = &self.root {
326            root.clone()
327        } else {
328            std::env::current_dir().map_err(|err| {
329                ProgrammaticError::new(
330                    format!("failed to resolve current working directory: {err}"),
331                    2,
332                )
333                .with_code("FALLOW_CWD_UNAVAILABLE")
334                .with_context("analysis.root")
335            })?
336        };
337
338        if !root.exists() {
339            return Err(ProgrammaticError::new(
340                format!("analysis root does not exist: {}", root.display()),
341                2,
342            )
343            .with_code("FALLOW_INVALID_ROOT")
344            .with_context("analysis.root"));
345        }
346        if !root.is_dir() {
347            return Err(ProgrammaticError::new(
348                format!("analysis root is not a directory: {}", root.display()),
349                2,
350            )
351            .with_code("FALLOW_INVALID_ROOT")
352            .with_context("analysis.root"));
353        }
354
355        if let Some(config_path) = &self.config_path
356            && !config_path.exists()
357        {
358            return Err(ProgrammaticError::new(
359                format!("config file does not exist: {}", config_path.display()),
360                2,
361            )
362            .with_code("FALLOW_INVALID_CONFIG_PATH")
363            .with_context("analysis.configPath"));
364        }
365
366        let threads = self.threads.unwrap_or_else(default_threads);
367        let pool = crate::rayon_pool::build_thread_pool(threads).map_err(|err| {
368            ProgrammaticError::new(format!("failed to build analysis thread pool: {err}"), 2)
369                .with_code("FALLOW_THREAD_POOL_INIT_FAILED")
370                .with_context("analysis.threads")
371        })?;
372        let diff = self
373            .diff_file
374            .as_deref()
375            .map(|path| load_explicit_diff_file(path, &root))
376            .transpose()?;
377        let production_override = self
378            .production_override
379            .or_else(|| self.production.then_some(true));
380
381        Ok(ResolvedAnalysisOptions {
382            root,
383            config_path: self.config_path.clone(),
384            no_cache: self.no_cache,
385            threads,
386            pool,
387            diff,
388            production_override,
389            changed_since: self.changed_since.clone(),
390            workspace: self.workspace.clone(),
391            changed_workspaces: self.changed_workspaces.clone(),
392            explain: self.explain,
393            legacy_envelope: self.legacy_envelope,
394        })
395    }
396}
397
398impl ResolvedAnalysisOptions {
399    fn install<R: Send>(&self, f: impl FnOnce() -> R + Send) -> R {
400        self.pool.install(f)
401    }
402
403    fn diff_index(&self) -> Option<&DiffIndex> {
404        self.diff.as_ref().map(|loaded| &loaded.index)
405    }
406}
407
408fn default_threads() -> usize {
409    std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
410}
411
412fn load_explicit_diff_file(path: &Path, root: &Path) -> ProgrammaticResult<LoadedDiff> {
413    if path == Path::new("-") {
414        return Err(ProgrammaticError::new(
415            "`diff_file` does not support stdin; pass a file path",
416            2,
417        )
418        .with_code("FALLOW_INVALID_DIFF_FILE")
419        .with_context("analysis.diffFile"));
420    }
421
422    let abs = if crate::path_util::is_absolute_path_any_platform(path) {
423        path.to_path_buf()
424    } else {
425        root.join(path)
426    };
427
428    let meta = std::fs::metadata(&abs).map_err(|err| {
429        ProgrammaticError::new(
430            format!(
431                "diff file does not exist or cannot be read: {} ({err})",
432                abs.display()
433            ),
434            2,
435        )
436        .with_code("FALLOW_INVALID_DIFF_FILE")
437        .with_context("analysis.diffFile")
438    })?;
439    if !meta.is_file() {
440        return Err(ProgrammaticError::new(
441            format!("diff path is not a file: {}", abs.display()),
442            2,
443        )
444        .with_code("FALLOW_INVALID_DIFF_FILE")
445        .with_context("analysis.diffFile"));
446    }
447    if meta.len() > MAX_DIFF_BYTES {
448        return Err(ProgrammaticError::new(
449            format!(
450                "diff file is {} bytes, above the {MAX_DIFF_BYTES} byte limit: {}",
451                meta.len(),
452                abs.display()
453            ),
454            2,
455        )
456        .with_code("FALLOW_INVALID_DIFF_FILE")
457        .with_context("analysis.diffFile"));
458    }
459
460    let text = std::fs::read_to_string(&abs).map_err(|err| {
461        ProgrammaticError::new(
462            format!("failed to read diff file {}: {err}", abs.display()),
463            2,
464        )
465        .with_code("FALLOW_INVALID_DIFF_FILE")
466        .with_context("analysis.diffFile")
467    })?;
468
469    Ok(LoadedDiff {
470        index: DiffIndex::from_unified_diff(&text),
471    })
472}
473
474fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
475    if let serde_json::Value::Object(map) = output {
476        let telemetry = map
477            .get("_meta")
478            .and_then(|existing| existing.get("telemetry"))
479            .cloned();
480        let mut meta = meta;
481        if let (Some(telemetry), Some(meta_map)) = (telemetry, meta.as_object_mut()) {
482            meta_map.insert("telemetry".to_string(), telemetry);
483        }
484        map.insert("_meta".to_string(), meta);
485    }
486}
487
488fn apply_programmatic_envelope_options(
489    output: &mut serde_json::Value,
490    resolved: &ResolvedAnalysisOptions,
491) {
492    if resolved.legacy_envelope {
493        crate::output_envelope::remove_root_kind(output);
494    }
495}
496
497fn build_dead_code_json(
498    results: &AnalysisResults,
499    root: &Path,
500    elapsed: std::time::Duration,
501    explain: bool,
502    config_fixable: bool,
503) -> ProgrammaticResult<serde_json::Value> {
504    let mut output =
505        crate::report::build_json_with_config_fixable(results, root, elapsed, config_fixable)
506            .map_err(|err| {
507                ProgrammaticError::new(format!("failed to serialize dead-code report: {err}"), 2)
508                    .with_code("FALLOW_SERIALIZE_DEAD_CODE_REPORT")
509                    .with_context("dead-code")
510            })?;
511    if explain {
512        insert_meta(&mut output, crate::explain::check_meta());
513    }
514    // `build_dead_code_json` is only called after options have been resolved;
515    // callers apply the root-envelope compatibility setting at the boundary.
516    Ok(output)
517}
518
519fn to_issue_filters(filters: &DeadCodeFilters) -> IssueFilters {
520    IssueFilters {
521        unused_files: filters.unused_files,
522        unused_exports: filters.unused_exports,
523        unused_deps: filters.unused_deps,
524        unused_types: filters.unused_types,
525        private_type_leaks: filters.private_type_leaks,
526        unused_enum_members: filters.unused_enum_members,
527        unused_class_members: filters.unused_class_members,
528        unused_store_members: filters.unused_store_members,
529        unprovided_injects: filters.unprovided_injects,
530        unrendered_components: filters.unrendered_components,
531        unused_component_props: filters.unused_component_props,
532        unused_component_emits: filters.unused_component_emits,
533        unused_component_inputs: filters.unused_component_inputs,
534        unused_component_outputs: filters.unused_component_outputs,
535        unused_svelte_events: filters.unused_svelte_events,
536        unused_server_actions: filters.unused_server_actions,
537        unused_load_data_keys: filters.unused_load_data_keys,
538        unresolved_imports: filters.unresolved_imports,
539        unlisted_deps: filters.unlisted_deps,
540        duplicate_exports: filters.duplicate_exports,
541        circular_deps: filters.circular_deps,
542        re_export_cycles: filters.re_export_cycles,
543        boundary_violations: filters.boundary_violations,
544        policy_violations: filters.policy_violations,
545        stale_suppressions: filters.stale_suppressions,
546        unused_catalog_entries: filters.unused_catalog_entries,
547        empty_catalog_groups: filters.empty_catalog_groups,
548        unresolved_catalog_references: filters.unresolved_catalog_references,
549        unused_dependency_overrides: filters.unused_dependency_overrides,
550        misconfigured_dependency_overrides: filters.misconfigured_dependency_overrides,
551        // No programmatic filter for invalid-client-exports yet; the rule runs
552        // and reports by default. Field exists for clear-parity only.
553        invalid_client_exports: false,
554        // No programmatic filter for mixed-client-server-barrels yet; the rule
555        // runs and reports by default. Field exists for clear-parity only.
556        mixed_client_server_barrels: false,
557        // No programmatic filter for misplaced-directives yet; the rule runs and
558        // reports by default. Field exists for clear-parity only.
559        misplaced_directives: false,
560        // No programmatic filter for route-collisions / dynamic-segment-name
561        // -conflicts yet; the rules run and report by default. Fields exist for
562        // clear-parity only.
563        route_collisions: false,
564        dynamic_segment_name_conflicts: false,
565    }
566}
567
568fn generic_analysis_error(command: &str) -> ProgrammaticError {
569    let code = format!(
570        "FALLOW_{}_FAILED",
571        command.replace('-', "_").to_ascii_uppercase()
572    );
573    ProgrammaticError::new(format!("{command} failed"), 2)
574        .with_code(code)
575        .with_context(format!("fallow {command}"))
576        .with_help(format!(
577            "Re-run `fallow {command} --format json --quiet` in the target project for CLI diagnostics"
578        ))
579}
580
581fn build_check_options<'a>(
582    resolved: &'a ResolvedAnalysisOptions,
583    options: &'a DeadCodeOptions,
584    filters: &'a IssueFilters,
585    trace_opts: &'a TraceOptions,
586) -> CheckOptions<'a> {
587    CheckOptions {
588        root: &resolved.root,
589        config_path: &resolved.config_path,
590        output: OutputFormat::Human,
591        no_cache: resolved.no_cache,
592        threads: resolved.threads,
593        quiet: true,
594        fail_on_issues: false,
595        filters,
596        changed_since: resolved.changed_since.as_deref(),
597        diff_index: resolved.diff_index(),
598        use_shared_diff_index: false,
599        baseline: None,
600        save_baseline: None,
601        sarif_file: None,
602        production: resolved.production_override.unwrap_or(false),
603        production_override: resolved.production_override,
604        workspace: resolved.workspace.as_deref(),
605        changed_workspaces: resolved.changed_workspaces.as_deref(),
606        group_by: None,
607        include_dupes: false,
608        trace_opts,
609        explain: resolved.explain,
610        top: None,
611        file: &options.files,
612        include_entry_exports: options.include_entry_exports,
613        summary: false,
614        regression_opts: crate::regression::RegressionOpts {
615            fail_on_regression: false,
616            tolerance: crate::regression::Tolerance::Absolute(0),
617            regression_baseline_file: None,
618            save_target: crate::regression::SaveRegressionTarget::None,
619            scoped: false,
620            quiet: true,
621            output: fallow_config::OutputFormat::Json,
622        },
623        retain_modules_for_health: false,
624        defer_performance: false,
625    }
626}
627
628fn filter_for_circular_dependencies(results: &AnalysisResults) -> AnalysisResults {
629    let mut filtered = results.clone();
630    filtered.unused_files.clear();
631    filtered.unused_exports.clear();
632    filtered.unused_types.clear();
633    filtered.private_type_leaks.clear();
634    filtered.unused_dependencies.clear();
635    filtered.unused_dev_dependencies.clear();
636    filtered.unused_optional_dependencies.clear();
637    filtered.unused_enum_members.clear();
638    filtered.unused_class_members.clear();
639    filtered.unused_store_members.clear();
640    filtered.unprovided_injects.clear();
641    filtered.unrendered_components.clear();
642    filtered.unused_component_props.clear();
643    filtered.unused_component_emits.clear();
644    filtered.unused_component_inputs.clear();
645    filtered.unused_component_outputs.clear();
646    filtered.unused_svelte_events.clear();
647    filtered.unused_server_actions.clear();
648    filtered.unused_load_data_keys.clear();
649    filtered.unresolved_imports.clear();
650    filtered.unlisted_dependencies.clear();
651    filtered.duplicate_exports.clear();
652    filtered.type_only_dependencies.clear();
653    filtered.test_only_dependencies.clear();
654    filtered.boundary_violations.clear();
655    filtered.boundary_coverage_violations.clear();
656    filtered.boundary_call_violations.clear();
657    filtered.policy_violations.clear();
658    filtered.stale_suppressions.clear();
659    filtered
660}
661
662fn filter_for_boundary_violations(results: &AnalysisResults) -> AnalysisResults {
663    let mut filtered = results.clone();
664    filtered.unused_files.clear();
665    filtered.unused_exports.clear();
666    filtered.unused_types.clear();
667    filtered.private_type_leaks.clear();
668    filtered.unused_dependencies.clear();
669    filtered.unused_dev_dependencies.clear();
670    filtered.unused_optional_dependencies.clear();
671    filtered.unused_enum_members.clear();
672    filtered.unused_class_members.clear();
673    filtered.unused_store_members.clear();
674    filtered.unprovided_injects.clear();
675    filtered.unrendered_components.clear();
676    filtered.unused_component_props.clear();
677    filtered.unused_component_emits.clear();
678    filtered.unused_component_inputs.clear();
679    filtered.unused_component_outputs.clear();
680    filtered.unused_svelte_events.clear();
681    filtered.unused_server_actions.clear();
682    filtered.unused_load_data_keys.clear();
683    filtered.unresolved_imports.clear();
684    filtered.unlisted_dependencies.clear();
685    filtered.duplicate_exports.clear();
686    filtered.type_only_dependencies.clear();
687    filtered.test_only_dependencies.clear();
688    filtered.circular_dependencies.clear();
689    filtered.stale_suppressions.clear();
690    filtered
691}
692
693/// Run the dead-code analysis and return the CLI JSON contract as a value.
694pub fn detect_dead_code(options: &DeadCodeOptions) -> ProgrammaticResult<serde_json::Value> {
695    let resolved = options.analysis.resolve()?;
696    resolved.install(|| {
697        let filters = to_issue_filters(&options.filters);
698        let trace_opts = TraceOptions {
699            trace_export: None,
700            trace_file: None,
701            trace_dependency: None,
702            performance: false,
703        };
704        let check_options = build_check_options(&resolved, options, &filters, &trace_opts);
705        let result = crate::check::execute_check(&check_options)
706            .map_err(|_| generic_analysis_error("dead-code"))?;
707        let mut output = build_dead_code_json(
708            &result.results,
709            &result.config.root,
710            result.elapsed,
711            resolved.explain,
712            result.config_fixable,
713        )?;
714        apply_programmatic_envelope_options(&mut output, &resolved);
715        Ok(output)
716    })
717}
718
719/// Run the circular-dependency analysis and return the standard dead-code JSON envelope
720/// filtered down to the `circular_dependencies` category.
721pub fn detect_circular_dependencies(
722    options: &DeadCodeOptions,
723) -> ProgrammaticResult<serde_json::Value> {
724    let resolved = options.analysis.resolve()?;
725    resolved.install(|| {
726        let filters = to_issue_filters(&options.filters);
727        let trace_opts = TraceOptions {
728            trace_export: None,
729            trace_file: None,
730            trace_dependency: None,
731            performance: false,
732        };
733        let check_options = build_check_options(&resolved, options, &filters, &trace_opts);
734        let result = crate::check::execute_check(&check_options)
735            .map_err(|_| generic_analysis_error("dead-code"))?;
736        let filtered = filter_for_circular_dependencies(&result.results);
737        let mut output = build_dead_code_json(
738            &filtered,
739            &result.config.root,
740            result.elapsed,
741            resolved.explain,
742            result.config_fixable,
743        )?;
744        apply_programmatic_envelope_options(&mut output, &resolved);
745        Ok(output)
746    })
747}
748
749/// Run the boundary-violation analysis and return the standard dead-code JSON envelope
750/// filtered down to the boundary family: `boundary_violations`,
751/// `boundary_coverage_violations`, and `boundary_call_violations`.
752pub fn detect_boundary_violations(
753    options: &DeadCodeOptions,
754) -> ProgrammaticResult<serde_json::Value> {
755    let resolved = options.analysis.resolve()?;
756    resolved.install(|| {
757        let filters = to_issue_filters(&options.filters);
758        let trace_opts = TraceOptions {
759            trace_export: None,
760            trace_file: None,
761            trace_dependency: None,
762            performance: false,
763        };
764        let check_options = build_check_options(&resolved, options, &filters, &trace_opts);
765        let result = crate::check::execute_check(&check_options)
766            .map_err(|_| generic_analysis_error("dead-code"))?;
767        let filtered = filter_for_boundary_violations(&result.results);
768        let mut output = build_dead_code_json(
769            &filtered,
770            &result.config.root,
771            result.elapsed,
772            resolved.explain,
773            result.config_fixable,
774        )?;
775        apply_programmatic_envelope_options(&mut output, &resolved);
776        Ok(output)
777    })
778}
779
780/// Run the duplication analysis and return the CLI JSON contract as a value.
781pub fn detect_duplication(options: &DuplicationOptions) -> ProgrammaticResult<serde_json::Value> {
782    let resolved = options.analysis.resolve()?;
783    resolved.install(|| {
784        let dupes_options = DupesOptions {
785            root: &resolved.root,
786            config_path: &resolved.config_path,
787            output: OutputFormat::Human,
788            no_cache: resolved.no_cache,
789            threads: resolved.threads,
790            quiet: true,
791            mode: Some(options.mode.to_cli()),
792            min_tokens: Some(options.min_tokens),
793            min_lines: Some(options.min_lines),
794            min_occurrences: Some(options.min_occurrences),
795            threshold: Some(options.threshold),
796            skip_local: options.skip_local,
797            cross_language: options.cross_language,
798            ignore_imports: options.ignore_imports,
799            top: options.top,
800            baseline_path: None,
801            save_baseline_path: None,
802            production: resolved.production_override.unwrap_or(false),
803            production_override: resolved.production_override,
804            trace: None,
805            changed_since: resolved.changed_since.as_deref(),
806            diff_index: resolved.diff_index(),
807            use_shared_diff_index: false,
808            changed_files: None,
809            workspace: resolved.workspace.as_deref(),
810            changed_workspaces: resolved.changed_workspaces.as_deref(),
811            explain: resolved.explain,
812            explain_skipped: false,
813            summary: false,
814            group_by: None,
815            performance: false,
816        };
817        let result = crate::dupes::execute_dupes(&dupes_options)
818            .map_err(|_| generic_analysis_error("dupes"))?;
819        let mut output = build_duplication_json(
820            &result.report,
821            &result.config.root,
822            result.elapsed,
823            resolved.explain,
824        )
825        .map_err(|err| {
826            ProgrammaticError::new(format!("failed to serialize duplication report: {err}"), 2)
827                .with_code("FALLOW_SERIALIZE_DUPLICATION_REPORT")
828                .with_context("dupes")
829        })?;
830        apply_programmatic_envelope_options(&mut output, &resolved);
831        Ok(output)
832    })
833}
834
835fn build_complexity_options<'a>(
836    resolved: &'a ResolvedAnalysisOptions,
837    options: &'a ComplexityOptions,
838) -> HealthOptions<'a> {
839    let state = derived_complexity_options(options);
840
841    HealthOptions {
842        root: &resolved.root,
843        config_path: &resolved.config_path,
844        output: OutputFormat::Human,
845        no_cache: resolved.no_cache,
846        threads: resolved.threads,
847        quiet: true,
848        max_cyclomatic: options.max_cyclomatic,
849        max_cognitive: options.max_cognitive,
850        max_crap: options.max_crap,
851        top: options.top,
852        sort: options.sort.to_cli(),
853        production: resolved.production_override.unwrap_or(false),
854        production_override: resolved.production_override,
855        changed_since: resolved.changed_since.as_deref(),
856        diff_index: resolved.diff_index(),
857        use_shared_diff_index: false,
858        workspace: resolved.workspace.as_deref(),
859        changed_workspaces: resolved.changed_workspaces.as_deref(),
860        baseline: None,
861        save_baseline: None,
862        complexity: state.complexity,
863        complexity_breakdown: false,
864        file_scores: state.file_scores,
865        coverage_gaps: state.coverage_gaps,
866        config_activates_coverage_gaps: !state.any_section,
867        hotspots: state.hotspots,
868        ownership: state.ownership,
869        ownership_emails: options.ownership_emails.map(OwnershipEmailMode::to_config),
870        targets: state.targets,
871        css: false,
872        force_full: state.force_full,
873        score_only_output: state.score_only_output,
874        enforce_coverage_gap_gate: true,
875        effort: options.effort.map(TargetEffort::to_cli),
876        score: state.score,
877        min_score: None,
878        since: options.since.as_deref(),
879        min_commits: options.min_commits,
880        explain: resolved.explain,
881        summary: false,
882        save_snapshot: None,
883        trend: false,
884        group_by: None,
885        coverage: options.coverage.as_deref(),
886        coverage_root: options.coverage_root.as_deref(),
887        performance: false,
888        min_severity: None,
889        report_only: false,
890        runtime_coverage: None,
891        // The programmatic facade has no churn-file knob; embedders that want
892        // imported hotspots call the CLI. Git churn is used when available.
893        churn_file: None,
894    }
895}
896
897struct DerivedComplexityOptions {
898    any_section: bool,
899    complexity: bool,
900    file_scores: bool,
901    coverage_gaps: bool,
902    hotspots: bool,
903    ownership: bool,
904    targets: bool,
905    force_full: bool,
906    score_only_output: bool,
907    score: bool,
908}
909
910fn derived_complexity_options(options: &ComplexityOptions) -> DerivedComplexityOptions {
911    let ownership = options.ownership || options.ownership_emails.is_some();
912    let requested_hotspots = options.hotspots || ownership;
913    let requested_targets = options.targets || options.effort.is_some();
914    let any_section = options.complexity
915        || options.file_scores
916        || options.coverage_gaps
917        || requested_hotspots
918        || requested_targets
919        || options.score;
920    let score = if any_section { options.score } else { true };
921    let hotspots = if any_section {
922        requested_hotspots
923    } else {
924        true
925    };
926
927    DerivedComplexityOptions {
928        any_section,
929        complexity: if any_section {
930            options.complexity
931        } else {
932            true
933        },
934        file_scores: effective_file_scores(options, any_section, score),
935        coverage_gaps: if any_section {
936            options.coverage_gaps
937        } else {
938            false
939        },
940        hotspots,
941        ownership: ownership && hotspots,
942        targets: if any_section { requested_targets } else { true },
943        force_full: score,
944        score_only_output: is_score_only_output(options, requested_hotspots, requested_targets),
945        score,
946    }
947}
948
949fn effective_file_scores(options: &ComplexityOptions, any_section: bool, force_full: bool) -> bool {
950    (if any_section {
951        options.file_scores
952    } else {
953        true
954    }) || force_full
955}
956
957fn is_score_only_output(options: &ComplexityOptions, hotspots: bool, targets: bool) -> bool {
958    options.score
959        && !options.complexity
960        && !options.file_scores
961        && !options.coverage_gaps
962        && !hotspots
963        && !targets
964}
965
966/// Run the health / complexity analysis and return the CLI JSON contract as a value.
967pub fn compute_complexity(options: &ComplexityOptions) -> ProgrammaticResult<serde_json::Value> {
968    let resolved = options.analysis.resolve()?;
969    if let Some(path) = &options.coverage
970        && !path.exists()
971    {
972        return Err(ProgrammaticError::new(
973            format!("coverage path does not exist: {}", path.display()),
974            2,
975        )
976        .with_code("FALLOW_INVALID_COVERAGE_PATH")
977        .with_context("health.coverage"));
978    }
979    if let Err(message) =
980        crate::health::scoring::validate_coverage_root_absolute(options.coverage_root.as_deref())
981    {
982        return Err(ProgrammaticError::new(message, 2)
983            .with_code("FALLOW_INVALID_COVERAGE_ROOT")
984            .with_context("health.coverage_root"));
985    }
986
987    resolved.install(|| {
988        let health_options = build_complexity_options(&resolved, options);
989        let result = crate::health::execute_health(&health_options)
990            .map_err(|_| generic_analysis_error("health"))?;
991        let mut output = build_health_json(
992            &result.report,
993            &result.config.root,
994            result.elapsed,
995            resolved.explain,
996        )
997        .map_err(|err| {
998            ProgrammaticError::new(format!("failed to serialize health report: {err}"), 2)
999                .with_code("FALLOW_SERIALIZE_HEALTH_REPORT")
1000                .with_context("health")
1001        })?;
1002        apply_programmatic_envelope_options(&mut output, &resolved);
1003        Ok(output)
1004    })
1005}
1006
1007/// Alias for `compute_complexity` with a more product-oriented name.
1008pub fn compute_health(options: &ComplexityOptions) -> ProgrammaticResult<serde_json::Value> {
1009    compute_complexity(options)
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014    use super::*;
1015    use crate::report::test_helpers::sample_results;
1016    use std::process::Command;
1017
1018    const SHARED_DIFF_CHILD_ENV: &str = "FALLOW_PROGRAMMATIC_SHARED_DIFF_CHILD";
1019    const SHARED_DIFF_CHILD_TEST: &str =
1020        "programmatic::tests::programmatic_without_diff_file_ignores_shared_diff_cache";
1021
1022    #[test]
1023    fn circular_dependency_filter_clears_other_issue_types() {
1024        let root = PathBuf::from("/project");
1025        let results = sample_results(&root);
1026        let filtered = filter_for_circular_dependencies(&results);
1027        let json = build_dead_code_json(&filtered, &root, std::time::Duration::ZERO, false, false)
1028            .expect("should serialize");
1029
1030        assert_eq!(json["kind"], "dead-code");
1031        assert_eq!(json["circular_dependencies"].as_array().unwrap().len(), 1);
1032        assert_eq!(json["boundary_violations"].as_array().unwrap().len(), 0);
1033        assert_eq!(json["unused_files"].as_array().unwrap().len(), 0);
1034        assert_eq!(json["summary"]["total_issues"], serde_json::Value::from(1));
1035    }
1036
1037    #[test]
1038    fn boundary_violation_filter_clears_other_issue_types() {
1039        let root = PathBuf::from("/project");
1040        let results = sample_results(&root);
1041        let filtered = filter_for_boundary_violations(&results);
1042        let json = build_dead_code_json(&filtered, &root, std::time::Duration::ZERO, false, false)
1043            .expect("should serialize");
1044
1045        assert_eq!(json["kind"], "dead-code");
1046        assert_eq!(json["boundary_violations"].as_array().unwrap().len(), 1);
1047        assert_eq!(json["circular_dependencies"].as_array().unwrap().len(), 0);
1048        assert_eq!(json["unused_exports"].as_array().unwrap().len(), 0);
1049        assert_eq!(json["summary"]["total_issues"], serde_json::Value::from(1));
1050    }
1051
1052    #[test]
1053    fn dead_code_without_production_override_uses_per_analysis_config() {
1054        let dir = tempfile::tempdir().expect("temp dir");
1055        let root = dir.path();
1056        std::fs::create_dir_all(root.join("src")).unwrap();
1057        std::fs::write(
1058            root.join("package.json"),
1059            r#"{"name":"programmatic-production","main":"src/index.ts"}"#,
1060        )
1061        .unwrap();
1062        std::fs::write(root.join("src/index.ts"), "export const ok = 1;\n").unwrap();
1063        std::fs::write(root.join("src/utils.test.ts"), "export const dead = 1;\n").unwrap();
1064        std::fs::write(
1065            root.join(".fallowrc.json"),
1066            r#"{"production":{"deadCode":true,"health":false,"dupes":false}}"#,
1067        )
1068        .unwrap();
1069
1070        let options = DeadCodeOptions {
1071            analysis: AnalysisOptions {
1072                root: Some(root.to_path_buf()),
1073                ..AnalysisOptions::default()
1074            },
1075            ..DeadCodeOptions::default()
1076        };
1077        let json = detect_dead_code(&options).expect("analysis should succeed");
1078        let paths = unused_file_paths(&json);
1079
1080        assert!(
1081            !paths.iter().any(|path| path.ends_with("utils.test.ts")),
1082            "omitted production option should defer to production.deadCode=true config: {paths:?}"
1083        );
1084    }
1085
1086    #[test]
1087    fn dead_code_legacy_envelope_removes_root_kind() {
1088        let dir = tempfile::tempdir().expect("temp dir");
1089        let root = dir.path();
1090        std::fs::create_dir_all(root.join("src")).unwrap();
1091        std::fs::write(
1092            root.join("package.json"),
1093            r#"{"name":"programmatic-legacy","main":"src/index.ts"}"#,
1094        )
1095        .unwrap();
1096        std::fs::write(root.join("src/index.ts"), "export const ok = 1;\n").unwrap();
1097
1098        let options = DeadCodeOptions {
1099            analysis: AnalysisOptions {
1100                root: Some(root.to_path_buf()),
1101                legacy_envelope: true,
1102                ..AnalysisOptions::default()
1103            },
1104            ..DeadCodeOptions::default()
1105        };
1106        let json = detect_dead_code(&options).expect("analysis should succeed");
1107
1108        assert!(json.get("kind").is_none());
1109        assert_eq!(json["schema_version"], crate::report::SCHEMA_VERSION);
1110    }
1111
1112    #[test]
1113    fn dead_code_explicit_production_false_overrides_config() {
1114        let dir = tempfile::tempdir().expect("temp dir");
1115        let root = dir.path();
1116        std::fs::create_dir_all(root.join("src")).unwrap();
1117        std::fs::write(
1118            root.join("package.json"),
1119            r#"{"name":"programmatic-production","main":"src/index.ts"}"#,
1120        )
1121        .unwrap();
1122        std::fs::write(root.join("src/index.ts"), "export const ok = 1;\n").unwrap();
1123        std::fs::write(root.join("src/utils.test.ts"), "export const dead = 1;\n").unwrap();
1124        std::fs::write(
1125            root.join(".fallowrc.json"),
1126            r#"{"production":{"deadCode":true,"health":false,"dupes":false}}"#,
1127        )
1128        .unwrap();
1129
1130        let options = DeadCodeOptions {
1131            analysis: AnalysisOptions {
1132                root: Some(root.to_path_buf()),
1133                production_override: Some(false),
1134                ..AnalysisOptions::default()
1135            },
1136            ..DeadCodeOptions::default()
1137        };
1138        let json = detect_dead_code(&options).expect("analysis should succeed");
1139        let paths = unused_file_paths(&json);
1140
1141        assert!(
1142            paths.iter().any(|path| path.ends_with("utils.test.ts")),
1143            "explicit production=false should include test files despite config: {paths:?}"
1144        );
1145    }
1146
1147    #[test]
1148    fn analysis_resolve_uses_per_call_thread_pool() {
1149        let dir = tempfile::tempdir().expect("temp dir");
1150        let root = dir.path();
1151
1152        let one = AnalysisOptions {
1153            root: Some(root.to_path_buf()),
1154            threads: Some(1),
1155            ..AnalysisOptions::default()
1156        }
1157        .resolve()
1158        .expect("one-thread options should resolve");
1159        let two = AnalysisOptions {
1160            root: Some(root.to_path_buf()),
1161            threads: Some(2),
1162            ..AnalysisOptions::default()
1163        }
1164        .resolve()
1165        .expect("two-thread options should resolve");
1166
1167        assert_eq!(one.install(rayon::current_num_threads), 1);
1168        assert_eq!(two.install(rayon::current_num_threads), 2);
1169    }
1170
1171    #[test]
1172    fn explicit_diff_file_scopes_dead_code_per_call() {
1173        let dir = tempfile::tempdir().expect("temp dir");
1174        let root = dir.path();
1175        std::fs::create_dir_all(root.join("src")).unwrap();
1176        std::fs::write(
1177            root.join("package.json"),
1178            r#"{"name":"programmatic-diff","main":"src/index.ts"}"#,
1179        )
1180        .unwrap();
1181        std::fs::write(
1182            root.join("src/index.ts"),
1183            "import { used } from './used';\nimport './a';\nimport './b';\nconsole.log(used);\n",
1184        )
1185        .unwrap();
1186        std::fs::write(root.join("src/used.ts"), "export const used = 1;\n").unwrap();
1187        std::fs::write(root.join("src/a.ts"), "export const deadA = 1;\n").unwrap();
1188        std::fs::write(root.join("src/b.ts"), "export const deadB = 1;\n").unwrap();
1189        std::fs::write(
1190            root.join("a.diff"),
1191            diff_for("src/a.ts", "export const deadA = 1;\n"),
1192        )
1193        .unwrap();
1194        std::fs::write(
1195            root.join("b.diff"),
1196            diff_for("src/b.ts", "export const deadB = 1;\n"),
1197        )
1198        .unwrap();
1199
1200        let filters = DeadCodeFilters {
1201            unused_exports: true,
1202            ..DeadCodeFilters::default()
1203        };
1204
1205        let a_json = detect_dead_code(&DeadCodeOptions {
1206            analysis: AnalysisOptions {
1207                root: Some(root.to_path_buf()),
1208                diff_file: Some(PathBuf::from("a.diff")),
1209                ..AnalysisOptions::default()
1210            },
1211            filters: filters.clone(),
1212            ..DeadCodeOptions::default()
1213        })
1214        .expect("a-scoped analysis should succeed");
1215        let b_json = detect_dead_code(&DeadCodeOptions {
1216            analysis: AnalysisOptions {
1217                root: Some(root.to_path_buf()),
1218                diff_file: Some(PathBuf::from("b.diff")),
1219                ..AnalysisOptions::default()
1220            },
1221            filters,
1222            ..DeadCodeOptions::default()
1223        })
1224        .expect("b-scoped analysis should succeed");
1225
1226        assert_eq!(unused_export_names(&a_json), vec!["deadA"]);
1227        assert_eq!(unused_export_names(&b_json), vec!["deadB"]);
1228    }
1229
1230    #[test]
1231    fn programmatic_without_diff_file_ignores_shared_diff_cache() {
1232        if std::env::var_os(SHARED_DIFF_CHILD_ENV).is_some() {
1233            run_programmatic_shared_diff_child();
1234            return;
1235        }
1236
1237        let current_exe = std::env::current_exe().expect("current test binary should be known");
1238        let output = Command::new(current_exe)
1239            .arg("--exact")
1240            .arg(SHARED_DIFF_CHILD_TEST)
1241            .arg("--nocapture")
1242            .env(SHARED_DIFF_CHILD_ENV, "1")
1243            .output()
1244            .expect("shared diff child should start");
1245
1246        assert!(
1247            output.status.success(),
1248            "shared diff child failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
1249            output.status.code(),
1250            String::from_utf8_lossy(&output.stdout),
1251            String::from_utf8_lossy(&output.stderr)
1252        );
1253    }
1254
1255    fn run_programmatic_shared_diff_child() {
1256        let dir = tempfile::tempdir().expect("temp dir");
1257        let root = dir.path();
1258        std::fs::create_dir_all(root.join("src")).unwrap();
1259        std::fs::write(
1260            root.join("package.json"),
1261            r#"{"name":"programmatic-shared-diff","main":"src/index.ts"}"#,
1262        )
1263        .unwrap();
1264        std::fs::write(
1265            root.join("src/index.ts"),
1266            "import { used } from './used';\nimport './a';\nimport './b';\nconsole.log(used);\n",
1267        )
1268        .unwrap();
1269        std::fs::write(root.join("src/used.ts"), "export const used = 1;\n").unwrap();
1270        std::fs::write(root.join("src/a.ts"), "export const deadA = 1;\n").unwrap();
1271        std::fs::write(root.join("src/b.ts"), "export const deadB = 1;\n").unwrap();
1272        std::fs::write(
1273            root.join("a.diff"),
1274            diff_for("src/a.ts", "export const deadA = 1;\n"),
1275        )
1276        .unwrap();
1277
1278        let source = crate::report::ci::diff_filter::DiffSource::Flag(root.join("a.diff"));
1279        let loaded = crate::report::ci::diff_filter::init_shared_diff(Some(&source), true);
1280        assert!(loaded.is_some(), "shared diff should load in child process");
1281
1282        let json = detect_dead_code(&DeadCodeOptions {
1283            analysis: AnalysisOptions {
1284                root: Some(root.to_path_buf()),
1285                ..AnalysisOptions::default()
1286            },
1287            filters: DeadCodeFilters {
1288                unused_exports: true,
1289                ..DeadCodeFilters::default()
1290            },
1291            ..DeadCodeOptions::default()
1292        })
1293        .expect("analysis without explicit diff should succeed");
1294
1295        assert_eq!(unused_export_names(&json), vec!["deadA", "deadB"]);
1296    }
1297
1298    #[test]
1299    fn explicit_diff_file_rejects_stdin_sentinel() {
1300        let dir = tempfile::tempdir().expect("temp dir");
1301        let Err(error) = AnalysisOptions {
1302            root: Some(dir.path().to_path_buf()),
1303            diff_file: Some(PathBuf::from("-")),
1304            ..AnalysisOptions::default()
1305        }
1306        .resolve() else {
1307            panic!("stdin sentinel is not part of the programmatic API");
1308        };
1309
1310        assert_eq!(error.code.as_deref(), Some("FALLOW_INVALID_DIFF_FILE"));
1311        assert_eq!(error.context.as_deref(), Some("analysis.diffFile"));
1312    }
1313
1314    /// Minimal valid project used by the end-to-end programmatic entry points.
1315    fn tiny_project() -> tempfile::TempDir {
1316        let dir = tempfile::tempdir().expect("temp dir");
1317        let root = dir.path();
1318        std::fs::create_dir_all(root.join("src")).unwrap();
1319        std::fs::write(
1320            root.join("package.json"),
1321            r#"{"name":"prog-e2e","main":"src/index.ts"}"#,
1322        )
1323        .unwrap();
1324        std::fs::write(
1325            root.join("src/index.ts"),
1326            "export const ok = 1;\nconsole.log(ok);\n",
1327        )
1328        .unwrap();
1329        dir
1330    }
1331
1332    fn analysis_at(root: &Path) -> AnalysisOptions {
1333        AnalysisOptions {
1334            root: Some(root.to_path_buf()),
1335            ..AnalysisOptions::default()
1336        }
1337    }
1338
1339    #[test]
1340    fn resolve_rejects_zero_threads() {
1341        let err = AnalysisOptions {
1342            threads: Some(0),
1343            ..AnalysisOptions::default()
1344        }
1345        .resolve()
1346        .err()
1347        .expect("zero threads must be rejected");
1348        assert_eq!(err.exit_code, 2);
1349        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_THREADS"));
1350        assert_eq!(err.context.as_deref(), Some("analysis.threads"));
1351    }
1352
1353    #[test]
1354    fn resolve_rejects_mutually_exclusive_workspace_flags() {
1355        let err = AnalysisOptions {
1356            workspace: Some(vec!["packages/*".to_owned()]),
1357            changed_workspaces: Some("HEAD~1".to_owned()),
1358            ..AnalysisOptions::default()
1359        }
1360        .resolve()
1361        .err()
1362        .expect("workspace + changed_workspaces must be rejected");
1363        assert_eq!(
1364            err.code.as_deref(),
1365            Some("FALLOW_MUTUALLY_EXCLUSIVE_OPTIONS")
1366        );
1367        assert_eq!(err.context.as_deref(), Some("analysis.workspace"));
1368    }
1369
1370    #[test]
1371    fn resolve_rejects_nonexistent_root() {
1372        let err = AnalysisOptions {
1373            root: Some(PathBuf::from("/definitely/not/a/real/path/xyzzy")),
1374            ..AnalysisOptions::default()
1375        }
1376        .resolve()
1377        .err()
1378        .expect("nonexistent root must be rejected");
1379        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_ROOT"));
1380        assert_eq!(err.context.as_deref(), Some("analysis.root"));
1381    }
1382
1383    #[test]
1384    fn resolve_rejects_root_that_is_a_file() {
1385        let dir = tempfile::tempdir().expect("temp dir");
1386        let file = dir.path().join("not-a-dir.txt");
1387        std::fs::write(&file, "x").unwrap();
1388        let err = AnalysisOptions {
1389            root: Some(file),
1390            ..AnalysisOptions::default()
1391        }
1392        .resolve()
1393        .err()
1394        .expect("a file root must be rejected");
1395        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_ROOT"));
1396    }
1397
1398    #[test]
1399    fn resolve_rejects_nonexistent_config_path() {
1400        let dir = tempfile::tempdir().expect("temp dir");
1401        let err = AnalysisOptions {
1402            root: Some(dir.path().to_path_buf()),
1403            config_path: Some(dir.path().join("missing.fallowrc.json")),
1404            ..AnalysisOptions::default()
1405        }
1406        .resolve()
1407        .err()
1408        .expect("nonexistent config must be rejected");
1409        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_CONFIG_PATH"));
1410        assert_eq!(err.context.as_deref(), Some("analysis.configPath"));
1411    }
1412
1413    #[test]
1414    fn resolve_rejects_missing_diff_file() {
1415        let dir = tempfile::tempdir().expect("temp dir");
1416        let err = AnalysisOptions {
1417            root: Some(dir.path().to_path_buf()),
1418            diff_file: Some(PathBuf::from("nope.diff")),
1419            ..AnalysisOptions::default()
1420        }
1421        .resolve()
1422        .err()
1423        .expect("missing diff file must be rejected");
1424        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_DIFF_FILE"));
1425        assert_eq!(err.context.as_deref(), Some("analysis.diffFile"));
1426    }
1427
1428    #[test]
1429    fn resolve_rejects_diff_path_that_is_a_directory() {
1430        let dir = tempfile::tempdir().expect("temp dir");
1431        std::fs::create_dir_all(dir.path().join("a-dir")).unwrap();
1432        let err = AnalysisOptions {
1433            root: Some(dir.path().to_path_buf()),
1434            diff_file: Some(PathBuf::from("a-dir")),
1435            ..AnalysisOptions::default()
1436        }
1437        .resolve()
1438        .err()
1439        .expect("a directory diff path must be rejected");
1440        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_DIFF_FILE"));
1441    }
1442
1443    #[test]
1444    fn detect_circular_dependencies_returns_dead_code_envelope() {
1445        let project = tiny_project();
1446        let json = detect_circular_dependencies(&DeadCodeOptions {
1447            analysis: analysis_at(project.path()),
1448            ..DeadCodeOptions::default()
1449        })
1450        .expect("circular-dependency analysis should succeed");
1451        assert_eq!(json["kind"], "dead-code");
1452        assert!(json["circular_dependencies"].is_array());
1453    }
1454
1455    #[test]
1456    fn detect_boundary_violations_returns_dead_code_envelope() {
1457        let project = tiny_project();
1458        let json = detect_boundary_violations(&DeadCodeOptions {
1459            analysis: analysis_at(project.path()),
1460            ..DeadCodeOptions::default()
1461        })
1462        .expect("boundary-violation analysis should succeed");
1463        assert_eq!(json["kind"], "dead-code");
1464        assert!(json["boundary_violations"].is_array());
1465    }
1466
1467    #[test]
1468    fn detect_boundary_violations_includes_boundary_coverage() {
1469        let project = tiny_project();
1470        let root = project.path();
1471        std::fs::write(
1472            root.join(".fallowrc.json"),
1473            r#"{
1474              "boundaries": {
1475                "zones": [
1476                  { "name": "domain", "patterns": ["src/domain/**"] }
1477                ],
1478                "coverage": { "requireAllFiles": true }
1479              }
1480            }"#,
1481        )
1482        .unwrap();
1483
1484        let json = detect_boundary_violations(&DeadCodeOptions {
1485            analysis: analysis_at(root),
1486            ..DeadCodeOptions::default()
1487        })
1488        .expect("boundary-violation analysis should succeed");
1489
1490        let coverage = json["boundary_coverage_violations"]
1491            .as_array()
1492            .expect("coverage findings should be an array");
1493        assert_eq!(coverage.len(), 1);
1494        assert_eq!(coverage[0]["path"], "src/index.ts");
1495        assert_eq!(json["summary"]["boundary_coverage_violations"], 1);
1496    }
1497
1498    #[test]
1499    fn detect_boundary_violations_includes_boundary_calls() {
1500        let project = tiny_project();
1501        let root = project.path();
1502        std::fs::write(
1503            root.join("src/index.ts"),
1504            "console.log('hello');\nexport const x = 1;\n",
1505        )
1506        .unwrap();
1507        std::fs::write(
1508            root.join(".fallowrc.json"),
1509            r#"{
1510              "boundaries": {
1511                "zones": [
1512                  { "name": "domain", "patterns": ["src/**"] }
1513                ],
1514                "calls": {
1515                  "forbidden": [
1516                    { "from": "domain", "callee": "console.*" }
1517                  ]
1518                }
1519              }
1520            }"#,
1521        )
1522        .unwrap();
1523
1524        let json = detect_boundary_violations(&DeadCodeOptions {
1525            analysis: analysis_at(root),
1526            ..DeadCodeOptions::default()
1527        })
1528        .expect("boundary-violation analysis should succeed");
1529
1530        let calls = json["boundary_call_violations"]
1531            .as_array()
1532            .expect("boundary call findings should be an array");
1533        assert_eq!(calls.len(), 1);
1534        assert_eq!(calls[0]["path"], "src/index.ts");
1535        assert_eq!(calls[0]["zone"], "domain");
1536        assert_eq!(calls[0]["callee"], "console.log");
1537        assert_eq!(calls[0]["pattern"], "console.*");
1538        assert_eq!(json["summary"]["boundary_call_violations"], 1);
1539    }
1540
1541    #[test]
1542    fn detect_duplication_returns_dupes_envelope() {
1543        let project = tiny_project();
1544        let json = detect_duplication(&DuplicationOptions {
1545            analysis: analysis_at(project.path()),
1546            ..DuplicationOptions::default()
1547        })
1548        .expect("duplication analysis should succeed");
1549        assert_eq!(json["kind"], "dupes");
1550        // DupesOutput.report is `#[serde(flatten)]`, so its fields are top-level.
1551        assert!(json["clone_groups"].is_array());
1552        assert!(json["stats"].is_object());
1553    }
1554
1555    #[test]
1556    fn compute_health_returns_health_envelope() {
1557        let project = tiny_project();
1558        let options = ComplexityOptions {
1559            analysis: analysis_at(project.path()),
1560            ..ComplexityOptions::default()
1561        };
1562        // compute_health is a thin alias for compute_complexity.
1563        let json = compute_health(&options).expect("health analysis should succeed");
1564        assert_eq!(json["kind"], "health");
1565        // HealthOutput.report is `#[serde(flatten)]`, so its fields are top-level.
1566        assert!(json["summary"].is_object());
1567        assert!(json["findings"].is_array());
1568    }
1569
1570    #[test]
1571    fn compute_complexity_rejects_missing_coverage_path() {
1572        let project = tiny_project();
1573        let err = compute_complexity(&ComplexityOptions {
1574            analysis: analysis_at(project.path()),
1575            coverage: Some(project.path().join("missing-coverage.json")),
1576            ..ComplexityOptions::default()
1577        })
1578        .expect_err("a missing coverage path must be rejected");
1579        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1580        assert_eq!(err.context.as_deref(), Some("health.coverage"));
1581    }
1582
1583    #[test]
1584    fn compute_complexity_rejects_relative_coverage_root() {
1585        let project = tiny_project();
1586        let err = compute_complexity(&ComplexityOptions {
1587            analysis: analysis_at(project.path()),
1588            coverage_root: Some(PathBuf::from("relative/prefix")),
1589            ..ComplexityOptions::default()
1590        })
1591        .expect_err("a relative coverage_root must be rejected");
1592        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_ROOT"));
1593        assert_eq!(err.context.as_deref(), Some("health.coverage_root"));
1594    }
1595
1596    #[test]
1597    fn programmatic_error_builders_compose_and_display() {
1598        let err = ProgrammaticError::new("boom", 7)
1599            .with_code("FALLOW_X")
1600            .with_help("try again")
1601            .with_context("ctx.path");
1602        assert_eq!(err.message, "boom");
1603        assert_eq!(err.exit_code, 7);
1604        assert_eq!(err.code.as_deref(), Some("FALLOW_X"));
1605        assert_eq!(err.help.as_deref(), Some("try again"));
1606        assert_eq!(err.context.as_deref(), Some("ctx.path"));
1607        // Display surfaces only the message.
1608        assert_eq!(format!("{err}"), "boom");
1609    }
1610
1611    #[test]
1612    fn generic_analysis_error_uppercases_command_into_code() {
1613        let err = generic_analysis_error("dead-code");
1614        assert_eq!(err.code.as_deref(), Some("FALLOW_DEAD_CODE_FAILED"));
1615        assert_eq!(err.exit_code, 2);
1616        assert_eq!(err.context.as_deref(), Some("fallow dead-code"));
1617        assert!(err.help.is_some(), "diagnostics hint should be attached");
1618    }
1619
1620    fn unused_file_paths(json: &serde_json::Value) -> Vec<String> {
1621        json["unused_files"]
1622            .as_array()
1623            .unwrap()
1624            .iter()
1625            .filter_map(|file| file["path"].as_str())
1626            .map(str::to_owned)
1627            .collect()
1628    }
1629
1630    fn unused_export_names(json: &serde_json::Value) -> Vec<String> {
1631        let mut names: Vec<String> = json["unused_exports"]
1632            .as_array()
1633            .unwrap()
1634            .iter()
1635            .filter_map(|export| export["export_name"].as_str())
1636            .map(str::to_owned)
1637            .collect();
1638        names.sort();
1639        names
1640    }
1641
1642    fn diff_for(path: &str, line: &str) -> String {
1643        format!("diff --git a/{path} b/{path}\n--- /dev/null\n+++ b/{path}\n@@ -0,0 +1 @@\n+{line}")
1644    }
1645}