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
//! The confirmed apply path for a field patch (WP03).
//!
//! Three things make this different from every other write in the codebase, and
//! each is a rule rather than a preference:
//!
//! ## An absent confirmation is refused before any filesystem operation
//!
//! Not "before the first write" — before the first **read**, before `Store` is
//! touched at all. A refusal that has already enumerated the board has already
//! leaked what the caller could see, and a caller that got the plan wrong should
//! learn that from the refusal alone. [`crate::bulk::plan::BatchPlan::confirmation`]
//! being `None` is therefore checked first, and the check is the first statement
//! in [`apply_fields`].
//!
//! ## An item is all-or-nothing, a batch is not
//!
//! `contracts/operation-outcomes.md`: a confirmation "authorizes independent item
//! processing; it is not an all-or-nothing transaction". So a failure on item 7 of
//! 200 is reported and the batch continues — and there is deliberately **no
//! rollback**, because a rollback is a second write policy the contract does not
//! describe and that would have to be correct in the presence of the failures it
//! was written to undo. There is also no silent retry: [`write_failures`] is the
//! report, and the count of items that failed is printed rather than absorbed.
//!
//! Within one item the order is the single-issue order, and the reason is
//! documented at the write site: a field write and a status move are two different
//! records, and a `--status` move re-reads the file it writes.
//!
//! ## A no-op writes nothing and appends nothing
//!
//! Not "appends nothing important" — writes **no bytes at all**, so `updated` is
//! not bumped either, matching [`Store::set_status`]. The comparison is against the
//! **final** state, computed before the first write, because comparing field by
//! field as each is applied would make a half-applied item look like a no-op after
//! its first write.

use crate::bulk::plan::{BatchPlan, ItemDiagnostic};
use crate::bulk::{IssuePatch, PatchValue};
use crate::error::{Result, TixError};
use crate::model::HistoryEventKind;
use crate::storage::Store;

/// What happened to one item.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ItemOutcome {
    /// The requested final state was written.
    Applied {
        /// The path the file is at now, which differs from the old one after a
        /// reassign.
        filename: String,
    },
    /// The requested final state was already present. Nothing was written and
    /// nothing was appended.
    ///
    /// A distinct outcome rather than a variant of `Applied`, because
    /// `contracts/operation-outcomes.md` says a retry "is explicit and reports the
    /// no-op; it is never silent" — so a caller has to be able to *count* these
    /// separately from the writes.
    NoOp,
    /// Refused at plan time. Nothing was attempted.
    Rejected(String),
    /// The write failed. Whatever succeeded before it stands; nothing is undone.
    Failed { item: ItemDiagnostic, error: String },
}

impl ItemOutcome {
    /// The one-line form a surface prints.
    pub fn as_line(&self, number: u32, title: &str) -> String {
        match self {
            Self::Applied { filename } => {
                format!("#{number} {title} -> applied ({filename})")
            }
            Self::NoOp => format!("#{number} {title} -> already in that state"),
            Self::Rejected(why) => format!("#{number} {title} -> refused: {why}"),
            Self::Failed { item, error } => {
                format!("#{number} {title} -> failed ({}): {error}", item.field)
            }
        }
    }

    pub fn is_applied(&self) -> bool {
        matches!(self, Self::Applied { .. })
    }

    /// The shared status word (`NFR-003`).
    ///
    /// **A method, not a match in each surface.** `NFR-003` requires CLI, TUI and MCP
    /// to expose equivalent per-item statuses, and three `match` blocks over this enum
    /// is three places for a fourth surface — or a rename — to disagree. The words are
    /// the ones `bulk::metadata::OUTCOME_STATUSES` lists, and a case asserts the two
    /// agree, so the vocabulary has exactly one definition.
    pub fn status(&self) -> &'static str {
        match self {
            Self::Applied { .. } => "applied",
            Self::NoOp => "no-op",
            Self::Rejected(_) => "rejected",
            Self::Failed { .. } => "failed",
        }
    }
}

/// The result of a whole apply.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApplyReport {
    pub outcomes: Vec<(u32, ItemOutcome)>,
    /// Items whose write failed, so a caller can report them without scanning.
    pub write_failures: Vec<ItemDiagnostic>,
}

