use std::{fs, path::PathBuf, process::Command}; use vamp::{ plugin::TextPosition, review::{Anchor, Comment, Review, Side, Store, Verdict}, }; struct Directory(PathBuf); impl Drop for Directory { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } #[test] fn cli_deletes_only_the_requested_comment_and_preserves_failed_targets() { let directory = Directory( std::env::temp_dir().join(format!("vamp-review-cli-delete-{}", std::process::id())), ); fs::create_dir(&directory.0).unwrap(); let root = directory.0.canonicalize().unwrap(); for name in ["repo", "other"] { let output = Command::new("jj") .args(["git", "init"]) .arg(root.join(name)) .env("JJ_CONFIG", "/dev/null") .output() .unwrap(); assert!(output.status.success(), "{:?}", output); } let config = root.join("config.rb"); fs::write( &config, "Vamp.configure { |config| config.review_directory = 'reviews' }", ) .unwrap(); let storage = root.join("reviews"); let mut store = Store::open(&storage).unwrap(); let mut review = Review::new(root.join("repo")); review.summary = "Keep summary".into(); review.verdict = Some(Verdict::ChangesRequested); review.comments = [1, u64::MAX] .into_iter() .map(|id| Comment { id, body: format!("Feedback {id}"), resolved: id == u64::MAX, anchor: Anchor { revision: "snapshot".into(), path: "file.rs".into(), side: Side::After, start: TextPosition { row: 0, column: 0 }, end: TextPosition { row: 0, column: 1 }, content: "界\n".into(), }, }) .collect(); store.save(&mut review).unwrap(); let mut other = Review::new(root.join("other")); other.comments = review.comments.clone(); store.save(&mut other).unwrap(); let id = review.storage_id.unwrap(); let cli = |repo: &str, args: &[&str]| { Command::new(env!("CARGO_BIN_EXE_vamp")) .arg("--config") .arg(&config) .arg("--review") .args(args) .current_dir(root.join(repo)) .output() .unwrap() }; let id_text = id.to_string(); for (repo, args) in [ ("repo", vec!["delete", "999", "1"]), ("repo", vec!["delete", &id_text, "2"]), ("other", vec!["delete", &id_text, "1"]), ] { let output = cli(repo, &args); assert!(!output.status.success(), "{:?}", output); let loaded = store.load(&review.root, id).unwrap(); assert_eq!(loaded.storage_revision, review.storage_revision); assert_eq!(loaded.comments, review.comments); } let output = cli( "repo", &["delete", &id_text, "18446744073709551615", "--json"], ); assert!(output.status.success(), "{:?}", output); let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(json["id"], id); assert_eq!( json["counts"], serde_json::json!({"total": 1, "resolved": 0, "unresolved": 1}) ); let loaded = store.load(&review.root, id).unwrap(); assert_eq!(loaded.storage_revision, review.storage_revision + 1); assert_eq!(loaded.comments, review.comments[..1]); assert_eq!(loaded.summary, review.summary); assert_eq!(loaded.verdict, review.verdict); assert!(store.save(&mut review).is_err()); // Stale editors cannot resurrect the deletion. let output = cli("repo", &["delete", &id_text, "18446744073709551615"]); assert!(!output.status.success()); assert_eq!( store.load(&review.root, id).unwrap().storage_revision, loaded.storage_revision ); let output = cli("repo", &["delete", &id_text, "1"]); assert!(output.status.success(), "{:?}", output); assert!( String::from_utf8(output.stdout) .unwrap() .contains("0 total") ); let empty = store.load(&review.root, id).unwrap(); assert!(empty.comments.is_empty()); assert_eq!(empty.storage_revision, loaded.storage_revision + 1); assert_eq!(empty.summary, review.summary); let output = cli("repo", &["list", "--json"]); assert!(output.status.success(), "{:?}", output); let list: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(list["reviews"], serde_json::json!([])); let output = cli("repo", &["list"]); assert!(output.status.success(), "{:?}", output); assert!( String::from_utf8(output.stdout) .unwrap() .contains("No stored reviews.") ); assert_eq!( store .load(&other.root, other.storage_id.unwrap()) .unwrap() .comments, other.comments ); drop(store); fs::remove_dir_all(&storage).unwrap(); let output = cli("repo", &["delete", &id_text, "1"]); assert!(!output.status.success()); assert!(!storage.exists()); } #[test] fn cli_resolves_only_the_requested_comment_and_preserves_original_feedback() { let directory = Directory( std::env::temp_dir().join(format!("vamp-review-cli-resolve-{}", std::process::id())), ); fs::create_dir(&directory.0).unwrap(); let root = directory.0.canonicalize().unwrap(); for name in ["repo", "other"] { let output = Command::new("jj") .args(["git", "init"]) .arg(root.join(name)) .env("JJ_CONFIG", "/dev/null") .output() .unwrap(); assert!(output.status.success(), "{:?}", output); } let config = root.join("config.rb"); fs::write( &config, "Vamp.configure { |config| config.review_directory = 'reviews' }", ) .unwrap(); let storage = root.join("reviews"); let mut store = Store::open(&storage).unwrap(); let mut review = Review::new(root.join("repo")); review.comparison = Some(["frozen-base".into(), "working:frozen-tip".into()]); review.summary = "Keep summary".into(); review.verdict = Some(Verdict::ChangesRequested); review.comments = [u64::MAX, 2, 3] .into_iter() .map(|id| Comment { id, body: format!("Original feedback {id}\nKeep this history."), resolved: id == 3, anchor: Anchor { revision: "original-snapshot".into(), path: "old/file.rs".into(), side: Side::Before, start: TextPosition { row: 0, column: 1 }, end: TextPosition { row: 1, column: 1 }, content: "xe\u{301}👩‍💻\n界z\n".into(), }, }) .collect(); store.save(&mut review).unwrap(); let mut other = Review::new(root.join("other")); other.comments = review.comments.clone(); store.save(&mut other).unwrap(); let mut source = Review::new(review.root.clone()); source.summary = "Progress without comments".into(); source.verdict = Some(Verdict::LooksGood); store.save(&mut source).unwrap(); let mut reversed = Review::new(review.root.clone()); reversed.comparison = Some(["working:frozen-tip".into(), "frozen-base".into()]); store.save(&mut reversed).unwrap(); let id = review.storage_id.unwrap(); let id_text = id.to_string(); let cli = |repo: &str, args: &[&str]| { Command::new(env!("CARGO_BIN_EXE_vamp")) .arg("--config") .arg(&config) .arg("--review") .args(args) .arg("--repo") .arg(root.join(repo)) .current_dir(&root) .output() .unwrap() }; let path = storage.join("reviews.sqlite3"); let before = fs::read(&path).unwrap(); for (repo, args) in [ ("repo", vec!["resolve"]), ("repo", vec!["resolve", &id_text]), ("repo", vec!["resolve", "0", "1"]), ("repo", vec!["resolve", &id_text, "0"]), ("repo", vec!["resolve", "999", "1"]), ("repo", vec!["resolve", &id_text, "1"]), ("other", vec!["resolve", &id_text, "2"]), ("repo", vec!["resolve", &id_text, "2", "--all"]), ] { let output = cli(repo, &args); assert!(!output.status.success(), "{:?}", output); assert_eq!(fs::read(&path).unwrap(), before); } let output = cli( "repo", &["resolve", &id_text, "18446744073709551615", "--json"], ); assert!(output.status.success(), "{:?}", output); let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(json["id"], id); assert_eq!(json["revision"], review.storage_revision + 1); assert_eq!(json["comparison"], serde_json::json!(review.comparison)); assert_eq!( json["counts"], serde_json::json!({"total": 3, "resolved": 2, "unresolved": 1}) ); assert_eq!(json["comments"].as_array().unwrap().len(), 1); assert_eq!(json["comments"][0]["id"], 2); let loaded = store.load(&review.root, id).unwrap(); let mut expected = review.comments.clone(); expected[0].resolved = true; assert_eq!(loaded.comments, expected); assert_eq!(loaded.summary, review.summary); assert_eq!(loaded.verdict, review.verdict); assert_eq!(loaded.reviewed_files, review.reviewed_files); assert_eq!(loaded.comparison, review.comparison); assert!(store.save(&mut review).is_err()); // Stale editors cannot undo resolution. assert_eq!(store.load(&review.root, id).unwrap().comments, expected); let output = cli("repo", &["show", &id_text, "--all", "--json"]); assert!(output.status.success(), "{:?}", output); let all: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(all["comments"].as_array().unwrap().len(), 3); let original = &all["comments"][0]; assert_eq!(original["id"], u64::MAX); assert_eq!(original["body"], review.comments[0].body); assert_eq!(original["resolved"], true); assert_eq!(original["anchor"]["revision"], "original-snapshot"); assert_eq!(original["anchor"]["path"], "old/file.rs"); assert_eq!( original["anchor"]["start"], serde_json::json!({"row": 0, "column": 1}) ); assert_eq!(original["excerpt"], "e\u{301}👩‍💻\n界"); let output = cli("repo", &["show", &id_text, "--json"]); assert!(output.status.success(), "{:?}", output); assert_eq!( serde_json::from_slice::(&output.stdout).unwrap(), json ); let before = fs::read(&path).unwrap(); for comment in ["18446744073709551615", "3"] { let output = cli("repo", &["resolve", &id_text, comment, "--json"]); assert!(output.status.success(), "{:?}", output); assert_eq!( serde_json::from_slice::(&output.stdout).unwrap(), json ); assert_eq!(fs::read(&path).unwrap(), before); } let output = cli("repo", &["list", "--json"]); assert!(output.status.success(), "{:?}", output); let list: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!( list["reviews"] .as_array() .unwrap() .iter() .map(|review| review["id"].as_i64().unwrap()) .collect::>(), vec![id] ); assert_eq!(list["reviews"][0]["comparison"], json["comparison"]); assert_eq!(list["reviews"][0]["counts"], json["counts"]); let output = cli("repo", &["list"]); assert!(output.status.success(), "{:?}", output); let text = String::from_utf8(output.stdout).unwrap(); assert!(!text.contains("Progress without comments")); assert!(text.contains("Scope: comparison frozen-base → working:frozen-tip")); assert!(!text.contains("Scope: comparison working:frozen-tip → frozen-base")); assert_eq!(fs::read(&path).unwrap(), before); let output = cli( "repo", &["show", &source.storage_id.unwrap().to_string(), "--json"], ); assert!(output.status.success(), "{:?}", output); let shown: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(shown["summary"], source.summary); assert_eq!(shown["verdict"], "looks_good"); let output = cli("repo", &["resolve", &id_text, "2"]); assert!(output.status.success(), "{:?}", output); let text = String::from_utf8(output.stdout).unwrap(); assert!(!text.contains("Original feedback")); let loaded = store.load(&review.root, id).unwrap(); assert_eq!(loaded.storage_revision, review.storage_revision + 2); assert!(loaded.comments.iter().all(|comment| comment.resolved)); let output = cli("repo", &["list", "--json"]); assert!(output.status.success(), "{:?}", output); let list: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(list["reviews"][0]["id"], id); assert_eq!( list["reviews"][0]["counts"], serde_json::json!({"total": 3, "resolved": 3, "unresolved": 0}) ); assert_eq!( store .load(&other.root, other.storage_id.unwrap()) .unwrap() .comments, other.comments ); drop(store); fs::remove_dir_all(&storage).unwrap(); let output = cli("repo", &["resolve", &id_text, "2"]); assert!(!output.status.success(), "{:?}", output); assert!(!storage.exists()); }