Skip to main content

fallow_api/
lib.rs

1//! Programmatic API contract types for fallow.
2//!
3//! Runtime execution for dead-code, duplication and health lives here. The
4//! default health runner, `EngineHealthRunner`, runs the engine pipeline.
5//! Tests and hosts can inject a different runner. This crate owns the
6//! CLI-independent option, error, and output contracts so NAPI, future Rust
7//! embedders, and the engine facade can share them without depending on the
8//! CLI crate.
9#![warn(missing_docs)]
10#![cfg_attr(
11    test,
12    allow(
13        clippy::expect_used,
14        reason = "tests use expect to keep fixture setup concise"
15    )
16)]
17
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20use std::sync::atomic::AtomicBool;
21
22use fallow_config::EmailMode;
23use fallow_output::EffortEstimate;
24use serde::Serialize;
25
26mod analysis_context;
27/// Stable per-finding keys and audit ledgers that compare head results against
28/// a base snapshot, plus helpers that annotate output JSON with
29/// introduced-vs-pre-existing attribution.
30pub mod audit_keys;
31pub mod audit_output;
32pub mod audit_run;
33pub mod combined_output;
34/// One-line-per-finding compact text builders for dead-code, grouped, health,
35/// and duplication output.
36pub mod compact_output;
37pub mod coverage;
38pub mod dead_code_codeclimate;
39pub mod dead_code_sarif;
40pub mod decision_surface;
41pub mod dependency_deltas;
42pub mod doctor;
43pub mod dupes_output;
44pub mod editor;
45pub mod explain;
46pub mod grouped_output;
47pub mod health_codeclimate;
48pub mod json_output;
49pub mod list_output;
50mod list_runtime;
51/// Markdown report builders for dead-code, grouped, duplication, health, and
52/// walkthrough output.
53pub mod markdown_output;
54mod next_steps;
55pub mod output_contracts;
56pub mod ownership;
57pub mod review_deltas;
58pub mod routing;
59pub mod runtime;
60mod runtime_json;
61mod runtime_output;
62pub mod sarif_output;
63/// JSON Schema documents and zero-config rule defaults re-exported from
64/// `fallow-config` for embedders such as the MCP resource surface.
65pub mod schemas;
66pub mod security_output;
67/// Local-only advisory similar-code discovery and provider status.
68pub mod similar_code;
69mod type_aware;
70pub use analysis_context::{ProgrammaticAnalysisContext, resolve_programmatic_analysis_context};
71pub use audit_output::{
72    AuditAttribution, AuditCodeClimateOutputInput, AuditJsonHeaderInput, AuditJsonOutputInput,
73    AuditSarifOutputInput, AuditSummary, AuditVerdict,
74    attach_audit_duplication_demotion_attribution, attach_audit_styling_attribution,
75    attach_audit_wire_attribution, build_audit_codeclimate, build_audit_codeclimate_issues,
76    build_audit_header_json, build_audit_header_map, build_audit_sarif, build_review_brief_header,
77    serialize_audit_json,
78};
79pub use combined_output::{
80    CombinedCheckJsonSection, CombinedJsonOutputInput, serialize_combined_dupes_json,
81    serialize_combined_health_json, serialize_combined_json,
82};
83pub use compact_output::{
84    build_compact_lines, build_duplication_compact_lines, build_grouped_compact_lines,
85    build_health_compact_lines,
86};
87pub use coverage::{
88    CoverageInputError, CoverageInputSource, CoverageInputs, resolve_coverage_inputs,
89};
90pub use dead_code_codeclimate::build_codeclimate;
91pub use dead_code_sarif::build_sarif;
92pub use doctor::{DoctorOptions, run_doctor, run_doctor_with_cache_dir};
93pub use dupes_output::{
94    AttributedCloneGroup, AttributedCloneGroupFinding, AttributedInstance, CloneDemotionReason,
95    CloneFamilyFinding, CloneGroupFinding, CombinedDupesSection, DupesReportPayload,
96    DuplicationGroup, DuplicationGrouping, build_duplication_codeclimate,
97};
98pub use editor::{
99    ChangedFilesError, EditorAnalysisOutput, EditorAnalysisResults, EditorAnalysisSession,
100    EditorCloneFamily, EditorCloneFingerprintSet, EditorCloneGroup, EditorCloneInstance,
101    EditorDeadCodeAnalysisOutput, EditorDuplicationReport, EditorDuplicationStats,
102    EditorInlineComplexityExceeded, EditorInlineComplexityFinding, EditorMirroredDirectory,
103    EditorProjectAnalysisOutput, EditorRefactoringKind, EditorRefactoringSuggestion,
104    collect_inline_complexity, editor_duplicates, editor_extract, editor_results, editor_security,
105    editor_suppress, filter_inline_complexity_by_changed_files, resolve_git_toplevel,
106    try_get_changed_files_with_toplevel,
107};
108pub use explain::{
109    CHECK_RULES, DUPES_RULES, FLAGS_RULES, HEALTH_RULES, RuleDef, RuleGuide, SECURITY_RULES,
110    all_rules, bare_rule_id, coverage_analyze_meta, coverage_setup_meta, explain_issue_type,
111    rule_by_id, rule_by_token, rule_command, rule_docs_url, rule_guide, rule_severity_key,
112    security_meta, serialize_explain_programmatic_json, unknown_explain_error,
113};
114pub use fallow_config::levenshtein::closest_match;
115pub use fallow_config::{AuditGate, HealthConfig, TypeAwareRequire};
116pub use fallow_output::serialize_similar_code_json_output;
117pub use fallow_types::trace::{
118    CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace, ReExportChain,
119    TracedCloneGroup, TracedExport, TracedReExport,
120};
121pub use grouped_output::{
122    ResultGroup, UNOWNED_GROUP_LABEL, build_duplication_grouping_with, group_analysis_results_with,
123    largest_clone_group_owner_with,
124};
125pub use health_codeclimate::build_health_codeclimate;
126pub use json_output::{
127    CheckJsonExtraOutputs, CheckJsonOutputInput, CheckJsonPayloadInput, DuplicationJsonOutputInput,
128    GroupedCheckJsonOutputInput, GroupedDuplicationJsonOutputInput, serialize_check_json,
129    serialize_check_json_payload, serialize_duplication_json, serialize_grouped_check_json,
130    serialize_grouped_duplication_json,
131};
132pub use list_output::{
133    ListJsonEnvelope, ListJsonOutputInput, build_list_json_output, serialize_list_json_output,
134};
135pub use list_runtime::{
136    BoundaryData, ListBoundariesOptions, ListBoundariesProgrammaticOutput, LogicalGroupInfo,
137    ProjectInfoOptions, ProjectInfoProgrammaticOutput, RuleInfo, ZoneInfo, boundary_data_to_output,
138    compute_boundary_data, run_list_boundaries, run_project_info,
139    serialize_list_boundaries_programmatic_json, serialize_project_info_programmatic_json,
140};
141pub use markdown_output::{
142    build_duplication_markdown, build_grouped_markdown, build_health_markdown, build_markdown,
143    build_walkthrough_markdown,
144};
145pub use output_contracts::{
146    AuditOutput, BoundariesListLogicalGroup, BoundariesListRule, BoundariesListZone,
147    BoundariesListing, CombinedOutput, FallowOutput, ImpactOutput, ListBoundariesOutput,
148    ListEntryPointOutput, ListOutput, ListPluginOutput, ReviewBriefWireOutput, SecurityGate,
149    SecurityOutput, SecurityOutputConfig, SecuritySummaryOutput, SimilarCodeCandidateSnapshot,
150    SimilarCodeOutput, TraceOutput, WorkspacesOutput,
151};
152pub use runtime::{
153    AuditProgrammaticKeySnapshot, AuditProgrammaticOutput, BoundaryViolationsOutput,
154    BoundaryViolationsProgrammaticOutput, CircularDependenciesOutput,
155    CircularDependenciesProgrammaticOutput, CombinedProgrammaticOutput, DeadCodeOutput,
156    DeadCodeProgrammaticOutput, DecisionSurfaceProgrammaticOutput, DuplicationOutput,
157    DuplicationProgrammaticOutput, EngineHealthRunner, FeatureFlagsOutput,
158    FeatureFlagsProgrammaticOutput, HealthJsonReportInput, HealthProgrammaticOutput,
159    ProgrammaticHealthAnalysis, ProgrammaticHealthNextStepFacts, ProgrammaticHealthRun,
160    ProgrammaticHealthRunner, TraceClassMemberOutput, TraceCloneBenchmarkResult, TraceCloneOutput,
161    TraceCloneProgrammaticOutput, TraceDependencyOutput, TraceDependencyProgrammaticOutput,
162    TraceErrorOutput, TraceErrorProgrammaticOutput, TraceExportOutput,
163    TraceExportProgrammaticOutput, TraceExportTargetOutput, TraceFileOutput,
164    TraceFileProgrammaticOutput, TraceImportPathOutput, TraceImportPathProgrammaticOutput,
165    benchmark_trace_clone_compact_json, benchmark_trace_graph_family_compact_json,
166    inspect_similar_code, load_health_config, parse_similar_code_candidate_snapshot,
167    review_similar_code, run_audit, run_boundary_violations, run_circular_dependencies,
168    run_combined, run_complexity_with_runner, run_dead_code, run_dead_code_with_baseline,
169    run_decision_surface, run_duplication, run_feature_flags, run_health, run_health_with_runner,
170    run_similar_code, run_trace_clone, run_trace_dependency, run_trace_error, run_trace_export,
171    run_trace_file, run_trace_import_path, select_similar_code_candidate_snapshot,
172    serialize_health_report_json,
173};
174pub use runtime_json::{
175    serialize_audit_programmatic_json, serialize_boundary_violations_programmatic_json,
176    serialize_circular_dependencies_programmatic_json, serialize_combined_programmatic_json,
177    serialize_dead_code_programmatic_json, serialize_decision_surface_programmatic_json,
178    serialize_duplication_programmatic_json, serialize_feature_flags_programmatic_json,
179    serialize_health_programmatic_json, serialize_trace_clone_programmatic_json,
180    serialize_trace_dependency_programmatic_json, serialize_trace_error_programmatic_json,
181    serialize_trace_export_programmatic_json, serialize_trace_file_programmatic_json,
182    serialize_trace_import_path_programmatic_json,
183};
184pub use sarif_output::{
185    annotate_sarif_results, build_duplication_sarif, build_grouped_duplication_sarif,
186    build_health_sarif,
187};
188pub use security_output::SecurityGateMode;
189pub use type_aware::{
190    SemanticCouplingOutcome, SemanticDeadCodeOutcome, SemanticInspectOutcome, TypeAwareError,
191    TypeAwareFileChanges, TypeAwareOutcome, TypeAwareSession, TypeAwareStatus,
192    discard_unverified_semantic_candidates, inspect_symbol as inspect_type_aware_symbol,
193    merge_type_aware_meta,
194    refine_configured_dead_code_results as refine_type_aware_results_with_config,
195    shutdown_type_aware_sidecars, status as type_aware_status,
196    symbol_impact as run_type_aware_symbol_impact, symbol_impact as type_aware_symbol_impact,
197    terminate_active_type_aware_sidecars, trace_symbol as run_type_aware_symbol_trace,
198    trace_symbol as trace_type_aware_symbol, type_coupling as analyze_type_coupling,
199};
200
201/// Long names of the analysis-affecting global CLI flags that
202/// [`AnalysisOptions`] mirrors for embedders.
203///
204/// A contract test in the CLI crate asserts this list stays in sync with the
205/// clap globals, so drift between the CLI surface and the programmatic
206/// options is caught at test time.
207pub const COMMON_ANALYSIS_OPTION_FLAGS: &[&str] = &[
208    "root",
209    "config",
210    "no-cache",
211    "threads",
212    "changed-since",
213    "diff-file",
214    "production",
215    "workspace",
216    "changed-workspaces",
217    "explain",
218    "allow-remote-extends",
219];
220
221/// Structured error surface for the programmatic API.
222#[derive(Debug, Clone, Serialize)]
223pub struct ProgrammaticError {
224    /// Human-readable description; also the `Display` output.
225    pub message: String,
226    /// Process exit code the CLI maps this failure to.
227    pub exit_code: u8,
228    /// Stable machine-readable code such as `FALLOW_INVALID_COVERAGE_PATH`.
229    pub code: Option<String>,
230    /// Optional remediation hint for the caller.
231    pub help: Option<String>,
232    /// Dotted path of the offending input, such as `health.coverage`.
233    pub context: Option<String>,
234}
235
236impl ProgrammaticError {
237    /// Create an error from the required message and exit code, with all
238    /// optional fields left empty.
239    #[must_use]
240    pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
241        Self {
242            message: message.into(),
243            exit_code,
244            code: None,
245            help: None,
246            context: None,
247        }
248    }
249
250    /// Attach a remediation hint shown to the caller alongside the message.
251    #[must_use]
252    pub fn with_help(mut self, help: impl Into<String>) -> Self {
253        self.help = Some(help.into());
254        self
255    }
256
257    /// Attach a stable machine-readable code such as
258    /// `FALLOW_INVALID_COVERAGE_PATH`.
259    #[must_use]
260    pub fn with_code(mut self, code: impl Into<String>) -> Self {
261        self.code = Some(code.into());
262        self
263    }
264
265    /// Attach the dotted path of the offending input, such as
266    /// `health.coverage`.
267    #[must_use]
268    pub fn with_context(mut self, context: impl Into<String>) -> Self {
269        self.context = Some(context.into());
270        self
271    }
272}
273
274impl std::fmt::Display for ProgrammaticError {
275    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
276        write!(f, "{}", self.message)
277    }
278}
279
280impl std::error::Error for ProgrammaticError {}
281
282/// Shared options for all one-shot analyses.
283#[derive(Debug, Clone, Default)]
284pub struct AnalysisOptions {
285    /// Project root to analyze. `None` resolves to the current working
286    /// directory.
287    pub root: Option<PathBuf>,
288    /// Explicit config file path. `None` uses config discovery from the root.
289    pub config_path: Option<PathBuf>,
290    /// Permit `https://` config inheritance for this analysis call.
291    pub allow_remote_extends: bool,
292    /// Bypass the on-disk analysis cache for this call.
293    pub no_cache: bool,
294    /// Worker thread count. `None` picks the default; `Some(0)` is rejected.
295    pub threads: Option<usize>,
296    /// Explicit unified diff file that scopes changed-code analysis.
297    ///
298    /// A file that cannot be read fails the call with
299    /// `FALLOW_INVALID_DIFF_FILE`: the caller named it and can fix it.
300    pub diff_file: Option<PathBuf>,
301    /// Unified diff file inherited from the environment (`FALLOW_DIFF_FILE`)
302    /// rather than named by the caller. Read only when `diff_file` is unset.
303    ///
304    /// A file that cannot be read or placed does not fail the call. The
305    /// analysis runs at full scope and `request_outcomes["diff-filter"]` states
306    /// the reason, exactly as the CLI does for the same variable: a caller
307    /// cannot fix a CI environment it inherited (issue #2799).
308    pub ambient_diff_file: Option<PathBuf>,
309    /// Legacy convenience override. `true` forces production mode; `false`
310    /// defers to config unless `production_override` is set.
311    pub production: bool,
312    /// Explicit production override from an embedder option. `None` means
313    /// use the project config for the current analysis.
314    pub production_override: Option<bool>,
315    /// Git base reference that scopes analysis to files changed since it.
316    ///
317    /// A ref that does not resolve fails the call with
318    /// `FALLOW_CHANGED_FILES_FAILED`: the caller named it and can fix it.
319    pub changed_since: Option<String>,
320    /// Git base reference inherited from the environment
321    /// (`FALLOW_CHANGED_SINCE`) rather than named by the caller. Read only when
322    /// `changed_since` is unset.
323    ///
324    /// A ref that does not resolve does not fail the call. The analysis runs at
325    /// full scope and `request_outcomes["changed-since"]` states the reason,
326    /// exactly as the CLI does for `--changed-since`.
327    pub ambient_changed_since: Option<String>,
328    /// Restrict analysis to the named workspace packages.
329    pub workspace: Option<Vec<String>>,
330    /// Restrict analysis to workspaces changed since the given git reference.
331    pub changed_workspaces: Option<String>,
332    /// Include rule and metric explanations (`_meta`) in machine output.
333    pub explain: bool,
334    /// Optional project-wide TypeScript semantic analysis. Disabled by default
335    /// and never changes compiler or typed-lint ownership.
336    pub type_aware: TypeAwareOptions,
337    /// Caller-owned cooperative cancellation for this analysis.
338    ///
339    /// Setting the flag asks the analysis to stop at its next stage boundary
340    /// and return a `FALLOW_CANCELLED` error. `None`, the default, is an
341    /// analysis that always runs to completion.
342    ///
343    /// The field is on the shared options struct, but the entry points do not
344    /// all honor it, so read the one being called before relying on it:
345    ///
346    /// - Cancelled at the entry, on both sides of the per-file parse loop, and
347    ///   at each pipeline stage boundary: [`run_dead_code`],
348    ///   [`run_circular_dependencies`], [`run_boundary_violations`],
349    ///   [`run_combined`], [`run_duplication`], [`run_feature_flags`], and the
350    ///   four trace routes.
351    /// - Cancelled only at the boundaries around config load and file
352    ///   discovery, because they are the whole run: [`run_project_info`] and
353    ///   [`run_list_boundaries`].
354    /// - Cancelled only at the entry, then run to completion:
355    ///   [`run_health`], which hands off to a runner that builds its own
356    ///   session below these options.
357    /// - Not cancellable at all: [`run_decision_surface`] and
358    ///   [`run_similar_code`].
359    ///
360    /// The stop is cooperative everywhere it exists, so it has no upper bound:
361    /// it is only as prompt as the longest stage holding no check, and
362    /// duplication detection and the dead-code detectors are each seconds of
363    /// uninterruptible work on a large repository. A caller that needs a
364    /// bounded stop has to run Fallow as a child process and kill it.
365    pub cancellation: Option<Arc<AtomicBool>>,
366}
367
368/// Typed options for Fallow's optional TypeScript semantic companion.
369#[derive(Debug, Clone, Default, PartialEq, Eq)]
370pub struct TypeAwareOptions {
371    /// Turn on TypeScript semantic refinement for this call.
372    pub enabled: bool,
373    /// Explicit TypeScript project paths handed to the semantic sidecar.
374    /// Empty defers to project discovery.
375    pub projects: Vec<PathBuf>,
376    /// How strictly semantic completion is required before results are kept.
377    pub require: fallow_config::TypeAwareRequire,
378}
379
380/// Issue-type filters for the dead-code analysis.
381///
382/// Each flag opts one issue type into the report. When no flag is enabled the
383/// analysis reports every issue type.
384#[derive(Debug, Clone, Default)]
385pub struct DeadCodeFilters {
386    /// Files never reached from any entry point.
387    pub unused_files: bool,
388    /// Exported symbols never imported elsewhere.
389    pub unused_exports: bool,
390    /// Declared dependencies never imported.
391    pub unused_deps: bool,
392    /// Exported types never referenced.
393    pub unused_types: bool,
394    /// Exported APIs that expose non-exported types.
395    pub private_type_leaks: bool,
396    /// Exports marked `@deprecated` that are still in use.
397    pub deprecated_exports_in_use: bool,
398    /// Enum members never read.
399    pub unused_enum_members: bool,
400    /// Class members never used outside their declaration.
401    pub unused_class_members: bool,
402    /// Store members (for example Pinia) never used outside the store.
403    pub unused_store_members: bool,
404    /// `inject` calls with no matching `provide`.
405    pub unprovided_injects: bool,
406    /// Components never rendered by any template or JSX.
407    pub unrendered_components: bool,
408    /// Declared component props never used.
409    pub unused_component_props: bool,
410    /// Declared component emits never used.
411    pub unused_component_emits: bool,
412    /// Declared component inputs never bound.
413    pub unused_component_inputs: bool,
414    /// Declared component outputs never listened to.
415    pub unused_component_outputs: bool,
416    /// Svelte component events never listened to.
417    pub unused_svelte_events: bool,
418    /// Server actions never invoked.
419    pub unused_server_actions: bool,
420    /// `load` data keys never read by the consuming page.
421    pub unused_load_data_keys: bool,
422    /// Imports that do not resolve to a file or package.
423    pub unresolved_imports: bool,
424    /// Imported packages missing from the dependency manifest.
425    pub unlisted_deps: bool,
426    /// The same symbol exported more than once.
427    pub duplicate_exports: bool,
428    /// Circular import chains.
429    pub circular_deps: bool,
430    /// Cycles formed through re-export chains.
431    pub re_export_cycles: bool,
432    /// Imports that cross configured architecture boundaries.
433    pub boundary_violations: bool,
434    /// Violations of configured dependency policy rules.
435    pub policy_violations: bool,
436    /// Suppression comments that no longer match a finding.
437    pub stale_suppressions: bool,
438    /// Catalog entries never referenced by a workspace package.
439    pub unused_catalog_entries: bool,
440    /// Catalog groups that contain no entries.
441    pub empty_catalog_groups: bool,
442    /// `catalog:` references without a matching catalog entry.
443    pub unresolved_catalog_references: bool,
444    /// Dependency overrides that never affect a resolved package.
445    pub unused_dependency_overrides: bool,
446    /// Dependency overrides that cannot apply as written.
447    pub misconfigured_dependency_overrides: bool,
448}
449
450impl DeadCodeFilters {
451    fn any_active(&self) -> bool {
452        self.unused_files
453            || self.unused_exports
454            || self.unused_deps
455            || self.unused_types
456            || self.private_type_leaks
457            || self.deprecated_exports_in_use
458            || self.unused_enum_members
459            || self.unused_class_members
460            || self.unused_store_members
461            || self.unprovided_injects
462            || self.unrendered_components
463            || self.unused_component_props
464            || self.unused_component_emits
465            || self.unused_component_inputs
466            || self.unused_component_outputs
467            || self.unused_svelte_events
468            || self.unused_server_actions
469            || self.unused_load_data_keys
470            || self.unresolved_imports
471            || self.unlisted_deps
472            || self.duplicate_exports
473            || self.circular_deps
474            || self.re_export_cycles
475            || self.boundary_violations
476            || self.policy_violations
477            || self.stale_suppressions
478            || self.unused_catalog_entries
479            || self.empty_catalog_groups
480            || self.unresolved_catalog_references
481            || self.unused_dependency_overrides
482            || self.misconfigured_dependency_overrides
483    }
484
485    /// Enable the issue filter addressed by a shared registry selector.
486    ///
487    /// Returns `false` when the selector is not registered for dead-code
488    /// filtering. Callers that expose user input should surface their own
489    /// validation error with the accepted registry values.
490    pub fn enable_registry_selector(&mut self, selector: &str) -> bool {
491        let Some(flag) = fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS
492            .iter()
493            .find_map(|&(name, flag)| (name == selector).then_some(flag))
494        else {
495            return false;
496        };
497        self.enable_cli_filter_flag(flag);
498        true
499    }
500
501    fn enable_cli_filter_flag(&mut self, flag: &str) {
502        match flag {
503            "--unused-files" => self.unused_files = true,
504            "--unused-exports" => self.unused_exports = true,
505            "--unused-types" => self.unused_types = true,
506            "--private-type-leaks" => self.private_type_leaks = true,
507            "--deprecated-exports-in-use" => self.deprecated_exports_in_use = true,
508            "--unused-deps" => self.unused_deps = true,
509            "--unused-enum-members" => self.unused_enum_members = true,
510            "--unused-class-members" => self.unused_class_members = true,
511            "--unused-store-members" => self.unused_store_members = true,
512            "--unprovided-injects" => self.unprovided_injects = true,
513            "--unrendered-components" => self.unrendered_components = true,
514            "--unused-component-props" => self.unused_component_props = true,
515            "--unused-component-emits" => self.unused_component_emits = true,
516            "--unused-component-inputs" => self.unused_component_inputs = true,
517            "--unused-component-outputs" => self.unused_component_outputs = true,
518            "--unused-svelte-events" => self.unused_svelte_events = true,
519            "--unused-server-actions" => self.unused_server_actions = true,
520            "--unused-load-data-keys" => self.unused_load_data_keys = true,
521            "--unresolved-imports" => self.unresolved_imports = true,
522            "--unlisted-deps" => self.unlisted_deps = true,
523            "--duplicate-exports" => self.duplicate_exports = true,
524            "--circular-deps" => self.circular_deps = true,
525            "--re-export-cycles" => self.re_export_cycles = true,
526            "--boundary-violations" => self.boundary_violations = true,
527            "--policy-violations" => self.policy_violations = true,
528            "--stale-suppressions" => self.stale_suppressions = true,
529            "--unused-catalog-entries" => self.unused_catalog_entries = true,
530            "--empty-catalog-groups" => self.empty_catalog_groups = true,
531            "--unresolved-catalog-references" => self.unresolved_catalog_references = true,
532            "--unused-dependency-overrides" => self.unused_dependency_overrides = true,
533            "--misconfigured-dependency-overrides" => {
534                self.misconfigured_dependency_overrides = true;
535            }
536            _ => unreachable!("registry emitted unsupported dead-code filter flag: {flag}"),
537        }
538    }
539}
540
541/// Options for dead-code-oriented analyses.
542#[derive(Debug, Clone, Default)]
543pub struct DeadCodeOptions {
544    /// Shared analysis options.
545    pub analysis: AnalysisOptions,
546    /// Issue-type selection; everything is reported when no filter is set.
547    pub filters: DeadCodeFilters,
548    /// Restrict findings to these files when non-empty.
549    pub files: Vec<PathBuf>,
550    /// Also report unused exports declared in entry-point files.
551    pub include_entry_exports: bool,
552}
553
554/// Options for changed-code audit analysis.
555#[derive(Debug, Clone, Default)]
556pub struct AuditOptions {
557    /// Shared analysis options.
558    pub analysis: AnalysisOptions,
559    /// Git base reference for the changed-code comparison. `None` lets the
560    /// audit detect a base itself.
561    pub base: Option<String>,
562    /// Force production mode for every audit domain.
563    pub production: bool,
564    /// Production override for the dead-code domain; `None` defers to config.
565    pub production_dead_code: Option<bool>,
566    /// Production override for the health domain; `None` defers to config.
567    pub production_health: Option<bool>,
568    /// Production override for the duplication domain; `None` defers to
569    /// config.
570    pub production_dupes: Option<bool>,
571    /// Enable CSS / styling analysis; `None` defers to config.
572    pub css: Option<bool>,
573    /// Enable deep cross-file CSS analysis; `None` defers to config.
574    pub css_deep: Option<bool>,
575    /// Gate mode deciding which findings fail the audit.
576    pub gate: fallow_config::AuditGate,
577    /// Fail the gate when a changed function exceeds this CRAP score.
578    pub max_crap: Option<f64>,
579    /// Test coverage report path for coverage-aware findings.
580    pub coverage: Option<PathBuf>,
581    /// Absolute path prefix the coverage report recorded its files under.
582    pub coverage_root: Option<PathBuf>,
583    /// Also report unused exports declared in entry-point files.
584    pub include_entry_exports: bool,
585    /// Runtime coverage capture merged into the audit.
586    pub runtime_coverage: Option<PathBuf>,
587    /// Minimum recorded invocations for a code path to count as hot.
588    pub min_invocations_hot: u64,
589}
590
591/// Options for bare combined analysis through the programmatic API.
592#[derive(Debug, Clone)]
593pub struct CombinedOptions {
594    /// Shared analysis options.
595    pub analysis: AnalysisOptions,
596    /// Run the dead-code domain.
597    pub dead_code: bool,
598    /// Run the duplication domain.
599    pub duplication: bool,
600    /// Run the health domain.
601    pub health: bool,
602    /// Also report unused exports declared in entry-point files.
603    pub include_entry_exports: bool,
604    /// Options for the duplication domain.
605    pub duplication_options: DuplicationOptions,
606    /// Options for the health domain.
607    pub health_options: ComplexityOptions,
608}
609
610impl Default for CombinedOptions {
611    fn default() -> Self {
612        Self {
613            analysis: AnalysisOptions::default(),
614            dead_code: true,
615            duplication: true,
616            health: true,
617            include_entry_exports: false,
618            duplication_options: DuplicationOptions::default(),
619            health_options: ComplexityOptions::default(),
620        }
621    }
622}
623
624/// Options for changed-code decision-surface analysis.
625#[derive(Debug, Clone, Default)]
626pub struct DecisionSurfaceOptions {
627    /// Shared analysis options.
628    pub analysis: AnalysisOptions,
629    /// Git base reference for the changed-code comparison. `None` lets the
630    /// analysis detect a base itself.
631    pub base: Option<String>,
632    /// Cap on the number of decisions surfaced.
633    pub max_decisions: Option<usize>,
634}
635
636/// Options for feature-flag analysis.
637#[derive(Debug, Clone, Default)]
638pub struct FeatureFlagsOptions {
639    /// Shared analysis options.
640    pub analysis: AnalysisOptions,
641    /// Cap on the number of reported flags.
642    pub top: Option<usize>,
643}
644
645/// Programmatic duplication mode selection.
646#[derive(Debug, Clone, Copy, Default)]
647pub enum DuplicationMode {
648    /// Preserve all tokens, including identifier names and literal values
649    /// (Type-1 clones only).
650    Strict,
651    /// Default mode, equivalent to strict for AST-based tokenization.
652    #[default]
653    Mild,
654    /// Blind string literal values while preserving structure.
655    Weak,
656    /// Blind all identifiers and literal values for structural (Type-2)
657    /// detection.
658    Semantic,
659}
660
661/// Options for duplication analysis.
662#[derive(Debug, Clone, Default)]
663pub struct DuplicationOptions {
664    /// Shared analysis options.
665    pub analysis: AnalysisOptions,
666    /// Detection mode; `None` defers to the project config.
667    pub mode: Option<DuplicationMode>,
668    /// Detect function-scoped near-miss clones in addition to exact clones.
669    /// `None` defers to the project config.
670    pub near: Option<bool>,
671    /// Minimum number of tokens for a clone.
672    pub min_tokens: Option<usize>,
673    /// Minimum number of lines for a clone.
674    pub min_lines: Option<usize>,
675    /// Minimum number of occurrences before a clone group is reported.
676    /// Values below 2 are silently treated as 2 by the engine-facing adapter.
677    pub min_occurrences: Option<usize>,
678    /// Maximum allowed duplication percentage before the gate fails; 0 means
679    /// no limit. `None` defers to the project config.
680    pub threshold: Option<f64>,
681    /// Only report cross-directory duplicates. `None` defers to the project
682    /// config.
683    pub skip_local: Option<bool>,
684    /// Match clones across languages. `None` defers to the project config.
685    pub cross_language: Option<bool>,
686    /// Exclude module wiring from clone detection. `None` defers to the project
687    /// config.
688    pub ignore_imports: Option<bool>,
689    /// Cap on the number of reported clone groups.
690    pub top: Option<usize>,
691    /// Emit the verbatim source text on each clone instance. `None` keeps the
692    /// text, matching `fallow dupes` without `--no-fragments`; `Some(false)`
693    /// yields a location-only payload.
694    pub include_fragments: Option<bool>,
695}
696
697/// Options for local advisory similar-code discovery.
698#[derive(Debug, Clone, Default)]
699pub struct SimilarCodeOptions {
700    /// Shared project, config, cache, diff, and workspace options.
701    pub analysis: AnalysisOptions,
702    /// Model-specific cosine floor. `None` uses `similarCode.threshold`.
703    pub threshold: Option<f64>,
704    /// Minimum source lines per function. `None` uses
705    /// `similarCode.minLines`.
706    pub min_lines: Option<usize>,
707    /// Cap on displayed candidates after the full bounded comparison.
708    pub top: Option<usize>,
709    /// Restrict work and reported pairs to those where at least one side matches
710    /// one of these project-relative files. Deterministic background context is
711    /// retained only within the bounded scoped comparison budget.
712    pub files: Vec<PathBuf>,
713    /// Exact companion binary resolved and signature-verified by an official
714    /// distribution adapter such as the Node loader. `None` uses trusted
715    /// sibling discovery. Project config, PATH commands, and remote providers
716    /// must never populate this field.
717    #[doc(hidden)]
718    pub adapter_provider_path: Option<PathBuf>,
719}
720
721/// Options for one similar-code candidate inspection packet.
722#[derive(Debug, Clone)]
723pub struct SimilarCodeInspectOptions {
724    /// Shared project and configuration options used only for source validation
725    /// and deterministic enrichment. Inspect never reruns provider retrieval.
726    pub analysis: AnalysisOptions,
727    /// Bounded candidate handoff selected from the original discovery output.
728    pub snapshot: fallow_output::SimilarCodeCandidateSnapshot,
729}
730
731/// Options for export trace analysis.
732#[derive(Debug, Clone, Default)]
733pub struct TraceExportOptions {
734    /// Shared analysis options.
735    pub analysis: AnalysisOptions,
736    /// Path of the module that declares the export.
737    pub file: String,
738    /// Name of the export to trace.
739    pub export_name: String,
740}
741
742/// Options for file trace analysis.
743#[derive(Debug, Clone, Default)]
744pub struct TraceFileOptions {
745    /// Shared analysis options.
746    pub analysis: AnalysisOptions,
747    /// Path of the file to trace.
748    pub file: String,
749}
750
751/// Options for shortest-import-path trace analysis.
752#[derive(Debug, Clone, Default)]
753pub struct TraceImportPathOptions {
754    /// Shared analysis options.
755    pub analysis: AnalysisOptions,
756    /// Path of the module the walk starts from.
757    pub from: String,
758    /// Path of the module the walk is looking for.
759    pub to: String,
760}
761
762/// Options for stack-trace frame resolution.
763#[derive(Debug, Clone, Default)]
764pub struct TraceErrorOptions {
765    /// Shared analysis options.
766    pub analysis: AnalysisOptions,
767    /// The runtime stack trace, verbatim.
768    pub trace: String,
769    /// Where the trace came from, reported back as the payload's `source`.
770    pub source: String,
771}
772
773/// Options for dependency trace analysis.
774#[derive(Debug, Clone, Default)]
775pub struct TraceDependencyOptions {
776    /// Shared analysis options.
777    pub analysis: AnalysisOptions,
778    /// Package whose importers are traced.
779    pub package_name: String,
780}
781
782/// Duplicate-code trace target.
783#[derive(Debug, Clone, PartialEq, Eq)]
784pub enum TraceCloneTarget {
785    /// Select the clone group covering this file and line.
786    Location {
787        /// Path of the file containing the clone instance.
788        file: String,
789        /// One-based line inside the clone instance.
790        line: usize,
791    },
792    /// Select the clone group by its fingerprint.
793    Fingerprint(String),
794}
795
796/// Options for duplicate-code trace analysis.
797#[derive(Debug, Clone)]
798pub struct TraceCloneOptions {
799    /// Duplication options controlling detection before the trace.
800    pub duplication: DuplicationOptions,
801    /// Clone group to trace.
802    pub target: TraceCloneTarget,
803}
804
805/// Sort criteria for complexity findings.
806#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
807pub enum ComplexitySort {
808    /// Sort by cyclomatic complexity (default).
809    #[default]
810    Cyclomatic,
811    /// Sort by cognitive complexity.
812    Cognitive,
813    /// Sort by function length in lines.
814    Lines,
815    /// Sort by finding severity.
816    Severity,
817}
818
819/// Privacy mode for ownership-aware hotspot output.
820#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
821pub enum OwnershipEmailMode {
822    /// Show the raw email address as it appears in git history.
823    Raw,
824    /// Show only the local part before the `@` (default).
825    #[default]
826    Handle,
827    /// Show a stable non-cryptographic pseudonym derived from the raw email.
828    Anonymized,
829    /// Legacy spelling retained for embedders that already pass `hash`.
830    Hash,
831}
832
833/// Effort filter for refactoring targets.
834#[derive(Debug, Clone, Copy, PartialEq, Eq)]
835pub enum TargetEffort {
836    /// Low estimated refactoring effort.
837    Low,
838    /// Medium estimated refactoring effort.
839    Medium,
840    /// High estimated refactoring effort.
841    High,
842}
843
844/// Options for complexity / health analysis.
845#[derive(Debug, Clone, Default)]
846pub struct ComplexityOptions {
847    /// Shared analysis options.
848    pub analysis: AnalysisOptions,
849    /// Override for the cyclomatic complexity threshold.
850    pub max_cyclomatic: Option<u16>,
851    /// Override for the cognitive complexity threshold.
852    pub max_cognitive: Option<u16>,
853    /// Override for the CRAP score threshold.
854    pub max_crap: Option<f64>,
855    /// Cap on the number of reported findings.
856    pub top: Option<usize>,
857    /// Sort order for complexity findings.
858    pub sort: ComplexitySort,
859    /// Include the per-metric complexity breakdown with each finding.
860    pub complexity_breakdown: bool,
861    /// Request the complexity findings section.
862    pub complexity: bool,
863    /// Request the per-file score section.
864    pub file_scores: bool,
865    /// Request the coverage-gap section.
866    pub coverage_gaps: bool,
867    /// Request the churn hotspot section.
868    pub hotspots: bool,
869    /// Include ownership data with hotspots; implies the hotspot section.
870    pub ownership: bool,
871    /// Email privacy mode for ownership output; implies ownership when set.
872    pub ownership_emails: Option<OwnershipEmailMode>,
873    /// Request the refactoring targets section.
874    pub targets: bool,
875    /// Include CSS / styling health.
876    pub css: bool,
877    /// Enable deep cross-file CSS analysis.
878    pub css_deep: bool,
879    /// Filter refactoring targets by estimated effort; implies the targets
880    /// section when set.
881    pub effort: Option<TargetEffort>,
882    /// Request the overall health score.
883    pub score: bool,
884    /// Git time window (for example `30d`) for churn-based sections.
885    pub since: Option<String>,
886    /// Minimum commit count for a file to count as a hotspot.
887    pub min_commits: Option<u32>,
888    /// Test coverage report path for coverage-aware sections.
889    pub coverage: Option<PathBuf>,
890    /// Absolute path prefix the coverage report recorded its files under.
891    pub coverage_root: Option<PathBuf>,
892    /// The coverage map was recorded against a different checkout of this
893    /// project, so function line numbers may have drifted arbitrarily.
894    /// Set internally by the audit base-worktree pass; leave `false` for
895    /// same-checkout coverage.
896    pub coverage_relocated: bool,
897}
898
899/// Health threshold overrides accepted by the programmatic API.
900#[derive(Debug, Clone, Copy, Default, PartialEq)]
901pub struct ComplexityThresholdOverrides {
902    /// Override for the cyclomatic complexity threshold.
903    pub max_cyclomatic: Option<u16>,
904    /// Override for the cognitive complexity threshold.
905    pub max_cognitive: Option<u16>,
906    /// Override for the CRAP score threshold.
907    pub max_crap: Option<f64>,
908}
909
910/// Coverage inputs accepted by the programmatic API.
911#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
912pub struct ComplexityCoverageInputs<'a> {
913    /// Test coverage report path.
914    pub coverage: Option<&'a Path>,
915    /// Absolute path prefix the coverage report recorded its files under.
916    pub coverage_root: Option<&'a Path>,
917    /// The coverage map was recorded against a different checkout of this
918    /// project; tolerate arbitrary line drift for unambiguous name matches.
919    pub coverage_relocated: bool,
920}
921
922/// Input for deriving effective health sections from API-owned flags.
923#[derive(Debug, Clone)]
924pub struct HealthSectionOptions {
925    /// Requested output format; `Badge` implies the score section.
926    pub output: fallow_types::output_format::OutputFormat,
927    /// The complexity findings section was requested.
928    pub complexity: bool,
929    /// The per-file score section was requested.
930    pub file_scores: bool,
931    /// The coverage-gap section was requested.
932    pub coverage_gaps: bool,
933    /// The churn hotspot section was requested.
934    pub hotspots: bool,
935    /// The refactoring targets section was requested.
936    pub targets: bool,
937    /// CSS / styling health was requested.
938    pub css: bool,
939    /// The overall health score was requested.
940    pub score: bool,
941    /// A score gate is active; implies the score section.
942    pub score_gate: bool,
943    /// A snapshot write was requested; forces full hidden inputs.
944    pub snapshot_requested: bool,
945    /// Trend output was requested; implies score and hotspot inputs.
946    pub trend: bool,
947}
948
949/// Derived section selection for health runs.
950#[derive(Debug, Clone, Copy, PartialEq, Eq)]
951pub struct DerivedHealthSections {
952    /// At least one section was explicitly requested.
953    pub any_section: bool,
954    /// Emit the complexity findings section.
955    pub complexity: bool,
956    /// Compute the per-file score section.
957    pub file_scores: bool,
958    /// Emit the coverage-gap section.
959    pub coverage_gaps: bool,
960    /// Compute the churn hotspot section.
961    pub hotspots: bool,
962    /// Emit the refactoring targets section.
963    pub targets: bool,
964    /// Include CSS / styling health.
965    pub css: bool,
966    /// Compute the overall health score.
967    pub score: bool,
968    /// Compute full inputs even for sections that are not emitted.
969    pub force_full: bool,
970    /// Only the score should be printed.
971    pub score_only_output: bool,
972}
973
974/// Input for deriving effective programmatic complexity sections.
975#[derive(Debug, Clone)]
976pub struct ComplexitySectionOptions {
977    /// The complexity findings section was requested.
978    pub complexity: bool,
979    /// The per-file score section was requested.
980    pub file_scores: bool,
981    /// The coverage-gap section was requested.
982    pub coverage_gaps: bool,
983    /// The churn hotspot section was requested.
984    pub hotspots: bool,
985    /// Ownership data was requested; implies the hotspot section.
986    pub ownership: bool,
987    /// The refactoring targets section was requested.
988    pub targets: bool,
989    /// CSS / styling health was requested.
990    pub css: bool,
991    /// The overall health score was requested.
992    pub score: bool,
993}
994
995/// Derived section selection for programmatic health / complexity runs.
996#[derive(Debug, Clone, Copy, PartialEq, Eq)]
997pub struct DerivedComplexityOptions {
998    /// At least one section was explicitly requested.
999    pub any_section: bool,
1000    /// Emit the complexity findings section.
1001    pub complexity: bool,
1002    /// Compute the per-file score section.
1003    pub file_scores: bool,
1004    /// Emit the coverage-gap section.
1005    pub coverage_gaps: bool,
1006    /// Compute the churn hotspot section.
1007    pub hotspots: bool,
1008    /// Include ownership data with hotspots.
1009    pub ownership: bool,
1010    /// Emit the refactoring targets section.
1011    pub targets: bool,
1012    /// Compute full inputs even for sections that are not emitted.
1013    pub force_full: bool,
1014    /// Only the score should be printed.
1015    pub score_only_output: bool,
1016    /// Compute the overall health score.
1017    pub score: bool,
1018}
1019
1020/// Normalized programmatic complexity / health inputs owned by `fallow-api`.
1021#[derive(Debug, Clone, PartialEq)]
1022pub struct ComplexityRunOptions<'a> {
1023    /// Complexity threshold overrides.
1024    pub thresholds: ComplexityThresholdOverrides,
1025    /// Cap on the number of reported findings.
1026    pub top: Option<usize>,
1027    /// Sort order for complexity findings.
1028    pub sort: ComplexitySort,
1029    /// Include the per-metric complexity breakdown with each finding.
1030    pub complexity_breakdown: bool,
1031    /// Derived effective section selection.
1032    pub sections: DerivedComplexityOptions,
1033    /// Email privacy mode for ownership output.
1034    pub ownership_emails: Option<OwnershipEmailMode>,
1035    /// Filter refactoring targets by estimated effort.
1036    pub effort: Option<TargetEffort>,
1037    /// Include CSS / styling health.
1038    pub css: bool,
1039    /// Enable deep cross-file CSS analysis.
1040    pub css_deep: bool,
1041    /// Git time window (for example `30d`) for churn-based sections.
1042    pub since: Option<&'a str>,
1043    /// Minimum commit count for a file to count as a hotspot.
1044    pub min_commits: Option<u32>,
1045    /// Test coverage inputs.
1046    pub coverage_inputs: ComplexityCoverageInputs<'a>,
1047}
1048
1049/// Derive effective health section flags for API consumers.
1050#[must_use]
1051pub fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
1052    let score = options.score
1053        || options.score_gate
1054        || options.trend
1055        || matches!(
1056            options.output,
1057            fallow_types::output_format::OutputFormat::Badge
1058        );
1059    let any_section = options.complexity
1060        || options.file_scores
1061        || options.coverage_gaps
1062        || options.hotspots
1063        || options.targets
1064        || score;
1065    let effective_score = if any_section { score } else { true } || options.snapshot_requested;
1066    let force_full = options.snapshot_requested || effective_score;
1067
1068    DerivedHealthSections {
1069        any_section,
1070        complexity: if any_section {
1071            options.complexity
1072        } else {
1073            true
1074        },
1075        file_scores: if any_section {
1076            options.file_scores
1077        } else {
1078            true
1079        } || force_full,
1080        coverage_gaps: if any_section {
1081            options.coverage_gaps
1082        } else {
1083            false
1084        },
1085        hotspots: if any_section { options.hotspots } else { true }
1086            || options.snapshot_requested
1087            || options.trend,
1088        targets: if any_section { options.targets } else { true },
1089        css: options.css,
1090        score: effective_score,
1091        force_full,
1092        score_only_output: is_health_score_only_output(options, score),
1093    }
1094}
1095
1096/// Derive effective programmatic health / complexity section flags.
1097#[must_use]
1098pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
1099    let requested_hotspots = options.hotspots || options.ownership;
1100    let sections = derive_health_sections(&HealthSectionOptions {
1101        output: fallow_types::output_format::OutputFormat::Human,
1102        complexity: options.complexity,
1103        file_scores: options.file_scores,
1104        coverage_gaps: options.coverage_gaps,
1105        hotspots: requested_hotspots,
1106        targets: options.targets,
1107        css: options.css,
1108        score: options.score,
1109        score_gate: false,
1110        snapshot_requested: false,
1111        trend: false,
1112    });
1113
1114    DerivedComplexityOptions {
1115        any_section: sections.any_section,
1116        complexity: sections.complexity,
1117        file_scores: sections.file_scores,
1118        coverage_gaps: sections.coverage_gaps,
1119        hotspots: sections.hotspots,
1120        ownership: options.ownership && sections.hotspots,
1121        targets: sections.targets,
1122        force_full: sections.force_full,
1123        score_only_output: sections.score_only_output,
1124        score: sections.score,
1125    }
1126}
1127
1128/// Derive effective programmatic health / complexity section flags.
1129#[must_use]
1130pub fn derive_complexity_options(options: &ComplexityOptions) -> DerivedComplexityOptions {
1131    derive_complexity_sections(&complexity_section_options(options))
1132}
1133
1134/// Normalize public API complexity options into engine-owned run contracts.
1135#[must_use]
1136pub fn derive_complexity_run_options(options: &ComplexityOptions) -> ComplexityRunOptions<'_> {
1137    ComplexityRunOptions {
1138        thresholds: ComplexityThresholdOverrides {
1139            max_cyclomatic: options.max_cyclomatic,
1140            max_cognitive: options.max_cognitive,
1141            max_crap: options.max_crap,
1142        },
1143        top: options.top,
1144        sort: options.sort,
1145        complexity_breakdown: options.complexity_breakdown,
1146        sections: derive_complexity_options(options),
1147        ownership_emails: options.ownership_emails,
1148        effort: options.effort,
1149        css: options.css,
1150        css_deep: options.css_deep,
1151        since: options.since.as_deref(),
1152        min_commits: options.min_commits,
1153        coverage_inputs: ComplexityCoverageInputs {
1154            coverage: options.coverage.as_deref(),
1155            coverage_root: options.coverage_root.as_deref(),
1156            coverage_relocated: options.coverage_relocated,
1157        },
1158    }
1159}
1160
1161/// Validate programmatic complexity / health inputs before invoking a concrete
1162/// runner.
1163///
1164/// These option contracts belong to the API boundary because NAPI and future
1165/// Rust embedders construct the same [`ComplexityOptions`] type.
1166///
1167/// A relative coverage path resolves against `analysis.root` (the cwd when
1168/// unset), exactly as the engine resolves it at read time, so a
1169/// project-relative `health.coverage` validates against the project and not
1170/// against the embedder's working directory.
1171///
1172/// The options carry no record of which layer supplied `coverage`, so the
1173/// path error's `context` is always `health.coverage`, whichever of the tool
1174/// parameter, `FALLOW_COVERAGE`, or the config field won
1175/// ([`crate::coverage::resolve_coverage_inputs`] resolves that order). The
1176/// sibling root error names its layer because
1177/// [`crate::coverage::CoverageInputError`] carries it.
1178///
1179/// # Errors
1180///
1181/// Returns a structured programmatic error when a coverage path does not exist
1182/// or when `coverage_root` is not an absolute prefix from the coverage data.
1183pub fn validate_complexity_options(options: &ComplexityOptions) -> Result<(), ProgrammaticError> {
1184    if let Some(path) = &options.coverage {
1185        let resolved = fallow_engine::health::scoring::resolve_relative_to_root(
1186            path,
1187            options.analysis.root.as_deref(),
1188        );
1189        if !resolved.exists() {
1190            return Err(ProgrammaticError::new(
1191                format!("coverage path does not exist: {}", resolved.display()),
1192                2,
1193            )
1194            .with_code("FALLOW_INVALID_COVERAGE_PATH")
1195            .with_context("health.coverage"));
1196        }
1197    }
1198    if let Err(message) =
1199        fallow_engine::health::validate_coverage_root_absolute(options.coverage_root.as_deref())
1200    {
1201        return Err(ProgrammaticError::new(message, 2)
1202            .with_code("FALLOW_INVALID_COVERAGE_ROOT")
1203            .with_context("health.coverage_root"));
1204    }
1205
1206    Ok(())
1207}
1208
1209fn complexity_section_options(options: &ComplexityOptions) -> ComplexitySectionOptions {
1210    let ownership = options.ownership || options.ownership_emails.is_some();
1211    let requested_targets = options.targets || options.effort.is_some();
1212    ComplexitySectionOptions {
1213        complexity: options.complexity,
1214        file_scores: options.file_scores,
1215        coverage_gaps: options.coverage_gaps,
1216        hotspots: options.hotspots,
1217        ownership,
1218        targets: requested_targets,
1219        css: options.css,
1220        score: options.score,
1221    }
1222}
1223
1224fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
1225    score
1226        && !options.complexity
1227        && !options.file_scores
1228        && !options.coverage_gaps
1229        && !options.hotspots
1230        && !options.targets
1231        && !options.trend
1232}
1233
1234const fn thresholds_to_engine(
1235    thresholds: ComplexityThresholdOverrides,
1236) -> fallow_engine::health::HealthThresholdOverrides {
1237    fallow_engine::health::HealthThresholdOverrides {
1238        max_cyclomatic: thresholds.max_cyclomatic,
1239        max_cognitive: thresholds.max_cognitive,
1240        max_crap: thresholds.max_crap,
1241    }
1242}
1243
1244const fn complexity_sort_to_engine(sort: ComplexitySort) -> fallow_engine::health::HealthSort {
1245    match sort {
1246        ComplexitySort::Severity => fallow_engine::health::HealthSort::Severity,
1247        ComplexitySort::Cyclomatic => fallow_engine::health::HealthSort::Cyclomatic,
1248        ComplexitySort::Cognitive => fallow_engine::health::HealthSort::Cognitive,
1249        ComplexitySort::Lines => fallow_engine::health::HealthSort::Lines,
1250    }
1251}
1252
1253const fn coverage_inputs_to_engine(
1254    coverage_inputs: ComplexityCoverageInputs<'_>,
1255) -> fallow_engine::health::HealthCoverageInputs<'_> {
1256    fallow_engine::health::HealthCoverageInputs {
1257        coverage: coverage_inputs.coverage,
1258        coverage_root: coverage_inputs.coverage_root,
1259        coverage_relocated: coverage_inputs.coverage_relocated,
1260    }
1261}
1262
1263const fn ownership_email_mode_to_config(mode: OwnershipEmailMode) -> EmailMode {
1264    match mode {
1265        OwnershipEmailMode::Raw => EmailMode::Raw,
1266        OwnershipEmailMode::Handle => EmailMode::Handle,
1267        OwnershipEmailMode::Anonymized => EmailMode::Anonymized,
1268        OwnershipEmailMode::Hash => EmailMode::Hash,
1269    }
1270}
1271
1272const fn target_effort_to_output(effort: TargetEffort) -> EffortEstimate {
1273    match effort {
1274        TargetEffort::Low => EffortEstimate::Low,
1275        TargetEffort::Medium => EffortEstimate::Medium,
1276        TargetEffort::High => EffortEstimate::High,
1277    }
1278}
1279
1280#[cfg(test)]
1281mod tests {
1282    use super::*;
1283
1284    #[test]
1285    fn duplication_defaults_match_cli_contract() {
1286        let options = DuplicationOptions::default();
1287        assert!(options.mode.is_none());
1288        assert!(options.min_tokens.is_none());
1289        assert!(options.min_lines.is_none());
1290        assert!(options.min_occurrences.is_none());
1291    }
1292
1293    #[test]
1294    fn programmatic_error_builder_keeps_optional_fields() {
1295        let error = ProgrammaticError::new("boom", 2)
1296            .with_code("FALLOW_TEST")
1297            .with_help("Try again")
1298            .with_context("analysis.root");
1299
1300        assert_eq!(error.message, "boom");
1301        assert_eq!(error.exit_code, 2);
1302        assert_eq!(error.code.as_deref(), Some("FALLOW_TEST"));
1303        assert_eq!(error.help.as_deref(), Some("Try again"));
1304        assert_eq!(error.context.as_deref(), Some("analysis.root"));
1305    }
1306
1307    #[test]
1308    fn dead_code_filters_accept_shared_registry_selectors() {
1309        for (selector, _) in fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS.iter() {
1310            let mut filters = DeadCodeFilters::default();
1311            assert!(
1312                filters.enable_registry_selector(selector),
1313                "{selector} should be accepted"
1314            );
1315        }
1316
1317        let mut filters = DeadCodeFilters::default();
1318        assert!(filters.enable_registry_selector("unused-files"));
1319        assert!(filters.unused_files);
1320        assert!(filters.enable_registry_selector("boundary-violations"));
1321        assert!(filters.boundary_violations);
1322        assert!(!filters.enable_registry_selector("not-a-real-selector"));
1323    }
1324
1325    #[test]
1326    fn default_complexity_options_match_programmatic_health_defaults() {
1327        let derived = derive_complexity_options(&ComplexityOptions::default());
1328
1329        assert!(!derived.any_section);
1330        assert!(derived.complexity);
1331        assert!(derived.file_scores);
1332        assert!(!derived.coverage_gaps);
1333        assert!(derived.hotspots);
1334        assert!(!derived.ownership);
1335        assert!(derived.targets);
1336        assert!(derived.force_full);
1337        assert!(!derived.score_only_output);
1338        assert!(derived.score);
1339    }
1340
1341    #[test]
1342    fn score_only_complexity_options_request_score_only_output() {
1343        let derived = derive_complexity_options(&ComplexityOptions {
1344            score: true,
1345            ..ComplexityOptions::default()
1346        });
1347
1348        assert!(derived.any_section);
1349        assert!(!derived.complexity);
1350        assert!(derived.file_scores);
1351        assert!(!derived.hotspots);
1352        assert!(!derived.targets);
1353        assert!(derived.force_full);
1354        assert!(derived.score_only_output);
1355        assert!(derived.score);
1356    }
1357
1358    #[test]
1359    fn ownership_implies_hotspots_when_requested() {
1360        let derived = derive_complexity_options(&ComplexityOptions {
1361            ownership: true,
1362            ..ComplexityOptions::default()
1363        });
1364
1365        assert!(derived.any_section);
1366        assert!(derived.hotspots);
1367        assert!(derived.ownership);
1368        assert!(!derived.targets);
1369    }
1370
1371    #[test]
1372    fn complexity_run_options_normalize_public_api_options() {
1373        let options = ComplexityOptions {
1374            max_cyclomatic: Some(42),
1375            max_cognitive: Some(21),
1376            max_crap: Some(18.5),
1377            top: Some(7),
1378            sort: ComplexitySort::Severity,
1379            complexity_breakdown: true,
1380            ownership_emails: Some(OwnershipEmailMode::Hash),
1381            effort: Some(TargetEffort::High),
1382            coverage: Some(PathBuf::from("coverage/coverage-final.json")),
1383            coverage_root: Some(PathBuf::from("/ci/workspace")),
1384            since: Some("30d".to_string()),
1385            min_commits: Some(4),
1386            ..ComplexityOptions::default()
1387        };
1388
1389        let run = derive_complexity_run_options(&options);
1390
1391        assert_eq!(run.thresholds.max_cyclomatic, Some(42));
1392        assert_eq!(run.thresholds.max_cognitive, Some(21));
1393        assert_eq!(run.thresholds.max_crap, Some(18.5));
1394        assert_eq!(run.top, Some(7));
1395        assert!(matches!(run.sort, ComplexitySort::Severity));
1396        assert!(run.complexity_breakdown);
1397        assert!(run.sections.hotspots);
1398        assert!(run.sections.ownership);
1399        assert!(run.sections.targets);
1400        assert!(matches!(
1401            run.ownership_emails,
1402            Some(OwnershipEmailMode::Hash)
1403        ));
1404        assert!(matches!(run.effort, Some(TargetEffort::High)));
1405        assert_eq!(run.since, Some("30d"));
1406        assert_eq!(run.min_commits, Some(4));
1407        assert_eq!(run.coverage_inputs.coverage, options.coverage.as_deref());
1408        assert_eq!(
1409            run.coverage_inputs.coverage_root,
1410            options.coverage_root.as_deref()
1411        );
1412    }
1413
1414    #[test]
1415    fn complexity_options_validation_accepts_existing_coverage_path_and_absolute_root() {
1416        let dir = tempfile::tempdir().expect("tempdir");
1417        let coverage = dir.path().join("coverage-final.json");
1418        std::fs::write(&coverage, "{}").expect("coverage fixture");
1419
1420        let result = validate_complexity_options(&ComplexityOptions {
1421            coverage: Some(coverage),
1422            coverage_root: Some(PathBuf::from("/ci/workspace")),
1423            ..ComplexityOptions::default()
1424        });
1425
1426        assert!(result.is_ok());
1427    }
1428
1429    #[test]
1430    fn complexity_options_validation_keeps_missing_coverage_error_contract() {
1431        let err = validate_complexity_options(&ComplexityOptions {
1432            coverage: Some(PathBuf::from("/missing/coverage-final.json")),
1433            ..ComplexityOptions::default()
1434        })
1435        .expect_err("missing coverage path should fail");
1436
1437        assert_eq!(err.exit_code, 2);
1438        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1439        assert_eq!(err.context.as_deref(), Some("health.coverage"));
1440    }
1441
1442    #[test]
1443    fn complexity_options_validation_keeps_relative_coverage_root_error_contract() {
1444        let err = validate_complexity_options(&ComplexityOptions {
1445            coverage_root: Some(PathBuf::from("coverage")),
1446            ..ComplexityOptions::default()
1447        })
1448        .expect_err("relative coverage root should fail");
1449
1450        assert_eq!(err.exit_code, 2);
1451        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_ROOT"));
1452        assert_eq!(err.context.as_deref(), Some("health.coverage_root"));
1453    }
1454
1455    /// #2368: a project-relative coverage path (the documented form of
1456    /// `health.coverage`) must be checked under the analysis root, not under
1457    /// the embedder's working directory.
1458    #[test]
1459    fn complexity_options_validation_resolves_relative_coverage_against_root() {
1460        let dir = tempfile::tempdir().expect("tempdir");
1461        std::fs::create_dir_all(dir.path().join("artifacts")).expect("artifacts dir");
1462        std::fs::write(dir.path().join("artifacts/coverage-final.json"), "{}")
1463            .expect("coverage fixture");
1464        let relative = PathBuf::from("artifacts/coverage-final.json");
1465        assert!(
1466            !relative.exists(),
1467            "the fixture must not also exist under the test cwd"
1468        );
1469
1470        let result = validate_complexity_options(&ComplexityOptions {
1471            analysis: AnalysisOptions {
1472                root: Some(dir.path().to_path_buf()),
1473                ..AnalysisOptions::default()
1474            },
1475            coverage: Some(relative.clone()),
1476            ..ComplexityOptions::default()
1477        });
1478        assert!(result.is_ok(), "{result:?}");
1479
1480        let err = validate_complexity_options(&ComplexityOptions {
1481            analysis: AnalysisOptions {
1482                root: Some(dir.path().join("elsewhere")),
1483                ..AnalysisOptions::default()
1484            },
1485            coverage: Some(relative),
1486            ..ComplexityOptions::default()
1487        })
1488        .expect_err("the path does not exist under the other root");
1489        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1490        assert!(
1491            err.message.contains("elsewhere"),
1492            "the message names the resolved path: {}",
1493            err.message
1494        );
1495    }
1496
1497    #[test]
1498    fn default_health_sections_match_full_health_output() {
1499        let derived = derive_health_sections(&HealthSectionOptions {
1500            output: fallow_types::output_format::OutputFormat::Human,
1501            complexity: false,
1502            file_scores: false,
1503            coverage_gaps: false,
1504            hotspots: false,
1505            targets: false,
1506            css: false,
1507            score: false,
1508            score_gate: false,
1509            snapshot_requested: false,
1510            trend: false,
1511        });
1512
1513        assert!(!derived.any_section);
1514        assert!(derived.complexity);
1515        assert!(derived.file_scores);
1516        assert!(!derived.coverage_gaps);
1517        assert!(derived.hotspots);
1518        assert!(derived.targets);
1519        assert!(derived.score);
1520        assert!(derived.force_full);
1521        assert!(!derived.score_only_output);
1522    }
1523
1524    #[test]
1525    fn health_score_gate_requests_score_only_output() {
1526        let derived = derive_health_sections(&HealthSectionOptions {
1527            output: fallow_types::output_format::OutputFormat::Human,
1528            complexity: false,
1529            file_scores: false,
1530            coverage_gaps: false,
1531            hotspots: false,
1532            targets: false,
1533            css: false,
1534            score: false,
1535            score_gate: true,
1536            snapshot_requested: false,
1537            trend: false,
1538        });
1539
1540        assert!(derived.any_section);
1541        assert!(!derived.complexity);
1542        assert!(derived.file_scores);
1543        assert!(!derived.hotspots);
1544        assert!(!derived.targets);
1545        assert!(derived.score);
1546        assert!(derived.force_full);
1547        assert!(derived.score_only_output);
1548    }
1549
1550    #[test]
1551    fn health_snapshot_keeps_full_hidden_inputs_without_section_request() {
1552        let derived = derive_health_sections(&HealthSectionOptions {
1553            output: fallow_types::output_format::OutputFormat::Human,
1554            complexity: false,
1555            file_scores: false,
1556            coverage_gaps: false,
1557            hotspots: false,
1558            targets: false,
1559            css: true,
1560            score: false,
1561            score_gate: false,
1562            snapshot_requested: true,
1563            trend: false,
1564        });
1565
1566        assert!(!derived.any_section);
1567        assert!(derived.css);
1568        assert!(derived.file_scores);
1569        assert!(derived.hotspots);
1570        assert!(derived.score);
1571        assert!(derived.force_full);
1572    }
1573}