tix-tracker 0.10.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
//! The portable capability projection for bulk operations (WP08 T037).
//!
//! ## Why this module exists
//!
//! `contracts/surface-parity.md` requires four surfaces to expose the same preview
//! and outcome data, and says the portable/remote projection "may report
//! representability, but this Mission does not change remote synchronization
//! semantics or introduce a second conflict model". So the honest shape is a
//! **declarative list of what a batch can do**, owned in one place, that the MCP
//! tool schema, the docs and a remote-capability query all read.
//!
//! It is a list rather than a flag per surface because the interesting failure is
//! drift: a field that is supported in the schema but not in the apply, or an
//! operation listed as supported whose service does not exist. A single list with a
//! **machine-checked** relationship to `PatchFlags` makes both impossible to state.
//!
//! ## What it deliberately does not do
//!
//! - **It does not change remote conflict or sync semantics.** It reports
//!   representability. C-008 makes a remote semantic change an escalation trigger,
//!   and this is not one.
//! - **It does not invent an eighth diagnostic.** The diagnostic names are the
//!   contract's seven; an unsupported *operation* reuses `unsupported_operation`,
//!   which is one of them, rather than adding a new one.
//! - **It does not list a field the apply cannot honour.** Every entry in
//!   [`PATCH_FIELDS`] is a name `PatchFlags` carries and `IssuePatch` has a slot
//!   for, and a case asserts the two sets are equal.

use crate::commands::bulk::PatchFlags;
use crate::error::{Result, TixError};

/// A bulk operation a surface can perform.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BulkOperation {
    /// Change issue fields through a typed patch.
    Edit,
    /// Add a relationship to each selected issue.
    Link,
    /// Remove a relationship from each selected issue.
    Unlink,
    /// Delegate to the archive service (source issue 190).
    Archive,
}

impl BulkOperation {
    /// Every operation, in the order the CLI lists them.
    pub const ALL: &'static [Self] = &[Self::Edit, Self::Link, Self::Unlink, Self::Archive];

    /// The name a surface, a schema and a doc all use.
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Edit => "edit",
            Self::Link => "link",
            Self::Unlink => "unlink",
            Self::Archive => "archive",
        }
    }

    /// Parse a name, or `None`.
    pub fn from_name(name: &str) -> Option<Self> {
        Self::ALL.iter().copied().find(|op| op.as_str() == name)
    }

    /// Whether the operation needs a second endpoint.
    pub fn needs_target(self) -> bool {
        matches!(self, Self::Link | Self::Unlink)
    }

    /// Whether the operation needs a relationship kind.
    pub fn needs_kind(self) -> bool {
        matches!(self, Self::Link | Self::Unlink)
    }

    /// Whether the operation changes fields rather than edges.
    pub fn is_field_patch(self) -> bool {
        matches!(self, Self::Edit)
    }

    /// The one availability word a client reads.
    ///
    /// Every operation this build **understands** it can also **perform**, so the
    /// word is `available` for all of them. The field is retained rather than removed
    /// because it is the one place a client reads representability from; a future
    /// operation that is understood but not yet performable would report
    /// `supported_not_applicable` here, which is why the vocabulary is a word rather
    /// than a boolean.
    pub fn availability(self) -> &'static str {
        // Every operation this build understands is performable, so the word is the
        // same for all of them; the `match` keeps the value derived from the
        // operation rather than a free-floating constant.
        match self {
            Self::Edit | Self::Link | Self::Unlink | Self::Archive => "available",
        }
    }

    /// Whether the operation can be **performed**, as opposed to planned.
    ///
    /// `true` for every operation this build understands: `archive` is performed by
    /// delegating to the store's archive service ([`crate::bulk::archive`] →
    /// `Store::archive`), so there is no longer an understood-but-unperformable
    /// operation to report.
    pub fn is_applicable(self) -> bool {
        match self {
            Self::Edit | Self::Link | Self::Unlink | Self::Archive => true,
        }
    }

    /// One line for a schema description or a doc.
    pub fn summary(self) -> &'static str {
        match self {
            Self::Edit => "change issue fields through a typed patch",
            Self::Link => "add a relationship from each selected issue to a target",
            Self::Unlink => "remove a relationship from each selected issue",
            Self::Archive => {
                "archive each selected issue by delegating to the store's archive \
                 service — the file is renamed in place to a dot-prefixed name, and one \
                 History event records it"
            }
        }
    }
}

