tix-tracker 0.3.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The clap command tree, the frozen exit-code scheme, and the process
//! boundary that resolves config, opens the store and hands off to the
//! per-command handlers.
//!
//! The command tree and every long flag mirror `contracts/cli.md` exactly;
//! the exit-code table there is frozen: `0` success, `1` usage/config/
//! unknown-error, `2` target-not-found or selection-ambiguous, `3` external
//! tool missing (`glab`/`linear-cli`, reserved for WP05). Handlers return
//! [`Result<(), TixError>`]; [`exit_code_for`] maps the typed error surface
//! to the exit-code enum at the boundary. Human-readable errors go to
//! stderr; stdout stays clean for the parseability contract.

use std::path::{Path, PathBuf};

use clap::builder::NonEmptyStringValueParser;
use clap::{Parser, Subcommand};

use crate::commands;
use crate::config::Config;
use crate::error::{Result, TixError};
use crate::storage::Store;

/// Exit codes frozen by `contracts/cli.md`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExitCode {
    Success = 0,
    UsageConfig = 1,
    NotFoundAmbiguous = 2,
    ExternalTool = 3,
}

/// Map a domain error to the frozen exit-code table.
///
/// Selection ambiguity shares exit `2` with target-not-found, per the
/// contract's exit-code table ("2 target-not-found or selection-ambiguous");
/// the ambiguity marker is the [`commands::AMBIGUITY_PREFIX`] message prefix.
pub fn exit_code_for(err: &TixError) -> ExitCode {
    match err {
        TixError::NotFound(_) => ExitCode::NotFoundAmbiguous,
        TixError::InvalidInput(s) if s.starts_with(commands::AMBIGUITY_PREFIX) => {
            ExitCode::NotFoundAmbiguous
        }
        TixError::External(_) => ExitCode::ExternalTool,
        _ => ExitCode::UsageConfig,
    }
}

#[derive(Parser)]
#[command(
    name = "tix",
    version,
    about = "Terminal issue tracker: markdown issues with a kanban TUI and CLI"
)]
pub struct Cli {
    #[command(subcommand)]
    pub command: Option<Command>,
}

#[derive(Subcommand)]
pub enum Command {
    /// Create a default tix.toml ([kanban.default], issues_dir, [export])
    Init,
    /// Append or replace the tix block in AGENTS.md (idempotent)
    InitAgents,
    /// Create an issue
    Create {
        /// Issue title (prompted when missing on a TTY)
        #[arg(long)]
        title: Option<String>,
        /// Issue body (markdown)
        #[arg(long)]
        body: Option<String>,
        /// Column name (default: first column)
        #[arg(long)]
        status: Option<String>,
        /// Priority: low | medium | high (default medium)
        #[arg(long)]
        priority: Option<String>,
        /// Assignee (@name)
        #[arg(long)]
        assignee: Option<String>,
        /// Labels, comma-separated
        #[arg(long)]
        labels: Option<String>,
        /// Links to existing issues, comma-separated
        #[arg(long)]
        links: Option<String>,
        /// Files governing this issue, comma-separated
        #[arg(long)]
        files: Option<String>,
        /// Estimate
        #[arg(long)]
        estimate: Option<String>,
    },
    /// Link two issues symmetrically
    Link {
        /// Issue number, filename fragment or unique title fragment
        a: String,
        /// Issue number, filename fragment or unique title fragment
        b: String,
    },
    /// Remove a symmetric link between two issues
    Unlink {
        /// Issue number, filename fragment or unique title fragment
        a: String,
        /// Issue number, filename fragment or unique title fragment
        b: String,
    },
    /// Edit an issue
    Edit {
        /// Issue number, filename fragment or unique title fragment
        issue: String,
        /// New title
        #[arg(long)]
        title: Option<String>,
        /// New body (markdown)
        #[arg(long)]
        body: Option<String>,
        /// New status
        #[arg(long)]
        status: Option<String>,
        /// New priority: low | medium | high
        #[arg(long)]
        priority: Option<String>,
        /// New assignee (@name)
        #[arg(long)]
        assignee: Option<String>,
        /// New label list, comma-separated
        #[arg(long)]
        labels: Option<String>,
        /// New link list, comma-separated
        #[arg(long)]
        links: Option<String>,
        /// New files list, comma-separated
        #[arg(long)]
        files: Option<String>,
        /// New estimate
        #[arg(long)]
        estimate: Option<String>,
        /// Commit hash to record (or `clear` to remove it)
        #[arg(long)]
        commit: Option<String>,
    },
    /// Move an issue to a column
    Move {
        /// Issue number, filename fragment or unique title fragment
        issue: String,
        /// Target column
        column: String,
        /// Commit hash to record (optional; recorded when moving to done)
        #[arg(long, value_name = "HASH")]
        commit: Option<String>,
    },
    /// Print the full issue (frontmatter + body) to stdout
    #[command(visible_alias = "show")]
    View {
        /// Issue number, filename fragment or unique title fragment
        issue: String,
    },
    /// Table of issues
    List {
        /// Only issues in this status
        #[arg(long)]
        status: Option<String>,
    },
    /// Board summary: count per column + total
    Status,
    /// Delete an issue file
    Delete {
        /// Issue number, filename fragment or unique title fragment
        issue: String,
        /// Confirm without prompting
        #[arg(long)]
        yes: bool,
    },
    /// Export local issues to an external tracker via glab/linear-cli
    Export {
        /// Target tracker: gitlab | linear
        #[arg(long, value_name = "gitlab|linear")]
        target: String,
        /// Print what would change without invoking the remote tracker
        #[arg(long)]
        dry_run: bool,
        /// Override the configured project path (gitlab) or team (linear)
        #[arg(long)]
        project: Option<String>,
    },
    /// Import open issues from an external tracker via glab/linear-cli
    Import {
        /// Target tracker: gitlab | linear
        #[arg(long, value_name = "gitlab|linear")]
        target: String,
    },
    /// Search issue files for a string (case-insensitive substring over full markdown)
    Search {
        /// Case-insensitive substring to match against the full markdown of every issue
        #[arg(value_name = "STRING", value_parser = NonEmptyStringValueParser::new())]
        string: String,
    },
    /// Validate frontmatter option values in all issue files
    Lint,
    /// Serve the MCP protocol (JSON-RPC 2.0 over stdio) for agent tools
    Mcp,
}

