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