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, delete_comment: Option, repo: Option, json: bool, all: bool, } impl Request { pub fn parse(args: &mut impl Iterator) -> Result { 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::().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::().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, ) -> 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::() + 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::>(), 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;