Skip to main content

weavatrix_rust/
engine.rs

1use crate::{Analyzer, Result, Snapshot};
2use blazingly_json::Value;
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, OnceLock};
6use std::time::{Duration, Instant};
7use weavatrix_graph::{Graph, Node, NodeIndex, weakly_connected_components};
8use weavatrix_scan::ScanReport;
9
10#[derive(Debug, Clone)]
11pub struct RepositoryState {
12    root: PathBuf,
13    snapshot: Snapshot,
14    graph: Arc<Graph>,
15    scan: ScanReport,
16    build_time: Duration,
17    weak_components: Arc<OnceLock<Vec<Vec<NodeIndex>>>>,
18}
19
20impl RepositoryState {
21    pub(crate) fn build(analyzer: &Analyzer, root: impl AsRef<Path>) -> Result<Self> {
22        let started = Instant::now();
23        let (snapshot, scan) = analyzer.analyze_with_report(root)?;
24        let graph = Graph::try_from_sorted_parts(snapshot.nodes.clone(), snapshot.edges.clone())?;
25        let snapshot_root = PathBuf::from(&snapshot.repository);
26        let root = snapshot_root
27            .canonicalize()
28            .map_err(|source| crate::Error::io(&snapshot_root, source))?;
29        Ok(Self {
30            root,
31            snapshot,
32            graph: Arc::new(graph),
33            scan,
34            build_time: started.elapsed(),
35            weak_components: Arc::new(OnceLock::new()),
36        })
37    }
38
39    fn from_scan(analyzer: &Analyzer, root: &Path, scan: ScanReport) -> Result<Self> {
40        let started = Instant::now();
41        let snapshot = analyzer.analyze_report(root, &scan)?;
42        let graph = Graph::try_from_sorted_parts(snapshot.nodes.clone(), snapshot.edges.clone())?;
43        Ok(Self {
44            root: root.to_path_buf(),
45            snapshot,
46            graph: Arc::new(graph),
47            scan,
48            build_time: started.elapsed(),
49            weak_components: Arc::new(OnceLock::new()),
50        })
51    }
52
53    #[must_use]
54    pub fn root(&self) -> &Path {
55        &self.root
56    }
57
58    #[must_use]
59    pub const fn snapshot(&self) -> &Snapshot {
60        &self.snapshot
61    }
62
63    #[must_use]
64    pub fn graph(&self) -> &Graph {
65        self.graph.as_ref()
66    }
67
68    #[must_use]
69    pub const fn build_time(&self) -> Duration {
70        self.build_time
71    }
72
73    #[must_use]
74    pub const fn scan_report(&self) -> &ScanReport {
75        &self.scan
76    }
77
78    pub(crate) fn weak_components(&self) -> &[Vec<NodeIndex>] {
79        self.weak_components
80            .get_or_init(|| {
81                let mut components = weakly_connected_components(self.graph.as_ref());
82                components.sort_unstable_by_key(|right| std::cmp::Reverse(right.len()));
83                components
84            })
85            .as_slice()
86    }
87
88    #[cfg(feature = "mcp")]
89    pub(crate) fn prime_weak_components(&self) {
90        if self.weak_components.get().is_some() {
91            return;
92        }
93        let graph = Arc::clone(&self.graph);
94        let destination = Arc::clone(&self.weak_components);
95        std::thread::spawn(move || {
96            // Let the first MCP response leave the process before using another
97            // core. A direct first call to get_community still initializes the
98            // same OnceLock immediately and this delayed worker simply waits.
99            std::thread::sleep(Duration::from_millis(10));
100            destination.get_or_init(|| {
101                let mut components = weakly_connected_components(graph.as_ref());
102                components.sort_unstable_by_key(|right| std::cmp::Reverse(right.len()));
103                components
104            });
105        });
106    }
107
108    pub(crate) fn resolve_node(&self, label: &str) -> std::result::Result<NodeIndex, String> {
109        if let Some(index) = self.graph.node_index(label) {
110            return Ok(index);
111        }
112        let matches = self
113            .graph
114            .nodes()
115            .iter()
116            .enumerate()
117            .filter(|(_, node)| node.label == label)
118            .collect::<Vec<_>>();
119        match matches.as_slice() {
120            [] => Err(format!("node not found: {label}")),
121            [(index, _)] => Ok(NodeIndex::new(
122                u32::try_from(*index).map_err(|_| "node index overflow")?,
123            )),
124            _ => Err(format!(
125                "ambiguous node label {label:?}; use one of: {}",
126                matches
127                    .iter()
128                    .take(8)
129                    .map(|(_, node)| node.id.as_str())
130                    .collect::<Vec<_>>()
131                    .join(", ")
132            )),
133        }
134    }
135
136    pub(crate) fn node(&self, index: NodeIndex) -> std::result::Result<&Node, String> {
137        self.graph
138            .node_at(index)
139            .ok_or_else(|| format!("node index out of range: {}", index.index()))
140    }
141}
142
143pub struct Weavatrix {
144    analyzer: Analyzer,
145    state: RepositoryState,
146    known_states: BTreeMap<PathBuf, RepositoryState>,
147    tool_cache: BTreeMap<String, Value>,
148}
149
150impl Weavatrix {
151    /// Opens and analyzes one local repository without running its code.
152    ///
153    /// # Errors
154    ///
155    /// Returns scan, parser, or graph validation failures.
156    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
157        let analyzer = Analyzer::default();
158        let state = RepositoryState::build(&analyzer, root)?;
159        let known_states = BTreeMap::from([(state.root.clone(), state.clone())]);
160        Ok(Self {
161            analyzer,
162            state,
163            known_states,
164            tool_cache: BTreeMap::new(),
165        })
166    }
167
168    pub(crate) fn from_state(state: RepositoryState) -> Self {
169        let known_states = BTreeMap::from([(state.root.clone(), state.clone())]);
170        Self {
171            analyzer: Analyzer::default(),
172            state,
173            known_states,
174            tool_cache: BTreeMap::new(),
175        }
176    }
177
178    #[must_use]
179    pub const fn state(&self) -> &RepositoryState {
180        &self.state
181    }
182
183    /// Rebuilds only the derived in-memory snapshot.
184    ///
185    /// # Errors
186    ///
187    /// Returns scan, parser, or graph validation failures.
188    pub fn rebuild(&mut self) -> Result<()> {
189        self.state = RepositoryState::build(&self.analyzer, &self.state.root)?;
190        self.tool_cache.clear();
191        self.remember_active_state();
192        Ok(())
193    }
194
195    /// Checks the incremental scanner revision and rebuilds only when source
196    /// evidence changed.
197    ///
198    /// # Errors
199    ///
200    /// Returns scan, parser, or graph validation failures.
201    pub fn refresh_if_stale(&mut self) -> Result<bool> {
202        let scan = self
203            .analyzer
204            .scan(&self.state.root, Some(&self.state.scan))?;
205        if scan.revision == self.state.scan.revision {
206            self.state.scan = scan;
207            self.remember_active_state();
208            return Ok(false);
209        }
210        self.state = RepositoryState::from_scan(&self.analyzer, &self.state.root, scan)?;
211        self.tool_cache.clear();
212        self.remember_active_state();
213        Ok(true)
214    }
215
216    /// Retargets this process to another local repository.
217    ///
218    /// # Errors
219    ///
220    /// Returns scan, parser, or graph validation failures.
221    pub fn open_repository(&mut self, root: impl AsRef<Path>) -> Result<()> {
222        self.open_repository_with_build(root, true)?;
223        Ok(())
224    }
225
226    /// Retargets this process, optionally requiring a fresh graph build.
227    ///
228    /// With `build == false`, only a repository already opened by this process
229    /// can be activated. Its exact analyzed state is retained in memory, so a
230    /// no-build switch never scans or executes repository code.
231    ///
232    /// # Errors
233    ///
234    /// Returns scan/parser failures for a requested build, or a concrete
235    /// missing-cache error for a no-build request.
236    pub fn open_repository_with_build(
237        &mut self,
238        root: impl AsRef<Path>,
239        build: bool,
240    ) -> Result<bool> {
241        if build {
242            let state = RepositoryState::build(&self.analyzer, root)?;
243            self.known_states
244                .insert(self.state.root.clone(), self.state.clone());
245            self.state = state;
246            self.tool_cache.clear();
247            self.remember_active_state();
248            return Ok(true);
249        }
250
251        let requested = root
252            .as_ref()
253            .canonicalize()
254            .map_err(|source| crate::Error::io(root.as_ref(), source))?;
255        if requested == self.state.root {
256            return Ok(false);
257        }
258        let cached = self.known_states.get(&requested).cloned().ok_or_else(|| {
259            crate::Error::Analysis(format!(
260                "no in-process graph for {}; call open_repo with build:true first",
261                requested.display()
262            ))
263        })?;
264        self.known_states
265            .insert(self.state.root.clone(), self.state.clone());
266        self.state = cached;
267        self.tool_cache.clear();
268        Ok(false)
269    }
270
271    pub fn known_roots(&self) -> impl Iterator<Item = &Path> {
272        self.known_states.keys().map(PathBuf::as_path)
273    }
274
275    pub(crate) fn ensure_repository_state(&mut self, root: impl AsRef<Path>) -> Result<PathBuf> {
276        let requested = root
277            .as_ref()
278            .canonicalize()
279            .map_err(|source| crate::Error::io(root.as_ref(), source))?;
280        if requested == self.state.root || self.known_states.contains_key(&requested) {
281            return Ok(requested);
282        }
283        let state = RepositoryState::build(&self.analyzer, &requested)?;
284        self.known_states.insert(requested.clone(), state);
285        Ok(requested)
286    }
287
288    pub(crate) fn known_state(&self, root: &Path) -> Option<&RepositoryState> {
289        if root == self.state.root {
290            Some(&self.state)
291        } else {
292            self.known_states.get(root)
293        }
294    }
295
296    pub(crate) fn cached_tool_result(&self, key: &str) -> Option<Value> {
297        self.tool_cache.get(key).cloned()
298    }
299
300    pub(crate) fn remember_tool_result(&mut self, key: String, value: Value) {
301        const MAX_TOOL_CACHE_ENTRIES: usize = 32;
302        if self.tool_cache.len() >= MAX_TOOL_CACHE_ENTRIES {
303            self.tool_cache.clear();
304        }
305        self.tool_cache.insert(key, value);
306    }
307
308    fn remember_active_state(&mut self) {
309        self.known_states
310            .insert(self.state.root.clone(), self.state.clone());
311    }
312}