tix-tracker 0.11.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
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
//! The batch plan: a read-only preview of what a bulk operation would do
//! (WP02, T007–T010).
//!
//! Everything here is **pure with respect to the store**. It reads issue files and
//! the config; it writes nothing — no file, no relationship, no archive, no History
//! event. `contracts/patch-plan.md` §3 requires a plan to be cancellable with no
//! change of any kind, and the only way that is guaranteed rather than hoped for is
//! if the code that builds the preview is the same code that has no write path.
//!
//! ## What a plan is not
//!
//! It is not a transaction and it is not a partial commit. Contract §4: a
//! confirmation "authorizes independent item processing; it is not an
//! all-or-nothing transaction". So a plan carries a per-item outcome and a plan
//! whose items partly conflict is **normal**, not a failure — three issues that move
//! to `wip` and one that cannot is a preview with one diagnostic, not a rejected
//! operation.
//!
//! ## Validation is borrowed, never restated
//!
//! T007 asks for "the same cores as single-issue operations", and the point is not
//! politeness: a bulk validator that re-implemented the date rule would be a
//! second definition that drifts. So [`crate::model::validate_issue_dates`],
//! `Priority::from_str`, the workflow transition service and the typed relationship
//! service are **called**, and this module adds only the thing a bulk plan has and
//! a single edit does not — which item the problem belongs to.

use std::collections::BTreeMap;

use crate::bulk::{ArchiveRequest, IssuePatch, PatchValue, RelationshipOp, Selection};
use crate::error::Result;
use crate::model::Issue;

/// One field's proposed change on one issue.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FieldChange {
    /// The field would take a new value.
    Set { from: String, to: String },
    /// The field would be emptied.
    Clear { from: String },
    /// The field already holds the requested value, so nothing is written for it.
    ///
    /// A **distinct variant**, not a missing entry. The data-model's rule is "a patch
    /// that is already the requested final state is a no-op and produces no
    /// duplicate History event", and the way that is enforced is by *knowing* it —
    /// a plan that merely omitted the field could not distinguish "no-op" from
    /// "not considered", and the History intent below depends on the difference.
    NoChange { value: String },
}

impl FieldChange {
    /// The name of the field, for a rendering line.
    pub fn field(&self) -> &'static str {
        match self {
            Self::Set { .. } | Self::Clear { .. } | Self::NoChange { .. } => {
                unreachable!("the field name is carried by the caller's key")
            }
        }
    }

    /// Whether this change writes anything.
    pub fn writes(&self) -> bool {
        matches!(self, Self::Set { .. } | Self::Clear { .. })
    }

    /// The change as `(from, to)`, for a surface that projects a plan to a
    /// structured value.
    ///
    /// A method rather than a match in each surface, for the same reason
    /// `ItemOutcome::status` is one: `surface-parity.md` §2 requires every surface
    /// to expose the same "proposed before/after values", and three matches over
    /// these variants is three places for a fourth surface — or a new variant — to
    /// disagree about what a `Clear`'s `to` is.
    ///
    /// `Clear`'s `to` is the **empty string**, not `null` and not a missing key, so a
    /// client reading `to` unconditionally gets a string for every field. A `NoChange`
    /// reports `from == to`, which is the truth: the value would not move.
    pub fn as_from_to(&self) -> (String, String) {
        match self {
            Self::Set { from, to } => (from.clone(), to.clone()),
            Self::Clear { from } => (from.clone(), String::new()),
            Self::NoChange { value } => (value.clone(), value.clone()),
        }
    }
}

/// Why one item cannot be applied, with the item it belongs to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ItemDiagnostic {
    /// The issue number, so a message says which item rather than "one of them".
    pub issue: u32,
    /// The field or operation, as the contract spells it.
    pub field: &'static str,
    pub message: String,
}

impl std::fmt::Display for ItemDiagnostic {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "#{}: {}", self.issue, self.message)
    }
}

