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::time::{Duration, Instant};
7
8const IDLE_UNLOAD: Duration = Duration::from_secs(20 * 60);
9
10impl Weavatrix {
11 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 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 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 pub fn open_repository(&mut self, root: impl AsRef<Path>) -> Result<()> {
87 self.open_repository_with_build(root, true)?;
88 Ok(())
89 }
90
91 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 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 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}