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