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::{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 /// Estimate
86 #[arg(long)]
87 estimate: Option<String>,
88 },
89 /// Edit an issue
90 Edit {
91 /// Issue number, filename fragment or unique title fragment
92 issue: String,
93 /// New title
94 #[arg(long)]
95 title: Option<String>,
96 /// New body (markdown)
97 #[arg(long)]
98 body: Option<String>,
99 /// New status
100 #[arg(long)]
101 status: Option<String>,
102 /// New priority: low | medium | high
103 #[arg(long)]
104 priority: Option<String>,
105 /// New assignee (@name)
106 #[arg(long)]
107 assignee: Option<String>,
108 /// New label list, comma-separated
109 #[arg(long)]
110 labels: Option<String>,
111 /// New estimate
112 #[arg(long)]
113 estimate: Option<String>,
114 /// Commit hash to record (or `clear` to remove it)
115 #[arg(long)]
116 commit: Option<String>,
117 },
118 /// Move an issue to a column
119 Move {
120 /// Issue number, filename fragment or unique title fragment
121 issue: String,
122 /// Target column
123 column: String,
124 /// Commit hash to record (optional; recorded when moving to done)
125 #[arg(long, value_name = "HASH")]
126 commit: Option<String>,
127 },
128 /// Print the full issue (frontmatter + body) to stdout
129 #[command(visible_alias = "show")]
130 View {
131 /// Issue number, filename fragment or unique title fragment
132 issue: String,
133 },
134 /// Table of issues
135 List {
136 /// Only issues in this status
137 #[arg(long)]
138 status: Option<String>,
139 },
140 /// Board summary: count per column + total
141 Status,
142 /// Delete an issue file
143 Delete {
144 /// Issue number, filename fragment or unique title fragment
145 issue: String,
146 /// Confirm without prompting
147 #[arg(long)]
148 yes: bool,
149 },
150 /// Export local issues to an external tracker via glab/linear-cli
151 Export {
152 /// Target tracker: gitlab | linear
153 #[arg(long, value_name = "gitlab|linear")]
154 target: String,
155 /// Print what would change without invoking the remote tracker
156 #[arg(long)]
157 dry_run: bool,
158 /// Override the configured project path (gitlab) or team (linear)
159 #[arg(long)]
160 project: Option<String>,
161 },
162 /// Import open issues from an external tracker via glab/linear-cli
163 Import {
164 /// Target tracker: gitlab | linear
165 #[arg(long, value_name = "gitlab|linear")]
166 target: String,
167 },
168 /// Search issue files for a string (case-insensitive substring over full markdown)
169 Search {
170 /// Case-insensitive substring to match against the full markdown of every issue
171 #[arg(value_name = "STRING", value_parser = NonEmptyStringValueParser::new())]
172 string: String,
173 },
174 /// Validate frontmatter option values in all issue files
175 Lint,
176 /// Serve the MCP protocol (JSON-RPC 2.0 over stdio) for agent tools
177 Mcp,
178}
179
180/// Process entry: route a parsed [`Cli`] through config resolution, store
181/// opening and dispatch, then return the exit code for `main` to exit with.
182pub fn run(cli: Cli) -> u8 {
183 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
184 match &cli.command {
185 Some(Command::Lint) => run_lint(&cwd),
186 Some(Command::Mcp) => match run_mcp(&cwd) {
187 Ok(()) => ExitCode::Success as u8,
188 Err(e) => {
189 eprintln!("tix: {e}");
190 exit_code_for(&e) as u8
191 }
192 },
193 _ => {
194 let result = match &cli.command {
195 None => run_tui(&cwd),
196 Some(Command::Init) => commands::init::run(&cwd),
197 Some(Command::InitAgents) => {
198 let root = Config::project_root(&cwd).unwrap_or(cwd.clone());
199 commands::agents::run(&root).map(|_| ())
200 }
201 Some(cmd) => run_subcommand(&cwd, cmd),
202 };
203 match result {
204 Ok(()) => ExitCode::Success as u8,
205 Err(e) => {
206 eprintln!("tix: {e}");
207 exit_code_for(&e) as u8
208 }
209 }
210 }
211 }
212}
213
214/// `tix lint` owns its own boundary: its exit-code table (`0` healthy, `1`
215/// findings, `2` usage/internal) does not match the generic
216/// [`exit_code_for`] mapping, which would collapse internal errors into `1`.
217fn run_lint(cwd: &Path) -> u8 {
218 let (config, store) = match resolve(cwd) {
219 Ok(pair) => pair,
220 Err(e) => {
221 eprintln!("tix: {e}");
222 return 2;
223 }
224 };
225 match commands::lint::run(&config, &store) {
226 Ok(true) => 1,
227 Ok(false) => 0,
228 Err(e) => {
229 eprintln!("tix: {e}");
230 2
231 }
232 }
233}
234
235/// `tix` with no subcommand: launch the TUI, or fail with the contract's
236/// exact message when no `tix.toml` is reachable (exit 1).
237fn run_tui(cwd: &Path) -> Result<()> {
238 let config = repo_config(cwd)?;
239 crate::app::run(&config)
240}
241
242/// Strict repository resolution shared by the TUI and `tix mcp` (FR-015):
243/// upward `tix.toml` discovery, failing with the contract's exact `no
244/// tix.toml found` guidance (exit 1) when no repository is reachable —
245/// never a guessed default store. This is the "CLI hot path" that
246/// `contracts/mcp-protocol.md` §7 requires `tix mcp` to reuse.
247fn repo_config(cwd: &Path) -> Result<Config> {
248 let root = Config::project_root(cwd)
249 .ok_or_else(|| TixError::Config("no tix.toml found — run \"tix init\" first".into()))?;
250 Config::load(&root)
251}
252
253/// `tix mcp`: strict repository resolution (same hot path as `tix` with no
254/// subcommand), then serve the MCP protocol synchronously over stdio. Outside
255/// a repository the process fails with exit 1 BEFORE reading any protocol
256/// bytes (FR-016, `contracts/mcp-protocol.md` §7).
257fn run_mcp(cwd: &Path) -> Result<()> {
258 let config = repo_config(cwd)?;
259 let store = Store::open(&config)?;
260 crate::mcp::serve(&config, &store)
261}
262
263/// Config resolution for subcommands: defaults when no `tix.toml` exists
264/// (relative to the project root via upward walk), with a one-time stderr
265/// notice. Only explicit per-command requirements fail hard.
266fn resolve(cwd: &Path) -> Result<(Config, Store)> {
267 let root = Config::project_root(cwd);
268 let mut config = match &root {
269 Some(r) => Config::load(r)?,
270 None => Config::default(),
271 };
272 // Resolve a relative issues_dir against the project root (or cwd when
273 // unconfigured) so subcommands behave identically from any subdirectory.
274 if config.issues_dir.is_relative() {
275 let base = root.as_deref().unwrap_or(cwd);
276 config.issues_dir = base.join(&config.issues_dir);
277 }
278 if root.is_none() {
279 eprintln!("tix: no tix.toml found (run 'tix init' to create one)");
280 }
281 let store = Store::open(&config)?;
282 Ok((config, store))
283}
284
285fn run_subcommand(cwd: &Path, cmd: &Command) -> Result<()> {
286 let (config, store) = resolve(cwd)?;
287 commands::dispatch(cmd, &config, &store)
288}