cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! `DrStatus` — the typed lifecycle state of a decision record.
//!
//! The five statuses, their transitions, and their terminality are part of
//! cartulary's vocabulary (DDR-018QWJVHRH35B). Encoding them as an enum
//! moves the workflow rules into the model: the compiler enforces
//! exhaustivity on every `match`, and `transition_to` is the single oracle
//! consumed by the use case, the cascade, and `cartu check`.

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

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum DrStatus {
    Proposed,
    Accepted,
    Rejected,
    Deprecated,
    Superseded,
}

impl DrStatus {
    /// The initial status of any new decision record.
    pub const INITIAL: DrStatus = DrStatus::Proposed;

    pub fn as_str(&self) -> &'static str {
        match self {
            DrStatus::Proposed => "proposed",
            DrStatus::Accepted => "accepted",
            DrStatus::Rejected => "rejected",
            DrStatus::Deprecated => "deprecated",
            DrStatus::Superseded => "superseded",
        }
    }

    /// Outgoing transitions allowed from this status (DDR-018QWJVHRH35B).
    pub fn allowed_next(&self) -> &'static [DrStatus] {
        match self {
            DrStatus::Proposed => &[DrStatus::Accepted, DrStatus::Rejected],
            DrStatus::Accepted => &[DrStatus::Deprecated, DrStatus::Superseded],
            DrStatus::Deprecated => &[DrStatus::Superseded],
            DrStatus::Rejected | DrStatus::Superseded => &[],
        }
    }

    /// Whether `next` is a legal direct transition from `self`.
    pub fn allows(&self, next: DrStatus) -> bool {
        self.allowed_next().contains(&next)
    }

    /// Whether this status is terminal (no outgoing transitions).
    pub fn is_terminal(&self) -> bool {
        self.allowed_next().is_empty()
    }

    /// Iterate the five variants in canonical order.
    pub fn all() -> &'static [DrStatus] {
        &[
            DrStatus::Proposed,
            DrStatus::Accepted,
            DrStatus::Rejected,
            DrStatus::Deprecated,
            DrStatus::Superseded,
        ]
    }
}

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

impl FromStr for DrStatus {
    type Err = anyhow::Error;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "proposed" => Ok(DrStatus::Proposed),
            "accepted" => Ok(DrStatus::Accepted),
            "rejected" => Ok(DrStatus::Rejected),
            "deprecated" => Ok(DrStatus::Deprecated),
            "superseded" => Ok(DrStatus::Superseded),
            other => anyhow::bail!("unknown decision-record status '{other}'"),
        }
    }
}

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

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

    #[test]
    fn round_trips_through_from_str_and_display() {
        for st in DrStatus::all() {
            let s = st.to_string();
            assert_eq!(DrStatus::from_str(&s).unwrap(), *st);
        }
    }

    #[test]
    fn unknown_name_is_rejected() {
        assert!(DrStatus::from_str("draft").is_err());
        assert!(DrStatus::from_str("").is_err());
    }

    #[test]
    fn proposed_allows_accepted_and_rejected_only() {
        let p = DrStatus::Proposed;
        assert!(p.allows(DrStatus::Accepted));
        assert!(p.allows(DrStatus::Rejected));
        assert!(!p.allows(DrStatus::Deprecated));
        assert!(!p.allows(DrStatus::Superseded));
        assert!(!p.allows(DrStatus::Proposed));
    }

    #[test]
    fn accepted_allows_deprecated_and_superseded_only() {
        let a = DrStatus::Accepted;
        assert!(a.allows(DrStatus::Deprecated));
        assert!(a.allows(DrStatus::Superseded));
        assert!(!a.allows(DrStatus::Rejected));
    }

    #[test]
    fn deprecated_allows_only_superseded() {
        let d = DrStatus::Deprecated;
        assert!(d.allows(DrStatus::Superseded));
        assert!(!d.allows(DrStatus::Accepted));
        assert!(!d.allows(DrStatus::Rejected));
    }

    #[test]
    fn rejected_and_superseded_are_terminal() {
        assert!(DrStatus::Rejected.is_terminal());
        assert!(DrStatus::Superseded.is_terminal());
        assert!(!DrStatus::Proposed.is_terminal());
        assert!(!DrStatus::Accepted.is_terminal());
        assert!(!DrStatus::Deprecated.is_terminal());
    }
}

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

    pub fn dr_status() -> impl Strategy<Value = DrStatus> {
        prop_oneof![
            Just(DrStatus::Proposed),
            Just(DrStatus::Accepted),
            Just(DrStatus::Rejected),
            Just(DrStatus::Deprecated),
            Just(DrStatus::Superseded),
        ]
    }
}