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::PathBuf;
18
19use fallow_config::EmailMode;
20use fallow_output::EffortEstimate;
21use serde::Serialize;
22
23pub mod audit_output;
24pub mod combined_output;
25pub mod compact_output;
26pub mod dead_code_codeclimate;
27pub mod dead_code_sarif;
28pub mod dupes_output;
29pub mod editor;
30pub mod grouped_output;
31pub mod health_codeclimate;
32pub mod json_output;
33pub mod list_output;
34pub mod markdown_output;
35pub mod output_contracts;
36pub mod runtime;
37pub mod sarif_output;
38pub mod security_output;
39pub mod ci_output {
40 pub use fallow_output::{
44 CiIssue, CiProvider, GroupedReviewIssues, MARKER_PREFIX_V2, MARKER_SUFFIX_V2,
45 MAX_COMMENT_BODY_BYTES, PROJECT_LEVEL_RULE_IDS, PrCommentRenderInput,
46 ReviewCommentRenderInput, ReviewEnvelopeRenderInput, ReviewEnvelopeRenderResult,
47 ReviewEnvelopeTruncation, ReviewGitlabDiffRefs, cap_body_with_marker, command_title,
48 composite_fingerprint, escape_md, github_check_conclusion,
49 group_review_issues_by_path_line, is_project_level_rule, issues_from_codeclimate,
50 issues_from_codeclimate_issues, render_pr_comment, render_review_comment_for_group,
51 render_review_envelope, review_label_from_codeclimate, summary_fingerprint, summary_label,
52 };
53}
54pub use audit_output::{
55 AuditAttribution, AuditCodeClimateOutputInput, AuditJsonHeaderInput, AuditJsonOutputInput,
56 AuditSarifOutputInput, AuditSummary, AuditVerdict, build_audit_codeclimate,
57 build_audit_codeclimate_issues, build_audit_header_json, build_audit_header_map,
58 build_audit_sarif, serialize_audit_json,
59};
60pub use ci_output::{
61 CiIssue, CiProvider, GroupedReviewIssues, MARKER_PREFIX_V2, MARKER_SUFFIX_V2,
62 MAX_COMMENT_BODY_BYTES, PROJECT_LEVEL_RULE_IDS, PrCommentRenderInput, ReviewCommentRenderInput,
63 ReviewEnvelopeRenderInput, ReviewEnvelopeRenderResult, ReviewEnvelopeTruncation,
64 ReviewGitlabDiffRefs, cap_body_with_marker, command_title, composite_fingerprint, escape_md,
65 github_check_conclusion, group_review_issues_by_path_line, is_project_level_rule,
66 issues_from_codeclimate, issues_from_codeclimate_issues, render_pr_comment,
67 render_review_comment_for_group, render_review_envelope, review_label_from_codeclimate,
68 summary_fingerprint, summary_label,
69};
70pub use combined_output::{
71 CombinedCheckJsonSection, CombinedJsonOutputInput, serialize_combined_dupes_json,
72 serialize_combined_health_json, serialize_combined_json,
73};
74pub use compact_output::{
75 build_compact_lines, build_duplication_compact_lines, build_grouped_compact_lines,
76 build_health_compact_lines,
77};
78pub use dead_code_codeclimate::build_codeclimate;
79pub use dead_code_sarif::build_sarif;
80pub use dupes_output::{
81 AttributedCloneGroup, AttributedCloneGroupFinding, AttributedInstance, CloneFamilyFinding,
82 CloneGroupFinding, DupesReportPayload, DuplicationGroup, DuplicationGrouping,
83 build_duplication_codeclimate,
84};
85pub use editor::{
86 ChangedFilesError, EditorAnalysisOutput, EditorAnalysisResults, EditorAnalysisSession,
87 EditorDeadCodeAnalysisOutput, EditorDuplicationReport, EditorInlineComplexityExceeded,
88 EditorInlineComplexityFinding, EditorProjectAnalysisOutput, collect_inline_complexity,
89 editor_duplicates, editor_extract, editor_results, editor_security, editor_suppress,
90 filter_inline_complexity_by_changed_files, resolve_git_toplevel,
91 try_get_changed_files_with_toplevel,
92};
93pub use grouped_output::{
94 ResultGroup, UNOWNED_GROUP_LABEL, build_duplication_grouping_with, group_analysis_results_with,
95 largest_clone_group_owner_with,
96};
97pub use health_codeclimate::build_health_codeclimate;
98pub use json_output::{
99 CheckJsonExtraOutputs, CheckJsonOutputInput, CheckJsonPayloadInput, DuplicationJsonOutputInput,
100 GroupedCheckJsonOutputInput, GroupedDuplicationJsonOutputInput,
101 harmonize_multi_kind_suppress_line_actions, serialize_check_json, serialize_check_json_payload,
102 serialize_duplication_json, serialize_grouped_check_json, serialize_grouped_duplication_json,
103};
104pub use list_output::{
105 ListJsonEnvelope, ListJsonOutputInput, build_list_json_output, serialize_list_json_output,
106};
107pub use markdown_output::{
108 build_duplication_markdown, build_grouped_markdown, build_health_markdown, build_markdown,
109};
110pub use output_contracts::{
111 AuditOutput, BoundariesListLogicalGroup, BoundariesListRule, BoundariesListZone,
112 BoundariesListing, CombinedOutput, FallowOutput, ListBoundariesOutput, ListEntryPointOutput,
113 ListOutput, ListPluginOutput, SecurityGate, SecurityOutput, SecurityOutputConfig,
114 SecuritySummaryOutput, WorkspacesOutput,
115};
116pub use runtime::{
117 DeadCodeProgrammaticOutput, DuplicationProgrammaticOutput, EngineHealthRunner,
118 HealthJsonReportInput, HealthProgrammaticOutput, ProgrammaticAnalysisContext,
119 ProgrammaticHealthNextStepFacts, ProgrammaticHealthRun, ProgrammaticHealthRunner,
120 compute_complexity_with_runner, compute_health, compute_health_with_runner,
121 derive_programmatic_health_execution_options, detect_boundary_violations,
122 detect_circular_dependencies, detect_dead_code, detect_duplication,
123 resolve_programmatic_analysis_context, run_boundary_violations, run_circular_dependencies,
124 run_complexity_with_runner, run_dead_code, run_duplication, run_health, run_health_with_runner,
125 serialize_health_report_json,
126};
127pub use sarif_output::{
128 annotate_sarif_results, build_duplication_sarif, build_grouped_duplication_sarif,
129 build_health_sarif,
130};
131pub use security_output::SecurityGateMode;
132
133pub const COMMON_ANALYSIS_OPTION_FLAGS: &[&str] = &[
134 "root",
135 "config",
136 "no-cache",
137 "threads",
138 "changed-since",
139 "diff-file",
140 "production",
141 "workspace",
142 "changed-workspaces",
143 "explain",
144 "legacy-envelope",
145];
146
147#[derive(Debug, Clone, Serialize)]
149pub struct ProgrammaticError {
150 pub message: String,
151 pub exit_code: u8,
152 pub code: Option<String>,
153 pub help: Option<String>,
154 pub context: Option<String>,
155}
156
157impl ProgrammaticError {
158 #[must_use]
159 pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
160 Self {
161 message: message.into(),
162 exit_code,
163 code: None,
164 help: None,
165 context: None,
166 }
167 }
168
169 #[must_use]
170 pub fn with_help(mut self, help: impl Into<String>) -> Self {
171 self.help = Some(help.into());
172 self
173 }
174
175 #[must_use]
176 pub fn with_code(mut self, code: impl Into<String>) -> Self {
177 self.code = Some(code.into());
178 self
179 }
180
181 #[must_use]
182 pub fn with_context(mut self, context: impl Into<String>) -> Self {
183 self.context = Some(context.into());
184 self
185 }
186}
187
188impl std::fmt::Display for ProgrammaticError {
189 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190 write!(f, "{}", self.message)
191 }
192}
193
194impl std::error::Error for ProgrammaticError {}
195
196#[derive(Debug, Clone, Default)]
198pub struct AnalysisOptions {
199 pub root: Option<PathBuf>,
200 pub config_path: Option<PathBuf>,
201 pub no_cache: bool,
202 pub threads: Option<usize>,
203 pub diff_file: Option<PathBuf>,
204 pub production: bool,
207 pub production_override: Option<bool>,
210 pub changed_since: Option<String>,
211 pub workspace: Option<Vec<String>>,
212 pub changed_workspaces: Option<String>,
213 pub explain: bool,
214 pub legacy_envelope: bool,
216}
217
218#[derive(Debug, Clone, Default)]
220pub struct DeadCodeFilters {
221 pub unused_files: bool,
222 pub unused_exports: bool,
223 pub unused_deps: bool,
224 pub unused_types: bool,
225 pub private_type_leaks: bool,
226 pub unused_enum_members: bool,
227 pub unused_class_members: bool,
228 pub unused_store_members: bool,
229 pub unprovided_injects: bool,
230 pub unrendered_components: bool,
231 pub unused_component_props: bool,
232 pub unused_component_emits: bool,
233 pub unused_component_inputs: bool,
234 pub unused_component_outputs: bool,
235 pub unused_svelte_events: bool,
236 pub unused_server_actions: bool,
237 pub unused_load_data_keys: bool,
238 pub unresolved_imports: bool,
239 pub unlisted_deps: bool,
240 pub duplicate_exports: bool,
241 pub circular_deps: bool,
242 pub re_export_cycles: bool,
243 pub boundary_violations: bool,
244 pub policy_violations: bool,
245 pub stale_suppressions: bool,
246 pub unused_catalog_entries: bool,
247 pub empty_catalog_groups: bool,
248 pub unresolved_catalog_references: bool,
249 pub unused_dependency_overrides: bool,
250 pub misconfigured_dependency_overrides: bool,
251}
252
253#[derive(Debug, Clone, Default)]
255pub struct DeadCodeOptions {
256 pub analysis: AnalysisOptions,
257 pub filters: DeadCodeFilters,
258 pub files: Vec<PathBuf>,
259 pub include_entry_exports: bool,
260}
261
262#[derive(Debug, Clone, Copy, Default)]
264pub enum DuplicationMode {
265 Strict,
266 #[default]
267 Mild,
268 Weak,
269 Semantic,
270}
271
272#[derive(Debug, Clone)]
274pub struct DuplicationOptions {
275 pub analysis: AnalysisOptions,
276 pub mode: DuplicationMode,
277 pub min_tokens: usize,
278 pub min_lines: usize,
279 pub min_occurrences: usize,
282 pub threshold: f64,
283 pub skip_local: bool,
284 pub cross_language: bool,
285 pub ignore_imports: Option<bool>,
288 pub top: Option<usize>,
289}
290
291impl Default for DuplicationOptions {
292 fn default() -> Self {
293 Self {
294 analysis: AnalysisOptions::default(),
295 mode: DuplicationMode::Mild,
296 min_tokens: 50,
297 min_lines: 5,
298 min_occurrences: 2,
299 threshold: 0.0,
300 skip_local: false,
301 cross_language: false,
302 ignore_imports: None,
303 top: None,
304 }
305 }
306}
307
308#[derive(Debug, Clone, Copy, Default)]
310pub enum ComplexitySort {
311 #[default]
312 Cyclomatic,
313 Cognitive,
314 Lines,
315 Severity,
316}
317
318#[derive(Debug, Clone, Copy, Default)]
320pub enum OwnershipEmailMode {
321 Raw,
322 #[default]
323 Handle,
324 Anonymized,
325 Hash,
327}
328
329#[derive(Debug, Clone, Copy)]
331pub enum TargetEffort {
332 Low,
333 Medium,
334 High,
335}
336
337#[derive(Debug, Clone, Default)]
339pub struct ComplexityOptions {
340 pub analysis: AnalysisOptions,
341 pub max_cyclomatic: Option<u16>,
342 pub max_cognitive: Option<u16>,
343 pub max_crap: Option<f64>,
344 pub top: Option<usize>,
345 pub sort: ComplexitySort,
346 pub complexity: bool,
347 pub file_scores: bool,
348 pub coverage_gaps: bool,
349 pub hotspots: bool,
350 pub ownership: bool,
351 pub ownership_emails: Option<OwnershipEmailMode>,
352 pub targets: bool,
353 pub css: bool,
354 pub effort: Option<TargetEffort>,
355 pub score: bool,
356 pub since: Option<String>,
357 pub min_commits: Option<u32>,
358 pub coverage: Option<PathBuf>,
359 pub coverage_root: Option<PathBuf>,
360}
361
362pub use fallow_engine::{
363 ComplexityRunOptions, ComplexitySectionOptions, DerivedComplexityOptions,
364 DerivedHealthSections, HealthSectionOptions, derive_complexity_sections,
365 derive_health_sections,
366};
367
368#[must_use]
370pub fn derive_complexity_options(options: &ComplexityOptions) -> DerivedComplexityOptions {
371 derive_complexity_sections(&complexity_section_options(options))
372}
373
374#[must_use]
376pub fn derive_complexity_run_options(options: &ComplexityOptions) -> ComplexityRunOptions<'_> {
377 ComplexityRunOptions {
378 thresholds: fallow_engine::HealthThresholdOverrides {
379 max_cyclomatic: options.max_cyclomatic,
380 max_cognitive: options.max_cognitive,
381 max_crap: options.max_crap,
382 },
383 top: options.top,
384 sort: complexity_sort_to_engine(options.sort),
385 sections: derive_complexity_options(options),
386 ownership_emails: options.ownership_emails.map(ownership_email_mode_to_config),
387 effort: options.effort.map(target_effort_to_output),
388 css: options.css,
389 since: options.since.as_deref(),
390 min_commits: options.min_commits,
391 coverage_inputs: fallow_engine::HealthCoverageInputs {
392 coverage: options.coverage.as_deref(),
393 coverage_root: options.coverage_root.as_deref(),
394 },
395 }
396}
397
398pub fn validate_complexity_options(options: &ComplexityOptions) -> Result<(), ProgrammaticError> {
409 if let Some(path) = &options.coverage
410 && !path.exists()
411 {
412 return Err(ProgrammaticError::new(
413 format!("coverage path does not exist: {}", path.display()),
414 2,
415 )
416 .with_code("FALLOW_INVALID_COVERAGE_PATH")
417 .with_context("health.coverage"));
418 }
419 if let Err(message) =
420 fallow_engine::validate_coverage_root_absolute(options.coverage_root.as_deref())
421 {
422 return Err(ProgrammaticError::new(message, 2)
423 .with_code("FALLOW_INVALID_COVERAGE_ROOT")
424 .with_context("health.coverage_root"));
425 }
426
427 Ok(())
428}
429
430fn complexity_section_options(options: &ComplexityOptions) -> ComplexitySectionOptions {
431 let ownership = options.ownership || options.ownership_emails.is_some();
432 let requested_targets = options.targets || options.effort.is_some();
433 ComplexitySectionOptions {
434 complexity: options.complexity,
435 file_scores: options.file_scores,
436 coverage_gaps: options.coverage_gaps,
437 hotspots: options.hotspots,
438 ownership,
439 targets: requested_targets,
440 css: options.css,
441 score: options.score,
442 }
443}
444
445const fn complexity_sort_to_engine(sort: ComplexitySort) -> fallow_engine::HealthSort {
446 match sort {
447 ComplexitySort::Severity => fallow_engine::HealthSort::Severity,
448 ComplexitySort::Cyclomatic => fallow_engine::HealthSort::Cyclomatic,
449 ComplexitySort::Cognitive => fallow_engine::HealthSort::Cognitive,
450 ComplexitySort::Lines => fallow_engine::HealthSort::Lines,
451 }
452}
453
454const fn ownership_email_mode_to_config(mode: OwnershipEmailMode) -> EmailMode {
455 match mode {
456 OwnershipEmailMode::Raw => EmailMode::Raw,
457 OwnershipEmailMode::Handle => EmailMode::Handle,
458 OwnershipEmailMode::Anonymized => EmailMode::Anonymized,
459 OwnershipEmailMode::Hash => EmailMode::Hash,
460 }
461}
462
463const fn target_effort_to_output(effort: TargetEffort) -> EffortEstimate {
464 match effort {
465 TargetEffort::Low => EffortEstimate::Low,
466 TargetEffort::Medium => EffortEstimate::Medium,
467 TargetEffort::High => EffortEstimate::High,
468 }
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 #[test]
476 fn duplication_defaults_match_cli_contract() {
477 let options = DuplicationOptions::default();
478 assert!(matches!(options.mode, DuplicationMode::Mild));
479 assert_eq!(options.min_tokens, 50);
480 assert_eq!(options.min_lines, 5);
481 assert_eq!(options.min_occurrences, 2);
482 }
483
484 #[test]
485 fn programmatic_error_builder_keeps_optional_fields() {
486 let error = ProgrammaticError::new("boom", 2)
487 .with_code("FALLOW_TEST")
488 .with_help("Try again")
489 .with_context("analysis.root");
490
491 assert_eq!(error.message, "boom");
492 assert_eq!(error.exit_code, 2);
493 assert_eq!(error.code.as_deref(), Some("FALLOW_TEST"));
494 assert_eq!(error.help.as_deref(), Some("Try again"));
495 assert_eq!(error.context.as_deref(), Some("analysis.root"));
496 }
497
498 #[test]
499 fn default_complexity_options_match_programmatic_health_defaults() {
500 let derived = derive_complexity_options(&ComplexityOptions::default());
501
502 assert!(!derived.any_section);
503 assert!(derived.complexity);
504 assert!(derived.file_scores);
505 assert!(!derived.coverage_gaps);
506 assert!(derived.hotspots);
507 assert!(!derived.ownership);
508 assert!(derived.targets);
509 assert!(derived.force_full);
510 assert!(!derived.score_only_output);
511 assert!(derived.score);
512 }
513
514 #[test]
515 fn score_only_complexity_options_request_score_only_output() {
516 let derived = derive_complexity_options(&ComplexityOptions {
517 score: true,
518 ..ComplexityOptions::default()
519 });
520
521 assert!(derived.any_section);
522 assert!(!derived.complexity);
523 assert!(derived.file_scores);
524 assert!(!derived.hotspots);
525 assert!(!derived.targets);
526 assert!(derived.force_full);
527 assert!(derived.score_only_output);
528 assert!(derived.score);
529 }
530
531 #[test]
532 fn ownership_implies_hotspots_when_requested() {
533 let derived = derive_complexity_options(&ComplexityOptions {
534 ownership: true,
535 ..ComplexityOptions::default()
536 });
537
538 assert!(derived.any_section);
539 assert!(derived.hotspots);
540 assert!(derived.ownership);
541 assert!(!derived.targets);
542 }
543
544 #[test]
545 fn complexity_run_options_normalize_public_api_options() {
546 let options = ComplexityOptions {
547 max_cyclomatic: Some(42),
548 max_cognitive: Some(21),
549 max_crap: Some(18.5),
550 top: Some(7),
551 sort: ComplexitySort::Severity,
552 ownership_emails: Some(OwnershipEmailMode::Hash),
553 effort: Some(TargetEffort::High),
554 coverage: Some(PathBuf::from("coverage/coverage-final.json")),
555 coverage_root: Some(PathBuf::from("/ci/workspace")),
556 since: Some("30d".to_string()),
557 min_commits: Some(4),
558 ..ComplexityOptions::default()
559 };
560
561 let run = derive_complexity_run_options(&options);
562
563 assert_eq!(run.thresholds.max_cyclomatic, Some(42));
564 assert_eq!(run.thresholds.max_cognitive, Some(21));
565 assert_eq!(run.thresholds.max_crap, Some(18.5));
566 assert_eq!(run.top, Some(7));
567 assert!(matches!(run.sort, fallow_engine::HealthSort::Severity));
568 assert!(run.sections.hotspots);
569 assert!(run.sections.ownership);
570 assert!(run.sections.targets);
571 assert!(matches!(
572 run.ownership_emails,
573 Some(fallow_config::EmailMode::Hash)
574 ));
575 assert!(matches!(
576 run.effort,
577 Some(fallow_output::EffortEstimate::High)
578 ));
579 assert_eq!(run.since, Some("30d"));
580 assert_eq!(run.min_commits, Some(4));
581 assert_eq!(run.coverage_inputs.coverage, options.coverage.as_deref());
582 assert_eq!(
583 run.coverage_inputs.coverage_root,
584 options.coverage_root.as_deref()
585 );
586 }
587
588 #[test]
589 fn complexity_options_validation_accepts_existing_coverage_path_and_absolute_root() {
590 let dir = tempfile::tempdir().expect("tempdir");
591 let coverage = dir.path().join("coverage-final.json");
592 std::fs::write(&coverage, "{}").expect("coverage fixture");
593
594 let result = validate_complexity_options(&ComplexityOptions {
595 coverage: Some(coverage),
596 coverage_root: Some(PathBuf::from("/ci/workspace")),
597 ..ComplexityOptions::default()
598 });
599
600 assert!(result.is_ok());
601 }
602
603 #[test]
604 fn complexity_options_validation_keeps_missing_coverage_error_contract() {
605 let err = validate_complexity_options(&ComplexityOptions {
606 coverage: Some(PathBuf::from("/missing/coverage-final.json")),
607 ..ComplexityOptions::default()
608 })
609 .expect_err("missing coverage path should fail");
610
611 assert_eq!(err.exit_code, 2);
612 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
613 assert_eq!(err.context.as_deref(), Some("health.coverage"));
614 }
615
616 #[test]
617 fn complexity_options_validation_keeps_relative_coverage_root_error_contract() {
618 let err = validate_complexity_options(&ComplexityOptions {
619 coverage_root: Some(PathBuf::from("coverage")),
620 ..ComplexityOptions::default()
621 })
622 .expect_err("relative coverage root should fail");
623
624 assert_eq!(err.exit_code, 2);
625 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_ROOT"));
626 assert_eq!(err.context.as_deref(), Some("health.coverage_root"));
627 }
628
629 #[test]
630 fn default_health_sections_match_full_health_output() {
631 let derived = derive_health_sections(&HealthSectionOptions {
632 output: fallow_config::OutputFormat::Human,
633 complexity: false,
634 file_scores: false,
635 coverage_gaps: false,
636 hotspots: false,
637 targets: false,
638 css: false,
639 score: false,
640 score_gate: false,
641 snapshot_requested: false,
642 trend: false,
643 });
644
645 assert!(!derived.any_section);
646 assert!(derived.complexity);
647 assert!(derived.file_scores);
648 assert!(!derived.coverage_gaps);
649 assert!(derived.hotspots);
650 assert!(derived.targets);
651 assert!(derived.score);
652 assert!(derived.force_full);
653 assert!(!derived.score_only_output);
654 }
655
656 #[test]
657 fn health_score_gate_requests_score_only_output() {
658 let derived = derive_health_sections(&HealthSectionOptions {
659 output: fallow_config::OutputFormat::Human,
660 complexity: false,
661 file_scores: false,
662 coverage_gaps: false,
663 hotspots: false,
664 targets: false,
665 css: false,
666 score: false,
667 score_gate: true,
668 snapshot_requested: false,
669 trend: false,
670 });
671
672 assert!(derived.any_section);
673 assert!(!derived.complexity);
674 assert!(derived.file_scores);
675 assert!(!derived.hotspots);
676 assert!(!derived.targets);
677 assert!(derived.score);
678 assert!(derived.force_full);
679 assert!(derived.score_only_output);
680 }
681
682 #[test]
683 fn health_snapshot_keeps_full_hidden_inputs_without_section_request() {
684 let derived = derive_health_sections(&HealthSectionOptions {
685 output: fallow_config::OutputFormat::Human,
686 complexity: false,
687 file_scores: false,
688 coverage_gaps: false,
689 hotspots: false,
690 targets: false,
691 css: true,
692 score: false,
693 score_gate: false,
694 snapshot_requested: true,
695 trend: false,
696 });
697
698 assert!(!derived.any_section);
699 assert!(derived.css);
700 assert!(derived.file_scores);
701 assert!(derived.hotspots);
702 assert!(derived.score);
703 assert!(derived.force_full);
704 }
705}