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