1#![warn(missing_docs)]
10#![cfg_attr(
11 test,
12 allow(
13 clippy::expect_used,
14 reason = "tests use expect to keep fixture setup concise"
15 )
16)]
17
18use std::path::{Path, PathBuf};
19
20use fallow_config::EmailMode;
21use fallow_output::EffortEstimate;
22use serde::Serialize;
23
24mod analysis_context;
25pub mod audit_keys;
29pub mod audit_output;
30pub mod combined_output;
31pub mod compact_output;
34pub mod coverage;
35pub mod dead_code_codeclimate;
36pub mod dead_code_sarif;
37pub mod decision_surface;
38pub mod dupes_output;
39mod duplication_filters;
40pub mod editor;
41pub mod explain;
42pub mod grouped_output;
43pub mod health_codeclimate;
44pub mod json_output;
45pub mod list_output;
46mod list_runtime;
47pub mod markdown_output;
50mod next_steps;
51pub mod output_contracts;
52pub mod review_deltas;
53pub mod routing;
54pub mod runtime;
55mod runtime_json;
56mod runtime_output;
57pub mod sarif_output;
58pub mod security_output;
59mod type_aware;
60pub mod ci_output {
61 pub use fallow_output::{
65 CiIssue, CiProvider, GroupedReviewIssues, MARKER_PREFIX_V2, MARKER_SUFFIX_V2,
66 MAX_COMMENT_BODY_BYTES, PROJECT_LEVEL_RULE_IDS, PrCommentRenderInput,
67 ReviewCommentRenderInput, ReviewEnvelopeRenderInput, ReviewEnvelopeRenderResult,
68 ReviewEnvelopeTruncation, ReviewGitlabDiffRefs, cap_body_with_marker, command_title,
69 composite_fingerprint, escape_md, github_check_conclusion,
70 group_review_issues_by_path_line, is_project_level_rule, issues_from_codeclimate,
71 issues_from_codeclimate_issues, render_pr_comment, render_review_comment_for_group,
72 render_review_envelope, review_label_from_codeclimate, summary_fingerprint, summary_label,
73 };
74}
75pub use analysis_context::{ProgrammaticAnalysisContext, resolve_programmatic_analysis_context};
76pub use audit_output::{
77 AuditAttribution, AuditCodeClimateOutputInput, AuditJsonHeaderInput, AuditJsonOutputInput,
78 AuditSarifOutputInput, AuditSummary, AuditVerdict,
79 attach_audit_duplication_demotion_attribution, attach_audit_styling_attribution,
80 attach_audit_wire_attribution, build_audit_codeclimate, build_audit_codeclimate_issues,
81 build_audit_header_json, build_audit_header_map, build_audit_sarif, build_review_brief_header,
82 serialize_audit_json,
83};
84pub use ci_output::{
85 CiIssue, CiProvider, GroupedReviewIssues, MARKER_PREFIX_V2, MARKER_SUFFIX_V2,
86 MAX_COMMENT_BODY_BYTES, PROJECT_LEVEL_RULE_IDS, PrCommentRenderInput, ReviewCommentRenderInput,
87 ReviewEnvelopeRenderInput, ReviewEnvelopeRenderResult, ReviewEnvelopeTruncation,
88 ReviewGitlabDiffRefs, cap_body_with_marker, command_title, composite_fingerprint, escape_md,
89 github_check_conclusion, group_review_issues_by_path_line, is_project_level_rule,
90 issues_from_codeclimate, issues_from_codeclimate_issues, render_pr_comment,
91 render_review_comment_for_group, render_review_envelope, review_label_from_codeclimate,
92 summary_fingerprint, summary_label,
93};
94pub use combined_output::{
95 CombinedCheckJsonSection, CombinedJsonOutputInput, serialize_combined_dupes_json,
96 serialize_combined_health_json, serialize_combined_json,
97};
98pub use compact_output::{
99 build_compact_lines, build_duplication_compact_lines, build_grouped_compact_lines,
100 build_health_compact_lines,
101};
102pub use coverage::{
103 CoverageInputError, CoverageInputSource, CoverageInputs, resolve_coverage_inputs,
104};
105pub use dead_code_codeclimate::build_codeclimate;
106pub use dead_code_sarif::build_sarif;
107pub use dupes_output::{
108 AttributedCloneGroup, AttributedCloneGroupFinding, AttributedInstance, CloneDemotionReason,
109 CloneFamilyFinding, CloneGroupFinding, DupesReportPayload, DuplicationGroup,
110 DuplicationGrouping, build_duplication_codeclimate,
111};
112pub use editor::{
113 ChangedFilesError, EditorAnalysisOutput, EditorAnalysisResults, EditorAnalysisSession,
114 EditorCloneFamily, EditorCloneFingerprintSet, EditorCloneGroup, EditorCloneInstance,
115 EditorDeadCodeAnalysisOutput, EditorDuplicationReport, EditorDuplicationStats,
116 EditorInlineComplexityExceeded, EditorInlineComplexityFinding, EditorMirroredDirectory,
117 EditorProjectAnalysisOutput, EditorRefactoringKind, EditorRefactoringSuggestion,
118 collect_inline_complexity, editor_duplicates, editor_extract, editor_results, editor_security,
119 editor_suppress, filter_inline_complexity_by_changed_files, resolve_git_toplevel,
120 try_get_changed_files_with_toplevel,
121};
122pub use explain::{
123 CHECK_RULES, DUPES_RULES, FLAGS_RULES, HEALTH_RULES, RuleDef, RuleGuide, SECURITY_RULES,
124 coverage_analyze_meta, coverage_setup_meta, explain_issue_type, rule_by_id, rule_by_token,
125 rule_docs_url, rule_guide, security_meta, serialize_explain_programmatic_json,
126 unknown_explain_error,
127};
128pub use fallow_config::{AuditGate, HealthConfig, TypeAwareRequire};
129pub use fallow_output::RootEnvelopeMode;
130pub use fallow_types::trace::{
131 CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace, ReExportChain,
132 TracedCloneGroup, TracedExport, TracedReExport,
133};
134pub use grouped_output::{
135 ResultGroup, UNOWNED_GROUP_LABEL, build_duplication_grouping_with, group_analysis_results_with,
136 largest_clone_group_owner_with,
137};
138pub use health_codeclimate::build_health_codeclimate;
139pub use json_output::{
140 CheckJsonExtraOutputs, CheckJsonOutputInput, CheckJsonPayloadInput, DuplicationJsonOutputInput,
141 GroupedCheckJsonOutputInput, GroupedDuplicationJsonOutputInput, serialize_check_json,
142 serialize_check_json_payload, serialize_duplication_json, serialize_grouped_check_json,
143 serialize_grouped_duplication_json,
144};
145pub use list_output::{
146 ListJsonEnvelope, ListJsonOutputInput, build_list_json_output, serialize_list_json_output,
147};
148pub use list_runtime::{
149 BoundaryData, ListBoundariesOptions, ListBoundariesProgrammaticOutput, LogicalGroupInfo,
150 ProjectInfoOptions, ProjectInfoProgrammaticOutput, RuleInfo, ZoneInfo, boundary_data_to_output,
151 compute_boundary_data, run_list_boundaries, run_project_info,
152 serialize_list_boundaries_programmatic_json, serialize_project_info_programmatic_json,
153};
154pub use markdown_output::{
155 build_duplication_markdown, build_grouped_markdown, build_health_markdown, build_markdown,
156 build_walkthrough_markdown,
157};
158pub use output_contracts::{
159 AuditOutput, BoundariesListLogicalGroup, BoundariesListRule, BoundariesListZone,
160 BoundariesListing, CombinedOutput, FallowOutput, ImpactOutput, ListBoundariesOutput,
161 ListEntryPointOutput, ListOutput, ListPluginOutput, ReviewBriefWireOutput, SecurityGate,
162 SecurityOutput, SecurityOutputConfig, SecuritySummaryOutput, TraceOutput, WorkspacesOutput,
163};
164pub use runtime::{
165 AuditProgrammaticKeySnapshot, AuditProgrammaticOutput, BoundaryViolationsOutput,
166 BoundaryViolationsProgrammaticOutput, CircularDependenciesOutput,
167 CircularDependenciesProgrammaticOutput, CombinedProgrammaticOutput, DeadCodeOutput,
168 DeadCodeProgrammaticOutput, DecisionSurfaceProgrammaticOutput, DuplicationOutput,
169 DuplicationProgrammaticOutput, EngineHealthRunner, FeatureFlagsOutput,
170 FeatureFlagsProgrammaticOutput, HealthJsonReportInput, HealthProgrammaticOutput,
171 ProgrammaticHealthAnalysis, ProgrammaticHealthNextStepFacts, ProgrammaticHealthRun,
172 ProgrammaticHealthRunner, TraceClassMemberOutput, TraceCloneBenchmarkResult, TraceCloneOutput,
173 TraceCloneProgrammaticOutput, TraceDependencyOutput, TraceDependencyProgrammaticOutput,
174 TraceExportOutput, TraceExportProgrammaticOutput, TraceExportTargetOutput, TraceFileOutput,
175 TraceFileProgrammaticOutput, benchmark_trace_clone_compact_json,
176 benchmark_trace_graph_family_compact_json, load_health_config, run_audit,
177 run_boundary_violations, run_circular_dependencies, run_combined, run_complexity_with_runner,
178 run_dead_code, run_decision_surface, run_duplication, run_feature_flags, run_health,
179 run_health_with_runner, run_trace_clone, run_trace_dependency, run_trace_export,
180 run_trace_file, serialize_health_report_json,
181};
182pub use runtime_json::{
183 serialize_audit_programmatic_json, serialize_boundary_violations_programmatic_json,
184 serialize_circular_dependencies_programmatic_json, serialize_combined_programmatic_json,
185 serialize_dead_code_programmatic_json, serialize_decision_surface_programmatic_json,
186 serialize_duplication_programmatic_json, serialize_feature_flags_programmatic_json,
187 serialize_health_programmatic_json, serialize_trace_clone_programmatic_json,
188 serialize_trace_dependency_programmatic_json, serialize_trace_export_programmatic_json,
189 serialize_trace_file_programmatic_json,
190};
191pub use sarif_output::{
192 annotate_sarif_results, build_duplication_sarif, build_grouped_duplication_sarif,
193 build_health_sarif,
194};
195pub use security_output::SecurityGateMode;
196pub use type_aware::{
197 SemanticCouplingOutcome, SemanticDeadCodeOutcome, SemanticInspectOutcome, TypeAwareError,
198 TypeAwareFileChanges, TypeAwareOutcome, TypeAwareSession, TypeAwareStatus,
199 discard_unverified_semantic_candidates, inspect_symbol as inspect_type_aware_symbol,
200 merge_type_aware_meta,
201 refine_configured_dead_code_results as refine_type_aware_results_with_config,
202 refine_configured_dead_code_results_in_session as refine_type_aware_results_in_session_with_config,
203 refine_dead_code_results as refine_type_aware_results,
204 refine_dead_code_results_in_session as refine_type_aware_results_in_session,
205 refine_programmatic_dead_code as refine_type_aware_dead_code, shutdown_type_aware_sidecars,
206 status as type_aware_status, symbol_impact as run_type_aware_symbol_impact,
207 symbol_impact as type_aware_symbol_impact, terminate_active_type_aware_sidecars,
208 trace_symbol as run_type_aware_symbol_trace, trace_symbol as trace_type_aware_symbol,
209 type_coupling as analyze_type_coupling,
210};
211
212pub const COMMON_ANALYSIS_OPTION_FLAGS: &[&str] = &[
219 "root",
220 "config",
221 "no-cache",
222 "threads",
223 "changed-since",
224 "diff-file",
225 "production",
226 "workspace",
227 "changed-workspaces",
228 "explain",
229 "allow-remote-extends",
230];
231
232#[derive(Debug, Clone, Serialize)]
234pub struct ProgrammaticError {
235 pub message: String,
237 pub exit_code: u8,
239 pub code: Option<String>,
241 pub help: Option<String>,
243 pub context: Option<String>,
245}
246
247impl ProgrammaticError {
248 #[must_use]
251 pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
252 Self {
253 message: message.into(),
254 exit_code,
255 code: None,
256 help: None,
257 context: None,
258 }
259 }
260
261 #[must_use]
263 pub fn with_help(mut self, help: impl Into<String>) -> Self {
264 self.help = Some(help.into());
265 self
266 }
267
268 #[must_use]
271 pub fn with_code(mut self, code: impl Into<String>) -> Self {
272 self.code = Some(code.into());
273 self
274 }
275
276 #[must_use]
279 pub fn with_context(mut self, context: impl Into<String>) -> Self {
280 self.context = Some(context.into());
281 self
282 }
283}
284
285impl std::fmt::Display for ProgrammaticError {
286 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287 write!(f, "{}", self.message)
288 }
289}
290
291impl std::error::Error for ProgrammaticError {}
292
293#[derive(Debug, Clone, Default)]
295pub struct AnalysisOptions {
296 pub root: Option<PathBuf>,
299 pub config_path: Option<PathBuf>,
301 pub allow_remote_extends: bool,
303 pub no_cache: bool,
305 pub threads: Option<usize>,
307 pub diff_file: Option<PathBuf>,
309 pub production: bool,
312 pub production_override: Option<bool>,
315 pub changed_since: Option<String>,
317 pub workspace: Option<Vec<String>>,
319 pub changed_workspaces: Option<String>,
321 pub explain: bool,
323 pub type_aware: TypeAwareOptions,
326}
327
328#[derive(Debug, Clone, Default, PartialEq, Eq)]
330pub struct TypeAwareOptions {
331 pub enabled: bool,
333 pub projects: Vec<PathBuf>,
336 pub require: fallow_config::TypeAwareRequire,
338}
339
340#[derive(Debug, Clone, Default)]
345pub struct DeadCodeFilters {
346 pub unused_files: bool,
348 pub unused_exports: bool,
350 pub unused_deps: bool,
352 pub unused_types: bool,
354 pub private_type_leaks: bool,
356 pub unused_enum_members: bool,
358 pub unused_class_members: bool,
360 pub unused_store_members: bool,
362 pub unprovided_injects: bool,
364 pub unrendered_components: bool,
366 pub unused_component_props: bool,
368 pub unused_component_emits: bool,
370 pub unused_component_inputs: bool,
372 pub unused_component_outputs: bool,
374 pub unused_svelte_events: bool,
376 pub unused_server_actions: bool,
378 pub unused_load_data_keys: bool,
380 pub unresolved_imports: bool,
382 pub unlisted_deps: bool,
384 pub duplicate_exports: bool,
386 pub circular_deps: bool,
388 pub re_export_cycles: bool,
390 pub boundary_violations: bool,
392 pub policy_violations: bool,
394 pub stale_suppressions: bool,
396 pub unused_catalog_entries: bool,
398 pub empty_catalog_groups: bool,
400 pub unresolved_catalog_references: bool,
402 pub unused_dependency_overrides: bool,
404 pub misconfigured_dependency_overrides: bool,
406}
407
408impl DeadCodeFilters {
409 fn any_active(&self) -> bool {
410 self.unused_files
411 || self.unused_exports
412 || self.unused_deps
413 || self.unused_types
414 || self.private_type_leaks
415 || self.unused_enum_members
416 || self.unused_class_members
417 || self.unused_store_members
418 || self.unprovided_injects
419 || self.unrendered_components
420 || self.unused_component_props
421 || self.unused_component_emits
422 || self.unused_component_inputs
423 || self.unused_component_outputs
424 || self.unused_svelte_events
425 || self.unused_server_actions
426 || self.unused_load_data_keys
427 || self.unresolved_imports
428 || self.unlisted_deps
429 || self.duplicate_exports
430 || self.circular_deps
431 || self.re_export_cycles
432 || self.boundary_violations
433 || self.policy_violations
434 || self.stale_suppressions
435 || self.unused_catalog_entries
436 || self.empty_catalog_groups
437 || self.unresolved_catalog_references
438 || self.unused_dependency_overrides
439 || self.misconfigured_dependency_overrides
440 }
441
442 pub fn enable_registry_selector(&mut self, selector: &str) -> bool {
448 let Some(flag) = fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS
449 .iter()
450 .find_map(|&(name, flag)| (name == selector).then_some(flag))
451 else {
452 return false;
453 };
454 self.enable_cli_filter_flag(flag);
455 true
456 }
457
458 fn enable_cli_filter_flag(&mut self, flag: &str) {
459 match flag {
460 "--unused-files" => self.unused_files = true,
461 "--unused-exports" => self.unused_exports = true,
462 "--unused-types" => self.unused_types = true,
463 "--private-type-leaks" => self.private_type_leaks = true,
464 "--unused-deps" => self.unused_deps = true,
465 "--unused-enum-members" => self.unused_enum_members = true,
466 "--unused-class-members" => self.unused_class_members = true,
467 "--unused-store-members" => self.unused_store_members = true,
468 "--unprovided-injects" => self.unprovided_injects = true,
469 "--unrendered-components" => self.unrendered_components = true,
470 "--unused-component-props" => self.unused_component_props = true,
471 "--unused-component-emits" => self.unused_component_emits = true,
472 "--unused-component-inputs" => self.unused_component_inputs = true,
473 "--unused-component-outputs" => self.unused_component_outputs = true,
474 "--unused-svelte-events" => self.unused_svelte_events = true,
475 "--unused-server-actions" => self.unused_server_actions = true,
476 "--unused-load-data-keys" => self.unused_load_data_keys = true,
477 "--unresolved-imports" => self.unresolved_imports = true,
478 "--unlisted-deps" => self.unlisted_deps = true,
479 "--duplicate-exports" => self.duplicate_exports = true,
480 "--circular-deps" => self.circular_deps = true,
481 "--re-export-cycles" => self.re_export_cycles = true,
482 "--boundary-violations" => self.boundary_violations = true,
483 "--policy-violations" => self.policy_violations = true,
484 "--stale-suppressions" => self.stale_suppressions = true,
485 "--unused-catalog-entries" => self.unused_catalog_entries = true,
486 "--empty-catalog-groups" => self.empty_catalog_groups = true,
487 "--unresolved-catalog-references" => self.unresolved_catalog_references = true,
488 "--unused-dependency-overrides" => self.unused_dependency_overrides = true,
489 "--misconfigured-dependency-overrides" => {
490 self.misconfigured_dependency_overrides = true;
491 }
492 _ => unreachable!("registry emitted unsupported dead-code filter flag: {flag}"),
493 }
494 }
495}
496
497#[derive(Debug, Clone, Default)]
499pub struct DeadCodeOptions {
500 pub analysis: AnalysisOptions,
502 pub filters: DeadCodeFilters,
504 pub files: Vec<PathBuf>,
506 pub include_entry_exports: bool,
508}
509
510#[derive(Debug, Clone, Default)]
512pub struct AuditOptions {
513 pub analysis: AnalysisOptions,
515 pub base: Option<String>,
518 pub production: bool,
520 pub production_dead_code: Option<bool>,
522 pub production_health: Option<bool>,
524 pub production_dupes: Option<bool>,
527 pub css: Option<bool>,
529 pub css_deep: Option<bool>,
531 pub gate: fallow_config::AuditGate,
533 pub max_crap: Option<f64>,
535 pub coverage: Option<PathBuf>,
537 pub coverage_root: Option<PathBuf>,
539 pub include_entry_exports: bool,
541 pub runtime_coverage: Option<PathBuf>,
543 pub min_invocations_hot: u64,
545}
546
547#[derive(Debug, Clone)]
549pub struct CombinedOptions {
550 pub analysis: AnalysisOptions,
552 pub dead_code: bool,
554 pub duplication: bool,
556 pub health: bool,
558 pub include_entry_exports: bool,
560 pub duplication_options: DuplicationOptions,
562 pub health_options: ComplexityOptions,
564}
565
566impl Default for CombinedOptions {
567 fn default() -> Self {
568 Self {
569 analysis: AnalysisOptions::default(),
570 dead_code: true,
571 duplication: true,
572 health: true,
573 include_entry_exports: false,
574 duplication_options: DuplicationOptions::default(),
575 health_options: ComplexityOptions::default(),
576 }
577 }
578}
579
580#[derive(Debug, Clone, Default)]
582pub struct DecisionSurfaceOptions {
583 pub analysis: AnalysisOptions,
585 pub base: Option<String>,
588 pub max_decisions: Option<usize>,
590}
591
592#[derive(Debug, Clone, Default)]
594pub struct FeatureFlagsOptions {
595 pub analysis: AnalysisOptions,
597 pub top: Option<usize>,
599}
600
601#[derive(Debug, Clone, Copy, Default)]
603pub enum DuplicationMode {
604 Strict,
607 #[default]
609 Mild,
610 Weak,
612 Semantic,
615}
616
617#[derive(Debug, Clone, Default)]
619pub struct DuplicationOptions {
620 pub analysis: AnalysisOptions,
622 pub mode: Option<DuplicationMode>,
624 pub near: Option<bool>,
627 pub min_tokens: Option<usize>,
629 pub min_lines: Option<usize>,
631 pub min_occurrences: Option<usize>,
634 pub threshold: Option<f64>,
637 pub skip_local: Option<bool>,
640 pub cross_language: Option<bool>,
642 pub ignore_imports: Option<bool>,
645 pub top: Option<usize>,
647}
648
649#[derive(Debug, Clone, Default)]
651pub struct TraceExportOptions {
652 pub analysis: AnalysisOptions,
654 pub file: String,
656 pub export_name: String,
658}
659
660#[derive(Debug, Clone, Default)]
662pub struct TraceFileOptions {
663 pub analysis: AnalysisOptions,
665 pub file: String,
667}
668
669#[derive(Debug, Clone, Default)]
671pub struct TraceDependencyOptions {
672 pub analysis: AnalysisOptions,
674 pub package_name: String,
676}
677
678#[derive(Debug, Clone, PartialEq, Eq)]
680pub enum TraceCloneTarget {
681 Location {
683 file: String,
685 line: usize,
687 },
688 Fingerprint(String),
690}
691
692#[derive(Debug, Clone)]
694pub struct TraceCloneOptions {
695 pub duplication: DuplicationOptions,
697 pub target: TraceCloneTarget,
699}
700
701#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
703pub enum ComplexitySort {
704 #[default]
706 Cyclomatic,
707 Cognitive,
709 Lines,
711 Severity,
713}
714
715#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
717pub enum OwnershipEmailMode {
718 Raw,
720 #[default]
722 Handle,
723 Anonymized,
725 Hash,
727}
728
729#[derive(Debug, Clone, Copy, PartialEq, Eq)]
731pub enum TargetEffort {
732 Low,
734 Medium,
736 High,
738}
739
740#[derive(Debug, Clone, Default)]
742pub struct ComplexityOptions {
743 pub analysis: AnalysisOptions,
745 pub max_cyclomatic: Option<u16>,
747 pub max_cognitive: Option<u16>,
749 pub max_crap: Option<f64>,
751 pub top: Option<usize>,
753 pub sort: ComplexitySort,
755 pub complexity_breakdown: bool,
757 pub complexity: bool,
759 pub file_scores: bool,
761 pub coverage_gaps: bool,
763 pub hotspots: bool,
765 pub ownership: bool,
767 pub ownership_emails: Option<OwnershipEmailMode>,
769 pub targets: bool,
771 pub css: bool,
773 pub css_deep: bool,
775 pub effort: Option<TargetEffort>,
778 pub score: bool,
780 pub since: Option<String>,
782 pub min_commits: Option<u32>,
784 pub coverage: Option<PathBuf>,
786 pub coverage_root: Option<PathBuf>,
788 pub coverage_relocated: bool,
793}
794
795#[derive(Debug, Clone, Copy, Default, PartialEq)]
797pub struct ComplexityThresholdOverrides {
798 pub max_cyclomatic: Option<u16>,
800 pub max_cognitive: Option<u16>,
802 pub max_crap: Option<f64>,
804}
805
806#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
808pub struct ComplexityCoverageInputs<'a> {
809 pub coverage: Option<&'a Path>,
811 pub coverage_root: Option<&'a Path>,
813 pub coverage_relocated: bool,
816}
817
818#[derive(Debug, Clone)]
820pub struct HealthSectionOptions {
821 pub output: fallow_types::output_format::OutputFormat,
823 pub complexity: bool,
825 pub file_scores: bool,
827 pub coverage_gaps: bool,
829 pub hotspots: bool,
831 pub targets: bool,
833 pub css: bool,
835 pub score: bool,
837 pub score_gate: bool,
839 pub snapshot_requested: bool,
841 pub trend: bool,
843}
844
845#[derive(Debug, Clone, Copy, PartialEq, Eq)]
847pub struct DerivedHealthSections {
848 pub any_section: bool,
850 pub complexity: bool,
852 pub file_scores: bool,
854 pub coverage_gaps: bool,
856 pub hotspots: bool,
858 pub targets: bool,
860 pub css: bool,
862 pub score: bool,
864 pub force_full: bool,
866 pub score_only_output: bool,
868}
869
870#[derive(Debug, Clone)]
872pub struct ComplexitySectionOptions {
873 pub complexity: bool,
875 pub file_scores: bool,
877 pub coverage_gaps: bool,
879 pub hotspots: bool,
881 pub ownership: bool,
883 pub targets: bool,
885 pub css: bool,
887 pub score: bool,
889}
890
891#[derive(Debug, Clone, Copy, PartialEq, Eq)]
893pub struct DerivedComplexityOptions {
894 pub any_section: bool,
896 pub complexity: bool,
898 pub file_scores: bool,
900 pub coverage_gaps: bool,
902 pub hotspots: bool,
904 pub ownership: bool,
906 pub targets: bool,
908 pub force_full: bool,
910 pub score_only_output: bool,
912 pub score: bool,
914}
915
916#[derive(Debug, Clone, PartialEq)]
918pub struct ComplexityRunOptions<'a> {
919 pub thresholds: ComplexityThresholdOverrides,
921 pub top: Option<usize>,
923 pub sort: ComplexitySort,
925 pub complexity_breakdown: bool,
927 pub sections: DerivedComplexityOptions,
929 pub ownership_emails: Option<OwnershipEmailMode>,
931 pub effort: Option<TargetEffort>,
933 pub css: bool,
935 pub css_deep: bool,
937 pub since: Option<&'a str>,
939 pub min_commits: Option<u32>,
941 pub coverage_inputs: ComplexityCoverageInputs<'a>,
943}
944
945#[must_use]
947pub fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
948 let score = options.score
949 || options.score_gate
950 || options.trend
951 || matches!(
952 options.output,
953 fallow_types::output_format::OutputFormat::Badge
954 );
955 let any_section = options.complexity
956 || options.file_scores
957 || options.coverage_gaps
958 || options.hotspots
959 || options.targets
960 || score;
961 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
962 let force_full = options.snapshot_requested || effective_score;
963
964 DerivedHealthSections {
965 any_section,
966 complexity: if any_section {
967 options.complexity
968 } else {
969 true
970 },
971 file_scores: if any_section {
972 options.file_scores
973 } else {
974 true
975 } || force_full,
976 coverage_gaps: if any_section {
977 options.coverage_gaps
978 } else {
979 false
980 },
981 hotspots: if any_section { options.hotspots } else { true }
982 || options.snapshot_requested
983 || options.trend,
984 targets: if any_section { options.targets } else { true },
985 css: options.css,
986 score: effective_score,
987 force_full,
988 score_only_output: is_health_score_only_output(options, score),
989 }
990}
991
992#[must_use]
994pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
995 let requested_hotspots = options.hotspots || options.ownership;
996 let sections = derive_health_sections(&HealthSectionOptions {
997 output: fallow_types::output_format::OutputFormat::Human,
998 complexity: options.complexity,
999 file_scores: options.file_scores,
1000 coverage_gaps: options.coverage_gaps,
1001 hotspots: requested_hotspots,
1002 targets: options.targets,
1003 css: options.css,
1004 score: options.score,
1005 score_gate: false,
1006 snapshot_requested: false,
1007 trend: false,
1008 });
1009
1010 DerivedComplexityOptions {
1011 any_section: sections.any_section,
1012 complexity: sections.complexity,
1013 file_scores: sections.file_scores,
1014 coverage_gaps: sections.coverage_gaps,
1015 hotspots: sections.hotspots,
1016 ownership: options.ownership && sections.hotspots,
1017 targets: sections.targets,
1018 force_full: sections.force_full,
1019 score_only_output: sections.score_only_output,
1020 score: sections.score,
1021 }
1022}
1023
1024#[must_use]
1026pub fn derive_complexity_options(options: &ComplexityOptions) -> DerivedComplexityOptions {
1027 derive_complexity_sections(&complexity_section_options(options))
1028}
1029
1030#[must_use]
1032pub fn derive_complexity_run_options(options: &ComplexityOptions) -> ComplexityRunOptions<'_> {
1033 ComplexityRunOptions {
1034 thresholds: ComplexityThresholdOverrides {
1035 max_cyclomatic: options.max_cyclomatic,
1036 max_cognitive: options.max_cognitive,
1037 max_crap: options.max_crap,
1038 },
1039 top: options.top,
1040 sort: options.sort,
1041 complexity_breakdown: options.complexity_breakdown,
1042 sections: derive_complexity_options(options),
1043 ownership_emails: options.ownership_emails,
1044 effort: options.effort,
1045 css: options.css,
1046 css_deep: options.css_deep,
1047 since: options.since.as_deref(),
1048 min_commits: options.min_commits,
1049 coverage_inputs: ComplexityCoverageInputs {
1050 coverage: options.coverage.as_deref(),
1051 coverage_root: options.coverage_root.as_deref(),
1052 coverage_relocated: options.coverage_relocated,
1053 },
1054 }
1055}
1056
1057pub fn validate_complexity_options(options: &ComplexityOptions) -> Result<(), ProgrammaticError> {
1080 if let Some(path) = &options.coverage {
1081 let resolved = fallow_engine::health::scoring::resolve_relative_to_root(
1082 path,
1083 options.analysis.root.as_deref(),
1084 );
1085 if !resolved.exists() {
1086 return Err(ProgrammaticError::new(
1087 format!("coverage path does not exist: {}", resolved.display()),
1088 2,
1089 )
1090 .with_code("FALLOW_INVALID_COVERAGE_PATH")
1091 .with_context("health.coverage"));
1092 }
1093 }
1094 if let Err(message) =
1095 fallow_engine::health::validate_coverage_root_absolute(options.coverage_root.as_deref())
1096 {
1097 return Err(ProgrammaticError::new(message, 2)
1098 .with_code("FALLOW_INVALID_COVERAGE_ROOT")
1099 .with_context("health.coverage_root"));
1100 }
1101
1102 Ok(())
1103}
1104
1105fn complexity_section_options(options: &ComplexityOptions) -> ComplexitySectionOptions {
1106 let ownership = options.ownership || options.ownership_emails.is_some();
1107 let requested_targets = options.targets || options.effort.is_some();
1108 ComplexitySectionOptions {
1109 complexity: options.complexity,
1110 file_scores: options.file_scores,
1111 coverage_gaps: options.coverage_gaps,
1112 hotspots: options.hotspots,
1113 ownership,
1114 targets: requested_targets,
1115 css: options.css,
1116 score: options.score,
1117 }
1118}
1119
1120fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
1121 score
1122 && !options.complexity
1123 && !options.file_scores
1124 && !options.coverage_gaps
1125 && !options.hotspots
1126 && !options.targets
1127 && !options.trend
1128}
1129
1130const fn thresholds_to_engine(
1131 thresholds: ComplexityThresholdOverrides,
1132) -> fallow_engine::health::HealthThresholdOverrides {
1133 fallow_engine::health::HealthThresholdOverrides {
1134 max_cyclomatic: thresholds.max_cyclomatic,
1135 max_cognitive: thresholds.max_cognitive,
1136 max_crap: thresholds.max_crap,
1137 }
1138}
1139
1140const fn complexity_sort_to_engine(sort: ComplexitySort) -> fallow_engine::health::HealthSort {
1141 match sort {
1142 ComplexitySort::Severity => fallow_engine::health::HealthSort::Severity,
1143 ComplexitySort::Cyclomatic => fallow_engine::health::HealthSort::Cyclomatic,
1144 ComplexitySort::Cognitive => fallow_engine::health::HealthSort::Cognitive,
1145 ComplexitySort::Lines => fallow_engine::health::HealthSort::Lines,
1146 }
1147}
1148
1149const fn coverage_inputs_to_engine(
1150 coverage_inputs: ComplexityCoverageInputs<'_>,
1151) -> fallow_engine::health::HealthCoverageInputs<'_> {
1152 fallow_engine::health::HealthCoverageInputs {
1153 coverage: coverage_inputs.coverage,
1154 coverage_root: coverage_inputs.coverage_root,
1155 coverage_relocated: coverage_inputs.coverage_relocated,
1156 }
1157}
1158
1159const fn ownership_email_mode_to_config(mode: OwnershipEmailMode) -> EmailMode {
1160 match mode {
1161 OwnershipEmailMode::Raw => EmailMode::Raw,
1162 OwnershipEmailMode::Handle => EmailMode::Handle,
1163 OwnershipEmailMode::Anonymized => EmailMode::Anonymized,
1164 OwnershipEmailMode::Hash => EmailMode::Hash,
1165 }
1166}
1167
1168const fn target_effort_to_output(effort: TargetEffort) -> EffortEstimate {
1169 match effort {
1170 TargetEffort::Low => EffortEstimate::Low,
1171 TargetEffort::Medium => EffortEstimate::Medium,
1172 TargetEffort::High => EffortEstimate::High,
1173 }
1174}
1175
1176#[cfg(test)]
1177mod tests {
1178 use super::*;
1179
1180 #[test]
1181 fn duplication_defaults_match_cli_contract() {
1182 let options = DuplicationOptions::default();
1183 assert!(options.mode.is_none());
1184 assert!(options.min_tokens.is_none());
1185 assert!(options.min_lines.is_none());
1186 assert!(options.min_occurrences.is_none());
1187 }
1188
1189 #[test]
1190 fn programmatic_error_builder_keeps_optional_fields() {
1191 let error = ProgrammaticError::new("boom", 2)
1192 .with_code("FALLOW_TEST")
1193 .with_help("Try again")
1194 .with_context("analysis.root");
1195
1196 assert_eq!(error.message, "boom");
1197 assert_eq!(error.exit_code, 2);
1198 assert_eq!(error.code.as_deref(), Some("FALLOW_TEST"));
1199 assert_eq!(error.help.as_deref(), Some("Try again"));
1200 assert_eq!(error.context.as_deref(), Some("analysis.root"));
1201 }
1202
1203 #[test]
1204 fn dead_code_filters_accept_shared_registry_selectors() {
1205 for (selector, _) in fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS.iter() {
1206 let mut filters = DeadCodeFilters::default();
1207 assert!(
1208 filters.enable_registry_selector(selector),
1209 "{selector} should be accepted"
1210 );
1211 }
1212
1213 let mut filters = DeadCodeFilters::default();
1214 assert!(filters.enable_registry_selector("unused-files"));
1215 assert!(filters.unused_files);
1216 assert!(filters.enable_registry_selector("boundary-violations"));
1217 assert!(filters.boundary_violations);
1218 assert!(!filters.enable_registry_selector("not-a-real-selector"));
1219 }
1220
1221 #[test]
1222 fn default_complexity_options_match_programmatic_health_defaults() {
1223 let derived = derive_complexity_options(&ComplexityOptions::default());
1224
1225 assert!(!derived.any_section);
1226 assert!(derived.complexity);
1227 assert!(derived.file_scores);
1228 assert!(!derived.coverage_gaps);
1229 assert!(derived.hotspots);
1230 assert!(!derived.ownership);
1231 assert!(derived.targets);
1232 assert!(derived.force_full);
1233 assert!(!derived.score_only_output);
1234 assert!(derived.score);
1235 }
1236
1237 #[test]
1238 fn score_only_complexity_options_request_score_only_output() {
1239 let derived = derive_complexity_options(&ComplexityOptions {
1240 score: true,
1241 ..ComplexityOptions::default()
1242 });
1243
1244 assert!(derived.any_section);
1245 assert!(!derived.complexity);
1246 assert!(derived.file_scores);
1247 assert!(!derived.hotspots);
1248 assert!(!derived.targets);
1249 assert!(derived.force_full);
1250 assert!(derived.score_only_output);
1251 assert!(derived.score);
1252 }
1253
1254 #[test]
1255 fn ownership_implies_hotspots_when_requested() {
1256 let derived = derive_complexity_options(&ComplexityOptions {
1257 ownership: true,
1258 ..ComplexityOptions::default()
1259 });
1260
1261 assert!(derived.any_section);
1262 assert!(derived.hotspots);
1263 assert!(derived.ownership);
1264 assert!(!derived.targets);
1265 }
1266
1267 #[test]
1268 fn complexity_run_options_normalize_public_api_options() {
1269 let options = ComplexityOptions {
1270 max_cyclomatic: Some(42),
1271 max_cognitive: Some(21),
1272 max_crap: Some(18.5),
1273 top: Some(7),
1274 sort: ComplexitySort::Severity,
1275 complexity_breakdown: true,
1276 ownership_emails: Some(OwnershipEmailMode::Hash),
1277 effort: Some(TargetEffort::High),
1278 coverage: Some(PathBuf::from("coverage/coverage-final.json")),
1279 coverage_root: Some(PathBuf::from("/ci/workspace")),
1280 since: Some("30d".to_string()),
1281 min_commits: Some(4),
1282 ..ComplexityOptions::default()
1283 };
1284
1285 let run = derive_complexity_run_options(&options);
1286
1287 assert_eq!(run.thresholds.max_cyclomatic, Some(42));
1288 assert_eq!(run.thresholds.max_cognitive, Some(21));
1289 assert_eq!(run.thresholds.max_crap, Some(18.5));
1290 assert_eq!(run.top, Some(7));
1291 assert!(matches!(run.sort, ComplexitySort::Severity));
1292 assert!(run.complexity_breakdown);
1293 assert!(run.sections.hotspots);
1294 assert!(run.sections.ownership);
1295 assert!(run.sections.targets);
1296 assert!(matches!(
1297 run.ownership_emails,
1298 Some(OwnershipEmailMode::Hash)
1299 ));
1300 assert!(matches!(run.effort, Some(TargetEffort::High)));
1301 assert_eq!(run.since, Some("30d"));
1302 assert_eq!(run.min_commits, Some(4));
1303 assert_eq!(run.coverage_inputs.coverage, options.coverage.as_deref());
1304 assert_eq!(
1305 run.coverage_inputs.coverage_root,
1306 options.coverage_root.as_deref()
1307 );
1308 }
1309
1310 #[test]
1311 fn complexity_options_validation_accepts_existing_coverage_path_and_absolute_root() {
1312 let dir = tempfile::tempdir().expect("tempdir");
1313 let coverage = dir.path().join("coverage-final.json");
1314 std::fs::write(&coverage, "{}").expect("coverage fixture");
1315
1316 let result = validate_complexity_options(&ComplexityOptions {
1317 coverage: Some(coverage),
1318 coverage_root: Some(PathBuf::from("/ci/workspace")),
1319 ..ComplexityOptions::default()
1320 });
1321
1322 assert!(result.is_ok());
1323 }
1324
1325 #[test]
1326 fn complexity_options_validation_keeps_missing_coverage_error_contract() {
1327 let err = validate_complexity_options(&ComplexityOptions {
1328 coverage: Some(PathBuf::from("/missing/coverage-final.json")),
1329 ..ComplexityOptions::default()
1330 })
1331 .expect_err("missing coverage path should fail");
1332
1333 assert_eq!(err.exit_code, 2);
1334 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1335 assert_eq!(err.context.as_deref(), Some("health.coverage"));
1336 }
1337
1338 #[test]
1339 fn complexity_options_validation_keeps_relative_coverage_root_error_contract() {
1340 let err = validate_complexity_options(&ComplexityOptions {
1341 coverage_root: Some(PathBuf::from("coverage")),
1342 ..ComplexityOptions::default()
1343 })
1344 .expect_err("relative coverage root should fail");
1345
1346 assert_eq!(err.exit_code, 2);
1347 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_ROOT"));
1348 assert_eq!(err.context.as_deref(), Some("health.coverage_root"));
1349 }
1350
1351 #[test]
1355 fn complexity_options_validation_resolves_relative_coverage_against_root() {
1356 let dir = tempfile::tempdir().expect("tempdir");
1357 std::fs::create_dir_all(dir.path().join("artifacts")).expect("artifacts dir");
1358 std::fs::write(dir.path().join("artifacts/coverage-final.json"), "{}")
1359 .expect("coverage fixture");
1360 let relative = PathBuf::from("artifacts/coverage-final.json");
1361 assert!(
1362 !relative.exists(),
1363 "the fixture must not also exist under the test cwd"
1364 );
1365
1366 let result = validate_complexity_options(&ComplexityOptions {
1367 analysis: AnalysisOptions {
1368 root: Some(dir.path().to_path_buf()),
1369 ..AnalysisOptions::default()
1370 },
1371 coverage: Some(relative.clone()),
1372 ..ComplexityOptions::default()
1373 });
1374 assert!(result.is_ok(), "{result:?}");
1375
1376 let err = validate_complexity_options(&ComplexityOptions {
1377 analysis: AnalysisOptions {
1378 root: Some(dir.path().join("elsewhere")),
1379 ..AnalysisOptions::default()
1380 },
1381 coverage: Some(relative),
1382 ..ComplexityOptions::default()
1383 })
1384 .expect_err("the path does not exist under the other root");
1385 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1386 assert!(
1387 err.message.contains("elsewhere"),
1388 "the message names the resolved path: {}",
1389 err.message
1390 );
1391 }
1392
1393 #[test]
1394 fn default_health_sections_match_full_health_output() {
1395 let derived = derive_health_sections(&HealthSectionOptions {
1396 output: fallow_types::output_format::OutputFormat::Human,
1397 complexity: false,
1398 file_scores: false,
1399 coverage_gaps: false,
1400 hotspots: false,
1401 targets: false,
1402 css: false,
1403 score: false,
1404 score_gate: false,
1405 snapshot_requested: false,
1406 trend: false,
1407 });
1408
1409 assert!(!derived.any_section);
1410 assert!(derived.complexity);
1411 assert!(derived.file_scores);
1412 assert!(!derived.coverage_gaps);
1413 assert!(derived.hotspots);
1414 assert!(derived.targets);
1415 assert!(derived.score);
1416 assert!(derived.force_full);
1417 assert!(!derived.score_only_output);
1418 }
1419
1420 #[test]
1421 fn health_score_gate_requests_score_only_output() {
1422 let derived = derive_health_sections(&HealthSectionOptions {
1423 output: fallow_types::output_format::OutputFormat::Human,
1424 complexity: false,
1425 file_scores: false,
1426 coverage_gaps: false,
1427 hotspots: false,
1428 targets: false,
1429 css: false,
1430 score: false,
1431 score_gate: true,
1432 snapshot_requested: false,
1433 trend: false,
1434 });
1435
1436 assert!(derived.any_section);
1437 assert!(!derived.complexity);
1438 assert!(derived.file_scores);
1439 assert!(!derived.hotspots);
1440 assert!(!derived.targets);
1441 assert!(derived.score);
1442 assert!(derived.force_full);
1443 assert!(derived.score_only_output);
1444 }
1445
1446 #[test]
1447 fn health_snapshot_keeps_full_hidden_inputs_without_section_request() {
1448 let derived = derive_health_sections(&HealthSectionOptions {
1449 output: fallow_types::output_format::OutputFormat::Human,
1450 complexity: false,
1451 file_scores: false,
1452 coverage_gaps: false,
1453 hotspots: false,
1454 targets: false,
1455 css: true,
1456 score: false,
1457 score_gate: false,
1458 snapshot_requested: true,
1459 trend: false,
1460 });
1461
1462 assert!(!derived.any_section);
1463 assert!(derived.css);
1464 assert!(derived.file_scores);
1465 assert!(derived.hotspots);
1466 assert!(derived.score);
1467 assert!(derived.force_full);
1468 }
1469}