Skip to main content

tix/bulk/
plan.rs

1//! The batch plan: a read-only preview of what a bulk operation would do
2//! (WP02, T007–T010).
3//!
4//! Everything here is **pure with respect to the store**. It reads issue files and
5//! the config; it writes nothing — no file, no relationship, no archive, no History
6//! event. `contracts/patch-plan.md` §3 requires a plan to be cancellable with no
7//! change of any kind, and the only way that is guaranteed rather than hoped for is
8//! if the code that builds the preview is the same code that has no write path.
9//!
10//! ## What a plan is not
11//!
12//! It is not a transaction and it is not a partial commit. Contract §4: a
13//! confirmation "authorizes independent item processing; it is not an
14//! all-or-nothing transaction". So a plan carries a per-item outcome and a plan
15//! whose items partly conflict is **normal**, not a failure — three issues that move
16//! to `wip` and one that cannot is a preview with one diagnostic, not a rejected
17//! operation.
18//!
19//! ## Validation is borrowed, never restated
20//!
21//! T007 asks for "the same cores as single-issue operations", and the point is not
22//! politeness: a bulk validator that re-implemented the date rule would be a
23//! second definition that drifts. So [`crate::model::validate_issue_dates`],
24//! `Priority::from_str`, the workflow transition service and the typed relationship
25//! service are **called**, and this module adds only the thing a bulk plan has and
26//! a single edit does not — which item the problem belongs to.
27
28use std::collections::BTreeMap;
29
30use crate::bulk::{ArchiveRequest, IssuePatch, PatchValue, RelationshipOp, Selection};
31use crate::error::Result;
32use crate::model::Issue;
33
34/// One field's proposed change on one issue.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum FieldChange {
37    /// The field would take a new value.
38    Set { from: String, to: String },
39    /// The field would be emptied.
40    Clear { from: String },
41    /// The field already holds the requested value, so nothing is written for it.
42    ///
43    /// A **distinct variant**, not a missing entry. The data-model's rule is "a patch
44    /// that is already the requested final state is a no-op and produces no
45    /// duplicate History event", and the way that is enforced is by *knowing* it —
46    /// a plan that merely omitted the field could not distinguish "no-op" from
47    /// "not considered", and the History intent below depends on the difference.
48    NoChange { value: String },
49}
50
51impl FieldChange {
52    /// The name of the field, for a rendering line.
53    pub fn field(&self) -> &'static str {
54        match self {
55            Self::Set { .. } | Self::Clear { .. } | Self::NoChange { .. } => {
56                unreachable!("the field name is carried by the caller's key")
57            }
58        }
59    }
60
61    /// Whether this change writes anything.
62    pub fn writes(&self) -> bool {
63        matches!(self, Self::Set { .. } | Self::Clear { .. })
64    }
65
66    /// The change as `(from, to)`, for a surface that projects a plan to a
67    /// structured value.
68    ///
69    /// A method rather than a match in each surface, for the same reason
70    /// `ItemOutcome::status` is one: `surface-parity.md` §2 requires every surface
71    /// to expose the same "proposed before/after values", and three matches over
72    /// these variants is three places for a fourth surface — or a new variant — to
73    /// disagree about what a `Clear`'s `to` is.
74    ///
75    /// `Clear`'s `to` is the **empty string**, not `null` and not a missing key, so a
76    /// client reading `to` unconditionally gets a string for every field. A `NoChange`
77    /// reports `from == to`, which is the truth: the value would not move.
78    pub fn as_from_to(&self) -> (String, String) {
79        match self {
80            Self::Set { from, to } => (from.clone(), to.clone()),
81            Self::Clear { from } => (from.clone(), String::new()),
82            Self::NoChange { value } => (value.clone(), value.clone()),
83        }
84    }
85}
86
87/// Why one item cannot be applied, with the item it belongs to.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct ItemDiagnostic {
90    /// The issue number, so a message says which item rather than "one of them".
91    pub issue: u32,
92    /// The field or operation, as the contract spells it.
93    pub field: &'static str,
94    pub message: String,
95}
96
97impl std::fmt::Display for ItemDiagnostic {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        write!(f, "#{}: {}", self.issue, self.message)
100    }
101}
102
103/// A History entry the apply **intends** to append. Not an event, and not an
104/// `HistoryEvent` — constructing one would be the second History system contract
105/// §7 prohibits, and appending one is the thing a plan must not do.
106///
107/// ## The vocabulary is the existing one, and it is short
108///
109/// The only kind a plan can currently predict is `transition_accepted`, because
110/// the only History event a bulk operation can cause is the one the **status
111/// seam** writes. A field change appends nothing, because the single-issue path
112/// appends nothing — `tix edit --estimate` does not write a History line, and
113/// `HistoryEventKind` has no field-update variant to write one under.
114///
115/// So "this plan appends no History" is a *prediction about an absence*, and the
116/// plan reports it as the empty list rather than inventing a name. The apply
117/// reaches the same answer from the other direction: the field write goes through
118/// `Store::update` and the transition goes through `Store::set_status`, and only
119/// the latter appends.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct HistoryIntent {
122    /// A [`crate::model::HistoryEventKind::as_str`] spelling. A `&'static str` and
123    /// not the enum, because a plan must not be able to *construct* one — but it
124    /// must be checked against the enum's spelling by the apply, and a test pins
125    /// that every kind a plan emits is one the model can actually produce.
126    pub kind: &'static str,
127    pub detail: String,
128}
129
130/// Where a file would move to, when the patch changes the assignee.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct PathChange {
133    pub from: String,
134    pub to: String,
135}
136
137/// One item's proposed outcome.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct ItemPlan {
140    pub number: u32,
141    pub title: String,
142    /// The field changes, keyed by the contract's field name.
143    pub changes: BTreeMap<&'static str, FieldChange>,
144    /// The file's destination, when the assignee change moves it.
145    pub path_change: Option<PathChange>,
146    /// The History entries this item would gain. Empty when nothing changes, which
147    /// is what makes a no-op produce no duplicate event.
148    pub history: Vec<HistoryIntent>,
149    /// The problem, if this item cannot be applied.
150    pub diagnostic: Option<ItemDiagnostic>,
151}
152
153impl ItemPlan {
154    /// Whether this item would write anything at all.
155    ///
156    /// A `## History` entry counts, because it **is** a write: a plan decides
157    /// what the apply puts in the file, and an appended line is bytes in that
158    /// file. The first version asked only about field and path changes, which is
159    /// right for #173 — there a history intent exists only alongside a field
160    /// change, so the two questions always had the same answer — and wrong for a
161    /// triage decision, which appends an event and may change no field at all: a
162    /// duplicate adds an edge and touches no frontmatter, and a decline back
163    /// into the entry column clears nothing. Such an item reported `no-op` while
164    /// carrying the event the apply would append.
165    pub fn is_actionable(&self) -> bool {
166        self.diagnostic.is_none()
167            && (self.changes.values().any(FieldChange::writes)
168                || self.path_change.is_some()
169                || !self.history.is_empty())
170    }
171
172    /// Whether this item is already in the requested state.
173    pub fn is_noop(&self) -> bool {
174        !self.is_actionable() && self.diagnostic.is_none()
175    }
176
177    /// The shared status word for a **planned** item (`NFR-003`).
178    ///
179    /// The same four words as `ItemOutcome::status`, and derived from the same two
180    /// predicates, so a preview and the apply that follows it cannot describe the same
181    /// item two different ways. The mapping is the obvious one and that is the point:
182    /// a plan that would write is `applied` in the report, a plan that would write
183    /// nothing and has no diagnostic is `no-op`, and a plan with a diagnostic is
184    /// `rejected` before anything is attempted.
185    pub fn status(&self) -> &'static str {
186        if self.diagnostic.is_some() {
187            "rejected"
188        } else if self.is_actionable() {
189            "applied"
190        } else {
191            "no-op"
192        }
193    }
194}
195
196/// A read-only preview. Cancelling one changes nothing, by construction.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct BatchPlan {
199    /// The exact selection this plan was built from, so an apply can check that
200    /// the thing it was authorized for is the thing in front of it.
201    pub selection: Selection,
202    pub patch: IssuePatch,
203    pub items: Vec<ItemPlan>,
204    /// The relationship change this plan proposes, if the patch named a pair.
205    pub pair: Option<PairPlan>,
206    /// The archive request this plan carries, **unapplied**.
207    pub archive: Option<ArchiveRequest>,
208    /// The confirmation, absent until one is issued. `None` is the ordinary state;
209    /// see [`Confirmation`].
210    pub confirmation: Option<Confirmation>,
211}
212
213impl BatchPlan {
214    /// The items that would be written.
215    pub fn actionable(&self) -> impl Iterator<Item = &ItemPlan> {
216        self.items.iter().filter(|i| i.is_actionable())
217    }
218
219    /// The items that cannot be applied.
220    pub fn blocked(&self) -> impl Iterator<Item = &ItemPlan> {
221        self.items.iter().filter(|i| i.diagnostic.is_some())
222    }
223
224    /// Whether applying this plan would change anything.
225    pub fn is_empty(&self) -> bool {
226        self.pair.is_none() && self.archive.is_none() && self.actionable().next().is_none()
227    }
228
229    /// The one-line summary the CLI prints above the table.
230    pub fn summary(&self) -> String {
231        let actionable = self.actionable().count();
232        let blocked = self.blocked().count();
233        let noop = self.items.iter().filter(|i| i.is_noop()).count();
234        // An archive or a pair is an operation on the **selection**, not a per-item
235        // field change, so the items are not subjects of it. The first version printed
236        // their counts anyway, and an archive preview read "0 of 2 would change; 2
237        // already in that state; 1 issue would be archived" — three claims, one of
238        // which ("2 already in that state") describes items the plan never proposed
239        // to change.
240        //
241        // So when the patch names no field, the item counts are omitted entirely and
242        // the operation is the whole summary. The counts are not zeroed: a reader
243        // would have to guess whether there were two items that were all no-ops, or
244        // whether the plan had no items at all.
245        if !self.patch.has_any_field() {
246            let mut parts = Vec::new();
247            if let Some(pair) = &self.pair {
248                parts.push(format!("1 relationship would {}", pair.verb()));
249            }
250            if let Some(archive) = &self.archive {
251                parts.push(format!(
252                    "1 issue would {}",
253                    if archive.archive {
254                        "be archived"
255                    } else {
256                        "be restored"
257                    }
258                ));
259            }
260            if parts.is_empty() {
261                parts.push("nothing to do".to_string());
262            }
263            return parts.join("; ");
264        }
265        let mut parts = vec![format!("{actionable} of {} would change", self.items.len())];
266        if noop > 0 {
267            parts.push(format!("{noop} already in that state"));
268        }
269        if blocked > 0 {
270            parts.push(format!("{blocked} refused"));
271        }
272        if let Some(pair) = &self.pair {
273            parts.push(format!("1 relationship would {}", pair.verb()));
274        }
275        if let Some(archive) = &self.archive {
276            let mut line = format!(
277                "1 issue would {}",
278                if archive.archive {
279                    "be archived"
280                } else {
281                    "be restored"
282                }
283            );
284            // A restore names the column it restores into, because "would be
285            // restored" is not actionable and #190 has no "previous status" to
286            // restore to (C-003). The **absence** is shown too, so a preview says
287            // what is missing rather than what would happen.
288            match (&archive.target_status, archive.problem()) {
289                (Some(target), _) => line.push_str(&format!(" into '{target}'")),
290                (None, Some(problem)) => line.push_str(&format!(" — {problem}")),
291                (None, None) => {}
292            }
293            parts.push(line);
294        }
295        parts.join("; ")
296    }
297
298    /// Whether a confirmation on this plan is still valid.
299    ///
300    /// A confirmation authorizes a **specific** plan. If either the selection or the
301    /// patch has changed, it is a different plan and the old confirmation does not
302    /// carry over (contract §4: "any change to selection or patch requires a new
303    /// preview").
304    pub fn confirmation_is_valid(&self) -> bool {
305        match &self.confirmation {
306            None => false,
307            Some(confirmation) => confirmation.fingerprint == self.selection.fingerprint(),
308        }
309    }
310}
311
312/// A confirmation, present only after one is issued and only for the plan it was
313/// issued against.
314#[derive(Debug, Clone, PartialEq, Eq)]
315pub struct Confirmation {
316    /// The selection membership it was issued against. Membership and not a hash of
317    /// the contents, for the reason in [`Selection::fingerprint`].
318    pub fingerprint: Vec<u32>,
319}
320
321impl Confirmation {
322    /// Issue a confirmation for this plan.
323    pub fn issue(plan: &BatchPlan) -> Self {
324        Self {
325            fingerprint: plan.selection.fingerprint(),
326        }
327    }
328}
329
330/// A proposed relationship change, with its targets resolved.
331///
332/// The `edge` and `identity` are the **canonical** form: `1 blocks 2` and
333/// `2 blocked-by 1` are one edge, and a plan that showed the two spellings as two
334/// plans would let a user confirm a pair the store then refuses as a duplicate.
335/// The `diagnostic` is the structured one, because T019 asks for a *stable* per-pair
336/// diagnostic and a message that varies with the wording of a selector is not
337/// stable — the message beside it is for a human, the diagnostic is for a test.
338#[derive(Debug, Clone, PartialEq, Eq)]
339pub struct PairPlan {
340    pub link: bool,
341    /// The resolved numbers, or the diagnostic that stopped resolution.
342    pub from: std::result::Result<u32, String>,
343    pub to: std::result::Result<u32, String>,
344    /// The canonical edge, when both endpoints resolved.
345    pub edge: Option<crate::relationships::CanonicalEdge>,
346    /// Deliberately **no** relationship id.
347    ///
348    /// `Relationship::new` mints a fresh ULID, so an id built at resolve time is a
349    /// *proposal*, not an identity: the two spellings of one edge would carry
350    /// different ids, and a caller that tried `tix link edit`/`remove` with a
351    /// preview's id would be addressing a row that does not exist. The store mints
352    /// the real one at write time and `PairOutcome` reports it.
353    ///
354    /// A first draft carried `identity: Option<String>` here and on
355    /// `PairResolution`, and a test asserting the two spellings share an id failed
356    /// — which is the type telling the truth and the test not listening.
357    /// The structured refusal, when there is one.
358    pub diagnostic: Option<crate::bulk::pair::PairDiagnostic>,
359}
360
361impl PairPlan {
362    /// A resolved pair, from the module that knows how to resolve one.
363    ///
364    /// The only way to build a `PairPlan`, on purpose. Hand-building one means
365    /// choosing what "resolved" means at the call site, and two call sites choosing
366    /// differently is a pair that plans as valid and refuses on apply. WP02's own
367    /// tests hand-built them, which is how three closures in two files had to be
368    /// edited when WP04 added the canonical edge and the structured diagnostic.
369    pub fn resolved(link: bool, edge: crate::relationships::CanonicalEdge) -> Self {
370        Self {
371            link,
372            from: Ok(edge.from),
373            to: Ok(edge.to),
374            edge: Some(edge),
375            diagnostic: None,
376        }
377    }
378
379    /// A refused pair, with both endpoints reported.
380    ///
381    /// `from` and `to` keep the shape of a resolved plan — one `Result` per
382    /// endpoint — so a renderer does not need a second code path for the refused
383    /// case. A self-link resolves both endpoints and fails on the *pair*, so its
384    /// `from`/`to` are both `Ok` and only `diagnostic` is set; that is what
385    /// distinguishes it from a missing endpoint without a new variant.
386    pub fn refused(
387        link: bool,
388        from: std::result::Result<u32, String>,
389        to: std::result::Result<u32, String>,
390        diagnostic: crate::bulk::pair::PairDiagnostic,
391    ) -> Self {
392        Self {
393            link,
394            from,
395            to,
396            edge: None,
397            diagnostic: Some(diagnostic),
398        }
399    }
400
401    /// Whether both endpoints resolved and the pair is not self-referential.
402    ///
403    /// Delegated to the structured diagnostic when there is one, because
404    /// `is_resolvable` is a question about the *pair* and the diagnostic is where
405    /// the answer is recorded — two places to answer it is one place too many.
406    pub fn is_resolvable(&self) -> bool {
407        // A self-link is the one refusal where **both** endpoints resolved, so
408        // `from`/`to` cannot decide it — hence the diagnostic first. `is_resolvable`
409        // is false for it, which is the answer: a plan reporting "resolvable" for
410        // `1 -> 1` would promise an apply that cannot happen.
411        if self.diagnostic.is_some() {
412            return false;
413        }
414        self.from.is_ok() && self.to.is_ok()
415    }
416
417    pub fn verb(&self) -> &'static str {
418        if self.link { "link" } else { "unlink" }
419    }
420}
421
422/// Build a read-only plan. Writes nothing (contract §3).
423///
424/// `resolve_pair_targets` is a parameter rather than a call so a caller that has no
425/// store to resolve against can pass one, and so the **pure** half of this function
426/// is testable without a store at all — which is what T011's 10,000-item case does.
427pub fn build_plan(
428    config: &crate::config::Config,
429    issues_dir: &std::path::Path,
430    selection: Selection,
431    patch: IssuePatch,
432    mut resolve_pair_targets: impl FnMut(&RelationshipOp) -> PairPlan,
433) -> Result<BatchPlan> {
434    patch.require_wellformed()?;
435
436    let items: Vec<ItemPlan> = selection
437        .issues()
438        .iter()
439        .map(|issue| {
440            let diagnostic = first_diagnostic(issue, &patch, config);
441            let (changes, path_change, history) = propose(issue, &patch, issues_dir);
442            ItemPlan {
443                number: issue.number,
444                title: issue.title.clone(),
445                changes,
446                path_change,
447                history,
448                diagnostic,
449            }
450        })
451        .collect();
452
453    let pair = patch.relationship.as_ref().map(&mut resolve_pair_targets);
454    let archive = patch.archive.clone();
455
456    Ok(BatchPlan {
457        selection,
458        patch,
459        items,
460        pair,
461        archive,
462        confirmation: None,
463    })
464}
465
466// ---------------------------------------------------------------------------
467// T007 — shared field validation
468// ---------------------------------------------------------------------------
469
470/// The first validation problem for one issue, or `None`.
471///
472/// **Calls the shared cores** and adds nothing. The contract's rule is that
473/// "bulk-specific validation may add item context but must not redefine the
474/// underlying rule", so this function's whole job is to call
475/// `model::validate_issue_dates`, `Priority::from_str` and the workflow service
476/// with the values the patch would produce, and to stop at the first failure.
477///
478/// Only the **first** problem per item: a plan is a preview, and a preview that
479/// lists eleven failures for one issue buries the one the user has to fix. The
480/// remaining problems are found when the fix is applied.
481fn first_diagnostic(
482    issue: &Issue,
483    patch: &IssuePatch,
484    config: &crate::config::Config,
485) -> Option<ItemDiagnostic> {
486    let numbers = |field: &'static str, message: String| {
487        Some(ItemDiagnostic {
488            issue: issue.number,
489            field,
490            message,
491        })
492    };
493
494    // Priority — `commands::parse_priority`, the core a single `tix edit --priority`
495    // uses. **Not** `Priority::from_str`, which the first version reached for and
496    // which is `Infallible`: it maps anything unrecognised to `Other(s)`, so a
497    // check written against it can never fire. The strict three-word rule lives in
498    // `parse_priority` and nowhere else, and a bulk validator that re-derived it
499    // would be the second definition the contract prohibits.
500    if let PatchValue::Set(value) = &patch.priority {
501        if let Err(e) = crate::commands::parse_priority(&value.to_string()) {
502            return numbers("priority", e.to_string());
503        }
504    }
505
506    // Dates — `validate_issue_dates` is the core, given the values the patch would
507    // *produce* rather than the ones the file has now, because a patch that clears
508    // `start_date` makes an existing `due_date` legal and one that sets
509    // `start_date` can make it illegal.
510    let start = produced(&patch.start_date, issue.start_date.as_deref());
511    let due = produced(&patch.due_date, issue.due_date.as_deref());
512    if let Err(e) = crate::model::validate_issue_dates(start.as_deref(), due.as_deref()) {
513        return numbers("dates", e.to_string());
514    }
515
516    // Status — the workflow service, judged as a **transition**, because a status
517    // patch is a move. `None` is the right override here: a bulk plan is a preview,
518    // and a terminal exit with no reason *is* a refusal the user needs to see.
519    if let PatchValue::Set(target) = &patch.status {
520        let (_, resolution) = crate::workflow::active_resolution(config);
521        match crate::workflow::validate_transition(issue, target, &resolution, config, None) {
522            crate::workflow::TransitionOutcome::Rejected(diagnostic) => {
523                return numbers("status", diagnostic.to_string());
524            }
525            crate::workflow::TransitionOutcome::Accepted { .. } => {}
526        }
527    }
528
529    // Assignee — the same normalisation a create uses, so a bulk assignee lands in
530    // the same segment a single create would have chosen.
531    if let PatchValue::Set(value) = &patch.assignee
532        && crate::assignee::parse_assignee(value).is_none()
533    {
534        return numbers(
535            "assignee",
536            format!("assignee {value:?} is not a usable handle or identity"),
537        );
538    }
539
540    None
541}
542
543/// The value a patch would leave in a field, given what is there now.
544///
545/// `Unchanged` keeps the current value; `Clear` produces no value; `Set` produces
546/// the new one. Getting this wrong in either direction produces a diagnostic about
547/// a field the patch was not touching.
548fn produced(patch: &PatchValue<String>, current: Option<&str>) -> Option<String> {
549    match patch {
550        PatchValue::Unchanged => current.map(str::to_string),
551        PatchValue::Set(v) => Some(v.clone()),
552        PatchValue::Clear => None,
553    }
554}
555
556// ---------------------------------------------------------------------------
557// T009 — proposed changes, paths, History intents
558// ---------------------------------------------------------------------------
559
560/// The field changes, path move and History intents for one issue.
561fn propose(
562    issue: &Issue,
563    patch: &IssuePatch,
564    issues_dir: &std::path::Path,
565) -> (
566    BTreeMap<&'static str, FieldChange>,
567    Option<PathChange>,
568    Vec<HistoryIntent>,
569) {
570    let mut changes: BTreeMap<&'static str, FieldChange> = BTreeMap::new();
571    let mut path_change = None;
572    let mut history = Vec::new();
573
574    // Status. A `NoChange` when the issue is already there, which is what stops a
575    // re-apply from appending a second decision.
576    if let PatchValue::Set(target) = &patch.status {
577        let from = issue.status.display().to_string();
578        if from == *target {
579            changes.insert("status", FieldChange::NoChange { value: from });
580        } else {
581            changes.insert(
582                "status",
583                FieldChange::Set {
584                    from: from.clone(),
585                    to: target.clone(),
586                },
587            );
588            history.push(HistoryIntent {
589                kind: "transition_accepted",
590                detail: format!("{from} -> {target}"),
591            });
592        }
593    }
594
595    // Priority is `PatchValue<Priority>` and estimate is `PatchValue<String>`, so
596    // they cannot share one array — the first version tried, and the borrow
597    // checker refused. Two short loops over the same shape is the smaller cost
598    // than a generic helper that would have to erase the field name too.
599    // `Priority` is an enum, not a string, and "unset" is `Priority::Other("")` —
600    // so the current value is read through `is_undefined` rather than by testing
601    // for an empty rendering, which would make `Other("bogus")` render as
602    // "bogus" and compare unequal to the `bogus` a patch sets.
603    match &patch.priority {
604        PatchValue::Unchanged => {}
605        value => record_clearing(
606            &mut changes,
607            "priority",
608            if issue.priority.is_undefined() {
609                String::new()
610            } else {
611                issue.priority.to_string()
612            },
613            render_typed(value),
614            matches!(value, PatchValue::Clear),
615        ),
616    }
617    match &patch.estimate {
618        PatchValue::Unchanged => {}
619        value => record_clearing(
620            &mut changes,
621            "estimate",
622            issue.estimate.clone().unwrap_or_default(),
623            render_typed(value),
624            matches!(value, PatchValue::Clear),
625        ),
626    }
627
628    for (name, value, current) in [
629        ("assignee", &patch.assignee, issue.assignee.clone()),
630        ("start_date", &patch.start_date, issue.start_date.clone()),
631        ("due_date", &patch.due_date, issue.due_date.clone()),
632    ] {
633        match value {
634            PatchValue::Unchanged => {}
635            PatchValue::Set(new) => record(
636                &mut changes,
637                name,
638                current.unwrap_or_default(),
639                new.clone(),
640                &mut history,
641            ),
642            PatchValue::Clear => record_clearing(
643                &mut changes,
644                name,
645                current.unwrap_or_default(),
646                String::new(),
647                true,
648            ),
649        }
650    }
651
652    // The list-valued fields, rendered the way the frontmatter writes them, so a
653    // preview line and a diff agree.
654    for (name, value, current) in [
655        ("labels", &patch.labels, Some(&issue.labels[..])),
656        ("cycles", &patch.cycles, issue.cycles.as_deref()),
657        ("milestones", &patch.milestones, issue.milestones.as_deref()),
658    ] {
659        match value {
660            PatchValue::Unchanged => {}
661            PatchValue::Set(new) => record(
662                &mut changes,
663                name,
664                render_list(current.unwrap_or_default()),
665                render_list(new),
666                &mut history,
667            ),
668            PatchValue::Clear => record_clearing(
669                &mut changes,
670                name,
671                render_list(current.unwrap_or_default()),
672                String::new(),
673                true,
674            ),
675        }
676    }
677
678    // The assignee move. The **destination segment**, which is where a create would
679    // have put the file — not the current directory, which is a path and not a
680    // prediction.
681    if let PatchValue::Set(new) = &patch.assignee {
682        if let Some(segment) = crate::assignee::parse_assignee(new) {
683            // The **destination segment**, via the same `derive_segment` a create
684            // uses, so a preview line and the file that eventually lands agree.
685            let from = issues_dir.join(crate::assignee::derive_segment(issue.assignee.as_deref()));
686            let to = issues_dir.join(crate::assignee::derive_segment(Some(&segment)));
687            if from != to {
688                path_change = Some(PathChange {
689                    from: from.display().to_string(),
690                    to: to.display().to_string(),
691                });
692            }
693        }
694    }
695
696    (changes, path_change, history)
697}
698
699/// Record one field change, and the History intent that goes with it — which for a
700/// field change is **none**.
701///
702/// The `history` parameter is `&mut Vec<HistoryIntent>` rather than absent, and it
703/// is left untouched here, because the first draft of this function pushed a
704/// `field_updated` intent for every changed field. That was **wrong**, and WP03
705/// caught it while building the apply:
706///
707/// - `tix edit <n> --estimate 3d` writes the field and appends **no** History
708///   event. Measured, not assumed.
709/// - `HistoryEventKind` has no field-update variant. The vocabulary is
710///   relationship, parent, blocked-move and transition.
711/// - So a bulk field patch appending `field_updated` would be a **bulk-only**
712///   History event: a second History vocabulary, which `contracts/patch-plan.md`
713///   §7 prohibits and which the contract's §"History and archive" reads the other
714///   way — "the appropriate event **from the prerequisite History service**", and
715///   for a field change the prerequisite service's appropriate event is none.
716///
717/// The parameter stays so the shape of the function is the same as the status
718/// branch's, and so a future field that *does* have a service has somewhere to
719/// push. The doc comment is the record: `record` is where the temptation lives.
720fn record(
721    changes: &mut BTreeMap<&'static str, FieldChange>,
722    name: &'static str,
723    from: String,
724    to: String,
725    _history: &mut Vec<HistoryIntent>,
726) {
727    record_clearing(changes, name, from, to, false)
728}
729
730/// [`record`], told whether the change is an *emptying* rather than a new value.
731///
732/// A `Clear` used to reach the plan as `Set { to: "" }` — the two are
733/// indistinguishable once rendered, and a plan that cannot tell them apart cannot
734/// report "this field would be emptied" to a surface that shows before/after values.
735/// `FieldChange::Clear` existed for exactly that and was **never constructed**, so the
736/// variant was dead and the doc comment on `as_from_to` promised a behaviour nothing
737/// could reach. The WP08 revert-and-red run is what found it: a mutation of the
738/// `Clear` arm left every test green, because no test could see a `Clear`.
739///
740/// The distinction matters to a surface and not to the file, which is why it lives
741/// here rather than at the apply: the write is identical (`field:` with no value) and
742/// only the *report* differs.
743fn record_clearing(
744    changes: &mut BTreeMap<&'static str, FieldChange>,
745    name: &'static str,
746    from: String,
747    to: String,
748    cleared: bool,
749) {
750    if cleared {
751        changes.insert(name, FieldChange::Clear { from });
752        return;
753    }
754    if from == to {
755        changes.insert(name, FieldChange::NoChange { value: to });
756        return;
757    }
758    changes.insert(name, FieldChange::Set { from, to });
759}
760
761fn render_typed<T: std::fmt::Display>(value: &PatchValue<T>) -> String {
762    match value {
763        PatchValue::Set(v) => v.to_string(),
764        _ => String::new(),
765    }
766}
767
768fn render_list(values: &[String]) -> String {
769    values.join(", ")
770}