//! Repository paths.
//!
//! A [`RepoPath`] is a validated, normalised sequence of segments — never
//! empty, never containing `.`, `..`, or an empty segment — so path
//! comparison and prefix matching are well-defined everywhere they are used:
//! tree diffing, decision scopes, and the gate's blast-radius check.
use core::fmt;
use serde::{Deserialize, Serialize};
use crate::error::CoreError;
/// A normalised repository path: one or more `/`-joined segments.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct RepoPath(String);
impl RepoPath {
/// Parses and validates a path.
///
/// Leading and trailing slashes are rejected rather than stripped:
/// normalisation-by-fixup is how two spellings of one path end up
/// governed by different decisions.
///
/// # Errors
///
/// Returns [`CoreError::InvalidPath`] if the path is empty, starts or
/// ends with `/`, or contains an empty, `.`, or `..` segment.
pub fn parse(s: &str) -> Result<Self, CoreError> {
if s.is_empty() {
return Err(CoreError::InvalidPath {
reason: "path is empty",
});
}
if s.starts_with('/') || s.ends_with('/') {
return Err(CoreError::InvalidPath {
reason: "path must not start or end with `/`",
});
}
for seg in s.split('/') {
if seg.is_empty() {
return Err(CoreError::InvalidPath {
reason: "path contains an empty segment",
});
}
if seg == "." || seg == ".." {
return Err(CoreError::InvalidPath {
reason: "path contains a `.` or `..` segment",
});
}
}
Ok(Self(s.to_owned()))
}
/// The path as a string slice.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
/// The path's segments, in order.
pub fn segments(&self) -> impl Iterator<Item = &str> {
self.0.split('/')
}
/// Segment-wise prefix test: does `self` sit at or under `prefix`?
///
/// Segment-wise, not string-wise: `src/auth-x` is **not** under
/// `src/auth`, though it is a string-prefix match. The classic
/// prefix-matching bug is excluded here, once, rather than at every call
/// site.
#[must_use]
pub fn is_under(&self, prefix: &Self) -> bool {
let mut mine = self.segments();
for wanted in prefix.segments() {
match mine.next() {
Some(seg) if seg == wanted => {}
_ => return false,
}
}
true
}
/// Joins a child segment onto this path.
///
/// # Errors
///
/// Returns [`CoreError::InvalidPath`] if `segment` is not a single valid
/// segment.
pub fn join(&self, segment: &str) -> Result<Self, CoreError> {
if segment.is_empty() || segment.contains('/') || segment == "." || segment == ".." {
return Err(CoreError::InvalidPath {
reason: "join takes a single valid segment",
});
}
Ok(Self(format!("{}/{segment}", self.0)))
}
}
impl fmt::Display for RepoPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
reason = "test assertions read better with unwrap"
)]
mod tests {
use super::*;
#[test]
fn rejects_malformed_paths() {
for bad in ["", "/x", "x/", "a//b", "a/./b", "a/../b"] {
assert!(RepoPath::parse(bad).is_err(), "{bad:?} should be rejected");
}
}
#[test]
fn prefix_is_segment_wise() {
let auth = RepoPath::parse("src/auth").unwrap();
assert!(
RepoPath::parse("src/auth/backoff.rs")
.unwrap()
.is_under(&auth)
);
assert!(RepoPath::parse("src/auth").unwrap().is_under(&auth));
assert!(
!RepoPath::parse("src/auth-x/lib.rs")
.unwrap()
.is_under(&auth),
"string-prefix match must not count"
);
}
}