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