use std::{path::Path, sync::Arc};
use sqry_core::graph::CodeGraph;
use crate::error::DaemonError;
#[cfg(test)]
fn hex_lower(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
pub trait WorkspaceBuilder: Send + Sync + std::fmt::Debug {
fn build(&self, workspace_root: &Path) -> Result<CodeGraph, DaemonError>;
fn load_persisted(&self, workspace_root: &Path) -> Result<CodeGraph, DaemonError> {
Err(DaemonError::WorkspaceBuildFailed {
root: workspace_root.to_path_buf(),
reason: "persisted graph rehydrate not implemented for this builder".to_string(),
})
}
}
impl<T: WorkspaceBuilder + ?Sized> WorkspaceBuilder for Arc<T> {
fn build(&self, workspace_root: &Path) -> Result<CodeGraph, DaemonError> {
(**self).build(workspace_root)
}
fn load_persisted(&self, workspace_root: &Path) -> Result<CodeGraph, DaemonError> {
(**self).load_persisted(workspace_root)
}
}
#[doc(hidden)]
#[derive(Debug, Default, Clone, Copy)]
pub struct EmptyGraphBuilder;
impl WorkspaceBuilder for EmptyGraphBuilder {
fn build(&self, _workspace_root: &Path) -> Result<CodeGraph, DaemonError> {
Ok(CodeGraph::new())
}
}
#[doc(hidden)]
#[derive(Debug, Clone)]
pub struct FailingGraphBuilder {
pub reason: String,
}
impl FailingGraphBuilder {
#[must_use]
pub fn new(reason: impl Into<String>) -> Self {
Self {
reason: reason.into(),
}
}
}
impl WorkspaceBuilder for FailingGraphBuilder {
fn build(&self, workspace_root: &Path) -> Result<CodeGraph, DaemonError> {
Err(DaemonError::WorkspaceBuildFailed {
root: workspace_root.to_path_buf(),
reason: self.reason.clone(),
})
}
}
pub struct RealWorkspaceBuilder {
plugins: Arc<sqry_core::plugin::PluginManager>,
build_config: sqry_core::graph::unified::build::BuildConfig,
}
impl std::fmt::Debug for RealWorkspaceBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RealWorkspaceBuilder")
.field(
"plugins",
&format_args!("<PluginManager@{:p}>", Arc::as_ptr(&self.plugins)),
)
.field("build_config", &self.build_config)
.finish()
}
}
impl RealWorkspaceBuilder {
#[must_use]
pub fn new(plugins: Arc<sqry_core::plugin::PluginManager>) -> Self {
Self {
plugins,
build_config: sqry_core::graph::unified::build::BuildConfig::default(),
}
}
#[must_use]
pub fn with_build_config(
plugins: Arc<sqry_core::plugin::PluginManager>,
build_config: sqry_core::graph::unified::build::BuildConfig,
) -> Self {
Self {
plugins,
build_config,
}
}
}
impl WorkspaceBuilder for RealWorkspaceBuilder {
fn build(&self, workspace_root: &Path) -> Result<CodeGraph, DaemonError> {
sqry_core::graph::unified::build::build_unified_graph(
workspace_root,
&self.plugins,
&self.build_config,
)
.map_err(|e| DaemonError::WorkspaceBuildFailed {
root: workspace_root.to_path_buf(),
reason: e.to_string(),
})
}
fn load_persisted(&self, workspace_root: &Path) -> Result<CodeGraph, DaemonError> {
let storage = sqry_core::graph::unified::persistence::GraphStorage::new(workspace_root);
if !storage.exists() {
return Err(DaemonError::WorkspaceBuildFailed {
root: workspace_root.to_path_buf(),
reason: format!(
"no persisted graph artifact at {} (.sqry/graph/manifest.json absent)",
workspace_root.display()
),
});
}
if !storage.snapshot_exists() {
return Err(DaemonError::WorkspaceBuildFailed {
root: workspace_root.to_path_buf(),
reason: format!(
"manifest present but snapshot missing at {}",
storage.snapshot_path().display()
),
});
}
sqry_core::graph::unified::persistence::load_from_path(
storage.snapshot_path(),
Some(&self.plugins),
)
.map_err(|e| match e {
sqry_core::graph::unified::persistence::PersistenceError::IncompatibleVersion {
expected,
found,
} => DaemonError::WorkspaceIncompatibleGraph {
root: workspace_root.to_path_buf(),
reason: format!("snapshot version mismatch: expected {expected}, found {found}"),
},
other => DaemonError::WorkspaceBuildFailed {
root: workspace_root.to_path_buf(),
reason: format!("snapshot load failed: {other}"),
},
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_builder_returns_fresh_graph() {
let b = EmptyGraphBuilder;
let g = b.build(Path::new("/repos/example")).expect("always ok");
assert_eq!(g.node_count(), 0);
}
#[test]
fn failing_builder_surfaces_reason_and_root() {
let b = FailingGraphBuilder::new("plugin panic");
let err = b
.build(Path::new("/repos/example"))
.expect_err("always fails");
match err {
DaemonError::WorkspaceBuildFailed { root, reason } => {
assert_eq!(root, Path::new("/repos/example"));
assert_eq!(reason, "plugin panic");
}
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn arc_builder_passes_through_to_inner() {
let inner: Arc<dyn WorkspaceBuilder> = Arc::new(EmptyGraphBuilder);
let g = inner
.build(Path::new("/repos/example"))
.expect("arc-wrapped builder delegates");
assert_eq!(g.node_count(), 0);
}
#[test]
fn real_workspace_builder_load_persisted_incompatible_snapshot_returns_incompatible_graph_error()
{
use sha2::{Digest, Sha256};
use sqry_core::graph::unified::persistence::{
BuildProvenance, GraphHeader, GraphStorage, MAGIC_BYTES_V10, MANIFEST_SCHEMA_VERSION,
Manifest, PluginSelectionManifest, SNAPSHOT_FORMAT_VERSION,
};
use std::fs;
use tempfile::TempDir;
let tmp = TempDir::new().expect("tempdir");
let workspace = tmp.path().to_path_buf();
let storage = GraphStorage::new(&workspace);
fs::create_dir_all(storage.graph_dir()).expect("graph dir");
let mut header = GraphHeader::new(0, 0, 0, 0);
header.version = 99;
let header_bytes = postcard::to_allocvec(&header).expect("encode header");
let mut bytes: Vec<u8> = Vec::with_capacity(14 + 4 + header_bytes.len() + 8);
bytes.extend_from_slice(MAGIC_BYTES_V10);
#[allow(clippy::cast_possible_truncation)]
bytes.extend_from_slice(&(header_bytes.len() as u32).to_le_bytes());
bytes.extend_from_slice(&header_bytes);
bytes.extend_from_slice(&0u64.to_le_bytes());
fs::write(storage.snapshot_path(), &bytes).expect("write snapshot");
let snapshot_sha256 = hex_lower(&Sha256::digest(&bytes));
let manifest = Manifest {
schema_version: MANIFEST_SCHEMA_VERSION,
snapshot_format_version: SNAPSHOT_FORMAT_VERSION,
built_at: "1970-01-01T00:00:00Z".to_string(),
root_path: workspace.to_string_lossy().into_owned(),
node_count: 0,
edge_count: 0,
raw_edge_count: None,
snapshot_sha256,
build_provenance: BuildProvenance {
sqry_version: env!("CARGO_PKG_VERSION").to_string(),
build_timestamp: "1970-01-01T00:00:00Z".to_string(),
build_command: "test:incompatible-version".to_string(),
plugin_hashes: std::collections::HashMap::new(),
},
file_count: std::collections::HashMap::new(),
languages: Vec::new(),
config: std::collections::HashMap::new(),
confidence: Default::default(),
last_indexed_commit: None,
plugin_selection: Some(PluginSelectionManifest {
active_plugin_ids: Vec::new(),
high_cost_mode: None,
}),
};
manifest
.save(storage.manifest_path())
.expect("save manifest");
let plugins = Arc::new(sqry_core::plugin::PluginManager::new());
let builder = RealWorkspaceBuilder::new(plugins);
let err = builder
.load_persisted(&workspace)
.expect_err("incompatible-version snapshot must fail load_persisted");
match err {
DaemonError::WorkspaceIncompatibleGraph { root, reason } => {
assert_eq!(root, workspace);
assert!(
reason.contains("snapshot version mismatch") && reason.contains("found 99"),
"expected snapshot version mismatch diagnostic, got {reason:?}"
);
}
other => panic!("expected DaemonError::WorkspaceIncompatibleGraph, got {other:?}"),
}
}
}