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