Skip to main content

fallow_api/
lib.rs

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