feat: add local diff review workflow

This commit is contained in:
Valdemar Lorenzen
2026-10-16 13:10:07 +02:00
parent 930735c537
commit 0446ec86c7
15 changed files with 1871 additions and 7 deletions
+395
View File
@@ -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");
}
}
}