/// Every field a batch can change, and whether it can be emptied.
///
/// `clearable` is part of the projection rather than a detail of the apply: a remote
/// surface that cannot represent "unset this field" cannot offer `--clear`, and a
/// capability list that omitted the distinction would advertise a request it has to
/// refuse.
pub struct PatchField {
    /// The name in `PatchFlags`, in the CLI's field order.
    pub name: &'static str,
    /// Whether `--clear <name>` is accepted.
    pub clearable: bool,
    /// One line for a schema description.
    pub summary: &'static str,
}

/// The patch whitelist, in the order a surface should present it.
///
/// **`title` and `body` are absent, and that is the point.** C-008 makes an unlisted
/// field mutation an escalation trigger, and this is the list that decides what is
/// listed. A batch `title` rewrite is the one bulk operation nobody has ever asked
/// for and everybody would regret, so its absence is a deliberate, stated boundary
/// rather than an oversight — and a case asserts it stays absent.
pub const PATCH_FIELDS: &[PatchField] = &[
    PatchField {
        name: "status",
        clearable: false,
        summary: "column, validated through the workflow service at apply time",
    },
    PatchField {
        name: "priority",
        clearable: true,
        summary: "one of the three strict words `low`, `medium`, `high`",
    },
    PatchField {
        name: "assignee",
        clearable: true,
        summary: "handle or full identity; changing it moves the file into the segment",
    },
    PatchField {
        name: "labels",
        clearable: true,
        summary: "comma-separated; replaces the whole set",
    },
    PatchField {
        name: "start-date",
        clearable: true,
        summary: "`YYYY-MM-DD`",
    },
    PatchField {
        name: "due-date",
        clearable: true,
        summary: "`YYYY-MM-DD`; must be `>= start-date`",
    },
    PatchField {
        name: "estimate",
        clearable: true,
        summary: "free text; a blank value is **unchanged**, not empty",
    },
    PatchField {
        name: "cycles",
        clearable: true,
        summary: "comma-separated cycle names; replaces the whole set",
    },
    PatchField {
        name: "milestones",
        clearable: true,
        summary: "comma-separated milestone names; replaces the whole set",
    },
];

/// The outcome vocabulary every surface shares (`NFR-003`, `C-005`).
///
/// The **same four words, the same four meanings**, in the same order, on the CLI,
/// in the TUI and over MCP. The TUI renders the CLI's `render_report` text and the
/// MCP result is the CLI's `ApplyReport` projected to JSON, so this list is a
/// *description* of what those two already do — it is not a second definition that
/// could drift from them.
pub const OUTCOME_STATUSES: &[&str] = &["applied", "no-op", "rejected", "failed"];

/// The selection grammar, as a surface should describe it (`C-003`).
///
/// A new filter language is an escalation trigger, so the projection names the
/// **existing** three and nothing else.
pub const SELECTION_GRAMMAR: &[&str] = &[
    "explicit: a comma-separated list of selectors — a number, a filename fragment, or a title fragment",
    "filter: the existing structured filter grammar, e.g. `status:todo label:bug`",
    "search: an existing search token, e.g. `search:fix crash`",
];

/// The selection size NFR-001 names, and the size above which progress is reported.
pub const PREVIEW_SELECTION_LIMIT: usize = 10_000;

/// The diagnostic name for an operation a surface cannot perform.
///
/// One of the contract's seven, reused rather than added. A surface asking for an
/// operation or a field outside the capability list raises this name, so a client
/// handles one string.
pub const UNSUPPORTED_OPERATION: &str = "unsupported_operation";

/// The refusal for an operation this build cannot perform.
///
/// Carries **both** facts a client needs: what it asked for, and why. A bare
/// `unsupported_operation` tells a client nothing it can act on, and this mission's
/// whole shape has been that a refusal names its cause.
pub fn unsupported(operation: &str, reason: &str) -> TixError {
    TixError::InvalidInput(format!(
        "unsupported_operation: `{operation}` is not available in this build — {reason}"
    ))
}

/// The refusal for a field outside the whitelist.
///
/// Also `unsupported_operation`, because "you named a field this batch does not
/// carry" and "you named an operation this build cannot perform" are the same fact
/// about the same capability list, and a client should not have to learn two names.
pub fn unsupported_field(field: &str) -> TixError {
    TixError::InvalidInput(format!(
        "unsupported_operation: `{field}` is not a batch field — the whitelist is: {}. \
         Bulk `title` and `body` rewrites are deliberately out of scope: a batch title \
         rewrite is the one operation nobody has asked for and everybody would regret.",
        field_names().join(", ")
    ))
}