/// A History entry the apply **intends** to append. Not an event, and not an
/// `HistoryEvent` — constructing one would be the second History system contract
/// §7 prohibits, and appending one is the thing a plan must not do.
///
/// ## The vocabulary is the existing one, and it is short
///
/// The only kind a plan can currently predict is `transition_accepted`, because
/// the only History event a bulk operation can cause is the one the **status
/// seam** writes. A field change appends nothing, because the single-issue path
/// appends nothing — `tix edit --estimate` does not write a History line, and
/// `HistoryEventKind` has no field-update variant to write one under.
///
/// So "this plan appends no History" is a *prediction about an absence*, and the
/// plan reports it as the empty list rather than inventing a name. The apply
/// reaches the same answer from the other direction: the field write goes through
/// `Store::update` and the transition goes through `Store::set_status`, and only
/// the latter appends.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HistoryIntent {
    /// A [`crate::model::HistoryEventKind::as_str`] spelling. A `&'static str` and
    /// not the enum, because a plan must not be able to *construct* one — but it
    /// must be checked against the enum's spelling by the apply, and a test pins
    /// that every kind a plan emits is one the model can actually produce.
    pub kind: &'static str,
    pub detail: String,
}

/// Where a file would move to, when the patch changes the assignee.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathChange {
    pub from: String,
    pub to: String,
}

/// One item's proposed outcome.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ItemPlan {
    pub number: u32,
    pub title: String,
    /// The field changes, keyed by the contract's field name.
    pub changes: BTreeMap<&'static str, FieldChange>,
    /// The file's destination, when the assignee change moves it.
    pub path_change: Option<PathChange>,
    /// The History entries this item would gain. Empty when nothing changes, which
    /// is what makes a no-op produce no duplicate event.
    pub history: Vec<HistoryIntent>,
    /// The problem, if this item cannot be applied.
    pub diagnostic: Option<ItemDiagnostic>,
    /// The WIP decision for this item's status move, when it makes one
    /// (mission `configurable-wip-limits-01M3C6WX`, WP07 T035).
    ///
    /// Evaluated **in plan order over a projection** — the state each item would
    /// see once the items before it landed — so the fourth item of four into a
    /// column capped at three is the one reported as blocked. A plan that
    /// evaluated every item against the same starting state would promise
    /// something the apply cannot deliver.
    pub wip: Option<crate::wip::ItemWip>,
}

impl ItemPlan {
    /// Whether this item would write anything at all.
    ///
    /// A `## History` entry counts, because it **is** a write: a plan decides
    /// what the apply puts in the file, and an appended line is bytes in that
    /// file. The first version asked only about field and path changes, which is
    /// right for #173 — there a history intent exists only alongside a field
    /// change, so the two questions always had the same answer — and wrong for a
    /// triage decision, which appends an event and may change no field at all: a
    /// duplicate adds an edge and touches no frontmatter, and a decline back
    /// into the entry column clears nothing. Such an item reported `no-op` while
    /// carrying the event the apply would append.
    pub fn is_actionable(&self) -> bool {
        self.diagnostic.is_none()
            && (self.changes.values().any(FieldChange::writes)
                || self.path_change.is_some()
                || !self.history.is_empty())
    }

    /// Whether this item is already in the requested state.
    pub fn is_noop(&self) -> bool {
        !self.is_actionable() && self.diagnostic.is_none()
    }

    /// The shared status word for a **planned** item (`NFR-003`).
    ///
    /// The same four words as `ItemOutcome::status`, and derived from the same two
    /// predicates, so a preview and the apply that follows it cannot describe the same
    /// item two different ways. The mapping is the obvious one and that is the point:
    /// a plan that would write is `applied` in the report, a plan that would write
    /// nothing and has no diagnostic is `no-op`, and a plan with a diagnostic is
    /// `rejected` before anything is attempted.
    pub fn status(&self) -> &'static str {
        if self.diagnostic.is_some() {
            "rejected"
        } else if self.is_actionable() {
            "applied"
        } else {
            "no-op"
        }
    }
}

