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};
19use std::sync::Arc;
20use std::sync::atomic::AtomicBool;
21
22use fallow_config::EmailMode;
23use fallow_output::EffortEstimate;
24use serde::Serialize;
25
26mod analysis_context;
27pub mod audit_keys;
31pub mod audit_output;
32pub mod combined_output;
33pub mod compact_output;
36pub mod coverage;
37pub mod dead_code_codeclimate;
38pub mod dead_code_sarif;
39pub mod decision_surface;
40pub mod dependency_deltas;
41pub mod doctor;
42pub mod dupes_output;
43mod duplication_filters;
44pub mod editor;
45pub mod explain;
46pub mod grouped_output;
47pub mod health_codeclimate;
48pub mod json_output;
49pub mod list_output;
50mod list_runtime;
51pub mod markdown_output;
54mod next_steps;
55pub mod output_contracts;
56pub mod review_deltas;
57pub mod routing;
58pub mod runtime;
59mod runtime_json;
60mod runtime_output;
61pub mod sarif_output;
62pub mod schemas;
65pub mod security_output;
66pub mod similar_code;
68mod type_aware;
69pub mod ci_output {
70 pub use fallow_output::{
74 CiIssue, CiProvider, GroupedReviewIssues, MARKER_PREFIX_V2, MARKER_SUFFIX_V2,
75 MAX_COMMENT_BODY_BYTES, PROJECT_LEVEL_RULE_IDS, PrCommentRenderInput,
76 ReviewCommentRenderInput, ReviewEnvelopeRenderInput, ReviewEnvelopeRenderResult,
77 ReviewEnvelopeTruncation, ReviewGitlabDiffRefs, cap_body_with_marker, command_title,
78 composite_fingerprint, escape_md, github_check_conclusion,
79 group_review_issues_by_path_line, is_project_level_rule, issues_from_codeclimate,
80 issues_from_codeclimate_issues, render_pr_comment, render_review_comment_for_group,
81 render_review_envelope, review_label_from_codeclimate, summary_fingerprint, summary_label,
82 };
83}
84pub use analysis_context::{ProgrammaticAnalysisContext, resolve_programmatic_analysis_context};
85pub use audit_output::{
86 AuditAttribution, AuditCodeClimateOutputInput, AuditJsonHeaderInput, AuditJsonOutputInput,
87 AuditSarifOutputInput, AuditSummary, AuditVerdict,
88 attach_audit_duplication_demotion_attribution, attach_audit_styling_attribution,
89 attach_audit_wire_attribution, build_audit_codeclimate, build_audit_codeclimate_issues,
90 build_audit_header_json, build_audit_header_map, build_audit_sarif, build_review_brief_header,
91 serialize_audit_json,
92};
93pub use ci_output::{
94 CiIssue, CiProvider, GroupedReviewIssues, MARKER_PREFIX_V2, MARKER_SUFFIX_V2,
95 MAX_COMMENT_BODY_BYTES, PROJECT_LEVEL_RULE_IDS, PrCommentRenderInput, ReviewCommentRenderInput,
96 ReviewEnvelopeRenderInput, ReviewEnvelopeRenderResult, ReviewEnvelopeTruncation,
97 ReviewGitlabDiffRefs, cap_body_with_marker, command_title, composite_fingerprint, escape_md,
98 github_check_conclusion, group_review_issues_by_path_line, is_project_level_rule,
99 issues_from_codeclimate, issues_from_codeclimate_issues, render_pr_comment,
100 render_review_comment_for_group, render_review_envelope, review_label_from_codeclimate,
101 summary_fingerprint, summary_label,
102};
103pub use combined_output::{
104 CombinedCheckJsonSection, CombinedJsonOutputInput, serialize_combined_dupes_json,
105 serialize_combined_health_json, serialize_combined_json,
106};
107pub use compact_output::{
108 build_compact_lines, build_duplication_compact_lines, build_grouped_compact_lines,
109 build_health_compact_lines,
110};
111pub use coverage::{
112 CoverageInputError, CoverageInputSource, CoverageInputs, resolve_coverage_inputs,
113};
114pub use dead_code_codeclimate::build_codeclimate;
115pub use dead_code_sarif::build_sarif;
116pub use doctor::{DoctorOptions, run_doctor};
117pub use dupes_output::{
118 AttributedCloneGroup, AttributedCloneGroupFinding, AttributedInstance, CloneDemotionReason,
119 CloneFamilyFinding, CloneGroupFinding, DupesReportPayload, DuplicationGroup,
120 DuplicationGrouping, build_duplication_codeclimate,
121};
122pub use editor::{
123 ChangedFilesError, EditorAnalysisOutput, EditorAnalysisResults, EditorAnalysisSession,
124 EditorCloneFamily, EditorCloneFingerprintSet, EditorCloneGroup, EditorCloneInstance,
125 EditorDeadCodeAnalysisOutput, EditorDuplicationReport, EditorDuplicationStats,
126 EditorInlineComplexityExceeded, EditorInlineComplexityFinding, EditorMirroredDirectory,
127 EditorProjectAnalysisOutput, EditorRefactoringKind, EditorRefactoringSuggestion,
128 collect_inline_complexity, editor_duplicates, editor_extract, editor_results, editor_security,
129 editor_suppress, filter_inline_complexity_by_changed_files, resolve_git_toplevel,
130 try_get_changed_files_with_toplevel,
131};
132pub use explain::{
133 CHECK_RULES, DUPES_RULES, FLAGS_RULES, HEALTH_RULES, RuleDef, RuleGuide, SECURITY_RULES,
134 all_rules, bare_rule_id, coverage_analyze_meta, coverage_setup_meta, explain_issue_type,
135 rule_by_id, rule_by_token, rule_command, rule_docs_url, rule_guide, rule_severity_key,
136 security_meta, serialize_explain_programmatic_json, unknown_explain_error,
137};
138pub use fallow_config::{AuditGate, HealthConfig, TypeAwareRequire};
139pub use fallow_output::{RootEnvelopeMode, serialize_similar_code_json_output};
140pub use fallow_types::trace::{
141 CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace, ReExportChain,
142 TracedCloneGroup, TracedExport, TracedReExport,
143};
144pub use grouped_output::{
145 ResultGroup, UNOWNED_GROUP_LABEL, build_duplication_grouping_with, group_analysis_results_with,
146 largest_clone_group_owner_with,
147};
148pub use health_codeclimate::build_health_codeclimate;
149pub use json_output::{
150 CheckJsonExtraOutputs, CheckJsonOutputInput, CheckJsonPayloadInput, DuplicationJsonOutputInput,
151 GroupedCheckJsonOutputInput, GroupedDuplicationJsonOutputInput, serialize_check_json,
152 serialize_check_json_payload, serialize_duplication_json, serialize_grouped_check_json,
153 serialize_grouped_duplication_json,
154};
155pub use list_output::{
156 ListJsonEnvelope, ListJsonOutputInput, build_list_json_output, serialize_list_json_output,
157};
158pub use list_runtime::{
159 BoundaryData, ListBoundariesOptions, ListBoundariesProgrammaticOutput, LogicalGroupInfo,
160 ProjectInfoOptions, ProjectInfoProgrammaticOutput, RuleInfo, ZoneInfo, boundary_data_to_output,
161 compute_boundary_data, run_list_boundaries, run_project_info,
162 serialize_list_boundaries_programmatic_json, serialize_project_info_programmatic_json,
163};
164pub use markdown_output::{
165 build_duplication_markdown, build_grouped_markdown, build_health_markdown, build_markdown,
166 build_walkthrough_markdown,
167};
168pub use output_contracts::{
169 AuditOutput, BoundariesListLogicalGroup, BoundariesListRule, BoundariesListZone,
170 BoundariesListing, CombinedOutput, FallowOutput, ImpactOutput, ListBoundariesOutput,
171 ListEntryPointOutput, ListOutput, ListPluginOutput, ReviewBriefWireOutput, SecurityGate,
172 SecurityOutput, SecurityOutputConfig, SecuritySummaryOutput, SimilarCodeCandidateSnapshot,
173 SimilarCodeOutput, TraceOutput, WorkspacesOutput,
174};
175pub use runtime::{
176 AuditProgrammaticKeySnapshot, AuditProgrammaticOutput, BoundaryViolationsOutput,
177 BoundaryViolationsProgrammaticOutput, CircularDependenciesOutput,
178 CircularDependenciesProgrammaticOutput, CombinedProgrammaticOutput, DeadCodeOutput,
179 DeadCodeProgrammaticOutput, DecisionSurfaceProgrammaticOutput, DuplicationOutput,
180 DuplicationProgrammaticOutput, EngineHealthRunner, FeatureFlagsOutput,
181 FeatureFlagsProgrammaticOutput, HealthJsonReportInput, HealthProgrammaticOutput,
182 ProgrammaticHealthAnalysis, ProgrammaticHealthNextStepFacts, ProgrammaticHealthRun,
183 ProgrammaticHealthRunner, TraceClassMemberOutput, TraceCloneBenchmarkResult, TraceCloneOutput,
184 TraceCloneProgrammaticOutput, TraceDependencyOutput, TraceDependencyProgrammaticOutput,
185 TraceExportOutput, TraceExportProgrammaticOutput, TraceExportTargetOutput, TraceFileOutput,
186 TraceFileProgrammaticOutput, benchmark_trace_clone_compact_json,
187 benchmark_trace_graph_family_compact_json, inspect_similar_code, load_health_config,
188 parse_similar_code_candidate_snapshot, review_similar_code, run_audit, run_boundary_violations,
189 run_circular_dependencies, run_combined, run_complexity_with_runner, run_dead_code,
190 run_decision_surface, run_duplication, run_feature_flags, run_health, run_health_with_runner,
191 run_similar_code, run_trace_clone, run_trace_dependency, run_trace_export, run_trace_file,
192 select_similar_code_candidate_snapshot, serialize_health_report_json,
193};
194pub use runtime_json::{
195 serialize_audit_programmatic_json, serialize_boundary_violations_programmatic_json,
196 serialize_circular_dependencies_programmatic_json, serialize_combined_programmatic_json,
197 serialize_dead_code_programmatic_json, serialize_decision_surface_programmatic_json,
198 serialize_duplication_programmatic_json, serialize_feature_flags_programmatic_json,
199 serialize_health_programmatic_json, serialize_trace_clone_programmatic_json,
200 serialize_trace_dependency_programmatic_json, serialize_trace_export_programmatic_json,
201 serialize_trace_file_programmatic_json,
202};
203pub use sarif_output::{
204 annotate_sarif_results, build_duplication_sarif, build_grouped_duplication_sarif,
205 build_health_sarif,
206};
207pub use security_output::SecurityGateMode;
208pub use type_aware::{
209 SemanticCouplingOutcome, SemanticDeadCodeOutcome, SemanticInspectOutcome, TypeAwareError,
210 TypeAwareFileChanges, TypeAwareOutcome, TypeAwareSession, TypeAwareStatus,
211 discard_unverified_semantic_candidates, inspect_symbol as inspect_type_aware_symbol,
212 merge_type_aware_meta,
213 refine_configured_dead_code_results as refine_type_aware_results_with_config,
214 refine_configured_dead_code_results_in_session as refine_type_aware_results_in_session_with_config,
215 refine_dead_code_results as refine_type_aware_results,
216 refine_dead_code_results_in_session as refine_type_aware_results_in_session,
217 refine_programmatic_dead_code as refine_type_aware_dead_code, shutdown_type_aware_sidecars,
218 status as type_aware_status, symbol_impact as run_type_aware_symbol_impact,
219 symbol_impact as type_aware_symbol_impact, terminate_active_type_aware_sidecars,
220 trace_symbol as run_type_aware_symbol_trace, trace_symbol as trace_type_aware_symbol,
221 type_coupling as analyze_type_coupling,
222};
223
224pub const COMMON_ANALYSIS_OPTION_FLAGS: &[&str] = &[
231 "root",
232 "config",
233 "no-cache",
234 "threads",
235 "changed-since",
236 "diff-file",
237 "production",
238 "workspace",
239 "changed-workspaces",
240 "explain",
241 "allow-remote-extends",
242];
243
244#[derive(Debug, Clone, Serialize)]
246pub struct ProgrammaticError {
247 pub message: String,
249 pub exit_code: u8,
251 pub code: Option<String>,
253 pub help: Option<String>,
255 pub context: Option<String>,
257}
258
259impl ProgrammaticError {
260 #[must_use]
263 pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
264 Self {
265 message: message.into(),
266 exit_code,
267 code: None,
268 help: None,
269 context: None,
270 }
271 }
272
273 #[must_use]
275 pub fn with_help(mut self, help: impl Into<String>) -> Self {
276 self.help = Some(help.into());
277 self
278 }
279
280 #[must_use]
283 pub fn with_code(mut self, code: impl Into<String>) -> Self {
284 self.code = Some(code.into());
285 self
286 }
287
288 #[must_use]
291 pub fn with_context(mut self, context: impl Into<String>) -> Self {
292 self.context = Some(context.into());
293 self
294 }
295}
296
297impl std::fmt::Display for ProgrammaticError {
298 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299 write!(f, "{}", self.message)
300 }
301}
302
303impl std::error::Error for ProgrammaticError {}
304
305#[derive(Debug, Clone, Default)]
307pub struct AnalysisOptions {
308 pub root: Option<PathBuf>,
311 pub config_path: Option<PathBuf>,
313 pub allow_remote_extends: bool,
315 pub no_cache: bool,
317 pub threads: Option<usize>,
319 pub diff_file: Option<PathBuf>,
321 pub production: bool,
324 pub production_override: Option<bool>,
327 pub changed_since: Option<String>,
329 pub workspace: Option<Vec<String>>,
331 pub changed_workspaces: Option<String>,
333 pub explain: bool,
335 pub type_aware: TypeAwareOptions,
338 pub cancellation: Option<Arc<AtomicBool>>,
367}
368
369#[derive(Debug, Clone, Default, PartialEq, Eq)]
371pub struct TypeAwareOptions {
372 pub enabled: bool,
374 pub projects: Vec<PathBuf>,
377 pub require: fallow_config::TypeAwareRequire,
379}
380
381#[derive(Debug, Clone, Default)]
386pub struct DeadCodeFilters {
387 pub unused_files: bool,
389 pub unused_exports: bool,
391 pub unused_deps: bool,
393 pub unused_types: bool,
395 pub private_type_leaks: bool,
397 pub unused_enum_members: bool,
399 pub unused_class_members: bool,
401 pub unused_store_members: bool,
403 pub unprovided_injects: bool,
405 pub unrendered_components: bool,
407 pub unused_component_props: bool,
409 pub unused_component_emits: bool,
411 pub unused_component_inputs: bool,
413 pub unused_component_outputs: bool,
415 pub unused_svelte_events: bool,
417 pub unused_server_actions: bool,
419 pub unused_load_data_keys: bool,
421 pub unresolved_imports: bool,
423 pub unlisted_deps: bool,
425 pub duplicate_exports: bool,
427 pub circular_deps: bool,
429 pub re_export_cycles: bool,
431 pub boundary_violations: bool,
433 pub policy_violations: bool,
435 pub stale_suppressions: bool,
437 pub unused_catalog_entries: bool,
439 pub empty_catalog_groups: bool,
441 pub unresolved_catalog_references: bool,
443 pub unused_dependency_overrides: bool,
445 pub misconfigured_dependency_overrides: bool,
447}
448
449impl DeadCodeFilters {
450 fn any_active(&self) -> bool {
451 self.unused_files
452 || self.unused_exports
453 || self.unused_deps
454 || self.unused_types
455 || self.private_type_leaks
456 || self.unused_enum_members
457 || self.unused_class_members
458 || self.unused_store_members
459 || self.unprovided_injects
460 || self.unrendered_components
461 || self.unused_component_props
462 || self.unused_component_emits
463 || self.unused_component_inputs
464 || self.unused_component_outputs
465 || self.unused_svelte_events
466 || self.unused_server_actions
467 || self.unused_load_data_keys
468 || self.unresolved_imports
469 || self.unlisted_deps
470 || self.duplicate_exports
471 || self.circular_deps
472 || self.re_export_cycles
473 || self.boundary_violations
474 || self.policy_violations
475 || self.stale_suppressions
476 || self.unused_catalog_entries
477 || self.empty_catalog_groups
478 || self.unresolved_catalog_references
479 || self.unused_dependency_overrides
480 || self.misconfigured_dependency_overrides
481 }
482
483 pub fn enable_registry_selector(&mut self, selector: &str) -> bool {
489 let Some(flag) = fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS
490 .iter()
491 .find_map(|&(name, flag)| (name == selector).then_some(flag))
492 else {
493 return false;
494 };
495 self.enable_cli_filter_flag(flag);
496 true
497 }
498
499 fn enable_cli_filter_flag(&mut self, flag: &str) {
500 match flag {
501 "--unused-files" => self.unused_files = true,
502 "--unused-exports" => self.unused_exports = true,
503 "--unused-types" => self.unused_types = true,
504 "--private-type-leaks" => self.private_type_leaks = true,
505 "--unused-deps" => self.unused_deps = true,
506 "--unused-enum-members" => self.unused_enum_members = true,
507 "--unused-class-members" => self.unused_class_members = true,
508 "--unused-store-members" => self.unused_store_members = true,
509 "--unprovided-injects" => self.unprovided_injects = true,
510 "--unrendered-components" => self.unrendered_components = true,
511 "--unused-component-props" => self.unused_component_props = true,
512 "--unused-component-emits" => self.unused_component_emits = true,
513 "--unused-component-inputs" => self.unused_component_inputs = true,
514 "--unused-component-outputs" => self.unused_component_outputs = true,
515 "--unused-svelte-events" => self.unused_svelte_events = true,
516 "--unused-server-actions" => self.unused_server_actions = true,
517 "--unused-load-data-keys" => self.unused_load_data_keys = true,
518 "--unresolved-imports" => self.unresolved_imports = true,
519 "--unlisted-deps" => self.unlisted_deps = true,
520 "--duplicate-exports" => self.duplicate_exports = true,
521 "--circular-deps" => self.circular_deps = true,
522 "--re-export-cycles" => self.re_export_cycles = true,
523 "--boundary-violations" => self.boundary_violations = true,
524 "--policy-violations" => self.policy_violations = true,
525 "--stale-suppressions" => self.stale_suppressions = true,
526 "--unused-catalog-entries" => self.unused_catalog_entries = true,
527 "--empty-catalog-groups" => self.empty_catalog_groups = true,
528 "--unresolved-catalog-references" => self.unresolved_catalog_references = true,
529 "--unused-dependency-overrides" => self.unused_dependency_overrides = true,
530 "--misconfigured-dependency-overrides" => {
531 self.misconfigured_dependency_overrides = true;
532 }
533 _ => unreachable!("registry emitted unsupported dead-code filter flag: {flag}"),
534 }
535 }
536}
537
538#[derive(Debug, Clone, Default)]
540pub struct DeadCodeOptions {
541 pub analysis: AnalysisOptions,
543 pub filters: DeadCodeFilters,
545 pub files: Vec<PathBuf>,
547 pub include_entry_exports: bool,
549}
550
551#[derive(Debug, Clone, Default)]
553pub struct AuditOptions {
554 pub analysis: AnalysisOptions,
556 pub base: Option<String>,
559 pub production: bool,
561 pub production_dead_code: Option<bool>,
563 pub production_health: Option<bool>,
565 pub production_dupes: Option<bool>,
568 pub css: Option<bool>,
570 pub css_deep: Option<bool>,
572 pub gate: fallow_config::AuditGate,
574 pub max_crap: Option<f64>,
576 pub coverage: Option<PathBuf>,
578 pub coverage_root: Option<PathBuf>,
580 pub include_entry_exports: bool,
582 pub runtime_coverage: Option<PathBuf>,
584 pub min_invocations_hot: u64,
586}
587
588#[derive(Debug, Clone)]
590pub struct CombinedOptions {
591 pub analysis: AnalysisOptions,
593 pub dead_code: bool,
595 pub duplication: bool,
597 pub health: bool,
599 pub include_entry_exports: bool,
601 pub duplication_options: DuplicationOptions,
603 pub health_options: ComplexityOptions,
605}
606
607impl Default for CombinedOptions {
608 fn default() -> Self {
609 Self {
610 analysis: AnalysisOptions::default(),
611 dead_code: true,
612 duplication: true,
613 health: true,
614 include_entry_exports: false,
615 duplication_options: DuplicationOptions::default(),
616 health_options: ComplexityOptions::default(),
617 }
618 }
619}
620
621#[derive(Debug, Clone, Default)]
623pub struct DecisionSurfaceOptions {
624 pub analysis: AnalysisOptions,
626 pub base: Option<String>,
629 pub max_decisions: Option<usize>,
631}
632
633#[derive(Debug, Clone, Default)]
635pub struct FeatureFlagsOptions {
636 pub analysis: AnalysisOptions,
638 pub top: Option<usize>,
640}
641
642#[derive(Debug, Clone, Copy, Default)]
644pub enum DuplicationMode {
645 Strict,
648 #[default]
650 Mild,
651 Weak,
653 Semantic,
656}
657
658#[derive(Debug, Clone, Default)]
660pub struct DuplicationOptions {
661 pub analysis: AnalysisOptions,
663 pub mode: Option<DuplicationMode>,
665 pub near: Option<bool>,
668 pub min_tokens: Option<usize>,
670 pub min_lines: Option<usize>,
672 pub min_occurrences: Option<usize>,
675 pub threshold: Option<f64>,
678 pub skip_local: Option<bool>,
681 pub cross_language: Option<bool>,
683 pub ignore_imports: Option<bool>,
686 pub top: Option<usize>,
688}
689
690#[derive(Debug, Clone, Default)]
692pub struct SimilarCodeOptions {
693 pub analysis: AnalysisOptions,
695 pub threshold: Option<f64>,
697 pub min_lines: Option<usize>,
700 pub top: Option<usize>,
702 pub files: Vec<PathBuf>,
706 #[doc(hidden)]
711 pub adapter_provider_path: Option<PathBuf>,
712}
713
714#[derive(Debug, Clone)]
716pub struct SimilarCodeInspectOptions {
717 pub analysis: AnalysisOptions,
720 pub snapshot: fallow_output::SimilarCodeCandidateSnapshot,
722}
723
724#[derive(Debug, Clone, Default)]
726pub struct TraceExportOptions {
727 pub analysis: AnalysisOptions,
729 pub file: String,
731 pub export_name: String,
733}
734
735#[derive(Debug, Clone, Default)]
737pub struct TraceFileOptions {
738 pub analysis: AnalysisOptions,
740 pub file: String,
742}
743
744#[derive(Debug, Clone, Default)]
746pub struct TraceDependencyOptions {
747 pub analysis: AnalysisOptions,
749 pub package_name: String,
751}
752
753#[derive(Debug, Clone, PartialEq, Eq)]
755pub enum TraceCloneTarget {
756 Location {
758 file: String,
760 line: usize,
762 },
763 Fingerprint(String),
765}
766
767#[derive(Debug, Clone)]
769pub struct TraceCloneOptions {
770 pub duplication: DuplicationOptions,
772 pub target: TraceCloneTarget,
774}
775
776#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
778pub enum ComplexitySort {
779 #[default]
781 Cyclomatic,
782 Cognitive,
784 Lines,
786 Severity,
788}
789
790#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
792pub enum OwnershipEmailMode {
793 Raw,
795 #[default]
797 Handle,
798 Anonymized,
800 Hash,
802}
803
804#[derive(Debug, Clone, Copy, PartialEq, Eq)]
806pub enum TargetEffort {
807 Low,
809 Medium,
811 High,
813}
814
815#[derive(Debug, Clone, Default)]
817pub struct ComplexityOptions {
818 pub analysis: AnalysisOptions,
820 pub max_cyclomatic: Option<u16>,
822 pub max_cognitive: Option<u16>,
824 pub max_crap: Option<f64>,
826 pub top: Option<usize>,
828 pub sort: ComplexitySort,
830 pub complexity_breakdown: bool,
832 pub complexity: bool,
834 pub file_scores: bool,
836 pub coverage_gaps: bool,
838 pub hotspots: bool,
840 pub ownership: bool,
842 pub ownership_emails: Option<OwnershipEmailMode>,
844 pub targets: bool,
846 pub css: bool,
848 pub css_deep: bool,
850 pub effort: Option<TargetEffort>,
853 pub score: bool,
855 pub since: Option<String>,
857 pub min_commits: Option<u32>,
859 pub coverage: Option<PathBuf>,
861 pub coverage_root: Option<PathBuf>,
863 pub coverage_relocated: bool,
868}
869
870#[derive(Debug, Clone, Copy, Default, PartialEq)]
872pub struct ComplexityThresholdOverrides {
873 pub max_cyclomatic: Option<u16>,
875 pub max_cognitive: Option<u16>,
877 pub max_crap: Option<f64>,
879}
880
881#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
883pub struct ComplexityCoverageInputs<'a> {
884 pub coverage: Option<&'a Path>,
886 pub coverage_root: Option<&'a Path>,
888 pub coverage_relocated: bool,
891}
892
893#[derive(Debug, Clone)]
895pub struct HealthSectionOptions {
896 pub output: fallow_types::output_format::OutputFormat,
898 pub complexity: bool,
900 pub file_scores: bool,
902 pub coverage_gaps: bool,
904 pub hotspots: bool,
906 pub targets: bool,
908 pub css: bool,
910 pub score: bool,
912 pub score_gate: bool,
914 pub snapshot_requested: bool,
916 pub trend: bool,
918}
919
920#[derive(Debug, Clone, Copy, PartialEq, Eq)]
922pub struct DerivedHealthSections {
923 pub any_section: bool,
925 pub complexity: bool,
927 pub file_scores: bool,
929 pub coverage_gaps: bool,
931 pub hotspots: bool,
933 pub targets: bool,
935 pub css: bool,
937 pub score: bool,
939 pub force_full: bool,
941 pub score_only_output: bool,
943}
944
945#[derive(Debug, Clone)]
947pub struct ComplexitySectionOptions {
948 pub complexity: bool,
950 pub file_scores: bool,
952 pub coverage_gaps: bool,
954 pub hotspots: bool,
956 pub ownership: bool,
958 pub targets: bool,
960 pub css: bool,
962 pub score: bool,
964}
965
966#[derive(Debug, Clone, Copy, PartialEq, Eq)]
968pub struct DerivedComplexityOptions {
969 pub any_section: bool,
971 pub complexity: bool,
973 pub file_scores: bool,
975 pub coverage_gaps: bool,
977 pub hotspots: bool,
979 pub ownership: bool,
981 pub targets: bool,
983 pub force_full: bool,
985 pub score_only_output: bool,
987 pub score: bool,
989}
990
991#[derive(Debug, Clone, PartialEq)]
993pub struct ComplexityRunOptions<'a> {
994 pub thresholds: ComplexityThresholdOverrides,
996 pub top: Option<usize>,
998 pub sort: ComplexitySort,
1000 pub complexity_breakdown: bool,
1002 pub sections: DerivedComplexityOptions,
1004 pub ownership_emails: Option<OwnershipEmailMode>,
1006 pub effort: Option<TargetEffort>,
1008 pub css: bool,
1010 pub css_deep: bool,
1012 pub since: Option<&'a str>,
1014 pub min_commits: Option<u32>,
1016 pub coverage_inputs: ComplexityCoverageInputs<'a>,
1018}
1019
1020#[must_use]
1022pub fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
1023 let score = options.score
1024 || options.score_gate
1025 || options.trend
1026 || matches!(
1027 options.output,
1028 fallow_types::output_format::OutputFormat::Badge
1029 );
1030 let any_section = options.complexity
1031 || options.file_scores
1032 || options.coverage_gaps
1033 || options.hotspots
1034 || options.targets
1035 || score;
1036 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
1037 let force_full = options.snapshot_requested || effective_score;
1038
1039 DerivedHealthSections {
1040 any_section,
1041 complexity: if any_section {
1042 options.complexity
1043 } else {
1044 true
1045 },
1046 file_scores: if any_section {
1047 options.file_scores
1048 } else {
1049 true
1050 } || force_full,
1051 coverage_gaps: if any_section {
1052 options.coverage_gaps
1053 } else {
1054 false
1055 },
1056 hotspots: if any_section { options.hotspots } else { true }
1057 || options.snapshot_requested
1058 || options.trend,
1059 targets: if any_section { options.targets } else { true },
1060 css: options.css,
1061 score: effective_score,
1062 force_full,
1063 score_only_output: is_health_score_only_output(options, score),
1064 }
1065}
1066
1067#[must_use]
1069pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
1070 let requested_hotspots = options.hotspots || options.ownership;
1071 let sections = derive_health_sections(&HealthSectionOptions {
1072 output: fallow_types::output_format::OutputFormat::Human,
1073 complexity: options.complexity,
1074 file_scores: options.file_scores,
1075 coverage_gaps: options.coverage_gaps,
1076 hotspots: requested_hotspots,
1077 targets: options.targets,
1078 css: options.css,
1079 score: options.score,
1080 score_gate: false,
1081 snapshot_requested: false,
1082 trend: false,
1083 });
1084
1085 DerivedComplexityOptions {
1086 any_section: sections.any_section,
1087 complexity: sections.complexity,
1088 file_scores: sections.file_scores,
1089 coverage_gaps: sections.coverage_gaps,
1090 hotspots: sections.hotspots,
1091 ownership: options.ownership && sections.hotspots,
1092 targets: sections.targets,
1093 force_full: sections.force_full,
1094 score_only_output: sections.score_only_output,
1095 score: sections.score,
1096 }
1097}
1098
1099#[must_use]
1101pub fn derive_complexity_options(options: &ComplexityOptions) -> DerivedComplexityOptions {
1102 derive_complexity_sections(&complexity_section_options(options))
1103}
1104
1105#[must_use]
1107pub fn derive_complexity_run_options(options: &ComplexityOptions) -> ComplexityRunOptions<'_> {
1108 ComplexityRunOptions {
1109 thresholds: ComplexityThresholdOverrides {
1110 max_cyclomatic: options.max_cyclomatic,
1111 max_cognitive: options.max_cognitive,
1112 max_crap: options.max_crap,
1113 },
1114 top: options.top,
1115 sort: options.sort,
1116 complexity_breakdown: options.complexity_breakdown,
1117 sections: derive_complexity_options(options),
1118 ownership_emails: options.ownership_emails,
1119 effort: options.effort,
1120 css: options.css,
1121 css_deep: options.css_deep,
1122 since: options.since.as_deref(),
1123 min_commits: options.min_commits,
1124 coverage_inputs: ComplexityCoverageInputs {
1125 coverage: options.coverage.as_deref(),
1126 coverage_root: options.coverage_root.as_deref(),
1127 coverage_relocated: options.coverage_relocated,
1128 },
1129 }
1130}
1131
1132pub fn validate_complexity_options(options: &ComplexityOptions) -> Result<(), ProgrammaticError> {
1155 if let Some(path) = &options.coverage {
1156 let resolved = fallow_engine::health::scoring::resolve_relative_to_root(
1157 path,
1158 options.analysis.root.as_deref(),
1159 );
1160 if !resolved.exists() {
1161 return Err(ProgrammaticError::new(
1162 format!("coverage path does not exist: {}", resolved.display()),
1163 2,
1164 )
1165 .with_code("FALLOW_INVALID_COVERAGE_PATH")
1166 .with_context("health.coverage"));
1167 }
1168 }
1169 if let Err(message) =
1170 fallow_engine::health::validate_coverage_root_absolute(options.coverage_root.as_deref())
1171 {
1172 return Err(ProgrammaticError::new(message, 2)
1173 .with_code("FALLOW_INVALID_COVERAGE_ROOT")
1174 .with_context("health.coverage_root"));
1175 }
1176
1177 Ok(())
1178}
1179
1180fn complexity_section_options(options: &ComplexityOptions) -> ComplexitySectionOptions {
1181 let ownership = options.ownership || options.ownership_emails.is_some();
1182 let requested_targets = options.targets || options.effort.is_some();
1183 ComplexitySectionOptions {
1184 complexity: options.complexity,
1185 file_scores: options.file_scores,
1186 coverage_gaps: options.coverage_gaps,
1187 hotspots: options.hotspots,
1188 ownership,
1189 targets: requested_targets,
1190 css: options.css,
1191 score: options.score,
1192 }
1193}
1194
1195fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
1196 score
1197 && !options.complexity
1198 && !options.file_scores
1199 && !options.coverage_gaps
1200 && !options.hotspots
1201 && !options.targets
1202 && !options.trend
1203}
1204
1205const fn thresholds_to_engine(
1206 thresholds: ComplexityThresholdOverrides,
1207) -> fallow_engine::health::HealthThresholdOverrides {
1208 fallow_engine::health::HealthThresholdOverrides {
1209 max_cyclomatic: thresholds.max_cyclomatic,
1210 max_cognitive: thresholds.max_cognitive,
1211 max_crap: thresholds.max_crap,
1212 }
1213}
1214
1215const fn complexity_sort_to_engine(sort: ComplexitySort) -> fallow_engine::health::HealthSort {
1216 match sort {
1217 ComplexitySort::Severity => fallow_engine::health::HealthSort::Severity,
1218 ComplexitySort::Cyclomatic => fallow_engine::health::HealthSort::Cyclomatic,
1219 ComplexitySort::Cognitive => fallow_engine::health::HealthSort::Cognitive,
1220 ComplexitySort::Lines => fallow_engine::health::HealthSort::Lines,
1221 }
1222}
1223
1224const fn coverage_inputs_to_engine(
1225 coverage_inputs: ComplexityCoverageInputs<'_>,
1226) -> fallow_engine::health::HealthCoverageInputs<'_> {
1227 fallow_engine::health::HealthCoverageInputs {
1228 coverage: coverage_inputs.coverage,
1229 coverage_root: coverage_inputs.coverage_root,
1230 coverage_relocated: coverage_inputs.coverage_relocated,
1231 }
1232}
1233
1234const fn ownership_email_mode_to_config(mode: OwnershipEmailMode) -> EmailMode {
1235 match mode {
1236 OwnershipEmailMode::Raw => EmailMode::Raw,
1237 OwnershipEmailMode::Handle => EmailMode::Handle,
1238 OwnershipEmailMode::Anonymized => EmailMode::Anonymized,
1239 OwnershipEmailMode::Hash => EmailMode::Hash,
1240 }
1241}
1242
1243const fn target_effort_to_output(effort: TargetEffort) -> EffortEstimate {
1244 match effort {
1245 TargetEffort::Low => EffortEstimate::Low,
1246 TargetEffort::Medium => EffortEstimate::Medium,
1247 TargetEffort::High => EffortEstimate::High,
1248 }
1249}
1250
1251#[cfg(test)]
1252mod tests {
1253 use super::*;
1254
1255 #[test]
1256 fn duplication_defaults_match_cli_contract() {
1257 let options = DuplicationOptions::default();
1258 assert!(options.mode.is_none());
1259 assert!(options.min_tokens.is_none());
1260 assert!(options.min_lines.is_none());
1261 assert!(options.min_occurrences.is_none());
1262 }
1263
1264 #[test]
1265 fn programmatic_error_builder_keeps_optional_fields() {
1266 let error = ProgrammaticError::new("boom", 2)
1267 .with_code("FALLOW_TEST")
1268 .with_help("Try again")
1269 .with_context("analysis.root");
1270
1271 assert_eq!(error.message, "boom");
1272 assert_eq!(error.exit_code, 2);
1273 assert_eq!(error.code.as_deref(), Some("FALLOW_TEST"));
1274 assert_eq!(error.help.as_deref(), Some("Try again"));
1275 assert_eq!(error.context.as_deref(), Some("analysis.root"));
1276 }
1277
1278 #[test]
1279 fn dead_code_filters_accept_shared_registry_selectors() {
1280 for (selector, _) in fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS.iter() {
1281 let mut filters = DeadCodeFilters::default();
1282 assert!(
1283 filters.enable_registry_selector(selector),
1284 "{selector} should be accepted"
1285 );
1286 }
1287
1288 let mut filters = DeadCodeFilters::default();
1289 assert!(filters.enable_registry_selector("unused-files"));
1290 assert!(filters.unused_files);
1291 assert!(filters.enable_registry_selector("boundary-violations"));
1292 assert!(filters.boundary_violations);
1293 assert!(!filters.enable_registry_selector("not-a-real-selector"));
1294 }
1295
1296 #[test]
1297 fn default_complexity_options_match_programmatic_health_defaults() {
1298 let derived = derive_complexity_options(&ComplexityOptions::default());
1299
1300 assert!(!derived.any_section);
1301 assert!(derived.complexity);
1302 assert!(derived.file_scores);
1303 assert!(!derived.coverage_gaps);
1304 assert!(derived.hotspots);
1305 assert!(!derived.ownership);
1306 assert!(derived.targets);
1307 assert!(derived.force_full);
1308 assert!(!derived.score_only_output);
1309 assert!(derived.score);
1310 }
1311
1312 #[test]
1313 fn score_only_complexity_options_request_score_only_output() {
1314 let derived = derive_complexity_options(&ComplexityOptions {
1315 score: true,
1316 ..ComplexityOptions::default()
1317 });
1318
1319 assert!(derived.any_section);
1320 assert!(!derived.complexity);
1321 assert!(derived.file_scores);
1322 assert!(!derived.hotspots);
1323 assert!(!derived.targets);
1324 assert!(derived.force_full);
1325 assert!(derived.score_only_output);
1326 assert!(derived.score);
1327 }
1328
1329 #[test]
1330 fn ownership_implies_hotspots_when_requested() {
1331 let derived = derive_complexity_options(&ComplexityOptions {
1332 ownership: true,
1333 ..ComplexityOptions::default()
1334 });
1335
1336 assert!(derived.any_section);
1337 assert!(derived.hotspots);
1338 assert!(derived.ownership);
1339 assert!(!derived.targets);
1340 }
1341
1342 #[test]
1343 fn complexity_run_options_normalize_public_api_options() {
1344 let options = ComplexityOptions {
1345 max_cyclomatic: Some(42),
1346 max_cognitive: Some(21),
1347 max_crap: Some(18.5),
1348 top: Some(7),
1349 sort: ComplexitySort::Severity,
1350 complexity_breakdown: true,
1351 ownership_emails: Some(OwnershipEmailMode::Hash),
1352 effort: Some(TargetEffort::High),
1353 coverage: Some(PathBuf::from("coverage/coverage-final.json")),
1354 coverage_root: Some(PathBuf::from("/ci/workspace")),
1355 since: Some("30d".to_string()),
1356 min_commits: Some(4),
1357 ..ComplexityOptions::default()
1358 };
1359
1360 let run = derive_complexity_run_options(&options);
1361
1362 assert_eq!(run.thresholds.max_cyclomatic, Some(42));
1363 assert_eq!(run.thresholds.max_cognitive, Some(21));
1364 assert_eq!(run.thresholds.max_crap, Some(18.5));
1365 assert_eq!(run.top, Some(7));
1366 assert!(matches!(run.sort, ComplexitySort::Severity));
1367 assert!(run.complexity_breakdown);
1368 assert!(run.sections.hotspots);
1369 assert!(run.sections.ownership);
1370 assert!(run.sections.targets);
1371 assert!(matches!(
1372 run.ownership_emails,
1373 Some(OwnershipEmailMode::Hash)
1374 ));
1375 assert!(matches!(run.effort, Some(TargetEffort::High)));
1376 assert_eq!(run.since, Some("30d"));
1377 assert_eq!(run.min_commits, Some(4));
1378 assert_eq!(run.coverage_inputs.coverage, options.coverage.as_deref());
1379 assert_eq!(
1380 run.coverage_inputs.coverage_root,
1381 options.coverage_root.as_deref()
1382 );
1383 }
1384
1385 #[test]
1386 fn complexity_options_validation_accepts_existing_coverage_path_and_absolute_root() {
1387 let dir = tempfile::tempdir().expect("tempdir");
1388 let coverage = dir.path().join("coverage-final.json");
1389 std::fs::write(&coverage, "{}").expect("coverage fixture");
1390
1391 let result = validate_complexity_options(&ComplexityOptions {
1392 coverage: Some(coverage),
1393 coverage_root: Some(PathBuf::from("/ci/workspace")),
1394 ..ComplexityOptions::default()
1395 });
1396
1397 assert!(result.is_ok());
1398 }
1399
1400 #[test]
1401 fn complexity_options_validation_keeps_missing_coverage_error_contract() {
1402 let err = validate_complexity_options(&ComplexityOptions {
1403 coverage: Some(PathBuf::from("/missing/coverage-final.json")),
1404 ..ComplexityOptions::default()
1405 })
1406 .expect_err("missing coverage path should fail");
1407
1408 assert_eq!(err.exit_code, 2);
1409 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1410 assert_eq!(err.context.as_deref(), Some("health.coverage"));
1411 }
1412
1413 #[test]
1414 fn complexity_options_validation_keeps_relative_coverage_root_error_contract() {
1415 let err = validate_complexity_options(&ComplexityOptions {
1416 coverage_root: Some(PathBuf::from("coverage")),
1417 ..ComplexityOptions::default()
1418 })
1419 .expect_err("relative coverage root should fail");
1420
1421 assert_eq!(err.exit_code, 2);
1422 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_ROOT"));
1423 assert_eq!(err.context.as_deref(), Some("health.coverage_root"));
1424 }
1425
1426 #[test]
1430 fn complexity_options_validation_resolves_relative_coverage_against_root() {
1431 let dir = tempfile::tempdir().expect("tempdir");
1432 std::fs::create_dir_all(dir.path().join("artifacts")).expect("artifacts dir");
1433 std::fs::write(dir.path().join("artifacts/coverage-final.json"), "{}")
1434 .expect("coverage fixture");
1435 let relative = PathBuf::from("artifacts/coverage-final.json");
1436 assert!(
1437 !relative.exists(),
1438 "the fixture must not also exist under the test cwd"
1439 );
1440
1441 let result = validate_complexity_options(&ComplexityOptions {
1442 analysis: AnalysisOptions {
1443 root: Some(dir.path().to_path_buf()),
1444 ..AnalysisOptions::default()
1445 },
1446 coverage: Some(relative.clone()),
1447 ..ComplexityOptions::default()
1448 });
1449 assert!(result.is_ok(), "{result:?}");
1450
1451 let err = validate_complexity_options(&ComplexityOptions {
1452 analysis: AnalysisOptions {
1453 root: Some(dir.path().join("elsewhere")),
1454 ..AnalysisOptions::default()
1455 },
1456 coverage: Some(relative),
1457 ..ComplexityOptions::default()
1458 })
1459 .expect_err("the path does not exist under the other root");
1460 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1461 assert!(
1462 err.message.contains("elsewhere"),
1463 "the message names the resolved path: {}",
1464 err.message
1465 );
1466 }
1467
1468 #[test]
1469 fn default_health_sections_match_full_health_output() {
1470 let derived = derive_health_sections(&HealthSectionOptions {
1471 output: fallow_types::output_format::OutputFormat::Human,
1472 complexity: false,
1473 file_scores: false,
1474 coverage_gaps: false,
1475 hotspots: false,
1476 targets: false,
1477 css: false,
1478 score: false,
1479 score_gate: false,
1480 snapshot_requested: false,
1481 trend: false,
1482 });
1483
1484 assert!(!derived.any_section);
1485 assert!(derived.complexity);
1486 assert!(derived.file_scores);
1487 assert!(!derived.coverage_gaps);
1488 assert!(derived.hotspots);
1489 assert!(derived.targets);
1490 assert!(derived.score);
1491 assert!(derived.force_full);
1492 assert!(!derived.score_only_output);
1493 }
1494
1495 #[test]
1496 fn health_score_gate_requests_score_only_output() {
1497 let derived = derive_health_sections(&HealthSectionOptions {
1498 output: fallow_types::output_format::OutputFormat::Human,
1499 complexity: false,
1500 file_scores: false,
1501 coverage_gaps: false,
1502 hotspots: false,
1503 targets: false,
1504 css: false,
1505 score: false,
1506 score_gate: true,
1507 snapshot_requested: false,
1508 trend: false,
1509 });
1510
1511 assert!(derived.any_section);
1512 assert!(!derived.complexity);
1513 assert!(derived.file_scores);
1514 assert!(!derived.hotspots);
1515 assert!(!derived.targets);
1516 assert!(derived.score);
1517 assert!(derived.force_full);
1518 assert!(derived.score_only_output);
1519 }
1520
1521 #[test]
1522 fn health_snapshot_keeps_full_hidden_inputs_without_section_request() {
1523 let derived = derive_health_sections(&HealthSectionOptions {
1524 output: fallow_types::output_format::OutputFormat::Human,
1525 complexity: false,
1526 file_scores: false,
1527 coverage_gaps: false,
1528 hotspots: false,
1529 targets: false,
1530 css: true,
1531 score: false,
1532 score_gate: false,
1533 snapshot_requested: true,
1534 trend: false,
1535 });
1536
1537 assert!(!derived.any_section);
1538 assert!(derived.css);
1539 assert!(derived.file_scores);
1540 assert!(derived.hotspots);
1541 assert!(derived.score);
1542 assert!(derived.force_full);
1543 }
1544}