use clap::Parser;
use crate::theme::{AppearanceArg, ThemeArg};
#[derive(Parser, Debug)]
#[command(name = "trv", version, about = "Agent-first TUI code review tool")]
pub struct Cli {
#[arg(conflicts_with_all = ["revisions", "working_tree", "path_filter", "file_path"])]
pub pr: Option<String>,
#[arg(
short = 'l',
long = "list",
conflicts_with_all = ["pr", "revisions", "working_tree", "path_filter", "file_path", "tour", "demo"]
)]
pub list: bool,
#[arg(long, value_enum)]
pub theme: Option<ThemeArg>,
#[arg(long, value_enum)]
pub appearance: Option<AppearanceArg>,
#[arg(long = "stdout")]
pub output_to_stdout: bool,
#[arg(long)]
pub no_update_check: bool,
#[arg(short = 'r', long, conflicts_with = "file_path")]
pub revisions: Option<String>,
#[arg(short = 'w', long = "working-tree", conflicts_with = "file_path")]
pub working_tree: bool,
#[arg(short = 'p', long = "path", conflicts_with = "file_path")]
pub path_filter: Option<String>,
#[arg(long = "file")]
pub file_path: Option<String>,
#[arg(long = "mcp-alongside", conflicts_with = "output_to_stdout")]
pub mcp_alongside: bool,
#[arg(
long = "mcp-socket",
num_args = 0..=1,
default_missing_value = "",
conflicts_with_all = ["output_to_stdout", "mcp_alongside", "attach"]
)]
pub mcp_socket: Option<String>,
#[arg(
long = "attach",
conflicts_with_all = ["pr", "revisions", "working_tree", "path_filter", "file_path", "mcp_alongside", "mcp_socket", "tour", "demo", "list"]
)]
pub attach: Option<String>,
#[arg(
long = "tour",
conflicts_with_all = ["pr", "working_tree", "path_filter", "file_path", "revisions"]
)]
pub tour: Option<String>,
#[arg(
long,
conflicts_with_all = ["pr", "working_tree", "path_filter", "file_path", "revisions", "tour"]
)]
pub demo: bool,
#[arg(long = "completions", value_enum, hide = true)]
pub completions: Option<clap_complete::Shell>,
#[arg(long = "live")]
pub live: bool,
#[arg(long = "no-risk-colors")]
pub no_risk_colors: bool,
#[arg(
long = "session-gc",
conflicts_with_all = ["pr", "revisions", "file_path", "tour", "demo", "list", "mcp_alongside", "mcp_socket", "attach", "working_tree", "path_filter", "live"]
)]
pub session_gc: bool,
#[arg(long = "gc-dry-run", requires = "session_gc")]
pub gc_dry_run: bool,
#[arg(long = "gc-max-age-days", requires = "session_gc")]
pub gc_max_age_days: Option<u64>,
#[arg(long = "gc-max-size-mb", requires = "session_gc")]
pub gc_max_size_mb: Option<u64>,
#[arg(long = "gc-max-count", requires = "session_gc")]
pub gc_max_count: Option<u64>,
#[arg(long = "blind-tests")]
pub blind_tests: bool,
#[arg(long = "spar")]
pub spar: bool,
#[arg(
long = "resume",
num_args = 0..=1,
default_missing_value = "",
conflicts_with_all = ["pr", "revisions", "working_tree", "path_filter", "file_path", "tour", "demo", "list", "attach"]
)]
pub resume: Option<String>,
#[arg(long = "alias")]
pub alias: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
Cli::try_parse_from(args)
}
#[test]
fn should_parse_theme_when_provided() {
let cli = parse(&["trv", "--theme", "light"]).expect("parse should succeed");
assert_eq!(cli.theme, Some(ThemeArg::Light));
}
#[test]
fn should_parse_catppuccin_themes() {
let cli = parse(&["trv", "--theme", "catppuccin-mocha"]).expect("parse should succeed");
assert_eq!(cli.theme, Some(ThemeArg::CatppuccinMocha));
let cli = parse(&["trv", "--theme=catppuccin-latte"]).expect("parse should succeed");
assert_eq!(cli.theme, Some(ThemeArg::CatppuccinLatte));
}
#[test]
fn should_parse_ayu_light_theme() {
let cli = parse(&["trv", "--theme", "ayu-light"]).expect("parse should succeed");
assert_eq!(cli.theme, Some(ThemeArg::AyuLight));
}
#[test]
fn should_parse_onedark_theme() {
let cli = parse(&["trv", "--theme", "onedark"]).expect("parse should succeed");
assert_eq!(cli.theme, Some(ThemeArg::Onedark));
}
#[test]
fn should_parse_gruvbox_themes() {
let cli = parse(&["trv", "--theme", "gruvbox-dark"]).expect("parse should succeed");
assert_eq!(cli.theme, Some(ThemeArg::GruvboxDark));
let cli = parse(&["trv", "--theme=gruvbox-light"]).expect("parse should succeed");
assert_eq!(cli.theme, Some(ThemeArg::GruvboxLight));
}
#[test]
fn should_leave_theme_none_when_not_provided() {
let cli = parse(&["trv"]).expect("parse should succeed");
assert_eq!(cli.theme, None);
}
#[test]
fn should_parse_working_tree_short_flag() {
let cli = parse(&["trv", "-w"]).expect("parse should succeed");
assert!(cli.working_tree);
}
#[test]
fn should_parse_working_tree_long_flag() {
let cli = parse(&["trv", "--working-tree"]).expect("parse should succeed");
assert!(cli.working_tree);
}
#[test]
fn should_default_working_tree_to_false() {
let cli = parse(&["trv"]).expect("parse should succeed");
assert!(!cli.working_tree);
}
#[test]
fn should_parse_working_tree_with_revisions() {
let cli = parse(&["trv", "-w", "-r", "HEAD~3..HEAD"]).expect("parse should succeed");
assert!(cli.working_tree);
assert_eq!(cli.revisions, Some("HEAD~3..HEAD".to_string()));
}
#[test]
fn should_error_for_invalid_theme() {
let err = parse(&["trv", "--theme", "nope"]).expect_err("parse should fail");
let msg = err.to_string();
assert!(
msg.contains("nope"),
"error should mention invalid value: {msg}"
);
}
#[test]
fn should_parse_appearance_when_provided() {
let cli = parse(&["trv", "--appearance", "system"]).expect("parse should succeed");
assert_eq!(cli.appearance, Some(AppearanceArg::System));
}
#[test]
fn should_error_for_invalid_appearance() {
let err = parse(&["trv", "--appearance", "nope"]).expect_err("parse should fail");
let msg = err.to_string();
assert!(
msg.contains("nope"),
"error should mention invalid value: {msg}"
);
}
#[test]
fn should_parse_path_short_flag() {
let cli = parse(&["trv", "-p", "src/main.rs"]).expect("parse should succeed");
assert_eq!(cli.path_filter, Some("src/main.rs".to_string()));
}
#[test]
fn should_parse_path_long_flag() {
let cli = parse(&["trv", "--path", "src/"]).expect("parse should succeed");
assert_eq!(cli.path_filter, Some("src/".to_string()));
}
#[test]
fn should_parse_path_equals_syntax() {
let cli = parse(&["trv", "--path=plans/current-plan.md"]).expect("parse should succeed");
assert_eq!(cli.path_filter, Some("plans/current-plan.md".to_string()));
}
#[test]
fn should_default_path_filter_to_none() {
let cli = parse(&["trv"]).expect("parse should succeed");
assert_eq!(cli.path_filter, None);
}
#[test]
fn should_parse_path_with_working_tree() {
let cli = parse(&["trv", "-p", "file.md", "-w"]).expect("parse should succeed");
assert_eq!(cli.path_filter, Some("file.md".to_string()));
assert!(cli.working_tree);
}
#[test]
fn should_parse_path_with_revisions() {
let cli =
parse(&["trv", "--path", "src/", "-r", "HEAD~3.."]).expect("parse should succeed");
assert_eq!(cli.path_filter, Some("src/".to_string()));
assert_eq!(cli.revisions, Some("HEAD~3..".to_string()));
}
#[test]
fn should_reject_pr_with_revisions() {
let err = parse(&["trv", "123", "-r", "HEAD~3"]).expect_err("parse should fail");
let msg = err.to_string();
assert!(
msg.contains("cannot be used with"),
"error should mention conflict: {msg}"
);
}
#[test]
fn should_reject_pr_with_working_tree() {
let err = parse(&["trv", "123", "-w"]).expect_err("parse should fail");
let msg = err.to_string();
assert!(
msg.contains("cannot be used with"),
"error should mention conflict: {msg}"
);
}
#[test]
fn should_reject_pr_with_path_filter() {
let err = parse(&["trv", "123", "-p", "src/"]).expect_err("parse should fail");
let msg = err.to_string();
assert!(
msg.contains("cannot be used with"),
"error should mention conflict: {msg}"
);
}
#[test]
fn should_reject_pr_with_file() {
let err = parse(&["trv", "123", "--file", "a.rs"]).expect_err("parse should fail");
let msg = err.to_string();
assert!(
msg.contains("cannot be used with"),
"error should mention conflict: {msg}"
);
}
#[test]
fn should_reject_file_with_path() {
let err =
parse(&["trv", "--file", "a.rs", "--path", "src/"]).expect_err("parse should fail");
let msg = err.to_string();
assert!(
msg.contains("cannot be used with"),
"error should mention conflict: {msg}"
);
}
#[test]
fn should_reject_file_with_revisions() {
let err =
parse(&["trv", "--file", "a.rs", "-r", "HEAD~3.."]).expect_err("parse should fail");
let msg = err.to_string();
assert!(
msg.contains("cannot be used with"),
"error should mention conflict: {msg}"
);
}
#[test]
fn should_reject_file_with_working_tree() {
let err = parse(&["trv", "--file", "a.rs", "-w"]).expect_err("parse should fail");
let msg = err.to_string();
assert!(
msg.contains("cannot be used with"),
"error should mention conflict: {msg}"
);
}
#[test]
fn should_parse_mcp_alongside() {
let cli = parse(&["trv", "--mcp-alongside"]).expect("parse should succeed");
assert!(cli.mcp_alongside);
}
#[test]
fn should_reject_mcp_alongside_with_stdout() {
let err = parse(&["trv", "--mcp-alongside", "--stdout"]).expect_err("parse should fail");
let msg = err.to_string();
assert!(
msg.contains("cannot be used with"),
"error should mention conflict: {msg}"
);
}
#[test]
fn should_parse_tour_flag() {
let cli = parse(&["trv", "--tour", "HEAD~5..HEAD"]).expect("parse should succeed");
assert_eq!(cli.tour.as_deref(), Some("HEAD~5..HEAD"));
}
#[test]
fn should_reject_tour_with_revisions() {
let err =
parse(&["trv", "--tour", "HEAD~3..", "-r", "HEAD~5.."]).expect_err("parse should fail");
assert!(err.to_string().contains("cannot be used with"));
}
#[test]
fn should_reject_tour_with_pr() {
let err = parse(&["trv", "--tour", "HEAD~3..", "123"]).expect_err("parse should fail");
assert!(err.to_string().contains("cannot be used with"));
}
#[test]
fn should_reject_tour_with_working_tree() {
let err = parse(&["trv", "--tour", "HEAD~3..", "-w"]).expect_err("parse should fail");
assert!(err.to_string().contains("cannot be used with"));
}
#[test]
fn should_parse_demo_flag() {
let cli = parse(&["trv", "--demo"]).expect("parse should succeed");
assert!(cli.demo);
}
#[test]
fn should_default_demo_to_false() {
let cli = parse(&["trv"]).expect("parse should succeed");
assert!(!cli.demo);
}
#[test]
fn should_reject_demo_with_pr() {
let err = parse(&["trv", "--demo", "123"]).expect_err("parse should fail");
assert!(err.to_string().contains("cannot be used with"));
}
#[test]
fn should_reject_demo_with_working_tree() {
let err = parse(&["trv", "--demo", "-w"]).expect_err("parse should fail");
assert!(err.to_string().contains("cannot be used with"));
}
#[test]
fn should_reject_demo_with_path_filter() {
let err = parse(&["trv", "--demo", "-p", "src/"]).expect_err("parse should fail");
assert!(err.to_string().contains("cannot be used with"));
}
#[test]
fn should_reject_demo_with_file() {
let err = parse(&["trv", "--demo", "--file", "a.rs"]).expect_err("parse should fail");
assert!(err.to_string().contains("cannot be used with"));
}
#[test]
fn should_reject_demo_with_revisions() {
let err = parse(&["trv", "--demo", "-r", "HEAD~3.."]).expect_err("parse should fail");
assert!(err.to_string().contains("cannot be used with"));
}
#[test]
fn should_reject_demo_with_tour() {
let err = parse(&["trv", "--demo", "--tour", "HEAD~3.."]).expect_err("parse should fail");
assert!(err.to_string().contains("cannot be used with"));
}
#[test]
fn should_parse_completions_shell() {
let cli = parse(&["trv", "--completions", "zsh"]).expect("parse should succeed");
assert_eq!(cli.completions, Some(clap_complete::Shell::Zsh));
}
#[test]
fn should_default_completions_to_none() {
let cli = parse(&["trv"]).expect("parse should succeed");
assert_eq!(cli.completions, None);
}
#[test]
fn should_default_live_to_false() {
let cli = parse(&["trv"]).expect("parse should succeed");
assert!(!cli.live);
}
#[test]
fn should_parse_live_flag() {
let cli = parse(&["trv", "--live"]).expect("parse should succeed");
assert!(cli.live);
}
#[test]
fn should_default_no_risk_colors_to_false() {
let cli = parse(&["trv"]).expect("parse should succeed");
assert!(!cli.no_risk_colors);
}
#[test]
fn should_parse_no_risk_colors_flag() {
let cli = parse(&["trv", "--no-risk-colors"]).expect("parse should succeed");
assert!(cli.no_risk_colors);
}
#[test]
fn should_parse_session_gc_with_overrides_and_dry_run() {
let cli = parse(&["trv", "--session-gc", "--gc-max-count", "5", "--gc-dry-run"])
.expect("parse should succeed");
assert!(cli.session_gc);
assert!(cli.gc_dry_run);
assert_eq!(cli.gc_max_count, Some(5));
assert_eq!(cli.gc_max_age_days, None);
assert_eq!(cli.gc_max_size_mb, None);
}
#[test]
fn should_default_session_gc_flags_to_false_or_none() {
let cli = parse(&["trv"]).expect("parse should succeed");
assert!(!cli.session_gc);
assert!(!cli.gc_dry_run);
assert_eq!(cli.gc_max_age_days, None);
assert_eq!(cli.gc_max_size_mb, None);
assert_eq!(cli.gc_max_count, None);
}
#[test]
fn should_reject_session_gc_with_pr() {
let err = parse(&["trv", "--session-gc", "123"]).expect_err("parse should fail");
assert!(
err.to_string().contains("cannot be used with"),
"error should mention conflict: {err}"
);
}
#[test]
fn should_reject_session_gc_with_tui_session_flags() {
for combo in [
&["trv", "--session-gc", "-w"][..],
&["trv", "--session-gc", "-p", "src/"][..],
&["trv", "--session-gc", "--live"][..],
] {
let err = parse(combo).expect_err("parse should fail");
assert!(
err.to_string().contains("cannot be used with"),
"error should mention conflict for {combo:?}: {err}"
);
}
}
#[test]
fn should_reject_gc_dry_run_without_session_gc() {
let err = parse(&["trv", "--gc-dry-run"]).expect_err("parse should fail");
let msg = err.to_string();
assert!(
msg.contains("--session-gc"),
"error should mention the --session-gc requirement: {msg}"
);
}
#[test]
fn should_reject_gc_max_count_without_session_gc() {
let err = parse(&["trv", "--gc-max-count", "5"]).expect_err("parse should fail");
assert!(err.to_string().contains("--session-gc"));
}
#[test]
fn generate_manpage_from_clap_command() {
use clap::CommandFactory;
let cmd = Cli::command();
let man = clap_mangen::Man::new(cmd);
let mut buf: Vec<u8> = Vec::new();
man.render(&mut buf).expect("render man page");
let text = String::from_utf8(buf).expect("man page is valid UTF-8");
assert!(!text.is_empty(), "man page must not be empty");
assert!(
text.contains(".TH"),
"man page must carry a title-header directive (.TH)"
);
assert!(
text.contains("trv"),
"man page must reference the binary name"
);
assert!(
text.contains("NAME") || text.contains(".SH"),
"man page must have section headers"
);
if std::env::var("TRV_GEN_MANPAGE").as_deref() == Ok("1") {
let target = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.map(|repo| repo.join("docs").join("trv.1.gen"))
.expect("resolve docs/trv.1.gen path");
std::fs::write(&target, &text).expect("write generated man page to docs/trv.1.gen");
eprintln!("trv: wrote man page to {}", target.display());
}
}
#[test]
fn should_parse_blind_tests_flag() {
let cli = parse(&["trv", "--blind-tests"]).expect("parse should succeed");
assert!(cli.blind_tests);
}
#[test]
fn should_default_blind_tests_to_false() {
let cli = parse(&["trv"]).expect("parse should succeed");
assert!(!cli.blind_tests);
}
#[test]
fn should_parse_spar_flag() {
let cli = parse(&["trv", "--spar"]).expect("parse should succeed");
assert!(cli.spar);
}
#[test]
fn should_default_spar_to_false() {
let cli = parse(&["trv"]).expect("parse should succeed");
assert!(!cli.spar);
}
#[test]
fn should_parse_bare_resume_to_empty_sentinel() {
let cli = parse(&["trv", "--resume"]).expect("parse should succeed");
assert_eq!(cli.resume.as_deref(), Some(""));
}
#[test]
fn should_parse_resume_with_token() {
let cli = parse(&["trv", "--resume=my-tour"]).expect("parse should succeed");
assert_eq!(cli.resume.as_deref(), Some("my-tour"));
let cli = parse(&["trv", "--resume", "a1b2c3d4"]).expect("parse should succeed");
assert_eq!(cli.resume.as_deref(), Some("a1b2c3d4"));
}
#[test]
fn should_default_resume_to_none() {
let cli = parse(&["trv"]).expect("parse should succeed");
assert_eq!(cli.resume, None);
}
#[test]
fn should_parse_alias() {
let cli = parse(&["trv", "--alias", "nightly-review"]).expect("parse should succeed");
assert_eq!(cli.alias.as_deref(), Some("nightly-review"));
}
#[test]
fn should_allow_resume_with_alias() {
let cli = parse(&["trv", "--resume", "old", "--alias", "new"])
.expect("resume + alias must coexist");
assert_eq!(cli.resume.as_deref(), Some("old"));
assert_eq!(cli.alias.as_deref(), Some("new"));
}
#[test]
fn should_reject_resume_with_pr() {
assert!(parse(&["trv", "--resume", "x", "123"]).is_err());
}
#[test]
fn should_reject_resume_with_working_tree() {
assert!(parse(&["trv", "--resume", "-w"]).is_err());
}
#[test]
fn should_reject_resume_with_tour() {
assert!(parse(&["trv", "--resume", "x", "--tour", "HEAD~3.."]).is_err());
}
#[test]
fn should_reject_resume_with_demo() {
assert!(parse(&["trv", "--resume", "--demo"]).is_err());
}
}