tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
//! The **one** move decision every surface asks (mission
//! `configurable-wip-limits-01M3C6WX`, WP03 T012-T016).
//!
//! # One decision, or the feature is a suggestion
//!
//! C-006 says a surface-local WIP bypass is a defect. The way to make that true is
//! to have exactly one function that answers "may this move happen?", with no
//! second copy of the rule in a command, a TUI handler or an MCP tool: those call
//! [`evaluate`] and act on the [`WipDecision`] it returns. This module therefore
//! does not write, read a file, read a clock or know what a keystroke is.
//!
//! # The order, and why it is this order
//!
//! 1. **The transition first** — [`workflow::validate_transition`], untouched. #183
//!    owns what a column *means* and which moves are legal (C-002); WIP is an
//!    additional question asked of a legal move, never a substitute for asking it.
//!    A refused transition is [`WipOutcome::Rejected`] with #183's own diagnostic.
//! 2. **A same-column move is a no-op** — #183 already says so (`changed: false`).
//!    It returns [`WipOutcome::Allow`] immediately: a move that moves nothing must
//!    not warn, must not block, and must not append an override event.
//! 3. **The limit and its unit** — from the board's configuration, resolved once
//!    through the aggregate snapshot, which is also where the before/after figures
//!    come from. The moving issue's own estimate is converted into the limit's unit;
//!    an estimate that cannot be counted **rejects** the move rather than counting
//!    as zero (C-003).
//! 4. **The configured policy** — within the limit is `allow`; over it is `warn` or
//!    `block` according to the configuration, never according to a default.
//!
//! # What an outcome means
//!
//! | outcome | may the caller write? | History |
//! |---|---|---|
//! | `allow` | yes | none — a move within the limit is not a decision |
//! | `warn` | yes | none — a warning is not an override (C-005 names overrides) |
//! | `block` | **no** | none — nothing was decided |
//! | `override` | yes | exactly one event, carrying actor/time/reason/from/to/limit/before/after |
//! | `rejected` | **no** | none |
//!
//! `block` is the refusal a caller can *try to lift*: it carries
//! [`WipDecision::requires_override`] and the exact [`BoundLimit`](super::r#override::BoundLimit)
//! an override must be authorized against. A caller that supplies that override
//! gets `override` on the same move; a caller that supplies an incomplete or stale
//! one gets `rejected` — inert, and named field by field.

use crate::config::Config;
use crate::model::{HistoryEventKind, Issue};
use crate::workflow::{self, TransitionOutcome};

use super::aggregate::{ColumnUsage, LimitState, WipSnapshot};
use super::config::{ColumnLimit, Estimate, LimitPolicy, WipDiagnostic, WipDiagnosticKind};
use super::r#override::OverrideRequest;

/// What a surface may do with a move.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum WipOutcome {
    /// The move is within every limit (or there is no limit): write it.
    Allow,
    /// The move is over a limit configured to warn: write it and report.
    Warn,
    /// The move is over a limit configured to block, and no override was supplied.
    /// Nothing may be written; an authorized override would carry it.
    Block,
    /// The move is over a blocking limit and a complete, current override carried
    /// it. The one History event is in [`WipDecision::history`].
    Override,
    /// The transition was refused, the estimate could not be counted, or the
    /// override was incomplete or stale. Nothing may be written.
    Rejected,
}

impl WipOutcome {
    /// The stable spelling every surface prints and every test asserts.
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Allow => "allow",
            Self::Warn => "warn",
            Self::Block => "block",
            Self::Override => "override",
            Self::Rejected => "rejected",
        }
    }

    /// Whether the caller may write the move.
    pub const fn is_committable(self) -> bool {
        matches!(self, Self::Allow | Self::Warn | Self::Override)
    }

    /// Whether this refusal is one an authorized override could lift.
    pub const fn requires_override(self) -> bool {
        matches!(self, Self::Block)
    }
}

/// What the policy needs to decide: the configuration and the issues in hand.
///
/// Both are already loaded by the caller, which is what keeps this service
/// filesystem-free — and what makes a decision reproducible in a test without a
/// repository.
pub struct MoveContext<'a> {
    /// The project configuration (the board, and #183's policy it carries).
    pub config: &'a Config,
    /// The issues as read, so the aggregate is the one every surface sees.
    pub issues: &'a [Issue],
}

impl<'a> MoveContext<'a> {
    /// A context over already-loaded state.
    pub const fn new(config: &'a Config, issues: &'a [Issue]) -> Self {
        Self { config, issues }
    }
}

/// One move, as a surface asks for it.
pub struct MoveRequest<'a> {
    /// The issue being moved.
    pub issue: &'a Issue,
    /// The column it is being moved to.
    pub to: &'a str,
    /// A reason for a **transition** override (#183's own gate, e.g. leaving a
    /// terminal column). Separate from the WIP override below: they are two
    /// different gates, and a caller may need either, both or neither.
    pub transition_override: Option<&'a str>,
    /// An authorized override of a blocking **WIP** limit, when the caller has one.
    pub over: Option<&'a OverrideRequest>,
}

