Skip to main content

tix/bulk/
patch.rs

1//! The typed patch: what a bulk operation is being asked to change (WP01 T003–T004).
2//!
3//! A patch is a **whitelist**, and that is the whole point. A raw `key=value` map
4//! would let `tix bulk --set priority=high --set number=99` through, and `number:`
5//! is not a field a bulk operation may touch. So every value is an enum over a
6//! named field, and the set of fields is closed by construction — there is no
7//! syntax by which a caller can name a key that is not in the contract
8//! (data-model §2, contract §1 and §7).
9//!
10//! ## `unchanged` is not `None`
11//!
12//! `PatchValue<T>` is `Unchanged | Set(T) | Clear`. An `Option` cannot
13//! distinguish "the user did not mention this field" from "the user asked to empty
14//! it", and those are different requests: the first must not touch the file at
15//! all, the second must. Collapsing them is how a bulk `--clear-dates` ends up
16//! clearing the assignee as well.
17
18use serde::{Deserialize, Serialize};
19
20use crate::error::{Result, TixError};
21
22/// One field's requested change.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
24pub enum PatchValue<T> {
25    /// The user did not name this field. **Never** mutates it.
26    #[default]
27    Unchanged,
28    /// Set to this value.
29    Set(T),
30    /// Empty the field, where the field supports clearing.
31    Clear,
32}
33
34impl<T> PatchValue<T> {
35    /// Whether this value would change anything. The cheap form of the
36    /// "already the requested state is a no-op" rule (data-model §2): a plan can
37    /// skip the field without comparing it to the current value.
38    pub fn is_unchanged(&self) -> bool {
39        matches!(self, Self::Unchanged)
40    }
41
42    pub fn is_clear(&self) -> bool {
43        matches!(self, Self::Clear)
44    }
45
46    /// The value, or `None` for both `Unchanged` and `Clear`.
47    pub fn as_set(&self) -> Option<&T> {
48        match self {
49            Self::Set(v) => Some(v),
50            _ => None,
51        }
52    }
53
54    /// Map the payload, leaving the variant alone.
55    pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> PatchValue<U> {
56        match self {
57            Self::Unchanged => PatchValue::Unchanged,
58            Self::Set(v) => PatchValue::Set(f(v)),
59            Self::Clear => PatchValue::Clear,
60        }
61    }
62}
63
64impl<T> From<Option<T>> for PatchValue<T> {
65    /// `None` becomes `Unchanged`, **not** `Clear`.
66    ///
67    /// This conversion is the one place the distinction could be lost, so it is
68    /// deliberately one-way: `Option` cannot express `Clear` on its own, and a
69    /// caller that means to clear has to write `PatchValue::Clear` by hand.
70    fn from(value: Option<T>) -> Self {
71        match value {
72            Some(v) => Self::Set(v),
73            None => Self::Unchanged,
74        }
75    }
76}
77
78/// The relationship half of a patch: one explicit pair operation.
79///
80/// A **pair**, not a list of endpoints, because an operation with two ends and no
81/// stated order cannot be applied without one of them being invented (contract §6).
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct RelationshipOp {
84    /// `true` adds the edge, `false` removes it.
85    pub link: bool,
86    /// The `from` selector, as typed. Resolved at plan time (WP02 T008) so a
87    /// missing or self-referential pair is a **diagnostic**, not a write.
88    pub from: String,
89    /// The `to` selector, as typed.
90    pub to: String,
91    /// The typed kind: `blocks`, `blocked_by`, `duplicates`, or `related`.
92    ///
93    /// The vocabulary is the relationship service's, not a copy — an unknown kind
94    /// is rejected there with its own message, and duplicating the list here is
95    /// how the two drift.
96    pub kind: crate::relationships::RelationKind,
97}
98
99impl RelationshipOp {
100    /// A `tix link`-shaped operation.
101    pub fn link(
102        from: impl Into<String>,
103        to: impl Into<String>,
104        kind: crate::relationships::RelationKind,
105    ) -> Self {
106        Self {
107            link: true,
108            from: from.into(),
109            to: to.into(),
110            kind,
111        }
112    }
113
114    /// A `tix unlink`-shaped operation.
115    pub fn unlink(
116        from: impl Into<String>,
117        to: impl Into<String>,
118        kind: crate::relationships::RelationKind,
119    ) -> Self {
120        Self {
121            link: false,
122            from: from.into(),
123            to: to.into(),
124            kind,
125        }
126    }
127
128    /// The word the diagnostics use, so a message says "link" or "unlink" rather
129    /// than "a relationship operation".
130    pub fn verb(&self) -> &'static str {
131        if self.link { "link" } else { "unlink" }
132    }
133}
134
135/// A request to delegate archiving to the #190 service.
136///
137/// A **request**, with no semantics of its own (contract §6): there is no
138/// bulk-specific archive behaviour, and inventing one here would be the exact
139/// "temporary archive behavior" the contract prohibits. WP05 builds the service
140/// request; `archive::apply_archive` is where the delegation will happen.
141///
142/// ## A restore carries an explicit target status
143///
144/// `target_status` and `reason` were **absent** from the first draft, and #190's own
145/// contract is what shows the gap:
146///
147/// > **Restore requires an explicit `target_status`.** A restore without one is
148/// > rejected; there is no "restore to the previous status" behavior (C-003).
149///
150/// So a `false`-direction request with no target cannot be expressed at all. That is
151/// the right shape: a request the service will refuse should not be constructible,
152/// and a restore whose target is inferred from a stored previous status is exactly
153/// the "restore to the previous status" behaviour C-003 rules out.
154///
155/// The fields are `Option` rather than a required-value-per-variant because a
156/// request is also what a **preview** carries, and a preview must be able to say
157/// "this restore is missing its target" rather than being unable to exist.
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159pub struct ArchiveRequest {
160    /// `true` archives, `false` restores.
161    pub archive: bool,
162    /// The column to restore into. Required when `archive` is `false`; ignored when
163    /// it is `true`, because archiving does not change status (C-001 in #190).
164    pub target_status: Option<String>,
165    /// The user's reason, preserved verbatim for the service's record.
166    pub reason: Option<String>,
167}
168
169impl ArchiveRequest {
170    /// An archive request, which needs no target status.
171    pub fn archive() -> Self {
172        Self {
173            archive: true,
174            target_status: None,
175            reason: None,
176        }
177    }
178
179    /// A restore request. A blank target is stored as `None` rather than `Some("")`,
180    /// so a missing target and an empty one are the same refusal — the same
181    /// "absent is not a value" rule the rest of the patch follows.
182    pub fn restore(target_status: impl Into<String>, reason: Option<String>) -> Self {
183        let target = target_status.into();
184        Self {
185            archive: false,
186            target_status: (!target.trim().is_empty()).then_some(target),
187            reason,
188        }
189    }
190
191    /// The refusal this request carries on its own, if any.
192    ///
193    /// In the patch rather than in `archive.rs`, because it is a property of the
194    /// request and both the preview and the apply have to see it — two places
195    /// deciding it is two places to disagree.
196    pub fn problem(&self) -> Option<&'static str> {
197        if self.archive || self.target_status.is_some() {
198            None
199        } else {
200            Some("missing_target_status")
201        }
202    }
203}
204
205/// The complete set of bulk field operations.
206///
207/// Closed by construction: adding a field means adding it here, and the
208/// `patch_fields` iterator is what the plan and the CLI both read, so neither can
209/// name a field the other does not know.
210#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
211pub struct IssuePatch {
212    /// The new column. Validated through the workflow service at apply time, like
213    /// every other status write.
214    pub status: PatchValue<String>,
215    pub priority: PatchValue<crate::model::Priority>,
216    pub assignee: PatchValue<String>,
217    pub labels: PatchValue<Vec<String>>,
218    pub start_date: PatchValue<String>,
219    pub due_date: PatchValue<String>,
220    pub estimate: PatchValue<String>,
221    pub cycles: PatchValue<Vec<String>>,
222    pub milestones: PatchValue<Vec<String>>,
223    /// The relationship operation, if this patch names one. A patch is
224    /// field-change **or** pair, not both: combining them would make the plan's
225    /// "one proposed change per item" ambiguous.
226    pub relationship: Option<RelationshipOp>,
227    /// The archive delegation, if this patch names one. Same one-of rule.
228    pub archive: Option<ArchiveRequest>,
229}
230
231/// The outcome of a patch's own structural validation.
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub struct PatchProblem {
234    /// Which field or operation, as the contract spells it.
235    pub field: &'static str,
236    pub message: String,
237}
238
239impl IssuePatch {
240    /// Whether this patch asks for nothing at all.
241    ///
242    /// Reads **every** field, not just the string-shaped ones. The first version
243    /// consulted [`Self::fields`], which yields only the `PatchValue<String>`
244    /// projection — so a patch that set only `priority` reported itself empty, and
245    /// `require_wellformed` refused it with "no fields to change" while the user
246    /// had named one. Caught by
247    /// `tests/bulk_selection.rs::t003_a_patch_is_a_whitelist_with_no_escape_hatch`,
248    /// which is the case this function's own doc comment describes.
249    pub fn is_empty(&self) -> bool {
250        self.changed_fields().all(|(_, changed)| !changed)
251            && self.relationship.is_none()
252            && self.archive.is_none()
253    }
254
255    /// Every field value, whether or not it is a `String`.
256    ///
257    /// The single source of "is this field set", so `is_empty` and the
258    /// plan's per-field walk cannot disagree about which fields exist. The
259    /// string-shaped [`Self::fields`] view is a convenience for rendering; it is
260    /// **not** the definition of which fields a patch carries.
261    pub fn all_field_values(&self) -> [(&'static str, bool); 9] {
262        [
263            ("status", !self.status.is_unchanged()),
264            ("priority", !self.priority.is_unchanged()),
265            ("assignee", !self.assignee.is_unchanged()),
266            ("labels", !self.labels.is_unchanged()),
267            ("start-date", !self.start_date.is_unchanged()),
268            ("due-date", !self.due_date.is_unchanged()),
269            ("estimate", !self.estimate.is_unchanged()),
270            ("cycles", !self.cycles.is_unchanged()),
271            ("milestones", !self.milestones.is_unchanged()),
272        ]
273    }
274
275    /// The field values that would change something, as `(name, changed)`.
276    pub fn changed_fields(&self) -> impl Iterator<Item = (&'static str, bool)> {
277        self.all_field_values()
278            .into_iter()
279            .filter(|(_, changed)| *changed)
280    }
281
282    /// Whether any **field** is set, ignoring the pair and archive variants. The
283    /// question `structural_problems` needs when it is deciding whether a patch
284    /// mixes two kinds of operation.
285    pub fn has_any_field(&self) -> bool {
286        self.all_field_values().iter().any(|(_, changed)| *changed)
287    }
288
289    /// The patch's own field values, in the contract's order.
290    ///
291    /// The single enumeration the plan, the CLI renderer and the tests all read.
292    /// A second list would be a place for the three to disagree about which
293    /// fields exist — and the WP04 MCP parity bug in the previous mission was
294    /// exactly a duplicated list drifting from the original.
295    pub fn fields(&self) -> impl Iterator<Item = (&'static str, &PatchValue<String>)> {
296        // `Priority` and the `Vec<String>` fields are rendered through `label`
297        // below, so the iterator yields the string-shaped projection and the
298        // typed values are read by `apply`, which has the issue in hand.
299        [
300            ("status", &self.status),
301            ("assignee", &self.assignee),
302            ("estimate", &self.estimate),
303            ("start_date", &self.start_date),
304            ("due_date", &self.due_date),
305        ]
306        .into_iter()
307    }
308
309    /// Every field name the patch can carry, in the contract's order. Used by the
310    /// CLI's help and by the "you named no fields" diagnostic.
311    pub fn field_names() -> &'static [&'static str] {
312        &[
313            "status",
314            "priority",
315            "assignee",
316            "labels",
317            "start-date",
318            "due-date",
319            "estimate",
320            "cycles",
321            "milestones",
322        ]
323    }
324
325    /// Structural problems that are **not** field-value validation.
326    ///
327    /// Field values are validated by the same cores as single-issue operations
328    /// (contract §5) — at plan time, per item, with the item's context added. What
329    /// is checked here is only what can be known without an issue: that the patch
330    /// is not empty, and that it is not two kinds of operation at once.
331    ///
332    /// A patch is one of *field change*, *relationship pair*, or *archive
333    /// delegation*. Combining them is refused rather than resolved, because
334    /// "what is the order" has no answer that is right for every pair of
335    /// operations, and an order invented here would be a policy nobody chose.
336    pub fn structural_problems(&self) -> Vec<PatchProblem> {
337        let mut out = Vec::new();
338        if self.is_empty() {
339            out.push(PatchProblem {
340                field: "patch",
341                message: format!(
342                    "no fields to change — name at least one of: {}",
343                    Self::field_names().join(", ")
344                ),
345            });
346            return out;
347        }
348        let kinds = [
349            self.relationship.is_some(),
350            self.archive.is_some(),
351            self.has_any_field(),
352        ];
353        if kinds.iter().filter(|k| **k).count() > 1 {
354            let named: Vec<&str> = [
355                self.relationship
356                    .as_ref()
357                    .map(|_| "a relationship operation"),
358                self.archive.as_ref().map(|_| "an archive request"),
359                (!self.has_any_field())
360                    .then_some("")
361                    .filter(|s| s.is_empty())
362                    .map(|_| "field changes"),
363            ]
364            .into_iter()
365            .flatten()
366            .collect();
367            out.push(PatchProblem {
368                field: "patch",
369                message: format!(
370                    "a patch is one of field changes, a relationship operation, or an \\
371                     archive request — this one names {}",
372                    named.join(" and ")
373                ),
374            });
375        }
376        out
377    }
378
379    /// Refuse the patch if it has structural problems, with the first message.
380    pub fn require_wellformed(&self) -> Result<()> {
381        match self.structural_problems().first() {
382            Some(problem) => Err(TixError::InvalidInput(problem.message.clone())),
383            None => Ok(()),
384        }
385    }
386}
387
388impl std::fmt::Display for PatchProblem {
389    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
390        write!(f, "{}: {}", self.field, self.message)
391    }
392}