//! 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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(&self, serializer: S) -> Result { self.as_str().serialize(serializer) } } impl<'de> Deserialize<'de> for StandardizedPath { fn deserialize>(deserializer: D) -> Result { 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;