Skip to main content

tix/bulk/
metadata.rs

1//! The portable capability projection for bulk operations (WP08 T037).
2//!
3//! ## Why this module exists
4//!
5//! `contracts/surface-parity.md` requires four surfaces to expose the same preview
6//! and outcome data, and says the portable/remote projection "may report
7//! representability, but this Mission does not change remote synchronization
8//! semantics or introduce a second conflict model". So the honest shape is a
9//! **declarative list of what a batch can do**, owned in one place, that the MCP
10//! tool schema, the docs and a remote-capability query all read.
11//!
12//! It is a list rather than a flag per surface because the interesting failure is
13//! drift: a field that is supported in the schema but not in the apply, or an
14//! operation listed as supported whose service does not exist. A single list with a
15//! **machine-checked** relationship to `PatchFlags` makes both impossible to state.
16//!
17//! ## What it deliberately does not do
18//!
19//! - **It does not change remote conflict or sync semantics.** It reports
20//!   representability. C-008 makes a remote semantic change an escalation trigger,
21//!   and this is not one.
22//! - **It does not invent an eighth diagnostic.** The diagnostic names are the
23//!   contract's seven; an unsupported *operation* reuses `unsupported_operation`,
24//!   which is one of them, rather than adding a new one.
25//! - **It does not list a field the apply cannot honour.** Every entry in
26//!   [`PATCH_FIELDS`] is a name `PatchFlags` carries and `IssuePatch` has a slot
27//!   for, and a case asserts the two sets are equal.
28
29use crate::commands::bulk::PatchFlags;
30use crate::error::{Result, TixError};
31
32/// A bulk operation a surface can perform.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum BulkOperation {
35    /// Change issue fields through a typed patch.
36    Edit,
37    /// Add a relationship to each selected issue.
38    Link,
39    /// Remove a relationship from each selected issue.
40    Unlink,
41    /// Delegate to the archive service (source issue 190).
42    Archive,
43}
44
45impl BulkOperation {
46    /// Every operation, in the order the CLI lists them.
47    pub const ALL: &'static [Self] = &[Self::Edit, Self::Link, Self::Unlink, Self::Archive];
48
49    /// The name a surface, a schema and a doc all use.
50    pub fn as_str(self) -> &'static str {
51        match self {
52            Self::Edit => "edit",
53            Self::Link => "link",
54            Self::Unlink => "unlink",
55            Self::Archive => "archive",
56        }
57    }
58
59    /// Parse a name, or `None`.
60    pub fn from_name(name: &str) -> Option<Self> {
61        Self::ALL.iter().copied().find(|op| op.as_str() == name)
62    }
63
64    /// Whether the operation needs a second endpoint.
65    pub fn needs_target(self) -> bool {
66        matches!(self, Self::Link | Self::Unlink)
67    }
68
69    /// Whether the operation needs a relationship kind.
70    pub fn needs_kind(self) -> bool {
71        matches!(self, Self::Link | Self::Unlink)
72    }
73
74    /// Whether the operation changes fields rather than edges.
75    pub fn is_field_patch(self) -> bool {
76        matches!(self, Self::Edit)
77    }
78
79    /// The one availability word a client reads.
80    ///
81    /// Three states, and the middle one is the whole reason this is an enum: the
82    /// archive operation is understood by this build and not performable by it, and a
83    /// client that must distinguish "you asked for something malformed" from "you
84    /// asked for something this build cannot do yet" has to be able to.
85    pub fn availability(self) -> &'static str {
86        match self {
87            Self::Archive => "supported_not_applicable",
88            _ => "available",
89        }
90    }
91
92    /// Whether the operation can be **performed**, as opposed to planned.
93    ///
94    /// `archive` is `false` because the archive service is a *planned* mission
95    /// (#190, `issue-archive-restore-01M3C4JF`): `bulk_archive` does not exist, so
96    /// the honest answer is "supported as a request, refused at apply with a named
97    /// diagnostic". Reporting it as supported would be inventing a capability, and
98    /// C-007 makes temporary archive behaviour an escalation trigger.
99    pub fn is_applicable(self) -> bool {
100        !matches!(self, Self::Archive)
101    }
102
103    /// One line for a schema description or a doc.
104    pub fn summary(self) -> &'static str {
105        match self {
106            Self::Edit => "change issue fields through a typed patch",
107            Self::Link => "add a relationship from each selected issue to a target",
108            Self::Unlink => "remove a relationship from each selected issue",
109            Self::Archive => {
110                "delegate to the archive service (source issue 190); the service is a \
111                 planned mission, so the apply is a named refusal until it lands"
112            }
113        }
114    }
115}
116
117/// Every field a batch can change, and whether it can be emptied.
118///
119/// `clearable` is part of the projection rather than a detail of the apply: a remote
120/// surface that cannot represent "unset this field" cannot offer `--clear`, and a
121/// capability list that omitted the distinction would advertise a request it has to
122/// refuse.
123pub struct PatchField {
124    /// The name in `PatchFlags`, in the CLI's field order.
125    pub name: &'static str,
126    /// Whether `--clear <name>` is accepted.
127    pub clearable: bool,
128    /// One line for a schema description.
129    pub summary: &'static str,
130}
131
132/// The patch whitelist, in the order a surface should present it.
133///
134/// **`title` and `body` are absent, and that is the point.** C-008 makes an unlisted
135/// field mutation an escalation trigger, and this is the list that decides what is
136/// listed. A batch `title` rewrite is the one bulk operation nobody has ever asked
137/// for and everybody would regret, so its absence is a deliberate, stated boundary
138/// rather than an oversight — and a case asserts it stays absent.
139pub const PATCH_FIELDS: &[PatchField] = &[
140    PatchField {
141        name: "status",
142        clearable: false,
143        summary: "column, validated through the workflow service at apply time",
144    },
145    PatchField {
146        name: "priority",
147        clearable: true,
148        summary: "one of the three strict words `low`, `medium`, `high`",
149    },
150    PatchField {
151        name: "assignee",
152        clearable: true,
153        summary: "handle or full identity; changing it moves the file into the segment",
154    },
155    PatchField {
156        name: "labels",
157        clearable: true,
158        summary: "comma-separated; replaces the whole set",
159    },
160    PatchField {
161        name: "start-date",
162        clearable: true,
163        summary: "`YYYY-MM-DD`",
164    },
165    PatchField {
166        name: "due-date",
167        clearable: true,
168        summary: "`YYYY-MM-DD`; must be `>= start-date`",
169    },
170    PatchField {
171        name: "estimate",
172        clearable: true,
173        summary: "free text; a blank value is **unchanged**, not empty",
174    },
175    PatchField {
176        name: "cycles",
177        clearable: true,
178        summary: "comma-separated cycle names; replaces the whole set",
179    },
180    PatchField {
181        name: "milestones",
182        clearable: true,
183        summary: "comma-separated milestone names; replaces the whole set",
184    },
185];
186
187/// The outcome vocabulary every surface shares (`NFR-003`, `C-005`).
188///
189/// The **same four words, the same four meanings**, in the same order, on the CLI,
190/// in the TUI and over MCP. The TUI renders the CLI's `render_report` text and the
191/// MCP result is the CLI's `ApplyReport` projected to JSON, so this list is a
192/// *description* of what those two already do — it is not a second definition that
193/// could drift from them.
194pub const OUTCOME_STATUSES: &[&str] = &["applied", "no-op", "rejected", "failed"];
195
196/// The selection grammar, as a surface should describe it (`C-003`).
197///
198/// A new filter language is an escalation trigger, so the projection names the
199/// **existing** three and nothing else.
200pub const SELECTION_GRAMMAR: &[&str] = &[
201    "explicit: a comma-separated list of selectors — a number, a filename fragment, or a title fragment",
202    "filter: the existing structured filter grammar, e.g. `status:todo label:bug`",
203    "search: an existing search token, e.g. `search:fix crash`",
204];
205
206/// The selection size NFR-001 names, and the size above which progress is reported.
207pub const PREVIEW_SELECTION_LIMIT: usize = 10_000;
208
209/// The diagnostic name for an operation a surface cannot perform.
210///
211/// One of the contract's seven, reused rather than added. The apply already raises
212/// it for the archive service that does not exist; a surface asking for something
213/// outside the whitelist raises the same name, so a client handles one string.
214pub const UNSUPPORTED_OPERATION: &str = "unsupported_operation";
215
216/// The refusal for an operation this build cannot perform.
217///
218/// Carries **both** facts a client needs: what it asked for, and why. A bare
219/// `unsupported_operation` tells a client nothing it can act on, and this mission's
220/// whole shape has been that a refusal names its cause — the archive delegation is
221/// the worked example.
222pub fn unsupported(operation: &str, reason: &str) -> TixError {
223    TixError::InvalidInput(format!(
224        "unsupported_operation: `{operation}` is not available in this build — {reason}"
225    ))
226}
227
228/// The refusal for a field outside the whitelist.
229///
230/// Also `unsupported_operation`, because "you named a field this batch does not
231/// carry" and "you named an operation this build cannot perform" are the same fact
232/// about the same capability list, and a client should not have to learn two names.
233pub fn unsupported_field(field: &str) -> TixError {
234    TixError::InvalidInput(format!(
235        "unsupported_operation: `{field}` is not a batch field — the whitelist is: {}. \
236         Bulk `title` and `body` rewrites are deliberately out of scope: a batch title \
237         rewrite is the one operation nobody has asked for and everybody would regret.",
238        field_names().join(", ")
239    ))
240}
241
242/// The whitelist, as names.
243pub fn field_names() -> Vec<&'static str> {
244    PATCH_FIELDS.iter().map(|f| f.name).collect()
245}
246
247/// The clearable subset, as names.
248pub fn clearable_field_names() -> Vec<&'static str> {
249    PATCH_FIELDS
250        .iter()
251        .filter(|f| f.clearable)
252        .map(|f| f.name)
253        .collect()
254}
255
256/// Whether `field` is in the whitelist.
257pub fn is_supported_field(field: &str) -> bool {
258    PATCH_FIELDS.iter().any(|f| f.name == field)
259}
260
261/// The refusal for an unsupported field, or `Ok(())`.
262///
263/// Called at the boundary — the MCP handler and the TUI's `set_row` — so a field the
264/// whitelist does not carry is refused **before** a plan is built, rather than
265/// appearing as a no-op in a plan that silently ignored it.
266pub fn check_field(field: &str) -> Result<()> {
267    if is_supported_field(field) {
268        Ok(())
269    } else {
270        Err(unsupported_field(field))
271    }
272}
273
274/// The refusal for an operation this build does not **understand**, or `Ok(())`.
275///
276/// **Understood, not performable.** `archive` passes this check on purpose. The first
277/// draft refused it here, which meant the refusal arrived *before* the plan was built
278/// and therefore named no issue — so a client that sent `archive` for ten issues got
279/// one line about a mission and no way to tell which ten it was about. That is
280/// precisely the failure WP05's CLI avoids by printing the plan before attempting the
281/// apply, and this handler was re-introducing it at the wrong layer.
282///
283/// So the boundary is: a **malformed** operation is refused here (there is nothing to
284/// build a plan for), and an operation that is well-formed but not performable is
285/// refused at the apply, where the selection is known and can be named per item. See
286/// [`check_applicable`].
287pub fn check_operation(operation: &str) -> Result<()> {
288    match BulkOperation::from_name(operation) {
289        Some(_) => Ok(()),
290        None => Err(unsupported(
291            operation,
292            "a bulk operation is one of: edit, link, unlink, archive",
293        )),
294    }
295}
296
297/// The refusal for an operation this build cannot **perform**, or `Ok(())`.
298///
299/// Called at the apply, never at the request boundary, so the caller already holds the
300/// plan and can report the refusal against every selected issue.
301pub fn check_applicable(operation: BulkOperation) -> Result<()> {
302    if operation.is_applicable() {
303        Ok(())
304    } else {
305        Err(unsupported(
306            operation.as_str(),
307            "the archive service is mission issue-archive-restore-01M3C4JF, which has \
308             not been implemented; this path will delegate to its `bulk_archive` once \
309             it lands, and it will not implement archiving itself",
310        ))
311    }
312}
313
314/// The whole capability set, as a value a surface can serialise.
315///
316/// One projection rather than four, so "what can a batch do" has one answer no
317/// matter who is asked. `applicable` is separate from `supported` on purpose: an
318/// operation can be *understood* and still not be *performable*, and a capability
319/// list that collapsed the two would either lie about the archive service or hide
320/// that a request for it is at least well-formed.
321#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
322pub struct BulkCapabilities {
323    /// Every operation this build understands.
324    pub operations: Vec<OperationCapability>,
325    /// The patch whitelist, in presentation order.
326    pub fields: Vec<FieldCapability>,
327    /// The shared outcome vocabulary.
328    pub outcome_statuses: Vec<String>,
329    /// The selection grammar, as prose lines.
330    pub selection_grammar: Vec<String>,
331    /// Whether a preview is read-only on every surface.
332    pub preview_is_read_only: bool,
333    /// Whether an apply requires an explicit confirmation argument.
334    pub apply_requires_confirmation: bool,
335    /// How a large selection behaves (NFR-001).
336    ///
337    /// `None` would leave NFR-001's second half — "**or** reports a bounded progress
338    /// state" — as an undiscoverable property, so a client could only find out by
339    /// timing a call. Stated here, the obligation is met by a surface reading the
340    /// projection rather than by a benchmark someone ran once.
341    pub progress: Option<ProgressReporting>,
342}
343
344/// NFR-001's reporting half.
345#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
346pub struct ProgressReporting {
347    /// The selection size above which a surface reports progress rather than
348    /// pretending the work is instantaneous.
349    pub preview_selection_limit: usize,
350    /// Whether the surfaces actually report bounded progress.
351    pub reports_bounded_progress: bool,
352    /// What a client sees when the limit is exceeded.
353    pub fallback: String,
354}
355
356/// One operation's capability.
357#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
358pub struct OperationCapability {
359    pub name: String,
360    pub summary: String,
361    /// One word, not two booleans.
362    ///
363    /// The first draft carried `supported` **and** `applicable`, which is four bools
364    /// on one struct and — more to the point — a shape a client has to know the
365    /// meaning of. They are not redundant only in the archive case, and that is the
366    /// case that matters: an operation can be *understood* and still not be
367    /// *performable*, and collapsing the two into one word would either have to call
368    /// archive "supported" (a lie a client acts on) or "unavailable" (which loses the
369    /// fact that the request was at least well-formed). So one enum with three
370    /// states, and a client reads a word rather than a pair of flags.
371    pub availability: &'static str,
372    /// What the request must also carry, besides a selection: `target`, `kind`, or
373    /// nothing. A list rather than two bools, so adding a requirement is a new entry
374    /// rather than a new field every client has to learn about.
375    pub requires: Vec<&'static str>,
376}
377
378/// One field's capability.
379#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
380pub struct FieldCapability {
381    pub name: String,
382    pub summary: String,
383    pub clearable: bool,
384}
385
386/// The capability set, built once.
387pub fn capabilities() -> &'static BulkCapabilities {
388    use std::sync::OnceLock;
389    static CAPS: OnceLock<BulkCapabilities> = OnceLock::new();
390    CAPS.get_or_init(|| BulkCapabilities {
391        operations: BulkOperation::ALL
392            .iter()
393            .map(|op| {
394                let mut requires = Vec::new();
395                if op.needs_target() {
396                    requires.push("target");
397                }
398                if op.needs_kind() {
399                    requires.push("kind");
400                }
401                OperationCapability {
402                    name: op.as_str().to_string(),
403                    summary: op.summary().to_string(),
404                    availability: op.availability(),
405                    requires,
406                }
407            })
408            .collect(),
409        fields: PATCH_FIELDS
410            .iter()
411            .map(|f| FieldCapability {
412                name: f.name.to_string(),
413                summary: f.summary.to_string(),
414                clearable: f.clearable,
415            })
416            .collect(),
417        outcome_statuses: OUTCOME_STATUSES.iter().map(|s| s.to_string()).collect(),
418        selection_grammar: SELECTION_GRAMMAR.iter().map(|s| s.to_string()).collect(),
419        // C-004. Stated rather than assumed, and a case asserts the apply refuses
420        // without the confirmation argument.
421        preview_is_read_only: true,
422        apply_requires_confirmation: true,
423        progress: Some(ProgressReporting {
424            // The number NFR-001 names, used as a constant rather than a literal at
425            // the call site: it is a contract figure, and a contract figure that can
426            // be written differently in two places is a contract figure that will be.
427            preview_selection_limit: PREVIEW_SELECTION_LIMIT,
428            reports_bounded_progress: true,
429            fallback: "A preview over this many issues reports bounded progress \
430                       (every Nth item) rather than going silent, and a surface with no \
431                       progress channel — a one-shot CLI run — reports it as a line \
432                       before the plan and after it."
433                .to_string(),
434        }),
435    })
436}
437
438/// The capability set as JSON, for the MCP tool and a remote-capability query.
439///
440/// Built from [`capabilities`] so there is no second description of the same list in
441/// a hand-written `json!` block that could disagree with it.
442pub fn capabilities_json() -> serde_json::Value {
443    use serde_json::json;
444    let caps = capabilities();
445    json!({
446        "operations": caps.operations,
447        "fields": caps.fields,
448        "outcome_statuses": caps.outcome_statuses,
449        "selection_grammar": caps.selection_grammar,
450        "preview_is_read_only": caps.preview_is_read_only,
451        "apply_requires_confirmation": caps.apply_requires_confirmation,
452    })
453}
454
455/// The whitelist and the `PatchFlags` struct must agree, in both directions.
456///
457/// This is the check that makes the projection load-bearing rather than decorative.
458/// A field in `PatchFlags` and not in `PATCH_FIELDS` is a field a surface cannot
459/// offer; a field in `PATCH_FIELDS` and not in `PatchFlags` is a field a surface
460/// would advertise and the apply would ignore. Both are silent, and both are exactly
461/// the drift C-008 forbids.
462///
463/// Returns the offending names rather than panicking: a caller at startup wants a
464/// message it can print, and a test wants the list.
465pub fn disagreements_with_flags() -> Vec<String> {
466    let mut problems = Vec::new();
467    let flag_rows: Vec<&str> = PatchFlags::field_rows()
468        .iter()
469        .copied()
470        .filter(|row| *row != "clear")
471        .collect();
472    for name in &flag_rows {
473        if !is_supported_field(name) {
474            problems.push(format!(
475                "PatchFlags carries `{name}`, the whitelist does not"
476            ));
477        }
478    }
479    for name in field_names() {
480        if !flag_rows.contains(&name) {
481            problems.push(format!(
482                "the whitelist carries `{name}`, PatchFlags does not"
483            ));
484        }
485    }
486    let clearable = clearable_field_names();
487    for name in PatchFlags::clearable() {
488        if !clearable.contains(name) {
489            problems.push(format!(
490                "PatchFlags calls `{name}` clearable, the whitelist does not"
491            ));
492        }
493    }
494    problems
495}