/// A read-only preview. Cancelling one changes nothing, by construction.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BatchPlan {
    /// The exact selection this plan was built from, so an apply can check that
    /// the thing it was authorized for is the thing in front of it.
    pub selection: Selection,
    pub patch: IssuePatch,
    pub items: Vec<ItemPlan>,
    /// The relationship change this plan proposes, if the patch named a pair.
    pub pair: Option<PairPlan>,
    /// The archive request this plan carries, **unapplied**.
    pub archive: Option<ArchiveRequest>,
    /// The confirmation, absent until one is issued. `None` is the ordinary state;
    /// see [`Confirmation`].
    pub confirmation: Option<Confirmation>,
}

impl BatchPlan {
    /// The items that would be written.
    pub fn actionable(&self) -> impl Iterator<Item = &ItemPlan> {
        self.items.iter().filter(|i| i.is_actionable())
    }

    /// The items that cannot be applied.
    pub fn blocked(&self) -> impl Iterator<Item = &ItemPlan> {
        self.items.iter().filter(|i| i.diagnostic.is_some())
    }

    /// Whether applying this plan would change anything.
    pub fn is_empty(&self) -> bool {
        self.pair.is_none() && self.archive.is_none() && self.actionable().next().is_none()
    }

    /// The one-line summary the CLI prints above the table.
    pub fn summary(&self) -> String {
        let actionable = self.actionable().count();
        let blocked = self.blocked().count();
        let noop = self.items.iter().filter(|i| i.is_noop()).count();
        // An archive or a pair is an operation on the **selection**, not a per-item
        // field change, so the items are not subjects of it. The first version printed
        // their counts anyway, and an archive preview read "0 of 2 would change; 2
        // already in that state; 1 issue would be archived" — three claims, one of
        // which ("2 already in that state") describes items the plan never proposed
        // to change.
        //
        // So when the patch names no field, the item counts are omitted entirely and
        // the operation is the whole summary. The counts are not zeroed: a reader
        // would have to guess whether there were two items that were all no-ops, or
        // whether the plan had no items at all.
        if !self.patch.has_any_field() {
            let mut parts = Vec::new();
            if let Some(pair) = &self.pair {
                parts.push(format!("1 relationship would {}", pair.verb()));
            }
            if let Some(archive) = &self.archive {
                let count = self.selection.len();
                parts.push(format!(
                    "{count} issue{} would {}",
                    if count == 1 { "" } else { "s" },
                    if archive.archive {
                        "be archived"
                    } else {
                        "be restored"
                    }
                ));
            }
            if parts.is_empty() {
                parts.push("nothing to do".to_string());
            }
            return parts.join("; ");
        }
        let mut parts = vec![format!("{actionable} of {} would change", self.items.len())];
        if noop > 0 {
            parts.push(format!("{noop} already in that state"));
        }
        if blocked > 0 {
            parts.push(format!("{blocked} refused"));
        }
        if let Some(pair) = &self.pair {
            parts.push(format!("1 relationship would {}", pair.verb()));
        }
        if let Some(archive) = &self.archive {
            let count = self.selection.len();
            let mut line = format!(
                "{count} issue{} would {}",
                if count == 1 { "" } else { "s" },
                if archive.archive {
                    "be archived"
                } else {
                    "be restored"
                }
            );
            // A restore names the column it restores into, because "would be
            // restored" is not actionable and #190 has no "previous status" to
            // restore to (C-003). The **absence** is shown too, so a preview says
            // what is missing rather than what would happen.
            match (&archive.target_status, archive.problem()) {
                (Some(target), _) => line.push_str(&format!(" into '{target}'")),
                (None, Some(problem)) => line.push_str(&format!(" — {problem}")),
                (None, None) => {}
            }
            parts.push(line);
        }
        parts.join("; ")
    }

    /// Whether a confirmation on this plan is still valid.
    ///
    /// A confirmation authorizes a **specific** plan. If either the selection or the
    /// patch has changed, it is a different plan and the old confirmation does not
    /// carry over (contract §4: "any change to selection or patch requires a new
    /// preview").
    pub fn confirmation_is_valid(&self) -> bool {
        match &self.confirmation {
            None => false,
            Some(confirmation) => confirmation.fingerprint == self.selection.fingerprint(),
        }
    }
}

