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