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::{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}