use std::num::NonZeroU8;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use thiserror::Error;
use crate::graph::CodeGraph;
use crate::graph::unified::persistence::{
GraphStorage, Manifest, PersistenceError, load_from_bytes, verify_snapshot_bytes,
};
use crate::plugin::PluginManager;
pub trait GraphAcquirer: Send + Sync {
fn acquire(
&self,
request: GraphAcquisitionRequest,
) -> Result<GraphAcquisition, GraphAcquisitionError>;
}
#[derive(Debug, Clone)]
pub struct GraphAcquisitionRequest {
pub requested_path: PathBuf,
pub operation: AcquisitionOperation,
pub path_policy: PathPolicy,
pub missing_graph_policy: MissingGraphPolicy,
pub stale_policy: StalePolicy,
pub plugin_selection_policy: PluginSelectionPolicy,
pub tool_name: Option<&'static str>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AcquisitionOperation {
ReadOnlyQuery,
MutatingRebuild,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MissingGraphPolicy {
Error,
AutoBuildIfEnabled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PathPolicy {
pub require_existing: bool,
pub require_within_workspace: bool,
pub allow_symlink_escape: bool,
}
impl Default for PathPolicy {
fn default() -> Self {
Self {
require_existing: true,
require_within_workspace: true,
allow_symlink_escape: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum StalePolicy {
RejectStale,
AcceptStaleWithinWindow {
max_age_hours: f64,
},
}
impl Default for StalePolicy {
fn default() -> Self {
Self::RejectStale
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum PluginSelectionPolicy {
#[default]
StrictMatch,
AllowUnknownIds {
allowed: Vec<String>,
},
}
#[derive(Debug, Clone)]
pub struct GraphAcquisition {
pub graph: Arc<CodeGraph>,
pub workspace_root: PathBuf,
pub query_scope: Option<PathBuf>,
pub is_file_scope: bool,
pub freshness: GraphFreshness,
pub identity: GraphIdentity,
pub metadata: GraphAcquisitionMetadata,
}
#[derive(Debug, Clone, PartialEq)]
pub enum GraphFreshness {
Fresh {
lifecycle_label: Option<&'static str>,
},
Stale {
last_good_at: Option<String>,
last_error: Option<String>,
age_hours: Option<f64>,
},
Reloaded {
original_lifecycle: ReloadOrigin,
final_lifecycle_label: &'static str,
reload_attempts: NonZeroU8,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReloadOrigin {
Unloaded {
detail: String,
},
Evicted {
detail: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GraphIdentity {
pub snapshot_sha256: Option<String>,
pub manifest_built_at: Option<String>,
pub snapshot_format_version: Option<u32>,
pub source_root: PathBuf,
pub plugin_selection_status: PluginSelectionStatus,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum PluginSelectionStatus {
Exact,
IncompatibleUnknownPluginIds {
unknown_plugin_ids: Vec<String>,
manifest_path: Option<PathBuf>,
},
IncompatibleSnapshotFormat {
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GraphAcquisitionMetadata {
pub acquisition_source: AcquisitionSource,
pub tool_name: Option<&'static str>,
pub notes: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AcquisitionSource {
Filesystem,
DaemonReadOnly,
DaemonReloaded,
}
#[derive(Debug, Error)]
pub enum GraphAcquisitionError {
#[error("invalid path {path:?}: {reason}")]
InvalidPath {
path: PathBuf,
reason: String,
},
#[error("no graph artifact for workspace {workspace_root:?}")]
NoGraph {
workspace_root: PathBuf,
},
#[error("graph load failed for {source_root:?}: {reason}")]
LoadFailed {
source_root: PathBuf,
reason: String,
},
#[error("incompatible graph for {source_root:?}: {status:?}")]
IncompatibleGraph {
source_root: PathBuf,
status: PluginSelectionStatus,
},
#[error("workspace not ready: {workspace_root:?} (lifecycle={lifecycle})")]
NotReady {
workspace_root: PathBuf,
lifecycle: String,
},
#[error("workspace evicted: {workspace_root:?} (original_lifecycle={original_lifecycle})")]
Evicted {
workspace_root: PathBuf,
original_lifecycle: String,
reload_failure: Option<String>,
},
#[error("stale graph expired for {workspace_root:?} (age_hours={age_hours:?})")]
StaleExpired {
workspace_root: PathBuf,
age_hours: Option<f64>,
},
#[error("graph build failed for {workspace_root:?}: {reason}")]
BuildFailed {
workspace_root: PathBuf,
reason: String,
},
#[error("internal acquisition error: {reason}")]
Internal {
reason: String,
},
}
pub type AutoBuildHook =
Arc<dyn Fn(&Path) -> Result<Arc<CodeGraph>, GraphAcquisitionError> + Send + Sync>;
pub struct FilesystemGraphProvider {
plugin_manager: Arc<PluginManager>,
auto_build: Option<AutoBuildHook>,
}
impl std::fmt::Debug for FilesystemGraphProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FilesystemGraphProvider")
.field("auto_build_hook", &self.auto_build.is_some())
.finish()
}
}
impl FilesystemGraphProvider {
#[must_use]
pub fn new(plugin_manager: Arc<PluginManager>) -> Self {
Self {
plugin_manager,
auto_build: None,
}
}
#[must_use]
pub fn with_auto_build_hook(mut self, hook: AutoBuildHook) -> Self {
self.auto_build = Some(hook);
self
}
#[must_use]
pub fn plugin_manager(&self) -> &PluginManager {
&self.plugin_manager
}
fn apply_path_policy(
&self,
request_path: &Path,
policy: &PathPolicy,
) -> Result<PathBuf, GraphAcquisitionError> {
let exists = request_path.exists();
if policy.require_existing && !exists {
return Err(GraphAcquisitionError::InvalidPath {
path: request_path.to_path_buf(),
reason: "path does not exist".to_string(),
});
}
let canonical = match request_path.canonicalize() {
Ok(p) => p,
Err(e) => {
if policy.require_existing {
return Err(GraphAcquisitionError::InvalidPath {
path: request_path.to_path_buf(),
reason: format!("path cannot be canonicalized: {e}"),
});
}
if request_path.is_absolute() {
request_path.to_path_buf()
} else {
std::env::current_dir()
.map(|cwd| cwd.join(request_path))
.unwrap_or_else(|_| request_path.to_path_buf())
}
}
};
Ok(canonical)
}
fn find_workspace_root(start: &Path) -> Option<(PathBuf, usize, bool)> {
match crate::workspace::discover_workspace_root(start) {
crate::workspace::WorkspaceRootDiscovery::GraphFound {
root,
depth,
is_file_scope,
..
} => Some((root, depth, is_file_scope)),
crate::workspace::WorkspaceRootDiscovery::BoundaryOnly { .. }
| crate::workspace::WorkspaceRootDiscovery::None => None,
}
}
fn classify_plugin_selection(
&self,
manifest: &Manifest,
manifest_path: Option<&Path>,
policy: &PluginSelectionPolicy,
) -> PluginSelectionStatus {
let Some(persisted) = manifest.plugin_selection.as_ref() else {
return PluginSelectionStatus::Exact;
};
let mut unknown: Vec<String> = persisted
.active_plugin_ids
.iter()
.filter(|id| self.plugin_manager.plugin_by_id(id).is_none())
.cloned()
.collect();
if unknown.is_empty() {
return PluginSelectionStatus::Exact;
}
if let PluginSelectionPolicy::AllowUnknownIds { allowed } = policy {
unknown.retain(|id| !allowed.contains(id));
if unknown.is_empty() {
return PluginSelectionStatus::Exact;
}
}
PluginSelectionStatus::IncompatibleUnknownPluginIds {
unknown_plugin_ids: unknown,
manifest_path: manifest_path.map(Path::to_path_buf),
}
}
}
impl GraphAcquirer for FilesystemGraphProvider {
fn acquire(
&self,
request: GraphAcquisitionRequest,
) -> Result<GraphAcquisition, GraphAcquisitionError> {
let canonical_request =
self.apply_path_policy(&request.requested_path, &request.path_policy)?;
let Some((workspace_root, ancestor_depth, is_file_scope)) =
Self::find_workspace_root(&canonical_request)
else {
return match request.missing_graph_policy {
MissingGraphPolicy::Error => Err(GraphAcquisitionError::NoGraph {
workspace_root: canonical_request,
}),
MissingGraphPolicy::AutoBuildIfEnabled => match &self.auto_build {
Some(hook) => {
let graph = hook(&canonical_request)?;
Ok(GraphAcquisition {
graph,
workspace_root: canonical_request.clone(),
query_scope: None,
is_file_scope: false,
freshness: GraphFreshness::Fresh {
lifecycle_label: None,
},
identity: GraphIdentity {
snapshot_sha256: None,
manifest_built_at: None,
snapshot_format_version: None,
source_root: canonical_request.clone(),
plugin_selection_status: PluginSelectionStatus::Exact,
},
metadata: GraphAcquisitionMetadata {
acquisition_source: AcquisitionSource::Filesystem,
tool_name: request.tool_name,
notes: vec!["auto-built via provider hook".to_string()],
},
})
}
None => Err(GraphAcquisitionError::NoGraph {
workspace_root: canonical_request,
}),
},
};
};
if request.path_policy.require_within_workspace
&& !canonical_request.starts_with(&workspace_root)
&& !request.path_policy.allow_symlink_escape
{
return Err(GraphAcquisitionError::InvalidPath {
path: request.requested_path,
reason: format!(
"canonical path {:?} escapes workspace root {:?}",
canonical_request, workspace_root
),
});
}
let (query_scope, is_file_scope) = if ancestor_depth > 0 || is_file_scope {
(Some(canonical_request.clone()), is_file_scope)
} else {
(None, false)
};
let storage = GraphStorage::new(&workspace_root);
let mut manifest_opt: Option<Manifest> = None;
let mut expected_sha = String::new();
if storage.manifest_path().exists() {
match storage.load_manifest() {
Ok(m) => {
expected_sha = m.snapshot_sha256.clone();
manifest_opt = Some(m);
}
Err(e) => {
return Err(GraphAcquisitionError::LoadFailed {
source_root: workspace_root,
reason: format!("manifest unreadable: {e}"),
});
}
}
}
let plugin_status = manifest_opt
.as_ref()
.map_or(PluginSelectionStatus::Exact, |m| {
self.classify_plugin_selection(
m,
Some(storage.manifest_path()),
&request.plugin_selection_policy,
)
});
if !matches!(plugin_status, PluginSelectionStatus::Exact) {
return Err(GraphAcquisitionError::IncompatibleGraph {
source_root: workspace_root,
status: plugin_status,
});
}
let snapshot_path = storage.snapshot_path().to_path_buf();
let snapshot_bytes = match std::fs::read(&snapshot_path) {
Ok(b) => b,
Err(e) => {
return Err(GraphAcquisitionError::LoadFailed {
source_root: workspace_root,
reason: format!("read snapshot {:?}: {e}", snapshot_path),
});
}
};
if let Err(e) = verify_snapshot_bytes(&snapshot_bytes, &expected_sha) {
return Err(GraphAcquisitionError::LoadFailed {
source_root: workspace_root,
reason: format!("snapshot integrity check failed: {e}"),
});
}
let graph = match load_from_bytes(&snapshot_bytes, Some(&self.plugin_manager)) {
Ok(g) => Arc::new(g),
Err(PersistenceError::IncompatibleVersion { expected, found }) => {
return Err(GraphAcquisitionError::IncompatibleGraph {
source_root: workspace_root,
status: PluginSelectionStatus::IncompatibleSnapshotFormat {
reason: format!(
"snapshot version mismatch: expected {expected}, found {found}"
),
},
});
}
Err(e) => {
return Err(GraphAcquisitionError::LoadFailed {
source_root: workspace_root,
reason: format!("snapshot deserialize: {e}"),
});
}
};
let identity = GraphIdentity {
snapshot_sha256: manifest_opt.as_ref().map(|m| m.snapshot_sha256.clone()),
manifest_built_at: manifest_opt.as_ref().map(|m| m.built_at.clone()),
snapshot_format_version: manifest_opt.as_ref().map(|m| m.snapshot_format_version),
source_root: workspace_root.clone(),
plugin_selection_status: PluginSelectionStatus::Exact,
};
Ok(GraphAcquisition {
graph,
workspace_root,
query_scope,
is_file_scope,
freshness: GraphFreshness::Fresh {
lifecycle_label: None,
},
identity,
metadata: GraphAcquisitionMetadata {
acquisition_source: AcquisitionSource::Filesystem,
tool_name: request.tool_name,
notes: Vec::new(),
},
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
fn make_request(operation: AcquisitionOperation) -> GraphAcquisitionRequest {
GraphAcquisitionRequest {
requested_path: PathBuf::from("/tmp/sga02-test-workspace"),
operation,
path_policy: PathPolicy::default(),
missing_graph_policy: MissingGraphPolicy::Error,
stale_policy: StalePolicy::default(),
plugin_selection_policy: PluginSelectionPolicy::default(),
tool_name: Some("sga02_test"),
}
}
fn freshness_metadata_pair(
freshness: GraphFreshness,
source: AcquisitionSource,
) -> (GraphFreshness, GraphAcquisitionMetadata) {
(
freshness,
GraphAcquisitionMetadata {
acquisition_source: source,
tool_name: Some("sga02_test"),
notes: vec![],
},
)
}
#[test]
fn acquire_mode_read_only_allows_stale_and_reloaded() {
let request = make_request(AcquisitionOperation::ReadOnlyQuery);
assert_eq!(request.operation, AcquisitionOperation::ReadOnlyQuery);
let cases = vec![
freshness_metadata_pair(
GraphFreshness::Fresh {
lifecycle_label: Some("Loaded"),
},
AcquisitionSource::DaemonReadOnly,
),
freshness_metadata_pair(
GraphFreshness::Stale {
last_good_at: Some("2026-05-07T12:00:00Z".to_string()),
last_error: Some("rebuild failed".to_string()),
age_hours: Some(0.5),
},
AcquisitionSource::DaemonReadOnly,
),
freshness_metadata_pair(
GraphFreshness::Reloaded {
original_lifecycle: ReloadOrigin::Evicted {
detail: "memory admission evicted A".to_string(),
},
final_lifecycle_label: "Loaded",
reload_attempts: NonZeroU8::new(1).expect("1 is non-zero"),
},
AcquisitionSource::DaemonReloaded,
),
];
for (freshness, metadata) in cases {
match &freshness {
GraphFreshness::Fresh { lifecycle_label } => {
assert_eq!(*lifecycle_label, Some("Loaded"));
}
GraphFreshness::Stale {
last_good_at,
age_hours,
..
} => {
assert!(last_good_at.is_some());
assert!(age_hours.is_some());
}
GraphFreshness::Reloaded {
final_lifecycle_label,
reload_attempts,
..
} => {
assert_eq!(*final_lifecycle_label, "Loaded");
assert_eq!(reload_attempts.get(), 1);
}
}
assert!(matches!(
metadata.acquisition_source,
AcquisitionSource::DaemonReadOnly | AcquisitionSource::DaemonReloaded
));
assert_eq!(metadata.tool_name, Some("sga02_test"));
}
}
struct ScriptedAcquirer {
load_attempts: AtomicUsize,
last_op: Mutex<Option<AcquisitionOperation>>,
invalid_path: bool,
rebuild_only_stale_available: bool,
}
impl ScriptedAcquirer {
fn new() -> Self {
Self {
load_attempts: AtomicUsize::new(0),
last_op: Mutex::new(None),
invalid_path: false,
rebuild_only_stale_available: false,
}
}
fn with_invalid_path(mut self) -> Self {
self.invalid_path = true;
self
}
fn with_rebuild_only_stale(mut self) -> Self {
self.rebuild_only_stale_available = true;
self
}
fn loads_attempted(&self) -> usize {
self.load_attempts.load(Ordering::SeqCst)
}
}
impl GraphAcquirer for ScriptedAcquirer {
fn acquire(
&self,
request: GraphAcquisitionRequest,
) -> Result<GraphAcquisition, GraphAcquisitionError> {
*self.last_op.lock().expect("mutex unpoisoned") = Some(request.operation);
if self.invalid_path {
return Err(GraphAcquisitionError::InvalidPath {
path: request.requested_path,
reason: "test fixture: path rejected before load".to_string(),
});
}
self.load_attempts.fetch_add(1, Ordering::SeqCst);
if self.rebuild_only_stale_available
&& request.operation == AcquisitionOperation::MutatingRebuild
{
return Err(GraphAcquisitionError::LoadFailed {
source_root: request.requested_path,
reason: "only stale graph available; rebuild requires fresh".to_string(),
});
}
Err(GraphAcquisitionError::Internal {
reason: "scripted acquirer reached unreachable arm".to_string(),
})
}
}
#[test]
fn acquire_mode_rebuild_rejects_read_only_fallback() {
let acquirer = ScriptedAcquirer::new().with_rebuild_only_stale();
let result = acquirer.acquire(make_request(AcquisitionOperation::MutatingRebuild));
match result {
Err(GraphAcquisitionError::LoadFailed { reason, .. }) => {
assert!(
reason.contains("stale"),
"expected stale-related diagnostic, got {reason}"
);
}
Err(other) => panic!("unexpected error variant: {other:?}"),
Ok(acq) => panic!(
"MutatingRebuild must not yield a stale/reloaded acquisition, got freshness={:?}",
acq.freshness
),
}
let last_op = *acquirer.last_op.lock().expect("mutex unpoisoned");
assert_eq!(last_op, Some(AcquisitionOperation::MutatingRebuild));
}
#[test]
fn invalid_path_error_precedes_load_error() {
let acquirer = ScriptedAcquirer::new().with_invalid_path();
let result = acquirer.acquire(make_request(AcquisitionOperation::ReadOnlyQuery));
assert!(
matches!(result, Err(GraphAcquisitionError::InvalidPath { .. })),
"expected InvalidPath error, got {result:?}"
);
assert_eq!(
acquirer.loads_attempted(),
0,
"no load should be attempted when the path policy rejects the request"
);
}
struct ReloadCountingManager {
reload_attempts: AtomicUsize,
reload_succeeds: bool,
}
impl ReloadCountingManager {
fn new(reload_succeeds: bool) -> Self {
Self {
reload_attempts: AtomicUsize::new(0),
reload_succeeds,
}
}
fn attempt_reload(&self) -> Result<&'static str, String> {
self.reload_attempts.fetch_add(1, Ordering::SeqCst);
if self.reload_succeeds {
Ok("Loaded")
} else {
Err("test fixture: reload failed".to_string())
}
}
fn reload_count(&self) -> usize {
self.reload_attempts.load(Ordering::SeqCst)
}
}
struct BoundedReloadAcquirer<'a> {
manager: &'a ReloadCountingManager,
original_lifecycle_label: &'static str,
original_eviction_detail: &'static str,
}
impl<'a> GraphAcquirer for BoundedReloadAcquirer<'a> {
fn acquire(
&self,
request: GraphAcquisitionRequest,
) -> Result<GraphAcquisition, GraphAcquisitionError> {
if request.operation != AcquisitionOperation::ReadOnlyQuery {
return Err(GraphAcquisitionError::Evicted {
workspace_root: request.requested_path,
original_lifecycle: self.original_lifecycle_label.to_string(),
reload_failure: None,
});
}
match self.manager.attempt_reload() {
Ok(_label) => Err(GraphAcquisitionError::Internal {
reason: "test stops here: success path requires real CodeGraph".to_string(),
}),
Err(reload_err) => Err(GraphAcquisitionError::Evicted {
workspace_root: request.requested_path,
original_lifecycle: self.original_lifecycle_label.to_string(),
reload_failure: Some(format!(
"evicted({}); reload: {}",
self.original_eviction_detail, reload_err
)),
}),
}
}
}
#[test]
fn evicted_reload_attempt_is_bounded() {
let manager = ReloadCountingManager::new(true);
let acquirer = BoundedReloadAcquirer {
manager: &manager,
original_lifecycle_label: "Evicted",
original_eviction_detail: "memory admission evicted A",
};
let _ = acquirer.acquire(make_request(AcquisitionOperation::ReadOnlyQuery));
assert_eq!(
manager.reload_count(),
1,
"ReadOnlyQuery reload must be attempted exactly once"
);
let _ = acquirer.acquire(make_request(AcquisitionOperation::ReadOnlyQuery));
assert_eq!(
manager.reload_count(),
2,
"second request can attempt its own single reload, but neither request looped"
);
}
#[test]
fn reload_failure_preserves_original_lifecycle_context() {
let manager = ReloadCountingManager::new(false);
let acquirer = BoundedReloadAcquirer {
manager: &manager,
original_lifecycle_label: "Evicted",
original_eviction_detail: "memory admission evicted A",
};
let result = acquirer.acquire(make_request(AcquisitionOperation::ReadOnlyQuery));
match result {
Err(GraphAcquisitionError::Evicted {
original_lifecycle,
reload_failure,
..
}) => {
assert_eq!(original_lifecycle, "Evicted");
let reload = reload_failure.expect("reload failure must be recorded");
assert!(
reload.contains("memory admission evicted A"),
"reload diagnostic must carry the original eviction detail, got: {reload}"
);
assert!(
reload.contains("test fixture: reload failed"),
"reload diagnostic must carry the reload failure detail, got: {reload}"
);
}
other => panic!("expected Evicted with reload_failure, got {other:?}"),
}
assert_eq!(manager.reload_count(), 1);
}
#[test]
fn graph_acquirer_is_object_safe() {
fn assert_object_safe(_: &dyn GraphAcquirer) {}
let acquirer = ScriptedAcquirer::new();
assert_object_safe(&acquirer);
}
#[test]
fn policy_defaults_are_strict() {
let p = PathPolicy::default();
assert!(p.require_existing);
assert!(p.require_within_workspace);
assert!(!p.allow_symlink_escape);
assert!(matches!(StalePolicy::default(), StalePolicy::RejectStale));
assert!(matches!(
PluginSelectionPolicy::default(),
PluginSelectionPolicy::StrictMatch
));
}
use crate::graph::FilesystemGraphProvider;
use crate::graph::unified::persistence::{
BuildProvenance, GraphStorage, MANIFEST_SCHEMA_VERSION, Manifest, PluginSelectionManifest,
SNAPSHOT_FORMAT_VERSION, save_to_path,
};
use crate::plugin::PluginManager;
use sha2::{Digest, Sha256};
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn build_test_fixture(root: &Path, plugin_ids: &[&str]) {
let storage = GraphStorage::new(root);
fs::create_dir_all(storage.graph_dir()).expect("graph dir");
let graph = CodeGraph::new();
save_to_path(&graph, storage.snapshot_path()).expect("save snapshot");
let snapshot_sha256 = {
let bytes = fs::read(storage.snapshot_path()).expect("read snapshot");
hex::encode(Sha256::digest(&bytes))
};
let snapshot = graph.snapshot();
let manifest = Manifest {
schema_version: MANIFEST_SCHEMA_VERSION,
snapshot_format_version: SNAPSHOT_FORMAT_VERSION,
built_at: chrono::Utc::now().to_rfc3339(),
root_path: root.to_string_lossy().into_owned(),
node_count: snapshot.nodes().len(),
edge_count: graph.edge_count(),
raw_edge_count: None,
snapshot_sha256,
build_provenance: BuildProvenance {
sqry_version: env!("CARGO_PKG_VERSION").to_string(),
build_timestamp: chrono::Utc::now().to_rfc3339(),
build_command: "test:filesystem-provider".to_string(),
plugin_hashes: std::collections::HashMap::new(),
},
file_count: std::collections::HashMap::new(),
languages: Vec::new(),
config: std::collections::HashMap::new(),
confidence: graph.confidence().clone(),
last_indexed_commit: None,
plugin_selection: Some(PluginSelectionManifest {
active_plugin_ids: plugin_ids.iter().map(|id| (*id).to_string()).collect(),
high_cost_mode: None,
}),
};
manifest
.save(storage.manifest_path())
.expect("save manifest");
}
fn make_provider() -> FilesystemGraphProvider {
FilesystemGraphProvider::new(Arc::new(PluginManager::new()))
}
fn fs_request(path: PathBuf) -> GraphAcquisitionRequest {
GraphAcquisitionRequest {
requested_path: path,
operation: AcquisitionOperation::ReadOnlyQuery,
path_policy: PathPolicy::default(),
missing_graph_policy: MissingGraphPolicy::Error,
stale_policy: StalePolicy::default(),
plugin_selection_policy: PluginSelectionPolicy::default(),
tool_name: Some("filesystem_provider_test"),
}
}
#[test]
fn filesystem_provider_returns_invalid_path_for_nonexistent_path() {
let tmp = TempDir::new().expect("tempdir");
let bogus = tmp.path().join("does/not/exist");
let provider = make_provider();
let err = provider
.acquire(fs_request(bogus.clone()))
.expect_err("non-existent path must fail");
match err {
GraphAcquisitionError::InvalidPath { path, reason } => {
assert_eq!(path, bogus);
assert!(
reason.contains("does not exist") || reason.contains("cannot be canonicalized"),
"unexpected reason: {reason}"
);
}
other => panic!("expected InvalidPath, got {other:?}"),
}
}
#[test]
fn filesystem_provider_returns_invalid_path_for_outside_workspace() {
let tmp = TempDir::new().expect("tempdir");
let workspace = tmp.path().join("workspace");
fs::create_dir_all(&workspace).expect("mk workspace");
let plugins = build_plugin_manager_for_tests();
build_test_fixture(&workspace, &["mock-rust"]);
let sibling = tmp.path().join("sibling");
fs::create_dir_all(&sibling).expect("mk sibling");
let provider = FilesystemGraphProvider::new(Arc::new(plugins));
let err = provider
.acquire(fs_request(sibling.clone()))
.expect_err("sibling without graph must fail");
assert!(
matches!(
err,
GraphAcquisitionError::NoGraph { .. } | GraphAcquisitionError::InvalidPath { .. }
),
"expected NoGraph or InvalidPath, got {err:?}"
);
}
#[test]
fn filesystem_provider_loads_existing_valid_graph() {
let tmp = TempDir::new().expect("tempdir");
let workspace = tmp.path().join("ws");
fs::create_dir_all(&workspace).expect("mk workspace");
let plugins = build_plugin_manager_for_tests();
build_test_fixture(&workspace, &["mock-rust"]);
let provider = FilesystemGraphProvider::new(Arc::new(plugins));
let acquisition = provider
.acquire(fs_request(workspace.clone()))
.expect("provider acquires existing graph");
assert_eq!(
acquisition.workspace_root,
workspace.canonicalize().expect("canon workspace")
);
assert!(matches!(
acquisition.freshness,
GraphFreshness::Fresh { .. }
));
assert_eq!(
acquisition.metadata.acquisition_source,
AcquisitionSource::Filesystem
);
assert!(acquisition.identity.snapshot_sha256.is_some());
assert_eq!(
acquisition.identity.plugin_selection_status,
PluginSelectionStatus::Exact
);
}
#[test]
fn filesystem_provider_unknown_plugin_ids_returns_incompatible_graph() {
let tmp = TempDir::new().expect("tempdir");
let workspace = tmp.path().join("ws");
fs::create_dir_all(&workspace).expect("mk workspace");
let plugins = build_plugin_manager_for_tests();
build_test_fixture(
&workspace,
&["mock-rust", "imaginary-unknown-plugin-id-zzz"],
);
let provider = FilesystemGraphProvider::new(Arc::new(plugins));
let err = provider
.acquire(fs_request(workspace.clone()))
.expect_err("manifest with unknown plugin id must fail");
match err {
GraphAcquisitionError::IncompatibleGraph { status, .. } => match status {
PluginSelectionStatus::IncompatibleUnknownPluginIds {
unknown_plugin_ids,
manifest_path,
} => {
assert!(
unknown_plugin_ids.iter().any(|id| id.contains("imaginary")),
"expected the synthetic id in the diagnostic, got {unknown_plugin_ids:?}"
);
assert!(
manifest_path
.as_ref()
.is_some_and(|p| p.ends_with("manifest.json")),
"manifest_path should point at the on-disk manifest, got {manifest_path:?}"
);
}
other => panic!("expected IncompatibleUnknownPluginIds, got {other:?}"),
},
other => panic!("expected IncompatibleGraph, got {other:?}"),
}
}
#[test]
fn filesystem_provider_incompatible_snapshot_version_returns_incompatible_graph() {
use crate::graph::unified::persistence::{GraphHeader, MAGIC_BYTES_V10};
let tmp = TempDir::new().expect("tempdir");
let workspace = tmp.path().join("ws");
fs::create_dir_all(&workspace).expect("mk workspace");
build_test_fixture(&workspace, &["mock-rust"]);
let storage = GraphStorage::new(&workspace);
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 bogus snapshot");
let snapshot_sha256 = hex::encode(Sha256::digest(&bytes));
let mut manifest = Manifest::load(storage.manifest_path()).expect("load manifest");
manifest.snapshot_sha256 = snapshot_sha256;
manifest
.save(storage.manifest_path())
.expect("save manifest");
let plugins = build_plugin_manager_for_tests();
let provider = FilesystemGraphProvider::new(Arc::new(plugins));
let err = provider
.acquire(fs_request(workspace.clone()))
.expect_err("incompatible-version snapshot must fail acquisition");
match err {
GraphAcquisitionError::IncompatibleGraph {
status,
source_root,
} => {
assert_eq!(source_root, workspace.canonicalize().unwrap_or(workspace));
match status {
PluginSelectionStatus::IncompatibleSnapshotFormat { reason } => {
assert!(
reason.contains("snapshot version mismatch")
&& reason.contains("found 99"),
"expected snapshot version mismatch diagnostic, got {reason:?}"
);
}
other => panic!("expected IncompatibleSnapshotFormat, got {other:?}"),
}
}
other => panic!("expected IncompatibleGraph, got {other:?}"),
}
}
fn build_plugin_manager_for_tests() -> PluginManager {
use crate::plugin::types::LanguageMetadata;
use crate::plugin::types::LanguagePlugin;
use std::path::Path;
struct AcquisitionTestPlugin;
impl LanguagePlugin for AcquisitionTestPlugin {
fn metadata(&self) -> LanguageMetadata {
LanguageMetadata {
id: "mock-rust",
name: "MockRust",
version: "0.0.0",
author: "sqry-tests",
description: "FilesystemGraphProvider acquisition tests",
tree_sitter_version: "0.24",
}
}
fn extensions(&self) -> &'static [&'static str] {
&["rs"]
}
fn language(&self) -> tree_sitter::Language {
tree_sitter_rust::LANGUAGE.into()
}
fn parse_ast(
&self,
_content: &[u8],
) -> Result<tree_sitter::Tree, crate::plugin::error::ParseError> {
Err(crate::plugin::error::ParseError::TreeSitterFailed)
}
fn extract_scopes(
&self,
_tree: &tree_sitter::Tree,
_content: &[u8],
_file: &Path,
) -> Result<Vec<crate::ast::Scope>, crate::plugin::error::ScopeError> {
Ok(Vec::new())
}
}
let mut pm = PluginManager::new();
pm.register_builtin(Box::new(AcquisitionTestPlugin));
pm
}
}