Skip to main content

everruns_core/
session_path.rs

1// Session path normalization: the `/workspace` display alias ⇄ the canonical
2// leading-slash session path (`/src/lib.rs`).
3//
4// These are host-agnostic *string* helpers shared by the VFS-backed stores, the
5// `file_system` capability, and the `SessionFileSystem` display defaults. They
6// carry no host-filesystem knowledge. Mapping the virtual namespace onto a real
7// host directory (containment, symlink rejection, worktree-root switches) is a
8// backend concern and lives with the host-backed store
9// (`everruns_runtime::RealDiskFileStore`), not here — `/workspace` is just the
10// model-facing view, resolved into mounts by `MountFs`.
11
12/// The display alias shown to models and UIs for the workspace root.
13///
14/// `/workspace` is a common cloud-agent convention (DevContainers, Codespaces).
15/// It is purely a *view*; addressing is done by [`crate::mount_fs::MountFs`].
16pub const WORKSPACE_PREFIX: &str = "/workspace";
17
18/// Compiled filter for [`crate::traits::SessionFileSystem::grep_files`].
19///
20/// Patterns containing glob metacharacters use segment-aware glob semantics.
21/// A basename-only glob (for example `*.rs`) matches at any depth. Patterns
22/// without glob metacharacters retain the legacy substring behavior.
23#[derive(Debug, Clone)]
24pub struct GrepPathPattern {
25    matcher: GrepPathMatcher,
26}
27
28#[derive(Debug, Clone)]
29enum GrepPathMatcher {
30    All,
31    Substring(String),
32    Glob(globset::GlobMatcher),
33}
34
35impl GrepPathPattern {
36    pub fn new(pattern: &str) -> crate::error::Result<Self> {
37        let normalized = to_session_path(pattern);
38        let relative = normalized.trim_start_matches('/');
39        if relative.is_empty() {
40            return Ok(Self {
41                matcher: GrepPathMatcher::All,
42            });
43        }
44        if !relative
45            .chars()
46            .any(|ch| matches!(ch, '*' | '?' | '[' | '{'))
47        {
48            return Ok(Self {
49                matcher: GrepPathMatcher::Substring(relative.to_string()),
50            });
51        }
52
53        let glob = if relative.contains('/') {
54            relative.to_string()
55        } else {
56            format!("**/{relative}")
57        };
58        let matcher = globset::GlobBuilder::new(&glob)
59            .literal_separator(true)
60            .backslash_escape(false)
61            .build()
62            .map_err(|error| {
63                crate::error::AgentLoopError::tool(format!(
64                    "invalid grep path_pattern {pattern:?}: {error}"
65                ))
66            })?
67            .compile_matcher();
68        Ok(Self {
69            matcher: GrepPathMatcher::Glob(matcher),
70        })
71    }
72
73    pub fn is_match(&self, canonical_path: &str) -> bool {
74        let relative = to_session_path(canonical_path);
75        let relative = relative.trim_start_matches('/');
76        match &self.matcher {
77            GrepPathMatcher::All => true,
78            GrepPathMatcher::Substring(needle) => relative.contains(needle),
79            GrepPathMatcher::Glob(matcher) => matcher.is_match(relative),
80        }
81    }
82
83    pub fn is_glob(&self) -> bool {
84        matches!(&self.matcher, GrepPathMatcher::Glob(_))
85    }
86}
87
88/// Canonical leading-slash session path from any accepted spelling: collapses
89/// repeated slashes, strips the `/workspace` alias, ensures a single leading
90/// slash, and trims a trailing slash.
91///
92/// This is the single normalizer for every session-path surface — the agent
93/// (via `MountFs`/the VFS backends) and the control-plane HTTP FS API both route
94/// through it, so a path resolves to the same key regardless of entry point.
95///
96/// Examples: `/workspace` → `/`, `/workspace/a.txt` → `/a.txt`,
97/// `/workspacefoo` → `/workspacefoo`, `a.txt` → `/a.txt`, `/sub/dir/` → `/sub/dir`,
98/// `/a//b/` → `/a/b`.
99pub fn to_session_path(input: &str) -> String {
100    let collapsed = collapse_slashes(input.trim());
101    let stripped = strip_workspace_alias(&collapsed);
102    if stripped.is_empty() || stripped == "/" {
103        return "/".to_string();
104    }
105    let mut normalized = if stripped.starts_with('/') {
106        stripped.to_string()
107    } else {
108        format!("/{stripped}")
109    };
110    while normalized.len() > 1 && normalized.ends_with('/') {
111        normalized.pop();
112    }
113    normalized
114}
115
116/// Collapse runs of `/` into a single `/` (`a//b` → `a/b`). Leaves the rest of
117/// the string untouched.
118fn collapse_slashes(s: &str) -> String {
119    let mut out = String::with_capacity(s.len());
120    let mut prev_slash = false;
121    for ch in s.chars() {
122        if ch == '/' {
123            if !prev_slash {
124                out.push(ch);
125            }
126            prev_slash = true;
127        } else {
128            out.push(ch);
129            prev_slash = false;
130        }
131    }
132    out
133}
134
135/// Model-facing display for a canonical session path: the `/workspace` alias.
136///
137/// `/` → `/workspace`, `/a.txt` → `/workspace/a.txt`. This is the default
138/// rendering for VFS stores; backends with a different notion of "where files
139/// live" (e.g. a host directory) override `display_path`/`display_root`.
140pub fn to_display_path(session_path: &str) -> String {
141    let canonical = to_session_path(session_path);
142    if canonical == "/" {
143        WORKSPACE_PREFIX.to_string()
144    } else {
145        format!("{WORKSPACE_PREFIX}{canonical}")
146    }
147}
148
149/// Strip the canonical `/workspace` alias only (exact match or `/workspace/`
150/// prefix). Returns the remainder, which may or may not have a leading slash.
151fn strip_workspace_alias(s: &str) -> &str {
152    if s == WORKSPACE_PREFIX {
153        return "";
154    }
155    if let Some(rest) = s.strip_prefix(&format!("{WORKSPACE_PREFIX}/")) {
156        return rest;
157    }
158    s
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn to_session_path_normalizes_alias_and_slashes() {
167        assert_eq!(to_session_path("/workspace"), "/");
168        assert_eq!(to_session_path("/workspace/test.txt"), "/test.txt");
169        assert_eq!(
170            to_session_path("/workspace/foo/bar/test.txt"),
171            "/foo/bar/test.txt"
172        );
173        assert_eq!(to_session_path("/test.txt"), "/test.txt");
174        // `/workspacefoo` is not the `/workspace` segment.
175        assert_eq!(to_session_path("/workspacefoo"), "/workspacefoo");
176        assert_eq!(to_session_path("foo.txt"), "/foo.txt");
177        assert_eq!(to_session_path("/sub/dir/"), "/sub/dir");
178        assert_eq!(to_session_path("  /workspace/x  "), "/x");
179    }
180
181    #[test]
182    fn to_session_path_collapses_repeated_slashes() {
183        assert_eq!(to_session_path("/a//b"), "/a/b");
184        assert_eq!(to_session_path("//a///b//"), "/a/b");
185        assert_eq!(to_session_path("//workspace//x"), "/x");
186        assert_eq!(to_session_path("///"), "/");
187    }
188
189    #[test]
190    fn to_display_path_adds_alias() {
191        assert_eq!(to_display_path("/"), "/workspace");
192        assert_eq!(to_display_path("/src/lib.rs"), "/workspace/src/lib.rs");
193        // Idempotent over an already-aliased path.
194        assert_eq!(
195            to_display_path("/workspace/src/lib.rs"),
196            "/workspace/src/lib.rs"
197        );
198    }
199
200    #[test]
201    fn grep_path_patterns_support_globs_and_legacy_substrings() {
202        let cases = [
203            ("src/**/*.rs", "/src/lib.rs", true),
204            ("src/**/*.rs", "/src/nested/mod.rs", true),
205            ("src/**/*.rs", "/docs/lib.rs", false),
206            ("**/*", "/notes.txt", true),
207            ("**/*", "/src/lib.rs", true),
208            ("docs/*", "/docs/readme.md", true),
209            ("docs/*", "/docs/nested/guide.md", false),
210            ("*.txt", "/notes.txt", true),
211            ("*.txt", "/nested/notes.txt", true),
212            ("/workspace/src/**/*.rs", "/src/lib.rs", true),
213            ("docs", "/my-docs/readme.md", true),
214        ];
215        for (pattern, path, expected) in cases {
216            let matcher = GrepPathPattern::new(pattern).unwrap();
217            assert_eq!(
218                matcher.is_match(path),
219                expected,
220                "pattern={pattern} path={path}"
221            );
222        }
223    }
224}