weavatrix_rust/engine/
session.rs1use 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 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 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 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 pub fn open_repository(&mut self, root: impl AsRef<Path>) -> Result<()> {
88 self.open_repository_with_build(root, true)?;
89 Ok(())
90 }
91
92 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 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 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}