/// A confirmation, present only after one is issued and only for the plan it was
/// issued against.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Confirmation {
    /// The selection membership it was issued against. Membership and not a hash of
    /// the contents, for the reason in [`Selection::fingerprint`].
    pub fingerprint: Vec<u32>,
}

impl Confirmation {
    /// Issue a confirmation for this plan.
    pub fn issue(plan: &BatchPlan) -> Self {
        Self {
            fingerprint: plan.selection.fingerprint(),
        }
    }
}

/// A proposed relationship change, with its targets resolved.
///
/// The `edge` and `identity` are the **canonical** form: `1 blocks 2` and
/// `2 blocked-by 1` are one edge, and a plan that showed the two spellings as two
/// plans would let a user confirm a pair the store then refuses as a duplicate.
/// The `diagnostic` is the structured one, because T019 asks for a *stable* per-pair
/// diagnostic and a message that varies with the wording of a selector is not
/// stable — the message beside it is for a human, the diagnostic is for a test.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PairPlan {
    pub link: bool,
    /// The resolved numbers, or the diagnostic that stopped resolution.
    pub from: std::result::Result<u32, String>,
    pub to: std::result::Result<u32, String>,
    /// The canonical edge, when both endpoints resolved.
    pub edge: Option<crate::relationships::CanonicalEdge>,
    /// Deliberately **no** relationship id.
    ///
    /// `Relationship::new` mints a fresh ULID, so an id built at resolve time is a
    /// *proposal*, not an identity: the two spellings of one edge would carry
    /// different ids, and a caller that tried `tix link edit`/`remove` with a
    /// preview's id would be addressing a row that does not exist. The store mints
    /// the real one at write time and `PairOutcome` reports it.
    ///
    /// A first draft carried `identity: Option<String>` here and on
    /// `PairResolution`, and a test asserting the two spellings share an id failed
    /// — which is the type telling the truth and the test not listening.
    /// The structured refusal, when there is one.
    pub diagnostic: Option<crate::bulk::pair::PairDiagnostic>,
}

impl PairPlan {
    /// A resolved pair, from the module that knows how to resolve one.
    ///
    /// The only way to build a `PairPlan`, on purpose. Hand-building one means
    /// choosing what "resolved" means at the call site, and two call sites choosing
    /// differently is a pair that plans as valid and refuses on apply. WP02's own
    /// tests hand-built them, which is how three closures in two files had to be
    /// edited when WP04 added the canonical edge and the structured diagnostic.
    pub fn resolved(link: bool, edge: crate::relationships::CanonicalEdge) -> Self {
        Self {
            link,
            from: Ok(edge.from),
            to: Ok(edge.to),
            edge: Some(edge),
            diagnostic: None,
        }
    }

    /// A refused pair, with both endpoints reported.
    ///
    /// `from` and `to` keep the shape of a resolved plan — one `Result` per
    /// endpoint — so a renderer does not need a second code path for the refused
    /// case. A self-link resolves both endpoints and fails on the *pair*, so its
    /// `from`/`to` are both `Ok` and only `diagnostic` is set; that is what
    /// distinguishes it from a missing endpoint without a new variant.
    pub fn refused(
        link: bool,
        from: std::result::Result<u32, String>,
        to: std::result::Result<u32, String>,
        diagnostic: crate::bulk::pair::PairDiagnostic,
    ) -> Self {
        Self {
            link,
            from,
            to,
            edge: None,
            diagnostic: Some(diagnostic),
        }
    }

    /// Whether both endpoints resolved and the pair is not self-referential.
    ///
    /// Delegated to the structured diagnostic when there is one, because
    /// `is_resolvable` is a question about the *pair* and the diagnostic is where
    /// the answer is recorded — two places to answer it is one place too many.
    pub fn is_resolvable(&self) -> bool {
        // A self-link is the one refusal where **both** endpoints resolved, so
        // `from`/`to` cannot decide it — hence the diagnostic first. `is_resolvable`
        // is false for it, which is the answer: a plan reporting "resolvable" for
        // `1 -> 1` would promise an apply that cannot happen.
        if self.diagnostic.is_some() {
            return false;
        }
        self.from.is_ok() && self.to.is_ok()
    }

