Skip to main content

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}