Files
vamp/src/review_cli.rs
T

221 lines
7.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>,
delete_comment: Option<u64>,
repo: Option<PathBuf>,
json: bool,
all: bool,
}
impl Request {
pub fn parse(args: &mut impl Iterator<Item = OsString>) -> Result<Self, String> {
let usage = "--review requires list, show ID, or delete REVIEW_ID COMMENT_ID";
let command = args.next().ok_or(usage)?;
let id = if command == "list" {
None
} else if command == "show" || command == "delete" {
let error = "show/delete requires a positive review ID";
let id = args.next().ok_or(error)?;
Some(
id.to_str()
.and_then(|id| id.parse::<i64>().ok())
.filter(|id| *id > 0)
.ok_or(error)?,
)
} else {
return Err(usage.into());
};
let delete_comment = if command == "delete" {
let error = "delete requires a positive comment ID";
let id = args.next().ok_or(error)?;
Some(
id.to_str()
.and_then(|id| id.parse::<u64>().ok())
.filter(|id| *id > 0)
.ok_or(error)?,
)
} else {
None
};
Ok(Self {
id,
delete_comment,
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.delete_comment.is_none() && !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 mut review = store
.as_ref()
.ok_or("Review does not exist in this repository")?
.load(&root, id)?;
if let Some(comment_id) = request.delete_comment {
let index = review
.comments
.iter()
.position(|comment| comment.id == comment_id)
.ok_or_else(|| format!("Comment {comment_id} does not exist in review {id}"))?;
review.comments.remove(index);
Store::open(&directory)?.save(&mut review)?;
}
envelope(&review, request.all, true)
} else {
let reviews = match store {
Some(store) => store
.list(&root)?
.into_iter()
.map(|review| envelope(&review, true, false))
.collect::<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)]
#[path = "../tests/unit/review_cli.rs"]
mod tests;