tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! An authorized override of a blocking limit (mission
//! `configurable-wip-limits-01M3C6WX`, WP03 T015).
//!
//! # Explicit, complete, and bound to the limit it was authorized against
//!
//! An override is the one way past a `block` limit, so it is the one place a
//! caller could quietly invent authorization. Four things are therefore required
//! and none is defaulted:
//!
//! - **Confirmation.** `confirmed` is a bool a caller sets deliberately; a
//!   surface that forwards a keystroke cannot set it by accident (C-004).
//! - **Actor and timestamp.** Both are supplied by the caller rather than read
//!   from the environment, so this module keeps the mission's rule that the domain
//!   reads no clock and no identity, and a test can pin both.
//! - **Reason.** An override with no reason is indistinguishable from a mistake,
//!   which is why #169's [`TransitionOverride`](crate::workflow::TransitionOverride)
//!   requires one too.
//! - **The limit it was authorized against** ([`BoundLimit`]). A limit is
//!   configuration: it can change between the moment a user is asked "override
//!   4/3?" and the moment the answer is applied. An override that names the
//!   ceilings it was given for is refused as `stale_override_limit` when they no
//!   longer match, instead of silently authorizing a limit nobody saw (T015).

use super::config::{ColumnLimit, WipDiagnostic, WipDiagnosticKind};

/// The limit an override was authorized against.
///
/// Compared field by field with the limit in force at the moment of the decision;
/// any difference makes the override stale. It is a *description* of a limit, not a
/// second place a limit is configured: nothing here enforces anything.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BoundLimit {
    /// The column the limit belongs to.
    pub column: String,
    /// The item ceiling the user was shown.
    pub max_items: Option<u32>,
    /// The estimate ceiling the user was shown.
    pub max_estimate: Option<u32>,
    /// The unit the estimate ceiling counted in.
    pub unit: Option<String>,
}

impl BoundLimit {
    /// The bound limit described by `limit`, as configured for `column`.
    pub fn of(column: &str, limit: &ColumnLimit) -> Self {
        Self {
            column: column.to_string(),
            max_items: limit.max_items,
            max_estimate: limit.max_estimate,
            unit: limit.unit.clone(),
        }
    }

    /// Whether this description still matches `limit` for `column`.
    pub fn matches(&self, column: &str, limit: &ColumnLimit) -> bool {
        self.column == column
            && self.max_items == limit.max_items
            && self.max_estimate == limit.max_estimate
            && self.unit == limit.unit
    }
}

/// A caller's request to proceed past a blocking limit.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OverrideRequest {
    /// Whether the caller's user confirmed the override explicitly.
    pub confirmed: bool,
    /// Who is overriding, for the History entry.
    pub actor: String,
    /// The event time, `RFC 3339`, supplied by the caller — never read here.
    pub timestamp: String,
    /// Why. Required, and recorded verbatim.
    pub reason: String,
    /// The ceilings the override was authorized against.
    pub limit: BoundLimit,
}

impl OverrideRequest {
    /// A complete, confirmed override.
    pub fn new(
        actor: impl Into<String>,
        timestamp: impl Into<String>,
        reason: impl Into<String>,
        limit: BoundLimit,
    ) -> Self {
        Self {
            confirmed: true,
            actor: actor.into(),
            timestamp: timestamp.into(),
            reason: reason.into(),
            limit,
        }
    }

    /// The one sentence naming this override, for a status line.
    pub fn summary(&self) -> String {
        format!("override by {} ({})", self.actor, self.reason)
    }

    /// Every reason this request cannot authorize `limit` on `column`.
    ///
    /// Empty means valid. All findings are returned rather than the first, so a
    /// surface can show a caller everything that is missing in one pass — and an
    /// empty `actor`, `reason` or `timestamp` counts as missing: a blank string is
    /// not a value, exactly as an empty `--reason` is not a reason.
    pub fn findings(&self, column: &str, limit: &ColumnLimit) -> Vec<WipDiagnostic> {
        let mut findings = Vec::new();
        if !self.confirmed {
            findings.push(WipDiagnostic::new(
                WipDiagnosticKind::UnconfirmedOverride,
                "an override must be confirmed explicitly; nothing was changed",
            ));
        }
        for (field, value) in [
            ("actor", self.actor.trim()),
            ("timestamp", self.timestamp.trim()),
            ("reason", self.reason.trim()),
        ] {
            if value.is_empty() {
                findings.push(WipDiagnostic::new(
                    WipDiagnosticKind::MissingOverrideField,
                    format!("an override needs a `{field}`; a blank `{field}` is not one"),
                ));
            }
        }
        if !self.limit.matches(column, limit) {
            findings.push(WipDiagnostic::new(
                WipDiagnosticKind::StaleOverrideLimit,
                format!(
                    "the override was authorized for {} on `{}`, but the limit now in force is \
                     {} on `{column}` — confirm the limit that is actually there",
                    self.limit.describe(),
                    self.limit.column,
                    BoundLimit::of(column, limit).describe(),
                ),
            ));
        }
        findings
    }
}

