use std::path::PathBuf;
use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum};
use crate::theme::{AppearanceArg, ThemeArg};
#[derive(Debug, Clone, Default)]
pub struct CliArgs {
pub theme: Option<String>,
pub appearance: Option<AppearanceArg>,
pub output_to_stdout: bool,
pub no_update_check: bool,
pub revisions: Option<String>,
pub working_tree: bool,
pub path_filter: Option<String>,
pub file_path: Option<String>,
pub all_files: bool,
pub pr_target: Option<String>,
pub repo_url: Option<String>,
pub review_command: Option<ReviewCommand>,
pub update_command: bool,
pub update_version: Option<semver::Version>,
}
#[derive(Parser, Debug)]
#[command(
name = "tuicr",
version,
about = "A code review TUI with vim keybindings. Export to GitHub or clipboard.",
after_help = "Press ? in the application for keybinding help.",
disable_help_subcommand = true
)]
struct Cli {
#[command(flatten)]
tui_options: TuiOptions,
#[command(subcommand)]
command: Option<Subcmd>,
}
#[derive(Args, Debug, Clone, Default)]
struct TuiOptions {
#[arg(
short = 'r',
long = "revisions",
value_name = "REVSET",
allow_hyphen_values = true
)]
revisions: Option<String>,
#[arg(long, value_name = "THEME", value_parser = non_empty_theme_name)]
theme: Option<String>,
#[arg(long, value_name = "MODE", value_parser = parse_appearance_arg)]
appearance: Option<AppearanceArg>,
#[arg(
short = 'p',
long = "path",
value_name = "PATH",
value_parser = non_empty_path,
conflicts_with_all = ["file_path", "all_files"],
)]
path_filter: Option<String>,
#[arg(
short = 'w',
long = "working-tree",
action = ArgAction::SetTrue,
conflicts_with_all = ["file_path", "all_files"],
)]
working_tree: bool,
#[arg(
long = "file",
value_name = "PATH",
value_parser = non_empty_path,
conflicts_with_all = ["path_filter", "revisions", "working_tree", "all_files"],
)]
file_path: Option<String>,
#[arg(
short = 'A',
long = "all-files",
action = ArgAction::SetTrue,
conflicts_with_all = ["path_filter", "revisions", "working_tree", "file_path"],
)]
all_files: bool,
#[arg(long = "stdout", action = ArgAction::SetTrue)]
stdout: bool,
#[arg(long = "no-update-check", action = ArgAction::SetTrue)]
no_update_check: bool,
#[arg(
long = "repo-url",
value_name = "URL",
value_parser = parse_repo_url
)]
repo_url: Option<String>,
}
#[derive(Subcommand, Debug)]
enum Subcmd {
Tui(TuiCommand),
#[command(visible_alias = "mr")]
Pr(PrCommand),
Review {
#[command(subcommand)]
command: ReviewCommand,
},
Update {
#[arg(value_name = "VERSION")]
version: Option<semver::Version>,
},
}
#[derive(Args, Debug, Clone, Default)]
struct TuiCommand {
#[command(flatten)]
options: TuiOptions,
#[command(subcommand)]
command: Option<TuiSubcmd>,
}
#[derive(Subcommand, Debug, Clone)]
enum TuiSubcmd {
#[command(visible_alias = "mr")]
Pr(PrCommand),
}
#[derive(Args, Debug, Clone, Default)]
struct PrCommand {
target: String,
#[command(flatten)]
options: TuiOptions,
}
#[derive(Subcommand, Debug, Clone, PartialEq, Eq)]
pub enum ReviewCommand {
List {
#[arg(long, value_name = "PATH|OWNER/REPO", default_value = ".")]
repo: PathBuf,
#[arg(long)]
all: bool,
},
Add {
#[arg(long, value_name = "SESSION")]
session: String,
#[arg(long, value_name = "JSON|@FILE|-")]
input: Option<String>,
#[arg(long, value_name = "PATH|OWNER/REPO", default_value = ".")]
repo: PathBuf,
#[arg(long = "type", value_name = "TYPE", default_value = "none", value_parser = non_empty_comment_type)]
comment_type: String,
#[arg(long = "target-file", value_name = "PATH")]
file: Option<PathBuf>,
#[arg(long, value_name = "LINE", requires = "file")]
line: Option<u32>,
#[arg(long = "end-line", value_name = "LINE", requires = "line")]
end_line: Option<u32>,
#[arg(long, value_enum, default_value_t = LineSideArg::New)]
side: LineSideArg,
#[arg(long, value_name = "NAME")]
username: Option<String>,
#[arg(
value_name = "COMMENT",
required_unless_present = "input",
value_parser = non_empty_comment_text,
allow_hyphen_values = true
)]
content: Option<String>,
},
#[command(alias = "get")]
Comments {
#[arg(long, value_name = "SESSION")]
session: String,
#[arg(long, value_name = "PATH|OWNER/REPO", default_value = ".")]
repo: PathBuf,
},
}
#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LineSideArg {
Old,
#[default]
New,
}
impl From<Cli> for CliArgs {
fn from(cli: Cli) -> Self {
let (options, pr_target, review_command, update_version, update_command) = match cli.command
{
Some(Subcmd::Tui(command)) => match command.command {
Some(TuiSubcmd::Pr(pr)) => (
cli.tui_options.merge(command.options).merge(pr.options),
Some(pr.target),
None,
None,
false,
),
None => (
cli.tui_options.merge(command.options),
None,
None,
None,
false,
),
},
Some(Subcmd::Pr(pr)) => (
cli.tui_options.merge(pr.options),
Some(pr.target),
None,
None,
false,
),
Some(Subcmd::Review { command }) => {
(TuiOptions::default(), None, Some(command), None, false)
}
Some(Subcmd::Update { version }) => (TuiOptions::default(), None, None, version, true),
None => (cli.tui_options, None, None, None, false),
};
Self {
theme: options.theme,
appearance: options.appearance,
output_to_stdout: options.stdout,
no_update_check: options.no_update_check,
revisions: options.revisions,
working_tree: options.working_tree,
path_filter: options.path_filter,
file_path: options.file_path,
all_files: options.all_files,
pr_target,
repo_url: options.repo_url,
review_command,
update_command,
update_version,
}
}
}
impl TuiOptions {
fn has_any_explicit_value(&self) -> bool {
self.theme.is_some()
|| self.appearance.is_some()
|| self.stdout
|| self.no_update_check
|| self.revisions.is_some()
|| self.working_tree
|| self.path_filter.is_some()
|| self.file_path.is_some()
|| self.all_files
|| self.repo_url.is_some()
}
fn merge(self, later: TuiOptions) -> Self {
Self {
theme: later.theme.or(self.theme),
appearance: later.appearance.or(self.appearance),
stdout: self.stdout || later.stdout,
no_update_check: self.no_update_check || later.no_update_check,
revisions: later.revisions.or(self.revisions),
working_tree: self.working_tree || later.working_tree,
path_filter: later.path_filter.or(self.path_filter),
file_path: later.file_path.or(self.file_path),
all_files: self.all_files || later.all_files,
repo_url: later.repo_url.or(self.repo_url),
}
}
}
impl Cli {
fn try_into_args(self) -> std::result::Result<CliArgs, clap::Error> {
match (
self.tui_options.has_any_explicit_value(),
self.non_tui_command_name(),
) {
(true, Some(command_name)) => Err(clap::Error::raw(
clap::error::ErrorKind::ArgumentConflict,
format!(
"TUI options cannot be used with `tuicr {command_name}`; run `tuicr {command_name} --help` for command options"
),
)),
_ => Ok(self.into()),
}
}
fn non_tui_command_name(&self) -> Option<&'static str> {
match self.command {
Some(Subcmd::Review { .. }) => Some("review"),
Some(Subcmd::Update { .. }) => Some("update"),
_ => None,
}
}
}
fn parse_appearance_arg(s: &str) -> Result<AppearanceArg, String> {
AppearanceArg::parse_name(s).ok_or_else(|| {
let valid = AppearanceArg::valid_values_display();
format!("Unknown appearance '{s}'. Valid options: {valid}")
})
}
fn non_empty_theme_name(s: &str) -> Result<String, String> {
if s.is_empty() {
let valid = ThemeArg::valid_values_display();
Err(format!("--theme requires a value ({valid})"))
} else {
Ok(s.to_string())
}
}
fn parse_repo_url(s: &str) -> Result<String, String> {
if crate::forge::github::gh::parse_github_remote_url(s).is_some() {
Ok(s.to_string())
} else {
Err(format!(
"--repo-url value '{s}' is not a recognized GitHub URL. \
Expected forms: https://github.com/owner/repo, git@github.com:owner/repo, \
or ssh://git@github.com/owner/repo"
))
}
}
fn non_empty_path(s: &str) -> Result<String, String> {
if s.is_empty() {
Err("a file or directory path is required".to_string())
} else {
Ok(s.to_string())
}
}
fn non_empty_comment_type(s: &str) -> Result<String, String> {
if s.is_empty() {
Err("a comment type is required".to_string())
} else {
Ok(s.to_string())
}
}
fn non_empty_comment_text(s: &str) -> Result<String, String> {
if s.trim().is_empty() {
Err("comment text cannot be empty".to_string())
} else {
Ok(s.to_string())
}
}
pub fn parse_cli_args() -> CliArgs {
match Cli::parse().try_into_args() {
Ok(args) => args,
Err(err) => err.exit(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::error::ErrorKind;
fn parse_for_test(args: &[&str]) -> Result<CliArgs, clap::Error> {
Cli::try_parse_from(args).and_then(Cli::try_into_args)
}
#[test]
fn should_parse_theme_when_provided() {
let parsed = parse_for_test(&["tuicr", "--theme", "light"]).expect("parse should succeed");
assert_eq!(parsed.theme, Some("light".to_string()));
}
#[test]
fn should_parse_catppuccin_themes() {
let parsed = parse_for_test(&["tuicr", "--theme", "catppuccin-mocha"])
.expect("parse should succeed");
assert_eq!(parsed.theme, Some("catppuccin-mocha".to_string()));
let parsed =
parse_for_test(&["tuicr", "--theme=catppuccin-latte"]).expect("parse should succeed");
assert_eq!(parsed.theme, Some("catppuccin-latte".to_string()));
}
#[test]
fn should_parse_ayu_light_theme() {
let parsed =
parse_for_test(&["tuicr", "--theme", "ayu-light"]).expect("parse should succeed");
assert_eq!(parsed.theme, Some("ayu-light".to_string()));
}
#[test]
fn should_parse_onedark_theme() {
let parsed =
parse_for_test(&["tuicr", "--theme", "onedark"]).expect("parse should succeed");
assert_eq!(parsed.theme, Some("onedark".to_string()));
}
#[test]
fn should_parse_gruvbox_themes() {
let parsed =
parse_for_test(&["tuicr", "--theme", "gruvbox-dark"]).expect("parse should succeed");
assert_eq!(parsed.theme, Some("gruvbox-dark".to_string()));
let parsed =
parse_for_test(&["tuicr", "--theme=gruvbox-light"]).expect("parse should succeed");
assert_eq!(parsed.theme, Some("gruvbox-light".to_string()));
}
#[test]
fn should_parse_everforest_themes() {
let parsed =
parse_for_test(&["tuicr", "--theme", "everforest-dark"]).expect("parse should succeed");
assert_eq!(parsed.theme, Some("everforest-dark".to_string()));
let parsed =
parse_for_test(&["tuicr", "--theme=everforest-light"]).expect("parse should succeed");
assert_eq!(parsed.theme, Some("everforest-light".to_string()));
}
#[test]
fn should_leave_theme_none_when_not_provided() {
let parsed = parse_for_test(&["tuicr"]).expect("parse should succeed");
assert_eq!(parsed.theme, None);
}
#[test]
fn should_parse_working_tree_short_flag() {
let parsed = parse_for_test(&["tuicr", "-w"]).expect("parse should succeed");
assert!(parsed.working_tree);
}
#[test]
fn should_parse_working_tree_long_flag() {
let parsed = parse_for_test(&["tuicr", "--working-tree"]).expect("parse should succeed");
assert!(parsed.working_tree);
}
#[test]
fn should_default_working_tree_to_false() {
let parsed = parse_for_test(&["tuicr"]).expect("parse should succeed");
assert!(!parsed.working_tree);
}
#[test]
fn should_parse_working_tree_with_revisions() {
let parsed =
parse_for_test(&["tuicr", "-w", "-r", "HEAD~3..HEAD"]).expect("parse should succeed");
assert!(parsed.working_tree);
assert_eq!(parsed.revisions, Some("HEAD~3..HEAD".to_string()));
}
#[test]
fn should_allow_custom_theme_name_in_separate_arg() {
let parsed = parse_for_test(&["tuicr", "--theme", "tuicr-teal"])
.expect("custom theme parse should succeed");
assert_eq!(parsed.theme, Some("tuicr-teal".to_string()));
}
#[test]
fn should_allow_custom_theme_name_in_equals_arg() {
let parsed = parse_for_test(&["tuicr", "--theme=tuicr-teal"])
.expect("custom theme parse should succeed");
assert_eq!(parsed.theme, Some("tuicr-teal".to_string()));
}
#[test]
fn should_error_when_theme_value_missing() {
let err = parse_for_test(&["tuicr", "--theme"]).expect_err("parse should fail");
assert_eq!(err.kind(), ErrorKind::InvalidValue);
}
#[test]
fn should_parse_appearance_when_provided() {
let parsed =
parse_for_test(&["tuicr", "--appearance", "system"]).expect("parse should succeed");
assert_eq!(parsed.appearance, Some(AppearanceArg::System));
}
#[test]
fn should_error_for_invalid_appearance() {
let err =
parse_for_test(&["tuicr", "--appearance", "nope"]).expect_err("parse should fail");
assert_eq!(err.kind(), ErrorKind::ValueValidation);
assert!(err.to_string().contains("Unknown appearance 'nope'"));
}
#[test]
fn should_parse_path_short_flag() {
let parsed = parse_for_test(&["tuicr", "-p", "src/main.rs"]).expect("parse should succeed");
assert_eq!(parsed.path_filter, Some("src/main.rs".to_string()));
}
#[test]
fn should_parse_path_long_flag() {
let parsed = parse_for_test(&["tuicr", "--path", "src/"]).expect("parse should succeed");
assert_eq!(parsed.path_filter, Some("src/".to_string()));
}
#[test]
fn should_parse_path_equals_syntax() {
let parsed = parse_for_test(&["tuicr", "--path=plans/current-plan.md"])
.expect("parse should succeed");
assert_eq!(
parsed.path_filter,
Some("plans/current-plan.md".to_string())
);
}
#[test]
fn should_error_when_path_value_missing() {
let err = parse_for_test(&["tuicr", "--path"]).expect_err("parse should fail");
assert_eq!(err.kind(), ErrorKind::InvalidValue);
}
#[test]
fn should_error_when_path_equals_empty() {
let err = parse_for_test(&["tuicr", "--path="]).expect_err("parse should fail");
assert_eq!(err.kind(), ErrorKind::ValueValidation);
}
#[test]
fn should_default_path_filter_to_none() {
let parsed = parse_for_test(&["tuicr"]).expect("parse should succeed");
assert_eq!(parsed.path_filter, None);
}
#[test]
fn should_parse_path_with_working_tree() {
let parsed =
parse_for_test(&["tuicr", "-p", "file.md", "-w"]).expect("parse should succeed");
assert_eq!(parsed.path_filter, Some("file.md".to_string()));
assert!(parsed.working_tree);
}
#[test]
fn should_parse_path_with_revisions() {
let parsed = parse_for_test(&["tuicr", "--path", "src/", "-r", "HEAD~3.."])
.expect("parse should succeed");
assert_eq!(parsed.path_filter, Some("src/".to_string()));
assert_eq!(parsed.revisions, Some("HEAD~3..".to_string()));
}
#[test]
fn should_reject_file_combined_with_path() {
let err = parse_for_test(&["tuicr", "--file", "f.md", "--path", "src/"])
.expect_err("parse should fail");
assert_eq!(err.kind(), ErrorKind::ArgumentConflict);
}
#[test]
fn should_reject_file_combined_with_revisions() {
let err = parse_for_test(&["tuicr", "--file", "f.md", "-r", "HEAD~1.."])
.expect_err("parse should fail");
assert_eq!(err.kind(), ErrorKind::ArgumentConflict);
}
#[test]
fn should_reject_file_combined_with_working_tree() {
let err =
parse_for_test(&["tuicr", "--file", "f.md", "-w"]).expect_err("parse should fail");
assert_eq!(err.kind(), ErrorKind::ArgumentConflict);
}
#[test]
fn should_reject_all_files_combined_with_path() {
let err =
parse_for_test(&["tuicr", "-A", "--path", "src/"]).expect_err("parse should fail");
assert_eq!(err.kind(), ErrorKind::ArgumentConflict);
}
#[test]
fn should_reject_all_files_combined_with_file() {
let err =
parse_for_test(&["tuicr", "-A", "--file", "f.md"]).expect_err("parse should fail");
assert_eq!(err.kind(), ErrorKind::ArgumentConflict);
}
#[test]
fn should_parse_all_files_short_flag() {
let parsed = parse_for_test(&["tuicr", "-A"]).expect("parse should succeed");
assert!(parsed.all_files);
}
#[test]
fn should_parse_all_files_long_flag() {
let parsed = parse_for_test(&["tuicr", "--all-files"]).expect("parse should succeed");
assert!(parsed.all_files);
}
#[test]
fn should_parse_stdout_flag() {
let parsed = parse_for_test(&["tuicr", "--stdout"]).expect("parse should succeed");
assert!(parsed.output_to_stdout);
}
#[test]
fn should_parse_no_update_check_flag() {
let parsed = parse_for_test(&["tuicr", "--no-update-check"]).expect("parse should succeed");
assert!(parsed.no_update_check);
}
#[test]
fn should_parse_update_command_without_tui_options() {
let parsed = parse_for_test(&["tuicr", "update"]).expect("parse should succeed");
assert!(parsed.update_command);
assert_eq!(parsed.update_version, None);
assert_eq!(parsed.review_command, None);
}
#[test]
fn should_parse_specific_update_version() {
let parsed = parse_for_test(&["tuicr", "update", "0.18.0"]).expect("parse should succeed");
assert!(parsed.update_command);
assert_eq!(
parsed.update_version.as_ref().map(ToString::to_string),
Some("0.18.0".to_string())
);
}
#[test]
fn should_reject_invalid_update_version() {
let err = parse_for_test(&["tuicr", "update", "latest"]).expect_err("parse should fail");
assert_eq!(err.kind(), ErrorKind::ValueValidation);
}
#[test]
fn should_reject_tui_options_with_update_command() {
let err =
parse_for_test(&["tuicr", "--theme", "dark", "update"]).expect_err("parse should fail");
assert_eq!(err.kind(), ErrorKind::ArgumentConflict);
}
#[test]
fn should_parse_pr_target_as_bare_number() {
let parsed = parse_for_test(&["tuicr", "pr", "125"]).expect("parse should succeed");
assert_eq!(parsed.pr_target, Some("125".to_string()));
}
#[test]
fn should_parse_mr_alias_like_pr() {
let parsed = parse_for_test(&["tuicr", "mr", "125"]).expect("parse should succeed");
assert_eq!(parsed.pr_target, Some("125".to_string()));
}
#[test]
fn should_parse_tui_mr_alias_like_pr() {
let parsed = parse_for_test(&["tuicr", "tui", "mr", "125"]).expect("parse should succeed");
assert_eq!(parsed.pr_target, Some("125".to_string()));
}
#[test]
fn should_parse_pr_target_as_owner_repo_hash() {
let parsed =
parse_for_test(&["tuicr", "pr", "agavra/tuicr#125"]).expect("parse should succeed");
assert_eq!(parsed.pr_target, Some("agavra/tuicr#125".to_string()));
}
#[test]
fn should_parse_pr_target_as_full_url() {
let parsed = parse_for_test(&["tuicr", "pr", "https://github.com/agavra/tuicr/pull/125"])
.expect("parse should succeed");
assert_eq!(
parsed.pr_target,
Some("https://github.com/agavra/tuicr/pull/125".to_string()),
);
}
#[test]
fn should_error_when_pr_target_is_missing() {
let err = parse_for_test(&["tuicr", "pr"]).expect_err("parse should fail");
assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
}
#[test]
fn should_combine_pr_target_with_theme_flag() {
let parsed = parse_for_test(&["tuicr", "pr", "125", "--theme", "dark"])
.expect("parse should succeed");
assert_eq!(parsed.pr_target, Some("125".to_string()));
assert_eq!(parsed.theme, Some("dark".to_string()));
}
#[test]
fn should_allow_root_tui_options_before_legacy_pr_subcommand() {
let parsed = parse_for_test(&["tuicr", "--theme", "dark", "pr", "125"])
.expect("parse should succeed");
assert_eq!(parsed.pr_target, Some("125".to_string()));
assert_eq!(parsed.theme, Some("dark".to_string()));
}
#[test]
fn should_parse_explicit_tui_command() {
let parsed = parse_for_test(&["tuicr", "tui", "-w", "--theme", "dark"])
.expect("parse should succeed");
assert!(parsed.working_tree);
assert_eq!(parsed.theme, Some("dark".to_string()));
assert_eq!(parsed.pr_target, None);
assert_eq!(parsed.review_command, None);
}
#[test]
fn should_parse_explicit_tui_pr_command() {
let parsed = parse_for_test(&["tuicr", "tui", "pr", "125", "--theme", "dark"])
.expect("parse should succeed");
assert_eq!(parsed.pr_target, Some("125".to_string()));
assert_eq!(parsed.theme, Some("dark".to_string()));
}
#[test]
fn should_reject_root_tui_options_before_subcommands() {
let err = parse_for_test(&["tuicr", "--theme", "dark", "review", "list"])
.expect_err("parse should fail");
assert_eq!(err.kind(), ErrorKind::ArgumentConflict);
}
#[test]
fn should_leave_pr_target_none_when_no_pr_subcommand() {
let parsed = parse_for_test(&["tuicr"]).expect("parse should succeed");
assert_eq!(parsed.pr_target, None);
}
#[test]
fn should_parse_repo_url_https() {
let parsed = parse_for_test(&["tuicr", "--repo-url", "https://github.com/slatedb/slatedb"])
.expect("parse should succeed");
assert_eq!(
parsed.repo_url,
Some("https://github.com/slatedb/slatedb".to_string())
);
}
#[test]
fn should_parse_repo_url_equals_form() {
let parsed = parse_for_test(&["tuicr", "--repo-url=git@github.com:slatedb/slatedb.git"])
.expect("parse should succeed");
assert_eq!(
parsed.repo_url,
Some("git@github.com:slatedb/slatedb.git".to_string())
);
}
#[test]
fn should_parse_repo_url_ssh_scheme() {
let parsed = parse_for_test(&[
"tuicr",
"--repo-url",
"ssh://git@github.com/slatedb/slatedb.git",
])
.expect("parse should succeed");
assert_eq!(
parsed.repo_url,
Some("ssh://git@github.com/slatedb/slatedb.git".to_string())
);
}
#[test]
fn should_error_when_repo_url_value_missing() {
let err = parse_for_test(&["tuicr", "--repo-url"]).expect_err("parse should fail");
assert_eq!(err.kind(), ErrorKind::InvalidValue);
}
#[test]
fn should_error_when_repo_url_unparseable() {
let err =
parse_for_test(&["tuicr", "--repo-url", "not-a-url"]).expect_err("parse should fail");
assert_eq!(err.kind(), ErrorKind::ValueValidation);
assert!(err.to_string().contains("not a recognized GitHub URL"));
}
#[test]
fn should_error_when_repo_url_equals_empty() {
let err = parse_for_test(&["tuicr", "--repo-url="]).expect_err("parse should fail");
assert_eq!(err.kind(), ErrorKind::ValueValidation);
}
#[test]
fn should_leave_repo_url_none_when_not_provided() {
let parsed = parse_for_test(&["tuicr"]).expect("parse should succeed");
assert_eq!(parsed.repo_url, None);
}
#[test]
fn should_parse_review_list_command() {
let parsed = parse_for_test(&["tuicr", "review", "list", "--repo", "/tmp/repo"])
.expect("parse should succeed");
assert_eq!(
parsed.review_command,
Some(ReviewCommand::List {
repo: PathBuf::from("/tmp/repo"),
all: false,
})
);
}
#[test]
fn should_parse_review_list_all_flag() {
let parsed =
parse_for_test(&["tuicr", "review", "list", "--all"]).expect("parse should succeed");
assert_eq!(
parsed.review_command,
Some(ReviewCommand::List {
repo: PathBuf::from("."),
all: true,
})
);
}
#[test]
fn should_parse_review_list_by_coordinate() {
let parsed = parse_for_test(&["tuicr", "review", "list", "--repo", "slatedb/slatedb"])
.expect("parse should succeed");
assert_eq!(
parsed.review_command,
Some(ReviewCommand::List {
repo: PathBuf::from("slatedb/slatedb"),
all: false,
})
);
}
#[test]
fn should_reject_review_json_flag_because_output_is_always_json() {
let err =
parse_for_test(&["tuicr", "review", "list", "--json"]).expect_err("parse should fail");
assert_eq!(err.kind(), ErrorKind::UnknownArgument);
}
#[test]
fn should_parse_review_add_line_comment() {
let parsed = parse_for_test(&[
"tuicr",
"review",
"add",
"--session",
"agavra/tuicr@main/worktree",
"--target-file",
"src/main.rs",
"--line",
"42",
"--type",
"issue",
"--side",
"old",
"Handle the empty case",
])
.expect("parse should succeed");
assert_eq!(
parsed.review_command,
Some(ReviewCommand::Add {
session: "agavra/tuicr@main/worktree".to_string(),
input: None,
repo: PathBuf::from("."),
comment_type: "issue".to_string(),
file: Some(PathBuf::from("src/main.rs")),
line: Some(42),
end_line: None,
side: LineSideArg::Old,
username: None,
content: Some("Handle the empty case".to_string()),
})
);
}
#[test]
fn should_parse_review_add_json_input() {
let parsed = parse_for_test(&[
"tuicr",
"review",
"add",
"--session",
"agavra/tuicr@main/worktree",
"--input",
r#"{"file":"src/main.rs","line":42,"side":"old","content":"note"}"#,
])
.expect("parse should succeed");
assert_eq!(
parsed.review_command,
Some(ReviewCommand::Add {
session: "agavra/tuicr@main/worktree".to_string(),
input: Some(
r#"{"file":"src/main.rs","line":42,"side":"old","content":"note"}"#.to_string()
),
repo: PathBuf::from("."),
comment_type: "none".to_string(),
file: None,
line: None,
end_line: None,
side: LineSideArg::New,
username: None,
content: None,
})
);
}
#[test]
fn should_parse_review_comments_command() {
let parsed = parse_for_test(&["tuicr", "review", "comments", "--session", "session.json"])
.expect("parse should succeed");
assert_eq!(
parsed.review_command,
Some(ReviewCommand::Comments {
session: "session.json".to_string(),
repo: PathBuf::from("."),
})
);
}
#[test]
fn should_parse_review_comments_get_alias() {
let parsed = parse_for_test(&["tuicr", "review", "get", "--session", "session.json"])
.expect("parse should succeed");
assert_eq!(
parsed.review_command,
Some(ReviewCommand::Comments {
session: "session.json".to_string(),
repo: PathBuf::from("."),
})
);
}
#[test]
fn should_require_file_for_review_add_line() {
let err = parse_for_test(&[
"tuicr",
"review",
"add",
"--session",
"session",
"--line",
"42",
"note",
])
.expect_err("parse should fail");
assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
}
}