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 Init,
63 /// Append or replace the tix block in AGENTS.md (idempotent)
64 InitAgents,
65 /// Create an issue
66 Create {
67 /// Issue title (prompted when missing on a TTY)
68 #[arg(long)]
69 title: Option<String>,
70 /// Issue body (markdown)
71 #[arg(long)]
72 body: Option<String>,
73 /// Column name (default: first column)
74 #[arg(long)]
75 status: Option<String>,
76 /// Priority: low | medium | high (default medium)
77 #[arg(long)]
78 priority: Option<String>,
79 /// Assignee (@name)
80 #[arg(long)]
81 assignee: Option<String>,
82 /// Labels, comma-separated
83 #[arg(long)]
84 labels: Option<String>,
85 /// Files governing this issue, comma-separated
86 #[arg(long)]
87 files: Option<String>,
88 /// Estimate
89 #[arg(long)]
90 estimate: Option<String>,
91 /// Cycle names, comma-separated
92 #[arg(long)]
93 cycles: Option<String>,
94 /// Milestone names, comma-separated
95 #[arg(long)]
96 milestones: Option<String>,
97 /// Reviewer (free-form name/identity)
98 #[arg(long)]
99 reviewer: Option<String>,
100 /// Start date (`YYYY-MM-DD`)
101 #[arg(long)]
102 start_date: Option<String>,
103 /// Due date (`YYYY-MM-DD`); must be `>= start_date`
104 #[arg(long)]
105 due_date: Option<String>,
106 /// Create as a child of this issue (number, filename or title fragment)
107 ///
108 /// At most one parent, so this takes a single selector and there is no list
109 /// form (C-001). The parent must already exist; a selector that matches no
110 /// issue is refused and nothing is created.
111 #[arg(long, value_name = "SELECTOR")]
112 parent: Option<String>,
113 },
114 /// Create, edit, remove, or list typed relationships
115 ///
116 /// Creating requires `--type`; there is no default kind, so an untyped
117 /// relationship is never recorded by accident (C-001). Omit the
118 /// subcommand to create: `tix link <from> <to> --type <kind>`.
119 Link {
120 /// `edit`, `remove`, or `list`. Omit to create a relationship.
121 ///
122 /// These three words are reserved on `tix link`: an issue whose number,
123 /// filename, or title selector is exactly `edit`, `remove`, or `list`
124 /// must be addressed by number.
125 #[command(subcommand)]
126 action: Option<LinkAction>,
127 /// [create] Issue number, filename fragment or unique title fragment
128 from: Option<String>,
129 /// [create] Issue number, filename fragment or unique title fragment
130 to: Option<String>,
131 /// [create] Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
132 #[arg(long, value_name = "KIND")]
133 r#type: Option<String>,
134 },
135 /// Batch editing over a selection
136 ///
137 /// Preview is the **default**; `--apply` writes. Name exactly one of `--select`,
138 /// `--filter` or `--search` — they are different grammars, and a bulk operation
139 /// will not guess which issues you meant.
140 Bulk {
141 #[command(subcommand)]
142 action: BulkAction,
143 },
144 /// Remove a typed relationship between two issues
145 Unlink {
146 /// Issue number, filename fragment or unique title fragment
147 from: String,
148 /// Issue number, filename fragment or unique title fragment
149 to: String,
150 /// Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
151 #[arg(long, value_name = "KIND")]
152 r#type: Option<String>,
153 },
154 /// Print the transitive dependency chain an issue holds up
155 Chain {
156 /// Issue number, filename fragment or unique title fragment
157 issue: String,
158 },
159 /// Inspect the issue hierarchy
160 ///
161 /// `show`, `children`, or `list`. Every figure comes from the hierarchy
162 /// service's typed values, so the CLI, the TUI detail block and the MCP read
163 /// tools report the same numbers for the same issue.
164 Parent {
165 /// `show`, `children`, or `list`
166 #[command(subcommand)]
167 action: ParentAction,
168 },
169 /// Edit an issue
170 Edit {
171 /// Issue number, filename fragment or unique title fragment
172 issue: String,
173 /// New title
174 #[arg(long)]
175 title: Option<String>,
176 /// New body (markdown)
177 #[arg(long)]
178 body: Option<String>,
179 /// New status
180 ///
181 /// Setting the status is a **move**, validated by the same service as
182 /// `tix mv` and recorded as the same `TransitionDecision` — it is not a
183 /// way around the transition policy. `--status ""` clears the field
184 /// (matching every other field here) and is a plain field write, not a
185 /// transition, because there is no target column to judge.
186 #[arg(long)]
187 status: Option<String>,
188 /// New priority: low | medium | high
189 #[arg(long)]
190 priority: Option<String>,
191 /// New assignee (@name)
192 #[arg(long)]
193 assignee: Option<String>,
194 /// New label list, comma-separated
195 #[arg(long)]
196 labels: Option<String>,
197 /// New files list, comma-separated
198 #[arg(long)]
199 files: Option<String>,
200 /// New estimate
201 #[arg(long)]
202 estimate: Option<String>,
203 /// New cycle list, comma-separated (`--cycles ""` clears)
204 #[arg(long)]
205 cycles: Option<String>,
206 /// New milestone list, comma-separated (`--milestones ""` clears)
207 #[arg(long)]
208 milestones: Option<String>,
209 /// New reviewer (`--reviewer ""` clears)
210 #[arg(long)]
211 reviewer: Option<String>,
212 /// New start date (`YYYY-MM-DD`; `--start-date ""` clears)
213 #[arg(long)]
214 start_date: Option<String>,
215 /// New due date (`YYYY-MM-DD`; `--due-date ""` clears)
216 #[arg(long)]
217 due_date: Option<String>,
218 /// Commit hash to record (`auto` resolves it from git history, `clear` removes it)
219 #[arg(long)]
220 commit: Option<String>,
221 /// Set or change the parent (number, filename or title fragment)
222 ///
223 /// `--parent ""` clears the parent, mirroring `--assignee ""`; omitting the
224 /// flag leaves it untouched. Validated before any write, so a self-parent,
225 /// an unknown parent, or a change that would close a cycle leaves the file
226 /// byte-identical.
227 #[arg(long, value_name = "SELECTOR")]
228 parent: Option<String>,
229 /// Why a transition policy is being overridden by `--status`
230 ///
231 /// `--status` is validated exactly as `tix mv` is, so moving an issue out
232 /// of a terminal category needs the same reasoned override, and the reason
233 /// is recorded in the same History decision. `--status` deliberately has
234 /// no `--yes`: acknowledging blockers is spelled `tix move … --yes`, and
235 /// giving this flag a second spelling would give the audit trail two.
236 #[arg(long, value_name = "TEXT")]
237 reason: Option<String>,
238 },
239 /// Move an issue to a column
240 Move {
241 /// Issue number, filename fragment or unique title fragment
242 issue: String,
243 /// Target column
244 column: String,
245 /// Commit hash to record when moving to a completion column (`auto`
246 /// resolves it from git history; omitted, the current HEAD is captured
247 /// automatically)
248 ///
249 /// "Completion" is whatever the board's category mapping calls
250 /// `completed` — not a column literally named `done`. On a board with
251 /// no `[kanban.<name>.category_map]` the board's `done_column` is the
252 /// completion column.
253 #[arg(long, value_name = "HASH")]
254 commit: Option<String>,
255 /// Why a transition policy is being overridden
256 ///
257 /// Required to move an issue *out of* a terminal category (`completed`
258 /// or `canceled`) when the board declares a `category_map`. The reason
259 /// is recorded in the issue's History, so the audit trail explains
260 /// itself later. A blank value is not a reason and is refused.
261 ///
262 /// A board with no `category_map` has no transition policy to override:
263 /// it keeps the completion definition and the commit rule, and moving
264 /// out of `done` stays permitted.
265 #[arg(long, value_name = "TEXT")]
266 reason: Option<String>,
267 /// Confirm without prompting
268 ///
269 /// Also the acknowledgement a move toward the board's completion column
270 /// needs when the issue has open blockers. Without it that move is
271 /// refused, and a recorded acknowledgement is appended to the issue's
272 /// History so the decision stays auditable.
273 #[arg(long)]
274 yes: bool,
275 },
276 /// Print the full issue (frontmatter + body) to stdout
277 #[command(visible_alias = "show")]
278 View {
279 /// Issue number, filename fragment or unique title fragment
280 issue: String,
281 /// Extra information to append, comma-separated
282 ///
283 /// `relationships` appends the service-derived relationship block, the
284 /// transitive chain, and the blocked reason. Without it the issue file
285 /// is printed verbatim, so `tix view <n> > file` still round-trips.
286 #[arg(long, value_name = "relationships")]
287 show: Option<String>,
288 },
289 /// Table of issues
290 List {
291 /// Only issues in this status
292 #[arg(long)]
293 status: Option<String>,
294 /// Token query (`cycle:`, `milestone:`, `reviewer:`, `overdue`, etc.)
295 #[arg(long)]
296 filter: Option<String>,
297 /// Include archived issues, marked with an ARCHIVED column
298 ///
299 /// Off by default, and off for a reason: archived issues are skipped
300 /// without being read, so a repository with thousands of them lists fast.
301 #[arg(long)]
302 with_archived: bool,
303 /// Sort key (`created`, `due`, `start`, `reviewer`, `cycle`, `milestone`, `status`, `priority`)
304 #[arg(long)]
305 sort: Option<String>,
306 /// Group key (`cycle` | `milestone`)
307 #[arg(long)]
308 group: Option<String>,
309 /// Comma-separated columns to display
310 #[arg(
311 long,
312 value_name = "num,title,status,priority,cycle,milestone,reviewer,start,due,overdue"
313 )]
314 columns: Option<String>,
315 /// Extra information to include, comma-separated
316 ///
317 /// `relationships` adds a relationship summary and a blocked marker
318 /// per issue, read from the relationship service so the CLI and the
319 /// TUI cannot disagree.
320 ///
321 /// `progress` adds a parent rollup; `category` adds the issue's resolved
322 /// category label, read from the shared service so this surface and the
323 /// TUI cannot spell one category two ways.
324 #[arg(long, value_name = "SHOW")]
325 show: Option<String>,
326 },
327 /// Inspect the status-category vocabulary, a board's mapping, and the
328 /// effective transition policy
329 ///
330 /// A read-only surface. It never writes and never repairs, so it is safe to run
331 /// on a repository whose `tix.toml` is mid-edit.
332 Workflow {
333 #[command(subcommand)]
334 action: WorkflowAction,
335 },
336 /// Board summary: count per column + total
337 Status {
338 /// Token query (`cycle:`, `milestone:`, `reviewer:`, `overdue`, etc.)
339 #[arg(long)]
340 filter: Option<String>,
341 /// Sort key (`created`, `due`, `start`, `reviewer`, `cycle`, `milestone`, `status`, `priority`)
342 #[arg(long)]
343 sort: Option<String>,
344 /// Group key (`cycle` | `milestone`)
345 #[arg(long)]
346 group: Option<String>,
347 },
348 /// Triage the inbox: read-only queue views and the four decisions
349 Triage {
350 #[command(subcommand)]
351 action: TriageAction,
352 },
353 /// Delete an issue file
354 Delete {
355 /// Issue number, filename fragment or unique title fragment
356 issue: String,
357 /// Confirm without prompting
358 #[arg(long)]
359 yes: bool,
360 },
361 /// Archive issue files: rename each in place to a dot-prefixed name
362 ///
363 /// The default views (`tix list`, `tix search`) skip archived issues; pass
364 /// their `--with-archived` flag or the `archived:true` filter to see them.
365 Archive {
366 /// Issue numbers, filename fragments or unique title fragments
367 #[arg(required = true, value_name = "ISSUE")]
368 issues: Vec<String>,
369 },
370 /// Restore archived issue files: the inverse of `tix archive`
371 Restore {
372 /// Issue numbers, filename fragments or unique title fragments
373 #[arg(required = true, value_name = "ISSUE")]
374 issues: Vec<String>,
375 },
376 /// Manage planning cycles
377 Cycle {
378 #[command(subcommand)]
379 command: CycleCommand,
380 },
381 /// Manage milestones
382 Milestone {
383 #[command(subcommand)]
384 command: MilestoneCommand,
385 },
386 /// Export local issues to an external tracker via glab/linear-cli
387 Export {
388 /// Target tracker: gitlab | linear
389 #[arg(long, value_name = "gitlab|linear|ics")]
390 target: String,
391 /// Print what would change without invoking the remote tracker
392 #[arg(long)]
393 dry_run: bool,
394 /// Override the configured project path (gitlab) or team (linear)
395 #[arg(long)]
396 project: Option<String>,
397 /// Cycle to export for ICS output
398 #[arg(long)]
399 cycle: Option<String>,
400 /// Output path for ICS export
401 #[arg(long)]
402 output: Option<String>,
403 },
404 /// Import open issues from an external tracker via glab/linear-cli
405 Import {
406 /// Target tracker: gitlab | linear
407 #[arg(long, value_name = "gitlab|linear")]
408 target: String,
409 /// Where an imported issue lands in the local triage inbox (WP07)
410 ///
411 /// `entry` routes into `[triage].entry_status` and refuses a project with
412 /// no `[triage]` section instead of quietly falling back; `remote` keeps
413 /// the column the remote mapping named. Absent is the documented default:
414 /// route when the project has an inbox, otherwise leave the mapping alone.
415 /// Either way the report names the mapping that was used.
416 #[arg(long, value_name = "entry|remote")]
417 triage_policy: Option<String>,
418 },
419 /// Roll open issues forward from expired cycles to the next successor
420 Rollover {
421 /// Restrict rollover to one source cycle
422 #[arg(long)]
423 cycle: Option<String>,
424 /// Skip interactive confirmation
425 #[arg(long)]
426 yes: bool,
427 },
428 /// Search issue files for a string (case-insensitive substring over full markdown)
429 Search {
430 /// Case-insensitive substring to match against the full markdown of every issue
431 #[arg(value_name = "STRING", value_parser = NonEmptyStringValueParser::new())]
432 string: String,
433 /// Include archived issues in the search corpus
434 #[arg(long)]
435 with_archived: bool,
436 },
437 /// Render the current board once and exit
438 Render,
439 /// Open the TUI with the create-issue form already open
440 ///
441 /// The session `tix` starts, with the modal that `n` opens already in front of
442 /// you: one command to file an issue, instead of "start the board, then press
443 /// n". It is the interactive twin of `tix create`, which never opens the TUI.
444 New,
445 /// Validate frontmatter option values in all issue files
446 Lint,
447 /// Serve the MCP protocol (JSON-RPC 2.0 over stdio) for agent tools
448 Mcp,
449}
450
451/// The `tix triage` tree (`contracts/cli-surface.md`, WP05 T023–T025).
452///
453/// `list` and `status` are read-only queue views; the four decision verbs take a
454/// single issue; `batch` is the selection-shaped twin of `tix bulk`, preview by
455/// default and writing only under an explicit `--apply`.
456///
457/// ## Why the reason flag is `Option` on `accept` and the rest
458///
459/// `contracts/decision-history.md` splits it: decline, duplicate and defer
460/// *require* a reason, accept records one when supplied. A required clap argument
461/// on three of the four would make the fourth's absence look like an oversight,
462/// and — more importantly — the enforcement belongs to the service, which refuses
463/// `missing_reason` before any write on every surface (NFR-003). The flag is
464/// therefore optional everywhere and the *rule* is the service's, so the CLI,
465/// TUI and MCP cannot end up with three different ideas of which decision needs
466/// one. `tix triage decline 1` with no `--reason` still fails, at the service,
467/// with the contract's own kind.
468#[derive(Subcommand)]
469pub enum TriageAction {
470 /// Read the inbox queue: one row per entry (FR-001)
471 List {
472 /// Sort by entry age, oldest first; an unknown age sorts last
473 #[arg(long)]
474 age: bool,
475 /// Print the counts only, with no entries
476 #[arg(long)]
477 count: bool,
478 },
479 /// Read the inbox summary: decision counts and the age histogram (FR-005)
480 Status,
481 /// Accept: move into the normal workflow, validated by #183's transition policy
482 Accept {
483 /// Issue number, filename fragment or unique title fragment
484 issue: String,
485 /// Reason recorded in History; accept records one when supplied
486 #[arg(long)]
487 reason: Option<String>,
488 /// Target column; defaults to `[triage].accepted_status`
489 #[arg(long, value_name = "COLUMN")]
490 to: Option<String>,
491 /// Who decided; defaults to the git identity
492 #[arg(long)]
493 actor: Option<String>,
494 },
495 /// Decline: move to `[triage].declined_status`; never deletes or archives
496 Decline {
497 /// Issue number, filename fragment or unique title fragment
498 issue: String,
499 /// Why it is declined (required)
500 #[arg(long)]
501 reason: Option<String>,
502 /// Who decided; defaults to the git identity
503 #[arg(long)]
504 actor: Option<String>,
505 },
506 /// Duplicate: record a typed `duplicates` relation; never merges files
507 Duplicate {
508 /// Issue number, filename fragment or unique title fragment
509 issue: String,
510 /// The issue this one duplicates
511 #[arg(long, value_name = "ISSUE")]
512 of: String,
513 /// Why it is a duplicate (required)
514 #[arg(long)]
515 reason: Option<String>,
516 /// Who decided; defaults to the git identity
517 #[arg(long)]
518 actor: Option<String>,
519 },
520 /// Defer: stay in the inbox with a refreshed entry time
521 Defer {
522 /// Issue number, filename fragment or unique title fragment
523 issue: String,
524 /// Why it is deferred (required)
525 #[arg(long)]
526 reason: Option<String>,
527 /// Who decided; defaults to the git identity
528 #[arg(long)]
529 actor: Option<String>,
530 },
531 /// One decision across a selection: preview by default, `--apply` to write
532 Batch {
533 /// Comma-separated selectors: number, filename or title fragment
534 #[arg(long, value_name = "LIST")]
535 select: Option<String>,
536 /// A `tix list --filter` query selecting the issues
537 #[arg(long, value_name = "QUERY")]
538 filter: Option<String>,
539 /// Which decision to make: accept | decline | duplicate | defer
540 #[arg(long, value_name = "KIND")]
541 decision: String,
542 /// Reason recorded on every applied event; required for three of four
543 #[arg(long)]
544 reason: Option<String>,
545 /// Who decided; defaults to the git identity
546 #[arg(long)]
547 actor: Option<String>,
548 /// The duplicate target selector, for `--decision duplicate`
549 #[arg(long, value_name = "ISSUE")]
550 of: Option<String>,
551 /// The accept target column, for `--decision accept`
552 #[arg(long, value_name = "COLUMN")]
553 to: Option<String>,
554 /// Apply the previewed plan; without it nothing is written
555 #[arg(long)]
556 apply: bool,
557 },
558}
559
560#[derive(Subcommand)]
561pub enum CycleCommand {
562 Create {
563 #[arg(long)]
564 name: Option<String>,
565 #[arg(long)]
566 description: Option<String>,
567 #[arg(long = "start-date")]
568 start_date: Option<String>,
569 #[arg(long = "due-date")]
570 due_date: Option<String>,
571 },
572 List,
573 Edit {
574 #[arg(long)]
575 name: String,
576 #[arg(long = "new-name")]
577 new_name: Option<String>,
578 #[arg(long)]
579 description: Option<String>,
580 #[arg(long = "start-date")]
581 start_date: Option<String>,
582 #[arg(long = "due-date")]
583 due_date: Option<String>,
584 },
585 Delete {
586 #[arg(long)]
587 name: String,
588 #[arg(long)]
589 yes: bool,
590 },
591}
592
593#[derive(Subcommand)]
594pub enum MilestoneCommand {
595 Create {
596 #[arg(long)]
597 name: String,
598 #[arg(long = "target-date")]
599 target_date: Option<String>,
600 #[arg(long)]
601 description: Option<String>,
602 },
603 List,
604 Edit {
605 #[arg(long)]
606 name: String,
607 #[arg(long = "new-name")]
608 new_name: Option<String>,
609 #[arg(long = "target-date")]
610 target_date: Option<String>,
611 #[arg(long)]
612 description: Option<String>,
613 },
614 Delete {
615 #[arg(long)]
616 name: String,
617 #[arg(long)]
618 yes: bool,
619 },
620}
621
622/// The `tix parent` subcommands.
623///
624/// A **required** subcommand, unlike `tix link`'s three optional verbs. `tix link`
625/// has a meaningful bare form (create); `tix parent` with no argument is a user
626/// mistake, and guessing which of three reports they wanted is worse than asking.
627#[derive(Debug, Clone, Subcommand)]
628pub enum ParentAction {
629 /// Report the parent, direct children, child count and rollup for one issue
630 Show {
631 /// Issue number, filename fragment or unique title fragment
632 issue: String,
633 },
634 /// List an issue's direct children, ordered by issue number
635 Children {
636 /// Issue number, filename fragment or unique title fragment
637 issue: String,
638 },
639 /// List every issue that has children, with a child count
640 List,
641}
642
643/// The `tix workflow` verbs, per `contracts/cli-surface.md` §1.
644///
645/// A real `#[command(subcommand)]` tree rather than an optional-verb enum like
646/// [`LinkAction`]: the three verbs share nothing but the prefix, and there is no
647/// sensible bare `tix workflow`. Making the verb **required** means `tix workflow`
648/// alone is a usage error rather than an empty success — printing the vocabulary
649/// when the user asked for the policy would be guessing at their intent.
650#[derive(Subcommand)]
651pub enum WorkflowAction {
652 /// The global category vocabulary, in order
653 Categories,
654 /// A board's columns, resolved categories, and where each resolution came from
655 Show {
656 /// Board name; omitted means the active board (`default`, or the first
657 /// declared). A name that is not configured is an error listing the boards
658 /// that are.
659 board: Option<String>,
660 },
661 /// The effective transition policy and its override escape hatch
662 Transitions,
663}
664
665/// The `tix link` subcommands.
666///
667/// `tix link <from> <to> --type <kind>` is the create form and carries no
668/// subcommand, so the three verbs below are optional rather than required
669/// (C-001 removes the old untyped symmetric form, not the bare create form the
670/// CLI surface contract specifies).
671#[derive(Subcommand)]
672pub enum LinkAction {
673 /// Retarget and/or re-kind an existing relationship, keeping its id
674 Edit {
675 /// Relationship id, as printed by `tix link list`
676 id: String,
677 /// New target issue number, filename fragment or unique title fragment
678 #[arg(long)]
679 to: Option<String>,
680 /// New relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
681 #[arg(long, value_name = "KIND")]
682 r#type: Option<String>,
683 },
684 /// Remove a relationship by id. An absent id is an idempotent no-op
685 Remove {
686 /// Relationship id, as printed by `tix link list`
687 id: String,
688 },
689 /// List relationships for one issue, or for the whole board
690 List {
691 /// Issue number, filename fragment or unique title fragment. Omit for every issue.
692 issue: Option<String>,
693 /// Only relationships of this kind
694 #[arg(long, value_name = "KIND")]
695 kind: Option<String>,
696 },
697}
698
699/// `tix bulk <action>` — four actions, and each carries **its own** flags.
700///
701/// The flags are on the subcommand rather than on the parent, which is what makes the
702/// surface unambiguous by construction rather than by a runtime check:
703/// `tix bulk archive --priority high` is a **clap usage error**, not a command that
704/// accepts a field flag and ignores it. `edit` is the only action with field flags,
705/// `link`/`unlink` are the only ones with `--target`, and `archive` takes neither — so
706/// there is no spelling of `tix bulk` that a flag can apply to by accident.
707///
708/// `archive` has no `--type`: archiving is not a relationship, and offering a kind
709/// would suggest it is one.
710/// The flags every `tix bulk` action carries: which issues, and whether to write.
711///
712/// Flattened into **each** action rather than held on `Command::Bulk`, because clap
713/// parses a parent's flags *before* its subcommand — so a parent would spell the
714/// contract's `tix bulk edit --select <...>` as `tix bulk --select <...> edit`, which
715/// is a different surface from the one the contract names. Four `#[command(flatten)]`
716/// of one struct gives the contract's spelling and one vocabulary, so a new flag
717/// cannot be added to one action and forgotten on the other three.
718#[derive(Args, Debug, Clone, PartialEq, Eq)]
719pub struct BulkSelection {
720 /// Comma-separated issue selectors: number, filename fragment or unique title
721 /// fragment
722 #[arg(long, value_name = "LIST")]
723 pub select: Option<String>,
724 /// A `--filter` query, in the existing filter grammar
725 #[arg(long, value_name = "QUERY")]
726 pub filter: Option<String>,
727 /// A `--search` query, in the existing search grammar
728 #[arg(long, value_name = "QUERY")]
729 pub search: Option<String>,
730 /// Which issues are in play: `active`, `active+archived` or `archived`
731 #[arg(long, value_name = "SCOPE")]
732 pub scope: Option<String>,
733 /// Write the batch. Omit for a preview, which is the default.
734 #[arg(long)]
735 pub apply: bool,
736}
737
738impl BulkAction {
739 /// The shared selection flags, whichever action carries them.
740 ///
741 /// One accessor for four flattened fields, so the routing code reads the flags
742 /// once and a new action cannot forget to pass them.
743 pub fn selection(&self) -> &BulkSelection {
744 match self {
745 Self::Edit { selection, .. }
746 | Self::Link { selection, .. }
747 | Self::Unlink { selection, .. }
748 | Self::Archive { selection } => selection,
749 }
750 }
751
752 /// Whether `--apply` was given. A preview is the default, so this is a positive
753 /// question rather than "is this not a preview".
754 pub fn is_apply(&self) -> bool {
755 self.selection().apply
756 }
757}
758
759#[derive(Subcommand, Debug, Clone, PartialEq, Eq)]
760pub enum BulkAction {
761 /// Apply a typed field patch to every selected issue
762 Edit {
763 #[command(flatten)]
764 selection: BulkSelection,
765 /// Target column
766 #[arg(long, value_name = "COLUMN")]
767 status: Option<String>,
768 /// `low`, `medium`, `high`, or `` for unset
769 #[arg(long, value_name = "PRIORITY")]
770 priority: Option<String>,
771 /// Bare handle or full identity
772 #[arg(long, value_name = "ASSIGNEE")]
773 assignee: Option<String>,
774 /// Comma-separated labels
775 #[arg(long, value_name = "LABELS")]
776 labels: Option<String>,
777 /// `YYYY-MM-DD`
778 #[arg(long, value_name = "DATE")]
779 start_date: Option<String>,
780 /// `YYYY-MM-DD`
781 #[arg(long, value_name = "DATE")]
782 due_date: Option<String>,
783 /// Free-form estimate
784 #[arg(long, value_name = "ESTIMATE")]
785 estimate: Option<String>,
786 /// Comma-separated cycle names
787 #[arg(long, value_name = "CYCLES")]
788 cycles: Option<String>,
789 /// Comma-separated milestone names
790 #[arg(long, value_name = "MILESTONES")]
791 milestones: Option<String>,
792 /// Empty a field. Takes one of the clearable field names.
793 #[arg(long, value_name = "FIELD")]
794 clear: Option<String>,
795 },
796 /// Link every selected issue to `--target`
797 Link {
798 #[command(flatten)]
799 selection: BulkSelection,
800 /// The other end of the relationship
801 #[arg(long, value_name = "SELECTOR")]
802 target: String,
803 /// Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
804 #[arg(long, value_name = "KIND")]
805 r#type: Option<String>,
806 },
807 /// Unlink every selected issue from `--target`
808 Unlink {
809 #[command(flatten)]
810 selection: BulkSelection,
811 /// The other end of the relationship
812 #[arg(long, value_name = "SELECTOR")]
813 target: String,
814 /// Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
815 #[arg(long, value_name = "KIND")]
816 r#type: Option<String>,
817 },
818 /// Ask the archive service to archive every selected issue
819 Archive {
820 #[command(flatten)]
821 selection: BulkSelection,
822 },
823}
824
825/// Process entry: route a parsed [`Cli`] through config resolution, store
826/// opening and dispatch, then return the exit code for `main` to exit with.
827pub fn run(cli: Cli) -> u8 {
828 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
829 match &cli.command {
830 Some(Command::Lint) => run_lint(&cwd),
831 Some(Command::Mcp) => match run_mcp(&cwd) {
832 Ok(()) => ExitCode::Success as u8,
833 Err(e) => {
834 eprintln!("tix: {e}");
835 exit_code_for(&e) as u8
836 }
837 },
838 Some(Command::Render) => match run_render(&cwd) {
839 Ok(()) => ExitCode::Success as u8,
840 Err(e) => {
841 eprintln!("tix: {e}");
842 exit_code_for(&e) as u8
843 }
844 },
845 // A bulk batch is the one command whose **status** is a result rather than an
846 // error: a partial batch completes and reports non-zero, with every outcome
847 // retained (`contracts/cli-surface.md`). So it is routed here, where the exit
848 // code is decided, and not through `commands::dispatch`, whose arms are all
849 // `Result<()>`.
850 Some(Command::Bulk { action }) => {
851 let result = resolve(&cwd).and_then(|(_, store)| {
852 commands::bulk::run(
853 &store,
854 action,
855 action.selection(),
856 if action.is_apply() {
857 commands::bulk::Mode::Apply
858 } else {
859 commands::bulk::Mode::Preview
860 },
861 )
862 });
863 match result {
864 Ok(code) => code as u8,
865 Err(e) => {
866 eprintln!("tix: {e}");
867 exit_code_for(&e) as u8
868 }
869 }
870 }
871 // `tix triage` shares bulk's exit-code shape for the same reason: a batch
872 // that partly succeeded is a *completed* command with a status, not an
873 // error, so `--apply` can print one outcome per selected issue and still
874 // exit non-zero (T026). It is routed here rather than through
875 // `commands::dispatch`, whose arms are all `Result<()>`.
876 Some(Command::Triage { action }) => {
877 let result = resolve(&cwd).and_then(|(_, store)| commands::triage::run(&store, action));
878 match result {
879 Ok(code) => code as u8,
880 Err(e) => {
881 eprintln!("tix: {e}");
882 exit_code_for(&e) as u8
883 }
884 }
885 }
886 // `tix archive`/`tix restore` share the batch exit-code shape: every
887 // selector is processed independently, a failure on one does not roll back
888 // another, and the process still exits non-zero if any failed. Routed here
889 // for the same reason `tix bulk` and `tix triage` are.
890 Some(Command::Archive { issues }) => {
891 route_archive(&cwd, commands::archive::Action::Archive, issues)
892 }
893 Some(Command::Restore { issues }) => {
894 route_archive(&cwd, commands::archive::Action::Restore, issues)
895 }
896 _ => {
897 let result = match &cli.command {
898 None => run_tui(&cwd),
899 Some(Command::New) => run_tui_new(&cwd),
900 Some(Command::Init) => commands::init::run(&cwd),
901 Some(Command::InitAgents) => {
902 let root = Config::project_root(&cwd).unwrap_or(cwd.clone());
903 commands::agents::run(&root).map(|_| ())
904 }
905 Some(cmd) => run_subcommand(&cwd, cmd),
906 };
907 match result {
908 Ok(()) => ExitCode::Success as u8,
909 Err(e) => {
910 eprintln!("tix: {e}");
911 exit_code_for(&e) as u8
912 }
913 }
914 }
915 }
916}
917
918/// Process one `tix archive`/`tix restore` batch and map it to an exit code.
919///
920/// Each selector is independent: an outcome is printed per item and a failure on
921/// one item neither aborts the rest nor rolls back a successful rename. The
922/// process exits with the first failure's code, or `0` when every item succeeded.
923fn route_archive(cwd: &Path, action: commands::archive::Action, issues: &[String]) -> u8 {
924 match resolve(cwd).and_then(|(_, store)| commands::archive::run(&store, action, issues)) {
925 Ok(code) => code as u8,
926 Err(e) => {
927 eprintln!("tix: {e}");
928 exit_code_for(&e) as u8
929 }
930 }
931}
932
933/// `tix lint` owns its own boundary: its exit-code table (`0` healthy, `1`
934/// findings, `2` usage/internal) does not match the generic
935/// [`exit_code_for`] mapping, which would collapse internal errors into `1`.
936fn run_lint(cwd: &Path) -> u8 {
937 let (config, store) = match resolve(cwd) {
938 Ok(pair) => pair,
939 Err(e) => {
940 eprintln!("tix: {e}");
941 return 2;
942 }
943 };
944 match commands::lint::run(&config, &store) {
945 Ok(true) => 1,
946 Ok(false) => 0,
947 Err(e) => {
948 eprintln!("tix: {e}");
949 2
950 }
951 }
952}
953
954/// `tix` with no subcommand: launch the TUI, or fail with the contract's
955/// exact message when no `tix.toml` is reachable (exit 1).
956fn run_tui(cwd: &Path) -> Result<()> {
957 let config = repo_config(cwd)?;
958 crate::app::run(&config)
959}
960
961/// `tix new`: the same session, opened on the create-issue form instead of the
962/// board. Same repository resolution as [`run_tui`], so a missing `tix.toml` fails
963/// with the same message rather than opening a form that could not be submitted.
964fn run_tui_new(cwd: &Path) -> Result<()> {
965 let config = repo_config(cwd)?;
966 crate::app::run_new_issue(&config)
967}
968
969/// Strict repository resolution shared by the TUI and `tix mcp` (FR-015):
970/// upward `tix.toml` discovery, failing with the contract's exact `no
971/// tix.toml found` guidance (exit 1) when no repository is reachable —
972/// never a guessed default store. This is the "CLI hot path" that
973/// `contracts/mcp-protocol.md` §7 requires `tix mcp` to reuse.
974fn repo_config(cwd: &Path) -> Result<Config> {
975 let root = Config::project_root(cwd)
976 .ok_or_else(|| TixError::Config("no tix.toml found — run \"tix init\" first".into()))?;
977 Config::load(&root)
978}
979
980/// `tix mcp`: strict repository resolution (same hot path as `tix` with no
981/// subcommand), then serve the MCP protocol synchronously over stdio. Outside
982/// a repository the process fails with exit 1 BEFORE reading any protocol
983/// bytes (FR-016, `contracts/mcp-protocol.md` §7).
984fn run_mcp(cwd: &Path) -> Result<()> {
985 let config = repo_config(cwd)?;
986 let store = Store::open(&config)?;
987 // Absolutize milestones against the same project root as issues_dir so the
988 // MCP `create_issue`/`update_issue` planning validators and the CLI resolve
989 // milestone files to the identical directory (parity cross-checker).
990 let mut config = config;
991 if config.milestones_dir().is_relative() {
992 let root = Config::project_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
993 let configured = config.milestones_dir();
994 config.milestones_dir = Some(root.join(configured));
995 }
996 crate::mcp::serve(&config, &store)
997}
998
999/// `tix render`: strict repository resolution like `tix`/`tix mcp` so the
1000/// command fails with the same no-config guidance outside a repository.
1001fn run_render(cwd: &Path) -> Result<()> {
1002 let config = repo_config(cwd)?;
1003 let store = Store::open(&config)?;
1004 commands::dispatch(&Command::Render, &config, &store)
1005}
1006
1007/// Config resolution for subcommands: defaults when no `tix.toml` exists
1008/// (relative to the project root via upward walk), with a one-time stderr
1009/// notice. Only explicit per-command requirements fail hard.
1010fn resolve(cwd: &Path) -> Result<(Config, Store)> {
1011 let root = Config::project_root(cwd);
1012 let mut config = match &root {
1013 Some(r) => Config::load(r)?,
1014 None => Config::default(),
1015 };
1016 // Resolve a relative issues_dir against the project root (or cwd when
1017 // unconfigured) so subcommands behave identically from any subdirectory.
1018 if config.issues_dir.is_relative() {
1019 let base = root.as_deref().unwrap_or(cwd);
1020 config.issues_dir = base.join(&config.issues_dir);
1021 }
1022 // Milestones resolve against the same base as issues_dir: the planning
1023 // validators (create/edit) and `lint` must agree on where milestone
1024 // registry files live regardless of the invocation directory.
1025 if config.milestones_dir().is_relative() {
1026 let base = root.as_deref().unwrap_or(cwd);
1027 let configured = config.milestones_dir();
1028 config.milestones_dir = Some(base.join(configured));
1029 }
1030 if root.is_none() {
1031 eprintln!("tix: no tix.toml found (run 'tix init' to create one)");
1032 }
1033 let store = Store::open(&config)?;
1034 Ok((config, store))
1035}
1036
1037fn run_subcommand(cwd: &Path, cmd: &Command) -> Result<()> {
1038 let (config, store) = resolve(cwd)?;
1039 if !matches!(cmd, Command::Rollover { .. }) {
1040 let planner = crate::planning::Planner::from_config(config.clone(), cwd.to_path_buf());
1041 let plan = planner.rollover_plan(&store, &crate::model::today());
1042 if let Some(first) = plan.first() {
1043 eprintln!(
1044 "hint: rollover of {} issue(s) from \"{}\" to \"{}\" is suggested; run 'tix rollover' to apply",
1045 first.issues.len(),
1046 first.from_cycle,
1047 first.to_cycle
1048 );
1049 }
1050 }
1051 commands::dispatch(cmd, &config, &store)
1052}