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, run_doctor_with_cache_dir};
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::levenshtein::closest_match;
139pub use fallow_config::{AuditGate, HealthConfig, TypeAwareRequire};
140pub use fallow_output::{RootEnvelopeMode, serialize_similar_code_json_output};
141pub use fallow_types::trace::{
142 CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace, ReExportChain,
143 TracedCloneGroup, TracedExport, TracedReExport,
144};
145pub use grouped_output::{
146 ResultGroup, UNOWNED_GROUP_LABEL, build_duplication_grouping_with, group_analysis_results_with,
147 largest_clone_group_owner_with,
148};
149pub use health_codeclimate::build_health_codeclimate;
150pub use json_output::{
151 CheckJsonExtraOutputs, CheckJsonOutputInput, CheckJsonPayloadInput, DuplicationJsonOutputInput,
152 GroupedCheckJsonOutputInput, GroupedDuplicationJsonOutputInput, serialize_check_json,
153 serialize_check_json_payload, serialize_duplication_json, serialize_grouped_check_json,
154 serialize_grouped_duplication_json,
155};
156pub use list_output::{
157 ListJsonEnvelope, ListJsonOutputInput, build_list_json_output, serialize_list_json_output,
158};
159pub use list_runtime::{
160 BoundaryData, ListBoundariesOptions, ListBoundariesProgrammaticOutput, LogicalGroupInfo,
161 ProjectInfoOptions, ProjectInfoProgrammaticOutput, RuleInfo, ZoneInfo, boundary_data_to_output,
162 compute_boundary_data, run_list_boundaries, run_project_info,
163 serialize_list_boundaries_programmatic_json, serialize_project_info_programmatic_json,
164};
165pub use markdown_output::{
166 build_duplication_markdown, build_grouped_markdown, build_health_markdown, build_markdown,
167 build_walkthrough_markdown,
168};
169pub use output_contracts::{
170 AuditOutput, BoundariesListLogicalGroup, BoundariesListRule, BoundariesListZone,
171 BoundariesListing, CombinedOutput, FallowOutput, ImpactOutput, ListBoundariesOutput,
172 ListEntryPointOutput, ListOutput, ListPluginOutput, ReviewBriefWireOutput, SecurityGate,
173 SecurityOutput, SecurityOutputConfig, SecuritySummaryOutput, SimilarCodeCandidateSnapshot,
174 SimilarCodeOutput, TraceOutput, WorkspacesOutput,
175};
176pub use runtime::{
177 AuditProgrammaticKeySnapshot, AuditProgrammaticOutput, BoundaryViolationsOutput,
178 BoundaryViolationsProgrammaticOutput, CircularDependenciesOutput,
179 CircularDependenciesProgrammaticOutput, CombinedProgrammaticOutput, DeadCodeOutput,
180 DeadCodeProgrammaticOutput, DecisionSurfaceProgrammaticOutput, DuplicationOutput,
181 DuplicationProgrammaticOutput, EngineHealthRunner, FeatureFlagsOutput,
182 FeatureFlagsProgrammaticOutput, HealthJsonReportInput, HealthProgrammaticOutput,
183 ProgrammaticHealthAnalysis, ProgrammaticHealthNextStepFacts, ProgrammaticHealthRun,
184 ProgrammaticHealthRunner, TraceClassMemberOutput, TraceCloneBenchmarkResult, TraceCloneOutput,
185 TraceCloneProgrammaticOutput, TraceDependencyOutput, TraceDependencyProgrammaticOutput,
186 TraceErrorOutput, TraceErrorProgrammaticOutput, TraceExportOutput,
187 TraceExportProgrammaticOutput, TraceExportTargetOutput, TraceFileOutput,
188 TraceFileProgrammaticOutput, TraceImportPathOutput, TraceImportPathProgrammaticOutput,
189 benchmark_trace_clone_compact_json, benchmark_trace_graph_family_compact_json,
190 inspect_similar_code, load_health_config, parse_similar_code_candidate_snapshot,
191 review_similar_code, run_audit, run_boundary_violations, run_circular_dependencies,
192 run_combined, run_complexity_with_runner, run_dead_code, run_decision_surface, run_duplication,
193 run_feature_flags, run_health, run_health_with_runner, run_similar_code, run_trace_clone,
194 run_trace_dependency, run_trace_error, run_trace_export, run_trace_file, run_trace_import_path,
195 select_similar_code_candidate_snapshot, serialize_health_report_json,
196};
197pub use runtime_json::{
198 serialize_audit_programmatic_json, serialize_boundary_violations_programmatic_json,
199 serialize_circular_dependencies_programmatic_json, serialize_combined_programmatic_json,
200 serialize_dead_code_programmatic_json, serialize_decision_surface_programmatic_json,
201 serialize_duplication_programmatic_json, serialize_feature_flags_programmatic_json,
202 serialize_health_programmatic_json, serialize_trace_clone_programmatic_json,
203 serialize_trace_dependency_programmatic_json, serialize_trace_error_programmatic_json,
204 serialize_trace_export_programmatic_json, serialize_trace_file_programmatic_json,
205 serialize_trace_import_path_programmatic_json,
206};
207pub use sarif_output::{
208 annotate_sarif_results, build_duplication_sarif, build_grouped_duplication_sarif,
209 build_health_sarif,
210};
211pub use security_output::SecurityGateMode;
212pub use type_aware::{
213 SemanticCouplingOutcome, SemanticDeadCodeOutcome, SemanticInspectOutcome, TypeAwareError,
214 TypeAwareFileChanges, TypeAwareOutcome, TypeAwareSession, TypeAwareStatus,
215 discard_unverified_semantic_candidates, inspect_symbol as inspect_type_aware_symbol,
216 merge_type_aware_meta,
217 refine_configured_dead_code_results as refine_type_aware_results_with_config,
218 refine_configured_dead_code_results_in_session as refine_type_aware_results_in_session_with_config,
219 refine_dead_code_results as refine_type_aware_results,
220 refine_dead_code_results_in_session as refine_type_aware_results_in_session,
221 refine_programmatic_dead_code as refine_type_aware_dead_code, shutdown_type_aware_sidecars,
222 status as type_aware_status, symbol_impact as run_type_aware_symbol_impact,
223 symbol_impact as type_aware_symbol_impact, terminate_active_type_aware_sidecars,
224 trace_symbol as run_type_aware_symbol_trace, trace_symbol as trace_type_aware_symbol,
225 type_coupling as analyze_type_coupling,
226};
227
228pub const COMMON_ANALYSIS_OPTION_FLAGS: &[&str] = &[
235 "root",
236 "config",
237 "no-cache",
238 "threads",
239 "changed-since",
240 "diff-file",
241 "production",
242 "workspace",
243 "changed-workspaces",
244 "explain",
245 "allow-remote-extends",
246];
247
248#[derive(Debug, Clone, Serialize)]
250pub struct ProgrammaticError {
251 pub message: String,
253 pub exit_code: u8,
255 pub code: Option<String>,
257 pub help: Option<String>,
259 pub context: Option<String>,
261}
262
263impl ProgrammaticError {
264 #[must_use]
267 pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
268 Self {
269 message: message.into(),
270 exit_code,
271 code: None,
272 help: None,
273 context: None,
274 }
275 }
276
277 #[must_use]
279 pub fn with_help(mut self, help: impl Into<String>) -> Self {
280 self.help = Some(help.into());
281 self
282 }
283
284 #[must_use]
287 pub fn with_code(mut self, code: impl Into<String>) -> Self {
288 self.code = Some(code.into());
289 self
290 }
291
292 #[must_use]
295 pub fn with_context(mut self, context: impl Into<String>) -> Self {
296 self.context = Some(context.into());
297 self
298 }
299}
300
301impl std::fmt::Display for ProgrammaticError {
302 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
303 write!(f, "{}", self.message)
304 }
305}
306
307impl std::error::Error for ProgrammaticError {}
308
309#[derive(Debug, Clone, Default)]
311pub struct AnalysisOptions {
312 pub root: Option<PathBuf>,
315 pub config_path: Option<PathBuf>,
317 pub allow_remote_extends: bool,
319 pub no_cache: bool,
321 pub threads: Option<usize>,
323 pub diff_file: Option<PathBuf>,
325 pub production: bool,
328 pub production_override: Option<bool>,
331 pub changed_since: Option<String>,
333 pub workspace: Option<Vec<String>>,
335 pub changed_workspaces: Option<String>,
337 pub explain: bool,
339 pub type_aware: TypeAwareOptions,
342 pub cancellation: Option<Arc<AtomicBool>>,
371}
372
373#[derive(Debug, Clone, Default, PartialEq, Eq)]
375pub struct TypeAwareOptions {
376 pub enabled: bool,
378 pub projects: Vec<PathBuf>,
381 pub require: fallow_config::TypeAwareRequire,
383}
384
385#[derive(Debug, Clone, Default)]
390pub struct DeadCodeFilters {
391 pub unused_files: bool,
393 pub unused_exports: bool,
395 pub unused_deps: bool,
397 pub unused_types: bool,
399 pub private_type_leaks: bool,
401 pub unused_enum_members: bool,
403 pub unused_class_members: bool,
405 pub unused_store_members: bool,
407 pub unprovided_injects: bool,
409 pub unrendered_components: bool,
411 pub unused_component_props: bool,
413 pub unused_component_emits: bool,
415 pub unused_component_inputs: bool,
417 pub unused_component_outputs: bool,
419 pub unused_svelte_events: bool,
421 pub unused_server_actions: bool,
423 pub unused_load_data_keys: bool,
425 pub unresolved_imports: bool,
427 pub unlisted_deps: bool,
429 pub duplicate_exports: bool,
431 pub circular_deps: bool,
433 pub re_export_cycles: bool,
435 pub boundary_violations: bool,
437 pub policy_violations: bool,
439 pub stale_suppressions: bool,
441 pub unused_catalog_entries: bool,
443 pub empty_catalog_groups: bool,
445 pub unresolved_catalog_references: bool,
447 pub unused_dependency_overrides: bool,
449 pub misconfigured_dependency_overrides: bool,
451}
452
453impl DeadCodeFilters {
454 fn any_active(&self) -> bool {
455 self.unused_files
456 || self.unused_exports
457 || self.unused_deps
458 || self.unused_types
459 || self.private_type_leaks
460 || self.unused_enum_members
461 || self.unused_class_members
462 || self.unused_store_members
463 || self.unprovided_injects
464 || self.unrendered_components
465 || self.unused_component_props
466 || self.unused_component_emits
467 || self.unused_component_inputs
468 || self.unused_component_outputs
469 || self.unused_svelte_events
470 || self.unused_server_actions
471 || self.unused_load_data_keys
472 || self.unresolved_imports
473 || self.unlisted_deps
474 || self.duplicate_exports
475 || self.circular_deps
476 || self.re_export_cycles
477 || self.boundary_violations
478 || self.policy_violations
479 || self.stale_suppressions
480 || self.unused_catalog_entries
481 || self.empty_catalog_groups
482 || self.unresolved_catalog_references
483 || self.unused_dependency_overrides
484 || self.misconfigured_dependency_overrides
485 }
486
487 pub fn enable_registry_selector(&mut self, selector: &str) -> bool {
493 let Some(flag) = fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS
494 .iter()
495 .find_map(|&(name, flag)| (name == selector).then_some(flag))
496 else {
497 return false;
498 };
499 self.enable_cli_filter_flag(flag);
500 true
501 }
502
503 fn enable_cli_filter_flag(&mut self, flag: &str) {
504 match flag {
505 "--unused-files" => self.unused_files = true,
506 "--unused-exports" => self.unused_exports = true,
507 "--unused-types" => self.unused_types = true,
508 "--private-type-leaks" => self.private_type_leaks = true,
509 "--unused-deps" => self.unused_deps = true,
510 "--unused-enum-members" => self.unused_enum_members = true,
511 "--unused-class-members" => self.unused_class_members = true,
512 "--unused-store-members" => self.unused_store_members = true,
513 "--unprovided-injects" => self.unprovided_injects = true,
514 "--unrendered-components" => self.unrendered_components = true,
515 "--unused-component-props" => self.unused_component_props = true,
516 "--unused-component-emits" => self.unused_component_emits = true,
517 "--unused-component-inputs" => self.unused_component_inputs = true,
518 "--unused-component-outputs" => self.unused_component_outputs = true,
519 "--unused-svelte-events" => self.unused_svelte_events = true,
520 "--unused-server-actions" => self.unused_server_actions = true,
521 "--unused-load-data-keys" => self.unused_load_data_keys = true,
522 "--unresolved-imports" => self.unresolved_imports = true,
523 "--unlisted-deps" => self.unlisted_deps = true,
524 "--duplicate-exports" => self.duplicate_exports = true,
525 "--circular-deps" => self.circular_deps = true,
526 "--re-export-cycles" => self.re_export_cycles = true,
527 "--boundary-violations" => self.boundary_violations = true,
528 "--policy-violations" => self.policy_violations = true,
529 "--stale-suppressions" => self.stale_suppressions = true,
530 "--unused-catalog-entries" => self.unused_catalog_entries = true,
531 "--empty-catalog-groups" => self.empty_catalog_groups = true,
532 "--unresolved-catalog-references" => self.unresolved_catalog_references = true,
533 "--unused-dependency-overrides" => self.unused_dependency_overrides = true,
534 "--misconfigured-dependency-overrides" => {
535 self.misconfigured_dependency_overrides = true;
536 }
537 _ => unreachable!("registry emitted unsupported dead-code filter flag: {flag}"),
538 }
539 }
540}
541
542#[derive(Debug, Clone, Default)]
544pub struct DeadCodeOptions {
545 pub analysis: AnalysisOptions,
547 pub filters: DeadCodeFilters,
549 pub files: Vec<PathBuf>,
551 pub include_entry_exports: bool,
553}
554
555#[derive(Debug, Clone, Default)]
557pub struct AuditOptions {
558 pub analysis: AnalysisOptions,
560 pub base: Option<String>,
563 pub production: bool,
565 pub production_dead_code: Option<bool>,
567 pub production_health: Option<bool>,
569 pub production_dupes: Option<bool>,
572 pub css: Option<bool>,
574 pub css_deep: Option<bool>,
576 pub gate: fallow_config::AuditGate,
578 pub max_crap: Option<f64>,
580 pub coverage: Option<PathBuf>,
582 pub coverage_root: Option<PathBuf>,
584 pub include_entry_exports: bool,
586 pub runtime_coverage: Option<PathBuf>,
588 pub min_invocations_hot: u64,
590}
591
592#[derive(Debug, Clone)]
594pub struct CombinedOptions {
595 pub analysis: AnalysisOptions,
597 pub dead_code: bool,
599 pub duplication: bool,
601 pub health: bool,
603 pub include_entry_exports: bool,
605 pub duplication_options: DuplicationOptions,
607 pub health_options: ComplexityOptions,
609}
610
611impl Default for CombinedOptions {
612 fn default() -> Self {
613 Self {
614 analysis: AnalysisOptions::default(),
615 dead_code: true,
616 duplication: true,
617 health: true,
618 include_entry_exports: false,
619 duplication_options: DuplicationOptions::default(),
620 health_options: ComplexityOptions::default(),
621 }
622 }
623}
624
625#[derive(Debug, Clone, Default)]
627pub struct DecisionSurfaceOptions {
628 pub analysis: AnalysisOptions,
630 pub base: Option<String>,
633 pub max_decisions: Option<usize>,
635}
636
637#[derive(Debug, Clone, Default)]
639pub struct FeatureFlagsOptions {
640 pub analysis: AnalysisOptions,
642 pub top: Option<usize>,
644}
645
646#[derive(Debug, Clone, Copy, Default)]
648pub enum DuplicationMode {
649 Strict,
652 #[default]
654 Mild,
655 Weak,
657 Semantic,
660}
661
662#[derive(Debug, Clone, Default)]
664pub struct DuplicationOptions {
665 pub analysis: AnalysisOptions,
667 pub mode: Option<DuplicationMode>,
669 pub near: Option<bool>,
672 pub min_tokens: Option<usize>,
674 pub min_lines: Option<usize>,
676 pub min_occurrences: Option<usize>,
679 pub threshold: Option<f64>,
682 pub skip_local: Option<bool>,
685 pub cross_language: Option<bool>,
687 pub ignore_imports: Option<bool>,
690 pub top: Option<usize>,
692 pub include_fragments: Option<bool>,
696}
697
698#[derive(Debug, Clone, Default)]
700pub struct SimilarCodeOptions {
701 pub analysis: AnalysisOptions,
703 pub threshold: Option<f64>,
705 pub min_lines: Option<usize>,
708 pub top: Option<usize>,
710 pub files: Vec<PathBuf>,
714 #[doc(hidden)]
719 pub adapter_provider_path: Option<PathBuf>,
720}
721
722#[derive(Debug, Clone)]
724pub struct SimilarCodeInspectOptions {
725 pub analysis: AnalysisOptions,
728 pub snapshot: fallow_output::SimilarCodeCandidateSnapshot,
730}
731
732#[derive(Debug, Clone, Default)]
734pub struct TraceExportOptions {
735 pub analysis: AnalysisOptions,
737 pub file: String,
739 pub export_name: String,
741}
742
743#[derive(Debug, Clone, Default)]
745pub struct TraceFileOptions {
746 pub analysis: AnalysisOptions,
748 pub file: String,
750}
751
752#[derive(Debug, Clone, Default)]
754pub struct TraceImportPathOptions {
755 pub analysis: AnalysisOptions,
757 pub from: String,
759 pub to: String,
761}
762
763#[derive(Debug, Clone, Default)]
765pub struct TraceErrorOptions {
766 pub analysis: AnalysisOptions,
768 pub trace: String,
770 pub source: String,
772}
773
774#[derive(Debug, Clone, Default)]
776pub struct TraceDependencyOptions {
777 pub analysis: AnalysisOptions,
779 pub package_name: String,
781}
782
783#[derive(Debug, Clone, PartialEq, Eq)]
785pub enum TraceCloneTarget {
786 Location {
788 file: String,
790 line: usize,
792 },
793 Fingerprint(String),
795}
796
797#[derive(Debug, Clone)]
799pub struct TraceCloneOptions {
800 pub duplication: DuplicationOptions,
802 pub target: TraceCloneTarget,
804}
805
806#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
808pub enum ComplexitySort {
809 #[default]
811 Cyclomatic,
812 Cognitive,
814 Lines,
816 Severity,
818}
819
820#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
822pub enum OwnershipEmailMode {
823 Raw,
825 #[default]
827 Handle,
828 Anonymized,
830 Hash,
832}
833
834#[derive(Debug, Clone, Copy, PartialEq, Eq)]
836pub enum TargetEffort {
837 Low,
839 Medium,
841 High,
843}
844
845#[derive(Debug, Clone, Default)]
847pub struct ComplexityOptions {
848 pub analysis: AnalysisOptions,
850 pub max_cyclomatic: Option<u16>,
852 pub max_cognitive: Option<u16>,
854 pub max_crap: Option<f64>,
856 pub top: Option<usize>,
858 pub sort: ComplexitySort,
860 pub complexity_breakdown: bool,
862 pub complexity: bool,
864 pub file_scores: bool,
866 pub coverage_gaps: bool,
868 pub hotspots: bool,
870 pub ownership: bool,
872 pub ownership_emails: Option<OwnershipEmailMode>,
874 pub targets: bool,
876 pub css: bool,
878 pub css_deep: bool,
880 pub effort: Option<TargetEffort>,
883 pub score: bool,
885 pub since: Option<String>,
887 pub min_commits: Option<u32>,
889 pub coverage: Option<PathBuf>,
891 pub coverage_root: Option<PathBuf>,
893 pub coverage_relocated: bool,
898}
899
900#[derive(Debug, Clone, Copy, Default, PartialEq)]
902pub struct ComplexityThresholdOverrides {
903 pub max_cyclomatic: Option<u16>,
905 pub max_cognitive: Option<u16>,
907 pub max_crap: Option<f64>,
909}
910
911#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
913pub struct ComplexityCoverageInputs<'a> {
914 pub coverage: Option<&'a Path>,
916 pub coverage_root: Option<&'a Path>,
918 pub coverage_relocated: bool,
921}
922
923#[derive(Debug, Clone)]
925pub struct HealthSectionOptions {
926 pub output: fallow_types::output_format::OutputFormat,
928 pub complexity: bool,
930 pub file_scores: bool,
932 pub coverage_gaps: bool,
934 pub hotspots: bool,
936 pub targets: bool,
938 pub css: bool,
940 pub score: bool,
942 pub score_gate: bool,
944 pub snapshot_requested: bool,
946 pub trend: bool,
948}
949
950#[derive(Debug, Clone, Copy, PartialEq, Eq)]
952pub struct DerivedHealthSections {
953 pub any_section: bool,
955 pub complexity: bool,
957 pub file_scores: bool,
959 pub coverage_gaps: bool,
961 pub hotspots: bool,
963 pub targets: bool,
965 pub css: bool,
967 pub score: bool,
969 pub force_full: bool,
971 pub score_only_output: bool,
973}
974
975#[derive(Debug, Clone)]
977pub struct ComplexitySectionOptions {
978 pub complexity: bool,
980 pub file_scores: bool,
982 pub coverage_gaps: bool,
984 pub hotspots: bool,
986 pub ownership: bool,
988 pub targets: bool,
990 pub css: bool,
992 pub score: bool,
994}
995
996#[derive(Debug, Clone, Copy, PartialEq, Eq)]
998pub struct DerivedComplexityOptions {
999 pub any_section: bool,
1001 pub complexity: bool,
1003 pub file_scores: bool,
1005 pub coverage_gaps: bool,
1007 pub hotspots: bool,
1009 pub ownership: bool,
1011 pub targets: bool,
1013 pub force_full: bool,
1015 pub score_only_output: bool,
1017 pub score: bool,
1019}
1020
1021#[derive(Debug, Clone, PartialEq)]
1023pub struct ComplexityRunOptions<'a> {
1024 pub thresholds: ComplexityThresholdOverrides,
1026 pub top: Option<usize>,
1028 pub sort: ComplexitySort,
1030 pub complexity_breakdown: bool,
1032 pub sections: DerivedComplexityOptions,
1034 pub ownership_emails: Option<OwnershipEmailMode>,
1036 pub effort: Option<TargetEffort>,
1038 pub css: bool,
1040 pub css_deep: bool,
1042 pub since: Option<&'a str>,
1044 pub min_commits: Option<u32>,
1046 pub coverage_inputs: ComplexityCoverageInputs<'a>,
1048}
1049
1050#[must_use]
1052pub fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
1053 let score = options.score
1054 || options.score_gate
1055 || options.trend
1056 || matches!(
1057 options.output,
1058 fallow_types::output_format::OutputFormat::Badge
1059 );
1060 let any_section = options.complexity
1061 || options.file_scores
1062 || options.coverage_gaps
1063 || options.hotspots
1064 || options.targets
1065 || score;
1066 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
1067 let force_full = options.snapshot_requested || effective_score;
1068
1069 DerivedHealthSections {
1070 any_section,
1071 complexity: if any_section {
1072 options.complexity
1073 } else {
1074 true
1075 },
1076 file_scores: if any_section {
1077 options.file_scores
1078 } else {
1079 true
1080 } || force_full,
1081 coverage_gaps: if any_section {
1082 options.coverage_gaps
1083 } else {
1084 false
1085 },
1086 hotspots: if any_section { options.hotspots } else { true }
1087 || options.snapshot_requested
1088 || options.trend,
1089 targets: if any_section { options.targets } else { true },
1090 css: options.css,
1091 score: effective_score,
1092 force_full,
1093 score_only_output: is_health_score_only_output(options, score),
1094 }
1095}
1096
1097#[must_use]
1099pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
1100 let requested_hotspots = options.hotspots || options.ownership;
1101 let sections = derive_health_sections(&HealthSectionOptions {
1102 output: fallow_types::output_format::OutputFormat::Human,
1103 complexity: options.complexity,
1104 file_scores: options.file_scores,
1105 coverage_gaps: options.coverage_gaps,
1106 hotspots: requested_hotspots,
1107 targets: options.targets,
1108 css: options.css,
1109 score: options.score,
1110 score_gate: false,
1111 snapshot_requested: false,
1112 trend: false,
1113 });
1114
1115 DerivedComplexityOptions {
1116 any_section: sections.any_section,
1117 complexity: sections.complexity,
1118 file_scores: sections.file_scores,
1119 coverage_gaps: sections.coverage_gaps,
1120 hotspots: sections.hotspots,
1121 ownership: options.ownership && sections.hotspots,
1122 targets: sections.targets,
1123 force_full: sections.force_full,
1124 score_only_output: sections.score_only_output,
1125 score: sections.score,
1126 }
1127}
1128
1129#[must_use]
1131pub fn derive_complexity_options(options: &ComplexityOptions) -> DerivedComplexityOptions {
1132 derive_complexity_sections(&complexity_section_options(options))
1133}
1134
1135#[must_use]
1137pub fn derive_complexity_run_options(options: &ComplexityOptions) -> ComplexityRunOptions<'_> {
1138 ComplexityRunOptions {
1139 thresholds: ComplexityThresholdOverrides {
1140 max_cyclomatic: options.max_cyclomatic,
1141 max_cognitive: options.max_cognitive,
1142 max_crap: options.max_crap,
1143 },
1144 top: options.top,
1145 sort: options.sort,
1146 complexity_breakdown: options.complexity_breakdown,
1147 sections: derive_complexity_options(options),
1148 ownership_emails: options.ownership_emails,
1149 effort: options.effort,
1150 css: options.css,
1151 css_deep: options.css_deep,
1152 since: options.since.as_deref(),
1153 min_commits: options.min_commits,
1154 coverage_inputs: ComplexityCoverageInputs {
1155 coverage: options.coverage.as_deref(),
1156 coverage_root: options.coverage_root.as_deref(),
1157 coverage_relocated: options.coverage_relocated,
1158 },
1159 }
1160}
1161
1162pub fn validate_complexity_options(options: &ComplexityOptions) -> Result<(), ProgrammaticError> {
1185 if let Some(path) = &options.coverage {
1186 let resolved = fallow_engine::health::scoring::resolve_relative_to_root(
1187 path,
1188 options.analysis.root.as_deref(),
1189 );
1190 if !resolved.exists() {
1191 return Err(ProgrammaticError::new(
1192 format!("coverage path does not exist: {}", resolved.display()),
1193 2,
1194 )
1195 .with_code("FALLOW_INVALID_COVERAGE_PATH")
1196 .with_context("health.coverage"));
1197 }
1198 }
1199 if let Err(message) =
1200 fallow_engine::health::validate_coverage_root_absolute(options.coverage_root.as_deref())
1201 {
1202 return Err(ProgrammaticError::new(message, 2)
1203 .with_code("FALLOW_INVALID_COVERAGE_ROOT")
1204 .with_context("health.coverage_root"));
1205 }
1206
1207 Ok(())
1208}
1209
1210fn complexity_section_options(options: &ComplexityOptions) -> ComplexitySectionOptions {
1211 let ownership = options.ownership || options.ownership_emails.is_some();
1212 let requested_targets = options.targets || options.effort.is_some();
1213 ComplexitySectionOptions {
1214 complexity: options.complexity,
1215 file_scores: options.file_scores,
1216 coverage_gaps: options.coverage_gaps,
1217 hotspots: options.hotspots,
1218 ownership,
1219 targets: requested_targets,
1220 css: options.css,
1221 score: options.score,
1222 }
1223}
1224
1225fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
1226 score
1227 && !options.complexity
1228 && !options.file_scores
1229 && !options.coverage_gaps
1230 && !options.hotspots
1231 && !options.targets
1232 && !options.trend
1233}
1234
1235const fn thresholds_to_engine(
1236 thresholds: ComplexityThresholdOverrides,
1237) -> fallow_engine::health::HealthThresholdOverrides {
1238 fallow_engine::health::HealthThresholdOverrides {
1239 max_cyclomatic: thresholds.max_cyclomatic,
1240 max_cognitive: thresholds.max_cognitive,
1241 max_crap: thresholds.max_crap,
1242 }
1243}
1244
1245const fn complexity_sort_to_engine(sort: ComplexitySort) -> fallow_engine::health::HealthSort {
1246 match sort {
1247 ComplexitySort::Severity => fallow_engine::health::HealthSort::Severity,
1248 ComplexitySort::Cyclomatic => fallow_engine::health::HealthSort::Cyclomatic,
1249 ComplexitySort::Cognitive => fallow_engine::health::HealthSort::Cognitive,
1250 ComplexitySort::Lines => fallow_engine::health::HealthSort::Lines,
1251 }
1252}
1253
1254const fn coverage_inputs_to_engine(
1255 coverage_inputs: ComplexityCoverageInputs<'_>,
1256) -> fallow_engine::health::HealthCoverageInputs<'_> {
1257 fallow_engine::health::HealthCoverageInputs {
1258 coverage: coverage_inputs.coverage,
1259 coverage_root: coverage_inputs.coverage_root,
1260 coverage_relocated: coverage_inputs.coverage_relocated,
1261 }
1262}
1263
1264const fn ownership_email_mode_to_config(mode: OwnershipEmailMode) -> EmailMode {
1265 match mode {
1266 OwnershipEmailMode::Raw => EmailMode::Raw,
1267 OwnershipEmailMode::Handle => EmailMode::Handle,
1268 OwnershipEmailMode::Anonymized => EmailMode::Anonymized,
1269 OwnershipEmailMode::Hash => EmailMode::Hash,
1270 }
1271}
1272
1273const fn target_effort_to_output(effort: TargetEffort) -> EffortEstimate {
1274 match effort {
1275 TargetEffort::Low => EffortEstimate::Low,
1276 TargetEffort::Medium => EffortEstimate::Medium,
1277 TargetEffort::High => EffortEstimate::High,
1278 }
1279}
1280
1281#[cfg(test)]
1282mod tests {
1283 use super::*;
1284
1285 #[test]
1286 fn duplication_defaults_match_cli_contract() {
1287 let options = DuplicationOptions::default();
1288 assert!(options.mode.is_none());
1289 assert!(options.min_tokens.is_none());
1290 assert!(options.min_lines.is_none());
1291 assert!(options.min_occurrences.is_none());
1292 }
1293
1294 #[test]
1295 fn programmatic_error_builder_keeps_optional_fields() {
1296 let error = ProgrammaticError::new("boom", 2)
1297 .with_code("FALLOW_TEST")
1298 .with_help("Try again")
1299 .with_context("analysis.root");
1300
1301 assert_eq!(error.message, "boom");
1302 assert_eq!(error.exit_code, 2);
1303 assert_eq!(error.code.as_deref(), Some("FALLOW_TEST"));
1304 assert_eq!(error.help.as_deref(), Some("Try again"));
1305 assert_eq!(error.context.as_deref(), Some("analysis.root"));
1306 }
1307
1308 #[test]
1309 fn dead_code_filters_accept_shared_registry_selectors() {
1310 for (selector, _) in fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS.iter() {
1311 let mut filters = DeadCodeFilters::default();
1312 assert!(
1313 filters.enable_registry_selector(selector),
1314 "{selector} should be accepted"
1315 );
1316 }
1317
1318 let mut filters = DeadCodeFilters::default();
1319 assert!(filters.enable_registry_selector("unused-files"));
1320 assert!(filters.unused_files);
1321 assert!(filters.enable_registry_selector("boundary-violations"));
1322 assert!(filters.boundary_violations);
1323 assert!(!filters.enable_registry_selector("not-a-real-selector"));
1324 }
1325
1326 #[test]
1327 fn default_complexity_options_match_programmatic_health_defaults() {
1328 let derived = derive_complexity_options(&ComplexityOptions::default());
1329
1330 assert!(!derived.any_section);
1331 assert!(derived.complexity);
1332 assert!(derived.file_scores);
1333 assert!(!derived.coverage_gaps);
1334 assert!(derived.hotspots);
1335 assert!(!derived.ownership);
1336 assert!(derived.targets);
1337 assert!(derived.force_full);
1338 assert!(!derived.score_only_output);
1339 assert!(derived.score);
1340 }
1341
1342 #[test]
1343 fn score_only_complexity_options_request_score_only_output() {
1344 let derived = derive_complexity_options(&ComplexityOptions {
1345 score: true,
1346 ..ComplexityOptions::default()
1347 });
1348
1349 assert!(derived.any_section);
1350 assert!(!derived.complexity);
1351 assert!(derived.file_scores);
1352 assert!(!derived.hotspots);
1353 assert!(!derived.targets);
1354 assert!(derived.force_full);
1355 assert!(derived.score_only_output);
1356 assert!(derived.score);
1357 }
1358
1359 #[test]
1360 fn ownership_implies_hotspots_when_requested() {
1361 let derived = derive_complexity_options(&ComplexityOptions {
1362 ownership: true,
1363 ..ComplexityOptions::default()
1364 });
1365
1366 assert!(derived.any_section);
1367 assert!(derived.hotspots);
1368 assert!(derived.ownership);
1369 assert!(!derived.targets);
1370 }
1371
1372 #[test]
1373 fn complexity_run_options_normalize_public_api_options() {
1374 let options = ComplexityOptions {
1375 max_cyclomatic: Some(42),
1376 max_cognitive: Some(21),
1377 max_crap: Some(18.5),
1378 top: Some(7),
1379 sort: ComplexitySort::Severity,
1380 complexity_breakdown: true,
1381 ownership_emails: Some(OwnershipEmailMode::Hash),
1382 effort: Some(TargetEffort::High),
1383 coverage: Some(PathBuf::from("coverage/coverage-final.json")),
1384 coverage_root: Some(PathBuf::from("/ci/workspace")),
1385 since: Some("30d".to_string()),
1386 min_commits: Some(4),
1387 ..ComplexityOptions::default()
1388 };
1389
1390 let run = derive_complexity_run_options(&options);
1391
1392 assert_eq!(run.thresholds.max_cyclomatic, Some(42));
1393 assert_eq!(run.thresholds.max_cognitive, Some(21));
1394 assert_eq!(run.thresholds.max_crap, Some(18.5));
1395 assert_eq!(run.top, Some(7));
1396 assert!(matches!(run.sort, ComplexitySort::Severity));
1397 assert!(run.complexity_breakdown);
1398 assert!(run.sections.hotspots);
1399 assert!(run.sections.ownership);
1400 assert!(run.sections.targets);
1401 assert!(matches!(
1402 run.ownership_emails,
1403 Some(OwnershipEmailMode::Hash)
1404 ));
1405 assert!(matches!(run.effort, Some(TargetEffort::High)));
1406 assert_eq!(run.since, Some("30d"));
1407 assert_eq!(run.min_commits, Some(4));
1408 assert_eq!(run.coverage_inputs.coverage, options.coverage.as_deref());
1409 assert_eq!(
1410 run.coverage_inputs.coverage_root,
1411 options.coverage_root.as_deref()
1412 );
1413 }
1414
1415 #[test]
1416 fn complexity_options_validation_accepts_existing_coverage_path_and_absolute_root() {
1417 let dir = tempfile::tempdir().expect("tempdir");
1418 let coverage = dir.path().join("coverage-final.json");
1419 std::fs::write(&coverage, "{}").expect("coverage fixture");
1420
1421 let result = validate_complexity_options(&ComplexityOptions {
1422 coverage: Some(coverage),
1423 coverage_root: Some(PathBuf::from("/ci/workspace")),
1424 ..ComplexityOptions::default()
1425 });
1426
1427 assert!(result.is_ok());
1428 }
1429
1430 #[test]
1431 fn complexity_options_validation_keeps_missing_coverage_error_contract() {
1432 let err = validate_complexity_options(&ComplexityOptions {
1433 coverage: Some(PathBuf::from("/missing/coverage-final.json")),
1434 ..ComplexityOptions::default()
1435 })
1436 .expect_err("missing coverage path should fail");
1437
1438 assert_eq!(err.exit_code, 2);
1439 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1440 assert_eq!(err.context.as_deref(), Some("health.coverage"));
1441 }
1442
1443 #[test]
1444 fn complexity_options_validation_keeps_relative_coverage_root_error_contract() {
1445 let err = validate_complexity_options(&ComplexityOptions {
1446 coverage_root: Some(PathBuf::from("coverage")),
1447 ..ComplexityOptions::default()
1448 })
1449 .expect_err("relative coverage root should fail");
1450
1451 assert_eq!(err.exit_code, 2);
1452 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_ROOT"));
1453 assert_eq!(err.context.as_deref(), Some("health.coverage_root"));
1454 }
1455
1456 #[test]
1460 fn complexity_options_validation_resolves_relative_coverage_against_root() {
1461 let dir = tempfile::tempdir().expect("tempdir");
1462 std::fs::create_dir_all(dir.path().join("artifacts")).expect("artifacts dir");
1463 std::fs::write(dir.path().join("artifacts/coverage-final.json"), "{}")
1464 .expect("coverage fixture");
1465 let relative = PathBuf::from("artifacts/coverage-final.json");
1466 assert!(
1467 !relative.exists(),
1468 "the fixture must not also exist under the test cwd"
1469 );
1470
1471 let result = validate_complexity_options(&ComplexityOptions {
1472 analysis: AnalysisOptions {
1473 root: Some(dir.path().to_path_buf()),
1474 ..AnalysisOptions::default()
1475 },
1476 coverage: Some(relative.clone()),
1477 ..ComplexityOptions::default()
1478 });
1479 assert!(result.is_ok(), "{result:?}");
1480
1481 let err = validate_complexity_options(&ComplexityOptions {
1482 analysis: AnalysisOptions {
1483 root: Some(dir.path().join("elsewhere")),
1484 ..AnalysisOptions::default()
1485 },
1486 coverage: Some(relative),
1487 ..ComplexityOptions::default()
1488 })
1489 .expect_err("the path does not exist under the other root");
1490 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1491 assert!(
1492 err.message.contains("elsewhere"),
1493 "the message names the resolved path: {}",
1494 err.message
1495 );
1496 }
1497
1498 #[test]
1499 fn default_health_sections_match_full_health_output() {
1500 let derived = derive_health_sections(&HealthSectionOptions {
1501 output: fallow_types::output_format::OutputFormat::Human,
1502 complexity: false,
1503 file_scores: false,
1504 coverage_gaps: false,
1505 hotspots: false,
1506 targets: false,
1507 css: false,
1508 score: false,
1509 score_gate: false,
1510 snapshot_requested: false,
1511 trend: false,
1512 });
1513
1514 assert!(!derived.any_section);
1515 assert!(derived.complexity);
1516 assert!(derived.file_scores);
1517 assert!(!derived.coverage_gaps);
1518 assert!(derived.hotspots);
1519 assert!(derived.targets);
1520 assert!(derived.score);
1521 assert!(derived.force_full);
1522 assert!(!derived.score_only_output);
1523 }
1524
1525 #[test]
1526 fn health_score_gate_requests_score_only_output() {
1527 let derived = derive_health_sections(&HealthSectionOptions {
1528 output: fallow_types::output_format::OutputFormat::Human,
1529 complexity: false,
1530 file_scores: false,
1531 coverage_gaps: false,
1532 hotspots: false,
1533 targets: false,
1534 css: false,
1535 score: false,
1536 score_gate: true,
1537 snapshot_requested: false,
1538 trend: false,
1539 });
1540
1541 assert!(derived.any_section);
1542 assert!(!derived.complexity);
1543 assert!(derived.file_scores);
1544 assert!(!derived.hotspots);
1545 assert!(!derived.targets);
1546 assert!(derived.score);
1547 assert!(derived.force_full);
1548 assert!(derived.score_only_output);
1549 }
1550
1551 #[test]
1552 fn health_snapshot_keeps_full_hidden_inputs_without_section_request() {
1553 let derived = derive_health_sections(&HealthSectionOptions {
1554 output: fallow_types::output_format::OutputFormat::Human,
1555 complexity: false,
1556 file_scores: false,
1557 coverage_gaps: false,
1558 hotspots: false,
1559 targets: false,
1560 css: true,
1561 score: false,
1562 score_gate: false,
1563 snapshot_requested: true,
1564 trend: false,
1565 });
1566
1567 assert!(!derived.any_section);
1568 assert!(derived.css);
1569 assert!(derived.file_scores);
1570 assert!(derived.hotspots);
1571 assert!(derived.score);
1572 assert!(derived.force_full);
1573 }
1574}