1use std::path::{Path, PathBuf};
2
3use fallow_config::{EmailMode, OutputFormat};
4use fallow_core::results::AnalysisResults;
5use serde::Serialize;
6
7use crate::check::{CheckOptions, IssueFilters, TraceOptions};
8use crate::dupes::{DupesMode, DupesOptions};
9use crate::health::{HealthOptions, SortBy};
10use crate::health_types::EffortEstimate;
11use crate::report::ci::diff_filter::{DiffIndex, LoadedDiff, MAX_DIFF_BYTES};
12use crate::report::{build_duplication_json, build_health_json};
13
14pub const COMMON_ANALYSIS_OPTION_FLAGS: &[&str] = &[
15 "root",
16 "config",
17 "no-cache",
18 "threads",
19 "changed-since",
20 "diff-file",
21 "production",
22 "workspace",
23 "changed-workspaces",
24 "explain",
25 "legacy-envelope",
26];
27
28#[derive(Debug, Clone, Serialize)]
30pub struct ProgrammaticError {
31 pub message: String,
32 pub exit_code: u8,
33 pub code: Option<String>,
34 pub help: Option<String>,
35 pub context: Option<String>,
36}
37
38impl ProgrammaticError {
39 #[must_use]
40 pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
41 Self {
42 message: message.into(),
43 exit_code,
44 code: None,
45 help: None,
46 context: None,
47 }
48 }
49
50 #[must_use]
51 pub fn with_help(mut self, help: impl Into<String>) -> Self {
52 self.help = Some(help.into());
53 self
54 }
55
56 #[must_use]
57 pub fn with_code(mut self, code: impl Into<String>) -> Self {
58 self.code = Some(code.into());
59 self
60 }
61
62 #[must_use]
63 pub fn with_context(mut self, context: impl Into<String>) -> Self {
64 self.context = Some(context.into());
65 self
66 }
67}
68
69impl std::fmt::Display for ProgrammaticError {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 write!(f, "{}", self.message)
72 }
73}
74
75impl std::error::Error for ProgrammaticError {}
76
77type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
78
79#[derive(Debug, Clone, Default)]
81pub struct AnalysisOptions {
82 pub root: Option<PathBuf>,
83 pub config_path: Option<PathBuf>,
84 pub no_cache: bool,
85 pub threads: Option<usize>,
86 pub diff_file: Option<PathBuf>,
87 pub production: bool,
90 pub production_override: Option<bool>,
93 pub changed_since: Option<String>,
94 pub workspace: Option<Vec<String>>,
95 pub changed_workspaces: Option<String>,
96 pub explain: bool,
97 pub legacy_envelope: bool,
99}
100
101#[derive(Debug, Clone, Default)]
103pub struct DeadCodeFilters {
104 pub unused_files: bool,
105 pub unused_exports: bool,
106 pub unused_deps: bool,
107 pub unused_types: bool,
108 pub private_type_leaks: bool,
109 pub unused_enum_members: bool,
110 pub unused_class_members: bool,
111 pub unresolved_imports: bool,
112 pub unlisted_deps: bool,
113 pub duplicate_exports: bool,
114 pub circular_deps: bool,
115 pub re_export_cycles: bool,
116 pub boundary_violations: bool,
117 pub stale_suppressions: bool,
118 pub unused_catalog_entries: bool,
119 pub empty_catalog_groups: bool,
120 pub unresolved_catalog_references: bool,
121 pub unused_dependency_overrides: bool,
122 pub misconfigured_dependency_overrides: bool,
123}
124
125#[derive(Debug, Clone, Default)]
127pub struct DeadCodeOptions {
128 pub analysis: AnalysisOptions,
129 pub filters: DeadCodeFilters,
130 pub files: Vec<PathBuf>,
131 pub include_entry_exports: bool,
132}
133
134#[derive(Debug, Clone, Copy, Default)]
136pub enum DuplicationMode {
137 Strict,
138 #[default]
139 Mild,
140 Weak,
141 Semantic,
142}
143
144impl DuplicationMode {
145 const fn to_cli(self) -> DupesMode {
146 match self {
147 Self::Strict => DupesMode::Strict,
148 Self::Mild => DupesMode::Mild,
149 Self::Weak => DupesMode::Weak,
150 Self::Semantic => DupesMode::Semantic,
151 }
152 }
153}
154
155#[derive(Debug, Clone)]
157pub struct DuplicationOptions {
158 pub analysis: AnalysisOptions,
159 pub mode: DuplicationMode,
160 pub min_tokens: usize,
161 pub min_lines: usize,
162 pub min_occurrences: usize,
169 pub threshold: f64,
170 pub skip_local: bool,
171 pub cross_language: bool,
172 pub ignore_imports: bool,
173 pub top: Option<usize>,
174}
175
176impl Default for DuplicationOptions {
177 fn default() -> Self {
178 Self {
179 analysis: AnalysisOptions::default(),
180 mode: DuplicationMode::Mild,
181 min_tokens: 50,
182 min_lines: 5,
183 min_occurrences: 2,
184 threshold: 0.0,
185 skip_local: false,
186 cross_language: false,
187 ignore_imports: false,
188 top: None,
189 }
190 }
191}
192
193#[derive(Debug, Clone, Copy, Default)]
195pub enum ComplexitySort {
196 #[default]
197 Cyclomatic,
198 Cognitive,
199 Lines,
200 Severity,
201}
202
203impl ComplexitySort {
204 const fn to_cli(self) -> SortBy {
205 match self {
206 Self::Severity => SortBy::Severity,
207 Self::Cyclomatic => SortBy::Cyclomatic,
208 Self::Cognitive => SortBy::Cognitive,
209 Self::Lines => SortBy::Lines,
210 }
211 }
212}
213
214#[derive(Debug, Clone, Copy, Default)]
216pub enum OwnershipEmailMode {
217 Raw,
218 #[default]
219 Handle,
220 Anonymized,
221 Hash,
223}
224
225impl OwnershipEmailMode {
226 const fn to_config(self) -> EmailMode {
227 match self {
228 Self::Raw => EmailMode::Raw,
229 Self::Handle => EmailMode::Handle,
230 Self::Anonymized => EmailMode::Anonymized,
231 Self::Hash => EmailMode::Hash,
232 }
233 }
234}
235
236#[derive(Debug, Clone, Copy)]
238pub enum TargetEffort {
239 Low,
240 Medium,
241 High,
242}
243
244impl TargetEffort {
245 const fn to_cli(self) -> EffortEstimate {
246 match self {
247 Self::Low => EffortEstimate::Low,
248 Self::Medium => EffortEstimate::Medium,
249 Self::High => EffortEstimate::High,
250 }
251 }
252}
253
254#[derive(Debug, Clone, Default)]
256pub struct ComplexityOptions {
257 pub analysis: AnalysisOptions,
258 pub max_cyclomatic: Option<u16>,
259 pub max_cognitive: Option<u16>,
260 pub max_crap: Option<f64>,
261 pub top: Option<usize>,
262 pub sort: ComplexitySort,
263 pub complexity: bool,
264 pub file_scores: bool,
265 pub coverage_gaps: bool,
266 pub hotspots: bool,
267 pub ownership: bool,
268 pub ownership_emails: Option<OwnershipEmailMode>,
269 pub targets: bool,
270 pub effort: Option<TargetEffort>,
271 pub score: bool,
272 pub since: Option<String>,
273 pub min_commits: Option<u32>,
274 pub coverage: Option<PathBuf>,
275 pub coverage_root: Option<PathBuf>,
276}
277
278struct ResolvedAnalysisOptions {
279 root: PathBuf,
280 config_path: Option<PathBuf>,
281 no_cache: bool,
282 threads: usize,
283 pool: rayon::ThreadPool,
284 diff: Option<LoadedDiff>,
285 production_override: Option<bool>,
286 changed_since: Option<String>,
287 workspace: Option<Vec<String>>,
288 changed_workspaces: Option<String>,
289 explain: bool,
290 legacy_envelope: bool,
291}
292
293impl AnalysisOptions {
294 fn resolve(&self) -> ProgrammaticResult<ResolvedAnalysisOptions> {
295 if self.threads == Some(0) {
296 return Err(
297 ProgrammaticError::new("`threads` must be greater than 0", 2)
298 .with_code("FALLOW_INVALID_THREADS")
299 .with_context("analysis.threads"),
300 );
301 }
302 if self.workspace.is_some() && self.changed_workspaces.is_some() {
303 return Err(ProgrammaticError::new(
304 "`workspace` and `changed_workspaces` are mutually exclusive",
305 2,
306 )
307 .with_code("FALLOW_MUTUALLY_EXCLUSIVE_OPTIONS")
308 .with_context("analysis.workspace"));
309 }
310
311 let root = if let Some(root) = &self.root {
312 root.clone()
313 } else {
314 std::env::current_dir().map_err(|err| {
315 ProgrammaticError::new(
316 format!("failed to resolve current working directory: {err}"),
317 2,
318 )
319 .with_code("FALLOW_CWD_UNAVAILABLE")
320 .with_context("analysis.root")
321 })?
322 };
323
324 if !root.exists() {
325 return Err(ProgrammaticError::new(
326 format!("analysis root does not exist: {}", root.display()),
327 2,
328 )
329 .with_code("FALLOW_INVALID_ROOT")
330 .with_context("analysis.root"));
331 }
332 if !root.is_dir() {
333 return Err(ProgrammaticError::new(
334 format!("analysis root is not a directory: {}", root.display()),
335 2,
336 )
337 .with_code("FALLOW_INVALID_ROOT")
338 .with_context("analysis.root"));
339 }
340
341 if let Some(config_path) = &self.config_path
342 && !config_path.exists()
343 {
344 return Err(ProgrammaticError::new(
345 format!("config file does not exist: {}", config_path.display()),
346 2,
347 )
348 .with_code("FALLOW_INVALID_CONFIG_PATH")
349 .with_context("analysis.configPath"));
350 }
351
352 let threads = self.threads.unwrap_or_else(default_threads);
353 let pool = crate::rayon_pool::build_thread_pool(threads).map_err(|err| {
354 ProgrammaticError::new(format!("failed to build analysis thread pool: {err}"), 2)
355 .with_code("FALLOW_THREAD_POOL_INIT_FAILED")
356 .with_context("analysis.threads")
357 })?;
358 let diff = self
359 .diff_file
360 .as_deref()
361 .map(|path| load_explicit_diff_file(path, &root))
362 .transpose()?;
363 let production_override = self
364 .production_override
365 .or_else(|| self.production.then_some(true));
366
367 Ok(ResolvedAnalysisOptions {
368 root,
369 config_path: self.config_path.clone(),
370 no_cache: self.no_cache,
371 threads,
372 pool,
373 diff,
374 production_override,
375 changed_since: self.changed_since.clone(),
376 workspace: self.workspace.clone(),
377 changed_workspaces: self.changed_workspaces.clone(),
378 explain: self.explain,
379 legacy_envelope: self.legacy_envelope,
380 })
381 }
382}
383
384impl ResolvedAnalysisOptions {
385 fn install<R: Send>(&self, f: impl FnOnce() -> R + Send) -> R {
386 self.pool.install(f)
387 }
388
389 fn diff_index(&self) -> Option<&DiffIndex> {
390 self.diff.as_ref().map(|loaded| &loaded.index)
391 }
392}
393
394fn default_threads() -> usize {
395 std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
396}
397
398fn load_explicit_diff_file(path: &Path, root: &Path) -> ProgrammaticResult<LoadedDiff> {
399 if path == Path::new("-") {
400 return Err(ProgrammaticError::new(
401 "`diff_file` does not support stdin; pass a file path",
402 2,
403 )
404 .with_code("FALLOW_INVALID_DIFF_FILE")
405 .with_context("analysis.diffFile"));
406 }
407
408 let abs = if crate::path_util::is_absolute_path_any_platform(path) {
409 path.to_path_buf()
410 } else {
411 root.join(path)
412 };
413
414 let meta = std::fs::metadata(&abs).map_err(|err| {
415 ProgrammaticError::new(
416 format!(
417 "diff file does not exist or cannot be read: {} ({err})",
418 abs.display()
419 ),
420 2,
421 )
422 .with_code("FALLOW_INVALID_DIFF_FILE")
423 .with_context("analysis.diffFile")
424 })?;
425 if !meta.is_file() {
426 return Err(ProgrammaticError::new(
427 format!("diff path is not a file: {}", abs.display()),
428 2,
429 )
430 .with_code("FALLOW_INVALID_DIFF_FILE")
431 .with_context("analysis.diffFile"));
432 }
433 if meta.len() > MAX_DIFF_BYTES {
434 return Err(ProgrammaticError::new(
435 format!(
436 "diff file is {} bytes, above the {MAX_DIFF_BYTES} byte limit: {}",
437 meta.len(),
438 abs.display()
439 ),
440 2,
441 )
442 .with_code("FALLOW_INVALID_DIFF_FILE")
443 .with_context("analysis.diffFile"));
444 }
445
446 let text = std::fs::read_to_string(&abs).map_err(|err| {
447 ProgrammaticError::new(
448 format!("failed to read diff file {}: {err}", abs.display()),
449 2,
450 )
451 .with_code("FALLOW_INVALID_DIFF_FILE")
452 .with_context("analysis.diffFile")
453 })?;
454
455 Ok(LoadedDiff {
456 index: DiffIndex::from_unified_diff(&text),
457 })
458}
459
460fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
461 if let serde_json::Value::Object(map) = output {
462 map.insert("_meta".to_string(), meta);
463 }
464}
465
466fn apply_programmatic_envelope_options(
467 output: &mut serde_json::Value,
468 resolved: &ResolvedAnalysisOptions,
469) {
470 if resolved.legacy_envelope {
471 crate::output_envelope::remove_root_kind(output);
472 }
473}
474
475fn build_dead_code_json(
476 results: &AnalysisResults,
477 root: &Path,
478 elapsed: std::time::Duration,
479 explain: bool,
480 config_fixable: bool,
481) -> ProgrammaticResult<serde_json::Value> {
482 let mut output =
483 crate::report::build_json_with_config_fixable(results, root, elapsed, config_fixable)
484 .map_err(|err| {
485 ProgrammaticError::new(format!("failed to serialize dead-code report: {err}"), 2)
486 .with_code("FALLOW_SERIALIZE_DEAD_CODE_REPORT")
487 .with_context("dead-code")
488 })?;
489 if explain {
490 insert_meta(&mut output, crate::explain::check_meta());
491 }
492 Ok(output)
495}
496
497fn to_issue_filters(filters: &DeadCodeFilters) -> IssueFilters {
498 IssueFilters {
499 unused_files: filters.unused_files,
500 unused_exports: filters.unused_exports,
501 unused_deps: filters.unused_deps,
502 unused_types: filters.unused_types,
503 private_type_leaks: filters.private_type_leaks,
504 unused_enum_members: filters.unused_enum_members,
505 unused_class_members: filters.unused_class_members,
506 unresolved_imports: filters.unresolved_imports,
507 unlisted_deps: filters.unlisted_deps,
508 duplicate_exports: filters.duplicate_exports,
509 circular_deps: filters.circular_deps,
510 re_export_cycles: filters.re_export_cycles,
511 boundary_violations: filters.boundary_violations,
512 stale_suppressions: filters.stale_suppressions,
513 unused_catalog_entries: filters.unused_catalog_entries,
514 empty_catalog_groups: filters.empty_catalog_groups,
515 unresolved_catalog_references: filters.unresolved_catalog_references,
516 unused_dependency_overrides: filters.unused_dependency_overrides,
517 misconfigured_dependency_overrides: filters.misconfigured_dependency_overrides,
518 }
519}
520
521fn generic_analysis_error(command: &str) -> ProgrammaticError {
522 let code = format!(
523 "FALLOW_{}_FAILED",
524 command.replace('-', "_").to_ascii_uppercase()
525 );
526 ProgrammaticError::new(format!("{command} failed"), 2)
527 .with_code(code)
528 .with_context(format!("fallow {command}"))
529 .with_help(format!(
530 "Re-run `fallow {command} --format json --quiet` in the target project for CLI diagnostics"
531 ))
532}
533
534fn build_check_options<'a>(
535 resolved: &'a ResolvedAnalysisOptions,
536 options: &'a DeadCodeOptions,
537 filters: &'a IssueFilters,
538 trace_opts: &'a TraceOptions,
539) -> CheckOptions<'a> {
540 CheckOptions {
541 root: &resolved.root,
542 config_path: &resolved.config_path,
543 output: OutputFormat::Human,
544 no_cache: resolved.no_cache,
545 threads: resolved.threads,
546 quiet: true,
547 fail_on_issues: false,
548 filters,
549 changed_since: resolved.changed_since.as_deref(),
550 diff_index: resolved.diff_index(),
551 use_shared_diff_index: false,
552 baseline: None,
553 save_baseline: None,
554 sarif_file: None,
555 production: resolved.production_override.unwrap_or(false),
556 production_override: resolved.production_override,
557 workspace: resolved.workspace.as_deref(),
558 changed_workspaces: resolved.changed_workspaces.as_deref(),
559 group_by: None,
560 include_dupes: false,
561 trace_opts,
562 explain: resolved.explain,
563 top: None,
564 file: &options.files,
565 include_entry_exports: options.include_entry_exports,
566 summary: false,
567 regression_opts: crate::regression::RegressionOpts {
568 fail_on_regression: false,
569 tolerance: crate::regression::Tolerance::Absolute(0),
570 regression_baseline_file: None,
571 save_target: crate::regression::SaveRegressionTarget::None,
572 scoped: false,
573 quiet: true,
574 output: fallow_config::OutputFormat::Json,
575 },
576 retain_modules_for_health: false,
577 defer_performance: false,
578 }
579}
580
581fn filter_for_circular_dependencies(results: &AnalysisResults) -> AnalysisResults {
582 let mut filtered = results.clone();
583 filtered.unused_files.clear();
584 filtered.unused_exports.clear();
585 filtered.unused_types.clear();
586 filtered.private_type_leaks.clear();
587 filtered.unused_dependencies.clear();
588 filtered.unused_dev_dependencies.clear();
589 filtered.unused_optional_dependencies.clear();
590 filtered.unused_enum_members.clear();
591 filtered.unused_class_members.clear();
592 filtered.unresolved_imports.clear();
593 filtered.unlisted_dependencies.clear();
594 filtered.duplicate_exports.clear();
595 filtered.type_only_dependencies.clear();
596 filtered.test_only_dependencies.clear();
597 filtered.boundary_violations.clear();
598 filtered.stale_suppressions.clear();
599 filtered
600}
601
602fn filter_for_boundary_violations(results: &AnalysisResults) -> AnalysisResults {
603 let mut filtered = results.clone();
604 filtered.unused_files.clear();
605 filtered.unused_exports.clear();
606 filtered.unused_types.clear();
607 filtered.private_type_leaks.clear();
608 filtered.unused_dependencies.clear();
609 filtered.unused_dev_dependencies.clear();
610 filtered.unused_optional_dependencies.clear();
611 filtered.unused_enum_members.clear();
612 filtered.unused_class_members.clear();
613 filtered.unresolved_imports.clear();
614 filtered.unlisted_dependencies.clear();
615 filtered.duplicate_exports.clear();
616 filtered.type_only_dependencies.clear();
617 filtered.test_only_dependencies.clear();
618 filtered.circular_dependencies.clear();
619 filtered.stale_suppressions.clear();
620 filtered
621}
622
623pub fn detect_dead_code(options: &DeadCodeOptions) -> ProgrammaticResult<serde_json::Value> {
625 let resolved = options.analysis.resolve()?;
626 resolved.install(|| {
627 let filters = to_issue_filters(&options.filters);
628 let trace_opts = TraceOptions {
629 trace_export: None,
630 trace_file: None,
631 trace_dependency: None,
632 performance: false,
633 };
634 let check_options = build_check_options(&resolved, options, &filters, &trace_opts);
635 let result = crate::check::execute_check(&check_options)
636 .map_err(|_| generic_analysis_error("dead-code"))?;
637 let mut output = build_dead_code_json(
638 &result.results,
639 &result.config.root,
640 result.elapsed,
641 resolved.explain,
642 result.config_fixable,
643 )?;
644 apply_programmatic_envelope_options(&mut output, &resolved);
645 Ok(output)
646 })
647}
648
649pub fn detect_circular_dependencies(
652 options: &DeadCodeOptions,
653) -> ProgrammaticResult<serde_json::Value> {
654 let resolved = options.analysis.resolve()?;
655 resolved.install(|| {
656 let filters = to_issue_filters(&options.filters);
657 let trace_opts = TraceOptions {
658 trace_export: None,
659 trace_file: None,
660 trace_dependency: None,
661 performance: false,
662 };
663 let check_options = build_check_options(&resolved, options, &filters, &trace_opts);
664 let result = crate::check::execute_check(&check_options)
665 .map_err(|_| generic_analysis_error("dead-code"))?;
666 let filtered = filter_for_circular_dependencies(&result.results);
667 let mut output = build_dead_code_json(
668 &filtered,
669 &result.config.root,
670 result.elapsed,
671 resolved.explain,
672 result.config_fixable,
673 )?;
674 apply_programmatic_envelope_options(&mut output, &resolved);
675 Ok(output)
676 })
677}
678
679pub fn detect_boundary_violations(
682 options: &DeadCodeOptions,
683) -> ProgrammaticResult<serde_json::Value> {
684 let resolved = options.analysis.resolve()?;
685 resolved.install(|| {
686 let filters = to_issue_filters(&options.filters);
687 let trace_opts = TraceOptions {
688 trace_export: None,
689 trace_file: None,
690 trace_dependency: None,
691 performance: false,
692 };
693 let check_options = build_check_options(&resolved, options, &filters, &trace_opts);
694 let result = crate::check::execute_check(&check_options)
695 .map_err(|_| generic_analysis_error("dead-code"))?;
696 let filtered = filter_for_boundary_violations(&result.results);
697 let mut output = build_dead_code_json(
698 &filtered,
699 &result.config.root,
700 result.elapsed,
701 resolved.explain,
702 result.config_fixable,
703 )?;
704 apply_programmatic_envelope_options(&mut output, &resolved);
705 Ok(output)
706 })
707}
708
709pub fn detect_duplication(options: &DuplicationOptions) -> ProgrammaticResult<serde_json::Value> {
711 let resolved = options.analysis.resolve()?;
712 resolved.install(|| {
713 let dupes_options = DupesOptions {
714 root: &resolved.root,
715 config_path: &resolved.config_path,
716 output: OutputFormat::Human,
717 no_cache: resolved.no_cache,
718 threads: resolved.threads,
719 quiet: true,
720 mode: Some(options.mode.to_cli()),
721 min_tokens: Some(options.min_tokens),
722 min_lines: Some(options.min_lines),
723 min_occurrences: Some(options.min_occurrences),
724 threshold: Some(options.threshold),
725 skip_local: options.skip_local,
726 cross_language: options.cross_language,
727 ignore_imports: options.ignore_imports,
728 top: options.top,
729 baseline_path: None,
730 save_baseline_path: None,
731 production: resolved.production_override.unwrap_or(false),
732 production_override: resolved.production_override,
733 trace: None,
734 changed_since: resolved.changed_since.as_deref(),
735 diff_index: resolved.diff_index(),
736 use_shared_diff_index: false,
737 changed_files: None,
738 workspace: resolved.workspace.as_deref(),
739 changed_workspaces: resolved.changed_workspaces.as_deref(),
740 explain: resolved.explain,
741 explain_skipped: false,
742 summary: false,
743 group_by: None,
744 performance: false,
745 };
746 let result = crate::dupes::execute_dupes(&dupes_options)
747 .map_err(|_| generic_analysis_error("dupes"))?;
748 let mut output = build_duplication_json(
749 &result.report,
750 &result.config.root,
751 result.elapsed,
752 resolved.explain,
753 )
754 .map_err(|err| {
755 ProgrammaticError::new(format!("failed to serialize duplication report: {err}"), 2)
756 .with_code("FALLOW_SERIALIZE_DUPLICATION_REPORT")
757 .with_context("dupes")
758 })?;
759 apply_programmatic_envelope_options(&mut output, &resolved);
760 Ok(output)
761 })
762}
763
764fn build_complexity_options<'a>(
765 resolved: &'a ResolvedAnalysisOptions,
766 options: &'a ComplexityOptions,
767) -> HealthOptions<'a> {
768 let ownership = options.ownership || options.ownership_emails.is_some();
769 let hotspots = options.hotspots || ownership;
770 let targets = options.targets || options.effort.is_some();
771 let any_section = options.complexity
772 || options.file_scores
773 || options.coverage_gaps
774 || hotspots
775 || targets
776 || options.score;
777 let eff_score = if any_section { options.score } else { true };
778 let force_full = eff_score;
779 let score_only_output = options.score
780 && !options.complexity
781 && !options.file_scores
782 && !options.coverage_gaps
783 && !hotspots
784 && !targets;
785 let eff_file_scores = if any_section {
786 options.file_scores
787 } else {
788 true
789 } || force_full;
790 let eff_hotspots = if any_section { hotspots } else { true };
791 let eff_complexity = if any_section {
792 options.complexity
793 } else {
794 true
795 };
796 let eff_targets = if any_section { targets } else { true };
797 let eff_coverage_gaps = if any_section {
798 options.coverage_gaps
799 } else {
800 false
801 };
802
803 HealthOptions {
804 root: &resolved.root,
805 config_path: &resolved.config_path,
806 output: OutputFormat::Human,
807 no_cache: resolved.no_cache,
808 threads: resolved.threads,
809 quiet: true,
810 max_cyclomatic: options.max_cyclomatic,
811 max_cognitive: options.max_cognitive,
812 max_crap: options.max_crap,
813 top: options.top,
814 sort: options.sort.to_cli(),
815 production: resolved.production_override.unwrap_or(false),
816 production_override: resolved.production_override,
817 changed_since: resolved.changed_since.as_deref(),
818 diff_index: resolved.diff_index(),
819 use_shared_diff_index: false,
820 workspace: resolved.workspace.as_deref(),
821 changed_workspaces: resolved.changed_workspaces.as_deref(),
822 baseline: None,
823 save_baseline: None,
824 complexity: eff_complexity,
825 complexity_breakdown: false,
826 file_scores: eff_file_scores,
827 coverage_gaps: eff_coverage_gaps,
828 config_activates_coverage_gaps: !any_section,
829 hotspots: eff_hotspots,
830 ownership: ownership && eff_hotspots,
831 ownership_emails: options.ownership_emails.map(OwnershipEmailMode::to_config),
832 targets: eff_targets,
833 force_full,
834 score_only_output,
835 enforce_coverage_gap_gate: true,
836 effort: options.effort.map(TargetEffort::to_cli),
837 score: eff_score,
838 min_score: None,
839 since: options.since.as_deref(),
840 min_commits: options.min_commits,
841 explain: resolved.explain,
842 summary: false,
843 save_snapshot: None,
844 trend: false,
845 group_by: None,
846 coverage: options.coverage.as_deref(),
847 coverage_root: options.coverage_root.as_deref(),
848 performance: false,
849 min_severity: None,
850 report_only: false,
851 runtime_coverage: None,
852 churn_file: None,
855 }
856}
857
858pub fn compute_complexity(options: &ComplexityOptions) -> ProgrammaticResult<serde_json::Value> {
860 let resolved = options.analysis.resolve()?;
861 if let Some(path) = &options.coverage
862 && !path.exists()
863 {
864 return Err(ProgrammaticError::new(
865 format!("coverage path does not exist: {}", path.display()),
866 2,
867 )
868 .with_code("FALLOW_INVALID_COVERAGE_PATH")
869 .with_context("health.coverage"));
870 }
871 if let Err(message) =
872 crate::health::scoring::validate_coverage_root_absolute(options.coverage_root.as_deref())
873 {
874 return Err(ProgrammaticError::new(message, 2)
875 .with_code("FALLOW_INVALID_COVERAGE_ROOT")
876 .with_context("health.coverage_root"));
877 }
878
879 resolved.install(|| {
880 let health_options = build_complexity_options(&resolved, options);
881 let result = crate::health::execute_health(&health_options)
882 .map_err(|_| generic_analysis_error("health"))?;
883 let mut output = build_health_json(
884 &result.report,
885 &result.config.root,
886 result.elapsed,
887 resolved.explain,
888 )
889 .map_err(|err| {
890 ProgrammaticError::new(format!("failed to serialize health report: {err}"), 2)
891 .with_code("FALLOW_SERIALIZE_HEALTH_REPORT")
892 .with_context("health")
893 })?;
894 apply_programmatic_envelope_options(&mut output, &resolved);
895 Ok(output)
896 })
897}
898
899pub fn compute_health(options: &ComplexityOptions) -> ProgrammaticResult<serde_json::Value> {
901 compute_complexity(options)
902}
903
904#[cfg(test)]
905mod tests {
906 use super::*;
907 use crate::report::test_helpers::sample_results;
908 use std::process::Command;
909
910 const SHARED_DIFF_CHILD_ENV: &str = "FALLOW_PROGRAMMATIC_SHARED_DIFF_CHILD";
911 const SHARED_DIFF_CHILD_TEST: &str =
912 "programmatic::tests::programmatic_without_diff_file_ignores_shared_diff_cache";
913
914 #[test]
915 fn circular_dependency_filter_clears_other_issue_types() {
916 let root = PathBuf::from("/project");
917 let results = sample_results(&root);
918 let filtered = filter_for_circular_dependencies(&results);
919 let json = build_dead_code_json(&filtered, &root, std::time::Duration::ZERO, false, false)
920 .expect("should serialize");
921
922 assert_eq!(json["kind"], "dead-code");
923 assert_eq!(json["circular_dependencies"].as_array().unwrap().len(), 1);
924 assert_eq!(json["boundary_violations"].as_array().unwrap().len(), 0);
925 assert_eq!(json["unused_files"].as_array().unwrap().len(), 0);
926 assert_eq!(json["summary"]["total_issues"], serde_json::Value::from(1));
927 }
928
929 #[test]
930 fn boundary_violation_filter_clears_other_issue_types() {
931 let root = PathBuf::from("/project");
932 let results = sample_results(&root);
933 let filtered = filter_for_boundary_violations(&results);
934 let json = build_dead_code_json(&filtered, &root, std::time::Duration::ZERO, false, false)
935 .expect("should serialize");
936
937 assert_eq!(json["kind"], "dead-code");
938 assert_eq!(json["boundary_violations"].as_array().unwrap().len(), 1);
939 assert_eq!(json["circular_dependencies"].as_array().unwrap().len(), 0);
940 assert_eq!(json["unused_exports"].as_array().unwrap().len(), 0);
941 assert_eq!(json["summary"]["total_issues"], serde_json::Value::from(1));
942 }
943
944 #[test]
945 fn dead_code_without_production_override_uses_per_analysis_config() {
946 let dir = tempfile::tempdir().expect("temp dir");
947 let root = dir.path();
948 std::fs::create_dir_all(root.join("src")).unwrap();
949 std::fs::write(
950 root.join("package.json"),
951 r#"{"name":"programmatic-production","main":"src/index.ts"}"#,
952 )
953 .unwrap();
954 std::fs::write(root.join("src/index.ts"), "export const ok = 1;\n").unwrap();
955 std::fs::write(root.join("src/utils.test.ts"), "export const dead = 1;\n").unwrap();
956 std::fs::write(
957 root.join(".fallowrc.json"),
958 r#"{"production":{"deadCode":true,"health":false,"dupes":false}}"#,
959 )
960 .unwrap();
961
962 let options = DeadCodeOptions {
963 analysis: AnalysisOptions {
964 root: Some(root.to_path_buf()),
965 ..AnalysisOptions::default()
966 },
967 ..DeadCodeOptions::default()
968 };
969 let json = detect_dead_code(&options).expect("analysis should succeed");
970 let paths = unused_file_paths(&json);
971
972 assert!(
973 !paths.iter().any(|path| path.ends_with("utils.test.ts")),
974 "omitted production option should defer to production.deadCode=true config: {paths:?}"
975 );
976 }
977
978 #[test]
979 fn dead_code_legacy_envelope_removes_root_kind() {
980 let dir = tempfile::tempdir().expect("temp dir");
981 let root = dir.path();
982 std::fs::create_dir_all(root.join("src")).unwrap();
983 std::fs::write(
984 root.join("package.json"),
985 r#"{"name":"programmatic-legacy","main":"src/index.ts"}"#,
986 )
987 .unwrap();
988 std::fs::write(root.join("src/index.ts"), "export const ok = 1;\n").unwrap();
989
990 let options = DeadCodeOptions {
991 analysis: AnalysisOptions {
992 root: Some(root.to_path_buf()),
993 legacy_envelope: true,
994 ..AnalysisOptions::default()
995 },
996 ..DeadCodeOptions::default()
997 };
998 let json = detect_dead_code(&options).expect("analysis should succeed");
999
1000 assert!(json.get("kind").is_none());
1001 assert_eq!(json["schema_version"], crate::report::SCHEMA_VERSION);
1002 }
1003
1004 #[test]
1005 fn dead_code_explicit_production_false_overrides_config() {
1006 let dir = tempfile::tempdir().expect("temp dir");
1007 let root = dir.path();
1008 std::fs::create_dir_all(root.join("src")).unwrap();
1009 std::fs::write(
1010 root.join("package.json"),
1011 r#"{"name":"programmatic-production","main":"src/index.ts"}"#,
1012 )
1013 .unwrap();
1014 std::fs::write(root.join("src/index.ts"), "export const ok = 1;\n").unwrap();
1015 std::fs::write(root.join("src/utils.test.ts"), "export const dead = 1;\n").unwrap();
1016 std::fs::write(
1017 root.join(".fallowrc.json"),
1018 r#"{"production":{"deadCode":true,"health":false,"dupes":false}}"#,
1019 )
1020 .unwrap();
1021
1022 let options = DeadCodeOptions {
1023 analysis: AnalysisOptions {
1024 root: Some(root.to_path_buf()),
1025 production_override: Some(false),
1026 ..AnalysisOptions::default()
1027 },
1028 ..DeadCodeOptions::default()
1029 };
1030 let json = detect_dead_code(&options).expect("analysis should succeed");
1031 let paths = unused_file_paths(&json);
1032
1033 assert!(
1034 paths.iter().any(|path| path.ends_with("utils.test.ts")),
1035 "explicit production=false should include test files despite config: {paths:?}"
1036 );
1037 }
1038
1039 #[test]
1040 fn analysis_resolve_uses_per_call_thread_pool() {
1041 let dir = tempfile::tempdir().expect("temp dir");
1042 let root = dir.path();
1043
1044 let one = AnalysisOptions {
1045 root: Some(root.to_path_buf()),
1046 threads: Some(1),
1047 ..AnalysisOptions::default()
1048 }
1049 .resolve()
1050 .expect("one-thread options should resolve");
1051 let two = AnalysisOptions {
1052 root: Some(root.to_path_buf()),
1053 threads: Some(2),
1054 ..AnalysisOptions::default()
1055 }
1056 .resolve()
1057 .expect("two-thread options should resolve");
1058
1059 assert_eq!(one.install(rayon::current_num_threads), 1);
1060 assert_eq!(two.install(rayon::current_num_threads), 2);
1061 }
1062
1063 #[test]
1064 fn explicit_diff_file_scopes_dead_code_per_call() {
1065 let dir = tempfile::tempdir().expect("temp dir");
1066 let root = dir.path();
1067 std::fs::create_dir_all(root.join("src")).unwrap();
1068 std::fs::write(
1069 root.join("package.json"),
1070 r#"{"name":"programmatic-diff","main":"src/index.ts"}"#,
1071 )
1072 .unwrap();
1073 std::fs::write(
1074 root.join("src/index.ts"),
1075 "import { used } from './used';\nimport './a';\nimport './b';\nconsole.log(used);\n",
1076 )
1077 .unwrap();
1078 std::fs::write(root.join("src/used.ts"), "export const used = 1;\n").unwrap();
1079 std::fs::write(root.join("src/a.ts"), "export const deadA = 1;\n").unwrap();
1080 std::fs::write(root.join("src/b.ts"), "export const deadB = 1;\n").unwrap();
1081 std::fs::write(
1082 root.join("a.diff"),
1083 diff_for("src/a.ts", "export const deadA = 1;\n"),
1084 )
1085 .unwrap();
1086 std::fs::write(
1087 root.join("b.diff"),
1088 diff_for("src/b.ts", "export const deadB = 1;\n"),
1089 )
1090 .unwrap();
1091
1092 let filters = DeadCodeFilters {
1093 unused_exports: true,
1094 ..DeadCodeFilters::default()
1095 };
1096
1097 let a_json = detect_dead_code(&DeadCodeOptions {
1098 analysis: AnalysisOptions {
1099 root: Some(root.to_path_buf()),
1100 diff_file: Some(PathBuf::from("a.diff")),
1101 ..AnalysisOptions::default()
1102 },
1103 filters: filters.clone(),
1104 ..DeadCodeOptions::default()
1105 })
1106 .expect("a-scoped analysis should succeed");
1107 let b_json = detect_dead_code(&DeadCodeOptions {
1108 analysis: AnalysisOptions {
1109 root: Some(root.to_path_buf()),
1110 diff_file: Some(PathBuf::from("b.diff")),
1111 ..AnalysisOptions::default()
1112 },
1113 filters,
1114 ..DeadCodeOptions::default()
1115 })
1116 .expect("b-scoped analysis should succeed");
1117
1118 assert_eq!(unused_export_names(&a_json), vec!["deadA"]);
1119 assert_eq!(unused_export_names(&b_json), vec!["deadB"]);
1120 }
1121
1122 #[test]
1123 fn programmatic_without_diff_file_ignores_shared_diff_cache() {
1124 if std::env::var_os(SHARED_DIFF_CHILD_ENV).is_some() {
1125 run_programmatic_shared_diff_child();
1126 return;
1127 }
1128
1129 let current_exe = std::env::current_exe().expect("current test binary should be known");
1130 let output = Command::new(current_exe)
1131 .arg("--exact")
1132 .arg(SHARED_DIFF_CHILD_TEST)
1133 .arg("--nocapture")
1134 .env(SHARED_DIFF_CHILD_ENV, "1")
1135 .output()
1136 .expect("shared diff child should start");
1137
1138 assert!(
1139 output.status.success(),
1140 "shared diff child failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
1141 output.status.code(),
1142 String::from_utf8_lossy(&output.stdout),
1143 String::from_utf8_lossy(&output.stderr)
1144 );
1145 }
1146
1147 fn run_programmatic_shared_diff_child() {
1148 let dir = tempfile::tempdir().expect("temp dir");
1149 let root = dir.path();
1150 std::fs::create_dir_all(root.join("src")).unwrap();
1151 std::fs::write(
1152 root.join("package.json"),
1153 r#"{"name":"programmatic-shared-diff","main":"src/index.ts"}"#,
1154 )
1155 .unwrap();
1156 std::fs::write(
1157 root.join("src/index.ts"),
1158 "import { used } from './used';\nimport './a';\nimport './b';\nconsole.log(used);\n",
1159 )
1160 .unwrap();
1161 std::fs::write(root.join("src/used.ts"), "export const used = 1;\n").unwrap();
1162 std::fs::write(root.join("src/a.ts"), "export const deadA = 1;\n").unwrap();
1163 std::fs::write(root.join("src/b.ts"), "export const deadB = 1;\n").unwrap();
1164 std::fs::write(
1165 root.join("a.diff"),
1166 diff_for("src/a.ts", "export const deadA = 1;\n"),
1167 )
1168 .unwrap();
1169
1170 let source = crate::report::ci::diff_filter::DiffSource::Flag(root.join("a.diff"));
1171 let loaded = crate::report::ci::diff_filter::init_shared_diff(Some(&source), true);
1172 assert!(loaded.is_some(), "shared diff should load in child process");
1173
1174 let json = detect_dead_code(&DeadCodeOptions {
1175 analysis: AnalysisOptions {
1176 root: Some(root.to_path_buf()),
1177 ..AnalysisOptions::default()
1178 },
1179 filters: DeadCodeFilters {
1180 unused_exports: true,
1181 ..DeadCodeFilters::default()
1182 },
1183 ..DeadCodeOptions::default()
1184 })
1185 .expect("analysis without explicit diff should succeed");
1186
1187 assert_eq!(unused_export_names(&json), vec!["deadA", "deadB"]);
1188 }
1189
1190 #[test]
1191 fn explicit_diff_file_rejects_stdin_sentinel() {
1192 let dir = tempfile::tempdir().expect("temp dir");
1193 let Err(error) = AnalysisOptions {
1194 root: Some(dir.path().to_path_buf()),
1195 diff_file: Some(PathBuf::from("-")),
1196 ..AnalysisOptions::default()
1197 }
1198 .resolve() else {
1199 panic!("stdin sentinel is not part of the programmatic API");
1200 };
1201
1202 assert_eq!(error.code.as_deref(), Some("FALLOW_INVALID_DIFF_FILE"));
1203 assert_eq!(error.context.as_deref(), Some("analysis.diffFile"));
1204 }
1205
1206 fn tiny_project() -> tempfile::TempDir {
1208 let dir = tempfile::tempdir().expect("temp dir");
1209 let root = dir.path();
1210 std::fs::create_dir_all(root.join("src")).unwrap();
1211 std::fs::write(
1212 root.join("package.json"),
1213 r#"{"name":"prog-e2e","main":"src/index.ts"}"#,
1214 )
1215 .unwrap();
1216 std::fs::write(
1217 root.join("src/index.ts"),
1218 "export const ok = 1;\nconsole.log(ok);\n",
1219 )
1220 .unwrap();
1221 dir
1222 }
1223
1224 fn analysis_at(root: &Path) -> AnalysisOptions {
1225 AnalysisOptions {
1226 root: Some(root.to_path_buf()),
1227 ..AnalysisOptions::default()
1228 }
1229 }
1230
1231 #[test]
1232 fn resolve_rejects_zero_threads() {
1233 let err = AnalysisOptions {
1234 threads: Some(0),
1235 ..AnalysisOptions::default()
1236 }
1237 .resolve()
1238 .err()
1239 .expect("zero threads must be rejected");
1240 assert_eq!(err.exit_code, 2);
1241 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_THREADS"));
1242 assert_eq!(err.context.as_deref(), Some("analysis.threads"));
1243 }
1244
1245 #[test]
1246 fn resolve_rejects_mutually_exclusive_workspace_flags() {
1247 let err = AnalysisOptions {
1248 workspace: Some(vec!["packages/*".to_owned()]),
1249 changed_workspaces: Some("HEAD~1".to_owned()),
1250 ..AnalysisOptions::default()
1251 }
1252 .resolve()
1253 .err()
1254 .expect("workspace + changed_workspaces must be rejected");
1255 assert_eq!(
1256 err.code.as_deref(),
1257 Some("FALLOW_MUTUALLY_EXCLUSIVE_OPTIONS")
1258 );
1259 assert_eq!(err.context.as_deref(), Some("analysis.workspace"));
1260 }
1261
1262 #[test]
1263 fn resolve_rejects_nonexistent_root() {
1264 let err = AnalysisOptions {
1265 root: Some(PathBuf::from("/definitely/not/a/real/path/xyzzy")),
1266 ..AnalysisOptions::default()
1267 }
1268 .resolve()
1269 .err()
1270 .expect("nonexistent root must be rejected");
1271 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_ROOT"));
1272 assert_eq!(err.context.as_deref(), Some("analysis.root"));
1273 }
1274
1275 #[test]
1276 fn resolve_rejects_root_that_is_a_file() {
1277 let dir = tempfile::tempdir().expect("temp dir");
1278 let file = dir.path().join("not-a-dir.txt");
1279 std::fs::write(&file, "x").unwrap();
1280 let err = AnalysisOptions {
1281 root: Some(file),
1282 ..AnalysisOptions::default()
1283 }
1284 .resolve()
1285 .err()
1286 .expect("a file root must be rejected");
1287 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_ROOT"));
1288 }
1289
1290 #[test]
1291 fn resolve_rejects_nonexistent_config_path() {
1292 let dir = tempfile::tempdir().expect("temp dir");
1293 let err = AnalysisOptions {
1294 root: Some(dir.path().to_path_buf()),
1295 config_path: Some(dir.path().join("missing.fallowrc.json")),
1296 ..AnalysisOptions::default()
1297 }
1298 .resolve()
1299 .err()
1300 .expect("nonexistent config must be rejected");
1301 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_CONFIG_PATH"));
1302 assert_eq!(err.context.as_deref(), Some("analysis.configPath"));
1303 }
1304
1305 #[test]
1306 fn resolve_rejects_missing_diff_file() {
1307 let dir = tempfile::tempdir().expect("temp dir");
1308 let err = AnalysisOptions {
1309 root: Some(dir.path().to_path_buf()),
1310 diff_file: Some(PathBuf::from("nope.diff")),
1311 ..AnalysisOptions::default()
1312 }
1313 .resolve()
1314 .err()
1315 .expect("missing diff file must be rejected");
1316 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_DIFF_FILE"));
1317 assert_eq!(err.context.as_deref(), Some("analysis.diffFile"));
1318 }
1319
1320 #[test]
1321 fn resolve_rejects_diff_path_that_is_a_directory() {
1322 let dir = tempfile::tempdir().expect("temp dir");
1323 std::fs::create_dir_all(dir.path().join("a-dir")).unwrap();
1324 let err = AnalysisOptions {
1325 root: Some(dir.path().to_path_buf()),
1326 diff_file: Some(PathBuf::from("a-dir")),
1327 ..AnalysisOptions::default()
1328 }
1329 .resolve()
1330 .err()
1331 .expect("a directory diff path must be rejected");
1332 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_DIFF_FILE"));
1333 }
1334
1335 #[test]
1336 fn detect_circular_dependencies_returns_dead_code_envelope() {
1337 let project = tiny_project();
1338 let json = detect_circular_dependencies(&DeadCodeOptions {
1339 analysis: analysis_at(project.path()),
1340 ..DeadCodeOptions::default()
1341 })
1342 .expect("circular-dependency analysis should succeed");
1343 assert_eq!(json["kind"], "dead-code");
1344 assert!(json["circular_dependencies"].is_array());
1345 }
1346
1347 #[test]
1348 fn detect_boundary_violations_returns_dead_code_envelope() {
1349 let project = tiny_project();
1350 let json = detect_boundary_violations(&DeadCodeOptions {
1351 analysis: analysis_at(project.path()),
1352 ..DeadCodeOptions::default()
1353 })
1354 .expect("boundary-violation analysis should succeed");
1355 assert_eq!(json["kind"], "dead-code");
1356 assert!(json["boundary_violations"].is_array());
1357 }
1358
1359 #[test]
1360 fn detect_duplication_returns_dupes_envelope() {
1361 let project = tiny_project();
1362 let json = detect_duplication(&DuplicationOptions {
1363 analysis: analysis_at(project.path()),
1364 ..DuplicationOptions::default()
1365 })
1366 .expect("duplication analysis should succeed");
1367 assert_eq!(json["kind"], "dupes");
1368 assert!(json["clone_groups"].is_array());
1370 assert!(json["stats"].is_object());
1371 }
1372
1373 #[test]
1374 fn compute_health_returns_health_envelope() {
1375 let project = tiny_project();
1376 let options = ComplexityOptions {
1377 analysis: analysis_at(project.path()),
1378 ..ComplexityOptions::default()
1379 };
1380 let json = compute_health(&options).expect("health analysis should succeed");
1382 assert_eq!(json["kind"], "health");
1383 assert!(json["summary"].is_object());
1385 assert!(json["findings"].is_array());
1386 }
1387
1388 #[test]
1389 fn compute_complexity_rejects_missing_coverage_path() {
1390 let project = tiny_project();
1391 let err = compute_complexity(&ComplexityOptions {
1392 analysis: analysis_at(project.path()),
1393 coverage: Some(project.path().join("missing-coverage.json")),
1394 ..ComplexityOptions::default()
1395 })
1396 .expect_err("a missing coverage path must be rejected");
1397 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1398 assert_eq!(err.context.as_deref(), Some("health.coverage"));
1399 }
1400
1401 #[test]
1402 fn compute_complexity_rejects_relative_coverage_root() {
1403 let project = tiny_project();
1404 let err = compute_complexity(&ComplexityOptions {
1405 analysis: analysis_at(project.path()),
1406 coverage_root: Some(PathBuf::from("relative/prefix")),
1407 ..ComplexityOptions::default()
1408 })
1409 .expect_err("a relative coverage_root must be rejected");
1410 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_ROOT"));
1411 assert_eq!(err.context.as_deref(), Some("health.coverage_root"));
1412 }
1413
1414 #[test]
1415 fn programmatic_error_builders_compose_and_display() {
1416 let err = ProgrammaticError::new("boom", 7)
1417 .with_code("FALLOW_X")
1418 .with_help("try again")
1419 .with_context("ctx.path");
1420 assert_eq!(err.message, "boom");
1421 assert_eq!(err.exit_code, 7);
1422 assert_eq!(err.code.as_deref(), Some("FALLOW_X"));
1423 assert_eq!(err.help.as_deref(), Some("try again"));
1424 assert_eq!(err.context.as_deref(), Some("ctx.path"));
1425 assert_eq!(format!("{err}"), "boom");
1427 }
1428
1429 #[test]
1430 fn generic_analysis_error_uppercases_command_into_code() {
1431 let err = generic_analysis_error("dead-code");
1432 assert_eq!(err.code.as_deref(), Some("FALLOW_DEAD_CODE_FAILED"));
1433 assert_eq!(err.exit_code, 2);
1434 assert_eq!(err.context.as_deref(), Some("fallow dead-code"));
1435 assert!(err.help.is_some(), "diagnostics hint should be attached");
1436 }
1437
1438 fn unused_file_paths(json: &serde_json::Value) -> Vec<String> {
1439 json["unused_files"]
1440 .as_array()
1441 .unwrap()
1442 .iter()
1443 .filter_map(|file| file["path"].as_str())
1444 .map(str::to_owned)
1445 .collect()
1446 }
1447
1448 fn unused_export_names(json: &serde_json::Value) -> Vec<String> {
1449 let mut names: Vec<String> = json["unused_exports"]
1450 .as_array()
1451 .unwrap()
1452 .iter()
1453 .filter_map(|export| export["export_name"].as_str())
1454 .map(str::to_owned)
1455 .collect();
1456 names.sort();
1457 names
1458 }
1459
1460 fn diff_for(path: &str, line: &str) -> String {
1461 format!("diff --git a/{path} b/{path}\n--- /dev/null\n+++ b/{path}\n@@ -0,0 +1 @@\n+{line}")
1462 }
1463}