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};
19
20use fallow_config::EmailMode;
21use fallow_output::EffortEstimate;
22use serde::Serialize;
23
24mod analysis_context;
25pub mod audit_keys;
29pub mod audit_output;
30pub mod combined_output;
31pub mod compact_output;
34pub mod coverage;
35pub mod dead_code_codeclimate;
36pub mod dead_code_sarif;
37pub mod decision_surface;
38pub mod dependency_deltas;
39pub mod dupes_output;
40mod duplication_filters;
41pub mod editor;
42pub mod explain;
43pub mod grouped_output;
44pub mod health_codeclimate;
45pub mod json_output;
46pub mod list_output;
47mod list_runtime;
48pub mod markdown_output;
51mod next_steps;
52pub mod output_contracts;
53pub mod review_deltas;
54pub mod routing;
55pub mod runtime;
56mod runtime_json;
57mod runtime_output;
58pub mod sarif_output;
59pub mod schemas;
62pub mod security_output;
63pub mod similar_code;
65mod type_aware;
66pub mod ci_output {
67 pub use fallow_output::{
71 CiIssue, CiProvider, GroupedReviewIssues, MARKER_PREFIX_V2, MARKER_SUFFIX_V2,
72 MAX_COMMENT_BODY_BYTES, PROJECT_LEVEL_RULE_IDS, PrCommentRenderInput,
73 ReviewCommentRenderInput, ReviewEnvelopeRenderInput, ReviewEnvelopeRenderResult,
74 ReviewEnvelopeTruncation, ReviewGitlabDiffRefs, cap_body_with_marker, command_title,
75 composite_fingerprint, escape_md, github_check_conclusion,
76 group_review_issues_by_path_line, is_project_level_rule, issues_from_codeclimate,
77 issues_from_codeclimate_issues, render_pr_comment, render_review_comment_for_group,
78 render_review_envelope, review_label_from_codeclimate, summary_fingerprint, summary_label,
79 };
80}
81pub use analysis_context::{ProgrammaticAnalysisContext, resolve_programmatic_analysis_context};
82pub use audit_output::{
83 AuditAttribution, AuditCodeClimateOutputInput, AuditJsonHeaderInput, AuditJsonOutputInput,
84 AuditSarifOutputInput, AuditSummary, AuditVerdict,
85 attach_audit_duplication_demotion_attribution, attach_audit_styling_attribution,
86 attach_audit_wire_attribution, build_audit_codeclimate, build_audit_codeclimate_issues,
87 build_audit_header_json, build_audit_header_map, build_audit_sarif, build_review_brief_header,
88 serialize_audit_json,
89};
90pub use ci_output::{
91 CiIssue, CiProvider, GroupedReviewIssues, MARKER_PREFIX_V2, MARKER_SUFFIX_V2,
92 MAX_COMMENT_BODY_BYTES, PROJECT_LEVEL_RULE_IDS, PrCommentRenderInput, ReviewCommentRenderInput,
93 ReviewEnvelopeRenderInput, ReviewEnvelopeRenderResult, ReviewEnvelopeTruncation,
94 ReviewGitlabDiffRefs, cap_body_with_marker, command_title, composite_fingerprint, escape_md,
95 github_check_conclusion, group_review_issues_by_path_line, is_project_level_rule,
96 issues_from_codeclimate, issues_from_codeclimate_issues, render_pr_comment,
97 render_review_comment_for_group, render_review_envelope, review_label_from_codeclimate,
98 summary_fingerprint, summary_label,
99};
100pub use combined_output::{
101 CombinedCheckJsonSection, CombinedJsonOutputInput, serialize_combined_dupes_json,
102 serialize_combined_health_json, serialize_combined_json,
103};
104pub use compact_output::{
105 build_compact_lines, build_duplication_compact_lines, build_grouped_compact_lines,
106 build_health_compact_lines,
107};
108pub use coverage::{
109 CoverageInputError, CoverageInputSource, CoverageInputs, resolve_coverage_inputs,
110};
111pub use dead_code_codeclimate::build_codeclimate;
112pub use dead_code_sarif::build_sarif;
113pub use dupes_output::{
114 AttributedCloneGroup, AttributedCloneGroupFinding, AttributedInstance, CloneDemotionReason,
115 CloneFamilyFinding, CloneGroupFinding, DupesReportPayload, DuplicationGroup,
116 DuplicationGrouping, build_duplication_codeclimate,
117};
118pub use editor::{
119 ChangedFilesError, EditorAnalysisOutput, EditorAnalysisResults, EditorAnalysisSession,
120 EditorCloneFamily, EditorCloneFingerprintSet, EditorCloneGroup, EditorCloneInstance,
121 EditorDeadCodeAnalysisOutput, EditorDuplicationReport, EditorDuplicationStats,
122 EditorInlineComplexityExceeded, EditorInlineComplexityFinding, EditorMirroredDirectory,
123 EditorProjectAnalysisOutput, EditorRefactoringKind, EditorRefactoringSuggestion,
124 collect_inline_complexity, editor_duplicates, editor_extract, editor_results, editor_security,
125 editor_suppress, filter_inline_complexity_by_changed_files, resolve_git_toplevel,
126 try_get_changed_files_with_toplevel,
127};
128pub use explain::{
129 CHECK_RULES, DUPES_RULES, FLAGS_RULES, HEALTH_RULES, RuleDef, RuleGuide, SECURITY_RULES,
130 all_rules, bare_rule_id, coverage_analyze_meta, coverage_setup_meta, explain_issue_type,
131 rule_by_id, rule_by_token, rule_command, rule_docs_url, rule_guide, rule_severity_key,
132 security_meta, serialize_explain_programmatic_json, unknown_explain_error,
133};
134pub use fallow_config::{AuditGate, HealthConfig, TypeAwareRequire};
135pub use fallow_output::{RootEnvelopeMode, serialize_similar_code_json_output};
136pub use fallow_types::trace::{
137 CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace, ReExportChain,
138 TracedCloneGroup, TracedExport, TracedReExport,
139};
140pub use grouped_output::{
141 ResultGroup, UNOWNED_GROUP_LABEL, build_duplication_grouping_with, group_analysis_results_with,
142 largest_clone_group_owner_with,
143};
144pub use health_codeclimate::build_health_codeclimate;
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 build_duplication_markdown, build_grouped_markdown, build_health_markdown, build_markdown,
162 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 TraceExportOutput, TraceExportProgrammaticOutput, TraceExportTargetOutput, TraceFileOutput,
182 TraceFileProgrammaticOutput, benchmark_trace_clone_compact_json,
183 benchmark_trace_graph_family_compact_json, inspect_similar_code, load_health_config,
184 parse_similar_code_candidate_snapshot, review_similar_code, run_audit, run_boundary_violations,
185 run_circular_dependencies, run_combined, run_complexity_with_runner, run_dead_code,
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_export, run_trace_file,
188 select_similar_code_candidate_snapshot, serialize_health_report_json,
189};
190pub use runtime_json::{
191 serialize_audit_programmatic_json, serialize_boundary_violations_programmatic_json,
192 serialize_circular_dependencies_programmatic_json, serialize_combined_programmatic_json,
193 serialize_dead_code_programmatic_json, serialize_decision_surface_programmatic_json,
194 serialize_duplication_programmatic_json, serialize_feature_flags_programmatic_json,
195 serialize_health_programmatic_json, serialize_trace_clone_programmatic_json,
196 serialize_trace_dependency_programmatic_json, serialize_trace_export_programmatic_json,
197 serialize_trace_file_programmatic_json,
198};
199pub use sarif_output::{
200 annotate_sarif_results, build_duplication_sarif, build_grouped_duplication_sarif,
201 build_health_sarif,
202};
203pub use security_output::SecurityGateMode;
204pub use type_aware::{
205 SemanticCouplingOutcome, SemanticDeadCodeOutcome, SemanticInspectOutcome, TypeAwareError,
206 TypeAwareFileChanges, TypeAwareOutcome, TypeAwareSession, TypeAwareStatus,
207 discard_unverified_semantic_candidates, inspect_symbol as inspect_type_aware_symbol,
208 merge_type_aware_meta,
209 refine_configured_dead_code_results as refine_type_aware_results_with_config,
210 refine_configured_dead_code_results_in_session as refine_type_aware_results_in_session_with_config,
211 refine_dead_code_results as refine_type_aware_results,
212 refine_dead_code_results_in_session as refine_type_aware_results_in_session,
213 refine_programmatic_dead_code as refine_type_aware_dead_code, shutdown_type_aware_sidecars,
214 status as type_aware_status, symbol_impact as run_type_aware_symbol_impact,
215 symbol_impact as type_aware_symbol_impact, terminate_active_type_aware_sidecars,
216 trace_symbol as run_type_aware_symbol_trace, trace_symbol as trace_type_aware_symbol,
217 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>,
317 pub production: bool,
320 pub production_override: Option<bool>,
323 pub changed_since: Option<String>,
325 pub workspace: Option<Vec<String>>,
327 pub changed_workspaces: Option<String>,
329 pub explain: bool,
331 pub type_aware: TypeAwareOptions,
334}
335
336#[derive(Debug, Clone, Default, PartialEq, Eq)]
338pub struct TypeAwareOptions {
339 pub enabled: bool,
341 pub projects: Vec<PathBuf>,
344 pub require: fallow_config::TypeAwareRequire,
346}
347
348#[derive(Debug, Clone, Default)]
353pub struct DeadCodeFilters {
354 pub unused_files: bool,
356 pub unused_exports: bool,
358 pub unused_deps: bool,
360 pub unused_types: bool,
362 pub private_type_leaks: bool,
364 pub unused_enum_members: bool,
366 pub unused_class_members: bool,
368 pub unused_store_members: bool,
370 pub unprovided_injects: bool,
372 pub unrendered_components: bool,
374 pub unused_component_props: bool,
376 pub unused_component_emits: bool,
378 pub unused_component_inputs: bool,
380 pub unused_component_outputs: bool,
382 pub unused_svelte_events: bool,
384 pub unused_server_actions: bool,
386 pub unused_load_data_keys: bool,
388 pub unresolved_imports: bool,
390 pub unlisted_deps: bool,
392 pub duplicate_exports: bool,
394 pub circular_deps: bool,
396 pub re_export_cycles: bool,
398 pub boundary_violations: bool,
400 pub policy_violations: bool,
402 pub stale_suppressions: bool,
404 pub unused_catalog_entries: bool,
406 pub empty_catalog_groups: bool,
408 pub unresolved_catalog_references: bool,
410 pub unused_dependency_overrides: bool,
412 pub misconfigured_dependency_overrides: bool,
414}
415
416impl DeadCodeFilters {
417 fn any_active(&self) -> bool {
418 self.unused_files
419 || self.unused_exports
420 || self.unused_deps
421 || self.unused_types
422 || self.private_type_leaks
423 || self.unused_enum_members
424 || self.unused_class_members
425 || self.unused_store_members
426 || self.unprovided_injects
427 || self.unrendered_components
428 || self.unused_component_props
429 || self.unused_component_emits
430 || self.unused_component_inputs
431 || self.unused_component_outputs
432 || self.unused_svelte_events
433 || self.unused_server_actions
434 || self.unused_load_data_keys
435 || self.unresolved_imports
436 || self.unlisted_deps
437 || self.duplicate_exports
438 || self.circular_deps
439 || self.re_export_cycles
440 || self.boundary_violations
441 || self.policy_violations
442 || self.stale_suppressions
443 || self.unused_catalog_entries
444 || self.empty_catalog_groups
445 || self.unresolved_catalog_references
446 || self.unused_dependency_overrides
447 || self.misconfigured_dependency_overrides
448 }
449
450 pub fn enable_registry_selector(&mut self, selector: &str) -> bool {
456 let Some(flag) = fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS
457 .iter()
458 .find_map(|&(name, flag)| (name == selector).then_some(flag))
459 else {
460 return false;
461 };
462 self.enable_cli_filter_flag(flag);
463 true
464 }
465
466 fn enable_cli_filter_flag(&mut self, flag: &str) {
467 match flag {
468 "--unused-files" => self.unused_files = true,
469 "--unused-exports" => self.unused_exports = true,
470 "--unused-types" => self.unused_types = true,
471 "--private-type-leaks" => self.private_type_leaks = true,
472 "--unused-deps" => self.unused_deps = true,
473 "--unused-enum-members" => self.unused_enum_members = true,
474 "--unused-class-members" => self.unused_class_members = true,
475 "--unused-store-members" => self.unused_store_members = true,
476 "--unprovided-injects" => self.unprovided_injects = true,
477 "--unrendered-components" => self.unrendered_components = true,
478 "--unused-component-props" => self.unused_component_props = true,
479 "--unused-component-emits" => self.unused_component_emits = true,
480 "--unused-component-inputs" => self.unused_component_inputs = true,
481 "--unused-component-outputs" => self.unused_component_outputs = true,
482 "--unused-svelte-events" => self.unused_svelte_events = true,
483 "--unused-server-actions" => self.unused_server_actions = true,
484 "--unused-load-data-keys" => self.unused_load_data_keys = true,
485 "--unresolved-imports" => self.unresolved_imports = true,
486 "--unlisted-deps" => self.unlisted_deps = true,
487 "--duplicate-exports" => self.duplicate_exports = true,
488 "--circular-deps" => self.circular_deps = true,
489 "--re-export-cycles" => self.re_export_cycles = true,
490 "--boundary-violations" => self.boundary_violations = true,
491 "--policy-violations" => self.policy_violations = true,
492 "--stale-suppressions" => self.stale_suppressions = true,
493 "--unused-catalog-entries" => self.unused_catalog_entries = true,
494 "--empty-catalog-groups" => self.empty_catalog_groups = true,
495 "--unresolved-catalog-references" => self.unresolved_catalog_references = true,
496 "--unused-dependency-overrides" => self.unused_dependency_overrides = true,
497 "--misconfigured-dependency-overrides" => {
498 self.misconfigured_dependency_overrides = true;
499 }
500 _ => unreachable!("registry emitted unsupported dead-code filter flag: {flag}"),
501 }
502 }
503}
504
505#[derive(Debug, Clone, Default)]
507pub struct DeadCodeOptions {
508 pub analysis: AnalysisOptions,
510 pub filters: DeadCodeFilters,
512 pub files: Vec<PathBuf>,
514 pub include_entry_exports: bool,
516}
517
518#[derive(Debug, Clone, Default)]
520pub struct AuditOptions {
521 pub analysis: AnalysisOptions,
523 pub base: Option<String>,
526 pub production: bool,
528 pub production_dead_code: Option<bool>,
530 pub production_health: Option<bool>,
532 pub production_dupes: Option<bool>,
535 pub css: Option<bool>,
537 pub css_deep: Option<bool>,
539 pub gate: fallow_config::AuditGate,
541 pub max_crap: Option<f64>,
543 pub coverage: Option<PathBuf>,
545 pub coverage_root: Option<PathBuf>,
547 pub include_entry_exports: bool,
549 pub runtime_coverage: Option<PathBuf>,
551 pub min_invocations_hot: u64,
553}
554
555#[derive(Debug, Clone)]
557pub struct CombinedOptions {
558 pub analysis: AnalysisOptions,
560 pub dead_code: bool,
562 pub duplication: bool,
564 pub health: bool,
566 pub include_entry_exports: bool,
568 pub duplication_options: DuplicationOptions,
570 pub health_options: ComplexityOptions,
572}
573
574impl Default for CombinedOptions {
575 fn default() -> Self {
576 Self {
577 analysis: AnalysisOptions::default(),
578 dead_code: true,
579 duplication: true,
580 health: true,
581 include_entry_exports: false,
582 duplication_options: DuplicationOptions::default(),
583 health_options: ComplexityOptions::default(),
584 }
585 }
586}
587
588#[derive(Debug, Clone, Default)]
590pub struct DecisionSurfaceOptions {
591 pub analysis: AnalysisOptions,
593 pub base: Option<String>,
596 pub max_decisions: Option<usize>,
598}
599
600#[derive(Debug, Clone, Default)]
602pub struct FeatureFlagsOptions {
603 pub analysis: AnalysisOptions,
605 pub top: Option<usize>,
607}
608
609#[derive(Debug, Clone, Copy, Default)]
611pub enum DuplicationMode {
612 Strict,
615 #[default]
617 Mild,
618 Weak,
620 Semantic,
623}
624
625#[derive(Debug, Clone, Default)]
627pub struct DuplicationOptions {
628 pub analysis: AnalysisOptions,
630 pub mode: Option<DuplicationMode>,
632 pub near: Option<bool>,
635 pub min_tokens: Option<usize>,
637 pub min_lines: Option<usize>,
639 pub min_occurrences: Option<usize>,
642 pub threshold: Option<f64>,
645 pub skip_local: Option<bool>,
648 pub cross_language: Option<bool>,
650 pub ignore_imports: Option<bool>,
653 pub top: Option<usize>,
655}
656
657#[derive(Debug, Clone, Default)]
659pub struct SimilarCodeOptions {
660 pub analysis: AnalysisOptions,
662 pub threshold: Option<f64>,
664 pub min_lines: Option<usize>,
667 pub top: Option<usize>,
669 pub files: Vec<PathBuf>,
673 #[doc(hidden)]
678 pub adapter_provider_path: Option<PathBuf>,
679}
680
681#[derive(Debug, Clone)]
683pub struct SimilarCodeInspectOptions {
684 pub analysis: AnalysisOptions,
687 pub snapshot: fallow_output::SimilarCodeCandidateSnapshot,
689}
690
691#[derive(Debug, Clone, Default)]
693pub struct TraceExportOptions {
694 pub analysis: AnalysisOptions,
696 pub file: String,
698 pub export_name: String,
700}
701
702#[derive(Debug, Clone, Default)]
704pub struct TraceFileOptions {
705 pub analysis: AnalysisOptions,
707 pub file: String,
709}
710
711#[derive(Debug, Clone, Default)]
713pub struct TraceDependencyOptions {
714 pub analysis: AnalysisOptions,
716 pub package_name: String,
718}
719
720#[derive(Debug, Clone, PartialEq, Eq)]
722pub enum TraceCloneTarget {
723 Location {
725 file: String,
727 line: usize,
729 },
730 Fingerprint(String),
732}
733
734#[derive(Debug, Clone)]
736pub struct TraceCloneOptions {
737 pub duplication: DuplicationOptions,
739 pub target: TraceCloneTarget,
741}
742
743#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
745pub enum ComplexitySort {
746 #[default]
748 Cyclomatic,
749 Cognitive,
751 Lines,
753 Severity,
755}
756
757#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
759pub enum OwnershipEmailMode {
760 Raw,
762 #[default]
764 Handle,
765 Anonymized,
767 Hash,
769}
770
771#[derive(Debug, Clone, Copy, PartialEq, Eq)]
773pub enum TargetEffort {
774 Low,
776 Medium,
778 High,
780}
781
782#[derive(Debug, Clone, Default)]
784pub struct ComplexityOptions {
785 pub analysis: AnalysisOptions,
787 pub max_cyclomatic: Option<u16>,
789 pub max_cognitive: Option<u16>,
791 pub max_crap: Option<f64>,
793 pub top: Option<usize>,
795 pub sort: ComplexitySort,
797 pub complexity_breakdown: bool,
799 pub complexity: bool,
801 pub file_scores: bool,
803 pub coverage_gaps: bool,
805 pub hotspots: bool,
807 pub ownership: bool,
809 pub ownership_emails: Option<OwnershipEmailMode>,
811 pub targets: bool,
813 pub css: bool,
815 pub css_deep: bool,
817 pub effort: Option<TargetEffort>,
820 pub score: bool,
822 pub since: Option<String>,
824 pub min_commits: Option<u32>,
826 pub coverage: Option<PathBuf>,
828 pub coverage_root: Option<PathBuf>,
830 pub coverage_relocated: bool,
835}
836
837#[derive(Debug, Clone, Copy, Default, PartialEq)]
839pub struct ComplexityThresholdOverrides {
840 pub max_cyclomatic: Option<u16>,
842 pub max_cognitive: Option<u16>,
844 pub max_crap: Option<f64>,
846}
847
848#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
850pub struct ComplexityCoverageInputs<'a> {
851 pub coverage: Option<&'a Path>,
853 pub coverage_root: Option<&'a Path>,
855 pub coverage_relocated: bool,
858}
859
860#[derive(Debug, Clone)]
862pub struct HealthSectionOptions {
863 pub output: fallow_types::output_format::OutputFormat,
865 pub complexity: bool,
867 pub file_scores: bool,
869 pub coverage_gaps: bool,
871 pub hotspots: bool,
873 pub targets: bool,
875 pub css: bool,
877 pub score: bool,
879 pub score_gate: bool,
881 pub snapshot_requested: bool,
883 pub trend: bool,
885}
886
887#[derive(Debug, Clone, Copy, PartialEq, Eq)]
889pub struct DerivedHealthSections {
890 pub any_section: bool,
892 pub complexity: bool,
894 pub file_scores: bool,
896 pub coverage_gaps: bool,
898 pub hotspots: bool,
900 pub targets: bool,
902 pub css: bool,
904 pub score: bool,
906 pub force_full: bool,
908 pub score_only_output: bool,
910}
911
912#[derive(Debug, Clone)]
914pub struct ComplexitySectionOptions {
915 pub complexity: bool,
917 pub file_scores: bool,
919 pub coverage_gaps: bool,
921 pub hotspots: bool,
923 pub ownership: bool,
925 pub targets: bool,
927 pub css: bool,
929 pub score: bool,
931}
932
933#[derive(Debug, Clone, Copy, PartialEq, Eq)]
935pub struct DerivedComplexityOptions {
936 pub any_section: bool,
938 pub complexity: bool,
940 pub file_scores: bool,
942 pub coverage_gaps: bool,
944 pub hotspots: bool,
946 pub ownership: bool,
948 pub targets: bool,
950 pub force_full: bool,
952 pub score_only_output: bool,
954 pub score: bool,
956}
957
958#[derive(Debug, Clone, PartialEq)]
960pub struct ComplexityRunOptions<'a> {
961 pub thresholds: ComplexityThresholdOverrides,
963 pub top: Option<usize>,
965 pub sort: ComplexitySort,
967 pub complexity_breakdown: bool,
969 pub sections: DerivedComplexityOptions,
971 pub ownership_emails: Option<OwnershipEmailMode>,
973 pub effort: Option<TargetEffort>,
975 pub css: bool,
977 pub css_deep: bool,
979 pub since: Option<&'a str>,
981 pub min_commits: Option<u32>,
983 pub coverage_inputs: ComplexityCoverageInputs<'a>,
985}
986
987#[must_use]
989pub fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
990 let score = options.score
991 || options.score_gate
992 || options.trend
993 || matches!(
994 options.output,
995 fallow_types::output_format::OutputFormat::Badge
996 );
997 let any_section = options.complexity
998 || options.file_scores
999 || options.coverage_gaps
1000 || options.hotspots
1001 || options.targets
1002 || score;
1003 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
1004 let force_full = options.snapshot_requested || effective_score;
1005
1006 DerivedHealthSections {
1007 any_section,
1008 complexity: if any_section {
1009 options.complexity
1010 } else {
1011 true
1012 },
1013 file_scores: if any_section {
1014 options.file_scores
1015 } else {
1016 true
1017 } || force_full,
1018 coverage_gaps: if any_section {
1019 options.coverage_gaps
1020 } else {
1021 false
1022 },
1023 hotspots: if any_section { options.hotspots } else { true }
1024 || options.snapshot_requested
1025 || options.trend,
1026 targets: if any_section { options.targets } else { true },
1027 css: options.css,
1028 score: effective_score,
1029 force_full,
1030 score_only_output: is_health_score_only_output(options, score),
1031 }
1032}
1033
1034#[must_use]
1036pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
1037 let requested_hotspots = options.hotspots || options.ownership;
1038 let sections = derive_health_sections(&HealthSectionOptions {
1039 output: fallow_types::output_format::OutputFormat::Human,
1040 complexity: options.complexity,
1041 file_scores: options.file_scores,
1042 coverage_gaps: options.coverage_gaps,
1043 hotspots: requested_hotspots,
1044 targets: options.targets,
1045 css: options.css,
1046 score: options.score,
1047 score_gate: false,
1048 snapshot_requested: false,
1049 trend: false,
1050 });
1051
1052 DerivedComplexityOptions {
1053 any_section: sections.any_section,
1054 complexity: sections.complexity,
1055 file_scores: sections.file_scores,
1056 coverage_gaps: sections.coverage_gaps,
1057 hotspots: sections.hotspots,
1058 ownership: options.ownership && sections.hotspots,
1059 targets: sections.targets,
1060 force_full: sections.force_full,
1061 score_only_output: sections.score_only_output,
1062 score: sections.score,
1063 }
1064}
1065
1066#[must_use]
1068pub fn derive_complexity_options(options: &ComplexityOptions) -> DerivedComplexityOptions {
1069 derive_complexity_sections(&complexity_section_options(options))
1070}
1071
1072#[must_use]
1074pub fn derive_complexity_run_options(options: &ComplexityOptions) -> ComplexityRunOptions<'_> {
1075 ComplexityRunOptions {
1076 thresholds: ComplexityThresholdOverrides {
1077 max_cyclomatic: options.max_cyclomatic,
1078 max_cognitive: options.max_cognitive,
1079 max_crap: options.max_crap,
1080 },
1081 top: options.top,
1082 sort: options.sort,
1083 complexity_breakdown: options.complexity_breakdown,
1084 sections: derive_complexity_options(options),
1085 ownership_emails: options.ownership_emails,
1086 effort: options.effort,
1087 css: options.css,
1088 css_deep: options.css_deep,
1089 since: options.since.as_deref(),
1090 min_commits: options.min_commits,
1091 coverage_inputs: ComplexityCoverageInputs {
1092 coverage: options.coverage.as_deref(),
1093 coverage_root: options.coverage_root.as_deref(),
1094 coverage_relocated: options.coverage_relocated,
1095 },
1096 }
1097}
1098
1099pub fn validate_complexity_options(options: &ComplexityOptions) -> Result<(), ProgrammaticError> {
1122 if let Some(path) = &options.coverage {
1123 let resolved = fallow_engine::health::scoring::resolve_relative_to_root(
1124 path,
1125 options.analysis.root.as_deref(),
1126 );
1127 if !resolved.exists() {
1128 return Err(ProgrammaticError::new(
1129 format!("coverage path does not exist: {}", resolved.display()),
1130 2,
1131 )
1132 .with_code("FALLOW_INVALID_COVERAGE_PATH")
1133 .with_context("health.coverage"));
1134 }
1135 }
1136 if let Err(message) =
1137 fallow_engine::health::validate_coverage_root_absolute(options.coverage_root.as_deref())
1138 {
1139 return Err(ProgrammaticError::new(message, 2)
1140 .with_code("FALLOW_INVALID_COVERAGE_ROOT")
1141 .with_context("health.coverage_root"));
1142 }
1143
1144 Ok(())
1145}
1146
1147fn complexity_section_options(options: &ComplexityOptions) -> ComplexitySectionOptions {
1148 let ownership = options.ownership || options.ownership_emails.is_some();
1149 let requested_targets = options.targets || options.effort.is_some();
1150 ComplexitySectionOptions {
1151 complexity: options.complexity,
1152 file_scores: options.file_scores,
1153 coverage_gaps: options.coverage_gaps,
1154 hotspots: options.hotspots,
1155 ownership,
1156 targets: requested_targets,
1157 css: options.css,
1158 score: options.score,
1159 }
1160}
1161
1162fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
1163 score
1164 && !options.complexity
1165 && !options.file_scores
1166 && !options.coverage_gaps
1167 && !options.hotspots
1168 && !options.targets
1169 && !options.trend
1170}
1171
1172const fn thresholds_to_engine(
1173 thresholds: ComplexityThresholdOverrides,
1174) -> fallow_engine::health::HealthThresholdOverrides {
1175 fallow_engine::health::HealthThresholdOverrides {
1176 max_cyclomatic: thresholds.max_cyclomatic,
1177 max_cognitive: thresholds.max_cognitive,
1178 max_crap: thresholds.max_crap,
1179 }
1180}
1181
1182const fn complexity_sort_to_engine(sort: ComplexitySort) -> fallow_engine::health::HealthSort {
1183 match sort {
1184 ComplexitySort::Severity => fallow_engine::health::HealthSort::Severity,
1185 ComplexitySort::Cyclomatic => fallow_engine::health::HealthSort::Cyclomatic,
1186 ComplexitySort::Cognitive => fallow_engine::health::HealthSort::Cognitive,
1187 ComplexitySort::Lines => fallow_engine::health::HealthSort::Lines,
1188 }
1189}
1190
1191const fn coverage_inputs_to_engine(
1192 coverage_inputs: ComplexityCoverageInputs<'_>,
1193) -> fallow_engine::health::HealthCoverageInputs<'_> {
1194 fallow_engine::health::HealthCoverageInputs {
1195 coverage: coverage_inputs.coverage,
1196 coverage_root: coverage_inputs.coverage_root,
1197 coverage_relocated: coverage_inputs.coverage_relocated,
1198 }
1199}
1200
1201const fn ownership_email_mode_to_config(mode: OwnershipEmailMode) -> EmailMode {
1202 match mode {
1203 OwnershipEmailMode::Raw => EmailMode::Raw,
1204 OwnershipEmailMode::Handle => EmailMode::Handle,
1205 OwnershipEmailMode::Anonymized => EmailMode::Anonymized,
1206 OwnershipEmailMode::Hash => EmailMode::Hash,
1207 }
1208}
1209
1210const fn target_effort_to_output(effort: TargetEffort) -> EffortEstimate {
1211 match effort {
1212 TargetEffort::Low => EffortEstimate::Low,
1213 TargetEffort::Medium => EffortEstimate::Medium,
1214 TargetEffort::High => EffortEstimate::High,
1215 }
1216}
1217
1218#[cfg(test)]
1219mod tests {
1220 use super::*;
1221
1222 #[test]
1223 fn duplication_defaults_match_cli_contract() {
1224 let options = DuplicationOptions::default();
1225 assert!(options.mode.is_none());
1226 assert!(options.min_tokens.is_none());
1227 assert!(options.min_lines.is_none());
1228 assert!(options.min_occurrences.is_none());
1229 }
1230
1231 #[test]
1232 fn programmatic_error_builder_keeps_optional_fields() {
1233 let error = ProgrammaticError::new("boom", 2)
1234 .with_code("FALLOW_TEST")
1235 .with_help("Try again")
1236 .with_context("analysis.root");
1237
1238 assert_eq!(error.message, "boom");
1239 assert_eq!(error.exit_code, 2);
1240 assert_eq!(error.code.as_deref(), Some("FALLOW_TEST"));
1241 assert_eq!(error.help.as_deref(), Some("Try again"));
1242 assert_eq!(error.context.as_deref(), Some("analysis.root"));
1243 }
1244
1245 #[test]
1246 fn dead_code_filters_accept_shared_registry_selectors() {
1247 for (selector, _) in fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS.iter() {
1248 let mut filters = DeadCodeFilters::default();
1249 assert!(
1250 filters.enable_registry_selector(selector),
1251 "{selector} should be accepted"
1252 );
1253 }
1254
1255 let mut filters = DeadCodeFilters::default();
1256 assert!(filters.enable_registry_selector("unused-files"));
1257 assert!(filters.unused_files);
1258 assert!(filters.enable_registry_selector("boundary-violations"));
1259 assert!(filters.boundary_violations);
1260 assert!(!filters.enable_registry_selector("not-a-real-selector"));
1261 }
1262
1263 #[test]
1264 fn default_complexity_options_match_programmatic_health_defaults() {
1265 let derived = derive_complexity_options(&ComplexityOptions::default());
1266
1267 assert!(!derived.any_section);
1268 assert!(derived.complexity);
1269 assert!(derived.file_scores);
1270 assert!(!derived.coverage_gaps);
1271 assert!(derived.hotspots);
1272 assert!(!derived.ownership);
1273 assert!(derived.targets);
1274 assert!(derived.force_full);
1275 assert!(!derived.score_only_output);
1276 assert!(derived.score);
1277 }
1278
1279 #[test]
1280 fn score_only_complexity_options_request_score_only_output() {
1281 let derived = derive_complexity_options(&ComplexityOptions {
1282 score: true,
1283 ..ComplexityOptions::default()
1284 });
1285
1286 assert!(derived.any_section);
1287 assert!(!derived.complexity);
1288 assert!(derived.file_scores);
1289 assert!(!derived.hotspots);
1290 assert!(!derived.targets);
1291 assert!(derived.force_full);
1292 assert!(derived.score_only_output);
1293 assert!(derived.score);
1294 }
1295
1296 #[test]
1297 fn ownership_implies_hotspots_when_requested() {
1298 let derived = derive_complexity_options(&ComplexityOptions {
1299 ownership: true,
1300 ..ComplexityOptions::default()
1301 });
1302
1303 assert!(derived.any_section);
1304 assert!(derived.hotspots);
1305 assert!(derived.ownership);
1306 assert!(!derived.targets);
1307 }
1308
1309 #[test]
1310 fn complexity_run_options_normalize_public_api_options() {
1311 let options = ComplexityOptions {
1312 max_cyclomatic: Some(42),
1313 max_cognitive: Some(21),
1314 max_crap: Some(18.5),
1315 top: Some(7),
1316 sort: ComplexitySort::Severity,
1317 complexity_breakdown: true,
1318 ownership_emails: Some(OwnershipEmailMode::Hash),
1319 effort: Some(TargetEffort::High),
1320 coverage: Some(PathBuf::from("coverage/coverage-final.json")),
1321 coverage_root: Some(PathBuf::from("/ci/workspace")),
1322 since: Some("30d".to_string()),
1323 min_commits: Some(4),
1324 ..ComplexityOptions::default()
1325 };
1326
1327 let run = derive_complexity_run_options(&options);
1328
1329 assert_eq!(run.thresholds.max_cyclomatic, Some(42));
1330 assert_eq!(run.thresholds.max_cognitive, Some(21));
1331 assert_eq!(run.thresholds.max_crap, Some(18.5));
1332 assert_eq!(run.top, Some(7));
1333 assert!(matches!(run.sort, ComplexitySort::Severity));
1334 assert!(run.complexity_breakdown);
1335 assert!(run.sections.hotspots);
1336 assert!(run.sections.ownership);
1337 assert!(run.sections.targets);
1338 assert!(matches!(
1339 run.ownership_emails,
1340 Some(OwnershipEmailMode::Hash)
1341 ));
1342 assert!(matches!(run.effort, Some(TargetEffort::High)));
1343 assert_eq!(run.since, Some("30d"));
1344 assert_eq!(run.min_commits, Some(4));
1345 assert_eq!(run.coverage_inputs.coverage, options.coverage.as_deref());
1346 assert_eq!(
1347 run.coverage_inputs.coverage_root,
1348 options.coverage_root.as_deref()
1349 );
1350 }
1351
1352 #[test]
1353 fn complexity_options_validation_accepts_existing_coverage_path_and_absolute_root() {
1354 let dir = tempfile::tempdir().expect("tempdir");
1355 let coverage = dir.path().join("coverage-final.json");
1356 std::fs::write(&coverage, "{}").expect("coverage fixture");
1357
1358 let result = validate_complexity_options(&ComplexityOptions {
1359 coverage: Some(coverage),
1360 coverage_root: Some(PathBuf::from("/ci/workspace")),
1361 ..ComplexityOptions::default()
1362 });
1363
1364 assert!(result.is_ok());
1365 }
1366
1367 #[test]
1368 fn complexity_options_validation_keeps_missing_coverage_error_contract() {
1369 let err = validate_complexity_options(&ComplexityOptions {
1370 coverage: Some(PathBuf::from("/missing/coverage-final.json")),
1371 ..ComplexityOptions::default()
1372 })
1373 .expect_err("missing coverage path should fail");
1374
1375 assert_eq!(err.exit_code, 2);
1376 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1377 assert_eq!(err.context.as_deref(), Some("health.coverage"));
1378 }
1379
1380 #[test]
1381 fn complexity_options_validation_keeps_relative_coverage_root_error_contract() {
1382 let err = validate_complexity_options(&ComplexityOptions {
1383 coverage_root: Some(PathBuf::from("coverage")),
1384 ..ComplexityOptions::default()
1385 })
1386 .expect_err("relative coverage root should fail");
1387
1388 assert_eq!(err.exit_code, 2);
1389 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_ROOT"));
1390 assert_eq!(err.context.as_deref(), Some("health.coverage_root"));
1391 }
1392
1393 #[test]
1397 fn complexity_options_validation_resolves_relative_coverage_against_root() {
1398 let dir = tempfile::tempdir().expect("tempdir");
1399 std::fs::create_dir_all(dir.path().join("artifacts")).expect("artifacts dir");
1400 std::fs::write(dir.path().join("artifacts/coverage-final.json"), "{}")
1401 .expect("coverage fixture");
1402 let relative = PathBuf::from("artifacts/coverage-final.json");
1403 assert!(
1404 !relative.exists(),
1405 "the fixture must not also exist under the test cwd"
1406 );
1407
1408 let result = validate_complexity_options(&ComplexityOptions {
1409 analysis: AnalysisOptions {
1410 root: Some(dir.path().to_path_buf()),
1411 ..AnalysisOptions::default()
1412 },
1413 coverage: Some(relative.clone()),
1414 ..ComplexityOptions::default()
1415 });
1416 assert!(result.is_ok(), "{result:?}");
1417
1418 let err = validate_complexity_options(&ComplexityOptions {
1419 analysis: AnalysisOptions {
1420 root: Some(dir.path().join("elsewhere")),
1421 ..AnalysisOptions::default()
1422 },
1423 coverage: Some(relative),
1424 ..ComplexityOptions::default()
1425 })
1426 .expect_err("the path does not exist under the other root");
1427 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1428 assert!(
1429 err.message.contains("elsewhere"),
1430 "the message names the resolved path: {}",
1431 err.message
1432 );
1433 }
1434
1435 #[test]
1436 fn default_health_sections_match_full_health_output() {
1437 let derived = derive_health_sections(&HealthSectionOptions {
1438 output: fallow_types::output_format::OutputFormat::Human,
1439 complexity: false,
1440 file_scores: false,
1441 coverage_gaps: false,
1442 hotspots: false,
1443 targets: false,
1444 css: false,
1445 score: false,
1446 score_gate: false,
1447 snapshot_requested: false,
1448 trend: false,
1449 });
1450
1451 assert!(!derived.any_section);
1452 assert!(derived.complexity);
1453 assert!(derived.file_scores);
1454 assert!(!derived.coverage_gaps);
1455 assert!(derived.hotspots);
1456 assert!(derived.targets);
1457 assert!(derived.score);
1458 assert!(derived.force_full);
1459 assert!(!derived.score_only_output);
1460 }
1461
1462 #[test]
1463 fn health_score_gate_requests_score_only_output() {
1464 let derived = derive_health_sections(&HealthSectionOptions {
1465 output: fallow_types::output_format::OutputFormat::Human,
1466 complexity: false,
1467 file_scores: false,
1468 coverage_gaps: false,
1469 hotspots: false,
1470 targets: false,
1471 css: false,
1472 score: false,
1473 score_gate: true,
1474 snapshot_requested: false,
1475 trend: false,
1476 });
1477
1478 assert!(derived.any_section);
1479 assert!(!derived.complexity);
1480 assert!(derived.file_scores);
1481 assert!(!derived.hotspots);
1482 assert!(!derived.targets);
1483 assert!(derived.score);
1484 assert!(derived.force_full);
1485 assert!(derived.score_only_output);
1486 }
1487
1488 #[test]
1489 fn health_snapshot_keeps_full_hidden_inputs_without_section_request() {
1490 let derived = derive_health_sections(&HealthSectionOptions {
1491 output: fallow_types::output_format::OutputFormat::Human,
1492 complexity: false,
1493 file_scores: false,
1494 coverage_gaps: false,
1495 hotspots: false,
1496 targets: false,
1497 css: true,
1498 score: false,
1499 score_gate: false,
1500 snapshot_requested: true,
1501 trend: false,
1502 });
1503
1504 assert!(!derived.any_section);
1505 assert!(derived.css);
1506 assert!(derived.file_scores);
1507 assert!(derived.hotspots);
1508 assert!(derived.score);
1509 assert!(derived.force_full);
1510 }
1511}