Skip to main content

fallow_api/
lib.rs

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