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