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