cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
/// Relative site path with constructor-enforced invariants.
///
/// Used at the `SiteWriter` port boundary instead of `std::path::Path`
/// so the domain owns the validity contract and adapters never have to
/// re-check.
///
/// Invariants:
/// - non-empty
/// - relative (no leading `/`)
/// - no `..` segments
/// - no backslash separators
/// - no trailing `/`
/// - no embedded NUL
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SitePath(String);

impl SitePath {
    pub fn new(s: impl Into<String>) -> Result<Self, &'static str> {
        let raw = s.into();
        if raw.is_empty() {
            return Err("site path must not be empty");
        }
        if raw.starts_with('/') {
            return Err("site path must be relative");
        }
        if raw.ends_with('/') {
            return Err("site path must not end with /");
        }
        if raw.contains('\\') {
            return Err("site path must use / as separator");
        }
        if raw.contains('\0') {
            return Err("site path must not contain NUL");
        }
        if raw
            .split('/')
            .any(|seg| seg.is_empty() || seg == "." || seg == "..")
        {
            return Err("site path must not contain empty, '.' or '..' segments");
        }
        Ok(SitePath(raw))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl std::fmt::Display for SitePath {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

impl TryFrom<String> for SitePath {
    type Error = &'static str;
    fn try_from(s: String) -> Result<Self, Self::Error> {
        SitePath::new(s)
    }
}

impl TryFrom<&str> for SitePath {
    type Error = &'static str;
    fn try_from(s: &str) -> Result<Self, Self::Error> {
        SitePath::new(s)
    }
}

#[cfg(test)]
pub mod strategy {
    use super::SitePath;
    use proptest::prelude::*;

    pub fn site_path() -> impl Strategy<Value = SitePath> {
        proptest::collection::vec("[a-z0-9][a-z0-9_-]{0,15}", 1..5)
            .prop_map(|segs| SitePath::new(segs.join("/")).expect("strategy produces valid paths"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;

    #[test]
    fn accepts_simple_relative_path() {
        assert_eq!(SitePath::new("index.html").unwrap().as_str(), "index.html");
    }

    #[test]
    fn accepts_nested_path() {
        assert_eq!(
            SitePath::new("assets/css/site.css").unwrap().as_str(),
            "assets/css/site.css"
        );
    }

    #[test]
    fn rejects_empty() {
        assert!(SitePath::new("").is_err());
    }

    #[test]
    fn rejects_absolute() {
        assert!(SitePath::new("/etc/passwd").is_err());
    }

    #[test]
    fn rejects_trailing_slash() {
        assert!(SitePath::new("issues/").is_err());
    }

    #[test]
    fn rejects_backslash() {
        assert!(SitePath::new("a\\b").is_err());
    }

    #[test]
    fn rejects_parent_segment() {
        assert!(SitePath::new("a/../b").is_err());
    }

    #[test]
    fn rejects_dot_segment() {
        assert!(SitePath::new("a/./b").is_err());
    }

    #[test]
    fn rejects_empty_segment() {
        assert!(SitePath::new("a//b").is_err());
    }

    #[test]
    fn rejects_nul() {
        assert!(SitePath::new("a\0b").is_err());
    }

    proptest! {
        #[test]
        fn strategy_produces_valid_paths(p in strategy::site_path()) {
            prop_assert!(!p.as_str().is_empty());
            prop_assert!(!p.as_str().starts_with('/'));
            prop_assert!(!p.as_str().ends_with('/'));
            prop_assert!(!p.as_str().contains(".."));
        }
    }
}