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