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        file_scores: eff_file_scores,
826        coverage_gaps: eff_coverage_gaps,
827        config_activates_coverage_gaps: !any_section,
828        hotspots: eff_hotspots,
829        ownership: ownership && eff_hotspots,
830        ownership_emails: options.ownership_emails.map(OwnershipEmailMode::to_config),
831        targets: eff_targets,
832        force_full,
833        score_only_output,
834        enforce_coverage_gap_gate: true,
835        effort: options.effort.map(TargetEffort::to_cli),
836        score: eff_score,
837        min_score: None,
838        since: options.since.as_deref(),
839        min_commits: options.min_commits,
840        explain: resolved.explain,
841        summary: false,
842        save_snapshot: None,
843        trend: false,
844        group_by: None,
845        coverage: options.coverage.as_deref(),
846        coverage_root: options.coverage_root.as_deref(),
847        performance: false,
848        min_severity: None,
849        report_only: false,
850        runtime_coverage: None,
851    }
852}
853
854/// Run the health / complexity analysis and return the CLI JSON contract as a value.
855pub fn compute_complexity(options: &ComplexityOptions) -> ProgrammaticResult<serde_json::Value> {
856    let resolved = options.analysis.resolve()?;
857    if let Some(path) = &options.coverage
858        && !path.exists()
859    {
860        return Err(ProgrammaticError::new(
861            format!("coverage path does not exist: {}", path.display()),
862            2,
863        )
864        .with_code("FALLOW_INVALID_COVERAGE_PATH")
865        .with_context("health.coverage"));
866    }
867    if let Err(message) =
868        crate::health::scoring::validate_coverage_root_absolute(options.coverage_root.as_deref())
869    {
870        return Err(ProgrammaticError::new(message, 2)
871            .with_code("FALLOW_INVALID_COVERAGE_ROOT")
872            .with_context("health.coverage_root"));
873    }
874
875    resolved.install(|| {
876        let health_options = build_complexity_options(&resolved, options);
877        let result = crate::health::execute_health(&health_options)
878            .map_err(|_| generic_analysis_error("health"))?;
879        let mut output = build_health_json(
880            &result.report,
881            &result.config.root,
882            result.elapsed,
883            resolved.explain,
884        )
885        .map_err(|err| {
886            ProgrammaticError::new(format!("failed to serialize health report: {err}"), 2)
887                .with_code("FALLOW_SERIALIZE_HEALTH_REPORT")
888                .with_context("health")
889        })?;
890        apply_programmatic_envelope_options(&mut output, &resolved);
891        Ok(output)
892    })
893}
894
895/// Alias for `compute_complexity` with a more product-oriented name.
896pub fn compute_health(options: &ComplexityOptions) -> ProgrammaticResult<serde_json::Value> {
897    compute_complexity(options)
898}
899
900#[cfg(test)]
901mod tests {
902    use super::*;
903    use crate::report::test_helpers::sample_results;
904    use std::process::Command;
905
906    const SHARED_DIFF_CHILD_ENV: &str = "FALLOW_PROGRAMMATIC_SHARED_DIFF_CHILD";
907    const SHARED_DIFF_CHILD_TEST: &str =
908        "programmatic::tests::programmatic_without_diff_file_ignores_shared_diff_cache";
909
910    #[test]
911    fn circular_dependency_filter_clears_other_issue_types() {
912        let root = PathBuf::from("/project");
913        let results = sample_results(&root);
914        let filtered = filter_for_circular_dependencies(&results);
915        let json = build_dead_code_json(&filtered, &root, std::time::Duration::ZERO, false, false)
916            .expect("should serialize");
917
918        assert_eq!(json["kind"], "dead-code");
919        assert_eq!(json["circular_dependencies"].as_array().unwrap().len(), 1);
920        assert_eq!(json["boundary_violations"].as_array().unwrap().len(), 0);
921        assert_eq!(json["unused_files"].as_array().unwrap().len(), 0);
922        assert_eq!(json["summary"]["total_issues"], serde_json::Value::from(1));
923    }
924
925    #[test]
926    fn boundary_violation_filter_clears_other_issue_types() {
927        let root = PathBuf::from("/project");
928        let results = sample_results(&root);
929        let filtered = filter_for_boundary_violations(&results);
930        let json = build_dead_code_json(&filtered, &root, std::time::Duration::ZERO, false, false)
931            .expect("should serialize");
932
933        assert_eq!(json["kind"], "dead-code");
934        assert_eq!(json["boundary_violations"].as_array().unwrap().len(), 1);
935        assert_eq!(json["circular_dependencies"].as_array().unwrap().len(), 0);
936        assert_eq!(json["unused_exports"].as_array().unwrap().len(), 0);
937        assert_eq!(json["summary"]["total_issues"], serde_json::Value::from(1));
938    }
939
940    #[test]
941    fn dead_code_without_production_override_uses_per_analysis_config() {
942        let dir = tempfile::tempdir().expect("temp dir");
943        let root = dir.path();
944        std::fs::create_dir_all(root.join("src")).unwrap();
945        std::fs::write(
946            root.join("package.json"),
947            r#"{"name":"programmatic-production","main":"src/index.ts"}"#,
948        )
949        .unwrap();
950        std::fs::write(root.join("src/index.ts"), "export const ok = 1;\n").unwrap();
951        std::fs::write(root.join("src/utils.test.ts"), "export const dead = 1;\n").unwrap();
952        std::fs::write(
953            root.join(".fallowrc.json"),
954            r#"{"production":{"deadCode":true,"health":false,"dupes":false}}"#,
955        )
956        .unwrap();
957
958        let options = DeadCodeOptions {
959            analysis: AnalysisOptions {
960                root: Some(root.to_path_buf()),
961                ..AnalysisOptions::default()
962            },
963            ..DeadCodeOptions::default()
964        };
965        let json = detect_dead_code(&options).expect("analysis should succeed");
966        let paths = unused_file_paths(&json);
967
968        assert!(
969            !paths.iter().any(|path| path.ends_with("utils.test.ts")),
970            "omitted production option should defer to production.deadCode=true config: {paths:?}"
971        );
972    }
973
974    #[test]
975    fn dead_code_legacy_envelope_removes_root_kind() {
976        let dir = tempfile::tempdir().expect("temp dir");
977        let root = dir.path();
978        std::fs::create_dir_all(root.join("src")).unwrap();
979        std::fs::write(
980            root.join("package.json"),
981            r#"{"name":"programmatic-legacy","main":"src/index.ts"}"#,
982        )
983        .unwrap();
984        std::fs::write(root.join("src/index.ts"), "export const ok = 1;\n").unwrap();
985
986        let options = DeadCodeOptions {
987            analysis: AnalysisOptions {
988                root: Some(root.to_path_buf()),
989                legacy_envelope: true,
990                ..AnalysisOptions::default()
991            },
992            ..DeadCodeOptions::default()
993        };
994        let json = detect_dead_code(&options).expect("analysis should succeed");
995
996        assert!(json.get("kind").is_none());
997        assert_eq!(json["schema_version"], crate::report::SCHEMA_VERSION);
998    }
999
1000    #[test]
1001    fn dead_code_explicit_production_false_overrides_config() {
1002        let dir = tempfile::tempdir().expect("temp dir");
1003        let root = dir.path();
1004        std::fs::create_dir_all(root.join("src")).unwrap();
1005        std::fs::write(
1006            root.join("package.json"),
1007            r#"{"name":"programmatic-production","main":"src/index.ts"}"#,
1008        )
1009        .unwrap();
1010        std::fs::write(root.join("src/index.ts"), "export const ok = 1;\n").unwrap();
1011        std::fs::write(root.join("src/utils.test.ts"), "export const dead = 1;\n").unwrap();
1012        std::fs::write(
1013            root.join(".fallowrc.json"),
1014            r#"{"production":{"deadCode":true,"health":false,"dupes":false}}"#,
1015        )
1016        .unwrap();
1017
1018        let options = DeadCodeOptions {
1019            analysis: AnalysisOptions {
1020                root: Some(root.to_path_buf()),
1021                production_override: Some(false),
1022                ..AnalysisOptions::default()
1023            },
1024            ..DeadCodeOptions::default()
1025        };
1026        let json = detect_dead_code(&options).expect("analysis should succeed");
1027        let paths = unused_file_paths(&json);
1028
1029        assert!(
1030            paths.iter().any(|path| path.ends_with("utils.test.ts")),
1031            "explicit production=false should include test files despite config: {paths:?}"
1032        );
1033    }
1034
1035    #[test]
1036    fn analysis_resolve_uses_per_call_thread_pool() {
1037        let dir = tempfile::tempdir().expect("temp dir");
1038        let root = dir.path();
1039
1040        let one = AnalysisOptions {
1041            root: Some(root.to_path_buf()),
1042            threads: Some(1),
1043            ..AnalysisOptions::default()
1044        }
1045        .resolve()
1046        .expect("one-thread options should resolve");
1047        let two = AnalysisOptions {
1048            root: Some(root.to_path_buf()),
1049            threads: Some(2),
1050            ..AnalysisOptions::default()
1051        }
1052        .resolve()
1053        .expect("two-thread options should resolve");
1054
1055        assert_eq!(one.install(rayon::current_num_threads), 1);
1056        assert_eq!(two.install(rayon::current_num_threads), 2);
1057    }
1058
1059    #[test]
1060    fn explicit_diff_file_scopes_dead_code_per_call() {
1061        let dir = tempfile::tempdir().expect("temp dir");
1062        let root = dir.path();
1063        std::fs::create_dir_all(root.join("src")).unwrap();
1064        std::fs::write(
1065            root.join("package.json"),
1066            r#"{"name":"programmatic-diff","main":"src/index.ts"}"#,
1067        )
1068        .unwrap();
1069        std::fs::write(
1070            root.join("src/index.ts"),
1071            "import { used } from './used';\nimport './a';\nimport './b';\nconsole.log(used);\n",
1072        )
1073        .unwrap();
1074        std::fs::write(root.join("src/used.ts"), "export const used = 1;\n").unwrap();
1075        std::fs::write(root.join("src/a.ts"), "export const deadA = 1;\n").unwrap();
1076        std::fs::write(root.join("src/b.ts"), "export const deadB = 1;\n").unwrap();
1077        std::fs::write(
1078            root.join("a.diff"),
1079            diff_for("src/a.ts", "export const deadA = 1;\n"),
1080        )
1081        .unwrap();
1082        std::fs::write(
1083            root.join("b.diff"),
1084            diff_for("src/b.ts", "export const deadB = 1;\n"),
1085        )
1086        .unwrap();
1087
1088        let filters = DeadCodeFilters {
1089            unused_exports: true,
1090            ..DeadCodeFilters::default()
1091        };
1092
1093        let a_json = detect_dead_code(&DeadCodeOptions {
1094            analysis: AnalysisOptions {
1095                root: Some(root.to_path_buf()),
1096                diff_file: Some(PathBuf::from("a.diff")),
1097                ..AnalysisOptions::default()
1098            },
1099            filters: filters.clone(),
1100            ..DeadCodeOptions::default()
1101        })
1102        .expect("a-scoped analysis should succeed");
1103        let b_json = detect_dead_code(&DeadCodeOptions {
1104            analysis: AnalysisOptions {
1105                root: Some(root.to_path_buf()),
1106                diff_file: Some(PathBuf::from("b.diff")),
1107                ..AnalysisOptions::default()
1108            },
1109            filters,
1110            ..DeadCodeOptions::default()
1111        })
1112        .expect("b-scoped analysis should succeed");
1113
1114        assert_eq!(unused_export_names(&a_json), vec!["deadA"]);
1115        assert_eq!(unused_export_names(&b_json), vec!["deadB"]);
1116    }
1117
1118    #[test]
1119    fn programmatic_without_diff_file_ignores_shared_diff_cache() {
1120        if std::env::var_os(SHARED_DIFF_CHILD_ENV).is_some() {
1121            run_programmatic_shared_diff_child();
1122            return;
1123        }
1124
1125        let current_exe = std::env::current_exe().expect("current test binary should be known");
1126        let output = Command::new(current_exe)
1127            .arg("--exact")
1128            .arg(SHARED_DIFF_CHILD_TEST)
1129            .arg("--nocapture")
1130            .env(SHARED_DIFF_CHILD_ENV, "1")
1131            .output()
1132            .expect("shared diff child should start");
1133
1134        assert!(
1135            output.status.success(),
1136            "shared diff child failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
1137            output.status.code(),
1138            String::from_utf8_lossy(&output.stdout),
1139            String::from_utf8_lossy(&output.stderr)
1140        );
1141    }
1142
1143    fn run_programmatic_shared_diff_child() {
1144        let dir = tempfile::tempdir().expect("temp dir");
1145        let root = dir.path();
1146        std::fs::create_dir_all(root.join("src")).unwrap();
1147        std::fs::write(
1148            root.join("package.json"),
1149            r#"{"name":"programmatic-shared-diff","main":"src/index.ts"}"#,
1150        )
1151        .unwrap();
1152        std::fs::write(
1153            root.join("src/index.ts"),
1154            "import { used } from './used';\nimport './a';\nimport './b';\nconsole.log(used);\n",
1155        )
1156        .unwrap();
1157        std::fs::write(root.join("src/used.ts"), "export const used = 1;\n").unwrap();
1158        std::fs::write(root.join("src/a.ts"), "export const deadA = 1;\n").unwrap();
1159        std::fs::write(root.join("src/b.ts"), "export const deadB = 1;\n").unwrap();
1160        std::fs::write(
1161            root.join("a.diff"),
1162            diff_for("src/a.ts", "export const deadA = 1;\n"),
1163        )
1164        .unwrap();
1165
1166        let source = crate::report::ci::diff_filter::DiffSource::Flag(root.join("a.diff"));
1167        let loaded = crate::report::ci::diff_filter::init_shared_diff(Some(&source), true);
1168        assert!(loaded.is_some(), "shared diff should load in child process");
1169
1170        let json = detect_dead_code(&DeadCodeOptions {
1171            analysis: AnalysisOptions {
1172                root: Some(root.to_path_buf()),
1173                ..AnalysisOptions::default()
1174            },
1175            filters: DeadCodeFilters {
1176                unused_exports: true,
1177                ..DeadCodeFilters::default()
1178            },
1179            ..DeadCodeOptions::default()
1180        })
1181        .expect("analysis without explicit diff should succeed");
1182
1183        assert_eq!(unused_export_names(&json), vec!["deadA", "deadB"]);
1184    }
1185
1186    #[test]
1187    fn explicit_diff_file_rejects_stdin_sentinel() {
1188        let dir = tempfile::tempdir().expect("temp dir");
1189        let Err(error) = AnalysisOptions {
1190            root: Some(dir.path().to_path_buf()),
1191            diff_file: Some(PathBuf::from("-")),
1192            ..AnalysisOptions::default()
1193        }
1194        .resolve() else {
1195            panic!("stdin sentinel is not part of the programmatic API");
1196        };
1197
1198        assert_eq!(error.code.as_deref(), Some("FALLOW_INVALID_DIFF_FILE"));
1199        assert_eq!(error.context.as_deref(), Some("analysis.diffFile"));
1200    }
1201
1202    /// Minimal valid project used by the end-to-end programmatic entry points.
1203    fn tiny_project() -> tempfile::TempDir {
1204        let dir = tempfile::tempdir().expect("temp dir");
1205        let root = dir.path();
1206        std::fs::create_dir_all(root.join("src")).unwrap();
1207        std::fs::write(
1208            root.join("package.json"),
1209            r#"{"name":"prog-e2e","main":"src/index.ts"}"#,
1210        )
1211        .unwrap();
1212        std::fs::write(
1213            root.join("src/index.ts"),
1214            "export const ok = 1;\nconsole.log(ok);\n",
1215        )
1216        .unwrap();
1217        dir
1218    }
1219
1220    fn analysis_at(root: &Path) -> AnalysisOptions {
1221        AnalysisOptions {
1222            root: Some(root.to_path_buf()),
1223            ..AnalysisOptions::default()
1224        }
1225    }
1226
1227    #[test]
1228    fn resolve_rejects_zero_threads() {
1229        let err = AnalysisOptions {
1230            threads: Some(0),
1231            ..AnalysisOptions::default()
1232        }
1233        .resolve()
1234        .err()
1235        .expect("zero threads must be rejected");
1236        assert_eq!(err.exit_code, 2);
1237        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_THREADS"));
1238        assert_eq!(err.context.as_deref(), Some("analysis.threads"));
1239    }
1240
1241    #[test]
1242    fn resolve_rejects_mutually_exclusive_workspace_flags() {
1243        let err = AnalysisOptions {
1244            workspace: Some(vec!["packages/*".to_owned()]),
1245            changed_workspaces: Some("HEAD~1".to_owned()),
1246            ..AnalysisOptions::default()
1247        }
1248        .resolve()
1249        .err()
1250        .expect("workspace + changed_workspaces must be rejected");
1251        assert_eq!(
1252            err.code.as_deref(),
1253            Some("FALLOW_MUTUALLY_EXCLUSIVE_OPTIONS")
1254        );
1255        assert_eq!(err.context.as_deref(), Some("analysis.workspace"));
1256    }
1257
1258    #[test]
1259    fn resolve_rejects_nonexistent_root() {
1260        let err = AnalysisOptions {
1261            root: Some(PathBuf::from("/definitely/not/a/real/path/xyzzy")),
1262            ..AnalysisOptions::default()
1263        }
1264        .resolve()
1265        .err()
1266        .expect("nonexistent root must be rejected");
1267        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_ROOT"));
1268        assert_eq!(err.context.as_deref(), Some("analysis.root"));
1269    }
1270
1271    #[test]
1272    fn resolve_rejects_root_that_is_a_file() {
1273        let dir = tempfile::tempdir().expect("temp dir");
1274        let file = dir.path().join("not-a-dir.txt");
1275        std::fs::write(&file, "x").unwrap();
1276        let err = AnalysisOptions {
1277            root: Some(file),
1278            ..AnalysisOptions::default()
1279        }
1280        .resolve()
1281        .err()
1282        .expect("a file root must be rejected");
1283        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_ROOT"));
1284    }
1285
1286    #[test]
1287    fn resolve_rejects_nonexistent_config_path() {
1288        let dir = tempfile::tempdir().expect("temp dir");
1289        let err = AnalysisOptions {
1290            root: Some(dir.path().to_path_buf()),
1291            config_path: Some(dir.path().join("missing.fallowrc.json")),
1292            ..AnalysisOptions::default()
1293        }
1294        .resolve()
1295        .err()
1296        .expect("nonexistent config must be rejected");
1297        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_CONFIG_PATH"));
1298        assert_eq!(err.context.as_deref(), Some("analysis.configPath"));
1299    }
1300
1301    #[test]
1302    fn resolve_rejects_missing_diff_file() {
1303        let dir = tempfile::tempdir().expect("temp dir");
1304        let err = AnalysisOptions {
1305            root: Some(dir.path().to_path_buf()),
1306            diff_file: Some(PathBuf::from("nope.diff")),
1307            ..AnalysisOptions::default()
1308        }
1309        .resolve()
1310        .err()
1311        .expect("missing diff file must be rejected");
1312        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_DIFF_FILE"));
1313        assert_eq!(err.context.as_deref(), Some("analysis.diffFile"));
1314    }
1315
1316    #[test]
1317    fn resolve_rejects_diff_path_that_is_a_directory() {
1318        let dir = tempfile::tempdir().expect("temp dir");
1319        std::fs::create_dir_all(dir.path().join("a-dir")).unwrap();
1320        let err = AnalysisOptions {
1321            root: Some(dir.path().to_path_buf()),
1322            diff_file: Some(PathBuf::from("a-dir")),
1323            ..AnalysisOptions::default()
1324        }
1325        .resolve()
1326        .err()
1327        .expect("a directory diff path must be rejected");
1328        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_DIFF_FILE"));
1329    }
1330
1331    #[test]
1332    fn detect_circular_dependencies_returns_dead_code_envelope() {
1333        let project = tiny_project();
1334        let json = detect_circular_dependencies(&DeadCodeOptions {
1335            analysis: analysis_at(project.path()),
1336            ..DeadCodeOptions::default()
1337        })
1338        .expect("circular-dependency analysis should succeed");
1339        assert_eq!(json["kind"], "dead-code");
1340        assert!(json["circular_dependencies"].is_array());
1341    }
1342
1343    #[test]
1344    fn detect_boundary_violations_returns_dead_code_envelope() {
1345        let project = tiny_project();
1346        let json = detect_boundary_violations(&DeadCodeOptions {
1347            analysis: analysis_at(project.path()),
1348            ..DeadCodeOptions::default()
1349        })
1350        .expect("boundary-violation analysis should succeed");
1351        assert_eq!(json["kind"], "dead-code");
1352        assert!(json["boundary_violations"].is_array());
1353    }
1354
1355    #[test]
1356    fn detect_duplication_returns_dupes_envelope() {
1357        let project = tiny_project();
1358        let json = detect_duplication(&DuplicationOptions {
1359            analysis: analysis_at(project.path()),
1360            ..DuplicationOptions::default()
1361        })
1362        .expect("duplication analysis should succeed");
1363        assert_eq!(json["kind"], "dupes");
1364        // DupesOutput.report is `#[serde(flatten)]`, so its fields are top-level.
1365        assert!(json["clone_groups"].is_array());
1366        assert!(json["stats"].is_object());
1367    }
1368
1369    #[test]
1370    fn compute_health_returns_health_envelope() {
1371        let project = tiny_project();
1372        let options = ComplexityOptions {
1373            analysis: analysis_at(project.path()),
1374            ..ComplexityOptions::default()
1375        };
1376        // compute_health is a thin alias for compute_complexity.
1377        let json = compute_health(&options).expect("health analysis should succeed");
1378        assert_eq!(json["kind"], "health");
1379        // HealthOutput.report is `#[serde(flatten)]`, so its fields are top-level.
1380        assert!(json["summary"].is_object());
1381        assert!(json["findings"].is_array());
1382    }
1383
1384    #[test]
1385    fn compute_complexity_rejects_missing_coverage_path() {
1386        let project = tiny_project();
1387        let err = compute_complexity(&ComplexityOptions {
1388            analysis: analysis_at(project.path()),
1389            coverage: Some(project.path().join("missing-coverage.json")),
1390            ..ComplexityOptions::default()
1391        })
1392        .expect_err("a missing coverage path must be rejected");
1393        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1394        assert_eq!(err.context.as_deref(), Some("health.coverage"));
1395    }
1396
1397    #[test]
1398    fn compute_complexity_rejects_relative_coverage_root() {
1399        let project = tiny_project();
1400        let err = compute_complexity(&ComplexityOptions {
1401            analysis: analysis_at(project.path()),
1402            coverage_root: Some(PathBuf::from("relative/prefix")),
1403            ..ComplexityOptions::default()
1404        })
1405        .expect_err("a relative coverage_root must be rejected");
1406        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_ROOT"));
1407        assert_eq!(err.context.as_deref(), Some("health.coverage_root"));
1408    }
1409
1410    #[test]
1411    fn programmatic_error_builders_compose_and_display() {
1412        let err = ProgrammaticError::new("boom", 7)
1413            .with_code("FALLOW_X")
1414            .with_help("try again")
1415            .with_context("ctx.path");
1416        assert_eq!(err.message, "boom");
1417        assert_eq!(err.exit_code, 7);
1418        assert_eq!(err.code.as_deref(), Some("FALLOW_X"));
1419        assert_eq!(err.help.as_deref(), Some("try again"));
1420        assert_eq!(err.context.as_deref(), Some("ctx.path"));
1421        // Display surfaces only the message.
1422        assert_eq!(format!("{err}"), "boom");
1423    }
1424
1425    #[test]
1426    fn generic_analysis_error_uppercases_command_into_code() {
1427        let err = generic_analysis_error("dead-code");
1428        assert_eq!(err.code.as_deref(), Some("FALLOW_DEAD_CODE_FAILED"));
1429        assert_eq!(err.exit_code, 2);
1430        assert_eq!(err.context.as_deref(), Some("fallow dead-code"));
1431        assert!(err.help.is_some(), "diagnostics hint should be attached");
1432    }
1433
1434    fn unused_file_paths(json: &serde_json::Value) -> Vec<String> {
1435        json["unused_files"]
1436            .as_array()
1437            .unwrap()
1438            .iter()
1439            .filter_map(|file| file["path"].as_str())
1440            .map(str::to_owned)
1441            .collect()
1442    }
1443
1444    fn unused_export_names(json: &serde_json::Value) -> Vec<String> {
1445        let mut names: Vec<String> = json["unused_exports"]
1446            .as_array()
1447            .unwrap()
1448            .iter()
1449            .filter_map(|export| export["export_name"].as_str())
1450            .map(str::to_owned)
1451            .collect();
1452        names.sort();
1453        names
1454    }
1455
1456    fn diff_for(path: &str, line: &str) -> String {
1457        format!("diff --git a/{path} b/{path}\n--- /dev/null\n+++ b/{path}\n@@ -0,0 +1 @@\n+{line}")
1458    }
1459}