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