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