impl<'a> MoveRequest<'a> {
    /// A move with no override of either kind.
    pub const fn new(issue: &'a Issue, to: &'a str) -> Self {
        Self {
            issue,
            to,
            transition_override: None,
            over: None,
        }
    }

    /// Attach a reason for #183's own transition gate.
    pub const fn because(mut self, reason: &'a str) -> Self {
        self.transition_override = Some(reason);
        self
    }

    /// Attach a WIP override.
    pub const fn overriding(mut self, over: &'a OverrideRequest) -> Self {
        self.over = Some(over);
        self
    }
}

/// The History entry an applied override writes.
///
/// An **intent**, not a write: the caller appends it through the existing grammar
/// ([`crate::model::History::append_now`] or its `at`-stamped sibling), so there is
/// no second audit log and the service stays inert (T016, C-005).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HistoryIntent {
    /// The existing #169 event kind an override is recorded as.
    pub kind: HistoryEventKind,
    /// Who overrode, as the request stated it.
    pub actor: String,
    /// When, as the request stated it.
    pub timestamp: String,
    /// The one sentence: reason, columns, limit and the before/after figures.
    pub detail: String,
}

/// The decision.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WipDecision {
    /// What the caller may do.
    pub outcome: WipOutcome,
    /// The column the issue is in.
    pub from: String,
    /// The column the move targets.
    pub to: String,
    /// The limit in force for the target column, when it has one.
    pub limit: Option<ColumnLimit>,
    /// The target column's state before the move.
    pub state_before: LimitState,
    /// The target column's state **if** the move happens.
    pub state_after: LimitState,
    /// The target column's usage, when it has a limit.
    pub usage: Option<ColumnUsage>,
    /// Everything that made the decision what it is.
    pub diagnostics: Vec<WipDiagnostic>,
    /// The one History entry an applied override writes; `None` otherwise.
    pub history: Option<HistoryIntent>,
}

impl WipDecision {
    /// Whether an authorized override could carry this refusal.
    pub const fn requires_override(&self) -> bool {
        self.outcome.requires_override()
    }

    /// The outcome's stable spelling.
    pub const fn as_str(&self) -> &'static str {
        self.outcome.as_str()
    }

    /// The [`BoundLimit`](super::r#override::BoundLimit) an override must be
    /// authorized against, when this is a `block`.
    pub fn bound_limit(&self) -> Option<super::r#override::BoundLimit> {
        self.limit
            .as_ref()
            .map(|limit| super::r#override::BoundLimit::of(&self.to, limit))
    }

    /// Whether the caller may write the move.
    pub const fn is_committable(&self) -> bool {
        self.outcome.is_committable()
    }
}

