199 lines
6.5 KiB
Rust
199 lines
6.5 KiB
Rust
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)]
|
||
#[path = "../tests/unit/review_cli.rs"]
|
||
mod tests;
|