/// The whitelist, as names.
pub fn field_names() -> Vec<&'static str> {
    PATCH_FIELDS.iter().map(|f| f.name).collect()
}

/// The clearable subset, as names.
pub fn clearable_field_names() -> Vec<&'static str> {
    PATCH_FIELDS
        .iter()
        .filter(|f| f.clearable)
        .map(|f| f.name)
        .collect()
}

/// Whether `field` is in the whitelist.
pub fn is_supported_field(field: &str) -> bool {
    PATCH_FIELDS.iter().any(|f| f.name == field)
}

/// The refusal for an unsupported field, or `Ok(())`.
///
/// Called at the boundary — the MCP handler and the TUI's `set_row` — so a field the
/// whitelist does not carry is refused **before** a plan is built, rather than
/// appearing as a no-op in a plan that silently ignored it.
pub fn check_field(field: &str) -> Result<()> {
    if is_supported_field(field) {
        Ok(())
    } else {
        Err(unsupported_field(field))
    }
}

/// The refusal for an operation this build does not **understand**, or `Ok(())`.
///
/// **Understood, not malformed.** This is the request-boundary check, and it refuses
/// only a name that is not a bulk operation at all (`frobnicate`), because there is
/// no plan to build for one. Every understood operation — `archive` included — passes
/// it: whether the build can *perform* the operation is a separate question, and it is
/// asked at the apply (see [`check_applicable`]) where the selection is known and a
/// refusal can name each issue.
pub fn check_operation(operation: &str) -> Result<()> {
    match BulkOperation::from_name(operation) {
        Some(_) => Ok(()),
        None => Err(unsupported(
            operation,
            "a bulk operation is one of: edit, link, unlink, archive",
        )),
    }
}

/// The refusal for an operation this build cannot **perform**, or `Ok(())`.
///
/// Called at the apply, never at the request boundary, so the caller already holds the
/// plan and can report the refusal against every selected issue.
///
/// Every operation this build understands it can also perform — `archive` delegates to
/// the store's archive service ([`crate::bulk::archive`]) — so this gate admits all of
/// them today. It is retained, and kept separate from [`check_operation`]'s
/// malformed-operation refusal, because the two are different questions: "is this a
/// bulk operation" is answered at the boundary, and "can this build perform it" is
/// answered here. Collapsing them would put a performability refusal where no
/// selection has been resolved yet.
pub fn check_applicable(operation: BulkOperation) -> Result<()> {
    if operation.is_applicable() {
        Ok(())
    } else {
        Err(unsupported(
            operation.as_str(),
            "this build understands the operation but cannot perform it",
        ))
    }
}

/// The whole capability set, as a value a surface can serialise.
///
/// One projection rather than four, so "what can a batch do" has one answer no
/// matter who is asked. `applicable` is separate from `supported` on purpose: an
/// operation can be *understood* and still not be *performable*, and a capability
/// list that collapsed the two would either lie about the archive service or hide
/// that a request for it is at least well-formed.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct BulkCapabilities {
    /// Every operation this build understands.
    pub operations: Vec<OperationCapability>,
    /// The patch whitelist, in presentation order.
    pub fields: Vec<FieldCapability>,
    /// The shared outcome vocabulary.
    pub outcome_statuses: Vec<String>,
    /// The selection grammar, as prose lines.
    pub selection_grammar: Vec<String>,
    /// Whether a preview is read-only on every surface.
    pub preview_is_read_only: bool,
    /// Whether an apply requires an explicit confirmation argument.
    pub apply_requires_confirmation: bool,
    /// How a large selection behaves (NFR-001).
    ///
    /// `None` would leave NFR-001's second half — "**or** reports a bounded progress
    /// state" — as an undiscoverable property, so a client could only find out by
    /// timing a call. Stated here, the obligation is met by a surface reading the
    /// projection rather than by a benchmark someone ran once.
    pub progress: Option<ProgressReporting>,
}

/// NFR-001's reporting half.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct ProgressReporting {
    /// The selection size above which a surface reports progress rather than
    /// pretending the work is instantaneous.
    pub preview_selection_limit: usize,
    /// Whether the surfaces actually report bounded progress.
    pub reports_bounded_progress: bool,
    /// What a client sees when the limit is exceeded.
    pub fallback: String,
}

