1use std::path::{Path, PathBuf};
21
22use anyhow::{Result, anyhow};
23
24#[derive(Debug, Clone, Default, PartialEq, Eq)]
26pub struct WorkspaceScope {
27 pub selector: Option<PathBuf>,
33 pub subtree: Option<String>,
36}
37
38impl WorkspaceScope {
39 #[must_use]
41 pub fn whole() -> Self {
42 Self {
43 selector: None,
44 subtree: None,
45 }
46 }
47}
48
49pub 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 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 let root = resolve_as_root(trimmed)?;
97 return Ok(WorkspaceScope {
98 selector: Some(root),
99 subtree: None,
100 });
101 }
102
103 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
126fn 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
143fn 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#[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#[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#[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 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 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 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 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 assert_eq!(subtree_within("does-not-exist", &root), None);
308
309 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 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 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 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 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 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 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 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 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 assert_eq!(
400 owning_workspace_root(Path::new("/srv/repos/alpha"), roots),
401 Some(a.clone())
402 );
403
404 assert_eq!(
406 owning_workspace_root(Path::new("/srv/repos/alpha/kernel/time.rs"), roots),
407 Some(a.clone())
408 );
409
410 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 assert_eq!(
418 owning_workspace_root(Path::new("/srv/repos/alphabet/x.rs"), roots),
419 None
420 );
421
422 assert_eq!(
424 owning_workspace_root(Path::new("/other/place/x.rs"), roots),
425 None
426 );
427
428 assert_eq!(
430 owning_workspace_root(Path::new("/srv/repos/alpha"), std::iter::empty()),
431 None
432 );
433 }
434}