use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkspaceSource {
Explicit,
ConfiguredRoot,
}
impl WorkspaceSource {
pub fn as_str(self) -> &'static str {
match self {
Self::Explicit => "explicit",
Self::ConfiguredRoot => "configured_root",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceId {
id: String,
root: PathBuf,
source: WorkspaceSource,
}
impl WorkspaceId {
pub fn explicit(id: impl Into<String>, root: impl Into<PathBuf>) -> Self {
Self {
id: sanitize_id(&id.into()),
root: root.into(),
source: WorkspaceSource::Explicit,
}
}
pub fn from_root(root: impl AsRef<Path>) -> Self {
let root = root.as_ref().to_path_buf();
let id_source = root.canonicalize().unwrap_or_else(|_| root.clone());
Self {
id: derive_id(&id_source),
root,
source: WorkspaceSource::ConfiguredRoot,
}
}
pub fn id(&self) -> &str {
&self.id
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn source(&self) -> WorkspaceSource {
self.source
}
}
pub fn resolve_within(root: &Path, rel: &str) -> Result<PathBuf, String> {
let rel = rel.trim_start_matches(['/', '\\']);
let canon_root = root
.canonicalize()
.map_err(|e| format!("workspace root: {e}"))?;
if rel.is_empty() {
return Ok(canon_root);
}
let candidate = canon_root.join(rel);
let resolved = if candidate.exists() {
candidate.canonicalize().map_err(|e| e.to_string())?
} else {
let parent = candidate.parent().ok_or("invalid path")?;
let canon_parent = parent
.canonicalize()
.map_err(|_| "parent directory does not exist".to_string())?;
canon_parent.join(candidate.file_name().ok_or("invalid path")?)
};
if !resolved.starts_with(&canon_root) {
return Err("path escapes workspace root".to_string());
}
Ok(resolved)
}
pub fn sanitize_id(raw: &str) -> String {
let cleaned: String = raw
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.take(64)
.collect();
if cleaned.is_empty() {
"default".to_string()
} else {
cleaned
}
}
fn derive_id(path: &Path) -> String {
const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
let mut hash = FNV_OFFSET;
for byte in path.to_string_lossy().as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
format!("ws_{hash:016x}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn explicit_id_is_sanitized() {
let ws = WorkspaceId::explicit("my project", PathBuf::from("/tmp/a"));
assert_eq!(ws.id(), "my_project");
assert_eq!(ws.source(), WorkspaceSource::Explicit);
}
#[test]
fn id_rejects_path_traversal_characters() {
let ws = WorkspaceId::explicit("../../etc/passwd", PathBuf::from("/tmp/a"));
assert!(!ws.id().contains('/'));
assert!(!ws.id().contains(".."));
}
#[test]
fn empty_id_falls_back_to_default() {
let ws = WorkspaceId::explicit("", PathBuf::from("/tmp/a"));
assert_eq!(ws.id(), "default");
}
#[test]
fn overlong_id_is_bounded() {
let ws = WorkspaceId::explicit("a".repeat(500), PathBuf::from("/tmp/a"));
assert_eq!(ws.id().len(), 64);
}
#[test]
fn derived_id_is_stable_across_calls() {
let a = WorkspaceId::from_root(PathBuf::from("/tmp/workspace-a"));
let b = WorkspaceId::from_root(PathBuf::from("/tmp/workspace-a"));
assert_eq!(a.id(), b.id());
assert_eq!(a.source(), WorkspaceSource::ConfiguredRoot);
}
#[test]
fn different_roots_get_different_ids() {
let a = WorkspaceId::from_root(PathBuf::from("/tmp/workspace-a"));
let b = WorkspaceId::from_root(PathBuf::from("/tmp/workspace-b"));
assert_ne!(a.id(), b.id());
}
#[test]
fn missing_root_still_yields_an_id() {
let ws = WorkspaceId::from_root(PathBuf::from("/definitely/not/here/at/all"));
assert!(ws.id().starts_with("ws_"));
}
}