/// One operation's capability.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct OperationCapability {
    pub name: String,
    pub summary: String,
    /// One word, not two booleans.
    ///
    /// Every operation this build understands is performable, so this is `available`
    /// for all of them; it is a word rather than a boolean so a future operation that
    /// is understood but not yet performable can report `supported_not_applicable`
    /// without a client having to learn a second field.
    pub availability: &'static str,
    /// What the request must also carry, besides a selection: `target`, `kind`, or
    /// nothing. A list rather than two bools, so adding a requirement is a new entry
    /// rather than a new field every client has to learn about.
    pub requires: Vec<&'static str>,
}

/// One field's capability.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct FieldCapability {
    pub name: String,
    pub summary: String,
    pub clearable: bool,
}

/// The capability set, built once.
pub fn capabilities() -> &'static BulkCapabilities {
    use std::sync::OnceLock;
    static CAPS: OnceLock<BulkCapabilities> = OnceLock::new();
    CAPS.get_or_init(|| BulkCapabilities {
        operations: BulkOperation::ALL
            .iter()
            .map(|op| {
                let mut requires = Vec::new();
                if op.needs_target() {
                    requires.push("target");
                }
                if op.needs_kind() {
                    requires.push("kind");
                }
                OperationCapability {
                    name: op.as_str().to_string(),
                    summary: op.summary().to_string(),
                    availability: op.availability(),
                    requires,
                }
            })
            .collect(),
        fields: PATCH_FIELDS
            .iter()
            .map(|f| FieldCapability {
                name: f.name.to_string(),
                summary: f.summary.to_string(),
                clearable: f.clearable,
            })
            .collect(),
        outcome_statuses: OUTCOME_STATUSES.iter().map(|s| s.to_string()).collect(),
        selection_grammar: SELECTION_GRAMMAR.iter().map(|s| s.to_string()).collect(),
        // C-004. Stated rather than assumed, and a case asserts the apply refuses
        // without the confirmation argument.
        preview_is_read_only: true,
        apply_requires_confirmation: true,
        progress: Some(ProgressReporting {
            // The number NFR-001 names, used as a constant rather than a literal at
            // the call site: it is a contract figure, and a contract figure that can
            // be written differently in two places is a contract figure that will be.
            preview_selection_limit: PREVIEW_SELECTION_LIMIT,
            reports_bounded_progress: true,
            fallback: "A preview over this many issues reports bounded progress \
                       (every Nth item) rather than going silent, and a surface with no \
                       progress channel — a one-shot CLI run — reports it as a line \
                       before the plan and after it."
                .to_string(),
        }),
    })
}

/// The capability set as JSON, for the MCP tool and a remote-capability query.
///
/// Built from [`capabilities`] so there is no second description of the same list in
/// a hand-written `json!` block that could disagree with it.
pub fn capabilities_json() -> serde_json::Value {
    use serde_json::json;
    let caps = capabilities();
    json!({
        "operations": caps.operations,
        "fields": caps.fields,
        "outcome_statuses": caps.outcome_statuses,
        "selection_grammar": caps.selection_grammar,
        "preview_is_read_only": caps.preview_is_read_only,
        "apply_requires_confirmation": caps.apply_requires_confirmation,
    })
}

/// The whitelist and the `PatchFlags` struct must agree, in both directions.
///
/// This is the check that makes the projection load-bearing rather than decorative.
/// A field in `PatchFlags` and not in `PATCH_FIELDS` is a field a surface cannot
/// offer; a field in `PATCH_FIELDS` and not in `PatchFlags` is a field a surface
/// would advertise and the apply would ignore. Both are silent, and both are exactly
/// the drift C-008 forbids.
///
/// Returns the offending names rather than panicking: a caller at startup wants a
/// message it can print, and a test wants the list.
pub fn disagreements_with_flags() -> Vec<String> {
    let mut problems = Vec::new();
    let flag_rows: Vec<&str> = PatchFlags::field_rows()
        .iter()
        .copied()
        .filter(|row| *row != "clear")
        .collect();
    for name in &flag_rows {
        if !is_supported_field(name) {
            problems.push(format!(
                "PatchFlags carries `{name}`, the whitelist does not"
            ));
        }
    }
    for name in field_names() {
        if !flag_rows.contains(&name) {
            problems.push(format!(
                "the whitelist carries `{name}`, PatchFlags does not"
            ));
        }
    }
    let clearable = clearable_field_names();
    for name in PatchFlags::clearable() {
        if !clearable.contains(name) {
            problems.push(format!(
                "PatchFlags calls `{name}` clearable, the whitelist does not"
            ));
        }
    }
    problems
}