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