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