Skip to main content

fallow_engine/
session.rs

1//! Engine-owned analysis session orchestration.
2
3use std::path::{Path, PathBuf};
4use std::time::Instant;
5
6use fallow_config::{DuplicatesConfig, ResolvedConfig};
7use fallow_types::discover::DiscoveredFile;
8use fallow_types::extract::{ModuleInfo, ParseResult};
9use fallow_types::source_fingerprint::SourceFingerprint;
10use fallow_types::workspace::WorkspaceDiagnostic;
11use rustc_hash::{FxHashMap, FxHashSet};
12
13use crate::{
14    DeadCodeAnalysis, DeadCodeAnalysisArtifacts, DeadCodeAnalysisOutput, DuplicationAnalysis,
15    EngineResult, ProjectAnalysisOutput, ProjectConfig, config_for_project, core_backend,
16    duplicates, project_config::default_project_config,
17};
18
19/// Reusable engine session for one resolved project.
20///
21/// The session owns the resolved config and discovered file set so future
22/// consumers can share graph-sensitive inputs without each surface recreating
23/// its own partial orchestration.
24#[derive(Debug)]
25pub struct AnalysisSession {
26    config: ResolvedConfig,
27    config_path: Option<PathBuf>,
28    discovery: crate::discover::AnalysisDiscovery,
29    workspace_diagnostics: Vec<WorkspaceDiagnostic>,
30}
31
32/// Owned session parts for runners that need to continue an existing pipeline.
33#[derive(Debug)]
34pub struct AnalysisSessionParts {
35    pub config: ResolvedConfig,
36    pub config_path: Option<PathBuf>,
37    pub files: Vec<DiscoveredFile>,
38    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
39}
40
41/// Owned session parts after parsing the discovered files.
42#[derive(Debug)]
43pub struct ParsedAnalysisSessionParts {
44    pub config: ResolvedConfig,
45    pub config_path: Option<PathBuf>,
46    pub files: Vec<DiscoveredFile>,
47    pub modules: Vec<ModuleInfo>,
48    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
49    pub parse_ms: f64,
50    pub parse_cpu_ms: f64,
51}
52
53/// Reusable artifacts produced by one session-owned dead-code run.
54#[derive(Debug)]
55pub struct AnalysisSessionArtifacts {
56    pub analysis: DeadCodeAnalysisArtifacts,
57    pub changed_files: Option<FxHashSet<PathBuf>>,
58    pub source_fingerprints: FxHashMap<PathBuf, SourceFingerprint>,
59}
60
61/// Borrowed session view for callers that expose `&ResolvedConfig`.
62///
63/// This keeps existing helper signatures intact while routing discovery and
64/// analysis through the same session-owned orchestration shape as
65/// [`AnalysisSession`].
66struct AnalysisSessionView<'a> {
67    config: &'a ResolvedConfig,
68    discovery: crate::discover::AnalysisDiscovery,
69}
70
71impl<'a> AnalysisSessionView<'a> {
72    fn new(config: &'a ResolvedConfig) -> Self {
73        Self {
74            config,
75            discovery: core_backend::prepare_analysis_discovery(config),
76        }
77    }
78
79    fn analyze_dead_code(&self) -> EngineResult<DeadCodeAnalysis> {
80        core_backend::analyze_with_usages_from_discovery(self.config, &self.discovery)
81    }
82
83    fn analyze_dead_code_with_complexity(&self) -> EngineResult<DeadCodeAnalysisOutput> {
84        core_backend::analyze_with_usages_and_complexity_from_discovery(
85            self.config,
86            &self.discovery,
87        )
88    }
89
90    fn analyze_dead_code_with_artifacts(
91        &self,
92        need_complexity: bool,
93        retain_graph: bool,
94    ) -> EngineResult<DeadCodeAnalysisArtifacts> {
95        core_backend::analyze_retaining_modules_from_discovery(
96            self.config,
97            &self.discovery,
98            need_complexity,
99            retain_graph,
100        )
101    }
102}
103
104impl AnalysisSession {
105    /// Load config and discover files for a project root.
106    ///
107    /// # Errors
108    ///
109    /// Returns an error when config loading fails.
110    pub fn load(root: &Path, config_path: Option<&Path>) -> EngineResult<Self> {
111        let project_config = config_for_project(root, config_path)?;
112        Ok(Self::from_config(project_config))
113    }
114
115    /// Load config, apply one caller-supplied config adjustment, then discover
116    /// files for a project root.
117    ///
118    /// # Errors
119    ///
120    /// Returns an error when config loading fails.
121    pub fn load_with_config(
122        root: &Path,
123        config_path: Option<&Path>,
124        configure: impl FnOnce(&mut ResolvedConfig),
125    ) -> EngineResult<Self> {
126        let mut project_config = config_for_project(root, config_path)?;
127        configure(&mut project_config.config);
128        Ok(Self::from_config(project_config))
129    }
130
131    /// Build a session from built-in defaults, ignoring project config files.
132    ///
133    /// This is intended for editor fallback paths that have already reported a
134    /// config-load warning but should still surface best-effort diagnostics.
135    #[must_use]
136    pub fn load_default(root: &Path) -> Self {
137        Self::from_config(default_project_config(root))
138    }
139
140    /// Build a session from a previously resolved config.
141    #[must_use]
142    pub fn from_config(project_config: ProjectConfig) -> Self {
143        let discovery = core_backend::prepare_analysis_discovery(&project_config.config);
144        let workspace_diagnostics = merge_workspace_diagnostics(
145            project_config.workspace_diagnostics,
146            fallow_config::workspace_diagnostics_for(&project_config.config.root),
147        );
148        Self {
149            config: project_config.config,
150            config_path: project_config.path,
151            discovery,
152            workspace_diagnostics,
153        }
154    }
155
156    /// Build a session from a resolved config when the caller already owns
157    /// command-specific config loading.
158    #[must_use]
159    pub fn from_resolved_config(config: ResolvedConfig) -> Self {
160        Self::from_config(ProjectConfig {
161            config,
162            path: None,
163            workspace_diagnostics: Vec::new(),
164        })
165    }
166
167    /// Resolved project root.
168    #[must_use]
169    pub fn root(&self) -> &Path {
170        &self.config.root
171    }
172
173    /// Resolved project config.
174    #[must_use]
175    pub fn config(&self) -> &ResolvedConfig {
176        &self.config
177    }
178
179    /// Config file path when one was loaded.
180    #[must_use]
181    pub fn config_path(&self) -> Option<&Path> {
182        self.config_path.as_deref()
183    }
184
185    /// Discovered files for this session.
186    #[must_use]
187    pub fn files(&self) -> &[DiscoveredFile] {
188        self.discovery.files()
189    }
190
191    /// Source metadata fingerprints for every discovered source file.
192    #[must_use]
193    pub fn source_fingerprints(&self) -> FxHashMap<PathBuf, SourceFingerprint> {
194        self.discovery
195            .files()
196            .iter()
197            .map(|file| {
198                let fingerprint = std::fs::metadata(&file.path).map_or_else(
199                    |_| SourceFingerprint::new(0, file.size_bytes),
200                    |metadata| SourceFingerprint::from_metadata(&metadata),
201                );
202                (file.path.clone(), fingerprint)
203            })
204            .collect()
205    }
206
207    /// Resolve files changed since a git ref against this session root.
208    ///
209    /// # Errors
210    ///
211    /// Returns an error when the ref is invalid, git is unavailable, or the
212    /// root is not part of a repository.
213    pub fn changed_files_since(
214        &self,
215        git_ref: &str,
216    ) -> Result<FxHashSet<PathBuf>, crate::ChangedFilesError> {
217        crate::changed_files(&self.config.root, git_ref)
218    }
219
220    /// Workspace and source-discovery diagnostics captured for this session.
221    #[must_use]
222    pub fn workspace_diagnostics(&self) -> &[WorkspaceDiagnostic] {
223        &self.workspace_diagnostics
224    }
225
226    /// Consume the session and return the resolved config plus discovery data.
227    #[must_use]
228    pub fn into_parts(self) -> AnalysisSessionParts {
229        AnalysisSessionParts {
230            config: self.config,
231            config_path: self.config_path,
232            files: self.discovery.into_files(),
233            workspace_diagnostics: self.workspace_diagnostics,
234        }
235    }
236
237    /// Consume the session, load the parser cache, and parse discovered files.
238    #[must_use]
239    pub fn into_parsed_parts(self, need_complexity: bool) -> ParsedAnalysisSessionParts {
240        let AnalysisSessionParts {
241            config,
242            config_path,
243            files,
244            workspace_diagnostics,
245        } = self.into_parts();
246        let (parse_result, parse_ms) = parse_files_with_config(&config, &files, need_complexity);
247        ParsedAnalysisSessionParts {
248            config,
249            config_path,
250            files,
251            modules: parse_result.modules,
252            workspace_diagnostics,
253            parse_ms,
254            parse_cpu_ms: parse_result.parse_cpu_ms,
255        }
256    }
257
258    /// Run dead-code analysis for this session.
259    ///
260    /// # Errors
261    ///
262    /// Returns an error if parsing or analysis fails.
263    pub fn analyze_dead_code(&self) -> EngineResult<DeadCodeAnalysis> {
264        core_backend::analyze_with_usages_from_discovery(&self.config, &self.discovery)
265    }
266
267    /// Run dead-code analysis with retained complexity artifacts.
268    ///
269    /// # Errors
270    ///
271    /// Returns an error if parsing or analysis fails.
272    pub fn analyze_dead_code_with_complexity(&self) -> EngineResult<DeadCodeAnalysisOutput> {
273        core_backend::analyze_with_usages_and_complexity_from_discovery(
274            &self.config,
275            &self.discovery,
276        )
277    }
278
279    /// Run dead-code analysis with retained modules, discovered files and graph.
280    ///
281    /// # Errors
282    ///
283    /// Returns an error if parsing or analysis fails.
284    pub fn analyze_dead_code_with_artifacts(
285        &self,
286        need_complexity: bool,
287        retain_graph: bool,
288    ) -> EngineResult<DeadCodeAnalysisArtifacts> {
289        core_backend::analyze_retaining_modules_from_discovery(
290            &self.config,
291            &self.discovery,
292            need_complexity,
293            retain_graph,
294        )
295    }
296
297    /// Run dead-code analysis and return the session-scoped reuse artifacts.
298    ///
299    /// Callers pass a changed-file set they have already resolved for the
300    /// command. The returned value keeps that set beside parser, graph, and
301    /// source-fingerprint data so downstream runners do not have to rebuild or
302    /// rediscover the same inputs.
303    ///
304    /// # Errors
305    ///
306    /// Returns an error if parsing or analysis fails.
307    pub fn analyze_dead_code_with_session_artifacts(
308        &self,
309        need_complexity: bool,
310        retain_graph: bool,
311        changed_files: Option<FxHashSet<PathBuf>>,
312    ) -> EngineResult<AnalysisSessionArtifacts> {
313        Ok(AnalysisSessionArtifacts {
314            analysis: self.analyze_dead_code_with_artifacts(need_complexity, retain_graph)?,
315            changed_files,
316            source_fingerprints: self.source_fingerprints(),
317        })
318    }
319
320    /// Run duplication detection using the session's discovered files.
321    #[must_use]
322    pub fn find_duplicates(&self) -> duplicates::DuplicationReport {
323        duplicates::find_duplicates(&self.config.root, self.files(), &self.config.duplicates)
324    }
325
326    /// Run duplication detection using custom duplicate options.
327    #[must_use]
328    pub fn find_duplicates_with(&self, config: &DuplicatesConfig) -> duplicates::DuplicationReport {
329        duplicates::find_duplicates(&self.config.root, self.files(), config)
330    }
331
332    /// Run dead-code and duplication analysis for this session.
333    ///
334    /// When `retain_complexity_artifacts` is true, the dead-code result keeps
335    /// parser artifacts needed by editor overlays such as inline complexity.
336    ///
337    /// # Errors
338    ///
339    /// Returns an error if dead-code parsing or analysis fails.
340    pub fn analyze_project_with(
341        &self,
342        duplicates_config: &DuplicatesConfig,
343        retain_complexity_artifacts: bool,
344    ) -> EngineResult<ProjectAnalysisOutput> {
345        let dead_code = if retain_complexity_artifacts {
346            self.analyze_dead_code_with_complexity()?
347        } else {
348            let analysis = self.analyze_dead_code()?;
349            DeadCodeAnalysisOutput {
350                results: analysis.results,
351                modules: None,
352                files: None,
353            }
354        };
355        let duplication = self.find_duplicates_with(duplicates_config);
356        Ok(ProjectAnalysisOutput {
357            dead_code,
358            duplication,
359        })
360    }
361
362    /// Run duplication detection and return report sidecar metadata.
363    #[must_use]
364    pub fn find_duplicates_with_defaults(
365        &self,
366        config: &DuplicatesConfig,
367        cache_dir: Option<&Path>,
368    ) -> DuplicationAnalysis {
369        duplicates::find_duplicates_with_defaults(
370            &self.config.root,
371            self.files(),
372            config,
373            cache_dir,
374        )
375    }
376
377    /// Run focused duplication detection for a changed-file set.
378    #[must_use]
379    pub fn find_duplicates_touching_files_with_defaults(
380        &self,
381        config: &DuplicatesConfig,
382        changed_files: &[PathBuf],
383        cache_dir: Option<&Path>,
384    ) -> DuplicationAnalysis {
385        duplicates::find_duplicates_touching_files_with_defaults(
386            &self.config.root,
387            self.files(),
388            config,
389            changed_files,
390            cache_dir,
391        )
392    }
393}
394
395pub fn parse_files_for_config(
396    config: &ResolvedConfig,
397    files: &[DiscoveredFile],
398    need_complexity: bool,
399) -> ParseResult {
400    parse_files_with_config(config, files, need_complexity).0
401}
402
403fn merge_workspace_diagnostics(
404    primary: Vec<WorkspaceDiagnostic>,
405    secondary: Vec<WorkspaceDiagnostic>,
406) -> Vec<WorkspaceDiagnostic> {
407    let mut merged = Vec::with_capacity(primary.len() + secondary.len());
408    let mut seen: FxHashSet<(String, PathBuf)> = FxHashSet::default();
409    for diagnostic in primary.into_iter().chain(secondary) {
410        let key = (diagnostic.kind.id().to_owned(), diagnostic.path.clone());
411        if seen.insert(key) {
412            merged.push(diagnostic);
413        }
414    }
415    merged
416}
417
418fn parse_files_with_config(
419    config: &ResolvedConfig,
420    files: &[DiscoveredFile],
421    need_complexity: bool,
422) -> (ParseResult, f64) {
423    let parse_start = Instant::now();
424    let cache = if config.no_cache {
425        None
426    } else {
427        fallow_extract::cache::CacheStore::load(
428            &config.cache_dir,
429            config.cache_config_hash,
430            crate::resolve_cache_max_size_bytes(config),
431        )
432    };
433    let parse_result = crate::source::parse_all_files(files, cache.as_ref(), need_complexity);
434    (parse_result, parse_start.elapsed().as_secs_f64() * 1000.0)
435}
436
437pub fn analyze_dead_code_from_config(config: &ResolvedConfig) -> EngineResult<DeadCodeAnalysis> {
438    AnalysisSessionView::new(config).analyze_dead_code()
439}
440
441pub fn analyze_dead_code_with_complexity_from_config(
442    config: &ResolvedConfig,
443) -> EngineResult<DeadCodeAnalysisOutput> {
444    AnalysisSessionView::new(config).analyze_dead_code_with_complexity()
445}
446
447pub fn analyze_dead_code_with_artifacts_from_config(
448    config: &ResolvedConfig,
449    need_complexity: bool,
450    retain_graph: bool,
451) -> EngineResult<DeadCodeAnalysisArtifacts> {
452    AnalysisSessionView::new(config).analyze_dead_code_with_artifacts(need_complexity, retain_graph)
453}