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