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