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::{build_duplication_json, build_health_json};
12
13#[derive(Debug, Clone, Serialize)]
15pub struct ProgrammaticError {
16 pub message: String,
17 pub exit_code: u8,
18 pub code: Option<String>,
19 pub help: Option<String>,
20 pub context: Option<String>,
21}
22
23impl ProgrammaticError {
24 #[must_use]
25 pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
26 Self {
27 message: message.into(),
28 exit_code,
29 code: None,
30 help: None,
31 context: None,
32 }
33 }
34
35 #[must_use]
36 pub fn with_help(mut self, help: impl Into<String>) -> Self {
37 self.help = Some(help.into());
38 self
39 }
40
41 #[must_use]
42 pub fn with_code(mut self, code: impl Into<String>) -> Self {
43 self.code = Some(code.into());
44 self
45 }
46
47 #[must_use]
48 pub fn with_context(mut self, context: impl Into<String>) -> Self {
49 self.context = Some(context.into());
50 self
51 }
52}
53
54impl std::fmt::Display for ProgrammaticError {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 write!(f, "{}", self.message)
57 }
58}
59
60impl std::error::Error for ProgrammaticError {}
61
62type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
63
64#[derive(Debug, Clone, Default)]
66pub struct AnalysisOptions {
67 pub root: Option<PathBuf>,
68 pub config_path: Option<PathBuf>,
69 pub no_cache: bool,
70 pub threads: Option<usize>,
71 pub production: bool,
74 pub production_override: Option<bool>,
77 pub changed_since: Option<String>,
78 pub workspace: Option<Vec<String>>,
79 pub changed_workspaces: Option<String>,
80 pub explain: bool,
81}
82
83#[derive(Debug, Clone, Default)]
85pub struct DeadCodeFilters {
86 pub unused_files: bool,
87 pub unused_exports: bool,
88 pub unused_deps: bool,
89 pub unused_types: bool,
90 pub private_type_leaks: bool,
91 pub unused_enum_members: bool,
92 pub unused_class_members: bool,
93 pub unresolved_imports: bool,
94 pub unlisted_deps: bool,
95 pub duplicate_exports: bool,
96 pub circular_deps: bool,
97 pub re_export_cycles: bool,
98 pub boundary_violations: bool,
99 pub stale_suppressions: bool,
100 pub unused_catalog_entries: bool,
101 pub empty_catalog_groups: bool,
102 pub unresolved_catalog_references: bool,
103 pub unused_dependency_overrides: bool,
104 pub misconfigured_dependency_overrides: bool,
105}
106
107#[derive(Debug, Clone, Default)]
109pub struct DeadCodeOptions {
110 pub analysis: AnalysisOptions,
111 pub filters: DeadCodeFilters,
112 pub files: Vec<PathBuf>,
113 pub include_entry_exports: bool,
114}
115
116#[derive(Debug, Clone, Copy, Default)]
118pub enum DuplicationMode {
119 Strict,
120 #[default]
121 Mild,
122 Weak,
123 Semantic,
124}
125
126impl DuplicationMode {
127 const fn to_cli(self) -> DupesMode {
128 match self {
129 Self::Strict => DupesMode::Strict,
130 Self::Mild => DupesMode::Mild,
131 Self::Weak => DupesMode::Weak,
132 Self::Semantic => DupesMode::Semantic,
133 }
134 }
135}
136
137#[derive(Debug, Clone)]
139pub struct DuplicationOptions {
140 pub analysis: AnalysisOptions,
141 pub mode: DuplicationMode,
142 pub min_tokens: usize,
143 pub min_lines: usize,
144 pub min_occurrences: usize,
151 pub threshold: f64,
152 pub skip_local: bool,
153 pub cross_language: bool,
154 pub ignore_imports: bool,
155 pub top: Option<usize>,
156}
157
158impl Default for DuplicationOptions {
159 fn default() -> Self {
160 Self {
161 analysis: AnalysisOptions::default(),
162 mode: DuplicationMode::Mild,
163 min_tokens: 50,
164 min_lines: 5,
165 min_occurrences: 2,
166 threshold: 0.0,
167 skip_local: false,
168 cross_language: false,
169 ignore_imports: false,
170 top: None,
171 }
172 }
173}
174
175#[derive(Debug, Clone, Copy, Default)]
177pub enum ComplexitySort {
178 #[default]
179 Cyclomatic,
180 Cognitive,
181 Lines,
182 Severity,
183}
184
185impl ComplexitySort {
186 const fn to_cli(self) -> SortBy {
187 match self {
188 Self::Severity => SortBy::Severity,
189 Self::Cyclomatic => SortBy::Cyclomatic,
190 Self::Cognitive => SortBy::Cognitive,
191 Self::Lines => SortBy::Lines,
192 }
193 }
194}
195
196#[derive(Debug, Clone, Copy, Default)]
198pub enum OwnershipEmailMode {
199 Raw,
200 #[default]
201 Handle,
202 Hash,
203}
204
205impl OwnershipEmailMode {
206 const fn to_config(self) -> EmailMode {
207 match self {
208 Self::Raw => EmailMode::Raw,
209 Self::Handle => EmailMode::Handle,
210 Self::Hash => EmailMode::Hash,
211 }
212 }
213}
214
215#[derive(Debug, Clone, Copy)]
217pub enum TargetEffort {
218 Low,
219 Medium,
220 High,
221}
222
223impl TargetEffort {
224 const fn to_cli(self) -> EffortEstimate {
225 match self {
226 Self::Low => EffortEstimate::Low,
227 Self::Medium => EffortEstimate::Medium,
228 Self::High => EffortEstimate::High,
229 }
230 }
231}
232
233#[derive(Debug, Clone, Default)]
235pub struct ComplexityOptions {
236 pub analysis: AnalysisOptions,
237 pub max_cyclomatic: Option<u16>,
238 pub max_cognitive: Option<u16>,
239 pub max_crap: Option<f64>,
240 pub top: Option<usize>,
241 pub sort: ComplexitySort,
242 pub complexity: bool,
243 pub file_scores: bool,
244 pub coverage_gaps: bool,
245 pub hotspots: bool,
246 pub ownership: bool,
247 pub ownership_emails: Option<OwnershipEmailMode>,
248 pub targets: bool,
249 pub effort: Option<TargetEffort>,
250 pub score: bool,
251 pub since: Option<String>,
252 pub min_commits: Option<u32>,
253 pub coverage: Option<PathBuf>,
254 pub coverage_root: Option<PathBuf>,
255}
256
257#[derive(Debug, Clone)]
258struct ResolvedAnalysisOptions {
259 root: PathBuf,
260 config_path: Option<PathBuf>,
261 no_cache: bool,
262 threads: usize,
263 production_override: Option<bool>,
264 changed_since: Option<String>,
265 workspace: Option<Vec<String>>,
266 changed_workspaces: Option<String>,
267 explain: bool,
268}
269
270impl AnalysisOptions {
271 fn resolve(&self) -> ProgrammaticResult<ResolvedAnalysisOptions> {
272 if self.threads == Some(0) {
273 return Err(
274 ProgrammaticError::new("`threads` must be greater than 0", 2)
275 .with_code("FALLOW_INVALID_THREADS")
276 .with_context("analysis.threads"),
277 );
278 }
279 if self.workspace.is_some() && self.changed_workspaces.is_some() {
280 return Err(ProgrammaticError::new(
281 "`workspace` and `changed_workspaces` are mutually exclusive",
282 2,
283 )
284 .with_code("FALLOW_MUTUALLY_EXCLUSIVE_OPTIONS")
285 .with_context("analysis.workspace"));
286 }
287
288 let root = if let Some(root) = &self.root {
289 root.clone()
290 } else {
291 std::env::current_dir().map_err(|err| {
292 ProgrammaticError::new(
293 format!("failed to resolve current working directory: {err}"),
294 2,
295 )
296 .with_code("FALLOW_CWD_UNAVAILABLE")
297 .with_context("analysis.root")
298 })?
299 };
300
301 if !root.exists() {
302 return Err(ProgrammaticError::new(
303 format!("analysis root does not exist: {}", root.display()),
304 2,
305 )
306 .with_code("FALLOW_INVALID_ROOT")
307 .with_context("analysis.root"));
308 }
309 if !root.is_dir() {
310 return Err(ProgrammaticError::new(
311 format!("analysis root is not a directory: {}", root.display()),
312 2,
313 )
314 .with_code("FALLOW_INVALID_ROOT")
315 .with_context("analysis.root"));
316 }
317
318 if let Some(config_path) = &self.config_path
319 && !config_path.exists()
320 {
321 return Err(ProgrammaticError::new(
322 format!("config file does not exist: {}", config_path.display()),
323 2,
324 )
325 .with_code("FALLOW_INVALID_CONFIG_PATH")
326 .with_context("analysis.configPath"));
327 }
328
329 let threads = self.threads.unwrap_or_else(default_threads);
330 crate::rayon_pool::configure_global_pool(threads);
331 let production_override = self
332 .production_override
333 .or_else(|| self.production.then_some(true));
334
335 Ok(ResolvedAnalysisOptions {
336 root,
337 config_path: self.config_path.clone(),
338 no_cache: self.no_cache,
339 threads,
340 production_override,
341 changed_since: self.changed_since.clone(),
342 workspace: self.workspace.clone(),
343 changed_workspaces: self.changed_workspaces.clone(),
344 explain: self.explain,
345 })
346 }
347}
348
349fn default_threads() -> usize {
350 std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
351}
352
353fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
354 if let serde_json::Value::Object(map) = output {
355 map.insert("_meta".to_string(), meta);
356 }
357}
358
359fn build_dead_code_json(
360 results: &AnalysisResults,
361 root: &Path,
362 elapsed: std::time::Duration,
363 explain: bool,
364 config_fixable: bool,
365) -> ProgrammaticResult<serde_json::Value> {
366 let mut output =
367 crate::report::build_json_with_config_fixable(results, root, elapsed, config_fixable)
368 .map_err(|err| {
369 ProgrammaticError::new(format!("failed to serialize dead-code report: {err}"), 2)
370 .with_code("FALLOW_SERIALIZE_DEAD_CODE_REPORT")
371 .with_context("dead-code")
372 })?;
373 if explain {
374 insert_meta(&mut output, crate::explain::check_meta());
375 }
376 Ok(output)
377}
378
379fn to_issue_filters(filters: &DeadCodeFilters) -> IssueFilters {
380 IssueFilters {
381 unused_files: filters.unused_files,
382 unused_exports: filters.unused_exports,
383 unused_deps: filters.unused_deps,
384 unused_types: filters.unused_types,
385 private_type_leaks: filters.private_type_leaks,
386 unused_enum_members: filters.unused_enum_members,
387 unused_class_members: filters.unused_class_members,
388 unresolved_imports: filters.unresolved_imports,
389 unlisted_deps: filters.unlisted_deps,
390 duplicate_exports: filters.duplicate_exports,
391 circular_deps: filters.circular_deps,
392 re_export_cycles: filters.re_export_cycles,
393 boundary_violations: filters.boundary_violations,
394 stale_suppressions: filters.stale_suppressions,
395 unused_catalog_entries: filters.unused_catalog_entries,
396 empty_catalog_groups: filters.empty_catalog_groups,
397 unresolved_catalog_references: filters.unresolved_catalog_references,
398 unused_dependency_overrides: filters.unused_dependency_overrides,
399 misconfigured_dependency_overrides: filters.misconfigured_dependency_overrides,
400 }
401}
402
403fn generic_analysis_error(command: &str) -> ProgrammaticError {
404 let code = format!(
405 "FALLOW_{}_FAILED",
406 command.replace('-', "_").to_ascii_uppercase()
407 );
408 ProgrammaticError::new(format!("{command} failed"), 2)
409 .with_code(code)
410 .with_context(format!("fallow {command}"))
411 .with_help(format!(
412 "Re-run `fallow {command} --format json --quiet` in the target project for CLI diagnostics"
413 ))
414}
415
416fn build_check_options<'a>(
417 resolved: &'a ResolvedAnalysisOptions,
418 options: &'a DeadCodeOptions,
419 filters: &'a IssueFilters,
420 trace_opts: &'a TraceOptions,
421) -> CheckOptions<'a> {
422 CheckOptions {
423 root: &resolved.root,
424 config_path: &resolved.config_path,
425 output: OutputFormat::Human,
426 no_cache: resolved.no_cache,
427 threads: resolved.threads,
428 quiet: true,
429 fail_on_issues: false,
430 filters,
431 changed_since: resolved.changed_since.as_deref(),
432 baseline: None,
433 save_baseline: None,
434 sarif_file: None,
435 production: resolved.production_override.unwrap_or(false),
436 production_override: resolved.production_override,
437 workspace: resolved.workspace.as_deref(),
438 changed_workspaces: resolved.changed_workspaces.as_deref(),
439 group_by: None,
440 include_dupes: false,
441 trace_opts,
442 explain: resolved.explain,
443 top: None,
444 file: &options.files,
445 include_entry_exports: options.include_entry_exports,
446 summary: false,
447 regression_opts: crate::regression::RegressionOpts {
448 fail_on_regression: false,
449 tolerance: crate::regression::Tolerance::Absolute(0),
450 regression_baseline_file: None,
451 save_target: crate::regression::SaveRegressionTarget::None,
452 scoped: false,
453 quiet: true,
454 output: fallow_config::OutputFormat::Json,
455 },
456 retain_modules_for_health: false,
457 defer_performance: false,
458 }
459}
460
461fn filter_for_circular_dependencies(results: &AnalysisResults) -> AnalysisResults {
462 let mut filtered = results.clone();
463 filtered.unused_files.clear();
464 filtered.unused_exports.clear();
465 filtered.unused_types.clear();
466 filtered.private_type_leaks.clear();
467 filtered.unused_dependencies.clear();
468 filtered.unused_dev_dependencies.clear();
469 filtered.unused_optional_dependencies.clear();
470 filtered.unused_enum_members.clear();
471 filtered.unused_class_members.clear();
472 filtered.unresolved_imports.clear();
473 filtered.unlisted_dependencies.clear();
474 filtered.duplicate_exports.clear();
475 filtered.type_only_dependencies.clear();
476 filtered.test_only_dependencies.clear();
477 filtered.boundary_violations.clear();
478 filtered.stale_suppressions.clear();
479 filtered
480}
481
482fn filter_for_boundary_violations(results: &AnalysisResults) -> AnalysisResults {
483 let mut filtered = results.clone();
484 filtered.unused_files.clear();
485 filtered.unused_exports.clear();
486 filtered.unused_types.clear();
487 filtered.private_type_leaks.clear();
488 filtered.unused_dependencies.clear();
489 filtered.unused_dev_dependencies.clear();
490 filtered.unused_optional_dependencies.clear();
491 filtered.unused_enum_members.clear();
492 filtered.unused_class_members.clear();
493 filtered.unresolved_imports.clear();
494 filtered.unlisted_dependencies.clear();
495 filtered.duplicate_exports.clear();
496 filtered.type_only_dependencies.clear();
497 filtered.test_only_dependencies.clear();
498 filtered.circular_dependencies.clear();
499 filtered.stale_suppressions.clear();
500 filtered
501}
502
503pub fn detect_dead_code(options: &DeadCodeOptions) -> ProgrammaticResult<serde_json::Value> {
505 let resolved = options.analysis.resolve()?;
506 let filters = to_issue_filters(&options.filters);
507 let trace_opts = TraceOptions {
508 trace_export: None,
509 trace_file: None,
510 trace_dependency: None,
511 performance: false,
512 };
513 let check_options = build_check_options(&resolved, options, &filters, &trace_opts);
514 let result = crate::check::execute_check(&check_options)
515 .map_err(|_| generic_analysis_error("dead-code"))?;
516 build_dead_code_json(
517 &result.results,
518 &result.config.root,
519 result.elapsed,
520 resolved.explain,
521 result.config_fixable,
522 )
523}
524
525pub fn detect_circular_dependencies(
528 options: &DeadCodeOptions,
529) -> ProgrammaticResult<serde_json::Value> {
530 let resolved = options.analysis.resolve()?;
531 let filters = to_issue_filters(&options.filters);
532 let trace_opts = TraceOptions {
533 trace_export: None,
534 trace_file: None,
535 trace_dependency: None,
536 performance: false,
537 };
538 let check_options = build_check_options(&resolved, options, &filters, &trace_opts);
539 let result = crate::check::execute_check(&check_options)
540 .map_err(|_| generic_analysis_error("dead-code"))?;
541 let filtered = filter_for_circular_dependencies(&result.results);
542 build_dead_code_json(
543 &filtered,
544 &result.config.root,
545 result.elapsed,
546 resolved.explain,
547 result.config_fixable,
548 )
549}
550
551pub fn detect_boundary_violations(
554 options: &DeadCodeOptions,
555) -> ProgrammaticResult<serde_json::Value> {
556 let resolved = options.analysis.resolve()?;
557 let filters = to_issue_filters(&options.filters);
558 let trace_opts = TraceOptions {
559 trace_export: None,
560 trace_file: None,
561 trace_dependency: None,
562 performance: false,
563 };
564 let check_options = build_check_options(&resolved, options, &filters, &trace_opts);
565 let result = crate::check::execute_check(&check_options)
566 .map_err(|_| generic_analysis_error("dead-code"))?;
567 let filtered = filter_for_boundary_violations(&result.results);
568 build_dead_code_json(
569 &filtered,
570 &result.config.root,
571 result.elapsed,
572 resolved.explain,
573 result.config_fixable,
574 )
575}
576
577pub fn detect_duplication(options: &DuplicationOptions) -> ProgrammaticResult<serde_json::Value> {
579 let resolved = options.analysis.resolve()?;
580 let dupes_options = DupesOptions {
581 root: &resolved.root,
582 config_path: &resolved.config_path,
583 output: OutputFormat::Human,
584 no_cache: resolved.no_cache,
585 threads: resolved.threads,
586 quiet: true,
587 mode: Some(options.mode.to_cli()),
591 min_tokens: Some(options.min_tokens),
592 min_lines: Some(options.min_lines),
593 min_occurrences: Some(options.min_occurrences),
594 threshold: Some(options.threshold),
595 skip_local: options.skip_local,
596 cross_language: options.cross_language,
597 ignore_imports: options.ignore_imports,
598 top: options.top,
599 baseline_path: None,
600 save_baseline_path: None,
601 production: resolved.production_override.unwrap_or(false),
602 production_override: resolved.production_override,
603 trace: None,
604 changed_since: resolved.changed_since.as_deref(),
605 changed_files: None,
606 workspace: resolved.workspace.as_deref(),
607 changed_workspaces: resolved.changed_workspaces.as_deref(),
608 explain: resolved.explain,
609 explain_skipped: false,
610 summary: false,
611 group_by: None,
612 performance: false,
615 };
616 let result =
617 crate::dupes::execute_dupes(&dupes_options).map_err(|_| generic_analysis_error("dupes"))?;
618 build_duplication_json(
619 &result.report,
620 &result.config.root,
621 result.elapsed,
622 resolved.explain,
623 )
624 .map_err(|err| {
625 ProgrammaticError::new(format!("failed to serialize duplication report: {err}"), 2)
626 .with_code("FALLOW_SERIALIZE_DUPLICATION_REPORT")
627 .with_context("dupes")
628 })
629}
630
631fn build_complexity_options<'a>(
632 resolved: &'a ResolvedAnalysisOptions,
633 options: &'a ComplexityOptions,
634) -> HealthOptions<'a> {
635 let ownership = options.ownership || options.ownership_emails.is_some();
636 let hotspots = options.hotspots || ownership;
637 let targets = options.targets || options.effort.is_some();
638 let any_section = options.complexity
639 || options.file_scores
640 || options.coverage_gaps
641 || hotspots
642 || targets
643 || options.score;
644 let eff_score = if any_section { options.score } else { true };
645 let force_full = eff_score;
646 let score_only_output = options.score
647 && !options.complexity
648 && !options.file_scores
649 && !options.coverage_gaps
650 && !hotspots
651 && !targets;
652 let eff_file_scores = if any_section {
653 options.file_scores
654 } else {
655 true
656 } || force_full;
657 let eff_hotspots = if any_section { hotspots } else { true };
658 let eff_complexity = if any_section {
659 options.complexity
660 } else {
661 true
662 };
663 let eff_targets = if any_section { targets } else { true };
664 let eff_coverage_gaps = if any_section {
665 options.coverage_gaps
666 } else {
667 false
668 };
669
670 HealthOptions {
671 root: &resolved.root,
672 config_path: &resolved.config_path,
673 output: OutputFormat::Human,
674 no_cache: resolved.no_cache,
675 threads: resolved.threads,
676 quiet: true,
677 max_cyclomatic: options.max_cyclomatic,
678 max_cognitive: options.max_cognitive,
679 max_crap: options.max_crap,
680 top: options.top,
681 sort: options.sort.to_cli(),
682 production: resolved.production_override.unwrap_or(false),
683 production_override: resolved.production_override,
684 changed_since: resolved.changed_since.as_deref(),
685 workspace: resolved.workspace.as_deref(),
686 changed_workspaces: resolved.changed_workspaces.as_deref(),
687 baseline: None,
688 save_baseline: None,
689 complexity: eff_complexity,
690 file_scores: eff_file_scores,
691 coverage_gaps: eff_coverage_gaps,
692 config_activates_coverage_gaps: !any_section,
693 hotspots: eff_hotspots,
694 ownership: ownership && eff_hotspots,
695 ownership_emails: options.ownership_emails.map(OwnershipEmailMode::to_config),
696 targets: eff_targets,
697 force_full,
698 score_only_output,
699 enforce_coverage_gap_gate: true,
700 effort: options.effort.map(TargetEffort::to_cli),
701 score: eff_score,
702 min_score: None,
703 since: options.since.as_deref(),
704 min_commits: options.min_commits,
705 explain: resolved.explain,
706 summary: false,
707 save_snapshot: None,
708 trend: false,
709 group_by: None,
710 coverage: options.coverage.as_deref(),
711 coverage_root: options.coverage_root.as_deref(),
712 performance: false,
713 min_severity: None,
714 runtime_coverage: None,
715 }
720}
721
722pub fn compute_complexity(options: &ComplexityOptions) -> ProgrammaticResult<serde_json::Value> {
724 let resolved = options.analysis.resolve()?;
725 if let Some(path) = &options.coverage
726 && !path.exists()
727 {
728 return Err(ProgrammaticError::new(
729 format!("coverage path does not exist: {}", path.display()),
730 2,
731 )
732 .with_code("FALLOW_INVALID_COVERAGE_PATH")
733 .with_context("health.coverage"));
734 }
735 if let Err(message) =
736 crate::health::scoring::validate_coverage_root_absolute(options.coverage_root.as_deref())
737 {
738 return Err(ProgrammaticError::new(message, 2)
739 .with_code("FALLOW_INVALID_COVERAGE_ROOT")
740 .with_context("health.coverage_root"));
741 }
742
743 let health_options = build_complexity_options(&resolved, options);
744 let result = crate::health::execute_health(&health_options)
745 .map_err(|_| generic_analysis_error("health"))?;
746 build_health_json(
747 &result.report,
748 &result.config.root,
749 result.elapsed,
750 resolved.explain,
751 )
752 .map_err(|err| {
753 ProgrammaticError::new(format!("failed to serialize health report: {err}"), 2)
754 .with_code("FALLOW_SERIALIZE_HEALTH_REPORT")
755 .with_context("health")
756 })
757}
758
759pub fn compute_health(options: &ComplexityOptions) -> ProgrammaticResult<serde_json::Value> {
761 compute_complexity(options)
762}
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767 use crate::report::test_helpers::sample_results;
768
769 #[test]
770 fn circular_dependency_filter_clears_other_issue_types() {
771 let root = PathBuf::from("/project");
772 let results = sample_results(&root);
773 let filtered = filter_for_circular_dependencies(&results);
774 let json = build_dead_code_json(&filtered, &root, std::time::Duration::ZERO, false, false)
775 .expect("should serialize");
776
777 assert_eq!(json["circular_dependencies"].as_array().unwrap().len(), 1);
778 assert_eq!(json["boundary_violations"].as_array().unwrap().len(), 0);
779 assert_eq!(json["unused_files"].as_array().unwrap().len(), 0);
780 assert_eq!(json["summary"]["total_issues"], serde_json::Value::from(1));
781 }
782
783 #[test]
784 fn boundary_violation_filter_clears_other_issue_types() {
785 let root = PathBuf::from("/project");
786 let results = sample_results(&root);
787 let filtered = filter_for_boundary_violations(&results);
788 let json = build_dead_code_json(&filtered, &root, std::time::Duration::ZERO, false, false)
789 .expect("should serialize");
790
791 assert_eq!(json["boundary_violations"].as_array().unwrap().len(), 1);
792 assert_eq!(json["circular_dependencies"].as_array().unwrap().len(), 0);
793 assert_eq!(json["unused_exports"].as_array().unwrap().len(), 0);
794 assert_eq!(json["summary"]["total_issues"], serde_json::Value::from(1));
795 }
796
797 #[test]
798 fn dead_code_without_production_override_uses_per_analysis_config() {
799 let dir = tempfile::tempdir().expect("temp dir");
800 let root = dir.path();
801 std::fs::create_dir_all(root.join("src")).unwrap();
802 std::fs::write(
803 root.join("package.json"),
804 r#"{"name":"programmatic-production","main":"src/index.ts"}"#,
805 )
806 .unwrap();
807 std::fs::write(root.join("src/index.ts"), "export const ok = 1;\n").unwrap();
808 std::fs::write(root.join("src/utils.test.ts"), "export const dead = 1;\n").unwrap();
809 std::fs::write(
810 root.join(".fallowrc.json"),
811 r#"{"production":{"deadCode":true,"health":false,"dupes":false}}"#,
812 )
813 .unwrap();
814
815 let options = DeadCodeOptions {
816 analysis: AnalysisOptions {
817 root: Some(root.to_path_buf()),
818 ..AnalysisOptions::default()
819 },
820 ..DeadCodeOptions::default()
821 };
822 let json = detect_dead_code(&options).expect("analysis should succeed");
823 let paths = unused_file_paths(&json);
824
825 assert!(
826 !paths.iter().any(|path| path.ends_with("utils.test.ts")),
827 "omitted production option should defer to production.deadCode=true config: {paths:?}"
828 );
829 }
830
831 #[test]
832 fn dead_code_explicit_production_false_overrides_config() {
833 let dir = tempfile::tempdir().expect("temp dir");
834 let root = dir.path();
835 std::fs::create_dir_all(root.join("src")).unwrap();
836 std::fs::write(
837 root.join("package.json"),
838 r#"{"name":"programmatic-production","main":"src/index.ts"}"#,
839 )
840 .unwrap();
841 std::fs::write(root.join("src/index.ts"), "export const ok = 1;\n").unwrap();
842 std::fs::write(root.join("src/utils.test.ts"), "export const dead = 1;\n").unwrap();
843 std::fs::write(
844 root.join(".fallowrc.json"),
845 r#"{"production":{"deadCode":true,"health":false,"dupes":false}}"#,
846 )
847 .unwrap();
848
849 let options = DeadCodeOptions {
850 analysis: AnalysisOptions {
851 root: Some(root.to_path_buf()),
852 production_override: Some(false),
853 ..AnalysisOptions::default()
854 },
855 ..DeadCodeOptions::default()
856 };
857 let json = detect_dead_code(&options).expect("analysis should succeed");
858 let paths = unused_file_paths(&json);
859
860 assert!(
861 paths.iter().any(|path| path.ends_with("utils.test.ts")),
862 "explicit production=false should include test files despite config: {paths:?}"
863 );
864 }
865
866 fn unused_file_paths(json: &serde_json::Value) -> Vec<String> {
867 json["unused_files"]
868 .as_array()
869 .unwrap()
870 .iter()
871 .filter_map(|file| file["path"].as_str())
872 .map(str::to_owned)
873 .collect()
874 }
875}