feat: persist diff file review checkmarks

This commit is contained in:
2026-10-19 18:48:44 +02:00
parent fe039e75c8
commit ea04e30072
11 changed files with 655 additions and 61 deletions
+244 -6
View File
@@ -38,6 +38,16 @@ fn database_restarts_preserve_identity_and_exact_stale_anchors() {
body: "Please preserve 👩‍🔬".into(),
resolved: true,
});
review.reviewed_files = [Some("live 👩‍🔬\r\n"), None, Some("")]
.into_iter()
.enumerate()
.map(|(index, after)| ReviewedFile {
revisions: [format!("before-{index}"), "after".into()],
path: "日本語.rs".into(),
old_path: (index == 0).then(|| "old.rs".into()),
after: after.map(str::to_owned),
})
.collect();
{
let mut store = Store::open(&storage).unwrap();
store.save(&mut review).unwrap();
@@ -50,10 +60,12 @@ fn database_restarts_preserve_identity_and_exact_stale_anchors() {
assert_eq!(listed[0].storage_id, Some(id));
assert_eq!(listed[0].summary, review.summary);
assert_eq!(listed[0].comments, review.comments);
assert_eq!(listed[0].reviewed_files, review.reviewed_files);
let mut loaded = store.load(&root, id).unwrap();
assert_eq!(loaded.storage_id, Some(id));
assert_eq!(loaded.storage_revision, 1);
assert_eq!(loaded.comments, review.comments);
assert_eq!(loaded.reviewed_files, review.reviewed_files);
assert_eq!(loaded.verdict, review.verdict);
assert!(!loaded.comments[0].anchor.matches_lines(
"working:new-snapshot",
@@ -67,7 +79,10 @@ fn database_restarts_preserve_identity_and_exact_stale_anchors() {
let exported = fs::read(&path).unwrap();
let json: serde_json::Value = serde_json::from_slice(&exported).unwrap();
assert_eq!(json["comments"][0]["resolved"], true);
assert_eq!(json["version"], 3);
assert_eq!(json["version"], 4);
assert_eq!(json["reviewed_files"][0]["after"], "live 👩‍🔬\r\n");
assert!(json["reviewed_files"][1]["after"].is_null());
assert_eq!(json["reviewed_files"][2]["after"], "");
assert!(json.get("storage_id").is_none());
assert!(json.get("storage_revision").is_none());
loaded.summary = "Database only".into();
@@ -78,9 +93,28 @@ fn database_restarts_preserve_identity_and_exact_stale_anchors() {
let mut imported = Review::load(&path, &root).unwrap();
assert_eq!(imported.storage_id, None);
assert_eq!(imported.storage_revision, 0);
assert_eq!(imported.reviewed_files, review.reviewed_files);
store.save(&mut imported).unwrap();
assert_ne!(imported.storage_id, Some(id));
assert_eq!(store.list(&root).unwrap().len(), 2);
assert_eq!(
store
.load(&root, imported.storage_id.unwrap())
.unwrap()
.reviewed_files,
review.reviewed_files,
);
let comments = loaded.comments.clone();
let summary = loaded.summary.clone();
let verdict = loaded.verdict;
loaded.reviewed_files.clear();
store.save(&mut loaded).unwrap();
drop(store);
let loaded = Store::open(&storage).unwrap().load(&root, id).unwrap();
assert!(loaded.reviewed_files.is_empty());
assert_eq!(loaded.comments, comments);
assert_eq!(loaded.summary, summary);
assert_eq!(loaded.verdict, verdict);
}
#[test]
@@ -122,12 +156,20 @@ fn database_conflicts_and_locked_writes_keep_drafts_and_identity() {
let mut first = Store::open(&storage).unwrap();
let mut second = Store::open(&storage).unwrap();
let mut review = Review::new(root.clone());
review.reviewed_files.push(ReviewedFile {
revisions: ["before".into(), "after".into()],
path: "source.rs".into(),
old_path: None,
after: None,
});
first.save(&mut review).unwrap();
let id = review.storage_id.unwrap();
let mut draft = second.load(&root, id).unwrap();
review.summary = "First writer".into();
review.reviewed_files[0].after = Some("First writer buffer\n".into());
first.save(&mut review).unwrap();
draft.summary = "Second writer draft".into();
draft.reviewed_files.clear();
assert!(
second
.save(&mut draft)
@@ -137,6 +179,11 @@ fn database_conflicts_and_locked_writes_keep_drafts_and_identity() {
assert_eq!(draft.summary, "Second writer draft");
assert_eq!((draft.storage_id, draft.storage_revision), (Some(id), 1));
assert_eq!(second.load(&root, id).unwrap().summary, "First writer");
assert_eq!(
second.load(&root, id).unwrap().reviewed_files,
review.reviewed_files,
);
assert!(draft.reviewed_files.is_empty());
first.connection.execute_batch("BEGIN IMMEDIATE").unwrap();
let mut fresh = Review::new(root.clone());
assert!(second.save(&mut fresh).unwrap_err().contains("locked"));
@@ -148,7 +195,13 @@ fn database_conflicts_and_locked_writes_keep_drafts_and_identity() {
#[test]
fn incompatible_database_schemas_are_preserved() {
let previous = format!(
"{REVIEWS_SCHEMA}; {COMMENTS_SCHEMA};
INSERT INTO reviews VALUES (1, '/old/repo', 1, 'Preserve me', 'looks_good');
PRAGMA user_version = 2"
);
for schema in [
previous.as_str(),
"CREATE TABLE other (value TEXT); PRAGMA user_version = 42",
"CREATE TABLE other (value TEXT); PRAGMA user_version = 1",
"CREATE TABLE reviews (id INTEGER PRIMARY KEY, root TEXT NOT NULL,
@@ -163,6 +216,8 @@ fn incompatible_database_schemas_are_preserved() {
connection.execute_batch(schema).unwrap();
drop(connection);
let original = fs::read(&path).unwrap();
assert!(Store::open_read_only(&directory.0).is_err());
assert_eq!(fs::read(&path).unwrap(), original);
assert!(Store::open(&directory.0).err().unwrap().contains("schema"));
assert_eq!(fs::read(&path).unwrap(), original);
}
@@ -461,6 +516,12 @@ fn comment_rows_preserve_order_full_ids_and_atomic_replacement() {
})
.collect();
review.summary = "Original".into();
review.reviewed_files.push(ReviewedFile {
revisions: ["before".into(), "after".into()],
path: "source.rs".into(),
old_path: None,
after: None,
});
store.save(&mut review).unwrap();
let id = review.storage_id.unwrap();
assert_eq!(store.load(&root, id).unwrap().comments, review.comments);
@@ -488,6 +549,7 @@ fn comment_rows_preserve_order_full_ids_and_atomic_replacement() {
.unwrap();
let revision = review.storage_revision;
let original = review.comments.clone();
let original_files = review.reviewed_files.clone();
review.summary = "Unsaved".into();
review.comments[0].body = "Changed".into();
assert!(
@@ -501,10 +563,30 @@ fn comment_rows_preserve_order_full_ids_and_atomic_replacement() {
assert_eq!(loaded.summary, "Original");
assert_eq!(loaded.comments, original);
assert_eq!(loaded.storage_revision, revision);
assert_eq!(loaded.reviewed_files, original_files);
store
.connection
.execute_batch("DROP TRIGGER reject_comment")
.unwrap();
store
.connection
.execute_batch(
"CREATE TEMP TRIGGER reject_reviewed BEFORE INSERT ON reviewed_files
BEGIN SELECT RAISE(ABORT, 'blocked reviewed file'); END",
)
.unwrap();
review.reviewed_files[0].after = Some("Unsaved buffer\n".into());
assert!(store.save(&mut review).is_err());
assert_eq!(review.storage_revision, revision);
let loaded = store.load(&root, id).unwrap();
assert_eq!(loaded.summary, "Original");
assert_eq!(loaded.comments, original);
assert_eq!(loaded.reviewed_files, original_files);
assert_eq!(loaded.storage_revision, revision);
store
.connection
.execute_batch("DROP TRIGGER reject_reviewed")
.unwrap();
review.comments.reverse();
store.save(&mut review).unwrap();
assert_eq!(store.load(&root, id).unwrap().comments, review.comments);
@@ -547,16 +629,16 @@ fn exact_grapheme_ranges_roundtrip_and_reject_invalid_coordinates() {
.as_object_mut()
.unwrap()
.remove("resolved");
for version in [2, 3] {
for version in [2, 3, 4] {
missing_resolution["version"] = version.into();
let bytes = serde_json::to_vec(&missing_resolution).unwrap();
fs::write(&path, &bytes).unwrap();
let error = Review::load(&path, &root).unwrap_err();
assert!(error.contains(if version == 2 {
"Unsupported review version 2"
if version < 4 {
assert!(error.contains("Unsupported review version"));
} else {
"missing field `resolved`"
}));
assert!(error.contains("missing field"));
}
assert_eq!(fs::read(&path).unwrap(), bytes);
}
for (field, value) in [
@@ -584,3 +666,159 @@ fn exact_grapheme_ranges_roundtrip_and_reject_invalid_coordinates() {
loaded.comments[0].anchor.end = TextPosition { row: 0, column: 0 };
assert!(loaded.save(&root.join("backwards.json")).is_err());
}
#[test]
fn reviewed_files_reject_invalid_json_and_preserve_existing_data() {
let directory = directory();
let root = directory.0.canonicalize().unwrap();
let path = root.join("review.json");
let mut store = Store::open(&root.join("storage")).unwrap();
let mut review = Review::new(root.clone());
review.reviewed_files.push(ReviewedFile {
revisions: ["before".into(), "after".into()],
path: "source.rs".into(),
old_path: Some("old.rs".into()),
after: Some("Exact live text\r\n".into()),
});
store.save(&mut review).unwrap();
let valid = serde_json::to_value(&review).unwrap();
let mut invalid_documents = Vec::new();
for (field, value) in [
("path", serde_json::json!("")),
("path", serde_json::json!("../outside")),
("path", serde_json::json!("/absolute")),
("path", serde_json::json!(".git/config")),
("path", serde_json::json!("nul\0.rs")),
("old_path", serde_json::json!("../outside")),
("old_path", serde_json::json!("/absolute")),
("old_path", serde_json::json!(".jj/repo")),
("old_path", serde_json::json!("nul\0.rs")),
("revisions", serde_json::json!(["", "after"])),
("revisions", serde_json::json!(["before", ""])),
("revisions", serde_json::json!(["before\n", "after"])),
("revisions", serde_json::json!(["before", "after\0"])),
("revisions", serde_json::json!(["before"])),
("unexpected", serde_json::json!(true)),
] {
let mut invalid = valid.clone();
invalid["reviewed_files"][0][field] = value;
invalid_documents.push(invalid);
}
let mut duplicate = valid.clone();
duplicate["reviewed_files"]
.as_array_mut()
.unwrap()
.push(valid["reviewed_files"][0].clone());
invalid_documents.push(duplicate);
let mut missing = valid.clone();
missing.as_object_mut().unwrap().remove("reviewed_files");
invalid_documents.push(missing);
let mut old = valid;
old["version"] = 3.into();
invalid_documents.push(old);
for invalid in invalid_documents {
let bytes = serde_json::to_vec(&invalid).unwrap();
fs::write(&path, &bytes).unwrap();
assert!(Review::load(&path, &root).is_err());
assert_eq!(fs::read(&path).unwrap(), bytes);
if let Ok(mut invalid) = serde_json::from_value::<Review>(invalid) {
assert!(store.save(&mut invalid).is_err());
assert!(invalid.save(&root.join("invalid.json")).is_err());
assert!(!root.join("invalid.json").exists());
}
}
let loaded = store.load(&root, review.storage_id.unwrap()).unwrap();
assert_eq!(loaded.reviewed_files, review.reviewed_files);
assert_eq!(store.list(&root).unwrap().len(), 1);
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
for old_path in [false, true] {
let mut invalid = review.clone();
let path = PathBuf::from(std::ffi::OsString::from_vec(vec![0xff]));
if old_path {
invalid.reviewed_files[0].old_path = Some(path);
} else {
invalid.reviewed_files[0].path = path;
}
assert!(store.save(&mut invalid).is_err());
}
}
}
#[test]
fn reviewed_file_rows_enforce_identity_and_limit_snapshots_before_loading() {
let directory = directory();
let root = directory.0.canonicalize().unwrap();
let mut store = Store::open(&root.join("storage")).unwrap();
let mut review = Review::new(root.clone());
review.reviewed_files.push(ReviewedFile {
revisions: ["before".into(), "after".into()],
path: "source.rs".into(),
old_path: None,
after: None,
});
store.save(&mut review).unwrap();
let id = review.storage_id.unwrap();
for update in [
"review_id = 0",
"before_revision = ''",
"after_revision = ''",
"path = NULL",
"after_content = zeroblob(1)",
"position = -1",
] {
assert!(
store
.connection
.execute(
&format!("UPDATE reviewed_files SET {update} WHERE review_id = ?1"),
[id],
)
.is_err(),
"{update}",
);
}
assert!(
store
.connection
.execute(
"INSERT INTO reviewed_files
SELECT review_id, position + 1, before_revision, after_revision,
path, old_path, after_content FROM reviewed_files WHERE review_id = ?1",
[id],
)
.is_err()
);
for update in [
"before_revision = char(10)",
"after_revision = 'after' || char(0)",
"path = '../outside'",
"old_path = '../outside'",
"old_path = 'nul' || char(0)",
"position = 1",
] {
store
.connection
.execute(
&format!("UPDATE reviewed_files SET {update} WHERE review_id = ?1"),
[id],
)
.unwrap();
assert!(store.load(&root, id).is_err(), "{update}");
assert!(store.list(&root).is_err(), "{update}");
store.save(&mut review).unwrap();
}
store
.connection
.execute(
"UPDATE reviewed_files SET after_content = CAST(zeroblob(?1) AS TEXT)
WHERE review_id = ?2",
params![LIMIT as i64 + 1, id],
)
.unwrap();
assert!(store.load(&root, id).unwrap_err().contains("64 MiB"));
assert!(store.list(&root).unwrap_err().contains("64 MiB"));
review.reviewed_files[0].after = Some("x".repeat(LIMIT));
assert!(store.save(&mut review).unwrap_err().contains("64 MiB"));
}