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 dead_code_codeclimate;
35pub mod dead_code_sarif;
36pub mod decision_surface;
37pub mod dupes_output;
38mod duplication_filters;
39pub mod editor;
40pub mod explain;
41pub mod grouped_output;
42pub mod health_codeclimate;
43pub mod json_output;
44pub mod list_output;
45mod list_runtime;
46pub mod markdown_output;
49mod next_steps;
50pub mod output_contracts;
51pub mod review_deltas;
52pub mod routing;
53pub mod runtime;
54mod runtime_json;
55mod runtime_output;
56pub mod sarif_output;
57pub mod security_output;
58mod type_aware;
59pub mod ci_output {
60 pub use fallow_output::{
64 CiIssue, CiProvider, GroupedReviewIssues, MARKER_PREFIX_V2, MARKER_SUFFIX_V2,
65 MAX_COMMENT_BODY_BYTES, PROJECT_LEVEL_RULE_IDS, PrCommentRenderInput,
66 ReviewCommentRenderInput, ReviewEnvelopeRenderInput, ReviewEnvelopeRenderResult,
67 ReviewEnvelopeTruncation, ReviewGitlabDiffRefs, cap_body_with_marker, command_title,
68 composite_fingerprint, escape_md, github_check_conclusion,
69 group_review_issues_by_path_line, is_project_level_rule, issues_from_codeclimate,
70 issues_from_codeclimate_issues, render_pr_comment, render_review_comment_for_group,
71 render_review_envelope, review_label_from_codeclimate, summary_fingerprint, summary_label,
72 };
73}
74pub use analysis_context::{ProgrammaticAnalysisContext, resolve_programmatic_analysis_context};
75pub use audit_output::{
76 AuditAttribution, AuditCodeClimateOutputInput, AuditJsonHeaderInput, AuditJsonOutputInput,
77 AuditSarifOutputInput, AuditSummary, AuditVerdict,
78 attach_audit_duplication_demotion_attribution, attach_audit_styling_attribution,
79 attach_audit_wire_attribution, build_audit_codeclimate, build_audit_codeclimate_issues,
80 build_audit_header_json, build_audit_header_map, build_audit_sarif, build_review_brief_header,
81 serialize_audit_json,
82};
83pub use ci_output::{
84 CiIssue, CiProvider, GroupedReviewIssues, MARKER_PREFIX_V2, MARKER_SUFFIX_V2,
85 MAX_COMMENT_BODY_BYTES, PROJECT_LEVEL_RULE_IDS, PrCommentRenderInput, ReviewCommentRenderInput,
86 ReviewEnvelopeRenderInput, ReviewEnvelopeRenderResult, ReviewEnvelopeTruncation,
87 ReviewGitlabDiffRefs, cap_body_with_marker, command_title, composite_fingerprint, escape_md,
88 github_check_conclusion, group_review_issues_by_path_line, is_project_level_rule,
89 issues_from_codeclimate, issues_from_codeclimate_issues, render_pr_comment,
90 render_review_comment_for_group, render_review_envelope, review_label_from_codeclimate,
91 summary_fingerprint, summary_label,
92};
93pub use combined_output::{
94 CombinedCheckJsonSection, CombinedJsonOutputInput, serialize_combined_dupes_json,
95 serialize_combined_health_json, serialize_combined_json,
96};
97pub use compact_output::{
98 build_compact_lines, build_duplication_compact_lines, build_grouped_compact_lines,
99 build_health_compact_lines,
100};
101pub use dead_code_codeclimate::build_codeclimate;
102pub use dead_code_sarif::build_sarif;
103pub use dupes_output::{
104 AttributedCloneGroup, AttributedCloneGroupFinding, AttributedInstance, CloneDemotionReason,
105 CloneFamilyFinding, CloneGroupFinding, DupesReportPayload, DuplicationGroup,
106 DuplicationGrouping, build_duplication_codeclimate,
107};
108pub use editor::{
109 ChangedFilesError, EditorAnalysisOutput, EditorAnalysisResults, EditorAnalysisSession,
110 EditorCloneFamily, EditorCloneFingerprintSet, EditorCloneGroup, EditorCloneInstance,
111 EditorDeadCodeAnalysisOutput, EditorDuplicationReport, EditorDuplicationStats,
112 EditorInlineComplexityExceeded, EditorInlineComplexityFinding, EditorMirroredDirectory,
113 EditorProjectAnalysisOutput, EditorRefactoringKind, EditorRefactoringSuggestion,
114 collect_inline_complexity, editor_duplicates, editor_extract, editor_results, editor_security,
115 editor_suppress, filter_inline_complexity_by_changed_files, resolve_git_toplevel,
116 try_get_changed_files_with_toplevel,
117};
118pub use explain::{
119 CHECK_RULES, DUPES_RULES, FLAGS_RULES, HEALTH_RULES, RuleDef, RuleGuide, SECURITY_RULES,
120 coverage_analyze_meta, coverage_setup_meta, explain_issue_type, rule_by_id, rule_by_token,
121 rule_docs_url, rule_guide, security_meta, serialize_explain_programmatic_json,
122 unknown_explain_error,
123};
124pub use fallow_config::{AuditGate, TypeAwareRequire};
125pub use fallow_output::RootEnvelopeMode;
126pub use fallow_types::trace::{
127 CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace, ReExportChain,
128 TracedCloneGroup, TracedExport, TracedReExport,
129};
130pub use grouped_output::{
131 ResultGroup, UNOWNED_GROUP_LABEL, build_duplication_grouping_with, group_analysis_results_with,
132 largest_clone_group_owner_with,
133};
134pub use health_codeclimate::build_health_codeclimate;
135pub use json_output::{
136 CheckJsonExtraOutputs, CheckJsonOutputInput, CheckJsonPayloadInput, DuplicationJsonOutputInput,
137 GroupedCheckJsonOutputInput, GroupedDuplicationJsonOutputInput, serialize_check_json,
138 serialize_check_json_payload, serialize_duplication_json, serialize_grouped_check_json,
139 serialize_grouped_duplication_json,
140};
141pub use list_output::{
142 ListJsonEnvelope, ListJsonOutputInput, build_list_json_output, serialize_list_json_output,
143};
144pub use list_runtime::{
145 BoundaryData, ListBoundariesOptions, ListBoundariesProgrammaticOutput, LogicalGroupInfo,
146 ProjectInfoOptions, ProjectInfoProgrammaticOutput, RuleInfo, ZoneInfo, boundary_data_to_output,
147 compute_boundary_data, run_list_boundaries, run_project_info,
148 serialize_list_boundaries_programmatic_json, serialize_project_info_programmatic_json,
149};
150pub use markdown_output::{
151 build_duplication_markdown, build_grouped_markdown, build_health_markdown, build_markdown,
152 build_walkthrough_markdown,
153};
154pub use output_contracts::{
155 AuditOutput, BoundariesListLogicalGroup, BoundariesListRule, BoundariesListZone,
156 BoundariesListing, CombinedOutput, FallowOutput, ImpactOutput, ListBoundariesOutput,
157 ListEntryPointOutput, ListOutput, ListPluginOutput, ReviewBriefWireOutput, SecurityGate,
158 SecurityOutput, SecurityOutputConfig, SecuritySummaryOutput, TraceOutput, WorkspacesOutput,
159};
160pub use runtime::{
161 AuditProgrammaticKeySnapshot, AuditProgrammaticOutput, BoundaryViolationsOutput,
162 BoundaryViolationsProgrammaticOutput, CircularDependenciesOutput,
163 CircularDependenciesProgrammaticOutput, CombinedProgrammaticOutput, DeadCodeOutput,
164 DeadCodeProgrammaticOutput, DecisionSurfaceProgrammaticOutput, DuplicationOutput,
165 DuplicationProgrammaticOutput, EngineHealthRunner, FeatureFlagsOutput,
166 FeatureFlagsProgrammaticOutput, HealthJsonReportInput, HealthProgrammaticOutput,
167 ProgrammaticHealthAnalysis, ProgrammaticHealthNextStepFacts, ProgrammaticHealthRun,
168 ProgrammaticHealthRunner, TraceClassMemberOutput, TraceCloneOutput,
169 TraceCloneProgrammaticOutput, TraceDependencyOutput, TraceDependencyProgrammaticOutput,
170 TraceExportOutput, TraceExportProgrammaticOutput, TraceExportTargetOutput, TraceFileOutput,
171 TraceFileProgrammaticOutput, run_audit, run_boundary_violations, run_circular_dependencies,
172 run_combined, run_complexity_with_runner, run_dead_code, run_decision_surface, run_duplication,
173 run_feature_flags, run_health, run_health_with_runner, run_trace_clone, run_trace_dependency,
174 run_trace_export, run_trace_file, serialize_health_report_json,
175};
176pub use runtime_json::{
177 serialize_audit_programmatic_json, serialize_boundary_violations_programmatic_json,
178 serialize_circular_dependencies_programmatic_json, serialize_combined_programmatic_json,
179 serialize_dead_code_programmatic_json, serialize_decision_surface_programmatic_json,
180 serialize_duplication_programmatic_json, serialize_feature_flags_programmatic_json,
181 serialize_health_programmatic_json, serialize_trace_clone_programmatic_json,
182 serialize_trace_dependency_programmatic_json, serialize_trace_export_programmatic_json,
183 serialize_trace_file_programmatic_json,
184};
185pub use sarif_output::{
186 annotate_sarif_results, build_duplication_sarif, build_grouped_duplication_sarif,
187 build_health_sarif,
188};
189pub use security_output::SecurityGateMode;
190pub use type_aware::{
191 SemanticCouplingOutcome, SemanticDeadCodeOutcome, SemanticInspectOutcome, TypeAwareError,
192 TypeAwareFileChanges, TypeAwareOutcome, TypeAwareSession, TypeAwareStatus,
193 discard_unverified_semantic_candidates, inspect_symbol as inspect_type_aware_symbol,
194 merge_type_aware_meta,
195 refine_configured_dead_code_results as refine_type_aware_results_with_config,
196 refine_configured_dead_code_results_in_session as refine_type_aware_results_in_session_with_config,
197 refine_dead_code_results as refine_type_aware_results,
198 refine_dead_code_results_in_session as refine_type_aware_results_in_session,
199 refine_programmatic_dead_code as refine_type_aware_dead_code, shutdown_type_aware_sidecars,
200 status as type_aware_status, symbol_impact as run_type_aware_symbol_impact,
201 symbol_impact as type_aware_symbol_impact, terminate_active_type_aware_sidecars,
202 trace_symbol as run_type_aware_symbol_trace, trace_symbol as trace_type_aware_symbol,
203 type_coupling as analyze_type_coupling,
204};
205
206pub const COMMON_ANALYSIS_OPTION_FLAGS: &[&str] = &[
213 "root",
214 "config",
215 "no-cache",
216 "threads",
217 "changed-since",
218 "diff-file",
219 "production",
220 "workspace",
221 "changed-workspaces",
222 "explain",
223 "allow-remote-extends",
224];
225
226#[derive(Debug, Clone, Serialize)]
228pub struct ProgrammaticError {
229 pub message: String,
231 pub exit_code: u8,
233 pub code: Option<String>,
235 pub help: Option<String>,
237 pub context: Option<String>,
239}
240
241impl ProgrammaticError {
242 #[must_use]
245 pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
246 Self {
247 message: message.into(),
248 exit_code,
249 code: None,
250 help: None,
251 context: None,
252 }
253 }
254
255 #[must_use]
257 pub fn with_help(mut self, help: impl Into<String>) -> Self {
258 self.help = Some(help.into());
259 self
260 }
261
262 #[must_use]
265 pub fn with_code(mut self, code: impl Into<String>) -> Self {
266 self.code = Some(code.into());
267 self
268 }
269
270 #[must_use]
273 pub fn with_context(mut self, context: impl Into<String>) -> Self {
274 self.context = Some(context.into());
275 self
276 }
277}
278
279impl std::fmt::Display for ProgrammaticError {
280 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281 write!(f, "{}", self.message)
282 }
283}
284
285impl std::error::Error for ProgrammaticError {}
286
287#[derive(Debug, Clone, Default)]
289pub struct AnalysisOptions {
290 pub root: Option<PathBuf>,
293 pub config_path: Option<PathBuf>,
295 pub allow_remote_extends: bool,
297 pub no_cache: bool,
299 pub threads: Option<usize>,
301 pub diff_file: Option<PathBuf>,
303 pub production: bool,
306 pub production_override: Option<bool>,
309 pub changed_since: Option<String>,
311 pub workspace: Option<Vec<String>>,
313 pub changed_workspaces: Option<String>,
315 pub explain: bool,
317 pub type_aware: TypeAwareOptions,
320}
321
322#[derive(Debug, Clone, Default, PartialEq, Eq)]
324pub struct TypeAwareOptions {
325 pub enabled: bool,
327 pub projects: Vec<PathBuf>,
330 pub require: fallow_config::TypeAwareRequire,
332}
333
334#[derive(Debug, Clone, Default)]
339pub struct DeadCodeFilters {
340 pub unused_files: bool,
342 pub unused_exports: bool,
344 pub unused_deps: bool,
346 pub unused_types: bool,
348 pub private_type_leaks: bool,
350 pub unused_enum_members: bool,
352 pub unused_class_members: bool,
354 pub unused_store_members: bool,
356 pub unprovided_injects: bool,
358 pub unrendered_components: bool,
360 pub unused_component_props: bool,
362 pub unused_component_emits: bool,
364 pub unused_component_inputs: bool,
366 pub unused_component_outputs: bool,
368 pub unused_svelte_events: bool,
370 pub unused_server_actions: bool,
372 pub unused_load_data_keys: bool,
374 pub unresolved_imports: bool,
376 pub unlisted_deps: bool,
378 pub duplicate_exports: bool,
380 pub circular_deps: bool,
382 pub re_export_cycles: bool,
384 pub boundary_violations: bool,
386 pub policy_violations: bool,
388 pub stale_suppressions: bool,
390 pub unused_catalog_entries: bool,
392 pub empty_catalog_groups: bool,
394 pub unresolved_catalog_references: bool,
396 pub unused_dependency_overrides: bool,
398 pub misconfigured_dependency_overrides: bool,
400}
401
402impl DeadCodeFilters {
403 fn any_active(&self) -> bool {
404 self.unused_files
405 || self.unused_exports
406 || self.unused_deps
407 || self.unused_types
408 || self.private_type_leaks
409 || self.unused_enum_members
410 || self.unused_class_members
411 || self.unused_store_members
412 || self.unprovided_injects
413 || self.unrendered_components
414 || self.unused_component_props
415 || self.unused_component_emits
416 || self.unused_component_inputs
417 || self.unused_component_outputs
418 || self.unused_svelte_events
419 || self.unused_server_actions
420 || self.unused_load_data_keys
421 || self.unresolved_imports
422 || self.unlisted_deps
423 || self.duplicate_exports
424 || self.circular_deps
425 || self.re_export_cycles
426 || self.boundary_violations
427 || self.policy_violations
428 || self.stale_suppressions
429 || self.unused_catalog_entries
430 || self.empty_catalog_groups
431 || self.unresolved_catalog_references
432 || self.unused_dependency_overrides
433 || self.misconfigured_dependency_overrides
434 }
435
436 pub fn enable_registry_selector(&mut self, selector: &str) -> bool {
442 let Some(flag) = fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS
443 .iter()
444 .find_map(|&(name, flag)| (name == selector).then_some(flag))
445 else {
446 return false;
447 };
448 self.enable_cli_filter_flag(flag);
449 true
450 }
451
452 fn enable_cli_filter_flag(&mut self, flag: &str) {
453 match flag {
454 "--unused-files" => self.unused_files = true,
455 "--unused-exports" => self.unused_exports = true,
456 "--unused-types" => self.unused_types = true,
457 "--private-type-leaks" => self.private_type_leaks = true,
458 "--unused-deps" => self.unused_deps = true,
459 "--unused-enum-members" => self.unused_enum_members = true,
460 "--unused-class-members" => self.unused_class_members = true,
461 "--unused-store-members" => self.unused_store_members = true,
462 "--unprovided-injects" => self.unprovided_injects = true,
463 "--unrendered-components" => self.unrendered_components = true,
464 "--unused-component-props" => self.unused_component_props = true,
465 "--unused-component-emits" => self.unused_component_emits = true,
466 "--unused-component-inputs" => self.unused_component_inputs = true,
467 "--unused-component-outputs" => self.unused_component_outputs = true,
468 "--unused-svelte-events" => self.unused_svelte_events = true,
469 "--unused-server-actions" => self.unused_server_actions = true,
470 "--unused-load-data-keys" => self.unused_load_data_keys = true,
471 "--unresolved-imports" => self.unresolved_imports = true,
472 "--unlisted-deps" => self.unlisted_deps = true,
473 "--duplicate-exports" => self.duplicate_exports = true,
474 "--circular-deps" => self.circular_deps = true,
475 "--re-export-cycles" => self.re_export_cycles = true,
476 "--boundary-violations" => self.boundary_violations = true,
477 "--policy-violations" => self.policy_violations = true,
478 "--stale-suppressions" => self.stale_suppressions = true,
479 "--unused-catalog-entries" => self.unused_catalog_entries = true,
480 "--empty-catalog-groups" => self.empty_catalog_groups = true,
481 "--unresolved-catalog-references" => self.unresolved_catalog_references = true,
482 "--unused-dependency-overrides" => self.unused_dependency_overrides = true,
483 "--misconfigured-dependency-overrides" => {
484 self.misconfigured_dependency_overrides = true;
485 }
486 _ => unreachable!("registry emitted unsupported dead-code filter flag: {flag}"),
487 }
488 }
489}
490
491#[derive(Debug, Clone, Default)]
493pub struct DeadCodeOptions {
494 pub analysis: AnalysisOptions,
496 pub filters: DeadCodeFilters,
498 pub files: Vec<PathBuf>,
500 pub include_entry_exports: bool,
502}
503
504#[derive(Debug, Clone, Default)]
506pub struct AuditOptions {
507 pub analysis: AnalysisOptions,
509 pub base: Option<String>,
512 pub production: bool,
514 pub production_dead_code: Option<bool>,
516 pub production_health: Option<bool>,
518 pub production_dupes: Option<bool>,
521 pub css: Option<bool>,
523 pub css_deep: Option<bool>,
525 pub gate: fallow_config::AuditGate,
527 pub max_crap: Option<f64>,
529 pub coverage: Option<PathBuf>,
531 pub coverage_root: Option<PathBuf>,
533 pub include_entry_exports: bool,
535 pub runtime_coverage: Option<PathBuf>,
537 pub min_invocations_hot: u64,
539}
540
541#[derive(Debug, Clone)]
543pub struct CombinedOptions {
544 pub analysis: AnalysisOptions,
546 pub dead_code: bool,
548 pub duplication: bool,
550 pub health: bool,
552 pub include_entry_exports: bool,
554 pub duplication_options: DuplicationOptions,
556 pub health_options: ComplexityOptions,
558}
559
560impl Default for CombinedOptions {
561 fn default() -> Self {
562 Self {
563 analysis: AnalysisOptions::default(),
564 dead_code: true,
565 duplication: true,
566 health: true,
567 include_entry_exports: false,
568 duplication_options: DuplicationOptions::default(),
569 health_options: ComplexityOptions::default(),
570 }
571 }
572}
573
574#[derive(Debug, Clone, Default)]
576pub struct DecisionSurfaceOptions {
577 pub analysis: AnalysisOptions,
579 pub base: Option<String>,
582 pub max_decisions: Option<usize>,
584}
585
586#[derive(Debug, Clone, Default)]
588pub struct FeatureFlagsOptions {
589 pub analysis: AnalysisOptions,
591 pub top: Option<usize>,
593}
594
595#[derive(Debug, Clone, Copy, Default)]
597pub enum DuplicationMode {
598 Strict,
601 #[default]
603 Mild,
604 Weak,
606 Semantic,
609}
610
611#[derive(Debug, Clone, Default)]
613pub struct DuplicationOptions {
614 pub analysis: AnalysisOptions,
616 pub mode: Option<DuplicationMode>,
618 pub near: Option<bool>,
621 pub min_tokens: Option<usize>,
623 pub min_lines: Option<usize>,
625 pub min_occurrences: Option<usize>,
628 pub threshold: Option<f64>,
631 pub skip_local: Option<bool>,
634 pub cross_language: Option<bool>,
636 pub ignore_imports: Option<bool>,
639 pub top: Option<usize>,
641}
642
643#[derive(Debug, Clone, Default)]
645pub struct TraceExportOptions {
646 pub analysis: AnalysisOptions,
648 pub file: String,
650 pub export_name: String,
652}
653
654#[derive(Debug, Clone, Default)]
656pub struct TraceFileOptions {
657 pub analysis: AnalysisOptions,
659 pub file: String,
661}
662
663#[derive(Debug, Clone, Default)]
665pub struct TraceDependencyOptions {
666 pub analysis: AnalysisOptions,
668 pub package_name: String,
670}
671
672#[derive(Debug, Clone, PartialEq, Eq)]
674pub enum TraceCloneTarget {
675 Location {
677 file: String,
679 line: usize,
681 },
682 Fingerprint(String),
684}
685
686#[derive(Debug, Clone)]
688pub struct TraceCloneOptions {
689 pub duplication: DuplicationOptions,
691 pub target: TraceCloneTarget,
693}
694
695#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
697pub enum ComplexitySort {
698 #[default]
700 Cyclomatic,
701 Cognitive,
703 Lines,
705 Severity,
707}
708
709#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
711pub enum OwnershipEmailMode {
712 Raw,
714 #[default]
716 Handle,
717 Anonymized,
719 Hash,
721}
722
723#[derive(Debug, Clone, Copy, PartialEq, Eq)]
725pub enum TargetEffort {
726 Low,
728 Medium,
730 High,
732}
733
734#[derive(Debug, Clone, Default)]
736pub struct ComplexityOptions {
737 pub analysis: AnalysisOptions,
739 pub max_cyclomatic: Option<u16>,
741 pub max_cognitive: Option<u16>,
743 pub max_crap: Option<f64>,
745 pub top: Option<usize>,
747 pub sort: ComplexitySort,
749 pub complexity_breakdown: bool,
751 pub complexity: bool,
753 pub file_scores: bool,
755 pub coverage_gaps: bool,
757 pub hotspots: bool,
759 pub ownership: bool,
761 pub ownership_emails: Option<OwnershipEmailMode>,
763 pub targets: bool,
765 pub css: bool,
767 pub css_deep: bool,
769 pub effort: Option<TargetEffort>,
772 pub score: bool,
774 pub since: Option<String>,
776 pub min_commits: Option<u32>,
778 pub coverage: Option<PathBuf>,
780 pub coverage_root: Option<PathBuf>,
782}
783
784#[derive(Debug, Clone, Copy, Default, PartialEq)]
786pub struct ComplexityThresholdOverrides {
787 pub max_cyclomatic: Option<u16>,
789 pub max_cognitive: Option<u16>,
791 pub max_crap: Option<f64>,
793}
794
795#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
797pub struct ComplexityCoverageInputs<'a> {
798 pub coverage: Option<&'a Path>,
800 pub coverage_root: Option<&'a Path>,
802}
803
804#[derive(Debug, Clone)]
806pub struct HealthSectionOptions {
807 pub output: fallow_types::output_format::OutputFormat,
809 pub complexity: bool,
811 pub file_scores: bool,
813 pub coverage_gaps: bool,
815 pub hotspots: bool,
817 pub targets: bool,
819 pub css: bool,
821 pub score: bool,
823 pub score_gate: bool,
825 pub snapshot_requested: bool,
827 pub trend: bool,
829}
830
831#[derive(Debug, Clone, Copy, PartialEq, Eq)]
833pub struct DerivedHealthSections {
834 pub any_section: bool,
836 pub complexity: bool,
838 pub file_scores: bool,
840 pub coverage_gaps: bool,
842 pub hotspots: bool,
844 pub targets: bool,
846 pub css: bool,
848 pub score: bool,
850 pub force_full: bool,
852 pub score_only_output: bool,
854}
855
856#[derive(Debug, Clone)]
858pub struct ComplexitySectionOptions {
859 pub complexity: bool,
861 pub file_scores: bool,
863 pub coverage_gaps: bool,
865 pub hotspots: bool,
867 pub ownership: bool,
869 pub targets: bool,
871 pub css: bool,
873 pub score: bool,
875}
876
877#[derive(Debug, Clone, Copy, PartialEq, Eq)]
879pub struct DerivedComplexityOptions {
880 pub any_section: bool,
882 pub complexity: bool,
884 pub file_scores: bool,
886 pub coverage_gaps: bool,
888 pub hotspots: bool,
890 pub ownership: bool,
892 pub targets: bool,
894 pub force_full: bool,
896 pub score_only_output: bool,
898 pub score: bool,
900}
901
902#[derive(Debug, Clone, PartialEq)]
904pub struct ComplexityRunOptions<'a> {
905 pub thresholds: ComplexityThresholdOverrides,
907 pub top: Option<usize>,
909 pub sort: ComplexitySort,
911 pub complexity_breakdown: bool,
913 pub sections: DerivedComplexityOptions,
915 pub ownership_emails: Option<OwnershipEmailMode>,
917 pub effort: Option<TargetEffort>,
919 pub css: bool,
921 pub css_deep: bool,
923 pub since: Option<&'a str>,
925 pub min_commits: Option<u32>,
927 pub coverage_inputs: ComplexityCoverageInputs<'a>,
929}
930
931#[must_use]
933pub fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
934 let score = options.score
935 || options.score_gate
936 || options.trend
937 || matches!(
938 options.output,
939 fallow_types::output_format::OutputFormat::Badge
940 );
941 let any_section = options.complexity
942 || options.file_scores
943 || options.coverage_gaps
944 || options.hotspots
945 || options.targets
946 || score;
947 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
948 let force_full = options.snapshot_requested || effective_score;
949
950 DerivedHealthSections {
951 any_section,
952 complexity: if any_section {
953 options.complexity
954 } else {
955 true
956 },
957 file_scores: if any_section {
958 options.file_scores
959 } else {
960 true
961 } || force_full,
962 coverage_gaps: if any_section {
963 options.coverage_gaps
964 } else {
965 false
966 },
967 hotspots: if any_section { options.hotspots } else { true }
968 || options.snapshot_requested
969 || options.trend,
970 targets: if any_section { options.targets } else { true },
971 css: options.css,
972 score: effective_score,
973 force_full,
974 score_only_output: is_health_score_only_output(options, score),
975 }
976}
977
978#[must_use]
980pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
981 let requested_hotspots = options.hotspots || options.ownership;
982 let sections = derive_health_sections(&HealthSectionOptions {
983 output: fallow_types::output_format::OutputFormat::Human,
984 complexity: options.complexity,
985 file_scores: options.file_scores,
986 coverage_gaps: options.coverage_gaps,
987 hotspots: requested_hotspots,
988 targets: options.targets,
989 css: options.css,
990 score: options.score,
991 score_gate: false,
992 snapshot_requested: false,
993 trend: false,
994 });
995
996 DerivedComplexityOptions {
997 any_section: sections.any_section,
998 complexity: sections.complexity,
999 file_scores: sections.file_scores,
1000 coverage_gaps: sections.coverage_gaps,
1001 hotspots: sections.hotspots,
1002 ownership: options.ownership && sections.hotspots,
1003 targets: sections.targets,
1004 force_full: sections.force_full,
1005 score_only_output: sections.score_only_output,
1006 score: sections.score,
1007 }
1008}
1009
1010#[must_use]
1012pub fn derive_complexity_options(options: &ComplexityOptions) -> DerivedComplexityOptions {
1013 derive_complexity_sections(&complexity_section_options(options))
1014}
1015
1016#[must_use]
1018pub fn derive_complexity_run_options(options: &ComplexityOptions) -> ComplexityRunOptions<'_> {
1019 ComplexityRunOptions {
1020 thresholds: ComplexityThresholdOverrides {
1021 max_cyclomatic: options.max_cyclomatic,
1022 max_cognitive: options.max_cognitive,
1023 max_crap: options.max_crap,
1024 },
1025 top: options.top,
1026 sort: options.sort,
1027 complexity_breakdown: options.complexity_breakdown,
1028 sections: derive_complexity_options(options),
1029 ownership_emails: options.ownership_emails,
1030 effort: options.effort,
1031 css: options.css,
1032 css_deep: options.css_deep,
1033 since: options.since.as_deref(),
1034 min_commits: options.min_commits,
1035 coverage_inputs: ComplexityCoverageInputs {
1036 coverage: options.coverage.as_deref(),
1037 coverage_root: options.coverage_root.as_deref(),
1038 },
1039 }
1040}
1041
1042pub fn validate_complexity_options(options: &ComplexityOptions) -> Result<(), ProgrammaticError> {
1053 if let Some(path) = &options.coverage
1054 && !path.exists()
1055 {
1056 return Err(ProgrammaticError::new(
1057 format!("coverage path does not exist: {}", path.display()),
1058 2,
1059 )
1060 .with_code("FALLOW_INVALID_COVERAGE_PATH")
1061 .with_context("health.coverage"));
1062 }
1063 if let Err(message) =
1064 fallow_engine::health::validate_coverage_root_absolute(options.coverage_root.as_deref())
1065 {
1066 return Err(ProgrammaticError::new(message, 2)
1067 .with_code("FALLOW_INVALID_COVERAGE_ROOT")
1068 .with_context("health.coverage_root"));
1069 }
1070
1071 Ok(())
1072}
1073
1074fn complexity_section_options(options: &ComplexityOptions) -> ComplexitySectionOptions {
1075 let ownership = options.ownership || options.ownership_emails.is_some();
1076 let requested_targets = options.targets || options.effort.is_some();
1077 ComplexitySectionOptions {
1078 complexity: options.complexity,
1079 file_scores: options.file_scores,
1080 coverage_gaps: options.coverage_gaps,
1081 hotspots: options.hotspots,
1082 ownership,
1083 targets: requested_targets,
1084 css: options.css,
1085 score: options.score,
1086 }
1087}
1088
1089fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
1090 score
1091 && !options.complexity
1092 && !options.file_scores
1093 && !options.coverage_gaps
1094 && !options.hotspots
1095 && !options.targets
1096 && !options.trend
1097}
1098
1099const fn thresholds_to_engine(
1100 thresholds: ComplexityThresholdOverrides,
1101) -> fallow_engine::health::HealthThresholdOverrides {
1102 fallow_engine::health::HealthThresholdOverrides {
1103 max_cyclomatic: thresholds.max_cyclomatic,
1104 max_cognitive: thresholds.max_cognitive,
1105 max_crap: thresholds.max_crap,
1106 }
1107}
1108
1109const fn complexity_sort_to_engine(sort: ComplexitySort) -> fallow_engine::health::HealthSort {
1110 match sort {
1111 ComplexitySort::Severity => fallow_engine::health::HealthSort::Severity,
1112 ComplexitySort::Cyclomatic => fallow_engine::health::HealthSort::Cyclomatic,
1113 ComplexitySort::Cognitive => fallow_engine::health::HealthSort::Cognitive,
1114 ComplexitySort::Lines => fallow_engine::health::HealthSort::Lines,
1115 }
1116}
1117
1118const fn coverage_inputs_to_engine(
1119 coverage_inputs: ComplexityCoverageInputs<'_>,
1120) -> fallow_engine::health::HealthCoverageInputs<'_> {
1121 fallow_engine::health::HealthCoverageInputs {
1122 coverage: coverage_inputs.coverage,
1123 coverage_root: coverage_inputs.coverage_root,
1124 }
1125}
1126
1127const fn ownership_email_mode_to_config(mode: OwnershipEmailMode) -> EmailMode {
1128 match mode {
1129 OwnershipEmailMode::Raw => EmailMode::Raw,
1130 OwnershipEmailMode::Handle => EmailMode::Handle,
1131 OwnershipEmailMode::Anonymized => EmailMode::Anonymized,
1132 OwnershipEmailMode::Hash => EmailMode::Hash,
1133 }
1134}
1135
1136const fn target_effort_to_output(effort: TargetEffort) -> EffortEstimate {
1137 match effort {
1138 TargetEffort::Low => EffortEstimate::Low,
1139 TargetEffort::Medium => EffortEstimate::Medium,
1140 TargetEffort::High => EffortEstimate::High,
1141 }
1142}
1143
1144#[cfg(test)]
1145mod tests {
1146 use super::*;
1147
1148 #[test]
1149 fn duplication_defaults_match_cli_contract() {
1150 let options = DuplicationOptions::default();
1151 assert!(options.mode.is_none());
1152 assert!(options.min_tokens.is_none());
1153 assert!(options.min_lines.is_none());
1154 assert!(options.min_occurrences.is_none());
1155 }
1156
1157 #[test]
1158 fn programmatic_error_builder_keeps_optional_fields() {
1159 let error = ProgrammaticError::new("boom", 2)
1160 .with_code("FALLOW_TEST")
1161 .with_help("Try again")
1162 .with_context("analysis.root");
1163
1164 assert_eq!(error.message, "boom");
1165 assert_eq!(error.exit_code, 2);
1166 assert_eq!(error.code.as_deref(), Some("FALLOW_TEST"));
1167 assert_eq!(error.help.as_deref(), Some("Try again"));
1168 assert_eq!(error.context.as_deref(), Some("analysis.root"));
1169 }
1170
1171 #[test]
1172 fn dead_code_filters_accept_shared_registry_selectors() {
1173 for (selector, _) in fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS.iter() {
1174 let mut filters = DeadCodeFilters::default();
1175 assert!(
1176 filters.enable_registry_selector(selector),
1177 "{selector} should be accepted"
1178 );
1179 }
1180
1181 let mut filters = DeadCodeFilters::default();
1182 assert!(filters.enable_registry_selector("unused-files"));
1183 assert!(filters.unused_files);
1184 assert!(filters.enable_registry_selector("boundary-violations"));
1185 assert!(filters.boundary_violations);
1186 assert!(!filters.enable_registry_selector("not-a-real-selector"));
1187 }
1188
1189 #[test]
1190 fn default_complexity_options_match_programmatic_health_defaults() {
1191 let derived = derive_complexity_options(&ComplexityOptions::default());
1192
1193 assert!(!derived.any_section);
1194 assert!(derived.complexity);
1195 assert!(derived.file_scores);
1196 assert!(!derived.coverage_gaps);
1197 assert!(derived.hotspots);
1198 assert!(!derived.ownership);
1199 assert!(derived.targets);
1200 assert!(derived.force_full);
1201 assert!(!derived.score_only_output);
1202 assert!(derived.score);
1203 }
1204
1205 #[test]
1206 fn score_only_complexity_options_request_score_only_output() {
1207 let derived = derive_complexity_options(&ComplexityOptions {
1208 score: true,
1209 ..ComplexityOptions::default()
1210 });
1211
1212 assert!(derived.any_section);
1213 assert!(!derived.complexity);
1214 assert!(derived.file_scores);
1215 assert!(!derived.hotspots);
1216 assert!(!derived.targets);
1217 assert!(derived.force_full);
1218 assert!(derived.score_only_output);
1219 assert!(derived.score);
1220 }
1221
1222 #[test]
1223 fn ownership_implies_hotspots_when_requested() {
1224 let derived = derive_complexity_options(&ComplexityOptions {
1225 ownership: true,
1226 ..ComplexityOptions::default()
1227 });
1228
1229 assert!(derived.any_section);
1230 assert!(derived.hotspots);
1231 assert!(derived.ownership);
1232 assert!(!derived.targets);
1233 }
1234
1235 #[test]
1236 fn complexity_run_options_normalize_public_api_options() {
1237 let options = ComplexityOptions {
1238 max_cyclomatic: Some(42),
1239 max_cognitive: Some(21),
1240 max_crap: Some(18.5),
1241 top: Some(7),
1242 sort: ComplexitySort::Severity,
1243 complexity_breakdown: true,
1244 ownership_emails: Some(OwnershipEmailMode::Hash),
1245 effort: Some(TargetEffort::High),
1246 coverage: Some(PathBuf::from("coverage/coverage-final.json")),
1247 coverage_root: Some(PathBuf::from("/ci/workspace")),
1248 since: Some("30d".to_string()),
1249 min_commits: Some(4),
1250 ..ComplexityOptions::default()
1251 };
1252
1253 let run = derive_complexity_run_options(&options);
1254
1255 assert_eq!(run.thresholds.max_cyclomatic, Some(42));
1256 assert_eq!(run.thresholds.max_cognitive, Some(21));
1257 assert_eq!(run.thresholds.max_crap, Some(18.5));
1258 assert_eq!(run.top, Some(7));
1259 assert!(matches!(run.sort, ComplexitySort::Severity));
1260 assert!(run.complexity_breakdown);
1261 assert!(run.sections.hotspots);
1262 assert!(run.sections.ownership);
1263 assert!(run.sections.targets);
1264 assert!(matches!(
1265 run.ownership_emails,
1266 Some(OwnershipEmailMode::Hash)
1267 ));
1268 assert!(matches!(run.effort, Some(TargetEffort::High)));
1269 assert_eq!(run.since, Some("30d"));
1270 assert_eq!(run.min_commits, Some(4));
1271 assert_eq!(run.coverage_inputs.coverage, options.coverage.as_deref());
1272 assert_eq!(
1273 run.coverage_inputs.coverage_root,
1274 options.coverage_root.as_deref()
1275 );
1276 }
1277
1278 #[test]
1279 fn complexity_options_validation_accepts_existing_coverage_path_and_absolute_root() {
1280 let dir = tempfile::tempdir().expect("tempdir");
1281 let coverage = dir.path().join("coverage-final.json");
1282 std::fs::write(&coverage, "{}").expect("coverage fixture");
1283
1284 let result = validate_complexity_options(&ComplexityOptions {
1285 coverage: Some(coverage),
1286 coverage_root: Some(PathBuf::from("/ci/workspace")),
1287 ..ComplexityOptions::default()
1288 });
1289
1290 assert!(result.is_ok());
1291 }
1292
1293 #[test]
1294 fn complexity_options_validation_keeps_missing_coverage_error_contract() {
1295 let err = validate_complexity_options(&ComplexityOptions {
1296 coverage: Some(PathBuf::from("/missing/coverage-final.json")),
1297 ..ComplexityOptions::default()
1298 })
1299 .expect_err("missing coverage path should fail");
1300
1301 assert_eq!(err.exit_code, 2);
1302 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1303 assert_eq!(err.context.as_deref(), Some("health.coverage"));
1304 }
1305
1306 #[test]
1307 fn complexity_options_validation_keeps_relative_coverage_root_error_contract() {
1308 let err = validate_complexity_options(&ComplexityOptions {
1309 coverage_root: Some(PathBuf::from("coverage")),
1310 ..ComplexityOptions::default()
1311 })
1312 .expect_err("relative coverage root should fail");
1313
1314 assert_eq!(err.exit_code, 2);
1315 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_ROOT"));
1316 assert_eq!(err.context.as_deref(), Some("health.coverage_root"));
1317 }
1318
1319 #[test]
1320 fn default_health_sections_match_full_health_output() {
1321 let derived = derive_health_sections(&HealthSectionOptions {
1322 output: fallow_types::output_format::OutputFormat::Human,
1323 complexity: false,
1324 file_scores: false,
1325 coverage_gaps: false,
1326 hotspots: false,
1327 targets: false,
1328 css: false,
1329 score: false,
1330 score_gate: false,
1331 snapshot_requested: false,
1332 trend: false,
1333 });
1334
1335 assert!(!derived.any_section);
1336 assert!(derived.complexity);
1337 assert!(derived.file_scores);
1338 assert!(!derived.coverage_gaps);
1339 assert!(derived.hotspots);
1340 assert!(derived.targets);
1341 assert!(derived.score);
1342 assert!(derived.force_full);
1343 assert!(!derived.score_only_output);
1344 }
1345
1346 #[test]
1347 fn health_score_gate_requests_score_only_output() {
1348 let derived = derive_health_sections(&HealthSectionOptions {
1349 output: fallow_types::output_format::OutputFormat::Human,
1350 complexity: false,
1351 file_scores: false,
1352 coverage_gaps: false,
1353 hotspots: false,
1354 targets: false,
1355 css: false,
1356 score: false,
1357 score_gate: true,
1358 snapshot_requested: false,
1359 trend: false,
1360 });
1361
1362 assert!(derived.any_section);
1363 assert!(!derived.complexity);
1364 assert!(derived.file_scores);
1365 assert!(!derived.hotspots);
1366 assert!(!derived.targets);
1367 assert!(derived.score);
1368 assert!(derived.force_full);
1369 assert!(derived.score_only_output);
1370 }
1371
1372 #[test]
1373 fn health_snapshot_keeps_full_hidden_inputs_without_section_request() {
1374 let derived = derive_health_sections(&HealthSectionOptions {
1375 output: fallow_types::output_format::OutputFormat::Human,
1376 complexity: false,
1377 file_scores: false,
1378 coverage_gaps: false,
1379 hotspots: false,
1380 targets: false,
1381 css: true,
1382 score: false,
1383 score_gate: false,
1384 snapshot_requested: true,
1385 trend: false,
1386 });
1387
1388 assert!(!derived.any_section);
1389 assert!(derived.css);
1390 assert!(derived.file_scores);
1391 assert!(derived.hotspots);
1392 assert!(derived.score);
1393 assert!(derived.force_full);
1394 }
1395}