/// Judge one move (T012).
#[allow(clippy::too_many_lines)]
pub fn evaluate(context: &MoveContext<'_>, request: &MoveRequest<'_>) -> WipDecision {
    let from = request.issue.status.display().to_string();
    let to = request.to.to_string();
    let allow = |state: LimitState, diagnostics| WipDecision {
        outcome: WipOutcome::Allow,
        from: from.clone(),
        to: to.clone(),
        limit: None,
        state_before: state,
        state_after: state,
        usage: None,
        diagnostics,
        history: None,
    };

    // 1. The transition, through #183's own service (C-002).
    let (_, resolution) = workflow::active_resolution(context.config);
    match workflow::validate_transition(
        request.issue,
        request.to,
        &resolution,
        context.config,
        request.transition_override,
    ) {
        TransitionOutcome::Rejected(diagnostic) => {
            return WipDecision {
                outcome: WipOutcome::Rejected,
                from,
                to,
                limit: None,
                state_before: LimitState::Within,
                state_after: LimitState::Within,
                usage: None,
                diagnostics: vec![WipDiagnostic::new(
                    WipDiagnosticKind::RejectedTransition,
                    diagnostic.message,
                )],
                history: None,
            };
        }
        // 2. A same-column move decides nothing and therefore warns and blocks
        //    nothing: #183 already calls it permitted and idempotent.
        TransitionOutcome::Accepted { changed: false, .. } => {
            return allow(LimitState::Within, Vec::new());
        }
        TransitionOutcome::Accepted { .. } => {}
    }

    // 3. The limit and its unit, from the one shared snapshot.
    let snapshot = WipSnapshot::build(context.config.default_config(), context.issues);
    let Some(usage) = snapshot.column(&to).cloned() else {
        // A column the board does not configure. #183 would have refused it above,
        // so this is unreachable in practice; treating it as "no limit" keeps the
        // policy from inventing a refusal #183 did not make.
        return allow(LimitState::Within, Vec::new());
    };
    let Some(limit) = usage.limit.clone() else {
        return allow(LimitState::Within, Vec::new());
    };

    // The moving issue's own contribution: one card, and the estimate it states
    // **counted in the limit's unit**. An estimate that cannot be counted rejects
    // the move (T014) — never zero, never a guess.
    let unit = usage.unit().unwrap_or_default().to_string();
    let mut diagnostics = Vec::new();
    let add_estimate = match request
        .issue
        .estimate
        .as_deref()
        .map(str::trim)
        .filter(|raw| !raw.is_empty())
    {
        None => 0,
        Some(raw) => match Estimate::parse_terms(raw).and_then(|terms| {
            context
                .config
                .default_config()
                .wip
                .convert_terms(&terms, &unit)
        }) {
            Ok(value) => value,
            Err(error) => {
                let first = error.first();
                diagnostics.push(
                    WipDiagnostic::new(
                        first.kind,
                        format!(
                            "issue #{} states `{raw}`, which this board cannot count in \
                             `{unit}`: {}",
                            request.issue.number, first.message
                        ),
                    )
                    .because(
                        "the move is refused rather than counted as zero: an uncountable \
                         estimate must not pass a limit silently (C-003)",
                    ),
                );
                return WipDecision {
                    outcome: WipOutcome::Rejected,
                    from,
                    to,
                    limit: Some(limit),
                    state_before: usage.state(),
                    state_after: LimitState::Unknown,
                    usage: Some(usage),
                    diagnostics,
                    history: None,
                };
            }
        },
    };

    let state_before = usage.state();
    let state_after = usage.prospective(1, add_estimate);

    // An aggregate nobody can trust is a refusal, not a pass: WP02 already made the
    // column's own state `Unknown` in this case, so the move inherits it.
    if state_after == LimitState::Unknown {
        diagnostics.push(
            WipDiagnostic::new(
                WipDiagnosticKind::AmbiguousConversion,
                format!(
                    "`{to}` has an estimate ceiling of {} that cannot be compared against its \
                     current contents",
                    limit.max_estimate.unwrap_or_default()
                ),
            )
            .because("fix the uncountable estimate first; the move is refused meanwhile"),
        );
        return WipDecision {
            outcome: WipOutcome::Rejected,
            from,
            to,
            limit: Some(limit),
            state_before,
            state_after,
            usage: Some(usage),
            diagnostics,
            history: None,
        };
    }

    // 4. Within the limit: allowed, and deliberately not an event (C-004/C-005).
    if state_after == LimitState::Within {
        return WipDecision {
            outcome: WipOutcome::Allow,
            from,
            to,
            limit: Some(limit),
            state_before,
            state_after,
            usage: Some(usage),
            diagnostics,
            history: None,
        };
    }

    // Over the limit: the configuration decides whether that warns or blocks.
    let policy = limit.policy.unwrap_or(LimitPolicy::Warn);
    let mut decision = WipDecision {
        outcome: match policy {
            LimitPolicy::Warn => WipOutcome::Warn,
            LimitPolicy::Block => WipOutcome::Block,
        },
        from: from.clone(),
        to: to.clone(),
        limit: Some(limit.clone()),
        state_before,
        state_after,
        usage: Some(usage.clone()),
        diagnostics: vec![WipDiagnostic::new(
            WipDiagnosticKind::OverLimit,
            format!(
                "`{to}` would hold {} against a limit of {} ({})",
                describe_after(&usage, add_estimate),
                describe_limit(&limit),
                policy.as_str()
            ),
        )],
        history: None,
    };

    if policy == LimitPolicy::Warn {
        return decision;
    }

    // A blocking limit: only a complete, current override carries it.
    let Some(over) = request.over else {
        decision.diagnostics.push(
            WipDiagnostic::new(
                WipDiagnosticKind::OverrideRequired,
                format!("`{to}` is configured to block; an authorized override is required"),
            )
            .because("nothing was changed — supply a confirmed override to proceed"),
        );
        return decision;
    };
    let findings = over.findings(&to, &limit);
    if !findings.is_empty() {
        decision.outcome = WipOutcome::Rejected;
        decision.diagnostics = findings;
        return decision;
    }

    decision.outcome = WipOutcome::Override;
    decision.history = Some(HistoryIntent {
        kind: HistoryEventKind::TransitionAccepted,
        actor: over.actor.clone(),
        timestamp: over.timestamp.clone(),
        detail: format!(
            "wip override: `{from}` -> `{to}` against {}; {} -> {}: {}",
            super::r#override::BoundLimit::of(&to, &limit).describe(),
            usage.cell(),
            describe_after(&usage, add_estimate),
            over.reason.trim()
        ),
    });
    decision
}

/// The target column's figures **after** the move, as text.
fn describe_after(usage: &ColumnUsage, add_estimate: u32) -> String {
    let items = usage.items.saturating_add(1);
    match usage.max_estimate() {
        Some(max) => format!(
            "{items} cards, {}/{}",
            usage
                .estimate
                .unwrap_or_default()
                .saturating_add(add_estimate),
            max
        ),
        None => format!("{items} cards"),
    }
}

/// The limit as text.
fn describe_limit(limit: &ColumnLimit) -> String {
    let mut parts = Vec::new();
    if let Some(max) = limit.max_items {
        parts.push(format!("max_items {max}"));
    }
    if let Some(max) = limit.max_estimate {
        match limit.unit.as_deref() {
            Some(unit) => parts.push(format!("max_estimate {max}{unit}")),
            None => parts.push(format!("max_estimate {max}")),
        }
    }
    parts.join(", ")
}