Skip to main content

sqry_core/workspace/
scope.rs

1//! Shared workspace + subtree resolution for tool `path` arguments.
2//!
3//! Issue #394 Slice B / Part 1b: the per-tool `path` argument is overloaded. It
4//! can be:
5//!
6//! - `"."` or empty: the ambient workspace (resolved from the request override
7//!   or discovery), no subtree scope.
8//! - an absolute directory that is itself an indexed workspace root: that
9//!   workspace, no subtree scope (the pre-existing multi-workspace behaviour).
10//! - a SUBDIRECTORY of the resolved workspace (a relative path like `rust`, or an
11//!   absolute path inside the workspace): the owning workspace is resolved
12//!   automatically and results are scoped to that subtree.
13//!
14//! These primitives are pure (no I/O beyond `canonicalize`, no thread-locals) so
15//! they can be shared across the standalone MCP server, the daemon acquirer, and
16//! any other surface that resolves a tool `path`. `sqry-daemon` cannot depend on
17//! `sqry-mcp`, so the reusable logic lives here in `sqry-core` and `sqry-mcp`
18//! delegates to it (Part 1b hoist), keeping standalone behaviour byte-identical.
19
20use std::path::{Path, PathBuf};
21
22use anyhow::{Result, anyhow};
23
24/// Resolved scope for a tool `path` argument.
25#[derive(Debug, Clone, Default, PartialEq, Eq)]
26pub struct WorkspaceScope {
27    /// Workspace selector to pass to the engine resolver. `None` means "use the
28    /// ambient request override / discovery" (the `"."` case). `Some(root)`
29    /// names a concrete workspace root. In request-override mode the engine
30    /// ignores this and uses the override, which equals the resolved root, so the
31    /// two stay consistent.
32    pub selector: Option<PathBuf>,
33    /// Normalised, forward-slash, workspace-relative subtree to scope results to.
34    /// `None` means the whole workspace.
35    pub subtree: Option<String>,
36}
37
38impl WorkspaceScope {
39    /// Whole-workspace scope (ambient workspace, no subtree).
40    #[must_use]
41    pub fn whole() -> Self {
42        Self {
43            selector: None,
44            subtree: None,
45        }
46    }
47}
48
49/// Pure classification core: split a tool `path` argument into a workspace
50/// selector and an optional subtree filter, with the ambient workspace root
51/// supplied explicitly so it is deterministically testable (no thread-local
52/// override or CWD discovery).
53///
54/// `resolve_as_root` supplies the "treat an absolute path as its own workspace
55/// root" fallback used when an absolute `path` is not inside the ambient
56/// workspace. Callers pass their own workspace-root validator (the standalone
57/// MCP server passes its `path_resolver::resolve_workspace_path`); this keeps
58/// the core free of surface-specific path policy.
59///
60/// # Errors
61///
62/// Returns an error when a relative subtree cannot be resolved against any
63/// workspace, when a path escapes the resolved workspace root, or when an
64/// absolute path neither lives inside the ambient workspace nor resolves as its
65/// own workspace root (per `resolve_as_root`).
66pub fn classify_within<F>(
67    path: &str,
68    ambient: Option<&Path>,
69    resolve_as_root: F,
70) -> Result<WorkspaceScope>
71where
72    F: FnOnce(&str) -> Result<PathBuf>,
73{
74    let trimmed = path.trim();
75    if trimmed.is_empty() || trimmed == "." {
76        return Ok(WorkspaceScope::whole());
77    }
78
79    let candidate = Path::new(trimmed);
80
81    if candidate.is_absolute() {
82        let canonical = candidate
83            .canonicalize()
84            .map_err(|e| anyhow!("path `{trimmed}` does not exist or is not accessible: {e}"))?;
85
86        // Inside the ambient workspace -> scope to the subtree.
87        if let Some(root) = ambient
88            && let Ok(relative) = canonical.strip_prefix(root)
89        {
90            return Ok(scope_for_relative(root, relative));
91        }
92
93        // Otherwise treat the path as its own workspace root (multi-workspace
94        // behaviour); the caller-supplied resolver validates that it is an
95        // indexed workspace.
96        let root = resolve_as_root(trimmed)?;
97        return Ok(WorkspaceScope {
98            selector: Some(root),
99            subtree: None,
100        });
101    }
102
103    // Relative path: a subtree of the ambient workspace.
104    let root = ambient.ok_or_else(|| {
105        anyhow!(
106            "cannot resolve a workspace to scope `{trimmed}` against; \
107             pass an absolute path or open the workspace first"
108        )
109    })?;
110    let joined = root.join(candidate);
111    let canonical = joined.canonicalize().map_err(|e| {
112        anyhow!(
113            "subtree `{trimmed}` was not found under workspace `{}`: {e}",
114            root.display()
115        )
116    })?;
117    let relative = canonical.strip_prefix(root).map_err(|_| {
118        anyhow!(
119            "path `{trimmed}` escapes workspace root `{}`",
120            root.display()
121        )
122    })?;
123    Ok(scope_for_relative(root, relative))
124}
125
126/// Build a scope for a path already known to be inside `root`. A path that
127/// strips to empty (the root itself) yields a whole-workspace scope.
128fn scope_for_relative(root: &Path, relative: &Path) -> WorkspaceScope {
129    let normalized = normalize_subtree(relative);
130    if normalized.is_empty() {
131        WorkspaceScope {
132            selector: Some(root.to_path_buf()),
133            subtree: None,
134        }
135    } else {
136        WorkspaceScope {
137            selector: Some(root.to_path_buf()),
138            subtree: Some(normalized),
139        }
140    }
141}
142
143/// Normalise a relative path to a forward-slash string with no leading `./` or
144/// trailing slash.
145fn normalize_subtree(relative: &Path) -> String {
146    let raw = relative.to_string_lossy();
147    let forward = if cfg!(windows) {
148        raw.replace('\\', "/")
149    } else {
150        raw.into_owned()
151    };
152    forward
153        .trim_start_matches("./")
154        .trim_matches('/')
155        .to_string()
156}
157
158/// Derive the workspace-relative subtree for a tool `path` against an
159/// already-resolved workspace root.
160///
161/// Unlike [`classify_within`], this does no workspace discovery: the caller
162/// already holds the canonical workspace root (for example a daemon
163/// `WorkspaceContext` or `engine.workspace_root()`), so this only classifies the
164/// `path` into "whole workspace" (`None`) vs a subtree (`Some(rel)`). Used by the
165/// shared `inner::` tool bodies so standalone and daemon-hosted execution scope
166/// identically.
167///
168/// Returns `None` (whole workspace) for "", ".", the root itself, an absolute
169/// path equal to the root, or any path that does not resolve to an existing
170/// location inside `root` (callers that need a hard error use [`classify_within`]
171/// at the request boundary instead).
172#[must_use]
173pub fn subtree_within(path: &str, root: &Path) -> Option<String> {
174    let trimmed = path.trim();
175    if trimmed.is_empty() || trimmed == "." {
176        return None;
177    }
178    let candidate = Path::new(trimmed);
179    let joined = if candidate.is_absolute() {
180        candidate.to_path_buf()
181    } else {
182        root.join(candidate)
183    };
184    let canonical = joined.canonicalize().ok()?;
185    let relative = canonical.strip_prefix(root).ok()?;
186    let normalized = normalize_subtree(relative);
187    if normalized.is_empty() {
188        None
189    } else {
190        Some(normalized)
191    }
192}
193
194/// Whether a workspace-relative path falls within `subtree`.
195///
196/// Both arguments are forward-slash workspace-relative paths. A directory
197/// `subtree` matches itself and everything beneath it. This is the directory
198/// semantics of the tool `path` argument; glob-style scoping is available
199/// through the query `path:` / `in:` predicate.
200#[must_use]
201pub fn path_in_subtree(relative_path: &str, subtree: &str) -> bool {
202    let subtree = subtree.trim_matches('/');
203    if subtree.is_empty() {
204        return true;
205    }
206    relative_path == subtree || relative_path.starts_with(&format!("{subtree}/"))
207}
208
209/// Resolve a canonical `target` path to the longest registered workspace root
210/// that contains it (the "owning" workspace).
211///
212/// `candidates` is the set of canonical registered workspace roots. A root owns
213/// `target` when it is an ancestor-or-equal of `target`. [`Path::starts_with`]
214/// is component-wise, so `/a/bc` is NOT owned by `/a/b`. When several roots are
215/// ancestors (nested workspaces), the longest (most specific) one wins. When the
216/// `target` is itself a registered root, the longest ancestor is that root, so
217/// the resolution is a no-op.
218///
219/// Returns `None` when no candidate root contains `target` (the caller then
220/// keeps `target` as-is and lets classification surface the existing
221/// "not loaded" error for an unknown path).
222#[must_use]
223pub fn owning_workspace_root<'a, I>(target: &Path, candidates: I) -> Option<PathBuf>
224where
225    I: IntoIterator<Item = &'a Path>,
226{
227    candidates
228        .into_iter()
229        .filter(|root| target.starts_with(root))
230        .max_by_key(|root| root.components().count())
231        .map(Path::to_path_buf)
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    /// Resolver stub for `classify_within` tests: canonicalises the path as its
239    /// own root (never exercised by the arms tested here, which stay inside the
240    /// ambient workspace, but keeps the closure honest).
241    fn canonicalizing_resolver(p: &str) -> Result<PathBuf> {
242        Path::new(p)
243            .canonicalize()
244            .map_err(|e| anyhow!("resolve_as_root: {e}"))
245    }
246
247    #[test]
248    fn dot_and_empty_are_whole_workspace() {
249        assert_eq!(
250            classify_within(".", None, canonicalizing_resolver).unwrap(),
251            WorkspaceScope::whole()
252        );
253        assert_eq!(
254            classify_within("", None, canonicalizing_resolver).unwrap(),
255            WorkspaceScope::whole()
256        );
257        assert_eq!(
258            classify_within("   ", None, canonicalizing_resolver).unwrap(),
259            WorkspaceScope::whole()
260        );
261    }
262
263    #[test]
264    fn path_in_subtree_matches_directory_and_descendants() {
265        assert!(path_in_subtree("rust", "rust"));
266        assert!(path_in_subtree("rust/kernel/time.rs", "rust"));
267        assert!(path_in_subtree("rust/kernel/time.rs", "rust/kernel"));
268        assert!(!path_in_subtree("rustfmt.toml", "rust"));
269        assert!(!path_in_subtree("drivers/rust/x.rs", "rust"));
270        assert!(path_in_subtree("anything", ""));
271    }
272
273    #[test]
274    fn normalize_subtree_strips_prefix_and_trailing() {
275        assert_eq!(normalize_subtree(Path::new("rust/kernel")), "rust/kernel");
276        assert_eq!(normalize_subtree(Path::new("")), "");
277    }
278
279    #[test]
280    fn subtree_within_covers_the_path_shapes() {
281        let tmp = tempfile::tempdir().expect("tempdir");
282        let root = tmp.path().canonicalize().expect("canonical root");
283        std::fs::create_dir_all(root.join("rust/kernel")).unwrap();
284        std::fs::create_dir_all(root.join("drivers")).unwrap();
285
286        // Relative subdirectory -> scoped.
287        assert_eq!(subtree_within("rust", &root).as_deref(), Some("rust"));
288        assert_eq!(
289            subtree_within("rust/kernel", &root).as_deref(),
290            Some("rust/kernel")
291        );
292
293        // Absolute path inside the workspace -> scoped to the relative subtree.
294        let abs = root.join("rust/kernel");
295        assert_eq!(
296            subtree_within(&abs.to_string_lossy(), &root).as_deref(),
297            Some("rust/kernel")
298        );
299
300        // The root itself, ".", and "" -> whole workspace (None).
301        assert_eq!(subtree_within(".", &root), None);
302        assert_eq!(subtree_within("", &root), None);
303        assert_eq!(subtree_within(&root.to_string_lossy(), &root), None);
304
305        // Nonexistent subtree -> None (callers that need a hard error use
306        // classify_within at the request boundary).
307        assert_eq!(subtree_within("does-not-exist", &root), None);
308
309        // A sibling that merely shares a name prefix is not matched by
310        // path_in_subtree, even though it resolves under root.
311        assert!(!path_in_subtree("drivers/rust/x.rs", "rust"));
312    }
313
314    #[test]
315    fn classify_within_resolution_arms_and_escape_rejection() {
316        let tmp = tempfile::tempdir().expect("tempdir");
317        let root = tmp.path().canonicalize().expect("canonical root");
318        std::fs::create_dir_all(root.join("rust/kernel")).unwrap();
319        let outside = tempfile::tempdir().expect("outside tempdir");
320        let outside_root = outside.path().canonicalize().expect("canonical outside");
321
322        // "." / empty -> whole workspace.
323        assert_eq!(
324            classify_within(".", Some(&root), canonicalizing_resolver).unwrap(),
325            WorkspaceScope::whole()
326        );
327        assert_eq!(
328            classify_within("", Some(&root), canonicalizing_resolver).unwrap(),
329            WorkspaceScope::whole()
330        );
331
332        // Relative subdirectory -> selector = root, subtree = the subdir.
333        let scoped = classify_within("rust/kernel", Some(&root), canonicalizing_resolver).unwrap();
334        assert_eq!(scoped.selector.as_deref(), Some(root.as_path()));
335        assert_eq!(scoped.subtree.as_deref(), Some("rust/kernel"));
336
337        // Absolute path inside the ambient workspace -> scoped to the subtree.
338        let abs = root.join("rust");
339        let scoped_abs =
340            classify_within(&abs.to_string_lossy(), Some(&root), canonicalizing_resolver).unwrap();
341        assert_eq!(scoped_abs.selector.as_deref(), Some(root.as_path()));
342        assert_eq!(scoped_abs.subtree.as_deref(), Some("rust"));
343
344        // Absolute path equal to the root -> whole workspace.
345        let at_root = classify_within(
346            &root.to_string_lossy(),
347            Some(&root),
348            canonicalizing_resolver,
349        )
350        .unwrap();
351        assert_eq!(at_root.subtree, None);
352
353        // Absolute path outside the ambient workspace -> resolver treats it as
354        // its own workspace root (selector = that root, no subtree).
355        let outside_scope = classify_within(
356            &outside_root.to_string_lossy(),
357            Some(&root),
358            canonicalizing_resolver,
359        )
360        .unwrap();
361        assert_eq!(
362            outside_scope.selector.as_deref(),
363            Some(outside_root.as_path())
364        );
365        assert_eq!(outside_scope.subtree, None);
366
367        // `../` escape: a relative path that resolves outside the workspace root
368        // is rejected with a clear error (fails closed).
369        let escape = format!("../{}", outside_root.file_name().unwrap().to_string_lossy());
370        let err = classify_within(&escape, Some(&root), canonicalizing_resolver)
371            .unwrap_err()
372            .to_string();
373        assert!(
374            err.contains("escapes workspace root") || err.contains("was not found"),
375            "unexpected escape error: {err}"
376        );
377
378        // Nonexistent relative subtree -> hard error (not a silent whole-workspace).
379        let missing =
380            classify_within("does-not-exist", Some(&root), canonicalizing_resolver).unwrap_err();
381        assert!(missing.to_string().contains("was not found"));
382
383        // Relative path with no ambient workspace -> clear "cannot resolve" error.
384        let no_ws = classify_within("rust", None, canonicalizing_resolver)
385            .unwrap_err()
386            .to_string();
387        assert!(no_ws.contains("cannot resolve a workspace"));
388    }
389
390    #[test]
391    fn owning_workspace_root_resolves_longest_ancestor() {
392        let a = PathBuf::from("/srv/repos/alpha");
393        let a_nested = PathBuf::from("/srv/repos/alpha/vendor/beta");
394        let b = PathBuf::from("/srv/repos/beta");
395
396        let roots = [a.as_path(), a_nested.as_path(), b.as_path()];
397
398        // Exact root -> itself.
399        assert_eq!(
400            owning_workspace_root(Path::new("/srv/repos/alpha"), roots),
401            Some(a.clone())
402        );
403
404        // Subtree of alpha -> alpha.
405        assert_eq!(
406            owning_workspace_root(Path::new("/srv/repos/alpha/kernel/time.rs"), roots),
407            Some(a.clone())
408        );
409
410        // Nested workspaces: deepest ancestor wins.
411        assert_eq!(
412            owning_workspace_root(Path::new("/srv/repos/alpha/vendor/beta/src/x.rs"), roots),
413            Some(a_nested.clone())
414        );
415
416        // Sibling that shares a name prefix is NOT owned (component-wise).
417        assert_eq!(
418            owning_workspace_root(Path::new("/srv/repos/alphabet/x.rs"), roots),
419            None
420        );
421
422        // Path under no registered root.
423        assert_eq!(
424            owning_workspace_root(Path::new("/other/place/x.rs"), roots),
425            None
426        );
427
428        // Empty candidate set.
429        assert_eq!(
430            owning_workspace_root(Path::new("/srv/repos/alpha"), std::iter::empty()),
431            None
432        );
433    }
434}