sqry_core/graph/acquisition.rs
1//! Shared graph acquisition API (DAG unit SGA02).
2//!
3//! Transport-neutral abstraction that lets every read-only semantic-search
4//! surface (CLI `sqry query`, standalone MCP, daemon-hosted MCP, LSP) acquire
5//! the same loaded [`CodeGraph`] before executing a query.
6//!
7//! This module owns *only* the contract: types, traits, and error taxonomy.
8//! Concrete providers (filesystem-backed, daemon-backed) live in subsequent
9//! DAG units (SGA03 / SGA04). Adapters in `sqry-cli`, `sqry-mcp`, `sqry-lsp`,
10//! and `sqry-daemon` consume the contract.
11//!
12//! ## Design references
13//!
14//! - `docs/development/shared-graph-acquisition/01_SPEC.md`
15//! - `docs/development/shared-graph-acquisition/02_DESIGN.md` (normative)
16//! - `docs/development/shared-graph-acquisition/03_IMPLEMENTATION_PLAN.md`
17//!
18//! ## Boundaries
19//!
20//! - The trait is **synchronous**. Daemon callers wrap blocking acquisition in
21//! `spawn_blocking`; standalone callers run on the calling thread. This
22//! matches today's synchronous query-execution path.
23//! - Path-policy errors ([`GraphAcquisitionError::InvalidPath`]) are first
24//! class and **must not** be collapsed into [`GraphAcquisitionError::Internal`].
25//! - Stale serves are explicit ([`GraphFreshness::Stale`]). Adapters cannot
26//! silently mask staleness.
27//! - The reload origin ([`ReloadOrigin`]) is a neutral diagnostic enum; it
28//! carries `String` detail rather than depending on `sqry-daemon` types so
29//! this module remains independent of any transport crate.
30
31use std::num::NonZeroU8;
32use std::path::{Path, PathBuf};
33use std::sync::Arc;
34
35use thiserror::Error;
36
37use crate::graph::CodeGraph;
38use crate::graph::unified::persistence::{
39 GraphStorage, Manifest, PersistenceError, load_from_bytes, verify_snapshot_bytes,
40};
41use crate::plugin::PluginManager;
42
43// ---------------------------------------------------------------------------
44// Trait
45// ---------------------------------------------------------------------------
46
47/// Acquire a loaded [`CodeGraph`] for the requested path.
48///
49/// Implementations must:
50/// * Validate the requested path *before* attempting any graph load.
51/// * Honor the [`AcquisitionOperation`] mode — `MutatingRebuild` callers must
52/// never receive a [`GraphFreshness::Stale`] or [`GraphFreshness::Reloaded`]
53/// result from a read-only fallback.
54/// * Surface [`GraphAcquisitionError::InvalidPath`],
55/// [`GraphAcquisitionError::Evicted`], [`GraphAcquisitionError::StaleExpired`],
56/// and [`GraphAcquisitionError::IncompatibleGraph`] distinctly — adapters
57/// rely on this to map to per-transport diagnostics.
58///
59/// The trait is `Send + Sync` so providers can be stored behind `Arc<dyn _>`
60/// in long-lived hosts (daemon workspace manager, LSP session, MCP engine).
61pub trait GraphAcquirer: Send + Sync {
62 /// Acquire a graph for `request`. Returns a populated [`GraphAcquisition`]
63 /// or a typed [`GraphAcquisitionError`].
64 ///
65 /// # Errors
66 ///
67 /// Returns [`GraphAcquisitionError`] when path validation fails, no usable
68 /// graph exists, a stale graph is outside policy, the persisted graph is
69 /// incompatible with the current runtime, or graph loading fails.
70 fn acquire(
71 &self,
72 request: GraphAcquisitionRequest,
73 ) -> Result<GraphAcquisition, GraphAcquisitionError>;
74}
75
76// ---------------------------------------------------------------------------
77// Request
78// ---------------------------------------------------------------------------
79
80/// Inputs to a single acquisition call.
81///
82/// All fields are required. Callers construct the request inline; there is no
83/// builder because every field has security or correctness implications and a
84/// silent default would let an adapter accidentally weaken the contract.
85#[derive(Debug, Clone)]
86pub struct GraphAcquisitionRequest {
87 /// Path the user supplied (may be a workspace root, a directory inside
88 /// the workspace, or a file). Providers canonicalize before use.
89 pub requested_path: PathBuf,
90 /// Whether the caller intends to read or mutate.
91 pub operation: AcquisitionOperation,
92 /// Path-security policy applied before any graph load.
93 pub path_policy: PathPolicy,
94 /// What to do when no graph artifact exists for the resolved workspace.
95 pub missing_graph_policy: MissingGraphPolicy,
96 /// What to do when only a stale graph is available.
97 pub stale_policy: StalePolicy,
98 /// How to react to manifest plugin selection mismatches.
99 pub plugin_selection_policy: PluginSelectionPolicy,
100 /// Optional tool name for diagnostics and observability. `'static` because
101 /// every call site is a fixed string literal.
102 pub tool_name: Option<&'static str>,
103}
104
105/// Whether the acquisition is for a read-only query or a mutating rebuild.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum AcquisitionOperation {
108 /// Read-only query path (CLI query, MCP search, LSP relations, etc.).
109 /// May load existing graphs, serve stale within policy, and trigger
110 /// exactly one daemon read-only reload after eviction.
111 ReadOnlyQuery,
112 /// Mutating rebuild path (`rebuild_index`, daemon rebuild, watcher).
113 /// Must never be served from stale or read-only-reloaded state.
114 MutatingRebuild,
115}
116
117/// Behavior when no graph artifact exists for the resolved workspace.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum MissingGraphPolicy {
120 /// Return [`GraphAcquisitionError::NoGraph`].
121 Error,
122 /// Auto-build the graph if the surface already supports auto-indexing
123 /// (standalone MCP, LSP). Disabled for CLI query.
124 AutoBuildIfEnabled,
125}
126
127/// Path-security policy applied *before* any graph load occurs.
128///
129/// All fields are independent flags: producers compose the desired strictness
130/// at the call site. The defaults (via [`PathPolicy::default`]) are the most
131/// restrictive options because weakening must be explicit.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub struct PathPolicy {
134 /// If `true`, the requested path must already exist on disk and be
135 /// canonicalizable. If `false`, the provider may accept a path that does
136 /// not yet exist (used by some auto-build flows).
137 pub require_existing: bool,
138 /// If `true`, after canonicalization the requested path must be inside the
139 /// resolved workspace root (or equal to it). Prevents
140 /// `../../outside-workspace` escapes.
141 pub require_within_workspace: bool,
142 /// If `true`, symlinks whose target escapes the workspace are accepted
143 /// (used for vendored sources). The default `false` rejects such escapes.
144 pub allow_symlink_escape: bool,
145}
146
147impl Default for PathPolicy {
148 /// The strict default: require an existing path inside the workspace and
149 /// reject symlink escapes. Adapters that need a weaker policy must opt in
150 /// explicitly.
151 fn default() -> Self {
152 Self {
153 require_existing: true,
154 require_within_workspace: true,
155 allow_symlink_escape: false,
156 }
157 }
158}
159
160/// Behavior when only a stale graph is available.
161///
162/// Does not derive `Eq` because [`StalePolicy::AcceptStaleWithinWindow`]
163/// carries an `f64` window. Adapters compare windows via `PartialEq` only.
164#[derive(Debug, Clone, Copy, PartialEq)]
165pub enum StalePolicy {
166 /// Refuse to serve a stale graph. Returns
167 /// [`GraphAcquisitionError::StaleExpired`] when only stale data exists.
168 RejectStale,
169 /// Allow stale serves whose age is within `max_age_hours`. Beyond the
170 /// window, providers must return [`GraphAcquisitionError::StaleExpired`].
171 AcceptStaleWithinWindow {
172 /// Maximum age of a stale graph that may still be served, in hours.
173 max_age_hours: f64,
174 },
175}
176
177impl Default for StalePolicy {
178 /// Default: reject stale. Stale-serve must be explicitly enabled per
179 /// transport surface (today only the daemon).
180 fn default() -> Self {
181 Self::RejectStale
182 }
183}
184
185/// How to react to manifest plugin selection mismatches.
186///
187/// Unknown plugin ids in a manifest mean the runtime cannot reproduce the
188/// indexed semantics — silent acceptance would hide language coverage loss.
189#[derive(Debug, Clone, PartialEq, Eq, Default)]
190pub enum PluginSelectionPolicy {
191 /// Default. Any unknown plugin id in the manifest is a terminal
192 /// [`GraphAcquisitionError::IncompatibleGraph`].
193 #[default]
194 StrictMatch,
195 /// Compatibility opt-in: only the listed unknown plugin ids are tolerated.
196 /// Any *other* unknown id still fails. Reserved for future use behind
197 /// explicit user configuration.
198 AllowUnknownIds {
199 /// Unknown plugin ids the caller has explicitly approved.
200 allowed: Vec<String>,
201 },
202}
203
204// ---------------------------------------------------------------------------
205// Result
206// ---------------------------------------------------------------------------
207
208/// Successful acquisition outcome.
209#[derive(Debug, Clone)]
210pub struct GraphAcquisition {
211 /// Reference-counted handle to the loaded graph. Cloning is cheap; the
212 /// caller may share it with `sqry-db`, the query executor, etc.
213 pub graph: Arc<CodeGraph>,
214 /// Canonical workspace root (the directory containing `.sqry/graph/`).
215 pub workspace_root: PathBuf,
216 /// Optional sub-scope when the user queried a directory or file inside
217 /// the workspace. `None` when the request targeted the workspace root.
218 pub query_scope: Option<PathBuf>,
219 /// `true` when [`Self::query_scope`] points at a single file rather than
220 /// a directory. Adapters use this to apply the file-scope filter.
221 pub is_file_scope: bool,
222 /// Freshness/lifecycle of the served graph.
223 pub freshness: GraphFreshness,
224 /// Identity (snapshot hash, manifest timestamp, plugin status).
225 pub identity: GraphIdentity,
226 /// Free-form per-acquisition metadata (source, tool, notes).
227 pub metadata: GraphAcquisitionMetadata,
228}
229
230/// Lifecycle state of the graph that was returned.
231///
232/// Adapters render this into transport-specific freshness signals
233/// (`ResponseMeta::stale_from`, `_stale_warning`, LSP diagnostics).
234#[derive(Debug, Clone, PartialEq)]
235pub enum GraphFreshness {
236 /// Graph is current relative to its source. Optional `lifecycle_label`
237 /// carries the daemon-side lifecycle name for diagnostics.
238 Fresh {
239 /// Daemon lifecycle label (`"Loaded"`, `"Rebuilding"`, etc.). `None`
240 /// for filesystem providers that have no lifecycle concept.
241 lifecycle_label: Option<&'static str>,
242 },
243 /// Graph is older than its source but is being served per stale policy.
244 Stale {
245 /// ISO-8601 timestamp of the last successful build, when available.
246 last_good_at: Option<String>,
247 /// Diagnostic for the failure that caused staleness.
248 last_error: Option<String>,
249 /// Age of the served graph, in hours.
250 age_hours: Option<f64>,
251 },
252 /// Graph was reloaded after an `Unloaded` or `Evicted` lifecycle. This is
253 /// the bounded one-shot reload path described in the design.
254 Reloaded {
255 /// Why the original lifecycle entered an unloaded/evicted state.
256 original_lifecycle: ReloadOrigin,
257 /// Daemon lifecycle label after the successful reload.
258 final_lifecycle_label: &'static str,
259 /// Number of reload attempts. The bounded contract caps this at one,
260 /// so `NonZeroU8::new(1)` is the only legal value today; the type
261 /// reserves headroom for future bounded retries while making zero
262 /// unrepresentable.
263 reload_attempts: NonZeroU8,
264 },
265}
266
267/// Why the daemon workspace required a reload.
268///
269/// Pure diagnostic enum: deliberately uses `String` rather than depending on
270/// `sqry-daemon` types so this contract crate stays transport-neutral.
271#[derive(Debug, Clone, PartialEq, Eq)]
272pub enum ReloadOrigin {
273 /// Workspace had been unloaded (idle timeout, explicit unload).
274 Unloaded {
275 /// Free-form detail captured by the adapter.
276 detail: String,
277 },
278 /// Workspace had been evicted by memory admission.
279 Evicted {
280 /// Free-form detail captured by the adapter.
281 detail: String,
282 },
283}
284
285/// Identity of the served graph.
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct GraphIdentity {
288 /// SHA-256 of the snapshot file when known (filesystem providers).
289 pub snapshot_sha256: Option<String>,
290 /// ISO-8601 manifest build timestamp when available.
291 pub manifest_built_at: Option<String>,
292 /// Snapshot persistence format version (e.g. `7`, `10`).
293 pub snapshot_format_version: Option<u32>,
294 /// Canonical source root the graph was built for.
295 pub source_root: PathBuf,
296 /// Plugin manifest compatibility status — see [`PluginSelectionStatus`].
297 pub plugin_selection_status: PluginSelectionStatus,
298}
299
300/// Compatibility verdict between the manifest plugin set and the runtime.
301#[derive(Debug, Clone, PartialEq, Eq)]
302#[non_exhaustive]
303pub enum PluginSelectionStatus {
304 /// Every manifest plugin id is supported by this binary.
305 Exact,
306 /// Manifest references plugin ids the runtime does not know.
307 /// `manifest_path` is set by [`FilesystemGraphProvider`] when the
308 /// status was produced from a persisted manifest; consumers (CLI,
309 /// MCP, daemon) feed both `unknown_plugin_ids` and `manifest_path`
310 /// to `sqry_plugin_registry::missing_features_for` to render an
311 /// actionable diagnostic (cluster-E §E.2).
312 IncompatibleUnknownPluginIds {
313 /// The unknown ids, in the order they appear in the manifest.
314 unknown_plugin_ids: Vec<String>,
315 /// Absolute path to the manifest file that produced the
316 /// unknown ids. `None` for synthetic in-memory manifests
317 /// (unit tests, in-process providers without on-disk state).
318 manifest_path: Option<PathBuf>,
319 },
320 /// Snapshot format itself cannot be loaded by this binary.
321 IncompatibleSnapshotFormat {
322 /// Human-readable explanation.
323 reason: String,
324 },
325}
326
327/// Free-form per-acquisition diagnostics.
328#[derive(Debug, Clone, PartialEq, Eq)]
329pub struct GraphAcquisitionMetadata {
330 /// Which provider served the request.
331 pub acquisition_source: AcquisitionSource,
332 /// Tool name forwarded from the request, if any.
333 pub tool_name: Option<&'static str>,
334 /// Provider-specific notes (e.g. cache hit/miss, reload counts). Adapters
335 /// append; this field is not part of any wire contract.
336 pub notes: Vec<String>,
337}
338
339/// Provider that served a given acquisition.
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub enum AcquisitionSource {
342 /// Filesystem-backed provider (CLI, standalone MCP, standalone LSP).
343 Filesystem,
344 /// Daemon provider returned a graph already loaded in workspace memory.
345 DaemonReadOnly,
346 /// Daemon provider performed the bounded one-shot reload before serving.
347 DaemonReloaded,
348}
349
350// ---------------------------------------------------------------------------
351// Errors
352// ---------------------------------------------------------------------------
353
354/// Acquisition failure taxonomy.
355///
356/// Each variant maps to a distinct user-facing diagnostic class. Adapters must
357/// preserve the variant — collapsing into a generic internal error violates
358/// the contract.
359#[derive(Debug, Error)]
360pub enum GraphAcquisitionError {
361 /// Path canonicalization failed, the path does not exist where required,
362 /// it is not under an allowed source root, or it escaped through a
363 /// symlink. Path validation runs before any graph load.
364 #[error("invalid path {path:?}: {reason}")]
365 InvalidPath {
366 /// The path that failed validation.
367 path: PathBuf,
368 /// Why validation failed.
369 reason: String,
370 },
371 /// No graph artifact exists for this workspace and the policy disallows
372 /// auto-build.
373 #[error("no graph artifact for workspace {workspace_root:?}")]
374 NoGraph {
375 /// Workspace root that has no graph.
376 workspace_root: PathBuf,
377 },
378 /// Snapshot, manifest, or analysis load failed.
379 #[error("graph load failed for {source_root:?}: {reason}")]
380 LoadFailed {
381 /// The source root the load was attempted for.
382 source_root: PathBuf,
383 /// Failure detail (I/O error, integrity check, format mismatch).
384 reason: String,
385 },
386 /// Snapshot or manifest cannot be used safely by this binary (unknown
387 /// plugin ids, unsupported snapshot format).
388 #[error("incompatible graph for {source_root:?}: {status:?}")]
389 IncompatibleGraph {
390 /// Source root the snapshot was built for.
391 source_root: PathBuf,
392 /// Specific compatibility verdict.
393 status: PluginSelectionStatus,
394 },
395 /// Daemon workspace is `Unloaded` or `Loading` and read-only reload is
396 /// not applicable.
397 #[error("workspace not ready: {workspace_root:?} (lifecycle={lifecycle})")]
398 NotReady {
399 /// Workspace root.
400 workspace_root: PathBuf,
401 /// Daemon lifecycle label.
402 lifecycle: String,
403 },
404 /// Daemon workspace was evicted and either reload was not allowed (e.g.
405 /// `MutatingRebuild`) or the bounded one-shot reload itself failed.
406 #[error("workspace evicted: {workspace_root:?} (original_lifecycle={original_lifecycle})")]
407 Evicted {
408 /// Workspace root.
409 workspace_root: PathBuf,
410 /// Daemon lifecycle label at the time of eviction.
411 original_lifecycle: String,
412 /// Reason the bounded reload failed, when a reload was attempted.
413 /// `None` means reload was not attempted (e.g. `MutatingRebuild`).
414 reload_failure: Option<String>,
415 },
416 /// Stale graph age exceeded the configured stale-serve window.
417 #[error("stale graph expired for {workspace_root:?} (age_hours={age_hours:?})")]
418 StaleExpired {
419 /// Workspace root.
420 workspace_root: PathBuf,
421 /// Age of the stale graph in hours, when known.
422 age_hours: Option<f64>,
423 },
424 /// An approved load/build path failed mid-way (auto-build, daemon reload).
425 #[error("graph build failed for {workspace_root:?}: {reason}")]
426 BuildFailed {
427 /// Workspace root.
428 workspace_root: PathBuf,
429 /// Build failure detail.
430 reason: String,
431 },
432 /// Invariant violation, join failure, or other condition that does not
433 /// fit the typed taxonomy. Adapters must not collapse path/eviction/
434 /// stale/incompatible failures into this variant.
435 #[error("internal acquisition error: {reason}")]
436 Internal {
437 /// Internal failure detail.
438 reason: String,
439 },
440}
441
442// ---------------------------------------------------------------------------
443// FilesystemGraphProvider (DAG unit SGA03)
444// ---------------------------------------------------------------------------
445
446/// Optional fallback hook for [`MissingGraphPolicy::AutoBuildIfEnabled`].
447///
448/// The provider lives in `sqry-core` and intentionally does not depend on any
449/// builder front-ends (CLI, MCP engine). Surfaces that already implement
450/// auto-build (standalone MCP, LSP) supply a closure that builds the graph,
451/// loads it, and returns the result. CLI omits this hook entirely.
452pub type AutoBuildHook =
453 Arc<dyn Fn(&Path) -> Result<Arc<CodeGraph>, GraphAcquisitionError> + Send + Sync>;
454
455/// Filesystem-backed [`GraphAcquirer`] used by CLI query, standalone MCP, and
456/// standalone LSP.
457///
458/// Responsibilities (per `02_DESIGN.md` §Providers):
459///
460/// 1. Canonicalize the requested path and reject non-existent / outside-
461/// workspace / symlink-escape inputs **before** any graph load.
462/// 2. Discover the nearest `.sqry/graph` ancestor using the same depth-
463/// bounded walk as `sqry-cli::index_discovery::find_nearest_index`.
464/// 3. Compute `query_scope` and `is_file_scope` exactly the way the CLI does
465/// today.
466/// 4. Read manifest, verify snapshot SHA-256 (when manifest is present), and
467/// load via [`load_from_bytes`] with the configured [`PluginManager`].
468/// 5. Surface unknown manifest plugin ids as
469/// [`GraphAcquisitionError::IncompatibleGraph`] when policy is
470/// [`PluginSelectionPolicy::StrictMatch`].
471/// 6. Honor [`MissingGraphPolicy`] — `Error` returns
472/// [`GraphAcquisitionError::NoGraph`]; `AutoBuildIfEnabled` delegates to
473/// the supplied [`AutoBuildHook`] (if any) and otherwise behaves like
474/// `Error`.
475pub struct FilesystemGraphProvider {
476 plugin_manager: Arc<PluginManager>,
477 auto_build: Option<AutoBuildHook>,
478}
479
480impl std::fmt::Debug for FilesystemGraphProvider {
481 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
482 f.debug_struct("FilesystemGraphProvider")
483 .field("plugin_manager", &"<PluginManager>")
484 .field("auto_build_hook", &self.auto_build.is_some())
485 .finish()
486 }
487}
488
489impl FilesystemGraphProvider {
490 /// Build a provider that uses the given plugin manager for snapshot loads
491 /// and for plugin-selection compatibility checks.
492 #[must_use]
493 pub fn new(plugin_manager: Arc<PluginManager>) -> Self {
494 Self {
495 plugin_manager,
496 auto_build: None,
497 }
498 }
499
500 /// Attach an auto-build hook that the provider invokes when
501 /// [`MissingGraphPolicy::AutoBuildIfEnabled`] is configured **and** no
502 /// graph artifact exists for the resolved workspace.
503 #[must_use]
504 pub fn with_auto_build_hook(mut self, hook: AutoBuildHook) -> Self {
505 self.auto_build = Some(hook);
506 self
507 }
508
509 /// Read access to the underlying plugin manager (used by adapters that
510 /// already share an executor with the same plugin selection).
511 #[must_use]
512 pub fn plugin_manager(&self) -> &PluginManager {
513 &self.plugin_manager
514 }
515
516 /// Step 1 — apply [`PathPolicy`] and return the canonical request path.
517 ///
518 /// On failure, the returned [`GraphAcquisitionError::InvalidPath`] carries
519 /// the original (un-canonicalized) request path so adapters can render
520 /// stable diagnostics.
521 fn apply_path_policy(
522 request_path: &Path,
523 policy: PathPolicy,
524 ) -> Result<PathBuf, GraphAcquisitionError> {
525 let exists = request_path.exists();
526 if policy.require_existing && !exists {
527 return Err(GraphAcquisitionError::InvalidPath {
528 path: request_path.to_path_buf(),
529 reason: "path does not exist".to_string(),
530 });
531 }
532
533 // Best-effort canonicalize. When the path does not exist and the
534 // policy permits non-existing paths, fall back to the absolute form.
535 let canonical = match request_path.canonicalize() {
536 Ok(p) => p,
537 Err(e) => {
538 if policy.require_existing {
539 return Err(GraphAcquisitionError::InvalidPath {
540 path: request_path.to_path_buf(),
541 reason: format!("path cannot be canonicalized: {e}"),
542 });
543 }
544 if request_path.is_absolute() {
545 request_path.to_path_buf()
546 } else {
547 std::env::current_dir()
548 .map_or_else(|_| request_path.to_path_buf(), |cwd| cwd.join(request_path))
549 }
550 }
551 };
552
553 Ok(canonical)
554 }
555
556 /// Step 2 — find the nearest `.sqry/graph` ancestor of `start`, bounded
557 /// by project markers (cluster-E §E.1).
558 ///
559 /// Delegates to [`crate::workspace::discover_workspace_root`] so the walk
560 /// terminates at the first ancestor containing any of
561 /// [`crate::workspace::PROJECT_MARKERS`] (`.git`, `Cargo.toml`,
562 /// `package.json`, `pyproject.toml`, `go.mod`). This eliminates the
563 /// "stray `~/.sqry/graph` foot-gun" where a leftover graph at `$HOME`
564 /// was silently picked up for a brand-new project that lacked its
565 /// own graph.
566 ///
567 /// Returns:
568 /// - `Some((root, depth, is_file_scope))` only when a graph exists at or
569 /// inside the project boundary
570 /// ([`crate::workspace::WorkspaceRootDiscovery::GraphFound`]).
571 /// - `None` when no graph was found, OR when a graph was found but lives
572 /// in an *outer* project (the
573 /// [`crate::workspace::WorkspaceRootDiscovery::BoundaryOnly`] case
574 /// where the discovered graph does not belong to the same project
575 /// boundary as `start`). In the `BoundaryOnly` case the caller path
576 /// below uses `canonical_request` as the `workspace_root` for
577 /// [`GraphAcquisitionError::NoGraph`] / `AutoBuildIfEnabled`, which
578 /// is always inside the right project even if it is deeper than the
579 /// boundary itself.
580 fn find_workspace_root(start: &Path) -> Option<(PathBuf, usize, bool)> {
581 match crate::workspace::discover_workspace_root(start) {
582 crate::workspace::WorkspaceRootDiscovery::GraphFound {
583 root,
584 depth,
585 is_file_scope,
586 ..
587 } => Some((root, depth, is_file_scope)),
588 crate::workspace::WorkspaceRootDiscovery::BoundaryOnly { .. }
589 | crate::workspace::WorkspaceRootDiscovery::None => None,
590 }
591 }
592
593 /// Step 4 — compute the plugin-selection compatibility verdict using the
594 /// configured plugin manager and the persisted manifest. `manifest_path`
595 /// (when known) is propagated into the
596 /// [`PluginSelectionStatus::IncompatibleUnknownPluginIds`] variant so
597 /// downstream consumers can render it in the user-facing error
598 /// (cluster-E §E.2).
599 fn classify_plugin_selection(
600 &self,
601 manifest: &Manifest,
602 manifest_path: Option<&Path>,
603 policy: &PluginSelectionPolicy,
604 ) -> PluginSelectionStatus {
605 let Some(persisted) = manifest.plugin_selection.as_ref() else {
606 return PluginSelectionStatus::Exact;
607 };
608
609 let mut unknown: Vec<String> = persisted
610 .active_plugin_ids
611 .iter()
612 .filter(|id| self.plugin_manager.plugin_by_id(id).is_none())
613 .cloned()
614 .collect();
615
616 if unknown.is_empty() {
617 return PluginSelectionStatus::Exact;
618 }
619
620 if let PluginSelectionPolicy::AllowUnknownIds { allowed } = policy {
621 unknown.retain(|id| !allowed.contains(id));
622 if unknown.is_empty() {
623 return PluginSelectionStatus::Exact;
624 }
625 }
626
627 PluginSelectionStatus::IncompatibleUnknownPluginIds {
628 unknown_plugin_ids: unknown,
629 manifest_path: manifest_path.map(Path::to_path_buf),
630 }
631 }
632
633 fn acquire_without_graph(
634 &self,
635 canonical_request: &Path,
636 request: &GraphAcquisitionRequest,
637 ) -> Result<GraphAcquisition, GraphAcquisitionError> {
638 match request.missing_graph_policy {
639 MissingGraphPolicy::Error => Err(GraphAcquisitionError::NoGraph {
640 workspace_root: canonical_request.to_path_buf(),
641 }),
642 MissingGraphPolicy::AutoBuildIfEnabled => match &self.auto_build {
643 Some(hook) => {
644 let graph = hook(canonical_request)?;
645 Ok(GraphAcquisition {
646 graph,
647 workspace_root: canonical_request.to_path_buf(),
648 query_scope: None,
649 is_file_scope: false,
650 freshness: GraphFreshness::Fresh {
651 lifecycle_label: None,
652 },
653 identity: GraphIdentity {
654 snapshot_sha256: None,
655 manifest_built_at: None,
656 snapshot_format_version: None,
657 source_root: canonical_request.to_path_buf(),
658 plugin_selection_status: PluginSelectionStatus::Exact,
659 },
660 metadata: GraphAcquisitionMetadata {
661 acquisition_source: AcquisitionSource::Filesystem,
662 tool_name: request.tool_name,
663 notes: vec!["auto-built via provider hook".to_string()],
664 },
665 })
666 }
667 None => Err(GraphAcquisitionError::NoGraph {
668 workspace_root: canonical_request.to_path_buf(),
669 }),
670 },
671 }
672 }
673
674 fn validate_workspace_boundary(
675 requested_path: PathBuf,
676 canonical_request: &Path,
677 workspace_root: &Path,
678 path_policy: PathPolicy,
679 ) -> Result<(), GraphAcquisitionError> {
680 if path_policy.require_within_workspace
681 && !canonical_request.starts_with(workspace_root)
682 && !path_policy.allow_symlink_escape
683 {
684 return Err(GraphAcquisitionError::InvalidPath {
685 path: requested_path,
686 reason: format!(
687 "canonical path {} escapes workspace root {}",
688 canonical_request.display(),
689 workspace_root.display()
690 ),
691 });
692 }
693 Ok(())
694 }
695
696 fn load_manifest_for_acquisition(
697 storage: &GraphStorage,
698 workspace_root: &Path,
699 ) -> Result<(Option<Manifest>, String), GraphAcquisitionError> {
700 if !storage.manifest_path().exists() {
701 return Ok((None, String::new()));
702 }
703
704 storage
705 .load_manifest()
706 .map(|manifest| {
707 let expected_sha = manifest.snapshot_sha256.clone();
708 (Some(manifest), expected_sha)
709 })
710 .map_err(|e| GraphAcquisitionError::LoadFailed {
711 source_root: workspace_root.to_path_buf(),
712 reason: format!("manifest unreadable: {e}"),
713 })
714 }
715
716 fn validate_plugin_selection(
717 &self,
718 manifest: Option<&Manifest>,
719 manifest_path: &Path,
720 policy: &PluginSelectionPolicy,
721 workspace_root: &Path,
722 ) -> Result<(), GraphAcquisitionError> {
723 let plugin_status = manifest.map_or(PluginSelectionStatus::Exact, |m| {
724 self.classify_plugin_selection(m, Some(manifest_path), policy)
725 });
726 if matches!(plugin_status, PluginSelectionStatus::Exact) {
727 Ok(())
728 } else {
729 Err(GraphAcquisitionError::IncompatibleGraph {
730 source_root: workspace_root.to_path_buf(),
731 status: plugin_status,
732 })
733 }
734 }
735
736 fn load_graph_snapshot(
737 &self,
738 storage: &GraphStorage,
739 workspace_root: &Path,
740 expected_sha: &str,
741 ) -> Result<Arc<CodeGraph>, GraphAcquisitionError> {
742 let snapshot_path = storage.snapshot_path().to_path_buf();
743 let snapshot_bytes =
744 std::fs::read(&snapshot_path).map_err(|e| GraphAcquisitionError::LoadFailed {
745 source_root: workspace_root.to_path_buf(),
746 reason: format!("read snapshot {}: {e}", snapshot_path.display()),
747 })?;
748
749 verify_snapshot_bytes(&snapshot_bytes, expected_sha).map_err(|e| {
750 GraphAcquisitionError::LoadFailed {
751 source_root: workspace_root.to_path_buf(),
752 reason: format!("snapshot integrity check failed: {e}"),
753 }
754 })?;
755
756 match load_from_bytes(&snapshot_bytes, Some(&self.plugin_manager)) {
757 Ok(graph) => Ok(Arc::new(graph)),
758 Err(PersistenceError::IncompatibleVersion { expected, found }) => {
759 Err(GraphAcquisitionError::IncompatibleGraph {
760 source_root: workspace_root.to_path_buf(),
761 status: PluginSelectionStatus::IncompatibleSnapshotFormat {
762 reason: format!(
763 "snapshot version mismatch: expected {expected}, found {found}"
764 ),
765 },
766 })
767 }
768 Err(e) => Err(GraphAcquisitionError::LoadFailed {
769 source_root: workspace_root.to_path_buf(),
770 reason: format!("snapshot deserialize: {e}"),
771 }),
772 }
773 }
774
775 fn identity_from_manifest(manifest: Option<&Manifest>, workspace_root: &Path) -> GraphIdentity {
776 GraphIdentity {
777 snapshot_sha256: manifest.map(|m| m.snapshot_sha256.clone()),
778 manifest_built_at: manifest.map(|m| m.built_at.clone()),
779 snapshot_format_version: manifest.map(|m| m.snapshot_format_version),
780 source_root: workspace_root.to_path_buf(),
781 plugin_selection_status: PluginSelectionStatus::Exact,
782 }
783 }
784}
785
786impl GraphAcquirer for FilesystemGraphProvider {
787 fn acquire(
788 &self,
789 request: GraphAcquisitionRequest,
790 ) -> Result<GraphAcquisition, GraphAcquisitionError> {
791 // Step 1: path policy. Runs BEFORE any disk graph load.
792 let canonical_request =
793 Self::apply_path_policy(&request.requested_path, request.path_policy)?;
794
795 // Step 2: find the nearest .sqry/graph ancestor.
796 let Some((workspace_root, ancestor_depth, is_file_scope)) =
797 Self::find_workspace_root(&canonical_request)
798 else {
799 return self.acquire_without_graph(&canonical_request, &request);
800 };
801
802 // Step 2b: workspace-boundary check. After ancestor discovery the
803 // canonical request must sit inside the resolved workspace root
804 // unless symlink escape is explicitly allowed.
805 Self::validate_workspace_boundary(
806 request.requested_path,
807 &canonical_request,
808 &workspace_root,
809 request.path_policy,
810 )?;
811
812 // Step 3: query scope and file-scope flags.
813 let (query_scope, is_file_scope) = if ancestor_depth > 0 || is_file_scope {
814 (Some(canonical_request.clone()), is_file_scope)
815 } else {
816 (None, false)
817 };
818
819 // Step 4: load manifest (when present) for SHA-256 + plugin compat.
820 let storage = GraphStorage::new(&workspace_root);
821 let (manifest_opt, expected_sha) =
822 Self::load_manifest_for_acquisition(&storage, &workspace_root)?;
823
824 // Step 5: plugin-selection compatibility before snapshot read.
825 self.validate_plugin_selection(
826 manifest_opt.as_ref(),
827 storage.manifest_path(),
828 &request.plugin_selection_policy,
829 &workspace_root,
830 )?;
831
832 // Step 6: read snapshot bytes, verify SHA-256, deserialize.
833 let graph = self.load_graph_snapshot(&storage, &workspace_root, &expected_sha)?;
834
835 // Step 7: assemble identity + metadata from the manifest (when present).
836 let identity = Self::identity_from_manifest(manifest_opt.as_ref(), &workspace_root);
837
838 Ok(GraphAcquisition {
839 graph,
840 workspace_root,
841 query_scope,
842 is_file_scope,
843 freshness: GraphFreshness::Fresh {
844 lifecycle_label: None,
845 },
846 identity,
847 metadata: GraphAcquisitionMetadata {
848 acquisition_source: AcquisitionSource::Filesystem,
849 tool_name: request.tool_name,
850 notes: Vec::new(),
851 },
852 })
853 }
854}
855
856// ---------------------------------------------------------------------------
857// Tests
858// ---------------------------------------------------------------------------
859
860#[cfg(test)]
861mod tests {
862 use super::*;
863 use std::sync::Mutex;
864 use std::sync::atomic::{AtomicUsize, Ordering};
865
866 /// Build a `GraphAcquisitionRequest` for tests with the strict defaults.
867 fn make_request(operation: AcquisitionOperation) -> GraphAcquisitionRequest {
868 GraphAcquisitionRequest {
869 requested_path: PathBuf::from("/tmp/sga02-test-workspace"),
870 operation,
871 path_policy: PathPolicy::default(),
872 missing_graph_policy: MissingGraphPolicy::Error,
873 stale_policy: StalePolicy::default(),
874 plugin_selection_policy: PluginSelectionPolicy::default(),
875 tool_name: Some("sga02_test"),
876 }
877 }
878
879 /// Make a freshness-only check possible without instantiating a real
880 /// `CodeGraph` arena. Tests exercise the contract types, not the
881 /// underlying graph; the freshness/source mapping is what SGA02 owns.
882 fn freshness_metadata_pair(
883 freshness: GraphFreshness,
884 source: AcquisitionSource,
885 ) -> (GraphFreshness, GraphAcquisitionMetadata) {
886 (
887 freshness,
888 GraphAcquisitionMetadata {
889 acquisition_source: source,
890 tool_name: Some("sga02_test"),
891 notes: vec![],
892 },
893 )
894 }
895
896 /// Test 1: `ReadOnlyQuery` may produce Fresh, Stale, and Reloaded
897 /// freshness states, and metadata exposes both the freshness variant and
898 /// the acquisition source for each one.
899 #[test]
900 fn acquire_mode_read_only_allows_stale_and_reloaded() {
901 let request = make_request(AcquisitionOperation::ReadOnlyQuery);
902 assert_eq!(request.operation, AcquisitionOperation::ReadOnlyQuery);
903
904 let cases = vec![
905 freshness_metadata_pair(
906 GraphFreshness::Fresh {
907 lifecycle_label: Some("Loaded"),
908 },
909 AcquisitionSource::DaemonReadOnly,
910 ),
911 freshness_metadata_pair(
912 GraphFreshness::Stale {
913 last_good_at: Some("2026-05-07T12:00:00Z".to_string()),
914 last_error: Some("rebuild failed".to_string()),
915 age_hours: Some(0.5),
916 },
917 AcquisitionSource::DaemonReadOnly,
918 ),
919 freshness_metadata_pair(
920 GraphFreshness::Reloaded {
921 original_lifecycle: ReloadOrigin::Evicted {
922 detail: "memory admission evicted A".to_string(),
923 },
924 final_lifecycle_label: "Loaded",
925 reload_attempts: NonZeroU8::new(1).expect("1 is non-zero"),
926 },
927 AcquisitionSource::DaemonReloaded,
928 ),
929 ];
930
931 for (freshness, metadata) in cases {
932 // Each variant carries enough metadata for adapters to render
933 // freshness signals without inspecting the graph contents.
934 match &freshness {
935 GraphFreshness::Fresh { lifecycle_label } => {
936 assert_eq!(*lifecycle_label, Some("Loaded"));
937 }
938 GraphFreshness::Stale {
939 last_good_at,
940 age_hours,
941 ..
942 } => {
943 assert!(last_good_at.is_some());
944 assert!(age_hours.is_some());
945 }
946 GraphFreshness::Reloaded {
947 final_lifecycle_label,
948 reload_attempts,
949 ..
950 } => {
951 assert_eq!(*final_lifecycle_label, "Loaded");
952 assert_eq!(reload_attempts.get(), 1);
953 }
954 }
955 // Source must be present and tool_name must propagate.
956 assert!(matches!(
957 metadata.acquisition_source,
958 AcquisitionSource::DaemonReadOnly | AcquisitionSource::DaemonReloaded
959 ));
960 assert_eq!(metadata.tool_name, Some("sga02_test"));
961 }
962 }
963
964 /// Mock acquirer used by tests 2 and 3. Records every call and returns a
965 /// scripted result.
966 struct ScriptedAcquirer {
967 load_attempts: AtomicUsize,
968 last_op: Mutex<Option<AcquisitionOperation>>,
969 invalid_path: bool,
970 rebuild_only_stale_available: bool,
971 }
972
973 impl ScriptedAcquirer {
974 fn new() -> Self {
975 Self {
976 load_attempts: AtomicUsize::new(0),
977 last_op: Mutex::new(None),
978 invalid_path: false,
979 rebuild_only_stale_available: false,
980 }
981 }
982
983 fn with_invalid_path(mut self) -> Self {
984 self.invalid_path = true;
985 self
986 }
987
988 fn with_rebuild_only_stale(mut self) -> Self {
989 self.rebuild_only_stale_available = true;
990 self
991 }
992
993 fn loads_attempted(&self) -> usize {
994 self.load_attempts.load(Ordering::SeqCst)
995 }
996 }
997
998 impl GraphAcquirer for ScriptedAcquirer {
999 fn acquire(
1000 &self,
1001 request: GraphAcquisitionRequest,
1002 ) -> Result<GraphAcquisition, GraphAcquisitionError> {
1003 *self.last_op.lock().expect("mutex unpoisoned") = Some(request.operation);
1004
1005 // Path policy runs *before* any load attempt.
1006 if self.invalid_path {
1007 return Err(GraphAcquisitionError::InvalidPath {
1008 path: request.requested_path,
1009 reason: "test fixture: path rejected before load".to_string(),
1010 });
1011 }
1012
1013 // Only after path validation do we count this as a load attempt.
1014 self.load_attempts.fetch_add(1, Ordering::SeqCst);
1015
1016 if self.rebuild_only_stale_available
1017 && request.operation == AcquisitionOperation::MutatingRebuild
1018 {
1019 // Mutating rebuild must NOT be served from stale data — the
1020 // mock returns LoadFailed instead of synthesising a stale or
1021 // reloaded GraphAcquisition.
1022 return Err(GraphAcquisitionError::LoadFailed {
1023 source_root: request.requested_path,
1024 reason: "only stale graph available; rebuild requires fresh".to_string(),
1025 });
1026 }
1027
1028 // Default: not exercised in these mock-only tests.
1029 Err(GraphAcquisitionError::Internal {
1030 reason: "scripted acquirer reached unreachable arm".to_string(),
1031 })
1032 }
1033 }
1034
1035 /// Test 2: `MutatingRebuild` cannot silently fall back to a stale or
1036 /// reloaded graph. The mock provider proves no such `GraphFreshness` is
1037 /// produced — instead a `LoadFailed` (or another typed error) is returned.
1038 #[test]
1039 fn acquire_mode_rebuild_rejects_read_only_fallback() {
1040 let acquirer = ScriptedAcquirer::new().with_rebuild_only_stale();
1041 let result = acquirer.acquire(make_request(AcquisitionOperation::MutatingRebuild));
1042
1043 match result {
1044 Err(GraphAcquisitionError::LoadFailed { reason, .. }) => {
1045 assert!(
1046 reason.contains("stale"),
1047 "expected stale-related diagnostic, got {reason}"
1048 );
1049 }
1050 Err(other) => panic!("unexpected error variant: {other:?}"),
1051 Ok(acq) => panic!(
1052 "MutatingRebuild must not yield a stale/reloaded acquisition, got freshness={:?}",
1053 acq.freshness
1054 ),
1055 }
1056
1057 // Sanity: we didn't accidentally hit a ReadOnlyQuery branch.
1058 let last_op = *acquirer.last_op.lock().expect("mutex unpoisoned");
1059 assert_eq!(last_op, Some(AcquisitionOperation::MutatingRebuild));
1060 }
1061
1062 /// Test 3: `InvalidPath` errors precede any load attempt — the mock's
1063 /// load counter must remain zero.
1064 #[test]
1065 fn invalid_path_error_precedes_load_error() {
1066 let acquirer = ScriptedAcquirer::new().with_invalid_path();
1067 let result = acquirer.acquire(make_request(AcquisitionOperation::ReadOnlyQuery));
1068
1069 assert!(
1070 matches!(result, Err(GraphAcquisitionError::InvalidPath { .. })),
1071 "expected InvalidPath error, got {result:?}"
1072 );
1073 assert_eq!(
1074 acquirer.loads_attempted(),
1075 0,
1076 "no load should be attempted when the path policy rejects the request"
1077 );
1078 }
1079
1080 /// Mock manager that counts daemon reload attempts. Used by tests 4 and 5.
1081 struct ReloadCountingManager {
1082 reload_attempts: AtomicUsize,
1083 reload_succeeds: bool,
1084 }
1085
1086 impl ReloadCountingManager {
1087 fn new(reload_succeeds: bool) -> Self {
1088 Self {
1089 reload_attempts: AtomicUsize::new(0),
1090 reload_succeeds,
1091 }
1092 }
1093
1094 fn attempt_reload(&self) -> Result<&'static str, String> {
1095 self.reload_attempts.fetch_add(1, Ordering::SeqCst);
1096 if self.reload_succeeds {
1097 Ok("Loaded")
1098 } else {
1099 Err("test fixture: reload failed".to_string())
1100 }
1101 }
1102
1103 fn reload_count(&self) -> usize {
1104 self.reload_attempts.load(Ordering::SeqCst)
1105 }
1106 }
1107
1108 /// Acquirer that emulates the daemon one-shot bounded reload contract for
1109 /// `ReadOnlyQuery`: at most one reload, never recursive.
1110 struct BoundedReloadAcquirer<'a> {
1111 manager: &'a ReloadCountingManager,
1112 original_lifecycle_label: &'static str,
1113 original_eviction_detail: &'static str,
1114 }
1115
1116 impl<'a> GraphAcquirer for BoundedReloadAcquirer<'a> {
1117 fn acquire(
1118 &self,
1119 request: GraphAcquisitionRequest,
1120 ) -> Result<GraphAcquisition, GraphAcquisitionError> {
1121 // ReadOnlyQuery is the only mode permitted to attempt reload —
1122 // a guard the trait contract requires implementations to honor.
1123 if request.operation != AcquisitionOperation::ReadOnlyQuery {
1124 return Err(GraphAcquisitionError::Evicted {
1125 workspace_root: request.requested_path,
1126 original_lifecycle: self.original_lifecycle_label.to_string(),
1127 reload_failure: None,
1128 });
1129 }
1130 // Exactly one reload attempt — no loop.
1131 match self.manager.attempt_reload() {
1132 Ok(_label) => Err(GraphAcquisitionError::Internal {
1133 reason: "test stops here: success path requires real CodeGraph".to_string(),
1134 }),
1135 Err(reload_err) => Err(GraphAcquisitionError::Evicted {
1136 workspace_root: request.requested_path,
1137 original_lifecycle: self.original_lifecycle_label.to_string(),
1138 reload_failure: Some(format!(
1139 "evicted({}); reload: {}",
1140 self.original_eviction_detail, reload_err
1141 )),
1142 }),
1143 }
1144 }
1145 }
1146
1147 /// Test 4: Eviction reload is bounded to exactly one attempt; no loop.
1148 #[test]
1149 fn evicted_reload_attempt_is_bounded() {
1150 let manager = ReloadCountingManager::new(true);
1151 let acquirer = BoundedReloadAcquirer {
1152 manager: &manager,
1153 original_lifecycle_label: "Evicted",
1154 original_eviction_detail: "memory admission evicted A",
1155 };
1156
1157 let _ = acquirer.acquire(make_request(AcquisitionOperation::ReadOnlyQuery));
1158 assert_eq!(
1159 manager.reload_count(),
1160 1,
1161 "ReadOnlyQuery reload must be attempted exactly once"
1162 );
1163
1164 // A subsequent acquire call is a separate request; the bounded rule
1165 // is *per request*, so a second request may attempt one more reload.
1166 // The critical guarantee is: within a single request, no recursive
1167 // re-entry. The acquirer above never calls itself.
1168 let _ = acquirer.acquire(make_request(AcquisitionOperation::ReadOnlyQuery));
1169 assert_eq!(
1170 manager.reload_count(),
1171 2,
1172 "second request can attempt its own single reload, but neither request looped"
1173 );
1174 }
1175
1176 /// Test 5: When the bounded reload fails, the resulting `Evicted` error
1177 /// preserves both the original lifecycle context and the reload failure
1178 /// detail so adapters can render full diagnostics.
1179 #[test]
1180 fn reload_failure_preserves_original_lifecycle_context() {
1181 let manager = ReloadCountingManager::new(false);
1182 let acquirer = BoundedReloadAcquirer {
1183 manager: &manager,
1184 original_lifecycle_label: "Evicted",
1185 original_eviction_detail: "memory admission evicted A",
1186 };
1187
1188 let result = acquirer.acquire(make_request(AcquisitionOperation::ReadOnlyQuery));
1189 match result {
1190 Err(GraphAcquisitionError::Evicted {
1191 original_lifecycle,
1192 reload_failure,
1193 ..
1194 }) => {
1195 assert_eq!(original_lifecycle, "Evicted");
1196 let reload = reload_failure.expect("reload failure must be recorded");
1197 assert!(
1198 reload.contains("memory admission evicted A"),
1199 "reload diagnostic must carry the original eviction detail, got: {reload}"
1200 );
1201 assert!(
1202 reload.contains("test fixture: reload failed"),
1203 "reload diagnostic must carry the reload failure detail, got: {reload}"
1204 );
1205 }
1206 other => panic!("expected Evicted with reload_failure, got {other:?}"),
1207 }
1208 assert_eq!(manager.reload_count(), 1);
1209 }
1210
1211 /// Compile-time guard: the contract types must not be hidden behind
1212 /// trait-object-incompatible bounds. If someone adds a non-object-safe
1213 /// method to `GraphAcquirer`, this fails to build.
1214 #[test]
1215 fn graph_acquirer_is_object_safe() {
1216 fn assert_object_safe(_: &dyn GraphAcquirer) {}
1217 let acquirer = ScriptedAcquirer::new();
1218 assert_object_safe(&acquirer);
1219 }
1220
1221 /// Defaults are the most-restrictive options: tightening must be the
1222 /// silent baseline, weakening must be explicit.
1223 #[test]
1224 fn policy_defaults_are_strict() {
1225 let p = PathPolicy::default();
1226 assert!(p.require_existing);
1227 assert!(p.require_within_workspace);
1228 assert!(!p.allow_symlink_escape);
1229 assert!(matches!(StalePolicy::default(), StalePolicy::RejectStale));
1230 assert!(matches!(
1231 PluginSelectionPolicy::default(),
1232 PluginSelectionPolicy::StrictMatch
1233 ));
1234 }
1235
1236 // -------------------------------------------------------------------
1237 // FilesystemGraphProvider unit tests (SGA03)
1238 // -------------------------------------------------------------------
1239
1240 use crate::graph::FilesystemGraphProvider;
1241 use crate::graph::unified::persistence::{
1242 BuildProvenance, GraphStorage, MANIFEST_SCHEMA_VERSION, Manifest, PluginSelectionManifest,
1243 SNAPSHOT_FORMAT_VERSION, save_to_path,
1244 };
1245 use crate::plugin::PluginManager;
1246 use sha2::{Digest, Sha256};
1247 use std::fs;
1248 use std::path::Path;
1249 use tempfile::TempDir;
1250
1251 /// Build a minimal valid `.sqry/graph` snapshot + manifest in `root`.
1252 ///
1253 /// We construct an empty `CodeGraph` and persist it directly via
1254 /// `save_to_path`, then write a matching `manifest.json`. Going through
1255 /// the full `build_unified_graph` pipeline would require registering a
1256 /// plugin with a `GraphBuilder` impl — and every such plugin lives in
1257 /// a crate that already depends on `sqry-core`, which would create a
1258 /// circular dev-dependency. The acquisition contract is independent of
1259 /// graph contents: an empty snapshot exercises the manifest, SHA-256
1260 /// integrity, snapshot format, and plugin-selection compatibility paths
1261 /// the same way a populated one would.
1262 fn build_test_fixture(root: &Path, plugin_ids: &[&str]) {
1263 let storage = GraphStorage::new(root);
1264 fs::create_dir_all(storage.graph_dir()).expect("graph dir");
1265
1266 let graph = CodeGraph::new();
1267 save_to_path(&graph, storage.snapshot_path()).expect("save snapshot");
1268
1269 let snapshot_sha256 = {
1270 let bytes = fs::read(storage.snapshot_path()).expect("read snapshot");
1271 hex::encode(Sha256::digest(&bytes))
1272 };
1273
1274 let snapshot = graph.snapshot();
1275 let manifest = Manifest {
1276 schema_version: MANIFEST_SCHEMA_VERSION,
1277 snapshot_format_version: SNAPSHOT_FORMAT_VERSION,
1278 built_at: chrono::Utc::now().to_rfc3339(),
1279 root_path: root.to_string_lossy().into_owned(),
1280 node_count: snapshot.nodes().len(),
1281 edge_count: graph.edge_count(),
1282 raw_edge_count: None,
1283 snapshot_sha256,
1284 build_provenance: BuildProvenance {
1285 sqry_version: env!("CARGO_PKG_VERSION").to_string(),
1286 build_timestamp: chrono::Utc::now().to_rfc3339(),
1287 build_command: "test:filesystem-provider".to_string(),
1288 plugin_hashes: std::collections::HashMap::new(),
1289 },
1290 file_count: std::collections::HashMap::new(),
1291 languages: Vec::new(),
1292 config: std::collections::HashMap::new(),
1293 confidence: graph.confidence().clone(),
1294 last_indexed_commit: None,
1295 plugin_selection: Some(PluginSelectionManifest {
1296 active_plugin_ids: plugin_ids.iter().map(|id| (*id).to_string()).collect(),
1297 high_cost_mode: None,
1298 }),
1299 };
1300 manifest
1301 .save(storage.manifest_path())
1302 .expect("save manifest");
1303 }
1304
1305 fn make_provider() -> FilesystemGraphProvider {
1306 FilesystemGraphProvider::new(Arc::new(PluginManager::new()))
1307 }
1308
1309 fn fs_request(path: PathBuf) -> GraphAcquisitionRequest {
1310 GraphAcquisitionRequest {
1311 requested_path: path,
1312 operation: AcquisitionOperation::ReadOnlyQuery,
1313 path_policy: PathPolicy::default(),
1314 missing_graph_policy: MissingGraphPolicy::Error,
1315 stale_policy: StalePolicy::default(),
1316 plugin_selection_policy: PluginSelectionPolicy::default(),
1317 tool_name: Some("filesystem_provider_test"),
1318 }
1319 }
1320
1321 /// Test 1 — non-existent paths must fail with InvalidPath BEFORE any
1322 /// graph load is attempted.
1323 #[test]
1324 fn filesystem_provider_returns_invalid_path_for_nonexistent_path() {
1325 let tmp = TempDir::new().expect("tempdir");
1326 let bogus = tmp.path().join("does/not/exist");
1327
1328 let provider = make_provider();
1329 let err = provider
1330 .acquire(fs_request(bogus.clone()))
1331 .expect_err("non-existent path must fail");
1332 match err {
1333 GraphAcquisitionError::InvalidPath { path, reason } => {
1334 assert_eq!(path, bogus);
1335 assert!(
1336 reason.contains("does not exist") || reason.contains("cannot be canonicalized"),
1337 "unexpected reason: {reason}"
1338 );
1339 }
1340 other => panic!("expected InvalidPath, got {other:?}"),
1341 }
1342 }
1343
1344 /// Test 2 — outside-workspace paths fail with InvalidPath. We construct
1345 /// two sibling tempdirs: a workspace with a graph, and an unrelated
1346 /// directory the user passes by mistake. The provider must reject the
1347 /// unrelated directory before any load attempt.
1348 #[test]
1349 fn filesystem_provider_returns_invalid_path_for_outside_workspace() {
1350 let tmp = TempDir::new().expect("tempdir");
1351 let workspace = tmp.path().join("workspace");
1352 fs::create_dir_all(&workspace).expect("mk workspace");
1353 // Build a real graph fixture so a workspace exists; this guarantees
1354 // that *unrelated* paths still fail because no ancestor traversal
1355 // from outside finds this graph.
1356 let plugins = build_plugin_manager_for_tests();
1357 build_test_fixture(&workspace, &["mock-rust"]);
1358
1359 // A sibling directory has no graph anywhere up the tree (as long as
1360 // no ancestor of the tempdir has one). The provider must therefore
1361 // surface NoGraph rather than IncompatibleGraph or LoadFailed.
1362 let sibling = tmp.path().join("sibling");
1363 fs::create_dir_all(&sibling).expect("mk sibling");
1364 let provider = FilesystemGraphProvider::new(Arc::new(plugins));
1365 let err = provider
1366 .acquire(fs_request(sibling.clone()))
1367 .expect_err("sibling without graph must fail");
1368 // The strict spec says this fails BEFORE any graph load. The
1369 // observable signal is either NoGraph (ancestor walk found nothing
1370 // within the tempdir hierarchy) or InvalidPath if a host-level
1371 // ancestor `.sqry/graph` is present. Either way the request_path
1372 // is preserved.
1373 assert!(
1374 matches!(
1375 err,
1376 GraphAcquisitionError::NoGraph { .. } | GraphAcquisitionError::InvalidPath { .. }
1377 ),
1378 "expected NoGraph or InvalidPath, got {err:?}"
1379 );
1380 }
1381
1382 /// Test 3 — happy path: a tempdir with a freshly built `.sqry/graph`
1383 /// snapshot loads successfully with `Fresh` freshness.
1384 #[test]
1385 fn filesystem_provider_loads_existing_valid_graph() {
1386 let tmp = TempDir::new().expect("tempdir");
1387 let workspace = tmp.path().join("ws");
1388 fs::create_dir_all(&workspace).expect("mk workspace");
1389
1390 let plugins = build_plugin_manager_for_tests();
1391 build_test_fixture(&workspace, &["mock-rust"]);
1392
1393 let provider = FilesystemGraphProvider::new(Arc::new(plugins));
1394 let acquisition = provider
1395 .acquire(fs_request(workspace.clone()))
1396 .expect("provider acquires existing graph");
1397
1398 assert_eq!(
1399 acquisition.workspace_root,
1400 workspace.canonicalize().expect("canon workspace")
1401 );
1402 assert!(matches!(
1403 acquisition.freshness,
1404 GraphFreshness::Fresh { .. }
1405 ));
1406 assert_eq!(
1407 acquisition.metadata.acquisition_source,
1408 AcquisitionSource::Filesystem
1409 );
1410 assert!(acquisition.identity.snapshot_sha256.is_some());
1411 assert_eq!(
1412 acquisition.identity.plugin_selection_status,
1413 PluginSelectionStatus::Exact
1414 );
1415 }
1416
1417 /// Test 4 — manifest plugin ids the runtime does not know must surface as
1418 /// `IncompatibleGraph { status: IncompatibleUnknownPluginIds }` and the
1419 /// snapshot must NOT be deserialized.
1420 #[test]
1421 fn filesystem_provider_unknown_plugin_ids_returns_incompatible_graph() {
1422 let tmp = TempDir::new().expect("tempdir");
1423 let workspace = tmp.path().join("ws");
1424 fs::create_dir_all(&workspace).expect("mk workspace");
1425
1426 let plugins = build_plugin_manager_for_tests();
1427 // Manifest references the runtime's known id ("mock-rust") plus a
1428 // bogus id the runtime does not register.
1429 build_test_fixture(
1430 &workspace,
1431 &["mock-rust", "imaginary-unknown-plugin-id-zzz"],
1432 );
1433
1434 let provider = FilesystemGraphProvider::new(Arc::new(plugins));
1435 let err = provider
1436 .acquire(fs_request(workspace.clone()))
1437 .expect_err("manifest with unknown plugin id must fail");
1438 match err {
1439 GraphAcquisitionError::IncompatibleGraph { status, .. } => match status {
1440 PluginSelectionStatus::IncompatibleUnknownPluginIds {
1441 unknown_plugin_ids,
1442 manifest_path,
1443 } => {
1444 assert!(
1445 unknown_plugin_ids.iter().any(|id| id.contains("imaginary")),
1446 "expected the synthetic id in the diagnostic, got {unknown_plugin_ids:?}"
1447 );
1448 assert!(
1449 manifest_path
1450 .as_ref()
1451 .is_some_and(|p| p.ends_with("manifest.json")),
1452 "manifest_path should point at the on-disk manifest, got {manifest_path:?}"
1453 );
1454 }
1455 other => panic!("expected IncompatibleUnknownPluginIds, got {other:?}"),
1456 },
1457 other => panic!("expected IncompatibleGraph, got {other:?}"),
1458 }
1459 }
1460
1461 /// SGA03 Major #2 (codex iter2) — when the on-disk snapshot uses an
1462 /// incompatible format version (e.g. a future or unknown V*), the
1463 /// filesystem provider must surface that as
1464 /// `IncompatibleGraph { status: IncompatibleSnapshotFormat { .. } }`,
1465 /// **not** as a generic `LoadFailed`. The daemon's
1466 /// `From<GraphAcquisitionError>` impl maps the typed variant to
1467 /// `WorkspaceIncompatibleGraph` (-32005), which is materially
1468 /// different from the transient-build retry signal `LoadFailed`
1469 /// → `WorkspaceBuildFailed` (-32001).
1470 #[test]
1471 fn filesystem_provider_incompatible_snapshot_version_returns_incompatible_graph() {
1472 use crate::graph::unified::persistence::{GraphHeader, MAGIC_BYTES_V10};
1473
1474 let tmp = TempDir::new().expect("tempdir");
1475 let workspace = tmp.path().join("ws");
1476 fs::create_dir_all(&workspace).expect("mk workspace");
1477
1478 // Build a normal manifest+snapshot fixture, then overwrite the
1479 // snapshot bytes with a hand-crafted file whose magic is the
1480 // V10 magic (accepted as an upconvert source) but whose
1481 // `GraphHeader.version` is `99` — outside the accepted set
1482 // {V7, V8, V9, V10, V11, V12, V13}. `load_from_bytes` returns
1483 // `PersistenceError::IncompatibleVersion` for that case. Using
1484 // V10 magic + version=99 (rather than V13+99) keeps this test
1485 // orthogonal to the writer-magic bump.
1486 build_test_fixture(&workspace, &["mock-rust"]);
1487
1488 let storage = GraphStorage::new(&workspace);
1489 let mut header = GraphHeader::new(0, 0, 0, 0);
1490 header.version = 99;
1491 let header_bytes = postcard::to_allocvec(&header).expect("encode header");
1492
1493 let mut bytes: Vec<u8> = Vec::with_capacity(14 + 4 + header_bytes.len() + 8);
1494 bytes.extend_from_slice(MAGIC_BYTES_V10);
1495 #[allow(clippy::cast_possible_truncation)]
1496 bytes.extend_from_slice(&(header_bytes.len() as u32).to_le_bytes());
1497 bytes.extend_from_slice(&header_bytes);
1498 // `data_len = 0` so the loader fails on the version check, not on
1499 // a short-read while parsing the data section.
1500 bytes.extend_from_slice(&0u64.to_le_bytes());
1501 fs::write(storage.snapshot_path(), &bytes).expect("write bogus snapshot");
1502
1503 // Update the manifest's `snapshot_sha256` so the integrity
1504 // check passes and we exercise the post-integrity version
1505 // mismatch path (the path the codex review pinpointed).
1506 let snapshot_sha256 = hex::encode(Sha256::digest(&bytes));
1507 let mut manifest = Manifest::load(storage.manifest_path()).expect("load manifest");
1508 manifest.snapshot_sha256 = snapshot_sha256;
1509 manifest
1510 .save(storage.manifest_path())
1511 .expect("save manifest");
1512
1513 let plugins = build_plugin_manager_for_tests();
1514 let provider = FilesystemGraphProvider::new(Arc::new(plugins));
1515 let err = provider
1516 .acquire(fs_request(workspace.clone()))
1517 .expect_err("incompatible-version snapshot must fail acquisition");
1518
1519 match err {
1520 GraphAcquisitionError::IncompatibleGraph {
1521 status,
1522 source_root,
1523 } => {
1524 assert_eq!(source_root, workspace.canonicalize().unwrap_or(workspace));
1525 match status {
1526 PluginSelectionStatus::IncompatibleSnapshotFormat { reason } => {
1527 assert!(
1528 reason.contains("snapshot version mismatch")
1529 && reason.contains("found 99"),
1530 "expected snapshot version mismatch diagnostic, got {reason:?}"
1531 );
1532 }
1533 other => panic!("expected IncompatibleSnapshotFormat, got {other:?}"),
1534 }
1535 }
1536 other => panic!("expected IncompatibleGraph, got {other:?}"),
1537 }
1538 }
1539
1540 /// Build a `PluginManager` with a single mock plugin registered under id
1541 /// `"mock-rust"`. We do NOT use `sqry-lang-rust` here — that crate
1542 /// depends on `sqry-core`, which would create a circular crate-version
1543 /// dependency in tests. Defining the mock inline keeps the unit tests
1544 /// hermetic and lets us exercise the plugin-selection compatibility
1545 /// path without dragging in any language-specific build infrastructure.
1546 fn build_plugin_manager_for_tests() -> PluginManager {
1547 use crate::plugin::types::LanguageMetadata;
1548 use crate::plugin::types::LanguagePlugin;
1549 use std::path::Path;
1550
1551 struct AcquisitionTestPlugin;
1552
1553 impl LanguagePlugin for AcquisitionTestPlugin {
1554 fn metadata(&self) -> LanguageMetadata {
1555 LanguageMetadata {
1556 id: "mock-rust",
1557 name: "MockRust",
1558 version: "0.0.0",
1559 author: "sqry-tests",
1560 description: "FilesystemGraphProvider acquisition tests",
1561 tree_sitter_version: "0.24",
1562 }
1563 }
1564
1565 fn extensions(&self) -> &'static [&'static str] {
1566 &["rs"]
1567 }
1568
1569 fn language(&self) -> tree_sitter::Language {
1570 tree_sitter_rust::LANGUAGE.into()
1571 }
1572
1573 fn parse_ast(
1574 &self,
1575 _content: &[u8],
1576 ) -> Result<tree_sitter::Tree, crate::plugin::error::ParseError> {
1577 Err(crate::plugin::error::ParseError::TreeSitterFailed)
1578 }
1579
1580 fn extract_scopes(
1581 &self,
1582 _tree: &tree_sitter::Tree,
1583 _content: &[u8],
1584 _file: &Path,
1585 ) -> Result<Vec<crate::ast::Scope>, crate::plugin::error::ScopeError> {
1586 Ok(Vec::new())
1587 }
1588 }
1589
1590 let mut pm = PluginManager::new();
1591 pm.register_builtin(Box::new(AcquisitionTestPlugin));
1592 pm
1593 }
1594}