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