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