impl ApplyReport {
    pub fn applied(&self) -> usize {
        self.outcomes.iter().filter(|(_, o)| o.is_applied()).count()
    }

    pub fn noop(&self) -> usize {
        self.outcomes
            .iter()
            .filter(|(_, o)| matches!(o, ItemOutcome::NoOp))
            .count()
    }

    pub fn rejected(&self) -> usize {
        self.outcomes
            .iter()
            .filter(|(_, o)| matches!(o, ItemOutcome::Rejected(_)))
            .count()
    }

    pub fn failed(&self) -> usize {
        self.write_failures.len()
    }

    /// The one-line summary, with every count named.
    ///
    /// Every number is stated even when it is zero, because a bulk apply that
    /// silently skipped 200 items would otherwise look identical to one that wrote
    /// them.
    pub fn summary(&self) -> String {
        let total = self.outcomes.len();
        let mut parts = vec![
            format!("{} of {total} applied", self.applied()),
            format!("{} already in that state", self.noop()),
            format!("{} refused", self.rejected()),
        ];
        if self.failed() > 0 {
            parts.push(format!("{} write failures", self.failed()));
        }
        parts.join("; ")
    }
}

/// Apply a confirmed plan's field patch.
///
/// T012: refuses an absent confirmation **before any filesystem operation**. T013:
/// every field goes through the same core a single-issue operation uses. T014: the
/// History event is appended only on success, and only the one the seam writes.
/// T015: per-file atomic writes, per-item failure classification, no rollback, no
/// silent retry.
pub fn apply_fields(
    store: &Store,
    plan: &BatchPlan,
    actor: &str,
    override_reason: Option<&str>,
) -> Result<ApplyReport> {
    // ---- The gate. First statement, before `store` is read at all. ----
    if plan.confirmation.is_none() || !plan.confirmation_is_valid() {
        return Err(TixError::InvalidInput(
            "a bulk apply needs a confirmation for exactly this plan — preview it \
             first, and preview again if the selection or the patch changed"
                .to_string(),
        ));
    }
    if plan.patch.relationship.is_some() {
        return Err(TixError::InvalidInput(
            "this plan carries a relationship operation, which WP04 applies — not \
             the field path"
                .to_string(),
        ));
    }
    if plan.patch.archive.is_some() {
        return Err(TixError::InvalidInput(
            "this plan carries an archive request, which WP05 applies — not the \
             field path"
                .to_string(),
        ));
    }

    let patch = &plan.patch;
    let mut outcomes = Vec::with_capacity(plan.items.len());
    let mut write_failures = Vec::new();

    // WP07 T034/T035 (mission `configurable-wip-limits-01M3C6WX`): the WIP decisions
    // for this batch's status moves, re-judged against the **current** configuration
    // and state, in plan order over a projection.
    //
    // Unlike a plan-time *field* validation — a fact about the bytes the plan read,
    // which this function deliberately does not retry — a limit is configuration:
    // a preview taken before a limit changed is a preview of a different board, and
    // `tix move` and the TUI both re-ask for that reason. So the batch asks again,
    // and an item the policy no longer allows is refused rather than written on a
    // stale promise. There is no batch-wide override: an item that needs one is
    // refused here and moved singly, where the override is explicit and audited.
    let wip_decisions: std::collections::BTreeMap<u32, crate::wip::ItemWip> = match &patch.status {
        crate::bulk::patch::PatchValue::Set(_) => {
            let issues = store.list()?;
            let moves: Vec<(u32, String)> = plan
                .items
                .iter()
                .map(|item| (item.number, target_status(patch)))
                .collect();
            crate::wip::evaluate_batch(
                &store.config,
                &issues,
                &moves,
                &std::collections::BTreeMap::default(),
            )
            .into_iter()
            .map(|item| (item.number, item))
            .collect()
        }
        _ => std::collections::BTreeMap::default(),
    };

    for item in &plan.items {
        // A plan-time refusal is **not retried**. Re-running the validator here
        // would be a second decision on the same inputs, and a bulk apply that
        // re-judges a plan it was handed is a bulk apply with its own policy.
        if let Some(diagnostic) = &item.diagnostic {
            outcomes.push((
                item.number,
                ItemOutcome::Rejected(diagnostic.message.clone()),
            ));
            continue;
        }

        if let Some(wip) = wip_decisions.get(&item.number)
            && !wip.is_committable()
        {
            // Refused with the policy's own sentence, and the items already applied
            // stand: a batch never rolls back.
            outcomes.push((
                item.number,
                ItemOutcome::Rejected(crate::wip::render_decision(&wip.decision)),
            ));
            continue;
        }

        let number = item.number;
        match apply_one(store, patch, number, actor, override_reason) {
            Ok(Outcome::NoOp) => outcomes.push((item.number, ItemOutcome::NoOp)),
            Ok(Outcome::Applied { filename }) => {
                outcomes.push((item.number, ItemOutcome::Applied { filename }))
            }
            // A refusal that only the seam can make — a terminal exit discovered
            // between preview and apply, because the board changed. Reported as a
            // failure, not silently skipped.
            Err((diagnostic, error)) => {
                write_failures.push(diagnostic.clone());
                outcomes.push((
                    number,
                    ItemOutcome::Failed {
                        item: diagnostic,
                        error: error.to_string(),
                    },
                ));
            }
        }
    }

    Ok(ApplyReport {
        outcomes,
        write_failures,
    })
}

