use std::path::{Component, Path, PathBuf};
use camino::Utf8PathBuf;
use path_slash::PathExt as _;
use crate::ModelError;
pub fn workspace_relative(path: impl AsRef<Path>) -> Result<PathBuf, ModelError> {
let p = path.as_ref();
if p.is_absolute() {
return Err(ModelError::AbsolutePath { path: p.to_path_buf() });
}
let utf8 = Utf8PathBuf::from_path_buf(p.to_path_buf()).map_err(|path| ModelError::NonUtf8Path { path })?;
let slash_str = utf8.as_std_path().to_slash().ok_or_else(|| ModelError::NonUtf8Path {
path: utf8.as_std_path().to_path_buf(),
})?;
let normalized_path = Path::new(slash_str.as_ref());
let mut components = Vec::new();
for component in normalized_path.components() {
match component {
Component::Prefix(_) | Component::RootDir => {
return Err(ModelError::AbsolutePath { path: p.to_path_buf() });
}
Component::CurDir => {}
Component::ParentDir => {
if let Some(Component::Normal(_)) = components.last() {
components.pop();
} else {
return Err(ModelError::PathTraversal { path: p.to_path_buf() });
}
}
Component::Normal(_) => {
components.push(component);
}
}
}
let mut out_str = String::new();
for (i, c) in components.iter().enumerate() {
if i > 0 {
out_str.push('/');
}
out_str.push_str(c.as_os_str().to_str().unwrap());
}
Ok(PathBuf::from(out_str))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_relative_paths_lexically() {
let p = workspace_relative("a/b/../c/./d").unwrap();
assert_eq!(p, PathBuf::from("a/c/d"));
}
#[test]
fn rejects_absolute_path() {
let err = workspace_relative("/usr/bin").unwrap_err();
assert!(matches!(err, ModelError::AbsolutePath { .. }));
}
#[test]
fn rejects_path_traversal_outside_workspace() {
let err = workspace_relative("../secret").unwrap_err();
assert!(matches!(err, ModelError::PathTraversal { .. }));
}
#[cfg(target_os = "windows")]
#[test]
fn normalizes_windows_backslashes_to_posix_slashes() {
let p = workspace_relative("crates\\callisto-cli\\src\\lib.rs").unwrap();
assert_eq!(p, PathBuf::from("crates/callisto-cli/src/lib.rs"));
}
#[cfg(not(target_os = "windows"))]
#[test]
fn preserves_literal_backslash_in_posix_filename() {
let p = workspace_relative("a/weird\\file.txt").unwrap();
assert_eq!(p, PathBuf::from("a/weird\\file.txt"));
}
}