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 /// Every operation this build **understands** it can also **perform**, so the
82 /// word is `available` for all of them. The field is retained rather than removed
83 /// because it is the one place a client reads representability from; a future
84 /// operation that is understood but not yet performable would report
85 /// `supported_not_applicable` here, which is why the vocabulary is a word rather
86 /// than a boolean.
87 pub fn availability(self) -> &'static str {
88 // Every operation this build understands is performable, so the word is the
89 // same for all of them; the `match` keeps the value derived from the
90 // operation rather than a free-floating constant.
91 match self {
92 Self::Edit | Self::Link | Self::Unlink | Self::Archive => "available",
93 }
94 }
95
96 /// Whether the operation can be **performed**, as opposed to planned.
97 ///
98 /// `true` for every operation this build understands: `archive` is performed by
99 /// delegating to the store's archive service ([`crate::bulk::archive`] →
100 /// `Store::archive`), so there is no longer an understood-but-unperformable
101 /// operation to report.
102 pub fn is_applicable(self) -> bool {
103 match self {
104 Self::Edit | Self::Link | Self::Unlink | Self::Archive => true,
105 }
106 }
107
108 /// One line for a schema description or a doc.
109 pub fn summary(self) -> &'static str {
110 match self {
111 Self::Edit => "change issue fields through a typed patch",
112 Self::Link => "add a relationship from each selected issue to a target",
113 Self::Unlink => "remove a relationship from each selected issue",
114 Self::Archive => {
115 "archive each selected issue by delegating to the store's archive \
116 service — the file is renamed in place to a dot-prefixed name, and one \
117 History event records it"
118 }
119 }
120 }
121}
122
123/// Every field a batch can change, and whether it can be emptied.
124///
125/// `clearable` is part of the projection rather than a detail of the apply: a remote
126/// surface that cannot represent "unset this field" cannot offer `--clear`, and a
127/// capability list that omitted the distinction would advertise a request it has to
128/// refuse.
129pub struct PatchField {
130 /// The name in `PatchFlags`, in the CLI's field order.
131 pub name: &'static str,
132 /// Whether `--clear <name>` is accepted.
133 pub clearable: bool,
134 /// One line for a schema description.
135 pub summary: &'static str,
136}
137
138/// The patch whitelist, in the order a surface should present it.
139///
140/// **`title` and `body` are absent, and that is the point.** C-008 makes an unlisted
141/// field mutation an escalation trigger, and this is the list that decides what is
142/// listed. A batch `title` rewrite is the one bulk operation nobody has ever asked
143/// for and everybody would regret, so its absence is a deliberate, stated boundary
144/// rather than an oversight — and a case asserts it stays absent.
145pub const PATCH_FIELDS: &[PatchField] = &[
146 PatchField {
147 name: "status",
148 clearable: false,
149 summary: "column, validated through the workflow service at apply time",
150 },
151 PatchField {
152 name: "priority",
153 clearable: true,
154 summary: "one of the three strict words `low`, `medium`, `high`",
155 },
156 PatchField {
157 name: "assignee",
158 clearable: true,
159 summary: "handle or full identity; changing it moves the file into the segment",
160 },
161 PatchField {
162 name: "labels",
163 clearable: true,
164 summary: "comma-separated; replaces the whole set",
165 },
166 PatchField {
167 name: "start-date",
168 clearable: true,
169 summary: "`YYYY-MM-DD`",
170 },
171 PatchField {
172 name: "due-date",
173 clearable: true,
174 summary: "`YYYY-MM-DD`; must be `>= start-date`",
175 },
176 PatchField {
177 name: "estimate",
178 clearable: true,
179 summary: "free text; a blank value is **unchanged**, not empty",
180 },
181 PatchField {
182 name: "cycles",
183 clearable: true,
184 summary: "comma-separated cycle names; replaces the whole set",
185 },
186 PatchField {
187 name: "milestones",
188 clearable: true,
189 summary: "comma-separated milestone names; replaces the whole set",
190 },
191];
192
193/// The outcome vocabulary every surface shares (`NFR-003`, `C-005`).
194///
195/// The **same four words, the same four meanings**, in the same order, on the CLI,
196/// in the TUI and over MCP. The TUI renders the CLI's `render_report` text and the
197/// MCP result is the CLI's `ApplyReport` projected to JSON, so this list is a
198/// *description* of what those two already do — it is not a second definition that
199/// could drift from them.
200pub const OUTCOME_STATUSES: &[&str] = &["applied", "no-op", "rejected", "failed"];
201
202/// The selection grammar, as a surface should describe it (`C-003`).
203///
204/// A new filter language is an escalation trigger, so the projection names the
205/// **existing** three and nothing else.
206pub const SELECTION_GRAMMAR: &[&str] = &[
207 "explicit: a comma-separated list of selectors — a number, a filename fragment, or a title fragment",
208 "filter: the existing structured filter grammar, e.g. `status:todo label:bug`",
209 "search: an existing search token, e.g. `search:fix crash`",
210];
211
212/// The selection size NFR-001 names, and the size above which progress is reported.
213pub const PREVIEW_SELECTION_LIMIT: usize = 10_000;
214
215/// The diagnostic name for an operation a surface cannot perform.
216///
217/// One of the contract's seven, reused rather than added. A surface asking for an
218/// operation or a field outside the capability list raises this name, so a client
219/// handles one string.
220pub const UNSUPPORTED_OPERATION: &str = "unsupported_operation";
221
222/// The refusal for an operation this build cannot perform.
223///
224/// Carries **both** facts a client needs: what it asked for, and why. A bare
225/// `unsupported_operation` tells a client nothing it can act on, and this mission's
226/// whole shape has been that a refusal names its cause.
227pub fn unsupported(operation: &str, reason: &str) -> TixError {
228 TixError::InvalidInput(format!(
229 "unsupported_operation: `{operation}` is not available in this build — {reason}"
230 ))
231}
232
233/// The refusal for a field outside the whitelist.
234///
235/// Also `unsupported_operation`, because "you named a field this batch does not
236/// carry" and "you named an operation this build cannot perform" are the same fact
237/// about the same capability list, and a client should not have to learn two names.
238pub fn unsupported_field(field: &str) -> TixError {
239 TixError::InvalidInput(format!(
240 "unsupported_operation: `{field}` is not a batch field — the whitelist is: {}. \
241 Bulk `title` and `body` rewrites are deliberately out of scope: a batch title \
242 rewrite is the one operation nobody has asked for and everybody would regret.",
243 field_names().join(", ")
244 ))
245}
246
247/// The whitelist, as names.
248pub fn field_names() -> Vec<&'static str> {
249 PATCH_FIELDS.iter().map(|f| f.name).collect()
250}
251
252/// The clearable subset, as names.
253pub fn clearable_field_names() -> Vec<&'static str> {
254 PATCH_FIELDS
255 .iter()
256 .filter(|f| f.clearable)
257 .map(|f| f.name)
258 .collect()
259}
260
261/// Whether `field` is in the whitelist.
262pub fn is_supported_field(field: &str) -> bool {
263 PATCH_FIELDS.iter().any(|f| f.name == field)
264}
265
266/// The refusal for an unsupported field, or `Ok(())`.
267///
268/// Called at the boundary — the MCP handler and the TUI's `set_row` — so a field the
269/// whitelist does not carry is refused **before** a plan is built, rather than
270/// appearing as a no-op in a plan that silently ignored it.
271pub fn check_field(field: &str) -> Result<()> {
272 if is_supported_field(field) {
273 Ok(())
274 } else {
275 Err(unsupported_field(field))
276 }
277}
278
279/// The refusal for an operation this build does not **understand**, or `Ok(())`.
280///
281/// **Understood, not malformed.** This is the request-boundary check, and it refuses
282/// only a name that is not a bulk operation at all (`frobnicate`), because there is
283/// no plan to build for one. Every understood operation — `archive` included — passes
284/// it: whether the build can *perform* the operation is a separate question, and it is
285/// asked at the apply (see [`check_applicable`]) where the selection is known and a
286/// refusal can name each issue.
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.
301///
302/// Every operation this build understands it can also perform — `archive` delegates to
303/// the store's archive service ([`crate::bulk::archive`]) — so this gate admits all of
304/// them today. It is retained, and kept separate from [`check_operation`]'s
305/// malformed-operation refusal, because the two are different questions: "is this a
306/// bulk operation" is answered at the boundary, and "can this build perform it" is
307/// answered here. Collapsing them would put a performability refusal where no
308/// selection has been resolved yet.
309pub fn check_applicable(operation: BulkOperation) -> Result<()> {
310 if operation.is_applicable() {
311 Ok(())
312 } else {
313 Err(unsupported(
314 operation.as_str(),
315 "this build understands the operation but cannot perform it",
316 ))
317 }
318}
319
320/// The whole capability set, as a value a surface can serialise.
321///
322/// One projection rather than four, so "what can a batch do" has one answer no
323/// matter who is asked. `applicable` is separate from `supported` on purpose: an
324/// operation can be *understood* and still not be *performable*, and a capability
325/// list that collapsed the two would either lie about the archive service or hide
326/// that a request for it is at least well-formed.
327#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
328pub struct BulkCapabilities {
329 /// Every operation this build understands.
330 pub operations: Vec<OperationCapability>,
331 /// The patch whitelist, in presentation order.
332 pub fields: Vec<FieldCapability>,
333 /// The shared outcome vocabulary.
334 pub outcome_statuses: Vec<String>,
335 /// The selection grammar, as prose lines.
336 pub selection_grammar: Vec<String>,
337 /// Whether a preview is read-only on every surface.
338 pub preview_is_read_only: bool,
339 /// Whether an apply requires an explicit confirmation argument.
340 pub apply_requires_confirmation: bool,
341 /// How a large selection behaves (NFR-001).
342 ///
343 /// `None` would leave NFR-001's second half — "**or** reports a bounded progress
344 /// state" — as an undiscoverable property, so a client could only find out by
345 /// timing a call. Stated here, the obligation is met by a surface reading the
346 /// projection rather than by a benchmark someone ran once.
347 pub progress: Option<ProgressReporting>,
348}
349
350/// NFR-001's reporting half.
351#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
352pub struct ProgressReporting {
353 /// The selection size above which a surface reports progress rather than
354 /// pretending the work is instantaneous.
355 pub preview_selection_limit: usize,
356 /// Whether the surfaces actually report bounded progress.
357 pub reports_bounded_progress: bool,
358 /// What a client sees when the limit is exceeded.
359 pub fallback: String,
360}
361
362/// One operation's capability.
363#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
364pub struct OperationCapability {
365 pub name: String,
366 pub summary: String,
367 /// One word, not two booleans.
368 ///
369 /// Every operation this build understands is performable, so this is `available`
370 /// for all of them; it is a word rather than a boolean so a future operation that
371 /// is understood but not yet performable can report `supported_not_applicable`
372 /// without a client having to learn a second field.
373 pub availability: &'static str,
374 /// What the request must also carry, besides a selection: `target`, `kind`, or
375 /// nothing. A list rather than two bools, so adding a requirement is a new entry
376 /// rather than a new field every client has to learn about.
377 pub requires: Vec<&'static str>,
378}
379
380/// One field's capability.
381#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
382pub struct FieldCapability {
383 pub name: String,
384 pub summary: String,
385 pub clearable: bool,
386}
387
388/// The capability set, built once.
389pub fn capabilities() -> &'static BulkCapabilities {
390 use std::sync::OnceLock;
391 static CAPS: OnceLock<BulkCapabilities> = OnceLock::new();
392 CAPS.get_or_init(|| BulkCapabilities {
393 operations: BulkOperation::ALL
394 .iter()
395 .map(|op| {
396 let mut requires = Vec::new();
397 if op.needs_target() {
398 requires.push("target");
399 }
400 if op.needs_kind() {
401 requires.push("kind");
402 }
403 OperationCapability {
404 name: op.as_str().to_string(),
405 summary: op.summary().to_string(),
406 availability: op.availability(),
407 requires,
408 }
409 })
410 .collect(),
411 fields: PATCH_FIELDS
412 .iter()
413 .map(|f| FieldCapability {
414 name: f.name.to_string(),
415 summary: f.summary.to_string(),
416 clearable: f.clearable,
417 })
418 .collect(),
419 outcome_statuses: OUTCOME_STATUSES.iter().map(|s| s.to_string()).collect(),
420 selection_grammar: SELECTION_GRAMMAR.iter().map(|s| s.to_string()).collect(),
421 // C-004. Stated rather than assumed, and a case asserts the apply refuses
422 // without the confirmation argument.
423 preview_is_read_only: true,
424 apply_requires_confirmation: true,
425 progress: Some(ProgressReporting {
426 // The number NFR-001 names, used as a constant rather than a literal at
427 // the call site: it is a contract figure, and a contract figure that can
428 // be written differently in two places is a contract figure that will be.
429 preview_selection_limit: PREVIEW_SELECTION_LIMIT,
430 reports_bounded_progress: true,
431 fallback: "A preview over this many issues reports bounded progress \
432 (every Nth item) rather than going silent, and a surface with no \
433 progress channel — a one-shot CLI run — reports it as a line \
434 before the plan and after it."
435 .to_string(),
436 }),
437 })
438}
439
440/// The capability set as JSON, for the MCP tool and a remote-capability query.
441///
442/// Built from [`capabilities`] so there is no second description of the same list in
443/// a hand-written `json!` block that could disagree with it.
444pub fn capabilities_json() -> serde_json::Value {
445 use serde_json::json;
446 let caps = capabilities();
447 json!({
448 "operations": caps.operations,
449 "fields": caps.fields,
450 "outcome_statuses": caps.outcome_statuses,
451 "selection_grammar": caps.selection_grammar,
452 "preview_is_read_only": caps.preview_is_read_only,
453 "apply_requires_confirmation": caps.apply_requires_confirmation,
454 })
455}
456
457/// The whitelist and the `PatchFlags` struct must agree, in both directions.
458///
459/// This is the check that makes the projection load-bearing rather than decorative.
460/// A field in `PatchFlags` and not in `PATCH_FIELDS` is a field a surface cannot
461/// offer; a field in `PATCH_FIELDS` and not in `PatchFlags` is a field a surface
462/// would advertise and the apply would ignore. Both are silent, and both are exactly
463/// the drift C-008 forbids.
464///
465/// Returns the offending names rather than panicking: a caller at startup wants a
466/// message it can print, and a test wants the list.
467pub fn disagreements_with_flags() -> Vec<String> {
468 let mut problems = Vec::new();
469 let flag_rows: Vec<&str> = PatchFlags::field_rows()
470 .iter()
471 .copied()
472 .filter(|row| *row != "clear")
473 .collect();
474 for name in &flag_rows {
475 if !is_supported_field(name) {
476 problems.push(format!(
477 "PatchFlags carries `{name}`, the whitelist does not"
478 ));
479 }
480 }
481 for name in field_names() {
482 if !flag_rows.contains(&name) {
483 problems.push(format!(
484 "the whitelist carries `{name}`, PatchFlags does not"
485 ));
486 }
487 }
488 let clearable = clearable_field_names();
489 for name in PatchFlags::clearable() {
490 if !clearable.contains(name) {
491 problems.push(format!(
492 "PatchFlags calls `{name}` clearable, the whitelist does not"
493 ));
494 }
495 }
496 problems
497}