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