    pub fn verb(&self) -> &'static str {
        if self.link { "link" } else { "unlink" }
    }
}

/// Build a read-only plan. Writes nothing (contract §3).
///
/// `resolve_pair_targets` is a parameter rather than a call so a caller that has no
/// store to resolve against can pass one, and so the **pure** half of this function
/// is testable without a store at all — which is what T011's 10,000-item case does.
pub fn build_plan(
    config: &crate::config::Config,
    issues_dir: &std::path::Path,
    selection: Selection,
    patch: IssuePatch,
    mut resolve_pair_targets: impl FnMut(&RelationshipOp) -> PairPlan,
) -> Result<BatchPlan> {
    patch.require_wellformed()?;

    let items: Vec<ItemPlan> = selection
        .issues()
        .iter()
        .map(|issue| {
            let diagnostic = first_diagnostic(issue, &patch, config);
            let (changes, path_change, history) = propose(issue, &patch, issues_dir);
            ItemPlan {
                number: issue.number,
                title: issue.title.clone(),
                changes,
                path_change,
                history,
                diagnostic,
                // Filled by `crate::wip::bulk::annotate_plan`, which the caller runs
                // with the **store's** issues: a column's usage counts the cards the
                // batch does not touch, and this function only ever sees the
                // selection (WP07 T035).
                wip: None,
            }
        })
        .collect();

    let pair = patch.relationship.as_ref().map(&mut resolve_pair_targets);
    let archive = patch.archive.clone();

    Ok(BatchPlan {
        selection,
        patch,
        items,
        pair,
        archive,
        confirmation: None,
    })
}

// ---------------------------------------------------------------------------
// T007 — shared field validation
// ---------------------------------------------------------------------------

/// The first validation problem for one issue, or `None`.
///
/// **Calls the shared cores** and adds nothing. The contract's rule is that
/// "bulk-specific validation may add item context but must not redefine the
/// underlying rule", so this function's whole job is to call
/// `model::validate_issue_dates`, `Priority::from_str` and the workflow service
/// with the values the patch would produce, and to stop at the first failure.
///
/// Only the **first** problem per item: a plan is a preview, and a preview that
/// lists eleven failures for one issue buries the one the user has to fix. The
/// remaining problems are found when the fix is applied.
fn first_diagnostic(
    issue: &Issue,
    patch: &IssuePatch,
    config: &crate::config::Config,
) -> Option<ItemDiagnostic> {
    let numbers = |field: &'static str, message: String| {
        Some(ItemDiagnostic {
            issue: issue.number,
            field,
            message,
        })
    };

    // Priority — `commands::parse_priority`, the core a single `tix edit --priority`
    // uses. **Not** `Priority::from_str`, which the first version reached for and
    // which is `Infallible`: it maps anything unrecognised to `Other(s)`, so a
    // check written against it can never fire. The strict three-word rule lives in
    // `parse_priority` and nowhere else, and a bulk validator that re-derived it
    // would be the second definition the contract prohibits.
    if let PatchValue::Set(value) = &patch.priority {
        if let Err(e) = crate::commands::parse_priority(&value.to_string()) {
            return numbers("priority", e.to_string());
        }
    }

    // Dates — `validate_issue_dates` is the core, given the values the patch would
    // *produce* rather than the ones the file has now, because a patch that clears
    // `start_date` makes an existing `due_date` legal and one that sets
    // `start_date` can make it illegal.
    let start = produced(&patch.start_date, issue.start_date.as_deref());
    let due = produced(&patch.due_date, issue.due_date.as_deref());
    if let Err(e) = crate::model::validate_issue_dates(start.as_deref(), due.as_deref()) {
        return numbers("dates", e.to_string());
    }

    // Status — the workflow service, judged as a **transition**, because a status
    // patch is a move. `None` is the right override here: a bulk plan is a preview,
    // and a terminal exit with no reason *is* a refusal the user needs to see.
    if let PatchValue::Set(target) = &patch.status {
        let (_, resolution) = crate::workflow::active_resolution(config);
        match crate::workflow::validate_transition(issue, target, &resolution, config, None) {
            crate::workflow::TransitionOutcome::Rejected(diagnostic) => {
                return numbers("status", diagnostic.to_string());
            }
            crate::workflow::TransitionOutcome::Accepted { .. } => {}
        }
    }

    // Assignee — the same normalisation a create uses, so a bulk assignee lands in
    // the same segment a single create would have chosen.
    if let PatchValue::Set(value) = &patch.assignee
        && crate::assignee::parse_assignee(value).is_none()
    {
        return numbers(
            "assignee",
            format!("assignee {value:?} is not a usable handle or identity"),
        );
    }

    None
}

