Skip to main content

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}