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 audit_run;
33pub mod combined_output;
34pub mod compact_output;
37pub mod coverage;
38pub mod dead_code_codeclimate;
39pub mod dead_code_sarif;
40pub mod decision_surface;
41pub mod dependency_deltas;
42pub mod doctor;
43pub mod dupes_output;
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 ownership;
57pub mod review_deltas;
58pub mod routing;
59pub mod runtime;
60mod runtime_json;
61mod runtime_output;
62pub mod sarif_output;
63pub mod schemas;
66pub mod security_output;
67pub mod similar_code;
69mod type_aware;
70pub use analysis_context::{ProgrammaticAnalysisContext, resolve_programmatic_analysis_context};
71pub use audit_output::{
72 AuditAttribution, AuditCodeClimateOutputInput, AuditJsonHeaderInput, AuditJsonOutputInput,
73 AuditSarifOutputInput, AuditSummary, AuditVerdict,
74 attach_audit_duplication_demotion_attribution, attach_audit_styling_attribution,
75 attach_audit_wire_attribution, build_audit_codeclimate, build_audit_codeclimate_issues,
76 build_audit_header_json, build_audit_header_map, build_audit_sarif, build_review_brief_header,
77 serialize_audit_json,
78};
79pub use combined_output::{
80 CombinedCheckJsonSection, CombinedJsonOutputInput, serialize_combined_dupes_json,
81 serialize_combined_health_json, serialize_combined_json,
82};
83pub use compact_output::{
84 build_compact_lines, build_duplication_compact_lines, build_grouped_compact_lines,
85 build_health_compact_lines,
86};
87pub use coverage::{
88 CoverageInputError, CoverageInputSource, CoverageInputs, resolve_coverage_inputs,
89};
90pub use dead_code_codeclimate::build_codeclimate;
91pub use dead_code_sarif::build_sarif;
92pub use doctor::{DoctorOptions, run_doctor, run_doctor_with_cache_dir};
93pub use dupes_output::{
94 AttributedCloneGroup, AttributedCloneGroupFinding, AttributedInstance, CloneDemotionReason,
95 CloneFamilyFinding, CloneGroupFinding, CombinedDupesSection, DupesReportPayload,
96 DuplicationGroup, DuplicationGrouping, build_duplication_codeclimate,
97};
98pub use editor::{
99 ChangedFilesError, EditorAnalysisOutput, EditorAnalysisResults, EditorAnalysisSession,
100 EditorCloneFamily, EditorCloneFingerprintSet, EditorCloneGroup, EditorCloneInstance,
101 EditorDeadCodeAnalysisOutput, EditorDuplicationReport, EditorDuplicationStats,
102 EditorInlineComplexityExceeded, EditorInlineComplexityFinding, EditorMirroredDirectory,
103 EditorProjectAnalysisOutput, EditorRefactoringKind, EditorRefactoringSuggestion,
104 collect_inline_complexity, editor_duplicates, editor_extract, editor_results, editor_security,
105 editor_suppress, filter_inline_complexity_by_changed_files, resolve_git_toplevel,
106 try_get_changed_files_with_toplevel,
107};
108pub use explain::{
109 CHECK_RULES, DUPES_RULES, FLAGS_RULES, HEALTH_RULES, RuleDef, RuleGuide, SECURITY_RULES,
110 all_rules, bare_rule_id, coverage_analyze_meta, coverage_setup_meta, explain_issue_type,
111 rule_by_id, rule_by_token, rule_command, rule_docs_url, rule_guide, rule_severity_key,
112 security_meta, serialize_explain_programmatic_json, unknown_explain_error,
113};
114pub use fallow_config::levenshtein::closest_match;
115pub use fallow_config::{AuditGate, HealthConfig, TypeAwareRequire};
116pub use fallow_output::serialize_similar_code_json_output;
117pub use fallow_types::trace::{
118 CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace, ReExportChain,
119 TracedCloneGroup, TracedExport, TracedReExport,
120};
121pub use grouped_output::{
122 ResultGroup, UNOWNED_GROUP_LABEL, build_duplication_grouping_with, group_analysis_results_with,
123 largest_clone_group_owner_with,
124};
125pub use health_codeclimate::build_health_codeclimate;
126pub use json_output::{
127 CheckJsonExtraOutputs, CheckJsonOutputInput, CheckJsonPayloadInput, DuplicationJsonOutputInput,
128 GroupedCheckJsonOutputInput, GroupedDuplicationJsonOutputInput, serialize_check_json,
129 serialize_check_json_payload, serialize_duplication_json, serialize_grouped_check_json,
130 serialize_grouped_duplication_json,
131};
132pub use list_output::{
133 ListJsonEnvelope, ListJsonOutputInput, build_list_json_output, serialize_list_json_output,
134};
135pub use list_runtime::{
136 BoundaryData, ListBoundariesOptions, ListBoundariesProgrammaticOutput, LogicalGroupInfo,
137 ProjectInfoOptions, ProjectInfoProgrammaticOutput, RuleInfo, ZoneInfo, boundary_data_to_output,
138 compute_boundary_data, run_list_boundaries, run_project_info,
139 serialize_list_boundaries_programmatic_json, serialize_project_info_programmatic_json,
140};
141pub use markdown_output::{
142 build_duplication_markdown, build_grouped_markdown, build_health_markdown, build_markdown,
143 build_walkthrough_markdown,
144};
145pub use output_contracts::{
146 AuditOutput, BoundariesListLogicalGroup, BoundariesListRule, BoundariesListZone,
147 BoundariesListing, CombinedOutput, FallowOutput, ImpactOutput, ListBoundariesOutput,
148 ListEntryPointOutput, ListOutput, ListPluginOutput, ReviewBriefWireOutput, SecurityGate,
149 SecurityOutput, SecurityOutputConfig, SecuritySummaryOutput, SimilarCodeCandidateSnapshot,
150 SimilarCodeOutput, TraceOutput, WorkspacesOutput,
151};
152pub use runtime::{
153 AuditProgrammaticKeySnapshot, AuditProgrammaticOutput, BoundaryViolationsOutput,
154 BoundaryViolationsProgrammaticOutput, CircularDependenciesOutput,
155 CircularDependenciesProgrammaticOutput, CombinedProgrammaticOutput, DeadCodeOutput,
156 DeadCodeProgrammaticOutput, DecisionSurfaceProgrammaticOutput, DuplicationOutput,
157 DuplicationProgrammaticOutput, EngineHealthRunner, FeatureFlagsOutput,
158 FeatureFlagsProgrammaticOutput, HealthJsonReportInput, HealthProgrammaticOutput,
159 ProgrammaticHealthAnalysis, ProgrammaticHealthNextStepFacts, ProgrammaticHealthRun,
160 ProgrammaticHealthRunner, TraceClassMemberOutput, TraceCloneBenchmarkResult, TraceCloneOutput,
161 TraceCloneProgrammaticOutput, TraceDependencyOutput, TraceDependencyProgrammaticOutput,
162 TraceErrorOutput, TraceErrorProgrammaticOutput, TraceExportOutput,
163 TraceExportProgrammaticOutput, TraceExportTargetOutput, TraceFileOutput,
164 TraceFileProgrammaticOutput, TraceImportPathOutput, TraceImportPathProgrammaticOutput,
165 benchmark_trace_clone_compact_json, benchmark_trace_graph_family_compact_json,
166 inspect_similar_code, load_health_config, parse_similar_code_candidate_snapshot,
167 review_similar_code, run_audit, run_boundary_violations, run_circular_dependencies,
168 run_combined, run_complexity_with_runner, run_dead_code, run_dead_code_with_baseline,
169 run_decision_surface, run_duplication, run_feature_flags, run_health, run_health_with_runner,
170 run_similar_code, run_trace_clone, run_trace_dependency, run_trace_error, run_trace_export,
171 run_trace_file, run_trace_import_path, select_similar_code_candidate_snapshot,
172 serialize_health_report_json,
173};
174pub use runtime_json::{
175 serialize_audit_programmatic_json, serialize_boundary_violations_programmatic_json,
176 serialize_circular_dependencies_programmatic_json, serialize_combined_programmatic_json,
177 serialize_dead_code_programmatic_json, serialize_decision_surface_programmatic_json,
178 serialize_duplication_programmatic_json, serialize_feature_flags_programmatic_json,
179 serialize_health_programmatic_json, serialize_trace_clone_programmatic_json,
180 serialize_trace_dependency_programmatic_json, serialize_trace_error_programmatic_json,
181 serialize_trace_export_programmatic_json, serialize_trace_file_programmatic_json,
182 serialize_trace_import_path_programmatic_json,
183};
184pub use sarif_output::{
185 annotate_sarif_results, build_duplication_sarif, build_grouped_duplication_sarif,
186 build_health_sarif,
187};
188pub use security_output::SecurityGateMode;
189pub use type_aware::{
190 SemanticCouplingOutcome, SemanticDeadCodeOutcome, SemanticInspectOutcome, TypeAwareError,
191 TypeAwareFileChanges, TypeAwareOutcome, TypeAwareSession, TypeAwareStatus,
192 discard_unverified_semantic_candidates, inspect_symbol as inspect_type_aware_symbol,
193 merge_type_aware_meta,
194 refine_configured_dead_code_results as refine_type_aware_results_with_config,
195 shutdown_type_aware_sidecars, status as type_aware_status,
196 symbol_impact as run_type_aware_symbol_impact, symbol_impact as type_aware_symbol_impact,
197 terminate_active_type_aware_sidecars, trace_symbol as run_type_aware_symbol_trace,
198 trace_symbol as trace_type_aware_symbol, type_coupling as analyze_type_coupling,
199};
200
201pub const COMMON_ANALYSIS_OPTION_FLAGS: &[&str] = &[
208 "root",
209 "config",
210 "no-cache",
211 "threads",
212 "changed-since",
213 "diff-file",
214 "production",
215 "workspace",
216 "changed-workspaces",
217 "explain",
218 "allow-remote-extends",
219];
220
221#[derive(Debug, Clone, Serialize)]
223pub struct ProgrammaticError {
224 pub message: String,
226 pub exit_code: u8,
228 pub code: Option<String>,
230 pub help: Option<String>,
232 pub context: Option<String>,
234}
235
236impl ProgrammaticError {
237 #[must_use]
240 pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
241 Self {
242 message: message.into(),
243 exit_code,
244 code: None,
245 help: None,
246 context: None,
247 }
248 }
249
250 #[must_use]
252 pub fn with_help(mut self, help: impl Into<String>) -> Self {
253 self.help = Some(help.into());
254 self
255 }
256
257 #[must_use]
260 pub fn with_code(mut self, code: impl Into<String>) -> Self {
261 self.code = Some(code.into());
262 self
263 }
264
265 #[must_use]
268 pub fn with_context(mut self, context: impl Into<String>) -> Self {
269 self.context = Some(context.into());
270 self
271 }
272}
273
274impl std::fmt::Display for ProgrammaticError {
275 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
276 write!(f, "{}", self.message)
277 }
278}
279
280impl std::error::Error for ProgrammaticError {}
281
282#[derive(Debug, Clone, Default)]
284pub struct AnalysisOptions {
285 pub root: Option<PathBuf>,
288 pub config_path: Option<PathBuf>,
290 pub allow_remote_extends: bool,
292 pub no_cache: bool,
294 pub threads: Option<usize>,
296 pub diff_file: Option<PathBuf>,
301 pub ambient_diff_file: Option<PathBuf>,
309 pub production: bool,
312 pub production_override: Option<bool>,
315 pub changed_since: Option<String>,
320 pub ambient_changed_since: Option<String>,
328 pub workspace: Option<Vec<String>>,
330 pub changed_workspaces: Option<String>,
332 pub explain: bool,
334 pub type_aware: TypeAwareOptions,
337 pub cancellation: Option<Arc<AtomicBool>>,
366}
367
368#[derive(Debug, Clone, Default, PartialEq, Eq)]
370pub struct TypeAwareOptions {
371 pub enabled: bool,
373 pub projects: Vec<PathBuf>,
376 pub require: fallow_config::TypeAwareRequire,
378}
379
380#[derive(Debug, Clone, Default)]
385pub struct DeadCodeFilters {
386 pub unused_files: bool,
388 pub unused_exports: bool,
390 pub unused_deps: bool,
392 pub unused_types: bool,
394 pub private_type_leaks: bool,
396 pub deprecated_exports_in_use: bool,
398 pub unused_enum_members: bool,
400 pub unused_class_members: bool,
402 pub unused_store_members: bool,
404 pub unprovided_injects: bool,
406 pub unrendered_components: bool,
408 pub unused_component_props: bool,
410 pub unused_component_emits: bool,
412 pub unused_component_inputs: bool,
414 pub unused_component_outputs: bool,
416 pub unused_svelte_events: bool,
418 pub unused_server_actions: bool,
420 pub unused_load_data_keys: bool,
422 pub unresolved_imports: bool,
424 pub unlisted_deps: bool,
426 pub duplicate_exports: bool,
428 pub circular_deps: bool,
430 pub re_export_cycles: bool,
432 pub boundary_violations: bool,
434 pub policy_violations: bool,
436 pub stale_suppressions: bool,
438 pub unused_catalog_entries: bool,
440 pub empty_catalog_groups: bool,
442 pub unresolved_catalog_references: bool,
444 pub unused_dependency_overrides: bool,
446 pub misconfigured_dependency_overrides: bool,
448}
449
450impl DeadCodeFilters {
451 fn any_active(&self) -> bool {
452 self.unused_files
453 || self.unused_exports
454 || self.unused_deps
455 || self.unused_types
456 || self.private_type_leaks
457 || self.deprecated_exports_in_use
458 || self.unused_enum_members
459 || self.unused_class_members
460 || self.unused_store_members
461 || self.unprovided_injects
462 || self.unrendered_components
463 || self.unused_component_props
464 || self.unused_component_emits
465 || self.unused_component_inputs
466 || self.unused_component_outputs
467 || self.unused_svelte_events
468 || self.unused_server_actions
469 || self.unused_load_data_keys
470 || self.unresolved_imports
471 || self.unlisted_deps
472 || self.duplicate_exports
473 || self.circular_deps
474 || self.re_export_cycles
475 || self.boundary_violations
476 || self.policy_violations
477 || self.stale_suppressions
478 || self.unused_catalog_entries
479 || self.empty_catalog_groups
480 || self.unresolved_catalog_references
481 || self.unused_dependency_overrides
482 || self.misconfigured_dependency_overrides
483 }
484
485 pub fn enable_registry_selector(&mut self, selector: &str) -> bool {
491 let Some(flag) = fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS
492 .iter()
493 .find_map(|&(name, flag)| (name == selector).then_some(flag))
494 else {
495 return false;
496 };
497 self.enable_cli_filter_flag(flag);
498 true
499 }
500
501 fn enable_cli_filter_flag(&mut self, flag: &str) {
502 match flag {
503 "--unused-files" => self.unused_files = true,
504 "--unused-exports" => self.unused_exports = true,
505 "--unused-types" => self.unused_types = true,
506 "--private-type-leaks" => self.private_type_leaks = true,
507 "--deprecated-exports-in-use" => self.deprecated_exports_in_use = true,
508 "--unused-deps" => self.unused_deps = true,
509 "--unused-enum-members" => self.unused_enum_members = true,
510 "--unused-class-members" => self.unused_class_members = true,
511 "--unused-store-members" => self.unused_store_members = true,
512 "--unprovided-injects" => self.unprovided_injects = true,
513 "--unrendered-components" => self.unrendered_components = true,
514 "--unused-component-props" => self.unused_component_props = true,
515 "--unused-component-emits" => self.unused_component_emits = true,
516 "--unused-component-inputs" => self.unused_component_inputs = true,
517 "--unused-component-outputs" => self.unused_component_outputs = true,
518 "--unused-svelte-events" => self.unused_svelte_events = true,
519 "--unused-server-actions" => self.unused_server_actions = true,
520 "--unused-load-data-keys" => self.unused_load_data_keys = true,
521 "--unresolved-imports" => self.unresolved_imports = true,
522 "--unlisted-deps" => self.unlisted_deps = true,
523 "--duplicate-exports" => self.duplicate_exports = true,
524 "--circular-deps" => self.circular_deps = true,
525 "--re-export-cycles" => self.re_export_cycles = true,
526 "--boundary-violations" => self.boundary_violations = true,
527 "--policy-violations" => self.policy_violations = true,
528 "--stale-suppressions" => self.stale_suppressions = true,
529 "--unused-catalog-entries" => self.unused_catalog_entries = true,
530 "--empty-catalog-groups" => self.empty_catalog_groups = true,
531 "--unresolved-catalog-references" => self.unresolved_catalog_references = true,
532 "--unused-dependency-overrides" => self.unused_dependency_overrides = true,
533 "--misconfigured-dependency-overrides" => {
534 self.misconfigured_dependency_overrides = true;
535 }
536 _ => unreachable!("registry emitted unsupported dead-code filter flag: {flag}"),
537 }
538 }
539}
540
541#[derive(Debug, Clone, Default)]
543pub struct DeadCodeOptions {
544 pub analysis: AnalysisOptions,
546 pub filters: DeadCodeFilters,
548 pub files: Vec<PathBuf>,
550 pub include_entry_exports: bool,
552}
553
554#[derive(Debug, Clone, Default)]
556pub struct AuditOptions {
557 pub analysis: AnalysisOptions,
559 pub base: Option<String>,
562 pub production: bool,
564 pub production_dead_code: Option<bool>,
566 pub production_health: Option<bool>,
568 pub production_dupes: Option<bool>,
571 pub css: Option<bool>,
573 pub css_deep: Option<bool>,
575 pub gate: fallow_config::AuditGate,
577 pub max_crap: Option<f64>,
579 pub coverage: Option<PathBuf>,
581 pub coverage_root: Option<PathBuf>,
583 pub include_entry_exports: bool,
585 pub runtime_coverage: Option<PathBuf>,
587 pub min_invocations_hot: u64,
589}
590
591#[derive(Debug, Clone)]
593pub struct CombinedOptions {
594 pub analysis: AnalysisOptions,
596 pub dead_code: bool,
598 pub duplication: bool,
600 pub health: bool,
602 pub include_entry_exports: bool,
604 pub duplication_options: DuplicationOptions,
606 pub health_options: ComplexityOptions,
608}
609
610impl Default for CombinedOptions {
611 fn default() -> Self {
612 Self {
613 analysis: AnalysisOptions::default(),
614 dead_code: true,
615 duplication: true,
616 health: true,
617 include_entry_exports: false,
618 duplication_options: DuplicationOptions::default(),
619 health_options: ComplexityOptions::default(),
620 }
621 }
622}
623
624#[derive(Debug, Clone, Default)]
626pub struct DecisionSurfaceOptions {
627 pub analysis: AnalysisOptions,
629 pub base: Option<String>,
632 pub max_decisions: Option<usize>,
634}
635
636#[derive(Debug, Clone, Default)]
638pub struct FeatureFlagsOptions {
639 pub analysis: AnalysisOptions,
641 pub top: Option<usize>,
643}
644
645#[derive(Debug, Clone, Copy, Default)]
647pub enum DuplicationMode {
648 Strict,
651 #[default]
653 Mild,
654 Weak,
656 Semantic,
659}
660
661#[derive(Debug, Clone, Default)]
663pub struct DuplicationOptions {
664 pub analysis: AnalysisOptions,
666 pub mode: Option<DuplicationMode>,
668 pub near: Option<bool>,
671 pub min_tokens: Option<usize>,
673 pub min_lines: Option<usize>,
675 pub min_occurrences: Option<usize>,
678 pub threshold: Option<f64>,
681 pub skip_local: Option<bool>,
684 pub cross_language: Option<bool>,
686 pub ignore_imports: Option<bool>,
689 pub top: Option<usize>,
691 pub include_fragments: Option<bool>,
695}
696
697#[derive(Debug, Clone, Default)]
699pub struct SimilarCodeOptions {
700 pub analysis: AnalysisOptions,
702 pub threshold: Option<f64>,
704 pub min_lines: Option<usize>,
707 pub top: Option<usize>,
709 pub files: Vec<PathBuf>,
713 #[doc(hidden)]
718 pub adapter_provider_path: Option<PathBuf>,
719}
720
721#[derive(Debug, Clone)]
723pub struct SimilarCodeInspectOptions {
724 pub analysis: AnalysisOptions,
727 pub snapshot: fallow_output::SimilarCodeCandidateSnapshot,
729}
730
731#[derive(Debug, Clone, Default)]
733pub struct TraceExportOptions {
734 pub analysis: AnalysisOptions,
736 pub file: String,
738 pub export_name: String,
740}
741
742#[derive(Debug, Clone, Default)]
744pub struct TraceFileOptions {
745 pub analysis: AnalysisOptions,
747 pub file: String,
749}
750
751#[derive(Debug, Clone, Default)]
753pub struct TraceImportPathOptions {
754 pub analysis: AnalysisOptions,
756 pub from: String,
758 pub to: String,
760}
761
762#[derive(Debug, Clone, Default)]
764pub struct TraceErrorOptions {
765 pub analysis: AnalysisOptions,
767 pub trace: String,
769 pub source: String,
771}
772
773#[derive(Debug, Clone, Default)]
775pub struct TraceDependencyOptions {
776 pub analysis: AnalysisOptions,
778 pub package_name: String,
780}
781
782#[derive(Debug, Clone, PartialEq, Eq)]
784pub enum TraceCloneTarget {
785 Location {
787 file: String,
789 line: usize,
791 },
792 Fingerprint(String),
794}
795
796#[derive(Debug, Clone)]
798pub struct TraceCloneOptions {
799 pub duplication: DuplicationOptions,
801 pub target: TraceCloneTarget,
803}
804
805#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
807pub enum ComplexitySort {
808 #[default]
810 Cyclomatic,
811 Cognitive,
813 Lines,
815 Severity,
817}
818
819#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
821pub enum OwnershipEmailMode {
822 Raw,
824 #[default]
826 Handle,
827 Anonymized,
829 Hash,
831}
832
833#[derive(Debug, Clone, Copy, PartialEq, Eq)]
835pub enum TargetEffort {
836 Low,
838 Medium,
840 High,
842}
843
844#[derive(Debug, Clone, Default)]
846pub struct ComplexityOptions {
847 pub analysis: AnalysisOptions,
849 pub max_cyclomatic: Option<u16>,
851 pub max_cognitive: Option<u16>,
853 pub max_crap: Option<f64>,
855 pub top: Option<usize>,
857 pub sort: ComplexitySort,
859 pub complexity_breakdown: bool,
861 pub complexity: bool,
863 pub file_scores: bool,
865 pub coverage_gaps: bool,
867 pub hotspots: bool,
869 pub ownership: bool,
871 pub ownership_emails: Option<OwnershipEmailMode>,
873 pub targets: bool,
875 pub css: bool,
877 pub css_deep: bool,
879 pub effort: Option<TargetEffort>,
882 pub score: bool,
884 pub since: Option<String>,
886 pub min_commits: Option<u32>,
888 pub coverage: Option<PathBuf>,
890 pub coverage_root: Option<PathBuf>,
892 pub coverage_relocated: bool,
897}
898
899#[derive(Debug, Clone, Copy, Default, PartialEq)]
901pub struct ComplexityThresholdOverrides {
902 pub max_cyclomatic: Option<u16>,
904 pub max_cognitive: Option<u16>,
906 pub max_crap: Option<f64>,
908}
909
910#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
912pub struct ComplexityCoverageInputs<'a> {
913 pub coverage: Option<&'a Path>,
915 pub coverage_root: Option<&'a Path>,
917 pub coverage_relocated: bool,
920}
921
922#[derive(Debug, Clone)]
924pub struct HealthSectionOptions {
925 pub output: fallow_types::output_format::OutputFormat,
927 pub complexity: bool,
929 pub file_scores: bool,
931 pub coverage_gaps: bool,
933 pub hotspots: bool,
935 pub targets: bool,
937 pub css: bool,
939 pub score: bool,
941 pub score_gate: bool,
943 pub snapshot_requested: bool,
945 pub trend: bool,
947}
948
949#[derive(Debug, Clone, Copy, PartialEq, Eq)]
951pub struct DerivedHealthSections {
952 pub any_section: bool,
954 pub complexity: bool,
956 pub file_scores: bool,
958 pub coverage_gaps: bool,
960 pub hotspots: bool,
962 pub targets: bool,
964 pub css: bool,
966 pub score: bool,
968 pub force_full: bool,
970 pub score_only_output: bool,
972}
973
974#[derive(Debug, Clone)]
976pub struct ComplexitySectionOptions {
977 pub complexity: bool,
979 pub file_scores: bool,
981 pub coverage_gaps: bool,
983 pub hotspots: bool,
985 pub ownership: bool,
987 pub targets: bool,
989 pub css: bool,
991 pub score: bool,
993}
994
995#[derive(Debug, Clone, Copy, PartialEq, Eq)]
997pub struct DerivedComplexityOptions {
998 pub any_section: bool,
1000 pub complexity: bool,
1002 pub file_scores: bool,
1004 pub coverage_gaps: bool,
1006 pub hotspots: bool,
1008 pub ownership: bool,
1010 pub targets: bool,
1012 pub force_full: bool,
1014 pub score_only_output: bool,
1016 pub score: bool,
1018}
1019
1020#[derive(Debug, Clone, PartialEq)]
1022pub struct ComplexityRunOptions<'a> {
1023 pub thresholds: ComplexityThresholdOverrides,
1025 pub top: Option<usize>,
1027 pub sort: ComplexitySort,
1029 pub complexity_breakdown: bool,
1031 pub sections: DerivedComplexityOptions,
1033 pub ownership_emails: Option<OwnershipEmailMode>,
1035 pub effort: Option<TargetEffort>,
1037 pub css: bool,
1039 pub css_deep: bool,
1041 pub since: Option<&'a str>,
1043 pub min_commits: Option<u32>,
1045 pub coverage_inputs: ComplexityCoverageInputs<'a>,
1047}
1048
1049#[must_use]
1051pub fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
1052 let score = options.score
1053 || options.score_gate
1054 || options.trend
1055 || matches!(
1056 options.output,
1057 fallow_types::output_format::OutputFormat::Badge
1058 );
1059 let any_section = options.complexity
1060 || options.file_scores
1061 || options.coverage_gaps
1062 || options.hotspots
1063 || options.targets
1064 || score;
1065 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
1066 let force_full = options.snapshot_requested || effective_score;
1067
1068 DerivedHealthSections {
1069 any_section,
1070 complexity: if any_section {
1071 options.complexity
1072 } else {
1073 true
1074 },
1075 file_scores: if any_section {
1076 options.file_scores
1077 } else {
1078 true
1079 } || force_full,
1080 coverage_gaps: if any_section {
1081 options.coverage_gaps
1082 } else {
1083 false
1084 },
1085 hotspots: if any_section { options.hotspots } else { true }
1086 || options.snapshot_requested
1087 || options.trend,
1088 targets: if any_section { options.targets } else { true },
1089 css: options.css,
1090 score: effective_score,
1091 force_full,
1092 score_only_output: is_health_score_only_output(options, score),
1093 }
1094}
1095
1096#[must_use]
1098pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
1099 let requested_hotspots = options.hotspots || options.ownership;
1100 let sections = derive_health_sections(&HealthSectionOptions {
1101 output: fallow_types::output_format::OutputFormat::Human,
1102 complexity: options.complexity,
1103 file_scores: options.file_scores,
1104 coverage_gaps: options.coverage_gaps,
1105 hotspots: requested_hotspots,
1106 targets: options.targets,
1107 css: options.css,
1108 score: options.score,
1109 score_gate: false,
1110 snapshot_requested: false,
1111 trend: false,
1112 });
1113
1114 DerivedComplexityOptions {
1115 any_section: sections.any_section,
1116 complexity: sections.complexity,
1117 file_scores: sections.file_scores,
1118 coverage_gaps: sections.coverage_gaps,
1119 hotspots: sections.hotspots,
1120 ownership: options.ownership && sections.hotspots,
1121 targets: sections.targets,
1122 force_full: sections.force_full,
1123 score_only_output: sections.score_only_output,
1124 score: sections.score,
1125 }
1126}
1127
1128#[must_use]
1130pub fn derive_complexity_options(options: &ComplexityOptions) -> DerivedComplexityOptions {
1131 derive_complexity_sections(&complexity_section_options(options))
1132}
1133
1134#[must_use]
1136pub fn derive_complexity_run_options(options: &ComplexityOptions) -> ComplexityRunOptions<'_> {
1137 ComplexityRunOptions {
1138 thresholds: ComplexityThresholdOverrides {
1139 max_cyclomatic: options.max_cyclomatic,
1140 max_cognitive: options.max_cognitive,
1141 max_crap: options.max_crap,
1142 },
1143 top: options.top,
1144 sort: options.sort,
1145 complexity_breakdown: options.complexity_breakdown,
1146 sections: derive_complexity_options(options),
1147 ownership_emails: options.ownership_emails,
1148 effort: options.effort,
1149 css: options.css,
1150 css_deep: options.css_deep,
1151 since: options.since.as_deref(),
1152 min_commits: options.min_commits,
1153 coverage_inputs: ComplexityCoverageInputs {
1154 coverage: options.coverage.as_deref(),
1155 coverage_root: options.coverage_root.as_deref(),
1156 coverage_relocated: options.coverage_relocated,
1157 },
1158 }
1159}
1160
1161pub fn validate_complexity_options(options: &ComplexityOptions) -> Result<(), ProgrammaticError> {
1184 if let Some(path) = &options.coverage {
1185 let resolved = fallow_engine::health::scoring::resolve_relative_to_root(
1186 path,
1187 options.analysis.root.as_deref(),
1188 );
1189 if !resolved.exists() {
1190 return Err(ProgrammaticError::new(
1191 format!("coverage path does not exist: {}", resolved.display()),
1192 2,
1193 )
1194 .with_code("FALLOW_INVALID_COVERAGE_PATH")
1195 .with_context("health.coverage"));
1196 }
1197 }
1198 if let Err(message) =
1199 fallow_engine::health::validate_coverage_root_absolute(options.coverage_root.as_deref())
1200 {
1201 return Err(ProgrammaticError::new(message, 2)
1202 .with_code("FALLOW_INVALID_COVERAGE_ROOT")
1203 .with_context("health.coverage_root"));
1204 }
1205
1206 Ok(())
1207}
1208
1209fn complexity_section_options(options: &ComplexityOptions) -> ComplexitySectionOptions {
1210 let ownership = options.ownership || options.ownership_emails.is_some();
1211 let requested_targets = options.targets || options.effort.is_some();
1212 ComplexitySectionOptions {
1213 complexity: options.complexity,
1214 file_scores: options.file_scores,
1215 coverage_gaps: options.coverage_gaps,
1216 hotspots: options.hotspots,
1217 ownership,
1218 targets: requested_targets,
1219 css: options.css,
1220 score: options.score,
1221 }
1222}
1223
1224fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
1225 score
1226 && !options.complexity
1227 && !options.file_scores
1228 && !options.coverage_gaps
1229 && !options.hotspots
1230 && !options.targets
1231 && !options.trend
1232}
1233
1234const fn thresholds_to_engine(
1235 thresholds: ComplexityThresholdOverrides,
1236) -> fallow_engine::health::HealthThresholdOverrides {
1237 fallow_engine::health::HealthThresholdOverrides {
1238 max_cyclomatic: thresholds.max_cyclomatic,
1239 max_cognitive: thresholds.max_cognitive,
1240 max_crap: thresholds.max_crap,
1241 }
1242}
1243
1244const fn complexity_sort_to_engine(sort: ComplexitySort) -> fallow_engine::health::HealthSort {
1245 match sort {
1246 ComplexitySort::Severity => fallow_engine::health::HealthSort::Severity,
1247 ComplexitySort::Cyclomatic => fallow_engine::health::HealthSort::Cyclomatic,
1248 ComplexitySort::Cognitive => fallow_engine::health::HealthSort::Cognitive,
1249 ComplexitySort::Lines => fallow_engine::health::HealthSort::Lines,
1250 }
1251}
1252
1253const fn coverage_inputs_to_engine(
1254 coverage_inputs: ComplexityCoverageInputs<'_>,
1255) -> fallow_engine::health::HealthCoverageInputs<'_> {
1256 fallow_engine::health::HealthCoverageInputs {
1257 coverage: coverage_inputs.coverage,
1258 coverage_root: coverage_inputs.coverage_root,
1259 coverage_relocated: coverage_inputs.coverage_relocated,
1260 }
1261}
1262
1263const fn ownership_email_mode_to_config(mode: OwnershipEmailMode) -> EmailMode {
1264 match mode {
1265 OwnershipEmailMode::Raw => EmailMode::Raw,
1266 OwnershipEmailMode::Handle => EmailMode::Handle,
1267 OwnershipEmailMode::Anonymized => EmailMode::Anonymized,
1268 OwnershipEmailMode::Hash => EmailMode::Hash,
1269 }
1270}
1271
1272const fn target_effort_to_output(effort: TargetEffort) -> EffortEstimate {
1273 match effort {
1274 TargetEffort::Low => EffortEstimate::Low,
1275 TargetEffort::Medium => EffortEstimate::Medium,
1276 TargetEffort::High => EffortEstimate::High,
1277 }
1278}
1279
1280#[cfg(test)]
1281mod tests {
1282 use super::*;
1283
1284 #[test]
1285 fn duplication_defaults_match_cli_contract() {
1286 let options = DuplicationOptions::default();
1287 assert!(options.mode.is_none());
1288 assert!(options.min_tokens.is_none());
1289 assert!(options.min_lines.is_none());
1290 assert!(options.min_occurrences.is_none());
1291 }
1292
1293 #[test]
1294 fn programmatic_error_builder_keeps_optional_fields() {
1295 let error = ProgrammaticError::new("boom", 2)
1296 .with_code("FALLOW_TEST")
1297 .with_help("Try again")
1298 .with_context("analysis.root");
1299
1300 assert_eq!(error.message, "boom");
1301 assert_eq!(error.exit_code, 2);
1302 assert_eq!(error.code.as_deref(), Some("FALLOW_TEST"));
1303 assert_eq!(error.help.as_deref(), Some("Try again"));
1304 assert_eq!(error.context.as_deref(), Some("analysis.root"));
1305 }
1306
1307 #[test]
1308 fn dead_code_filters_accept_shared_registry_selectors() {
1309 for (selector, _) in fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS.iter() {
1310 let mut filters = DeadCodeFilters::default();
1311 assert!(
1312 filters.enable_registry_selector(selector),
1313 "{selector} should be accepted"
1314 );
1315 }
1316
1317 let mut filters = DeadCodeFilters::default();
1318 assert!(filters.enable_registry_selector("unused-files"));
1319 assert!(filters.unused_files);
1320 assert!(filters.enable_registry_selector("boundary-violations"));
1321 assert!(filters.boundary_violations);
1322 assert!(!filters.enable_registry_selector("not-a-real-selector"));
1323 }
1324
1325 #[test]
1326 fn default_complexity_options_match_programmatic_health_defaults() {
1327 let derived = derive_complexity_options(&ComplexityOptions::default());
1328
1329 assert!(!derived.any_section);
1330 assert!(derived.complexity);
1331 assert!(derived.file_scores);
1332 assert!(!derived.coverage_gaps);
1333 assert!(derived.hotspots);
1334 assert!(!derived.ownership);
1335 assert!(derived.targets);
1336 assert!(derived.force_full);
1337 assert!(!derived.score_only_output);
1338 assert!(derived.score);
1339 }
1340
1341 #[test]
1342 fn score_only_complexity_options_request_score_only_output() {
1343 let derived = derive_complexity_options(&ComplexityOptions {
1344 score: true,
1345 ..ComplexityOptions::default()
1346 });
1347
1348 assert!(derived.any_section);
1349 assert!(!derived.complexity);
1350 assert!(derived.file_scores);
1351 assert!(!derived.hotspots);
1352 assert!(!derived.targets);
1353 assert!(derived.force_full);
1354 assert!(derived.score_only_output);
1355 assert!(derived.score);
1356 }
1357
1358 #[test]
1359 fn ownership_implies_hotspots_when_requested() {
1360 let derived = derive_complexity_options(&ComplexityOptions {
1361 ownership: true,
1362 ..ComplexityOptions::default()
1363 });
1364
1365 assert!(derived.any_section);
1366 assert!(derived.hotspots);
1367 assert!(derived.ownership);
1368 assert!(!derived.targets);
1369 }
1370
1371 #[test]
1372 fn complexity_run_options_normalize_public_api_options() {
1373 let options = ComplexityOptions {
1374 max_cyclomatic: Some(42),
1375 max_cognitive: Some(21),
1376 max_crap: Some(18.5),
1377 top: Some(7),
1378 sort: ComplexitySort::Severity,
1379 complexity_breakdown: true,
1380 ownership_emails: Some(OwnershipEmailMode::Hash),
1381 effort: Some(TargetEffort::High),
1382 coverage: Some(PathBuf::from("coverage/coverage-final.json")),
1383 coverage_root: Some(PathBuf::from("/ci/workspace")),
1384 since: Some("30d".to_string()),
1385 min_commits: Some(4),
1386 ..ComplexityOptions::default()
1387 };
1388
1389 let run = derive_complexity_run_options(&options);
1390
1391 assert_eq!(run.thresholds.max_cyclomatic, Some(42));
1392 assert_eq!(run.thresholds.max_cognitive, Some(21));
1393 assert_eq!(run.thresholds.max_crap, Some(18.5));
1394 assert_eq!(run.top, Some(7));
1395 assert!(matches!(run.sort, ComplexitySort::Severity));
1396 assert!(run.complexity_breakdown);
1397 assert!(run.sections.hotspots);
1398 assert!(run.sections.ownership);
1399 assert!(run.sections.targets);
1400 assert!(matches!(
1401 run.ownership_emails,
1402 Some(OwnershipEmailMode::Hash)
1403 ));
1404 assert!(matches!(run.effort, Some(TargetEffort::High)));
1405 assert_eq!(run.since, Some("30d"));
1406 assert_eq!(run.min_commits, Some(4));
1407 assert_eq!(run.coverage_inputs.coverage, options.coverage.as_deref());
1408 assert_eq!(
1409 run.coverage_inputs.coverage_root,
1410 options.coverage_root.as_deref()
1411 );
1412 }
1413
1414 #[test]
1415 fn complexity_options_validation_accepts_existing_coverage_path_and_absolute_root() {
1416 let dir = tempfile::tempdir().expect("tempdir");
1417 let coverage = dir.path().join("coverage-final.json");
1418 std::fs::write(&coverage, "{}").expect("coverage fixture");
1419
1420 let result = validate_complexity_options(&ComplexityOptions {
1421 coverage: Some(coverage),
1422 coverage_root: Some(PathBuf::from("/ci/workspace")),
1423 ..ComplexityOptions::default()
1424 });
1425
1426 assert!(result.is_ok());
1427 }
1428
1429 #[test]
1430 fn complexity_options_validation_keeps_missing_coverage_error_contract() {
1431 let err = validate_complexity_options(&ComplexityOptions {
1432 coverage: Some(PathBuf::from("/missing/coverage-final.json")),
1433 ..ComplexityOptions::default()
1434 })
1435 .expect_err("missing coverage path should fail");
1436
1437 assert_eq!(err.exit_code, 2);
1438 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1439 assert_eq!(err.context.as_deref(), Some("health.coverage"));
1440 }
1441
1442 #[test]
1443 fn complexity_options_validation_keeps_relative_coverage_root_error_contract() {
1444 let err = validate_complexity_options(&ComplexityOptions {
1445 coverage_root: Some(PathBuf::from("coverage")),
1446 ..ComplexityOptions::default()
1447 })
1448 .expect_err("relative coverage root should fail");
1449
1450 assert_eq!(err.exit_code, 2);
1451 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_ROOT"));
1452 assert_eq!(err.context.as_deref(), Some("health.coverage_root"));
1453 }
1454
1455 #[test]
1459 fn complexity_options_validation_resolves_relative_coverage_against_root() {
1460 let dir = tempfile::tempdir().expect("tempdir");
1461 std::fs::create_dir_all(dir.path().join("artifacts")).expect("artifacts dir");
1462 std::fs::write(dir.path().join("artifacts/coverage-final.json"), "{}")
1463 .expect("coverage fixture");
1464 let relative = PathBuf::from("artifacts/coverage-final.json");
1465 assert!(
1466 !relative.exists(),
1467 "the fixture must not also exist under the test cwd"
1468 );
1469
1470 let result = validate_complexity_options(&ComplexityOptions {
1471 analysis: AnalysisOptions {
1472 root: Some(dir.path().to_path_buf()),
1473 ..AnalysisOptions::default()
1474 },
1475 coverage: Some(relative.clone()),
1476 ..ComplexityOptions::default()
1477 });
1478 assert!(result.is_ok(), "{result:?}");
1479
1480 let err = validate_complexity_options(&ComplexityOptions {
1481 analysis: AnalysisOptions {
1482 root: Some(dir.path().join("elsewhere")),
1483 ..AnalysisOptions::default()
1484 },
1485 coverage: Some(relative),
1486 ..ComplexityOptions::default()
1487 })
1488 .expect_err("the path does not exist under the other root");
1489 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1490 assert!(
1491 err.message.contains("elsewhere"),
1492 "the message names the resolved path: {}",
1493 err.message
1494 );
1495 }
1496
1497 #[test]
1498 fn default_health_sections_match_full_health_output() {
1499 let derived = derive_health_sections(&HealthSectionOptions {
1500 output: fallow_types::output_format::OutputFormat::Human,
1501 complexity: false,
1502 file_scores: false,
1503 coverage_gaps: false,
1504 hotspots: false,
1505 targets: false,
1506 css: false,
1507 score: false,
1508 score_gate: false,
1509 snapshot_requested: false,
1510 trend: false,
1511 });
1512
1513 assert!(!derived.any_section);
1514 assert!(derived.complexity);
1515 assert!(derived.file_scores);
1516 assert!(!derived.coverage_gaps);
1517 assert!(derived.hotspots);
1518 assert!(derived.targets);
1519 assert!(derived.score);
1520 assert!(derived.force_full);
1521 assert!(!derived.score_only_output);
1522 }
1523
1524 #[test]
1525 fn health_score_gate_requests_score_only_output() {
1526 let derived = derive_health_sections(&HealthSectionOptions {
1527 output: fallow_types::output_format::OutputFormat::Human,
1528 complexity: false,
1529 file_scores: false,
1530 coverage_gaps: false,
1531 hotspots: false,
1532 targets: false,
1533 css: false,
1534 score: false,
1535 score_gate: true,
1536 snapshot_requested: false,
1537 trend: false,
1538 });
1539
1540 assert!(derived.any_section);
1541 assert!(!derived.complexity);
1542 assert!(derived.file_scores);
1543 assert!(!derived.hotspots);
1544 assert!(!derived.targets);
1545 assert!(derived.score);
1546 assert!(derived.force_full);
1547 assert!(derived.score_only_output);
1548 }
1549
1550 #[test]
1551 fn health_snapshot_keeps_full_hidden_inputs_without_section_request() {
1552 let derived = derive_health_sections(&HealthSectionOptions {
1553 output: fallow_types::output_format::OutputFormat::Human,
1554 complexity: false,
1555 file_scores: false,
1556 coverage_gaps: false,
1557 hotspots: false,
1558 targets: false,
1559 css: true,
1560 score: false,
1561 score_gate: false,
1562 snapshot_requested: true,
1563 trend: false,
1564 });
1565
1566 assert!(!derived.any_section);
1567 assert!(derived.css);
1568 assert!(derived.file_scores);
1569 assert!(derived.hotspots);
1570 assert!(derived.score);
1571 assert!(derived.force_full);
1572 }
1573}