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 dead_code_codeclimate;
35pub mod dead_code_sarif;
36pub mod decision_surface;
37pub mod dupes_output;
38mod duplication_filters;
39pub mod editor;
40pub mod explain;
41pub mod grouped_output;
42pub mod health_codeclimate;
43pub mod json_output;
44pub mod list_output;
45mod list_runtime;
46pub mod markdown_output;
49mod next_steps;
50pub mod output_contracts;
51pub mod review_deltas;
52pub mod routing;
53pub mod runtime;
54mod runtime_json;
55mod runtime_output;
56pub mod sarif_output;
57pub mod security_output;
58mod type_aware;
59pub mod ci_output {
60 pub use fallow_output::{
64 CiIssue, CiProvider, GroupedReviewIssues, MARKER_PREFIX_V2, MARKER_SUFFIX_V2,
65 MAX_COMMENT_BODY_BYTES, PROJECT_LEVEL_RULE_IDS, PrCommentRenderInput,
66 ReviewCommentRenderInput, ReviewEnvelopeRenderInput, ReviewEnvelopeRenderResult,
67 ReviewEnvelopeTruncation, ReviewGitlabDiffRefs, cap_body_with_marker, command_title,
68 composite_fingerprint, escape_md, github_check_conclusion,
69 group_review_issues_by_path_line, is_project_level_rule, issues_from_codeclimate,
70 issues_from_codeclimate_issues, render_pr_comment, render_review_comment_for_group,
71 render_review_envelope, review_label_from_codeclimate, summary_fingerprint, summary_label,
72 };
73}
74pub use analysis_context::{ProgrammaticAnalysisContext, resolve_programmatic_analysis_context};
75pub use audit_output::{
76 AuditAttribution, AuditCodeClimateOutputInput, AuditJsonHeaderInput, AuditJsonOutputInput,
77 AuditSarifOutputInput, AuditSummary, AuditVerdict, attach_audit_styling_attribution,
78 build_audit_codeclimate, build_audit_codeclimate_issues, build_audit_header_json,
79 build_audit_header_map, build_audit_sarif, build_review_brief_header, serialize_audit_json,
80};
81pub use ci_output::{
82 CiIssue, CiProvider, GroupedReviewIssues, MARKER_PREFIX_V2, MARKER_SUFFIX_V2,
83 MAX_COMMENT_BODY_BYTES, PROJECT_LEVEL_RULE_IDS, PrCommentRenderInput, ReviewCommentRenderInput,
84 ReviewEnvelopeRenderInput, ReviewEnvelopeRenderResult, ReviewEnvelopeTruncation,
85 ReviewGitlabDiffRefs, cap_body_with_marker, command_title, composite_fingerprint, escape_md,
86 github_check_conclusion, group_review_issues_by_path_line, is_project_level_rule,
87 issues_from_codeclimate, issues_from_codeclimate_issues, render_pr_comment,
88 render_review_comment_for_group, render_review_envelope, review_label_from_codeclimate,
89 summary_fingerprint, summary_label,
90};
91pub use combined_output::{
92 CombinedCheckJsonSection, CombinedJsonOutputInput, serialize_combined_dupes_json,
93 serialize_combined_health_json, serialize_combined_json,
94};
95pub use compact_output::{
96 build_compact_lines, build_duplication_compact_lines, build_grouped_compact_lines,
97 build_health_compact_lines,
98};
99pub use dead_code_codeclimate::build_codeclimate;
100pub use dead_code_sarif::build_sarif;
101pub use dupes_output::{
102 AttributedCloneGroup, AttributedCloneGroupFinding, AttributedInstance, CloneFamilyFinding,
103 CloneGroupFinding, DupesReportPayload, DuplicationGroup, DuplicationGrouping,
104 build_duplication_codeclimate,
105};
106pub use editor::{
107 ChangedFilesError, EditorAnalysisOutput, EditorAnalysisResults, EditorAnalysisSession,
108 EditorCloneFamily, EditorCloneFingerprintSet, EditorCloneGroup, EditorCloneInstance,
109 EditorDeadCodeAnalysisOutput, EditorDuplicationReport, EditorDuplicationStats,
110 EditorInlineComplexityExceeded, EditorInlineComplexityFinding, EditorMirroredDirectory,
111 EditorProjectAnalysisOutput, EditorRefactoringKind, EditorRefactoringSuggestion,
112 collect_inline_complexity, editor_duplicates, editor_extract, editor_results, editor_security,
113 editor_suppress, filter_inline_complexity_by_changed_files, resolve_git_toplevel,
114 try_get_changed_files_with_toplevel,
115};
116pub use explain::{
117 CHECK_RULES, DUPES_RULES, FLAGS_RULES, HEALTH_RULES, RuleDef, RuleGuide, SECURITY_RULES,
118 coverage_analyze_meta, coverage_setup_meta, explain_issue_type, rule_by_id, rule_by_token,
119 rule_docs_url, rule_guide, security_meta, serialize_explain_programmatic_json,
120 unknown_explain_error,
121};
122pub use fallow_config::{AuditGate, TypeAwareRequire};
123pub use fallow_output::RootEnvelopeMode;
124pub use fallow_types::trace::{
125 CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace, ReExportChain,
126 TracedCloneGroup, TracedExport, TracedReExport,
127};
128pub use grouped_output::{
129 ResultGroup, UNOWNED_GROUP_LABEL, build_duplication_grouping_with, group_analysis_results_with,
130 largest_clone_group_owner_with,
131};
132pub use health_codeclimate::build_health_codeclimate;
133pub use json_output::{
134 CheckJsonExtraOutputs, CheckJsonOutputInput, CheckJsonPayloadInput, DuplicationJsonOutputInput,
135 GroupedCheckJsonOutputInput, GroupedDuplicationJsonOutputInput, serialize_check_json,
136 serialize_check_json_payload, serialize_duplication_json, serialize_grouped_check_json,
137 serialize_grouped_duplication_json,
138};
139pub use list_output::{
140 ListJsonEnvelope, ListJsonOutputInput, build_list_json_output, serialize_list_json_output,
141};
142pub use list_runtime::{
143 BoundaryData, ListBoundariesOptions, ListBoundariesProgrammaticOutput, LogicalGroupInfo,
144 ProjectInfoOptions, ProjectInfoProgrammaticOutput, RuleInfo, ZoneInfo, boundary_data_to_output,
145 compute_boundary_data, run_list_boundaries, run_project_info,
146 serialize_list_boundaries_programmatic_json, serialize_project_info_programmatic_json,
147};
148pub use markdown_output::{
149 build_duplication_markdown, build_grouped_markdown, build_health_markdown, build_markdown,
150 build_walkthrough_markdown,
151};
152pub use output_contracts::{
153 AuditOutput, BoundariesListLogicalGroup, BoundariesListRule, BoundariesListZone,
154 BoundariesListing, CombinedOutput, FallowOutput, ImpactOutput, ListBoundariesOutput,
155 ListEntryPointOutput, ListOutput, ListPluginOutput, ReviewBriefWireOutput, SecurityGate,
156 SecurityOutput, SecurityOutputConfig, SecuritySummaryOutput, TraceOutput, WorkspacesOutput,
157};
158pub use runtime::{
159 AuditProgrammaticKeySnapshot, AuditProgrammaticOutput, BoundaryViolationsOutput,
160 BoundaryViolationsProgrammaticOutput, CircularDependenciesOutput,
161 CircularDependenciesProgrammaticOutput, CombinedProgrammaticOutput, DeadCodeOutput,
162 DeadCodeProgrammaticOutput, DecisionSurfaceProgrammaticOutput, DuplicationOutput,
163 DuplicationProgrammaticOutput, EngineHealthRunner, FeatureFlagsOutput,
164 FeatureFlagsProgrammaticOutput, HealthJsonReportInput, HealthProgrammaticOutput,
165 ProgrammaticHealthAnalysis, ProgrammaticHealthNextStepFacts, ProgrammaticHealthRun,
166 ProgrammaticHealthRunner, TraceClassMemberOutput, TraceCloneOutput,
167 TraceCloneProgrammaticOutput, TraceDependencyOutput, TraceDependencyProgrammaticOutput,
168 TraceExportOutput, TraceExportProgrammaticOutput, TraceExportTargetOutput, TraceFileOutput,
169 TraceFileProgrammaticOutput, run_audit, run_boundary_violations, run_circular_dependencies,
170 run_combined, run_complexity_with_runner, run_dead_code, run_decision_surface, run_duplication,
171 run_feature_flags, run_health, run_health_with_runner, run_trace_clone, run_trace_dependency,
172 run_trace_export, run_trace_file, serialize_health_report_json,
173};
174pub use runtime_json::{
175 serialize_audit_programmatic_json, serialize_boundary_violations_programmatic_json,
176 serialize_circular_dependencies_programmatic_json, serialize_combined_programmatic_json,
177 serialize_dead_code_programmatic_json, serialize_decision_surface_programmatic_json,
178 serialize_duplication_programmatic_json, serialize_feature_flags_programmatic_json,
179 serialize_health_programmatic_json, serialize_trace_clone_programmatic_json,
180 serialize_trace_dependency_programmatic_json, serialize_trace_export_programmatic_json,
181 serialize_trace_file_programmatic_json,
182};
183pub use sarif_output::{
184 annotate_sarif_results, build_duplication_sarif, build_grouped_duplication_sarif,
185 build_health_sarif,
186};
187pub use security_output::SecurityGateMode;
188pub use type_aware::{
189 SemanticCouplingOutcome, SemanticDeadCodeOutcome, SemanticInspectOutcome, TypeAwareError,
190 TypeAwareFileChanges, TypeAwareOutcome, TypeAwareSession, TypeAwareStatus,
191 discard_unverified_semantic_candidates, inspect_symbol as inspect_type_aware_symbol,
192 merge_type_aware_meta,
193 refine_configured_dead_code_results as refine_type_aware_results_with_config,
194 refine_configured_dead_code_results_in_session as refine_type_aware_results_in_session_with_config,
195 refine_dead_code_results as refine_type_aware_results,
196 refine_dead_code_results_in_session as refine_type_aware_results_in_session,
197 refine_programmatic_dead_code as refine_type_aware_dead_code, shutdown_type_aware_sidecars,
198 status as type_aware_status, symbol_impact as run_type_aware_symbol_impact,
199 symbol_impact as type_aware_symbol_impact, terminate_active_type_aware_sidecars,
200 trace_symbol as run_type_aware_symbol_trace, trace_symbol as trace_type_aware_symbol,
201 type_coupling as analyze_type_coupling,
202};
203
204pub const COMMON_ANALYSIS_OPTION_FLAGS: &[&str] = &[
211 "root",
212 "config",
213 "no-cache",
214 "threads",
215 "changed-since",
216 "diff-file",
217 "production",
218 "workspace",
219 "changed-workspaces",
220 "explain",
221 "allow-remote-extends",
222];
223
224#[derive(Debug, Clone, Serialize)]
226pub struct ProgrammaticError {
227 pub message: String,
229 pub exit_code: u8,
231 pub code: Option<String>,
233 pub help: Option<String>,
235 pub context: Option<String>,
237}
238
239impl ProgrammaticError {
240 #[must_use]
243 pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
244 Self {
245 message: message.into(),
246 exit_code,
247 code: None,
248 help: None,
249 context: None,
250 }
251 }
252
253 #[must_use]
255 pub fn with_help(mut self, help: impl Into<String>) -> Self {
256 self.help = Some(help.into());
257 self
258 }
259
260 #[must_use]
263 pub fn with_code(mut self, code: impl Into<String>) -> Self {
264 self.code = Some(code.into());
265 self
266 }
267
268 #[must_use]
271 pub fn with_context(mut self, context: impl Into<String>) -> Self {
272 self.context = Some(context.into());
273 self
274 }
275}
276
277impl std::fmt::Display for ProgrammaticError {
278 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279 write!(f, "{}", self.message)
280 }
281}
282
283impl std::error::Error for ProgrammaticError {}
284
285#[derive(Debug, Clone, Default)]
287pub struct AnalysisOptions {
288 pub root: Option<PathBuf>,
291 pub config_path: Option<PathBuf>,
293 pub allow_remote_extends: bool,
295 pub no_cache: bool,
297 pub threads: Option<usize>,
299 pub diff_file: Option<PathBuf>,
301 pub production: bool,
304 pub production_override: Option<bool>,
307 pub changed_since: Option<String>,
309 pub workspace: Option<Vec<String>>,
311 pub changed_workspaces: Option<String>,
313 pub explain: bool,
315 pub type_aware: TypeAwareOptions,
318}
319
320#[derive(Debug, Clone, Default, PartialEq, Eq)]
322pub struct TypeAwareOptions {
323 pub enabled: bool,
325 pub projects: Vec<PathBuf>,
328 pub require: fallow_config::TypeAwareRequire,
330}
331
332#[derive(Debug, Clone, Default)]
337pub struct DeadCodeFilters {
338 pub unused_files: bool,
340 pub unused_exports: bool,
342 pub unused_deps: bool,
344 pub unused_types: bool,
346 pub private_type_leaks: bool,
348 pub unused_enum_members: bool,
350 pub unused_class_members: bool,
352 pub unused_store_members: bool,
354 pub unprovided_injects: bool,
356 pub unrendered_components: bool,
358 pub unused_component_props: bool,
360 pub unused_component_emits: bool,
362 pub unused_component_inputs: bool,
364 pub unused_component_outputs: bool,
366 pub unused_svelte_events: bool,
368 pub unused_server_actions: bool,
370 pub unused_load_data_keys: bool,
372 pub unresolved_imports: bool,
374 pub unlisted_deps: bool,
376 pub duplicate_exports: bool,
378 pub circular_deps: bool,
380 pub re_export_cycles: bool,
382 pub boundary_violations: bool,
384 pub policy_violations: bool,
386 pub stale_suppressions: bool,
388 pub unused_catalog_entries: bool,
390 pub empty_catalog_groups: bool,
392 pub unresolved_catalog_references: bool,
394 pub unused_dependency_overrides: bool,
396 pub misconfigured_dependency_overrides: bool,
398}
399
400impl DeadCodeFilters {
401 fn any_active(&self) -> bool {
402 self.unused_files
403 || self.unused_exports
404 || self.unused_deps
405 || self.unused_types
406 || self.private_type_leaks
407 || self.unused_enum_members
408 || self.unused_class_members
409 || self.unused_store_members
410 || self.unprovided_injects
411 || self.unrendered_components
412 || self.unused_component_props
413 || self.unused_component_emits
414 || self.unused_component_inputs
415 || self.unused_component_outputs
416 || self.unused_svelte_events
417 || self.unused_server_actions
418 || self.unused_load_data_keys
419 || self.unresolved_imports
420 || self.unlisted_deps
421 || self.duplicate_exports
422 || self.circular_deps
423 || self.re_export_cycles
424 || self.boundary_violations
425 || self.policy_violations
426 || self.stale_suppressions
427 || self.unused_catalog_entries
428 || self.empty_catalog_groups
429 || self.unresolved_catalog_references
430 || self.unused_dependency_overrides
431 || self.misconfigured_dependency_overrides
432 }
433
434 pub fn enable_registry_selector(&mut self, selector: &str) -> bool {
440 let Some(flag) = fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS
441 .iter()
442 .find_map(|&(name, flag)| (name == selector).then_some(flag))
443 else {
444 return false;
445 };
446 self.enable_cli_filter_flag(flag);
447 true
448 }
449
450 fn enable_cli_filter_flag(&mut self, flag: &str) {
451 match flag {
452 "--unused-files" => self.unused_files = true,
453 "--unused-exports" => self.unused_exports = true,
454 "--unused-types" => self.unused_types = true,
455 "--private-type-leaks" => self.private_type_leaks = true,
456 "--unused-deps" => self.unused_deps = true,
457 "--unused-enum-members" => self.unused_enum_members = true,
458 "--unused-class-members" => self.unused_class_members = true,
459 "--unused-store-members" => self.unused_store_members = true,
460 "--unprovided-injects" => self.unprovided_injects = true,
461 "--unrendered-components" => self.unrendered_components = true,
462 "--unused-component-props" => self.unused_component_props = true,
463 "--unused-component-emits" => self.unused_component_emits = true,
464 "--unused-component-inputs" => self.unused_component_inputs = true,
465 "--unused-component-outputs" => self.unused_component_outputs = true,
466 "--unused-svelte-events" => self.unused_svelte_events = true,
467 "--unused-server-actions" => self.unused_server_actions = true,
468 "--unused-load-data-keys" => self.unused_load_data_keys = true,
469 "--unresolved-imports" => self.unresolved_imports = true,
470 "--unlisted-deps" => self.unlisted_deps = true,
471 "--duplicate-exports" => self.duplicate_exports = true,
472 "--circular-deps" => self.circular_deps = true,
473 "--re-export-cycles" => self.re_export_cycles = true,
474 "--boundary-violations" => self.boundary_violations = true,
475 "--policy-violations" => self.policy_violations = true,
476 "--stale-suppressions" => self.stale_suppressions = true,
477 "--unused-catalog-entries" => self.unused_catalog_entries = true,
478 "--empty-catalog-groups" => self.empty_catalog_groups = true,
479 "--unresolved-catalog-references" => self.unresolved_catalog_references = true,
480 "--unused-dependency-overrides" => self.unused_dependency_overrides = true,
481 "--misconfigured-dependency-overrides" => {
482 self.misconfigured_dependency_overrides = true;
483 }
484 _ => unreachable!("registry emitted unsupported dead-code filter flag: {flag}"),
485 }
486 }
487}
488
489#[derive(Debug, Clone, Default)]
491pub struct DeadCodeOptions {
492 pub analysis: AnalysisOptions,
494 pub filters: DeadCodeFilters,
496 pub files: Vec<PathBuf>,
498 pub include_entry_exports: bool,
500}
501
502#[derive(Debug, Clone, Default)]
504pub struct AuditOptions {
505 pub analysis: AnalysisOptions,
507 pub base: Option<String>,
510 pub production: bool,
512 pub production_dead_code: Option<bool>,
514 pub production_health: Option<bool>,
516 pub production_dupes: Option<bool>,
519 pub css: Option<bool>,
521 pub css_deep: Option<bool>,
523 pub gate: fallow_config::AuditGate,
525 pub max_crap: Option<f64>,
527 pub coverage: Option<PathBuf>,
529 pub coverage_root: Option<PathBuf>,
531 pub include_entry_exports: bool,
533 pub runtime_coverage: Option<PathBuf>,
535 pub min_invocations_hot: u64,
537}
538
539#[derive(Debug, Clone)]
541pub struct CombinedOptions {
542 pub analysis: AnalysisOptions,
544 pub dead_code: bool,
546 pub duplication: bool,
548 pub health: bool,
550 pub include_entry_exports: bool,
552 pub duplication_options: DuplicationOptions,
554 pub health_options: ComplexityOptions,
556}
557
558impl Default for CombinedOptions {
559 fn default() -> Self {
560 Self {
561 analysis: AnalysisOptions::default(),
562 dead_code: true,
563 duplication: true,
564 health: true,
565 include_entry_exports: false,
566 duplication_options: DuplicationOptions::default(),
567 health_options: ComplexityOptions::default(),
568 }
569 }
570}
571
572#[derive(Debug, Clone, Default)]
574pub struct DecisionSurfaceOptions {
575 pub analysis: AnalysisOptions,
577 pub base: Option<String>,
580 pub max_decisions: Option<usize>,
582}
583
584#[derive(Debug, Clone, Default)]
586pub struct FeatureFlagsOptions {
587 pub analysis: AnalysisOptions,
589 pub top: Option<usize>,
591}
592
593#[derive(Debug, Clone, Copy, Default)]
595pub enum DuplicationMode {
596 Strict,
599 #[default]
601 Mild,
602 Weak,
604 Semantic,
607}
608
609#[derive(Debug, Clone, Default)]
611pub struct DuplicationOptions {
612 pub analysis: AnalysisOptions,
614 pub mode: Option<DuplicationMode>,
616 pub near: Option<bool>,
619 pub min_tokens: Option<usize>,
621 pub min_lines: Option<usize>,
623 pub min_occurrences: Option<usize>,
626 pub threshold: Option<f64>,
629 pub skip_local: Option<bool>,
632 pub cross_language: Option<bool>,
634 pub ignore_imports: Option<bool>,
637 pub top: Option<usize>,
639}
640
641#[derive(Debug, Clone, Default)]
643pub struct TraceExportOptions {
644 pub analysis: AnalysisOptions,
646 pub file: String,
648 pub export_name: String,
650}
651
652#[derive(Debug, Clone, Default)]
654pub struct TraceFileOptions {
655 pub analysis: AnalysisOptions,
657 pub file: String,
659}
660
661#[derive(Debug, Clone, Default)]
663pub struct TraceDependencyOptions {
664 pub analysis: AnalysisOptions,
666 pub package_name: String,
668}
669
670#[derive(Debug, Clone, PartialEq, Eq)]
672pub enum TraceCloneTarget {
673 Location {
675 file: String,
677 line: usize,
679 },
680 Fingerprint(String),
682}
683
684#[derive(Debug, Clone)]
686pub struct TraceCloneOptions {
687 pub duplication: DuplicationOptions,
689 pub target: TraceCloneTarget,
691}
692
693#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
695pub enum ComplexitySort {
696 #[default]
698 Cyclomatic,
699 Cognitive,
701 Lines,
703 Severity,
705}
706
707#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
709pub enum OwnershipEmailMode {
710 Raw,
712 #[default]
714 Handle,
715 Anonymized,
717 Hash,
719}
720
721#[derive(Debug, Clone, Copy, PartialEq, Eq)]
723pub enum TargetEffort {
724 Low,
726 Medium,
728 High,
730}
731
732#[derive(Debug, Clone, Default)]
734pub struct ComplexityOptions {
735 pub analysis: AnalysisOptions,
737 pub max_cyclomatic: Option<u16>,
739 pub max_cognitive: Option<u16>,
741 pub max_crap: Option<f64>,
743 pub top: Option<usize>,
745 pub sort: ComplexitySort,
747 pub complexity_breakdown: bool,
749 pub complexity: bool,
751 pub file_scores: bool,
753 pub coverage_gaps: bool,
755 pub hotspots: bool,
757 pub ownership: bool,
759 pub ownership_emails: Option<OwnershipEmailMode>,
761 pub targets: bool,
763 pub css: bool,
765 pub css_deep: bool,
767 pub effort: Option<TargetEffort>,
770 pub score: bool,
772 pub since: Option<String>,
774 pub min_commits: Option<u32>,
776 pub coverage: Option<PathBuf>,
778 pub coverage_root: Option<PathBuf>,
780}
781
782#[derive(Debug, Clone, Copy, Default, PartialEq)]
784pub struct ComplexityThresholdOverrides {
785 pub max_cyclomatic: Option<u16>,
787 pub max_cognitive: Option<u16>,
789 pub max_crap: Option<f64>,
791}
792
793#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
795pub struct ComplexityCoverageInputs<'a> {
796 pub coverage: Option<&'a Path>,
798 pub coverage_root: Option<&'a Path>,
800}
801
802#[derive(Debug, Clone)]
804pub struct HealthSectionOptions {
805 pub output: fallow_types::output_format::OutputFormat,
807 pub complexity: bool,
809 pub file_scores: bool,
811 pub coverage_gaps: bool,
813 pub hotspots: bool,
815 pub targets: bool,
817 pub css: bool,
819 pub score: bool,
821 pub score_gate: bool,
823 pub snapshot_requested: bool,
825 pub trend: bool,
827}
828
829#[derive(Debug, Clone, Copy, PartialEq, Eq)]
831pub struct DerivedHealthSections {
832 pub any_section: bool,
834 pub complexity: bool,
836 pub file_scores: bool,
838 pub coverage_gaps: bool,
840 pub hotspots: bool,
842 pub targets: bool,
844 pub css: bool,
846 pub score: bool,
848 pub force_full: bool,
850 pub score_only_output: bool,
852}
853
854#[derive(Debug, Clone)]
856pub struct ComplexitySectionOptions {
857 pub complexity: bool,
859 pub file_scores: bool,
861 pub coverage_gaps: bool,
863 pub hotspots: bool,
865 pub ownership: bool,
867 pub targets: bool,
869 pub css: bool,
871 pub score: bool,
873}
874
875#[derive(Debug, Clone, Copy, PartialEq, Eq)]
877pub struct DerivedComplexityOptions {
878 pub any_section: bool,
880 pub complexity: bool,
882 pub file_scores: bool,
884 pub coverage_gaps: bool,
886 pub hotspots: bool,
888 pub ownership: bool,
890 pub targets: bool,
892 pub force_full: bool,
894 pub score_only_output: bool,
896 pub score: bool,
898}
899
900#[derive(Debug, Clone, PartialEq)]
902pub struct ComplexityRunOptions<'a> {
903 pub thresholds: ComplexityThresholdOverrides,
905 pub top: Option<usize>,
907 pub sort: ComplexitySort,
909 pub complexity_breakdown: bool,
911 pub sections: DerivedComplexityOptions,
913 pub ownership_emails: Option<OwnershipEmailMode>,
915 pub effort: Option<TargetEffort>,
917 pub css: bool,
919 pub css_deep: bool,
921 pub since: Option<&'a str>,
923 pub min_commits: Option<u32>,
925 pub coverage_inputs: ComplexityCoverageInputs<'a>,
927}
928
929#[must_use]
931pub fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
932 let score = options.score
933 || options.score_gate
934 || options.trend
935 || matches!(
936 options.output,
937 fallow_types::output_format::OutputFormat::Badge
938 );
939 let any_section = options.complexity
940 || options.file_scores
941 || options.coverage_gaps
942 || options.hotspots
943 || options.targets
944 || score;
945 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
946 let force_full = options.snapshot_requested || effective_score;
947
948 DerivedHealthSections {
949 any_section,
950 complexity: if any_section {
951 options.complexity
952 } else {
953 true
954 },
955 file_scores: if any_section {
956 options.file_scores
957 } else {
958 true
959 } || force_full,
960 coverage_gaps: if any_section {
961 options.coverage_gaps
962 } else {
963 false
964 },
965 hotspots: if any_section { options.hotspots } else { true }
966 || options.snapshot_requested
967 || options.trend,
968 targets: if any_section { options.targets } else { true },
969 css: options.css,
970 score: effective_score,
971 force_full,
972 score_only_output: is_health_score_only_output(options, score),
973 }
974}
975
976#[must_use]
978pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
979 let requested_hotspots = options.hotspots || options.ownership;
980 let sections = derive_health_sections(&HealthSectionOptions {
981 output: fallow_types::output_format::OutputFormat::Human,
982 complexity: options.complexity,
983 file_scores: options.file_scores,
984 coverage_gaps: options.coverage_gaps,
985 hotspots: requested_hotspots,
986 targets: options.targets,
987 css: options.css,
988 score: options.score,
989 score_gate: false,
990 snapshot_requested: false,
991 trend: false,
992 });
993
994 DerivedComplexityOptions {
995 any_section: sections.any_section,
996 complexity: sections.complexity,
997 file_scores: sections.file_scores,
998 coverage_gaps: sections.coverage_gaps,
999 hotspots: sections.hotspots,
1000 ownership: options.ownership && sections.hotspots,
1001 targets: sections.targets,
1002 force_full: sections.force_full,
1003 score_only_output: sections.score_only_output,
1004 score: sections.score,
1005 }
1006}
1007
1008#[must_use]
1010pub fn derive_complexity_options(options: &ComplexityOptions) -> DerivedComplexityOptions {
1011 derive_complexity_sections(&complexity_section_options(options))
1012}
1013
1014#[must_use]
1016pub fn derive_complexity_run_options(options: &ComplexityOptions) -> ComplexityRunOptions<'_> {
1017 ComplexityRunOptions {
1018 thresholds: ComplexityThresholdOverrides {
1019 max_cyclomatic: options.max_cyclomatic,
1020 max_cognitive: options.max_cognitive,
1021 max_crap: options.max_crap,
1022 },
1023 top: options.top,
1024 sort: options.sort,
1025 complexity_breakdown: options.complexity_breakdown,
1026 sections: derive_complexity_options(options),
1027 ownership_emails: options.ownership_emails,
1028 effort: options.effort,
1029 css: options.css,
1030 css_deep: options.css_deep,
1031 since: options.since.as_deref(),
1032 min_commits: options.min_commits,
1033 coverage_inputs: ComplexityCoverageInputs {
1034 coverage: options.coverage.as_deref(),
1035 coverage_root: options.coverage_root.as_deref(),
1036 },
1037 }
1038}
1039
1040pub fn validate_complexity_options(options: &ComplexityOptions) -> Result<(), ProgrammaticError> {
1051 if let Some(path) = &options.coverage
1052 && !path.exists()
1053 {
1054 return Err(ProgrammaticError::new(
1055 format!("coverage path does not exist: {}", path.display()),
1056 2,
1057 )
1058 .with_code("FALLOW_INVALID_COVERAGE_PATH")
1059 .with_context("health.coverage"));
1060 }
1061 if let Err(message) =
1062 fallow_engine::health::validate_coverage_root_absolute(options.coverage_root.as_deref())
1063 {
1064 return Err(ProgrammaticError::new(message, 2)
1065 .with_code("FALLOW_INVALID_COVERAGE_ROOT")
1066 .with_context("health.coverage_root"));
1067 }
1068
1069 Ok(())
1070}
1071
1072fn complexity_section_options(options: &ComplexityOptions) -> ComplexitySectionOptions {
1073 let ownership = options.ownership || options.ownership_emails.is_some();
1074 let requested_targets = options.targets || options.effort.is_some();
1075 ComplexitySectionOptions {
1076 complexity: options.complexity,
1077 file_scores: options.file_scores,
1078 coverage_gaps: options.coverage_gaps,
1079 hotspots: options.hotspots,
1080 ownership,
1081 targets: requested_targets,
1082 css: options.css,
1083 score: options.score,
1084 }
1085}
1086
1087fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
1088 score
1089 && !options.complexity
1090 && !options.file_scores
1091 && !options.coverage_gaps
1092 && !options.hotspots
1093 && !options.targets
1094 && !options.trend
1095}
1096
1097const fn thresholds_to_engine(
1098 thresholds: ComplexityThresholdOverrides,
1099) -> fallow_engine::health::HealthThresholdOverrides {
1100 fallow_engine::health::HealthThresholdOverrides {
1101 max_cyclomatic: thresholds.max_cyclomatic,
1102 max_cognitive: thresholds.max_cognitive,
1103 max_crap: thresholds.max_crap,
1104 }
1105}
1106
1107const fn complexity_sort_to_engine(sort: ComplexitySort) -> fallow_engine::health::HealthSort {
1108 match sort {
1109 ComplexitySort::Severity => fallow_engine::health::HealthSort::Severity,
1110 ComplexitySort::Cyclomatic => fallow_engine::health::HealthSort::Cyclomatic,
1111 ComplexitySort::Cognitive => fallow_engine::health::HealthSort::Cognitive,
1112 ComplexitySort::Lines => fallow_engine::health::HealthSort::Lines,
1113 }
1114}
1115
1116const fn coverage_inputs_to_engine(
1117 coverage_inputs: ComplexityCoverageInputs<'_>,
1118) -> fallow_engine::health::HealthCoverageInputs<'_> {
1119 fallow_engine::health::HealthCoverageInputs {
1120 coverage: coverage_inputs.coverage,
1121 coverage_root: coverage_inputs.coverage_root,
1122 }
1123}
1124
1125const fn ownership_email_mode_to_config(mode: OwnershipEmailMode) -> EmailMode {
1126 match mode {
1127 OwnershipEmailMode::Raw => EmailMode::Raw,
1128 OwnershipEmailMode::Handle => EmailMode::Handle,
1129 OwnershipEmailMode::Anonymized => EmailMode::Anonymized,
1130 OwnershipEmailMode::Hash => EmailMode::Hash,
1131 }
1132}
1133
1134const fn target_effort_to_output(effort: TargetEffort) -> EffortEstimate {
1135 match effort {
1136 TargetEffort::Low => EffortEstimate::Low,
1137 TargetEffort::Medium => EffortEstimate::Medium,
1138 TargetEffort::High => EffortEstimate::High,
1139 }
1140}
1141
1142#[cfg(test)]
1143mod tests {
1144 use super::*;
1145
1146 #[test]
1147 fn duplication_defaults_match_cli_contract() {
1148 let options = DuplicationOptions::default();
1149 assert!(options.mode.is_none());
1150 assert!(options.min_tokens.is_none());
1151 assert!(options.min_lines.is_none());
1152 assert!(options.min_occurrences.is_none());
1153 }
1154
1155 #[test]
1156 fn programmatic_error_builder_keeps_optional_fields() {
1157 let error = ProgrammaticError::new("boom", 2)
1158 .with_code("FALLOW_TEST")
1159 .with_help("Try again")
1160 .with_context("analysis.root");
1161
1162 assert_eq!(error.message, "boom");
1163 assert_eq!(error.exit_code, 2);
1164 assert_eq!(error.code.as_deref(), Some("FALLOW_TEST"));
1165 assert_eq!(error.help.as_deref(), Some("Try again"));
1166 assert_eq!(error.context.as_deref(), Some("analysis.root"));
1167 }
1168
1169 #[test]
1170 fn dead_code_filters_accept_shared_registry_selectors() {
1171 for (selector, _) in fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS.iter() {
1172 let mut filters = DeadCodeFilters::default();
1173 assert!(
1174 filters.enable_registry_selector(selector),
1175 "{selector} should be accepted"
1176 );
1177 }
1178
1179 let mut filters = DeadCodeFilters::default();
1180 assert!(filters.enable_registry_selector("unused-files"));
1181 assert!(filters.unused_files);
1182 assert!(filters.enable_registry_selector("boundary-violations"));
1183 assert!(filters.boundary_violations);
1184 assert!(!filters.enable_registry_selector("not-a-real-selector"));
1185 }
1186
1187 #[test]
1188 fn default_complexity_options_match_programmatic_health_defaults() {
1189 let derived = derive_complexity_options(&ComplexityOptions::default());
1190
1191 assert!(!derived.any_section);
1192 assert!(derived.complexity);
1193 assert!(derived.file_scores);
1194 assert!(!derived.coverage_gaps);
1195 assert!(derived.hotspots);
1196 assert!(!derived.ownership);
1197 assert!(derived.targets);
1198 assert!(derived.force_full);
1199 assert!(!derived.score_only_output);
1200 assert!(derived.score);
1201 }
1202
1203 #[test]
1204 fn score_only_complexity_options_request_score_only_output() {
1205 let derived = derive_complexity_options(&ComplexityOptions {
1206 score: true,
1207 ..ComplexityOptions::default()
1208 });
1209
1210 assert!(derived.any_section);
1211 assert!(!derived.complexity);
1212 assert!(derived.file_scores);
1213 assert!(!derived.hotspots);
1214 assert!(!derived.targets);
1215 assert!(derived.force_full);
1216 assert!(derived.score_only_output);
1217 assert!(derived.score);
1218 }
1219
1220 #[test]
1221 fn ownership_implies_hotspots_when_requested() {
1222 let derived = derive_complexity_options(&ComplexityOptions {
1223 ownership: true,
1224 ..ComplexityOptions::default()
1225 });
1226
1227 assert!(derived.any_section);
1228 assert!(derived.hotspots);
1229 assert!(derived.ownership);
1230 assert!(!derived.targets);
1231 }
1232
1233 #[test]
1234 fn complexity_run_options_normalize_public_api_options() {
1235 let options = ComplexityOptions {
1236 max_cyclomatic: Some(42),
1237 max_cognitive: Some(21),
1238 max_crap: Some(18.5),
1239 top: Some(7),
1240 sort: ComplexitySort::Severity,
1241 complexity_breakdown: true,
1242 ownership_emails: Some(OwnershipEmailMode::Hash),
1243 effort: Some(TargetEffort::High),
1244 coverage: Some(PathBuf::from("coverage/coverage-final.json")),
1245 coverage_root: Some(PathBuf::from("/ci/workspace")),
1246 since: Some("30d".to_string()),
1247 min_commits: Some(4),
1248 ..ComplexityOptions::default()
1249 };
1250
1251 let run = derive_complexity_run_options(&options);
1252
1253 assert_eq!(run.thresholds.max_cyclomatic, Some(42));
1254 assert_eq!(run.thresholds.max_cognitive, Some(21));
1255 assert_eq!(run.thresholds.max_crap, Some(18.5));
1256 assert_eq!(run.top, Some(7));
1257 assert!(matches!(run.sort, ComplexitySort::Severity));
1258 assert!(run.complexity_breakdown);
1259 assert!(run.sections.hotspots);
1260 assert!(run.sections.ownership);
1261 assert!(run.sections.targets);
1262 assert!(matches!(
1263 run.ownership_emails,
1264 Some(OwnershipEmailMode::Hash)
1265 ));
1266 assert!(matches!(run.effort, Some(TargetEffort::High)));
1267 assert_eq!(run.since, Some("30d"));
1268 assert_eq!(run.min_commits, Some(4));
1269 assert_eq!(run.coverage_inputs.coverage, options.coverage.as_deref());
1270 assert_eq!(
1271 run.coverage_inputs.coverage_root,
1272 options.coverage_root.as_deref()
1273 );
1274 }
1275
1276 #[test]
1277 fn complexity_options_validation_accepts_existing_coverage_path_and_absolute_root() {
1278 let dir = tempfile::tempdir().expect("tempdir");
1279 let coverage = dir.path().join("coverage-final.json");
1280 std::fs::write(&coverage, "{}").expect("coverage fixture");
1281
1282 let result = validate_complexity_options(&ComplexityOptions {
1283 coverage: Some(coverage),
1284 coverage_root: Some(PathBuf::from("/ci/workspace")),
1285 ..ComplexityOptions::default()
1286 });
1287
1288 assert!(result.is_ok());
1289 }
1290
1291 #[test]
1292 fn complexity_options_validation_keeps_missing_coverage_error_contract() {
1293 let err = validate_complexity_options(&ComplexityOptions {
1294 coverage: Some(PathBuf::from("/missing/coverage-final.json")),
1295 ..ComplexityOptions::default()
1296 })
1297 .expect_err("missing coverage path should fail");
1298
1299 assert_eq!(err.exit_code, 2);
1300 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1301 assert_eq!(err.context.as_deref(), Some("health.coverage"));
1302 }
1303
1304 #[test]
1305 fn complexity_options_validation_keeps_relative_coverage_root_error_contract() {
1306 let err = validate_complexity_options(&ComplexityOptions {
1307 coverage_root: Some(PathBuf::from("coverage")),
1308 ..ComplexityOptions::default()
1309 })
1310 .expect_err("relative coverage root should fail");
1311
1312 assert_eq!(err.exit_code, 2);
1313 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_ROOT"));
1314 assert_eq!(err.context.as_deref(), Some("health.coverage_root"));
1315 }
1316
1317 #[test]
1318 fn default_health_sections_match_full_health_output() {
1319 let derived = derive_health_sections(&HealthSectionOptions {
1320 output: fallow_types::output_format::OutputFormat::Human,
1321 complexity: false,
1322 file_scores: false,
1323 coverage_gaps: false,
1324 hotspots: false,
1325 targets: false,
1326 css: false,
1327 score: false,
1328 score_gate: false,
1329 snapshot_requested: false,
1330 trend: false,
1331 });
1332
1333 assert!(!derived.any_section);
1334 assert!(derived.complexity);
1335 assert!(derived.file_scores);
1336 assert!(!derived.coverage_gaps);
1337 assert!(derived.hotspots);
1338 assert!(derived.targets);
1339 assert!(derived.score);
1340 assert!(derived.force_full);
1341 assert!(!derived.score_only_output);
1342 }
1343
1344 #[test]
1345 fn health_score_gate_requests_score_only_output() {
1346 let derived = derive_health_sections(&HealthSectionOptions {
1347 output: fallow_types::output_format::OutputFormat::Human,
1348 complexity: false,
1349 file_scores: false,
1350 coverage_gaps: false,
1351 hotspots: false,
1352 targets: false,
1353 css: false,
1354 score: false,
1355 score_gate: true,
1356 snapshot_requested: false,
1357 trend: false,
1358 });
1359
1360 assert!(derived.any_section);
1361 assert!(!derived.complexity);
1362 assert!(derived.file_scores);
1363 assert!(!derived.hotspots);
1364 assert!(!derived.targets);
1365 assert!(derived.score);
1366 assert!(derived.force_full);
1367 assert!(derived.score_only_output);
1368 }
1369
1370 #[test]
1371 fn health_snapshot_keeps_full_hidden_inputs_without_section_request() {
1372 let derived = derive_health_sections(&HealthSectionOptions {
1373 output: fallow_types::output_format::OutputFormat::Human,
1374 complexity: false,
1375 file_scores: false,
1376 coverage_gaps: false,
1377 hotspots: false,
1378 targets: false,
1379 css: true,
1380 score: false,
1381 score_gate: false,
1382 snapshot_requested: true,
1383 trend: false,
1384 });
1385
1386 assert!(!derived.any_section);
1387 assert!(derived.css);
1388 assert!(derived.file_scores);
1389 assert!(derived.hotspots);
1390 assert!(derived.score);
1391 assert!(derived.force_full);
1392 }
1393}