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 companion within an issue directory. Conceptually
/// a relative path from the directory (e.g. `plan.md`, `mockup UX.xml`,
/// `img/screen.png`), but the domain treats it as a string with three
/// portability invariants:
///
/// - non-empty,
/// - not absolute (no leading `/`),
/// - no `..` segment (escape attempt).
///
/// No further constraint on characters: companions are user-authored
/// files, names can carry spaces, extensions, anything the filesystem
/// accepts.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct CompanionIdentifier(String);

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

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

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

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

    /// Generate a valid `CompanionIdentifier` from a small alphabet:
    /// 1..=20 chars, no `/`, no leading dot, no `..`.
    pub fn companion_identifier() -> impl Strategy<Value = CompanionIdentifier> {
        "[a-zA-Z][a-zA-Z0-9 _.-]{0,19}\\.[a-z]{1,4}"
            .prop_filter("must not start with '..'", |s| !s.starts_with(".."))
            .prop_map(|s| CompanionIdentifier::new(&s).unwrap())
    }
}

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

    proptest! {
        #[test]
        fn as_str_roundtrips_through_new(id in strategy::companion_identifier()) {
            let s = id.as_str().to_string();
            let again = CompanionIdentifier::new(&s).unwrap();
            prop_assert_eq!(again.as_str().to_string(), s);
        }
    }

    #[test]
    fn accepts_simple_filenames() {
        assert_eq!(
            CompanionIdentifier::new("plan.md").unwrap().as_str(),
            "plan.md"
        );
        assert_eq!(
            CompanionIdentifier::new("design-decision.md")
                .unwrap()
                .as_str(),
            "design-decision.md"
        );
    }

    #[test]
    fn accepts_filenames_with_spaces_and_arbitrary_extensions() {
        assert_eq!(
            CompanionIdentifier::new("mockup UX.xml").unwrap().as_str(),
            "mockup UX.xml"
        );
        assert_eq!(
            CompanionIdentifier::new("screenshot.png").unwrap().as_str(),
            "screenshot.png"
        );
    }

    #[test]
    fn accepts_nested_paths() {
        assert_eq!(
            CompanionIdentifier::new("img/screen.png").unwrap().as_str(),
            "img/screen.png"
        );
    }

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

    #[test]
    fn rejects_absolute_paths() {
        assert!(CompanionIdentifier::new("/plan.md").is_err());
    }

    #[test]
    fn rejects_parent_escape() {
        assert!(CompanionIdentifier::new("../leak.md").is_err());
        assert!(CompanionIdentifier::new("img/../../leak.md").is_err());
    }
}