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
+134 -16
View File
@@ -86,6 +86,15 @@ pub struct Comment {
pub resolved: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReviewedFile {
pub revisions: [String; 2],
pub path: PathBuf,
pub old_path: Option<PathBuf>,
pub after: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Review {
@@ -94,6 +103,7 @@ pub struct Review {
pub summary: String,
pub verdict: Option<Verdict>,
pub comments: Vec<Comment>,
pub reviewed_files: Vec<ReviewedFile>,
#[serde(skip)]
pub storage_id: Option<i64>,
#[serde(skip)]
@@ -126,11 +136,12 @@ impl Review {
fs::canonicalize(&root).unwrap_or(root)
};
Self {
version: 3,
version: 4,
root,
summary: String::new(),
verdict: None,
comments: Vec::new(),
reviewed_files: Vec::new(),
storage_id: None,
storage_revision: 0,
saved: RefCell::new(None),
@@ -138,7 +149,7 @@ impl Review {
}
fn validate(&self) -> Result<(), String> {
if self.version != 3 {
if self.version != 4 {
return Err(format!("Unsupported review version {}", self.version));
}
if !self.root.is_absolute() || !self.root.is_dir() {
@@ -150,10 +161,7 @@ impl Review {
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());
}
validate_path(&anchor.path)?;
if anchor.revision.is_empty() || anchor.revision.chars().any(char::is_control) {
return Err("Review anchors require an immutable revision identity".into());
}
@@ -181,6 +189,23 @@ impl Review {
return Err("Review rows must be within the anchor text".into());
}
}
let mut files = BTreeSet::new();
for file in &self.reviewed_files {
validate_path(&file.path)?;
if let Some(path) = &file.old_path {
validate_path(path)?;
}
if file
.revisions
.iter()
.any(|revision| revision.is_empty() || revision.chars().any(char::is_control))
{
return Err("Reviewed files require immutable revision identities".into());
}
if !files.insert((&file.revisions, &file.path)) {
return Err("Reviewed files must be unique by revision pair and path".into());
}
}
Ok(())
}
@@ -191,7 +216,7 @@ impl Review {
}
let header: Header = serde_json::from_slice(bytes)
.map_err(|error| format!("Invalid review JSON: {error}"))?;
if header.version != 3 {
if header.version != 4 {
return Err(format!("Unsupported review version {}", header.version));
}
serde_json::from_slice(bytes).map_err(|error| format!("Invalid review JSON: {error}"))
@@ -264,6 +289,14 @@ impl Review {
}
}
fn validate_path(path: &Path) -> Result<(), String> {
crate::repository::safe_path(path)?;
if path.to_str().is_none_or(|path| path.contains('\0')) {
return Err("Review paths must be UTF-8 without NUL bytes".into());
}
Ok(())
}
pub fn default_directory() -> Result<PathBuf, String> {
directory_from(
std::env::var_os("XDG_DATA_HOME").as_deref(),
@@ -313,6 +346,18 @@ const COMMENTS_SCHEMA: &str = "CREATE TABLE comments (
CHECK (end_row > start_row OR (end_row = start_row AND end_column >= start_column))
) STRICT";
const REVIEWED_FILES_SCHEMA: &str = "CREATE TABLE reviewed_files (
review_id INTEGER NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
position INTEGER NOT NULL CHECK (position >= 0),
before_revision TEXT NOT NULL CHECK (length(before_revision) > 0),
after_revision TEXT NOT NULL CHECK (length(after_revision) > 0),
path TEXT NOT NULL,
old_path TEXT,
after_content TEXT,
PRIMARY KEY (review_id, before_revision, after_revision, path),
UNIQUE (review_id, position)
) STRICT";
pub struct Store {
connection: Connection,
}
@@ -357,7 +402,7 @@ impl Store {
.map_err(database_error)?
.collect::<Result<_, _>>()
.map_err(database_error)?;
if version != 2 || schema != [COMMENTS_SCHEMA, REVIEWS_SCHEMA] {
if version != 3 || schema != [COMMENTS_SCHEMA, REVIEWED_FILES_SCHEMA, REVIEWS_SCHEMA] {
return Err(format!(
"Unsupported review database schema (version {version}); existing data was not changed"
));
@@ -422,7 +467,10 @@ impl Store {
.execute_batch(COMMENTS_SCHEMA)
.map_err(database_error)?;
transaction
.pragma_update(None, "user_version", 2)
.execute_batch(REVIEWED_FILES_SCHEMA)
.map_err(database_error)?;
transaction
.pragma_update(None, "user_version", 3)
.map_err(database_error)?;
transaction.commit().map_err(database_error)?;
} else {
@@ -518,6 +566,21 @@ impl Store {
)
.ok_or("Review exceeds the 64 MiB limit")?;
}
for file in &review.reviewed_files {
size = size
.checked_add(
file.revisions[0].len()
+ file.revisions[1].len()
+ file.path.as_os_str().len()
+ file
.old_path
.as_ref()
.map_or(0, |path| path.as_os_str().len())
+ file.after.as_ref().map_or(0, String::len)
+ 64,
)
.ok_or("Review exceeds the 64 MiB limit")?;
}
if size > LIMIT {
return Err("Review exceeds the 64 MiB limit".into());
}
@@ -550,7 +613,7 @@ impl Store {
.map_err(database_error)?;
transaction.last_insert_rowid()
};
// ponytail: replace comment rows per save; diff rows if snapshot writes become costly.
// ponytail: replace child rows per save; diff rows if snapshot writes become costly.
transaction
.execute("DELETE FROM comments WHERE review_id = ?1", [id])
.map_err(database_error)?;
@@ -584,6 +647,31 @@ impl Store {
.map_err(database_error)?;
}
}
transaction
.execute("DELETE FROM reviewed_files WHERE review_id = ?1", [id])
.map_err(database_error)?;
{
let mut insert = transaction
.prepare(
"INSERT INTO reviewed_files (review_id, position, before_revision,
after_revision, path, old_path, after_content)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)
.map_err(database_error)?;
for (position, file) in review.reviewed_files.iter().enumerate() {
insert
.execute(params![
id,
sql_index(position)?,
file.revisions[0],
file.revisions[1],
file.path.to_str().unwrap(),
file.old_path.as_ref().map(|path| path.to_str().unwrap()),
file.after,
])
.map_err(database_error)?;
}
}
transaction.commit().map_err(database_error)?;
review.storage_id = Some(id);
review.storage_revision = revision;
@@ -624,17 +712,23 @@ fn stored_review(
.map_err(database_error)?
.as_str()
.map_err(|error| error.to_string())?;
let comment_bytes: i64 = connection
let stored_bytes: i64 = connection
.query_row(
"SELECT COALESCE(SUM(length(CAST(body AS BLOB)) + length(CAST(anchor_revision AS BLOB))
+ length(CAST(path AS BLOB)) + length(CAST(content AS BLOB)) + 64), 0)
FROM comments WHERE review_id = ?1",
"SELECT COALESCE(SUM(size), 0) FROM (
SELECT length(CAST(body AS BLOB)) + length(CAST(anchor_revision AS BLOB))
+ length(CAST(path AS BLOB)) + length(CAST(content AS BLOB)) + 64 AS size
FROM comments WHERE review_id = ?1
UNION ALL
SELECT length(CAST(before_revision AS BLOB)) + length(CAST(after_revision AS BLOB))
+ length(CAST(path AS BLOB)) + COALESCE(length(CAST(old_path AS BLOB)), 0)
+ COALESCE(length(CAST(after_content AS BLOB)), 0) + 64
FROM reviewed_files WHERE review_id = ?1)",
[id],
|row| row.get(0),
)
.map_err(database_error)?;
if comment_bytes < 0
|| comment_bytes as u64 + stored_root.len() as u64 + summary.len() as u64 > LIMIT as u64
if stored_bytes < 0
|| stored_bytes as u64 + stored_root.len() as u64 + summary.len() as u64 > LIMIT as u64
{
return Err("Review exceeds the 64 MiB limit".into());
}
@@ -699,6 +793,30 @@ fn stored_review(
},
});
}
let mut statement = connection
.prepare(
"SELECT position, before_revision, after_revision, path, old_path, after_content
FROM reviewed_files WHERE review_id = ?1 ORDER BY position",
)
.map_err(database_error)?;
let mut rows = statement.query([id]).map_err(database_error)?;
while let Some(row) = rows.next().map_err(database_error)? {
if stored_index(row, 0)? != review.reviewed_files.len() {
return Err("Invalid stored reviewed-file order".into());
}
review.reviewed_files.push(ReviewedFile {
revisions: [
row.get(1).map_err(database_error)?,
row.get(2).map_err(database_error)?,
],
path: PathBuf::from(row.get::<_, String>(3).map_err(database_error)?),
old_path: row
.get::<_, Option<String>>(4)
.map_err(database_error)?
.map(PathBuf::from),
after: row.get(5).map_err(database_error)?,
});
}
review.validate()?;
review.storage_id = Some(id);
review.storage_revision = revision;