/// Process entry: route a parsed [`Cli`] through config resolution, store
/// opening and dispatch, then return the exit code for `main` to exit with.
pub fn run(cli: Cli) -> u8 {
    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
    match &cli.command {
        Some(Command::Lint) => run_lint(&cwd),
        Some(Command::Mcp) => match run_mcp(&cwd) {
            Ok(()) => ExitCode::Success as u8,
            Err(e) => {
                eprintln!("tix: {e}");
                exit_code_for(&e) as u8
            }
        },
        _ => {
            let result = match &cli.command {
                None => run_tui(&cwd),
                Some(Command::Init) => commands::init::run(&cwd),
                Some(Command::InitAgents) => {
                    let root = Config::project_root(&cwd).unwrap_or(cwd.clone());
                    commands::agents::run(&root).map(|_| ())
                }
                Some(cmd) => run_subcommand(&cwd, cmd),
            };
            match result {
                Ok(()) => ExitCode::Success as u8,
                Err(e) => {
                    eprintln!("tix: {e}");
                    exit_code_for(&e) as u8
                }
            }
        }
    }
}

/// `tix lint` owns its own boundary: its exit-code table (`0` healthy, `1`
/// findings, `2` usage/internal) does not match the generic
/// [`exit_code_for`] mapping, which would collapse internal errors into `1`.
fn run_lint(cwd: &Path) -> u8 {
    let (config, store) = match resolve(cwd) {
        Ok(pair) => pair,
        Err(e) => {
            eprintln!("tix: {e}");
            return 2;
        }
    };
    match commands::lint::run(&config, &store) {
        Ok(true) => 1,
        Ok(false) => 0,
        Err(e) => {
            eprintln!("tix: {e}");
            2
        }
    }
}

/// `tix` with no subcommand: launch the TUI, or fail with the contract's
/// exact message when no `tix.toml` is reachable (exit 1).
fn run_tui(cwd: &Path) -> Result<()> {
    let config = repo_config(cwd)?;
    crate::app::run(&config)
}

/// Strict repository resolution shared by the TUI and `tix mcp` (FR-015):
/// upward `tix.toml` discovery, failing with the contract's exact `no
/// tix.toml found` guidance (exit 1) when no repository is reachable —
/// never a guessed default store. This is the "CLI hot path" that
/// `contracts/mcp-protocol.md` §7 requires `tix mcp` to reuse.
fn repo_config(cwd: &Path) -> Result<Config> {
    let root = Config::project_root(cwd)
        .ok_or_else(|| TixError::Config("no tix.toml found — run \"tix init\" first".into()))?;
    Config::load(&root)
}

/// `tix mcp`: strict repository resolution (same hot path as `tix` with no
/// subcommand), then serve the MCP protocol synchronously over stdio. Outside
/// a repository the process fails with exit 1 BEFORE reading any protocol
/// bytes (FR-016, `contracts/mcp-protocol.md` §7).
fn run_mcp(cwd: &Path) -> Result<()> {
    let config = repo_config(cwd)?;
    let store = Store::open(&config)?;
    crate::mcp::serve(&config, &store)
}

/// Config resolution for subcommands: defaults when no `tix.toml` exists
/// (relative to the project root via upward walk), with a one-time stderr
/// notice. Only explicit per-command requirements fail hard.
fn resolve(cwd: &Path) -> Result<(Config, Store)> {
    let root = Config::project_root(cwd);
    let mut config = match &root {
        Some(r) => Config::load(r)?,
        None => Config::default(),
    };
    // Resolve a relative issues_dir against the project root (or cwd when
    // unconfigured) so subcommands behave identically from any subdirectory.
    if config.issues_dir.is_relative() {
        let base = root.as_deref().unwrap_or(cwd);
        config.issues_dir = base.join(&config.issues_dir);
    }
    if root.is_none() {
        eprintln!("tix: no tix.toml found (run 'tix init' to create one)");
    }
    let store = Store::open(&config)?;
    Ok((config, store))
}

fn run_subcommand(cwd: &Path, cmd: &Command) -> Result<()> {
    let (config, store) = resolve(cwd)?;
    commands::dispatch(cmd, &config, &store)
}