cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! `State` — the primitive name of a status as recorded in the event log.
//!
//! The event log is universe-agnostic: it does not know what an issue's
//! `open` or a decision-record's `accepted` *mean*. It only knows that a
//! transition happened from one named state to another. The interpretation
//! lives in the consuming universe (issue uses `StatusesConfig` to resolve;
//! decision record uses the typed `DrStatus` enum).
//!
//! Invariant: same kebab-lowercase grammar as the rest of the project's
//! tag/identifier names — non-empty, `[a-z0-9][a-z0-9-]*`.

use std::fmt;
use std::str::FromStr;

use crate::domain::model::is_valid_kebab_lowercase;

/// A syntactically-validated state name, free of any universe-specific
/// projection (no `terminal`, no `category`, no `label`).
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct State(String);

impl State {
    pub fn new(s: &str) -> anyhow::Result<Self> {
        if is_valid_kebab_lowercase(s) {
            Ok(State(s.to_string()))
        } else {
            anyhow::bail!("invalid state '{s}': must match [a-z0-9][a-z0-9-]*")
        }
    }

    /// Synthetic state used by [`enrich_record`] when a legacy event
    /// carries no parseable status name. Materialised as a typed
    /// constant so the convention has one home; `cartu check`
    /// surfaces it as `CreatedStatusUnknown` (see [`EventLogIssue`]).
    ///
    /// [`enrich_record`]: crate::domain::model::event::enrich_legacy_action
    /// [`EventLogIssue`]: crate::domain::model::check::EventLogIssue
    pub fn unknown() -> Self {
        // `unknown` is a valid kebab-lowercase identifier — infallible.
        State::new("unknown").expect("'unknown' is a valid State")
    }

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

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

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

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

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

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

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

    #[test]
    fn accepts_simple_lowercase() {
        assert!(State::new("proposed").is_ok());
        assert!(State::new("in-progress").is_ok());
        assert!(State::new("phase2").is_ok());
    }

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

    #[test]
    fn rejects_uppercase() {
        assert!(State::new("Accepted").is_err());
    }

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

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

    #[test]
    fn unknown_is_a_valid_state_named_unknown() {
        assert_eq!(State::unknown().as_str(), "unknown");
    }

    proptest! {
        #[test]
        fn round_trips_through_as_str(state in strategy::state()) {
            prop_assert_eq!(State::new(state.as_str()).unwrap(), state);
        }

        #[test]
        fn from_str_round_trips(state in strategy::state()) {
            let s = state.to_string();
            prop_assert_eq!(s.parse::<State>().unwrap(), state);
        }
    }
}