tix/bulk/apply.rs
1//! The confirmed apply path for a field patch (WP03).
2//!
3//! Three things make this different from every other write in the codebase, and
4//! each is a rule rather than a preference:
5//!
6//! ## An absent confirmation is refused before any filesystem operation
7//!
8//! Not "before the first write" — before the first **read**, before `Store` is
9//! touched at all. A refusal that has already enumerated the board has already
10//! leaked what the caller could see, and a caller that got the plan wrong should
11//! learn that from the refusal alone. [`crate::bulk::plan::BatchPlan::confirmation`]
12//! being `None` is therefore checked first, and the check is the first statement
13//! in [`apply_fields`].
14//!
15//! ## An item is all-or-nothing, a batch is not
16//!
17//! `contracts/operation-outcomes.md`: a confirmation "authorizes independent item
18//! processing; it is not an all-or-nothing transaction". So a failure on item 7 of
19//! 200 is reported and the batch continues — and there is deliberately **no
20//! rollback**, because a rollback is a second write policy the contract does not
21//! describe and that would have to be correct in the presence of the failures it
22//! was written to undo. There is also no silent retry: [`write_failures`] is the
23//! report, and the count of items that failed is printed rather than absorbed.
24//!
25//! Within one item the order is the single-issue order, and the reason is
26//! documented at the write site: a field write and a status move are two different
27//! records, and a `--status` move re-reads the file it writes.
28//!
29//! ## A no-op writes nothing and appends nothing
30//!
31//! Not "appends nothing important" — writes **no bytes at all**, so `updated` is
32//! not bumped either, matching [`Store::set_status`]. The comparison is against the
33//! **final** state, computed before the first write, because comparing field by
34//! field as each is applied would make a half-applied item look like a no-op after
35//! its first write.
36
37use crate::bulk::plan::{BatchPlan, ItemDiagnostic};
38use crate::bulk::{IssuePatch, PatchValue};
39use crate::error::{Result, TixError};
40use crate::model::HistoryEventKind;
41use crate::storage::Store;
42
43/// What happened to one item.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum ItemOutcome {
46 /// The requested final state was written.
47 Applied {
48 /// The path the file is at now, which differs from the old one after a
49 /// reassign.
50 filename: String,
51 },
52 /// The requested final state was already present. Nothing was written and
53 /// nothing was appended.
54 ///
55 /// A distinct outcome rather than a variant of `Applied`, because
56 /// `contracts/operation-outcomes.md` says a retry "is explicit and reports the
57 /// no-op; it is never silent" — so a caller has to be able to *count* these
58 /// separately from the writes.
59 NoOp,
60 /// Refused at plan time. Nothing was attempted.
61 Rejected(String),
62 /// The write failed. Whatever succeeded before it stands; nothing is undone.
63 Failed { item: ItemDiagnostic, error: String },
64}
65
66impl ItemOutcome {
67 /// The one-line form a surface prints.
68 pub fn as_line(&self, number: u32, title: &str) -> String {
69 match self {
70 Self::Applied { filename } => {
71 format!("#{number} {title} -> applied ({filename})")
72 }
73 Self::NoOp => format!("#{number} {title} -> already in that state"),
74 Self::Rejected(why) => format!("#{number} {title} -> refused: {why}"),
75 Self::Failed { item, error } => {
76 format!("#{number} {title} -> failed ({}): {error}", item.field)
77 }
78 }
79 }
80
81 pub fn is_applied(&self) -> bool {
82 matches!(self, Self::Applied { .. })
83 }
84
85 /// The shared status word (`NFR-003`).
86 ///
87 /// **A method, not a match in each surface.** `NFR-003` requires CLI, TUI and MCP
88 /// to expose equivalent per-item statuses, and three `match` blocks over this enum
89 /// is three places for a fourth surface — or a rename — to disagree. The words are
90 /// the ones `bulk::metadata::OUTCOME_STATUSES` lists, and a case asserts the two
91 /// agree, so the vocabulary has exactly one definition.
92 pub fn status(&self) -> &'static str {
93 match self {
94 Self::Applied { .. } => "applied",
95 Self::NoOp => "no-op",
96 Self::Rejected(_) => "rejected",
97 Self::Failed { .. } => "failed",
98 }
99 }
100}
101
102/// The result of a whole apply.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct ApplyReport {
105 pub outcomes: Vec<(u32, ItemOutcome)>,
106 /// Items whose write failed, so a caller can report them without scanning.
107 pub write_failures: Vec<ItemDiagnostic>,
108}
109
110impl ApplyReport {
111 pub fn applied(&self) -> usize {
112 self.outcomes.iter().filter(|(_, o)| o.is_applied()).count()
113 }
114
115 pub fn noop(&self) -> usize {
116 self.outcomes
117 .iter()
118 .filter(|(_, o)| matches!(o, ItemOutcome::NoOp))
119 .count()
120 }
121
122 pub fn rejected(&self) -> usize {
123 self.outcomes
124 .iter()
125 .filter(|(_, o)| matches!(o, ItemOutcome::Rejected(_)))
126 .count()
127 }
128
129 pub fn failed(&self) -> usize {
130 self.write_failures.len()
131 }
132
133 /// The one-line summary, with every count named.
134 ///
135 /// Every number is stated even when it is zero, because a bulk apply that
136 /// silently skipped 200 items would otherwise look identical to one that wrote
137 /// them.
138 pub fn summary(&self) -> String {
139 let total = self.outcomes.len();
140 let mut parts = vec![
141 format!("{} of {total} applied", self.applied()),
142 format!("{} already in that state", self.noop()),
143 format!("{} refused", self.rejected()),
144 ];
145 if self.failed() > 0 {
146 parts.push(format!("{} write failures", self.failed()));
147 }
148 parts.join("; ")
149 }
150}
151
152/// Apply a confirmed plan's field patch.
153///
154/// T012: refuses an absent confirmation **before any filesystem operation**. T013:
155/// every field goes through the same core a single-issue operation uses. T014: the
156/// History event is appended only on success, and only the one the seam writes.
157/// T015: per-file atomic writes, per-item failure classification, no rollback, no
158/// silent retry.
159pub fn apply_fields(
160 store: &Store,
161 plan: &BatchPlan,
162 actor: &str,
163 override_reason: Option<&str>,
164) -> Result<ApplyReport> {
165 // ---- The gate. First statement, before `store` is read at all. ----
166 if plan.confirmation.is_none() || !plan.confirmation_is_valid() {
167 return Err(TixError::InvalidInput(
168 "a bulk apply needs a confirmation for exactly this plan — preview it \
169 first, and preview again if the selection or the patch changed"
170 .to_string(),
171 ));
172 }
173 if plan.patch.relationship.is_some() {
174 return Err(TixError::InvalidInput(
175 "this plan carries a relationship operation, which WP04 applies — not \
176 the field path"
177 .to_string(),
178 ));
179 }
180 if plan.patch.archive.is_some() {
181 return Err(TixError::InvalidInput(
182 "this plan carries an archive request, which WP05 applies — not the \
183 field path"
184 .to_string(),
185 ));
186 }
187
188 let patch = &plan.patch;
189 let mut outcomes = Vec::with_capacity(plan.items.len());
190 let mut write_failures = Vec::new();
191
192 // WP07 T034/T035 (mission `configurable-wip-limits-01M3C6WX`): the WIP decisions
193 // for this batch's status moves, re-judged against the **current** configuration
194 // and state, in plan order over a projection.
195 //
196 // Unlike a plan-time *field* validation — a fact about the bytes the plan read,
197 // which this function deliberately does not retry — a limit is configuration:
198 // a preview taken before a limit changed is a preview of a different board, and
199 // `tix move` and the TUI both re-ask for that reason. So the batch asks again,
200 // and an item the policy no longer allows is refused rather than written on a
201 // stale promise. There is no batch-wide override: an item that needs one is
202 // refused here and moved singly, where the override is explicit and audited.
203 let wip_decisions: std::collections::BTreeMap<u32, crate::wip::ItemWip> = match &patch.status {
204 crate::bulk::patch::PatchValue::Set(_) => {
205 let issues = store.list()?;
206 let moves: Vec<(u32, String)> = plan
207 .items
208 .iter()
209 .map(|item| (item.number, target_status(patch)))
210 .collect();
211 crate::wip::evaluate_batch(
212 &store.config,
213 &issues,
214 &moves,
215 &std::collections::BTreeMap::default(),
216 )
217 .into_iter()
218 .map(|item| (item.number, item))
219 .collect()
220 }
221 _ => std::collections::BTreeMap::default(),
222 };
223
224 for item in &plan.items {
225 // A plan-time refusal is **not retried**. Re-running the validator here
226 // would be a second decision on the same inputs, and a bulk apply that
227 // re-judges a plan it was handed is a bulk apply with its own policy.
228 if let Some(diagnostic) = &item.diagnostic {
229 outcomes.push((
230 item.number,
231 ItemOutcome::Rejected(diagnostic.message.clone()),
232 ));
233 continue;
234 }
235
236 if let Some(wip) = wip_decisions.get(&item.number)
237 && !wip.is_committable()
238 {
239 // Refused with the policy's own sentence, and the items already applied
240 // stand: a batch never rolls back.
241 outcomes.push((
242 item.number,
243 ItemOutcome::Rejected(crate::wip::render_decision(&wip.decision)),
244 ));
245 continue;
246 }
247
248 let number = item.number;
249 match apply_one(store, patch, number, actor, override_reason) {
250 Ok(Outcome::NoOp) => outcomes.push((item.number, ItemOutcome::NoOp)),
251 Ok(Outcome::Applied { filename }) => {
252 outcomes.push((item.number, ItemOutcome::Applied { filename }))
253 }
254 // A refusal that only the seam can make — a terminal exit discovered
255 // between preview and apply, because the board changed. Reported as a
256 // failure, not silently skipped.
257 Err((diagnostic, error)) => {
258 write_failures.push(diagnostic.clone());
259 outcomes.push((
260 number,
261 ItemOutcome::Failed {
262 item: diagnostic,
263 error: error.to_string(),
264 },
265 ));
266 }
267 }
268 }
269
270 Ok(ApplyReport {
271 outcomes,
272 write_failures,
273 })
274}
275
276/// One item's result, before it is classified.
277enum Outcome {
278 Applied { filename: String },
279 NoOp,
280}
281
282/// Apply one item inside exactly one write transaction (#1580).
283///
284/// `bulk` applies each selected issue in its **own** transaction, never one over
285/// the whole batch: a per-item failure must not roll back the siblings (the batch
286/// contract in `AGENTS.md`). One item can write up to three times — a reassign
287/// rename, the field write, and the status seam — and the window between those
288/// writes is what this closes. The item's diagnostic is captured through the
289/// transaction and reported exactly as before.
290#[allow(clippy::type_complexity)]
291fn apply_one(
292 store: &Store,
293 patch: &IssuePatch,
294 number: u32,
295 actor: &str,
296 override_reason: Option<&str>,
297) -> std::result::Result<Outcome, (ItemDiagnostic, TixError)> {
298 let mut diagnostic: Option<ItemDiagnostic> = None;
299 let result = store.transaction(|| {
300 apply_one_body(store, patch, number, actor, override_reason).map_err(|(d, e)| {
301 diagnostic = Some(d);
302 e
303 })
304 });
305 result.map_err(|e| {
306 (
307 diagnostic.unwrap_or_else(|| failed_item(number, "write", e.to_string())),
308 e,
309 )
310 })
311}
312
313/// The write sequence of one item, run inside [`apply_one`]'s transaction.
314#[allow(clippy::type_complexity)]
315fn apply_one_body(
316 store: &Store,
317 patch: &IssuePatch,
318 number: u32,
319 actor: &str,
320 override_reason: Option<&str>,
321) -> std::result::Result<Outcome, (ItemDiagnostic, TixError)> {
322 let issue = store
323 .get(crate::storage::IssueSelector::Number(number))
324 .map_err(|e| (failed_item(number, "read", e.to_string()), e))?;
325 let fail = |field: &'static str, e: TixError| (failed_item(number, field, e.to_string()), e);
326
327 // ---- The final state, computed before the first write. ----
328 let merged = crate::bulk::field_apply::merge_issue(&issue, patch);
329 let changes_status = match (&patch.status, &issue.status) {
330 (PatchValue::Set(target), current) => current.display() != target,
331 _ => false,
332 };
333 let changes_fields = merged != issue;
334 let moves_file = crate::bulk::field_apply::moves_segment(&issue, patch);
335
336 // T014: the no-op test is "nothing would change", and it is made **once**, up
337 // front. A per-field comparison as each field is applied would make an item
338 // that changed its status and then failed look like a no-op, and would decide
339 // the question after bytes had already been written.
340 if !changes_status && !changes_fields && !moves_file {
341 return Ok(Outcome::NoOp);
342 }
343
344 let mut filename = issue.filename.clone();
345
346 // ---- The write, in the single-issue order. ----
347 //
348 // Reassign first, then the field write at the (possibly new) path, then the
349 // status seam. `tix edit` does the same in the same order, and the reason is
350 // in `edit.rs`: a field write and a status move are **two different records**,
351 // and `set_status` re-reads the file it writes — so writing the fields first
352 // means the decision is appended to an issue whose other changes are already
353 // saved.
354 if moves_file {
355 let moved = store
356 .reassign(&issue.filename, merged.assignee.as_deref())
357 .map_err(|e| fail("assignee", e))?;
358 filename = moved.to_string_lossy().to_string();
359 }
360 if changes_fields {
361 let mut written = merged.clone();
362 written.filename = filename.clone();
363 // `updated` bumps only when the merge actually changed something, the way
364 // `tix edit` does it: a reassign on its own moves the file and is not a
365 // content edit, so it must not look like one.
366 //
367 // There is a second `written != issue` guard here, and a first draft
368 // wrapped the bump in a *third* check — `if changes_fields` — which is
369 // always true inside this block. It was harmless and it was also
370 // **unobservable**: a revert of redundant code is a revert of nothing, so
371 // the harness case written for it came back green while proving nothing.
372 // That is the eighth no-op revert this programme has produced, and the
373 // lesson is the same each time — a case needs a revert that changes
374 // behaviour, or it is decoration. The guard is gone because it carried no
375 // meaning; the case is gone because it could not be made to carry any.
376 if written != issue || filename != issue.filename {
377 written.bump_updated();
378 }
379 filename = store
380 .update(&filename, &written)
381 .map_err(|e| fail("write", e))?;
382 }
383 if changes_status {
384 let target = match &patch.status {
385 PatchValue::Set(t) => t.clone(),
386 _ => unreachable!("changes_status is only true for a Set"),
387 };
388 // The seam: the transition is judged again, the completion coupling is
389 // applied, and exactly one `transition_accepted` is appended. A bulk apply
390 // does not re-implement any of that, and a refusal here is a *failure* of
391 // this item, not a silent skip.
392 let outcome = store
393 .set_status(
394 &filename,
395 crate::storage::StatusChange::to(&target).with_override(override_reason),
396 )
397 .map_err(|e| fail("status", e))?;
398 filename = outcome.issue.filename.clone();
399 let _ = actor;
400 }
401
402 Ok(Outcome::Applied { filename })
403}
404
405/// The status this patch sets, for the WIP projection.
406///
407/// Only ever called when the patch sets a status, so the `Set` arm is the one that
408/// runs; the fallback exists so a future patch shape cannot make this panic in a
409/// function whose whole job is to avoid surprises.
410fn target_status(patch: &IssuePatch) -> String {
411 match &patch.status {
412 crate::bulk::patch::PatchValue::Set(target) => target.clone(),
413 _ => String::new(),
414 }
415}
416
417fn failed_item(number: u32, field: &'static str, message: String) -> ItemDiagnostic {
418 ItemDiagnostic {
419 issue: number,
420 field,
421 message,
422 }
423}
424
425/// The single History kind an apply can append, if it appends one.
426///
427/// A pure function of the patch, so a surface can render "this apply will append 1
428/// History event" with no plan and no store — and so a test can assert the claim
429/// for **every** patch shape rather than one fixture.
430///
431/// It is `Option` rather than a count because the answer is a vocabulary, and a
432/// count would let a second kind be added without anything noticing. There is one:
433/// the transition seam's. A field write goes through `Store::update` and appends
434/// nothing, which is what the single-issue path does too.
435pub fn history_kind_for(patch: &IssuePatch) -> Option<HistoryEventKind> {
436 match patch.status {
437 PatchValue::Set(_) => Some(HistoryEventKind::TransitionAccepted),
438 _ => None,
439 }
440}
441
442/// How many History entries an apply of this patch would append.
443pub fn predicted_history_events(patch: &IssuePatch) -> usize {
444 usize::from(history_kind_for(patch).is_some())
445}
446
447/// Whether this module knows how to apply the patch, and says so rather than
448/// doing half of it.
449///
450/// WP03 owns the field path, WP04 the pair, WP05 the archive. A plan carrying a
451/// pair must be **refused** here, not partially applied — which is why the check
452/// lives in [`apply_fields`] and is not left to the caller. Exposed as a predicate
453/// so a surface can route a plan to the right work package before it starts.
454pub fn owns(patch: &IssuePatch) -> bool {
455 crate::bulk::field_apply::is_field_path(patch)
456}
457
458// ---------------------------------------------------------------------------
459// Re-exported so a caller writes `bulk::apply::merge_issue` rather than reaching
460// into `field_apply`, which is where the merge lives.
461// ---------------------------------------------------------------------------
462
463pub use crate::bulk::field_apply::{
464 FieldOutcome, destination_path, field_outcomes, merge_issue, moves_segment,
465};