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