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}