1#![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 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#[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#[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 pub production: bool,
251 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#[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#[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#[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#[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#[derive(Debug, Clone, Default)]
332pub struct FeatureFlagsOptions {
333 pub analysis: AnalysisOptions,
334 pub top: Option<usize>,
335}
336
337#[derive(Debug, Clone, Copy, Default)]
339pub enum DuplicationMode {
340 Strict,
341 #[default]
342 Mild,
343 Weak,
344 Semantic,
345}
346
347#[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 pub min_occurrences: Option<usize>,
357 pub threshold: Option<f64>,
358 pub skip_local: Option<bool>,
359 pub cross_language: Option<bool>,
360 pub ignore_imports: Option<bool>,
363 pub top: Option<usize>,
364}
365
366#[derive(Debug, Clone, Default)]
368pub struct TraceExportOptions {
369 pub analysis: AnalysisOptions,
370 pub file: String,
371 pub export_name: String,
372}
373
374#[derive(Debug, Clone, Default)]
376pub struct TraceFileOptions {
377 pub analysis: AnalysisOptions,
378 pub file: String,
379}
380
381#[derive(Debug, Clone, Default)]
383pub struct TraceDependencyOptions {
384 pub analysis: AnalysisOptions,
385 pub package_name: String,
386}
387
388#[derive(Debug, Clone, PartialEq, Eq)]
390pub enum TraceCloneTarget {
391 Location { file: String, line: usize },
392 Fingerprint(String),
393}
394
395#[derive(Debug, Clone)]
397pub struct TraceCloneOptions {
398 pub duplication: DuplicationOptions,
399 pub target: TraceCloneTarget,
400}
401
402#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
404pub enum ComplexitySort {
405 #[default]
406 Cyclomatic,
407 Cognitive,
408 Lines,
409 Severity,
410}
411
412#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
414pub enum OwnershipEmailMode {
415 Raw,
416 #[default]
417 Handle,
418 Anonymized,
419 Hash,
421}
422
423#[derive(Debug, Clone, Copy, PartialEq, Eq)]
425pub enum TargetEffort {
426 Low,
427 Medium,
428 High,
429}
430
431#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[must_use]
628pub fn derive_complexity_options(options: &ComplexityOptions) -> DerivedComplexityOptions {
629 derive_complexity_sections(&complexity_section_options(options))
630}
631
632#[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
657pub 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}