cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
use super::category::StatusCategory;
use super::value::Status;

/// Configuration for a single status value, as read from `cartulary.toml`.
///
/// Defines workflow constraints (`next`, `terminal`) and visibility (`active`).
/// The semantic fields (`label`, `category`) are promoted to [`Status`] by
/// [`StatusesConfig::resolve`] so that a resolved `Status` is self-contained.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusConfig {
    /// Status values that may follow this one in a valid transition.
    pub next: Vec<String>,
    /// Whether this status counts as "active" (shown by `--active`).
    pub active: bool,
    /// Whether this status is terminal (no further transitions expected).
    pub terminal: bool,
    /// Optional human-readable display label (e.g. "In Progress" for `in-progress`).
    /// Promoted to `Status.label` by `resolve`; falls back to the name.
    pub label: Option<String>,
    /// Time category for cycle-time analysis. Promoted to `Status.category` by `resolve`.
    pub category: StatusCategory,
}

/// The full set of configured statuses for a record kind.
///
/// Built from `cartulary.toml` or from the built-in defaults.
/// Use [`resolve`] to produce a fully-enriched [`Status`] from a name string.
/// The `initial` field is the status name assigned to a newly-created record.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusesConfig {
    pub(super) entries: Vec<(String, StatusConfig)>,
    /// The status name assigned to a newly-created record.
    pub(super) initial: String,
}

impl StatusesConfig {
    /// Construct from an explicit list of `(name, config)` pairs and an initial status name.
    pub fn new(entries: Vec<(String, StatusConfig)>, initial: String) -> Self {
        StatusesConfig { entries, initial }
    }

    /// The status name assigned to a newly-created record.
    pub fn initial(&self) -> &str {
        &self.initial
    }

    /// Resolve a raw name string into a fully-enriched [`Status`].
    ///
    /// Returns an error if the name is not in this configuration.
    pub fn resolve(&self, name: &str) -> anyhow::Result<Status> {
        self.entries
            .iter()
            .find(|(n, _)| n == name)
            .map(|(n, cfg)| {
                Status::from_parts(
                    n.clone(),
                    cfg.label.clone(),
                    cfg.category,
                    cfg.active,
                    cfg.terminal,
                )
            })
            .ok_or_else(|| anyhow::anyhow!("unknown status '{name}': not in configuration"))
    }

    /// Return `true` if a status with this name is known in this configuration.
    pub fn contains_name(&self, name: &str) -> bool {
        self.entries.iter().any(|(n, _)| n == name)
    }

    /// Return `true` if `status` is known in this configuration.
    pub fn contains(&self, status: &Status) -> bool {
        self.contains_name(status.as_str())
    }

    /// Return the allowed next status names for `status`, or `None` if unknown.
    pub fn next_for(&self, status: &Status) -> Option<&[String]> {
        self.entries
            .iter()
            .find(|(n, _)| n == status.as_str())
            .map(|(_, cfg)| cfg.next.as_slice())
    }

    /// Return an iterator over all known status names.
    pub fn status_names(&self) -> impl Iterator<Item = &str> {
        self.entries.iter().map(|(n, _)| n.as_str())
    }

    /// Return an iterator over all resolved [`Status`] values.
    pub fn statuses(&self) -> impl Iterator<Item = Status> + '_ {
        self.entries.iter().map(|(n, cfg)| {
            Status::from_parts(
                n.clone(),
                cfg.label.clone(),
                cfg.category,
                cfg.active,
                cfg.terminal,
            )
        })
    }

    /// Consume this config and return the raw entries vector.
    ///
    /// Useful when reconstructing a `StatusesConfig` with a different `initial`.
    pub fn into_entries(self) -> Vec<(String, StatusConfig)> {
        self.entries
    }
}

// ── Built-in default status sets ─────────────────────────────────────────────

/// Default statuses for issues.
pub const DEFAULT_ISSUE_STATUSES: &[&str] = &["open", "in-progress", "closed"];

#[cfg(test)]
pub mod strategy {
    use super::{StatusConfig, StatusesConfig};
    use crate::domain::model::status::category::strategy::status_category;
    use crate::domain::model::status::status_name::strategy::status_name;
    use proptest::prelude::*;

    pub fn status_config() -> impl Strategy<Value = StatusConfig> {
        (
            proptest::collection::vec(status_name().prop_map(|n| n.to_string()), 0..3),
            any::<bool>(),
            any::<bool>(),
            proptest::option::of("[A-Za-z ]{1,20}"),
            status_category(),
        )
            .prop_map(|(next, active, terminal, label, category)| StatusConfig {
                next,
                active,
                terminal,
                label,
                category,
            })
    }

    /// Generate a `StatusesConfig` with at least one entry; `initial` is one
    /// of the configured names so `resolve(initial)` always succeeds.
    pub fn statuses_config() -> impl Strategy<Value = StatusesConfig> {
        proptest::collection::vec(
            (status_name().prop_map(|n| n.to_string()), status_config()),
            1..5,
        )
        .prop_flat_map(|entries| {
            let names: Vec<String> = entries.iter().map(|(n, _)| n.clone()).collect();
            (Just(entries), proptest::sample::select(names))
                .prop_map(|(entries, initial)| StatusesConfig::new(entries, initial))
        })
    }
}

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

    proptest! {
        #[test]
        fn initial_is_always_resolvable(cfg in strategy::statuses_config()) {
            prop_assert!(cfg.resolve(cfg.initial()).is_ok());
        }

    }

    fn cfg() -> StatusesConfig {
        StatusesConfig::default_issue()
    }

    #[test]
    fn resolve_returns_enriched_status() {
        let s = cfg().resolve("closed").unwrap();
        assert_eq!(s.name, "closed");
        assert_eq!(s.category, StatusCategory::Resolved);
        assert!(s.terminal);
        assert!(!s.active);
    }

    #[test]
    fn resolve_unknown_returns_error() {
        assert!(cfg().resolve("unknown-status").is_err());
    }

    #[test]
    fn resolve_uses_configured_label() {
        let c = StatusesConfig::new(
            vec![(
                "in-progress".to_string(),
                StatusConfig {
                    next: vec![],
                    active: true,
                    terminal: false,
                    label: Some("In Progress".to_string()),
                    category: StatusCategory::Active,
                },
            )],
            "in-progress".to_string(),
        );
        assert_eq!(c.resolve("in-progress").unwrap().label, "In Progress");
    }

    #[test]
    fn contains_returns_true_for_known_status() {
        let c = cfg();
        let open = c.resolve("open").unwrap();
        let closed = c.resolve("closed").unwrap();
        assert!(c.contains(&open));
        assert!(open.active);
        assert!(!closed.active);
        assert!(closed.terminal);
        assert!(!open.terminal);
    }

    #[test]
    fn next_for_returns_allowed_transitions() {
        let c = StatusesConfig::default_issue();
        let next = c.next_for(&c.resolve("open").unwrap()).unwrap();
        assert!(next.iter().any(|s| s == "closed"));
        assert!(!next.iter().any(|s| s == "open"));
    }

    #[test]
    fn statuses_iterates_all() {
        let names: Vec<String> = cfg().statuses().map(|s| s.name).collect();
        assert!(names.contains(&"open".to_string()));
        assert!(names.contains(&"in-progress".to_string()));
        assert!(names.contains(&"closed".to_string()));
    }

    #[test]
    fn default_statuses_are_all_resolvable() {
        let c = cfg();
        for s in DEFAULT_ISSUE_STATUSES {
            assert!(c.resolve(s).is_ok(), "cannot resolve: {s}");
        }
    }
}