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}