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}