feat: expose read-only local review CLI

This commit is contained in:
Valdemar Lorenzen
2026-10-16 15:42:33 +02:00
parent 6ae8bf06a8
commit a6310594c5
7 changed files with 484 additions and 40 deletions
+84 -9
View File
@@ -2,12 +2,19 @@ use std::{env, io, path::PathBuf};
use vamp::{session::EditorSession, tui};
mod review_cli;
// Only the first argument can be this process-to-process terminal handoff marker.
const INHERITED_SCREEN: &str = "--vamp-inherit-alternate-screen";
const HELP: &str = "vamp — modal editor with isolated Ruby plugins
Usage: vamp [--config PATH | --no-config] [FILE]
vamp [--config PATH | --no-config] --review list [--repo PATH] [--json]
vamp [--config PATH | --no-config] --review show ID [--repo PATH] [--json] [--all]
Review CLI: read-only local storage; show defaults to unresolved comments; --all includes resolved.
Use vamp -- --review to edit a file named --review.
Default keys (configurable with config.bind):
All modes: Ctrl+p command palette; type to filter, Ctrl+j/k or arrows select, Enter accept, Esc cancel
@@ -114,20 +121,33 @@ Hover binding: config.bind \"K\", config.core.lsp.hover (no default key)";
struct Options {
file: Option<PathBuf>,
config: Option<PathBuf>,
review: Option<review_cli::Request>,
}
fn options(inherited_screen: bool) -> Result<Option<Options>, String> {
let mut args = env::args_os().skip(1 + usize::from(inherited_screen));
parse_options(env::args_os().skip(1 + usize::from(inherited_screen)))
}
fn parse_options(
args: impl IntoIterator<Item = std::ffi::OsString>,
) -> Result<Option<Options>, String> {
let mut args = args.into_iter();
let mut file = None;
let mut config = None;
let mut config_selected = false;
let mut positional = false;
let mut review = None;
while let Some(arg) = args.next() {
if !positional && (arg == "--help" || arg == "-h") {
println!("{HELP}");
return Ok(None);
} else if !positional && arg == "--" {
if review.is_some() {
return Err("unexpected review argument: --".into());
}
positional = true;
} else if !positional && arg == "--review" && file.is_none() && review.is_none() {
review = Some(review_cli::Request::parse(&mut args)?);
} else if !positional && arg == "--config" {
if config_selected {
return Err("use only one of --config and --no-config".into());
@@ -141,6 +161,8 @@ fn options(inherited_screen: bool) -> Result<Option<Options>, String> {
return Err("use only one of --config and --no-config".into());
}
config_selected = true;
} else if !positional && review.is_some() {
review.as_mut().unwrap().option(&arg, &mut args)?;
} else if !positional && arg.to_string_lossy().starts_with('-') {
return Err(format!("unknown option: {}", arg.to_string_lossy()));
} else if file.replace(PathBuf::from(arg)).is_some() {
@@ -155,24 +177,27 @@ fn options(inherited_screen: bool) -> Result<Option<Options>, String> {
.map(|home| home.join("vamp/config.rb"))
.filter(|path| path.exists());
}
Ok(Some(Options { file, config }))
Ok(Some(Options {
file,
config,
review,
}))
}
fn run() -> Result<(), String> {
let inherited_screen = env::args_os()
.nth(1)
.is_some_and(|arg| arg == INHERITED_SCREEN);
if inherited_screen {
return tui::TerminalSession::run(true, || {
run_editor(options(true).map_err(io::Error::other)?, true)
})
.map_err(|error| error.to_string());
let options = options(inherited_screen)?;
if let Some(options) = &options {
if let Some(request) = &options.review {
return review_cli::run(request, options.config.as_deref());
}
}
let options = options(false)?;
if options.is_none() {
return Ok(());
}
tui::TerminalSession::run(false, || run_editor(options, false))
tui::TerminalSession::run(inherited_screen, || run_editor(options, inherited_screen))
.map_err(|error| error.to_string())
}
@@ -226,3 +251,53 @@ fn main() {
std::process::exit(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Result<Option<Options>, String> {
parse_options(args.iter().map(std::ffi::OsString::from))
}
#[test]
fn review_commands_preserve_filenames_and_validate_arguments() {
assert_eq!(
parse(&["review"]).unwrap().unwrap().file,
Some("review".into())
);
assert_eq!(
parse(&["--", "--review"]).unwrap().unwrap().file,
Some("--review".into())
);
assert!(
parse(&["--no-config", "--review", "list", "--repo", ".", "--json"])
.unwrap()
.unwrap()
.review
.is_some()
);
assert!(
parse(&["--review", "show", "1", "--all", "--no-config"])
.unwrap()
.unwrap()
.review
.is_some()
);
for args in [
vec!["--review"],
vec!["--review", "show"],
vec!["--review", "show", "0"],
vec!["--review", "show", "-1"],
vec!["--review", "show", "abc"],
vec!["--review", "list", "--all"],
vec!["--review", "list", "--repo"],
vec!["--review", "list", "--repo", ".", "--repo", "."],
vec!["--review", "list", "extra"],
vec!["--review", "list", "--", "extra"],
vec!["--review", "list", "--config", "x", "--no-config"],
] {
assert!(parse(&args).is_err(), "{args:?}");
}
}
}