feat: add local diff review workflow
This commit is contained in:
+395
@@ -0,0 +1,395 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::BTreeSet,
|
||||
fs::{self, OpenOptions},
|
||||
io::{Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
const LIMIT: usize = 64 * 1024 * 1024;
|
||||
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Side {
|
||||
Before,
|
||||
After,
|
||||
}
|
||||
impl Side {
|
||||
pub fn index(self) -> usize {
|
||||
match self {
|
||||
Self::Before => 0,
|
||||
Self::After => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Verdict {
|
||||
LooksGood,
|
||||
ChangesRequested,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Anchor {
|
||||
/// Frozen comparison context; `content` additionally captures any live buffer edits.
|
||||
pub revision: String,
|
||||
pub path: PathBuf,
|
||||
pub side: Side,
|
||||
pub start_line: usize,
|
||||
pub end_line: usize,
|
||||
/// Exact whole-side text, including its final newline when present.
|
||||
// ponytail: whole-file text per comment; deduplicate snapshots if the 64 MiB ceiling matters.
|
||||
pub content: String,
|
||||
}
|
||||
impl Anchor {
|
||||
pub fn matches(&self, revision: &str, path: &Path, side: Side, text: &str) -> bool {
|
||||
self.revision == revision && self.path == path && self.side == side && self.content == text
|
||||
}
|
||||
|
||||
pub fn matches_lines(
|
||||
&self,
|
||||
revision: &str,
|
||||
path: &Path,
|
||||
side: Side,
|
||||
lines: &[String],
|
||||
eof: bool,
|
||||
) -> bool {
|
||||
self.revision == revision
|
||||
&& self.path == path
|
||||
&& self.side == side
|
||||
&& self.content.split('\n').eq(lines
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.chain((eof || lines.is_empty()).then_some("")))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Comment {
|
||||
pub id: u64,
|
||||
pub anchor: Anchor,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Review {
|
||||
pub version: u32,
|
||||
pub root: PathBuf,
|
||||
pub summary: String,
|
||||
pub verdict: Option<Verdict>,
|
||||
pub comments: Vec<Comment>,
|
||||
#[serde(skip)]
|
||||
saved: RefCell<Option<(PathBuf, Arc<Vec<u8>>)>>,
|
||||
}
|
||||
impl Default for Review {
|
||||
fn default() -> Self {
|
||||
Self::new(PathBuf::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ReviewState {
|
||||
pub document: Review,
|
||||
pub path: Option<PathBuf>,
|
||||
pub selected: Option<u64>,
|
||||
pub pending_navigation: Option<u64>,
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
impl Review {
|
||||
pub fn new(root: PathBuf) -> Self {
|
||||
let root = if root.as_os_str().is_empty() {
|
||||
root
|
||||
} else {
|
||||
fs::canonicalize(&root).unwrap_or(root)
|
||||
};
|
||||
Self {
|
||||
version: 1,
|
||||
root,
|
||||
summary: String::new(),
|
||||
verdict: None,
|
||||
comments: Vec::new(),
|
||||
saved: RefCell::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
if self.version != 1 {
|
||||
return Err(format!("Unsupported review version {}", self.version));
|
||||
}
|
||||
if !self.root.is_absolute() || !self.root.is_dir() {
|
||||
return Err("Review repository root must be an existing absolute directory".into());
|
||||
}
|
||||
let mut ids = BTreeSet::new();
|
||||
for comment in &self.comments {
|
||||
if comment.id == 0 || !ids.insert(comment.id) {
|
||||
return Err("Review comment IDs must be unique and nonzero".into());
|
||||
}
|
||||
let anchor = &comment.anchor;
|
||||
crate::repository::safe_path(&anchor.path)?;
|
||||
if anchor.path.to_str().is_none_or(|path| path.contains('\0')) {
|
||||
return Err("Review paths must be UTF-8 without NUL bytes".into());
|
||||
}
|
||||
if anchor.revision.is_empty() || anchor.revision.chars().any(char::is_control) {
|
||||
return Err("Review anchors require an immutable revision identity".into());
|
||||
}
|
||||
if anchor.start_line == 0
|
||||
|| anchor.end_line < anchor.start_line
|
||||
|| anchor.end_line > anchor.content.split('\n').count()
|
||||
{
|
||||
return Err(
|
||||
"Review ranges must be inclusive, nonzero and within the anchor text".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load(path: &Path, root: &Path) -> Result<Self, String> {
|
||||
let bytes = read_file(path)?.ok_or("Review file does not exist")?;
|
||||
let mut review: Self = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| format!("Invalid review JSON: {error}"))?;
|
||||
review.validate()?;
|
||||
let expected = fs::canonicalize(root).map_err(|error| error.to_string())?;
|
||||
let actual = fs::canonicalize(&review.root).map_err(|error| error.to_string())?;
|
||||
if actual != expected {
|
||||
return Err("Review belongs to a different repository".into());
|
||||
}
|
||||
review.root = actual;
|
||||
*review.saved.borrow_mut() = Some((identity(path)?, Arc::new(bytes)));
|
||||
Ok(review)
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &Path) -> Result<(), String> {
|
||||
self.validate()?;
|
||||
let bytes = serde_json::to_vec_pretty(self).map_err(|error| error.to_string())?;
|
||||
if bytes.len() > LIMIT {
|
||||
return Err("Review exceeds the 64 MiB limit".into());
|
||||
}
|
||||
let path = identity(path)?;
|
||||
let saved = self.saved.borrow();
|
||||
let expected = saved
|
||||
.as_ref()
|
||||
.filter(|(previous, _)| previous == &path)
|
||||
.map(|(_, bytes)| bytes.as_slice());
|
||||
check_file(&path, expected)?;
|
||||
let parent = path.parent().ok_or("Review path has no parent")?;
|
||||
let (temporary, mut file) = loop {
|
||||
let id = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
|
||||
let temporary = parent.join(format!(".vamp-review-{}-{id}.tmp", std::process::id()));
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
match options.open(&temporary) {
|
||||
Ok(file) => break (temporary, file),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
};
|
||||
let result: Result<(), String> = (|| {
|
||||
file.write_all(&bytes)
|
||||
.and_then(|_| file.sync_all())
|
||||
.map_err(|error| error.to_string())?;
|
||||
drop(file);
|
||||
check_file(&path, expected)?;
|
||||
if expected.is_some() {
|
||||
// ponytail: optimistic check like editor saves; OS compare-and-swap for hostile writers.
|
||||
fs::rename(&temporary, &path).map_err(|error| error.to_string())?;
|
||||
} else {
|
||||
// Publishing a new file must not replace a concurrently created target.
|
||||
fs::hard_link(&temporary, &path).map_err(|error| error.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
let _ = fs::remove_file(&temporary);
|
||||
result?;
|
||||
drop(saved);
|
||||
*self.saved.borrow_mut() = Some((path, Arc::new(bytes)));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn identity(path: &Path) -> Result<PathBuf, String> {
|
||||
// Resolve the parent only: never silently follow a target symlink.
|
||||
let parent = path
|
||||
.parent()
|
||||
.filter(|path| !path.as_os_str().is_empty())
|
||||
.unwrap_or(Path::new("."));
|
||||
let name = path.file_name().ok_or("Review path must name a file")?;
|
||||
Ok(fs::canonicalize(parent)
|
||||
.map_err(|error| error.to_string())?
|
||||
.join(name))
|
||||
}
|
||||
|
||||
fn read_file(path: &Path) -> Result<Option<Vec<u8>>, String> {
|
||||
let metadata = match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(error.to_string()),
|
||||
};
|
||||
if !metadata.is_file() || metadata.file_type().is_symlink() {
|
||||
return Err("Review file must be a regular file, not a symbolic link".into());
|
||||
}
|
||||
if metadata.len() > LIMIT as u64 {
|
||||
return Err("Review exceeds the 64 MiB limit".into());
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
fs::File::open(path)
|
||||
.and_then(|file| file.take(LIMIT as u64 + 1).read_to_end(&mut bytes))
|
||||
.map_err(|error| error.to_string())?;
|
||||
if bytes.len() > LIMIT {
|
||||
return Err("Review exceeds the 64 MiB limit".into());
|
||||
}
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
|
||||
fn check_file(path: &Path, expected: Option<&[u8]>) -> Result<(), String> {
|
||||
let actual = read_file(path)?;
|
||||
if actual.as_deref() != expected {
|
||||
return Err(
|
||||
"Review file changed outside vamp or already exists; open it before saving".into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct Directory(PathBuf);
|
||||
impl Drop for Directory {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistence_validates_anchors_and_preserves_files_on_failure() {
|
||||
let directory = Directory(std::env::temp_dir().join(format!(
|
||||
"vamp-review-test-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_TEMP.fetch_add(1, Ordering::Relaxed),
|
||||
)));
|
||||
fs::create_dir(&directory.0).unwrap();
|
||||
let root = directory.0.canonicalize().unwrap();
|
||||
let path = root.join("review.json");
|
||||
let mut review = Review::new(root.clone());
|
||||
review.summary = "Local review".into();
|
||||
review.verdict = Some(Verdict::ChangesRequested);
|
||||
review.comments.push(Comment {
|
||||
id: 1,
|
||||
anchor: Anchor {
|
||||
revision: "working:immutable".into(),
|
||||
path: "source.rs".into(),
|
||||
side: Side::After,
|
||||
start_line: 1,
|
||||
end_line: 2,
|
||||
content: "first\nlast\n".into(),
|
||||
},
|
||||
body: "Please adjust this".into(),
|
||||
});
|
||||
review.save(&path).unwrap();
|
||||
let mut loaded = Review::load(&path, &root).unwrap();
|
||||
assert_eq!(loaded.comments, review.comments);
|
||||
assert_eq!(loaded.verdict, review.verdict);
|
||||
let anchor = &loaded.comments[0].anchor;
|
||||
assert!(anchor.matches(
|
||||
"working:immutable",
|
||||
Path::new("source.rs"),
|
||||
Side::After,
|
||||
"first\nlast\n"
|
||||
));
|
||||
assert!(!anchor.matches(
|
||||
"working:other",
|
||||
Path::new("source.rs"),
|
||||
Side::After,
|
||||
"first\nlast\n"
|
||||
));
|
||||
assert!(anchor.matches_lines(
|
||||
"working:immutable",
|
||||
Path::new("source.rs"),
|
||||
Side::After,
|
||||
&["first".into(), "last".into()],
|
||||
true
|
||||
));
|
||||
assert!(!anchor.matches_lines(
|
||||
"working:immutable",
|
||||
Path::new("source.rs"),
|
||||
Side::After,
|
||||
&["first".into(), "last".into()],
|
||||
false
|
||||
));
|
||||
loaded.summary = "Updated".into();
|
||||
loaded.save(&path).unwrap();
|
||||
assert_eq!(Review::load(&path, &root).unwrap().summary, "Updated");
|
||||
assert!(Review::new(root.clone()).save(&path).is_err());
|
||||
let valid = fs::read(&path).unwrap();
|
||||
for (field, value) in [
|
||||
("version", serde_json::json!(2)),
|
||||
("root", serde_json::json!(root.join("other"))),
|
||||
(
|
||||
"comments",
|
||||
serde_json::json!([loaded.comments[0], loaded.comments[0]]),
|
||||
),
|
||||
] {
|
||||
let mut invalid = serde_json::to_value(&loaded).unwrap();
|
||||
invalid[field] = value;
|
||||
fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
|
||||
assert!(Review::load(&path, &root).is_err());
|
||||
}
|
||||
for (field, value) in [
|
||||
("path", serde_json::json!("../outside")),
|
||||
("path", serde_json::json!("/absolute")),
|
||||
("path", serde_json::json!(".git/config")),
|
||||
("start_line", serde_json::json!(0)),
|
||||
("start_line", serde_json::json!(3)),
|
||||
("end_line", serde_json::json!(4)),
|
||||
("revision", serde_json::json!("")),
|
||||
] {
|
||||
let mut invalid = serde_json::to_value(&loaded).unwrap();
|
||||
invalid["comments"][0]["anchor"][field] = value;
|
||||
fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
|
||||
assert!(Review::load(&path, &root).is_err());
|
||||
}
|
||||
fs::create_dir(root.join("other")).unwrap();
|
||||
fs::write(&path, &valid).unwrap();
|
||||
assert!(Review::load(&path, &root.join("other")).is_err());
|
||||
fs::write(&path, b"external edit").unwrap();
|
||||
assert!(loaded.save(&path).is_err());
|
||||
assert_eq!(fs::read(&path).unwrap(), b"external edit");
|
||||
assert_eq!(loaded.summary, "Updated");
|
||||
let invalid = root.join("invalid.json");
|
||||
loaded.comments[0].anchor.start_line = 0;
|
||||
assert!(loaded.save(&invalid).is_err());
|
||||
assert!(!invalid.exists());
|
||||
let large = root.join("large.json");
|
||||
fs::File::create(&large)
|
||||
.unwrap()
|
||||
.set_len(LIMIT as u64 + 1)
|
||||
.unwrap();
|
||||
assert!(Review::load(&large, &root).is_err());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let link = root.join("link.json");
|
||||
std::os::unix::fs::symlink(&path, &link).unwrap();
|
||||
assert!(Review::load(&link, &root).is_err());
|
||||
assert!(review.save(&link).is_err());
|
||||
assert_eq!(fs::read(&path).unwrap(), b"external edit");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user