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