Skip to main content

sqry_daemon/workspace/
builder.rs

1//! Workspace graph builder abstraction.
2//!
3//! [`WorkspaceBuilder`] is the dependency-injection seam between the
4//! daemon's workspace manager and sqry-core's full graph-build
5//! pipeline. Production code wraps
6//! [`sqry_core::graph::unified::build::build_unified_graph`] via
7//! [`RealWorkspaceBuilder`]; unit tests supply [`EmptyGraphBuilder`],
8//! [`FailingGraphBuilder`], or a custom impl.
9//!
10//! The trait is `Send + Sync` because the builder is held across a
11//! rebuild-dispatcher task boundary. Every concrete implementation
12//! must be cheap to clone — callers typically `Arc`-wrap the builder
13//! and share it across the reaper + dispatcher + lifecycle tasks.
14
15use std::{path::Path, sync::Arc};
16
17use sqry_core::graph::CodeGraph;
18
19use crate::error::DaemonError;
20
21#[cfg(test)]
22fn hex_lower(bytes: &[u8]) -> String {
23    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
24}
25
26/// Build a [`CodeGraph`] for the given workspace root.
27///
28/// Trait object–friendly; [`get_or_load`] and friends accept a
29/// `&dyn WorkspaceBuilder` so the caller can choose between the
30/// production [`UnifiedGraphBuilder`] and a test-local in-memory
31/// variant without the manager caring.
32///
33/// [`get_or_load`]: super::WorkspaceManager::get_or_load
34pub trait WorkspaceBuilder: Send + Sync + std::fmt::Debug {
35    /// Build the graph rooted at `workspace_root`. The implementation
36    /// is responsible for any required lock-free / rayon parallelism
37    /// and for honouring cancellation signals it consumes (e.g.
38    /// pass-boundary checks in the rebuild pipeline).
39    ///
40    /// # Errors
41    ///
42    /// The daemon converts any returned [`DaemonError`] into a Failed
43    /// workspace state + JSON-RPC `-32001 workspace_build_failed`.
44    fn build(&self, workspace_root: &Path) -> Result<CodeGraph, DaemonError>;
45
46    /// Read-only, persisted-graph rehydrate.
47    ///
48    /// SGA04 (shared graph acquisition, daemon provider): reload an
49    /// existing valid persisted graph from `<workspace_root>/.sqry/graph/`
50    /// **without** running [`Self::build`] — no parse, no plugin
51    /// pipeline, no durable publish. Used by
52    /// [`super::WorkspaceManager::reload_from_disk_read_only`] to fulfil
53    /// the bounded one-shot eviction-reload contract for read-only
54    /// queries (see `docs/development/shared-graph-acquisition/02_DESIGN.md`,
55    /// "Bounded reload rule").
56    ///
57    /// The default impl returns [`DaemonError::WorkspaceBuildFailed`]
58    /// with a reason of `"persisted graph rehydrate not implemented"`.
59    /// Test fakes that don't need the read-only reload path keep that
60    /// behaviour; production code uses [`RealWorkspaceBuilder`] which
61    /// drives `GraphStorage::load_from_path` against the workspace's
62    /// snapshot file.
63    ///
64    /// # Errors
65    ///
66    /// - [`DaemonError::WorkspaceBuildFailed`] when no persisted graph
67    ///   exists, when integrity verification fails, when the snapshot
68    ///   format is incompatible, or when this builder does not support
69    ///   read-only rehydrate.
70    fn load_persisted(&self, workspace_root: &Path) -> Result<CodeGraph, DaemonError> {
71        Err(DaemonError::WorkspaceBuildFailed {
72            root: workspace_root.to_path_buf(),
73            reason: "persisted graph rehydrate not implemented for this builder".to_string(),
74        })
75    }
76}
77
78// Allow calling builders through an `Arc` so they can be shared
79// between tasks without explicit `.as_ref()` spam at every call site.
80impl<T: WorkspaceBuilder + ?Sized> WorkspaceBuilder for Arc<T> {
81    fn build(&self, workspace_root: &Path) -> Result<CodeGraph, DaemonError> {
82        (**self).build(workspace_root)
83    }
84
85    fn load_persisted(&self, workspace_root: &Path) -> Result<CodeGraph, DaemonError> {
86        (**self).load_persisted(workspace_root)
87    }
88}
89
90/// Test-only builder that always returns a freshly-built empty
91/// [`CodeGraph`]. Useful for admission-accounting tests where the
92/// actual graph content does not matter.
93#[doc(hidden)]
94#[derive(Debug, Default, Clone, Copy)]
95pub struct EmptyGraphBuilder;
96
97impl WorkspaceBuilder for EmptyGraphBuilder {
98    fn build(&self, _workspace_root: &Path) -> Result<CodeGraph, DaemonError> {
99        Ok(CodeGraph::new())
100    }
101}
102
103/// Test builder that always returns a configured error. Used by the
104/// Failed-state unit tests in Phase 6c; shipping the type here so
105/// every phase after 6b uses the same builder abstraction.
106#[doc(hidden)]
107#[derive(Debug, Clone)]
108pub struct FailingGraphBuilder {
109    /// Reason string surfaced via [`DaemonError::WorkspaceBuildFailed`].
110    pub reason: String,
111}
112
113impl FailingGraphBuilder {
114    /// Construct a failing builder with the given reason.
115    #[must_use]
116    pub fn new(reason: impl Into<String>) -> Self {
117        Self {
118            reason: reason.into(),
119        }
120    }
121}
122
123impl WorkspaceBuilder for FailingGraphBuilder {
124    fn build(&self, workspace_root: &Path) -> Result<CodeGraph, DaemonError> {
125        Err(DaemonError::WorkspaceBuildFailed {
126            root: workspace_root.to_path_buf(),
127            reason: self.reason.clone(),
128        })
129    }
130}
131
132/// Production [`WorkspaceBuilder`] that delegates to
133/// [`sqry_core::graph::unified::build::build_unified_graph`].
134///
135/// Task 8 Phase 8a. Task 9's daemon bootstrap constructs exactly one
136/// [`RealWorkspaceBuilder`] per daemon process, wrapping a shared
137/// [`sqry_core::plugin::PluginManager`]. Tests inject
138/// [`EmptyGraphBuilder`] or a custom builder.
139pub struct RealWorkspaceBuilder {
140    plugins: Arc<sqry_core::plugin::PluginManager>,
141    build_config: sqry_core::graph::unified::build::BuildConfig,
142}
143
144impl std::fmt::Debug for RealWorkspaceBuilder {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        // `PluginManager` is intentionally not `Debug` (the 37 plugin
147        // registrations are too noisy). Report pointer identity only.
148        f.debug_struct("RealWorkspaceBuilder")
149            .field(
150                "plugins",
151                &format_args!("<PluginManager@{:p}>", Arc::as_ptr(&self.plugins)),
152            )
153            .field("build_config", &self.build_config)
154            .finish()
155    }
156}
157
158impl RealWorkspaceBuilder {
159    /// Construct a real builder with default
160    /// [`sqry_core::graph::unified::build::BuildConfig`].
161    #[must_use]
162    pub fn new(plugins: Arc<sqry_core::plugin::PluginManager>) -> Self {
163        Self {
164            plugins,
165            build_config: sqry_core::graph::unified::build::BuildConfig::default(),
166        }
167    }
168
169    /// Construct a real builder with a caller-supplied
170    /// [`sqry_core::graph::unified::build::BuildConfig`].
171    #[must_use]
172    pub fn with_build_config(
173        plugins: Arc<sqry_core::plugin::PluginManager>,
174        build_config: sqry_core::graph::unified::build::BuildConfig,
175    ) -> Self {
176        Self {
177            plugins,
178            build_config,
179        }
180    }
181}
182
183impl WorkspaceBuilder for RealWorkspaceBuilder {
184    fn build(&self, workspace_root: &Path) -> Result<CodeGraph, DaemonError> {
185        sqry_core::graph::unified::build::build_unified_graph(
186            workspace_root,
187            &self.plugins,
188            &self.build_config,
189        )
190        .map_err(|e| DaemonError::WorkspaceBuildFailed {
191            root: workspace_root.to_path_buf(),
192            reason: e.to_string(),
193        })
194    }
195
196    /// SGA04 read-only persisted-graph rehydrate.
197    ///
198    /// Loads `<workspace_root>/.sqry/graph/snapshot.sqry` via
199    /// [`sqry_core::graph::unified::persistence::load_from_path`] using
200    /// the production [`PluginManager`]. Never invokes the build
201    /// pipeline; never writes any artifact.
202    ///
203    /// [`PluginManager`]: sqry_core::plugin::PluginManager
204    fn load_persisted(&self, workspace_root: &Path) -> Result<CodeGraph, DaemonError> {
205        let storage = sqry_core::graph::unified::persistence::GraphStorage::new(workspace_root);
206        if !storage.exists() {
207            return Err(DaemonError::WorkspaceBuildFailed {
208                root: workspace_root.to_path_buf(),
209                reason: format!(
210                    "no persisted graph artifact at {} (.sqry/graph/manifest.json absent)",
211                    workspace_root.display()
212                ),
213            });
214        }
215        if !storage.snapshot_exists() {
216            return Err(DaemonError::WorkspaceBuildFailed {
217                root: workspace_root.to_path_buf(),
218                reason: format!(
219                    "manifest present but snapshot missing at {}",
220                    storage.snapshot_path().display()
221                ),
222            });
223        }
224        sqry_core::graph::unified::persistence::load_from_path(
225            storage.snapshot_path(),
226            Some(&self.plugins),
227        )
228        .map_err(|e| match e {
229            // SGA04 Major #2 (codex iter2) — preserve the
230            // incompatible-snapshot distinction through the daemon
231            // builder. The dispatcher surfaces this as
232            // JSON-RPC -32005 / `WorkspaceIncompatibleGraph` rather than
233            // the generic -32001 `WorkspaceBuildFailed` (which would
234            // suggest a transient build problem the user could retry).
235            sqry_core::graph::unified::persistence::PersistenceError::IncompatibleVersion {
236                expected,
237                found,
238            } => DaemonError::WorkspaceIncompatibleGraph {
239                root: workspace_root.to_path_buf(),
240                reason: format!("snapshot version mismatch: expected {expected}, found {found}"),
241            },
242            other => DaemonError::WorkspaceBuildFailed {
243                root: workspace_root.to_path_buf(),
244                reason: format!("snapshot load failed: {other}"),
245            },
246        })
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn empty_builder_returns_fresh_graph() {
256        let b = EmptyGraphBuilder;
257        let g = b.build(Path::new("/repos/example")).expect("always ok");
258        assert_eq!(g.node_count(), 0);
259    }
260
261    #[test]
262    fn failing_builder_surfaces_reason_and_root() {
263        let b = FailingGraphBuilder::new("plugin panic");
264        let err = b
265            .build(Path::new("/repos/example"))
266            .expect_err("always fails");
267        match err {
268            DaemonError::WorkspaceBuildFailed { root, reason } => {
269                assert_eq!(root, Path::new("/repos/example"));
270                assert_eq!(reason, "plugin panic");
271            }
272            other => panic!("wrong variant: {other:?}"),
273        }
274    }
275
276    #[test]
277    fn arc_builder_passes_through_to_inner() {
278        let inner: Arc<dyn WorkspaceBuilder> = Arc::new(EmptyGraphBuilder);
279        let g = inner
280            .build(Path::new("/repos/example"))
281            .expect("arc-wrapped builder delegates");
282        assert_eq!(g.node_count(), 0);
283    }
284
285    /// SGA04 Major #2 (codex iter2) — when the persisted snapshot
286    /// reports an incompatible format version, `load_persisted` must
287    /// return [`DaemonError::WorkspaceIncompatibleGraph`] (which the
288    /// dispatcher exposes as JSON-RPC -32005), **not** the generic
289    /// transient-build [`DaemonError::WorkspaceBuildFailed`] (-32001).
290    /// We hand-craft a snapshot file with the current V10 magic but a
291    /// `GraphHeader.version` of `99` to force
292    /// `PersistenceError::IncompatibleVersion`.
293    #[test]
294    fn real_workspace_builder_load_persisted_incompatible_snapshot_returns_incompatible_graph_error()
295     {
296        use sha2::{Digest, Sha256};
297        use sqry_core::graph::unified::persistence::{
298            BuildProvenance, GraphHeader, GraphStorage, MAGIC_BYTES_V10, MANIFEST_SCHEMA_VERSION,
299            Manifest, PluginSelectionManifest, SNAPSHOT_FORMAT_VERSION,
300        };
301        use std::fs;
302        use tempfile::TempDir;
303
304        let tmp = TempDir::new().expect("tempdir");
305        let workspace = tmp.path().to_path_buf();
306        let storage = GraphStorage::new(&workspace);
307        fs::create_dir_all(storage.graph_dir()).expect("graph dir");
308
309        // Build V10-magic + bogus-version-99 header bytes.
310        let mut header = GraphHeader::new(0, 0, 0, 0);
311        header.version = 99;
312        let header_bytes = postcard::to_allocvec(&header).expect("encode header");
313        let mut bytes: Vec<u8> = Vec::with_capacity(14 + 4 + header_bytes.len() + 8);
314        bytes.extend_from_slice(MAGIC_BYTES_V10);
315        #[allow(clippy::cast_possible_truncation)]
316        bytes.extend_from_slice(&(header_bytes.len() as u32).to_le_bytes());
317        bytes.extend_from_slice(&header_bytes);
318        bytes.extend_from_slice(&0u64.to_le_bytes());
319        fs::write(storage.snapshot_path(), &bytes).expect("write snapshot");
320
321        // Stub manifest pointing at the bogus snapshot SHA so the
322        // GraphStorage `exists()` / `snapshot_exists()` precondition
323        // checks pass and `load_persisted` actually reaches
324        // `load_from_path`.
325        let snapshot_sha256 = hex_lower(&Sha256::digest(&bytes));
326        let manifest = Manifest {
327            schema_version: MANIFEST_SCHEMA_VERSION,
328            snapshot_format_version: SNAPSHOT_FORMAT_VERSION,
329            built_at: "1970-01-01T00:00:00Z".to_string(),
330            root_path: workspace.to_string_lossy().into_owned(),
331            node_count: 0,
332            edge_count: 0,
333            raw_edge_count: None,
334            snapshot_sha256,
335            build_provenance: BuildProvenance {
336                sqry_version: env!("CARGO_PKG_VERSION").to_string(),
337                build_timestamp: "1970-01-01T00:00:00Z".to_string(),
338                build_command: "test:incompatible-version".to_string(),
339                plugin_hashes: std::collections::HashMap::new(),
340            },
341            file_count: std::collections::HashMap::new(),
342            languages: Vec::new(),
343            config: std::collections::HashMap::new(),
344            confidence: Default::default(),
345            last_indexed_commit: None,
346            plugin_selection: Some(PluginSelectionManifest {
347                active_plugin_ids: Vec::new(),
348                high_cost_mode: None,
349            }),
350        };
351        manifest
352            .save(storage.manifest_path())
353            .expect("save manifest");
354
355        let plugins = Arc::new(sqry_core::plugin::PluginManager::new());
356        let builder = RealWorkspaceBuilder::new(plugins);
357        let err = builder
358            .load_persisted(&workspace)
359            .expect_err("incompatible-version snapshot must fail load_persisted");
360
361        match err {
362            DaemonError::WorkspaceIncompatibleGraph { root, reason } => {
363                assert_eq!(root, workspace);
364                assert!(
365                    reason.contains("snapshot version mismatch") && reason.contains("found 99"),
366                    "expected snapshot version mismatch diagnostic, got {reason:?}"
367                );
368            }
369            other => panic!("expected DaemonError::WorkspaceIncompatibleGraph, got {other:?}"),
370        }
371    }
372}