Skip to main content

fallow_api/
lib.rs

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