tix/cli.rs
1//! The clap command tree, the frozen exit-code scheme, and the process
2//! boundary that resolves config, opens the store and hands off to the
3//! per-command handlers.
4//!
5//! The command tree and every long flag mirror `contracts/cli.md` exactly;
6//! the exit-code table there is frozen: `0` success, `1` usage/config/
7//! unknown-error, `2` target-not-found or selection-ambiguous, `3` external
8//! tool missing (`glab`/`linear-cli`, reserved for WP05). Handlers return
9//! [`Result<(), TixError>`]; [`exit_code_for`] maps the typed error surface
10//! to the exit-code enum at the boundary. Human-readable errors go to
11//! stderr; stdout stays clean for the parseability contract.
12
13use std::path::{Path, PathBuf};
14
15use clap::builder::NonEmptyStringValueParser;
16use clap::{Args, Parser, Subcommand};
17
18use crate::commands;
19use crate::config::Config;
20use crate::error::{Result, TixError};
21use crate::storage::Store;
22
23/// Exit codes frozen by `contracts/cli.md`.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum ExitCode {
26 Success = 0,
27 UsageConfig = 1,
28 NotFoundAmbiguous = 2,
29 ExternalTool = 3,
30}
31
32/// Map a domain error to the frozen exit-code table.
33///
34/// Selection ambiguity shares exit `2` with target-not-found, per the
35/// contract's exit-code table ("2 target-not-found or selection-ambiguous");
36/// the ambiguity marker is the [`commands::AMBIGUITY_PREFIX`] message prefix.
37pub fn exit_code_for(err: &TixError) -> ExitCode {
38 match err {
39 TixError::NotFound(_) => ExitCode::NotFoundAmbiguous,
40 TixError::InvalidInput(s) if s.starts_with(commands::AMBIGUITY_PREFIX) => {
41 ExitCode::NotFoundAmbiguous
42 }
43 TixError::External(_) => ExitCode::ExternalTool,
44 _ => ExitCode::UsageConfig,
45 }
46}
47
48#[derive(Parser)]
49#[command(
50 name = "tix",
51 version,
52 about = "Terminal issue tracker: markdown issues with a kanban TUI and CLI"
53)]
54pub struct Cli {
55 #[command(subcommand)]
56 pub command: Option<Command>,
57}
58
59#[derive(Subcommand)]
60pub enum Command {
61 /// Create a default tix.toml ([kanban.default], issues_dir, [export]).
62 ///
63 /// When `bd` is on PATH and a beads workspace is reachable, an interactive
64 /// run also offers the beads modes; `--beads` decides without a prompt, and a
65 /// non-interactive run never prompts (it keeps `off` unless `--beads` says
66 /// otherwise).
67 Init {
68 /// Beads mode to record: off | mirror | sync
69 ///
70 /// `mirror` reads a beads workspace through `bd` for viewing only;
71 /// `sync` installs the `.beads/hooks` that pull beads changes into tix.
72 #[arg(long, value_name = "off|mirror|sync")]
73 beads: Option<String>,
74 },
75 /// Append or replace the tix block in AGENTS.md (idempotent)
76 InitAgents,
77 /// Print the crate version and how this binary was built
78 ///
79 /// Answers the question `--version` cannot: whether the binary on disk came
80 /// from the source on disk. It reports the commit it was built from and when,
81 /// so a rebuild that did not happen is visible. Needs no `tix.toml`, so it
82 /// works in any directory.
83 Version,
84 /// Read, or write, a [beads](https://github.com/gastownhall/beads) workspace
85 /// through the `bd` CLI
86 ///
87 /// tix never opens the beads Dolt database: every verb here shells out to
88 /// `bd`. The reads (`detect`, `status`, `list`, `show`, `watch`) write
89 /// nothing anywhere; the writes (`push`, `pull`, `reconcile`) are refused
90 /// while the beads mode is `mirror`, which is read-only by contract. See
91 /// `docs/for-users/beads-integration.md`.
92 Beads {
93 #[command(subcommand)]
94 action: BeadsAction,
95 },
96 /// Create an issue
97 Create {
98 /// Issue title (prompted when missing on a TTY)
99 #[arg(long)]
100 title: Option<String>,
101 /// Issue body (markdown)
102 #[arg(long)]
103 body: Option<String>,
104 /// Column name (default: first column)
105 #[arg(long)]
106 status: Option<String>,
107 /// Priority: low | medium | high (default medium)
108 #[arg(long)]
109 priority: Option<String>,
110 /// Assignee (@name)
111 #[arg(long)]
112 assignee: Option<String>,
113 /// Labels, comma-separated
114 #[arg(long)]
115 labels: Option<String>,
116 /// Files governing this issue, comma-separated
117 #[arg(long)]
118 files: Option<String>,
119 /// Estimate
120 #[arg(long)]
121 estimate: Option<String>,
122 /// Cycle names, comma-separated
123 #[arg(long)]
124 cycles: Option<String>,
125 /// Milestone names, comma-separated
126 #[arg(long)]
127 milestones: Option<String>,
128 /// Projects to associate the new issue with: ids or names, comma-separated
129 ///
130 /// Every reference is resolved through the project service **before** the
131 /// issue is written, so an unknown id refuses the whole create rather than
132 /// leaving a half-associated issue. This flag lives on the shared create
133 /// path, which is the handoff point a future template surface (source issue
134 /// #174) would populate (mission `first-class-projects-01M3BYBW`).
135 #[arg(long, value_name = "ID")]
136 projects: Option<String>,
137 /// Epics the new issue references: ids or names, comma-separated
138 ///
139 /// Every reference is resolved through the epic service **before** the issue
140 /// is written, so an unknown id refuses the whole create rather than leaving
141 /// a half-referenced issue. This flag lives on the shared create path, which
142 /// is the handoff point a future template surface (source issue #174) would
143 /// populate. Referencing an epic never changes how the issue itself is
144 /// navigated: it stays an ordinary issue (mission `epics-work-items-01M3BYW4`).
145 #[arg(long, value_name = "ID")]
146 epics: Option<String>,
147 /// Reviewer (free-form name/identity)
148 #[arg(long)]
149 reviewer: Option<String>,
150 /// Start date (`YYYY-MM-DD`)
151 #[arg(long)]
152 start_date: Option<String>,
153 /// Due date (`YYYY-MM-DD`); must be `>= start_date`
154 #[arg(long)]
155 due_date: Option<String>,
156 /// Create as a child of this issue (number, filename or title fragment)
157 ///
158 /// At most one parent, so this takes a single selector and there is no list
159 /// form (C-001). The parent must already exist; a selector that matches no
160 /// issue is refused and nothing is created.
161 #[arg(long, value_name = "SELECTOR")]
162 parent: Option<String>,
163 /// Related issues, comma-separated: a local number/selector, a relative
164 /// or absolute path to another issue file, or a repository URL
165 /// (`host/path#id`). Free-form; stored verbatim.
166 #[arg(long)]
167 related_issues: Option<String>,
168 /// Issue type (e.g. `bug`, `feature`); `tix lint` warns on a value not in
169 /// `custom_types`.
170 #[arg(long)]
171 r#type: Option<String>,
172 /// Create from a stable template ID (`tix template list`)
173 ///
174 /// The template's defaults and prompts are merged with the flags above
175 /// by the pure resolver, then the resolved request goes through the
176 /// shared create core — there is no second issue writer. Resolution
177 /// never prompts: a required prompt with no value is a diagnostic, the
178 /// template and the store are left untouched, and no issue is written.
179 #[arg(long, value_name = "STABLE-ID")]
180 template: Option<String>,
181 /// Template scope: `repository` | `board:<name>` | `team:<label>`
182 ///
183 /// Only meaningful with `--template` (clap requires it). An omitted
184 /// scope is the explicit `repository` default, never an implicit
185 /// inheritance. The selected template ID and scope are echoed on
186 /// stderr so stdout keeps the `Created <file>` shape.
187 #[arg(long, value_name = "SCOPE", requires = "template")]
188 scope: Option<String>,
189 },
190 /// Create, edit, remove, or list typed relationships
191 ///
192 /// Creating requires `--type`; there is no default kind, so an untyped
193 /// relationship is never recorded by accident (C-001). Omit the
194 /// subcommand to create: `tix link <from> <to> --type <kind>`.
195 Link {
196 /// `edit`, `remove`, or `list`. Omit to create a relationship.
197 ///
198 /// These three words are reserved on `tix link`: an issue whose number,
199 /// filename, or title selector is exactly `edit`, `remove`, or `list`
200 /// must be addressed by number.
201 #[command(subcommand)]
202 action: Option<LinkAction>,
203 /// [create] Issue number, filename fragment or unique title fragment
204 from: Option<String>,
205 /// [create] Issue number, filename fragment or unique title fragment
206 to: Option<String>,
207 /// [create] Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
208 #[arg(long, value_name = "KIND")]
209 r#type: Option<String>,
210 },
211 /// Batch editing over a selection
212 ///
213 /// Preview is the **default**; `--apply` writes. Name exactly one of `--select`,
214 /// `--filter` or `--search` — they are different grammars, and a bulk operation
215 /// will not guess which issues you meant.
216 Bulk {
217 #[command(subcommand)]
218 action: BulkAction,
219 },
220 /// Remove a typed relationship between two issues
221 Unlink {
222 /// Issue number, filename fragment or unique title fragment
223 from: String,
224 /// Issue number, filename fragment or unique title fragment
225 to: String,
226 /// Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
227 #[arg(long, value_name = "KIND")]
228 r#type: Option<String>,
229 },
230 /// Print the transitive dependency chain an issue holds up
231 Chain {
232 /// Issue number, filename fragment or unique title fragment
233 issue: String,
234 },
235 /// Inspect the issue hierarchy
236 ///
237 /// `show`, `children`, or `list`. Every figure comes from the hierarchy
238 /// service's typed values, so the CLI, the TUI detail block and the MCP read
239 /// tools report the same numbers for the same issue.
240 Parent {
241 /// `show`, `children`, or `list`
242 #[command(subcommand)]
243 action: ParentAction,
244 },
245 /// Edit an issue
246 Edit {
247 /// Issue number, filename fragment or unique title fragment
248 issue: String,
249 /// New title
250 #[arg(long)]
251 title: Option<String>,
252 /// New body (markdown)
253 #[arg(long)]
254 body: Option<String>,
255 /// New status
256 ///
257 /// Setting the status is a **move**, validated by the same service as
258 /// `tix mv` and recorded as the same `TransitionDecision` — it is not a
259 /// way around the transition policy. `--status ""` clears the field
260 /// (matching every other field here) and is a plain field write, not a
261 /// transition, because there is no target column to judge.
262 #[arg(long)]
263 status: Option<String>,
264 /// New priority: low | medium | high
265 #[arg(long)]
266 priority: Option<String>,
267 /// New assignee (@name)
268 #[arg(long)]
269 assignee: Option<String>,
270 /// New label list, comma-separated
271 #[arg(long)]
272 labels: Option<String>,
273 /// New files list, comma-separated
274 #[arg(long)]
275 files: Option<String>,
276 /// New estimate
277 #[arg(long)]
278 estimate: Option<String>,
279 /// New cycle list, comma-separated (`--cycles ""` clears)
280 #[arg(long)]
281 cycles: Option<String>,
282 /// New milestone list, comma-separated (`--milestones ""` clears)
283 #[arg(long)]
284 milestones: Option<String>,
285 /// Associate the issue with a project (id or name); repeatable
286 ///
287 /// Idempotent: an already-present association is a no-op with no History
288 /// entry. The reference is resolved through the project service before
289 /// anything is written, so an unknown project refuses the edit and leaves
290 /// the file byte-identical.
291 #[arg(long, value_name = "ID")]
292 add_project: Vec<String>,
293 /// Dissociate the issue from a project (id or name); repeatable
294 ///
295 /// Drops only the association — the issue is never deleted. A known project
296 /// that is not associated is a no-op; an unresolvable reference that the
297 /// issue does not carry is refused as `unknown_project`.
298 #[arg(long, value_name = "ID")]
299 remove_project: Vec<String>,
300 /// Replace the whole association list, comma-separated
301 ///
302 /// Only a blank value is accepted here: `--projects ""` clears every
303 /// association, mirroring the empty-string-clears convention the other list
304 /// flags follow. To add or drop individual associations use
305 /// `--add-project` / `--remove-project`; a non-blank value is refused rather
306 /// than guessed at.
307 #[arg(long, value_name = "IDS")]
308 projects: Option<String>,
309 /// Add an epic reference (id or name); repeatable
310 ///
311 /// Idempotent: an already-present reference is a no-op with no History
312 /// entry. The reference is resolved through the epic service before
313 /// anything is written, so an unknown epic refuses the edit and leaves the
314 /// file byte-identical.
315 #[arg(long = "add-epic", value_name = "ID")]
316 add_epic: Vec<String>,
317 /// Remove an epic reference; repeatable
318 ///
319 /// Drops only the reference — the issue is never deleted. A known epic that
320 /// is not referenced is a no-op; an unresolvable reference the issue does not
321 /// carry is refused as `unknown_epic`. A retained stale id can still be
322 /// removed by naming it verbatim.
323 #[arg(long = "remove-epic", value_name = "ID")]
324 remove_epic: Vec<String>,
325 /// Clear every epic reference
326 ///
327 /// Only a blank value is accepted here: `--epics ""` clears every reference,
328 /// mirroring the empty-string-clears convention `--projects` follows. To add
329 /// or drop individual references use `--add-epic` / `--remove-epic`; a
330 /// non-blank value is refused rather than guessed at.
331 #[arg(long, value_name = "IDS")]
332 epics: Option<String>,
333 /// New reviewer (`--reviewer ""` clears)
334 #[arg(long)]
335 reviewer: Option<String>,
336 /// New start date (`YYYY-MM-DD`; `--start-date ""` clears)
337 #[arg(long)]
338 start_date: Option<String>,
339 /// New due date (`YYYY-MM-DD`; `--due-date ""` clears)
340 #[arg(long)]
341 due_date: Option<String>,
342 /// Commit hash to record (`auto` resolves it from git history, `clear` removes it)
343 #[arg(long)]
344 commit: Option<String>,
345 /// Set or change the parent (number, filename or title fragment)
346 ///
347 /// `--parent ""` clears the parent, mirroring `--assignee ""`; omitting the
348 /// flag leaves it untouched. Validated before any write, so a self-parent,
349 /// an unknown parent, or a change that would close a cycle leaves the file
350 /// byte-identical.
351 #[arg(long, value_name = "SELECTOR")]
352 parent: Option<String>,
353 /// Why a transition policy is being overridden by `--status`
354 ///
355 /// `--status` is validated exactly as `tix mv` is, so moving an issue out
356 /// of a terminal category needs the same reasoned override, and the reason
357 /// is recorded in the same History decision. `--status` deliberately has
358 /// no `--yes`: acknowledging blockers is spelled `tix move … --yes`, and
359 /// giving this flag a second spelling would give the audit trail two.
360 #[arg(long, value_name = "TEXT")]
361 reason: Option<String>,
362 /// New related-issues list, comma-separated (`--related-issues ""` clears).
363 /// A local number/selector, a relative or absolute path to another issue
364 /// file, or a repository URL (`host/path#id`).
365 #[arg(long)]
366 related_issues: Option<String>,
367 /// New issue type (`--type ""` clears it); `tix lint` warns on a value not
368 /// in `custom_types`.
369 #[arg(long)]
370 r#type: Option<String>,
371 },
372 /// Move an issue to a column
373 Move {
374 /// Issue number, filename fragment or unique title fragment
375 issue: String,
376 /// Target column
377 column: String,
378 /// Commit hash to record when moving to a completion column (`auto`
379 /// resolves it from git history; omitted, the current HEAD is captured
380 /// automatically)
381 ///
382 /// "Completion" is whatever the board's category mapping calls
383 /// `completed` — not a column literally named `done`. On a board with
384 /// no `[kanban.<name>.category_map]` the board's `done_column` is the
385 /// completion column.
386 #[arg(long, value_name = "HASH")]
387 commit: Option<String>,
388 /// Why a transition policy is being overridden
389 ///
390 /// Required to move an issue *out of* a terminal category (`completed`
391 /// or `canceled`) when the board declares a `category_map`. The reason
392 /// is recorded in the issue's History, so the audit trail explains
393 /// itself later. A blank value is not a reason and is refused.
394 ///
395 /// A board with no `category_map` has no transition policy to override:
396 /// it keeps the completion definition and the commit rule, and moving
397 /// out of `done` stays permitted.
398 #[arg(long, value_name = "TEXT")]
399 reason: Option<String>,
400 /// Confirm without prompting
401 ///
402 /// Also the acknowledgement a move toward the board's completion column
403 /// needs when the issue has open blockers. Without it that move is
404 /// refused, and a recorded acknowledgement is appended to the issue's
405 /// History so the decision stays auditable.
406 #[arg(long)]
407 yes: bool,
408 /// Authorize overriding a **blocking work-in-progress limit**
409 ///
410 /// `--override` is a request, not an authorization. The move only proceeds
411 /// when `--reason` states why and `--yes` confirms it, and the override is
412 /// recorded — actor, timestamp, reason and the limit it was authorized
413 /// against — as a History entry on the issue. Without `--override` a move
414 /// that would exceed a `block` limit is refused and nothing is written; a
415 /// `warn` limit never needs one.
416 #[arg(long)]
417 r#override: bool,
418 },
419 /// Inspect or configure work-in-progress limits
420 ///
421 /// Limits live in `tix.toml` under `[kanban.<board>.wip]`: a column's ceilings
422 /// (`max_items`, `max_estimate`), the unit an estimate ceiling counts in, and
423 /// whether exceeding it `warn`s or `block`s. A board with no limits behaves
424 /// exactly as it did before they existed.
425 Wip {
426 #[command(subcommand)]
427 action: WipAction,
428 },
429 /// Print an issue, or manage saved views (#172)
430 ///
431 /// `tix view <issue>` prints the issue file, unchanged. Without an issue
432 /// selector the subcommands below manage repository-owned **saved views**:
433 /// named, stable-ID query definitions with explicit scope metadata and a
434 /// normalized plan over the existing filter/sort/group/visibility grammar.
435 ///
436 /// The nine subcommand words (`list`, `show`, `create`, `update`,
437 /// `duplicate`, `delete`, `apply`, `status`, `share`) are **reserved** on
438 /// `tix view`: an issue whose selector is exactly one of them must be
439 /// addressed by number, exactly as `tix link` reserves `edit` / `remove` /
440 /// `list`.
441 #[command(visible_alias = "show")]
442 View {
443 /// `list`, `show`, `create`, `update`, `duplicate`, `delete`, `apply`,
444 /// `status`, or `share`. Omit to print an issue.
445 #[command(subcommand)]
446 action: Option<ViewAction>,
447 /// [print] Issue number, filename fragment or unique title fragment
448 issue: Option<String>,
449 /// [print] Extra information to append, comma-separated
450 ///
451 /// `relationships` appends the service-derived relationship block, the
452 /// transitive chain, and the blocked reason. Without it the issue file
453 /// is printed verbatim, so `tix view <n> > file` still round-trips.
454 #[arg(long, value_name = "relationships")]
455 show: Option<String>,
456 },
457 /// Manage recurring issue series (tix #175)
458 ///
459 /// A series is a **normal issue** that carries a `series:` table in its
460 /// frontmatter: the issue holds the rule, the lifecycle state, and the
461 /// template an occurrence is created from; each occurrence is another normal
462 /// issue carrying the series id, the date and the idempotency key.
463 ///
464 /// `detect` is read-only and reports what is due; `generate` and every
465 /// lifecycle action need `--yes`, because no refresh, timer or detection may
466 /// change a schedule on its own.
467 Recurring {
468 #[command(subcommand)]
469 action: RecurringAction,
470 },
471 /// One line per issue: number, id and title
472 ///
473 /// The default listing is **scoped**: your issues (the git author identity,
474 /// when it can be derived) in the board's entry column — `todo` on the board
475 /// `tix init` writes. `--all` drops the scope, and `--status`/`--filter`
476 /// replace it. The title is cropped to the terminal's width, so every issue
477 /// occupies exactly one line; the full board is `tix render`.
478 List {
479 /// Only issues in this status
480 #[arg(long)]
481 status: Option<String>,
482 /// Token query (`cycle:`, `milestone:`, `reviewer:`, `overdue`, etc.)
483 #[arg(long)]
484 filter: Option<String>,
485 /// Restrict the listing to one project (stable id, or a display name)
486 ///
487 /// Resolved through the project service and applied through the shared
488 /// `project:` criterion, so `tix list --project <id>` and
489 /// `tix search "project:<id>"` return the same matching set.
490 #[arg(long)]
491 project: Option<String>,
492 /// Restrict the listing to one epic (stable id, or a display name)
493 ///
494 /// Resolved through the epic service and applied through the shared
495 /// `epic:` criterion, so `tix list --epic <id>` and
496 /// `tix search "epic:<id>"` return the same matching set. An unknown
497 /// reference matches nothing, so a typo yields a visible empty listing
498 /// rather than a silent unfiltered one.
499 #[arg(long)]
500 epic: Option<String>,
501 /// Include archived issues, marked with an ARCHIVED column
502 ///
503 /// Off by default, and off for a reason: archived issues are skipped
504 /// without being read, so a repository with thousands of them lists fast.
505 #[arg(long)]
506 with_archived: bool,
507 /// Sort key (`created`, `due`, `start`, `reviewer`, `cycle`, `milestone`, `project`, `epic`, `status`, `priority`)
508 #[arg(long)]
509 sort: Option<String>,
510 /// Group key (`cycle` | `milestone` | `project` | `epic`)
511 #[arg(long)]
512 group: Option<String>,
513 /// Comma-separated columns to display
514 ///
515 /// The full set: `num`, `id`, `title`, `status`, `priority`, `cycle`,
516 /// `milestone`, `reviewer`, `start`, `due`, `overdue`, `blocked`,
517 /// `relations`, `progress`, `projects`, `epics`, `category`, `archived`,
518 /// `wip`, `health`. The `value_name` used to list only the first ten, so a
519 /// column this build had supported for two missions looked unsupported in
520 /// `--help`; a column left out of this list is the same lie about a smaller
521 /// set.
522 #[arg(long, value_name = "COLUMNS")]
523 columns: Option<String>,
524 /// Extra information to include, comma-separated
525 ///
526 /// `relationships` adds a relationship summary and a blocked marker
527 /// per issue, read from the relationship service so the CLI and the
528 /// TUI cannot disagree.
529 ///
530 /// `progress` adds a parent rollup; `category` adds the issue's resolved
531 /// category label, read from the shared service so this surface and the
532 /// TUI cannot spell one category two ways.
533 ///
534 /// `wip` adds the column's usage against its limit, and `health` adds the
535 /// issue's health (its own value, with the derived rollup beside it when the
536 /// two differ), read from the one portable health document.
537 #[arg(long, value_name = "SHOW")]
538 show: Option<String>,
539 /// List every issue, ignoring the default scope
540 ///
541 /// The default listing is scoped to your issues in the board's entry
542 /// column. `--all` drops both halves of that scope — the listing is then
543 /// every active issue, exactly as it was before the scope existed.
544 #[arg(long)]
545 all: bool,
546 /// Emit a structured document instead of the table: `xml`, `yml` or `toml`
547 ///
548 /// One record per issue, keyed by the columns being displayed, under the
549 /// single root `issues` — so `--columns` chooses the fields. JSON is
550 /// deliberately not one of them.
551 #[arg(long, value_name = "FORMAT")]
552 format: Option<String>,
553 },
554 /// The next issue to start: unblocked, highest priority first
555 ///
556 /// The `tix list` a working session opens with. It shows every issue that
557 /// nothing blocks and that has not begun — a column the board maps to `backlog`
558 /// or `unstarted`, no further along than the column new work lands in — ordered
559 /// by priority (highest first, then oldest first), so the **first row** is the
560 /// issue to pick up next. Blocked means an **open** blocker: the same graph the
561 /// `blocked` column reads, so finishing a blocker returns its dependents here.
562 Ready {
563 /// Comma-separated columns to display
564 ///
565 /// The default set is `num,id,priority,title`: the identity pair, the
566 /// priority the list is ordered by, and the title.
567 #[arg(long, value_name = "COLUMNS")]
568 columns: Option<String>,
569 /// Show at most this many issues
570 ///
571 /// Applied after the ordering, so `--limit 1` is the single next issue.
572 #[arg(long, value_name = "N")]
573 limit: Option<usize>,
574 /// Restrict the listing to one project (stable id, or a display name)
575 #[arg(long)]
576 project: Option<String>,
577 /// Restrict the listing to one epic (stable id, or a display name)
578 #[arg(long)]
579 epic: Option<String>,
580 /// Emit a structured document instead of the table: `xml`, `yml` or `toml`
581 #[arg(long, value_name = "FORMAT")]
582 format: Option<String>,
583 },
584 /// Inspect the status-category vocabulary, a board's mapping, and the
585 /// effective transition policy
586 ///
587 /// A read-only surface. It never writes and never repairs, so it is safe to run
588 /// on a repository whose `tix.toml` is mid-edit.
589 Workflow {
590 #[command(subcommand)]
591 action: WorkflowAction,
592 },
593 /// Board summary: count per column + total
594 Status {
595 /// Token query (`cycle:`, `milestone:`, `reviewer:`, `overdue`, etc.)
596 #[arg(long)]
597 filter: Option<String>,
598 /// Sort key (`created`, `due`, `start`, `reviewer`, `cycle`, `milestone`, `status`, `priority`)
599 #[arg(long)]
600 sort: Option<String>,
601 /// Group key (`cycle` | `milestone` | `project` | `epic`)
602 #[arg(long)]
603 group: Option<String>,
604 /// Add the health summary: issue counts per state, with diagnostics
605 ///
606 /// Read from the same portable health document `tix health board` and the
607 /// TUI render, so the figures are the same figures. Off by default: the board
608 /// summary is about columns, and health is metadata about the work (C-006).
609 #[arg(long)]
610 health: bool,
611 },
612 /// Project and issue health: read a scope, roll it up, or record a change
613 ///
614 /// Health is **metadata**, not the board's status: it never replaces a status
615 /// category and never means "done". The current value lives in an issue's
616 /// frontmatter, every change is an append-only History entry carrying an actor, a
617 /// date and a rationale, and a scope's derived value comes from what contributes
618 /// to it under a versioned policy (`health-v1`).
619 ///
620 /// `set` and `override` are the mutations, and both refuse — having written
621 /// nothing — when an audit field is missing. `show`, `rollup`, `history` and
622 /// `board` read.
623 Health {
624 #[command(subcommand)]
625 action: HealthAction,
626 },
627 /// Read and act on your `@mentions` (issue-mentions)
628 ///
629 /// A mention is `@handle` or `@name@host` in an issue's title or body, recorded
630 /// per mentioned target under `docs/mentions/`. This command is the
631 /// **non-interactive** surface: it lists what is pending for you and applies the
632 /// three answers without a prompt, so the flow works from a script and without a
633 /// TTY. The TUI has the same three answers on the bottom indicator row's chord.
634 Mentions {
635 #[command(subcommand)]
636 action: MentionsAction,
637 },
638 /// Triage the inbox: read-only queue views and the four decisions
639 Triage {
640 #[command(subcommand)]
641 action: TriageAction,
642 },
643 /// Delete an issue file
644 Delete {
645 /// Issue number, filename fragment or unique title fragment
646 issue: String,
647 /// Confirm without prompting
648 #[arg(long)]
649 yes: bool,
650 },
651 /// Archive issue files: rename each in place to a dot-prefixed name
652 ///
653 /// The default views (`tix list`, `tix search`) skip archived issues; pass
654 /// their `--with-archived` flag or the `archived:true` filter to see them.
655 Archive {
656 /// Issue numbers, filename fragments or unique title fragments
657 #[arg(required = true, value_name = "ISSUE")]
658 issues: Vec<String>,
659 },
660 /// Restore archived issue files: the inverse of `tix archive`
661 Restore {
662 /// Issue numbers, filename fragments or unique title fragments
663 #[arg(required = true, value_name = "ISSUE")]
664 issues: Vec<String>,
665 },
666 /// Manage planning cycles
667 Cycle {
668 #[command(subcommand)]
669 command: CycleCommand,
670 },
671 /// Configure and inspect cycle, team and assignee capacity
672 ///
673 /// A ceiling is a scope (one cycle, one team label, or one assignee identity)
674 /// with a non-negative value in an estimate unit the active board declares.
675 /// `calculate` reports the shared committed/completed/remaining/overflow/
676 /// utilization values and never mutates an issue; `snapshot` reads back the
677 /// immutable evidence a cycle close captured.
678 Capacity {
679 #[command(subcommand)]
680 command: CapacityCommand,
681 },
682 /// Record and review actual effort against issues, in whole minutes
683 ///
684 /// A time entry is a duration, an optional `YYYY-MM-DD` calendar date, an author
685 /// and an optional note — there is no timer, no timezone conversion and no overlap
686 /// rule. Current entries live in the issue's own frontmatter, every correction and
687 /// deletion appends an attributable History event, and a deletion removes the
688 /// current value only. `add`, `edit` and `delete` are the mutations (`edit` and
689 /// `delete` require `--reason`; `delete` also requires `--confirm`), and `show`,
690 /// `history` and `total` are reads whose numbers come from one shared calculation.
691 Time {
692 #[command(subcommand)]
693 command: TimeCommand,
694 },
695 /// Manage milestones
696 Milestone {
697 #[command(subcommand)]
698 command: MilestoneCommand,
699 },
700 /// Create, edit, list, inspect, delete and populate projects
701 ///
702 /// A project is a stable id (a registry key in `tix.toml`) with a mutable
703 /// display name. An issue carries **project ids**, so a rename breaks no
704 /// association: the id is the identity and the name is a label. Every listing
705 /// prints the id beside the name, because without it a rename is
706 /// indistinguishable from a new project.
707 ///
708 /// `delete` removes the **registry entry only**. The issues that referenced
709 /// the project keep their references, which then read as diagnosable missing
710 /// references (`tix lint` names each one), so nothing is destroyed by deleting
711 /// a project and there is deliberately no `--cascade` and no bulk-delete of
712 /// issues.
713 ///
714 /// `add`, `remove` and `bulk-add` process each issue selector
715 /// **independently**: a rejection on one selector is reported and never aborts
716 /// the rest. Their scope is project association only — general bulk editing is
717 /// a different feature.
718 Project {
719 #[command(subcommand)]
720 command: ProjectCommand,
721 },
722 /// Create, edit, list, inspect, navigate and delete epics; associate work items
723 ///
724 /// An epic is a stable id (a registry key in `tix.toml`) with a mutable
725 /// display name. A work item references epic **ids**, so a rename breaks no
726 /// reference: the id is the identity and the name is a label. Every listing
727 /// prints the id beside the name, because without it a rename is
728 /// indistinguishable from a new epic.
729 ///
730 /// A "task" is an ordinary issue: `tix edit <sel> --labels task --add-epic
731 /// <id>` adds a reference to an issue. No task entity, file or frontmatter key
732 /// is created, and direct issue navigation stays available from every view.
733 ///
734 /// `delete` removes the **registry entry only**. The issues that referenced the
735 /// epic keep their references (which then read as missing references) and the
736 /// child epics keep their recorded parent (which then reads as unresolved), so
737 /// nothing is destroyed by deleting an epic and there is deliberately no
738 /// `--cascade`.
739 ///
740 /// `add`, `remove` and `bulk-add` process each issue selector
741 /// **independently**: a rejection on one selector is reported and never aborts
742 /// the rest. Their scope is epic reference only — general bulk editing is a
743 /// different feature.
744 Epic {
745 #[command(subcommand)]
746 command: EpicCommand,
747 },
748 /// Create, edit, list, inspect, delete and associate roadmap initiatives
749 ///
750 /// An initiative is a stable id (a registry key in `tix.toml`) with a mutable
751 /// display name, an optional target date/status/owner, and explicit
752 /// project/epic/milestone associations. The id is the identity, so a rename
753 /// breaks no association. Every listing prints the id, because without it a
754 /// rename is indistinguishable from a new initiative.
755 ///
756 /// `delete` removes the **registry entry only**. The record's own references are
757 /// reported and nothing cascades: no project, epic, milestone or issue is
758 /// touched, and no association is silently repaired.
759 ///
760 /// `associate`/`dereference` name exactly one of `--project`, `--epic` or
761 /// `--milestone`; a well-formed reference that names nothing is stored and stays
762 /// diagnosable (`tix roadmap` and `tix lint` report it), and a repeated
763 /// association is an idempotent no-op.
764 Initiative {
765 #[command(subcommand)]
766 command: InitiativeCommand,
767 },
768 /// List and inspect the roadmap: initiatives ordered by target date
769 ///
770 /// Read-only. Each row carries the target date (or the explicit `undated`
771 /// state), the source-attributed summary — one contribution per referenced
772 /// project/epic/milestone with its own value and shared policy version — and the
773 /// ordered diagnostics, so a missing reference is visible on the row and an
774 /// empty summary is explicit. No figure is blended across sources.
775 Roadmap {
776 #[command(subcommand)]
777 command: RoadmapCommand,
778 },
779 /// Read the burndown and scope analytics of a cycle or milestone, or write its
780 /// deterministic chart document
781 ///
782 /// Read-only. `burndown` prints the daily remaining/completed series the shared fold
783 /// produces, `projection` the read-only end-date forecast at the caller's `--as-of`,
784 /// and `export` the versioned local chart document (WP04's target, called by this
785 /// verb rather than re-serialized). The period comes from the cycle/milestone's own
786 /// planning data and `--from`/`--to` replace the bound they name; count and estimate
787 /// modes are explicit and an estimate without a compatible unit is refused rather
788 /// than guessed. A value the record cannot support prints as `-` and never as a zero,
789 /// a day with no recorded history is explicit about its coverage, and nothing here
790 /// mutates an issue, a cycle or a snapshot. The remote `gitlab`/`linear` export
791 /// namespaces are a different command (`tix export`) and are never consulted.
792 Analytics {
793 #[command(subcommand)]
794 command: AnalyticsCommand,
795 },
796 /// Export local issues to an external tracker via glab/linear-cli, or write
797 /// them into a beads workspace via `bd`
798 Export {
799 /// Target tracker: gitlab | linear | ics | beads
800 #[arg(long, value_name = "gitlab|linear|ics|beads")]
801 target: String,
802 /// Print what would change without invoking the remote tracker
803 #[arg(long)]
804 dry_run: bool,
805 /// Override the configured project path (gitlab) or team (linear)
806 #[arg(long)]
807 project: Option<String>,
808 /// Cycle to export for ICS output
809 #[arg(long)]
810 cycle: Option<String>,
811 /// Output path for ICS export
812 #[arg(long)]
813 output: Option<String>,
814 /// List the per-issue diagnostics — which local fields beads cannot hold
815 ///
816 /// Off by default: a board of a few thousand issues produces hundreds of
817 /// them, and a run that sends three issues should not rehearse the notes
818 /// for the other thousand. The counts are always reported either way.
819 #[arg(long)]
820 verbose: bool,
821 },
822 /// Import open issues from an external tracker via glab/linear-cli, mirror a
823 /// local GitHub Spec Kit project, or pull a beads workspace through `bd`
824 Import {
825 /// Target: gitlab | linear (remote trackers) | speckit (local mirror) |
826 /// beads (a beads workspace, through `bd`)
827 #[arg(long, value_name = "gitlab|linear|speckit|beads")]
828 target: String,
829 /// Where an imported issue lands in the local triage inbox (WP07)
830 ///
831 /// `entry` routes into `[triage].entry_status` and refuses a project with
832 /// no `[triage]` section instead of quietly falling back; `remote` keeps
833 /// the column the remote mapping named. Absent is the documented default:
834 /// route when the project has an inbox, otherwise leave the mapping alone.
835 /// Either way the report names the mapping that was used.
836 #[arg(long, value_name = "entry|remote")]
837 triage_policy: Option<String>,
838 },
839 /// Roll open issues forward from expired cycles to the next successor
840 Rollover {
841 /// Restrict rollover to one source cycle
842 #[arg(long)]
843 cycle: Option<String>,
844 /// Skip interactive confirmation
845 #[arg(long)]
846 yes: bool,
847 },
848 /// Search issue files for a string (case-insensitive substring over full markdown)
849 Search {
850 /// Case-insensitive substring to match against the full markdown of every issue
851 #[arg(value_name = "STRING", value_parser = NonEmptyStringValueParser::new())]
852 string: String,
853 /// Include archived issues in the search corpus
854 #[arg(long)]
855 with_archived: bool,
856 },
857 /// Render the current board once and exit
858 Render,
859 /// Prune the centralized backups and remove the scattered ones
860 ///
861 /// Backups live in `.tix-backups/` beside the issues directory (#1581);
862 /// `--keep` (default 10) bounds how many are kept per issue. A legacy
863 /// `*.tixbak` or `*.md.bak` from an older release, still beside an issue, is
864 /// removed too — by default each such file is confirmed interactively.
865 Clean {
866 /// Remove every legacy scattered backup without prompting
867 #[arg(short, long)]
868 force: bool,
869 /// Backups to keep per issue in `.tix-backups/`
870 #[arg(long, default_value_t = 10)]
871 keep: usize,
872 },
873 /// Open the TUI with the create-issue form already open
874 ///
875 /// The session `tix` starts, with the modal that `n` opens already in front of
876 /// you: one command to file an issue, instead of "start the board, then press
877 /// n". It is the interactive twin of `tix create`, which never opens the TUI.
878 New,
879 /// Validate frontmatter option values in all issue files
880 ///
881 /// Every finding renders as a rustc-style diagnostic on stderr: the file and
882 /// line, the offending value, and a `help:` line naming the fix. An **error**
883 /// sets the exit code (a broken file); a **warning** does not (a valid file
884 /// that is worth a look) — so a store with warnings still exits `0`.
885 ///
886 /// What is checked:
887 ///
888 /// - per issue: `status` against the board's columns; `priority`, `title`,
889 /// `type`, `created`/`updated`, `estimate`; the planning fields `cycles`,
890 /// `milestones`, `start_date`, `due_date`; a `## History` section that is
891 /// not first in the body (fenced code examples are skipped) or an entry the
892 /// grammar cannot read; and unrecognised top-level keys (a warning, since
893 /// the key is preserved on write);
894 /// - the typed relationships: self-links, missing or conflicting targets,
895 /// cycles, an unknown kind, and the retired `links:` key;
896 /// - the `parent:` hierarchy: `self_parent`, `unresolved_parent`,
897 /// `unknown_parent`, `cycle`;
898 /// - `related_issues` entries: blank, or a local selector that resolves to
899 /// nothing (paths and URLs are accepted verbatim);
900 /// - the configuration: board column/category mapping, `[layout]` drift, and
901 /// a `status:` that contradicts the issue's own recorded History.
902 ///
903 /// Exit: `0` clean or warnings only, `1` any error, `2` usage or an internal
904 /// failure.
905 Lint {
906 /// Do not lint the foreign repositories reachable from `related_issues`
907 /// for this run (overrides `lint_foreign_repositories` in tix.toml).
908 #[arg(long)]
909 no_foreign: bool,
910 },
911 /// Inspect or migrate the configured on-disk issue layout (`[layout]`)
912 Layout {
913 #[command(subcommand)]
914 action: LayoutAction,
915 },
916 /// Serve the MCP protocol (JSON-RPC 2.0 over stdio) for agent tools
917 Mcp,
918 /// Manage stable-ID issue/form templates
919 ///
920 /// Templates live in `[issue_templates.<id>]` in `tix.toml`, keyed by an
921 /// immutable stable ID with a mutable display name and an explicit scope.
922 /// `list`/`show` are read-only; `create`/`update`/`duplicate`/`delete`
923 /// persist through one atomic save, and a refused operation writes nothing.
924 Template {
925 #[command(subcommand)]
926 action: TemplateAction,
927 },
928}
929
930/// The `tix template` management tree (`contracts/cli-surface.md`).
931///
932/// `list`/`show` are read-only; the four mutations call one service that
933/// validates the complete candidate registry before a single atomic
934/// `tix.toml` save, so a refused operation leaves the file byte-identical.
935/// Identity is the immutable stable ID: `update` changes a display name
936/// without touching scope, defaults, prompts, references, or preserved
937/// unknown metadata.
938#[derive(Subcommand)]
939pub enum TemplateAction {
940 /// List templates: one `id<TAB>name<TAB>scope` row each, in declaration order
941 List,
942 /// Show one template by stable ID (or a unique display name)
943 Show {
944 /// Stable ID, or a display name that names exactly one template
945 reference: String,
946 },
947 /// Create a template under a new stable ID
948 Create {
949 /// The new stable ID: ASCII letters, digits, `-` or `_`
950 #[arg(long, value_name = "ID")]
951 id: String,
952 /// Display name; omitted means the ID is shown in its place
953 #[arg(long, value_name = "NAME")]
954 name: Option<String>,
955 /// Scope: `repository` (default) | `board:<name>` | `team:<label>`
956 #[arg(long, value_name = "SCOPE")]
957 scope: Option<String>,
958 /// A normal create default, `FIELD=VALUE` (repeatable). Supplying any
959 /// `--default` replaces the whole defaults block.
960 #[arg(long = "default", value_name = "FIELD=VALUE")]
961 defaults: Vec<String>,
962 /// A structured prompt,
963 /// `key=<field>[,kind=text|select|multi-select|date][,required=true|false][,default=<v>][,label=<l>][,options_ref=<source>]`
964 /// (repeatable). Supplying any `--prompt` replaces the whole prompt list.
965 #[arg(long = "prompt", value_name = "KEY,ATTR=VALUE...")]
966 prompts: Vec<String>,
967 /// A typed reference,
968 /// `kind=label|assignee|file|cycle|milestone|relationship,value=<v>[,required=true|false]`
969 /// (repeatable). Supplying any `--reference` replaces the whole list.
970 #[arg(long = "reference", value_name = "KIND=VALUE,ATTR=VALUE...")]
971 references: Vec<String>,
972 },
973 /// Update the flags supplied, leaving every other stored field as it was
974 Update {
975 /// Stable ID, or a display name that names exactly one template
976 reference: String,
977 /// New display name (the stable ID never changes)
978 #[arg(long, value_name = "NAME")]
979 name: Option<String>,
980 /// New scope: `repository` | `board:<name>` | `team:<label>`
981 #[arg(long, value_name = "SCOPE")]
982 scope: Option<String>,
983 /// Replacement defaults, `FIELD=VALUE` (repeatable)
984 #[arg(long = "default", value_name = "FIELD=VALUE")]
985 defaults: Vec<String>,
986 /// Replacement prompts (repeatable; same grammar as `create`)
987 #[arg(long = "prompt", value_name = "KEY,ATTR=VALUE...")]
988 prompts: Vec<String>,
989 /// Replacement references (repeatable; same grammar as `create`)
990 #[arg(long = "reference", value_name = "KIND=VALUE,ATTR=VALUE...")]
991 references: Vec<String>,
992 },
993 /// Copy a template to a new stable ID, leaving the source untouched
994 Duplicate {
995 /// Stable ID, or a display name that names exactly one template
996 reference: String,
997 /// The new stable ID; omitted allocates `<source>-copy`, `-copy-2`, …
998 #[arg(long, value_name = "ID")]
999 id: Option<String>,
1000 /// Display name for the copy; omitted keeps the source's label
1001 #[arg(long, value_name = "NAME")]
1002 name: Option<String>,
1003 },
1004 /// Delete a template definition (never the issues created from it)
1005 Delete {
1006 /// Stable ID, or a display name that names exactly one template
1007 reference: String,
1008 /// Confirm without prompting
1009 #[arg(long)]
1010 yes: bool,
1011 },
1012}
1013
1014/// The `tix triage` tree (`contracts/cli-surface.md`, WP05 T023–T025).
1015///
1016/// `list` and `status` are read-only queue views; the four decision verbs take a
1017/// single issue; `batch` is the selection-shaped twin of `tix bulk`, preview by
1018/// default and writing only under an explicit `--apply`.
1019///
1020/// ## Why the reason flag is `Option` on `accept` and the rest
1021///
1022/// `contracts/decision-history.md` splits it: decline, duplicate and defer
1023/// *require* a reason, accept records one when supplied. A required clap argument
1024/// on three of the four would make the fourth's absence look like an oversight,
1025/// and — more importantly — the enforcement belongs to the service, which refuses
1026/// `missing_reason` before any write on every surface (NFR-003). The flag is
1027/// therefore optional everywhere and the *rule* is the service's, so the CLI,
1028/// TUI and MCP cannot end up with three different ideas of which decision needs
1029/// one. `tix triage decline 1` with no `--reason` still fails, at the service,
1030/// with the contract's own kind.
1031/// `tix health`'s actions.
1032#[derive(Subcommand)]
1033pub enum HealthAction {
1034 /// Record an issue's health state (append-only; needs --rationale)
1035 ///
1036 /// Writes the frontmatter value and **one** History entry, together or not at
1037 /// all. Any of the four states is accepted; anything else is refused by name.
1038 Set {
1039 /// The issue, by number or slug
1040 issue: String,
1041 /// One of `unknown`, `on_track`, `needs_attention`, `at_risk`
1042 #[arg(long)]
1043 state: String,
1044 /// Who is making the change (defaults to the configured git identity)
1045 #[arg(long)]
1046 actor: Option<String>,
1047 /// When, as `YYYY-MM-DD` (defaults to today)
1048 #[arg(long)]
1049 date: Option<String>,
1050 /// Why — required: a health change nobody can explain is not recorded
1051 #[arg(long)]
1052 rationale: Option<String>,
1053 },
1054 /// Show one issue's health: its own value, its derived rollup, its diagnostics
1055 Show {
1056 /// The issue, by number or slug
1057 issue: String,
1058 },
1059 /// Show a scope's derived health, with its contributors and policy version
1060 Rollup {
1061 /// `issue` | `project` | `epic` | `cycle` | `milestone`
1062 #[arg(long)]
1063 scope: String,
1064 /// The scope's stable id (an issue number for `--scope issue`)
1065 #[arg(long)]
1066 id: String,
1067 },
1068 /// Layer an explicit override over a derived scope's health
1069 ///
1070 /// An override is a deliberate act about a whole scope, so it needs `--confirm`:
1071 /// it does not replace what the children say, it is recorded *over* it.
1072 Override {
1073 /// `project` | `epic` | `cycle` | `milestone`
1074 #[arg(long)]
1075 scope: String,
1076 /// The scope's stable id
1077 #[arg(long)]
1078 id: String,
1079 /// One of `unknown`, `on_track`, `needs_attention`, `at_risk`
1080 #[arg(long)]
1081 state: String,
1082 /// Who is making the change (defaults to the configured git identity)
1083 #[arg(long)]
1084 actor: Option<String>,
1085 /// When, as `YYYY-MM-DD` (defaults to today)
1086 #[arg(long)]
1087 date: Option<String>,
1088 /// Why — required
1089 #[arg(long)]
1090 rationale: Option<String>,
1091 /// Confirm an override of derived health
1092 #[arg(long)]
1093 confirm: bool,
1094 },
1095 /// The append-only health History entries about a scope
1096 History {
1097 /// A scope: `issue:42`, `cycle:2026-10`, `milestone:m1`, …
1098 scope: String,
1099 },
1100 /// Every scope's health, with what contributes to each
1101 Board,
1102}
1103
1104#[derive(Subcommand)]
1105pub enum TriageAction {
1106 /// Read the inbox queue: one row per entry (FR-001)
1107 List {
1108 /// Sort by entry age, oldest first; an unknown age sorts last
1109 #[arg(long)]
1110 age: bool,
1111 /// Print the counts only, with no entries
1112 #[arg(long)]
1113 count: bool,
1114 },
1115 /// Read the inbox summary: decision counts and the age histogram (FR-005)
1116 Status,
1117 /// Accept: move into the normal workflow, validated by #183's transition policy
1118 Accept {
1119 /// Issue number, filename fragment or unique title fragment
1120 issue: String,
1121 /// Reason recorded in History; accept records one when supplied
1122 #[arg(long)]
1123 reason: Option<String>,
1124 /// Target column; defaults to `[triage].accepted_status`
1125 #[arg(long, value_name = "COLUMN")]
1126 to: Option<String>,
1127 /// Who decided; defaults to the git identity
1128 #[arg(long)]
1129 actor: Option<String>,
1130 },
1131 /// Decline: move to `[triage].declined_status`; never deletes or archives
1132 Decline {
1133 /// Issue number, filename fragment or unique title fragment
1134 issue: String,
1135 /// Why it is declined (required)
1136 #[arg(long)]
1137 reason: Option<String>,
1138 /// Who decided; defaults to the git identity
1139 #[arg(long)]
1140 actor: Option<String>,
1141 },
1142 /// Duplicate: record a typed `duplicates` relation; never merges files
1143 Duplicate {
1144 /// Issue number, filename fragment or unique title fragment
1145 issue: String,
1146 /// The issue this one duplicates
1147 #[arg(long, value_name = "ISSUE")]
1148 of: String,
1149 /// Why it is a duplicate (required)
1150 #[arg(long)]
1151 reason: Option<String>,
1152 /// Who decided; defaults to the git identity
1153 #[arg(long)]
1154 actor: Option<String>,
1155 },
1156 /// Defer: stay in the inbox with a refreshed entry time
1157 Defer {
1158 /// Issue number, filename fragment or unique title fragment
1159 issue: String,
1160 /// Why it is deferred (required)
1161 #[arg(long)]
1162 reason: Option<String>,
1163 /// Who decided; defaults to the git identity
1164 #[arg(long)]
1165 actor: Option<String>,
1166 },
1167 /// One decision across a selection: preview by default, `--apply` to write
1168 Batch {
1169 /// Comma-separated selectors: number, filename or title fragment
1170 #[arg(long, value_name = "LIST")]
1171 select: Option<String>,
1172 /// A `tix list --filter` query selecting the issues
1173 #[arg(long, value_name = "QUERY")]
1174 filter: Option<String>,
1175 /// Which decision to make: accept | decline | duplicate | defer
1176 #[arg(long, value_name = "KIND")]
1177 decision: String,
1178 /// Reason recorded on every applied event; required for three of four
1179 #[arg(long)]
1180 reason: Option<String>,
1181 /// Who decided; defaults to the git identity
1182 #[arg(long)]
1183 actor: Option<String>,
1184 /// The duplicate target selector, for `--decision duplicate`
1185 #[arg(long, value_name = "ISSUE")]
1186 of: Option<String>,
1187 /// The accept target column, for `--decision accept`
1188 #[arg(long, value_name = "COLUMN")]
1189 to: Option<String>,
1190 /// Apply the previewed plan; without it nothing is written
1191 #[arg(long)]
1192 apply: bool,
1193 },
1194}
1195
1196#[derive(Subcommand)]
1197pub enum CycleCommand {
1198 Create {
1199 #[arg(long)]
1200 name: Option<String>,
1201 #[arg(long)]
1202 description: Option<String>,
1203 #[arg(long = "start-date")]
1204 start_date: Option<String>,
1205 #[arg(long = "due-date")]
1206 due_date: Option<String>,
1207 },
1208 List,
1209 Edit {
1210 #[arg(long)]
1211 name: String,
1212 #[arg(long = "new-name")]
1213 new_name: Option<String>,
1214 #[arg(long)]
1215 description: Option<String>,
1216 #[arg(long = "start-date")]
1217 start_date: Option<String>,
1218 #[arg(long = "due-date")]
1219 due_date: Option<String>,
1220 },
1221 Delete {
1222 #[arg(long)]
1223 name: String,
1224 #[arg(long)]
1225 yes: bool,
1226 },
1227 /// Manage repeating cycle schedules (`[cycle_schedules.<id>]`)
1228 ///
1229 /// A schedule is a stable id (the registry key) with a mutable display name;
1230 /// generated cycle instances are ordinary cycle records this family creates
1231 /// idempotently from the schedule's definition. Detection is read-only; every
1232 /// mutation requires `--confirm`.
1233 Schedule {
1234 #[command(subcommand)]
1235 command: CycleScheduleCommand,
1236 },
1237}
1238
1239/// The `tix cycle schedule` subcommands (mission
1240/// `repeating-cycle-schedules-01M3C7W1`, WP06).
1241///
1242/// The surface contract's spelling (`contracts/cli-surface.md`): a schedule
1243/// namespace under `tix cycle`, with stable-ID selectors, explicit date/timezone
1244/// context and confirmed mutations. This enum is a thin adapter — the schedule
1245/// record, the read model, evaluation, generation and the lifecycle all live in
1246/// `crate::cycle_schedule`; nothing here re-derives a cadence, a cursor or a
1247/// History entry.
1248#[derive(Debug, Clone, Subcommand)]
1249pub enum CycleScheduleCommand {
1250 /// List schedules, one tab-separated row per schedule
1251 ///
1252 /// Columns: `id`, `name`, `state`, `cadence`, `duration`, `start_date`,
1253 /// `look_ahead`, `next_sequence`, `generated`.
1254 List,
1255 /// Show one schedule: identity, cadence, duration, cursor and its instances
1256 Show {
1257 /// Stable schedule id, or its unique display name
1258 #[arg(value_name = "SCHEDULE")]
1259 schedule: String,
1260 },
1261 /// Define a new repeating schedule; validated before persistence, requires `--confirm`
1262 Create(CycleScheduleCreateArgs),
1263 /// Change a schedule's fields, keeping its stable id; requires `--confirm`
1264 ///
1265 /// Only the flags you name change; the rest of the record (including its
1266 /// generation cursor and preserved metadata) is carried over.
1267 Edit {
1268 /// Stable schedule id, or its unique display name
1269 #[arg(value_name = "SCHEDULE")]
1270 schedule: String,
1271 #[command(flatten)]
1272 args: CycleScheduleEditArgs,
1273 /// Confirm the change without prompting
1274 #[arg(long)]
1275 confirm: bool,
1276 },
1277 /// Delete a schedule's registry entry; requires `--confirm`
1278 ///
1279 /// The generated cycles it created and their snapshots are **not** deleted:
1280 /// they keep their schedule reference, which then reads as a diagnosable
1281 /// missing schedule, so nothing is destroyed by deleting a definition.
1282 Delete {
1283 /// Stable schedule id, or its unique display name
1284 #[arg(value_name = "SCHEDULE")]
1285 schedule: String,
1286 /// Confirm the deletion without prompting
1287 #[arg(long)]
1288 confirm: bool,
1289 },
1290 /// Report ended/due/look-ahead warnings, read-only
1291 ///
1292 /// Prints the warnings and the explicit decisions each accepts. It writes
1293 /// nothing: no file, no cursor move, no History entry.
1294 Detect {
1295 /// Evaluate against this `YYYY-MM-DD` date instead of today
1296 #[arg(long, value_name = "YYYY-MM-DD")]
1297 date: Option<String>,
1298 },
1299 /// Create the schedule's future cycles; idempotent, requires `--confirm`
1300 ///
1301 /// A repeated call under the same stable keys is a no-op, never a duplicate.
1302 Generate {
1303 /// Stable schedule id, or its unique display name
1304 #[arg(value_name = "SCHEDULE")]
1305 schedule: String,
1306 /// Generate every candidate whose start date is on or before this date
1307 #[arg(long, value_name = "YYYY-MM-DD")]
1308 through: Option<String>,
1309 /// Confirm the generation without prompting
1310 #[arg(long)]
1311 confirm: bool,
1312 },
1313 /// Close an instance: capture its immutable snapshot and mark it closed
1314 Close {
1315 /// Stable schedule id, or its unique display name
1316 #[arg(value_name = "SCHEDULE")]
1317 schedule: String,
1318 #[command(flatten)]
1319 args: CycleScheduleReasonArgs,
1320 },
1321 /// Reopen a closed instance: restore `active` and append one History event
1322 ///
1323 /// The closed cycle's capacity snapshot is **not** rewritten: it stays the
1324 /// immutable evidence the close captured, and the appended `cycle_reopened`
1325 /// event names it. Reopening an open instance is a no-op, and the action
1326 /// requires both a reason and the instance it names.
1327 Reopen {
1328 /// Stable schedule id, or its unique display name
1329 #[arg(value_name = "SCHEDULE")]
1330 schedule: String,
1331 #[command(flatten)]
1332 args: CycleScheduleReasonArgs,
1333 },
1334 /// Push an instance's due date out by a cadence period
1335 Extend {
1336 /// Stable schedule id, or its unique display name
1337 #[arg(value_name = "SCHEDULE")]
1338 schedule: String,
1339 #[command(flatten)]
1340 args: CycleScheduleExtendArgs,
1341 },
1342 /// Record a sequence as skipped so it is never regenerated
1343 Skip {
1344 /// Stable schedule id, or its unique display name
1345 #[arg(value_name = "SCHEDULE")]
1346 schedule: String,
1347 #[command(flatten)]
1348 args: CycleScheduleReasonArgs,
1349 },
1350 /// Pause a schedule and its non-terminal instances
1351 Pause {
1352 /// Stable schedule id, or its unique display name
1353 #[arg(value_name = "SCHEDULE")]
1354 schedule: String,
1355 #[command(flatten)]
1356 args: CycleScheduleLifecycleArgs,
1357 },
1358 /// Resume a paused schedule and its instances
1359 Resume {
1360 /// Stable schedule id, or its unique display name
1361 #[arg(value_name = "SCHEDULE")]
1362 schedule: String,
1363 #[command(flatten)]
1364 args: CycleScheduleLifecycleArgs,
1365 },
1366 /// Re-derive every future instance's dates from the schedule's definition
1367 AdjustFuture {
1368 /// Stable schedule id, or its unique display name
1369 #[arg(value_name = "SCHEDULE")]
1370 schedule: String,
1371 #[command(flatten)]
1372 args: CycleScheduleReasonArgs,
1373 },
1374 /// Close a warning without deciding anything; nothing is written
1375 Ignore {
1376 /// Stable schedule id, or its unique display name
1377 #[arg(value_name = "SCHEDULE")]
1378 schedule: String,
1379 #[command(flatten)]
1380 args: CycleScheduleLifecycleArgs,
1381 },
1382}
1383
1384/// The flags that define a schedule (`tix cycle schedule create`).
1385#[derive(Debug, Clone, Args)]
1386pub struct CycleScheduleCreateArgs {
1387 /// Stable schedule id (letters, digits, `-`, `_`, `:`); never reused
1388 #[arg(long, value_name = "ID")]
1389 pub id: String,
1390 /// Display label, unique case-insensitively within the registry
1391 #[arg(long, value_name = "NAME")]
1392 pub name: String,
1393 /// Cadence family: `daily`, `weekly`, `monthly` or `interval`
1394 ///
1395 /// `interval` is the family that carries `--unit`; the other three are the
1396 /// calendar-step families and refuse a unit.
1397 #[arg(long, value_name = "KIND")]
1398 pub cadence: String,
1399 /// How many cadence units between cycle starts (1-1000; absent means 1)
1400 #[arg(long, value_name = "N")]
1401 pub interval: Option<u32>,
1402 /// The unit an `interval` cadence counts in: `days`, `weeks`, `months`
1403 #[arg(long, value_name = "UNIT")]
1404 pub unit: Option<String>,
1405 /// How long one cycle lasts, as a positive count
1406 #[arg(long, value_name = "N")]
1407 pub duration: u32,
1408 /// The duration's unit: `days`, `weeks`, `months`
1409 #[arg(long = "duration-unit", value_name = "UNIT")]
1410 pub duration_unit: String,
1411 /// The schedule's stable anchor, a `YYYY-MM-DD` calendar date
1412 #[arg(long = "start-date", value_name = "YYYY-MM-DD")]
1413 pub start_date: String,
1414 /// Fixed UTC offset the schedule is expressed in: `UTC` or `±HH:MM`
1415 ///
1416 /// `allow_hyphen_values` so a west-of-UTC offset (`-05:00`) reaches the
1417 /// validator instead of being swallowed by clap as an unknown flag.
1418 #[arg(long, value_name = "TZ", allow_hyphen_values = true)]
1419 pub timezone: String,
1420 /// How many future cycles to consider beyond the current one (0 when absent)
1421 #[arg(long = "look-ahead", value_name = "N")]
1422 pub look_ahead: Option<u32>,
1423 /// Confirm the creation without prompting
1424 #[arg(long)]
1425 pub confirm: bool,
1426}
1427
1428/// What `tix cycle schedule edit` may change. Every field is optional; only the
1429/// named ones change, so a partial edit keeps the rest of the record.
1430#[derive(Debug, Clone, Args)]
1431pub struct CycleScheduleEditArgs {
1432 /// New display label
1433 #[arg(long, value_name = "NAME")]
1434 pub name: Option<String>,
1435 /// New cadence family: `daily`, `weekly`, `monthly` or `interval`
1436 #[arg(long, value_name = "KIND")]
1437 pub cadence: Option<String>,
1438 /// New cadence interval (1-1000)
1439 #[arg(long, value_name = "N")]
1440 pub interval: Option<u32>,
1441 /// New cadence unit (only meaningful for the `interval` family)
1442 #[arg(long, value_name = "UNIT")]
1443 pub unit: Option<String>,
1444 /// New duration count
1445 #[arg(long, value_name = "N")]
1446 pub duration: Option<u32>,
1447 /// New duration unit
1448 #[arg(long = "duration-unit", value_name = "UNIT")]
1449 pub duration_unit: Option<String>,
1450 /// New anchor date, `YYYY-MM-DD`
1451 #[arg(long = "start-date", value_name = "YYYY-MM-DD")]
1452 pub start_date: Option<String>,
1453 /// New timezone: `UTC` or `±HH:MM` (`allow_hyphen_values` for `-05:00`)
1454 #[arg(long, value_name = "TZ", allow_hyphen_values = true)]
1455 pub timezone: Option<String>,
1456 /// New look-ahead count
1457 #[arg(long = "look-ahead", value_name = "N")]
1458 pub look_ahead: Option<u32>,
1459}
1460
1461/// The flags every `tix cycle schedule` lifecycle action shares.
1462#[derive(Debug, Clone, Args)]
1463pub struct CycleScheduleLifecycleArgs {
1464 /// Target instance, `<schedule-id>:<sequence>` (e.g. `cs-sprint:000001`)
1465 #[arg(long, value_name = "CYCLE")]
1466 pub cycle: Option<String>,
1467 /// Select the instance by its start date, `YYYY-MM-DD`
1468 #[arg(long, value_name = "YYYY-MM-DD")]
1469 pub from: Option<String>,
1470 /// Confirm the action without prompting
1471 #[arg(long)]
1472 pub confirm: bool,
1473}
1474
1475/// The lifecycle flags plus the reason the recording actions require.
1476#[derive(Debug, Clone, Args)]
1477pub struct CycleScheduleReasonArgs {
1478 #[command(flatten)]
1479 pub lifecycle: CycleScheduleLifecycleArgs,
1480 /// Why; recorded in the instance's History
1481 #[arg(long, value_name = "TEXT")]
1482 pub reason: Option<String>,
1483}
1484
1485/// `tix cycle schedule extend`'s flags: the reason plus the period count.
1486#[derive(Debug, Clone, Args)]
1487pub struct CycleScheduleExtendArgs {
1488 #[command(flatten)]
1489 pub reason: CycleScheduleReasonArgs,
1490 /// How many cadence periods to extend by; the shared lifecycle supports 1
1491 #[arg(long, value_name = "N")]
1492 pub by: Option<u32>,
1493}
1494
1495/// The `tix capacity` subcommands (mission `cycle-capacity-planning-01M3C85C`, WP05).
1496///
1497/// A thin adapter: the `[capacity]` section, its validation, the shared calculation,
1498/// the findings and the close evidence all live in `crate::capacity`. This enum owns
1499/// only the spelling of the surface — the scope and the unit are always explicit
1500/// (C-003/C-004), and no flag here can change an issue.
1501#[derive(Debug, Clone, Subcommand)]
1502pub enum BeadsAction {
1503 /// Report what `bd` and its workspace look like (read-only)
1504 ///
1505 /// Runs only `bd version`, `bd where` and `bd config get`, so it is safe on a
1506 /// repository that has not opted in — and it names a workspace whose events
1507 /// journal is off, because a live mirror would then see nothing.
1508 Detect,
1509 /// A read-only summary of the beads workspace
1510 ///
1511 /// The open/closed/blocked/ready figures are computed the way beads derives
1512 /// them — from the `blocks` dependencies — so they agree with `bd ready`
1513 /// rather than guessing.
1514 Status,
1515 /// List beads issues, read-only
1516 List {
1517 /// Include closed issues
1518 #[arg(long)]
1519 all: bool,
1520 },
1521 /// Show one beads issue, read-only
1522 Show {
1523 /// Beads id, or a unique fragment of one (or of a title)
1524 issue: String,
1525 },
1526 /// Follow the beads events journal, printing each change as it commits
1527 ///
1528 /// Reads the journal in pages and re-reads from its own checkpoint, so it
1529 /// survives a gap between reads. Requires the journal to be enabled wherever
1530 /// beads writes; `detect` says whether it is.
1531 Watch {
1532 /// Sequence to resume from (default: the beginning of what is retained)
1533 #[arg(long)]
1534 since: Option<u64>,
1535 /// Seconds between reads
1536 #[arg(long, default_value_t = 1)]
1537 interval: u64,
1538 },
1539 /// Write local issues into beads through `bd import`
1540 ///
1541 /// **Incremental by default**: only issues whose exported row changed since
1542 /// the last push are sent, so a repeat run with nothing changed invokes `bd`
1543 /// not at all. `--all` forces every issue through, which is what a discarded
1544 /// state file or a rebuilt beads workspace needs.
1545 Push {
1546 /// Print the JSONL instead of importing it
1547 #[arg(long)]
1548 dry_run: bool,
1549 /// Send every issue, ignoring the export watermark
1550 #[arg(long)]
1551 all: bool,
1552 /// List the per-issue diagnostics (which local fields beads cannot hold)
1553 #[arg(long)]
1554 verbose: bool,
1555 },
1556 /// Pull beads issues into local files
1557 Pull {
1558 /// Print nothing on success (used by the beads hooks)
1559 #[arg(long)]
1560 quiet: bool,
1561 },
1562 /// Pull, then push — the full both-ways pass
1563 Reconcile {
1564 /// Print nothing on success beyond the push report
1565 #[arg(long)]
1566 quiet: bool,
1567 },
1568}
1569
1570#[derive(Debug, Clone, Subcommand)]
1571pub enum CapacityCommand {
1572 /// List every configured ceiling, one tab-separated row per entry
1573 ///
1574 /// Columns: `id`, `scope`, `reference`, `display`, `capacity`, `unit`,
1575 /// `enabled`. The header prints even with no entries, so an empty section is
1576 /// visibly empty rather than indistinguishable from a failure.
1577 List,
1578 /// Show one ceiling by the reference it names
1579 ///
1580 /// A reference is a cycle's name or stable id, a team label, or an assignee
1581 /// identity; `--scope` narrows it when one reference names two scopes.
1582 Show {
1583 /// Cycle name/stable id, team label, or assignee identity
1584 #[arg(value_name = "REFERENCE")]
1585 reference: String,
1586 /// Restrict the match to one scope: `cycle`, `team` or `assignee`
1587 #[arg(long, value_name = "SCOPE")]
1588 scope: Option<String>,
1589 },
1590 /// Set one scope's ceiling; validated before persistence
1591 ///
1592 /// The scope and the unit are explicit: a value with no unit, a unit no board
1593 /// declares, an unknown cycle/team, or a second ceiling for one scope is
1594 /// refused before `tix.toml` changes.
1595 Set {
1596 /// Scope dimension: `cycle`, `team` or `assignee`
1597 #[arg(long, value_name = "SCOPE")]
1598 scope: String,
1599 /// The scope's reference (a cycle name or stable id, a team label, an identity)
1600 #[arg(long, value_name = "ID")]
1601 id: String,
1602 /// The ceiling, in `--unit`, at or above zero
1603 ///
1604 /// A negative value is accepted by the parser and refused by the shared
1605 /// validator with its field path (`capacity.<id>.capacity`), which is a better
1606 /// message than clap's "unexpected argument".
1607 #[arg(long, value_name = "N", allow_hyphen_values = true)]
1608 value: i64,
1609 /// The estimate unit the ceiling counts in (declared by the active board)
1610 #[arg(long, value_name = "UNIT")]
1611 unit: String,
1612 /// Keep the value and the unit, but enforce nothing
1613 #[arg(long)]
1614 disabled: bool,
1615 },
1616 /// Delete one scope's ceiling
1617 Delete {
1618 /// Scope dimension: `cycle`, `team` or `assignee`
1619 #[arg(long, value_name = "SCOPE")]
1620 scope: String,
1621 /// The scope's reference (a cycle name or stable id, a team label, an identity)
1622 #[arg(long, value_name = "ID")]
1623 id: String,
1624 },
1625 /// Calculate one scope's committed/completed/remaining/overflow/utilization
1626 ///
1627 /// Every value comes from the shared calculation DTO; a value the calculation
1628 /// could not derive prints as `-`, never as a zero. Findings go to stderr.
1629 Calculate {
1630 /// Scope dimension: `cycle`, `team` or `assignee`
1631 #[arg(long, value_name = "SCOPE")]
1632 scope: String,
1633 /// The scope's reference (a cycle name or stable id, a team label, an identity)
1634 #[arg(long, value_name = "ID")]
1635 id: String,
1636 /// Also render the overflow report (estimate and reference findings always render)
1637 #[arg(long)]
1638 warnings: bool,
1639 },
1640 /// Read back a closed cycle's immutable capacity evidence
1641 Snapshot {
1642 /// Cycle name or stable `<schedule-id>:<sequence>` id
1643 #[arg(value_name = "CYCLE")]
1644 cycle: String,
1645 /// Also render the overflow finding the close recorded
1646 #[arg(long)]
1647 warnings: bool,
1648 },
1649}
1650
1651/// The `tix time` subcommands (mission `per-issue-time-tracking-01M3C9A4`, WP04).
1652///
1653/// A thin adapter: the entry model and its validation, the mutation service and its
1654/// append-only History, and every filter, total and finding live under
1655/// `crate::time_tracking`. This enum owns only the spelling of the surface — the
1656/// duration is whole minutes, a date is a calendar `YYYY-MM-DD`, and there is no flag
1657/// here that could express a timer, a timezone, an overlap rule or a billing unit
1658/// (C-002/C-009).
1659#[derive(Debug, Clone, Subcommand)]
1660pub enum TimeCommand {
1661 /// Record effort against an issue, in whole minutes
1662 ///
1663 /// The date is optional and is a calendar date, never an instant: an entry without
1664 /// one is real, and a period-bounded read reports it as undated rather than as zero.
1665 Add {
1666 /// Issue number, filename fragment or unique title fragment
1667 #[arg(value_name = "ISSUE")]
1668 issue: String,
1669 /// The effort, in whole minutes at or above zero
1670 ///
1671 /// A negative value is accepted by the parser and refused by the shared entry
1672 /// validator with its field name, which is a better message than clap's
1673 /// "unexpected argument".
1674 #[arg(long, value_name = "N", allow_hyphen_values = true)]
1675 minutes: i64,
1676 /// The calendar date the effort belongs to, `YYYY-MM-DD`
1677 #[arg(long, value_name = "YYYY-MM-DD")]
1678 date: Option<String>,
1679 /// Who did the work: a handle (`ada`, `@ada`) or a `Name <email>` identity
1680 #[arg(long, value_name = "IDENTITY")]
1681 author: String,
1682 /// Free-text context, preserved verbatim
1683 #[arg(long, value_name = "TEXT")]
1684 note: Option<String>,
1685 },
1686 /// Correct one entry, naming only the fields that change
1687 ///
1688 /// A field the command does not name is left exactly as it was, including the entry's
1689 /// date, note and any metadata another build attached. `--clear-date` and
1690 /// `--clear-note` are how an optional value is removed (an empty `--note ""` would be
1691 /// a value, not an absence). A correction requires `--reason`: the prior value stays
1692 /// in History, and the reason is what makes the change attributable.
1693 Edit {
1694 /// Issue number, filename fragment or unique title fragment
1695 #[arg(value_name = "ISSUE")]
1696 issue: String,
1697 /// The stable id of the entry to correct (`tix time show <ISSUE>` lists them)
1698 #[arg(long, value_name = "ID")]
1699 entry: String,
1700 #[command(flatten)]
1701 change: TimeEditArgs,
1702 },
1703 /// Remove one entry from the issue's current entries
1704 ///
1705 /// The entry leaves the frontmatter; its value and its event stay in History, so a
1706 /// deletion is auditable rather than a rewrite. Requires `--reason` and `--confirm`.
1707 Delete {
1708 /// Issue number, filename fragment or unique title fragment
1709 #[arg(value_name = "ISSUE")]
1710 issue: String,
1711 /// The stable id of the entry to remove
1712 #[arg(long, value_name = "ID")]
1713 entry: String,
1714 /// Why the entry is being removed; recorded in History
1715 #[arg(long, value_name = "TEXT")]
1716 reason: Option<String>,
1717 /// Confirm the removal without prompting
1718 #[arg(long)]
1719 confirm: bool,
1720 },
1721 /// Show an issue's current entries and its total
1722 Show {
1723 /// Issue number, filename fragment or unique title fragment
1724 #[arg(value_name = "ISSUE")]
1725 issue: String,
1726 },
1727 /// Show what History retains for an issue: current entries beside its audit
1728 History {
1729 /// Issue number, filename fragment or unique title fragment
1730 #[arg(value_name = "ISSUE")]
1731 issue: String,
1732 },
1733 /// Total recorded effort, by issue, assignee and calendar period
1734 ///
1735 /// Every value comes from the shared calculation; a scope with nothing to total and a
1736 /// scope whose entries carry no usable duration print `-` with their state, never a
1737 /// zero. Deleted entries are excluded unless `--audit` asks for them.
1738 Total {
1739 /// Issue number, filename fragment or unique title fragment (alternative to `--issue`)
1740 #[arg(value_name = "ISSUE")]
1741 selector: Option<String>,
1742 #[command(flatten)]
1743 filter: TimeTotalArgs,
1744 },
1745}
1746
1747/// The fields an entry correction can name (T020).
1748///
1749/// A patch, not a replacement: a flag that is absent leaves its field alone, which is why
1750/// removal is spelled `--clear-date`/`--clear-note` rather than an empty value. The reason
1751/// is required — by the service, which refuses a missing one by name — and is what the
1752/// History event keeps once the prior value is replaced.
1753#[derive(Debug, Clone, Args)]
1754pub struct TimeEditArgs {
1755 /// Replacement duration, in whole minutes
1756 #[arg(long, value_name = "N", allow_hyphen_values = true)]
1757 pub minutes: Option<i64>,
1758 /// Replacement calendar date, `YYYY-MM-DD`
1759 #[arg(long, value_name = "YYYY-MM-DD", conflicts_with = "clear_date")]
1760 pub date: Option<String>,
1761 /// Remove the entry's date
1762 #[arg(long)]
1763 pub clear_date: bool,
1764 /// Replacement note
1765 #[arg(long, value_name = "TEXT", conflicts_with = "clear_note")]
1766 pub note: Option<String>,
1767 /// Remove the entry's note
1768 #[arg(long)]
1769 pub clear_note: bool,
1770 /// Replacement author identity
1771 #[arg(long, value_name = "IDENTITY")]
1772 pub author: Option<String>,
1773 /// Why the entry is being corrected; recorded in History, and required
1774 #[arg(long, value_name = "TEXT")]
1775 pub reason: Option<String>,
1776}
1777
1778/// The read-only scope a `tix time total` is evaluated under (T021).
1779///
1780/// Every criterion is optional and every named one must match, and the period is
1781/// inclusive. The dates are the caller's: this surface never resolves a `today`, so a
1782/// total means exactly the calendar window it was asked for.
1783#[derive(Debug, Clone, Args)]
1784pub struct TimeTotalArgs {
1785 /// Stable issue number to scope the total to
1786 #[arg(long, value_name = "N")]
1787 pub issue: Option<u32>,
1788 /// The identity that did the work (an entry's author)
1789 #[arg(long, value_name = "IDENTITY")]
1790 pub assignee: Option<String>,
1791 /// Inclusive period start, `YYYY-MM-DD`
1792 #[arg(long, value_name = "YYYY-MM-DD")]
1793 pub from: Option<String>,
1794 /// Inclusive period end, `YYYY-MM-DD`
1795 #[arg(long, value_name = "YYYY-MM-DD")]
1796 pub to: Option<String>,
1797 /// Also report the entries deletions left in History (never added to the total)
1798 #[arg(long)]
1799 pub audit: bool,
1800 /// Break the total down: `issue`, `assignee` or `date`
1801 #[arg(long, value_name = "DIMENSION")]
1802 pub by: Option<String>,
1803}
1804
1805#[derive(Subcommand)]
1806pub enum MilestoneCommand {
1807 Create {
1808 #[arg(long)]
1809 name: String,
1810 #[arg(long = "target-date")]
1811 target_date: Option<String>,
1812 #[arg(long)]
1813 description: Option<String>,
1814 },
1815 List,
1816 Edit {
1817 #[arg(long)]
1818 name: String,
1819 #[arg(long = "new-name")]
1820 new_name: Option<String>,
1821 #[arg(long = "target-date")]
1822 target_date: Option<String>,
1823 #[arg(long)]
1824 description: Option<String>,
1825 },
1826 Delete {
1827 #[arg(long)]
1828 name: String,
1829 #[arg(long)]
1830 yes: bool,
1831 },
1832}
1833
1834/// The `tix project` subcommands (mission `first-class-projects-01M3BYBW`, WP05).
1835///
1836/// A **required** subcommand, like `tix milestone` and `tix cycle`: `tix project`
1837/// alone is a usage error, not a listing, because guessing which of ten verbs the
1838/// user meant is worse than asking.
1839///
1840/// Selection is by the stable id (the registry key); a name is accepted as a
1841/// convenience and resolves to the same project, since `tix.toml` guarantees the
1842/// names are unique.
1843#[derive(Subcommand)]
1844pub enum ProjectCommand {
1845 /// Create a project and allocate a stable id
1846 ///
1847 /// The name is a display label, not the identity: it can be changed later with
1848 /// `tix project edit --name`, and every issue association survives because the
1849 /// issues reference the id.
1850 Create {
1851 /// Display name (unique within the registry, case-insensitively)
1852 #[arg(value_name = "NAME")]
1853 name: String,
1854 /// Long-form context
1855 #[arg(long)]
1856 description: Option<String>,
1857 /// Free-form project status (not a board column)
1858 #[arg(long)]
1859 status: Option<String>,
1860 /// Lead identity string
1861 #[arg(long)]
1862 lead: Option<String>,
1863 /// Start date (`YYYY-MM-DD`)
1864 #[arg(long = "start-date")]
1865 start_date: Option<String>,
1866 /// Due date (`YYYY-MM-DD`); must be `>= start-date`
1867 #[arg(long = "due-date")]
1868 due_date: Option<String>,
1869 },
1870 /// Edit a project's fields; a rename changes the label only
1871 ///
1872 /// Nothing is re-keyed: the id stays, so every association and every derived
1873 /// figure is untouched. An optional text flag with a blank value clears it
1874 /// (`--lead ""`), the same convention `tix edit` uses.
1875 Edit {
1876 /// Project id (a name is accepted as a convenience)
1877 #[arg(value_name = "ID")]
1878 reference: String,
1879 /// New display name
1880 #[arg(long)]
1881 name: Option<String>,
1882 /// New long-form context (`--description ""` clears)
1883 #[arg(long)]
1884 description: Option<String>,
1885 /// New free-form status (`--status ""` clears)
1886 #[arg(long)]
1887 status: Option<String>,
1888 /// New lead identity string (`--lead ""` clears)
1889 #[arg(long)]
1890 lead: Option<String>,
1891 /// New start date (`YYYY-MM-DD`; `--start-date ""` clears)
1892 #[arg(long = "start-date")]
1893 start_date: Option<String>,
1894 /// New due date (`YYYY-MM-DD`; `--due-date ""` clears)
1895 #[arg(long = "due-date")]
1896 due_date: Option<String>,
1897 },
1898 /// List projects in declaration order, with ids and member counts
1899 List,
1900 /// Show one project's record and its members
1901 Show {
1902 /// Project id (a name is accepted as a convenience)
1903 #[arg(value_name = "ID")]
1904 reference: String,
1905 },
1906 /// Delete a project's registry entry (its issues are never deleted)
1907 ///
1908 /// The issues that referenced the project keep their references; the report
1909 /// names them so the dangling references can be repaired deliberately.
1910 Delete {
1911 /// Project id (a name is accepted as a convenience)
1912 #[arg(value_name = "ID")]
1913 reference: String,
1914 /// Confirm without prompting
1915 #[arg(long)]
1916 yes: bool,
1917 },
1918 /// Associate one or more issues with a project
1919 ///
1920 /// Each selector is processed independently: a rejection on one is reported
1921 /// and does not abort the rest.
1922 Add {
1923 /// Project id (a name is accepted as a convenience)
1924 #[arg(value_name = "ID")]
1925 reference: String,
1926 /// Issue numbers, filename fragments or unique title fragments
1927 #[arg(required = true, value_name = "ISSUE")]
1928 issues: Vec<String>,
1929 },
1930 /// Dissociate one or more issues from a project
1931 ///
1932 /// Drops only the association; no issue is deleted, and each selector is
1933 /// processed independently.
1934 ///
1935 /// A reference that does not name a live project is used **verbatim**, because
1936 /// removing a retained stale id is the repair path a project deletion leaves
1937 /// open (C-005). A typo that matches nothing is therefore an inert no-op that
1938 /// reports `0 changed`, not a refusal — which is the one place this differs
1939 /// from `tix edit <issue> --remove-project <id>`, where an unknown reference the
1940 /// issue does not carry is refused as `unknown_project`.
1941 Remove {
1942 /// Project id (a name is accepted as a convenience)
1943 #[arg(value_name = "ID")]
1944 reference: String,
1945 /// Issue numbers, filename fragments or unique title fragments
1946 #[arg(required = true, value_name = "ISSUE")]
1947 issues: Vec<String>,
1948 },
1949 /// Bulk-associate one or more issues with a project
1950 ///
1951 /// Project association only — general bulk editing is a different feature.
1952 /// Every selector is processed independently and every outcome is reported,
1953 /// so one unknown selector does not discard the associations that succeeded.
1954 ///
1955 /// An unknown **project** reference refuses the whole command before anything
1956 /// is written (all entries then fail with `unknown_project`); use
1957 /// `tix project remove` to drop a retained stale id, whose reference is matched
1958 /// verbatim and reported as `0 changed` rather than refused.
1959 BulkAdd {
1960 /// Project id (a name is accepted as a convenience)
1961 #[arg(value_name = "ID")]
1962 reference: String,
1963 /// Issue numbers, filename fragments or unique title fragments
1964 #[arg(required = true, value_name = "ISSUE")]
1965 issues: Vec<String>,
1966 },
1967}
1968
1969/// The `tix epic` subcommands (mission `epics-work-items-01M3BYW4`, WP05).
1970///
1971/// A **required** subcommand, like `tix project`. Selection is by the stable id
1972/// (the registry key); a name resolves to the same epic as a convenience. A work
1973/// item is an ordinary issue (`tix create --epics`, `tix edit --add-epic`), so
1974/// there is no task verb here.
1975#[derive(Subcommand)]
1976pub enum EpicCommand {
1977 /// Create an epic and allocate a stable id
1978 ///
1979 /// The name is a display label, not the identity: `tix epic edit --name`
1980 /// changes it and every issue reference survives, because issues reference the id.
1981 Create {
1982 /// Display name (unique within the registry, case-insensitively)
1983 #[arg(value_name = "NAME")]
1984 name: String,
1985 /// Long-form context
1986 #[arg(long)]
1987 description: Option<String>,
1988 /// Free-form epic status (not a board column, not a category)
1989 #[arg(long)]
1990 status: Option<String>,
1991 /// Owner identity string
1992 #[arg(long)]
1993 owner: Option<String>,
1994 /// Start date (`YYYY-MM-DD`)
1995 #[arg(long = "start-date")]
1996 start_date: Option<String>,
1997 /// Due date (`YYYY-MM-DD`); must be `>= start-date`
1998 #[arg(long = "due-date")]
1999 due_date: Option<String>,
2000 /// Positive relative size, used by the epic rollup
2001 ///
2002 /// `allow_hyphen_values` so `--weight -5` reaches the service's
2003 /// `invalid_weight` refusal rather than being swallowed by clap as an
2004 /// unknown flag — the same invalid input must report one thing whichever
2005 /// spelling is used.
2006 #[arg(long, allow_hyphen_values = true)]
2007 weight: Option<String>,
2008 /// Project ids this epic concerns, comma-separated
2009 ///
2010 /// Shape-checked only, never resolved: a well-formed id the registry does
2011 /// not hold is stored and diagnosed by `tix lint`, so it is repairable.
2012 #[arg(long, value_name = "ID")]
2013 projects: Option<String>,
2014 /// Milestone name; stored verbatim, never resolved
2015 #[arg(long)]
2016 milestone: Option<String>,
2017 /// Cycle name; stored verbatim, never resolved
2018 #[arg(long)]
2019 cycle: Option<String>,
2020 },
2021 /// Edit an epic's fields; a rename changes the label only
2022 ///
2023 /// Nothing is re-keyed, so every issue reference and derived figure stays
2024 /// intact. A blank optional value clears the field (`--owner ""`).
2025 Edit {
2026 /// Epic id (a name is accepted as a convenience)
2027 #[arg(value_name = "ID")]
2028 reference: String,
2029 /// New display name
2030 #[arg(long)]
2031 name: Option<String>,
2032 /// New long-form context (`--description ""` clears)
2033 #[arg(long)]
2034 description: Option<String>,
2035 /// New free-form status (`--status ""` clears)
2036 #[arg(long)]
2037 status: Option<String>,
2038 /// New owner identity string (`--owner ""` clears)
2039 #[arg(long)]
2040 owner: Option<String>,
2041 /// New start date (`--start-date ""` clears)
2042 #[arg(long = "start-date")]
2043 start_date: Option<String>,
2044 /// New due date (`--due-date ""` clears)
2045 #[arg(long = "due-date")]
2046 due_date: Option<String>,
2047 /// New positive relative size (`--weight ""` clears)
2048 ///
2049 /// `allow_hyphen_values` so `--weight -5` reaches the service's
2050 /// `invalid_weight` refusal rather than clap's unknown-flag error.
2051 #[arg(long, allow_hyphen_values = true)]
2052 weight: Option<String>,
2053 /// Set or change the parent epic; `--parent ""` detaches it
2054 ///
2055 /// Validated by the epic service before any write, so a self-parent or a
2056 /// cycle-closing change leaves the registry byte-identical.
2057 #[arg(long, value_name = "ID")]
2058 parent: Option<String>,
2059 /// Replace the whole project list, comma-separated (`--projects ""` clears)
2060 ///
2061 /// Shape-checked only, never resolved: a dangling but well-formed id is
2062 /// stored and diagnosed by `tix lint`.
2063 #[arg(long, value_name = "ID")]
2064 projects: Option<String>,
2065 /// New milestone name (`--milestone ""` clears); stored verbatim
2066 #[arg(long)]
2067 milestone: Option<String>,
2068 /// New cycle name (`--cycle ""` clears); stored verbatim
2069 #[arg(long)]
2070 cycle: Option<String>,
2071 },
2072 /// List epics in declaration order, with ids, member and child counts
2073 List,
2074 /// Show one epic's record, hierarchy, progress and members
2075 Show {
2076 /// Epic id (a name is accepted as a convenience)
2077 #[arg(value_name = "ID")]
2078 reference: String,
2079 },
2080 /// Navigate an epic's hierarchy: parent, children, ancestors and depth
2081 ///
2082 /// Direct issue navigation stays available: the member list is included and a
2083 /// member is an ordinary issue, reachable with `tix show <number>`.
2084 Navigate {
2085 /// Epic id (a name is accepted as a convenience)
2086 #[arg(value_name = "ID")]
2087 reference: String,
2088 },
2089 /// Delete an epic's registry entry (its issues and child epics are never deleted)
2090 ///
2091 /// The issues that referenced the epic keep their references and the child
2092 /// epics keep their parent reference; both are reported so the dangling
2093 /// references can be repaired deliberately.
2094 Delete {
2095 /// Epic id (a name is accepted as a convenience)
2096 #[arg(value_name = "ID")]
2097 reference: String,
2098 /// Confirm without prompting
2099 #[arg(long)]
2100 yes: bool,
2101 },
2102 /// Reference an epic from one or more issues
2103 ///
2104 /// Each selector is processed independently: a rejection on one is reported
2105 /// and does not abort the rest.
2106 Add {
2107 /// Epic id (a name is accepted as a convenience)
2108 #[arg(value_name = "ID")]
2109 reference: String,
2110 /// Issue numbers, filename fragments or unique title fragments
2111 #[arg(required = true, value_name = "ISSUE")]
2112 issues: Vec<String>,
2113 },
2114 /// Remove an epic reference from one or more issues
2115 ///
2116 /// Drops only the reference; no issue is deleted. A reference that does not
2117 /// name a live epic is used **verbatim**, so a retained stale id can still be
2118 /// removed and is reported as `0 changed` rather than refused.
2119 Remove {
2120 /// Epic id (a name is accepted as a convenience)
2121 #[arg(value_name = "ID")]
2122 reference: String,
2123 /// Issue numbers, filename fragments or unique title fragments
2124 #[arg(required = true, value_name = "ISSUE")]
2125 issues: Vec<String>,
2126 },
2127 /// Bulk-reference one or more issues from an epic
2128 ///
2129 /// Epic reference only — general bulk editing is a different feature. Every
2130 /// selector is independent, so one unknown selector does not discard the rest.
2131 /// An unknown **epic** reference refuses the whole command before anything is
2132 /// written; use `tix epic remove` to drop a retained stale id.
2133 BulkAdd {
2134 /// Epic id (a name is accepted as a convenience)
2135 #[arg(value_name = "ID")]
2136 reference: String,
2137 /// Issue numbers, filename fragments or unique title fragments
2138 #[arg(required = true, value_name = "ISSUE")]
2139 issues: Vec<String>,
2140 },
2141}
2142
2143/// The `tix initiative` subcommands (mission `roadmap-initiatives-01M3C8SX`, WP04).
2144///
2145/// A **required** subcommand, like `tix project` and `tix epic`. Selection is by the
2146/// stable id (the registry key); a name resolves to the same initiative as a
2147/// convenience. A rename through `edit --name` changes the label only: the id is not
2148/// re-keyed, so every association and derived value survives.
2149#[derive(Subcommand)]
2150pub enum InitiativeCommand {
2151 /// Create an initiative and allocate a stable id
2152 ///
2153 /// The name is a display label, not the identity: `tix initiative edit --name`
2154 /// changes it and every association survives, because the record is keyed by id.
2155 Create {
2156 /// Display name (unique within the registry, case-insensitively)
2157 #[arg(value_name = "NAME")]
2158 name: String,
2159 /// Optional strategic outcome
2160 #[arg(long)]
2161 outcome: Option<String>,
2162 /// Owner identity string; no lookup, never inferred
2163 #[arg(long)]
2164 owner: Option<String>,
2165 /// Free-form initiative status (not a board column)
2166 #[arg(long)]
2167 status: Option<String>,
2168 /// Initiative priority (a known spelling is normalized; an unknown one is kept)
2169 #[arg(long)]
2170 priority: Option<String>,
2171 /// Filterable labels, comma-separated
2172 #[arg(long)]
2173 labels: Option<String>,
2174 /// Target date (`YYYY-MM-DD`)
2175 #[arg(long = "target-date")]
2176 target_date: Option<String>,
2177 /// Project ids this initiative concerns, comma-separated
2178 ///
2179 /// Shape-checked only, never resolved: a well-formed id the registry does not
2180 /// hold is stored and diagnosed by `tix roadmap`/`tix lint`, so it is
2181 /// repairable.
2182 #[arg(long, value_name = "ID")]
2183 projects: Option<String>,
2184 /// Epic ids this initiative concerns, comma-separated
2185 #[arg(long, value_name = "ID")]
2186 epics: Option<String>,
2187 /// Milestone names this initiative concerns, comma-separated
2188 #[arg(long, value_name = "NAME")]
2189 milestones: Option<String>,
2190 },
2191 /// Edit an initiative's fields; a rename changes the label only
2192 ///
2193 /// Nothing is re-keyed, so every association and derived value stays intact. An
2194 /// optional value with a blank spelling clears the field (`--owner ""`), and a
2195 /// blank reference list clears it (`--projects ""`).
2196 Edit {
2197 /// Initiative id (a name is accepted as a convenience)
2198 #[arg(value_name = "ID")]
2199 reference: String,
2200 /// New display name
2201 #[arg(long)]
2202 name: Option<String>,
2203 /// New strategic outcome (`--outcome ""` clears)
2204 #[arg(long)]
2205 outcome: Option<String>,
2206 /// New owner identity string (`--owner ""` clears)
2207 #[arg(long)]
2208 owner: Option<String>,
2209 /// New free-form status (`--status ""` clears)
2210 #[arg(long)]
2211 status: Option<String>,
2212 /// New priority (`--priority ""` clears)
2213 #[arg(long)]
2214 priority: Option<String>,
2215 /// Replace the label list, comma-separated (`--labels ""` clears)
2216 #[arg(long)]
2217 labels: Option<String>,
2218 /// New target date (`YYYY-MM-DD`; `--target-date ""` clears)
2219 #[arg(long = "target-date")]
2220 target_date: Option<String>,
2221 /// Replace the project ids, comma-separated (`--projects ""` clears)
2222 #[arg(long, value_name = "ID")]
2223 projects: Option<String>,
2224 /// Replace the epic ids, comma-separated (`--epics ""` clears)
2225 #[arg(long, value_name = "ID")]
2226 epics: Option<String>,
2227 /// Replace the milestone names, comma-separated (`--milestones ""` clears)
2228 #[arg(long, value_name = "NAME")]
2229 milestones: Option<String>,
2230 },
2231 /// List initiatives in declaration order, with ids, target dates and status
2232 List,
2233 /// Show one initiative's record, metadata and references
2234 Show {
2235 /// Initiative id (a name is accepted as a convenience)
2236 #[arg(value_name = "ID")]
2237 reference: String,
2238 },
2239 /// Delete an initiative's registry entry (nothing else is ever deleted)
2240 ///
2241 /// The record's own references are reported and nothing cascades: no project,
2242 /// epic, milestone or issue is touched, and no association is silently repaired.
2243 Delete {
2244 /// Initiative id (a name is accepted as a convenience)
2245 #[arg(value_name = "ID")]
2246 reference: String,
2247 /// Confirm without prompting
2248 #[arg(long)]
2249 yes: bool,
2250 },
2251 /// Associate an initiative with exactly one project, epic or milestone
2252 ///
2253 /// Name exactly one of `--project`, `--epic`, `--milestone`; the kind is part of
2254 /// the record, so guessing is worse than asking. A well-formed reference that
2255 /// names nothing is stored and stays diagnosable, and a repeated association is
2256 /// an idempotent no-op that writes no bytes.
2257 Associate {
2258 /// Initiative id (a name is accepted as a convenience)
2259 #[arg(value_name = "ID")]
2260 reference: String,
2261 /// Project **id** to associate
2262 #[arg(long, value_name = "ID")]
2263 project: Option<String>,
2264 /// Epic **id** to associate
2265 #[arg(long, value_name = "ID")]
2266 epic: Option<String>,
2267 /// Milestone **name** to associate
2268 #[arg(long, value_name = "NAME")]
2269 milestone: Option<String>,
2270 },
2271 /// Drop an explicit association from an initiative
2272 ///
2273 /// Name exactly one of `--project`, `--epic`, `--milestone`. Dropping an
2274 /// association that is not recorded is an idempotent no-op that writes no bytes.
2275 Dereference {
2276 /// Initiative id (a name is accepted as a convenience)
2277 #[arg(value_name = "ID")]
2278 reference: String,
2279 /// Project **id** to drop
2280 #[arg(long, value_name = "ID")]
2281 project: Option<String>,
2282 /// Epic **id** to drop
2283 #[arg(long, value_name = "ID")]
2284 epic: Option<String>,
2285 /// Milestone **name** to drop
2286 #[arg(long, value_name = "NAME")]
2287 milestone: Option<String>,
2288 },
2289}
2290
2291/// The `tix roadmap` subcommands (mission `roadmap-initiatives-01M3C8SX`, WP04).
2292///
2293/// Read-only. `list` applies the one normalized [`crate::roadmap::filter::RoadmapFilter`];
2294/// `show` prints the single-row detail in the same rendering.
2295#[derive(Subcommand)]
2296pub enum RoadmapCommand {
2297 /// List initiatives in roadmap order: target date ascending, then stable id
2298 ///
2299 /// An undated initiative sorts after every dated one and renders the explicit
2300 /// `undated` state — never a blank or an invented date. A bounded period filter
2301 /// excludes it, because inventing a period is exactly what the contract forbids.
2302 List {
2303 /// Inclusive lower bound on the target date (`YYYY-MM-DD`)
2304 #[arg(long, value_name = "DATE")]
2305 from: Option<String>,
2306 /// Inclusive upper bound on the target date (`YYYY-MM-DD`)
2307 #[arg(long, value_name = "DATE")]
2308 to: Option<String>,
2309 /// Owner to match, compared trimmed and case-insensitively
2310 #[arg(long, value_name = "ID")]
2311 owner: Option<String>,
2312 /// Initiative status to match, compared trimmed and case-insensitively
2313 #[arg(long, value_name = "STATE")]
2314 status: Option<String>,
2315 /// A label the initiative must carry; repeat for an AND over labels
2316 #[arg(long = "label", value_name = "LABEL")]
2317 labels: Vec<String>,
2318 /// Free text matched case-insensitively against the searchable fields
2319 #[arg(long, value_name = "TEXT")]
2320 query: Option<String>,
2321 },
2322 /// Show one initiative's roadmap row: summary contributions and diagnostics
2323 Show {
2324 /// Initiative id (a name is accepted as a convenience)
2325 #[arg(value_name = "ID")]
2326 reference: String,
2327 },
2328}
2329
2330/// The `tix parent` subcommands.
2331///
2332/// A **required** subcommand, unlike `tix link`'s three optional verbs. `tix link`
2333/// has a meaningful bare form (create); `tix parent` with no argument is a user
2334/// mistake, and guessing which of three reports they wanted is worse than asking.
2335#[derive(Debug, Clone, Subcommand)]
2336pub enum ParentAction {
2337 /// Report the parent, direct children, child count and rollup for one issue
2338 Show {
2339 /// Issue number, filename fragment or unique title fragment
2340 issue: String,
2341 },
2342 /// List an issue's direct children, ordered by issue number
2343 Children {
2344 /// Issue number, filename fragment or unique title fragment
2345 issue: String,
2346 },
2347 /// List every issue that has children, with a child count
2348 List,
2349}
2350
2351/// The `tix workflow` verbs, per `contracts/cli-surface.md` §1.
2352///
2353/// A real `#[command(subcommand)]` tree rather than an optional-verb enum like
2354/// [`LinkAction`]: the three verbs share nothing but the prefix, and there is no
2355/// sensible bare `tix workflow`. Making the verb **required** means `tix workflow`
2356/// alone is a usage error rather than an empty success — printing the vocabulary
2357/// when the user asked for the policy would be guessing at their intent.
2358#[derive(Subcommand)]
2359pub enum WorkflowAction {
2360 /// The global category vocabulary, in order
2361 Categories,
2362 /// A board's columns, resolved categories, and where each resolution came from
2363 Show {
2364 /// Board name; omitted means the active board (`default`, or the first
2365 /// declared). A name that is not configured is an error listing the boards
2366 /// that are.
2367 board: Option<String>,
2368 },
2369 /// The effective transition policy and its override escape hatch
2370 Transitions,
2371}
2372
2373/// `tix layout` subcommands.
2374#[derive(Subcommand)]
2375pub enum LayoutAction {
2376 /// Print the effective `[layout]` template and a worked example
2377 Show,
2378 /// Relocate every issue to the configured layout (dry run unless `--apply`)
2379 Migrate {
2380 /// Write the moves; without it the command only prints the plan
2381 #[arg(long)]
2382 apply: bool,
2383 },
2384}
2385
2386/// The `tix link` subcommands.
2387///
2388/// `tix link <from> <to> --type <kind>` is the create form and carries no
2389/// subcommand, so the three verbs below are optional rather than required
2390/// (C-001 removes the old untyped symmetric form, not the bare create form the
2391/// CLI surface contract specifies).
2392#[derive(Subcommand)]
2393pub enum LinkAction {
2394 /// Retarget and/or re-kind an existing relationship, keeping its id
2395 Edit {
2396 /// Relationship id, as printed by `tix link list`
2397 id: String,
2398 /// New target issue number, filename fragment or unique title fragment
2399 #[arg(long)]
2400 to: Option<String>,
2401 /// New relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
2402 #[arg(long, value_name = "KIND")]
2403 r#type: Option<String>,
2404 },
2405 /// Remove a relationship by id. An absent id is an idempotent no-op
2406 Remove {
2407 /// Relationship id, as printed by `tix link list`
2408 id: String,
2409 },
2410 /// List relationships for one issue, or for the whole board
2411 List {
2412 /// Issue number, filename fragment or unique title fragment. Omit for every issue.
2413 issue: Option<String>,
2414 /// Only relationships of this kind
2415 #[arg(long, value_name = "KIND")]
2416 kind: Option<String>,
2417 },
2418}
2419
2420/// What `tix recurring` does.
2421///
2422/// Every read-only subcommand resolves its date explicitly (`--date`), and every
2423/// mutating one requires the confirmation flag it documents. The subcommands are a
2424/// thin adapter: they parse, call one service, and print.
2425#[derive(Debug, Clone, Subcommand)]
2426pub enum RecurringAction {
2427 /// List every series, one tab-separated row per series
2428 ///
2429 /// Columns: `id`, `name`, `state`, `next_cursor`, `skipped`.
2430 List,
2431 /// Show one series: identity, rule, state, cursor, skipped dates, occurrences
2432 Show {
2433 /// Stable series id, or its unique display name
2434 series: String,
2435 },
2436 /// Create a series: the issue that *is* the definition
2437 ///
2438 /// The issue's own fields are the template an occurrence is created from, so
2439 /// `--title`, `--labels` and the rest are also what each occurrence inherits.
2440 /// The rule is validated **before** the issue is written.
2441 Create(RecurringCreateArgs),
2442 /// Change a series' name and/or rule, keeping its stable id
2443 ///
2444 /// Only the flags you name change; the rest of the rule is carried over, so
2445 /// `tix recurring edit <id> --name X` is a rename and nothing else.
2446 Edit {
2447 /// Stable series id, or its unique display name
2448 series: String,
2449 #[command(flatten)]
2450 args: RecurringEditArgs,
2451 },
2452 /// Report what is due, read-only, for an explicit date
2453 Detect {
2454 /// Evaluate against this `YYYY-MM-DD` date instead of today
2455 #[arg(long, value_name = "YYYY-MM-DD")]
2456 date: Option<String>,
2457 /// Timezone policy to carry on the context, e.g. `+02:00`
2458 #[arg(long, value_name = "TZ")]
2459 timezone: Option<String>,
2460 },
2461 /// Create the occurrence for one due date; requires `--yes`
2462 ///
2463 /// Idempotent: the occurrence key is checked first, so a repeated call is a
2464 /// no-op and never a second issue.
2465 Generate {
2466 /// Stable series id, or its unique display name
2467 series: String,
2468 /// The occurrence date, `YYYY-MM-DD`
2469 #[arg(long, value_name = "YYYY-MM-DD")]
2470 date: String,
2471 #[command(flatten)]
2472 confirmation: RecurringLifecycleArgs,
2473 },
2474 /// Suspend generation until an explicit resume
2475 Pause {
2476 /// Stable series id, or its unique display name
2477 series: String,
2478 #[command(flatten)]
2479 confirmation: RecurringLifecycleArgs,
2480 },
2481 /// Resume a paused series; never inferred from a refresh
2482 Resume {
2483 /// Stable series id, or its unique display name
2484 series: String,
2485 #[command(flatten)]
2486 confirmation: RecurringLifecycleArgs,
2487 },
2488 /// End a series: terminal for generation
2489 End {
2490 /// Stable series id, or its unique display name
2491 series: String,
2492 #[command(flatten)]
2493 confirmation: RecurringLifecycleArgs,
2494 },
2495 /// Record one occurrence date as skipped, so it is never generated
2496 Skip {
2497 /// Stable series id, or its unique display name
2498 series: String,
2499 /// The occurrence date, `YYYY-MM-DD`
2500 #[arg(long, value_name = "YYYY-MM-DD")]
2501 date: String,
2502 #[command(flatten)]
2503 confirmation: RecurringLifecycleArgs,
2504 },
2505 /// Move the candidate past a date without recording it as skipped
2506 Defer {
2507 /// Stable series id, or its unique display name
2508 series: String,
2509 /// The occurrence date, `YYYY-MM-DD`
2510 #[arg(long, value_name = "YYYY-MM-DD")]
2511 date: String,
2512 #[command(flatten)]
2513 confirmation: RecurringLifecycleArgs,
2514 },
2515 /// Stop offering one occurrence date: recorded, like `skip`
2516 Ignore {
2517 /// Stable series id, or its unique display name
2518 series: String,
2519 /// The occurrence date, `YYYY-MM-DD`
2520 #[arg(long, value_name = "YYYY-MM-DD")]
2521 date: String,
2522 #[command(flatten)]
2523 confirmation: RecurringLifecycleArgs,
2524 },
2525}
2526
2527/// `tix wip`'s subcommands (mission `configurable-wip-limits-01M3C6WX`, WP04).
2528///
2529/// The CLI parses and routes; the policy service owns every evaluation, and the
2530/// schema's own validation decides whether a configuration is acceptable. Nothing
2531/// here decides what a limit *means*.
2532#[derive(Debug, Clone, Subcommand)]
2533pub enum WipAction {
2534 /// Show the configured limits, their units, and the current usage
2535 Show {
2536 /// Board name; default is the active board
2537 #[arg(long, value_name = "BOARD")]
2538 board: Option<String>,
2539 },
2540 /// Set or update one column's limit
2541 ///
2542 /// Only the flags you name change, exactly as `tix recurring edit` works, so
2543 /// `tix wip set "in progress" --max-items 4` keeps the column's estimate
2544 /// ceiling, unit and policy.
2545 Set {
2546 /// Column name, as `[kanban.<board>.columns]` spells it
2547 column: String,
2548 /// The most cards the column may hold
2549 #[arg(long, value_name = "N")]
2550 max_items: Option<u32>,
2551 /// The most estimate the column's cards may add up to
2552 #[arg(long, value_name = "N")]
2553 max_estimate: Option<u32>,
2554 /// The unit `--max-estimate` counts in; it must be declared with `tix wip unit`
2555 #[arg(long, value_name = "UNIT")]
2556 unit: Option<String>,
2557 /// What exceeding the limit does: `warn` or `block`
2558 #[arg(long, value_name = "warn|block")]
2559 policy: Option<String>,
2560 /// Keep the numbers but enforce nothing
2561 #[arg(long)]
2562 disabled: bool,
2563 /// Board name; default is the active board
2564 #[arg(long, value_name = "BOARD")]
2565 board: Option<String>,
2566 },
2567 /// Remove one column's limit
2568 Unset {
2569 /// Column name
2570 column: String,
2571 /// Board name; default is the active board
2572 #[arg(long, value_name = "BOARD")]
2573 board: Option<String>,
2574 },
2575 /// Declare an estimate unit, or remove one
2576 ///
2577 /// A unit is a name and its factor to the board's base unit: with `d = 1` and
2578 /// `w = 5`, an issue estimated `1w` counts as `5d`. A unit the board does not
2579 /// declare is a `missing_conversion` refusal wherever it is used, never a zero.
2580 Unit {
2581 /// Unit name, e.g. `d`
2582 name: String,
2583 /// The factor to the board's base unit (at least 1)
2584 factor: Option<u32>,
2585 /// Remove the unit instead of declaring it
2586 #[arg(long)]
2587 remove: bool,
2588 /// Board name; default is the active board
2589 #[arg(long, value_name = "BOARD")]
2590 board: Option<String>,
2591 },
2592}
2593
2594/// The rule flags `tix recurring create` and `edit` share.
2595#[derive(Debug, Clone, Args)]
2596pub struct RecurringRuleArgs {
2597 /// Family: `daily`, `weekly`, `monthly`, `interval`, `cron-like`
2598 #[arg(long, value_name = "KIND")]
2599 pub rule: Option<String>,
2600 /// Schedule origin, `YYYY-MM-DD` (required by every family)
2601 #[arg(long, value_name = "YYYY-MM-DD")]
2602 pub anchor: Option<String>,
2603 /// Cadence, where the family takes one (1-1000)
2604 #[arg(long, value_name = "N")]
2605 pub interval: Option<u32>,
2606 /// What an `interval` rule counts in: `days`, `weeks`, `months`
2607 #[arg(long, value_name = "UNIT")]
2608 pub unit: Option<String>,
2609 /// Cron-like expression: `@daily`, `@weekly`, `@monthly`
2610 #[arg(long, value_name = "EXPR")]
2611 pub expression: Option<String>,
2612 /// Timezone policy, e.g. `+02:00`
2613 #[arg(long, value_name = "TZ")]
2614 pub timezone: Option<String>,
2615}
2616
2617impl RecurringRuleArgs {
2618 /// Whether the caller named any rule field at all.
2619 pub const fn is_any(&self) -> bool {
2620 self.rule.is_some()
2621 || self.anchor.is_some()
2622 || self.interval.is_some()
2623 || self.unit.is_some()
2624 || self.expression.is_some()
2625 || self.timezone.is_some()
2626 }
2627}
2628
2629/// The confirmation every mutating `tix recurring` subcommand takes.
2630#[derive(Debug, Clone, Args)]
2631pub struct RecurringLifecycleArgs {
2632 /// Confirm the action without prompting
2633 #[arg(long)]
2634 pub yes: bool,
2635 /// Why; recorded in the series' History
2636 #[arg(long, value_name = "TEXT")]
2637 pub reason: Option<String>,
2638}
2639
2640/// The fields of the definition issue plus its rule.
2641#[derive(Debug, Clone, Args)]
2642pub struct RecurringCreateArgs {
2643 /// Title of the series issue (and the default series name)
2644 #[arg(long, value_name = "TITLE")]
2645 pub title: String,
2646 /// Body an occurrence inherits
2647 #[arg(long, value_name = "BODY")]
2648 pub body: Option<String>,
2649 /// Column the definition (and its occurrences) starts in
2650 #[arg(long, value_name = "STATUS")]
2651 pub status: Option<String>,
2652 /// Priority: `low`, `medium`, `high`
2653 #[arg(long, value_name = "PRIORITY")]
2654 pub priority: Option<String>,
2655 /// Labels, comma-separated
2656 #[arg(long, value_name = "LABELS")]
2657 pub labels: Option<String>,
2658 /// Assignee (`@name`)
2659 #[arg(long, value_name = "ASSIGNEE")]
2660 pub assignee: Option<String>,
2661 /// Display name; defaults to the title
2662 #[arg(long, value_name = "NAME")]
2663 pub name: Option<String>,
2664 /// Stable series id; generated when omitted
2665 #[arg(long, value_name = "ID")]
2666 pub id: Option<String>,
2667 #[command(flatten)]
2668 pub rule: RecurringRuleArgs,
2669}
2670
2671/// What `tix recurring edit` may change.
2672#[derive(Debug, Clone, Args)]
2673pub struct RecurringEditArgs {
2674 /// New display name; omitted means keep the current one
2675 #[arg(long, value_name = "NAME")]
2676 pub name: Option<String>,
2677 #[command(flatten)]
2678 pub rule: RecurringRuleArgs,
2679}
2680
2681/// The `tix view` saved-view subcommands (#172; mission
2682/// `saved-issue-views-01M3C926`, WP04).
2683///
2684/// A **real** subcommand tree, not an optional-verb enum like [`LinkAction`]:
2685/// there is no meaningful bare `tix view` (bare `tix view` would be the
2686/// issue-printer, which takes an issue selector), so every saved-view operation
2687/// names its verb. The nine verbs are the surface contract's spelling
2688/// (`contracts/cli-surface.md`).
2689///
2690/// The plan-shaping flags are [`ViewPlanArgs`] and the scope flags are
2691/// [`ViewScopeArgs`], flattened into the verbs that take them so `create`,
2692/// `update`, `share`, and `status` cannot drift apart.
2693#[derive(Subcommand)]
2694pub enum ViewAction {
2695 /// List saved views, in declaration order, as tab-separated rows
2696 List {
2697 /// Only views in this scope family: `repository` | `project` | `user`
2698 #[arg(long, value_name = "repository|project|user")]
2699 scope: Option<String>,
2700 },
2701 /// Show one saved view: stable id, name, scope, normalized plan, diagnostics
2702 Show {
2703 /// Stable view id, or its display name
2704 reference: String,
2705 },
2706 /// Save a new view from the existing query grammar
2707 ///
2708 /// The id is **generated** and stable (spec §3: "use stable generated IDs;
2709 /// names are display labels"); it is printed on the first stdout line so a
2710 /// script can capture it.
2711 Create {
2712 /// Display label
2713 #[arg(long, value_name = "NAME")]
2714 name: String,
2715 #[command(flatten)]
2716 scope: ViewScopeArgs,
2717 #[command(flatten)]
2718 plan: ViewPlanArgs,
2719 },
2720 /// Replace an existing view's definition, keeping its stable id
2721 ///
2722 /// Only the fields you name change; the rest are carried over from the
2723 /// stored definition, so `tix view update <id> --name X` is a rename and
2724 /// nothing else (identity survives a rename, C-002).
2725 Update {
2726 /// Stable view id, or its display name
2727 reference: String,
2728 /// New display label; omitted means keep the current one
2729 #[arg(long, value_name = "NAME")]
2730 name: Option<String>,
2731 #[command(flatten)]
2732 plan: ViewPlanArgs,
2733 },
2734 /// Copy a view under a **new** stable id
2735 Duplicate {
2736 /// Stable view id, or its display name
2737 reference: String,
2738 /// Label for the copy; omitted means the source's label
2739 #[arg(long, value_name = "NAME")]
2740 name: Option<String>,
2741 },
2742 /// Remove a view **definition** only; requires `--confirm`
2743 ///
2744 /// Never deletes an issue, comment, or relationship.
2745 Delete {
2746 /// Stable view id, or its display name
2747 reference: String,
2748 /// Acknowledge that only the saved definition is removed
2749 #[arg(long)]
2750 confirm: bool,
2751 },
2752 /// Apply a saved view read-only: render its plan, result, and active marker
2753 Apply {
2754 /// Stable view id, or its display name
2755 reference: String,
2756 },
2757 /// Report the saved plan against a live plan, field by field
2758 ///
2759 /// Without an id it reports the (absent) active marker — never a silent
2760 /// "in sync". With an id, the live plan is assembled from the plan flags
2761 /// given here, in the existing grammar.
2762 Status {
2763 /// Stable view id, or its display name; omitted reports the active marker
2764 reference: Option<String>,
2765 #[command(flatten)]
2766 live: ViewPlanArgs,
2767 },
2768 /// Change a view's explicit sharing/visibility scope metadata
2769 ///
2770 /// Scope is metadata: `user` scope stores an owner/shared-with label and
2771 /// grants no permission and creates no account (C-003/C-009).
2772 Share {
2773 /// Stable view id, or its display name
2774 reference: String,
2775 #[command(flatten)]
2776 scope: ViewScopeArgs,
2777 },
2778}
2779
2780/// The explicit scope flags shared by `create` and `share`.
2781///
2782/// One flatten per verb rather than two hand-maintained copies, so a new scope
2783/// field cannot be added to one and forgotten on the other.
2784#[derive(Args, Debug, Clone, PartialEq, Eq)]
2785pub struct ViewScopeArgs {
2786 /// Scope family: `repository` (the default) | `project` | `user`
2787 #[arg(long, value_name = "repository|project|user")]
2788 pub scope: Option<String>,
2789 /// Project id, for `--scope project`
2790 ///
2791 /// Resolved through the project registry; a project this repository does
2792 /// not know is refused by the registry, not by this flag.
2793 #[arg(long, value_name = "PROJECT")]
2794 pub project: Option<String>,
2795 /// Owner identity, for `--scope user` (metadata only — not an account)
2796 #[arg(long, value_name = "IDENTITY")]
2797 pub owner: Option<String>,
2798 /// Comma-separated shared-with identities, for `--scope user`
2799 #[arg(long, value_name = "LIST")]
2800 pub shared_with: Option<String>,
2801}
2802
2803/// The plan-shaping flags shared by `create`, `update`, and `status`.
2804///
2805/// The grammar is the **existing** one: `--filter` is the token query
2806/// `tix list --filter` accepts, `--sort`/`--group` are that grammar's control
2807/// tokens, and the visibility pair mirrors the board's `hidden_columns` /
2808/// `show_mismatch`. Nothing here parses a second query language (C-004).
2809#[derive(Args, Debug, Clone, PartialEq, Eq)]
2810pub struct ViewPlanArgs {
2811 /// Existing token query (`status:todo`, `label:urgent`, `category:done`, …)
2812 #[arg(long, value_name = "QUERY")]
2813 pub filter: Option<String>,
2814 /// Sort key (`created`, `due`, `start`, `reviewer`, `cycle`, `milestone`,
2815 /// `status`, `priority`)
2816 #[arg(long, value_name = "KEY")]
2817 pub sort: Option<String>,
2818 /// Group key (`cycle` | `milestone`)
2819 #[arg(long, value_name = "KEY")]
2820 pub group: Option<String>,
2821 /// Board columns to hide when the view is applied (comma-separated)
2822 ///
2823 /// Giving this **or** `--show-mismatch` makes visibility an absolute
2824 /// snapshot; omitting both means the view inherits the live board's
2825 /// visibility.
2826 #[arg(long, value_name = "LIST")]
2827 pub hide_columns: Option<String>,
2828 /// Show the computed "Mismatched" column when applied (`true` | `false`)
2829 #[arg(long, value_name = "true|false", num_args = 1, action = clap::ArgAction::Set)]
2830 pub show_mismatch: Option<bool>,
2831}
2832
2833impl ViewPlanArgs {
2834 /// Whether the caller named **any** plan field.
2835 ///
2836 /// The question `update` asks: no field named means "leave the stored plan
2837 /// alone", so a rename cannot silently wipe it.
2838 #[must_use]
2839 pub const fn is_any(&self) -> bool {
2840 self.filter.is_some()
2841 || self.sort.is_some()
2842 || self.group.is_some()
2843 || self.hide_columns.is_some()
2844 || self.show_mismatch.is_some()
2845 }
2846}
2847
2848/// `tix bulk <action>` — four actions, and each carries **its own** flags.
2849///
2850/// The flags are on the subcommand rather than on the parent, which is what makes the
2851/// surface unambiguous by construction rather than by a runtime check:
2852/// `tix bulk archive --priority high` is a **clap usage error**, not a command that
2853/// accepts a field flag and ignores it. `edit` is the only action with field flags,
2854/// `link`/`unlink` are the only ones with `--target`, and `archive` takes neither — so
2855/// there is no spelling of `tix bulk` that a flag can apply to by accident.
2856///
2857/// `archive` has no `--type`: archiving is not a relationship, and offering a kind
2858/// would suggest it is one.
2859/// The flags every `tix bulk` action carries: which issues, and whether to write.
2860///
2861/// Flattened into **each** action rather than held on `Command::Bulk`, because clap
2862/// parses a parent's flags *before* its subcommand — so a parent would spell the
2863/// contract's `tix bulk edit --select <...>` as `tix bulk --select <...> edit`, which
2864/// is a different surface from the one the contract names. Four `#[command(flatten)]`
2865/// of one struct gives the contract's spelling and one vocabulary, so a new flag
2866/// cannot be added to one action and forgotten on the other three.
2867#[derive(Args, Debug, Clone, PartialEq, Eq)]
2868pub struct BulkSelection {
2869 /// Comma-separated issue selectors: number, filename fragment or unique title
2870 /// fragment
2871 #[arg(long, value_name = "LIST")]
2872 pub select: Option<String>,
2873 /// A `--filter` query, in the existing filter grammar
2874 #[arg(long, value_name = "QUERY")]
2875 pub filter: Option<String>,
2876 /// A `--search` query, in the existing search grammar
2877 #[arg(long, value_name = "QUERY")]
2878 pub search: Option<String>,
2879 /// Which issues are in play: `active`, `active+archived` or `archived`
2880 #[arg(long, value_name = "SCOPE")]
2881 pub scope: Option<String>,
2882 /// Write the batch. Omit for a preview, which is the default.
2883 #[arg(long)]
2884 pub apply: bool,
2885}
2886
2887impl BulkAction {
2888 /// The shared selection flags, whichever action carries them.
2889 ///
2890 /// One accessor for four flattened fields, so the routing code reads the flags
2891 /// once and a new action cannot forget to pass them.
2892 pub fn selection(&self) -> &BulkSelection {
2893 match self {
2894 Self::Edit { selection, .. }
2895 | Self::Link { selection, .. }
2896 | Self::Unlink { selection, .. }
2897 | Self::Archive { selection } => selection,
2898 }
2899 }
2900
2901 /// Whether `--apply` was given. A preview is the default, so this is a positive
2902 /// question rather than "is this not a preview".
2903 pub fn is_apply(&self) -> bool {
2904 self.selection().apply
2905 }
2906}
2907
2908#[derive(Subcommand, Debug, Clone, PartialEq, Eq)]
2909pub enum BulkAction {
2910 /// Apply a typed field patch to every selected issue
2911 Edit {
2912 #[command(flatten)]
2913 selection: BulkSelection,
2914 /// Target column
2915 #[arg(long, value_name = "COLUMN")]
2916 status: Option<String>,
2917 /// `low`, `medium`, `high`, or `` for unset
2918 #[arg(long, value_name = "PRIORITY")]
2919 priority: Option<String>,
2920 /// Bare handle or full identity
2921 #[arg(long, value_name = "ASSIGNEE")]
2922 assignee: Option<String>,
2923 /// Comma-separated labels
2924 #[arg(long, value_name = "LABELS")]
2925 labels: Option<String>,
2926 /// `YYYY-MM-DD`
2927 #[arg(long, value_name = "DATE")]
2928 start_date: Option<String>,
2929 /// `YYYY-MM-DD`
2930 #[arg(long, value_name = "DATE")]
2931 due_date: Option<String>,
2932 /// Free-form estimate
2933 #[arg(long, value_name = "ESTIMATE")]
2934 estimate: Option<String>,
2935 /// Comma-separated cycle names
2936 #[arg(long, value_name = "CYCLES")]
2937 cycles: Option<String>,
2938 /// Comma-separated milestone names
2939 #[arg(long, value_name = "MILESTONES")]
2940 milestones: Option<String>,
2941 /// Empty a field. Takes one of the clearable field names.
2942 #[arg(long, value_name = "FIELD")]
2943 clear: Option<String>,
2944 },
2945 /// Link every selected issue to `--target`
2946 Link {
2947 #[command(flatten)]
2948 selection: BulkSelection,
2949 /// The other end of the relationship
2950 #[arg(long, value_name = "SELECTOR")]
2951 target: String,
2952 /// Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
2953 #[arg(long, value_name = "KIND")]
2954 r#type: Option<String>,
2955 },
2956 /// Unlink every selected issue from `--target`
2957 Unlink {
2958 #[command(flatten)]
2959 selection: BulkSelection,
2960 /// The other end of the relationship
2961 #[arg(long, value_name = "SELECTOR")]
2962 target: String,
2963 /// Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
2964 #[arg(long, value_name = "KIND")]
2965 r#type: Option<String>,
2966 },
2967 /// Ask the archive service to archive every selected issue
2968 Archive {
2969 #[command(flatten)]
2970 selection: BulkSelection,
2971 },
2972}
2973
2974/// The `tix mentions` verbs.
2975///
2976/// `list` and `show` are read-only. `read` is the one mutating answer; `ignore` is
2977/// deliberately the **absence** of a write, which is what makes the mention show
2978/// again the next time the issue is opened.
2979#[derive(Subcommand)]
2980pub enum MentionsAction {
2981 /// List the mentions pending for you
2982 List,
2983 /// Print one mention in full
2984 Show {
2985 /// Mention id, as shown by `tix mentions list`
2986 id: String,
2987 },
2988 /// Mark a mention read, so it is never shown again
2989 Read {
2990 /// Mention id, as shown by `tix mentions list`
2991 id: String,
2992 },
2993 /// Leave a mention pending (writes nothing; it shows again later)
2994 Ignore {
2995 /// Mention id, as shown by `tix mentions list`
2996 id: String,
2997 },
2998 /// Print where a mention sits in its issue
2999 Jump {
3000 /// Mention id, as shown by `tix mentions list`
3001 id: String,
3002 },
3003}
3004
3005/// The `tix analytics` subcommands (mission `burndown-scope-analytics-01M3C9J1`, WP05).
3006///
3007/// A thin adapter over the shared analytics DTOs. The daily fold, the unit conversion
3008/// and the projection live in `crate::analytics`, and the versioned chart document is
3009/// WP04's (`crate::commands::export::chart_document`), which every verb here calls rather
3010/// than re-composing. This enum owns only the spelling of the surface: the scope, the
3011/// period and the mode/unit are always explicit, and no flag here can change an issue, a
3012/// cycle date, a milestone or a snapshot.
3013#[derive(Debug, Clone, Subcommand)]
3014pub enum AnalyticsCommand {
3015 /// Print the daily remaining/completed series for one cycle or milestone
3016 ///
3017 /// A tab-separated table with a `date` column followed by the point's own fields —
3018 /// `scope_total`, `remaining`, `completed`, `scope_added`, `completions` — and the
3019 /// day's `coverage`. A value the record cannot support is `-`, never a zero, and a
3020 /// day with no recorded history is `unknown` or `coverage_limited`. The scope,
3021 /// period, mode, unit, formula version and coverage tally print above the header
3022 /// row, and the findings render to stderr with their remedies.
3023 Burndown {
3024 #[command(flatten)]
3025 args: AnalyticsScopeArgs,
3026 },
3027 /// Print the read-only end-date forecast over one cycle or milestone
3028 ///
3029 /// The planning end date is the scope's own (`--to` overrides it) and `--as-of` is
3030 /// the caller's day, never a clock. A forecast the record cannot answer prints its
3031 /// `status` (`insufficient_data` or `unknown`) with `-` for every value it could not
3032 /// derive — no completion date is ever invented, and no planning date or issue
3033 /// changes.
3034 Projection {
3035 #[command(flatten)]
3036 args: AnalyticsScopeArgs,
3037 /// The forecast origin, a `YYYY-MM-DD` calendar date
3038 #[arg(long, value_name = "YYYY-MM-DD")]
3039 as_of: String,
3040 },
3041 /// Write the deterministic local chart document (WP04's target)
3042 ///
3043 /// The document goes to stdout, or to `--output` through the atomic export writer the
3044 /// other local targets use. `--mode` is required: the document states the mode it was
3045 /// folded in, so a default would be a guess about which arithmetic produced the
3046 /// numbers. `--as-of` asks the same document for a forecast. The remote
3047 /// `gitlab`/`linear` namespaces are never consulted and cannot collide with a chart
3048 /// document.
3049 Export {
3050 #[command(flatten)]
3051 args: AnalyticsScopeArgs,
3052 /// Ask the document for a forecast at this origin, a `YYYY-MM-DD` calendar date
3053 #[arg(long, value_name = "YYYY-MM-DD")]
3054 as_of: Option<String>,
3055 /// Write the document to this path instead of stdout
3056 #[arg(long, value_name = "PATH")]
3057 output: Option<String>,
3058 },
3059}
3060
3061/// The scope, period, mode and unit flags every `tix analytics` verb shares.
3062///
3063/// The period is the scope's own planning data unless a bound is named here, and the mode
3064/// is explicit rather than inferred: an estimate valuation is only drawn through the
3065/// project's configured unit conversion, so a mode the record cannot support is refused
3066/// instead of silently falling back to a count.
3067#[derive(Debug, Clone, Args)]
3068pub struct AnalyticsScopeArgs {
3069 /// Scope kind: `cycle` or `milestone`
3070 #[arg(long, value_name = "cycle|milestone")]
3071 pub scope: String,
3072 /// The scope's planning identity: a cycle name or stable id, or a milestone name
3073 #[arg(long, value_name = "ID")]
3074 pub id: String,
3075 /// The series' inclusive start, `YYYY-MM-DD`; absent means the scope's own start
3076 #[arg(long, value_name = "YYYY-MM-DD")]
3077 pub from: Option<String>,
3078 /// The series' inclusive end, `YYYY-MM-DD`; absent means the scope's own planning end
3079 #[arg(long, value_name = "YYYY-MM-DD")]
3080 pub to: Option<String>,
3081 /// How the series counts: `count` (issue counts, the default) or `estimate`
3082 ///
3083 /// `estimate` requires `--unit` and a unit the project declares a conversion for;
3084 /// a missing or incompatible unit is refused with the shared remedy, never guessed.
3085 #[arg(long, value_name = "count|estimate")]
3086 pub mode: Option<String>,
3087 /// The unit estimate mode counts in; count mode ignores it (an issue count is unitless)
3088 #[arg(long, value_name = "UNIT")]
3089 pub unit: Option<String>,
3090}
3091
3092/// Process entry: route a parsed [`Cli`] through config resolution, store
3093/// opening and dispatch, then return the exit code for `main` to exit with.
3094pub fn run(cli: Cli) -> u8 {
3095 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
3096 match &cli.command {
3097 Some(Command::Lint { no_foreign }) => run_lint(&cwd, *no_foreign),
3098 Some(Command::Mcp) => match run_mcp(&cwd) {
3099 Ok(()) => ExitCode::Success as u8,
3100 Err(e) => {
3101 eprintln!("{}", crate::error::render_error(&e));
3102 exit_code_for(&e) as u8
3103 }
3104 },
3105 Some(Command::Render) => match run_render(&cwd) {
3106 Ok(()) => ExitCode::Success as u8,
3107 Err(e) => {
3108 eprintln!("{}", crate::error::render_error(&e));
3109 exit_code_for(&e) as u8
3110 }
3111 },
3112 // A bulk batch is the one command whose **status** is a result rather than an
3113 // error: a partial batch completes and reports non-zero, with every outcome
3114 // retained (`contracts/cli-surface.md`). So it is routed here, where the exit
3115 // code is decided, and not through `commands::dispatch`, whose arms are all
3116 // `Result<()>`.
3117 Some(Command::Bulk { action }) => {
3118 let result = resolve(&cwd).and_then(|(_, store)| {
3119 commands::bulk::run(
3120 &store,
3121 action,
3122 action.selection(),
3123 if action.is_apply() {
3124 commands::bulk::Mode::Apply
3125 } else {
3126 commands::bulk::Mode::Preview
3127 },
3128 )
3129 });
3130 match result {
3131 Ok(code) => code as u8,
3132 Err(e) => {
3133 eprintln!("{}", crate::error::render_error(&e));
3134 exit_code_for(&e) as u8
3135 }
3136 }
3137 }
3138 // `tix triage` shares bulk's exit-code shape for the same reason: a batch
3139 // that partly succeeded is a *completed* command with a status, not an
3140 // error, so `--apply` can print one outcome per selected issue and still
3141 // exit non-zero (T026). It is routed here rather than through
3142 // `commands::dispatch`, whose arms are all `Result<()>`.
3143 Some(Command::Triage { action }) => {
3144 let result = resolve(&cwd).and_then(|(_, store)| commands::triage::run(&store, action));
3145 match result {
3146 Ok(code) => code as u8,
3147 Err(e) => {
3148 eprintln!("{}", crate::error::render_error(&e));
3149 exit_code_for(&e) as u8
3150 }
3151 }
3152 }
3153 // `tix archive`/`tix restore` share the batch exit-code shape: every
3154 // selector is processed independently, a failure on one does not roll back
3155 // another, and the process still exits non-zero if any failed. Routed here
3156 // for the same reason `tix bulk` and `tix triage` are.
3157 Some(Command::Archive { issues }) => {
3158 route_archive(&cwd, commands::archive::Action::Archive, issues)
3159 }
3160 Some(Command::Restore { issues }) => {
3161 route_archive(&cwd, commands::archive::Action::Restore, issues)
3162 }
3163 // `tix project` needs a **mutable** `Config` — an accepted create/edit/delete
3164 // *is* `tix.toml` changing — which `commands::dispatch` (whose arms all borrow
3165 // `&Config`) cannot express, exactly as `tix wip` and `tix view <subcommand>`
3166 // do. Its batch verbs (`add`, `remove`, `bulk-add`) also carry a **status**
3167 // rather than an error: each selector is independent and a partly-successful
3168 // run exits non-zero. Both properties are why it is routed here, where the
3169 // config is owned and the exit code is decided.
3170 Some(Command::Project { command }) => {
3171 let result = resolve(&cwd).and_then(|(mut config, store)| {
3172 commands::project::run(&mut config, &store, command)
3173 });
3174 match result {
3175 Ok(code) => code as u8,
3176 Err(e) => {
3177 eprintln!("{}", crate::error::render_error(&e));
3178 exit_code_for(&e) as u8
3179 }
3180 }
3181 }
3182 // `tix epic` shares `tix project`'s two properties exactly: its lifecycle
3183 // verbs (`create`/`edit`/`delete`) mutate `tix.toml`, so it needs the
3184 // **mutable** `Config` only `run` owns, and its batch verbs
3185 // (`add`/`remove`/`bulk-add`) carry a **status** rather than an error,
3186 // because each selector is independent and a partly-successful run exits
3187 // non-zero. Both are why it is routed here.
3188 Some(Command::Epic { command }) => {
3189 let result = resolve(&cwd)
3190 .and_then(|(mut config, store)| commands::epic::run(&mut config, &store, command));
3191 match result {
3192 Ok(code) => code as u8,
3193 Err(e) => {
3194 eprintln!("{}", crate::error::render_error(&e));
3195 exit_code_for(&e) as u8
3196 }
3197 }
3198 }
3199 // `tix initiative` shares `tix project`'s property: its lifecycle verbs
3200 // (`create`/`edit`/`delete`) and its association verbs mutate `tix.toml`, so
3201 // it needs the **mutable** `Config` only `run` owns, and an accepted change
3202 // is written through the registry's atomic boundary. `tix roadmap` is
3203 // read-only and fits `commands::dispatch`, so it is routed there.
3204 Some(Command::Initiative { command }) => {
3205 let result = resolve(&cwd).and_then(|(mut config, store)| {
3206 commands::roadmap::run_initiative(&mut config, &store, command)
3207 });
3208 match result {
3209 Ok(code) => code as u8,
3210 Err(e) => {
3211 eprintln!("{}", crate::error::render_error(&e));
3212 exit_code_for(&e) as u8
3213 }
3214 }
3215 }
3216 // Only `tix cycle schedule …` needs the **mutable** config: an accepted
3217 // `schedule create|edit|delete` *is* `tix.toml` changing, and
3218 // `commands::dispatch` (whose arms all borrow `&Config`) cannot provide it.
3219 // The frozen legacy verbs are deliberately **not** intercepted: they fall
3220 // through to `run_subcommand`, so they keep `commands::dispatch`'s legacy
3221 // arm *and* the rollover advisory that every `tix cycle …` emitted at the
3222 // baseline — behavior WP06 must not change.
3223 Some(Command::Cycle {
3224 command: CycleCommand::Schedule { command },
3225 }) => {
3226 let result = resolve(&cwd).and_then(|(mut config, store)| {
3227 commands::cycle::run_schedule(&mut config, &store, command)
3228 });
3229 match result {
3230 Ok(()) => ExitCode::Success as u8,
3231 Err(e) => {
3232 eprintln!("{}", crate::error::render_error(&e));
3233 exit_code_for(&e) as u8
3234 }
3235 }
3236 }
3237 // `tix capacity set|delete` mutate `tix.toml`, so they need the **mutable**
3238 // `Config` only the process entry owns — the same reason `tix wip` is routed
3239 // here. The read verbs (`list`/`show`/`calculate`/`snapshot`) ride along so
3240 // the family has one boundary, and none of them writes.
3241 Some(Command::Capacity { command }) => {
3242 let result = resolve(&cwd).and_then(|(mut config, store)| {
3243 commands::capacity::run(&mut config, &store, command)
3244 });
3245 match result {
3246 Ok(()) => ExitCode::Success as u8,
3247 Err(e) => {
3248 eprintln!("{}", crate::error::render_error(&e));
3249 exit_code_for(&e) as u8
3250 }
3251 }
3252 }
3253 // `tix template`: management mutates `tix.toml`, so it needs a **mutable**
3254 // config and the path it came from — neither of which `commands::dispatch`
3255 // carries. Routed here, before the store is opened, exactly like the TUI
3256 // and `tix mcp`.
3257 Some(Command::Template { action }) => match commands::templates::run(&cwd, action) {
3258 Ok(()) => ExitCode::Success as u8,
3259 Err(e) => {
3260 eprintln!("{}", crate::error::render_error(&e));
3261 exit_code_for(&e) as u8
3262 }
3263 },
3264 // `tix version` needs neither a store nor a config, so it is answered
3265 // before any repository resolution: it works in a directory where every
3266 // other command would refuse to start, which is exactly where someone
3267 // checking "did this build?" may be standing.
3268 Some(Command::Version) => {
3269 commands::version::print();
3270 ExitCode::Success as u8
3271 }
3272 Some(Command::Beads { action }) => {
3273 let result = resolve(&cwd)
3274 .and_then(|(config, store)| commands::beads::run(&config, &store, action));
3275 match result {
3276 Ok(()) => ExitCode::Success as u8,
3277 Err(e) => {
3278 eprintln!("{}", crate::error::render_error(&e));
3279 exit_code_for(&e) as u8
3280 }
3281 }
3282 }
3283 _ => {
3284 let result = match &cli.command {
3285 None => run_tui(&cwd),
3286 Some(Command::New) => run_tui_new(&cwd),
3287 Some(Command::Init { beads }) => commands::init::run_with(&cwd, beads.as_deref()),
3288 Some(Command::InitAgents) => {
3289 let root = Config::project_root(&cwd).unwrap_or(cwd.clone());
3290 commands::agents::run(&root).map(|_| ())
3291 }
3292 Some(cmd) => run_subcommand(&cwd, cmd),
3293 };
3294 match result {
3295 Ok(()) => ExitCode::Success as u8,
3296 Err(e) => {
3297 eprintln!("{}", crate::error::render_error(&e));
3298 exit_code_for(&e) as u8
3299 }
3300 }
3301 }
3302 }
3303}
3304
3305/// Process one `tix archive`/`tix restore` batch and map it to an exit code.
3306///
3307/// Each selector is independent: an outcome is printed per item and a failure on
3308/// one item neither aborts the rest nor rolls back a successful rename. The
3309/// process exits with the first failure's code, or `0` when every item succeeded.
3310fn route_archive(cwd: &Path, action: commands::archive::Action, issues: &[String]) -> u8 {
3311 match resolve(cwd).and_then(|(_, store)| commands::archive::run(&store, action, issues)) {
3312 Ok(code) => code as u8,
3313 Err(e) => {
3314 eprintln!("{}", crate::error::render_error(&e));
3315 exit_code_for(&e) as u8
3316 }
3317 }
3318}
3319
3320/// `tix lint` owns its own boundary: its exit-code table (`0` healthy, `1`
3321/// findings, `2` usage/internal) does not match the generic
3322/// [`exit_code_for`] mapping, which would collapse internal errors into `1`.
3323fn run_lint(cwd: &Path, no_foreign: bool) -> u8 {
3324 let (config, store) = match resolve(cwd) {
3325 Ok(pair) => pair,
3326 Err(e) => {
3327 eprintln!("{}", crate::error::render_error(&e));
3328 return 2;
3329 }
3330 };
3331 match commands::lint::run_with(&config, &store, !no_foreign) {
3332 Ok(true) => 1,
3333 Ok(false) => 0,
3334 Err(e) => {
3335 eprintln!("{}", crate::error::render_error(&e));
3336 2
3337 }
3338 }
3339}
3340
3341/// `tix` with no subcommand: launch the TUI, or fail with the contract's
3342/// exact message when no `tix.toml` is reachable (exit 1).
3343///
3344/// tix #1632: on a terminal, a missing `tix.toml` is offered a one-key escape —
3345/// create the default configuration and continue into the TUI — rather than
3346/// being turned away with a message naming a command to run. A declined prompt,
3347/// and a stdin that cannot be asked (not a terminal), both keep the exact
3348/// contract message.
3349fn run_tui(cwd: &Path) -> Result<()> {
3350 let config = tui_config(cwd)?;
3351 crate::app::run(&config)
3352}
3353
3354/// `tix new`: the same session, opened on the create-issue form instead of the
3355/// board. Same repository resolution as [`run_tui`], so a missing `tix.toml`
3356/// fails with the same message rather than opening a form that could not be
3357/// submitted.
3358fn run_tui_new(cwd: &Path) -> Result<()> {
3359 let config = tui_config(cwd)?;
3360 crate::app::run_new_issue(&config)
3361}
3362
3363/// Repository resolution for the TUI commands, offering to scaffold a repository
3364/// first when none is reachable (tix #1632).
3365fn tui_config(cwd: &Path) -> Result<Config> {
3366 if !offer_first_run_init(cwd)? {
3367 return Err(TixError::Config(
3368 "no tix.toml found — run \"tix init\" first".into(),
3369 ));
3370 }
3371 repo_config(cwd)
3372}
3373
3374/// Ask, once, whether a missing `tix.toml` should be created with the defaults.
3375///
3376/// Returns `true` when the caller may proceed (a repository was already there, or
3377/// the user said yes to `tix init` default values). Returns `false` when there is
3378/// no repository and the user declined — or could not be asked at all, since a
3379/// non-terminal stdin cannot consent, and silently writing a project into a pipe's
3380/// directory would be the wrong answer.
3381fn offer_first_run_init(cwd: &Path) -> Result<bool> {
3382 use std::io::IsTerminal;
3383
3384 if Config::project_root(cwd).is_some() {
3385 return Ok(true);
3386 }
3387 if !std::io::stdin().is_terminal() {
3388 return Ok(false);
3389 }
3390 eprint!("tix: no tix.toml found — create one with default settings? [Y/n] ");
3391 let mut line = String::new();
3392 std::io::stdin()
3393 .read_line(&mut line)
3394 .map_err(TixError::Io)?;
3395 let answer = line.trim();
3396 if answer.is_empty() || answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
3397 commands::init::run_with(cwd, Some("off"))?;
3398 return Ok(true);
3399 }
3400 eprintln!("tix: aborted — run \"tix init\" when you are ready");
3401 Ok(false)
3402}
3403
3404/// Strict repository resolution shared by the TUI and `tix mcp` (FR-015):
3405/// upward `tix.toml` discovery, failing with the contract's exact `no
3406/// tix.toml found` guidance (exit 1) when no repository is reachable —
3407/// never a guessed default store. This is the "CLI hot path" that
3408/// `contracts/mcp-protocol.md` §7 requires `tix mcp` to reuse.
3409fn repo_config(cwd: &Path) -> Result<Config> {
3410 let root = Config::project_root(cwd)
3411 .ok_or_else(|| TixError::Config("no tix.toml found — run \"tix init\" first".into()))?;
3412 let mut config = Config::load(&root)?;
3413 // `tags_dir`, `[repos]` and `parent_repo` are relative to the project root
3414 // (tix #1529/#1530), so the TUI can spawn tix in a child from anywhere.
3415 config.resolve_repo_paths(&root);
3416 Ok(config)
3417}
3418
3419/// `tix mcp`: strict repository resolution (same hot path as `tix` with no
3420/// subcommand), then serve the MCP protocol synchronously over stdio. Outside
3421/// a repository the process fails with exit 1 BEFORE reading any protocol
3422/// bytes (FR-016, `contracts/mcp-protocol.md` §7).
3423fn run_mcp(cwd: &Path) -> Result<()> {
3424 let config = repo_config(cwd)?;
3425 let store = Store::open(&config)?;
3426 // Absolutize milestones against the same project root as issues_dir so the
3427 // MCP `create_issue`/`update_issue` planning validators and the CLI resolve
3428 // milestone files to the identical directory (parity cross-checker).
3429 let mut config = config;
3430 if config.milestones_dir().is_relative() {
3431 let root = Config::project_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
3432 let configured = config.milestones_dir();
3433 config.milestones_dir = Some(root.join(configured));
3434 }
3435 crate::mcp::serve(&config, &store)
3436}
3437
3438/// `tix render`: strict repository resolution like `tix`/`tix mcp` so the
3439/// command fails with the same no-config guidance outside a repository.
3440fn run_render(cwd: &Path) -> Result<()> {
3441 let config = repo_config(cwd)?;
3442 let store = Store::open(&config)?;
3443 commands::dispatch(&Command::Render, &config, &store)
3444}
3445
3446/// Config resolution for subcommands: defaults when no `tix.toml` exists
3447/// (relative to the project root via upward walk), with a one-time stderr
3448/// notice. Only explicit per-command requirements fail hard.
3449fn resolve(cwd: &Path) -> Result<(Config, Store)> {
3450 let root = Config::project_root(cwd);
3451 let mut config = match &root {
3452 Some(r) => Config::load(r)?,
3453 None => Config::default(),
3454 };
3455 // Resolve a relative issues_dir against the project root (or cwd when
3456 // unconfigured) so subcommands behave identically from any subdirectory.
3457 if config.issues_dir.is_relative() {
3458 let base = root.as_deref().unwrap_or(cwd);
3459 config.issues_dir = base.join(&config.issues_dir);
3460 }
3461 // Milestones resolve against the same base as issues_dir: the planning
3462 // validators (create/edit) and `lint` must agree on where milestone
3463 // registry files live regardless of the invocation directory.
3464 if config.milestones_dir().is_relative() {
3465 let base = root.as_deref().unwrap_or(cwd);
3466 let configured = config.milestones_dir();
3467 config.milestones_dir = Some(base.join(configured));
3468 }
3469 // `tags_dir`, `[repos]` and `parent_repo` resolve against the same base.
3470 config.resolve_repo_paths(root.as_deref().unwrap_or(cwd));
3471 if root.is_none() {
3472 eprintln!("tix: no tix.toml found (run 'tix init' to create one)");
3473 }
3474 let store = Store::open(&config)?;
3475 Ok((config, store))
3476}
3477
3478fn run_subcommand(cwd: &Path, cmd: &Command) -> Result<()> {
3479 let (mut config, store) = resolve(cwd)?;
3480 // `tix view <subcommand>` is the one command tree that needs the config
3481 // **mutably**: an accepted view mutation *is* `config.views` changing, and
3482 // `commands::dispatch` (whose every arm borrows `&Config`) cannot express
3483 // that. Routed here, at the process boundary where the config is owned, for
3484 // the same reason `tix bulk` and `tix triage` are routed in `run`.
3485 if let Command::View {
3486 action: Some(action),
3487 ..
3488 } = cmd
3489 {
3490 return commands::views::run(&mut config, &store, action);
3491 }
3492 // `tix wip set/unset/unit` mutate `tix.toml`, so they are routed here for the
3493 // same reason the view mutations are: `commands::dispatch`'s arms borrow
3494 // `&Config`, and an accepted WIP edit *is* the configuration changing.
3495 if let Command::Wip { action } = cmd {
3496 return commands::wip::run(&mut config, &store, action);
3497 }
3498 if !matches!(cmd, Command::Rollover { .. }) {
3499 let planner = crate::planning::Planner::from_config(config.clone(), cwd.to_path_buf());
3500 let plan = planner.rollover_plan(&store, &crate::model::today());
3501 if let Some(first) = plan.first() {
3502 eprintln!(
3503 "hint: rollover of {} issue(s) from \"{}\" to \"{}\" is suggested; run 'tix rollover' to apply",
3504 first.issues.len(),
3505 first.from_cycle,
3506 first.to_cycle
3507 );
3508 }
3509 }
3510 commands::dispatch(cmd, &config, &store)
3511}