Skip to main content

tix/wip/
policy.rs

1//! The **one** move decision every surface asks (mission
2//! `configurable-wip-limits-01M3C6WX`, WP03 T012-T016).
3//!
4//! # One decision, or the feature is a suggestion
5//!
6//! C-006 says a surface-local WIP bypass is a defect. The way to make that true is
7//! to have exactly one function that answers "may this move happen?", with no
8//! second copy of the rule in a command, a TUI handler or an MCP tool: those call
9//! [`evaluate`] and act on the [`WipDecision`] it returns. This module therefore
10//! does not write, read a file, read a clock or know what a keystroke is.
11//!
12//! # The order, and why it is this order
13//!
14//! 1. **The transition first** — [`workflow::validate_transition`], untouched. #183
15//!    owns what a column *means* and which moves are legal (C-002); WIP is an
16//!    additional question asked of a legal move, never a substitute for asking it.
17//!    A refused transition is [`WipOutcome::Rejected`] with #183's own diagnostic.
18//! 2. **A same-column move is a no-op** — #183 already says so (`changed: false`).
19//!    It returns [`WipOutcome::Allow`] immediately: a move that moves nothing must
20//!    not warn, must not block, and must not append an override event.
21//! 3. **The limit and its unit** — from the board's configuration, resolved once
22//!    through the aggregate snapshot, which is also where the before/after figures
23//!    come from. The moving issue's own estimate is converted into the limit's unit;
24//!    an estimate that cannot be counted **rejects** the move rather than counting
25//!    as zero (C-003).
26//! 4. **The configured policy** — within the limit is `allow`; over it is `warn` or
27//!    `block` according to the configuration, never according to a default.
28//!
29//! # What an outcome means
30//!
31//! | outcome | may the caller write? | History |
32//! |---|---|---|
33//! | `allow` | yes | none — a move within the limit is not a decision |
34//! | `warn` | yes | none — a warning is not an override (C-005 names overrides) |
35//! | `block` | **no** | none — nothing was decided |
36//! | `override` | yes | exactly one event, carrying actor/time/reason/from/to/limit/before/after |
37//! | `rejected` | **no** | none |
38//!
39//! `block` is the refusal a caller can *try to lift*: it carries
40//! [`WipDecision::requires_override`] and the exact [`BoundLimit`](super::r#override::BoundLimit)
41//! an override must be authorized against. A caller that supplies that override
42//! gets `override` on the same move; a caller that supplies an incomplete or stale
43//! one gets `rejected` — inert, and named field by field.
44
45use crate::config::Config;
46use crate::model::{HistoryEventKind, Issue};
47use crate::workflow::{self, TransitionOutcome};
48
49use super::aggregate::{ColumnUsage, LimitState, WipSnapshot};
50use super::config::{ColumnLimit, Estimate, LimitPolicy, WipDiagnostic, WipDiagnosticKind};
51use super::r#override::OverrideRequest;
52
53/// What a surface may do with a move.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55pub enum WipOutcome {
56    /// The move is within every limit (or there is no limit): write it.
57    Allow,
58    /// The move is over a limit configured to warn: write it and report.
59    Warn,
60    /// The move is over a limit configured to block, and no override was supplied.
61    /// Nothing may be written; an authorized override would carry it.
62    Block,
63    /// The move is over a blocking limit and a complete, current override carried
64    /// it. The one History event is in [`WipDecision::history`].
65    Override,
66    /// The transition was refused, the estimate could not be counted, or the
67    /// override was incomplete or stale. Nothing may be written.
68    Rejected,
69}
70
71impl WipOutcome {
72    /// The stable spelling every surface prints and every test asserts.
73    pub const fn as_str(self) -> &'static str {
74        match self {
75            Self::Allow => "allow",
76            Self::Warn => "warn",
77            Self::Block => "block",
78            Self::Override => "override",
79            Self::Rejected => "rejected",
80        }
81    }
82
83    /// Whether the caller may write the move.
84    pub const fn is_committable(self) -> bool {
85        matches!(self, Self::Allow | Self::Warn | Self::Override)
86    }
87
88    /// Whether this refusal is one an authorized override could lift.
89    pub const fn requires_override(self) -> bool {
90        matches!(self, Self::Block)
91    }
92}
93
94/// What the policy needs to decide: the configuration and the issues in hand.
95///
96/// Both are already loaded by the caller, which is what keeps this service
97/// filesystem-free — and what makes a decision reproducible in a test without a
98/// repository.
99pub struct MoveContext<'a> {
100    /// The project configuration (the board, and #183's policy it carries).
101    pub config: &'a Config,
102    /// The issues as read, so the aggregate is the one every surface sees.
103    pub issues: &'a [Issue],
104}
105
106impl<'a> MoveContext<'a> {
107    /// A context over already-loaded state.
108    pub const fn new(config: &'a Config, issues: &'a [Issue]) -> Self {
109        Self { config, issues }
110    }
111}
112
113/// One move, as a surface asks for it.
114pub struct MoveRequest<'a> {
115    /// The issue being moved.
116    pub issue: &'a Issue,
117    /// The column it is being moved to.
118    pub to: &'a str,
119    /// A reason for a **transition** override (#183's own gate, e.g. leaving a
120    /// terminal column). Separate from the WIP override below: they are two
121    /// different gates, and a caller may need either, both or neither.
122    pub transition_override: Option<&'a str>,
123    /// An authorized override of a blocking **WIP** limit, when the caller has one.
124    pub over: Option<&'a OverrideRequest>,
125}
126
127impl<'a> MoveRequest<'a> {
128    /// A move with no override of either kind.
129    pub const fn new(issue: &'a Issue, to: &'a str) -> Self {
130        Self {
131            issue,
132            to,
133            transition_override: None,
134            over: None,
135        }
136    }
137
138    /// Attach a reason for #183's own transition gate.
139    pub const fn because(mut self, reason: &'a str) -> Self {
140        self.transition_override = Some(reason);
141        self
142    }
143
144    /// Attach a WIP override.
145    pub const fn overriding(mut self, over: &'a OverrideRequest) -> Self {
146        self.over = Some(over);
147        self
148    }
149}
150
151/// The History entry an applied override writes.
152///
153/// An **intent**, not a write: the caller appends it through the existing grammar
154/// ([`crate::model::History::append_now`] or its `at`-stamped sibling), so there is
155/// no second audit log and the service stays inert (T016, C-005).
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct HistoryIntent {
158    /// The existing #169 event kind an override is recorded as.
159    pub kind: HistoryEventKind,
160    /// Who overrode, as the request stated it.
161    pub actor: String,
162    /// When, as the request stated it.
163    pub timestamp: String,
164    /// The one sentence: reason, columns, limit and the before/after figures.
165    pub detail: String,
166}
167
168/// The decision.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct WipDecision {
171    /// What the caller may do.
172    pub outcome: WipOutcome,
173    /// The column the issue is in.
174    pub from: String,
175    /// The column the move targets.
176    pub to: String,
177    /// The limit in force for the target column, when it has one.
178    pub limit: Option<ColumnLimit>,
179    /// The target column's state before the move.
180    pub state_before: LimitState,
181    /// The target column's state **if** the move happens.
182    pub state_after: LimitState,
183    /// The target column's usage, when it has a limit.
184    pub usage: Option<ColumnUsage>,
185    /// Everything that made the decision what it is.
186    pub diagnostics: Vec<WipDiagnostic>,
187    /// The one History entry an applied override writes; `None` otherwise.
188    pub history: Option<HistoryIntent>,
189}
190
191impl WipDecision {
192    /// Whether an authorized override could carry this refusal.
193    pub const fn requires_override(&self) -> bool {
194        self.outcome.requires_override()
195    }
196
197    /// The outcome's stable spelling.
198    pub const fn as_str(&self) -> &'static str {
199        self.outcome.as_str()
200    }
201
202    /// The [`BoundLimit`](super::r#override::BoundLimit) an override must be
203    /// authorized against, when this is a `block`.
204    pub fn bound_limit(&self) -> Option<super::r#override::BoundLimit> {
205        self.limit
206            .as_ref()
207            .map(|limit| super::r#override::BoundLimit::of(&self.to, limit))
208    }
209
210    /// Whether the caller may write the move.
211    pub const fn is_committable(&self) -> bool {
212        self.outcome.is_committable()
213    }
214}
215
216/// Judge one move (T012).
217#[allow(clippy::too_many_lines)]
218pub fn evaluate(context: &MoveContext<'_>, request: &MoveRequest<'_>) -> WipDecision {
219    let from = request.issue.status.display().to_string();
220    let to = request.to.to_string();
221    let allow = |state: LimitState, diagnostics| WipDecision {
222        outcome: WipOutcome::Allow,
223        from: from.clone(),
224        to: to.clone(),
225        limit: None,
226        state_before: state,
227        state_after: state,
228        usage: None,
229        diagnostics,
230        history: None,
231    };
232
233    // 1. The transition, through #183's own service (C-002).
234    let (_, resolution) = workflow::active_resolution(context.config);
235    match workflow::validate_transition(
236        request.issue,
237        request.to,
238        &resolution,
239        context.config,
240        request.transition_override,
241    ) {
242        TransitionOutcome::Rejected(diagnostic) => {
243            return WipDecision {
244                outcome: WipOutcome::Rejected,
245                from,
246                to,
247                limit: None,
248                state_before: LimitState::Within,
249                state_after: LimitState::Within,
250                usage: None,
251                diagnostics: vec![WipDiagnostic::new(
252                    WipDiagnosticKind::RejectedTransition,
253                    diagnostic.message,
254                )],
255                history: None,
256            };
257        }
258        // 2. A same-column move decides nothing and therefore warns and blocks
259        //    nothing: #183 already calls it permitted and idempotent.
260        TransitionOutcome::Accepted { changed: false, .. } => {
261            return allow(LimitState::Within, Vec::new());
262        }
263        TransitionOutcome::Accepted { .. } => {}
264    }
265
266    // 3. The limit and its unit, from the one shared snapshot.
267    let snapshot = WipSnapshot::build(context.config.default_config(), context.issues);
268    let Some(usage) = snapshot.column(&to).cloned() else {
269        // A column the board does not configure. #183 would have refused it above,
270        // so this is unreachable in practice; treating it as "no limit" keeps the
271        // policy from inventing a refusal #183 did not make.
272        return allow(LimitState::Within, Vec::new());
273    };
274    let Some(limit) = usage.limit.clone() else {
275        return allow(LimitState::Within, Vec::new());
276    };
277
278    // The moving issue's own contribution: one card, and the estimate it states
279    // **counted in the limit's unit**. An estimate that cannot be counted rejects
280    // the move (T014) — never zero, never a guess.
281    let unit = usage.unit().unwrap_or_default().to_string();
282    let mut diagnostics = Vec::new();
283    let add_estimate = match request
284        .issue
285        .estimate
286        .as_deref()
287        .map(str::trim)
288        .filter(|raw| !raw.is_empty())
289    {
290        None => 0,
291        Some(raw) => match Estimate::parse_terms(raw).and_then(|terms| {
292            context
293                .config
294                .default_config()
295                .wip
296                .convert_terms(&terms, &unit)
297        }) {
298            Ok(value) => value,
299            Err(error) => {
300                let first = error.first();
301                diagnostics.push(
302                    WipDiagnostic::new(
303                        first.kind,
304                        format!(
305                            "issue #{} states `{raw}`, which this board cannot count in \
306                             `{unit}`: {}",
307                            request.issue.number, first.message
308                        ),
309                    )
310                    .because(
311                        "the move is refused rather than counted as zero: an uncountable \
312                         estimate must not pass a limit silently (C-003)",
313                    ),
314                );
315                return WipDecision {
316                    outcome: WipOutcome::Rejected,
317                    from,
318                    to,
319                    limit: Some(limit),
320                    state_before: usage.state(),
321                    state_after: LimitState::Unknown,
322                    usage: Some(usage),
323                    diagnostics,
324                    history: None,
325                };
326            }
327        },
328    };
329
330    let state_before = usage.state();
331    let state_after = usage.prospective(1, add_estimate);
332
333    // An aggregate nobody can trust is a refusal, not a pass: WP02 already made the
334    // column's own state `Unknown` in this case, so the move inherits it.
335    if state_after == LimitState::Unknown {
336        diagnostics.push(
337            WipDiagnostic::new(
338                WipDiagnosticKind::AmbiguousConversion,
339                format!(
340                    "`{to}` has an estimate ceiling of {} that cannot be compared against its \
341                     current contents",
342                    limit.max_estimate.unwrap_or_default()
343                ),
344            )
345            .because("fix the uncountable estimate first; the move is refused meanwhile"),
346        );
347        return WipDecision {
348            outcome: WipOutcome::Rejected,
349            from,
350            to,
351            limit: Some(limit),
352            state_before,
353            state_after,
354            usage: Some(usage),
355            diagnostics,
356            history: None,
357        };
358    }
359
360    // 4. Within the limit: allowed, and deliberately not an event (C-004/C-005).
361    if state_after == LimitState::Within {
362        return WipDecision {
363            outcome: WipOutcome::Allow,
364            from,
365            to,
366            limit: Some(limit),
367            state_before,
368            state_after,
369            usage: Some(usage),
370            diagnostics,
371            history: None,
372        };
373    }
374
375    // Over the limit: the configuration decides whether that warns or blocks.
376    let policy = limit.policy.unwrap_or(LimitPolicy::Warn);
377    let mut decision = WipDecision {
378        outcome: match policy {
379            LimitPolicy::Warn => WipOutcome::Warn,
380            LimitPolicy::Block => WipOutcome::Block,
381        },
382        from: from.clone(),
383        to: to.clone(),
384        limit: Some(limit.clone()),
385        state_before,
386        state_after,
387        usage: Some(usage.clone()),
388        diagnostics: vec![WipDiagnostic::new(
389            WipDiagnosticKind::OverLimit,
390            format!(
391                "`{to}` would hold {} against a limit of {} ({})",
392                describe_after(&usage, add_estimate),
393                describe_limit(&limit),
394                policy.as_str()
395            ),
396        )],
397        history: None,
398    };
399
400    if policy == LimitPolicy::Warn {
401        return decision;
402    }
403
404    // A blocking limit: only a complete, current override carries it.
405    let Some(over) = request.over else {
406        decision.diagnostics.push(
407            WipDiagnostic::new(
408                WipDiagnosticKind::OverrideRequired,
409                format!("`{to}` is configured to block; an authorized override is required"),
410            )
411            .because("nothing was changed — supply a confirmed override to proceed"),
412        );
413        return decision;
414    };
415    let findings = over.findings(&to, &limit);
416    if !findings.is_empty() {
417        decision.outcome = WipOutcome::Rejected;
418        decision.diagnostics = findings;
419        return decision;
420    }
421
422    decision.outcome = WipOutcome::Override;
423    decision.history = Some(HistoryIntent {
424        kind: HistoryEventKind::TransitionAccepted,
425        actor: over.actor.clone(),
426        timestamp: over.timestamp.clone(),
427        detail: format!(
428            "wip override: `{from}` -> `{to}` against {}; {} -> {}: {}",
429            super::r#override::BoundLimit::of(&to, &limit).describe(),
430            usage.cell(),
431            describe_after(&usage, add_estimate),
432            over.reason.trim()
433        ),
434    });
435    decision
436}
437
438/// The target column's figures **after** the move, as text.
439fn describe_after(usage: &ColumnUsage, add_estimate: u32) -> String {
440    let items = usage.items.saturating_add(1);
441    match usage.max_estimate() {
442        Some(max) => format!(
443            "{items} cards, {}/{}",
444            usage
445                .estimate
446                .unwrap_or_default()
447                .saturating_add(add_estimate),
448            max
449        ),
450        None => format!("{items} cards"),
451    }
452}
453
454/// The limit as text.
455fn describe_limit(limit: &ColumnLimit) -> String {
456    let mut parts = Vec::new();
457    if let Some(max) = limit.max_items {
458        parts.push(format!("max_items {max}"));
459    }
460    if let Some(max) = limit.max_estimate {
461        match limit.unit.as_deref() {
462            Some(unit) => parts.push(format!("max_estimate {max}{unit}")),
463            None => parts.push(format!("max_estimate {max}")),
464        }
465    }
466    parts.join(", ")
467}