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