Skip to main content

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};