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_change_scope,
106 filter_inline_complexity_by_changed_files, resolve_git_toplevel,
107 try_get_changed_files_with_toplevel,
108};
109pub use explain::{
110 CHECK_RULES, DUPES_RULES, FLAGS_RULES, HEALTH_RULES, RuleDef, RuleGuide, SECURITY_RULES,
111 all_rules, bare_rule_id, coverage_analyze_meta, coverage_setup_meta, explain_issue_type,
112 rule_by_id, rule_by_token, rule_command, rule_docs_url, rule_guide, rule_severity_key,
113 security_meta, serialize_explain_programmatic_json, unknown_explain_error,
114};
115pub use fallow_config::levenshtein::closest_match;
116pub use fallow_config::{AuditGate, HealthConfig, TypeAwareRequire};
117pub use fallow_engine::change_scope::{
120 ChangeScope, ChangeScopeOwner, ChangeScopeRequest, first_package_baselines,
121 package_baseline_statuses,
122};
123pub use fallow_engine::package_baselines::{PackageBaselineError, PackageChangeScope};
125pub use fallow_engine::warm_parse;
131pub use fallow_output::PackageBaselineStatus;
133pub use fallow_output::serialize_similar_code_json_output;
134pub use fallow_types::trace::{
135 CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace, ReExportChain,
136 TracedCloneGroup, TracedExport, TracedReExport,
137};
138pub use grouped_output::{
139 ResultGroup, UNOWNED_GROUP_LABEL, build_duplication_grouping_with, group_analysis_results_with,
140 health_signal_header_part, largest_clone_group_owner_with,
141};
142pub use health_codeclimate::build_health_codeclimate;
143pub use json_output::{
144 CheckJsonExtraOutputs, CheckJsonOutputInput, CheckJsonPayloadInput, DuplicationJsonOutputInput,
145 GroupedCheckJsonOutputInput, GroupedDuplicationJsonOutputInput, serialize_check_json,
146 serialize_check_json_payload, serialize_duplication_json, serialize_grouped_check_json,
147 serialize_grouped_duplication_json,
148};
149pub use list_output::{
150 ListJsonEnvelope, ListJsonOutputInput, build_list_json_output, serialize_list_json_output,
151};
152pub use list_runtime::{
153 BoundaryData, ListBoundariesOptions, ListBoundariesProgrammaticOutput, LogicalGroupInfo,
154 ProjectInfoOptions, ProjectInfoProgrammaticOutput, RuleInfo, ZoneInfo, boundary_data_to_output,
155 compute_boundary_data, run_list_boundaries, run_project_info,
156 serialize_list_boundaries_programmatic_json, serialize_project_info_programmatic_json,
157};
158pub use markdown_output::{
159 build_duplication_markdown, build_grouped_markdown, build_health_markdown, build_markdown,
160 build_walkthrough_markdown,
161};
162pub use output_contracts::{
163 AuditOutput, BoundariesListLogicalGroup, BoundariesListRule, BoundariesListZone,
164 BoundariesListing, CombinedOutput, FallowOutput, ImpactOutput, ListBoundariesOutput,
165 ListEntryPointOutput, ListOutput, ListPluginOutput, ReviewBriefWireOutput, SecurityGate,
166 SecurityOutput, SecurityOutputConfig, SecuritySummaryOutput, SimilarCodeCandidateSnapshot,
167 SimilarCodeOutput, TraceOutput, WorkspacesOutput,
168};
169pub use runtime::{
170 AuditProgrammaticKeySnapshot, AuditProgrammaticOutput, BoundaryViolationsOutput,
171 BoundaryViolationsProgrammaticOutput, CircularDependenciesOutput,
172 CircularDependenciesProgrammaticOutput, CombinedProgrammaticOutput, DeadCodeOutput,
173 DeadCodeProgrammaticOutput, DecisionSurfaceProgrammaticOutput, DuplicationOutput,
174 DuplicationProgrammaticOutput, EngineHealthRunner, FeatureFlagsOutput,
175 FeatureFlagsProgrammaticOutput, HealthJsonReportInput, HealthProgrammaticOutput,
176 ProgrammaticHealthAnalysis, ProgrammaticHealthNextStepFacts, ProgrammaticHealthRun,
177 ProgrammaticHealthRunner, TraceClassMemberOutput, TraceCloneBenchmarkResult, TraceCloneOutput,
178 TraceCloneProgrammaticOutput, TraceDependencyOutput, TraceDependencyProgrammaticOutput,
179 TraceErrorOutput, TraceErrorProgrammaticOutput, TraceExportOutput,
180 TraceExportProgrammaticOutput, TraceExportTargetOutput, TraceFileOutput,
181 TraceFileProgrammaticOutput, TraceImportPathOutput, TraceImportPathProgrammaticOutput,
182 benchmark_trace_clone_compact_json, benchmark_trace_graph_family_compact_json,
183 inspect_similar_code, load_health_config, parse_similar_code_candidate_snapshot,
184 review_similar_code, run_audit, run_boundary_violations, run_circular_dependencies,
185 run_combined, run_complexity_with_runner, run_dead_code, run_dead_code_with_baseline,
186 run_decision_surface, run_duplication, run_feature_flags, run_health, run_health_with_runner,
187 run_similar_code, run_trace_clone, run_trace_dependency, run_trace_error, run_trace_export,
188 run_trace_file, run_trace_import_path, select_similar_code_candidate_snapshot,
189 serialize_health_report_json,
190};
191pub use runtime_json::{
192 serialize_audit_programmatic_json, serialize_boundary_violations_programmatic_json,
193 serialize_circular_dependencies_programmatic_json, serialize_combined_programmatic_json,
194 serialize_dead_code_programmatic_json, serialize_decision_surface_programmatic_json,
195 serialize_duplication_programmatic_json, serialize_feature_flags_programmatic_json,
196 serialize_health_programmatic_json, serialize_trace_clone_programmatic_json,
197 serialize_trace_dependency_programmatic_json, serialize_trace_error_programmatic_json,
198 serialize_trace_export_programmatic_json, serialize_trace_file_programmatic_json,
199 serialize_trace_import_path_programmatic_json,
200};
201pub use sarif_output::{
202 annotate_sarif_results, build_duplication_sarif, build_grouped_duplication_sarif,
203 build_health_sarif,
204};
205pub use security_output::SecurityGateMode;
206pub use type_aware::{
207 SemanticCouplingOutcome, SemanticDeadCodeOutcome, SemanticInspectOutcome, TypeAwareError,
208 TypeAwareFileChanges, TypeAwareOutcome, TypeAwareSession, TypeAwareStatus,
209 discard_unverified_semantic_candidates, inspect_symbol as inspect_type_aware_symbol,
210 merge_type_aware_meta,
211 refine_configured_dead_code_results as refine_type_aware_results_with_config,
212 shutdown_type_aware_sidecars, status as type_aware_status,
213 symbol_impact as run_type_aware_symbol_impact, symbol_impact as type_aware_symbol_impact,
214 terminate_active_type_aware_sidecars, trace_symbol as run_type_aware_symbol_trace,
215 trace_symbol as trace_type_aware_symbol, type_coupling as analyze_type_coupling,
216};
217
218pub const COMMON_ANALYSIS_OPTION_FLAGS: &[&str] = &[
225 "root",
226 "config",
227 "no-cache",
228 "threads",
229 "changed-since",
230 "diff-file",
231 "production",
232 "workspace",
233 "changed-workspaces",
234 "explain",
235 "allow-remote-extends",
236];
237
238#[derive(Debug, Clone, Serialize)]
240pub struct ProgrammaticError {
241 pub message: String,
243 pub exit_code: u8,
245 pub code: Option<String>,
247 pub help: Option<String>,
249 pub context: Option<String>,
251}
252
253impl ProgrammaticError {
254 #[must_use]
257 pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
258 Self {
259 message: message.into(),
260 exit_code,
261 code: None,
262 help: None,
263 context: None,
264 }
265 }
266
267 #[must_use]
269 pub fn with_help(mut self, help: impl Into<String>) -> Self {
270 self.help = Some(help.into());
271 self
272 }
273
274 #[must_use]
277 pub fn with_code(mut self, code: impl Into<String>) -> Self {
278 self.code = Some(code.into());
279 self
280 }
281
282 #[must_use]
285 pub fn with_context(mut self, context: impl Into<String>) -> Self {
286 self.context = Some(context.into());
287 self
288 }
289}
290
291impl std::fmt::Display for ProgrammaticError {
292 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293 write!(f, "{}", self.message)
294 }
295}
296
297impl std::error::Error for ProgrammaticError {}
298
299#[derive(Debug, Clone, Default)]
301pub struct AnalysisOptions {
302 pub root: Option<PathBuf>,
305 pub config_path: Option<PathBuf>,
307 pub allow_remote_extends: bool,
309 pub no_cache: bool,
311 pub threads: Option<usize>,
313 pub diff_file: Option<PathBuf>,
318 pub ambient_diff_file: Option<PathBuf>,
326 pub production: bool,
329 pub production_override: Option<bool>,
332 pub changed_since: Option<String>,
337 pub ambient_changed_since: Option<String>,
345 pub no_package_baselines: bool,
349 pub workspace: Option<Vec<String>>,
351 pub changed_workspaces: Option<String>,
353 pub explain: bool,
355 pub type_aware: TypeAwareOptions,
358 pub cancellation: Option<Arc<AtomicBool>>,
387}
388
389#[derive(Debug, Clone, Default, PartialEq, Eq)]
391pub struct TypeAwareOptions {
392 pub enabled: bool,
394 pub projects: Vec<PathBuf>,
397 pub require: fallow_config::TypeAwareRequire,
399}
400
401#[derive(Debug, Clone, Default)]
406pub struct DeadCodeFilters {
407 pub unused_files: bool,
409 pub unused_exports: bool,
411 pub unused_deps: bool,
413 pub unused_types: bool,
415 pub private_type_leaks: bool,
417 pub deprecated_exports_in_use: bool,
419 pub unused_enum_members: bool,
421 pub unused_class_members: bool,
423 pub unused_store_members: bool,
425 pub unprovided_injects: bool,
427 pub unrendered_components: bool,
429 pub unused_component_props: bool,
431 pub unused_component_emits: bool,
433 pub unused_component_inputs: bool,
435 pub unused_component_outputs: bool,
437 pub unused_svelte_events: bool,
439 pub unused_server_actions: bool,
441 pub unused_load_data_keys: bool,
443 pub unresolved_imports: bool,
445 pub unlisted_deps: bool,
447 pub duplicate_exports: bool,
449 pub circular_deps: bool,
451 pub re_export_cycles: bool,
453 pub package_cycles: bool,
455 pub boundary_violations: bool,
457 pub policy_violations: bool,
459 pub stale_suppressions: bool,
461 pub unused_catalog_entries: bool,
463 pub empty_catalog_groups: bool,
465 pub unresolved_catalog_references: bool,
467 pub unused_dependency_overrides: bool,
469 pub misconfigured_dependency_overrides: bool,
471}
472
473impl DeadCodeFilters {
474 pub(crate) fn reports_dependency_findings(&self) -> bool {
478 !self.any_active() || self.unused_deps || self.unlisted_deps
479 }
480
481 fn any_active(&self) -> bool {
482 self.unused_files
483 || self.unused_exports
484 || self.unused_deps
485 || self.unused_types
486 || self.private_type_leaks
487 || self.deprecated_exports_in_use
488 || self.unused_enum_members
489 || self.unused_class_members
490 || self.unused_store_members
491 || self.unprovided_injects
492 || self.unrendered_components
493 || self.unused_component_props
494 || self.unused_component_emits
495 || self.unused_component_inputs
496 || self.unused_component_outputs
497 || self.unused_svelte_events
498 || self.unused_server_actions
499 || self.unused_load_data_keys
500 || self.unresolved_imports
501 || self.unlisted_deps
502 || self.duplicate_exports
503 || self.circular_deps
504 || self.re_export_cycles
505 || self.package_cycles
506 || self.boundary_violations
507 || self.policy_violations
508 || self.stale_suppressions
509 || self.unused_catalog_entries
510 || self.empty_catalog_groups
511 || self.unresolved_catalog_references
512 || self.unused_dependency_overrides
513 || self.misconfigured_dependency_overrides
514 }
515
516 pub fn enable_registry_selector(&mut self, selector: &str) -> bool {
522 let Some(flag) = fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS
523 .iter()
524 .find_map(|&(name, flag)| (name == selector).then_some(flag))
525 else {
526 return false;
527 };
528 self.enable_cli_filter_flag(flag);
529 true
530 }
531
532 fn enable_cli_filter_flag(&mut self, flag: &str) {
533 match flag {
534 "--unused-files" => self.unused_files = true,
535 "--unused-exports" => self.unused_exports = true,
536 "--unused-types" => self.unused_types = true,
537 "--private-type-leaks" => self.private_type_leaks = true,
538 "--deprecated-exports-in-use" => self.deprecated_exports_in_use = true,
539 "--unused-deps" => self.unused_deps = true,
540 "--unused-enum-members" => self.unused_enum_members = true,
541 "--unused-class-members" => self.unused_class_members = true,
542 "--unused-store-members" => self.unused_store_members = true,
543 "--unprovided-injects" => self.unprovided_injects = true,
544 "--unrendered-components" => self.unrendered_components = true,
545 "--unused-component-props" => self.unused_component_props = true,
546 "--unused-component-emits" => self.unused_component_emits = true,
547 "--unused-component-inputs" => self.unused_component_inputs = true,
548 "--unused-component-outputs" => self.unused_component_outputs = true,
549 "--unused-svelte-events" => self.unused_svelte_events = true,
550 "--unused-server-actions" => self.unused_server_actions = true,
551 "--unused-load-data-keys" => self.unused_load_data_keys = true,
552 "--unresolved-imports" => self.unresolved_imports = true,
553 "--unlisted-deps" => self.unlisted_deps = true,
554 "--duplicate-exports" => self.duplicate_exports = true,
555 "--circular-deps" => self.circular_deps = true,
556 "--re-export-cycles" => self.re_export_cycles = true,
557 "--package-cycles" => self.package_cycles = true,
558 "--boundary-violations" => self.boundary_violations = true,
559 "--policy-violations" => self.policy_violations = true,
560 "--stale-suppressions" => self.stale_suppressions = true,
561 "--unused-catalog-entries" => self.unused_catalog_entries = true,
562 "--empty-catalog-groups" => self.empty_catalog_groups = true,
563 "--unresolved-catalog-references" => self.unresolved_catalog_references = true,
564 "--unused-dependency-overrides" => self.unused_dependency_overrides = true,
565 "--misconfigured-dependency-overrides" => {
566 self.misconfigured_dependency_overrides = true;
567 }
568 _ => unreachable!("registry emitted unsupported dead-code filter flag: {flag}"),
569 }
570 }
571}
572
573#[derive(Debug, Clone, Default)]
575pub struct DeadCodeOptions {
576 pub analysis: AnalysisOptions,
578 pub filters: DeadCodeFilters,
580 pub files: Vec<PathBuf>,
582 pub include_entry_exports: bool,
584 pub finding_ids: Vec<String>,
589}
590
591#[derive(Debug, Clone, Default)]
593pub struct AuditOptions {
594 pub analysis: AnalysisOptions,
596 pub base: Option<String>,
599 pub production: bool,
601 pub production_dead_code: Option<bool>,
603 pub production_health: Option<bool>,
605 pub production_dupes: Option<bool>,
608 pub css: Option<bool>,
610 pub css_deep: Option<bool>,
612 pub gate: fallow_config::AuditGate,
614 pub max_crap: Option<f64>,
616 pub coverage: Option<PathBuf>,
618 pub coverage_root: Option<PathBuf>,
620 pub include_entry_exports: bool,
622 pub runtime_coverage: Option<PathBuf>,
624 pub min_invocations_hot: u64,
626}
627
628#[derive(Debug, Clone)]
630pub struct CombinedOptions {
631 pub analysis: AnalysisOptions,
633 pub dead_code: bool,
635 pub duplication: bool,
637 pub health: bool,
639 pub include_entry_exports: bool,
641 pub duplication_options: DuplicationOptions,
643 pub health_options: ComplexityOptions,
645}
646
647impl Default for CombinedOptions {
648 fn default() -> Self {
649 Self {
650 analysis: AnalysisOptions::default(),
651 dead_code: true,
652 duplication: true,
653 health: true,
654 include_entry_exports: false,
655 duplication_options: DuplicationOptions::default(),
656 health_options: ComplexityOptions::default(),
657 }
658 }
659}
660
661#[derive(Debug, Clone, Default)]
663pub struct DecisionSurfaceOptions {
664 pub analysis: AnalysisOptions,
666 pub base: Option<String>,
669 pub max_decisions: Option<usize>,
671}
672
673#[derive(Debug, Clone, Default)]
675pub struct FeatureFlagsOptions {
676 pub analysis: AnalysisOptions,
678 pub top: Option<usize>,
681 pub retirement: Option<FeatureFlagsRetirementOptions>,
683}
684
685#[derive(Debug, Clone, Default)]
688pub struct FeatureFlagsRetirementOptions {
689 pub flag_age: fallow_types::flag_retirement::FlagAgeMode,
691 pub flag_state: Option<std::path::PathBuf>,
693 pub reasons: Vec<fallow_types::flag_retirement::RetirementReason>,
695 pub sort: fallow_engine::flag_retirement::RetirementSort,
697 pub min_age_days: Option<u64>,
699 pub max_flag_age: Option<u64>,
701}
702
703#[derive(Debug, Clone, Copy, Default)]
705pub enum DuplicationMode {
706 Strict,
709 #[default]
711 Mild,
712 Weak,
714 Semantic,
717}
718
719#[derive(Debug, Clone, Default)]
721pub struct DuplicationOptions {
722 pub analysis: AnalysisOptions,
724 pub mode: Option<DuplicationMode>,
726 pub near: Option<bool>,
729 pub min_tokens: Option<usize>,
731 pub min_lines: Option<usize>,
733 pub min_occurrences: Option<usize>,
736 pub threshold: Option<f64>,
739 pub skip_local: Option<bool>,
742 pub cross_language: Option<bool>,
744 pub ignore_imports: Option<bool>,
747 pub top: Option<usize>,
749 pub include_fragments: Option<bool>,
753}
754
755#[derive(Debug, Clone, Default)]
757pub struct SimilarCodeOptions {
758 pub analysis: AnalysisOptions,
760 pub threshold: Option<f64>,
762 pub min_lines: Option<usize>,
765 pub top: Option<usize>,
767 pub files: Vec<PathBuf>,
771 #[doc(hidden)]
776 pub adapter_provider_path: Option<PathBuf>,
777}
778
779#[derive(Debug, Clone)]
781pub struct SimilarCodeInspectOptions {
782 pub analysis: AnalysisOptions,
785 pub snapshot: fallow_output::SimilarCodeCandidateSnapshot,
787}
788
789#[derive(Debug, Clone, Default)]
791pub struct TraceExportOptions {
792 pub analysis: AnalysisOptions,
794 pub file: String,
796 pub export_name: String,
798}
799
800#[derive(Debug, Clone, Default)]
802pub struct TraceFileOptions {
803 pub analysis: AnalysisOptions,
805 pub file: String,
807}
808
809#[derive(Debug, Clone, Default)]
811pub struct TraceImportPathOptions {
812 pub analysis: AnalysisOptions,
814 pub from: String,
816 pub to: String,
818 pub eager_only: bool,
821}
822
823#[derive(Debug, Clone, Default)]
825pub struct TraceErrorOptions {
826 pub analysis: AnalysisOptions,
828 pub trace: String,
830 pub source: String,
832}
833
834#[derive(Debug, Clone, Default)]
836pub struct TraceDependencyOptions {
837 pub analysis: AnalysisOptions,
839 pub package_name: String,
841}
842
843#[derive(Debug, Clone, PartialEq, Eq)]
845pub enum TraceCloneTarget {
846 Location {
848 file: String,
850 line: usize,
852 },
853 Fingerprint(String),
855}
856
857#[derive(Debug, Clone)]
859pub struct TraceCloneOptions {
860 pub duplication: DuplicationOptions,
862 pub target: TraceCloneTarget,
864}
865
866#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
868pub enum ComplexitySort {
869 #[default]
871 Cyclomatic,
872 Cognitive,
874 Lines,
876 Severity,
878}
879
880#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
882pub enum OwnershipEmailMode {
883 Raw,
885 #[default]
887 Handle,
888 Anonymized,
890 Hash,
892}
893
894#[derive(Debug, Clone, Copy, PartialEq, Eq)]
896pub enum TargetEffort {
897 Low,
899 Medium,
901 High,
903}
904
905#[derive(Debug, Clone, Default)]
907pub struct ComplexityOptions {
908 pub analysis: AnalysisOptions,
910 pub max_cyclomatic: Option<u16>,
912 pub max_cognitive: Option<u16>,
914 pub max_crap: Option<f64>,
916 pub top: Option<usize>,
918 pub sort: ComplexitySort,
920 pub complexity_breakdown: bool,
922 pub complexity: bool,
924 pub file_scores: bool,
926 pub coverage_gaps: bool,
928 pub hotspots: bool,
930 pub ownership: bool,
932 pub ownership_emails: Option<OwnershipEmailMode>,
934 pub targets: bool,
936 pub css: bool,
938 pub css_deep: bool,
940 pub effort: Option<TargetEffort>,
943 pub score: bool,
945 pub since: Option<String>,
947 pub min_commits: Option<u32>,
949 pub coverage: Option<PathBuf>,
951 pub coverage_root: Option<PathBuf>,
953 pub coverage_relocated: bool,
958}
959
960#[derive(Debug, Clone, Copy, Default, PartialEq)]
962pub struct ComplexityThresholdOverrides {
963 pub max_cyclomatic: Option<u16>,
965 pub max_cognitive: Option<u16>,
967 pub max_crap: Option<f64>,
969}
970
971#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
973pub struct ComplexityCoverageInputs<'a> {
974 pub coverage: Option<&'a Path>,
976 pub coverage_root: Option<&'a Path>,
978 pub coverage_relocated: bool,
981}
982
983#[derive(Debug, Clone)]
985pub struct HealthSectionOptions {
986 pub output: fallow_types::output_format::OutputFormat,
988 pub complexity: bool,
990 pub file_scores: bool,
992 pub coverage_gaps: bool,
994 pub hotspots: bool,
996 pub targets: bool,
998 pub css: bool,
1000 pub score: bool,
1002 pub score_gate: bool,
1004 pub snapshot_requested: bool,
1006 pub trend: bool,
1008}
1009
1010#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1012pub struct DerivedHealthSections {
1013 pub any_section: bool,
1015 pub complexity: bool,
1017 pub file_scores: bool,
1019 pub coverage_gaps: bool,
1021 pub hotspots: bool,
1023 pub targets: bool,
1025 pub css: bool,
1027 pub score: bool,
1029 pub force_full: bool,
1031 pub score_only_output: bool,
1033}
1034
1035#[derive(Debug, Clone)]
1037pub struct ComplexitySectionOptions {
1038 pub complexity: bool,
1040 pub file_scores: bool,
1042 pub coverage_gaps: bool,
1044 pub hotspots: bool,
1046 pub ownership: bool,
1048 pub targets: bool,
1050 pub css: bool,
1052 pub score: bool,
1054}
1055
1056#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1058pub struct DerivedComplexityOptions {
1059 pub any_section: bool,
1061 pub complexity: bool,
1063 pub file_scores: bool,
1065 pub coverage_gaps: bool,
1067 pub hotspots: bool,
1069 pub ownership: bool,
1071 pub targets: bool,
1073 pub force_full: bool,
1075 pub score_only_output: bool,
1077 pub score: bool,
1079}
1080
1081#[derive(Debug, Clone, PartialEq)]
1083pub struct ComplexityRunOptions<'a> {
1084 pub thresholds: ComplexityThresholdOverrides,
1086 pub top: Option<usize>,
1088 pub sort: ComplexitySort,
1090 pub complexity_breakdown: bool,
1092 pub sections: DerivedComplexityOptions,
1094 pub ownership_emails: Option<OwnershipEmailMode>,
1096 pub effort: Option<TargetEffort>,
1098 pub css: bool,
1100 pub css_deep: bool,
1102 pub since: Option<&'a str>,
1104 pub min_commits: Option<u32>,
1106 pub coverage_inputs: ComplexityCoverageInputs<'a>,
1108}
1109
1110#[must_use]
1112pub fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
1113 let score = options.score
1114 || options.score_gate
1115 || options.trend
1116 || matches!(
1117 options.output,
1118 fallow_types::output_format::OutputFormat::Badge
1119 );
1120 let any_section = options.complexity
1121 || options.file_scores
1122 || options.coverage_gaps
1123 || options.hotspots
1124 || options.targets
1125 || score;
1126 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
1127 let force_full = options.snapshot_requested || effective_score;
1128
1129 DerivedHealthSections {
1130 any_section,
1131 complexity: if any_section {
1132 options.complexity
1133 } else {
1134 true
1135 },
1136 file_scores: if any_section {
1137 options.file_scores
1138 } else {
1139 true
1140 } || force_full,
1141 coverage_gaps: if any_section {
1142 options.coverage_gaps
1143 } else {
1144 false
1145 },
1146 hotspots: if any_section { options.hotspots } else { true }
1147 || options.snapshot_requested
1148 || options.trend,
1149 targets: if any_section { options.targets } else { true },
1150 css: options.css,
1151 score: effective_score,
1152 force_full,
1153 score_only_output: is_health_score_only_output(options, score),
1154 }
1155}
1156
1157#[must_use]
1159pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
1160 let requested_hotspots = options.hotspots || options.ownership;
1161 let sections = derive_health_sections(&HealthSectionOptions {
1162 output: fallow_types::output_format::OutputFormat::Human,
1163 complexity: options.complexity,
1164 file_scores: options.file_scores,
1165 coverage_gaps: options.coverage_gaps,
1166 hotspots: requested_hotspots,
1167 targets: options.targets,
1168 css: options.css,
1169 score: options.score,
1170 score_gate: false,
1171 snapshot_requested: false,
1172 trend: false,
1173 });
1174
1175 DerivedComplexityOptions {
1176 any_section: sections.any_section,
1177 complexity: sections.complexity,
1178 file_scores: sections.file_scores,
1179 coverage_gaps: sections.coverage_gaps,
1180 hotspots: sections.hotspots,
1181 ownership: options.ownership && sections.hotspots,
1182 targets: sections.targets,
1183 force_full: sections.force_full,
1184 score_only_output: sections.score_only_output,
1185 score: sections.score,
1186 }
1187}
1188
1189#[must_use]
1191pub fn derive_complexity_options(options: &ComplexityOptions) -> DerivedComplexityOptions {
1192 derive_complexity_sections(&complexity_section_options(options))
1193}
1194
1195#[must_use]
1197pub fn derive_complexity_run_options(options: &ComplexityOptions) -> ComplexityRunOptions<'_> {
1198 ComplexityRunOptions {
1199 thresholds: ComplexityThresholdOverrides {
1200 max_cyclomatic: options.max_cyclomatic,
1201 max_cognitive: options.max_cognitive,
1202 max_crap: options.max_crap,
1203 },
1204 top: options.top,
1205 sort: options.sort,
1206 complexity_breakdown: options.complexity_breakdown,
1207 sections: derive_complexity_options(options),
1208 ownership_emails: options.ownership_emails,
1209 effort: options.effort,
1210 css: options.css,
1211 css_deep: options.css_deep,
1212 since: options.since.as_deref(),
1213 min_commits: options.min_commits,
1214 coverage_inputs: ComplexityCoverageInputs {
1215 coverage: options.coverage.as_deref(),
1216 coverage_root: options.coverage_root.as_deref(),
1217 coverage_relocated: options.coverage_relocated,
1218 },
1219 }
1220}
1221
1222pub fn validate_complexity_options(options: &ComplexityOptions) -> Result<(), ProgrammaticError> {
1245 if let Some(path) = &options.coverage {
1246 let resolved = fallow_engine::health::scoring::resolve_relative_to_root(
1247 path,
1248 options.analysis.root.as_deref(),
1249 );
1250 if !resolved.exists() {
1251 return Err(ProgrammaticError::new(
1252 format!("coverage path does not exist: {}", resolved.display()),
1253 2,
1254 )
1255 .with_code("FALLOW_INVALID_COVERAGE_PATH")
1256 .with_context("health.coverage"));
1257 }
1258 }
1259 if let Err(message) =
1260 fallow_engine::health::validate_coverage_root_absolute(options.coverage_root.as_deref())
1261 {
1262 return Err(ProgrammaticError::new(message, 2)
1263 .with_code("FALLOW_INVALID_COVERAGE_ROOT")
1264 .with_context("health.coverage_root"));
1265 }
1266
1267 Ok(())
1268}
1269
1270fn complexity_section_options(options: &ComplexityOptions) -> ComplexitySectionOptions {
1271 let ownership = options.ownership || options.ownership_emails.is_some();
1272 let requested_targets = options.targets || options.effort.is_some();
1273 ComplexitySectionOptions {
1274 complexity: options.complexity,
1275 file_scores: options.file_scores,
1276 coverage_gaps: options.coverage_gaps,
1277 hotspots: options.hotspots,
1278 ownership,
1279 targets: requested_targets,
1280 css: options.css,
1281 score: options.score,
1282 }
1283}
1284
1285fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
1286 score
1287 && !options.complexity
1288 && !options.file_scores
1289 && !options.coverage_gaps
1290 && !options.hotspots
1291 && !options.targets
1292 && !options.trend
1293}
1294
1295const fn thresholds_to_engine(
1296 thresholds: ComplexityThresholdOverrides,
1297) -> fallow_engine::health::HealthThresholdOverrides {
1298 fallow_engine::health::HealthThresholdOverrides {
1299 max_cyclomatic: thresholds.max_cyclomatic,
1300 max_cognitive: thresholds.max_cognitive,
1301 max_crap: thresholds.max_crap,
1302 }
1303}
1304
1305const fn complexity_sort_to_engine(sort: ComplexitySort) -> fallow_engine::health::HealthSort {
1306 match sort {
1307 ComplexitySort::Severity => fallow_engine::health::HealthSort::Severity,
1308 ComplexitySort::Cyclomatic => fallow_engine::health::HealthSort::Cyclomatic,
1309 ComplexitySort::Cognitive => fallow_engine::health::HealthSort::Cognitive,
1310 ComplexitySort::Lines => fallow_engine::health::HealthSort::Lines,
1311 }
1312}
1313
1314const fn coverage_inputs_to_engine(
1315 coverage_inputs: ComplexityCoverageInputs<'_>,
1316) -> fallow_engine::health::HealthCoverageInputs<'_> {
1317 fallow_engine::health::HealthCoverageInputs {
1318 coverage: coverage_inputs.coverage,
1319 coverage_root: coverage_inputs.coverage_root,
1320 coverage_relocated: coverage_inputs.coverage_relocated,
1321 }
1322}
1323
1324const fn ownership_email_mode_to_config(mode: OwnershipEmailMode) -> EmailMode {
1325 match mode {
1326 OwnershipEmailMode::Raw => EmailMode::Raw,
1327 OwnershipEmailMode::Handle => EmailMode::Handle,
1328 OwnershipEmailMode::Anonymized => EmailMode::Anonymized,
1329 OwnershipEmailMode::Hash => EmailMode::Hash,
1330 }
1331}
1332
1333const fn target_effort_to_output(effort: TargetEffort) -> EffortEstimate {
1334 match effort {
1335 TargetEffort::Low => EffortEstimate::Low,
1336 TargetEffort::Medium => EffortEstimate::Medium,
1337 TargetEffort::High => EffortEstimate::High,
1338 }
1339}
1340
1341#[cfg(test)]
1342mod tests {
1343 use super::*;
1344
1345 #[test]
1346 fn duplication_defaults_match_cli_contract() {
1347 let options = DuplicationOptions::default();
1348 assert!(options.mode.is_none());
1349 assert!(options.min_tokens.is_none());
1350 assert!(options.min_lines.is_none());
1351 assert!(options.min_occurrences.is_none());
1352 }
1353
1354 #[test]
1355 fn programmatic_error_builder_keeps_optional_fields() {
1356 let error = ProgrammaticError::new("boom", 2)
1357 .with_code("FALLOW_TEST")
1358 .with_help("Try again")
1359 .with_context("analysis.root");
1360
1361 assert_eq!(error.message, "boom");
1362 assert_eq!(error.exit_code, 2);
1363 assert_eq!(error.code.as_deref(), Some("FALLOW_TEST"));
1364 assert_eq!(error.help.as_deref(), Some("Try again"));
1365 assert_eq!(error.context.as_deref(), Some("analysis.root"));
1366 }
1367
1368 #[test]
1369 fn dead_code_filters_accept_shared_registry_selectors() {
1370 for (selector, _) in fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS.iter() {
1371 let mut filters = DeadCodeFilters::default();
1372 assert!(
1373 filters.enable_registry_selector(selector),
1374 "{selector} should be accepted"
1375 );
1376 }
1377
1378 let mut filters = DeadCodeFilters::default();
1379 assert!(filters.enable_registry_selector("unused-files"));
1380 assert!(filters.unused_files);
1381 assert!(filters.enable_registry_selector("boundary-violations"));
1382 assert!(filters.boundary_violations);
1383 assert!(!filters.enable_registry_selector("not-a-real-selector"));
1384 }
1385
1386 #[test]
1387 fn default_complexity_options_match_programmatic_health_defaults() {
1388 let derived = derive_complexity_options(&ComplexityOptions::default());
1389
1390 assert!(!derived.any_section);
1391 assert!(derived.complexity);
1392 assert!(derived.file_scores);
1393 assert!(!derived.coverage_gaps);
1394 assert!(derived.hotspots);
1395 assert!(!derived.ownership);
1396 assert!(derived.targets);
1397 assert!(derived.force_full);
1398 assert!(!derived.score_only_output);
1399 assert!(derived.score);
1400 }
1401
1402 #[test]
1403 fn score_only_complexity_options_request_score_only_output() {
1404 let derived = derive_complexity_options(&ComplexityOptions {
1405 score: true,
1406 ..ComplexityOptions::default()
1407 });
1408
1409 assert!(derived.any_section);
1410 assert!(!derived.complexity);
1411 assert!(derived.file_scores);
1412 assert!(!derived.hotspots);
1413 assert!(!derived.targets);
1414 assert!(derived.force_full);
1415 assert!(derived.score_only_output);
1416 assert!(derived.score);
1417 }
1418
1419 #[test]
1420 fn ownership_implies_hotspots_when_requested() {
1421 let derived = derive_complexity_options(&ComplexityOptions {
1422 ownership: true,
1423 ..ComplexityOptions::default()
1424 });
1425
1426 assert!(derived.any_section);
1427 assert!(derived.hotspots);
1428 assert!(derived.ownership);
1429 assert!(!derived.targets);
1430 }
1431
1432 #[test]
1433 fn complexity_run_options_normalize_public_api_options() {
1434 let options = ComplexityOptions {
1435 max_cyclomatic: Some(42),
1436 max_cognitive: Some(21),
1437 max_crap: Some(18.5),
1438 top: Some(7),
1439 sort: ComplexitySort::Severity,
1440 complexity_breakdown: true,
1441 ownership_emails: Some(OwnershipEmailMode::Hash),
1442 effort: Some(TargetEffort::High),
1443 coverage: Some(PathBuf::from("coverage/coverage-final.json")),
1444 coverage_root: Some(PathBuf::from("/ci/workspace")),
1445 since: Some("30d".to_string()),
1446 min_commits: Some(4),
1447 ..ComplexityOptions::default()
1448 };
1449
1450 let run = derive_complexity_run_options(&options);
1451
1452 assert_eq!(run.thresholds.max_cyclomatic, Some(42));
1453 assert_eq!(run.thresholds.max_cognitive, Some(21));
1454 assert_eq!(run.thresholds.max_crap, Some(18.5));
1455 assert_eq!(run.top, Some(7));
1456 assert!(matches!(run.sort, ComplexitySort::Severity));
1457 assert!(run.complexity_breakdown);
1458 assert!(run.sections.hotspots);
1459 assert!(run.sections.ownership);
1460 assert!(run.sections.targets);
1461 assert!(matches!(
1462 run.ownership_emails,
1463 Some(OwnershipEmailMode::Hash)
1464 ));
1465 assert!(matches!(run.effort, Some(TargetEffort::High)));
1466 assert_eq!(run.since, Some("30d"));
1467 assert_eq!(run.min_commits, Some(4));
1468 assert_eq!(run.coverage_inputs.coverage, options.coverage.as_deref());
1469 assert_eq!(
1470 run.coverage_inputs.coverage_root,
1471 options.coverage_root.as_deref()
1472 );
1473 }
1474
1475 #[test]
1476 fn complexity_options_validation_accepts_existing_coverage_path_and_absolute_root() {
1477 let dir = tempfile::tempdir().expect("tempdir");
1478 let coverage = dir.path().join("coverage-final.json");
1479 std::fs::write(&coverage, "{}").expect("coverage fixture");
1480
1481 let result = validate_complexity_options(&ComplexityOptions {
1482 coverage: Some(coverage),
1483 coverage_root: Some(PathBuf::from("/ci/workspace")),
1484 ..ComplexityOptions::default()
1485 });
1486
1487 assert!(result.is_ok());
1488 }
1489
1490 #[test]
1491 fn complexity_options_validation_keeps_missing_coverage_error_contract() {
1492 let err = validate_complexity_options(&ComplexityOptions {
1493 coverage: Some(PathBuf::from("/missing/coverage-final.json")),
1494 ..ComplexityOptions::default()
1495 })
1496 .expect_err("missing coverage path should fail");
1497
1498 assert_eq!(err.exit_code, 2);
1499 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1500 assert_eq!(err.context.as_deref(), Some("health.coverage"));
1501 }
1502
1503 #[test]
1504 fn complexity_options_validation_keeps_relative_coverage_root_error_contract() {
1505 let err = validate_complexity_options(&ComplexityOptions {
1506 coverage_root: Some(PathBuf::from("coverage")),
1507 ..ComplexityOptions::default()
1508 })
1509 .expect_err("relative coverage root should fail");
1510
1511 assert_eq!(err.exit_code, 2);
1512 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_ROOT"));
1513 assert_eq!(err.context.as_deref(), Some("health.coverage_root"));
1514 }
1515
1516 #[test]
1520 fn complexity_options_validation_resolves_relative_coverage_against_root() {
1521 let dir = tempfile::tempdir().expect("tempdir");
1522 std::fs::create_dir_all(dir.path().join("artifacts")).expect("artifacts dir");
1523 std::fs::write(dir.path().join("artifacts/coverage-final.json"), "{}")
1524 .expect("coverage fixture");
1525 let relative = PathBuf::from("artifacts/coverage-final.json");
1526 assert!(
1527 !relative.exists(),
1528 "the fixture must not also exist under the test cwd"
1529 );
1530
1531 let result = validate_complexity_options(&ComplexityOptions {
1532 analysis: AnalysisOptions {
1533 root: Some(dir.path().to_path_buf()),
1534 ..AnalysisOptions::default()
1535 },
1536 coverage: Some(relative.clone()),
1537 ..ComplexityOptions::default()
1538 });
1539 assert!(result.is_ok(), "{result:?}");
1540
1541 let err = validate_complexity_options(&ComplexityOptions {
1542 analysis: AnalysisOptions {
1543 root: Some(dir.path().join("elsewhere")),
1544 ..AnalysisOptions::default()
1545 },
1546 coverage: Some(relative),
1547 ..ComplexityOptions::default()
1548 })
1549 .expect_err("the path does not exist under the other root");
1550 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1551 assert!(
1552 err.message.contains("elsewhere"),
1553 "the message names the resolved path: {}",
1554 err.message
1555 );
1556 }
1557
1558 #[test]
1559 fn default_health_sections_match_full_health_output() {
1560 let derived = derive_health_sections(&HealthSectionOptions {
1561 output: fallow_types::output_format::OutputFormat::Human,
1562 complexity: false,
1563 file_scores: false,
1564 coverage_gaps: false,
1565 hotspots: false,
1566 targets: false,
1567 css: false,
1568 score: false,
1569 score_gate: false,
1570 snapshot_requested: false,
1571 trend: false,
1572 });
1573
1574 assert!(!derived.any_section);
1575 assert!(derived.complexity);
1576 assert!(derived.file_scores);
1577 assert!(!derived.coverage_gaps);
1578 assert!(derived.hotspots);
1579 assert!(derived.targets);
1580 assert!(derived.score);
1581 assert!(derived.force_full);
1582 assert!(!derived.score_only_output);
1583 }
1584
1585 #[test]
1586 fn health_score_gate_requests_score_only_output() {
1587 let derived = derive_health_sections(&HealthSectionOptions {
1588 output: fallow_types::output_format::OutputFormat::Human,
1589 complexity: false,
1590 file_scores: false,
1591 coverage_gaps: false,
1592 hotspots: false,
1593 targets: false,
1594 css: false,
1595 score: false,
1596 score_gate: true,
1597 snapshot_requested: false,
1598 trend: false,
1599 });
1600
1601 assert!(derived.any_section);
1602 assert!(!derived.complexity);
1603 assert!(derived.file_scores);
1604 assert!(!derived.hotspots);
1605 assert!(!derived.targets);
1606 assert!(derived.score);
1607 assert!(derived.force_full);
1608 assert!(derived.score_only_output);
1609 }
1610
1611 #[test]
1612 fn health_snapshot_keeps_full_hidden_inputs_without_section_request() {
1613 let derived = derive_health_sections(&HealthSectionOptions {
1614 output: fallow_types::output_format::OutputFormat::Human,
1615 complexity: false,
1616 file_scores: false,
1617 coverage_gaps: false,
1618 hotspots: false,
1619 targets: false,
1620 css: true,
1621 score: false,
1622 score_gate: false,
1623 snapshot_requested: true,
1624 trend: false,
1625 });
1626
1627 assert!(!derived.any_section);
1628 assert!(derived.css);
1629 assert!(derived.file_scores);
1630 assert!(derived.hotspots);
1631 assert!(derived.score);
1632 assert!(derived.force_full);
1633 }
1634}