1use 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
26pub trait WorkspaceBuilder: Send + Sync + std::fmt::Debug {
35 fn build(&self, workspace_root: &Path) -> Result<CodeGraph, DaemonError>;
45
46 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
78impl<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#[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#[doc(hidden)]
107#[derive(Debug, Clone)]
108pub struct FailingGraphBuilder {
109 pub reason: String,
111}
112
113impl FailingGraphBuilder {
114 #[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
132pub 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 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 #[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 #[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 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 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 #[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 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 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}