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    /// 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}