1#![cfg_attr(
10 test,
11 allow(
12 clippy::expect_used,
13 reason = "tests use expect to keep fixture setup concise"
14 )
15)]
16
17use std::path::{Path, PathBuf};
18
19use fallow_config::EmailMode;
20use fallow_output::EffortEstimate;
21use serde::Serialize;
22
23mod analysis_context;
24pub mod audit_keys;
25pub mod audit_output;
26pub mod combined_output;
27pub mod compact_output;
28pub mod dead_code_codeclimate;
29pub mod dead_code_sarif;
30pub mod decision_surface;
31pub mod dupes_output;
32mod duplication_filters;
33pub mod editor;
34pub mod explain;
35pub mod grouped_output;
36pub mod health_codeclimate;
37pub mod json_output;
38pub mod list_output;
39mod list_runtime;
40pub mod markdown_output;
41mod next_steps;
42pub mod output_contracts;
43pub mod review_deltas;
44pub mod routing;
45pub mod runtime;
46mod runtime_json;
47mod runtime_output;
48pub mod sarif_output;
49pub mod security_output;
50mod type_aware;
51pub mod ci_output {
52 pub use fallow_output::{
56 CiIssue, CiProvider, GroupedReviewIssues, MARKER_PREFIX_V2, MARKER_SUFFIX_V2,
57 MAX_COMMENT_BODY_BYTES, PROJECT_LEVEL_RULE_IDS, PrCommentRenderInput,
58 ReviewCommentRenderInput, ReviewEnvelopeRenderInput, ReviewEnvelopeRenderResult,
59 ReviewEnvelopeTruncation, ReviewGitlabDiffRefs, cap_body_with_marker, command_title,
60 composite_fingerprint, escape_md, github_check_conclusion,
61 group_review_issues_by_path_line, is_project_level_rule, issues_from_codeclimate,
62 issues_from_codeclimate_issues, render_pr_comment, render_review_comment_for_group,
63 render_review_envelope, review_label_from_codeclimate, summary_fingerprint, summary_label,
64 };
65}
66pub use analysis_context::{ProgrammaticAnalysisContext, resolve_programmatic_analysis_context};
67pub use audit_output::{
68 AuditAttribution, AuditCodeClimateOutputInput, AuditJsonHeaderInput, AuditJsonOutputInput,
69 AuditSarifOutputInput, AuditSummary, AuditVerdict, attach_audit_styling_attribution,
70 build_audit_codeclimate, build_audit_codeclimate_issues, build_audit_header_json,
71 build_audit_header_map, build_audit_sarif, build_review_brief_header, serialize_audit_json,
72};
73pub use ci_output::{
74 CiIssue, CiProvider, GroupedReviewIssues, MARKER_PREFIX_V2, MARKER_SUFFIX_V2,
75 MAX_COMMENT_BODY_BYTES, PROJECT_LEVEL_RULE_IDS, PrCommentRenderInput, ReviewCommentRenderInput,
76 ReviewEnvelopeRenderInput, ReviewEnvelopeRenderResult, ReviewEnvelopeTruncation,
77 ReviewGitlabDiffRefs, cap_body_with_marker, command_title, composite_fingerprint, escape_md,
78 github_check_conclusion, group_review_issues_by_path_line, is_project_level_rule,
79 issues_from_codeclimate, issues_from_codeclimate_issues, render_pr_comment,
80 render_review_comment_for_group, render_review_envelope, review_label_from_codeclimate,
81 summary_fingerprint, summary_label,
82};
83pub use combined_output::{
84 CombinedCheckJsonSection, CombinedJsonOutputInput, serialize_combined_dupes_json,
85 serialize_combined_health_json, serialize_combined_json,
86};
87pub use compact_output::{
88 build_compact_lines, build_duplication_compact_lines, build_grouped_compact_lines,
89 build_health_compact_lines,
90};
91pub use dead_code_codeclimate::build_codeclimate;
92pub use dead_code_sarif::build_sarif;
93pub use dupes_output::{
94 AttributedCloneGroup, AttributedCloneGroupFinding, AttributedInstance, CloneFamilyFinding,
95 CloneGroupFinding, DupesReportPayload, DuplicationGroup, DuplicationGrouping,
96 build_duplication_codeclimate,
97};
98pub use editor::{
99 ChangedFilesError, EditorAnalysisOutput, EditorAnalysisResults, EditorAnalysisSession,
100 EditorCloneFamily, EditorCloneFingerprintSet, EditorCloneGroup, EditorCloneInstance,
101 EditorDeadCodeAnalysisOutput, EditorDuplicationReport, EditorDuplicationStats,
102 EditorInlineComplexityExceeded, EditorInlineComplexityFinding, EditorMirroredDirectory,
103 EditorProjectAnalysisOutput, EditorRefactoringKind, EditorRefactoringSuggestion,
104 collect_inline_complexity, editor_duplicates, editor_extract, editor_results, editor_security,
105 editor_suppress, filter_inline_complexity_by_changed_files, resolve_git_toplevel,
106 try_get_changed_files_with_toplevel,
107};
108pub use explain::{
109 CHECK_RULES, DUPES_RULES, FLAGS_RULES, HEALTH_RULES, RuleDef, RuleGuide, SECURITY_RULES,
110 coverage_analyze_meta, coverage_setup_meta, explain_issue_type, rule_by_id, rule_by_token,
111 rule_docs_url, rule_guide, security_meta, serialize_explain_programmatic_json,
112 unknown_explain_error,
113};
114pub use fallow_config::{AuditGate, TypeAwareRequire};
115pub use fallow_output::RootEnvelopeMode;
116pub use fallow_types::trace::{
117 CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace, ReExportChain,
118 TracedCloneGroup, TracedExport, TracedReExport,
119};
120pub use grouped_output::{
121 ResultGroup, UNOWNED_GROUP_LABEL, build_duplication_grouping_with, group_analysis_results_with,
122 largest_clone_group_owner_with,
123};
124pub use health_codeclimate::build_health_codeclimate;
125pub use json_output::{
126 CheckJsonExtraOutputs, CheckJsonOutputInput, CheckJsonPayloadInput, DuplicationJsonOutputInput,
127 GroupedCheckJsonOutputInput, GroupedDuplicationJsonOutputInput, serialize_check_json,
128 serialize_check_json_payload, serialize_duplication_json, serialize_grouped_check_json,
129 serialize_grouped_duplication_json,
130};
131pub use list_output::{
132 ListJsonEnvelope, ListJsonOutputInput, build_list_json_output, serialize_list_json_output,
133};
134pub use list_runtime::{
135 BoundaryData, ListBoundariesOptions, ListBoundariesProgrammaticOutput, LogicalGroupInfo,
136 ProjectInfoOptions, ProjectInfoProgrammaticOutput, RuleInfo, ZoneInfo, boundary_data_to_output,
137 compute_boundary_data, run_list_boundaries, run_project_info,
138 serialize_list_boundaries_programmatic_json, serialize_project_info_programmatic_json,
139};
140pub use markdown_output::{
141 build_duplication_markdown, build_grouped_markdown, build_health_markdown, build_markdown,
142 build_walkthrough_markdown,
143};
144pub use output_contracts::{
145 AuditOutput, BoundariesListLogicalGroup, BoundariesListRule, BoundariesListZone,
146 BoundariesListing, CombinedOutput, FallowOutput, ImpactOutput, ListBoundariesOutput,
147 ListEntryPointOutput, ListOutput, ListPluginOutput, ReviewBriefWireOutput, SecurityGate,
148 SecurityOutput, SecurityOutputConfig, SecuritySummaryOutput, TraceOutput, WorkspacesOutput,
149};
150pub use runtime::{
151 AuditProgrammaticKeySnapshot, AuditProgrammaticOutput, BoundaryViolationsOutput,
152 BoundaryViolationsProgrammaticOutput, CircularDependenciesOutput,
153 CircularDependenciesProgrammaticOutput, CombinedProgrammaticOutput, DeadCodeOutput,
154 DeadCodeProgrammaticOutput, DecisionSurfaceProgrammaticOutput, DuplicationOutput,
155 DuplicationProgrammaticOutput, EngineHealthRunner, FeatureFlagsOutput,
156 FeatureFlagsProgrammaticOutput, HealthJsonReportInput, HealthProgrammaticOutput,
157 ProgrammaticHealthAnalysis, ProgrammaticHealthNextStepFacts, ProgrammaticHealthRun,
158 ProgrammaticHealthRunner, TraceClassMemberOutput, TraceCloneOutput,
159 TraceCloneProgrammaticOutput, TraceDependencyOutput, TraceDependencyProgrammaticOutput,
160 TraceExportOutput, TraceExportProgrammaticOutput, TraceExportTargetOutput, TraceFileOutput,
161 TraceFileProgrammaticOutput, run_audit, run_boundary_violations, run_circular_dependencies,
162 run_combined, run_complexity_with_runner, run_dead_code, run_decision_surface, run_duplication,
163 run_feature_flags, run_health, run_health_with_runner, run_trace_clone, run_trace_dependency,
164 run_trace_export, run_trace_file, serialize_health_report_json,
165};
166pub use runtime_json::{
167 serialize_audit_programmatic_json, serialize_boundary_violations_programmatic_json,
168 serialize_circular_dependencies_programmatic_json, serialize_combined_programmatic_json,
169 serialize_dead_code_programmatic_json, serialize_decision_surface_programmatic_json,
170 serialize_duplication_programmatic_json, serialize_feature_flags_programmatic_json,
171 serialize_health_programmatic_json, serialize_trace_clone_programmatic_json,
172 serialize_trace_dependency_programmatic_json, serialize_trace_export_programmatic_json,
173 serialize_trace_file_programmatic_json,
174};
175pub use sarif_output::{
176 annotate_sarif_results, build_duplication_sarif, build_grouped_duplication_sarif,
177 build_health_sarif,
178};
179pub use security_output::SecurityGateMode;
180pub use type_aware::{
181 SemanticCouplingOutcome, SemanticDeadCodeOutcome, SemanticInspectOutcome, TypeAwareError,
182 TypeAwareFileChanges, TypeAwareOutcome, TypeAwareSession, TypeAwareStatus,
183 discard_unverified_semantic_candidates, inspect_symbol as inspect_type_aware_symbol,
184 merge_type_aware_meta,
185 refine_configured_dead_code_results as refine_type_aware_results_with_config,
186 refine_configured_dead_code_results_in_session as refine_type_aware_results_in_session_with_config,
187 refine_dead_code_results as refine_type_aware_results,
188 refine_dead_code_results_in_session as refine_type_aware_results_in_session,
189 refine_programmatic_dead_code as refine_type_aware_dead_code, shutdown_type_aware_sidecars,
190 status as type_aware_status, symbol_impact as run_type_aware_symbol_impact,
191 symbol_impact as type_aware_symbol_impact, terminate_active_type_aware_sidecars,
192 trace_symbol as run_type_aware_symbol_trace, trace_symbol as trace_type_aware_symbol,
193 type_coupling as analyze_type_coupling,
194};
195
196pub const COMMON_ANALYSIS_OPTION_FLAGS: &[&str] = &[
197 "root",
198 "config",
199 "no-cache",
200 "threads",
201 "changed-since",
202 "diff-file",
203 "production",
204 "workspace",
205 "changed-workspaces",
206 "explain",
207 "allow-remote-extends",
208];
209
210#[derive(Debug, Clone, Serialize)]
212pub struct ProgrammaticError {
213 pub message: String,
214 pub exit_code: u8,
215 pub code: Option<String>,
216 pub help: Option<String>,
217 pub context: Option<String>,
218}
219
220impl ProgrammaticError {
221 #[must_use]
222 pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
223 Self {
224 message: message.into(),
225 exit_code,
226 code: None,
227 help: None,
228 context: None,
229 }
230 }
231
232 #[must_use]
233 pub fn with_help(mut self, help: impl Into<String>) -> Self {
234 self.help = Some(help.into());
235 self
236 }
237
238 #[must_use]
239 pub fn with_code(mut self, code: impl Into<String>) -> Self {
240 self.code = Some(code.into());
241 self
242 }
243
244 #[must_use]
245 pub fn with_context(mut self, context: impl Into<String>) -> Self {
246 self.context = Some(context.into());
247 self
248 }
249}
250
251impl std::fmt::Display for ProgrammaticError {
252 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253 write!(f, "{}", self.message)
254 }
255}
256
257impl std::error::Error for ProgrammaticError {}
258
259#[derive(Debug, Clone, Default)]
261pub struct AnalysisOptions {
262 pub root: Option<PathBuf>,
263 pub config_path: Option<PathBuf>,
264 pub allow_remote_extends: bool,
266 pub no_cache: bool,
267 pub threads: Option<usize>,
268 pub diff_file: Option<PathBuf>,
269 pub production: bool,
272 pub production_override: Option<bool>,
275 pub changed_since: Option<String>,
276 pub workspace: Option<Vec<String>>,
277 pub changed_workspaces: Option<String>,
278 pub explain: bool,
279 pub type_aware: TypeAwareOptions,
282}
283
284#[derive(Debug, Clone, Default, PartialEq, Eq)]
286pub struct TypeAwareOptions {
287 pub enabled: bool,
288 pub projects: Vec<PathBuf>,
289 pub require: fallow_config::TypeAwareRequire,
290}
291
292#[derive(Debug, Clone, Default)]
294pub struct DeadCodeFilters {
295 pub unused_files: bool,
296 pub unused_exports: bool,
297 pub unused_deps: bool,
298 pub unused_types: bool,
299 pub private_type_leaks: bool,
300 pub unused_enum_members: bool,
301 pub unused_class_members: bool,
302 pub unused_store_members: bool,
303 pub unprovided_injects: bool,
304 pub unrendered_components: bool,
305 pub unused_component_props: bool,
306 pub unused_component_emits: bool,
307 pub unused_component_inputs: bool,
308 pub unused_component_outputs: bool,
309 pub unused_svelte_events: bool,
310 pub unused_server_actions: bool,
311 pub unused_load_data_keys: bool,
312 pub unresolved_imports: bool,
313 pub unlisted_deps: bool,
314 pub duplicate_exports: bool,
315 pub circular_deps: bool,
316 pub re_export_cycles: bool,
317 pub boundary_violations: bool,
318 pub policy_violations: bool,
319 pub stale_suppressions: bool,
320 pub unused_catalog_entries: bool,
321 pub empty_catalog_groups: bool,
322 pub unresolved_catalog_references: bool,
323 pub unused_dependency_overrides: bool,
324 pub misconfigured_dependency_overrides: bool,
325}
326
327impl DeadCodeFilters {
328 fn any_active(&self) -> bool {
329 self.unused_files
330 || self.unused_exports
331 || self.unused_deps
332 || self.unused_types
333 || self.private_type_leaks
334 || self.unused_enum_members
335 || self.unused_class_members
336 || self.unused_store_members
337 || self.unprovided_injects
338 || self.unrendered_components
339 || self.unused_component_props
340 || self.unused_component_emits
341 || self.unused_component_inputs
342 || self.unused_component_outputs
343 || self.unused_svelte_events
344 || self.unused_server_actions
345 || self.unused_load_data_keys
346 || self.unresolved_imports
347 || self.unlisted_deps
348 || self.duplicate_exports
349 || self.circular_deps
350 || self.re_export_cycles
351 || self.boundary_violations
352 || self.policy_violations
353 || self.stale_suppressions
354 || self.unused_catalog_entries
355 || self.empty_catalog_groups
356 || self.unresolved_catalog_references
357 || self.unused_dependency_overrides
358 || self.misconfigured_dependency_overrides
359 }
360
361 pub fn enable_registry_selector(&mut self, selector: &str) -> bool {
367 let Some(flag) = fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS
368 .iter()
369 .find_map(|&(name, flag)| (name == selector).then_some(flag))
370 else {
371 return false;
372 };
373 self.enable_cli_filter_flag(flag);
374 true
375 }
376
377 fn enable_cli_filter_flag(&mut self, flag: &str) {
378 match flag {
379 "--unused-files" => self.unused_files = true,
380 "--unused-exports" => self.unused_exports = true,
381 "--unused-types" => self.unused_types = true,
382 "--private-type-leaks" => self.private_type_leaks = true,
383 "--unused-deps" => self.unused_deps = true,
384 "--unused-enum-members" => self.unused_enum_members = true,
385 "--unused-class-members" => self.unused_class_members = true,
386 "--unused-store-members" => self.unused_store_members = true,
387 "--unprovided-injects" => self.unprovided_injects = true,
388 "--unrendered-components" => self.unrendered_components = true,
389 "--unused-component-props" => self.unused_component_props = true,
390 "--unused-component-emits" => self.unused_component_emits = true,
391 "--unused-component-inputs" => self.unused_component_inputs = true,
392 "--unused-component-outputs" => self.unused_component_outputs = true,
393 "--unused-svelte-events" => self.unused_svelte_events = true,
394 "--unused-server-actions" => self.unused_server_actions = true,
395 "--unused-load-data-keys" => self.unused_load_data_keys = true,
396 "--unresolved-imports" => self.unresolved_imports = true,
397 "--unlisted-deps" => self.unlisted_deps = true,
398 "--duplicate-exports" => self.duplicate_exports = true,
399 "--circular-deps" => self.circular_deps = true,
400 "--re-export-cycles" => self.re_export_cycles = true,
401 "--boundary-violations" => self.boundary_violations = true,
402 "--policy-violations" => self.policy_violations = true,
403 "--stale-suppressions" => self.stale_suppressions = true,
404 "--unused-catalog-entries" => self.unused_catalog_entries = true,
405 "--empty-catalog-groups" => self.empty_catalog_groups = true,
406 "--unresolved-catalog-references" => self.unresolved_catalog_references = true,
407 "--unused-dependency-overrides" => self.unused_dependency_overrides = true,
408 "--misconfigured-dependency-overrides" => {
409 self.misconfigured_dependency_overrides = true;
410 }
411 _ => unreachable!("registry emitted unsupported dead-code filter flag: {flag}"),
412 }
413 }
414}
415
416#[derive(Debug, Clone, Default)]
418pub struct DeadCodeOptions {
419 pub analysis: AnalysisOptions,
420 pub filters: DeadCodeFilters,
421 pub files: Vec<PathBuf>,
422 pub include_entry_exports: bool,
423}
424
425#[derive(Debug, Clone, Default)]
427pub struct AuditOptions {
428 pub analysis: AnalysisOptions,
429 pub base: Option<String>,
430 pub production: bool,
431 pub production_dead_code: Option<bool>,
432 pub production_health: Option<bool>,
433 pub production_dupes: Option<bool>,
434 pub css: Option<bool>,
435 pub css_deep: Option<bool>,
436 pub gate: fallow_config::AuditGate,
437 pub max_crap: Option<f64>,
438 pub coverage: Option<PathBuf>,
439 pub coverage_root: Option<PathBuf>,
440 pub include_entry_exports: bool,
441 pub runtime_coverage: Option<PathBuf>,
442 pub min_invocations_hot: u64,
443}
444
445#[derive(Debug, Clone)]
447pub struct CombinedOptions {
448 pub analysis: AnalysisOptions,
449 pub dead_code: bool,
450 pub duplication: bool,
451 pub health: bool,
452 pub include_entry_exports: bool,
453 pub duplication_options: DuplicationOptions,
454 pub health_options: ComplexityOptions,
455}
456
457impl Default for CombinedOptions {
458 fn default() -> Self {
459 Self {
460 analysis: AnalysisOptions::default(),
461 dead_code: true,
462 duplication: true,
463 health: true,
464 include_entry_exports: false,
465 duplication_options: DuplicationOptions::default(),
466 health_options: ComplexityOptions::default(),
467 }
468 }
469}
470
471#[derive(Debug, Clone, Default)]
473pub struct DecisionSurfaceOptions {
474 pub analysis: AnalysisOptions,
475 pub base: Option<String>,
476 pub max_decisions: Option<usize>,
477}
478
479#[derive(Debug, Clone, Default)]
481pub struct FeatureFlagsOptions {
482 pub analysis: AnalysisOptions,
483 pub top: Option<usize>,
484}
485
486#[derive(Debug, Clone, Copy, Default)]
488pub enum DuplicationMode {
489 Strict,
490 #[default]
491 Mild,
492 Weak,
493 Semantic,
494}
495
496#[derive(Debug, Clone, Default)]
498pub struct DuplicationOptions {
499 pub analysis: AnalysisOptions,
500 pub mode: Option<DuplicationMode>,
501 pub min_tokens: Option<usize>,
502 pub min_lines: Option<usize>,
503 pub min_occurrences: Option<usize>,
506 pub threshold: Option<f64>,
507 pub skip_local: Option<bool>,
508 pub cross_language: Option<bool>,
509 pub ignore_imports: Option<bool>,
512 pub top: Option<usize>,
513}
514
515#[derive(Debug, Clone, Default)]
517pub struct TraceExportOptions {
518 pub analysis: AnalysisOptions,
519 pub file: String,
520 pub export_name: String,
521}
522
523#[derive(Debug, Clone, Default)]
525pub struct TraceFileOptions {
526 pub analysis: AnalysisOptions,
527 pub file: String,
528}
529
530#[derive(Debug, Clone, Default)]
532pub struct TraceDependencyOptions {
533 pub analysis: AnalysisOptions,
534 pub package_name: String,
535}
536
537#[derive(Debug, Clone, PartialEq, Eq)]
539pub enum TraceCloneTarget {
540 Location { file: String, line: usize },
541 Fingerprint(String),
542}
543
544#[derive(Debug, Clone)]
546pub struct TraceCloneOptions {
547 pub duplication: DuplicationOptions,
548 pub target: TraceCloneTarget,
549}
550
551#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
553pub enum ComplexitySort {
554 #[default]
555 Cyclomatic,
556 Cognitive,
557 Lines,
558 Severity,
559}
560
561#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
563pub enum OwnershipEmailMode {
564 Raw,
565 #[default]
566 Handle,
567 Anonymized,
568 Hash,
570}
571
572#[derive(Debug, Clone, Copy, PartialEq, Eq)]
574pub enum TargetEffort {
575 Low,
576 Medium,
577 High,
578}
579
580#[derive(Debug, Clone, Default)]
582pub struct ComplexityOptions {
583 pub analysis: AnalysisOptions,
584 pub max_cyclomatic: Option<u16>,
585 pub max_cognitive: Option<u16>,
586 pub max_crap: Option<f64>,
587 pub top: Option<usize>,
588 pub sort: ComplexitySort,
589 pub complexity_breakdown: bool,
590 pub complexity: bool,
591 pub file_scores: bool,
592 pub coverage_gaps: bool,
593 pub hotspots: bool,
594 pub ownership: bool,
595 pub ownership_emails: Option<OwnershipEmailMode>,
596 pub targets: bool,
597 pub css: bool,
598 pub css_deep: bool,
599 pub effort: Option<TargetEffort>,
600 pub score: bool,
601 pub since: Option<String>,
602 pub min_commits: Option<u32>,
603 pub coverage: Option<PathBuf>,
604 pub coverage_root: Option<PathBuf>,
605}
606
607#[derive(Debug, Clone, Copy, Default, PartialEq)]
609pub struct ComplexityThresholdOverrides {
610 pub max_cyclomatic: Option<u16>,
611 pub max_cognitive: Option<u16>,
612 pub max_crap: Option<f64>,
613}
614
615#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
617pub struct ComplexityCoverageInputs<'a> {
618 pub coverage: Option<&'a Path>,
619 pub coverage_root: Option<&'a Path>,
620}
621
622#[derive(Debug, Clone)]
624pub struct HealthSectionOptions {
625 pub output: fallow_types::output_format::OutputFormat,
626 pub complexity: bool,
627 pub file_scores: bool,
628 pub coverage_gaps: bool,
629 pub hotspots: bool,
630 pub targets: bool,
631 pub css: bool,
632 pub score: bool,
633 pub score_gate: bool,
634 pub snapshot_requested: bool,
635 pub trend: bool,
636}
637
638#[derive(Debug, Clone, Copy, PartialEq, Eq)]
640pub struct DerivedHealthSections {
641 pub any_section: bool,
642 pub complexity: bool,
643 pub file_scores: bool,
644 pub coverage_gaps: bool,
645 pub hotspots: bool,
646 pub targets: bool,
647 pub css: bool,
648 pub score: bool,
649 pub force_full: bool,
650 pub score_only_output: bool,
651}
652
653#[derive(Debug, Clone)]
655pub struct ComplexitySectionOptions {
656 pub complexity: bool,
657 pub file_scores: bool,
658 pub coverage_gaps: bool,
659 pub hotspots: bool,
660 pub ownership: bool,
661 pub targets: bool,
662 pub css: bool,
663 pub score: bool,
664}
665
666#[derive(Debug, Clone, Copy, PartialEq, Eq)]
668pub struct DerivedComplexityOptions {
669 pub any_section: bool,
670 pub complexity: bool,
671 pub file_scores: bool,
672 pub coverage_gaps: bool,
673 pub hotspots: bool,
674 pub ownership: bool,
675 pub targets: bool,
676 pub force_full: bool,
677 pub score_only_output: bool,
678 pub score: bool,
679}
680
681#[derive(Debug, Clone, PartialEq)]
683pub struct ComplexityRunOptions<'a> {
684 pub thresholds: ComplexityThresholdOverrides,
685 pub top: Option<usize>,
686 pub sort: ComplexitySort,
687 pub complexity_breakdown: bool,
688 pub sections: DerivedComplexityOptions,
689 pub ownership_emails: Option<OwnershipEmailMode>,
690 pub effort: Option<TargetEffort>,
691 pub css: bool,
692 pub css_deep: bool,
693 pub since: Option<&'a str>,
694 pub min_commits: Option<u32>,
695 pub coverage_inputs: ComplexityCoverageInputs<'a>,
696}
697
698#[must_use]
700pub fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
701 let score = options.score
702 || options.score_gate
703 || options.trend
704 || matches!(
705 options.output,
706 fallow_types::output_format::OutputFormat::Badge
707 );
708 let any_section = options.complexity
709 || options.file_scores
710 || options.coverage_gaps
711 || options.hotspots
712 || options.targets
713 || score;
714 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
715 let force_full = options.snapshot_requested || effective_score;
716
717 DerivedHealthSections {
718 any_section,
719 complexity: if any_section {
720 options.complexity
721 } else {
722 true
723 },
724 file_scores: if any_section {
725 options.file_scores
726 } else {
727 true
728 } || force_full,
729 coverage_gaps: if any_section {
730 options.coverage_gaps
731 } else {
732 false
733 },
734 hotspots: if any_section { options.hotspots } else { true }
735 || options.snapshot_requested
736 || options.trend,
737 targets: if any_section { options.targets } else { true },
738 css: options.css,
739 score: effective_score,
740 force_full,
741 score_only_output: is_health_score_only_output(options, score),
742 }
743}
744
745#[must_use]
747pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
748 let requested_hotspots = options.hotspots || options.ownership;
749 let sections = derive_health_sections(&HealthSectionOptions {
750 output: fallow_types::output_format::OutputFormat::Human,
751 complexity: options.complexity,
752 file_scores: options.file_scores,
753 coverage_gaps: options.coverage_gaps,
754 hotspots: requested_hotspots,
755 targets: options.targets,
756 css: options.css,
757 score: options.score,
758 score_gate: false,
759 snapshot_requested: false,
760 trend: false,
761 });
762
763 DerivedComplexityOptions {
764 any_section: sections.any_section,
765 complexity: sections.complexity,
766 file_scores: sections.file_scores,
767 coverage_gaps: sections.coverage_gaps,
768 hotspots: sections.hotspots,
769 ownership: options.ownership && sections.hotspots,
770 targets: sections.targets,
771 force_full: sections.force_full,
772 score_only_output: sections.score_only_output,
773 score: sections.score,
774 }
775}
776
777#[must_use]
779pub fn derive_complexity_options(options: &ComplexityOptions) -> DerivedComplexityOptions {
780 derive_complexity_sections(&complexity_section_options(options))
781}
782
783#[must_use]
785pub fn derive_complexity_run_options(options: &ComplexityOptions) -> ComplexityRunOptions<'_> {
786 ComplexityRunOptions {
787 thresholds: ComplexityThresholdOverrides {
788 max_cyclomatic: options.max_cyclomatic,
789 max_cognitive: options.max_cognitive,
790 max_crap: options.max_crap,
791 },
792 top: options.top,
793 sort: options.sort,
794 complexity_breakdown: options.complexity_breakdown,
795 sections: derive_complexity_options(options),
796 ownership_emails: options.ownership_emails,
797 effort: options.effort,
798 css: options.css,
799 css_deep: options.css_deep,
800 since: options.since.as_deref(),
801 min_commits: options.min_commits,
802 coverage_inputs: ComplexityCoverageInputs {
803 coverage: options.coverage.as_deref(),
804 coverage_root: options.coverage_root.as_deref(),
805 },
806 }
807}
808
809pub fn validate_complexity_options(options: &ComplexityOptions) -> Result<(), ProgrammaticError> {
820 if let Some(path) = &options.coverage
821 && !path.exists()
822 {
823 return Err(ProgrammaticError::new(
824 format!("coverage path does not exist: {}", path.display()),
825 2,
826 )
827 .with_code("FALLOW_INVALID_COVERAGE_PATH")
828 .with_context("health.coverage"));
829 }
830 if let Err(message) =
831 fallow_engine::health::validate_coverage_root_absolute(options.coverage_root.as_deref())
832 {
833 return Err(ProgrammaticError::new(message, 2)
834 .with_code("FALLOW_INVALID_COVERAGE_ROOT")
835 .with_context("health.coverage_root"));
836 }
837
838 Ok(())
839}
840
841fn complexity_section_options(options: &ComplexityOptions) -> ComplexitySectionOptions {
842 let ownership = options.ownership || options.ownership_emails.is_some();
843 let requested_targets = options.targets || options.effort.is_some();
844 ComplexitySectionOptions {
845 complexity: options.complexity,
846 file_scores: options.file_scores,
847 coverage_gaps: options.coverage_gaps,
848 hotspots: options.hotspots,
849 ownership,
850 targets: requested_targets,
851 css: options.css,
852 score: options.score,
853 }
854}
855
856fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
857 score
858 && !options.complexity
859 && !options.file_scores
860 && !options.coverage_gaps
861 && !options.hotspots
862 && !options.targets
863 && !options.trend
864}
865
866const fn thresholds_to_engine(
867 thresholds: ComplexityThresholdOverrides,
868) -> fallow_engine::health::HealthThresholdOverrides {
869 fallow_engine::health::HealthThresholdOverrides {
870 max_cyclomatic: thresholds.max_cyclomatic,
871 max_cognitive: thresholds.max_cognitive,
872 max_crap: thresholds.max_crap,
873 }
874}
875
876const fn complexity_sort_to_engine(sort: ComplexitySort) -> fallow_engine::health::HealthSort {
877 match sort {
878 ComplexitySort::Severity => fallow_engine::health::HealthSort::Severity,
879 ComplexitySort::Cyclomatic => fallow_engine::health::HealthSort::Cyclomatic,
880 ComplexitySort::Cognitive => fallow_engine::health::HealthSort::Cognitive,
881 ComplexitySort::Lines => fallow_engine::health::HealthSort::Lines,
882 }
883}
884
885const fn coverage_inputs_to_engine(
886 coverage_inputs: ComplexityCoverageInputs<'_>,
887) -> fallow_engine::health::HealthCoverageInputs<'_> {
888 fallow_engine::health::HealthCoverageInputs {
889 coverage: coverage_inputs.coverage,
890 coverage_root: coverage_inputs.coverage_root,
891 }
892}
893
894const fn ownership_email_mode_to_config(mode: OwnershipEmailMode) -> EmailMode {
895 match mode {
896 OwnershipEmailMode::Raw => EmailMode::Raw,
897 OwnershipEmailMode::Handle => EmailMode::Handle,
898 OwnershipEmailMode::Anonymized => EmailMode::Anonymized,
899 OwnershipEmailMode::Hash => EmailMode::Hash,
900 }
901}
902
903const fn target_effort_to_output(effort: TargetEffort) -> EffortEstimate {
904 match effort {
905 TargetEffort::Low => EffortEstimate::Low,
906 TargetEffort::Medium => EffortEstimate::Medium,
907 TargetEffort::High => EffortEstimate::High,
908 }
909}
910
911#[cfg(test)]
912mod tests {
913 use super::*;
914
915 #[test]
916 fn duplication_defaults_match_cli_contract() {
917 let options = DuplicationOptions::default();
918 assert!(options.mode.is_none());
919 assert!(options.min_tokens.is_none());
920 assert!(options.min_lines.is_none());
921 assert!(options.min_occurrences.is_none());
922 }
923
924 #[test]
925 fn programmatic_error_builder_keeps_optional_fields() {
926 let error = ProgrammaticError::new("boom", 2)
927 .with_code("FALLOW_TEST")
928 .with_help("Try again")
929 .with_context("analysis.root");
930
931 assert_eq!(error.message, "boom");
932 assert_eq!(error.exit_code, 2);
933 assert_eq!(error.code.as_deref(), Some("FALLOW_TEST"));
934 assert_eq!(error.help.as_deref(), Some("Try again"));
935 assert_eq!(error.context.as_deref(), Some("analysis.root"));
936 }
937
938 #[test]
939 fn dead_code_filters_accept_shared_registry_selectors() {
940 for (selector, _) in fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS.iter() {
941 let mut filters = DeadCodeFilters::default();
942 assert!(
943 filters.enable_registry_selector(selector),
944 "{selector} should be accepted"
945 );
946 }
947
948 let mut filters = DeadCodeFilters::default();
949 assert!(filters.enable_registry_selector("unused-files"));
950 assert!(filters.unused_files);
951 assert!(filters.enable_registry_selector("boundary-violations"));
952 assert!(filters.boundary_violations);
953 assert!(!filters.enable_registry_selector("not-a-real-selector"));
954 }
955
956 #[test]
957 fn default_complexity_options_match_programmatic_health_defaults() {
958 let derived = derive_complexity_options(&ComplexityOptions::default());
959
960 assert!(!derived.any_section);
961 assert!(derived.complexity);
962 assert!(derived.file_scores);
963 assert!(!derived.coverage_gaps);
964 assert!(derived.hotspots);
965 assert!(!derived.ownership);
966 assert!(derived.targets);
967 assert!(derived.force_full);
968 assert!(!derived.score_only_output);
969 assert!(derived.score);
970 }
971
972 #[test]
973 fn score_only_complexity_options_request_score_only_output() {
974 let derived = derive_complexity_options(&ComplexityOptions {
975 score: true,
976 ..ComplexityOptions::default()
977 });
978
979 assert!(derived.any_section);
980 assert!(!derived.complexity);
981 assert!(derived.file_scores);
982 assert!(!derived.hotspots);
983 assert!(!derived.targets);
984 assert!(derived.force_full);
985 assert!(derived.score_only_output);
986 assert!(derived.score);
987 }
988
989 #[test]
990 fn ownership_implies_hotspots_when_requested() {
991 let derived = derive_complexity_options(&ComplexityOptions {
992 ownership: true,
993 ..ComplexityOptions::default()
994 });
995
996 assert!(derived.any_section);
997 assert!(derived.hotspots);
998 assert!(derived.ownership);
999 assert!(!derived.targets);
1000 }
1001
1002 #[test]
1003 fn complexity_run_options_normalize_public_api_options() {
1004 let options = ComplexityOptions {
1005 max_cyclomatic: Some(42),
1006 max_cognitive: Some(21),
1007 max_crap: Some(18.5),
1008 top: Some(7),
1009 sort: ComplexitySort::Severity,
1010 complexity_breakdown: true,
1011 ownership_emails: Some(OwnershipEmailMode::Hash),
1012 effort: Some(TargetEffort::High),
1013 coverage: Some(PathBuf::from("coverage/coverage-final.json")),
1014 coverage_root: Some(PathBuf::from("/ci/workspace")),
1015 since: Some("30d".to_string()),
1016 min_commits: Some(4),
1017 ..ComplexityOptions::default()
1018 };
1019
1020 let run = derive_complexity_run_options(&options);
1021
1022 assert_eq!(run.thresholds.max_cyclomatic, Some(42));
1023 assert_eq!(run.thresholds.max_cognitive, Some(21));
1024 assert_eq!(run.thresholds.max_crap, Some(18.5));
1025 assert_eq!(run.top, Some(7));
1026 assert!(matches!(run.sort, ComplexitySort::Severity));
1027 assert!(run.complexity_breakdown);
1028 assert!(run.sections.hotspots);
1029 assert!(run.sections.ownership);
1030 assert!(run.sections.targets);
1031 assert!(matches!(
1032 run.ownership_emails,
1033 Some(OwnershipEmailMode::Hash)
1034 ));
1035 assert!(matches!(run.effort, Some(TargetEffort::High)));
1036 assert_eq!(run.since, Some("30d"));
1037 assert_eq!(run.min_commits, Some(4));
1038 assert_eq!(run.coverage_inputs.coverage, options.coverage.as_deref());
1039 assert_eq!(
1040 run.coverage_inputs.coverage_root,
1041 options.coverage_root.as_deref()
1042 );
1043 }
1044
1045 #[test]
1046 fn complexity_options_validation_accepts_existing_coverage_path_and_absolute_root() {
1047 let dir = tempfile::tempdir().expect("tempdir");
1048 let coverage = dir.path().join("coverage-final.json");
1049 std::fs::write(&coverage, "{}").expect("coverage fixture");
1050
1051 let result = validate_complexity_options(&ComplexityOptions {
1052 coverage: Some(coverage),
1053 coverage_root: Some(PathBuf::from("/ci/workspace")),
1054 ..ComplexityOptions::default()
1055 });
1056
1057 assert!(result.is_ok());
1058 }
1059
1060 #[test]
1061 fn complexity_options_validation_keeps_missing_coverage_error_contract() {
1062 let err = validate_complexity_options(&ComplexityOptions {
1063 coverage: Some(PathBuf::from("/missing/coverage-final.json")),
1064 ..ComplexityOptions::default()
1065 })
1066 .expect_err("missing coverage path should fail");
1067
1068 assert_eq!(err.exit_code, 2);
1069 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1070 assert_eq!(err.context.as_deref(), Some("health.coverage"));
1071 }
1072
1073 #[test]
1074 fn complexity_options_validation_keeps_relative_coverage_root_error_contract() {
1075 let err = validate_complexity_options(&ComplexityOptions {
1076 coverage_root: Some(PathBuf::from("coverage")),
1077 ..ComplexityOptions::default()
1078 })
1079 .expect_err("relative coverage root should fail");
1080
1081 assert_eq!(err.exit_code, 2);
1082 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_ROOT"));
1083 assert_eq!(err.context.as_deref(), Some("health.coverage_root"));
1084 }
1085
1086 #[test]
1087 fn default_health_sections_match_full_health_output() {
1088 let derived = derive_health_sections(&HealthSectionOptions {
1089 output: fallow_types::output_format::OutputFormat::Human,
1090 complexity: false,
1091 file_scores: false,
1092 coverage_gaps: false,
1093 hotspots: false,
1094 targets: false,
1095 css: false,
1096 score: false,
1097 score_gate: false,
1098 snapshot_requested: false,
1099 trend: false,
1100 });
1101
1102 assert!(!derived.any_section);
1103 assert!(derived.complexity);
1104 assert!(derived.file_scores);
1105 assert!(!derived.coverage_gaps);
1106 assert!(derived.hotspots);
1107 assert!(derived.targets);
1108 assert!(derived.score);
1109 assert!(derived.force_full);
1110 assert!(!derived.score_only_output);
1111 }
1112
1113 #[test]
1114 fn health_score_gate_requests_score_only_output() {
1115 let derived = derive_health_sections(&HealthSectionOptions {
1116 output: fallow_types::output_format::OutputFormat::Human,
1117 complexity: false,
1118 file_scores: false,
1119 coverage_gaps: false,
1120 hotspots: false,
1121 targets: false,
1122 css: false,
1123 score: false,
1124 score_gate: true,
1125 snapshot_requested: false,
1126 trend: false,
1127 });
1128
1129 assert!(derived.any_section);
1130 assert!(!derived.complexity);
1131 assert!(derived.file_scores);
1132 assert!(!derived.hotspots);
1133 assert!(!derived.targets);
1134 assert!(derived.score);
1135 assert!(derived.force_full);
1136 assert!(derived.score_only_output);
1137 }
1138
1139 #[test]
1140 fn health_snapshot_keeps_full_hidden_inputs_without_section_request() {
1141 let derived = derive_health_sections(&HealthSectionOptions {
1142 output: fallow_types::output_format::OutputFormat::Human,
1143 complexity: false,
1144 file_scores: false,
1145 coverage_gaps: false,
1146 hotspots: false,
1147 targets: false,
1148 css: true,
1149 score: false,
1150 score_gate: false,
1151 snapshot_requested: true,
1152 trend: false,
1153 });
1154
1155 assert!(!derived.any_section);
1156 assert!(derived.css);
1157 assert!(derived.file_scores);
1158 assert!(derived.hotspots);
1159 assert!(derived.score);
1160 assert!(derived.force_full);
1161 }
1162}