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