/// The value a patch would leave in a field, given what is there now.
///
/// `Unchanged` keeps the current value; `Clear` produces no value; `Set` produces
/// the new one. Getting this wrong in either direction produces a diagnostic about
/// a field the patch was not touching.
fn produced(patch: &PatchValue<String>, current: Option<&str>) -> Option<String> {
    match patch {
        PatchValue::Unchanged => current.map(str::to_string),
        PatchValue::Set(v) => Some(v.clone()),
        PatchValue::Clear => None,
    }
}

// ---------------------------------------------------------------------------
// T009 — proposed changes, paths, History intents
// ---------------------------------------------------------------------------

/// The field changes, path move and History intents for one issue.
fn propose(
    issue: &Issue,
    patch: &IssuePatch,
    issues_dir: &std::path::Path,
) -> (
    BTreeMap<&'static str, FieldChange>,
    Option<PathChange>,
    Vec<HistoryIntent>,
) {
    let mut changes: BTreeMap<&'static str, FieldChange> = BTreeMap::new();
    let mut path_change = None;
    let mut history = Vec::new();

    // Status. A `NoChange` when the issue is already there, which is what stops a
    // re-apply from appending a second decision.
    if let PatchValue::Set(target) = &patch.status {
        let from = issue.status.display().to_string();
        if from == *target {
            changes.insert("status", FieldChange::NoChange { value: from });
        } else {
            changes.insert(
                "status",
                FieldChange::Set {
                    from: from.clone(),
                    to: target.clone(),
                },
            );
            history.push(HistoryIntent {
                kind: "transition_accepted",
                detail: format!("{from} -> {target}"),
            });
        }
    }

    // Priority is `PatchValue<Priority>` and estimate is `PatchValue<String>`, so
    // they cannot share one array — the first version tried, and the borrow
    // checker refused. Two short loops over the same shape is the smaller cost
    // than a generic helper that would have to erase the field name too.
    // `Priority` is an enum, not a string, and "unset" is `Priority::Other("")` —
    // so the current value is read through `is_undefined` rather than by testing
    // for an empty rendering, which would make `Other("bogus")` render as
    // "bogus" and compare unequal to the `bogus` a patch sets.
    match &patch.priority {
        PatchValue::Unchanged => {}
        value => record_clearing(
            &mut changes,
            "priority",
            if issue.priority.is_undefined() {
                String::new()
            } else {
                issue.priority.to_string()
            },
            render_typed(value),
            matches!(value, PatchValue::Clear),
        ),
    }
    match &patch.estimate {
        PatchValue::Unchanged => {}
        value => record_clearing(
            &mut changes,
            "estimate",
            issue.estimate.clone().unwrap_or_default(),
            render_typed(value),
            matches!(value, PatchValue::Clear),
        ),
    }

    for (name, value, current) in [
        ("assignee", &patch.assignee, issue.assignee.clone()),
        ("start_date", &patch.start_date, issue.start_date.clone()),
        ("due_date", &patch.due_date, issue.due_date.clone()),
    ] {
        match value {
            PatchValue::Unchanged => {}
            PatchValue::Set(new) => record(
                &mut changes,
                name,
                current.unwrap_or_default(),
                new.clone(),
                &mut history,
            ),
            PatchValue::Clear => record_clearing(
                &mut changes,
                name,
                current.unwrap_or_default(),
                String::new(),
                true,
            ),
        }
    }

    // The list-valued fields, rendered the way the frontmatter writes them, so a
    // preview line and a diff agree.
    for (name, value, current) in [
        ("labels", &patch.labels, Some(&issue.labels[..])),
        ("cycles", &patch.cycles, issue.cycles.as_deref()),
        ("milestones", &patch.milestones, issue.milestones.as_deref()),
    ] {
        match value {
            PatchValue::Unchanged => {}
            PatchValue::Set(new) => record(
                &mut changes,
                name,
                render_list(current.unwrap_or_default()),
                render_list(new),
                &mut history,
            ),
            PatchValue::Clear => record_clearing(
                &mut changes,
                name,
                render_list(current.unwrap_or_default()),
                String::new(),
                true,
            ),
        }
    }

    // The assignee move. The **destination segment**, which is where a create would
    // have put the file — not the current directory, which is a path and not a
    // prediction.
    if let PatchValue::Set(new) = &patch.assignee {
        if let Some(segment) = crate::assignee::parse_assignee(new) {
            // The **destination segment**, via the same `derive_segment` a create
            // uses, so a preview line and the file that eventually lands agree.
            let from = issues_dir.join(crate::assignee::derive_segment(issue.assignee.as_deref()));
            let to = issues_dir.join(crate::assignee::derive_segment(Some(&segment)));
            if from != to {
                path_change = Some(PathChange {
                    from: from.display().to_string(),
                    to: to.display().to_string(),
                });
            }
        }
    }

    (changes, path_change, history)
}

