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 file_scores: eff_file_scores,
826 coverage_gaps: eff_coverage_gaps,
827 config_activates_coverage_gaps: !any_section,
828 hotspots: eff_hotspots,
829 ownership: ownership && eff_hotspots,
830 ownership_emails: options.ownership_emails.map(OwnershipEmailMode::to_config),
831 targets: eff_targets,
832 force_full,
833 score_only_output,
834 enforce_coverage_gap_gate: true,
835 effort: options.effort.map(TargetEffort::to_cli),
836 score: eff_score,
837 min_score: None,
838 since: options.since.as_deref(),
839 min_commits: options.min_commits,
840 explain: resolved.explain,
841 summary: false,
842 save_snapshot: None,
843 trend: false,
844 group_by: None,
845 coverage: options.coverage.as_deref(),
846 coverage_root: options.coverage_root.as_deref(),
847 performance: false,
848 min_severity: None,
849 report_only: false,
850 runtime_coverage: None,
851 }
852}
853
854pub fn compute_complexity(options: &ComplexityOptions) -> ProgrammaticResult<serde_json::Value> {
856 let resolved = options.analysis.resolve()?;
857 if let Some(path) = &options.coverage
858 && !path.exists()
859 {
860 return Err(ProgrammaticError::new(
861 format!("coverage path does not exist: {}", path.display()),
862 2,
863 )
864 .with_code("FALLOW_INVALID_COVERAGE_PATH")
865 .with_context("health.coverage"));
866 }
867 if let Err(message) =
868 crate::health::scoring::validate_coverage_root_absolute(options.coverage_root.as_deref())
869 {
870 return Err(ProgrammaticError::new(message, 2)
871 .with_code("FALLOW_INVALID_COVERAGE_ROOT")
872 .with_context("health.coverage_root"));
873 }
874
875 resolved.install(|| {
876 let health_options = build_complexity_options(&resolved, options);
877 let result = crate::health::execute_health(&health_options)
878 .map_err(|_| generic_analysis_error("health"))?;
879 let mut output = build_health_json(
880 &result.report,
881 &result.config.root,
882 result.elapsed,
883 resolved.explain,
884 )
885 .map_err(|err| {
886 ProgrammaticError::new(format!("failed to serialize health report: {err}"), 2)
887 .with_code("FALLOW_SERIALIZE_HEALTH_REPORT")
888 .with_context("health")
889 })?;
890 apply_programmatic_envelope_options(&mut output, &resolved);
891 Ok(output)
892 })
893}
894
895pub fn compute_health(options: &ComplexityOptions) -> ProgrammaticResult<serde_json::Value> {
897 compute_complexity(options)
898}
899
900#[cfg(test)]
901mod tests {
902 use super::*;
903 use crate::report::test_helpers::sample_results;
904 use std::process::Command;
905
906 const SHARED_DIFF_CHILD_ENV: &str = "FALLOW_PROGRAMMATIC_SHARED_DIFF_CHILD";
907 const SHARED_DIFF_CHILD_TEST: &str =
908 "programmatic::tests::programmatic_without_diff_file_ignores_shared_diff_cache";
909
910 #[test]
911 fn circular_dependency_filter_clears_other_issue_types() {
912 let root = PathBuf::from("/project");
913 let results = sample_results(&root);
914 let filtered = filter_for_circular_dependencies(&results);
915 let json = build_dead_code_json(&filtered, &root, std::time::Duration::ZERO, false, false)
916 .expect("should serialize");
917
918 assert_eq!(json["kind"], "dead-code");
919 assert_eq!(json["circular_dependencies"].as_array().unwrap().len(), 1);
920 assert_eq!(json["boundary_violations"].as_array().unwrap().len(), 0);
921 assert_eq!(json["unused_files"].as_array().unwrap().len(), 0);
922 assert_eq!(json["summary"]["total_issues"], serde_json::Value::from(1));
923 }
924
925 #[test]
926 fn boundary_violation_filter_clears_other_issue_types() {
927 let root = PathBuf::from("/project");
928 let results = sample_results(&root);
929 let filtered = filter_for_boundary_violations(&results);
930 let json = build_dead_code_json(&filtered, &root, std::time::Duration::ZERO, false, false)
931 .expect("should serialize");
932
933 assert_eq!(json["kind"], "dead-code");
934 assert_eq!(json["boundary_violations"].as_array().unwrap().len(), 1);
935 assert_eq!(json["circular_dependencies"].as_array().unwrap().len(), 0);
936 assert_eq!(json["unused_exports"].as_array().unwrap().len(), 0);
937 assert_eq!(json["summary"]["total_issues"], serde_json::Value::from(1));
938 }
939
940 #[test]
941 fn dead_code_without_production_override_uses_per_analysis_config() {
942 let dir = tempfile::tempdir().expect("temp dir");
943 let root = dir.path();
944 std::fs::create_dir_all(root.join("src")).unwrap();
945 std::fs::write(
946 root.join("package.json"),
947 r#"{"name":"programmatic-production","main":"src/index.ts"}"#,
948 )
949 .unwrap();
950 std::fs::write(root.join("src/index.ts"), "export const ok = 1;\n").unwrap();
951 std::fs::write(root.join("src/utils.test.ts"), "export const dead = 1;\n").unwrap();
952 std::fs::write(
953 root.join(".fallowrc.json"),
954 r#"{"production":{"deadCode":true,"health":false,"dupes":false}}"#,
955 )
956 .unwrap();
957
958 let options = DeadCodeOptions {
959 analysis: AnalysisOptions {
960 root: Some(root.to_path_buf()),
961 ..AnalysisOptions::default()
962 },
963 ..DeadCodeOptions::default()
964 };
965 let json = detect_dead_code(&options).expect("analysis should succeed");
966 let paths = unused_file_paths(&json);
967
968 assert!(
969 !paths.iter().any(|path| path.ends_with("utils.test.ts")),
970 "omitted production option should defer to production.deadCode=true config: {paths:?}"
971 );
972 }
973
974 #[test]
975 fn dead_code_legacy_envelope_removes_root_kind() {
976 let dir = tempfile::tempdir().expect("temp dir");
977 let root = dir.path();
978 std::fs::create_dir_all(root.join("src")).unwrap();
979 std::fs::write(
980 root.join("package.json"),
981 r#"{"name":"programmatic-legacy","main":"src/index.ts"}"#,
982 )
983 .unwrap();
984 std::fs::write(root.join("src/index.ts"), "export const ok = 1;\n").unwrap();
985
986 let options = DeadCodeOptions {
987 analysis: AnalysisOptions {
988 root: Some(root.to_path_buf()),
989 legacy_envelope: true,
990 ..AnalysisOptions::default()
991 },
992 ..DeadCodeOptions::default()
993 };
994 let json = detect_dead_code(&options).expect("analysis should succeed");
995
996 assert!(json.get("kind").is_none());
997 assert_eq!(json["schema_version"], crate::report::SCHEMA_VERSION);
998 }
999
1000 #[test]
1001 fn dead_code_explicit_production_false_overrides_config() {
1002 let dir = tempfile::tempdir().expect("temp dir");
1003 let root = dir.path();
1004 std::fs::create_dir_all(root.join("src")).unwrap();
1005 std::fs::write(
1006 root.join("package.json"),
1007 r#"{"name":"programmatic-production","main":"src/index.ts"}"#,
1008 )
1009 .unwrap();
1010 std::fs::write(root.join("src/index.ts"), "export const ok = 1;\n").unwrap();
1011 std::fs::write(root.join("src/utils.test.ts"), "export const dead = 1;\n").unwrap();
1012 std::fs::write(
1013 root.join(".fallowrc.json"),
1014 r#"{"production":{"deadCode":true,"health":false,"dupes":false}}"#,
1015 )
1016 .unwrap();
1017
1018 let options = DeadCodeOptions {
1019 analysis: AnalysisOptions {
1020 root: Some(root.to_path_buf()),
1021 production_override: Some(false),
1022 ..AnalysisOptions::default()
1023 },
1024 ..DeadCodeOptions::default()
1025 };
1026 let json = detect_dead_code(&options).expect("analysis should succeed");
1027 let paths = unused_file_paths(&json);
1028
1029 assert!(
1030 paths.iter().any(|path| path.ends_with("utils.test.ts")),
1031 "explicit production=false should include test files despite config: {paths:?}"
1032 );
1033 }
1034
1035 #[test]
1036 fn analysis_resolve_uses_per_call_thread_pool() {
1037 let dir = tempfile::tempdir().expect("temp dir");
1038 let root = dir.path();
1039
1040 let one = AnalysisOptions {
1041 root: Some(root.to_path_buf()),
1042 threads: Some(1),
1043 ..AnalysisOptions::default()
1044 }
1045 .resolve()
1046 .expect("one-thread options should resolve");
1047 let two = AnalysisOptions {
1048 root: Some(root.to_path_buf()),
1049 threads: Some(2),
1050 ..AnalysisOptions::default()
1051 }
1052 .resolve()
1053 .expect("two-thread options should resolve");
1054
1055 assert_eq!(one.install(rayon::current_num_threads), 1);
1056 assert_eq!(two.install(rayon::current_num_threads), 2);
1057 }
1058
1059 #[test]
1060 fn explicit_diff_file_scopes_dead_code_per_call() {
1061 let dir = tempfile::tempdir().expect("temp dir");
1062 let root = dir.path();
1063 std::fs::create_dir_all(root.join("src")).unwrap();
1064 std::fs::write(
1065 root.join("package.json"),
1066 r#"{"name":"programmatic-diff","main":"src/index.ts"}"#,
1067 )
1068 .unwrap();
1069 std::fs::write(
1070 root.join("src/index.ts"),
1071 "import { used } from './used';\nimport './a';\nimport './b';\nconsole.log(used);\n",
1072 )
1073 .unwrap();
1074 std::fs::write(root.join("src/used.ts"), "export const used = 1;\n").unwrap();
1075 std::fs::write(root.join("src/a.ts"), "export const deadA = 1;\n").unwrap();
1076 std::fs::write(root.join("src/b.ts"), "export const deadB = 1;\n").unwrap();
1077 std::fs::write(
1078 root.join("a.diff"),
1079 diff_for("src/a.ts", "export const deadA = 1;\n"),
1080 )
1081 .unwrap();
1082 std::fs::write(
1083 root.join("b.diff"),
1084 diff_for("src/b.ts", "export const deadB = 1;\n"),
1085 )
1086 .unwrap();
1087
1088 let filters = DeadCodeFilters {
1089 unused_exports: true,
1090 ..DeadCodeFilters::default()
1091 };
1092
1093 let a_json = detect_dead_code(&DeadCodeOptions {
1094 analysis: AnalysisOptions {
1095 root: Some(root.to_path_buf()),
1096 diff_file: Some(PathBuf::from("a.diff")),
1097 ..AnalysisOptions::default()
1098 },
1099 filters: filters.clone(),
1100 ..DeadCodeOptions::default()
1101 })
1102 .expect("a-scoped analysis should succeed");
1103 let b_json = detect_dead_code(&DeadCodeOptions {
1104 analysis: AnalysisOptions {
1105 root: Some(root.to_path_buf()),
1106 diff_file: Some(PathBuf::from("b.diff")),
1107 ..AnalysisOptions::default()
1108 },
1109 filters,
1110 ..DeadCodeOptions::default()
1111 })
1112 .expect("b-scoped analysis should succeed");
1113
1114 assert_eq!(unused_export_names(&a_json), vec!["deadA"]);
1115 assert_eq!(unused_export_names(&b_json), vec!["deadB"]);
1116 }
1117
1118 #[test]
1119 fn programmatic_without_diff_file_ignores_shared_diff_cache() {
1120 if std::env::var_os(SHARED_DIFF_CHILD_ENV).is_some() {
1121 run_programmatic_shared_diff_child();
1122 return;
1123 }
1124
1125 let current_exe = std::env::current_exe().expect("current test binary should be known");
1126 let output = Command::new(current_exe)
1127 .arg("--exact")
1128 .arg(SHARED_DIFF_CHILD_TEST)
1129 .arg("--nocapture")
1130 .env(SHARED_DIFF_CHILD_ENV, "1")
1131 .output()
1132 .expect("shared diff child should start");
1133
1134 assert!(
1135 output.status.success(),
1136 "shared diff child failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
1137 output.status.code(),
1138 String::from_utf8_lossy(&output.stdout),
1139 String::from_utf8_lossy(&output.stderr)
1140 );
1141 }
1142
1143 fn run_programmatic_shared_diff_child() {
1144 let dir = tempfile::tempdir().expect("temp dir");
1145 let root = dir.path();
1146 std::fs::create_dir_all(root.join("src")).unwrap();
1147 std::fs::write(
1148 root.join("package.json"),
1149 r#"{"name":"programmatic-shared-diff","main":"src/index.ts"}"#,
1150 )
1151 .unwrap();
1152 std::fs::write(
1153 root.join("src/index.ts"),
1154 "import { used } from './used';\nimport './a';\nimport './b';\nconsole.log(used);\n",
1155 )
1156 .unwrap();
1157 std::fs::write(root.join("src/used.ts"), "export const used = 1;\n").unwrap();
1158 std::fs::write(root.join("src/a.ts"), "export const deadA = 1;\n").unwrap();
1159 std::fs::write(root.join("src/b.ts"), "export const deadB = 1;\n").unwrap();
1160 std::fs::write(
1161 root.join("a.diff"),
1162 diff_for("src/a.ts", "export const deadA = 1;\n"),
1163 )
1164 .unwrap();
1165
1166 let source = crate::report::ci::diff_filter::DiffSource::Flag(root.join("a.diff"));
1167 let loaded = crate::report::ci::diff_filter::init_shared_diff(Some(&source), true);
1168 assert!(loaded.is_some(), "shared diff should load in child process");
1169
1170 let json = detect_dead_code(&DeadCodeOptions {
1171 analysis: AnalysisOptions {
1172 root: Some(root.to_path_buf()),
1173 ..AnalysisOptions::default()
1174 },
1175 filters: DeadCodeFilters {
1176 unused_exports: true,
1177 ..DeadCodeFilters::default()
1178 },
1179 ..DeadCodeOptions::default()
1180 })
1181 .expect("analysis without explicit diff should succeed");
1182
1183 assert_eq!(unused_export_names(&json), vec!["deadA", "deadB"]);
1184 }
1185
1186 #[test]
1187 fn explicit_diff_file_rejects_stdin_sentinel() {
1188 let dir = tempfile::tempdir().expect("temp dir");
1189 let Err(error) = AnalysisOptions {
1190 root: Some(dir.path().to_path_buf()),
1191 diff_file: Some(PathBuf::from("-")),
1192 ..AnalysisOptions::default()
1193 }
1194 .resolve() else {
1195 panic!("stdin sentinel is not part of the programmatic API");
1196 };
1197
1198 assert_eq!(error.code.as_deref(), Some("FALLOW_INVALID_DIFF_FILE"));
1199 assert_eq!(error.context.as_deref(), Some("analysis.diffFile"));
1200 }
1201
1202 fn tiny_project() -> tempfile::TempDir {
1204 let dir = tempfile::tempdir().expect("temp dir");
1205 let root = dir.path();
1206 std::fs::create_dir_all(root.join("src")).unwrap();
1207 std::fs::write(
1208 root.join("package.json"),
1209 r#"{"name":"prog-e2e","main":"src/index.ts"}"#,
1210 )
1211 .unwrap();
1212 std::fs::write(
1213 root.join("src/index.ts"),
1214 "export const ok = 1;\nconsole.log(ok);\n",
1215 )
1216 .unwrap();
1217 dir
1218 }
1219
1220 fn analysis_at(root: &Path) -> AnalysisOptions {
1221 AnalysisOptions {
1222 root: Some(root.to_path_buf()),
1223 ..AnalysisOptions::default()
1224 }
1225 }
1226
1227 #[test]
1228 fn resolve_rejects_zero_threads() {
1229 let err = AnalysisOptions {
1230 threads: Some(0),
1231 ..AnalysisOptions::default()
1232 }
1233 .resolve()
1234 .err()
1235 .expect("zero threads must be rejected");
1236 assert_eq!(err.exit_code, 2);
1237 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_THREADS"));
1238 assert_eq!(err.context.as_deref(), Some("analysis.threads"));
1239 }
1240
1241 #[test]
1242 fn resolve_rejects_mutually_exclusive_workspace_flags() {
1243 let err = AnalysisOptions {
1244 workspace: Some(vec!["packages/*".to_owned()]),
1245 changed_workspaces: Some("HEAD~1".to_owned()),
1246 ..AnalysisOptions::default()
1247 }
1248 .resolve()
1249 .err()
1250 .expect("workspace + changed_workspaces must be rejected");
1251 assert_eq!(
1252 err.code.as_deref(),
1253 Some("FALLOW_MUTUALLY_EXCLUSIVE_OPTIONS")
1254 );
1255 assert_eq!(err.context.as_deref(), Some("analysis.workspace"));
1256 }
1257
1258 #[test]
1259 fn resolve_rejects_nonexistent_root() {
1260 let err = AnalysisOptions {
1261 root: Some(PathBuf::from("/definitely/not/a/real/path/xyzzy")),
1262 ..AnalysisOptions::default()
1263 }
1264 .resolve()
1265 .err()
1266 .expect("nonexistent root must be rejected");
1267 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_ROOT"));
1268 assert_eq!(err.context.as_deref(), Some("analysis.root"));
1269 }
1270
1271 #[test]
1272 fn resolve_rejects_root_that_is_a_file() {
1273 let dir = tempfile::tempdir().expect("temp dir");
1274 let file = dir.path().join("not-a-dir.txt");
1275 std::fs::write(&file, "x").unwrap();
1276 let err = AnalysisOptions {
1277 root: Some(file),
1278 ..AnalysisOptions::default()
1279 }
1280 .resolve()
1281 .err()
1282 .expect("a file root must be rejected");
1283 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_ROOT"));
1284 }
1285
1286 #[test]
1287 fn resolve_rejects_nonexistent_config_path() {
1288 let dir = tempfile::tempdir().expect("temp dir");
1289 let err = AnalysisOptions {
1290 root: Some(dir.path().to_path_buf()),
1291 config_path: Some(dir.path().join("missing.fallowrc.json")),
1292 ..AnalysisOptions::default()
1293 }
1294 .resolve()
1295 .err()
1296 .expect("nonexistent config must be rejected");
1297 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_CONFIG_PATH"));
1298 assert_eq!(err.context.as_deref(), Some("analysis.configPath"));
1299 }
1300
1301 #[test]
1302 fn resolve_rejects_missing_diff_file() {
1303 let dir = tempfile::tempdir().expect("temp dir");
1304 let err = AnalysisOptions {
1305 root: Some(dir.path().to_path_buf()),
1306 diff_file: Some(PathBuf::from("nope.diff")),
1307 ..AnalysisOptions::default()
1308 }
1309 .resolve()
1310 .err()
1311 .expect("missing diff file must be rejected");
1312 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_DIFF_FILE"));
1313 assert_eq!(err.context.as_deref(), Some("analysis.diffFile"));
1314 }
1315
1316 #[test]
1317 fn resolve_rejects_diff_path_that_is_a_directory() {
1318 let dir = tempfile::tempdir().expect("temp dir");
1319 std::fs::create_dir_all(dir.path().join("a-dir")).unwrap();
1320 let err = AnalysisOptions {
1321 root: Some(dir.path().to_path_buf()),
1322 diff_file: Some(PathBuf::from("a-dir")),
1323 ..AnalysisOptions::default()
1324 }
1325 .resolve()
1326 .err()
1327 .expect("a directory diff path must be rejected");
1328 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_DIFF_FILE"));
1329 }
1330
1331 #[test]
1332 fn detect_circular_dependencies_returns_dead_code_envelope() {
1333 let project = tiny_project();
1334 let json = detect_circular_dependencies(&DeadCodeOptions {
1335 analysis: analysis_at(project.path()),
1336 ..DeadCodeOptions::default()
1337 })
1338 .expect("circular-dependency analysis should succeed");
1339 assert_eq!(json["kind"], "dead-code");
1340 assert!(json["circular_dependencies"].is_array());
1341 }
1342
1343 #[test]
1344 fn detect_boundary_violations_returns_dead_code_envelope() {
1345 let project = tiny_project();
1346 let json = detect_boundary_violations(&DeadCodeOptions {
1347 analysis: analysis_at(project.path()),
1348 ..DeadCodeOptions::default()
1349 })
1350 .expect("boundary-violation analysis should succeed");
1351 assert_eq!(json["kind"], "dead-code");
1352 assert!(json["boundary_violations"].is_array());
1353 }
1354
1355 #[test]
1356 fn detect_duplication_returns_dupes_envelope() {
1357 let project = tiny_project();
1358 let json = detect_duplication(&DuplicationOptions {
1359 analysis: analysis_at(project.path()),
1360 ..DuplicationOptions::default()
1361 })
1362 .expect("duplication analysis should succeed");
1363 assert_eq!(json["kind"], "dupes");
1364 assert!(json["clone_groups"].is_array());
1366 assert!(json["stats"].is_object());
1367 }
1368
1369 #[test]
1370 fn compute_health_returns_health_envelope() {
1371 let project = tiny_project();
1372 let options = ComplexityOptions {
1373 analysis: analysis_at(project.path()),
1374 ..ComplexityOptions::default()
1375 };
1376 let json = compute_health(&options).expect("health analysis should succeed");
1378 assert_eq!(json["kind"], "health");
1379 assert!(json["summary"].is_object());
1381 assert!(json["findings"].is_array());
1382 }
1383
1384 #[test]
1385 fn compute_complexity_rejects_missing_coverage_path() {
1386 let project = tiny_project();
1387 let err = compute_complexity(&ComplexityOptions {
1388 analysis: analysis_at(project.path()),
1389 coverage: Some(project.path().join("missing-coverage.json")),
1390 ..ComplexityOptions::default()
1391 })
1392 .expect_err("a missing coverage path must be rejected");
1393 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1394 assert_eq!(err.context.as_deref(), Some("health.coverage"));
1395 }
1396
1397 #[test]
1398 fn compute_complexity_rejects_relative_coverage_root() {
1399 let project = tiny_project();
1400 let err = compute_complexity(&ComplexityOptions {
1401 analysis: analysis_at(project.path()),
1402 coverage_root: Some(PathBuf::from("relative/prefix")),
1403 ..ComplexityOptions::default()
1404 })
1405 .expect_err("a relative coverage_root must be rejected");
1406 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_ROOT"));
1407 assert_eq!(err.context.as_deref(), Some("health.coverage_root"));
1408 }
1409
1410 #[test]
1411 fn programmatic_error_builders_compose_and_display() {
1412 let err = ProgrammaticError::new("boom", 7)
1413 .with_code("FALLOW_X")
1414 .with_help("try again")
1415 .with_context("ctx.path");
1416 assert_eq!(err.message, "boom");
1417 assert_eq!(err.exit_code, 7);
1418 assert_eq!(err.code.as_deref(), Some("FALLOW_X"));
1419 assert_eq!(err.help.as_deref(), Some("try again"));
1420 assert_eq!(err.context.as_deref(), Some("ctx.path"));
1421 assert_eq!(format!("{err}"), "boom");
1423 }
1424
1425 #[test]
1426 fn generic_analysis_error_uppercases_command_into_code() {
1427 let err = generic_analysis_error("dead-code");
1428 assert_eq!(err.code.as_deref(), Some("FALLOW_DEAD_CODE_FAILED"));
1429 assert_eq!(err.exit_code, 2);
1430 assert_eq!(err.context.as_deref(), Some("fallow dead-code"));
1431 assert!(err.help.is_some(), "diagnostics hint should be attached");
1432 }
1433
1434 fn unused_file_paths(json: &serde_json::Value) -> Vec<String> {
1435 json["unused_files"]
1436 .as_array()
1437 .unwrap()
1438 .iter()
1439 .filter_map(|file| file["path"].as_str())
1440 .map(str::to_owned)
1441 .collect()
1442 }
1443
1444 fn unused_export_names(json: &serde_json::Value) -> Vec<String> {
1445 let mut names: Vec<String> = json["unused_exports"]
1446 .as_array()
1447 .unwrap()
1448 .iter()
1449 .filter_map(|export| export["export_name"].as_str())
1450 .map(str::to_owned)
1451 .collect();
1452 names.sort();
1453 names
1454 }
1455
1456 fn diff_for(path: &str, line: &str) -> String {
1457 format!("diff --git a/{path} b/{path}\n--- /dev/null\n+++ b/{path}\n@@ -0,0 +1 @@\n+{line}")
1458 }
1459}