use std::path::{Path, PathBuf};
use clap::builder::NonEmptyStringValueParser;
use clap::{Args, Parser, Subcommand};
use crate::commands;
use crate::config::Config;
use crate::error::{Result, TixError};
use crate::storage::Store;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExitCode {
Success = 0,
UsageConfig = 1,
NotFoundAmbiguous = 2,
ExternalTool = 3,
}
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 {
Init,
InitAgents,
Create {
#[arg(long)]
title: Option<String>,
#[arg(long)]
body: Option<String>,
#[arg(long)]
status: Option<String>,
#[arg(long)]
priority: Option<String>,
#[arg(long)]
assignee: Option<String>,
#[arg(long)]
labels: Option<String>,
#[arg(long)]
files: Option<String>,
#[arg(long)]
estimate: Option<String>,
#[arg(long)]
cycles: Option<String>,
#[arg(long)]
milestones: Option<String>,
#[arg(long)]
reviewer: Option<String>,
#[arg(long)]
start_date: Option<String>,
#[arg(long)]
due_date: Option<String>,
#[arg(long, value_name = "SELECTOR")]
parent: Option<String>,
},
Link {
#[command(subcommand)]
action: Option<LinkAction>,
from: Option<String>,
to: Option<String>,
#[arg(long, value_name = "KIND")]
r#type: Option<String>,
},
Bulk {
#[command(subcommand)]
action: BulkAction,
},
Unlink {
from: String,
to: String,
#[arg(long, value_name = "KIND")]
r#type: Option<String>,
},
Chain {
issue: String,
},
Parent {
#[command(subcommand)]
action: ParentAction,
},
Edit {
issue: String,
#[arg(long)]
title: Option<String>,
#[arg(long)]
body: Option<String>,
#[arg(long)]
status: Option<String>,
#[arg(long)]
priority: Option<String>,
#[arg(long)]
assignee: Option<String>,
#[arg(long)]
labels: Option<String>,
#[arg(long)]
files: Option<String>,
#[arg(long)]
estimate: Option<String>,
#[arg(long)]
cycles: Option<String>,
#[arg(long)]
milestones: Option<String>,
#[arg(long)]
reviewer: Option<String>,
#[arg(long)]
start_date: Option<String>,
#[arg(long)]
due_date: Option<String>,
#[arg(long)]
commit: Option<String>,
#[arg(long, value_name = "SELECTOR")]
parent: Option<String>,
#[arg(long, value_name = "TEXT")]
reason: Option<String>,
},
Move {
issue: String,
column: String,
#[arg(long, value_name = "HASH")]
commit: Option<String>,
#[arg(long, value_name = "TEXT")]
reason: Option<String>,
#[arg(long)]
yes: bool,
},
#[command(visible_alias = "show")]
View {
issue: String,
#[arg(long, value_name = "relationships")]
show: Option<String>,
},
List {
#[arg(long)]
status: Option<String>,
#[arg(long)]
filter: Option<String>,
#[arg(long)]
with_archived: bool,
#[arg(long)]
sort: Option<String>,
#[arg(long)]
group: Option<String>,
#[arg(
long,
value_name = "num,title,status,priority,cycle,milestone,reviewer,start,due,overdue"
)]
columns: Option<String>,
#[arg(long, value_name = "SHOW")]
show: Option<String>,
},
Workflow {
#[command(subcommand)]
action: WorkflowAction,
},
Status {
#[arg(long)]
filter: Option<String>,
#[arg(long)]
sort: Option<String>,
#[arg(long)]
group: Option<String>,
},
Triage {
#[command(subcommand)]
action: TriageAction,
},
Delete {
issue: String,
#[arg(long)]
yes: bool,
},
Archive {
#[arg(required = true, value_name = "ISSUE")]
issues: Vec<String>,
},
Restore {
#[arg(required = true, value_name = "ISSUE")]
issues: Vec<String>,
},
Cycle {
#[command(subcommand)]
command: CycleCommand,
},
Milestone {
#[command(subcommand)]
command: MilestoneCommand,
},
Export {
#[arg(long, value_name = "gitlab|linear|ics")]
target: String,
#[arg(long)]
dry_run: bool,
#[arg(long)]
project: Option<String>,
#[arg(long)]
cycle: Option<String>,
#[arg(long)]
output: Option<String>,
},
Import {
#[arg(long, value_name = "gitlab|linear")]
target: String,
#[arg(long, value_name = "entry|remote")]
triage_policy: Option<String>,
},
Rollover {
#[arg(long)]
cycle: Option<String>,
#[arg(long)]
yes: bool,
},
Search {
#[arg(value_name = "STRING", value_parser = NonEmptyStringValueParser::new())]
string: String,
#[arg(long)]
with_archived: bool,
},
Render,
New,
Lint,
Mcp,
}
#[derive(Subcommand)]
pub enum TriageAction {
List {
#[arg(long)]
age: bool,
#[arg(long)]
count: bool,
},
Status,
Accept {
issue: String,
#[arg(long)]
reason: Option<String>,
#[arg(long, value_name = "COLUMN")]
to: Option<String>,
#[arg(long)]
actor: Option<String>,
},
Decline {
issue: String,
#[arg(long)]
reason: Option<String>,
#[arg(long)]
actor: Option<String>,
},
Duplicate {
issue: String,
#[arg(long, value_name = "ISSUE")]
of: String,
#[arg(long)]
reason: Option<String>,
#[arg(long)]
actor: Option<String>,
},
Defer {
issue: String,
#[arg(long)]
reason: Option<String>,
#[arg(long)]
actor: Option<String>,
},
Batch {
#[arg(long, value_name = "LIST")]
select: Option<String>,
#[arg(long, value_name = "QUERY")]
filter: Option<String>,
#[arg(long, value_name = "KIND")]
decision: String,
#[arg(long)]
reason: Option<String>,
#[arg(long)]
actor: Option<String>,
#[arg(long, value_name = "ISSUE")]
of: Option<String>,
#[arg(long, value_name = "COLUMN")]
to: Option<String>,
#[arg(long)]
apply: bool,
},
}
#[derive(Subcommand)]
pub enum CycleCommand {
Create {
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
#[arg(long = "start-date")]
start_date: Option<String>,
#[arg(long = "due-date")]
due_date: Option<String>,
},
List,
Edit {
#[arg(long)]
name: String,
#[arg(long = "new-name")]
new_name: Option<String>,
#[arg(long)]
description: Option<String>,
#[arg(long = "start-date")]
start_date: Option<String>,
#[arg(long = "due-date")]
due_date: Option<String>,
},
Delete {
#[arg(long)]
name: String,
#[arg(long)]
yes: bool,
},
}
#[derive(Subcommand)]
pub enum MilestoneCommand {
Create {
#[arg(long)]
name: String,
#[arg(long = "target-date")]
target_date: Option<String>,
#[arg(long)]
description: Option<String>,
},
List,
Edit {
#[arg(long)]
name: String,
#[arg(long = "new-name")]
new_name: Option<String>,
#[arg(long = "target-date")]
target_date: Option<String>,
#[arg(long)]
description: Option<String>,
},
Delete {
#[arg(long)]
name: String,
#[arg(long)]
yes: bool,
},
}
#[derive(Debug, Clone, Subcommand)]
pub enum ParentAction {
Show {
issue: String,
},
Children {
issue: String,
},
List,
}
#[derive(Subcommand)]
pub enum WorkflowAction {
Categories,
Show {
board: Option<String>,
},
Transitions,
}
#[derive(Subcommand)]
pub enum LinkAction {
Edit {
id: String,
#[arg(long)]
to: Option<String>,
#[arg(long, value_name = "KIND")]
r#type: Option<String>,
},
Remove {
id: String,
},
List {
issue: Option<String>,
#[arg(long, value_name = "KIND")]
kind: Option<String>,
},
}
#[derive(Args, Debug, Clone, PartialEq, Eq)]
pub struct BulkSelection {
#[arg(long, value_name = "LIST")]
pub select: Option<String>,
#[arg(long, value_name = "QUERY")]
pub filter: Option<String>,
#[arg(long, value_name = "QUERY")]
pub search: Option<String>,
#[arg(long, value_name = "SCOPE")]
pub scope: Option<String>,
#[arg(long)]
pub apply: bool,
}
impl BulkAction {
pub fn selection(&self) -> &BulkSelection {
match self {
Self::Edit { selection, .. }
| Self::Link { selection, .. }
| Self::Unlink { selection, .. }
| Self::Archive { selection } => selection,
}
}
pub fn is_apply(&self) -> bool {
self.selection().apply
}
}
#[derive(Subcommand, Debug, Clone, PartialEq, Eq)]
pub enum BulkAction {
Edit {
#[command(flatten)]
selection: BulkSelection,
#[arg(long, value_name = "COLUMN")]
status: Option<String>,
#[arg(long, value_name = "PRIORITY")]
priority: Option<String>,
#[arg(long, value_name = "ASSIGNEE")]
assignee: Option<String>,
#[arg(long, value_name = "LABELS")]
labels: Option<String>,
#[arg(long, value_name = "DATE")]
start_date: Option<String>,
#[arg(long, value_name = "DATE")]
due_date: Option<String>,
#[arg(long, value_name = "ESTIMATE")]
estimate: Option<String>,
#[arg(long, value_name = "CYCLES")]
cycles: Option<String>,
#[arg(long, value_name = "MILESTONES")]
milestones: Option<String>,
#[arg(long, value_name = "FIELD")]
clear: Option<String>,
},
Link {
#[command(flatten)]
selection: BulkSelection,
#[arg(long, value_name = "SELECTOR")]
target: String,
#[arg(long, value_name = "KIND")]
r#type: Option<String>,
},
Unlink {
#[command(flatten)]
selection: BulkSelection,
#[arg(long, value_name = "SELECTOR")]
target: String,
#[arg(long, value_name = "KIND")]
r#type: Option<String>,
},
Archive {
#[command(flatten)]
selection: BulkSelection,
},
}
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
}
},
Some(Command::Render) => match run_render(&cwd) {
Ok(()) => ExitCode::Success as u8,
Err(e) => {
eprintln!("tix: {e}");
exit_code_for(&e) as u8
}
},
Some(Command::Bulk { action }) => {
let result = resolve(&cwd).and_then(|(_, store)| {
commands::bulk::run(
&store,
action,
action.selection(),
if action.is_apply() {
commands::bulk::Mode::Apply
} else {
commands::bulk::Mode::Preview
},
)
});
match result {
Ok(code) => code as u8,
Err(e) => {
eprintln!("tix: {e}");
exit_code_for(&e) as u8
}
}
}
Some(Command::Triage { action }) => {
let result = resolve(&cwd).and_then(|(_, store)| commands::triage::run(&store, action));
match result {
Ok(code) => code as u8,
Err(e) => {
eprintln!("tix: {e}");
exit_code_for(&e) as u8
}
}
}
Some(Command::Archive { issues }) => {
route_archive(&cwd, commands::archive::Action::Archive, issues)
}
Some(Command::Restore { issues }) => {
route_archive(&cwd, commands::archive::Action::Restore, issues)
}
_ => {
let result = match &cli.command {
None => run_tui(&cwd),
Some(Command::New) => run_tui_new(&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
}
}
}
}
}
fn route_archive(cwd: &Path, action: commands::archive::Action, issues: &[String]) -> u8 {
match resolve(cwd).and_then(|(_, store)| commands::archive::run(&store, action, issues)) {
Ok(code) => code as u8,
Err(e) => {
eprintln!("tix: {e}");
exit_code_for(&e) as u8
}
}
}
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
}
}
}
fn run_tui(cwd: &Path) -> Result<()> {
let config = repo_config(cwd)?;
crate::app::run(&config)
}
fn run_tui_new(cwd: &Path) -> Result<()> {
let config = repo_config(cwd)?;
crate::app::run_new_issue(&config)
}
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)
}
fn run_mcp(cwd: &Path) -> Result<()> {
let config = repo_config(cwd)?;
let store = Store::open(&config)?;
let mut config = config;
if config.milestones_dir().is_relative() {
let root = Config::project_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
let configured = config.milestones_dir();
config.milestones_dir = Some(root.join(configured));
}
crate::mcp::serve(&config, &store)
}
fn run_render(cwd: &Path) -> Result<()> {
let config = repo_config(cwd)?;
let store = Store::open(&config)?;
commands::dispatch(&Command::Render, &config, &store)
}
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(),
};
if config.issues_dir.is_relative() {
let base = root.as_deref().unwrap_or(cwd);
config.issues_dir = base.join(&config.issues_dir);
}
if config.milestones_dir().is_relative() {
let base = root.as_deref().unwrap_or(cwd);
let configured = config.milestones_dir();
config.milestones_dir = Some(base.join(configured));
}
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)?;
if !matches!(cmd, Command::Rollover { .. }) {
let planner = crate::planning::Planner::from_config(config.clone(), cwd.to_path_buf());
let plan = planner.rollover_plan(&store, &crate::model::today());
if let Some(first) = plan.first() {
eprintln!(
"hint: rollover of {} issue(s) from \"{}\" to \"{}\" is suggested; run 'tix rollover' to apply",
first.issues.len(),
first.from_cycle,
first.to_cycle
);
}
}
commands::dispatch(cmd, &config, &store)
}