Skip to main content

weavatrix_rust/engine/
session.rs

1use super::{RepositoryState, Weavatrix};
2use crate::analyzer::Analyzer;
3use crate::model::{Error, Result};
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use std::time::{Duration, Instant};
8
9const IDLE_UNLOAD: Duration = Duration::from_secs(20 * 60);
10
11impl Weavatrix {
12    /// Opens and analyzes one local repository without running its code.
13    ///
14    /// # Errors
15    ///
16    /// Returns scan, parser, or graph validation failures.
17    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
18        let analyzer = Analyzer::default();
19        let state = RepositoryState::build(&analyzer, root)?;
20        let known_states = BTreeMap::from([(state.root.clone(), state.clone())]);
21        let last_used = BTreeMap::from([(state.root.clone(), Instant::now())]);
22        Ok(Self {
23            analyzer,
24            state,
25            known_states,
26            last_used,
27            tool_cache: BTreeMap::new(),
28        })
29    }
30
31    pub(crate) fn from_state(state: RepositoryState) -> Self {
32        let known_states = BTreeMap::from([(state.root.clone(), state.clone())]);
33        let last_used = BTreeMap::from([(state.root.clone(), Instant::now())]);
34        Self {
35            analyzer: Analyzer::default(),
36            state,
37            known_states,
38            last_used,
39            tool_cache: BTreeMap::new(),
40        }
41    }
42
43    #[must_use]
44    pub const fn state(&self) -> &RepositoryState {
45        &self.state
46    }
47
48    /// Rebuilds only the derived in-memory snapshot.
49    ///
50    /// # Errors
51    ///
52    /// Returns scan, parser, or graph validation failures.
53    pub fn rebuild(&mut self) -> Result<()> {
54        self.prepare();
55        self.state = RepositoryState::build(&self.analyzer, &self.state.root)?;
56        self.tool_cache.clear();
57        self.remember_active_state();
58        Ok(())
59    }
60
61    /// Checks the incremental scanner revision and rebuilds only when source
62    /// evidence changed.
63    ///
64    /// # Errors
65    ///
66    /// Returns scan, parser, or graph validation failures.
67    pub fn refresh_if_stale(&mut self) -> Result<bool> {
68        let scan = self
69            .analyzer
70            .scan(&self.state.root, Some(self.state.scan.as_ref()))?;
71        if scan.revision == self.state.scan.revision {
72            self.state.scan = Arc::new(scan);
73            self.remember_active_state();
74            return Ok(false);
75        }
76        self.state = RepositoryState::from_scan(&self.analyzer, &self.state.root, scan)?;
77        self.tool_cache.clear();
78        self.remember_active_state();
79        Ok(true)
80    }
81
82    /// Retargets this process to another local repository.
83    ///
84    /// # Errors
85    ///
86    /// Returns scan, parser, or graph validation failures.
87    pub fn open_repository(&mut self, root: impl AsRef<Path>) -> Result<()> {
88        self.open_repository_with_build(root, true)?;
89        Ok(())
90    }
91
92    /// Retargets this process, optionally requiring a fresh graph build.
93    ///
94    /// With `build == false`, a cached graph is reused when it is still
95    /// loaded. A root that was unloaded is scanned from that folder again.
96    ///
97    /// # Errors
98    ///
99    /// Returns scan/parser failures for a requested build.
100    pub fn open_repository_with_build(
101        &mut self,
102        root: impl AsRef<Path>,
103        build: bool,
104    ) -> Result<bool> {
105        self.prepare();
106        if build {
107            return self.switch_to_built(root.as_ref());
108        }
109
110        let requested = root
111            .as_ref()
112            .canonicalize()
113            .map_err(|source| Error::io(root.as_ref(), source))?;
114        if requested == self.state.root {
115            return Ok(false);
116        }
117        if let Some(cached) = self.known_states.get(&requested).cloned() {
118            self.known_states
119                .insert(self.state.root.clone(), self.state.clone());
120            self.state = cached;
121            self.tool_cache.clear();
122            self.touch(&self.state.root.clone());
123            return Ok(false);
124        }
125        self.switch_to_built(&requested)
126    }
127
128    pub fn known_roots(&self) -> impl Iterator<Item = &Path> {
129        self.known_states.keys().map(PathBuf::as_path)
130    }
131
132    pub(crate) fn ensure_repository_state(&mut self, root: impl AsRef<Path>) -> Result<PathBuf> {
133        let requested = root
134            .as_ref()
135            .canonicalize()
136            .map_err(|source| Error::io(root.as_ref(), source))?;
137        if requested == self.state.root || self.known_states.contains_key(&requested) {
138            self.touch(&requested);
139            return Ok(requested);
140        }
141        let state = RepositoryState::build(&self.analyzer, &requested)?;
142        self.known_states.insert(requested.clone(), state);
143        self.touch(&requested);
144        Ok(requested)
145    }
146
147    pub(crate) fn known_state(&self, root: &Path) -> Option<&RepositoryState> {
148        if root == self.state.root {
149            Some(&self.state)
150        } else {
151            self.known_states.get(root)
152        }
153    }
154
155    pub(crate) fn cached_tool_result(&self, key: &str) -> Option<blazingly_json::Value> {
156        self.tool_cache.get(key).cloned()
157    }
158
159    pub(crate) fn remember_tool_result(&mut self, key: String, value: blazingly_json::Value) {
160        const MAX_TOOL_CACHE_ENTRIES: usize = 32;
161        if self.tool_cache.len() >= MAX_TOOL_CACHE_ENTRIES {
162            self.tool_cache.clear();
163        }
164        self.tool_cache.insert(key, value);
165    }
166
167    pub(crate) fn prepare(&mut self) {
168        self.unload_idle(IDLE_UNLOAD);
169        self.touch(&self.state.root.clone());
170    }
171
172    fn switch_to_built(&mut self, root: &Path) -> Result<bool> {
173        let state = RepositoryState::build(&self.analyzer, root)?;
174        self.known_states
175            .insert(self.state.root.clone(), self.state.clone());
176        self.state = state;
177        self.tool_cache.clear();
178        self.remember_active_state();
179        Ok(true)
180    }
181
182    fn remember_active_state(&mut self) {
183        self.known_states
184            .insert(self.state.root.clone(), self.state.clone());
185        self.touch(&self.state.root.clone());
186    }
187
188    fn touch(&mut self, root: &Path) {
189        self.last_used.insert(root.to_path_buf(), Instant::now());
190    }
191
192    fn unload_idle(&mut self, max_idle: Duration) {
193        self.unload_idle_at(Instant::now(), max_idle);
194    }
195
196    fn unload_idle_at(&mut self, now: Instant, max_idle: Duration) {
197        let active = self.state.root.clone();
198        let before = self.known_states.len();
199        // Keep the live graph and any root requested in the last window.
200        // Related-but-unasked roots are not kept.
201        self.known_states.retain(|root, _| {
202            *root == active
203                || self
204                    .last_used
205                    .get(root)
206                    .is_some_and(|used| now.saturating_duration_since(*used) < max_idle)
207        });
208        self.last_used.retain(|root, used| {
209            *root == active || now.saturating_duration_since(*used) < max_idle
210        });
211        if self.known_states.len() != before {
212            self.tool_cache.clear();
213        }
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use std::fs;
221    use std::sync::atomic::{AtomicU64, Ordering};
222
223    static SEQUENCE: AtomicU64 = AtomicU64::new(0);
224
225    fn fixture(name: &str) -> PathBuf {
226        let root = std::env::temp_dir().join(format!(
227            "weavatrix-idle-{}-{}-{}",
228            std::process::id(),
229            SEQUENCE.fetch_add(1, Ordering::Relaxed),
230            name
231        ));
232        fs::create_dir_all(root.join("src")).unwrap();
233        fs::write(root.join("src/lib.rs"), format!("pub fn {name}() {{}}\n")).unwrap();
234        root
235    }
236
237    #[test]
238    fn unused_repos_unload_and_a_later_request_rescans_that_folder() {
239        let keep_a = fixture("keep_a");
240        let keep_b = fixture("keep_b");
241        let keep_c = fixture("keep_c");
242        let idle = fixture("idle_x");
243        let mut engine = Weavatrix::open(&keep_a).expect("open a");
244        engine.open_repository(&keep_b).expect("open b");
245        engine.open_repository(&idle).expect("open idle");
246        engine.open_repository(&keep_c).expect("open c");
247        assert_eq!(engine.known_roots().count(), 4);
248
249        let idle_root = engine
250            .known_roots()
251            .find(|root| {
252                root.file_name()
253                    .and_then(|name| name.to_str())
254                    .is_some_and(|name| name.ends_with("idle_x"))
255            })
256            .expect("idle root")
257            .to_path_buf();
258        // A fresh CI host may have booted seconds ago, so the past is not
259        // reachable by subtraction; the idle window is created by judging
260        // from a future instant instead.
261        let future = Instant::now()
262            .checked_add(IDLE_UNLOAD + Duration::from_secs(1))
263            .expect("clock supports idle window");
264        let roots: Vec<PathBuf> = engine.known_roots().map(Path::to_path_buf).collect();
265        for root in roots {
266            if root != idle_root {
267                engine.last_used.insert(root, future);
268            }
269        }
270        engine.unload_idle_at(future, IDLE_UNLOAD);
271
272        let remaining: Vec<_> = engine.known_roots().collect();
273        assert_eq!(remaining.len(), 3, "only the working set should stay");
274        assert!(
275            !remaining.contains(&idle_root.as_path()),
276            "unasked repo must unload"
277        );
278
279        let rebuilt = engine
280            .open_repository_with_build(&idle_root, false)
281            .expect("requesting an unloaded folder rescans it");
282        assert!(rebuilt, "missing graph must scan the folder from disk");
283        assert!(
284            engine.known_roots().any(|root| root == idle_root.as_path()),
285            "rescanned folder must be loaded again"
286        );
287
288        let _ = fs::remove_dir_all(keep_a);
289        let _ = fs::remove_dir_all(keep_b);
290        let _ = fs::remove_dir_all(keep_c);
291        let _ = fs::remove_dir_all(idle);
292    }
293}