1#![warn(missing_docs)]
10#![cfg_attr(
11 test,
12 allow(
13 clippy::expect_used,
14 reason = "tests use expect to keep fixture setup concise"
15 )
16)]
17
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20use std::sync::atomic::AtomicBool;
21
22use fallow_config::EmailMode;
23use fallow_output::EffortEstimate;
24use serde::Serialize;
25
26mod analysis_context;
27pub mod audit_keys;
31pub mod audit_output;
32pub mod combined_output;
33pub mod compact_output;
36pub mod coverage;
37pub mod dead_code_codeclimate;
38pub mod dead_code_sarif;
39pub mod decision_surface;
40pub mod dependency_deltas;
41pub mod dupes_output;
42mod duplication_filters;
43pub mod editor;
44pub mod explain;
45pub mod grouped_output;
46pub mod health_codeclimate;
47pub mod json_output;
48pub mod list_output;
49mod list_runtime;
50pub mod markdown_output;
53mod next_steps;
54pub mod output_contracts;
55pub mod review_deltas;
56pub mod routing;
57pub mod runtime;
58mod runtime_json;
59mod runtime_output;
60pub mod sarif_output;
61pub mod schemas;
64pub mod security_output;
65pub mod similar_code;
67mod type_aware;
68pub mod ci_output {
69 pub use fallow_output::{
73 CiIssue, CiProvider, GroupedReviewIssues, MARKER_PREFIX_V2, MARKER_SUFFIX_V2,
74 MAX_COMMENT_BODY_BYTES, PROJECT_LEVEL_RULE_IDS, PrCommentRenderInput,
75 ReviewCommentRenderInput, ReviewEnvelopeRenderInput, ReviewEnvelopeRenderResult,
76 ReviewEnvelopeTruncation, ReviewGitlabDiffRefs, cap_body_with_marker, command_title,
77 composite_fingerprint, escape_md, github_check_conclusion,
78 group_review_issues_by_path_line, is_project_level_rule, issues_from_codeclimate,
79 issues_from_codeclimate_issues, render_pr_comment, render_review_comment_for_group,
80 render_review_envelope, review_label_from_codeclimate, summary_fingerprint, summary_label,
81 };
82}
83pub use analysis_context::{ProgrammaticAnalysisContext, resolve_programmatic_analysis_context};
84pub use audit_output::{
85 AuditAttribution, AuditCodeClimateOutputInput, AuditJsonHeaderInput, AuditJsonOutputInput,
86 AuditSarifOutputInput, AuditSummary, AuditVerdict,
87 attach_audit_duplication_demotion_attribution, attach_audit_styling_attribution,
88 attach_audit_wire_attribution, build_audit_codeclimate, build_audit_codeclimate_issues,
89 build_audit_header_json, build_audit_header_map, build_audit_sarif, build_review_brief_header,
90 serialize_audit_json,
91};
92pub use ci_output::{
93 CiIssue, CiProvider, GroupedReviewIssues, MARKER_PREFIX_V2, MARKER_SUFFIX_V2,
94 MAX_COMMENT_BODY_BYTES, PROJECT_LEVEL_RULE_IDS, PrCommentRenderInput, ReviewCommentRenderInput,
95 ReviewEnvelopeRenderInput, ReviewEnvelopeRenderResult, ReviewEnvelopeTruncation,
96 ReviewGitlabDiffRefs, cap_body_with_marker, command_title, composite_fingerprint, escape_md,
97 github_check_conclusion, group_review_issues_by_path_line, is_project_level_rule,
98 issues_from_codeclimate, issues_from_codeclimate_issues, render_pr_comment,
99 render_review_comment_for_group, render_review_envelope, review_label_from_codeclimate,
100 summary_fingerprint, summary_label,
101};
102pub use combined_output::{
103 CombinedCheckJsonSection, CombinedJsonOutputInput, serialize_combined_dupes_json,
104 serialize_combined_health_json, serialize_combined_json,
105};
106pub use compact_output::{
107 build_compact_lines, build_duplication_compact_lines, build_grouped_compact_lines,
108 build_health_compact_lines,
109};
110pub use coverage::{
111 CoverageInputError, CoverageInputSource, CoverageInputs, resolve_coverage_inputs,
112};
113pub use dead_code_codeclimate::build_codeclimate;
114pub use dead_code_sarif::build_sarif;
115pub use dupes_output::{
116 AttributedCloneGroup, AttributedCloneGroupFinding, AttributedInstance, CloneDemotionReason,
117 CloneFamilyFinding, CloneGroupFinding, DupesReportPayload, DuplicationGroup,
118 DuplicationGrouping, build_duplication_codeclimate,
119};
120pub use editor::{
121 ChangedFilesError, EditorAnalysisOutput, EditorAnalysisResults, EditorAnalysisSession,
122 EditorCloneFamily, EditorCloneFingerprintSet, EditorCloneGroup, EditorCloneInstance,
123 EditorDeadCodeAnalysisOutput, EditorDuplicationReport, EditorDuplicationStats,
124 EditorInlineComplexityExceeded, EditorInlineComplexityFinding, EditorMirroredDirectory,
125 EditorProjectAnalysisOutput, EditorRefactoringKind, EditorRefactoringSuggestion,
126 collect_inline_complexity, editor_duplicates, editor_extract, editor_results, editor_security,
127 editor_suppress, filter_inline_complexity_by_changed_files, resolve_git_toplevel,
128 try_get_changed_files_with_toplevel,
129};
130pub use explain::{
131 CHECK_RULES, DUPES_RULES, FLAGS_RULES, HEALTH_RULES, RuleDef, RuleGuide, SECURITY_RULES,
132 all_rules, bare_rule_id, coverage_analyze_meta, coverage_setup_meta, explain_issue_type,
133 rule_by_id, rule_by_token, rule_command, rule_docs_url, rule_guide, rule_severity_key,
134 security_meta, serialize_explain_programmatic_json, unknown_explain_error,
135};
136pub use fallow_config::{AuditGate, HealthConfig, TypeAwareRequire};
137pub use fallow_output::{RootEnvelopeMode, serialize_similar_code_json_output};
138pub use fallow_types::trace::{
139 CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace, ReExportChain,
140 TracedCloneGroup, TracedExport, TracedReExport,
141};
142pub use grouped_output::{
143 ResultGroup, UNOWNED_GROUP_LABEL, build_duplication_grouping_with, group_analysis_results_with,
144 largest_clone_group_owner_with,
145};
146pub use health_codeclimate::build_health_codeclimate;
147pub use json_output::{
148 CheckJsonExtraOutputs, CheckJsonOutputInput, CheckJsonPayloadInput, DuplicationJsonOutputInput,
149 GroupedCheckJsonOutputInput, GroupedDuplicationJsonOutputInput, serialize_check_json,
150 serialize_check_json_payload, serialize_duplication_json, serialize_grouped_check_json,
151 serialize_grouped_duplication_json,
152};
153pub use list_output::{
154 ListJsonEnvelope, ListJsonOutputInput, build_list_json_output, serialize_list_json_output,
155};
156pub use list_runtime::{
157 BoundaryData, ListBoundariesOptions, ListBoundariesProgrammaticOutput, LogicalGroupInfo,
158 ProjectInfoOptions, ProjectInfoProgrammaticOutput, RuleInfo, ZoneInfo, boundary_data_to_output,
159 compute_boundary_data, run_list_boundaries, run_project_info,
160 serialize_list_boundaries_programmatic_json, serialize_project_info_programmatic_json,
161};
162pub use markdown_output::{
163 build_duplication_markdown, build_grouped_markdown, build_health_markdown, build_markdown,
164 build_walkthrough_markdown,
165};
166pub use output_contracts::{
167 AuditOutput, BoundariesListLogicalGroup, BoundariesListRule, BoundariesListZone,
168 BoundariesListing, CombinedOutput, FallowOutput, ImpactOutput, ListBoundariesOutput,
169 ListEntryPointOutput, ListOutput, ListPluginOutput, ReviewBriefWireOutput, SecurityGate,
170 SecurityOutput, SecurityOutputConfig, SecuritySummaryOutput, SimilarCodeCandidateSnapshot,
171 SimilarCodeOutput, TraceOutput, WorkspacesOutput,
172};
173pub use runtime::{
174 AuditProgrammaticKeySnapshot, AuditProgrammaticOutput, BoundaryViolationsOutput,
175 BoundaryViolationsProgrammaticOutput, CircularDependenciesOutput,
176 CircularDependenciesProgrammaticOutput, CombinedProgrammaticOutput, DeadCodeOutput,
177 DeadCodeProgrammaticOutput, DecisionSurfaceProgrammaticOutput, DuplicationOutput,
178 DuplicationProgrammaticOutput, EngineHealthRunner, FeatureFlagsOutput,
179 FeatureFlagsProgrammaticOutput, HealthJsonReportInput, HealthProgrammaticOutput,
180 ProgrammaticHealthAnalysis, ProgrammaticHealthNextStepFacts, ProgrammaticHealthRun,
181 ProgrammaticHealthRunner, TraceClassMemberOutput, TraceCloneBenchmarkResult, TraceCloneOutput,
182 TraceCloneProgrammaticOutput, TraceDependencyOutput, TraceDependencyProgrammaticOutput,
183 TraceExportOutput, TraceExportProgrammaticOutput, TraceExportTargetOutput, TraceFileOutput,
184 TraceFileProgrammaticOutput, benchmark_trace_clone_compact_json,
185 benchmark_trace_graph_family_compact_json, inspect_similar_code, load_health_config,
186 parse_similar_code_candidate_snapshot, review_similar_code, run_audit, run_boundary_violations,
187 run_circular_dependencies, run_combined, run_complexity_with_runner, run_dead_code,
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_export, run_trace_file,
190 select_similar_code_candidate_snapshot, serialize_health_report_json,
191};
192pub use runtime_json::{
193 serialize_audit_programmatic_json, serialize_boundary_violations_programmatic_json,
194 serialize_circular_dependencies_programmatic_json, serialize_combined_programmatic_json,
195 serialize_dead_code_programmatic_json, serialize_decision_surface_programmatic_json,
196 serialize_duplication_programmatic_json, serialize_feature_flags_programmatic_json,
197 serialize_health_programmatic_json, serialize_trace_clone_programmatic_json,
198 serialize_trace_dependency_programmatic_json, serialize_trace_export_programmatic_json,
199 serialize_trace_file_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 refine_configured_dead_code_results_in_session as refine_type_aware_results_in_session_with_config,
213 refine_dead_code_results as refine_type_aware_results,
214 refine_dead_code_results_in_session as refine_type_aware_results_in_session,
215 refine_programmatic_dead_code as refine_type_aware_dead_code, shutdown_type_aware_sidecars,
216 status as type_aware_status, symbol_impact as run_type_aware_symbol_impact,
217 symbol_impact as type_aware_symbol_impact, terminate_active_type_aware_sidecars,
218 trace_symbol as run_type_aware_symbol_trace, trace_symbol as trace_type_aware_symbol,
219 type_coupling as analyze_type_coupling,
220};
221
222pub const COMMON_ANALYSIS_OPTION_FLAGS: &[&str] = &[
229 "root",
230 "config",
231 "no-cache",
232 "threads",
233 "changed-since",
234 "diff-file",
235 "production",
236 "workspace",
237 "changed-workspaces",
238 "explain",
239 "allow-remote-extends",
240];
241
242#[derive(Debug, Clone, Serialize)]
244pub struct ProgrammaticError {
245 pub message: String,
247 pub exit_code: u8,
249 pub code: Option<String>,
251 pub help: Option<String>,
253 pub context: Option<String>,
255}
256
257impl ProgrammaticError {
258 #[must_use]
261 pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
262 Self {
263 message: message.into(),
264 exit_code,
265 code: None,
266 help: None,
267 context: None,
268 }
269 }
270
271 #[must_use]
273 pub fn with_help(mut self, help: impl Into<String>) -> Self {
274 self.help = Some(help.into());
275 self
276 }
277
278 #[must_use]
281 pub fn with_code(mut self, code: impl Into<String>) -> Self {
282 self.code = Some(code.into());
283 self
284 }
285
286 #[must_use]
289 pub fn with_context(mut self, context: impl Into<String>) -> Self {
290 self.context = Some(context.into());
291 self
292 }
293}
294
295impl std::fmt::Display for ProgrammaticError {
296 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297 write!(f, "{}", self.message)
298 }
299}
300
301impl std::error::Error for ProgrammaticError {}
302
303#[derive(Debug, Clone, Default)]
305pub struct AnalysisOptions {
306 pub root: Option<PathBuf>,
309 pub config_path: Option<PathBuf>,
311 pub allow_remote_extends: bool,
313 pub no_cache: bool,
315 pub threads: Option<usize>,
317 pub diff_file: Option<PathBuf>,
319 pub production: bool,
322 pub production_override: Option<bool>,
325 pub changed_since: Option<String>,
327 pub workspace: Option<Vec<String>>,
329 pub changed_workspaces: Option<String>,
331 pub explain: bool,
333 pub type_aware: TypeAwareOptions,
336 pub cancellation: Option<Arc<AtomicBool>>,
365}
366
367#[derive(Debug, Clone, Default, PartialEq, Eq)]
369pub struct TypeAwareOptions {
370 pub enabled: bool,
372 pub projects: Vec<PathBuf>,
375 pub require: fallow_config::TypeAwareRequire,
377}
378
379#[derive(Debug, Clone, Default)]
384pub struct DeadCodeFilters {
385 pub unused_files: bool,
387 pub unused_exports: bool,
389 pub unused_deps: bool,
391 pub unused_types: bool,
393 pub private_type_leaks: bool,
395 pub unused_enum_members: bool,
397 pub unused_class_members: bool,
399 pub unused_store_members: bool,
401 pub unprovided_injects: bool,
403 pub unrendered_components: bool,
405 pub unused_component_props: bool,
407 pub unused_component_emits: bool,
409 pub unused_component_inputs: bool,
411 pub unused_component_outputs: bool,
413 pub unused_svelte_events: bool,
415 pub unused_server_actions: bool,
417 pub unused_load_data_keys: bool,
419 pub unresolved_imports: bool,
421 pub unlisted_deps: bool,
423 pub duplicate_exports: bool,
425 pub circular_deps: bool,
427 pub re_export_cycles: bool,
429 pub boundary_violations: bool,
431 pub policy_violations: bool,
433 pub stale_suppressions: bool,
435 pub unused_catalog_entries: bool,
437 pub empty_catalog_groups: bool,
439 pub unresolved_catalog_references: bool,
441 pub unused_dependency_overrides: bool,
443 pub misconfigured_dependency_overrides: bool,
445}
446
447impl DeadCodeFilters {
448 fn any_active(&self) -> bool {
449 self.unused_files
450 || self.unused_exports
451 || self.unused_deps
452 || self.unused_types
453 || self.private_type_leaks
454 || self.unused_enum_members
455 || self.unused_class_members
456 || self.unused_store_members
457 || self.unprovided_injects
458 || self.unrendered_components
459 || self.unused_component_props
460 || self.unused_component_emits
461 || self.unused_component_inputs
462 || self.unused_component_outputs
463 || self.unused_svelte_events
464 || self.unused_server_actions
465 || self.unused_load_data_keys
466 || self.unresolved_imports
467 || self.unlisted_deps
468 || self.duplicate_exports
469 || self.circular_deps
470 || self.re_export_cycles
471 || self.boundary_violations
472 || self.policy_violations
473 || self.stale_suppressions
474 || self.unused_catalog_entries
475 || self.empty_catalog_groups
476 || self.unresolved_catalog_references
477 || self.unused_dependency_overrides
478 || self.misconfigured_dependency_overrides
479 }
480
481 pub fn enable_registry_selector(&mut self, selector: &str) -> bool {
487 let Some(flag) = fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS
488 .iter()
489 .find_map(|&(name, flag)| (name == selector).then_some(flag))
490 else {
491 return false;
492 };
493 self.enable_cli_filter_flag(flag);
494 true
495 }
496
497 fn enable_cli_filter_flag(&mut self, flag: &str) {
498 match flag {
499 "--unused-files" => self.unused_files = true,
500 "--unused-exports" => self.unused_exports = true,
501 "--unused-types" => self.unused_types = true,
502 "--private-type-leaks" => self.private_type_leaks = true,
503 "--unused-deps" => self.unused_deps = true,
504 "--unused-enum-members" => self.unused_enum_members = true,
505 "--unused-class-members" => self.unused_class_members = true,
506 "--unused-store-members" => self.unused_store_members = true,
507 "--unprovided-injects" => self.unprovided_injects = true,
508 "--unrendered-components" => self.unrendered_components = true,
509 "--unused-component-props" => self.unused_component_props = true,
510 "--unused-component-emits" => self.unused_component_emits = true,
511 "--unused-component-inputs" => self.unused_component_inputs = true,
512 "--unused-component-outputs" => self.unused_component_outputs = true,
513 "--unused-svelte-events" => self.unused_svelte_events = true,
514 "--unused-server-actions" => self.unused_server_actions = true,
515 "--unused-load-data-keys" => self.unused_load_data_keys = true,
516 "--unresolved-imports" => self.unresolved_imports = true,
517 "--unlisted-deps" => self.unlisted_deps = true,
518 "--duplicate-exports" => self.duplicate_exports = true,
519 "--circular-deps" => self.circular_deps = true,
520 "--re-export-cycles" => self.re_export_cycles = true,
521 "--boundary-violations" => self.boundary_violations = true,
522 "--policy-violations" => self.policy_violations = true,
523 "--stale-suppressions" => self.stale_suppressions = true,
524 "--unused-catalog-entries" => self.unused_catalog_entries = true,
525 "--empty-catalog-groups" => self.empty_catalog_groups = true,
526 "--unresolved-catalog-references" => self.unresolved_catalog_references = true,
527 "--unused-dependency-overrides" => self.unused_dependency_overrides = true,
528 "--misconfigured-dependency-overrides" => {
529 self.misconfigured_dependency_overrides = true;
530 }
531 _ => unreachable!("registry emitted unsupported dead-code filter flag: {flag}"),
532 }
533 }
534}
535
536#[derive(Debug, Clone, Default)]
538pub struct DeadCodeOptions {
539 pub analysis: AnalysisOptions,
541 pub filters: DeadCodeFilters,
543 pub files: Vec<PathBuf>,
545 pub include_entry_exports: bool,
547}
548
549#[derive(Debug, Clone, Default)]
551pub struct AuditOptions {
552 pub analysis: AnalysisOptions,
554 pub base: Option<String>,
557 pub production: bool,
559 pub production_dead_code: Option<bool>,
561 pub production_health: Option<bool>,
563 pub production_dupes: Option<bool>,
566 pub css: Option<bool>,
568 pub css_deep: Option<bool>,
570 pub gate: fallow_config::AuditGate,
572 pub max_crap: Option<f64>,
574 pub coverage: Option<PathBuf>,
576 pub coverage_root: Option<PathBuf>,
578 pub include_entry_exports: bool,
580 pub runtime_coverage: Option<PathBuf>,
582 pub min_invocations_hot: u64,
584}
585
586#[derive(Debug, Clone)]
588pub struct CombinedOptions {
589 pub analysis: AnalysisOptions,
591 pub dead_code: bool,
593 pub duplication: bool,
595 pub health: bool,
597 pub include_entry_exports: bool,
599 pub duplication_options: DuplicationOptions,
601 pub health_options: ComplexityOptions,
603}
604
605impl Default for CombinedOptions {
606 fn default() -> Self {
607 Self {
608 analysis: AnalysisOptions::default(),
609 dead_code: true,
610 duplication: true,
611 health: true,
612 include_entry_exports: false,
613 duplication_options: DuplicationOptions::default(),
614 health_options: ComplexityOptions::default(),
615 }
616 }
617}
618
619#[derive(Debug, Clone, Default)]
621pub struct DecisionSurfaceOptions {
622 pub analysis: AnalysisOptions,
624 pub base: Option<String>,
627 pub max_decisions: Option<usize>,
629}
630
631#[derive(Debug, Clone, Default)]
633pub struct FeatureFlagsOptions {
634 pub analysis: AnalysisOptions,
636 pub top: Option<usize>,
638}
639
640#[derive(Debug, Clone, Copy, Default)]
642pub enum DuplicationMode {
643 Strict,
646 #[default]
648 Mild,
649 Weak,
651 Semantic,
654}
655
656#[derive(Debug, Clone, Default)]
658pub struct DuplicationOptions {
659 pub analysis: AnalysisOptions,
661 pub mode: Option<DuplicationMode>,
663 pub near: Option<bool>,
666 pub min_tokens: Option<usize>,
668 pub min_lines: Option<usize>,
670 pub min_occurrences: Option<usize>,
673 pub threshold: Option<f64>,
676 pub skip_local: Option<bool>,
679 pub cross_language: Option<bool>,
681 pub ignore_imports: Option<bool>,
684 pub top: Option<usize>,
686}
687
688#[derive(Debug, Clone, Default)]
690pub struct SimilarCodeOptions {
691 pub analysis: AnalysisOptions,
693 pub threshold: Option<f64>,
695 pub min_lines: Option<usize>,
698 pub top: Option<usize>,
700 pub files: Vec<PathBuf>,
704 #[doc(hidden)]
709 pub adapter_provider_path: Option<PathBuf>,
710}
711
712#[derive(Debug, Clone)]
714pub struct SimilarCodeInspectOptions {
715 pub analysis: AnalysisOptions,
718 pub snapshot: fallow_output::SimilarCodeCandidateSnapshot,
720}
721
722#[derive(Debug, Clone, Default)]
724pub struct TraceExportOptions {
725 pub analysis: AnalysisOptions,
727 pub file: String,
729 pub export_name: String,
731}
732
733#[derive(Debug, Clone, Default)]
735pub struct TraceFileOptions {
736 pub analysis: AnalysisOptions,
738 pub file: String,
740}
741
742#[derive(Debug, Clone, Default)]
744pub struct TraceDependencyOptions {
745 pub analysis: AnalysisOptions,
747 pub package_name: String,
749}
750
751#[derive(Debug, Clone, PartialEq, Eq)]
753pub enum TraceCloneTarget {
754 Location {
756 file: String,
758 line: usize,
760 },
761 Fingerprint(String),
763}
764
765#[derive(Debug, Clone)]
767pub struct TraceCloneOptions {
768 pub duplication: DuplicationOptions,
770 pub target: TraceCloneTarget,
772}
773
774#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
776pub enum ComplexitySort {
777 #[default]
779 Cyclomatic,
780 Cognitive,
782 Lines,
784 Severity,
786}
787
788#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
790pub enum OwnershipEmailMode {
791 Raw,
793 #[default]
795 Handle,
796 Anonymized,
798 Hash,
800}
801
802#[derive(Debug, Clone, Copy, PartialEq, Eq)]
804pub enum TargetEffort {
805 Low,
807 Medium,
809 High,
811}
812
813#[derive(Debug, Clone, Default)]
815pub struct ComplexityOptions {
816 pub analysis: AnalysisOptions,
818 pub max_cyclomatic: Option<u16>,
820 pub max_cognitive: Option<u16>,
822 pub max_crap: Option<f64>,
824 pub top: Option<usize>,
826 pub sort: ComplexitySort,
828 pub complexity_breakdown: bool,
830 pub complexity: bool,
832 pub file_scores: bool,
834 pub coverage_gaps: bool,
836 pub hotspots: bool,
838 pub ownership: bool,
840 pub ownership_emails: Option<OwnershipEmailMode>,
842 pub targets: bool,
844 pub css: bool,
846 pub css_deep: bool,
848 pub effort: Option<TargetEffort>,
851 pub score: bool,
853 pub since: Option<String>,
855 pub min_commits: Option<u32>,
857 pub coverage: Option<PathBuf>,
859 pub coverage_root: Option<PathBuf>,
861 pub coverage_relocated: bool,
866}
867
868#[derive(Debug, Clone, Copy, Default, PartialEq)]
870pub struct ComplexityThresholdOverrides {
871 pub max_cyclomatic: Option<u16>,
873 pub max_cognitive: Option<u16>,
875 pub max_crap: Option<f64>,
877}
878
879#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
881pub struct ComplexityCoverageInputs<'a> {
882 pub coverage: Option<&'a Path>,
884 pub coverage_root: Option<&'a Path>,
886 pub coverage_relocated: bool,
889}
890
891#[derive(Debug, Clone)]
893pub struct HealthSectionOptions {
894 pub output: fallow_types::output_format::OutputFormat,
896 pub complexity: bool,
898 pub file_scores: bool,
900 pub coverage_gaps: bool,
902 pub hotspots: bool,
904 pub targets: bool,
906 pub css: bool,
908 pub score: bool,
910 pub score_gate: bool,
912 pub snapshot_requested: bool,
914 pub trend: bool,
916}
917
918#[derive(Debug, Clone, Copy, PartialEq, Eq)]
920pub struct DerivedHealthSections {
921 pub any_section: bool,
923 pub complexity: bool,
925 pub file_scores: bool,
927 pub coverage_gaps: bool,
929 pub hotspots: bool,
931 pub targets: bool,
933 pub css: bool,
935 pub score: bool,
937 pub force_full: bool,
939 pub score_only_output: bool,
941}
942
943#[derive(Debug, Clone)]
945pub struct ComplexitySectionOptions {
946 pub complexity: bool,
948 pub file_scores: bool,
950 pub coverage_gaps: bool,
952 pub hotspots: bool,
954 pub ownership: bool,
956 pub targets: bool,
958 pub css: bool,
960 pub score: bool,
962}
963
964#[derive(Debug, Clone, Copy, PartialEq, Eq)]
966pub struct DerivedComplexityOptions {
967 pub any_section: bool,
969 pub complexity: bool,
971 pub file_scores: bool,
973 pub coverage_gaps: bool,
975 pub hotspots: bool,
977 pub ownership: bool,
979 pub targets: bool,
981 pub force_full: bool,
983 pub score_only_output: bool,
985 pub score: bool,
987}
988
989#[derive(Debug, Clone, PartialEq)]
991pub struct ComplexityRunOptions<'a> {
992 pub thresholds: ComplexityThresholdOverrides,
994 pub top: Option<usize>,
996 pub sort: ComplexitySort,
998 pub complexity_breakdown: bool,
1000 pub sections: DerivedComplexityOptions,
1002 pub ownership_emails: Option<OwnershipEmailMode>,
1004 pub effort: Option<TargetEffort>,
1006 pub css: bool,
1008 pub css_deep: bool,
1010 pub since: Option<&'a str>,
1012 pub min_commits: Option<u32>,
1014 pub coverage_inputs: ComplexityCoverageInputs<'a>,
1016}
1017
1018#[must_use]
1020pub fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
1021 let score = options.score
1022 || options.score_gate
1023 || options.trend
1024 || matches!(
1025 options.output,
1026 fallow_types::output_format::OutputFormat::Badge
1027 );
1028 let any_section = options.complexity
1029 || options.file_scores
1030 || options.coverage_gaps
1031 || options.hotspots
1032 || options.targets
1033 || score;
1034 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
1035 let force_full = options.snapshot_requested || effective_score;
1036
1037 DerivedHealthSections {
1038 any_section,
1039 complexity: if any_section {
1040 options.complexity
1041 } else {
1042 true
1043 },
1044 file_scores: if any_section {
1045 options.file_scores
1046 } else {
1047 true
1048 } || force_full,
1049 coverage_gaps: if any_section {
1050 options.coverage_gaps
1051 } else {
1052 false
1053 },
1054 hotspots: if any_section { options.hotspots } else { true }
1055 || options.snapshot_requested
1056 || options.trend,
1057 targets: if any_section { options.targets } else { true },
1058 css: options.css,
1059 score: effective_score,
1060 force_full,
1061 score_only_output: is_health_score_only_output(options, score),
1062 }
1063}
1064
1065#[must_use]
1067pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
1068 let requested_hotspots = options.hotspots || options.ownership;
1069 let sections = derive_health_sections(&HealthSectionOptions {
1070 output: fallow_types::output_format::OutputFormat::Human,
1071 complexity: options.complexity,
1072 file_scores: options.file_scores,
1073 coverage_gaps: options.coverage_gaps,
1074 hotspots: requested_hotspots,
1075 targets: options.targets,
1076 css: options.css,
1077 score: options.score,
1078 score_gate: false,
1079 snapshot_requested: false,
1080 trend: false,
1081 });
1082
1083 DerivedComplexityOptions {
1084 any_section: sections.any_section,
1085 complexity: sections.complexity,
1086 file_scores: sections.file_scores,
1087 coverage_gaps: sections.coverage_gaps,
1088 hotspots: sections.hotspots,
1089 ownership: options.ownership && sections.hotspots,
1090 targets: sections.targets,
1091 force_full: sections.force_full,
1092 score_only_output: sections.score_only_output,
1093 score: sections.score,
1094 }
1095}
1096
1097#[must_use]
1099pub fn derive_complexity_options(options: &ComplexityOptions) -> DerivedComplexityOptions {
1100 derive_complexity_sections(&complexity_section_options(options))
1101}
1102
1103#[must_use]
1105pub fn derive_complexity_run_options(options: &ComplexityOptions) -> ComplexityRunOptions<'_> {
1106 ComplexityRunOptions {
1107 thresholds: ComplexityThresholdOverrides {
1108 max_cyclomatic: options.max_cyclomatic,
1109 max_cognitive: options.max_cognitive,
1110 max_crap: options.max_crap,
1111 },
1112 top: options.top,
1113 sort: options.sort,
1114 complexity_breakdown: options.complexity_breakdown,
1115 sections: derive_complexity_options(options),
1116 ownership_emails: options.ownership_emails,
1117 effort: options.effort,
1118 css: options.css,
1119 css_deep: options.css_deep,
1120 since: options.since.as_deref(),
1121 min_commits: options.min_commits,
1122 coverage_inputs: ComplexityCoverageInputs {
1123 coverage: options.coverage.as_deref(),
1124 coverage_root: options.coverage_root.as_deref(),
1125 coverage_relocated: options.coverage_relocated,
1126 },
1127 }
1128}
1129
1130pub fn validate_complexity_options(options: &ComplexityOptions) -> Result<(), ProgrammaticError> {
1153 if let Some(path) = &options.coverage {
1154 let resolved = fallow_engine::health::scoring::resolve_relative_to_root(
1155 path,
1156 options.analysis.root.as_deref(),
1157 );
1158 if !resolved.exists() {
1159 return Err(ProgrammaticError::new(
1160 format!("coverage path does not exist: {}", resolved.display()),
1161 2,
1162 )
1163 .with_code("FALLOW_INVALID_COVERAGE_PATH")
1164 .with_context("health.coverage"));
1165 }
1166 }
1167 if let Err(message) =
1168 fallow_engine::health::validate_coverage_root_absolute(options.coverage_root.as_deref())
1169 {
1170 return Err(ProgrammaticError::new(message, 2)
1171 .with_code("FALLOW_INVALID_COVERAGE_ROOT")
1172 .with_context("health.coverage_root"));
1173 }
1174
1175 Ok(())
1176}
1177
1178fn complexity_section_options(options: &ComplexityOptions) -> ComplexitySectionOptions {
1179 let ownership = options.ownership || options.ownership_emails.is_some();
1180 let requested_targets = options.targets || options.effort.is_some();
1181 ComplexitySectionOptions {
1182 complexity: options.complexity,
1183 file_scores: options.file_scores,
1184 coverage_gaps: options.coverage_gaps,
1185 hotspots: options.hotspots,
1186 ownership,
1187 targets: requested_targets,
1188 css: options.css,
1189 score: options.score,
1190 }
1191}
1192
1193fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
1194 score
1195 && !options.complexity
1196 && !options.file_scores
1197 && !options.coverage_gaps
1198 && !options.hotspots
1199 && !options.targets
1200 && !options.trend
1201}
1202
1203const fn thresholds_to_engine(
1204 thresholds: ComplexityThresholdOverrides,
1205) -> fallow_engine::health::HealthThresholdOverrides {
1206 fallow_engine::health::HealthThresholdOverrides {
1207 max_cyclomatic: thresholds.max_cyclomatic,
1208 max_cognitive: thresholds.max_cognitive,
1209 max_crap: thresholds.max_crap,
1210 }
1211}
1212
1213const fn complexity_sort_to_engine(sort: ComplexitySort) -> fallow_engine::health::HealthSort {
1214 match sort {
1215 ComplexitySort::Severity => fallow_engine::health::HealthSort::Severity,
1216 ComplexitySort::Cyclomatic => fallow_engine::health::HealthSort::Cyclomatic,
1217 ComplexitySort::Cognitive => fallow_engine::health::HealthSort::Cognitive,
1218 ComplexitySort::Lines => fallow_engine::health::HealthSort::Lines,
1219 }
1220}
1221
1222const fn coverage_inputs_to_engine(
1223 coverage_inputs: ComplexityCoverageInputs<'_>,
1224) -> fallow_engine::health::HealthCoverageInputs<'_> {
1225 fallow_engine::health::HealthCoverageInputs {
1226 coverage: coverage_inputs.coverage,
1227 coverage_root: coverage_inputs.coverage_root,
1228 coverage_relocated: coverage_inputs.coverage_relocated,
1229 }
1230}
1231
1232const fn ownership_email_mode_to_config(mode: OwnershipEmailMode) -> EmailMode {
1233 match mode {
1234 OwnershipEmailMode::Raw => EmailMode::Raw,
1235 OwnershipEmailMode::Handle => EmailMode::Handle,
1236 OwnershipEmailMode::Anonymized => EmailMode::Anonymized,
1237 OwnershipEmailMode::Hash => EmailMode::Hash,
1238 }
1239}
1240
1241const fn target_effort_to_output(effort: TargetEffort) -> EffortEstimate {
1242 match effort {
1243 TargetEffort::Low => EffortEstimate::Low,
1244 TargetEffort::Medium => EffortEstimate::Medium,
1245 TargetEffort::High => EffortEstimate::High,
1246 }
1247}
1248
1249#[cfg(test)]
1250mod tests {
1251 use super::*;
1252
1253 #[test]
1254 fn duplication_defaults_match_cli_contract() {
1255 let options = DuplicationOptions::default();
1256 assert!(options.mode.is_none());
1257 assert!(options.min_tokens.is_none());
1258 assert!(options.min_lines.is_none());
1259 assert!(options.min_occurrences.is_none());
1260 }
1261
1262 #[test]
1263 fn programmatic_error_builder_keeps_optional_fields() {
1264 let error = ProgrammaticError::new("boom", 2)
1265 .with_code("FALLOW_TEST")
1266 .with_help("Try again")
1267 .with_context("analysis.root");
1268
1269 assert_eq!(error.message, "boom");
1270 assert_eq!(error.exit_code, 2);
1271 assert_eq!(error.code.as_deref(), Some("FALLOW_TEST"));
1272 assert_eq!(error.help.as_deref(), Some("Try again"));
1273 assert_eq!(error.context.as_deref(), Some("analysis.root"));
1274 }
1275
1276 #[test]
1277 fn dead_code_filters_accept_shared_registry_selectors() {
1278 for (selector, _) in fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS.iter() {
1279 let mut filters = DeadCodeFilters::default();
1280 assert!(
1281 filters.enable_registry_selector(selector),
1282 "{selector} should be accepted"
1283 );
1284 }
1285
1286 let mut filters = DeadCodeFilters::default();
1287 assert!(filters.enable_registry_selector("unused-files"));
1288 assert!(filters.unused_files);
1289 assert!(filters.enable_registry_selector("boundary-violations"));
1290 assert!(filters.boundary_violations);
1291 assert!(!filters.enable_registry_selector("not-a-real-selector"));
1292 }
1293
1294 #[test]
1295 fn default_complexity_options_match_programmatic_health_defaults() {
1296 let derived = derive_complexity_options(&ComplexityOptions::default());
1297
1298 assert!(!derived.any_section);
1299 assert!(derived.complexity);
1300 assert!(derived.file_scores);
1301 assert!(!derived.coverage_gaps);
1302 assert!(derived.hotspots);
1303 assert!(!derived.ownership);
1304 assert!(derived.targets);
1305 assert!(derived.force_full);
1306 assert!(!derived.score_only_output);
1307 assert!(derived.score);
1308 }
1309
1310 #[test]
1311 fn score_only_complexity_options_request_score_only_output() {
1312 let derived = derive_complexity_options(&ComplexityOptions {
1313 score: true,
1314 ..ComplexityOptions::default()
1315 });
1316
1317 assert!(derived.any_section);
1318 assert!(!derived.complexity);
1319 assert!(derived.file_scores);
1320 assert!(!derived.hotspots);
1321 assert!(!derived.targets);
1322 assert!(derived.force_full);
1323 assert!(derived.score_only_output);
1324 assert!(derived.score);
1325 }
1326
1327 #[test]
1328 fn ownership_implies_hotspots_when_requested() {
1329 let derived = derive_complexity_options(&ComplexityOptions {
1330 ownership: true,
1331 ..ComplexityOptions::default()
1332 });
1333
1334 assert!(derived.any_section);
1335 assert!(derived.hotspots);
1336 assert!(derived.ownership);
1337 assert!(!derived.targets);
1338 }
1339
1340 #[test]
1341 fn complexity_run_options_normalize_public_api_options() {
1342 let options = ComplexityOptions {
1343 max_cyclomatic: Some(42),
1344 max_cognitive: Some(21),
1345 max_crap: Some(18.5),
1346 top: Some(7),
1347 sort: ComplexitySort::Severity,
1348 complexity_breakdown: true,
1349 ownership_emails: Some(OwnershipEmailMode::Hash),
1350 effort: Some(TargetEffort::High),
1351 coverage: Some(PathBuf::from("coverage/coverage-final.json")),
1352 coverage_root: Some(PathBuf::from("/ci/workspace")),
1353 since: Some("30d".to_string()),
1354 min_commits: Some(4),
1355 ..ComplexityOptions::default()
1356 };
1357
1358 let run = derive_complexity_run_options(&options);
1359
1360 assert_eq!(run.thresholds.max_cyclomatic, Some(42));
1361 assert_eq!(run.thresholds.max_cognitive, Some(21));
1362 assert_eq!(run.thresholds.max_crap, Some(18.5));
1363 assert_eq!(run.top, Some(7));
1364 assert!(matches!(run.sort, ComplexitySort::Severity));
1365 assert!(run.complexity_breakdown);
1366 assert!(run.sections.hotspots);
1367 assert!(run.sections.ownership);
1368 assert!(run.sections.targets);
1369 assert!(matches!(
1370 run.ownership_emails,
1371 Some(OwnershipEmailMode::Hash)
1372 ));
1373 assert!(matches!(run.effort, Some(TargetEffort::High)));
1374 assert_eq!(run.since, Some("30d"));
1375 assert_eq!(run.min_commits, Some(4));
1376 assert_eq!(run.coverage_inputs.coverage, options.coverage.as_deref());
1377 assert_eq!(
1378 run.coverage_inputs.coverage_root,
1379 options.coverage_root.as_deref()
1380 );
1381 }
1382
1383 #[test]
1384 fn complexity_options_validation_accepts_existing_coverage_path_and_absolute_root() {
1385 let dir = tempfile::tempdir().expect("tempdir");
1386 let coverage = dir.path().join("coverage-final.json");
1387 std::fs::write(&coverage, "{}").expect("coverage fixture");
1388
1389 let result = validate_complexity_options(&ComplexityOptions {
1390 coverage: Some(coverage),
1391 coverage_root: Some(PathBuf::from("/ci/workspace")),
1392 ..ComplexityOptions::default()
1393 });
1394
1395 assert!(result.is_ok());
1396 }
1397
1398 #[test]
1399 fn complexity_options_validation_keeps_missing_coverage_error_contract() {
1400 let err = validate_complexity_options(&ComplexityOptions {
1401 coverage: Some(PathBuf::from("/missing/coverage-final.json")),
1402 ..ComplexityOptions::default()
1403 })
1404 .expect_err("missing coverage path should fail");
1405
1406 assert_eq!(err.exit_code, 2);
1407 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1408 assert_eq!(err.context.as_deref(), Some("health.coverage"));
1409 }
1410
1411 #[test]
1412 fn complexity_options_validation_keeps_relative_coverage_root_error_contract() {
1413 let err = validate_complexity_options(&ComplexityOptions {
1414 coverage_root: Some(PathBuf::from("coverage")),
1415 ..ComplexityOptions::default()
1416 })
1417 .expect_err("relative coverage root should fail");
1418
1419 assert_eq!(err.exit_code, 2);
1420 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_ROOT"));
1421 assert_eq!(err.context.as_deref(), Some("health.coverage_root"));
1422 }
1423
1424 #[test]
1428 fn complexity_options_validation_resolves_relative_coverage_against_root() {
1429 let dir = tempfile::tempdir().expect("tempdir");
1430 std::fs::create_dir_all(dir.path().join("artifacts")).expect("artifacts dir");
1431 std::fs::write(dir.path().join("artifacts/coverage-final.json"), "{}")
1432 .expect("coverage fixture");
1433 let relative = PathBuf::from("artifacts/coverage-final.json");
1434 assert!(
1435 !relative.exists(),
1436 "the fixture must not also exist under the test cwd"
1437 );
1438
1439 let result = validate_complexity_options(&ComplexityOptions {
1440 analysis: AnalysisOptions {
1441 root: Some(dir.path().to_path_buf()),
1442 ..AnalysisOptions::default()
1443 },
1444 coverage: Some(relative.clone()),
1445 ..ComplexityOptions::default()
1446 });
1447 assert!(result.is_ok(), "{result:?}");
1448
1449 let err = validate_complexity_options(&ComplexityOptions {
1450 analysis: AnalysisOptions {
1451 root: Some(dir.path().join("elsewhere")),
1452 ..AnalysisOptions::default()
1453 },
1454 coverage: Some(relative),
1455 ..ComplexityOptions::default()
1456 })
1457 .expect_err("the path does not exist under the other root");
1458 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1459 assert!(
1460 err.message.contains("elsewhere"),
1461 "the message names the resolved path: {}",
1462 err.message
1463 );
1464 }
1465
1466 #[test]
1467 fn default_health_sections_match_full_health_output() {
1468 let derived = derive_health_sections(&HealthSectionOptions {
1469 output: fallow_types::output_format::OutputFormat::Human,
1470 complexity: false,
1471 file_scores: false,
1472 coverage_gaps: false,
1473 hotspots: false,
1474 targets: false,
1475 css: false,
1476 score: false,
1477 score_gate: false,
1478 snapshot_requested: false,
1479 trend: false,
1480 });
1481
1482 assert!(!derived.any_section);
1483 assert!(derived.complexity);
1484 assert!(derived.file_scores);
1485 assert!(!derived.coverage_gaps);
1486 assert!(derived.hotspots);
1487 assert!(derived.targets);
1488 assert!(derived.score);
1489 assert!(derived.force_full);
1490 assert!(!derived.score_only_output);
1491 }
1492
1493 #[test]
1494 fn health_score_gate_requests_score_only_output() {
1495 let derived = derive_health_sections(&HealthSectionOptions {
1496 output: fallow_types::output_format::OutputFormat::Human,
1497 complexity: false,
1498 file_scores: false,
1499 coverage_gaps: false,
1500 hotspots: false,
1501 targets: false,
1502 css: false,
1503 score: false,
1504 score_gate: true,
1505 snapshot_requested: false,
1506 trend: false,
1507 });
1508
1509 assert!(derived.any_section);
1510 assert!(!derived.complexity);
1511 assert!(derived.file_scores);
1512 assert!(!derived.hotspots);
1513 assert!(!derived.targets);
1514 assert!(derived.score);
1515 assert!(derived.force_full);
1516 assert!(derived.score_only_output);
1517 }
1518
1519 #[test]
1520 fn health_snapshot_keeps_full_hidden_inputs_without_section_request() {
1521 let derived = derive_health_sections(&HealthSectionOptions {
1522 output: fallow_types::output_format::OutputFormat::Human,
1523 complexity: false,
1524 file_scores: false,
1525 coverage_gaps: false,
1526 hotspots: false,
1527 targets: false,
1528 css: true,
1529 score: false,
1530 score_gate: false,
1531 snapshot_requested: true,
1532 trend: false,
1533 });
1534
1535 assert!(!derived.any_section);
1536 assert!(derived.css);
1537 assert!(derived.file_scores);
1538 assert!(derived.hotspots);
1539 assert!(derived.score);
1540 assert!(derived.force_full);
1541 }
1542}