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