/// Record one field change, and the History intent that goes with it — which for a
/// field change is **none**.
///
/// The `history` parameter is `&mut Vec<HistoryIntent>` rather than absent, and it
/// is left untouched here, because the first draft of this function pushed a
/// `field_updated` intent for every changed field. That was **wrong**, and WP03
/// caught it while building the apply:
///
/// - `tix edit <n> --estimate 3d` writes the field and appends **no** History
///   event. Measured, not assumed.
/// - `HistoryEventKind` has no field-update variant. The vocabulary is
///   relationship, parent, blocked-move and transition.
/// - So a bulk field patch appending `field_updated` would be a **bulk-only**
///   History event: a second History vocabulary, which `contracts/patch-plan.md`
///   §7 prohibits and which the contract's §"History and archive" reads the other
///   way — "the appropriate event **from the prerequisite History service**", and
///   for a field change the prerequisite service's appropriate event is none.
///
/// The parameter stays so the shape of the function is the same as the status
/// branch's, and so a future field that *does* have a service has somewhere to
/// push. The doc comment is the record: `record` is where the temptation lives.
fn record(
    changes: &mut BTreeMap<&'static str, FieldChange>,
    name: &'static str,
    from: String,
    to: String,
    _history: &mut Vec<HistoryIntent>,
) {
    record_clearing(changes, name, from, to, false)
}

/// [`record`], told whether the change is an *emptying* rather than a new value.
///
/// A `Clear` used to reach the plan as `Set { to: "" }` — the two are
/// indistinguishable once rendered, and a plan that cannot tell them apart cannot
/// report "this field would be emptied" to a surface that shows before/after values.
/// `FieldChange::Clear` existed for exactly that and was **never constructed**, so the
/// variant was dead and the doc comment on `as_from_to` promised a behaviour nothing
/// could reach. The WP08 revert-and-red run is what found it: a mutation of the
/// `Clear` arm left every test green, because no test could see a `Clear`.
///
/// The distinction matters to a surface and not to the file, which is why it lives
/// here rather than at the apply: the write is identical (`field:` with no value) and
/// only the *report* differs.
fn record_clearing(
    changes: &mut BTreeMap<&'static str, FieldChange>,
    name: &'static str,
    from: String,
    to: String,
    cleared: bool,
) {
    if cleared {
        changes.insert(name, FieldChange::Clear { from });
        return;
    }
    if from == to {
        changes.insert(name, FieldChange::NoChange { value: to });
        return;
    }
    changes.insert(name, FieldChange::Set { from, to });
}

fn render_typed<T: std::fmt::Display>(value: &PatchValue<T>) -> String {
    match value {
        PatchValue::Set(v) => v.to_string(),
        _ => String::new(),
    }
}

fn render_list(values: &[String]) -> String {
    values.join(", ")
}