impl BoundLimit {
    /// The ceilings as one phrase, for a diagnostic.
    pub fn describe(&self) -> String {
        let mut parts = Vec::new();
        if let Some(max) = self.max_items {
            parts.push(format!("max_items {max}"));
        }
        if let Some(max) = self.max_estimate {
            match self.unit.as_deref() {
                Some(unit) => parts.push(format!("max_estimate {max}{unit}")),
                None => parts.push(format!("max_estimate {max}")),
            }
        }
        if parts.is_empty() {
            "no ceilings".to_string()
        } else {
            parts.join(", ")
        }
    }
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;
    use crate::wip::{ColumnLimit, LimitPolicy, WipDiagnosticKind};

    fn limit() -> ColumnLimit {
        ColumnLimit {
            max_items: Some(3),
            max_estimate: Some(10),
            unit: Some("d".to_string()),
            policy: Some(LimitPolicy::Block),
            enabled: true,
        }
    }

    fn request(limit: &ColumnLimit) -> OverrideRequest {
        OverrideRequest::new(
            "alice",
            "2026-01-02T03:04:05Z",
            "release week",
            BoundLimit::of("in progress", limit),
        )
    }

    #[test]
    fn wip_override_a_complete_request_has_no_findings() {
        let limit = limit();
        assert_eq!(
            request(&limit).findings("in progress", &limit),
            [] as [crate::wip::config::WipDiagnostic; 0]
        );
    }

    #[test]
    fn wip_override_every_missing_field_is_named() {
        let limit = limit();
        let cases = [
            ("confirmed", {
                let mut request = request(&limit);
                request.confirmed = false;
                request
            }),
            ("actor", {
                let mut request = request(&limit);
                request.actor = "  ".to_string();
                request
            }),
            ("timestamp", {
                let mut request = request(&limit);
                request.timestamp = String::new();
                request
            }),
            ("reason", {
                let mut request = request(&limit);
                request.reason = String::new();
                request
            }),
        ];
        for (field, request) in cases {
            let findings = request.findings("in progress", &limit);
            assert_eq!(findings.len(), 1, "{field}: {findings:?}");
            assert!(
                findings[0].message.contains(field) || field == "confirmed",
                "{field}: {}",
                findings[0].message
            );
            assert_ne!(
                findings[0].kind,
                WipDiagnosticKind::StaleOverrideLimit,
                "{field} must not be reported as a stale limit"
            );
        }
        // An unconfirmed override is its own kind, so a surface can say
        // "you did not confirm" rather than "something is missing".
        let mut unconfirmed = request(&limit);
        unconfirmed.confirmed = false;
        assert_eq!(
            unconfirmed.findings("in progress", &limit)[0].kind,
            WipDiagnosticKind::UnconfirmedOverride
        );
    }

    #[test]
    fn wip_override_a_changed_limit_makes_the_override_stale() {
        let original = limit();
        let request = request(&original);

        // The ceiling moved.
        let mut raised = original.clone();
        raised.max_items = Some(5);
        let findings = request.findings("in progress", &raised);
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].kind, WipDiagnosticKind::StaleOverrideLimit);
        assert!(
            findings[0].message.contains("max_items 3")
                && findings[0].message.contains("max_items 5"),
            "{}",
            findings[0].message
        );

        // The unit moved: a different ceiling expressed differently.
        let mut in_weeks = original.clone();
        in_weeks.unit = Some("w".to_string());
        assert_eq!(
            request.findings("in progress", &in_weeks)[0].kind,
            WipDiagnosticKind::StaleOverrideLimit
        );

        // The column moved.
        assert_eq!(
            request.findings("in review", &original)[0].kind,
            WipDiagnosticKind::StaleOverrideLimit
        );
    }

    #[test]
    fn wip_override_a_disabled_limit_is_still_described() {
        let limit = ColumnLimit {
            enabled: false,
            ..limit()
        };
        let bound = BoundLimit::of("in progress", &limit);
        assert_eq!(bound.describe(), "max_items 3, max_estimate 10d");
        assert!(bound.matches("in progress", &limit));
    }
}