Files
galaxy/crates/warp_util/src/standardized_path.rs
T

288 lines
11 KiB
Rust

//! A normalized, platform-aware path type that does not require filesystem I/O.
//!
//! [`StandardizedPath`] wraps [`TypedPathBuf`] and guarantees that the inner path is always
//! absolute and normalized (`.` and `..` segments removed, separators collapsed). Unlike
//! [`CanonicalizedPath`](repo_metadata::CanonicalizedPath), construction does **not** resolve
//! symlinks or verify existence on disk.
use std::fmt;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use typed_path::{PathType, TypedPath, TypedPathBuf};
/// Error returned when a path cannot be converted into a [`StandardizedPath`].
#[derive(Debug, thiserror::Error)]
pub enum InvalidPathError {
#[error("path is not absolute: {0}")]
NotAbsolute(String),
#[error("path contains invalid UTF-8")]
InvalidUtf8,
}
/// A normalized, platform-aware path that does not require the file to exist.
///
/// Unlike `CanonicalizedPath`, construction does NOT perform filesystem I/O.
/// Normalization removes `.` and `..` segments and collapses separators, but
/// does not resolve symlinks or verify existence.
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct StandardizedPath(TypedPathBuf);
impl StandardizedPath {
// ── Construction APIs ─────────────────────────────────────────────
/// Create from a string, inferring Unix vs Windows encoding.
/// Normalizes the path (removes `.`/`..`, collapses separators).
/// Returns an error if the path is not absolute.
pub fn try_new(path: &str) -> Result<Self, InvalidPathError> {
let typed = TypedPathBuf::from(path);
let normalized = typed.normalize();
if !normalized.is_absolute() {
return Err(InvalidPathError::NotAbsolute(path.to_owned()));
}
Ok(Self(normalized))
}
/// Create with an explicit path type (Unix or Windows).
/// Returns an error if the path is not absolute.
pub fn try_with_encoding(path: &str, path_type: PathType) -> Result<Self, InvalidPathError> {
let typed = TypedPathBuf::new(path_type);
let typed = typed.join(path);
let normalized = typed.normalize();
if !normalized.is_absolute() {
return Err(InvalidPathError::NotAbsolute(path.to_owned()));
}
Ok(Self(normalized))
}
/// Create from a local `std::path::Path`, inferring encoding from
/// the compile target. Normalizes but does NOT canonicalize.
/// Returns an error if the path is not absolute.
pub fn try_from_local(path: &Path) -> Result<Self, InvalidPathError> {
let path_str = path.to_str().ok_or(InvalidPathError::InvalidUtf8)?;
let typed = local_typed_path_buf(path_str);
let normalized = typed.normalize();
if !normalized.is_absolute() {
return Err(InvalidPathError::NotAbsolute(path_str.to_owned()));
}
Ok(Self(normalized))
}
/// Create from a local `std::path::Path` that is **known** to be absolute.
///
/// # Panics
/// Panics (debug-only) if the path is not absolute or contains invalid
/// UTF-8. In release builds the path is accepted as-is to avoid
/// to penalize hot paths.
pub fn from_local_absolute_unchecked(path: &Path) -> Self {
debug_assert!(
path.is_absolute(),
"from_local_absolute called with non-absolute path: {}",
path.display()
);
debug_assert!(
path.to_str().is_some(),
"from_local_absolute called with non-UTF-8 path: {}",
path.display()
);
let path_str = path.to_str().unwrap_or_default();
let typed = local_typed_path_buf(path_str);
Self(typed.normalize())
}
/// Create from a local path with full canonicalization (resolves
/// symlinks, verifies existence). This is the I/O-performing
/// equivalent of `CanonicalizedPath::try_from`.
/// Use at shell boundaries when receiving paths from the OS.
pub fn from_local_canonicalized(path: &Path) -> io::Result<Self> {
let canonical = dunce::canonicalize(path)?;
// dunce::simplified strips the UNC prefix when safe.
let simplified = dunce::simplified(&canonical);
let path_str = simplified.to_str().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"canonicalized path is not valid UTF-8",
)
})?;
let typed = local_typed_path_buf(path_str);
// Canonical paths are already normalized, but normalize anyway for consistency.
Ok(Self(typed.normalize()))
}
// ── Query APIs ───────────────────────────────────────────────────
/// Returns the underlying `TypedPath`.
pub fn as_typed_path(&self) -> TypedPath<'_> {
self.0.to_path()
}
/// Returns the string representation of the path.
pub fn as_str(&self) -> &str {
self.0.to_str().unwrap_or_default()
}
/// Returns the file name component, if any.
pub fn file_name(&self) -> Option<&str> {
self.0.file_name().and_then(|b| std::str::from_utf8(b).ok())
}
/// Returns the extension, if any.
pub fn extension(&self) -> Option<&str> {
self.0.extension().and_then(|b| std::str::from_utf8(b).ok())
}
/// Returns the parent path, if any.
pub fn parent(&self) -> Option<StandardizedPath> {
self.0.parent().map(|p| StandardizedPath(p.to_path_buf()))
}
/// Whether this path starts with the given prefix.
pub fn starts_with(&self, base: &StandardizedPath) -> bool {
self.0.starts_with(&base.0)
}
/// Whether this path ends with the given suffix (component-aware).
///
/// The suffix can be a relative path string (e.g. `.agents/skills`).
/// Matching is done at the component level, so `/repo/myskills` does
/// **not** match the suffix `skills`.
pub fn ends_with(&self, suffix: &str) -> bool {
self.0.ends_with(suffix)
}
/// Strip a prefix from this path, returning the relative remainder.
pub fn strip_prefix(&self, base: &StandardizedPath) -> Option<&str> {
let self_str = self.as_str();
let base_str = base.as_str();
self_str.strip_prefix(base_str).map(|remainder| {
// Remove leading separator if present.
remainder
.strip_prefix('/')
.or_else(|| remainder.strip_prefix('\\'))
.unwrap_or(remainder)
})
}
/// Join a relative segment onto this path.
pub fn join(&self, segment: &str) -> StandardizedPath {
StandardizedPath(self.0.join(segment).normalize())
}
/// Whether the path uses Unix encoding.
pub fn is_unix(&self) -> bool {
self.0.to_path().is_unix()
}
/// Whether the path uses Windows encoding.
pub fn is_windows(&self) -> bool {
self.0.to_path().is_windows()
}
/// Sets the file name component of this path, analogous to
/// [`PathBuf::set_file_name`].
pub fn set_file_name(&mut self, name: &str) {
self.0.set_file_name(name);
self.0 = self.0.normalize();
}
/// Returns an iterator over the ancestors of this path, starting with
/// the path itself and ending at the root.
pub fn ancestors(&self) -> impl Iterator<Item = StandardizedPath> {
let mut current = Some(self.clone());
std::iter::from_fn(move || {
let path = current.take()?;
current = path.parent();
Some(path)
})
}
// ── Conversion APIs ──────────────────────────────────────────────
/// Convert to a local `PathBuf` if the encoding matches the current OS.
/// Returns `None` for a Unix-encoded path on Windows or vice versa.
pub fn to_local_path(&self) -> Option<PathBuf> {
if encoding_matches_local(&self.0) {
Some(PathBuf::from(self.as_str()))
} else {
None
}
}
/// Converts this path to a local [`PathBuf`] by re-encoding its components
/// for the current OS.
///
/// **Use this only when the path is known to originate from the local
/// filesystem** (e.g. from [`LocalRepoMetadataModel`], [`Repository`],
/// [`DetectedRepositories`], or any path that was constructed via
/// [`from_local_canonicalized`](Self::from_local_canonicalized) /
/// [`try_from_local`](Self::try_from_local)). For those paths the
/// encoding already matches and the conversion is lossless.
///
/// If the path was constructed with a foreign encoding (e.g. a
/// Windows-encoded remote path on a macOS host), the conversion is lossy:
/// platform-specific prefixes like `C:` are dropped and separators are
/// translated. Prefer [`to_local_path`](Self::to_local_path) when the
/// encoding match is not guaranteed and you need to handle the mismatch
/// explicitly.
///
/// This function is generally something you shouldn't use. We are using this
/// as a stop gap to avoid `unwrap` as we migrate from PathBuf to StandardizedPath.
pub fn to_local_path_lossy(&self) -> PathBuf {
let local = if cfg!(windows) {
self.0.with_windows_encoding()
} else {
self.0.with_unix_encoding()
};
PathBuf::from(local.to_str().unwrap_or_default())
}
}
impl fmt::Display for StandardizedPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl Serialize for StandardizedPath {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.as_str().serialize(serializer)
}
}
impl<'de> Deserialize<'de> for StandardizedPath {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::try_new(&s).map_err(serde::de::Error::custom)
}
}
// ── Helpers ──────────────────────────────────────────────────────────
/// Construct a `TypedPathBuf` using the local platform's encoding.
///
/// On Unix targets the path is always treated as Unix-encoded; on Windows
/// targets it is always treated as Windows-encoded. This avoids ambiguity
/// from the heuristic-based `TypedPathBuf::from` inference.
fn local_typed_path_buf(path_str: &str) -> TypedPathBuf {
if cfg!(windows) {
typed_path::WindowsPathBuf::from(path_str).to_typed_path_buf()
} else {
typed_path::UnixPathBuf::from(path_str).to_typed_path_buf()
}
}
/// Returns true if the `TypedPathBuf` encoding matches the compilation target.
fn encoding_matches_local(typed: &TypedPathBuf) -> bool {
let path = typed.to_path();
if cfg!(windows) {
path.is_windows()
} else {
path.is_unix()
}
}
#[cfg(test)]
#[path = "standardized_path_tests.rs"]
mod tests;