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