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