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
+306
View File
@@ -0,0 +1,306 @@
use serde_json::{Value, json};
use std::{
ffi::{OsStr, OsString},
path::{Path, PathBuf},
};
use unicode_segmentation::UnicodeSegmentation;
use vamp::{
repository,
review::{Anchor, Review, Store},
ruby,
};
pub struct Request {
id: Option<i64>,
repo: Option<PathBuf>,
json: bool,
all: bool,
}
impl Request {
pub fn parse(args: &mut impl Iterator<Item = OsString>) -> Result<Self, String> {
let command = args.next().ok_or("--review requires list or show ID")?;
let id = if command == "list" {
None
} else if command == "show" {
let id = args.next().ok_or("show requires a positive review ID")?;
Some(
id.to_str()
.and_then(|id| id.parse::<i64>().ok())
.filter(|id| *id > 0)
.ok_or("show requires a positive review ID")?,
)
} else {
return Err("--review requires list or show ID".into());
};
Ok(Self {
id,
repo: None,
json: false,
all: false,
})
}
pub fn option(
&mut self,
arg: &OsStr,
args: &mut impl Iterator<Item = OsString>,
) -> Result<(), String> {
if arg == "--repo" && self.repo.is_none() {
self.repo = Some(PathBuf::from(args.next().ok_or("--repo requires a path")?));
} else if arg == "--json" && !self.json {
self.json = true;
} else if arg == "--all" && self.id.is_some() && !self.all {
self.all = true;
} else {
return Err(format!(
"unexpected review argument: {}",
arg.to_string_lossy()
));
}
Ok(())
}
}
fn excerpt(anchor: &Anchor) -> String {
// Positions use extended graphemes, not bytes or Unicode scalar values.
let offset = |position: vamp::plugin::TextPosition| {
let line = anchor.content.split('\n').nth(position.row).unwrap_or("");
anchor
.content
.split('\n')
.take(position.row)
.map(|line| line.len() + 1)
.sum::<usize>()
+ line
.grapheme_indices(true)
.nth(position.column)
.map_or(line.len(), |(index, _)| index)
};
anchor.content[offset(anchor.start)..offset(anchor.end)].to_owned()
}
fn envelope(review: &Review, all: bool, comments: bool) -> Value {
let resolved = review
.comments
.iter()
.filter(|comment| comment.resolved)
.count();
let mut value = json!({
"repository": review.root,
"id": review.storage_id,
"revision": review.storage_revision,
"summary": review.summary,
"verdict": review.verdict,
"counts": { "total": review.comments.len(), "resolved": resolved, "unresolved": review.comments.len() - resolved },
});
if comments {
value["comments"] = review.comments.iter().filter(|comment| all || !comment.resolved).map(|comment| {
json!({"id": comment.id, "body": comment.body, "resolved": comment.resolved,
"anchor": { "revision": comment.anchor.revision, "path": comment.anchor.path,
"side": comment.anchor.side, "start": comment.anchor.start, "end": comment.anchor.end },
"excerpt": excerpt(&comment.anchor)})
}).collect();
}
value
}
pub fn run(request: &Request, config: Option<&Path>) -> Result<(), String> {
let directory = config
.map(ruby::load_config)
.transpose()?
.and_then(|config| config.review_directory)
.map_or_else(vamp::review::default_directory, Ok)?;
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
let root = repository::discover_root(request.repo.as_deref().unwrap_or(&cwd))?;
let store = Store::open_read_only(&directory)?;
let output = if let Some(id) = request.id {
let review = store
.as_ref()
.ok_or("Review does not exist in this repository")?
.load(&root, id)?;
envelope(&review, request.all, true)
} else {
let reviews = match store {
Some(store) => store
.list(&root)?
.into_iter()
.map(|(id, _)| {
store
.load(&root, id)
.map(|review| envelope(&review, true, false))
})
.collect::<Result<Vec<_>, _>>()?,
None => Vec::new(),
};
json!({"repository": root, "reviews": reviews})
};
if request.json {
println!(
"{}",
serde_json::to_string_pretty(&output).map_err(|error| error.to_string())?
);
} else {
println!("Repository: {}", root.display());
if let Some(reviews) = output["reviews"].as_array() {
if reviews.is_empty() {
println!("No stored reviews.");
}
for review in reviews {
print_review(review);
}
} else {
print_review(&output);
for comment in output["comments"].as_array().unwrap() {
let anchor = &comment["anchor"];
println!(
"\nComment {} [{}] {} ({}) {}:{}–{}:{} @ {}",
comment["id"],
if comment["resolved"] == true {
"resolved"
} else {
"unresolved"
},
anchor["path"].as_str().unwrap(),
anchor["side"].as_str().unwrap(),
anchor["start"]["row"],
anchor["start"]["column"],
anchor["end"]["row"],
anchor["end"]["column"],
anchor["revision"].as_str().unwrap()
);
println!(
"{}\n{}",
comment["excerpt"].as_str().unwrap(),
comment["body"].as_str().unwrap()
);
}
}
}
Ok(())
}
fn print_review(review: &Value) {
println!(
"Review {} (revision {}): {}\n Verdict: {}; {} unresolved, {} resolved ({} total)",
review["id"],
review["revision"],
review["summary"].as_str().unwrap(),
review["verdict"].as_str().unwrap_or("none"),
review["counts"]["unresolved"],
review["counts"]["resolved"],
review["counts"]["total"]
);
}
#[cfg(test)]
mod tests {
use super::*;
use vamp::{
plugin::TextPosition,
review::{Comment, Side},
};
#[test]
fn output_filters_resolution_and_extracts_exact_graphemes() {
let mut review = Review::new(PathBuf::new());
review.storage_id = Some(7);
review.storage_revision = 2;
review.comments = [false, true]
.into_iter()
.enumerate()
.map(|(id, resolved)| Comment {
id: id as u64 + 1,
body: "fix".into(),
resolved,
anchor: Anchor {
revision: "commit".into(),
path: "a.rs".into(),
side: Side::After,
start: TextPosition { row: 0, column: 1 },
end: TextPosition { row: 1, column: 1 },
content: "xe\u{301}👩‍💻\n界z\n".into(),
},
})
.collect();
let output = envelope(&review, false, true);
assert_eq!(output["comments"].as_array().unwrap().len(), 1);
assert_eq!(output["comments"][0]["excerpt"], "e\u{301}👩‍💻\n界");
assert!(output["comments"][0]["anchor"].get("content").is_none());
assert_eq!(output["counts"]["resolved"], 1);
assert_eq!(
envelope(&review, true, true)["comments"]
.as_array()
.unwrap()
.len(),
2
);
}
#[test]
fn absent_storage_is_not_created_and_incompatible_storage_is_preserved() {
let directory =
std::env::temp_dir().join(format!("vamp-review-cli-{}", std::process::id()));
std::fs::create_dir(&directory).unwrap();
let absent = directory.join("absent");
assert!(Store::open_read_only(&absent).unwrap().is_none());
assert!(!absent.exists());
let path = directory.join("reviews.sqlite3");
std::fs::write(&path, b"incompatible").unwrap();
assert!(Store::open_read_only(&directory).is_err());
assert_eq!(std::fs::read(&path).unwrap(), b"incompatible");
std::fs::remove_file(&path).unwrap();
let connection = rusqlite::Connection::open(&path).unwrap();
connection
.execute_batch("CREATE TABLE incompatible (value TEXT); PRAGMA user_version = 42")
.unwrap();
drop(connection);
let before = std::fs::read(&path).unwrap();
assert!(
Store::open_read_only(&directory)
.err()
.unwrap()
.contains("schema")
);
assert_eq!(std::fs::read(&path).unwrap(), before);
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn existing_storage_is_read_only_and_unchanged() {
let directory =
std::env::temp_dir().join(format!("vamp-review-cli-existing-{}", std::process::id()));
let mut store = Store::open(&directory).unwrap();
let mut review = Review::new(directory.clone());
review.summary = "stored".into();
store.save(&mut review).unwrap();
drop(store);
let path = directory.join("reviews.sqlite3");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
}
let before = std::fs::read(&path).unwrap();
let mut store = Store::open_read_only(&directory).unwrap().unwrap();
let mut loaded = store.load(&directory, review.storage_id.unwrap()).unwrap();
assert_eq!(loaded.summary, "stored");
loaded.summary = "changed".into();
assert!(store.save(&mut loaded).is_err());
drop(store);
assert_eq!(std::fs::read(&path).unwrap(), before);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o640
);
let links = directory.join("links");
std::fs::create_dir(&links).unwrap();
std::os::unix::fs::symlink(&path, links.join("reviews.sqlite3")).unwrap();
assert!(Store::open_read_only(&links).is_err());
}
std::fs::remove_dir_all(directory).unwrap();
}
}