/// One item's result, before it is classified.
enum Outcome {
    Applied { filename: String },
    NoOp,
}

/// Apply one item inside exactly one write transaction (#1580).
///
/// `bulk` applies each selected issue in its **own** transaction, never one over
/// the whole batch: a per-item failure must not roll back the siblings (the batch
/// contract in `AGENTS.md`). One item can write up to three times — a reassign
/// rename, the field write, and the status seam — and the window between those
/// writes is what this closes. The item's diagnostic is captured through the
/// transaction and reported exactly as before.
#[allow(clippy::type_complexity)]
fn apply_one(
    store: &Store,
    patch: &IssuePatch,
    number: u32,
    actor: &str,
    override_reason: Option<&str>,
) -> std::result::Result<Outcome, (ItemDiagnostic, TixError)> {
    let mut diagnostic: Option<ItemDiagnostic> = None;
    let result = store.transaction(|| {
        apply_one_body(store, patch, number, actor, override_reason).map_err(|(d, e)| {
            diagnostic = Some(d);
            e
        })
    });
    result.map_err(|e| {
        (
            diagnostic.unwrap_or_else(|| failed_item(number, "write", e.to_string())),
            e,
        )
    })
}

/// The write sequence of one item, run inside [`apply_one`]'s transaction.
#[allow(clippy::type_complexity)]
fn apply_one_body(
    store: &Store,
    patch: &IssuePatch,
    number: u32,
    actor: &str,
    override_reason: Option<&str>,
) -> std::result::Result<Outcome, (ItemDiagnostic, TixError)> {
    let issue = store
        .get(crate::storage::IssueSelector::Number(number))
        .map_err(|e| (failed_item(number, "read", e.to_string()), e))?;
    let fail = |field: &'static str, e: TixError| (failed_item(number, field, e.to_string()), e);

    // ---- The final state, computed before the first write. ----
    let merged = crate::bulk::field_apply::merge_issue(&issue, patch);
    let changes_status = match (&patch.status, &issue.status) {
        (PatchValue::Set(target), current) => current.display() != target,
        _ => false,
    };
    let changes_fields = merged != issue;
    let moves_file = crate::bulk::field_apply::moves_segment(&issue, patch);

    // T014: the no-op test is "nothing would change", and it is made **once**, up
    // front. A per-field comparison as each field is applied would make an item
    // that changed its status and then failed look like a no-op, and would decide
    // the question after bytes had already been written.
    if !changes_status && !changes_fields && !moves_file {
        return Ok(Outcome::NoOp);
    }

    let mut filename = issue.filename.clone();

    // ---- The write, in the single-issue order. ----
    //
    // Reassign first, then the field write at the (possibly new) path, then the
    // status seam. `tix edit` does the same in the same order, and the reason is
    // in `edit.rs`: a field write and a status move are **two different records**,
    // and `set_status` re-reads the file it writes — so writing the fields first
    // means the decision is appended to an issue whose other changes are already
    // saved.
    if moves_file {
        let moved = store
            .reassign(&issue.filename, merged.assignee.as_deref())
            .map_err(|e| fail("assignee", e))?;
        filename = moved.to_string_lossy().to_string();
    }
    if changes_fields {
        let mut written = merged.clone();
        written.filename = filename.clone();
        // `updated` bumps only when the merge actually changed something, the way
        // `tix edit` does it: a reassign on its own moves the file and is not a
        // content edit, so it must not look like one.
        //
        // There is a second `written != issue` guard here, and a first draft
        // wrapped the bump in a *third* check — `if changes_fields` — which is
        // always true inside this block. It was harmless and it was also
        // **unobservable**: a revert of redundant code is a revert of nothing, so
        // the harness case written for it came back green while proving nothing.
        // That is the eighth no-op revert this programme has produced, and the
        // lesson is the same each time — a case needs a revert that changes
        // behaviour, or it is decoration. The guard is gone because it carried no
        // meaning; the case is gone because it could not be made to carry any.
        if written != issue || filename != issue.filename {
            written.bump_updated();
        }
        filename = store
            .update(&filename, &written)
            .map_err(|e| fail("write", e))?;
    }
    if changes_status {
        let target = match &patch.status {
            PatchValue::Set(t) => t.clone(),
            _ => unreachable!("changes_status is only true for a Set"),
        };
        // The seam: the transition is judged again, the completion coupling is
        // applied, and exactly one `transition_accepted` is appended. A bulk apply
        // does not re-implement any of that, and a refusal here is a *failure* of
        // this item, not a silent skip.
        let outcome = store
            .set_status(
                &filename,
                crate::storage::StatusChange::to(&target).with_override(override_reason),
            )
            .map_err(|e| fail("status", e))?;
        filename = outcome.issue.filename.clone();
        let _ = actor;
    }

    Ok(Outcome::Applied { filename })
}

