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#![cfg_attr(
10    test,
11    allow(
12        clippy::expect_used,
13        reason = "tests use expect to keep fixture setup concise"
14    )
15)]
16
17use std::path::{Path, PathBuf};
18
19use fallow_config::EmailMode;
20use fallow_output::EffortEstimate;
21use serde::Serialize;
22
23mod analysis_context;
24pub mod audit_keys;
25pub mod audit_output;
26pub mod combined_output;
27pub mod compact_output;
28pub mod dead_code_codeclimate;
29pub mod dead_code_sarif;
30pub mod decision_surface;
31pub mod dupes_output;
32mod duplication_filters;
33pub mod editor;
34pub mod explain;
35pub mod grouped_output;
36pub mod health_codeclimate;
37pub mod json_output;
38pub mod list_output;
39mod list_runtime;
40pub mod markdown_output;
41mod next_steps;
42pub mod output_contracts;
43pub mod review_deltas;
44pub mod routing;
45pub mod runtime;
46mod runtime_json;
47mod runtime_output;
48pub mod sarif_output;
49pub mod security_output;
50mod type_aware;
51pub mod ci_output {
52    //! Compatibility re-exports for CI output builders now owned by
53    //! `fallow-output`.
54
55    pub use fallow_output::{
56        CiIssue, CiProvider, GroupedReviewIssues, MARKER_PREFIX_V2, MARKER_SUFFIX_V2,
57        MAX_COMMENT_BODY_BYTES, PROJECT_LEVEL_RULE_IDS, PrCommentRenderInput,
58        ReviewCommentRenderInput, ReviewEnvelopeRenderInput, ReviewEnvelopeRenderResult,
59        ReviewEnvelopeTruncation, ReviewGitlabDiffRefs, cap_body_with_marker, command_title,
60        composite_fingerprint, escape_md, github_check_conclusion,
61        group_review_issues_by_path_line, is_project_level_rule, issues_from_codeclimate,
62        issues_from_codeclimate_issues, render_pr_comment, render_review_comment_for_group,
63        render_review_envelope, review_label_from_codeclimate, summary_fingerprint, summary_label,
64    };
65}
66pub use analysis_context::{ProgrammaticAnalysisContext, resolve_programmatic_analysis_context};
67pub use audit_output::{
68    AuditAttribution, AuditCodeClimateOutputInput, AuditJsonHeaderInput, AuditJsonOutputInput,
69    AuditSarifOutputInput, AuditSummary, AuditVerdict, attach_audit_styling_attribution,
70    build_audit_codeclimate, build_audit_codeclimate_issues, build_audit_header_json,
71    build_audit_header_map, build_audit_sarif, build_review_brief_header, serialize_audit_json,
72};
73pub use ci_output::{
74    CiIssue, CiProvider, GroupedReviewIssues, MARKER_PREFIX_V2, MARKER_SUFFIX_V2,
75    MAX_COMMENT_BODY_BYTES, PROJECT_LEVEL_RULE_IDS, PrCommentRenderInput, ReviewCommentRenderInput,
76    ReviewEnvelopeRenderInput, ReviewEnvelopeRenderResult, ReviewEnvelopeTruncation,
77    ReviewGitlabDiffRefs, cap_body_with_marker, command_title, composite_fingerprint, escape_md,
78    github_check_conclusion, group_review_issues_by_path_line, is_project_level_rule,
79    issues_from_codeclimate, issues_from_codeclimate_issues, render_pr_comment,
80    render_review_comment_for_group, render_review_envelope, review_label_from_codeclimate,
81    summary_fingerprint, summary_label,
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_duplication_compact_lines, build_grouped_compact_lines,
89    build_health_compact_lines,
90};
91pub use dead_code_codeclimate::build_codeclimate;
92pub use dead_code_sarif::build_sarif;
93pub use dupes_output::{
94    AttributedCloneGroup, AttributedCloneGroupFinding, AttributedInstance, CloneFamilyFinding,
95    CloneGroupFinding, DupesReportPayload, DuplicationGroup, DuplicationGrouping,
96    build_duplication_codeclimate,
97};
98pub use editor::{
99    ChangedFilesError, EditorAnalysisOutput, EditorAnalysisResults, EditorAnalysisSession,
100    EditorCloneFamily, EditorCloneFingerprintSet, EditorCloneGroup, EditorCloneInstance,
101    EditorDeadCodeAnalysisOutput, EditorDuplicationReport, EditorDuplicationStats,
102    EditorInlineComplexityExceeded, EditorInlineComplexityFinding, EditorMirroredDirectory,
103    EditorProjectAnalysisOutput, EditorRefactoringKind, EditorRefactoringSuggestion,
104    collect_inline_complexity, editor_duplicates, editor_extract, editor_results, editor_security,
105    editor_suppress, filter_inline_complexity_by_changed_files, resolve_git_toplevel,
106    try_get_changed_files_with_toplevel,
107};
108pub use explain::{
109    CHECK_RULES, DUPES_RULES, FLAGS_RULES, HEALTH_RULES, RuleDef, RuleGuide, SECURITY_RULES,
110    coverage_analyze_meta, coverage_setup_meta, explain_issue_type, rule_by_id, rule_by_token,
111    rule_docs_url, rule_guide, security_meta, serialize_explain_programmatic_json,
112    unknown_explain_error,
113};
114pub use fallow_config::{AuditGate, TypeAwareRequire};
115pub use fallow_output::RootEnvelopeMode;
116pub use fallow_types::trace::{
117    CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace, ReExportChain,
118    TracedCloneGroup, TracedExport, TracedReExport,
119};
120pub use grouped_output::{
121    ResultGroup, UNOWNED_GROUP_LABEL, build_duplication_grouping_with, group_analysis_results_with,
122    largest_clone_group_owner_with,
123};
124pub use health_codeclimate::build_health_codeclimate;
125pub use json_output::{
126    CheckJsonExtraOutputs, CheckJsonOutputInput, CheckJsonPayloadInput, DuplicationJsonOutputInput,
127    GroupedCheckJsonOutputInput, GroupedDuplicationJsonOutputInput, serialize_check_json,
128    serialize_check_json_payload, serialize_duplication_json, serialize_grouped_check_json,
129    serialize_grouped_duplication_json,
130};
131pub use list_output::{
132    ListJsonEnvelope, ListJsonOutputInput, build_list_json_output, serialize_list_json_output,
133};
134pub use list_runtime::{
135    BoundaryData, ListBoundariesOptions, ListBoundariesProgrammaticOutput, LogicalGroupInfo,
136    ProjectInfoOptions, ProjectInfoProgrammaticOutput, RuleInfo, ZoneInfo, boundary_data_to_output,
137    compute_boundary_data, run_list_boundaries, run_project_info,
138    serialize_list_boundaries_programmatic_json, serialize_project_info_programmatic_json,
139};
140pub use markdown_output::{
141    build_duplication_markdown, build_grouped_markdown, build_health_markdown, build_markdown,
142    build_walkthrough_markdown,
143};
144pub use output_contracts::{
145    AuditOutput, BoundariesListLogicalGroup, BoundariesListRule, BoundariesListZone,
146    BoundariesListing, CombinedOutput, FallowOutput, ImpactOutput, ListBoundariesOutput,
147    ListEntryPointOutput, ListOutput, ListPluginOutput, ReviewBriefWireOutput, SecurityGate,
148    SecurityOutput, SecurityOutputConfig, SecuritySummaryOutput, TraceOutput, WorkspacesOutput,
149};
150pub use runtime::{
151    AuditProgrammaticKeySnapshot, AuditProgrammaticOutput, BoundaryViolationsOutput,
152    BoundaryViolationsProgrammaticOutput, CircularDependenciesOutput,
153    CircularDependenciesProgrammaticOutput, CombinedProgrammaticOutput, DeadCodeOutput,
154    DeadCodeProgrammaticOutput, DecisionSurfaceProgrammaticOutput, DuplicationOutput,
155    DuplicationProgrammaticOutput, EngineHealthRunner, FeatureFlagsOutput,
156    FeatureFlagsProgrammaticOutput, HealthJsonReportInput, HealthProgrammaticOutput,
157    ProgrammaticHealthAnalysis, ProgrammaticHealthNextStepFacts, ProgrammaticHealthRun,
158    ProgrammaticHealthRunner, TraceClassMemberOutput, TraceCloneOutput,
159    TraceCloneProgrammaticOutput, TraceDependencyOutput, TraceDependencyProgrammaticOutput,
160    TraceExportOutput, TraceExportProgrammaticOutput, TraceExportTargetOutput, TraceFileOutput,
161    TraceFileProgrammaticOutput, run_audit, run_boundary_violations, run_circular_dependencies,
162    run_combined, run_complexity_with_runner, run_dead_code, run_decision_surface, run_duplication,
163    run_feature_flags, run_health, run_health_with_runner, run_trace_clone, run_trace_dependency,
164    run_trace_export, run_trace_file, serialize_health_report_json,
165};
166pub use runtime_json::{
167    serialize_audit_programmatic_json, serialize_boundary_violations_programmatic_json,
168    serialize_circular_dependencies_programmatic_json, serialize_combined_programmatic_json,
169    serialize_dead_code_programmatic_json, serialize_decision_surface_programmatic_json,
170    serialize_duplication_programmatic_json, serialize_feature_flags_programmatic_json,
171    serialize_health_programmatic_json, serialize_trace_clone_programmatic_json,
172    serialize_trace_dependency_programmatic_json, serialize_trace_export_programmatic_json,
173    serialize_trace_file_programmatic_json,
174};
175pub use sarif_output::{
176    annotate_sarif_results, build_duplication_sarif, build_grouped_duplication_sarif,
177    build_health_sarif,
178};
179pub use security_output::SecurityGateMode;
180pub use type_aware::{
181    SemanticCouplingOutcome, SemanticDeadCodeOutcome, SemanticInspectOutcome, TypeAwareError,
182    TypeAwareFileChanges, TypeAwareOutcome, TypeAwareSession, TypeAwareStatus,
183    discard_unverified_semantic_candidates, inspect_symbol as inspect_type_aware_symbol,
184    merge_type_aware_meta,
185    refine_configured_dead_code_results as refine_type_aware_results_with_config,
186    refine_configured_dead_code_results_in_session as refine_type_aware_results_in_session_with_config,
187    refine_dead_code_results as refine_type_aware_results,
188    refine_dead_code_results_in_session as refine_type_aware_results_in_session,
189    refine_programmatic_dead_code as refine_type_aware_dead_code, shutdown_type_aware_sidecars,
190    status as type_aware_status, symbol_impact as run_type_aware_symbol_impact,
191    symbol_impact as type_aware_symbol_impact, terminate_active_type_aware_sidecars,
192    trace_symbol as run_type_aware_symbol_trace, trace_symbol as trace_type_aware_symbol,
193    type_coupling as analyze_type_coupling,
194};
195
196pub const COMMON_ANALYSIS_OPTION_FLAGS: &[&str] = &[
197    "root",
198    "config",
199    "no-cache",
200    "threads",
201    "changed-since",
202    "diff-file",
203    "production",
204    "workspace",
205    "changed-workspaces",
206    "explain",
207    "allow-remote-extends",
208];
209
210/// Structured error surface for the programmatic API.
211#[derive(Debug, Clone, Serialize)]
212pub struct ProgrammaticError {
213    pub message: String,
214    pub exit_code: u8,
215    pub code: Option<String>,
216    pub help: Option<String>,
217    pub context: Option<String>,
218}
219
220impl ProgrammaticError {
221    #[must_use]
222    pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
223        Self {
224            message: message.into(),
225            exit_code,
226            code: None,
227            help: None,
228            context: None,
229        }
230    }
231
232    #[must_use]
233    pub fn with_help(mut self, help: impl Into<String>) -> Self {
234        self.help = Some(help.into());
235        self
236    }
237
238    #[must_use]
239    pub fn with_code(mut self, code: impl Into<String>) -> Self {
240        self.code = Some(code.into());
241        self
242    }
243
244    #[must_use]
245    pub fn with_context(mut self, context: impl Into<String>) -> Self {
246        self.context = Some(context.into());
247        self
248    }
249}
250
251impl std::fmt::Display for ProgrammaticError {
252    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253        write!(f, "{}", self.message)
254    }
255}
256
257impl std::error::Error for ProgrammaticError {}
258
259/// Shared options for all one-shot analyses.
260#[derive(Debug, Clone, Default)]
261pub struct AnalysisOptions {
262    pub root: Option<PathBuf>,
263    pub config_path: Option<PathBuf>,
264    /// Permit `https://` config inheritance for this analysis call.
265    pub allow_remote_extends: bool,
266    pub no_cache: bool,
267    pub threads: Option<usize>,
268    pub diff_file: Option<PathBuf>,
269    /// Legacy convenience override. `true` forces production mode; `false`
270    /// defers to config unless `production_override` is set.
271    pub production: bool,
272    /// Explicit production override from an embedder option. `None` means
273    /// use the project config for the current analysis.
274    pub production_override: Option<bool>,
275    pub changed_since: Option<String>,
276    pub workspace: Option<Vec<String>>,
277    pub changed_workspaces: Option<String>,
278    pub explain: bool,
279    /// Optional project-wide TypeScript semantic analysis. Disabled by default
280    /// and never changes compiler or typed-lint ownership.
281    pub type_aware: TypeAwareOptions,
282}
283
284/// Typed options for Fallow's optional TypeScript semantic companion.
285#[derive(Debug, Clone, Default, PartialEq, Eq)]
286pub struct TypeAwareOptions {
287    pub enabled: bool,
288    pub projects: Vec<PathBuf>,
289    pub require: fallow_config::TypeAwareRequire,
290}
291
292/// Issue-type filters for the dead-code analysis.
293#[derive(Debug, Clone, Default)]
294pub struct DeadCodeFilters {
295    pub unused_files: bool,
296    pub unused_exports: bool,
297    pub unused_deps: bool,
298    pub unused_types: bool,
299    pub private_type_leaks: bool,
300    pub unused_enum_members: bool,
301    pub unused_class_members: bool,
302    pub unused_store_members: bool,
303    pub unprovided_injects: bool,
304    pub unrendered_components: bool,
305    pub unused_component_props: bool,
306    pub unused_component_emits: bool,
307    pub unused_component_inputs: bool,
308    pub unused_component_outputs: bool,
309    pub unused_svelte_events: bool,
310    pub unused_server_actions: bool,
311    pub unused_load_data_keys: bool,
312    pub unresolved_imports: bool,
313    pub unlisted_deps: bool,
314    pub duplicate_exports: bool,
315    pub circular_deps: bool,
316    pub re_export_cycles: bool,
317    pub boundary_violations: bool,
318    pub policy_violations: bool,
319    pub stale_suppressions: bool,
320    pub unused_catalog_entries: bool,
321    pub empty_catalog_groups: bool,
322    pub unresolved_catalog_references: bool,
323    pub unused_dependency_overrides: bool,
324    pub misconfigured_dependency_overrides: bool,
325}
326
327impl DeadCodeFilters {
328    fn any_active(&self) -> bool {
329        self.unused_files
330            || self.unused_exports
331            || self.unused_deps
332            || self.unused_types
333            || self.private_type_leaks
334            || self.unused_enum_members
335            || self.unused_class_members
336            || self.unused_store_members
337            || self.unprovided_injects
338            || self.unrendered_components
339            || self.unused_component_props
340            || self.unused_component_emits
341            || self.unused_component_inputs
342            || self.unused_component_outputs
343            || self.unused_svelte_events
344            || self.unused_server_actions
345            || self.unused_load_data_keys
346            || self.unresolved_imports
347            || self.unlisted_deps
348            || self.duplicate_exports
349            || self.circular_deps
350            || self.re_export_cycles
351            || self.boundary_violations
352            || self.policy_violations
353            || self.stale_suppressions
354            || self.unused_catalog_entries
355            || self.empty_catalog_groups
356            || self.unresolved_catalog_references
357            || self.unused_dependency_overrides
358            || self.misconfigured_dependency_overrides
359    }
360
361    /// Enable the issue filter addressed by a shared registry selector.
362    ///
363    /// Returns `false` when the selector is not registered for dead-code
364    /// filtering. Callers that expose user input should surface their own
365    /// validation error with the accepted registry values.
366    pub fn enable_registry_selector(&mut self, selector: &str) -> bool {
367        let Some(flag) = fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS
368            .iter()
369            .find_map(|&(name, flag)| (name == selector).then_some(flag))
370        else {
371            return false;
372        };
373        self.enable_cli_filter_flag(flag);
374        true
375    }
376
377    fn enable_cli_filter_flag(&mut self, flag: &str) {
378        match flag {
379            "--unused-files" => self.unused_files = true,
380            "--unused-exports" => self.unused_exports = true,
381            "--unused-types" => self.unused_types = true,
382            "--private-type-leaks" => self.private_type_leaks = true,
383            "--unused-deps" => self.unused_deps = true,
384            "--unused-enum-members" => self.unused_enum_members = true,
385            "--unused-class-members" => self.unused_class_members = true,
386            "--unused-store-members" => self.unused_store_members = true,
387            "--unprovided-injects" => self.unprovided_injects = true,
388            "--unrendered-components" => self.unrendered_components = true,
389            "--unused-component-props" => self.unused_component_props = true,
390            "--unused-component-emits" => self.unused_component_emits = true,
391            "--unused-component-inputs" => self.unused_component_inputs = true,
392            "--unused-component-outputs" => self.unused_component_outputs = true,
393            "--unused-svelte-events" => self.unused_svelte_events = true,
394            "--unused-server-actions" => self.unused_server_actions = true,
395            "--unused-load-data-keys" => self.unused_load_data_keys = true,
396            "--unresolved-imports" => self.unresolved_imports = true,
397            "--unlisted-deps" => self.unlisted_deps = true,
398            "--duplicate-exports" => self.duplicate_exports = true,
399            "--circular-deps" => self.circular_deps = true,
400            "--re-export-cycles" => self.re_export_cycles = true,
401            "--boundary-violations" => self.boundary_violations = true,
402            "--policy-violations" => self.policy_violations = true,
403            "--stale-suppressions" => self.stale_suppressions = true,
404            "--unused-catalog-entries" => self.unused_catalog_entries = true,
405            "--empty-catalog-groups" => self.empty_catalog_groups = true,
406            "--unresolved-catalog-references" => self.unresolved_catalog_references = true,
407            "--unused-dependency-overrides" => self.unused_dependency_overrides = true,
408            "--misconfigured-dependency-overrides" => {
409                self.misconfigured_dependency_overrides = true;
410            }
411            _ => unreachable!("registry emitted unsupported dead-code filter flag: {flag}"),
412        }
413    }
414}
415
416/// Options for dead-code-oriented analyses.
417#[derive(Debug, Clone, Default)]
418pub struct DeadCodeOptions {
419    pub analysis: AnalysisOptions,
420    pub filters: DeadCodeFilters,
421    pub files: Vec<PathBuf>,
422    pub include_entry_exports: bool,
423}
424
425/// Options for changed-code audit analysis.
426#[derive(Debug, Clone, Default)]
427pub struct AuditOptions {
428    pub analysis: AnalysisOptions,
429    pub base: Option<String>,
430    pub production: bool,
431    pub production_dead_code: Option<bool>,
432    pub production_health: Option<bool>,
433    pub production_dupes: Option<bool>,
434    pub css: Option<bool>,
435    pub css_deep: Option<bool>,
436    pub gate: fallow_config::AuditGate,
437    pub max_crap: Option<f64>,
438    pub coverage: Option<PathBuf>,
439    pub coverage_root: Option<PathBuf>,
440    pub include_entry_exports: bool,
441    pub runtime_coverage: Option<PathBuf>,
442    pub min_invocations_hot: u64,
443}
444
445/// Options for bare combined analysis through the programmatic API.
446#[derive(Debug, Clone)]
447pub struct CombinedOptions {
448    pub analysis: AnalysisOptions,
449    pub dead_code: bool,
450    pub duplication: bool,
451    pub health: bool,
452    pub include_entry_exports: bool,
453    pub duplication_options: DuplicationOptions,
454    pub health_options: ComplexityOptions,
455}
456
457impl Default for CombinedOptions {
458    fn default() -> Self {
459        Self {
460            analysis: AnalysisOptions::default(),
461            dead_code: true,
462            duplication: true,
463            health: true,
464            include_entry_exports: false,
465            duplication_options: DuplicationOptions::default(),
466            health_options: ComplexityOptions::default(),
467        }
468    }
469}
470
471/// Options for changed-code decision-surface analysis.
472#[derive(Debug, Clone, Default)]
473pub struct DecisionSurfaceOptions {
474    pub analysis: AnalysisOptions,
475    pub base: Option<String>,
476    pub max_decisions: Option<usize>,
477}
478
479/// Options for feature-flag analysis.
480#[derive(Debug, Clone, Default)]
481pub struct FeatureFlagsOptions {
482    pub analysis: AnalysisOptions,
483    pub top: Option<usize>,
484}
485
486/// Programmatic duplication mode selection.
487#[derive(Debug, Clone, Copy, Default)]
488pub enum DuplicationMode {
489    Strict,
490    #[default]
491    Mild,
492    Weak,
493    Semantic,
494}
495
496/// Options for duplication analysis.
497#[derive(Debug, Clone, Default)]
498pub struct DuplicationOptions {
499    pub analysis: AnalysisOptions,
500    pub mode: Option<DuplicationMode>,
501    pub min_tokens: Option<usize>,
502    pub min_lines: Option<usize>,
503    /// Minimum number of occurrences before a clone group is reported.
504    /// Values below 2 are silently treated as 2 by the engine-facing adapter.
505    pub min_occurrences: Option<usize>,
506    pub threshold: Option<f64>,
507    pub skip_local: Option<bool>,
508    pub cross_language: Option<bool>,
509    /// Exclude module wiring from clone detection. `None` defers to the project
510    /// config.
511    pub ignore_imports: Option<bool>,
512    pub top: Option<usize>,
513}
514
515/// Options for export trace analysis.
516#[derive(Debug, Clone, Default)]
517pub struct TraceExportOptions {
518    pub analysis: AnalysisOptions,
519    pub file: String,
520    pub export_name: String,
521}
522
523/// Options for file trace analysis.
524#[derive(Debug, Clone, Default)]
525pub struct TraceFileOptions {
526    pub analysis: AnalysisOptions,
527    pub file: String,
528}
529
530/// Options for dependency trace analysis.
531#[derive(Debug, Clone, Default)]
532pub struct TraceDependencyOptions {
533    pub analysis: AnalysisOptions,
534    pub package_name: String,
535}
536
537/// Duplicate-code trace target.
538#[derive(Debug, Clone, PartialEq, Eq)]
539pub enum TraceCloneTarget {
540    Location { file: String, line: usize },
541    Fingerprint(String),
542}
543
544/// Options for duplicate-code trace analysis.
545#[derive(Debug, Clone)]
546pub struct TraceCloneOptions {
547    pub duplication: DuplicationOptions,
548    pub target: TraceCloneTarget,
549}
550
551/// Sort criteria for complexity findings.
552#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
553pub enum ComplexitySort {
554    #[default]
555    Cyclomatic,
556    Cognitive,
557    Lines,
558    Severity,
559}
560
561/// Privacy mode for ownership-aware hotspot output.
562#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
563pub enum OwnershipEmailMode {
564    Raw,
565    #[default]
566    Handle,
567    Anonymized,
568    /// Legacy spelling retained for embedders that already pass `hash`.
569    Hash,
570}
571
572/// Effort filter for refactoring targets.
573#[derive(Debug, Clone, Copy, PartialEq, Eq)]
574pub enum TargetEffort {
575    Low,
576    Medium,
577    High,
578}
579
580/// Options for complexity / health analysis.
581#[derive(Debug, Clone, Default)]
582pub struct ComplexityOptions {
583    pub analysis: AnalysisOptions,
584    pub max_cyclomatic: Option<u16>,
585    pub max_cognitive: Option<u16>,
586    pub max_crap: Option<f64>,
587    pub top: Option<usize>,
588    pub sort: ComplexitySort,
589    pub complexity_breakdown: bool,
590    pub complexity: bool,
591    pub file_scores: bool,
592    pub coverage_gaps: bool,
593    pub hotspots: bool,
594    pub ownership: bool,
595    pub ownership_emails: Option<OwnershipEmailMode>,
596    pub targets: bool,
597    pub css: bool,
598    pub css_deep: bool,
599    pub effort: Option<TargetEffort>,
600    pub score: bool,
601    pub since: Option<String>,
602    pub min_commits: Option<u32>,
603    pub coverage: Option<PathBuf>,
604    pub coverage_root: Option<PathBuf>,
605}
606
607/// Health threshold overrides accepted by the programmatic API.
608#[derive(Debug, Clone, Copy, Default, PartialEq)]
609pub struct ComplexityThresholdOverrides {
610    pub max_cyclomatic: Option<u16>,
611    pub max_cognitive: Option<u16>,
612    pub max_crap: Option<f64>,
613}
614
615/// Coverage inputs accepted by the programmatic API.
616#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
617pub struct ComplexityCoverageInputs<'a> {
618    pub coverage: Option<&'a Path>,
619    pub coverage_root: Option<&'a Path>,
620}
621
622/// Input for deriving effective health sections from API-owned flags.
623#[derive(Debug, Clone)]
624pub struct HealthSectionOptions {
625    pub output: fallow_types::output_format::OutputFormat,
626    pub complexity: bool,
627    pub file_scores: bool,
628    pub coverage_gaps: bool,
629    pub hotspots: bool,
630    pub targets: bool,
631    pub css: bool,
632    pub score: bool,
633    pub score_gate: bool,
634    pub snapshot_requested: bool,
635    pub trend: bool,
636}
637
638/// Derived section selection for health runs.
639#[derive(Debug, Clone, Copy, PartialEq, Eq)]
640pub struct DerivedHealthSections {
641    pub any_section: bool,
642    pub complexity: bool,
643    pub file_scores: bool,
644    pub coverage_gaps: bool,
645    pub hotspots: bool,
646    pub targets: bool,
647    pub css: bool,
648    pub score: bool,
649    pub force_full: bool,
650    pub score_only_output: bool,
651}
652
653/// Input for deriving effective programmatic complexity sections.
654#[derive(Debug, Clone)]
655pub struct ComplexitySectionOptions {
656    pub complexity: bool,
657    pub file_scores: bool,
658    pub coverage_gaps: bool,
659    pub hotspots: bool,
660    pub ownership: bool,
661    pub targets: bool,
662    pub css: bool,
663    pub score: bool,
664}
665
666/// Derived section selection for programmatic health / complexity runs.
667#[derive(Debug, Clone, Copy, PartialEq, Eq)]
668pub struct DerivedComplexityOptions {
669    pub any_section: bool,
670    pub complexity: bool,
671    pub file_scores: bool,
672    pub coverage_gaps: bool,
673    pub hotspots: bool,
674    pub ownership: bool,
675    pub targets: bool,
676    pub force_full: bool,
677    pub score_only_output: bool,
678    pub score: bool,
679}
680
681/// Normalized programmatic complexity / health inputs owned by `fallow-api`.
682#[derive(Debug, Clone, PartialEq)]
683pub struct ComplexityRunOptions<'a> {
684    pub thresholds: ComplexityThresholdOverrides,
685    pub top: Option<usize>,
686    pub sort: ComplexitySort,
687    pub complexity_breakdown: bool,
688    pub sections: DerivedComplexityOptions,
689    pub ownership_emails: Option<OwnershipEmailMode>,
690    pub effort: Option<TargetEffort>,
691    pub css: bool,
692    pub css_deep: bool,
693    pub since: Option<&'a str>,
694    pub min_commits: Option<u32>,
695    pub coverage_inputs: ComplexityCoverageInputs<'a>,
696}
697
698/// Derive effective health section flags for API consumers.
699#[must_use]
700pub fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
701    let score = options.score
702        || options.score_gate
703        || options.trend
704        || matches!(
705            options.output,
706            fallow_types::output_format::OutputFormat::Badge
707        );
708    let any_section = options.complexity
709        || options.file_scores
710        || options.coverage_gaps
711        || options.hotspots
712        || options.targets
713        || score;
714    let effective_score = if any_section { score } else { true } || options.snapshot_requested;
715    let force_full = options.snapshot_requested || effective_score;
716
717    DerivedHealthSections {
718        any_section,
719        complexity: if any_section {
720            options.complexity
721        } else {
722            true
723        },
724        file_scores: if any_section {
725            options.file_scores
726        } else {
727            true
728        } || force_full,
729        coverage_gaps: if any_section {
730            options.coverage_gaps
731        } else {
732            false
733        },
734        hotspots: if any_section { options.hotspots } else { true }
735            || options.snapshot_requested
736            || options.trend,
737        targets: if any_section { options.targets } else { true },
738        css: options.css,
739        score: effective_score,
740        force_full,
741        score_only_output: is_health_score_only_output(options, score),
742    }
743}
744
745/// Derive effective programmatic health / complexity section flags.
746#[must_use]
747pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
748    let requested_hotspots = options.hotspots || options.ownership;
749    let sections = derive_health_sections(&HealthSectionOptions {
750        output: fallow_types::output_format::OutputFormat::Human,
751        complexity: options.complexity,
752        file_scores: options.file_scores,
753        coverage_gaps: options.coverage_gaps,
754        hotspots: requested_hotspots,
755        targets: options.targets,
756        css: options.css,
757        score: options.score,
758        score_gate: false,
759        snapshot_requested: false,
760        trend: false,
761    });
762
763    DerivedComplexityOptions {
764        any_section: sections.any_section,
765        complexity: sections.complexity,
766        file_scores: sections.file_scores,
767        coverage_gaps: sections.coverage_gaps,
768        hotspots: sections.hotspots,
769        ownership: options.ownership && sections.hotspots,
770        targets: sections.targets,
771        force_full: sections.force_full,
772        score_only_output: sections.score_only_output,
773        score: sections.score,
774    }
775}
776
777/// Derive effective programmatic health / complexity section flags.
778#[must_use]
779pub fn derive_complexity_options(options: &ComplexityOptions) -> DerivedComplexityOptions {
780    derive_complexity_sections(&complexity_section_options(options))
781}
782
783/// Normalize public API complexity options into engine-owned run contracts.
784#[must_use]
785pub fn derive_complexity_run_options(options: &ComplexityOptions) -> ComplexityRunOptions<'_> {
786    ComplexityRunOptions {
787        thresholds: ComplexityThresholdOverrides {
788            max_cyclomatic: options.max_cyclomatic,
789            max_cognitive: options.max_cognitive,
790            max_crap: options.max_crap,
791        },
792        top: options.top,
793        sort: options.sort,
794        complexity_breakdown: options.complexity_breakdown,
795        sections: derive_complexity_options(options),
796        ownership_emails: options.ownership_emails,
797        effort: options.effort,
798        css: options.css,
799        css_deep: options.css_deep,
800        since: options.since.as_deref(),
801        min_commits: options.min_commits,
802        coverage_inputs: ComplexityCoverageInputs {
803            coverage: options.coverage.as_deref(),
804            coverage_root: options.coverage_root.as_deref(),
805        },
806    }
807}
808
809/// Validate programmatic complexity / health inputs before invoking a concrete
810/// runner.
811///
812/// These option contracts belong to the API boundary because NAPI and future
813/// Rust embedders construct the same [`ComplexityOptions`] type.
814///
815/// # Errors
816///
817/// Returns a structured programmatic error when a coverage path does not exist
818/// or when `coverage_root` is not an absolute prefix from the coverage data.
819pub fn validate_complexity_options(options: &ComplexityOptions) -> Result<(), ProgrammaticError> {
820    if let Some(path) = &options.coverage
821        && !path.exists()
822    {
823        return Err(ProgrammaticError::new(
824            format!("coverage path does not exist: {}", path.display()),
825            2,
826        )
827        .with_code("FALLOW_INVALID_COVERAGE_PATH")
828        .with_context("health.coverage"));
829    }
830    if let Err(message) =
831        fallow_engine::health::validate_coverage_root_absolute(options.coverage_root.as_deref())
832    {
833        return Err(ProgrammaticError::new(message, 2)
834            .with_code("FALLOW_INVALID_COVERAGE_ROOT")
835            .with_context("health.coverage_root"));
836    }
837
838    Ok(())
839}
840
841fn complexity_section_options(options: &ComplexityOptions) -> ComplexitySectionOptions {
842    let ownership = options.ownership || options.ownership_emails.is_some();
843    let requested_targets = options.targets || options.effort.is_some();
844    ComplexitySectionOptions {
845        complexity: options.complexity,
846        file_scores: options.file_scores,
847        coverage_gaps: options.coverage_gaps,
848        hotspots: options.hotspots,
849        ownership,
850        targets: requested_targets,
851        css: options.css,
852        score: options.score,
853    }
854}
855
856fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
857    score
858        && !options.complexity
859        && !options.file_scores
860        && !options.coverage_gaps
861        && !options.hotspots
862        && !options.targets
863        && !options.trend
864}
865
866const fn thresholds_to_engine(
867    thresholds: ComplexityThresholdOverrides,
868) -> fallow_engine::health::HealthThresholdOverrides {
869    fallow_engine::health::HealthThresholdOverrides {
870        max_cyclomatic: thresholds.max_cyclomatic,
871        max_cognitive: thresholds.max_cognitive,
872        max_crap: thresholds.max_crap,
873    }
874}
875
876const fn complexity_sort_to_engine(sort: ComplexitySort) -> fallow_engine::health::HealthSort {
877    match sort {
878        ComplexitySort::Severity => fallow_engine::health::HealthSort::Severity,
879        ComplexitySort::Cyclomatic => fallow_engine::health::HealthSort::Cyclomatic,
880        ComplexitySort::Cognitive => fallow_engine::health::HealthSort::Cognitive,
881        ComplexitySort::Lines => fallow_engine::health::HealthSort::Lines,
882    }
883}
884
885const fn coverage_inputs_to_engine(
886    coverage_inputs: ComplexityCoverageInputs<'_>,
887) -> fallow_engine::health::HealthCoverageInputs<'_> {
888    fallow_engine::health::HealthCoverageInputs {
889        coverage: coverage_inputs.coverage,
890        coverage_root: coverage_inputs.coverage_root,
891    }
892}
893
894const fn ownership_email_mode_to_config(mode: OwnershipEmailMode) -> EmailMode {
895    match mode {
896        OwnershipEmailMode::Raw => EmailMode::Raw,
897        OwnershipEmailMode::Handle => EmailMode::Handle,
898        OwnershipEmailMode::Anonymized => EmailMode::Anonymized,
899        OwnershipEmailMode::Hash => EmailMode::Hash,
900    }
901}
902
903const fn target_effort_to_output(effort: TargetEffort) -> EffortEstimate {
904    match effort {
905        TargetEffort::Low => EffortEstimate::Low,
906        TargetEffort::Medium => EffortEstimate::Medium,
907        TargetEffort::High => EffortEstimate::High,
908    }
909}
910
911#[cfg(test)]
912mod tests {
913    use super::*;
914
915    #[test]
916    fn duplication_defaults_match_cli_contract() {
917        let options = DuplicationOptions::default();
918        assert!(options.mode.is_none());
919        assert!(options.min_tokens.is_none());
920        assert!(options.min_lines.is_none());
921        assert!(options.min_occurrences.is_none());
922    }
923
924    #[test]
925    fn programmatic_error_builder_keeps_optional_fields() {
926        let error = ProgrammaticError::new("boom", 2)
927            .with_code("FALLOW_TEST")
928            .with_help("Try again")
929            .with_context("analysis.root");
930
931        assert_eq!(error.message, "boom");
932        assert_eq!(error.exit_code, 2);
933        assert_eq!(error.code.as_deref(), Some("FALLOW_TEST"));
934        assert_eq!(error.help.as_deref(), Some("Try again"));
935        assert_eq!(error.context.as_deref(), Some("analysis.root"));
936    }
937
938    #[test]
939    fn dead_code_filters_accept_shared_registry_selectors() {
940        for (selector, _) in fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS.iter() {
941            let mut filters = DeadCodeFilters::default();
942            assert!(
943                filters.enable_registry_selector(selector),
944                "{selector} should be accepted"
945            );
946        }
947
948        let mut filters = DeadCodeFilters::default();
949        assert!(filters.enable_registry_selector("unused-files"));
950        assert!(filters.unused_files);
951        assert!(filters.enable_registry_selector("boundary-violations"));
952        assert!(filters.boundary_violations);
953        assert!(!filters.enable_registry_selector("not-a-real-selector"));
954    }
955
956    #[test]
957    fn default_complexity_options_match_programmatic_health_defaults() {
958        let derived = derive_complexity_options(&ComplexityOptions::default());
959
960        assert!(!derived.any_section);
961        assert!(derived.complexity);
962        assert!(derived.file_scores);
963        assert!(!derived.coverage_gaps);
964        assert!(derived.hotspots);
965        assert!(!derived.ownership);
966        assert!(derived.targets);
967        assert!(derived.force_full);
968        assert!(!derived.score_only_output);
969        assert!(derived.score);
970    }
971
972    #[test]
973    fn score_only_complexity_options_request_score_only_output() {
974        let derived = derive_complexity_options(&ComplexityOptions {
975            score: true,
976            ..ComplexityOptions::default()
977        });
978
979        assert!(derived.any_section);
980        assert!(!derived.complexity);
981        assert!(derived.file_scores);
982        assert!(!derived.hotspots);
983        assert!(!derived.targets);
984        assert!(derived.force_full);
985        assert!(derived.score_only_output);
986        assert!(derived.score);
987    }
988
989    #[test]
990    fn ownership_implies_hotspots_when_requested() {
991        let derived = derive_complexity_options(&ComplexityOptions {
992            ownership: true,
993            ..ComplexityOptions::default()
994        });
995
996        assert!(derived.any_section);
997        assert!(derived.hotspots);
998        assert!(derived.ownership);
999        assert!(!derived.targets);
1000    }
1001
1002    #[test]
1003    fn complexity_run_options_normalize_public_api_options() {
1004        let options = ComplexityOptions {
1005            max_cyclomatic: Some(42),
1006            max_cognitive: Some(21),
1007            max_crap: Some(18.5),
1008            top: Some(7),
1009            sort: ComplexitySort::Severity,
1010            complexity_breakdown: true,
1011            ownership_emails: Some(OwnershipEmailMode::Hash),
1012            effort: Some(TargetEffort::High),
1013            coverage: Some(PathBuf::from("coverage/coverage-final.json")),
1014            coverage_root: Some(PathBuf::from("/ci/workspace")),
1015            since: Some("30d".to_string()),
1016            min_commits: Some(4),
1017            ..ComplexityOptions::default()
1018        };
1019
1020        let run = derive_complexity_run_options(&options);
1021
1022        assert_eq!(run.thresholds.max_cyclomatic, Some(42));
1023        assert_eq!(run.thresholds.max_cognitive, Some(21));
1024        assert_eq!(run.thresholds.max_crap, Some(18.5));
1025        assert_eq!(run.top, Some(7));
1026        assert!(matches!(run.sort, ComplexitySort::Severity));
1027        assert!(run.complexity_breakdown);
1028        assert!(run.sections.hotspots);
1029        assert!(run.sections.ownership);
1030        assert!(run.sections.targets);
1031        assert!(matches!(
1032            run.ownership_emails,
1033            Some(OwnershipEmailMode::Hash)
1034        ));
1035        assert!(matches!(run.effort, Some(TargetEffort::High)));
1036        assert_eq!(run.since, Some("30d"));
1037        assert_eq!(run.min_commits, Some(4));
1038        assert_eq!(run.coverage_inputs.coverage, options.coverage.as_deref());
1039        assert_eq!(
1040            run.coverage_inputs.coverage_root,
1041            options.coverage_root.as_deref()
1042        );
1043    }
1044
1045    #[test]
1046    fn complexity_options_validation_accepts_existing_coverage_path_and_absolute_root() {
1047        let dir = tempfile::tempdir().expect("tempdir");
1048        let coverage = dir.path().join("coverage-final.json");
1049        std::fs::write(&coverage, "{}").expect("coverage fixture");
1050
1051        let result = validate_complexity_options(&ComplexityOptions {
1052            coverage: Some(coverage),
1053            coverage_root: Some(PathBuf::from("/ci/workspace")),
1054            ..ComplexityOptions::default()
1055        });
1056
1057        assert!(result.is_ok());
1058    }
1059
1060    #[test]
1061    fn complexity_options_validation_keeps_missing_coverage_error_contract() {
1062        let err = validate_complexity_options(&ComplexityOptions {
1063            coverage: Some(PathBuf::from("/missing/coverage-final.json")),
1064            ..ComplexityOptions::default()
1065        })
1066        .expect_err("missing coverage path should fail");
1067
1068        assert_eq!(err.exit_code, 2);
1069        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1070        assert_eq!(err.context.as_deref(), Some("health.coverage"));
1071    }
1072
1073    #[test]
1074    fn complexity_options_validation_keeps_relative_coverage_root_error_contract() {
1075        let err = validate_complexity_options(&ComplexityOptions {
1076            coverage_root: Some(PathBuf::from("coverage")),
1077            ..ComplexityOptions::default()
1078        })
1079        .expect_err("relative coverage root should fail");
1080
1081        assert_eq!(err.exit_code, 2);
1082        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_ROOT"));
1083        assert_eq!(err.context.as_deref(), Some("health.coverage_root"));
1084    }
1085
1086    #[test]
1087    fn default_health_sections_match_full_health_output() {
1088        let derived = derive_health_sections(&HealthSectionOptions {
1089            output: fallow_types::output_format::OutputFormat::Human,
1090            complexity: false,
1091            file_scores: false,
1092            coverage_gaps: false,
1093            hotspots: false,
1094            targets: false,
1095            css: false,
1096            score: false,
1097            score_gate: false,
1098            snapshot_requested: false,
1099            trend: false,
1100        });
1101
1102        assert!(!derived.any_section);
1103        assert!(derived.complexity);
1104        assert!(derived.file_scores);
1105        assert!(!derived.coverage_gaps);
1106        assert!(derived.hotspots);
1107        assert!(derived.targets);
1108        assert!(derived.score);
1109        assert!(derived.force_full);
1110        assert!(!derived.score_only_output);
1111    }
1112
1113    #[test]
1114    fn health_score_gate_requests_score_only_output() {
1115        let derived = derive_health_sections(&HealthSectionOptions {
1116            output: fallow_types::output_format::OutputFormat::Human,
1117            complexity: false,
1118            file_scores: false,
1119            coverage_gaps: false,
1120            hotspots: false,
1121            targets: false,
1122            css: false,
1123            score: false,
1124            score_gate: true,
1125            snapshot_requested: false,
1126            trend: false,
1127        });
1128
1129        assert!(derived.any_section);
1130        assert!(!derived.complexity);
1131        assert!(derived.file_scores);
1132        assert!(!derived.hotspots);
1133        assert!(!derived.targets);
1134        assert!(derived.score);
1135        assert!(derived.force_full);
1136        assert!(derived.score_only_output);
1137    }
1138
1139    #[test]
1140    fn health_snapshot_keeps_full_hidden_inputs_without_section_request() {
1141        let derived = derive_health_sections(&HealthSectionOptions {
1142            output: fallow_types::output_format::OutputFormat::Human,
1143            complexity: false,
1144            file_scores: false,
1145            coverage_gaps: false,
1146            hotspots: false,
1147            targets: false,
1148            css: true,
1149            score: false,
1150            score_gate: false,
1151            snapshot_requested: true,
1152            trend: false,
1153        });
1154
1155        assert!(!derived.any_section);
1156        assert!(derived.css);
1157        assert!(derived.file_scores);
1158        assert!(derived.hotspots);
1159        assert!(derived.score);
1160        assert!(derived.force_full);
1161    }
1162}