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