use clap::{Parser, Subcommand};
use std::process;
const VERSION: &str = env!("PLANR_VERSION");
mod abandon;
mod board;
mod claim;
mod close_cmd;
mod git;
mod lint;
mod lock;
mod new_cmd;
mod parse;
mod review;
mod ticket;
pub fn fail(msg: &str) -> ! {
eprintln!("{msg}");
process::exit(1);
}
fn read_abandon_message(input: Option<&str>) -> Result<String, String> {
match input {
Some(s) if s != "-" => Ok(s.to_string()),
_ => {
use std::io::Read;
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(|e| format!("cannot read message from stdin: {e}"))?;
Ok(buf.trim().to_string())
}
}
}
#[derive(Parser)]
#[command(
name = "planr",
version = VERSION,
about = "Trunk-based backlog management for multi-agent development",
disable_help_subcommand = true
)]
struct Cli {
#[command(subcommand)]
command: Command,
#[arg(
short = 'D',
long,
env = "PLANR_DIR",
default_value = ".plan",
global = true
)]
plan_dir: String,
#[arg(
short = 't',
long,
env = "PLANR_TRUNK",
default_value = "main",
global = true
)]
trunk: String,
}
#[derive(Subcommand)]
enum Command {
Board {
r#ref: Option<String>,
},
Lint {
r#ref: Option<String>,
},
New {
kind: String,
slug: String,
title: String,
parent_slug: Option<String>,
},
Claim {
slug: String,
trunk_override: Option<String>,
#[arg(
long = "worktree",
num_args = 0..=1,
default_missing_value = ""
)]
worktree: Option<String>,
#[arg(long, conflicts_with = "worktree")]
no_worktree: bool,
},
Review {
slug: String,
},
Abandon {
kind: String,
slug: String,
message: Option<String>,
},
Close {
kind: String,
slug: String,
},
}
fn main() {
let cli = Cli::parse();
match cli.command {
Command::Board { r#ref } => {
let tickets = match r#ref {
Some(ref_) if ref_.is_empty() => board::read_working_tree_tickets(&cli.plan_dir),
Some(ref_) => board::read_ref_tickets(&ref_, &cli.plan_dir),
None => board::read_ref_tickets(&cli.trunk, &cli.plan_dir),
};
let branches = board::read_in_flight_branches(&cli.plan_dir);
let input = board::BoardInput {
trunk_tickets: tickets,
branch_statuses: branches,
};
let out = board::render_board(&input);
if !out.is_empty() {
print!("{out}");
}
}
Command::Lint { r#ref } => {
let report = match r#ref {
Some(ref_) if !ref_.is_empty() => lint::lint_ref(&ref_, &cli.plan_dir),
_ => lint::lint_working_tree(&cli.plan_dir),
};
let out = lint::render_report(&report);
if !out.is_empty() {
print!("{out}");
}
if report.error_count > 0 {
process::exit(1);
}
}
Command::New {
kind,
slug,
title,
parent_slug,
} => {
match new_cmd::create_ticket(
&kind,
&slug,
&title,
parent_slug.as_deref(),
&cli.plan_dir,
) {
Ok(relative_path) => {
println!("{relative_path}");
let findings = new_cmd::lint_findings(&cli.plan_dir);
if !findings.is_empty() {
eprint!("{findings}");
}
}
Err(e) => fail(&e),
}
}
Command::Claim {
slug,
trunk_override,
worktree,
no_worktree,
} => {
let trunk = trunk_override.as_deref().unwrap_or(&cli.trunk);
let wt = if no_worktree { None } else { worktree };
match claim::claim_task(&slug, trunk, &cli.plan_dir, wt, std::path::Path::new(".")) {
Ok(out) => println!("{out}"),
Err(e) => fail(&e),
}
}
Command::Review { slug } => {
match review::generate_review_brief(&slug, &cli.trunk, &cli.plan_dir) {
Ok(brief) => print!("{brief}"),
Err(e) => fail(&e),
}
}
Command::Abandon {
kind,
slug,
message,
} => {
let msg = match read_abandon_message(message.as_deref()) {
Ok(m) => m,
Err(e) => fail(&e),
};
match abandon::abandon_ticket(
&kind,
&slug,
&msg,
&cli.trunk,
&cli.plan_dir,
std::path::Path::new("."),
) {
Ok(out) => println!("{out}"),
Err(e) => fail(&e),
}
}
Command::Close { kind, slug } => {
let result = match kind.as_str() {
"task" => close_cmd::close_task(
&slug,
&cli.trunk,
&cli.plan_dir,
std::path::Path::new("."),
),
"story" => close_cmd::close_story(
&slug,
&cli.trunk,
&cli.plan_dir,
std::path::Path::new("."),
),
"epic" => close_cmd::close_epic(
&slug,
&cli.trunk,
&cli.plan_dir,
std::path::Path::new("."),
),
_ => Err(format!("unknown kind: {kind} (want task|story|epic)")),
};
match result {
Ok(msg) => println!("{msg}"),
Err(e) => fail(&e),
}
}
}
}