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 schemas;
61pub mod security_output;
62pub mod similar_code;
64mod type_aware;
65pub mod ci_output {
66 pub use fallow_output::{
70 CiIssue, CiProvider, GroupedReviewIssues, MARKER_PREFIX_V2, MARKER_SUFFIX_V2,
71 MAX_COMMENT_BODY_BYTES, PROJECT_LEVEL_RULE_IDS, PrCommentRenderInput,
72 ReviewCommentRenderInput, ReviewEnvelopeRenderInput, ReviewEnvelopeRenderResult,
73 ReviewEnvelopeTruncation, ReviewGitlabDiffRefs, cap_body_with_marker, command_title,
74 composite_fingerprint, escape_md, github_check_conclusion,
75 group_review_issues_by_path_line, is_project_level_rule, issues_from_codeclimate,
76 issues_from_codeclimate_issues, render_pr_comment, render_review_comment_for_group,
77 render_review_envelope, review_label_from_codeclimate, summary_fingerprint, summary_label,
78 };
79}
80pub use analysis_context::{ProgrammaticAnalysisContext, resolve_programmatic_analysis_context};
81pub use audit_output::{
82 AuditAttribution, AuditCodeClimateOutputInput, AuditJsonHeaderInput, AuditJsonOutputInput,
83 AuditSarifOutputInput, AuditSummary, AuditVerdict,
84 attach_audit_duplication_demotion_attribution, attach_audit_styling_attribution,
85 attach_audit_wire_attribution, build_audit_codeclimate, build_audit_codeclimate_issues,
86 build_audit_header_json, build_audit_header_map, build_audit_sarif, build_review_brief_header,
87 serialize_audit_json,
88};
89pub use ci_output::{
90 CiIssue, CiProvider, GroupedReviewIssues, MARKER_PREFIX_V2, MARKER_SUFFIX_V2,
91 MAX_COMMENT_BODY_BYTES, PROJECT_LEVEL_RULE_IDS, PrCommentRenderInput, ReviewCommentRenderInput,
92 ReviewEnvelopeRenderInput, ReviewEnvelopeRenderResult, ReviewEnvelopeTruncation,
93 ReviewGitlabDiffRefs, cap_body_with_marker, command_title, composite_fingerprint, escape_md,
94 github_check_conclusion, group_review_issues_by_path_line, is_project_level_rule,
95 issues_from_codeclimate, issues_from_codeclimate_issues, render_pr_comment,
96 render_review_comment_for_group, render_review_envelope, review_label_from_codeclimate,
97 summary_fingerprint, summary_label,
98};
99pub use combined_output::{
100 CombinedCheckJsonSection, CombinedJsonOutputInput, serialize_combined_dupes_json,
101 serialize_combined_health_json, serialize_combined_json,
102};
103pub use compact_output::{
104 build_compact_lines, build_duplication_compact_lines, build_grouped_compact_lines,
105 build_health_compact_lines,
106};
107pub use coverage::{
108 CoverageInputError, CoverageInputSource, CoverageInputs, resolve_coverage_inputs,
109};
110pub use dead_code_codeclimate::build_codeclimate;
111pub use dead_code_sarif::build_sarif;
112pub use dupes_output::{
113 AttributedCloneGroup, AttributedCloneGroupFinding, AttributedInstance, CloneDemotionReason,
114 CloneFamilyFinding, CloneGroupFinding, DupesReportPayload, DuplicationGroup,
115 DuplicationGrouping, build_duplication_codeclimate,
116};
117pub use editor::{
118 ChangedFilesError, EditorAnalysisOutput, EditorAnalysisResults, EditorAnalysisSession,
119 EditorCloneFamily, EditorCloneFingerprintSet, EditorCloneGroup, EditorCloneInstance,
120 EditorDeadCodeAnalysisOutput, EditorDuplicationReport, EditorDuplicationStats,
121 EditorInlineComplexityExceeded, EditorInlineComplexityFinding, EditorMirroredDirectory,
122 EditorProjectAnalysisOutput, EditorRefactoringKind, EditorRefactoringSuggestion,
123 collect_inline_complexity, editor_duplicates, editor_extract, editor_results, editor_security,
124 editor_suppress, filter_inline_complexity_by_changed_files, resolve_git_toplevel,
125 try_get_changed_files_with_toplevel,
126};
127pub use explain::{
128 CHECK_RULES, DUPES_RULES, FLAGS_RULES, HEALTH_RULES, RuleDef, RuleGuide, SECURITY_RULES,
129 all_rules, bare_rule_id, coverage_analyze_meta, coverage_setup_meta, explain_issue_type,
130 rule_by_id, rule_by_token, rule_command, rule_docs_url, rule_guide, rule_severity_key,
131 security_meta, serialize_explain_programmatic_json, unknown_explain_error,
132};
133pub use fallow_config::{AuditGate, HealthConfig, TypeAwareRequire};
134pub use fallow_output::{RootEnvelopeMode, serialize_similar_code_json_output};
135pub use fallow_types::trace::{
136 CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace, ReExportChain,
137 TracedCloneGroup, TracedExport, TracedReExport,
138};
139pub use grouped_output::{
140 ResultGroup, UNOWNED_GROUP_LABEL, build_duplication_grouping_with, group_analysis_results_with,
141 largest_clone_group_owner_with,
142};
143pub use health_codeclimate::build_health_codeclimate;
144pub use json_output::{
145 CheckJsonExtraOutputs, CheckJsonOutputInput, CheckJsonPayloadInput, DuplicationJsonOutputInput,
146 GroupedCheckJsonOutputInput, GroupedDuplicationJsonOutputInput, serialize_check_json,
147 serialize_check_json_payload, serialize_duplication_json, serialize_grouped_check_json,
148 serialize_grouped_duplication_json,
149};
150pub use list_output::{
151 ListJsonEnvelope, ListJsonOutputInput, build_list_json_output, serialize_list_json_output,
152};
153pub use list_runtime::{
154 BoundaryData, ListBoundariesOptions, ListBoundariesProgrammaticOutput, LogicalGroupInfo,
155 ProjectInfoOptions, ProjectInfoProgrammaticOutput, RuleInfo, ZoneInfo, boundary_data_to_output,
156 compute_boundary_data, run_list_boundaries, run_project_info,
157 serialize_list_boundaries_programmatic_json, serialize_project_info_programmatic_json,
158};
159pub use markdown_output::{
160 build_duplication_markdown, build_grouped_markdown, build_health_markdown, build_markdown,
161 build_walkthrough_markdown,
162};
163pub use output_contracts::{
164 AuditOutput, BoundariesListLogicalGroup, BoundariesListRule, BoundariesListZone,
165 BoundariesListing, CombinedOutput, FallowOutput, ImpactOutput, ListBoundariesOutput,
166 ListEntryPointOutput, ListOutput, ListPluginOutput, ReviewBriefWireOutput, SecurityGate,
167 SecurityOutput, SecurityOutputConfig, SecuritySummaryOutput, SimilarCodeCandidateSnapshot,
168 SimilarCodeOutput, TraceOutput, WorkspacesOutput,
169};
170pub use runtime::{
171 AuditProgrammaticKeySnapshot, AuditProgrammaticOutput, BoundaryViolationsOutput,
172 BoundaryViolationsProgrammaticOutput, CircularDependenciesOutput,
173 CircularDependenciesProgrammaticOutput, CombinedProgrammaticOutput, DeadCodeOutput,
174 DeadCodeProgrammaticOutput, DecisionSurfaceProgrammaticOutput, DuplicationOutput,
175 DuplicationProgrammaticOutput, EngineHealthRunner, FeatureFlagsOutput,
176 FeatureFlagsProgrammaticOutput, HealthJsonReportInput, HealthProgrammaticOutput,
177 ProgrammaticHealthAnalysis, ProgrammaticHealthNextStepFacts, ProgrammaticHealthRun,
178 ProgrammaticHealthRunner, TraceClassMemberOutput, TraceCloneBenchmarkResult, TraceCloneOutput,
179 TraceCloneProgrammaticOutput, TraceDependencyOutput, TraceDependencyProgrammaticOutput,
180 TraceExportOutput, TraceExportProgrammaticOutput, TraceExportTargetOutput, TraceFileOutput,
181 TraceFileProgrammaticOutput, benchmark_trace_clone_compact_json,
182 benchmark_trace_graph_family_compact_json, inspect_similar_code, load_health_config,
183 parse_similar_code_candidate_snapshot, review_similar_code, run_audit, run_boundary_violations,
184 run_circular_dependencies, run_combined, run_complexity_with_runner, run_dead_code,
185 run_decision_surface, run_duplication, run_feature_flags, run_health, run_health_with_runner,
186 run_similar_code, run_trace_clone, run_trace_dependency, run_trace_export, run_trace_file,
187 select_similar_code_candidate_snapshot, serialize_health_report_json,
188};
189pub use runtime_json::{
190 serialize_audit_programmatic_json, serialize_boundary_violations_programmatic_json,
191 serialize_circular_dependencies_programmatic_json, serialize_combined_programmatic_json,
192 serialize_dead_code_programmatic_json, serialize_decision_surface_programmatic_json,
193 serialize_duplication_programmatic_json, serialize_feature_flags_programmatic_json,
194 serialize_health_programmatic_json, serialize_trace_clone_programmatic_json,
195 serialize_trace_dependency_programmatic_json, serialize_trace_export_programmatic_json,
196 serialize_trace_file_programmatic_json,
197};
198pub use sarif_output::{
199 annotate_sarif_results, build_duplication_sarif, build_grouped_duplication_sarif,
200 build_health_sarif,
201};
202pub use security_output::SecurityGateMode;
203pub use type_aware::{
204 SemanticCouplingOutcome, SemanticDeadCodeOutcome, SemanticInspectOutcome, TypeAwareError,
205 TypeAwareFileChanges, TypeAwareOutcome, TypeAwareSession, TypeAwareStatus,
206 discard_unverified_semantic_candidates, inspect_symbol as inspect_type_aware_symbol,
207 merge_type_aware_meta,
208 refine_configured_dead_code_results as refine_type_aware_results_with_config,
209 refine_configured_dead_code_results_in_session as refine_type_aware_results_in_session_with_config,
210 refine_dead_code_results as refine_type_aware_results,
211 refine_dead_code_results_in_session as refine_type_aware_results_in_session,
212 refine_programmatic_dead_code as refine_type_aware_dead_code, shutdown_type_aware_sidecars,
213 status as type_aware_status, symbol_impact as run_type_aware_symbol_impact,
214 symbol_impact as type_aware_symbol_impact, terminate_active_type_aware_sidecars,
215 trace_symbol as run_type_aware_symbol_trace, trace_symbol as trace_type_aware_symbol,
216 type_coupling as analyze_type_coupling,
217};
218
219pub const COMMON_ANALYSIS_OPTION_FLAGS: &[&str] = &[
226 "root",
227 "config",
228 "no-cache",
229 "threads",
230 "changed-since",
231 "diff-file",
232 "production",
233 "workspace",
234 "changed-workspaces",
235 "explain",
236 "allow-remote-extends",
237];
238
239#[derive(Debug, Clone, Serialize)]
241pub struct ProgrammaticError {
242 pub message: String,
244 pub exit_code: u8,
246 pub code: Option<String>,
248 pub help: Option<String>,
250 pub context: Option<String>,
252}
253
254impl ProgrammaticError {
255 #[must_use]
258 pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
259 Self {
260 message: message.into(),
261 exit_code,
262 code: None,
263 help: None,
264 context: None,
265 }
266 }
267
268 #[must_use]
270 pub fn with_help(mut self, help: impl Into<String>) -> Self {
271 self.help = Some(help.into());
272 self
273 }
274
275 #[must_use]
278 pub fn with_code(mut self, code: impl Into<String>) -> Self {
279 self.code = Some(code.into());
280 self
281 }
282
283 #[must_use]
286 pub fn with_context(mut self, context: impl Into<String>) -> Self {
287 self.context = Some(context.into());
288 self
289 }
290}
291
292impl std::fmt::Display for ProgrammaticError {
293 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294 write!(f, "{}", self.message)
295 }
296}
297
298impl std::error::Error for ProgrammaticError {}
299
300#[derive(Debug, Clone, Default)]
302pub struct AnalysisOptions {
303 pub root: Option<PathBuf>,
306 pub config_path: Option<PathBuf>,
308 pub allow_remote_extends: bool,
310 pub no_cache: bool,
312 pub threads: Option<usize>,
314 pub diff_file: Option<PathBuf>,
316 pub production: bool,
319 pub production_override: Option<bool>,
322 pub changed_since: Option<String>,
324 pub workspace: Option<Vec<String>>,
326 pub changed_workspaces: Option<String>,
328 pub explain: bool,
330 pub type_aware: TypeAwareOptions,
333}
334
335#[derive(Debug, Clone, Default, PartialEq, Eq)]
337pub struct TypeAwareOptions {
338 pub enabled: bool,
340 pub projects: Vec<PathBuf>,
343 pub require: fallow_config::TypeAwareRequire,
345}
346
347#[derive(Debug, Clone, Default)]
352pub struct DeadCodeFilters {
353 pub unused_files: bool,
355 pub unused_exports: bool,
357 pub unused_deps: bool,
359 pub unused_types: bool,
361 pub private_type_leaks: bool,
363 pub unused_enum_members: bool,
365 pub unused_class_members: bool,
367 pub unused_store_members: bool,
369 pub unprovided_injects: bool,
371 pub unrendered_components: bool,
373 pub unused_component_props: bool,
375 pub unused_component_emits: bool,
377 pub unused_component_inputs: bool,
379 pub unused_component_outputs: bool,
381 pub unused_svelte_events: bool,
383 pub unused_server_actions: bool,
385 pub unused_load_data_keys: bool,
387 pub unresolved_imports: bool,
389 pub unlisted_deps: bool,
391 pub duplicate_exports: bool,
393 pub circular_deps: bool,
395 pub re_export_cycles: bool,
397 pub boundary_violations: bool,
399 pub policy_violations: bool,
401 pub stale_suppressions: bool,
403 pub unused_catalog_entries: bool,
405 pub empty_catalog_groups: bool,
407 pub unresolved_catalog_references: bool,
409 pub unused_dependency_overrides: bool,
411 pub misconfigured_dependency_overrides: bool,
413}
414
415impl DeadCodeFilters {
416 fn any_active(&self) -> bool {
417 self.unused_files
418 || self.unused_exports
419 || self.unused_deps
420 || self.unused_types
421 || self.private_type_leaks
422 || self.unused_enum_members
423 || self.unused_class_members
424 || self.unused_store_members
425 || self.unprovided_injects
426 || self.unrendered_components
427 || self.unused_component_props
428 || self.unused_component_emits
429 || self.unused_component_inputs
430 || self.unused_component_outputs
431 || self.unused_svelte_events
432 || self.unused_server_actions
433 || self.unused_load_data_keys
434 || self.unresolved_imports
435 || self.unlisted_deps
436 || self.duplicate_exports
437 || self.circular_deps
438 || self.re_export_cycles
439 || self.boundary_violations
440 || self.policy_violations
441 || self.stale_suppressions
442 || self.unused_catalog_entries
443 || self.empty_catalog_groups
444 || self.unresolved_catalog_references
445 || self.unused_dependency_overrides
446 || self.misconfigured_dependency_overrides
447 }
448
449 pub fn enable_registry_selector(&mut self, selector: &str) -> bool {
455 let Some(flag) = fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS
456 .iter()
457 .find_map(|&(name, flag)| (name == selector).then_some(flag))
458 else {
459 return false;
460 };
461 self.enable_cli_filter_flag(flag);
462 true
463 }
464
465 fn enable_cli_filter_flag(&mut self, flag: &str) {
466 match flag {
467 "--unused-files" => self.unused_files = true,
468 "--unused-exports" => self.unused_exports = true,
469 "--unused-types" => self.unused_types = true,
470 "--private-type-leaks" => self.private_type_leaks = true,
471 "--unused-deps" => self.unused_deps = true,
472 "--unused-enum-members" => self.unused_enum_members = true,
473 "--unused-class-members" => self.unused_class_members = true,
474 "--unused-store-members" => self.unused_store_members = true,
475 "--unprovided-injects" => self.unprovided_injects = true,
476 "--unrendered-components" => self.unrendered_components = true,
477 "--unused-component-props" => self.unused_component_props = true,
478 "--unused-component-emits" => self.unused_component_emits = true,
479 "--unused-component-inputs" => self.unused_component_inputs = true,
480 "--unused-component-outputs" => self.unused_component_outputs = true,
481 "--unused-svelte-events" => self.unused_svelte_events = true,
482 "--unused-server-actions" => self.unused_server_actions = true,
483 "--unused-load-data-keys" => self.unused_load_data_keys = true,
484 "--unresolved-imports" => self.unresolved_imports = true,
485 "--unlisted-deps" => self.unlisted_deps = true,
486 "--duplicate-exports" => self.duplicate_exports = true,
487 "--circular-deps" => self.circular_deps = true,
488 "--re-export-cycles" => self.re_export_cycles = true,
489 "--boundary-violations" => self.boundary_violations = true,
490 "--policy-violations" => self.policy_violations = true,
491 "--stale-suppressions" => self.stale_suppressions = true,
492 "--unused-catalog-entries" => self.unused_catalog_entries = true,
493 "--empty-catalog-groups" => self.empty_catalog_groups = true,
494 "--unresolved-catalog-references" => self.unresolved_catalog_references = true,
495 "--unused-dependency-overrides" => self.unused_dependency_overrides = true,
496 "--misconfigured-dependency-overrides" => {
497 self.misconfigured_dependency_overrides = true;
498 }
499 _ => unreachable!("registry emitted unsupported dead-code filter flag: {flag}"),
500 }
501 }
502}
503
504#[derive(Debug, Clone, Default)]
506pub struct DeadCodeOptions {
507 pub analysis: AnalysisOptions,
509 pub filters: DeadCodeFilters,
511 pub files: Vec<PathBuf>,
513 pub include_entry_exports: bool,
515}
516
517#[derive(Debug, Clone, Default)]
519pub struct AuditOptions {
520 pub analysis: AnalysisOptions,
522 pub base: Option<String>,
525 pub production: bool,
527 pub production_dead_code: Option<bool>,
529 pub production_health: Option<bool>,
531 pub production_dupes: Option<bool>,
534 pub css: Option<bool>,
536 pub css_deep: Option<bool>,
538 pub gate: fallow_config::AuditGate,
540 pub max_crap: Option<f64>,
542 pub coverage: Option<PathBuf>,
544 pub coverage_root: Option<PathBuf>,
546 pub include_entry_exports: bool,
548 pub runtime_coverage: Option<PathBuf>,
550 pub min_invocations_hot: u64,
552}
553
554#[derive(Debug, Clone)]
556pub struct CombinedOptions {
557 pub analysis: AnalysisOptions,
559 pub dead_code: bool,
561 pub duplication: bool,
563 pub health: bool,
565 pub include_entry_exports: bool,
567 pub duplication_options: DuplicationOptions,
569 pub health_options: ComplexityOptions,
571}
572
573impl Default for CombinedOptions {
574 fn default() -> Self {
575 Self {
576 analysis: AnalysisOptions::default(),
577 dead_code: true,
578 duplication: true,
579 health: true,
580 include_entry_exports: false,
581 duplication_options: DuplicationOptions::default(),
582 health_options: ComplexityOptions::default(),
583 }
584 }
585}
586
587#[derive(Debug, Clone, Default)]
589pub struct DecisionSurfaceOptions {
590 pub analysis: AnalysisOptions,
592 pub base: Option<String>,
595 pub max_decisions: Option<usize>,
597}
598
599#[derive(Debug, Clone, Default)]
601pub struct FeatureFlagsOptions {
602 pub analysis: AnalysisOptions,
604 pub top: Option<usize>,
606}
607
608#[derive(Debug, Clone, Copy, Default)]
610pub enum DuplicationMode {
611 Strict,
614 #[default]
616 Mild,
617 Weak,
619 Semantic,
622}
623
624#[derive(Debug, Clone, Default)]
626pub struct DuplicationOptions {
627 pub analysis: AnalysisOptions,
629 pub mode: Option<DuplicationMode>,
631 pub near: Option<bool>,
634 pub min_tokens: Option<usize>,
636 pub min_lines: Option<usize>,
638 pub min_occurrences: Option<usize>,
641 pub threshold: Option<f64>,
644 pub skip_local: Option<bool>,
647 pub cross_language: Option<bool>,
649 pub ignore_imports: Option<bool>,
652 pub top: Option<usize>,
654}
655
656#[derive(Debug, Clone, Default)]
658pub struct SimilarCodeOptions {
659 pub analysis: AnalysisOptions,
661 pub threshold: Option<f64>,
663 pub min_lines: Option<usize>,
666 pub top: Option<usize>,
668 pub files: Vec<PathBuf>,
672 #[doc(hidden)]
677 pub adapter_provider_path: Option<PathBuf>,
678}
679
680#[derive(Debug, Clone)]
682pub struct SimilarCodeInspectOptions {
683 pub analysis: AnalysisOptions,
686 pub snapshot: fallow_output::SimilarCodeCandidateSnapshot,
688}
689
690#[derive(Debug, Clone, Default)]
692pub struct TraceExportOptions {
693 pub analysis: AnalysisOptions,
695 pub file: String,
697 pub export_name: String,
699}
700
701#[derive(Debug, Clone, Default)]
703pub struct TraceFileOptions {
704 pub analysis: AnalysisOptions,
706 pub file: String,
708}
709
710#[derive(Debug, Clone, Default)]
712pub struct TraceDependencyOptions {
713 pub analysis: AnalysisOptions,
715 pub package_name: String,
717}
718
719#[derive(Debug, Clone, PartialEq, Eq)]
721pub enum TraceCloneTarget {
722 Location {
724 file: String,
726 line: usize,
728 },
729 Fingerprint(String),
731}
732
733#[derive(Debug, Clone)]
735pub struct TraceCloneOptions {
736 pub duplication: DuplicationOptions,
738 pub target: TraceCloneTarget,
740}
741
742#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
744pub enum ComplexitySort {
745 #[default]
747 Cyclomatic,
748 Cognitive,
750 Lines,
752 Severity,
754}
755
756#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
758pub enum OwnershipEmailMode {
759 Raw,
761 #[default]
763 Handle,
764 Anonymized,
766 Hash,
768}
769
770#[derive(Debug, Clone, Copy, PartialEq, Eq)]
772pub enum TargetEffort {
773 Low,
775 Medium,
777 High,
779}
780
781#[derive(Debug, Clone, Default)]
783pub struct ComplexityOptions {
784 pub analysis: AnalysisOptions,
786 pub max_cyclomatic: Option<u16>,
788 pub max_cognitive: Option<u16>,
790 pub max_crap: Option<f64>,
792 pub top: Option<usize>,
794 pub sort: ComplexitySort,
796 pub complexity_breakdown: bool,
798 pub complexity: bool,
800 pub file_scores: bool,
802 pub coverage_gaps: bool,
804 pub hotspots: bool,
806 pub ownership: bool,
808 pub ownership_emails: Option<OwnershipEmailMode>,
810 pub targets: bool,
812 pub css: bool,
814 pub css_deep: bool,
816 pub effort: Option<TargetEffort>,
819 pub score: bool,
821 pub since: Option<String>,
823 pub min_commits: Option<u32>,
825 pub coverage: Option<PathBuf>,
827 pub coverage_root: Option<PathBuf>,
829 pub coverage_relocated: bool,
834}
835
836#[derive(Debug, Clone, Copy, Default, PartialEq)]
838pub struct ComplexityThresholdOverrides {
839 pub max_cyclomatic: Option<u16>,
841 pub max_cognitive: Option<u16>,
843 pub max_crap: Option<f64>,
845}
846
847#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
849pub struct ComplexityCoverageInputs<'a> {
850 pub coverage: Option<&'a Path>,
852 pub coverage_root: Option<&'a Path>,
854 pub coverage_relocated: bool,
857}
858
859#[derive(Debug, Clone)]
861pub struct HealthSectionOptions {
862 pub output: fallow_types::output_format::OutputFormat,
864 pub complexity: bool,
866 pub file_scores: bool,
868 pub coverage_gaps: bool,
870 pub hotspots: bool,
872 pub targets: bool,
874 pub css: bool,
876 pub score: bool,
878 pub score_gate: bool,
880 pub snapshot_requested: bool,
882 pub trend: bool,
884}
885
886#[derive(Debug, Clone, Copy, PartialEq, Eq)]
888pub struct DerivedHealthSections {
889 pub any_section: bool,
891 pub complexity: bool,
893 pub file_scores: bool,
895 pub coverage_gaps: bool,
897 pub hotspots: bool,
899 pub targets: bool,
901 pub css: bool,
903 pub score: bool,
905 pub force_full: bool,
907 pub score_only_output: bool,
909}
910
911#[derive(Debug, Clone)]
913pub struct ComplexitySectionOptions {
914 pub complexity: bool,
916 pub file_scores: bool,
918 pub coverage_gaps: bool,
920 pub hotspots: bool,
922 pub ownership: bool,
924 pub targets: bool,
926 pub css: bool,
928 pub score: bool,
930}
931
932#[derive(Debug, Clone, Copy, PartialEq, Eq)]
934pub struct DerivedComplexityOptions {
935 pub any_section: bool,
937 pub complexity: bool,
939 pub file_scores: bool,
941 pub coverage_gaps: bool,
943 pub hotspots: bool,
945 pub ownership: bool,
947 pub targets: bool,
949 pub force_full: bool,
951 pub score_only_output: bool,
953 pub score: bool,
955}
956
957#[derive(Debug, Clone, PartialEq)]
959pub struct ComplexityRunOptions<'a> {
960 pub thresholds: ComplexityThresholdOverrides,
962 pub top: Option<usize>,
964 pub sort: ComplexitySort,
966 pub complexity_breakdown: bool,
968 pub sections: DerivedComplexityOptions,
970 pub ownership_emails: Option<OwnershipEmailMode>,
972 pub effort: Option<TargetEffort>,
974 pub css: bool,
976 pub css_deep: bool,
978 pub since: Option<&'a str>,
980 pub min_commits: Option<u32>,
982 pub coverage_inputs: ComplexityCoverageInputs<'a>,
984}
985
986#[must_use]
988pub fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
989 let score = options.score
990 || options.score_gate
991 || options.trend
992 || matches!(
993 options.output,
994 fallow_types::output_format::OutputFormat::Badge
995 );
996 let any_section = options.complexity
997 || options.file_scores
998 || options.coverage_gaps
999 || options.hotspots
1000 || options.targets
1001 || score;
1002 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
1003 let force_full = options.snapshot_requested || effective_score;
1004
1005 DerivedHealthSections {
1006 any_section,
1007 complexity: if any_section {
1008 options.complexity
1009 } else {
1010 true
1011 },
1012 file_scores: if any_section {
1013 options.file_scores
1014 } else {
1015 true
1016 } || force_full,
1017 coverage_gaps: if any_section {
1018 options.coverage_gaps
1019 } else {
1020 false
1021 },
1022 hotspots: if any_section { options.hotspots } else { true }
1023 || options.snapshot_requested
1024 || options.trend,
1025 targets: if any_section { options.targets } else { true },
1026 css: options.css,
1027 score: effective_score,
1028 force_full,
1029 score_only_output: is_health_score_only_output(options, score),
1030 }
1031}
1032
1033#[must_use]
1035pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
1036 let requested_hotspots = options.hotspots || options.ownership;
1037 let sections = derive_health_sections(&HealthSectionOptions {
1038 output: fallow_types::output_format::OutputFormat::Human,
1039 complexity: options.complexity,
1040 file_scores: options.file_scores,
1041 coverage_gaps: options.coverage_gaps,
1042 hotspots: requested_hotspots,
1043 targets: options.targets,
1044 css: options.css,
1045 score: options.score,
1046 score_gate: false,
1047 snapshot_requested: false,
1048 trend: false,
1049 });
1050
1051 DerivedComplexityOptions {
1052 any_section: sections.any_section,
1053 complexity: sections.complexity,
1054 file_scores: sections.file_scores,
1055 coverage_gaps: sections.coverage_gaps,
1056 hotspots: sections.hotspots,
1057 ownership: options.ownership && sections.hotspots,
1058 targets: sections.targets,
1059 force_full: sections.force_full,
1060 score_only_output: sections.score_only_output,
1061 score: sections.score,
1062 }
1063}
1064
1065#[must_use]
1067pub fn derive_complexity_options(options: &ComplexityOptions) -> DerivedComplexityOptions {
1068 derive_complexity_sections(&complexity_section_options(options))
1069}
1070
1071#[must_use]
1073pub fn derive_complexity_run_options(options: &ComplexityOptions) -> ComplexityRunOptions<'_> {
1074 ComplexityRunOptions {
1075 thresholds: ComplexityThresholdOverrides {
1076 max_cyclomatic: options.max_cyclomatic,
1077 max_cognitive: options.max_cognitive,
1078 max_crap: options.max_crap,
1079 },
1080 top: options.top,
1081 sort: options.sort,
1082 complexity_breakdown: options.complexity_breakdown,
1083 sections: derive_complexity_options(options),
1084 ownership_emails: options.ownership_emails,
1085 effort: options.effort,
1086 css: options.css,
1087 css_deep: options.css_deep,
1088 since: options.since.as_deref(),
1089 min_commits: options.min_commits,
1090 coverage_inputs: ComplexityCoverageInputs {
1091 coverage: options.coverage.as_deref(),
1092 coverage_root: options.coverage_root.as_deref(),
1093 coverage_relocated: options.coverage_relocated,
1094 },
1095 }
1096}
1097
1098pub fn validate_complexity_options(options: &ComplexityOptions) -> Result<(), ProgrammaticError> {
1121 if let Some(path) = &options.coverage {
1122 let resolved = fallow_engine::health::scoring::resolve_relative_to_root(
1123 path,
1124 options.analysis.root.as_deref(),
1125 );
1126 if !resolved.exists() {
1127 return Err(ProgrammaticError::new(
1128 format!("coverage path does not exist: {}", resolved.display()),
1129 2,
1130 )
1131 .with_code("FALLOW_INVALID_COVERAGE_PATH")
1132 .with_context("health.coverage"));
1133 }
1134 }
1135 if let Err(message) =
1136 fallow_engine::health::validate_coverage_root_absolute(options.coverage_root.as_deref())
1137 {
1138 return Err(ProgrammaticError::new(message, 2)
1139 .with_code("FALLOW_INVALID_COVERAGE_ROOT")
1140 .with_context("health.coverage_root"));
1141 }
1142
1143 Ok(())
1144}
1145
1146fn complexity_section_options(options: &ComplexityOptions) -> ComplexitySectionOptions {
1147 let ownership = options.ownership || options.ownership_emails.is_some();
1148 let requested_targets = options.targets || options.effort.is_some();
1149 ComplexitySectionOptions {
1150 complexity: options.complexity,
1151 file_scores: options.file_scores,
1152 coverage_gaps: options.coverage_gaps,
1153 hotspots: options.hotspots,
1154 ownership,
1155 targets: requested_targets,
1156 css: options.css,
1157 score: options.score,
1158 }
1159}
1160
1161fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
1162 score
1163 && !options.complexity
1164 && !options.file_scores
1165 && !options.coverage_gaps
1166 && !options.hotspots
1167 && !options.targets
1168 && !options.trend
1169}
1170
1171const fn thresholds_to_engine(
1172 thresholds: ComplexityThresholdOverrides,
1173) -> fallow_engine::health::HealthThresholdOverrides {
1174 fallow_engine::health::HealthThresholdOverrides {
1175 max_cyclomatic: thresholds.max_cyclomatic,
1176 max_cognitive: thresholds.max_cognitive,
1177 max_crap: thresholds.max_crap,
1178 }
1179}
1180
1181const fn complexity_sort_to_engine(sort: ComplexitySort) -> fallow_engine::health::HealthSort {
1182 match sort {
1183 ComplexitySort::Severity => fallow_engine::health::HealthSort::Severity,
1184 ComplexitySort::Cyclomatic => fallow_engine::health::HealthSort::Cyclomatic,
1185 ComplexitySort::Cognitive => fallow_engine::health::HealthSort::Cognitive,
1186 ComplexitySort::Lines => fallow_engine::health::HealthSort::Lines,
1187 }
1188}
1189
1190const fn coverage_inputs_to_engine(
1191 coverage_inputs: ComplexityCoverageInputs<'_>,
1192) -> fallow_engine::health::HealthCoverageInputs<'_> {
1193 fallow_engine::health::HealthCoverageInputs {
1194 coverage: coverage_inputs.coverage,
1195 coverage_root: coverage_inputs.coverage_root,
1196 coverage_relocated: coverage_inputs.coverage_relocated,
1197 }
1198}
1199
1200const fn ownership_email_mode_to_config(mode: OwnershipEmailMode) -> EmailMode {
1201 match mode {
1202 OwnershipEmailMode::Raw => EmailMode::Raw,
1203 OwnershipEmailMode::Handle => EmailMode::Handle,
1204 OwnershipEmailMode::Anonymized => EmailMode::Anonymized,
1205 OwnershipEmailMode::Hash => EmailMode::Hash,
1206 }
1207}
1208
1209const fn target_effort_to_output(effort: TargetEffort) -> EffortEstimate {
1210 match effort {
1211 TargetEffort::Low => EffortEstimate::Low,
1212 TargetEffort::Medium => EffortEstimate::Medium,
1213 TargetEffort::High => EffortEstimate::High,
1214 }
1215}
1216
1217#[cfg(test)]
1218mod tests {
1219 use super::*;
1220
1221 #[test]
1222 fn duplication_defaults_match_cli_contract() {
1223 let options = DuplicationOptions::default();
1224 assert!(options.mode.is_none());
1225 assert!(options.min_tokens.is_none());
1226 assert!(options.min_lines.is_none());
1227 assert!(options.min_occurrences.is_none());
1228 }
1229
1230 #[test]
1231 fn programmatic_error_builder_keeps_optional_fields() {
1232 let error = ProgrammaticError::new("boom", 2)
1233 .with_code("FALLOW_TEST")
1234 .with_help("Try again")
1235 .with_context("analysis.root");
1236
1237 assert_eq!(error.message, "boom");
1238 assert_eq!(error.exit_code, 2);
1239 assert_eq!(error.code.as_deref(), Some("FALLOW_TEST"));
1240 assert_eq!(error.help.as_deref(), Some("Try again"));
1241 assert_eq!(error.context.as_deref(), Some("analysis.root"));
1242 }
1243
1244 #[test]
1245 fn dead_code_filters_accept_shared_registry_selectors() {
1246 for (selector, _) in fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS.iter() {
1247 let mut filters = DeadCodeFilters::default();
1248 assert!(
1249 filters.enable_registry_selector(selector),
1250 "{selector} should be accepted"
1251 );
1252 }
1253
1254 let mut filters = DeadCodeFilters::default();
1255 assert!(filters.enable_registry_selector("unused-files"));
1256 assert!(filters.unused_files);
1257 assert!(filters.enable_registry_selector("boundary-violations"));
1258 assert!(filters.boundary_violations);
1259 assert!(!filters.enable_registry_selector("not-a-real-selector"));
1260 }
1261
1262 #[test]
1263 fn default_complexity_options_match_programmatic_health_defaults() {
1264 let derived = derive_complexity_options(&ComplexityOptions::default());
1265
1266 assert!(!derived.any_section);
1267 assert!(derived.complexity);
1268 assert!(derived.file_scores);
1269 assert!(!derived.coverage_gaps);
1270 assert!(derived.hotspots);
1271 assert!(!derived.ownership);
1272 assert!(derived.targets);
1273 assert!(derived.force_full);
1274 assert!(!derived.score_only_output);
1275 assert!(derived.score);
1276 }
1277
1278 #[test]
1279 fn score_only_complexity_options_request_score_only_output() {
1280 let derived = derive_complexity_options(&ComplexityOptions {
1281 score: true,
1282 ..ComplexityOptions::default()
1283 });
1284
1285 assert!(derived.any_section);
1286 assert!(!derived.complexity);
1287 assert!(derived.file_scores);
1288 assert!(!derived.hotspots);
1289 assert!(!derived.targets);
1290 assert!(derived.force_full);
1291 assert!(derived.score_only_output);
1292 assert!(derived.score);
1293 }
1294
1295 #[test]
1296 fn ownership_implies_hotspots_when_requested() {
1297 let derived = derive_complexity_options(&ComplexityOptions {
1298 ownership: true,
1299 ..ComplexityOptions::default()
1300 });
1301
1302 assert!(derived.any_section);
1303 assert!(derived.hotspots);
1304 assert!(derived.ownership);
1305 assert!(!derived.targets);
1306 }
1307
1308 #[test]
1309 fn complexity_run_options_normalize_public_api_options() {
1310 let options = ComplexityOptions {
1311 max_cyclomatic: Some(42),
1312 max_cognitive: Some(21),
1313 max_crap: Some(18.5),
1314 top: Some(7),
1315 sort: ComplexitySort::Severity,
1316 complexity_breakdown: true,
1317 ownership_emails: Some(OwnershipEmailMode::Hash),
1318 effort: Some(TargetEffort::High),
1319 coverage: Some(PathBuf::from("coverage/coverage-final.json")),
1320 coverage_root: Some(PathBuf::from("/ci/workspace")),
1321 since: Some("30d".to_string()),
1322 min_commits: Some(4),
1323 ..ComplexityOptions::default()
1324 };
1325
1326 let run = derive_complexity_run_options(&options);
1327
1328 assert_eq!(run.thresholds.max_cyclomatic, Some(42));
1329 assert_eq!(run.thresholds.max_cognitive, Some(21));
1330 assert_eq!(run.thresholds.max_crap, Some(18.5));
1331 assert_eq!(run.top, Some(7));
1332 assert!(matches!(run.sort, ComplexitySort::Severity));
1333 assert!(run.complexity_breakdown);
1334 assert!(run.sections.hotspots);
1335 assert!(run.sections.ownership);
1336 assert!(run.sections.targets);
1337 assert!(matches!(
1338 run.ownership_emails,
1339 Some(OwnershipEmailMode::Hash)
1340 ));
1341 assert!(matches!(run.effort, Some(TargetEffort::High)));
1342 assert_eq!(run.since, Some("30d"));
1343 assert_eq!(run.min_commits, Some(4));
1344 assert_eq!(run.coverage_inputs.coverage, options.coverage.as_deref());
1345 assert_eq!(
1346 run.coverage_inputs.coverage_root,
1347 options.coverage_root.as_deref()
1348 );
1349 }
1350
1351 #[test]
1352 fn complexity_options_validation_accepts_existing_coverage_path_and_absolute_root() {
1353 let dir = tempfile::tempdir().expect("tempdir");
1354 let coverage = dir.path().join("coverage-final.json");
1355 std::fs::write(&coverage, "{}").expect("coverage fixture");
1356
1357 let result = validate_complexity_options(&ComplexityOptions {
1358 coverage: Some(coverage),
1359 coverage_root: Some(PathBuf::from("/ci/workspace")),
1360 ..ComplexityOptions::default()
1361 });
1362
1363 assert!(result.is_ok());
1364 }
1365
1366 #[test]
1367 fn complexity_options_validation_keeps_missing_coverage_error_contract() {
1368 let err = validate_complexity_options(&ComplexityOptions {
1369 coverage: Some(PathBuf::from("/missing/coverage-final.json")),
1370 ..ComplexityOptions::default()
1371 })
1372 .expect_err("missing coverage path should fail");
1373
1374 assert_eq!(err.exit_code, 2);
1375 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1376 assert_eq!(err.context.as_deref(), Some("health.coverage"));
1377 }
1378
1379 #[test]
1380 fn complexity_options_validation_keeps_relative_coverage_root_error_contract() {
1381 let err = validate_complexity_options(&ComplexityOptions {
1382 coverage_root: Some(PathBuf::from("coverage")),
1383 ..ComplexityOptions::default()
1384 })
1385 .expect_err("relative coverage root should fail");
1386
1387 assert_eq!(err.exit_code, 2);
1388 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_ROOT"));
1389 assert_eq!(err.context.as_deref(), Some("health.coverage_root"));
1390 }
1391
1392 #[test]
1396 fn complexity_options_validation_resolves_relative_coverage_against_root() {
1397 let dir = tempfile::tempdir().expect("tempdir");
1398 std::fs::create_dir_all(dir.path().join("artifacts")).expect("artifacts dir");
1399 std::fs::write(dir.path().join("artifacts/coverage-final.json"), "{}")
1400 .expect("coverage fixture");
1401 let relative = PathBuf::from("artifacts/coverage-final.json");
1402 assert!(
1403 !relative.exists(),
1404 "the fixture must not also exist under the test cwd"
1405 );
1406
1407 let result = validate_complexity_options(&ComplexityOptions {
1408 analysis: AnalysisOptions {
1409 root: Some(dir.path().to_path_buf()),
1410 ..AnalysisOptions::default()
1411 },
1412 coverage: Some(relative.clone()),
1413 ..ComplexityOptions::default()
1414 });
1415 assert!(result.is_ok(), "{result:?}");
1416
1417 let err = validate_complexity_options(&ComplexityOptions {
1418 analysis: AnalysisOptions {
1419 root: Some(dir.path().join("elsewhere")),
1420 ..AnalysisOptions::default()
1421 },
1422 coverage: Some(relative),
1423 ..ComplexityOptions::default()
1424 })
1425 .expect_err("the path does not exist under the other root");
1426 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1427 assert!(
1428 err.message.contains("elsewhere"),
1429 "the message names the resolved path: {}",
1430 err.message
1431 );
1432 }
1433
1434 #[test]
1435 fn default_health_sections_match_full_health_output() {
1436 let derived = derive_health_sections(&HealthSectionOptions {
1437 output: fallow_types::output_format::OutputFormat::Human,
1438 complexity: false,
1439 file_scores: false,
1440 coverage_gaps: false,
1441 hotspots: false,
1442 targets: false,
1443 css: false,
1444 score: false,
1445 score_gate: false,
1446 snapshot_requested: false,
1447 trend: false,
1448 });
1449
1450 assert!(!derived.any_section);
1451 assert!(derived.complexity);
1452 assert!(derived.file_scores);
1453 assert!(!derived.coverage_gaps);
1454 assert!(derived.hotspots);
1455 assert!(derived.targets);
1456 assert!(derived.score);
1457 assert!(derived.force_full);
1458 assert!(!derived.score_only_output);
1459 }
1460
1461 #[test]
1462 fn health_score_gate_requests_score_only_output() {
1463 let derived = derive_health_sections(&HealthSectionOptions {
1464 output: fallow_types::output_format::OutputFormat::Human,
1465 complexity: false,
1466 file_scores: false,
1467 coverage_gaps: false,
1468 hotspots: false,
1469 targets: false,
1470 css: false,
1471 score: false,
1472 score_gate: true,
1473 snapshot_requested: false,
1474 trend: false,
1475 });
1476
1477 assert!(derived.any_section);
1478 assert!(!derived.complexity);
1479 assert!(derived.file_scores);
1480 assert!(!derived.hotspots);
1481 assert!(!derived.targets);
1482 assert!(derived.score);
1483 assert!(derived.force_full);
1484 assert!(derived.score_only_output);
1485 }
1486
1487 #[test]
1488 fn health_snapshot_keeps_full_hidden_inputs_without_section_request() {
1489 let derived = derive_health_sections(&HealthSectionOptions {
1490 output: fallow_types::output_format::OutputFormat::Human,
1491 complexity: false,
1492 file_scores: false,
1493 coverage_gaps: false,
1494 hotspots: false,
1495 targets: false,
1496 css: true,
1497 score: false,
1498 score_gate: false,
1499 snapshot_requested: true,
1500 trend: false,
1501 });
1502
1503 assert!(!derived.any_section);
1504 assert!(derived.css);
1505 assert!(derived.file_scores);
1506 assert!(derived.hotspots);
1507 assert!(derived.score);
1508 assert!(derived.force_full);
1509 }
1510}