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;

/// A free-form label attached to an issue or a decision record.
///
/// Two shapes are accepted:
/// - **Simple**: `[a-z0-9][a-z0-9-]*` — a single token (`backend`, `auth`).
/// - **Structured**: `<key>:<value>` where both `key` and `value` match the
///   simple form (`area:backend`, `priority:high`). Exactly one colon; an
///   empty key or value is rejected.
///
/// The simple form follows the same rule as [`Status`] and [`RecordKind`].
///
/// [`Status`]: super::status::Status
/// [`RecordKind`]: super::record_kind::RecordKind
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Tag(String);

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

impl Tag {
    /// Construct a `Tag` from a string slice.
    ///
    /// Returns an error if `s` does not match the simple `[a-z0-9][a-z0-9-]*`
    /// form or the structured `<key>:<value>` form.
    pub fn new(s: &str) -> anyhow::Result<Self> {
        if is_valid_tag(s) {
            Ok(Tag(s.to_string()))
        } else {
            anyhow::bail!(
                "invalid tag '{s}': must match [a-z0-9][a-z0-9-]* \
                 or [a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9-]*"
            )
        }
    }

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

    /// If this is a structured `<key>:<value>` tag, return the pair. Otherwise
    /// return `None`.
    pub fn as_kv(&self) -> Option<(&str, &str)> {
        self.0.split_once(':')
    }

    /// If this is a structured tag, return its key. Otherwise `None`.
    pub fn key(&self) -> Option<&str> {
        self.as_kv().map(|(k, _)| k)
    }

    /// If this is a structured tag, return its value. Otherwise `None`.
    pub fn value(&self) -> Option<&str> {
        self.as_kv().map(|(_, v)| v)
    }
}

/// Validate the two-shape tag grammar.
fn is_valid_tag(s: &str) -> bool {
    match s.split_once(':') {
        None => super::is_valid_kebab_lowercase(s),
        Some((key, value)) => {
            super::is_valid_kebab_lowercase(key) && super::is_valid_kebab_lowercase(value)
        }
    }
}

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

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

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

// ── Proptest strategy ─────────────────────────────────────────────────────────

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

    pub fn simple_tag() -> impl Strategy<Value = Tag> {
        "[a-z][a-z0-9-]{0,19}".prop_map(|s| Tag::new(&s).unwrap())
    }

    pub fn structured_tag() -> impl Strategy<Value = Tag> {
        ("[a-z][a-z0-9-]{0,9}", "[a-z][a-z0-9-]{0,9}")
            .prop_map(|(k, v)| Tag::new(&format!("{k}:{v}")).unwrap())
    }

    pub fn valid_tag() -> impl Strategy<Value = Tag> {
        prop_oneof![simple_tag(), structured_tag()]
    }
}

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

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

    #[test]
    fn new_accepts_simple_tag() {
        assert!(Tag::new("auth").is_ok());
    }

    #[test]
    fn new_accepts_tag_with_hyphen() {
        assert!(Tag::new("sprint-3").is_ok());
    }

    #[test]
    fn new_accepts_tag_with_digit() {
        assert!(Tag::new("v2").is_ok());
    }

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

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

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

    #[test]
    fn new_rejects_spaces() {
        assert!(Tag::new("invalid tag").is_err());
    }

    #[test]
    fn display_roundtrips() {
        let tag = Tag::new("backend").unwrap();
        assert_eq!(tag.to_string(), "backend");
    }

    #[test]
    fn from_str_roundtrips() {
        let tag: Tag = "sprint-3".parse().unwrap();
        assert_eq!(tag.as_str(), "sprint-3");
    }

    #[test]
    fn equality_holds_for_same_value() {
        assert_eq!(Tag::new("auth").unwrap(), Tag::new("auth").unwrap());
    }

    #[test]
    fn ordering_is_lexicographic() {
        let a = Tag::new("auth").unwrap();
        let b = Tag::new("backend").unwrap();
        assert!(a < b);
    }

    // ── Structured key:value tags ────────────────────────────────────────

    #[test]
    fn new_accepts_structured_tag() {
        assert!(Tag::new("area:backend").is_ok());
    }

    #[test]
    fn new_accepts_structured_with_hyphens() {
        assert!(Tag::new("priority:high").is_ok());
        assert!(Tag::new("area:back-end").is_ok());
    }

    #[test]
    fn new_rejects_empty_value_after_colon() {
        assert!(Tag::new("area:").is_err());
    }

    #[test]
    fn new_rejects_empty_key_before_colon() {
        assert!(Tag::new(":backend").is_err());
    }

    #[test]
    fn new_rejects_multiple_colons() {
        assert!(Tag::new("area:backend:more").is_err());
    }

    #[test]
    fn new_rejects_uppercase_in_value() {
        assert!(Tag::new("area:Backend").is_err());
    }

    #[test]
    fn as_kv_returns_pair_for_structured_tag() {
        let tag = Tag::new("area:backend").unwrap();
        assert_eq!(tag.as_kv(), Some(("area", "backend")));
    }

    #[test]
    fn as_kv_returns_none_for_simple_tag() {
        let tag = Tag::new("backend").unwrap();
        assert_eq!(tag.as_kv(), None);
    }

    #[test]
    fn key_returns_only_the_key() {
        let structured = Tag::new("area:backend").unwrap();
        let simple = Tag::new("backend").unwrap();
        assert_eq!(structured.key(), Some("area"));
        assert_eq!(simple.key(), None);
    }

    #[test]
    fn value_returns_only_the_value() {
        let structured = Tag::new("area:backend").unwrap();
        let simple = Tag::new("backend").unwrap();
        assert_eq!(structured.value(), Some("backend"));
        assert_eq!(simple.value(), None);
    }

    // ── proptest tests ────────────────────────────────────────────────
    use super::strategy;

    proptest::proptest! {
        #[test]
        fn prop_display_roundtrips(tag in strategy::valid_tag()) {
            proptest::prop_assert_eq!(tag.to_string(), tag.as_str());
        }

        #[test]
        fn prop_strategy_always_produces_valid_tags(tag in strategy::valid_tag()) {
            proptest::prop_assert!(Tag::new(tag.as_str()).is_ok());
        }
    }
}