1#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
8#![cfg_attr(
9 test,
10 allow(
11 clippy::unwrap_used,
12 clippy::expect_used,
13 reason = "tests use unwrap and expect to keep fixture setup concise"
14 )
15)]
16
17use std::fmt;
18use std::path::{Path, PathBuf};
19
20use fallow_config::{
21 DuplicatesConfig, FallowConfig, OutputFormat, ProductionAnalysis, ResolvedConfig,
22};
23use rustc_hash::{FxHashMap, FxHashSet};
24
25pub mod duplicates {
27 pub mod families {
28 pub use fallow_core::duplicates::families::{
29 detect_mirrored_directories, group_into_families,
30 };
31 }
32
33 pub mod tokenize {
34 pub use fallow_core::duplicates::tokenize::tokenize_file;
35 }
36
37 pub use fallow_core::duplicates::{
38 CloneFamily, CloneFingerprintSet, CloneGroup, CloneInstance, DefaultIgnoreSkips,
39 DuplicationReport, DuplicationStats, FINGERPRINT_PREFIX, MirroredDirectory,
40 RefactoringKind, RefactoringSuggestion, clone_fingerprint, dominant_identifier,
41 find_duplicates, find_duplicates_cached, find_duplicates_cached_with_default_ignore_skips,
42 find_duplicates_in_project, find_duplicates_touching_files,
43 find_duplicates_touching_files_cached,
44 find_duplicates_touching_files_cached_with_default_ignore_skips,
45 find_duplicates_touching_files_with_default_ignore_skips,
46 find_duplicates_with_default_ignore_skips, fingerprint_for_fragment,
47 };
48}
49
50pub mod discover {
52 pub use fallow_core::discover::{
53 CategorizedEntryPoints, HiddenDirScope, PRODUCTION_EXCLUDE_PATTERNS, SOURCE_EXTENSIONS,
54 collect_hidden_dir_scopes, collect_plugin_hidden_dir_scopes, compile_glob_set,
55 discover_dynamically_loaded_entry_points, discover_entry_points, discover_files,
56 discover_files_and_config_candidates, discover_files_with_additional_hidden_dirs,
57 discover_files_with_plugin_scopes, discover_infrastructure_entry_points,
58 discover_plugin_entry_point_sets, discover_plugin_entry_points,
59 discover_workspace_entry_points, is_allowed_hidden_dir,
60 };
61 pub use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
62}
63
64pub mod baseline;
65pub mod codeowners;
66pub mod dead_code;
67pub mod error;
68pub mod flags;
69pub mod health;
70pub mod validate;
71pub mod vital_signs;
72
73pub mod extract {
75 pub mod inventory {
76 pub use fallow_extract::inventory::{InventoryEntry, walk_source};
77 }
78
79 pub use fallow_extract::css::{
80 extract_apply_tokens, extract_css_module_exports, scan_theme_blocks,
81 };
82 pub use fallow_extract::css_classes::{is_typo_edit, scan_markup_class_tokens};
83 pub use fallow_extract::css_metrics::compute_css_analytics;
84 pub use fallow_extract::parse_all_files;
85 pub use fallow_extract::sfc::extract_sfc_styles;
86 pub use fallow_extract::sfc_css::{scoped_unused_classes, sfc_virtual_stylesheet};
87 pub use fallow_extract::tailwind::scan_tailwind_arbitrary_values;
88 pub use fallow_types::extract::*;
89}
90
91pub mod cache {
93 pub use fallow_extract::cache::CacheStore;
94}
95
96pub mod graph {
98 pub use fallow_graph::graph::{
99 CoordinationGapPaths, ExportSymbol, FocusFileFactsPaths, ImpactClosurePaths, ModuleGraph,
100 ModuleNode, PartitionOrderPaths,
101 };
102}
103
104pub mod resolve {
106 pub use fallow_graph::resolve::ResolvedModule;
107}
108
109pub mod public_api {
111 pub use fallow_core::analyze::public_api_package_entry_points;
112}
113
114pub mod plugins {
116 pub mod registry {
117 pub use fallow_core::plugins::registry::{
118 builtin_plugin_names, format_plugin_regex_errors,
119 };
120 }
121
122 pub use fallow_core::plugins::{AggregatedPluginResult, PluginRegistry};
123}
124
125pub mod git_env {
127 pub use fallow_core::git_env::{AMBIENT_GIT_ENV_VARS, clear_ambient_git_env};
128}
129
130pub mod results {
132 pub use fallow_types::output_dead_code::*;
133 pub use fallow_types::results::*;
134}
135
136pub mod suppress {
138 pub use fallow_core::suppress::{IssueKind, Suppression, is_file_suppressed, is_suppressed};
139}
140
141pub mod changed_files {
144 pub use fallow_core::changed_files::{
145 ChangedFilesError, filter_duplication_by_changed_files, filter_results_by_changed_files,
146 get_changed_files, resolve_git_common_dir, resolve_git_toplevel, set_spawn_hook,
147 try_get_changed_diff, try_get_changed_files, try_get_changed_files_with_toplevel,
148 validate_git_ref,
149 };
150}
151
152pub mod cross_reference {
154 pub use fallow_core::cross_reference::{
155 CombinedFinding, CrossReferenceResult, DeadCodeKind, cross_reference,
156 };
157}
158
159pub mod churn {
161 pub use fallow_core::churn::{
162 AuthorContribution, ChurnResult, ChurnSpawnHook, FileChurn, SinceDuration, analyze_churn,
163 analyze_churn_cached, analyze_churn_from_file, is_git_repo, parse_since, set_spawn_hook,
164 };
165 pub use fallow_types::churn::ChurnTrend;
166}
167
168pub mod security {
170 pub use fallow_core::analyze::{derive_security_severity, security_catalogue_title};
171}
172
173pub mod trace_chain {
175 pub use fallow_core::trace_chain::{
176 ChainHop, DEFAULT_TRACE_DEPTH, SymbolChainQuery, SymbolChainTrace, TraceDirections,
177 UnresolvedCallee, UnresolvedReason,
178 };
179}
180
181pub mod trace {
183 pub use fallow_core::trace::{
184 CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace, ImpactClosureGap,
185 ImpactClosureTrace, PipelineTimings, ReExportChain, TracedCloneGroup, TracedExport,
186 TracedReExport, trace_clone, trace_clone_by_fingerprint, trace_dependency, trace_export,
187 trace_file, trace_impact_closure,
188 };
189}
190
191pub use fallow_core::AnalysisDiscovery;
192pub use fallow_core::duplicates::{
193 CloneFamily, CloneGroup, CloneInstance, DefaultIgnoreSkips, DuplicationReport,
194 DuplicationStats, MirroredDirectory, RefactoringSuggestion,
195};
196pub use fallow_types::discover::{DiscoveredFile, FileId};
197pub use fallow_types::extract::ModuleInfo;
198pub use fallow_types::results::AnalysisResults;
199pub use health::{
200 ComplexityRunOptions, ComplexitySectionOptions, DerivedComplexityOptions,
201 DerivedHealthSections, HealthAnalysisResult, HealthCoverageInputs, HealthExecutionOptions,
202 HealthGateOptions, HealthRunOptions, HealthRunOptionsInput, HealthSectionOptions,
203 HealthSharedParseData, HealthSort, HealthThresholdOverrides, RuntimeCoverageOptions,
204 derive_complexity_sections, derive_health_run_options, derive_health_sections,
205 validate_coverage_root_absolute,
206};
207
208pub type EngineResult<T> = Result<T, EngineError>;
210
211#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct EngineError {
214 message: String,
215}
216
217impl EngineError {
218 #[must_use]
220 pub fn new(message: impl Into<String>) -> Self {
221 Self {
222 message: message.into(),
223 }
224 }
225
226 #[must_use]
228 pub fn message(&self) -> &str {
229 &self.message
230 }
231}
232
233impl fmt::Display for EngineError {
234 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
235 f.write_str(&self.message)
236 }
237}
238
239impl std::error::Error for EngineError {}
240
241fn engine_error(err: impl fmt::Display) -> EngineError {
242 EngineError::new(err.to_string())
243}
244
245#[derive(Debug)]
247pub struct ProjectConfig {
248 pub config: ResolvedConfig,
249 pub path: Option<PathBuf>,
250}
251
252#[derive(Debug, Clone, Copy)]
254pub struct ProjectConfigOptions {
255 pub output: OutputFormat,
256 pub no_cache: bool,
257 pub threads: usize,
258 pub production_override: Option<bool>,
259 pub quiet: bool,
260 pub analysis: ProductionAnalysis,
261}
262
263#[derive(Debug)]
265pub struct DeadCodeAnalysis {
266 pub results: AnalysisResults,
267}
268
269#[derive(Debug)]
271pub struct DeadCodeAnalysisWithHashes {
272 pub results: AnalysisResults,
273 pub file_hashes: FxHashMap<PathBuf, u64>,
274}
275
276#[derive(Debug)]
278pub struct DeadCodeAnalysisOutput {
279 pub results: AnalysisResults,
280 pub modules: Option<Vec<ModuleInfo>>,
281 pub files: Option<Vec<DiscoveredFile>>,
282}
283
284#[derive(Debug)]
286pub struct DeadCodeAnalysisArtifacts {
287 pub results: AnalysisResults,
288 pub timings: Option<trace::PipelineTimings>,
289 pub graph: Option<graph::ModuleGraph>,
290 pub modules: Option<Vec<ModuleInfo>>,
291 pub files: Option<Vec<DiscoveredFile>>,
292 pub script_used_packages: FxHashSet<String>,
293 pub file_hashes: FxHashMap<PathBuf, u64>,
294}
295
296#[derive(Debug)]
298pub struct ProjectAnalysisOutput {
299 pub dead_code: DeadCodeAnalysisOutput,
300 pub duplication: DuplicationReport,
301}
302
303#[derive(Debug)]
305pub struct DuplicationAnalysis {
306 pub report: DuplicationReport,
307 pub default_ignore_skips: DefaultIgnoreSkips,
308}
309
310#[derive(Debug)]
316pub struct AnalysisSession {
317 config: ResolvedConfig,
318 config_path: Option<PathBuf>,
319 discovery: AnalysisDiscovery,
320}
321
322#[derive(Debug)]
324pub struct AnalysisSessionParts {
325 pub config: ResolvedConfig,
326 pub config_path: Option<PathBuf>,
327 pub files: Vec<DiscoveredFile>,
328}
329
330impl AnalysisSession {
331 pub fn load(root: &Path, config_path: Option<&Path>) -> EngineResult<Self> {
337 let project_config = config_for_project(root, config_path)?;
338 Ok(Self::from_config(project_config))
339 }
340
341 pub fn load_with_config(
348 root: &Path,
349 config_path: Option<&Path>,
350 configure: impl FnOnce(&mut ResolvedConfig),
351 ) -> EngineResult<Self> {
352 let mut project_config = config_for_project(root, config_path)?;
353 configure(&mut project_config.config);
354 Ok(Self::from_config(project_config))
355 }
356
357 #[must_use]
362 pub fn load_default(root: &Path) -> Self {
363 Self::from_config(default_project_config(root))
364 }
365
366 #[must_use]
368 pub fn from_config(project_config: ProjectConfig) -> Self {
369 let discovery = fallow_core::prepare_analysis_discovery(&project_config.config);
370 Self {
371 config: project_config.config,
372 config_path: project_config.path,
373 discovery,
374 }
375 }
376
377 #[must_use]
380 pub fn from_resolved_config(config: ResolvedConfig) -> Self {
381 Self::from_config(ProjectConfig { config, path: None })
382 }
383
384 #[must_use]
386 pub fn root(&self) -> &Path {
387 &self.config.root
388 }
389
390 #[must_use]
392 pub fn config(&self) -> &ResolvedConfig {
393 &self.config
394 }
395
396 #[must_use]
398 pub fn config_path(&self) -> Option<&Path> {
399 self.config_path.as_deref()
400 }
401
402 #[must_use]
404 pub fn files(&self) -> &[DiscoveredFile] {
405 self.discovery.files()
406 }
407
408 #[must_use]
410 pub fn into_parts(self) -> AnalysisSessionParts {
411 AnalysisSessionParts {
412 config: self.config,
413 config_path: self.config_path,
414 files: self.discovery.into_files(),
415 }
416 }
417
418 pub fn analyze_dead_code(&self) -> EngineResult<DeadCodeAnalysis> {
424 fallow_core::analyze_with_usages_from_discovery(&self.config, &self.discovery)
425 .map(|results| DeadCodeAnalysis { results })
426 .map_err(engine_error)
427 }
428
429 pub fn analyze_dead_code_with_complexity(&self) -> EngineResult<DeadCodeAnalysisOutput> {
435 fallow_core::analyze_with_usages_and_complexity_from_discovery(
436 &self.config,
437 &self.discovery,
438 )
439 .map(|output| DeadCodeAnalysisOutput {
440 results: output.results,
441 modules: output.modules,
442 files: output.files,
443 })
444 .map_err(engine_error)
445 }
446
447 pub fn analyze_dead_code_with_artifacts(
453 &self,
454 need_complexity: bool,
455 retain_graph: bool,
456 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
457 fallow_core::analyze_retaining_modules_from_discovery(
458 &self.config,
459 &self.discovery,
460 need_complexity,
461 retain_graph,
462 )
463 .map(dead_code_artifacts)
464 .map_err(engine_error)
465 }
466
467 #[must_use]
469 pub fn find_duplicates(&self) -> DuplicationReport {
470 find_duplicates(&self.config.root, self.files(), &self.config.duplicates)
471 }
472
473 #[must_use]
475 pub fn find_duplicates_with(&self, config: &DuplicatesConfig) -> DuplicationReport {
476 find_duplicates(&self.config.root, self.files(), config)
477 }
478
479 pub fn analyze_project_with(
488 &self,
489 duplicates_config: &DuplicatesConfig,
490 retain_complexity_artifacts: bool,
491 ) -> EngineResult<ProjectAnalysisOutput> {
492 let dead_code = if retain_complexity_artifacts {
493 self.analyze_dead_code_with_complexity()?
494 } else {
495 let analysis = self.analyze_dead_code()?;
496 DeadCodeAnalysisOutput {
497 results: analysis.results,
498 modules: None,
499 files: None,
500 }
501 };
502 let duplication = self.find_duplicates_with(duplicates_config);
503 Ok(ProjectAnalysisOutput {
504 dead_code,
505 duplication,
506 })
507 }
508
509 #[must_use]
511 pub fn find_duplicates_with_defaults(
512 &self,
513 config: &DuplicatesConfig,
514 cache_dir: Option<&Path>,
515 ) -> DuplicationAnalysis {
516 find_duplicates_with_defaults(&self.config.root, self.files(), config, cache_dir)
517 }
518
519 #[must_use]
521 pub fn find_duplicates_touching_files_with_defaults(
522 &self,
523 config: &DuplicatesConfig,
524 changed_files: &[PathBuf],
525 cache_dir: Option<&Path>,
526 ) -> DuplicationAnalysis {
527 find_duplicates_touching_files_with_defaults(
528 &self.config.root,
529 self.files(),
530 config,
531 changed_files,
532 cache_dir,
533 )
534 }
535}
536
537pub fn config_for_project(root: &Path, config_path: Option<&Path>) -> EngineResult<ProjectConfig> {
544 fallow_core::config_for_project(root, config_path)
545 .map(|(config, path)| ProjectConfig { config, path })
546 .map_err(engine_error)
547}
548
549#[must_use]
551pub fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
552 fallow_core::resolve_cache_max_size_bytes(config)
553}
554
555fn default_project_config(root: &Path) -> ProjectConfig {
556 let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
557 ProjectConfig {
558 config: FallowConfig::default().resolve(
559 root.to_path_buf(),
560 OutputFormat::Human,
561 threads,
562 false,
563 true,
564 None,
565 ),
566 path: None,
567 }
568}
569
570pub fn config_for_project_analysis(
581 root: &Path,
582 config_path: Option<&Path>,
583 options: ProjectConfigOptions,
584) -> EngineResult<ProjectConfig> {
585 let user_config = load_user_config(root, config_path)?;
586 let loaded_user_config = user_config.is_some();
587 let (mut config, path) = match user_config {
588 Some((config, path)) => (config, Some(path)),
589 None => (
590 FallowConfig {
591 production: options.production_override.unwrap_or(false).into(),
592 ..FallowConfig::default()
593 },
594 None,
595 ),
596 };
597
598 if loaded_user_config {
599 let production = options
600 .production_override
601 .unwrap_or_else(|| config.production.for_analysis(options.analysis));
602 config.production = production.into();
603 }
604 validate_config(root, &config)?;
605 let resolved = config.resolve(
606 root.to_path_buf(),
607 options.output,
608 options.threads,
609 options.no_cache,
610 options.quiet,
611 None,
612 );
613 Ok(ProjectConfig {
614 config: resolved,
615 path,
616 })
617}
618
619fn load_user_config(
620 root: &Path,
621 config_path: Option<&Path>,
622) -> EngineResult<Option<(FallowConfig, PathBuf)>> {
623 if let Some(path) = config_path {
624 let config = FallowConfig::load(path)
625 .map_err(|err| EngineError::new(format!("invalid config: {err:#}")))?;
626 return Ok(Some((config, path.to_path_buf())));
627 }
628 FallowConfig::find_and_load(root)
629 .map_err(|err| EngineError::new(format!("invalid config: {err}")))
630}
631
632fn validate_config(root: &Path, config: &FallowConfig) -> EngineResult<()> {
633 fallow_config::discover_and_validate_external_plugins(root, &config.plugins)
634 .map_err(|errors| joined_config_errors("invalid external plugin definition", &errors))?;
635 config
636 .validate_resolved_boundaries(root)
637 .map_err(|errors| joined_config_errors("invalid boundary configuration", &errors))?;
638 fallow_config::load_rule_packs(root, &config.rule_packs)
639 .map_err(|errors| joined_config_errors("invalid rule pack", &errors))?;
640 Ok(())
641}
642
643fn joined_config_errors(label: &str, errors: &[impl ToString]) -> EngineError {
644 let joined = errors
645 .iter()
646 .map(ToString::to_string)
647 .collect::<Vec<_>>()
648 .join("\n - ");
649 EngineError::new(format!("{label}:\n - {joined}"))
650}
651
652pub fn analyze(config: &ResolvedConfig) -> EngineResult<DeadCodeAnalysis> {
658 #[expect(
659 deprecated,
660 reason = "fallow-engine is the typed migration boundary over the internal core backend"
661 )]
662 fallow_core::analyze(config)
663 .map(|results| DeadCodeAnalysis { results })
664 .map_err(engine_error)
665}
666
667pub fn analyze_with_usages(config: &ResolvedConfig) -> EngineResult<DeadCodeAnalysis> {
673 #[expect(
674 deprecated,
675 reason = "fallow-engine is the typed migration boundary over the internal core backend"
676 )]
677 fallow_core::analyze_with_usages(config)
678 .map(|results| DeadCodeAnalysis { results })
679 .map_err(engine_error)
680}
681
682pub fn analyze_with_file_hashes(
688 config: &ResolvedConfig,
689) -> EngineResult<DeadCodeAnalysisWithHashes> {
690 #[expect(
691 deprecated,
692 reason = "fallow-engine is the typed migration boundary over the internal core backend"
693 )]
694 fallow_core::analyze_with_file_hashes(config)
695 .map(|output| DeadCodeAnalysisWithHashes {
696 results: output.results,
697 file_hashes: output.file_hashes,
698 })
699 .map_err(engine_error)
700}
701
702pub fn analyze_with_trace(config: &ResolvedConfig) -> EngineResult<DeadCodeAnalysisArtifacts> {
708 #[expect(
709 deprecated,
710 reason = "fallow-engine is the typed migration boundary over the internal core backend"
711 )]
712 fallow_core::analyze_with_trace(config)
713 .map(dead_code_artifacts)
714 .map_err(engine_error)
715}
716
717pub fn analyze_retaining_modules(
723 config: &ResolvedConfig,
724 need_complexity: bool,
725 retain_graph: bool,
726) -> EngineResult<DeadCodeAnalysisArtifacts> {
727 #[expect(
728 deprecated,
729 reason = "fallow-engine is the typed migration boundary over the internal core backend"
730 )]
731 fallow_core::analyze_retaining_modules(config, need_complexity, retain_graph)
732 .map(dead_code_artifacts)
733 .map_err(engine_error)
734}
735
736pub fn analyze_with_parse_result(
742 config: &ResolvedConfig,
743 modules: &[ModuleInfo],
744) -> EngineResult<DeadCodeAnalysisArtifacts> {
745 #[expect(
746 deprecated,
747 reason = "fallow-engine is the typed migration boundary over the internal core backend"
748 )]
749 fallow_core::analyze_with_parse_result(config, modules)
750 .map(dead_code_artifacts)
751 .map_err(engine_error)
752}
753
754pub fn analyze_with_usages_and_complexity(
760 config: &ResolvedConfig,
761) -> EngineResult<DeadCodeAnalysisOutput> {
762 #[expect(
763 deprecated,
764 reason = "fallow-engine is the typed migration boundary over the internal core backend"
765 )]
766 fallow_core::analyze_with_usages_and_complexity(config)
767 .map(|output| DeadCodeAnalysisOutput {
768 results: output.results,
769 modules: output.modules,
770 files: output.files,
771 })
772 .map_err(engine_error)
773}
774
775#[must_use]
777pub fn health_shared_parse_data_from_artifacts(
778 results: &AnalysisResults,
779 graph: Option<graph::ModuleGraph>,
780 modules: Option<Vec<ModuleInfo>>,
781 files: Option<Vec<DiscoveredFile>>,
782 script_used_packages: impl IntoIterator<Item = String>,
783) -> Option<HealthSharedParseData> {
784 let (Some(modules), Some(files)) = (modules, files) else {
785 return None;
786 };
787 let analysis_output = graph.map(|graph| DeadCodeAnalysisArtifacts {
788 results: results.clone(),
789 timings: None,
790 graph: Some(graph),
791 modules: None,
792 files: None,
793 script_used_packages: script_used_packages.into_iter().collect(),
794 file_hashes: FxHashMap::default(),
795 });
796 Some(HealthSharedParseData {
797 files,
798 modules,
799 analysis_output,
800 })
801}
802
803#[must_use]
805pub fn discover_files_with_plugin_scopes(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
806 fallow_core::discover::discover_files_with_plugin_scopes(config)
807}
808
809#[must_use]
811pub fn find_duplicates(
812 root: &Path,
813 files: &[DiscoveredFile],
814 config: &DuplicatesConfig,
815) -> DuplicationReport {
816 fallow_core::duplicates::find_duplicates(root, files, config)
817}
818
819pub fn changed_files(
825 root: &Path,
826 git_ref: &str,
827) -> Result<FxHashSet<PathBuf>, fallow_core::changed_files::ChangedFilesError> {
828 fallow_core::changed_files::try_get_changed_files(root, git_ref)
829}
830
831pub fn trace_symbol_chain(
838 config: &ResolvedConfig,
839 query: trace_chain::SymbolChainQuery<'_>,
840) -> EngineResult<Option<trace_chain::SymbolChainTrace>> {
841 #[expect(
842 deprecated,
843 reason = "fallow-engine is the typed migration boundary over the internal core backend"
844 )]
845 let output =
846 fallow_core::analyze_retaining_modules(config, true, true).map_err(engine_error)?;
847 let graph = output
848 .graph
849 .as_ref()
850 .ok_or_else(|| EngineError::new("trace requires a retained module graph"))?;
851 let modules = output.modules.as_deref().unwrap_or(&[]);
852 Ok(fallow_core::trace_chain::trace_symbol_chain(
853 graph,
854 modules,
855 &config.root,
856 query,
857 ))
858}
859
860fn dead_code_artifacts(output: fallow_core::AnalysisOutput) -> DeadCodeAnalysisArtifacts {
861 DeadCodeAnalysisArtifacts {
862 results: output.results,
863 timings: output.timings,
864 graph: output.graph,
865 modules: output.modules,
866 files: output.files,
867 script_used_packages: output.script_used_packages,
868 file_hashes: output.file_hashes,
869 }
870}
871
872#[must_use]
874pub fn find_duplicates_with_defaults(
875 root: &Path,
876 files: &[DiscoveredFile],
877 config: &DuplicatesConfig,
878 cache_dir: Option<&Path>,
879) -> DuplicationAnalysis {
880 let (report, default_ignore_skips) = if let Some(cache_dir) = cache_dir {
881 fallow_core::duplicates::find_duplicates_cached_with_default_ignore_skips(
882 root, files, config, cache_dir,
883 )
884 } else {
885 fallow_core::duplicates::find_duplicates_with_default_ignore_skips(root, files, config)
886 };
887 DuplicationAnalysis {
888 report,
889 default_ignore_skips,
890 }
891}
892
893#[must_use]
895pub fn find_duplicates_touching_files_with_defaults(
896 root: &Path,
897 files: &[DiscoveredFile],
898 config: &DuplicatesConfig,
899 changed_files: &[PathBuf],
900 cache_dir: Option<&Path>,
901) -> DuplicationAnalysis {
902 let changed_files = changed_files.iter().cloned().collect::<FxHashSet<_>>();
903 let (report, default_ignore_skips) = if let Some(cache_dir) = cache_dir {
904 fallow_core::duplicates::find_duplicates_touching_files_cached_with_default_ignore_skips(
905 root,
906 files,
907 config,
908 &changed_files,
909 cache_dir,
910 )
911 } else {
912 fallow_core::duplicates::find_duplicates_touching_files_with_default_ignore_skips(
913 root,
914 files,
915 config,
916 &changed_files,
917 )
918 };
919 DuplicationAnalysis {
920 report,
921 default_ignore_skips,
922 }
923}
924
925#[cfg(test)]
926mod tests {
927 use super::*;
928
929 #[test]
930 fn engine_error_displays_message() {
931 let err = EngineError::new("config failed");
932
933 assert_eq!(err.message(), "config failed");
934 assert_eq!(err.to_string(), "config failed");
935 }
936
937 #[test]
938 fn analysis_session_loads_config_and_discovered_files() {
939 let temp = tempfile::tempdir().expect("tempdir");
940 let src = temp.path().join("src");
941 std::fs::create_dir(&src).expect("src dir");
942 std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
943
944 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
945
946 assert_eq!(session.root(), temp.path());
947 assert!(session.config_path().is_none());
948 assert!(session.files().iter().any(|file| {
949 file.path
950 .strip_prefix(temp.path())
951 .is_ok_and(|path| path == Path::new("src/index.ts"))
952 }));
953 }
954
955 #[test]
956 fn analysis_session_applies_config_adjustment_before_discovery() {
957 let temp = tempfile::tempdir().expect("tempdir");
958 let src = temp.path().join("src");
959 std::fs::create_dir(&src).expect("src dir");
960 std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
961 std::fs::write(src.join("index.test.ts"), "export const testValue = 1;\n")
962 .expect("test source file");
963
964 let session = AnalysisSession::load_with_config(temp.path(), None, |config| {
965 config.production = true;
966 })
967 .expect("session loads");
968
969 let relative_paths: Vec<_> = session
970 .files()
971 .iter()
972 .filter_map(|file| file.path.strip_prefix(temp.path()).ok())
973 .collect();
974 assert!(relative_paths.contains(&Path::new("src/index.ts")));
975 assert!(!relative_paths.contains(&Path::new("src/index.test.ts")));
976 }
977
978 #[test]
979 fn analysis_session_can_be_consumed_into_pipeline_parts() {
980 let temp = tempfile::tempdir().expect("tempdir");
981 let src = temp.path().join("src");
982 std::fs::create_dir(&src).expect("src dir");
983 std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
984
985 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
986 let parts = session.into_parts();
987
988 assert_eq!(parts.config.root, temp.path());
989 assert!(parts.config_path.is_none());
990 assert!(parts.files.iter().any(|file| {
991 file.path
992 .strip_prefix(temp.path())
993 .is_ok_and(|path| path == Path::new("src/index.ts"))
994 }));
995 }
996
997 #[test]
998 fn analysis_session_returns_combined_project_analysis() {
999 let temp = tempfile::tempdir().expect("tempdir");
1000 let src = temp.path().join("src");
1001 std::fs::create_dir(&src).expect("src dir");
1002 let repeated =
1003 "export function repeated() {\n return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
1004 std::fs::write(src.join("a.ts"), repeated).expect("source file");
1005 std::fs::write(src.join("b.ts"), repeated).expect("source file");
1006
1007 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
1008 let mut config = session.config().duplicates.clone();
1009 config.min_tokens = 1;
1010 config.min_lines = 1;
1011
1012 let analysis = session
1013 .analyze_project_with(&config, true)
1014 .expect("project analysis succeeds");
1015
1016 assert!(analysis.dead_code.modules.is_some());
1017 assert!(analysis.dead_code.files.is_some());
1018 assert!(!analysis.duplication.clone_groups.is_empty());
1019 }
1020
1021 #[test]
1022 fn analysis_session_reuses_discovery_for_dead_code() {
1023 let temp = tempfile::tempdir().expect("tempdir");
1024 let src = temp.path().join("src");
1025 std::fs::create_dir(&src).expect("src dir");
1026 std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
1027
1028 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
1029 std::fs::write(src.join("late.ts"), "export const late = 1;\n").expect("late source file");
1030
1031 let analysis = session.analyze_dead_code().expect("analysis succeeds");
1032
1033 assert!(
1034 analysis
1035 .results
1036 .unused_files
1037 .iter()
1038 .all(|finding| !finding.file.path.ends_with("late.ts")),
1039 "session analysis must not rediscover files added after session load"
1040 );
1041 }
1042
1043 #[test]
1044 fn analysis_session_returns_retained_artifacts() {
1045 let temp = tempfile::tempdir().expect("tempdir");
1046 let src = temp.path().join("src");
1047 std::fs::create_dir(&src).expect("src dir");
1048 std::fs::write(
1049 src.join("index.ts"),
1050 "export function used() { return 1; }\nused();\n",
1051 )
1052 .expect("source file");
1053
1054 let config = config_for_project(temp.path(), None)
1055 .expect("config")
1056 .config;
1057 let session = AnalysisSession::from_resolved_config(config);
1058 let artifacts = session
1059 .analyze_dead_code_with_artifacts(true, true)
1060 .expect("analysis succeeds");
1061
1062 assert!(artifacts.graph.is_some());
1063 assert!(artifacts.modules.is_some_and(|modules| !modules.is_empty()));
1064 assert!(artifacts.files.is_some_and(|files| !files.is_empty()));
1065 }
1066
1067 #[test]
1068 fn analysis_session_runs_duplication_with_default_skip_metadata() {
1069 let temp = tempfile::tempdir().expect("tempdir");
1070 let src = temp.path().join("src");
1071 let generated = temp.path().join("storybook-static");
1072 std::fs::create_dir(&src).expect("src dir");
1073 std::fs::create_dir(&generated).expect("generated dir");
1074 let repeated =
1075 "export function repeated() {\n return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
1076 std::fs::write(src.join("a.ts"), repeated).expect("source file");
1077 std::fs::write(src.join("b.ts"), repeated).expect("source file");
1078 std::fs::write(generated.join("generated.ts"), repeated).expect("generated file");
1079
1080 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
1081 let mut config = session.config().duplicates.clone();
1082 config.min_tokens = 1;
1083 config.min_lines = 1;
1084
1085 let analysis = session.find_duplicates_with_defaults(&config, None);
1086
1087 assert!(!analysis.report.clone_groups.is_empty());
1088 assert!(analysis.default_ignore_skips.total > 0);
1089 }
1090
1091 #[test]
1092 fn trace_symbol_chain_uses_retained_engine_analysis() {
1093 let temp = tempfile::tempdir().expect("tempdir");
1094 let src = temp.path().join("src");
1095 std::fs::create_dir(&src).expect("src dir");
1096 std::fs::write(
1097 src.join("util.ts"),
1098 "export function helper() { return 1; }\n",
1099 )
1100 .expect("util source");
1101 std::fs::write(
1102 src.join("index.ts"),
1103 "import { helper } from './util';\nexport const value = helper();\n",
1104 )
1105 .expect("index source");
1106
1107 let project_config = config_for_project_analysis(
1108 temp.path(),
1109 None,
1110 ProjectConfigOptions {
1111 output: OutputFormat::Json,
1112 no_cache: true,
1113 threads: 1,
1114 production_override: None,
1115 quiet: true,
1116 analysis: ProductionAnalysis::DeadCode,
1117 },
1118 )
1119 .expect("project config loads");
1120 let trace = trace_symbol_chain(
1121 &project_config.config,
1122 trace_chain::SymbolChainQuery {
1123 file: "src/util.ts",
1124 symbol: "helper",
1125 depth: 1,
1126 directions: trace_chain::TraceDirections {
1127 callers: true,
1128 callees: false,
1129 },
1130 },
1131 )
1132 .expect("trace succeeds")
1133 .expect("trace target exists");
1134
1135 assert!(trace.symbol_found);
1136 assert_eq!(trace.file, Path::new("src/util.ts"));
1137 assert!(trace.callers.is_some_and(|callers| {
1138 callers
1139 .iter()
1140 .any(|caller| caller.file == Path::new("src/index.ts"))
1141 }));
1142 }
1143}