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