cartulary 0.3.0-alpha.1

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

/// The kind of a decision record (e.g. `"adr"`, `"ddr"`, `"gddr"`).
///
/// The invariant is enforced at construction time: a `RecordKind` must be
/// non-empty and match `[a-z0-9][a-z0-9-]*` (lowercase ASCII alphanumerics
/// and hyphens, must start with an alphanumeric character).
///
/// The value comes from `cartulary.toml` configuration and is injected by the
/// repository — it is never stored in the file itself.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct RecordKind(String);

impl serde::Serialize for RecordKind {
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        s.serialize_str(&self.0)
    }
}

impl RecordKind {
    /// Construct a `RecordKind` from a string slice.
    ///
    /// Returns an error if `s` is empty or contains characters outside
    /// `[a-z0-9-]`, or starts with a hyphen.
    pub fn new(s: &str) -> anyhow::Result<Self> {
        if super::is_valid_kebab_lowercase(s) {
            Ok(RecordKind(s.to_string()))
        } else {
            anyhow::bail!("invalid record kind '{s}': must match [a-z0-9][a-z0-9-]*")
        }
    }

    /// Return the kind as a `&str`.
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

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

impl FromStr for RecordKind {
    type Err = anyhow::Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        RecordKind::new(s)
    }
}

// ── Tests ─────────────────────────────────────────────────────────────────────

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

    /// Generate valid `RecordKind` values.
    pub fn record_kind() -> impl Strategy<Value = RecordKind> {
        proptest::string::string_regex("[a-z][a-z0-9-]{1,7}")
            .unwrap()
            .prop_map(|s| RecordKind::new(&s).expect("regex guarantees valid RecordKind"))
    }
}

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

    // ── Unit tests ────────────────────────────────────────────────────────────

    #[test]
    fn new_accepts_simple_kind() {
        assert!(RecordKind::new("adr").is_ok());
    }

    #[test]
    fn new_accepts_kind_with_hyphen() {
        assert!(RecordKind::new("game-ddr").is_ok());
    }

    #[test]
    fn new_accepts_kind_with_digits() {
        assert!(RecordKind::new("adr2").is_ok());
    }

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

    #[test]
    fn new_rejects_uppercase() {
        assert!(RecordKind::new("ADR").is_err());
    }

    #[test]
    fn new_rejects_leading_hyphen() {
        assert!(RecordKind::new("-adr").is_err());
    }

    #[test]
    fn new_rejects_spaces() {
        assert!(RecordKind::new("my kind").is_err());
    }

    #[test]
    fn new_rejects_special_chars() {
        assert!(RecordKind::new("adr!").is_err());
    }

    #[test]
    fn display_roundtrips() {
        let k = RecordKind::new("adr").unwrap();
        assert_eq!(k.to_string(), "adr");
    }

    #[test]
    fn as_str_returns_inner() {
        let k = RecordKind::new("ddr").unwrap();
        assert_eq!(k.as_str(), "ddr");
    }

    #[test]
    fn from_str_accepts_valid_kind() {
        let k: RecordKind = "gddr".parse().unwrap();
        assert_eq!(k.as_str(), "gddr");
    }

    #[test]
    fn from_str_rejects_invalid_kind() {
        assert!("".parse::<RecordKind>().is_err());
        assert!("ADR".parse::<RecordKind>().is_err());
    }

    #[test]
    fn equality_holds_for_same_kind() {
        let a = RecordKind::new("adr").unwrap();
        let b = RecordKind::new("adr").unwrap();
        assert_eq!(a, b);
    }

    #[test]
    fn ordering_is_lexicographic() {
        let adr = RecordKind::new("adr").unwrap();
        let ddr = RecordKind::new("ddr").unwrap();
        assert!(adr < ddr);
    }

    // ── Property-based tests ──────────────────────────────────────────────────

    proptest! {
        #[test]
        fn prop_strategy_always_produces_valid_kinds(k in strategy::record_kind()) {
            prop_assert!(RecordKind::new(k.as_str()).is_ok());
        }

        #[test]
        fn prop_display_roundtrips(k in strategy::record_kind()) {
            let s = k.to_string();
            let parsed: RecordKind = s.parse().unwrap();
            prop_assert_eq!(k, parsed);
        }

        #[test]
        fn prop_kind_chars_are_valid(k in strategy::record_kind()) {
            let s = k.as_str();
            prop_assert!(!s.is_empty());
            prop_assert!(s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'));
        }
    }
}