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, TraceClassMemberOutput, TraceCloneOutput,
158    TraceCloneProgrammaticOutput, TraceDependencyOutput, TraceDependencyProgrammaticOutput,
159    TraceExportOutput, TraceExportProgrammaticOutput, TraceExportTargetOutput, TraceFileOutput,
160    TraceFileProgrammaticOutput, run_audit, run_boundary_violations, run_circular_dependencies,
161    run_combined, run_complexity_with_runner, run_dead_code, run_decision_surface, run_duplication,
162    run_feature_flags, run_health, run_health_with_runner, run_trace_clone, run_trace_dependency,
163    run_trace_export, 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    "allow-remote-extends",
192];
193
194/// Structured error surface for the programmatic API.
195#[derive(Debug, Clone, Serialize)]
196pub struct ProgrammaticError {
197    pub message: String,
198    pub exit_code: u8,
199    pub code: Option<String>,
200    pub help: Option<String>,
201    pub context: Option<String>,
202}
203
204impl ProgrammaticError {
205    #[must_use]
206    pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
207        Self {
208            message: message.into(),
209            exit_code,
210            code: None,
211            help: None,
212            context: None,
213        }
214    }
215
216    #[must_use]
217    pub fn with_help(mut self, help: impl Into<String>) -> Self {
218        self.help = Some(help.into());
219        self
220    }
221
222    #[must_use]
223    pub fn with_code(mut self, code: impl Into<String>) -> Self {
224        self.code = Some(code.into());
225        self
226    }
227
228    #[must_use]
229    pub fn with_context(mut self, context: impl Into<String>) -> Self {
230        self.context = Some(context.into());
231        self
232    }
233}
234
235impl std::fmt::Display for ProgrammaticError {
236    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237        write!(f, "{}", self.message)
238    }
239}
240
241impl std::error::Error for ProgrammaticError {}
242
243/// Shared options for all one-shot analyses.
244#[derive(Debug, Clone, Default)]
245pub struct AnalysisOptions {
246    pub root: Option<PathBuf>,
247    pub config_path: Option<PathBuf>,
248    /// Permit `https://` config inheritance for this analysis call.
249    pub allow_remote_extends: bool,
250    pub no_cache: bool,
251    pub threads: Option<usize>,
252    pub diff_file: Option<PathBuf>,
253    /// Legacy convenience override. `true` forces production mode; `false`
254    /// defers to config unless `production_override` is set.
255    pub production: bool,
256    /// Explicit production override from an embedder option. `None` means
257    /// use the project config for the current analysis.
258    pub production_override: Option<bool>,
259    pub changed_since: Option<String>,
260    pub workspace: Option<Vec<String>>,
261    pub changed_workspaces: Option<String>,
262    pub explain: bool,
263}
264
265/// Issue-type filters for the dead-code analysis.
266#[derive(Debug, Clone, Default)]
267pub struct DeadCodeFilters {
268    pub unused_files: bool,
269    pub unused_exports: bool,
270    pub unused_deps: bool,
271    pub unused_types: bool,
272    pub private_type_leaks: bool,
273    pub unused_enum_members: bool,
274    pub unused_class_members: bool,
275    pub unused_store_members: bool,
276    pub unprovided_injects: bool,
277    pub unrendered_components: bool,
278    pub unused_component_props: bool,
279    pub unused_component_emits: bool,
280    pub unused_component_inputs: bool,
281    pub unused_component_outputs: bool,
282    pub unused_svelte_events: bool,
283    pub unused_server_actions: bool,
284    pub unused_load_data_keys: bool,
285    pub unresolved_imports: bool,
286    pub unlisted_deps: bool,
287    pub duplicate_exports: bool,
288    pub circular_deps: bool,
289    pub re_export_cycles: bool,
290    pub boundary_violations: bool,
291    pub policy_violations: bool,
292    pub stale_suppressions: bool,
293    pub unused_catalog_entries: bool,
294    pub empty_catalog_groups: bool,
295    pub unresolved_catalog_references: bool,
296    pub unused_dependency_overrides: bool,
297    pub misconfigured_dependency_overrides: bool,
298}
299
300impl DeadCodeFilters {
301    /// Enable the issue filter addressed by a shared registry selector.
302    ///
303    /// Returns `false` when the selector is not registered for dead-code
304    /// filtering. Callers that expose user input should surface their own
305    /// validation error with the accepted registry values.
306    pub fn enable_registry_selector(&mut self, selector: &str) -> bool {
307        let Some(flag) = fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS
308            .iter()
309            .find_map(|&(name, flag)| (name == selector).then_some(flag))
310        else {
311            return false;
312        };
313        self.enable_cli_filter_flag(flag);
314        true
315    }
316
317    fn enable_cli_filter_flag(&mut self, flag: &str) {
318        match flag {
319            "--unused-files" => self.unused_files = true,
320            "--unused-exports" => self.unused_exports = true,
321            "--unused-types" => self.unused_types = true,
322            "--private-type-leaks" => self.private_type_leaks = true,
323            "--unused-deps" => self.unused_deps = true,
324            "--unused-enum-members" => self.unused_enum_members = true,
325            "--unused-class-members" => self.unused_class_members = true,
326            "--unused-store-members" => self.unused_store_members = true,
327            "--unprovided-injects" => self.unprovided_injects = true,
328            "--unrendered-components" => self.unrendered_components = true,
329            "--unused-component-props" => self.unused_component_props = true,
330            "--unused-component-emits" => self.unused_component_emits = true,
331            "--unused-component-inputs" => self.unused_component_inputs = true,
332            "--unused-component-outputs" => self.unused_component_outputs = true,
333            "--unused-svelte-events" => self.unused_svelte_events = true,
334            "--unused-server-actions" => self.unused_server_actions = true,
335            "--unused-load-data-keys" => self.unused_load_data_keys = true,
336            "--unresolved-imports" => self.unresolved_imports = true,
337            "--unlisted-deps" => self.unlisted_deps = true,
338            "--duplicate-exports" => self.duplicate_exports = true,
339            "--circular-deps" => self.circular_deps = true,
340            "--re-export-cycles" => self.re_export_cycles = true,
341            "--boundary-violations" => self.boundary_violations = true,
342            "--policy-violations" => self.policy_violations = true,
343            "--stale-suppressions" => self.stale_suppressions = true,
344            "--unused-catalog-entries" => self.unused_catalog_entries = true,
345            "--empty-catalog-groups" => self.empty_catalog_groups = true,
346            "--unresolved-catalog-references" => self.unresolved_catalog_references = true,
347            "--unused-dependency-overrides" => self.unused_dependency_overrides = true,
348            "--misconfigured-dependency-overrides" => {
349                self.misconfigured_dependency_overrides = true;
350            }
351            _ => unreachable!("registry emitted unsupported dead-code filter flag: {flag}"),
352        }
353    }
354}
355
356/// Options for dead-code-oriented analyses.
357#[derive(Debug, Clone, Default)]
358pub struct DeadCodeOptions {
359    pub analysis: AnalysisOptions,
360    pub filters: DeadCodeFilters,
361    pub files: Vec<PathBuf>,
362    pub include_entry_exports: bool,
363}
364
365/// Options for changed-code audit analysis.
366#[derive(Debug, Clone, Default)]
367pub struct AuditOptions {
368    pub analysis: AnalysisOptions,
369    pub base: Option<String>,
370    pub production: bool,
371    pub production_dead_code: Option<bool>,
372    pub production_health: Option<bool>,
373    pub production_dupes: Option<bool>,
374    pub css: Option<bool>,
375    pub css_deep: Option<bool>,
376    pub gate: fallow_config::AuditGate,
377    pub max_crap: Option<f64>,
378    pub coverage: Option<PathBuf>,
379    pub coverage_root: Option<PathBuf>,
380    pub include_entry_exports: bool,
381    pub runtime_coverage: Option<PathBuf>,
382    pub min_invocations_hot: u64,
383}
384
385/// Options for bare combined analysis through the programmatic API.
386#[derive(Debug, Clone)]
387pub struct CombinedOptions {
388    pub analysis: AnalysisOptions,
389    pub dead_code: bool,
390    pub duplication: bool,
391    pub health: bool,
392    pub include_entry_exports: bool,
393    pub duplication_options: DuplicationOptions,
394    pub health_options: ComplexityOptions,
395}
396
397impl Default for CombinedOptions {
398    fn default() -> Self {
399        Self {
400            analysis: AnalysisOptions::default(),
401            dead_code: true,
402            duplication: true,
403            health: true,
404            include_entry_exports: false,
405            duplication_options: DuplicationOptions::default(),
406            health_options: ComplexityOptions::default(),
407        }
408    }
409}
410
411/// Options for changed-code decision-surface analysis.
412#[derive(Debug, Clone, Default)]
413pub struct DecisionSurfaceOptions {
414    pub analysis: AnalysisOptions,
415    pub base: Option<String>,
416    pub max_decisions: Option<usize>,
417}
418
419/// Options for feature-flag analysis.
420#[derive(Debug, Clone, Default)]
421pub struct FeatureFlagsOptions {
422    pub analysis: AnalysisOptions,
423    pub top: Option<usize>,
424}
425
426/// Programmatic duplication mode selection.
427#[derive(Debug, Clone, Copy, Default)]
428pub enum DuplicationMode {
429    Strict,
430    #[default]
431    Mild,
432    Weak,
433    Semantic,
434}
435
436/// Options for duplication analysis.
437#[derive(Debug, Clone, Default)]
438pub struct DuplicationOptions {
439    pub analysis: AnalysisOptions,
440    pub mode: Option<DuplicationMode>,
441    pub min_tokens: Option<usize>,
442    pub min_lines: Option<usize>,
443    /// Minimum number of occurrences before a clone group is reported.
444    /// Values below 2 are silently treated as 2 by the engine-facing adapter.
445    pub min_occurrences: Option<usize>,
446    pub threshold: Option<f64>,
447    pub skip_local: Option<bool>,
448    pub cross_language: Option<bool>,
449    /// Exclude module wiring from clone detection. `None` defers to the project
450    /// config.
451    pub ignore_imports: Option<bool>,
452    pub top: Option<usize>,
453}
454
455/// Options for export trace analysis.
456#[derive(Debug, Clone, Default)]
457pub struct TraceExportOptions {
458    pub analysis: AnalysisOptions,
459    pub file: String,
460    pub export_name: String,
461}
462
463/// Options for file trace analysis.
464#[derive(Debug, Clone, Default)]
465pub struct TraceFileOptions {
466    pub analysis: AnalysisOptions,
467    pub file: String,
468}
469
470/// Options for dependency trace analysis.
471#[derive(Debug, Clone, Default)]
472pub struct TraceDependencyOptions {
473    pub analysis: AnalysisOptions,
474    pub package_name: String,
475}
476
477/// Duplicate-code trace target.
478#[derive(Debug, Clone, PartialEq, Eq)]
479pub enum TraceCloneTarget {
480    Location { file: String, line: usize },
481    Fingerprint(String),
482}
483
484/// Options for duplicate-code trace analysis.
485#[derive(Debug, Clone)]
486pub struct TraceCloneOptions {
487    pub duplication: DuplicationOptions,
488    pub target: TraceCloneTarget,
489}
490
491/// Sort criteria for complexity findings.
492#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
493pub enum ComplexitySort {
494    #[default]
495    Cyclomatic,
496    Cognitive,
497    Lines,
498    Severity,
499}
500
501/// Privacy mode for ownership-aware hotspot output.
502#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
503pub enum OwnershipEmailMode {
504    Raw,
505    #[default]
506    Handle,
507    Anonymized,
508    /// Legacy spelling retained for embedders that already pass `hash`.
509    Hash,
510}
511
512/// Effort filter for refactoring targets.
513#[derive(Debug, Clone, Copy, PartialEq, Eq)]
514pub enum TargetEffort {
515    Low,
516    Medium,
517    High,
518}
519
520/// Options for complexity / health analysis.
521#[derive(Debug, Clone, Default)]
522pub struct ComplexityOptions {
523    pub analysis: AnalysisOptions,
524    pub max_cyclomatic: Option<u16>,
525    pub max_cognitive: Option<u16>,
526    pub max_crap: Option<f64>,
527    pub top: Option<usize>,
528    pub sort: ComplexitySort,
529    pub complexity_breakdown: bool,
530    pub complexity: bool,
531    pub file_scores: bool,
532    pub coverage_gaps: bool,
533    pub hotspots: bool,
534    pub ownership: bool,
535    pub ownership_emails: Option<OwnershipEmailMode>,
536    pub targets: bool,
537    pub css: bool,
538    pub css_deep: bool,
539    pub effort: Option<TargetEffort>,
540    pub score: bool,
541    pub since: Option<String>,
542    pub min_commits: Option<u32>,
543    pub coverage: Option<PathBuf>,
544    pub coverage_root: Option<PathBuf>,
545}
546
547/// Health threshold overrides accepted by the programmatic API.
548#[derive(Debug, Clone, Copy, Default, PartialEq)]
549pub struct ComplexityThresholdOverrides {
550    pub max_cyclomatic: Option<u16>,
551    pub max_cognitive: Option<u16>,
552    pub max_crap: Option<f64>,
553}
554
555/// Coverage inputs accepted by the programmatic API.
556#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
557pub struct ComplexityCoverageInputs<'a> {
558    pub coverage: Option<&'a Path>,
559    pub coverage_root: Option<&'a Path>,
560}
561
562/// Input for deriving effective health sections from API-owned flags.
563#[derive(Debug, Clone)]
564pub struct HealthSectionOptions {
565    pub output: fallow_types::output_format::OutputFormat,
566    pub complexity: bool,
567    pub file_scores: bool,
568    pub coverage_gaps: bool,
569    pub hotspots: bool,
570    pub targets: bool,
571    pub css: bool,
572    pub score: bool,
573    pub score_gate: bool,
574    pub snapshot_requested: bool,
575    pub trend: bool,
576}
577
578/// Derived section selection for health runs.
579#[derive(Debug, Clone, Copy, PartialEq, Eq)]
580pub struct DerivedHealthSections {
581    pub any_section: bool,
582    pub complexity: bool,
583    pub file_scores: bool,
584    pub coverage_gaps: bool,
585    pub hotspots: bool,
586    pub targets: bool,
587    pub css: bool,
588    pub score: bool,
589    pub force_full: bool,
590    pub score_only_output: bool,
591}
592
593/// Input for deriving effective programmatic complexity sections.
594#[derive(Debug, Clone)]
595pub struct ComplexitySectionOptions {
596    pub complexity: bool,
597    pub file_scores: bool,
598    pub coverage_gaps: bool,
599    pub hotspots: bool,
600    pub ownership: bool,
601    pub targets: bool,
602    pub css: bool,
603    pub score: bool,
604}
605
606/// Derived section selection for programmatic health / complexity runs.
607#[derive(Debug, Clone, Copy, PartialEq, Eq)]
608pub struct DerivedComplexityOptions {
609    pub any_section: bool,
610    pub complexity: bool,
611    pub file_scores: bool,
612    pub coverage_gaps: bool,
613    pub hotspots: bool,
614    pub ownership: bool,
615    pub targets: bool,
616    pub force_full: bool,
617    pub score_only_output: bool,
618    pub score: bool,
619}
620
621/// Normalized programmatic complexity / health inputs owned by `fallow-api`.
622#[derive(Debug, Clone, PartialEq)]
623pub struct ComplexityRunOptions<'a> {
624    pub thresholds: ComplexityThresholdOverrides,
625    pub top: Option<usize>,
626    pub sort: ComplexitySort,
627    pub complexity_breakdown: bool,
628    pub sections: DerivedComplexityOptions,
629    pub ownership_emails: Option<OwnershipEmailMode>,
630    pub effort: Option<TargetEffort>,
631    pub css: bool,
632    pub css_deep: bool,
633    pub since: Option<&'a str>,
634    pub min_commits: Option<u32>,
635    pub coverage_inputs: ComplexityCoverageInputs<'a>,
636}
637
638/// Derive effective health section flags for API consumers.
639#[must_use]
640pub fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
641    let score = options.score
642        || options.score_gate
643        || options.trend
644        || matches!(
645            options.output,
646            fallow_types::output_format::OutputFormat::Badge
647        );
648    let any_section = options.complexity
649        || options.file_scores
650        || options.coverage_gaps
651        || options.hotspots
652        || options.targets
653        || score;
654    let effective_score = if any_section { score } else { true } || options.snapshot_requested;
655    let force_full = options.snapshot_requested || effective_score;
656
657    DerivedHealthSections {
658        any_section,
659        complexity: if any_section {
660            options.complexity
661        } else {
662            true
663        },
664        file_scores: if any_section {
665            options.file_scores
666        } else {
667            true
668        } || force_full,
669        coverage_gaps: if any_section {
670            options.coverage_gaps
671        } else {
672            false
673        },
674        hotspots: if any_section { options.hotspots } else { true }
675            || options.snapshot_requested
676            || options.trend,
677        targets: if any_section { options.targets } else { true },
678        css: options.css,
679        score: effective_score,
680        force_full,
681        score_only_output: is_health_score_only_output(options, score),
682    }
683}
684
685/// Derive effective programmatic health / complexity section flags.
686#[must_use]
687pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
688    let requested_hotspots = options.hotspots || options.ownership;
689    let sections = derive_health_sections(&HealthSectionOptions {
690        output: fallow_types::output_format::OutputFormat::Human,
691        complexity: options.complexity,
692        file_scores: options.file_scores,
693        coverage_gaps: options.coverage_gaps,
694        hotspots: requested_hotspots,
695        targets: options.targets,
696        css: options.css,
697        score: options.score,
698        score_gate: false,
699        snapshot_requested: false,
700        trend: false,
701    });
702
703    DerivedComplexityOptions {
704        any_section: sections.any_section,
705        complexity: sections.complexity,
706        file_scores: sections.file_scores,
707        coverage_gaps: sections.coverage_gaps,
708        hotspots: sections.hotspots,
709        ownership: options.ownership && sections.hotspots,
710        targets: sections.targets,
711        force_full: sections.force_full,
712        score_only_output: sections.score_only_output,
713        score: sections.score,
714    }
715}
716
717/// Derive effective programmatic health / complexity section flags.
718#[must_use]
719pub fn derive_complexity_options(options: &ComplexityOptions) -> DerivedComplexityOptions {
720    derive_complexity_sections(&complexity_section_options(options))
721}
722
723/// Normalize public API complexity options into engine-owned run contracts.
724#[must_use]
725pub fn derive_complexity_run_options(options: &ComplexityOptions) -> ComplexityRunOptions<'_> {
726    ComplexityRunOptions {
727        thresholds: ComplexityThresholdOverrides {
728            max_cyclomatic: options.max_cyclomatic,
729            max_cognitive: options.max_cognitive,
730            max_crap: options.max_crap,
731        },
732        top: options.top,
733        sort: options.sort,
734        complexity_breakdown: options.complexity_breakdown,
735        sections: derive_complexity_options(options),
736        ownership_emails: options.ownership_emails,
737        effort: options.effort,
738        css: options.css,
739        css_deep: options.css_deep,
740        since: options.since.as_deref(),
741        min_commits: options.min_commits,
742        coverage_inputs: ComplexityCoverageInputs {
743            coverage: options.coverage.as_deref(),
744            coverage_root: options.coverage_root.as_deref(),
745        },
746    }
747}
748
749/// Validate programmatic complexity / health inputs before invoking a concrete
750/// runner.
751///
752/// These option contracts belong to the API boundary because NAPI and future
753/// Rust embedders construct the same [`ComplexityOptions`] type.
754///
755/// # Errors
756///
757/// Returns a structured programmatic error when a coverage path does not exist
758/// or when `coverage_root` is not an absolute prefix from the coverage data.
759pub fn validate_complexity_options(options: &ComplexityOptions) -> Result<(), ProgrammaticError> {
760    if let Some(path) = &options.coverage
761        && !path.exists()
762    {
763        return Err(ProgrammaticError::new(
764            format!("coverage path does not exist: {}", path.display()),
765            2,
766        )
767        .with_code("FALLOW_INVALID_COVERAGE_PATH")
768        .with_context("health.coverage"));
769    }
770    if let Err(message) =
771        fallow_engine::health::validate_coverage_root_absolute(options.coverage_root.as_deref())
772    {
773        return Err(ProgrammaticError::new(message, 2)
774            .with_code("FALLOW_INVALID_COVERAGE_ROOT")
775            .with_context("health.coverage_root"));
776    }
777
778    Ok(())
779}
780
781fn complexity_section_options(options: &ComplexityOptions) -> ComplexitySectionOptions {
782    let ownership = options.ownership || options.ownership_emails.is_some();
783    let requested_targets = options.targets || options.effort.is_some();
784    ComplexitySectionOptions {
785        complexity: options.complexity,
786        file_scores: options.file_scores,
787        coverage_gaps: options.coverage_gaps,
788        hotspots: options.hotspots,
789        ownership,
790        targets: requested_targets,
791        css: options.css,
792        score: options.score,
793    }
794}
795
796fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
797    score
798        && !options.complexity
799        && !options.file_scores
800        && !options.coverage_gaps
801        && !options.hotspots
802        && !options.targets
803        && !options.trend
804}
805
806const fn thresholds_to_engine(
807    thresholds: ComplexityThresholdOverrides,
808) -> fallow_engine::health::HealthThresholdOverrides {
809    fallow_engine::health::HealthThresholdOverrides {
810        max_cyclomatic: thresholds.max_cyclomatic,
811        max_cognitive: thresholds.max_cognitive,
812        max_crap: thresholds.max_crap,
813    }
814}
815
816const fn complexity_sort_to_engine(sort: ComplexitySort) -> fallow_engine::health::HealthSort {
817    match sort {
818        ComplexitySort::Severity => fallow_engine::health::HealthSort::Severity,
819        ComplexitySort::Cyclomatic => fallow_engine::health::HealthSort::Cyclomatic,
820        ComplexitySort::Cognitive => fallow_engine::health::HealthSort::Cognitive,
821        ComplexitySort::Lines => fallow_engine::health::HealthSort::Lines,
822    }
823}
824
825const fn coverage_inputs_to_engine(
826    coverage_inputs: ComplexityCoverageInputs<'_>,
827) -> fallow_engine::health::HealthCoverageInputs<'_> {
828    fallow_engine::health::HealthCoverageInputs {
829        coverage: coverage_inputs.coverage,
830        coverage_root: coverage_inputs.coverage_root,
831    }
832}
833
834const fn ownership_email_mode_to_config(mode: OwnershipEmailMode) -> EmailMode {
835    match mode {
836        OwnershipEmailMode::Raw => EmailMode::Raw,
837        OwnershipEmailMode::Handle => EmailMode::Handle,
838        OwnershipEmailMode::Anonymized => EmailMode::Anonymized,
839        OwnershipEmailMode::Hash => EmailMode::Hash,
840    }
841}
842
843const fn target_effort_to_output(effort: TargetEffort) -> EffortEstimate {
844    match effort {
845        TargetEffort::Low => EffortEstimate::Low,
846        TargetEffort::Medium => EffortEstimate::Medium,
847        TargetEffort::High => EffortEstimate::High,
848    }
849}
850
851#[cfg(test)]
852mod tests {
853    use super::*;
854
855    #[test]
856    fn duplication_defaults_match_cli_contract() {
857        let options = DuplicationOptions::default();
858        assert!(options.mode.is_none());
859        assert!(options.min_tokens.is_none());
860        assert!(options.min_lines.is_none());
861        assert!(options.min_occurrences.is_none());
862    }
863
864    #[test]
865    fn programmatic_error_builder_keeps_optional_fields() {
866        let error = ProgrammaticError::new("boom", 2)
867            .with_code("FALLOW_TEST")
868            .with_help("Try again")
869            .with_context("analysis.root");
870
871        assert_eq!(error.message, "boom");
872        assert_eq!(error.exit_code, 2);
873        assert_eq!(error.code.as_deref(), Some("FALLOW_TEST"));
874        assert_eq!(error.help.as_deref(), Some("Try again"));
875        assert_eq!(error.context.as_deref(), Some("analysis.root"));
876    }
877
878    #[test]
879    fn dead_code_filters_accept_shared_registry_selectors() {
880        for (selector, _) in fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS.iter() {
881            let mut filters = DeadCodeFilters::default();
882            assert!(
883                filters.enable_registry_selector(selector),
884                "{selector} should be accepted"
885            );
886        }
887
888        let mut filters = DeadCodeFilters::default();
889        assert!(filters.enable_registry_selector("unused-files"));
890        assert!(filters.unused_files);
891        assert!(filters.enable_registry_selector("boundary-violations"));
892        assert!(filters.boundary_violations);
893        assert!(!filters.enable_registry_selector("not-a-real-selector"));
894    }
895
896    #[test]
897    fn default_complexity_options_match_programmatic_health_defaults() {
898        let derived = derive_complexity_options(&ComplexityOptions::default());
899
900        assert!(!derived.any_section);
901        assert!(derived.complexity);
902        assert!(derived.file_scores);
903        assert!(!derived.coverage_gaps);
904        assert!(derived.hotspots);
905        assert!(!derived.ownership);
906        assert!(derived.targets);
907        assert!(derived.force_full);
908        assert!(!derived.score_only_output);
909        assert!(derived.score);
910    }
911
912    #[test]
913    fn score_only_complexity_options_request_score_only_output() {
914        let derived = derive_complexity_options(&ComplexityOptions {
915            score: true,
916            ..ComplexityOptions::default()
917        });
918
919        assert!(derived.any_section);
920        assert!(!derived.complexity);
921        assert!(derived.file_scores);
922        assert!(!derived.hotspots);
923        assert!(!derived.targets);
924        assert!(derived.force_full);
925        assert!(derived.score_only_output);
926        assert!(derived.score);
927    }
928
929    #[test]
930    fn ownership_implies_hotspots_when_requested() {
931        let derived = derive_complexity_options(&ComplexityOptions {
932            ownership: true,
933            ..ComplexityOptions::default()
934        });
935
936        assert!(derived.any_section);
937        assert!(derived.hotspots);
938        assert!(derived.ownership);
939        assert!(!derived.targets);
940    }
941
942    #[test]
943    fn complexity_run_options_normalize_public_api_options() {
944        let options = ComplexityOptions {
945            max_cyclomatic: Some(42),
946            max_cognitive: Some(21),
947            max_crap: Some(18.5),
948            top: Some(7),
949            sort: ComplexitySort::Severity,
950            complexity_breakdown: true,
951            ownership_emails: Some(OwnershipEmailMode::Hash),
952            effort: Some(TargetEffort::High),
953            coverage: Some(PathBuf::from("coverage/coverage-final.json")),
954            coverage_root: Some(PathBuf::from("/ci/workspace")),
955            since: Some("30d".to_string()),
956            min_commits: Some(4),
957            ..ComplexityOptions::default()
958        };
959
960        let run = derive_complexity_run_options(&options);
961
962        assert_eq!(run.thresholds.max_cyclomatic, Some(42));
963        assert_eq!(run.thresholds.max_cognitive, Some(21));
964        assert_eq!(run.thresholds.max_crap, Some(18.5));
965        assert_eq!(run.top, Some(7));
966        assert!(matches!(run.sort, ComplexitySort::Severity));
967        assert!(run.complexity_breakdown);
968        assert!(run.sections.hotspots);
969        assert!(run.sections.ownership);
970        assert!(run.sections.targets);
971        assert!(matches!(
972            run.ownership_emails,
973            Some(OwnershipEmailMode::Hash)
974        ));
975        assert!(matches!(run.effort, Some(TargetEffort::High)));
976        assert_eq!(run.since, Some("30d"));
977        assert_eq!(run.min_commits, Some(4));
978        assert_eq!(run.coverage_inputs.coverage, options.coverage.as_deref());
979        assert_eq!(
980            run.coverage_inputs.coverage_root,
981            options.coverage_root.as_deref()
982        );
983    }
984
985    #[test]
986    fn complexity_options_validation_accepts_existing_coverage_path_and_absolute_root() {
987        let dir = tempfile::tempdir().expect("tempdir");
988        let coverage = dir.path().join("coverage-final.json");
989        std::fs::write(&coverage, "{}").expect("coverage fixture");
990
991        let result = validate_complexity_options(&ComplexityOptions {
992            coverage: Some(coverage),
993            coverage_root: Some(PathBuf::from("/ci/workspace")),
994            ..ComplexityOptions::default()
995        });
996
997        assert!(result.is_ok());
998    }
999
1000    #[test]
1001    fn complexity_options_validation_keeps_missing_coverage_error_contract() {
1002        let err = validate_complexity_options(&ComplexityOptions {
1003            coverage: Some(PathBuf::from("/missing/coverage-final.json")),
1004            ..ComplexityOptions::default()
1005        })
1006        .expect_err("missing coverage path should fail");
1007
1008        assert_eq!(err.exit_code, 2);
1009        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1010        assert_eq!(err.context.as_deref(), Some("health.coverage"));
1011    }
1012
1013    #[test]
1014    fn complexity_options_validation_keeps_relative_coverage_root_error_contract() {
1015        let err = validate_complexity_options(&ComplexityOptions {
1016            coverage_root: Some(PathBuf::from("coverage")),
1017            ..ComplexityOptions::default()
1018        })
1019        .expect_err("relative coverage root should fail");
1020
1021        assert_eq!(err.exit_code, 2);
1022        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_ROOT"));
1023        assert_eq!(err.context.as_deref(), Some("health.coverage_root"));
1024    }
1025
1026    #[test]
1027    fn default_health_sections_match_full_health_output() {
1028        let derived = derive_health_sections(&HealthSectionOptions {
1029            output: fallow_types::output_format::OutputFormat::Human,
1030            complexity: false,
1031            file_scores: false,
1032            coverage_gaps: false,
1033            hotspots: false,
1034            targets: false,
1035            css: false,
1036            score: false,
1037            score_gate: false,
1038            snapshot_requested: false,
1039            trend: false,
1040        });
1041
1042        assert!(!derived.any_section);
1043        assert!(derived.complexity);
1044        assert!(derived.file_scores);
1045        assert!(!derived.coverage_gaps);
1046        assert!(derived.hotspots);
1047        assert!(derived.targets);
1048        assert!(derived.score);
1049        assert!(derived.force_full);
1050        assert!(!derived.score_only_output);
1051    }
1052
1053    #[test]
1054    fn health_score_gate_requests_score_only_output() {
1055        let derived = derive_health_sections(&HealthSectionOptions {
1056            output: fallow_types::output_format::OutputFormat::Human,
1057            complexity: false,
1058            file_scores: false,
1059            coverage_gaps: false,
1060            hotspots: false,
1061            targets: false,
1062            css: false,
1063            score: false,
1064            score_gate: true,
1065            snapshot_requested: false,
1066            trend: false,
1067        });
1068
1069        assert!(derived.any_section);
1070        assert!(!derived.complexity);
1071        assert!(derived.file_scores);
1072        assert!(!derived.hotspots);
1073        assert!(!derived.targets);
1074        assert!(derived.score);
1075        assert!(derived.force_full);
1076        assert!(derived.score_only_output);
1077    }
1078
1079    #[test]
1080    fn health_snapshot_keeps_full_hidden_inputs_without_section_request() {
1081        let derived = derive_health_sections(&HealthSectionOptions {
1082            output: fallow_types::output_format::OutputFormat::Human,
1083            complexity: false,
1084            file_scores: false,
1085            coverage_gaps: false,
1086            hotspots: false,
1087            targets: false,
1088            css: true,
1089            score: false,
1090            score_gate: false,
1091            snapshot_requested: true,
1092            trend: false,
1093        });
1094
1095        assert!(!derived.any_section);
1096        assert!(derived.css);
1097        assert!(derived.file_scores);
1098        assert!(derived.hotspots);
1099        assert!(derived.score);
1100        assert!(derived.force_full);
1101    }
1102}