use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WorkspaceScope {
pub selector: Option<PathBuf>,
pub subtree: Option<String>,
}
impl WorkspaceScope {
#[must_use]
pub fn whole() -> Self {
Self {
selector: None,
subtree: None,
}
}
}
pub fn classify_within<F>(
path: &str,
ambient: Option<&Path>,
resolve_as_root: F,
) -> Result<WorkspaceScope>
where
F: FnOnce(&str) -> Result<PathBuf>,
{
let trimmed = path.trim();
if trimmed.is_empty() || trimmed == "." {
return Ok(WorkspaceScope::whole());
}
let candidate = Path::new(trimmed);
if candidate.is_absolute() {
let canonical = candidate
.canonicalize()
.map_err(|e| anyhow!("path `{trimmed}` does not exist or is not accessible: {e}"))?;
if let Some(root) = ambient
&& let Ok(relative) = canonical.strip_prefix(root)
{
return Ok(scope_for_relative(root, relative));
}
let root = resolve_as_root(trimmed)?;
return Ok(WorkspaceScope {
selector: Some(root),
subtree: None,
});
}
let root = ambient.ok_or_else(|| {
anyhow!(
"cannot resolve a workspace to scope `{trimmed}` against; \
pass an absolute path or open the workspace first"
)
})?;
let joined = root.join(candidate);
let canonical = joined.canonicalize().map_err(|e| {
anyhow!(
"subtree `{trimmed}` was not found under workspace `{}`: {e}",
root.display()
)
})?;
let relative = canonical.strip_prefix(root).map_err(|_| {
anyhow!(
"path `{trimmed}` escapes workspace root `{}`",
root.display()
)
})?;
Ok(scope_for_relative(root, relative))
}
fn scope_for_relative(root: &Path, relative: &Path) -> WorkspaceScope {
let normalized = normalize_subtree(relative);
if normalized.is_empty() {
WorkspaceScope {
selector: Some(root.to_path_buf()),
subtree: None,
}
} else {
WorkspaceScope {
selector: Some(root.to_path_buf()),
subtree: Some(normalized),
}
}
}
fn normalize_subtree(relative: &Path) -> String {
let raw = relative.to_string_lossy();
let forward = if cfg!(windows) {
raw.replace('\\', "/")
} else {
raw.into_owned()
};
forward
.trim_start_matches("./")
.trim_matches('/')
.to_string()
}
#[must_use]
pub fn subtree_within(path: &str, root: &Path) -> Option<String> {
let trimmed = path.trim();
if trimmed.is_empty() || trimmed == "." {
return None;
}
let candidate = Path::new(trimmed);
let joined = if candidate.is_absolute() {
candidate.to_path_buf()
} else {
root.join(candidate)
};
let canonical = joined.canonicalize().ok()?;
let relative = canonical.strip_prefix(root).ok()?;
let normalized = normalize_subtree(relative);
if normalized.is_empty() {
None
} else {
Some(normalized)
}
}
#[must_use]
pub fn path_in_subtree(relative_path: &str, subtree: &str) -> bool {
let subtree = subtree.trim_matches('/');
if subtree.is_empty() {
return true;
}
relative_path == subtree || relative_path.starts_with(&format!("{subtree}/"))
}
#[must_use]
pub fn owning_workspace_root<'a, I>(target: &Path, candidates: I) -> Option<PathBuf>
where
I: IntoIterator<Item = &'a Path>,
{
candidates
.into_iter()
.filter(|root| target.starts_with(root))
.max_by_key(|root| root.components().count())
.map(Path::to_path_buf)
}
#[cfg(test)]
mod tests {
use super::*;
fn canonicalizing_resolver(p: &str) -> Result<PathBuf> {
Path::new(p)
.canonicalize()
.map_err(|e| anyhow!("resolve_as_root: {e}"))
}
#[test]
fn dot_and_empty_are_whole_workspace() {
assert_eq!(
classify_within(".", None, canonicalizing_resolver).unwrap(),
WorkspaceScope::whole()
);
assert_eq!(
classify_within("", None, canonicalizing_resolver).unwrap(),
WorkspaceScope::whole()
);
assert_eq!(
classify_within(" ", None, canonicalizing_resolver).unwrap(),
WorkspaceScope::whole()
);
}
#[test]
fn path_in_subtree_matches_directory_and_descendants() {
assert!(path_in_subtree("rust", "rust"));
assert!(path_in_subtree("rust/kernel/time.rs", "rust"));
assert!(path_in_subtree("rust/kernel/time.rs", "rust/kernel"));
assert!(!path_in_subtree("rustfmt.toml", "rust"));
assert!(!path_in_subtree("drivers/rust/x.rs", "rust"));
assert!(path_in_subtree("anything", ""));
}
#[test]
fn normalize_subtree_strips_prefix_and_trailing() {
assert_eq!(normalize_subtree(Path::new("rust/kernel")), "rust/kernel");
assert_eq!(normalize_subtree(Path::new("")), "");
}
#[test]
fn subtree_within_covers_the_path_shapes() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().canonicalize().expect("canonical root");
std::fs::create_dir_all(root.join("rust/kernel")).unwrap();
std::fs::create_dir_all(root.join("drivers")).unwrap();
assert_eq!(subtree_within("rust", &root).as_deref(), Some("rust"));
assert_eq!(
subtree_within("rust/kernel", &root).as_deref(),
Some("rust/kernel")
);
let abs = root.join("rust/kernel");
assert_eq!(
subtree_within(&abs.to_string_lossy(), &root).as_deref(),
Some("rust/kernel")
);
assert_eq!(subtree_within(".", &root), None);
assert_eq!(subtree_within("", &root), None);
assert_eq!(subtree_within(&root.to_string_lossy(), &root), None);
assert_eq!(subtree_within("does-not-exist", &root), None);
assert!(!path_in_subtree("drivers/rust/x.rs", "rust"));
}
#[test]
fn classify_within_resolution_arms_and_escape_rejection() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().canonicalize().expect("canonical root");
std::fs::create_dir_all(root.join("rust/kernel")).unwrap();
let outside = tempfile::tempdir().expect("outside tempdir");
let outside_root = outside.path().canonicalize().expect("canonical outside");
assert_eq!(
classify_within(".", Some(&root), canonicalizing_resolver).unwrap(),
WorkspaceScope::whole()
);
assert_eq!(
classify_within("", Some(&root), canonicalizing_resolver).unwrap(),
WorkspaceScope::whole()
);
let scoped = classify_within("rust/kernel", Some(&root), canonicalizing_resolver).unwrap();
assert_eq!(scoped.selector.as_deref(), Some(root.as_path()));
assert_eq!(scoped.subtree.as_deref(), Some("rust/kernel"));
let abs = root.join("rust");
let scoped_abs =
classify_within(&abs.to_string_lossy(), Some(&root), canonicalizing_resolver).unwrap();
assert_eq!(scoped_abs.selector.as_deref(), Some(root.as_path()));
assert_eq!(scoped_abs.subtree.as_deref(), Some("rust"));
let at_root = classify_within(
&root.to_string_lossy(),
Some(&root),
canonicalizing_resolver,
)
.unwrap();
assert_eq!(at_root.subtree, None);
let outside_scope = classify_within(
&outside_root.to_string_lossy(),
Some(&root),
canonicalizing_resolver,
)
.unwrap();
assert_eq!(
outside_scope.selector.as_deref(),
Some(outside_root.as_path())
);
assert_eq!(outside_scope.subtree, None);
let escape = format!("../{}", outside_root.file_name().unwrap().to_string_lossy());
let err = classify_within(&escape, Some(&root), canonicalizing_resolver)
.unwrap_err()
.to_string();
assert!(
err.contains("escapes workspace root") || err.contains("was not found"),
"unexpected escape error: {err}"
);
let missing =
classify_within("does-not-exist", Some(&root), canonicalizing_resolver).unwrap_err();
assert!(missing.to_string().contains("was not found"));
let no_ws = classify_within("rust", None, canonicalizing_resolver)
.unwrap_err()
.to_string();
assert!(no_ws.contains("cannot resolve a workspace"));
}
#[test]
fn owning_workspace_root_resolves_longest_ancestor() {
let a = PathBuf::from("/srv/repos/alpha");
let a_nested = PathBuf::from("/srv/repos/alpha/vendor/beta");
let b = PathBuf::from("/srv/repos/beta");
let roots = [a.as_path(), a_nested.as_path(), b.as_path()];
assert_eq!(
owning_workspace_root(Path::new("/srv/repos/alpha"), roots),
Some(a.clone())
);
assert_eq!(
owning_workspace_root(Path::new("/srv/repos/alpha/kernel/time.rs"), roots),
Some(a.clone())
);
assert_eq!(
owning_workspace_root(Path::new("/srv/repos/alpha/vendor/beta/src/x.rs"), roots),
Some(a_nested.clone())
);
assert_eq!(
owning_workspace_root(Path::new("/srv/repos/alphabet/x.rs"), roots),
None
);
assert_eq!(
owning_workspace_root(Path::new("/other/place/x.rs"), roots),
None
);
assert_eq!(
owning_workspace_root(Path::new("/srv/repos/alpha"), std::iter::empty()),
None
);
}
}