Skip to main content

fallow_engine/
lib.rs

1//! Typed analysis engine facade for fallow consumers.
2//!
3//! `fallow-core` remains the internal orchestration backend. This crate owns
4//! the typed boundary that editor, API, and embedding surfaces can depend on
5//! without calling deprecated core entry points directly.
6
7#![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
25/// Duplication result types exposed through the engine boundary.
26pub 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
50/// Discovery helpers and types exposed through the engine boundary.
51pub 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
73/// Extracted semantic types exposed through the engine boundary.
74pub 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
91/// Parse cache helpers exposed through the engine boundary.
92pub mod cache {
93    pub use fallow_extract::cache::CacheStore;
94}
95
96/// Module graph types exposed through the engine boundary.
97pub mod graph {
98    pub use fallow_graph::graph::{
99        CoordinationGapPaths, ExportSymbol, FocusFileFactsPaths, ImpactClosurePaths, ModuleGraph,
100        ModuleNode, PartitionOrderPaths,
101    };
102}
103
104/// Module resolution types exposed through the engine boundary.
105pub mod resolve {
106    pub use fallow_graph::resolve::ResolvedModule;
107}
108
109/// Public API graph helpers exposed through the engine boundary.
110pub mod public_api {
111    pub use fallow_core::analyze::public_api_package_entry_points;
112}
113
114/// Plugin registry helpers and types exposed through the engine boundary.
115pub 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
125/// Git process environment helpers exposed through the engine boundary.
126pub mod git_env {
127    pub use fallow_core::git_env::{AMBIENT_GIT_ENV_VARS, clear_ambient_git_env};
128}
129
130/// Analysis result types exposed through the engine boundary.
131pub mod results {
132    pub use fallow_types::output_dead_code::*;
133    pub use fallow_types::results::*;
134}
135
136/// Suppression helpers exposed for editor and embedding surfaces.
137pub mod suppress {
138    pub use fallow_core::suppress::{IssueKind, Suppression, is_file_suppressed, is_suppressed};
139}
140
141/// Changed-file helpers exposed through the engine boundary for editor and
142/// embedding surfaces.
143pub 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
152/// Cross-reference helpers exposed through the engine boundary.
153pub mod cross_reference {
154    pub use fallow_core::cross_reference::{
155        CombinedFinding, CrossReferenceResult, DeadCodeKind, cross_reference,
156    };
157}
158
159/// Git churn helpers and types exposed through the engine boundary.
160pub 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
168/// Security metadata helpers exposed through the engine boundary.
169pub mod security {
170    pub use fallow_core::analyze::{derive_security_severity, security_catalogue_title};
171}
172
173/// Symbol trace types exposed through the engine boundary.
174pub mod trace_chain {
175    pub use fallow_core::trace_chain::{
176        ChainHop, DEFAULT_TRACE_DEPTH, SymbolChainQuery, SymbolChainTrace, TraceDirections,
177        UnresolvedCallee, UnresolvedReason,
178    };
179}
180
181/// Read-only trace helpers exposed through the engine boundary.
182pub 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
208/// Result alias for typed engine operations.
209pub type EngineResult<T> = Result<T, EngineError>;
210
211/// Error type exposed by the typed engine boundary.
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct EngineError {
214    message: String,
215}
216
217impl EngineError {
218    /// Create an engine error from a user-facing message.
219    #[must_use]
220    pub fn new(message: impl Into<String>) -> Self {
221        Self {
222            message: message.into(),
223        }
224    }
225
226    /// User-facing error message from the backend.
227    #[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/// Resolved project config plus the config file path when one was loaded.
246#[derive(Debug)]
247pub struct ProjectConfig {
248    pub config: ResolvedConfig,
249    pub path: Option<PathBuf>,
250}
251
252/// Scalar config-loading knobs for one analysis family.
253#[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/// Typed dead-code analysis result.
264#[derive(Debug)]
265pub struct DeadCodeAnalysis {
266    pub results: AnalysisResults,
267}
268
269/// Typed dead-code analysis result with per-file source hashes.
270#[derive(Debug)]
271pub struct DeadCodeAnalysisWithHashes {
272    pub results: AnalysisResults,
273    pub file_hashes: FxHashMap<PathBuf, u64>,
274}
275
276/// Typed dead-code analysis result with retained parser artifacts.
277#[derive(Debug)]
278pub struct DeadCodeAnalysisOutput {
279    pub results: AnalysisResults,
280    pub modules: Option<Vec<ModuleInfo>>,
281    pub files: Option<Vec<DiscoveredFile>>,
282}
283
284/// Typed dead-code analysis result with all reusable pipeline artifacts.
285#[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/// Typed project analysis result combining dead-code and duplication outputs.
297#[derive(Debug)]
298pub struct ProjectAnalysisOutput {
299    pub dead_code: DeadCodeAnalysisOutput,
300    pub duplication: DuplicationReport,
301}
302
303/// Typed duplication analysis result.
304#[derive(Debug)]
305pub struct DuplicationAnalysis {
306    pub report: DuplicationReport,
307    pub default_ignore_skips: DefaultIgnoreSkips,
308}
309
310/// Reusable engine session for one resolved project.
311///
312/// The session owns the resolved config and discovered file set so future
313/// consumers can share graph-sensitive inputs without each surface recreating
314/// its own partial orchestration.
315#[derive(Debug)]
316pub struct AnalysisSession {
317    config: ResolvedConfig,
318    config_path: Option<PathBuf>,
319    discovery: AnalysisDiscovery,
320}
321
322/// Owned session parts for runners that need to continue an existing pipeline.
323#[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    /// Load config and discover files for a project root.
332    ///
333    /// # Errors
334    ///
335    /// Returns an error when config loading fails.
336    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    /// Load config, apply one caller-supplied config adjustment, then discover
342    /// files for a project root.
343    ///
344    /// # Errors
345    ///
346    /// Returns an error when config loading fails.
347    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    /// Build a session from built-in defaults, ignoring project config files.
358    ///
359    /// This is intended for editor fallback paths that have already reported a
360    /// config-load warning but should still surface best-effort diagnostics.
361    #[must_use]
362    pub fn load_default(root: &Path) -> Self {
363        Self::from_config(default_project_config(root))
364    }
365
366    /// Build a session from a previously resolved config.
367    #[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    /// Build a session from a resolved config when the caller already owns
378    /// command-specific config loading.
379    #[must_use]
380    pub fn from_resolved_config(config: ResolvedConfig) -> Self {
381        Self::from_config(ProjectConfig { config, path: None })
382    }
383
384    /// Resolved project root.
385    #[must_use]
386    pub fn root(&self) -> &Path {
387        &self.config.root
388    }
389
390    /// Resolved project config.
391    #[must_use]
392    pub fn config(&self) -> &ResolvedConfig {
393        &self.config
394    }
395
396    /// Config file path when one was loaded.
397    #[must_use]
398    pub fn config_path(&self) -> Option<&Path> {
399        self.config_path.as_deref()
400    }
401
402    /// Discovered files for this session.
403    #[must_use]
404    pub fn files(&self) -> &[DiscoveredFile] {
405        self.discovery.files()
406    }
407
408    /// Consume the session and return the resolved config plus discovery data.
409    #[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    /// Run dead-code analysis for this session.
419    ///
420    /// # Errors
421    ///
422    /// Returns an error if parsing or analysis fails.
423    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    /// Run dead-code analysis with retained complexity artifacts.
430    ///
431    /// # Errors
432    ///
433    /// Returns an error if parsing or analysis fails.
434    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    /// Run dead-code analysis with retained modules, discovered files and graph.
448    ///
449    /// # Errors
450    ///
451    /// Returns an error if parsing or analysis fails.
452    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    /// Run duplication detection using the session's discovered files.
468    #[must_use]
469    pub fn find_duplicates(&self) -> DuplicationReport {
470        find_duplicates(&self.config.root, self.files(), &self.config.duplicates)
471    }
472
473    /// Run duplication detection using custom duplicate options.
474    #[must_use]
475    pub fn find_duplicates_with(&self, config: &DuplicatesConfig) -> DuplicationReport {
476        find_duplicates(&self.config.root, self.files(), config)
477    }
478
479    /// Run dead-code and duplication analysis for this session.
480    ///
481    /// When `retain_complexity_artifacts` is true, the dead-code result keeps
482    /// parser artifacts needed by editor overlays such as inline complexity.
483    ///
484    /// # Errors
485    ///
486    /// Returns an error if dead-code parsing or analysis fails.
487    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    /// Run duplication detection and return report sidecar metadata.
510    #[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    /// Run focused duplication detection for a changed-file set.
520    #[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
537/// Resolve the analysis config for a project.
538///
539/// # Errors
540///
541/// Returns an error when an explicit config cannot be loaded or automatic
542/// config discovery finds an invalid config.
543pub 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/// Resolve the parse-cache size limit for a resolved config.
550#[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
570/// Resolve config for a specific analysis without depending on the CLI crate.
571///
572/// This mirrors the CLI's core config semantics: explicit production overrides
573/// are applied before resolution, per-analysis production config is flattened
574/// for the requested analysis, and boundary / external plugin / rule-pack
575/// validation happens before the resolved config reaches the engine.
576///
577/// # Errors
578///
579/// Returns an engine error when config loading or validation fails.
580pub 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
652/// Run dead-code analysis for a resolved config.
653///
654/// # Errors
655///
656/// Returns an error if file discovery, parsing, or analysis fails.
657pub 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
667/// Run dead-code analysis with export usage collection for a resolved config.
668///
669/// # Errors
670///
671/// Returns an error if file discovery, parsing, or analysis fails.
672pub 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
682/// Run dead-code analysis with source hashes for drift-sensitive fixers.
683///
684/// # Errors
685///
686/// Returns an error if file discovery, parsing, or analysis fails.
687pub 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
702/// Run dead-code analysis with trace timings and retained graph artifacts.
703///
704/// # Errors
705///
706/// Returns an error if file discovery, parsing, or analysis fails.
707pub 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
717/// Run dead-code analysis while retaining module and file artifacts.
718///
719/// # Errors
720///
721/// Returns an error if file discovery, parsing, or analysis fails.
722pub 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
736/// Run dead-code analysis from pre-parsed modules.
737///
738/// # Errors
739///
740/// Returns an error if discovery, graph construction, or analysis fails.
741pub 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
754/// Run dead-code analysis with export usage and retained complexity artifacts.
755///
756/// # Errors
757///
758/// Returns an error if file discovery, parsing, or analysis fails.
759pub 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/// Build health shared parse data from retained dead-code artifacts.
776#[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/// Discover source files for a resolved config, including plugin scopes.
804#[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/// Run duplication detection on a discovered file set.
810#[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
819/// Resolve changed files for a git ref relative to a project root.
820///
821/// # Errors
822///
823/// Returns an error when git cannot resolve the ref or repository state.
824pub 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
831/// Run symbol-level call-chain tracing through the engine boundary.
832///
833/// # Errors
834///
835/// Returns an error if parsing, graph construction, or retained module
836/// analysis fails.
837pub 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/// Run duplication detection and include metadata about built-in ignored files.
873#[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/// Run focused duplication detection and include metadata about built-in ignored files.
894#[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}