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}