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 EditorSessionParseCounts, collect_inline_complexity, editor_duplicates, editor_extract,
105 editor_results, editor_security, editor_suppress, filter_inline_complexity_by_changed_files,
106 resolve_git_toplevel, 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_engine::warm_parse;
122pub use fallow_output::serialize_similar_code_json_output;
123pub use fallow_types::trace::{
124 CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace, ReExportChain,
125 TracedCloneGroup, TracedExport, TracedReExport,
126};
127pub use grouped_output::{
128 ResultGroup, UNOWNED_GROUP_LABEL, build_duplication_grouping_with, group_analysis_results_with,
129 largest_clone_group_owner_with,
130};
131pub use health_codeclimate::build_health_codeclimate;
132pub use json_output::{
133 CheckJsonExtraOutputs, CheckJsonOutputInput, CheckJsonPayloadInput, DuplicationJsonOutputInput,
134 GroupedCheckJsonOutputInput, GroupedDuplicationJsonOutputInput, serialize_check_json,
135 serialize_check_json_payload, serialize_duplication_json, serialize_grouped_check_json,
136 serialize_grouped_duplication_json,
137};
138pub use list_output::{
139 ListJsonEnvelope, ListJsonOutputInput, build_list_json_output, serialize_list_json_output,
140};
141pub use list_runtime::{
142 BoundaryData, ListBoundariesOptions, ListBoundariesProgrammaticOutput, LogicalGroupInfo,
143 ProjectInfoOptions, ProjectInfoProgrammaticOutput, RuleInfo, ZoneInfo, boundary_data_to_output,
144 compute_boundary_data, run_list_boundaries, run_project_info,
145 serialize_list_boundaries_programmatic_json, serialize_project_info_programmatic_json,
146};
147pub use markdown_output::{
148 build_duplication_markdown, build_grouped_markdown, build_health_markdown, build_markdown,
149 build_walkthrough_markdown,
150};
151pub use output_contracts::{
152 AuditOutput, BoundariesListLogicalGroup, BoundariesListRule, BoundariesListZone,
153 BoundariesListing, CombinedOutput, FallowOutput, ImpactOutput, ListBoundariesOutput,
154 ListEntryPointOutput, ListOutput, ListPluginOutput, ReviewBriefWireOutput, SecurityGate,
155 SecurityOutput, SecurityOutputConfig, SecuritySummaryOutput, SimilarCodeCandidateSnapshot,
156 SimilarCodeOutput, TraceOutput, WorkspacesOutput,
157};
158pub use runtime::{
159 AuditProgrammaticKeySnapshot, AuditProgrammaticOutput, BoundaryViolationsOutput,
160 BoundaryViolationsProgrammaticOutput, CircularDependenciesOutput,
161 CircularDependenciesProgrammaticOutput, CombinedProgrammaticOutput, DeadCodeOutput,
162 DeadCodeProgrammaticOutput, DecisionSurfaceProgrammaticOutput, DuplicationOutput,
163 DuplicationProgrammaticOutput, EngineHealthRunner, FeatureFlagsOutput,
164 FeatureFlagsProgrammaticOutput, HealthJsonReportInput, HealthProgrammaticOutput,
165 ProgrammaticHealthAnalysis, ProgrammaticHealthNextStepFacts, ProgrammaticHealthRun,
166 ProgrammaticHealthRunner, TraceClassMemberOutput, TraceCloneBenchmarkResult, TraceCloneOutput,
167 TraceCloneProgrammaticOutput, TraceDependencyOutput, TraceDependencyProgrammaticOutput,
168 TraceErrorOutput, TraceErrorProgrammaticOutput, TraceExportOutput,
169 TraceExportProgrammaticOutput, TraceExportTargetOutput, TraceFileOutput,
170 TraceFileProgrammaticOutput, TraceImportPathOutput, TraceImportPathProgrammaticOutput,
171 benchmark_trace_clone_compact_json, benchmark_trace_graph_family_compact_json,
172 inspect_similar_code, load_health_config, parse_similar_code_candidate_snapshot,
173 review_similar_code, run_audit, run_boundary_violations, run_circular_dependencies,
174 run_combined, run_complexity_with_runner, run_dead_code, run_dead_code_with_baseline,
175 run_decision_surface, run_duplication, run_feature_flags, run_health, run_health_with_runner,
176 run_similar_code, run_trace_clone, run_trace_dependency, run_trace_error, run_trace_export,
177 run_trace_file, run_trace_import_path, select_similar_code_candidate_snapshot,
178 serialize_health_report_json,
179};
180pub use runtime_json::{
181 serialize_audit_programmatic_json, serialize_boundary_violations_programmatic_json,
182 serialize_circular_dependencies_programmatic_json, serialize_combined_programmatic_json,
183 serialize_dead_code_programmatic_json, serialize_decision_surface_programmatic_json,
184 serialize_duplication_programmatic_json, serialize_feature_flags_programmatic_json,
185 serialize_health_programmatic_json, serialize_trace_clone_programmatic_json,
186 serialize_trace_dependency_programmatic_json, serialize_trace_error_programmatic_json,
187 serialize_trace_export_programmatic_json, serialize_trace_file_programmatic_json,
188 serialize_trace_import_path_programmatic_json,
189};
190pub use sarif_output::{
191 annotate_sarif_results, build_duplication_sarif, build_grouped_duplication_sarif,
192 build_health_sarif,
193};
194pub use security_output::SecurityGateMode;
195pub use type_aware::{
196 SemanticCouplingOutcome, SemanticDeadCodeOutcome, SemanticInspectOutcome, TypeAwareError,
197 TypeAwareFileChanges, TypeAwareOutcome, TypeAwareSession, TypeAwareStatus,
198 discard_unverified_semantic_candidates, inspect_symbol as inspect_type_aware_symbol,
199 merge_type_aware_meta,
200 refine_configured_dead_code_results as refine_type_aware_results_with_config,
201 shutdown_type_aware_sidecars, status as type_aware_status,
202 symbol_impact as run_type_aware_symbol_impact, symbol_impact as type_aware_symbol_impact,
203 terminate_active_type_aware_sidecars, trace_symbol as run_type_aware_symbol_trace,
204 trace_symbol as trace_type_aware_symbol, type_coupling as analyze_type_coupling,
205};
206
207pub const COMMON_ANALYSIS_OPTION_FLAGS: &[&str] = &[
214 "root",
215 "config",
216 "no-cache",
217 "threads",
218 "changed-since",
219 "diff-file",
220 "production",
221 "workspace",
222 "changed-workspaces",
223 "explain",
224 "allow-remote-extends",
225];
226
227#[derive(Debug, Clone, Serialize)]
229pub struct ProgrammaticError {
230 pub message: String,
232 pub exit_code: u8,
234 pub code: Option<String>,
236 pub help: Option<String>,
238 pub context: Option<String>,
240}
241
242impl ProgrammaticError {
243 #[must_use]
246 pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
247 Self {
248 message: message.into(),
249 exit_code,
250 code: None,
251 help: None,
252 context: None,
253 }
254 }
255
256 #[must_use]
258 pub fn with_help(mut self, help: impl Into<String>) -> Self {
259 self.help = Some(help.into());
260 self
261 }
262
263 #[must_use]
266 pub fn with_code(mut self, code: impl Into<String>) -> Self {
267 self.code = Some(code.into());
268 self
269 }
270
271 #[must_use]
274 pub fn with_context(mut self, context: impl Into<String>) -> Self {
275 self.context = Some(context.into());
276 self
277 }
278}
279
280impl std::fmt::Display for ProgrammaticError {
281 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
282 write!(f, "{}", self.message)
283 }
284}
285
286impl std::error::Error for ProgrammaticError {}
287
288#[derive(Debug, Clone, Default)]
290pub struct AnalysisOptions {
291 pub root: Option<PathBuf>,
294 pub config_path: Option<PathBuf>,
296 pub allow_remote_extends: bool,
298 pub no_cache: bool,
300 pub threads: Option<usize>,
302 pub diff_file: Option<PathBuf>,
307 pub ambient_diff_file: Option<PathBuf>,
315 pub production: bool,
318 pub production_override: Option<bool>,
321 pub changed_since: Option<String>,
326 pub ambient_changed_since: Option<String>,
334 pub workspace: Option<Vec<String>>,
336 pub changed_workspaces: Option<String>,
338 pub explain: bool,
340 pub type_aware: TypeAwareOptions,
343 pub cancellation: Option<Arc<AtomicBool>>,
372}
373
374#[derive(Debug, Clone, Default, PartialEq, Eq)]
376pub struct TypeAwareOptions {
377 pub enabled: bool,
379 pub projects: Vec<PathBuf>,
382 pub require: fallow_config::TypeAwareRequire,
384}
385
386#[derive(Debug, Clone, Default)]
391pub struct DeadCodeFilters {
392 pub unused_files: bool,
394 pub unused_exports: bool,
396 pub unused_deps: bool,
398 pub unused_types: bool,
400 pub private_type_leaks: bool,
402 pub deprecated_exports_in_use: bool,
404 pub unused_enum_members: bool,
406 pub unused_class_members: bool,
408 pub unused_store_members: bool,
410 pub unprovided_injects: bool,
412 pub unrendered_components: bool,
414 pub unused_component_props: bool,
416 pub unused_component_emits: bool,
418 pub unused_component_inputs: bool,
420 pub unused_component_outputs: bool,
422 pub unused_svelte_events: bool,
424 pub unused_server_actions: bool,
426 pub unused_load_data_keys: bool,
428 pub unresolved_imports: bool,
430 pub unlisted_deps: bool,
432 pub duplicate_exports: bool,
434 pub circular_deps: bool,
436 pub re_export_cycles: bool,
438 pub boundary_violations: bool,
440 pub policy_violations: bool,
442 pub stale_suppressions: bool,
444 pub unused_catalog_entries: bool,
446 pub empty_catalog_groups: bool,
448 pub unresolved_catalog_references: bool,
450 pub unused_dependency_overrides: bool,
452 pub misconfigured_dependency_overrides: bool,
454}
455
456impl DeadCodeFilters {
457 fn any_active(&self) -> bool {
458 self.unused_files
459 || self.unused_exports
460 || self.unused_deps
461 || self.unused_types
462 || self.private_type_leaks
463 || self.deprecated_exports_in_use
464 || self.unused_enum_members
465 || self.unused_class_members
466 || self.unused_store_members
467 || self.unprovided_injects
468 || self.unrendered_components
469 || self.unused_component_props
470 || self.unused_component_emits
471 || self.unused_component_inputs
472 || self.unused_component_outputs
473 || self.unused_svelte_events
474 || self.unused_server_actions
475 || self.unused_load_data_keys
476 || self.unresolved_imports
477 || self.unlisted_deps
478 || self.duplicate_exports
479 || self.circular_deps
480 || self.re_export_cycles
481 || self.boundary_violations
482 || self.policy_violations
483 || self.stale_suppressions
484 || self.unused_catalog_entries
485 || self.empty_catalog_groups
486 || self.unresolved_catalog_references
487 || self.unused_dependency_overrides
488 || self.misconfigured_dependency_overrides
489 }
490
491 pub fn enable_registry_selector(&mut self, selector: &str) -> bool {
497 let Some(flag) = fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS
498 .iter()
499 .find_map(|&(name, flag)| (name == selector).then_some(flag))
500 else {
501 return false;
502 };
503 self.enable_cli_filter_flag(flag);
504 true
505 }
506
507 fn enable_cli_filter_flag(&mut self, flag: &str) {
508 match flag {
509 "--unused-files" => self.unused_files = true,
510 "--unused-exports" => self.unused_exports = true,
511 "--unused-types" => self.unused_types = true,
512 "--private-type-leaks" => self.private_type_leaks = true,
513 "--deprecated-exports-in-use" => self.deprecated_exports_in_use = true,
514 "--unused-deps" => self.unused_deps = true,
515 "--unused-enum-members" => self.unused_enum_members = true,
516 "--unused-class-members" => self.unused_class_members = true,
517 "--unused-store-members" => self.unused_store_members = true,
518 "--unprovided-injects" => self.unprovided_injects = true,
519 "--unrendered-components" => self.unrendered_components = true,
520 "--unused-component-props" => self.unused_component_props = true,
521 "--unused-component-emits" => self.unused_component_emits = true,
522 "--unused-component-inputs" => self.unused_component_inputs = true,
523 "--unused-component-outputs" => self.unused_component_outputs = true,
524 "--unused-svelte-events" => self.unused_svelte_events = true,
525 "--unused-server-actions" => self.unused_server_actions = true,
526 "--unused-load-data-keys" => self.unused_load_data_keys = true,
527 "--unresolved-imports" => self.unresolved_imports = true,
528 "--unlisted-deps" => self.unlisted_deps = true,
529 "--duplicate-exports" => self.duplicate_exports = true,
530 "--circular-deps" => self.circular_deps = true,
531 "--re-export-cycles" => self.re_export_cycles = true,
532 "--boundary-violations" => self.boundary_violations = true,
533 "--policy-violations" => self.policy_violations = true,
534 "--stale-suppressions" => self.stale_suppressions = true,
535 "--unused-catalog-entries" => self.unused_catalog_entries = true,
536 "--empty-catalog-groups" => self.empty_catalog_groups = true,
537 "--unresolved-catalog-references" => self.unresolved_catalog_references = true,
538 "--unused-dependency-overrides" => self.unused_dependency_overrides = true,
539 "--misconfigured-dependency-overrides" => {
540 self.misconfigured_dependency_overrides = true;
541 }
542 _ => unreachable!("registry emitted unsupported dead-code filter flag: {flag}"),
543 }
544 }
545}
546
547#[derive(Debug, Clone, Default)]
549pub struct DeadCodeOptions {
550 pub analysis: AnalysisOptions,
552 pub filters: DeadCodeFilters,
554 pub files: Vec<PathBuf>,
556 pub include_entry_exports: bool,
558}
559
560#[derive(Debug, Clone, Default)]
562pub struct AuditOptions {
563 pub analysis: AnalysisOptions,
565 pub base: Option<String>,
568 pub production: bool,
570 pub production_dead_code: Option<bool>,
572 pub production_health: Option<bool>,
574 pub production_dupes: Option<bool>,
577 pub css: Option<bool>,
579 pub css_deep: Option<bool>,
581 pub gate: fallow_config::AuditGate,
583 pub max_crap: Option<f64>,
585 pub coverage: Option<PathBuf>,
587 pub coverage_root: Option<PathBuf>,
589 pub include_entry_exports: bool,
591 pub runtime_coverage: Option<PathBuf>,
593 pub min_invocations_hot: u64,
595}
596
597#[derive(Debug, Clone)]
599pub struct CombinedOptions {
600 pub analysis: AnalysisOptions,
602 pub dead_code: bool,
604 pub duplication: bool,
606 pub health: bool,
608 pub include_entry_exports: bool,
610 pub duplication_options: DuplicationOptions,
612 pub health_options: ComplexityOptions,
614}
615
616impl Default for CombinedOptions {
617 fn default() -> Self {
618 Self {
619 analysis: AnalysisOptions::default(),
620 dead_code: true,
621 duplication: true,
622 health: true,
623 include_entry_exports: false,
624 duplication_options: DuplicationOptions::default(),
625 health_options: ComplexityOptions::default(),
626 }
627 }
628}
629
630#[derive(Debug, Clone, Default)]
632pub struct DecisionSurfaceOptions {
633 pub analysis: AnalysisOptions,
635 pub base: Option<String>,
638 pub max_decisions: Option<usize>,
640}
641
642#[derive(Debug, Clone, Default)]
644pub struct FeatureFlagsOptions {
645 pub analysis: AnalysisOptions,
647 pub top: Option<usize>,
650 pub retirement: Option<FeatureFlagsRetirementOptions>,
652}
653
654#[derive(Debug, Clone, Default)]
657pub struct FeatureFlagsRetirementOptions {
658 pub flag_age: fallow_types::flag_retirement::FlagAgeMode,
660 pub flag_state: Option<std::path::PathBuf>,
662 pub reasons: Vec<fallow_types::flag_retirement::RetirementReason>,
664 pub sort: fallow_engine::flag_retirement::RetirementSort,
666 pub min_age_days: Option<u64>,
668 pub max_flag_age: Option<u64>,
670}
671
672#[derive(Debug, Clone, Copy, Default)]
674pub enum DuplicationMode {
675 Strict,
678 #[default]
680 Mild,
681 Weak,
683 Semantic,
686}
687
688#[derive(Debug, Clone, Default)]
690pub struct DuplicationOptions {
691 pub analysis: AnalysisOptions,
693 pub mode: Option<DuplicationMode>,
695 pub near: Option<bool>,
698 pub min_tokens: Option<usize>,
700 pub min_lines: Option<usize>,
702 pub min_occurrences: Option<usize>,
705 pub threshold: Option<f64>,
708 pub skip_local: Option<bool>,
711 pub cross_language: Option<bool>,
713 pub ignore_imports: Option<bool>,
716 pub top: Option<usize>,
718 pub include_fragments: Option<bool>,
722}
723
724#[derive(Debug, Clone, Default)]
726pub struct SimilarCodeOptions {
727 pub analysis: AnalysisOptions,
729 pub threshold: Option<f64>,
731 pub min_lines: Option<usize>,
734 pub top: Option<usize>,
736 pub files: Vec<PathBuf>,
740 #[doc(hidden)]
745 pub adapter_provider_path: Option<PathBuf>,
746}
747
748#[derive(Debug, Clone)]
750pub struct SimilarCodeInspectOptions {
751 pub analysis: AnalysisOptions,
754 pub snapshot: fallow_output::SimilarCodeCandidateSnapshot,
756}
757
758#[derive(Debug, Clone, Default)]
760pub struct TraceExportOptions {
761 pub analysis: AnalysisOptions,
763 pub file: String,
765 pub export_name: String,
767}
768
769#[derive(Debug, Clone, Default)]
771pub struct TraceFileOptions {
772 pub analysis: AnalysisOptions,
774 pub file: String,
776}
777
778#[derive(Debug, Clone, Default)]
780pub struct TraceImportPathOptions {
781 pub analysis: AnalysisOptions,
783 pub from: String,
785 pub to: String,
787 pub eager_only: bool,
790}
791
792#[derive(Debug, Clone, Default)]
794pub struct TraceErrorOptions {
795 pub analysis: AnalysisOptions,
797 pub trace: String,
799 pub source: String,
801}
802
803#[derive(Debug, Clone, Default)]
805pub struct TraceDependencyOptions {
806 pub analysis: AnalysisOptions,
808 pub package_name: String,
810}
811
812#[derive(Debug, Clone, PartialEq, Eq)]
814pub enum TraceCloneTarget {
815 Location {
817 file: String,
819 line: usize,
821 },
822 Fingerprint(String),
824}
825
826#[derive(Debug, Clone)]
828pub struct TraceCloneOptions {
829 pub duplication: DuplicationOptions,
831 pub target: TraceCloneTarget,
833}
834
835#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
837pub enum ComplexitySort {
838 #[default]
840 Cyclomatic,
841 Cognitive,
843 Lines,
845 Severity,
847}
848
849#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
851pub enum OwnershipEmailMode {
852 Raw,
854 #[default]
856 Handle,
857 Anonymized,
859 Hash,
861}
862
863#[derive(Debug, Clone, Copy, PartialEq, Eq)]
865pub enum TargetEffort {
866 Low,
868 Medium,
870 High,
872}
873
874#[derive(Debug, Clone, Default)]
876pub struct ComplexityOptions {
877 pub analysis: AnalysisOptions,
879 pub max_cyclomatic: Option<u16>,
881 pub max_cognitive: Option<u16>,
883 pub max_crap: Option<f64>,
885 pub top: Option<usize>,
887 pub sort: ComplexitySort,
889 pub complexity_breakdown: bool,
891 pub complexity: bool,
893 pub file_scores: bool,
895 pub coverage_gaps: bool,
897 pub hotspots: bool,
899 pub ownership: bool,
901 pub ownership_emails: Option<OwnershipEmailMode>,
903 pub targets: bool,
905 pub css: bool,
907 pub css_deep: bool,
909 pub effort: Option<TargetEffort>,
912 pub score: bool,
914 pub since: Option<String>,
916 pub min_commits: Option<u32>,
918 pub coverage: Option<PathBuf>,
920 pub coverage_root: Option<PathBuf>,
922 pub coverage_relocated: bool,
927}
928
929#[derive(Debug, Clone, Copy, Default, PartialEq)]
931pub struct ComplexityThresholdOverrides {
932 pub max_cyclomatic: Option<u16>,
934 pub max_cognitive: Option<u16>,
936 pub max_crap: Option<f64>,
938}
939
940#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
942pub struct ComplexityCoverageInputs<'a> {
943 pub coverage: Option<&'a Path>,
945 pub coverage_root: Option<&'a Path>,
947 pub coverage_relocated: bool,
950}
951
952#[derive(Debug, Clone)]
954pub struct HealthSectionOptions {
955 pub output: fallow_types::output_format::OutputFormat,
957 pub complexity: bool,
959 pub file_scores: bool,
961 pub coverage_gaps: bool,
963 pub hotspots: bool,
965 pub targets: bool,
967 pub css: bool,
969 pub score: bool,
971 pub score_gate: bool,
973 pub snapshot_requested: bool,
975 pub trend: bool,
977}
978
979#[derive(Debug, Clone, Copy, PartialEq, Eq)]
981pub struct DerivedHealthSections {
982 pub any_section: bool,
984 pub complexity: bool,
986 pub file_scores: bool,
988 pub coverage_gaps: bool,
990 pub hotspots: bool,
992 pub targets: bool,
994 pub css: bool,
996 pub score: bool,
998 pub force_full: bool,
1000 pub score_only_output: bool,
1002}
1003
1004#[derive(Debug, Clone)]
1006pub struct ComplexitySectionOptions {
1007 pub complexity: bool,
1009 pub file_scores: bool,
1011 pub coverage_gaps: bool,
1013 pub hotspots: bool,
1015 pub ownership: bool,
1017 pub targets: bool,
1019 pub css: bool,
1021 pub score: bool,
1023}
1024
1025#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1027pub struct DerivedComplexityOptions {
1028 pub any_section: bool,
1030 pub complexity: bool,
1032 pub file_scores: bool,
1034 pub coverage_gaps: bool,
1036 pub hotspots: bool,
1038 pub ownership: bool,
1040 pub targets: bool,
1042 pub force_full: bool,
1044 pub score_only_output: bool,
1046 pub score: bool,
1048}
1049
1050#[derive(Debug, Clone, PartialEq)]
1052pub struct ComplexityRunOptions<'a> {
1053 pub thresholds: ComplexityThresholdOverrides,
1055 pub top: Option<usize>,
1057 pub sort: ComplexitySort,
1059 pub complexity_breakdown: bool,
1061 pub sections: DerivedComplexityOptions,
1063 pub ownership_emails: Option<OwnershipEmailMode>,
1065 pub effort: Option<TargetEffort>,
1067 pub css: bool,
1069 pub css_deep: bool,
1071 pub since: Option<&'a str>,
1073 pub min_commits: Option<u32>,
1075 pub coverage_inputs: ComplexityCoverageInputs<'a>,
1077}
1078
1079#[must_use]
1081pub fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
1082 let score = options.score
1083 || options.score_gate
1084 || options.trend
1085 || matches!(
1086 options.output,
1087 fallow_types::output_format::OutputFormat::Badge
1088 );
1089 let any_section = options.complexity
1090 || options.file_scores
1091 || options.coverage_gaps
1092 || options.hotspots
1093 || options.targets
1094 || score;
1095 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
1096 let force_full = options.snapshot_requested || effective_score;
1097
1098 DerivedHealthSections {
1099 any_section,
1100 complexity: if any_section {
1101 options.complexity
1102 } else {
1103 true
1104 },
1105 file_scores: if any_section {
1106 options.file_scores
1107 } else {
1108 true
1109 } || force_full,
1110 coverage_gaps: if any_section {
1111 options.coverage_gaps
1112 } else {
1113 false
1114 },
1115 hotspots: if any_section { options.hotspots } else { true }
1116 || options.snapshot_requested
1117 || options.trend,
1118 targets: if any_section { options.targets } else { true },
1119 css: options.css,
1120 score: effective_score,
1121 force_full,
1122 score_only_output: is_health_score_only_output(options, score),
1123 }
1124}
1125
1126#[must_use]
1128pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
1129 let requested_hotspots = options.hotspots || options.ownership;
1130 let sections = derive_health_sections(&HealthSectionOptions {
1131 output: fallow_types::output_format::OutputFormat::Human,
1132 complexity: options.complexity,
1133 file_scores: options.file_scores,
1134 coverage_gaps: options.coverage_gaps,
1135 hotspots: requested_hotspots,
1136 targets: options.targets,
1137 css: options.css,
1138 score: options.score,
1139 score_gate: false,
1140 snapshot_requested: false,
1141 trend: false,
1142 });
1143
1144 DerivedComplexityOptions {
1145 any_section: sections.any_section,
1146 complexity: sections.complexity,
1147 file_scores: sections.file_scores,
1148 coverage_gaps: sections.coverage_gaps,
1149 hotspots: sections.hotspots,
1150 ownership: options.ownership && sections.hotspots,
1151 targets: sections.targets,
1152 force_full: sections.force_full,
1153 score_only_output: sections.score_only_output,
1154 score: sections.score,
1155 }
1156}
1157
1158#[must_use]
1160pub fn derive_complexity_options(options: &ComplexityOptions) -> DerivedComplexityOptions {
1161 derive_complexity_sections(&complexity_section_options(options))
1162}
1163
1164#[must_use]
1166pub fn derive_complexity_run_options(options: &ComplexityOptions) -> ComplexityRunOptions<'_> {
1167 ComplexityRunOptions {
1168 thresholds: ComplexityThresholdOverrides {
1169 max_cyclomatic: options.max_cyclomatic,
1170 max_cognitive: options.max_cognitive,
1171 max_crap: options.max_crap,
1172 },
1173 top: options.top,
1174 sort: options.sort,
1175 complexity_breakdown: options.complexity_breakdown,
1176 sections: derive_complexity_options(options),
1177 ownership_emails: options.ownership_emails,
1178 effort: options.effort,
1179 css: options.css,
1180 css_deep: options.css_deep,
1181 since: options.since.as_deref(),
1182 min_commits: options.min_commits,
1183 coverage_inputs: ComplexityCoverageInputs {
1184 coverage: options.coverage.as_deref(),
1185 coverage_root: options.coverage_root.as_deref(),
1186 coverage_relocated: options.coverage_relocated,
1187 },
1188 }
1189}
1190
1191pub fn validate_complexity_options(options: &ComplexityOptions) -> Result<(), ProgrammaticError> {
1214 if let Some(path) = &options.coverage {
1215 let resolved = fallow_engine::health::scoring::resolve_relative_to_root(
1216 path,
1217 options.analysis.root.as_deref(),
1218 );
1219 if !resolved.exists() {
1220 return Err(ProgrammaticError::new(
1221 format!("coverage path does not exist: {}", resolved.display()),
1222 2,
1223 )
1224 .with_code("FALLOW_INVALID_COVERAGE_PATH")
1225 .with_context("health.coverage"));
1226 }
1227 }
1228 if let Err(message) =
1229 fallow_engine::health::validate_coverage_root_absolute(options.coverage_root.as_deref())
1230 {
1231 return Err(ProgrammaticError::new(message, 2)
1232 .with_code("FALLOW_INVALID_COVERAGE_ROOT")
1233 .with_context("health.coverage_root"));
1234 }
1235
1236 Ok(())
1237}
1238
1239fn complexity_section_options(options: &ComplexityOptions) -> ComplexitySectionOptions {
1240 let ownership = options.ownership || options.ownership_emails.is_some();
1241 let requested_targets = options.targets || options.effort.is_some();
1242 ComplexitySectionOptions {
1243 complexity: options.complexity,
1244 file_scores: options.file_scores,
1245 coverage_gaps: options.coverage_gaps,
1246 hotspots: options.hotspots,
1247 ownership,
1248 targets: requested_targets,
1249 css: options.css,
1250 score: options.score,
1251 }
1252}
1253
1254fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
1255 score
1256 && !options.complexity
1257 && !options.file_scores
1258 && !options.coverage_gaps
1259 && !options.hotspots
1260 && !options.targets
1261 && !options.trend
1262}
1263
1264const fn thresholds_to_engine(
1265 thresholds: ComplexityThresholdOverrides,
1266) -> fallow_engine::health::HealthThresholdOverrides {
1267 fallow_engine::health::HealthThresholdOverrides {
1268 max_cyclomatic: thresholds.max_cyclomatic,
1269 max_cognitive: thresholds.max_cognitive,
1270 max_crap: thresholds.max_crap,
1271 }
1272}
1273
1274const fn complexity_sort_to_engine(sort: ComplexitySort) -> fallow_engine::health::HealthSort {
1275 match sort {
1276 ComplexitySort::Severity => fallow_engine::health::HealthSort::Severity,
1277 ComplexitySort::Cyclomatic => fallow_engine::health::HealthSort::Cyclomatic,
1278 ComplexitySort::Cognitive => fallow_engine::health::HealthSort::Cognitive,
1279 ComplexitySort::Lines => fallow_engine::health::HealthSort::Lines,
1280 }
1281}
1282
1283const fn coverage_inputs_to_engine(
1284 coverage_inputs: ComplexityCoverageInputs<'_>,
1285) -> fallow_engine::health::HealthCoverageInputs<'_> {
1286 fallow_engine::health::HealthCoverageInputs {
1287 coverage: coverage_inputs.coverage,
1288 coverage_root: coverage_inputs.coverage_root,
1289 coverage_relocated: coverage_inputs.coverage_relocated,
1290 }
1291}
1292
1293const fn ownership_email_mode_to_config(mode: OwnershipEmailMode) -> EmailMode {
1294 match mode {
1295 OwnershipEmailMode::Raw => EmailMode::Raw,
1296 OwnershipEmailMode::Handle => EmailMode::Handle,
1297 OwnershipEmailMode::Anonymized => EmailMode::Anonymized,
1298 OwnershipEmailMode::Hash => EmailMode::Hash,
1299 }
1300}
1301
1302const fn target_effort_to_output(effort: TargetEffort) -> EffortEstimate {
1303 match effort {
1304 TargetEffort::Low => EffortEstimate::Low,
1305 TargetEffort::Medium => EffortEstimate::Medium,
1306 TargetEffort::High => EffortEstimate::High,
1307 }
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312 use super::*;
1313
1314 #[test]
1315 fn duplication_defaults_match_cli_contract() {
1316 let options = DuplicationOptions::default();
1317 assert!(options.mode.is_none());
1318 assert!(options.min_tokens.is_none());
1319 assert!(options.min_lines.is_none());
1320 assert!(options.min_occurrences.is_none());
1321 }
1322
1323 #[test]
1324 fn programmatic_error_builder_keeps_optional_fields() {
1325 let error = ProgrammaticError::new("boom", 2)
1326 .with_code("FALLOW_TEST")
1327 .with_help("Try again")
1328 .with_context("analysis.root");
1329
1330 assert_eq!(error.message, "boom");
1331 assert_eq!(error.exit_code, 2);
1332 assert_eq!(error.code.as_deref(), Some("FALLOW_TEST"));
1333 assert_eq!(error.help.as_deref(), Some("Try again"));
1334 assert_eq!(error.context.as_deref(), Some("analysis.root"));
1335 }
1336
1337 #[test]
1338 fn dead_code_filters_accept_shared_registry_selectors() {
1339 for (selector, _) in fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS.iter() {
1340 let mut filters = DeadCodeFilters::default();
1341 assert!(
1342 filters.enable_registry_selector(selector),
1343 "{selector} should be accepted"
1344 );
1345 }
1346
1347 let mut filters = DeadCodeFilters::default();
1348 assert!(filters.enable_registry_selector("unused-files"));
1349 assert!(filters.unused_files);
1350 assert!(filters.enable_registry_selector("boundary-violations"));
1351 assert!(filters.boundary_violations);
1352 assert!(!filters.enable_registry_selector("not-a-real-selector"));
1353 }
1354
1355 #[test]
1356 fn default_complexity_options_match_programmatic_health_defaults() {
1357 let derived = derive_complexity_options(&ComplexityOptions::default());
1358
1359 assert!(!derived.any_section);
1360 assert!(derived.complexity);
1361 assert!(derived.file_scores);
1362 assert!(!derived.coverage_gaps);
1363 assert!(derived.hotspots);
1364 assert!(!derived.ownership);
1365 assert!(derived.targets);
1366 assert!(derived.force_full);
1367 assert!(!derived.score_only_output);
1368 assert!(derived.score);
1369 }
1370
1371 #[test]
1372 fn score_only_complexity_options_request_score_only_output() {
1373 let derived = derive_complexity_options(&ComplexityOptions {
1374 score: true,
1375 ..ComplexityOptions::default()
1376 });
1377
1378 assert!(derived.any_section);
1379 assert!(!derived.complexity);
1380 assert!(derived.file_scores);
1381 assert!(!derived.hotspots);
1382 assert!(!derived.targets);
1383 assert!(derived.force_full);
1384 assert!(derived.score_only_output);
1385 assert!(derived.score);
1386 }
1387
1388 #[test]
1389 fn ownership_implies_hotspots_when_requested() {
1390 let derived = derive_complexity_options(&ComplexityOptions {
1391 ownership: true,
1392 ..ComplexityOptions::default()
1393 });
1394
1395 assert!(derived.any_section);
1396 assert!(derived.hotspots);
1397 assert!(derived.ownership);
1398 assert!(!derived.targets);
1399 }
1400
1401 #[test]
1402 fn complexity_run_options_normalize_public_api_options() {
1403 let options = ComplexityOptions {
1404 max_cyclomatic: Some(42),
1405 max_cognitive: Some(21),
1406 max_crap: Some(18.5),
1407 top: Some(7),
1408 sort: ComplexitySort::Severity,
1409 complexity_breakdown: true,
1410 ownership_emails: Some(OwnershipEmailMode::Hash),
1411 effort: Some(TargetEffort::High),
1412 coverage: Some(PathBuf::from("coverage/coverage-final.json")),
1413 coverage_root: Some(PathBuf::from("/ci/workspace")),
1414 since: Some("30d".to_string()),
1415 min_commits: Some(4),
1416 ..ComplexityOptions::default()
1417 };
1418
1419 let run = derive_complexity_run_options(&options);
1420
1421 assert_eq!(run.thresholds.max_cyclomatic, Some(42));
1422 assert_eq!(run.thresholds.max_cognitive, Some(21));
1423 assert_eq!(run.thresholds.max_crap, Some(18.5));
1424 assert_eq!(run.top, Some(7));
1425 assert!(matches!(run.sort, ComplexitySort::Severity));
1426 assert!(run.complexity_breakdown);
1427 assert!(run.sections.hotspots);
1428 assert!(run.sections.ownership);
1429 assert!(run.sections.targets);
1430 assert!(matches!(
1431 run.ownership_emails,
1432 Some(OwnershipEmailMode::Hash)
1433 ));
1434 assert!(matches!(run.effort, Some(TargetEffort::High)));
1435 assert_eq!(run.since, Some("30d"));
1436 assert_eq!(run.min_commits, Some(4));
1437 assert_eq!(run.coverage_inputs.coverage, options.coverage.as_deref());
1438 assert_eq!(
1439 run.coverage_inputs.coverage_root,
1440 options.coverage_root.as_deref()
1441 );
1442 }
1443
1444 #[test]
1445 fn complexity_options_validation_accepts_existing_coverage_path_and_absolute_root() {
1446 let dir = tempfile::tempdir().expect("tempdir");
1447 let coverage = dir.path().join("coverage-final.json");
1448 std::fs::write(&coverage, "{}").expect("coverage fixture");
1449
1450 let result = validate_complexity_options(&ComplexityOptions {
1451 coverage: Some(coverage),
1452 coverage_root: Some(PathBuf::from("/ci/workspace")),
1453 ..ComplexityOptions::default()
1454 });
1455
1456 assert!(result.is_ok());
1457 }
1458
1459 #[test]
1460 fn complexity_options_validation_keeps_missing_coverage_error_contract() {
1461 let err = validate_complexity_options(&ComplexityOptions {
1462 coverage: Some(PathBuf::from("/missing/coverage-final.json")),
1463 ..ComplexityOptions::default()
1464 })
1465 .expect_err("missing coverage path should fail");
1466
1467 assert_eq!(err.exit_code, 2);
1468 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1469 assert_eq!(err.context.as_deref(), Some("health.coverage"));
1470 }
1471
1472 #[test]
1473 fn complexity_options_validation_keeps_relative_coverage_root_error_contract() {
1474 let err = validate_complexity_options(&ComplexityOptions {
1475 coverage_root: Some(PathBuf::from("coverage")),
1476 ..ComplexityOptions::default()
1477 })
1478 .expect_err("relative coverage root should fail");
1479
1480 assert_eq!(err.exit_code, 2);
1481 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_ROOT"));
1482 assert_eq!(err.context.as_deref(), Some("health.coverage_root"));
1483 }
1484
1485 #[test]
1489 fn complexity_options_validation_resolves_relative_coverage_against_root() {
1490 let dir = tempfile::tempdir().expect("tempdir");
1491 std::fs::create_dir_all(dir.path().join("artifacts")).expect("artifacts dir");
1492 std::fs::write(dir.path().join("artifacts/coverage-final.json"), "{}")
1493 .expect("coverage fixture");
1494 let relative = PathBuf::from("artifacts/coverage-final.json");
1495 assert!(
1496 !relative.exists(),
1497 "the fixture must not also exist under the test cwd"
1498 );
1499
1500 let result = validate_complexity_options(&ComplexityOptions {
1501 analysis: AnalysisOptions {
1502 root: Some(dir.path().to_path_buf()),
1503 ..AnalysisOptions::default()
1504 },
1505 coverage: Some(relative.clone()),
1506 ..ComplexityOptions::default()
1507 });
1508 assert!(result.is_ok(), "{result:?}");
1509
1510 let err = validate_complexity_options(&ComplexityOptions {
1511 analysis: AnalysisOptions {
1512 root: Some(dir.path().join("elsewhere")),
1513 ..AnalysisOptions::default()
1514 },
1515 coverage: Some(relative),
1516 ..ComplexityOptions::default()
1517 })
1518 .expect_err("the path does not exist under the other root");
1519 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1520 assert!(
1521 err.message.contains("elsewhere"),
1522 "the message names the resolved path: {}",
1523 err.message
1524 );
1525 }
1526
1527 #[test]
1528 fn default_health_sections_match_full_health_output() {
1529 let derived = derive_health_sections(&HealthSectionOptions {
1530 output: fallow_types::output_format::OutputFormat::Human,
1531 complexity: false,
1532 file_scores: false,
1533 coverage_gaps: false,
1534 hotspots: false,
1535 targets: false,
1536 css: false,
1537 score: false,
1538 score_gate: false,
1539 snapshot_requested: false,
1540 trend: false,
1541 });
1542
1543 assert!(!derived.any_section);
1544 assert!(derived.complexity);
1545 assert!(derived.file_scores);
1546 assert!(!derived.coverage_gaps);
1547 assert!(derived.hotspots);
1548 assert!(derived.targets);
1549 assert!(derived.score);
1550 assert!(derived.force_full);
1551 assert!(!derived.score_only_output);
1552 }
1553
1554 #[test]
1555 fn health_score_gate_requests_score_only_output() {
1556 let derived = derive_health_sections(&HealthSectionOptions {
1557 output: fallow_types::output_format::OutputFormat::Human,
1558 complexity: false,
1559 file_scores: false,
1560 coverage_gaps: false,
1561 hotspots: false,
1562 targets: false,
1563 css: false,
1564 score: false,
1565 score_gate: true,
1566 snapshot_requested: false,
1567 trend: false,
1568 });
1569
1570 assert!(derived.any_section);
1571 assert!(!derived.complexity);
1572 assert!(derived.file_scores);
1573 assert!(!derived.hotspots);
1574 assert!(!derived.targets);
1575 assert!(derived.score);
1576 assert!(derived.force_full);
1577 assert!(derived.score_only_output);
1578 }
1579
1580 #[test]
1581 fn health_snapshot_keeps_full_hidden_inputs_without_section_request() {
1582 let derived = derive_health_sections(&HealthSectionOptions {
1583 output: fallow_types::output_format::OutputFormat::Human,
1584 complexity: false,
1585 file_scores: false,
1586 coverage_gaps: false,
1587 hotspots: false,
1588 targets: false,
1589 css: true,
1590 score: false,
1591 score_gate: false,
1592 snapshot_requested: true,
1593 trend: false,
1594 });
1595
1596 assert!(!derived.any_section);
1597 assert!(derived.css);
1598 assert!(derived.file_scores);
1599 assert!(derived.hotspots);
1600 assert!(derived.score);
1601 assert!(derived.force_full);
1602 }
1603}