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