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;
48mod health_groups_markdown;
49pub mod json_output;
50pub mod list_output;
51mod list_runtime;
52pub mod markdown_output;
55mod next_steps;
56pub mod output_contracts;
57pub mod ownership;
58pub mod review_deltas;
59pub mod routing;
60pub mod runtime;
61mod runtime_json;
62mod runtime_output;
63pub mod sarif_output;
64pub mod schemas;
67pub mod security_output;
68pub mod similar_code;
70mod type_aware;
71pub use analysis_context::{ProgrammaticAnalysisContext, resolve_programmatic_analysis_context};
72pub use audit_output::{
73 AuditAttribution, AuditCodeClimateOutputInput, AuditJsonHeaderInput, AuditJsonOutputInput,
74 AuditSarifOutputInput, AuditSummary, AuditVerdict,
75 attach_audit_duplication_demotion_attribution, attach_audit_styling_attribution,
76 attach_audit_wire_attribution, build_audit_codeclimate, build_audit_codeclimate_issues,
77 build_audit_header_json, build_audit_header_map, build_audit_sarif, build_review_brief_header,
78 serialize_audit_json,
79};
80pub use combined_output::{
81 CombinedCheckJsonSection, CombinedJsonOutputInput, serialize_combined_dupes_json,
82 serialize_combined_health_json, serialize_combined_json,
83};
84pub use compact_output::{
85 build_compact_lines, build_duplication_compact_lines, build_grouped_compact_lines,
86 build_health_compact_lines,
87};
88pub use coverage::{
89 CoverageInputError, CoverageInputSource, CoverageInputs, resolve_coverage_inputs,
90};
91pub use dead_code_codeclimate::build_codeclimate;
92pub use dead_code_sarif::build_sarif;
93pub use doctor::{DoctorOptions, run_doctor, run_doctor_with_cache_dir};
94pub use dupes_output::{
95 AttributedCloneGroup, AttributedCloneGroupFinding, AttributedInstance, CloneDemotionReason,
96 CloneFamilyFinding, CloneGroupFinding, CombinedDupesSection, DupesReportPayload,
97 DuplicationGroup, DuplicationGrouping, build_duplication_codeclimate,
98};
99pub use editor::{
100 ChangedFilesError, EditorAnalysisOutput, EditorAnalysisResults, EditorAnalysisSession,
101 EditorCloneFamily, EditorCloneFingerprintSet, EditorCloneGroup, EditorCloneInstance,
102 EditorDeadCodeAnalysisOutput, EditorDuplicationReport, EditorDuplicationStats,
103 EditorInlineComplexityExceeded, EditorInlineComplexityFinding, EditorMirroredDirectory,
104 EditorProjectAnalysisOutput, EditorRefactoringKind, EditorRefactoringSuggestion,
105 EditorSessionParseCounts, collect_inline_complexity, editor_duplicates, editor_extract,
106 editor_results, editor_security, editor_suppress, filter_inline_complexity_by_change_scope,
107 filter_inline_complexity_by_changed_files, resolve_git_toplevel,
108 try_get_changed_files_with_toplevel,
109};
110pub use explain::{
111 CHECK_RULES, DUPES_RULES, FLAGS_RULES, HEALTH_RULES, RuleDef, RuleGuide, SECURITY_RULES,
112 all_rules, bare_rule_id, coverage_analyze_meta, coverage_setup_meta, explain_issue_type,
113 rule_by_id, rule_by_token, rule_command, rule_docs_url, rule_guide, rule_severity_key,
114 security_meta, serialize_explain_programmatic_json, unknown_explain_error,
115};
116pub use fallow_config::levenshtein::closest_match;
117pub use fallow_config::{AuditGate, HealthConfig, TypeAwareRequire};
118pub use fallow_engine::change_scope::{
121 ChangeScope, ChangeScopeOwner, ChangeScopeRequest, first_package_baselines,
122 package_baseline_statuses,
123};
124pub use fallow_engine::package_baselines::{PackageBaselineError, PackageChangeScope};
126pub use fallow_engine::warm_parse;
132pub use fallow_output::PackageBaselineStatus;
134pub use fallow_output::serialize_similar_code_json_output;
135pub use fallow_types::trace::{
136 CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace, ReExportChain,
137 TracedCloneGroup, TracedExport, TracedReExport,
138};
139pub use grouped_output::{
140 ResultGroup, UNOWNED_GROUP_LABEL, build_duplication_grouping_with, group_analysis_results_with,
141 health_signal_header_part, largest_clone_group_owner_with,
142};
143pub use health_codeclimate::build_health_codeclimate;
144pub use health_groups_markdown::build_health_groups_markdown;
145pub use json_output::{
146 CheckJsonExtraOutputs, CheckJsonOutputInput, CheckJsonPayloadInput, DuplicationJsonOutputInput,
147 GroupedCheckJsonOutputInput, GroupedDuplicationJsonOutputInput, serialize_check_json,
148 serialize_check_json_payload, serialize_duplication_json, serialize_grouped_check_json,
149 serialize_grouped_duplication_json,
150};
151pub use list_output::{
152 ListJsonEnvelope, ListJsonOutputInput, build_list_json_output, serialize_list_json_output,
153};
154pub use list_runtime::{
155 BoundaryData, ListBoundariesOptions, ListBoundariesProgrammaticOutput, LogicalGroupInfo,
156 ProjectInfoOptions, ProjectInfoProgrammaticOutput, RuleInfo, ZoneInfo, boundary_data_to_output,
157 compute_boundary_data, run_list_boundaries, run_project_info,
158 serialize_list_boundaries_programmatic_json, serialize_project_info_programmatic_json,
159};
160pub use markdown_output::{
161 ComplexityListing, build_duplication_markdown, build_grouped_markdown, build_health_markdown,
162 build_markdown, build_walkthrough_markdown,
163};
164pub use output_contracts::{
165 AuditOutput, BoundariesListLogicalGroup, BoundariesListRule, BoundariesListZone,
166 BoundariesListing, CombinedOutput, FallowOutput, ImpactOutput, ListBoundariesOutput,
167 ListEntryPointOutput, ListOutput, ListPluginOutput, ReviewBriefWireOutput, SecurityGate,
168 SecurityOutput, SecurityOutputConfig, SecuritySummaryOutput, SimilarCodeCandidateSnapshot,
169 SimilarCodeOutput, TraceOutput, WorkspacesOutput,
170};
171pub use runtime::{
172 AuditProgrammaticKeySnapshot, AuditProgrammaticOutput, BoundaryViolationsOutput,
173 BoundaryViolationsProgrammaticOutput, CircularDependenciesOutput,
174 CircularDependenciesProgrammaticOutput, CombinedProgrammaticOutput, DeadCodeOutput,
175 DeadCodeProgrammaticOutput, DecisionSurfaceProgrammaticOutput, DuplicationOutput,
176 DuplicationProgrammaticOutput, EngineHealthRunner, FeatureFlagsOutput,
177 FeatureFlagsProgrammaticOutput, HealthJsonReportInput, HealthProgrammaticOutput,
178 ProgrammaticHealthAnalysis, ProgrammaticHealthNextStepFacts, ProgrammaticHealthRun,
179 ProgrammaticHealthRunner, TraceClassMemberOutput, TraceCloneBenchmarkResult, TraceCloneOutput,
180 TraceCloneProgrammaticOutput, TraceDependencyOutput, TraceDependencyProgrammaticOutput,
181 TraceErrorOutput, TraceErrorProgrammaticOutput, TraceExportOutput,
182 TraceExportProgrammaticOutput, TraceExportTargetOutput, TraceFileOutput,
183 TraceFileProgrammaticOutput, TraceImportPathOutput, TraceImportPathProgrammaticOutput,
184 benchmark_trace_clone_compact_json, benchmark_trace_graph_family_compact_json,
185 inspect_similar_code, load_health_config, parse_similar_code_candidate_snapshot,
186 review_similar_code, run_audit, run_boundary_violations, run_circular_dependencies,
187 run_combined, run_complexity_with_runner, run_dead_code, run_dead_code_with_baseline,
188 run_decision_surface, run_duplication, run_feature_flags, run_health, run_health_with_runner,
189 run_similar_code, run_trace_clone, run_trace_dependency, run_trace_error, run_trace_export,
190 run_trace_file, run_trace_import_path, select_similar_code_candidate_snapshot,
191 serialize_health_report_json,
192};
193pub use runtime_json::{
194 serialize_audit_programmatic_json, serialize_boundary_violations_programmatic_json,
195 serialize_circular_dependencies_programmatic_json, serialize_combined_programmatic_json,
196 serialize_dead_code_programmatic_json, serialize_decision_surface_programmatic_json,
197 serialize_duplication_programmatic_json, serialize_feature_flags_programmatic_json,
198 serialize_health_programmatic_json, serialize_trace_clone_programmatic_json,
199 serialize_trace_dependency_programmatic_json, serialize_trace_error_programmatic_json,
200 serialize_trace_export_programmatic_json, serialize_trace_file_programmatic_json,
201 serialize_trace_import_path_programmatic_json,
202};
203pub use sarif_output::{
204 annotate_sarif_results, build_duplication_sarif, build_grouped_duplication_sarif,
205 build_health_sarif,
206};
207pub use security_output::SecurityGateMode;
208pub use type_aware::{
209 SemanticCouplingOutcome, SemanticDeadCodeOutcome, SemanticInspectOutcome, TypeAwareError,
210 TypeAwareFileChanges, TypeAwareOutcome, TypeAwareSession, TypeAwareStatus,
211 discard_unverified_semantic_candidates, inspect_symbol as inspect_type_aware_symbol,
212 merge_type_aware_meta,
213 refine_configured_dead_code_results as refine_type_aware_results_with_config,
214 shutdown_type_aware_sidecars, status as type_aware_status,
215 symbol_impact as run_type_aware_symbol_impact, symbol_impact as type_aware_symbol_impact,
216 terminate_active_type_aware_sidecars, trace_symbol as run_type_aware_symbol_trace,
217 trace_symbol as trace_type_aware_symbol, type_coupling as analyze_type_coupling,
218};
219
220pub const COMMON_ANALYSIS_OPTION_FLAGS: &[&str] = &[
227 "root",
228 "config",
229 "no-cache",
230 "threads",
231 "changed-since",
232 "diff-file",
233 "production",
234 "workspace",
235 "changed-workspaces",
236 "explain",
237 "allow-remote-extends",
238];
239
240#[derive(Debug, Clone, Serialize)]
242pub struct ProgrammaticError {
243 pub message: String,
245 pub exit_code: u8,
247 pub code: Option<String>,
249 pub help: Option<String>,
251 pub context: Option<String>,
253}
254
255impl ProgrammaticError {
256 #[must_use]
259 pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
260 Self {
261 message: message.into(),
262 exit_code,
263 code: None,
264 help: None,
265 context: None,
266 }
267 }
268
269 #[must_use]
271 pub fn with_help(mut self, help: impl Into<String>) -> Self {
272 self.help = Some(help.into());
273 self
274 }
275
276 #[must_use]
279 pub fn with_code(mut self, code: impl Into<String>) -> Self {
280 self.code = Some(code.into());
281 self
282 }
283
284 #[must_use]
287 pub fn with_context(mut self, context: impl Into<String>) -> Self {
288 self.context = Some(context.into());
289 self
290 }
291}
292
293impl std::fmt::Display for ProgrammaticError {
294 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295 write!(f, "{}", self.message)
296 }
297}
298
299impl std::error::Error for ProgrammaticError {}
300
301#[derive(Debug, Clone, Default)]
303pub struct AnalysisOptions {
304 pub root: Option<PathBuf>,
307 pub config_path: Option<PathBuf>,
309 pub allow_remote_extends: bool,
311 pub no_cache: bool,
313 pub threads: Option<usize>,
315 pub diff_file: Option<PathBuf>,
320 pub ambient_diff_file: Option<PathBuf>,
328 pub production: bool,
331 pub production_override: Option<bool>,
334 pub changed_since: Option<String>,
339 pub ambient_changed_since: Option<String>,
347 pub no_package_baselines: bool,
351 pub workspace: Option<Vec<String>>,
353 pub changed_workspaces: Option<String>,
355 pub explain: bool,
357 pub type_aware: TypeAwareOptions,
360 pub cancellation: Option<Arc<AtomicBool>>,
389}
390
391#[derive(Debug, Clone, Default, PartialEq, Eq)]
393pub struct TypeAwareOptions {
394 pub enabled: bool,
396 pub projects: Vec<PathBuf>,
399 pub require: fallow_config::TypeAwareRequire,
401}
402
403#[derive(Debug, Clone, Default)]
408pub struct DeadCodeFilters {
409 pub unused_files: bool,
411 pub unused_exports: bool,
413 pub unused_deps: bool,
415 pub unused_types: bool,
417 pub private_type_leaks: bool,
419 pub deprecated_exports_in_use: bool,
421 pub unused_enum_members: bool,
423 pub unused_class_members: bool,
425 pub unused_store_members: bool,
427 pub unprovided_injects: bool,
429 pub unrendered_components: bool,
431 pub unused_component_props: bool,
433 pub absent_component_props: bool,
435 pub unused_component_emits: bool,
437 pub unused_component_inputs: bool,
439 pub unused_component_outputs: bool,
441 pub unused_svelte_events: bool,
443 pub unused_server_actions: bool,
445 pub unused_load_data_keys: bool,
447 pub unresolved_imports: bool,
449 pub unlisted_deps: bool,
451 pub duplicate_exports: bool,
453 pub circular_deps: bool,
455 pub re_export_cycles: bool,
457 pub package_cycles: bool,
459 pub boundary_violations: bool,
461 pub policy_violations: bool,
463 pub stale_suppressions: bool,
465 pub unused_catalog_entries: bool,
467 pub empty_catalog_groups: bool,
469 pub unresolved_catalog_references: bool,
471 pub unused_dependency_overrides: bool,
473 pub misconfigured_dependency_overrides: bool,
475}
476
477impl DeadCodeFilters {
478 pub(crate) fn reports_dependency_findings(&self) -> bool {
482 !self.any_active() || self.unused_deps || self.unlisted_deps
483 }
484
485 fn any_active(&self) -> bool {
486 self.unused_files
487 || self.unused_exports
488 || self.unused_deps
489 || self.unused_types
490 || self.private_type_leaks
491 || self.deprecated_exports_in_use
492 || self.unused_enum_members
493 || self.unused_class_members
494 || self.unused_store_members
495 || self.unprovided_injects
496 || self.unrendered_components
497 || self.unused_component_props
498 || self.absent_component_props
499 || self.unused_component_emits
500 || self.unused_component_inputs
501 || self.unused_component_outputs
502 || self.unused_svelte_events
503 || self.unused_server_actions
504 || self.unused_load_data_keys
505 || self.unresolved_imports
506 || self.unlisted_deps
507 || self.duplicate_exports
508 || self.circular_deps
509 || self.re_export_cycles
510 || self.package_cycles
511 || self.boundary_violations
512 || self.policy_violations
513 || self.stale_suppressions
514 || self.unused_catalog_entries
515 || self.empty_catalog_groups
516 || self.unresolved_catalog_references
517 || self.unused_dependency_overrides
518 || self.misconfigured_dependency_overrides
519 }
520
521 pub fn enable_registry_selector(&mut self, selector: &str) -> bool {
527 let Some(flag) = fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS
528 .iter()
529 .find_map(|&(name, flag)| (name == selector).then_some(flag))
530 else {
531 return false;
532 };
533 self.enable_cli_filter_flag(flag);
534 true
535 }
536
537 fn enable_cli_filter_flag(&mut self, flag: &str) {
538 match flag {
539 "--unused-files" => self.unused_files = true,
540 "--unused-exports" => self.unused_exports = true,
541 "--unused-types" => self.unused_types = true,
542 "--private-type-leaks" => self.private_type_leaks = true,
543 "--deprecated-exports-in-use" => self.deprecated_exports_in_use = true,
544 "--unused-deps" => self.unused_deps = true,
545 "--unused-enum-members" => self.unused_enum_members = true,
546 "--unused-class-members" => self.unused_class_members = true,
547 "--unused-store-members" => self.unused_store_members = true,
548 "--unprovided-injects" => self.unprovided_injects = true,
549 "--unrendered-components" => self.unrendered_components = true,
550 "--unused-component-props" => self.unused_component_props = true,
551 "--absent-component-props" => self.absent_component_props = true,
552 "--unused-component-emits" => self.unused_component_emits = true,
553 "--unused-component-inputs" => self.unused_component_inputs = true,
554 "--unused-component-outputs" => self.unused_component_outputs = true,
555 "--unused-svelte-events" => self.unused_svelte_events = true,
556 "--unused-server-actions" => self.unused_server_actions = true,
557 "--unused-load-data-keys" => self.unused_load_data_keys = true,
558 "--unresolved-imports" => self.unresolved_imports = true,
559 "--unlisted-deps" => self.unlisted_deps = true,
560 "--duplicate-exports" => self.duplicate_exports = true,
561 "--circular-deps" => self.circular_deps = true,
562 "--re-export-cycles" => self.re_export_cycles = true,
563 "--package-cycles" => self.package_cycles = true,
564 "--boundary-violations" => self.boundary_violations = true,
565 "--policy-violations" => self.policy_violations = true,
566 "--stale-suppressions" => self.stale_suppressions = true,
567 "--unused-catalog-entries" => self.unused_catalog_entries = true,
568 "--empty-catalog-groups" => self.empty_catalog_groups = true,
569 "--unresolved-catalog-references" => self.unresolved_catalog_references = true,
570 "--unused-dependency-overrides" => self.unused_dependency_overrides = true,
571 "--misconfigured-dependency-overrides" => {
572 self.misconfigured_dependency_overrides = true;
573 }
574 _ => unreachable!("registry emitted unsupported dead-code filter flag: {flag}"),
575 }
576 }
577}
578
579#[derive(Debug, Clone, Default)]
581pub struct DeadCodeOptions {
582 pub analysis: AnalysisOptions,
584 pub filters: DeadCodeFilters,
586 pub files: Vec<PathBuf>,
588 pub include_entry_exports: bool,
590 pub finding_ids: Vec<String>,
595}
596
597#[derive(Debug, Clone, Default)]
599pub struct AuditOptions {
600 pub analysis: AnalysisOptions,
602 pub base: Option<String>,
605 pub production: bool,
607 pub production_dead_code: Option<bool>,
609 pub production_health: Option<bool>,
611 pub production_dupes: Option<bool>,
614 pub css: Option<bool>,
616 pub css_deep: Option<bool>,
618 pub gate: fallow_config::AuditGate,
620 pub max_crap: Option<f64>,
622 pub coverage: Option<PathBuf>,
624 pub coverage_root: Option<PathBuf>,
626 pub include_entry_exports: bool,
628 pub runtime_coverage: Option<PathBuf>,
630 pub min_invocations_hot: u64,
632}
633
634#[derive(Debug, Clone)]
636pub struct CombinedOptions {
637 pub analysis: AnalysisOptions,
639 pub dead_code: bool,
641 pub duplication: bool,
643 pub health: bool,
645 pub include_entry_exports: bool,
647 pub duplication_options: DuplicationOptions,
649 pub health_options: ComplexityOptions,
651}
652
653impl Default for CombinedOptions {
654 fn default() -> Self {
655 Self {
656 analysis: AnalysisOptions::default(),
657 dead_code: true,
658 duplication: true,
659 health: true,
660 include_entry_exports: false,
661 duplication_options: DuplicationOptions::default(),
662 health_options: ComplexityOptions::default(),
663 }
664 }
665}
666
667#[derive(Debug, Clone, Default)]
669pub struct DecisionSurfaceOptions {
670 pub analysis: AnalysisOptions,
672 pub base: Option<String>,
675 pub max_decisions: Option<usize>,
677}
678
679#[derive(Debug, Clone, Default)]
681pub struct FeatureFlagsOptions {
682 pub analysis: AnalysisOptions,
684 pub top: Option<usize>,
687 pub retirement: Option<FeatureFlagsRetirementOptions>,
689}
690
691#[derive(Debug, Clone, Default)]
694pub struct FeatureFlagsRetirementOptions {
695 pub flag_age: fallow_types::flag_retirement::FlagAgeMode,
697 pub flag_state: Option<std::path::PathBuf>,
699 pub reasons: Vec<fallow_types::flag_retirement::RetirementReason>,
701 pub sort: fallow_engine::flag_retirement::RetirementSort,
703 pub min_age_days: Option<u64>,
705 pub max_flag_age: Option<u64>,
707}
708
709#[derive(Debug, Clone, Copy, Default)]
711pub enum DuplicationMode {
712 Strict,
715 #[default]
717 Mild,
718 Weak,
720 Semantic,
723}
724
725#[derive(Debug, Clone, Default)]
727pub struct DuplicationOptions {
728 pub analysis: AnalysisOptions,
730 pub mode: Option<DuplicationMode>,
732 pub near: Option<bool>,
735 pub min_tokens: Option<usize>,
737 pub min_lines: Option<usize>,
739 pub min_occurrences: Option<usize>,
742 pub threshold: Option<f64>,
745 pub skip_local: Option<bool>,
748 pub ignore_symlinks: Option<bool>,
751 pub cross_language: Option<bool>,
753 pub ignore_imports: Option<bool>,
756 pub top: Option<usize>,
758 pub include_fragments: Option<bool>,
762}
763
764#[derive(Debug, Clone, Default)]
766pub struct SimilarCodeOptions {
767 pub analysis: AnalysisOptions,
769 pub threshold: Option<f64>,
771 pub min_lines: Option<usize>,
774 pub top: Option<usize>,
776 pub files: Vec<PathBuf>,
780 #[doc(hidden)]
785 pub adapter_provider_path: Option<PathBuf>,
786}
787
788#[derive(Debug, Clone)]
790pub struct SimilarCodeInspectOptions {
791 pub analysis: AnalysisOptions,
794 pub snapshot: fallow_output::SimilarCodeCandidateSnapshot,
796}
797
798#[derive(Debug, Clone, Default)]
800pub struct TraceExportOptions {
801 pub analysis: AnalysisOptions,
803 pub file: String,
805 pub export_name: String,
807}
808
809#[derive(Debug, Clone, Default)]
811pub struct TraceFileOptions {
812 pub analysis: AnalysisOptions,
814 pub file: String,
816}
817
818#[derive(Debug, Clone, Default)]
820pub struct TraceImportPathOptions {
821 pub analysis: AnalysisOptions,
823 pub from: String,
825 pub to: String,
827 pub eager_only: bool,
830}
831
832#[derive(Debug, Clone, Default)]
834pub struct TraceErrorOptions {
835 pub analysis: AnalysisOptions,
837 pub trace: String,
839 pub source: String,
841}
842
843#[derive(Debug, Clone, Default)]
845pub struct TraceDependencyOptions {
846 pub analysis: AnalysisOptions,
848 pub package_name: String,
850}
851
852#[derive(Debug, Clone, PartialEq, Eq)]
854pub enum TraceCloneTarget {
855 Location {
857 file: String,
859 line: usize,
861 },
862 Fingerprint(String),
864}
865
866#[derive(Debug, Clone)]
868pub struct TraceCloneOptions {
869 pub duplication: DuplicationOptions,
871 pub target: TraceCloneTarget,
873}
874
875#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
877pub enum ComplexitySort {
878 #[default]
880 Cyclomatic,
881 Cognitive,
883 Lines,
885 Severity,
887}
888
889#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
891pub enum OwnershipEmailMode {
892 Raw,
894 #[default]
896 Handle,
897 Anonymized,
899 Hash,
901}
902
903#[derive(Debug, Clone, Copy, PartialEq, Eq)]
905pub enum TargetEffort {
906 Low,
908 Medium,
910 High,
912}
913
914#[derive(Debug, Clone, Default)]
916pub struct ComplexityOptions {
917 pub analysis: AnalysisOptions,
919 pub max_cyclomatic: Option<u16>,
921 pub max_cognitive: Option<u16>,
923 pub max_crap: Option<f64>,
925 pub top: Option<usize>,
927 pub sort: ComplexitySort,
929 pub complexity_breakdown: bool,
931 pub complexity: bool,
933 pub file_scores: bool,
935 pub coverage_gaps: bool,
937 pub hotspots: bool,
939 pub ownership: bool,
941 pub ownership_emails: Option<OwnershipEmailMode>,
943 pub targets: bool,
945 pub css: bool,
947 pub css_deep: bool,
949 pub effort: Option<TargetEffort>,
952 pub score: bool,
954 pub since: Option<String>,
956 pub min_commits: Option<u32>,
958 pub coverage: Option<PathBuf>,
960 pub coverage_root: Option<PathBuf>,
962 pub coverage_relocated: bool,
967}
968
969#[derive(Debug, Clone, Copy, Default, PartialEq)]
971pub struct ComplexityThresholdOverrides {
972 pub max_cyclomatic: Option<u16>,
974 pub max_cognitive: Option<u16>,
976 pub max_crap: Option<f64>,
978}
979
980#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
982pub struct ComplexityCoverageInputs<'a> {
983 pub coverage: Option<&'a Path>,
985 pub coverage_root: Option<&'a Path>,
987 pub coverage_relocated: bool,
990}
991
992#[derive(Debug, Clone)]
994pub struct HealthSectionOptions {
995 pub output: fallow_types::output_format::OutputFormat,
997 pub complexity: bool,
999 pub file_scores: bool,
1001 pub coverage_gaps: bool,
1003 pub hotspots: bool,
1005 pub targets: bool,
1007 pub css: bool,
1009 pub score: bool,
1011 pub score_gate: bool,
1013 pub snapshot_requested: bool,
1015 pub trend: bool,
1017}
1018
1019#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1021pub struct DerivedHealthSections {
1022 pub any_section: bool,
1024 pub complexity: bool,
1026 pub file_scores: bool,
1028 pub coverage_gaps: bool,
1030 pub hotspots: bool,
1032 pub targets: bool,
1034 pub css: bool,
1036 pub score: bool,
1038 pub force_full: bool,
1040 pub score_only_output: bool,
1042}
1043
1044#[derive(Debug, Clone)]
1046pub struct ComplexitySectionOptions {
1047 pub complexity: bool,
1049 pub file_scores: bool,
1051 pub coverage_gaps: bool,
1053 pub hotspots: bool,
1055 pub ownership: bool,
1057 pub targets: bool,
1059 pub css: bool,
1061 pub score: bool,
1063}
1064
1065#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1067pub struct DerivedComplexityOptions {
1068 pub any_section: bool,
1070 pub complexity: bool,
1072 pub file_scores: bool,
1074 pub coverage_gaps: bool,
1076 pub hotspots: bool,
1078 pub ownership: bool,
1080 pub targets: bool,
1082 pub force_full: bool,
1084 pub score_only_output: bool,
1086 pub score: bool,
1088}
1089
1090#[derive(Debug, Clone, PartialEq)]
1092pub struct ComplexityRunOptions<'a> {
1093 pub thresholds: ComplexityThresholdOverrides,
1095 pub top: Option<usize>,
1097 pub sort: ComplexitySort,
1099 pub complexity_breakdown: bool,
1101 pub sections: DerivedComplexityOptions,
1103 pub ownership_emails: Option<OwnershipEmailMode>,
1105 pub effort: Option<TargetEffort>,
1107 pub css: bool,
1109 pub css_deep: bool,
1111 pub since: Option<&'a str>,
1113 pub min_commits: Option<u32>,
1115 pub coverage_inputs: ComplexityCoverageInputs<'a>,
1117}
1118
1119#[must_use]
1121pub fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
1122 let score = options.score
1123 || options.score_gate
1124 || options.trend
1125 || matches!(
1126 options.output,
1127 fallow_types::output_format::OutputFormat::Badge
1128 );
1129 let any_section = options.complexity
1130 || options.file_scores
1131 || options.coverage_gaps
1132 || options.hotspots
1133 || options.targets
1134 || score;
1135 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
1136 let force_full = options.snapshot_requested || effective_score;
1137
1138 DerivedHealthSections {
1139 any_section,
1140 complexity: if any_section {
1141 options.complexity
1142 } else {
1143 true
1144 },
1145 file_scores: if any_section {
1146 options.file_scores
1147 } else {
1148 true
1149 } || force_full,
1150 coverage_gaps: if any_section {
1151 options.coverage_gaps
1152 } else {
1153 false
1154 },
1155 hotspots: if any_section { options.hotspots } else { true }
1156 || options.snapshot_requested
1157 || options.trend,
1158 targets: if any_section { options.targets } else { true },
1159 css: options.css,
1160 score: effective_score,
1161 force_full,
1162 score_only_output: is_health_score_only_output(options, score),
1163 }
1164}
1165
1166#[must_use]
1168pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
1169 let requested_hotspots = options.hotspots || options.ownership;
1170 let sections = derive_health_sections(&HealthSectionOptions {
1171 output: fallow_types::output_format::OutputFormat::Human,
1172 complexity: options.complexity,
1173 file_scores: options.file_scores,
1174 coverage_gaps: options.coverage_gaps,
1175 hotspots: requested_hotspots,
1176 targets: options.targets,
1177 css: options.css,
1178 score: options.score,
1179 score_gate: false,
1180 snapshot_requested: false,
1181 trend: false,
1182 });
1183
1184 DerivedComplexityOptions {
1185 any_section: sections.any_section,
1186 complexity: sections.complexity,
1187 file_scores: sections.file_scores,
1188 coverage_gaps: sections.coverage_gaps,
1189 hotspots: sections.hotspots,
1190 ownership: options.ownership && sections.hotspots,
1191 targets: sections.targets,
1192 force_full: sections.force_full,
1193 score_only_output: sections.score_only_output,
1194 score: sections.score,
1195 }
1196}
1197
1198#[must_use]
1200pub fn derive_complexity_options(options: &ComplexityOptions) -> DerivedComplexityOptions {
1201 derive_complexity_sections(&complexity_section_options(options))
1202}
1203
1204#[must_use]
1206pub fn derive_complexity_run_options(options: &ComplexityOptions) -> ComplexityRunOptions<'_> {
1207 ComplexityRunOptions {
1208 thresholds: ComplexityThresholdOverrides {
1209 max_cyclomatic: options.max_cyclomatic,
1210 max_cognitive: options.max_cognitive,
1211 max_crap: options.max_crap,
1212 },
1213 top: options.top,
1214 sort: options.sort,
1215 complexity_breakdown: options.complexity_breakdown,
1216 sections: derive_complexity_options(options),
1217 ownership_emails: options.ownership_emails,
1218 effort: options.effort,
1219 css: options.css,
1220 css_deep: options.css_deep,
1221 since: options.since.as_deref(),
1222 min_commits: options.min_commits,
1223 coverage_inputs: ComplexityCoverageInputs {
1224 coverage: options.coverage.as_deref(),
1225 coverage_root: options.coverage_root.as_deref(),
1226 coverage_relocated: options.coverage_relocated,
1227 },
1228 }
1229}
1230
1231pub fn validate_complexity_options(options: &ComplexityOptions) -> Result<(), ProgrammaticError> {
1254 if let Some(path) = &options.coverage {
1255 let resolved = fallow_engine::health::scoring::resolve_relative_to_root(
1256 path,
1257 options.analysis.root.as_deref(),
1258 );
1259 if !resolved.exists() {
1260 return Err(ProgrammaticError::new(
1261 format!("coverage path does not exist: {}", resolved.display()),
1262 2,
1263 )
1264 .with_code("FALLOW_INVALID_COVERAGE_PATH")
1265 .with_context("health.coverage"));
1266 }
1267 }
1268 if let Err(message) =
1269 fallow_engine::health::validate_coverage_root_absolute(options.coverage_root.as_deref())
1270 {
1271 return Err(ProgrammaticError::new(message, 2)
1272 .with_code("FALLOW_INVALID_COVERAGE_ROOT")
1273 .with_context("health.coverage_root"));
1274 }
1275
1276 Ok(())
1277}
1278
1279fn complexity_section_options(options: &ComplexityOptions) -> ComplexitySectionOptions {
1280 let ownership = options.ownership || options.ownership_emails.is_some();
1281 let requested_targets = options.targets || options.effort.is_some();
1282 ComplexitySectionOptions {
1283 complexity: options.complexity,
1284 file_scores: options.file_scores,
1285 coverage_gaps: options.coverage_gaps,
1286 hotspots: options.hotspots,
1287 ownership,
1288 targets: requested_targets,
1289 css: options.css,
1290 score: options.score,
1291 }
1292}
1293
1294fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
1295 score
1296 && !options.complexity
1297 && !options.file_scores
1298 && !options.coverage_gaps
1299 && !options.hotspots
1300 && !options.targets
1301 && !options.trend
1302}
1303
1304const fn thresholds_to_engine(
1305 thresholds: ComplexityThresholdOverrides,
1306) -> fallow_engine::health::HealthThresholdOverrides {
1307 fallow_engine::health::HealthThresholdOverrides {
1308 max_cyclomatic: thresholds.max_cyclomatic,
1309 max_cognitive: thresholds.max_cognitive,
1310 max_crap: thresholds.max_crap,
1311 }
1312}
1313
1314const fn complexity_sort_to_engine(sort: ComplexitySort) -> fallow_engine::health::HealthSort {
1315 match sort {
1316 ComplexitySort::Severity => fallow_engine::health::HealthSort::Severity,
1317 ComplexitySort::Cyclomatic => fallow_engine::health::HealthSort::Cyclomatic,
1318 ComplexitySort::Cognitive => fallow_engine::health::HealthSort::Cognitive,
1319 ComplexitySort::Lines => fallow_engine::health::HealthSort::Lines,
1320 }
1321}
1322
1323const fn coverage_inputs_to_engine(
1324 coverage_inputs: ComplexityCoverageInputs<'_>,
1325) -> fallow_engine::health::HealthCoverageInputs<'_> {
1326 fallow_engine::health::HealthCoverageInputs {
1327 coverage: coverage_inputs.coverage,
1328 coverage_root: coverage_inputs.coverage_root,
1329 coverage_relocated: coverage_inputs.coverage_relocated,
1330 }
1331}
1332
1333const fn ownership_email_mode_to_config(mode: OwnershipEmailMode) -> EmailMode {
1334 match mode {
1335 OwnershipEmailMode::Raw => EmailMode::Raw,
1336 OwnershipEmailMode::Handle => EmailMode::Handle,
1337 OwnershipEmailMode::Anonymized => EmailMode::Anonymized,
1338 OwnershipEmailMode::Hash => EmailMode::Hash,
1339 }
1340}
1341
1342const fn target_effort_to_output(effort: TargetEffort) -> EffortEstimate {
1343 match effort {
1344 TargetEffort::Low => EffortEstimate::Low,
1345 TargetEffort::Medium => EffortEstimate::Medium,
1346 TargetEffort::High => EffortEstimate::High,
1347 }
1348}
1349
1350#[cfg(test)]
1351mod tests {
1352 use super::*;
1353
1354 #[test]
1355 fn duplication_defaults_match_cli_contract() {
1356 let options = DuplicationOptions::default();
1357 assert!(options.mode.is_none());
1358 assert!(options.min_tokens.is_none());
1359 assert!(options.min_lines.is_none());
1360 assert!(options.min_occurrences.is_none());
1361 }
1362
1363 #[test]
1364 fn programmatic_error_builder_keeps_optional_fields() {
1365 let error = ProgrammaticError::new("boom", 2)
1366 .with_code("FALLOW_TEST")
1367 .with_help("Try again")
1368 .with_context("analysis.root");
1369
1370 assert_eq!(error.message, "boom");
1371 assert_eq!(error.exit_code, 2);
1372 assert_eq!(error.code.as_deref(), Some("FALLOW_TEST"));
1373 assert_eq!(error.help.as_deref(), Some("Try again"));
1374 assert_eq!(error.context.as_deref(), Some("analysis.root"));
1375 }
1376
1377 #[test]
1378 fn dead_code_filters_accept_shared_registry_selectors() {
1379 for (selector, _) in fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS.iter() {
1380 let mut filters = DeadCodeFilters::default();
1381 assert!(
1382 filters.enable_registry_selector(selector),
1383 "{selector} should be accepted"
1384 );
1385 }
1386
1387 let mut filters = DeadCodeFilters::default();
1388 assert!(filters.enable_registry_selector("unused-files"));
1389 assert!(filters.unused_files);
1390 assert!(filters.enable_registry_selector("boundary-violations"));
1391 assert!(filters.boundary_violations);
1392 assert!(!filters.enable_registry_selector("not-a-real-selector"));
1393 }
1394
1395 #[test]
1396 fn default_complexity_options_match_programmatic_health_defaults() {
1397 let derived = derive_complexity_options(&ComplexityOptions::default());
1398
1399 assert!(!derived.any_section);
1400 assert!(derived.complexity);
1401 assert!(derived.file_scores);
1402 assert!(!derived.coverage_gaps);
1403 assert!(derived.hotspots);
1404 assert!(!derived.ownership);
1405 assert!(derived.targets);
1406 assert!(derived.force_full);
1407 assert!(!derived.score_only_output);
1408 assert!(derived.score);
1409 }
1410
1411 #[test]
1412 fn score_only_complexity_options_request_score_only_output() {
1413 let derived = derive_complexity_options(&ComplexityOptions {
1414 score: true,
1415 ..ComplexityOptions::default()
1416 });
1417
1418 assert!(derived.any_section);
1419 assert!(!derived.complexity);
1420 assert!(derived.file_scores);
1421 assert!(!derived.hotspots);
1422 assert!(!derived.targets);
1423 assert!(derived.force_full);
1424 assert!(derived.score_only_output);
1425 assert!(derived.score);
1426 }
1427
1428 #[test]
1429 fn ownership_implies_hotspots_when_requested() {
1430 let derived = derive_complexity_options(&ComplexityOptions {
1431 ownership: true,
1432 ..ComplexityOptions::default()
1433 });
1434
1435 assert!(derived.any_section);
1436 assert!(derived.hotspots);
1437 assert!(derived.ownership);
1438 assert!(!derived.targets);
1439 }
1440
1441 #[test]
1442 fn complexity_run_options_normalize_public_api_options() {
1443 let options = ComplexityOptions {
1444 max_cyclomatic: Some(42),
1445 max_cognitive: Some(21),
1446 max_crap: Some(18.5),
1447 top: Some(7),
1448 sort: ComplexitySort::Severity,
1449 complexity_breakdown: true,
1450 ownership_emails: Some(OwnershipEmailMode::Hash),
1451 effort: Some(TargetEffort::High),
1452 coverage: Some(PathBuf::from("coverage/coverage-final.json")),
1453 coverage_root: Some(PathBuf::from("/ci/workspace")),
1454 since: Some("30d".to_string()),
1455 min_commits: Some(4),
1456 ..ComplexityOptions::default()
1457 };
1458
1459 let run = derive_complexity_run_options(&options);
1460
1461 assert_eq!(run.thresholds.max_cyclomatic, Some(42));
1462 assert_eq!(run.thresholds.max_cognitive, Some(21));
1463 assert_eq!(run.thresholds.max_crap, Some(18.5));
1464 assert_eq!(run.top, Some(7));
1465 assert!(matches!(run.sort, ComplexitySort::Severity));
1466 assert!(run.complexity_breakdown);
1467 assert!(run.sections.hotspots);
1468 assert!(run.sections.ownership);
1469 assert!(run.sections.targets);
1470 assert!(matches!(
1471 run.ownership_emails,
1472 Some(OwnershipEmailMode::Hash)
1473 ));
1474 assert!(matches!(run.effort, Some(TargetEffort::High)));
1475 assert_eq!(run.since, Some("30d"));
1476 assert_eq!(run.min_commits, Some(4));
1477 assert_eq!(run.coverage_inputs.coverage, options.coverage.as_deref());
1478 assert_eq!(
1479 run.coverage_inputs.coverage_root,
1480 options.coverage_root.as_deref()
1481 );
1482 }
1483
1484 #[test]
1485 fn complexity_options_validation_accepts_existing_coverage_path_and_absolute_root() {
1486 let dir = tempfile::tempdir().expect("tempdir");
1487 let coverage = dir.path().join("coverage-final.json");
1488 std::fs::write(&coverage, "{}").expect("coverage fixture");
1489
1490 let result = validate_complexity_options(&ComplexityOptions {
1491 coverage: Some(coverage),
1492 coverage_root: Some(PathBuf::from("/ci/workspace")),
1493 ..ComplexityOptions::default()
1494 });
1495
1496 assert!(result.is_ok());
1497 }
1498
1499 #[test]
1500 fn complexity_options_validation_keeps_missing_coverage_error_contract() {
1501 let err = validate_complexity_options(&ComplexityOptions {
1502 coverage: Some(PathBuf::from("/missing/coverage-final.json")),
1503 ..ComplexityOptions::default()
1504 })
1505 .expect_err("missing coverage path should fail");
1506
1507 assert_eq!(err.exit_code, 2);
1508 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1509 assert_eq!(err.context.as_deref(), Some("health.coverage"));
1510 }
1511
1512 #[test]
1513 fn complexity_options_validation_keeps_relative_coverage_root_error_contract() {
1514 let err = validate_complexity_options(&ComplexityOptions {
1515 coverage_root: Some(PathBuf::from("coverage")),
1516 ..ComplexityOptions::default()
1517 })
1518 .expect_err("relative coverage root should fail");
1519
1520 assert_eq!(err.exit_code, 2);
1521 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_ROOT"));
1522 assert_eq!(err.context.as_deref(), Some("health.coverage_root"));
1523 }
1524
1525 #[test]
1529 fn complexity_options_validation_resolves_relative_coverage_against_root() {
1530 let dir = tempfile::tempdir().expect("tempdir");
1531 std::fs::create_dir_all(dir.path().join("artifacts")).expect("artifacts dir");
1532 std::fs::write(dir.path().join("artifacts/coverage-final.json"), "{}")
1533 .expect("coverage fixture");
1534 let relative = PathBuf::from("artifacts/coverage-final.json");
1535 assert!(
1536 !relative.exists(),
1537 "the fixture must not also exist under the test cwd"
1538 );
1539
1540 let result = validate_complexity_options(&ComplexityOptions {
1541 analysis: AnalysisOptions {
1542 root: Some(dir.path().to_path_buf()),
1543 ..AnalysisOptions::default()
1544 },
1545 coverage: Some(relative.clone()),
1546 ..ComplexityOptions::default()
1547 });
1548 assert!(result.is_ok(), "{result:?}");
1549
1550 let err = validate_complexity_options(&ComplexityOptions {
1551 analysis: AnalysisOptions {
1552 root: Some(dir.path().join("elsewhere")),
1553 ..AnalysisOptions::default()
1554 },
1555 coverage: Some(relative),
1556 ..ComplexityOptions::default()
1557 })
1558 .expect_err("the path does not exist under the other root");
1559 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1560 assert!(
1561 err.message.contains("elsewhere"),
1562 "the message names the resolved path: {}",
1563 err.message
1564 );
1565 }
1566
1567 #[test]
1568 fn default_health_sections_match_full_health_output() {
1569 let derived = derive_health_sections(&HealthSectionOptions {
1570 output: fallow_types::output_format::OutputFormat::Human,
1571 complexity: false,
1572 file_scores: false,
1573 coverage_gaps: false,
1574 hotspots: false,
1575 targets: false,
1576 css: false,
1577 score: false,
1578 score_gate: false,
1579 snapshot_requested: false,
1580 trend: false,
1581 });
1582
1583 assert!(!derived.any_section);
1584 assert!(derived.complexity);
1585 assert!(derived.file_scores);
1586 assert!(!derived.coverage_gaps);
1587 assert!(derived.hotspots);
1588 assert!(derived.targets);
1589 assert!(derived.score);
1590 assert!(derived.force_full);
1591 assert!(!derived.score_only_output);
1592 }
1593
1594 #[test]
1595 fn health_score_gate_requests_score_only_output() {
1596 let derived = derive_health_sections(&HealthSectionOptions {
1597 output: fallow_types::output_format::OutputFormat::Human,
1598 complexity: false,
1599 file_scores: false,
1600 coverage_gaps: false,
1601 hotspots: false,
1602 targets: false,
1603 css: false,
1604 score: false,
1605 score_gate: true,
1606 snapshot_requested: false,
1607 trend: false,
1608 });
1609
1610 assert!(derived.any_section);
1611 assert!(!derived.complexity);
1612 assert!(derived.file_scores);
1613 assert!(!derived.hotspots);
1614 assert!(!derived.targets);
1615 assert!(derived.score);
1616 assert!(derived.force_full);
1617 assert!(derived.score_only_output);
1618 }
1619
1620 #[test]
1621 fn health_snapshot_keeps_full_hidden_inputs_without_section_request() {
1622 let derived = derive_health_sections(&HealthSectionOptions {
1623 output: fallow_types::output_format::OutputFormat::Human,
1624 complexity: false,
1625 file_scores: false,
1626 coverage_gaps: false,
1627 hotspots: false,
1628 targets: false,
1629 css: true,
1630 score: false,
1631 score_gate: false,
1632 snapshot_requested: true,
1633 trend: false,
1634 });
1635
1636 assert!(!derived.any_section);
1637 assert!(derived.css);
1638 assert!(derived.file_scores);
1639 assert!(derived.hotspots);
1640 assert!(derived.score);
1641 assert!(derived.force_full);
1642 }
1643}