/// The status this patch sets, for the WIP projection.
///
/// Only ever called when the patch sets a status, so the `Set` arm is the one that
/// runs; the fallback exists so a future patch shape cannot make this panic in a
/// function whose whole job is to avoid surprises.
fn target_status(patch: &IssuePatch) -> String {
    match &patch.status {
        crate::bulk::patch::PatchValue::Set(target) => target.clone(),
        _ => String::new(),
    }
}

fn failed_item(number: u32, field: &'static str, message: String) -> ItemDiagnostic {
    ItemDiagnostic {
        issue: number,
        field,
        message,
    }
}

/// The single History kind an apply can append, if it appends one.
///
/// A pure function of the patch, so a surface can render "this apply will append 1
/// History event" with no plan and no store — and so a test can assert the claim
/// for **every** patch shape rather than one fixture.
///
/// It is `Option` rather than a count because the answer is a vocabulary, and a
/// count would let a second kind be added without anything noticing. There is one:
/// the transition seam's. A field write goes through `Store::update` and appends
/// nothing, which is what the single-issue path does too.
pub fn history_kind_for(patch: &IssuePatch) -> Option<HistoryEventKind> {
    match patch.status {
        PatchValue::Set(_) => Some(HistoryEventKind::TransitionAccepted),
        _ => None,
    }
}

/// How many History entries an apply of this patch would append.
pub fn predicted_history_events(patch: &IssuePatch) -> usize {
    usize::from(history_kind_for(patch).is_some())
}

/// Whether this module knows how to apply the patch, and says so rather than
/// doing half of it.
///
/// WP03 owns the field path, WP04 the pair, WP05 the archive. A plan carrying a
/// pair must be **refused** here, not partially applied — which is why the check
/// lives in [`apply_fields`] and is not left to the caller. Exposed as a predicate
/// so a surface can route a plan to the right work package before it starts.
pub fn owns(patch: &IssuePatch) -> bool {
    crate::bulk::field_apply::is_field_path(patch)
}

// ---------------------------------------------------------------------------
// Re-exported so a caller writes `bulk::apply::merge_issue` rather than reaching
// into `field_apply`, which is where the merge lives.
// ---------------------------------------------------------------------------

pub use crate::bulk::field_apply::{
    FieldOutcome, destination_path, field_outcomes, merge_issue, moves_segment,
};