everruns_core/
session_path.rs1pub const WORKSPACE_PREFIX: &str = "/workspace";
17
18#[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
88pub 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
116fn 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
135pub 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
149fn 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 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 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}