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;

use super::super::is_valid_kebab_lowercase;

/// A syntactically-validated status identifier, without config-derived semantics.
///
/// `StatusName` is the result of parsing a raw string from a frontmatter file or
/// CLI input. It carries no `category` or `label` — those require a
/// [`StatusesConfig`] and are resolved into a full [`Status`] by the repository
/// via [`StatusesConfig::resolve`].
///
/// The invariant: non-empty, matches `[a-z0-9][a-z0-9-]*`.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct StatusName(pub(super) String);

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

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

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

impl FromStr for StatusName {
    type Err = anyhow::Error;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        StatusName::new(s)
    }
}

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

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

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

    #[test]
    fn status_name_accepts_simple() {
        assert!(StatusName::new("proposed").is_ok());
    }

    #[test]
    fn status_name_accepts_hyphen() {
        assert!(StatusName::new("in-progress").is_ok());
    }

    #[test]
    fn status_name_accepts_digit() {
        assert!(StatusName::new("phase2").is_ok());
    }

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

    #[test]
    fn status_name_rejects_uppercase() {
        assert!(StatusName::new("Open").is_err());
    }

    #[test]
    fn status_name_rejects_leading_hyphen() {
        assert!(StatusName::new("-open").is_err());
    }

    #[test]
    fn status_name_rejects_spaces() {
        assert!(StatusName::new("in progress").is_err());
    }

    #[test]
    fn status_name_rejects_special_chars() {
        assert!(StatusName::new("open!").is_err());
    }

    #[test]
    fn status_name_from_str_accepts_valid() {
        let s: StatusName = "deprecated".parse().unwrap();
        assert_eq!(s.as_str(), "deprecated");
    }

    #[test]
    fn status_name_from_str_rejects_invalid() {
        assert!("".parse::<StatusName>().is_err());
        assert!("Bad".parse::<StatusName>().is_err());
    }

    proptest! {
        #[test]
        fn prop_status_name_strategy_valid(n in strategy::status_name()) {
            prop_assert!(StatusName::new(n.as_str()).is_ok());
        }

        #[test]
        fn prop_status_name_chars_are_valid(n in strategy::status_name()) {
            let text = n.as_str();
            prop_assert!(!text.is_empty());
            prop_assert!(text.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'));
        }
    }
}