Skip to main content

synwire_daemon/
indexing.rs

1//! Daemon-side indexing coordinator.
2//!
3//! Wires the [`DependencyIndex`] (from `synwire-storage`) and the
4//! [`XrefGraph`] (from `synwire-index`) into a single coordinator that the
5//! daemon uses to manage global dependency tracking and cross-project
6//! symbol references.
7
8#![forbid(unsafe_code)]
9
10use std::path::Path;
11use std::sync::Mutex;
12
13use synwire_index::{XrefDirection, XrefEdge, XrefGraph, rebuild_project_xrefs, xref_query};
14use synwire_storage::{
15    DependencyEntry, DependencyIndex, DependencyIndexError, StorageLayout, WorktreeId,
16};
17
18/// Errors produced by [`IndexingCoordinator`].
19#[derive(Debug, thiserror::Error)]
20#[non_exhaustive]
21pub enum IndexingError {
22    /// An error from the underlying storage layer.
23    #[error("storage error: {0}")]
24    Storage(#[from] synwire_storage::StorageError),
25    /// An error from the dependency index database.
26    #[error("dependency index error: {0}")]
27    DependencyIndex(#[from] DependencyIndexError),
28    /// An error from the cross-project reference graph.
29    #[error("xref error: {0}")]
30    Xref(String),
31    /// An I/O error.
32    #[error("io error: {0}")]
33    Io(#[from] std::io::Error),
34}
35
36/// Daemon-side coordinator for global dependency indexing and cross-project
37/// symbol reference tracking.
38///
39/// Owns a [`DependencyIndex`] (backed by `SQLite` at
40/// `StorageLayout::global_dependency_db()`) and an in-memory [`XrefGraph`]
41/// for cross-project symbol edges.
42///
43/// # Thread safety
44///
45/// `IndexingCoordinator` is `Send + Sync` -- the [`DependencyIndex`] uses an
46/// internal `Mutex<Connection>` and the [`XrefGraph`] is wrapped in a
47/// `Mutex` here.
48pub struct IndexingCoordinator {
49    /// The storage layout used to resolve paths.
50    layout: StorageLayout,
51    /// Global cross-project dependency index.
52    dep_index: DependencyIndex,
53    /// In-memory cross-project symbol reference graph.
54    xref_graph: Mutex<XrefGraph>,
55}
56
57impl IndexingCoordinator {
58    /// Create a new indexing coordinator.
59    ///
60    /// Opens (or creates) the dependency index database at
61    /// `layout.global_dependency_db()` and initialises an empty cross-project
62    /// reference graph.
63    ///
64    /// # Errors
65    ///
66    /// Returns [`IndexingError::DependencyIndex`] if the database cannot be
67    /// opened or initialised.
68    pub fn new(layout: &StorageLayout) -> Result<Self, IndexingError> {
69        let dep_db_path = layout.global_dependency_db();
70        let dep_index = DependencyIndex::open(&dep_db_path)?;
71
72        Ok(Self {
73            layout: layout.clone(),
74            dep_index,
75            xref_graph: Mutex::new(XrefGraph::new()),
76        })
77    }
78
79    /// Parse project manifests and index their dependencies into the global
80    /// dependency database.
81    ///
82    /// Detects the manifest type (`Cargo.toml`, `go.mod`, `package.json`,
83    /// `pyproject.toml`) from files present in `project_root` and inserts all
84    /// discovered dependencies.  Returns the count of dependencies indexed.
85    ///
86    /// # Errors
87    ///
88    /// Returns [`IndexingError::DependencyIndex`] if the manifest cannot be
89    /// read, parsed, or written to the database.
90    pub fn index_project_deps(&self, project_root: &Path) -> Result<usize, IndexingError> {
91        let count = self.dep_index.index_project(project_root)?;
92        Ok(count)
93    }
94
95    /// Rebuild cross-project symbol references for the given worktree.
96    ///
97    /// Marks existing edges for this project as stale, prunes them, and
98    /// inserts the provided `new_edges`.  Returns the number of new edges
99    /// added.
100    ///
101    /// The project identifier used for the xref graph is derived from the
102    /// worktree's graph directory path.
103    ///
104    /// # Errors
105    ///
106    /// Returns [`IndexingError::Xref`] if the internal mutex is poisoned.
107    #[allow(clippy::significant_drop_tightening)]
108    pub fn rebuild_xrefs(
109        &self,
110        worktree_id: &WorktreeId,
111        new_edges: Vec<XrefEdge>,
112    ) -> Result<usize, IndexingError> {
113        let graph_dir = self.layout.graph_dir(worktree_id);
114        let project_key = graph_dir.to_string_lossy().into_owned();
115
116        let mut graph = self
117            .xref_graph
118            .lock()
119            .map_err(|e| IndexingError::Xref(format!("xref graph lock poisoned: {e}")))?;
120
121        let count = rebuild_project_xrefs(&mut graph, &project_key, new_edges);
122        Ok(count)
123    }
124
125    /// Query cross-project symbol references for a given symbol within a
126    /// worktree context.
127    ///
128    /// Returns all non-stale [`XrefEdge`]s that reference `symbol` in either
129    /// direction (incoming and outgoing).
130    ///
131    /// # Errors
132    ///
133    /// Returns [`IndexingError::Xref`] if the internal mutex is poisoned.
134    #[allow(clippy::significant_drop_tightening)]
135    pub fn query_xrefs(
136        &self,
137        symbol: &str,
138        _worktree_id: &WorktreeId,
139    ) -> Result<Vec<XrefEdge>, IndexingError> {
140        let graph = self
141            .xref_graph
142            .lock()
143            .map_err(|e| IndexingError::Xref(format!("xref graph lock poisoned: {e}")))?;
144
145        let edges = xref_query(&graph, symbol, XrefDirection::Both);
146        Ok(edges.into_iter().cloned().collect())
147    }
148
149    /// Query which projects depend on the named library.
150    ///
151    /// Delegates to [`DependencyIndex::projects_using`].
152    ///
153    /// # Errors
154    ///
155    /// Returns [`IndexingError::DependencyIndex`] if the database query fails.
156    pub fn projects_using_dep(
157        &self,
158        dep_name: &str,
159    ) -> Result<Vec<DependencyEntry>, IndexingError> {
160        let entries = self.dep_index.projects_using(dep_name)?;
161        Ok(entries)
162    }
163
164    /// Query all dependencies of a given project.
165    ///
166    /// Delegates to [`DependencyIndex::dependencies_of`].
167    ///
168    /// # Errors
169    ///
170    /// Returns [`IndexingError::DependencyIndex`] if the database query fails.
171    pub fn project_dependencies(
172        &self,
173        project_path: &str,
174    ) -> Result<Vec<DependencyEntry>, IndexingError> {
175        let entries = self.dep_index.dependencies_of(project_path)?;
176        Ok(entries)
177    }
178}
179
180// Ensure the public API is `Send + Sync`.
181const _: () = {
182    const fn assert_send_sync<T: Send + Sync>() {}
183    const fn check() {
184        assert_send_sync::<IndexingCoordinator>();
185    }
186    let _ = check;
187};
188
189#[cfg(test)]
190#[allow(clippy::expect_used, clippy::unwrap_used)]
191mod tests {
192    use super::*;
193    use tempfile::tempdir;
194
195    fn test_layout(dir: &Path) -> StorageLayout {
196        StorageLayout::with_root(dir, "synwire")
197    }
198
199    fn dummy_worktree() -> WorktreeId {
200        use synwire_storage::identity::RepoId;
201        WorktreeId::from_parts(
202            RepoId::from_string("abc123"),
203            "def456789012".to_owned(),
204            "myrepo@main".to_owned(),
205        )
206    }
207
208    #[test]
209    fn coordinator_indexes_cargo_project() {
210        let dir = tempdir().expect("tempdir");
211        let layout = test_layout(dir.path());
212        let coordinator = IndexingCoordinator::new(&layout).expect("new");
213
214        // Create a project with a Cargo.toml.
215        let project_dir = dir.path().join("my-project");
216        std::fs::create_dir_all(&project_dir).expect("create dir");
217        std::fs::write(
218            project_dir.join("Cargo.toml"),
219            "[package]\nname = \"test\"\n\n[dependencies]\nserde = \"1\"\ntokio = \"1\"\n",
220        )
221        .expect("write Cargo.toml");
222
223        let count = coordinator
224            .index_project_deps(&project_dir)
225            .expect("index_project_deps");
226        assert!(count >= 2);
227    }
228
229    #[test]
230    fn coordinator_queries_deps() {
231        let dir = tempdir().expect("tempdir");
232        let layout = test_layout(dir.path());
233        let coordinator = IndexingCoordinator::new(&layout).expect("new");
234
235        let project_dir = dir.path().join("proj-a");
236        std::fs::create_dir_all(&project_dir).expect("create dir");
237        std::fs::write(
238            project_dir.join("Cargo.toml"),
239            "[package]\nname = \"a\"\n\n[dependencies]\nserde = \"1\"\n",
240        )
241        .expect("write Cargo.toml");
242
243        let _ = coordinator.index_project_deps(&project_dir).expect("index");
244
245        let projects = coordinator.projects_using_dep("serde").expect("query");
246        assert!(!projects.is_empty());
247
248        let deps = coordinator
249            .project_dependencies(&project_dir.to_string_lossy())
250            .expect("deps");
251        assert!(!deps.is_empty());
252    }
253
254    #[test]
255    fn coordinator_rebuilds_and_queries_xrefs() {
256        let dir = tempdir().expect("tempdir");
257        let layout = test_layout(dir.path());
258        let coordinator = IndexingCoordinator::new(&layout).expect("new");
259        let wid = dummy_worktree();
260
261        let edges = vec![
262            XrefEdge::new("proj_a", "proj_a::Foo", "proj_b", "proj_b::Bar"),
263            XrefEdge::new("proj_a", "proj_a::Baz", "proj_c", "proj_c::Qux"),
264        ];
265
266        let count = coordinator
267            .rebuild_xrefs(&wid, edges)
268            .expect("rebuild_xrefs");
269        assert_eq!(count, 2);
270
271        let results = coordinator
272            .query_xrefs("proj_b::Bar", &wid)
273            .expect("query_xrefs");
274        assert_eq!(results.len(), 1);
275        assert_eq!(results[0].source_symbol, "proj_a::Foo");
276    }
277
278    #[test]
279    fn coordinator_xref_rebuild_replaces_old_edges() {
280        let dir = tempdir().expect("tempdir");
281        let layout = test_layout(dir.path());
282        let coordinator = IndexingCoordinator::new(&layout).expect("new");
283        let wid = dummy_worktree();
284
285        // The project key used internally is the graph_dir path for this
286        // worktree.  Edges must reference this key as source/target project
287        // for `rebuild_project_xrefs` to correctly mark them stale.
288        let project_key = layout.graph_dir(&wid).to_string_lossy().into_owned();
289
290        // First build: one edge.
291        let edges1 = vec![XrefEdge::new(
292            &project_key,
293            "proj_a::OldSym",
294            "proj_b",
295            "proj_b::Target",
296        )];
297        let _ = coordinator.rebuild_xrefs(&wid, edges1).expect("rebuild 1");
298
299        // Second build: replace with a new edge.
300        let edges2 = vec![XrefEdge::new(
301            &project_key,
302            "proj_a::NewSym",
303            "proj_b",
304            "proj_b::Target",
305        )];
306        let count = coordinator.rebuild_xrefs(&wid, edges2).expect("rebuild 2");
307        assert_eq!(count, 1);
308
309        // Old edge should be gone.
310        let old = coordinator
311            .query_xrefs("proj_a::OldSym", &wid)
312            .expect("query old");
313        assert!(old.is_empty());
314
315        // New edge should be present.
316        let new = coordinator
317            .query_xrefs("proj_a::NewSym", &wid)
318            .expect("query new");
319        assert_eq!(new.len(), 1);
320    }
321
322    #[test]
323    fn coordinator_indexes_go_mod_project() {
324        let dir = tempdir().expect("tempdir");
325        let layout = test_layout(dir.path());
326        let coordinator = IndexingCoordinator::new(&layout).expect("new");
327
328        let project_dir = dir.path().join("go-project");
329        std::fs::create_dir_all(&project_dir).expect("create dir");
330        std::fs::write(
331            project_dir.join("go.mod"),
332            "module example.com/app\n\ngo 1.21\n\nrequire (\n\tgithub.com/gin-gonic/gin v1.9.1\n)\n",
333        )
334        .expect("write go.mod");
335
336        let count = coordinator.index_project_deps(&project_dir).expect("index");
337        assert_eq!(count, 1);
338
339        let projects = coordinator
340            .projects_using_dep("github.com/gin-gonic/gin")
341            .expect("query");
342        assert!(!projects.is_empty());
343    }
344
345    #[test]
346    fn coordinator_indexes_package_json_project() {
347        let dir = tempdir().expect("tempdir");
348        let layout = test_layout(dir.path());
349        let coordinator = IndexingCoordinator::new(&layout).expect("new");
350
351        let project_dir = dir.path().join("node-project");
352        std::fs::create_dir_all(&project_dir).expect("create dir");
353        std::fs::write(
354            project_dir.join("package.json"),
355            r#"{"name":"app","dependencies":{"react":"^18.0.0","axios":"^1.0.0"}}"#,
356        )
357        .expect("write package.json");
358
359        let count = coordinator.index_project_deps(&project_dir).expect("index");
360        assert_eq!(count, 2);
361    }
362}