jacquardSnapshot

← snapshot

4253 bytes
//! 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"
        );
    }
}