cartulary 0.3.0-alpha.1

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

use super::category::StatusCategory;
use super::status_name::StatusName;

/// A fully-resolved status value — a semantic snapshot produced from
/// [`StatusesConfig::resolve`].
///
/// Carries all the information needed for display and metrics without requiring
/// access to the configuration. `category`, `active` and `terminal` are
/// snapshotted from the config at resolution time.
///
/// Equality and ordering are based on `name` only.
#[derive(Debug, Clone, Eq)]
pub struct Status {
    pub name: String,
    pub label: String,
    pub category: StatusCategory,
    pub active: bool,
    pub terminal: bool,
}

/// Verdict of a pure status transition, computed by [`Status::transition_to`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StatusTransition {
    /// `target == self`; no change to apply.
    Unchanged,
    /// `target != self`; carries the from / to pair so the shell can
    /// publish it as the user-visible outcome.
    Changed { from: Status, to: Status },
}

impl Status {
    pub fn as_str(&self) -> &str {
        &self.name
    }

    /// Pure transition : compare `self` to `target`, return [`Unchanged`]
    /// when they are equal (status equality is name-based, see the type
    /// docs) or [`Changed`] carrying both ends of the transition. Does not
    /// mutate.
    pub fn transition_to(&self, target: &Status) -> StatusTransition {
        if self == target {
            StatusTransition::Unchanged
        } else {
            StatusTransition::Changed {
                from: self.clone(),
                to: target.clone(),
            }
        }
    }

    /// Construct a syntactically-valid `Status` from a name string, without
    /// config enrichment.
    ///
    /// Compatibility constructor for contexts where `StatusesConfig` is not
    /// available (parsers, test stubs). In production code prefer
    /// [`StatusesConfig::resolve`].
    pub fn new(s: &str) -> anyhow::Result<Self> {
        StatusName::new(s)?;
        Ok(Self::unresolved(s))
    }

    /// Construct a placeholder `Status` with only a name — no semantic fields.
    ///
    /// Used by parsers before the [`StatusesConfig`] is available.
    /// `category` defaults to `Unknown`, `active` and `terminal` to `false`.
    ///
    /// Do **not** use for application logic — prefer [`StatusesConfig::resolve`].
    pub fn unresolved(name: impl Into<String>) -> Self {
        let name = name.into();
        let label = name.clone();
        Status {
            name,
            label,
            category: StatusCategory::Unknown,
            active: false,
            terminal: false,
        }
    }

    /// Construct a `Status` directly from all its parts.
    ///
    /// Used by [`StatusesConfig`] presets and `resolve`. Callers outside this
    /// module should go through [`StatusesConfig::resolve`].
    pub(crate) fn from_parts(
        name: impl Into<String>,
        label: Option<String>,
        category: StatusCategory,
        active: bool,
        terminal: bool,
    ) -> Self {
        let name = name.into();
        let label = label.unwrap_or_else(|| name.clone());
        Status {
            name,
            label,
            category,
            active,
            terminal,
        }
    }
}

impl PartialEq for Status {
    fn eq(&self, other: &Self) -> bool {
        self.name == other.name
    }
}

impl PartialOrd for Status {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for Status {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        self.name.cmp(&other.name)
    }
}

impl std::hash::Hash for Status {
    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
        self.name.hash(state);
    }
}

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

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

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

    pub fn status() -> impl Strategy<Value = Status> {
        let cfg = StatusesConfig::default_issue();
        prop_oneof![
            Just(cfg.resolve("open").unwrap()),
            Just(cfg.resolve("in-progress").unwrap()),
            Just(cfg.resolve("closed").unwrap()),
        ]
    }

    pub fn issue_status() -> impl Strategy<Value = Status> {
        let cfg = StatusesConfig::default_issue();
        prop_oneof![
            Just(cfg.resolve("open").unwrap()),
            Just(cfg.resolve("in-progress").unwrap()),
            Just(cfg.resolve("closed").unwrap()),
        ]
    }
}

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

    fn issue_st(s: &str) -> Status {
        StatusesConfig::default_issue().resolve(s).unwrap()
    }

    #[test]
    fn status_display_roundtrips() {
        assert_eq!(issue_st("open").to_string(), "open");
    }

    #[test]
    fn status_as_str_returns_name() {
        assert_eq!(issue_st("in-progress").as_str(), "in-progress");
    }

    #[test]
    fn status_equality_based_on_name() {
        assert_eq!(issue_st("open"), issue_st("open"));
    }

    #[test]
    fn status_ordering_is_lexicographic() {
        assert!(issue_st("closed") < issue_st("open"));
    }

    #[test]
    fn status_carries_category() {
        assert_eq!(issue_st("open").category, StatusCategory::Queued);
        assert_eq!(issue_st("in-progress").category, StatusCategory::Active);
        assert_eq!(issue_st("closed").category, StatusCategory::Resolved);
    }

    #[test]
    fn status_carries_active_and_terminal() {
        assert!(issue_st("open").active);
        assert!(!issue_st("open").terminal);
        assert!(!issue_st("closed").active);
        assert!(issue_st("closed").terminal);
    }

    #[test]
    fn status_carries_label_fallback_to_name() {
        assert_eq!(issue_st("open").label, "open");
    }

    #[test]
    fn unresolved_has_unknown_category() {
        let s = Status::unresolved("custom");
        assert_eq!(s.category, StatusCategory::Unknown);
        assert!(!s.active);
        assert!(!s.terminal);
    }

    proptest! {
        #[test]
        fn prop_resolved_status_roundtrips_name(s in strategy::status()) {
            let cfg = StatusesConfig::default_issue();
            let resolved = cfg.resolve(s.as_str()).unwrap();
            prop_assert_eq!(resolved.name, s.name);
        }
    }
}