cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
use std::fmt;

/// Opaque handle to a site source — pages, assets, anywhere a file
/// flows into the site build. The algebraic shape leaves room for
/// future variants (URLs, embedded archives) without breaking
/// callers; today only `RelativePath` is exercised.
///
/// Invariants on the relative path string:
///
/// - non-empty,
/// - not absolute (no leading `/`),
/// - no `..` segment.
///
/// No further constraint on characters: sources are user-authored
/// content, names can carry spaces, extensions, anything the
/// filesystem accepts.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Source {
    RelativePath(String),
}

impl Source {
    pub fn relative_path(s: &str) -> anyhow::Result<Self> {
        if s.is_empty() {
            anyhow::bail!("source path cannot be empty");
        }
        if s.starts_with('/') {
            anyhow::bail!("source path '{s}' must be relative, not absolute");
        }
        if s.split('/').any(|seg| seg == "..") {
            anyhow::bail!("source path '{s}' must not contain '..'");
        }
        Ok(Self::RelativePath(s.to_owned()))
    }

    pub fn as_str(&self) -> &str {
        match self {
            Source::RelativePath(p) => p,
        }
    }
}

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

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

    pub fn relative_path() -> impl Strategy<Value = Source> {
        "[a-z][a-z0-9_-]{0,15}(/[a-z][a-z0-9_-]{0,15}){0,3}\\.[a-z]{1,4}"
            .prop_map(|s| Source::relative_path(&s).unwrap())
    }
}

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

    #[test]
    fn accepts_simple_filename() {
        let s = Source::relative_path("index.md").unwrap();
        assert_eq!(s.as_str(), "index.md");
    }

    #[test]
    fn accepts_nested_path() {
        let s = Source::relative_path("assets/css/site.css").unwrap();
        assert_eq!(s.as_str(), "assets/css/site.css");
    }

    #[test]
    fn accepts_filenames_with_spaces() {
        let s = Source::relative_path("mockup UX.xml").unwrap();
        assert_eq!(s.as_str(), "mockup UX.xml");
    }

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

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

    #[test]
    fn rejects_parent_escape() {
        assert!(Source::relative_path("../outside.md").is_err());
        assert!(Source::relative_path("a/../../escape.md").is_err());
    }

    #[test]
    fn display_renders_inner_path() {
        let s = Source::relative_path("pages/intro.md").unwrap();
        assert_eq!(format!("{s}"), "pages/intro.md");
    }

    proptest::proptest! {
        #[test]
        fn strategy_produces_valid_sources(s in strategy::relative_path()) {
            // Round-trips through the constructor — the strategy is honest.
            let reparsed = Source::relative_path(s.as_str()).unwrap();
            assert_eq!(reparsed, s);
        }
    }
}