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        /// 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}