Files
vamp/src/review.rs
T

1034 lines
39 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use rusqlite::{Connection, OpenFlags, TransactionBehavior, params};
use serde::{Deserialize, Serialize};
use std::{
cell::RefCell,
collections::BTreeSet,
ffi::OsStr,
fmt::Write as _,
fs::{self, OpenOptions},
io::{Read, Write},
path::{Path, PathBuf},
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
};
use unicode_segmentation::UnicodeSegmentation;
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 repository context; source comments use the exact buffer's Git blob identity.
pub revision: String,
pub path: PathBuf,
pub side: Side,
/// Zero-based extended-grapheme bounds; `end` is exclusive.
pub start: crate::plugin::TextPosition,
pub end: crate::plugin::TextPosition,
/// 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 rows(&self) -> std::ops::RangeInclusive<usize> {
let end = self.end.row - usize::from(self.end.column == 0 && self.end.row > self.start.row);
self.start.row..=end
}
pub fn excerpt(&self) -> &str {
let offset = |position: crate::plugin::TextPosition| {
let line = self.content.split('\n').nth(position.row).unwrap_or("");
self.content
.split('\n')
.take(position.row)
.map(|line| line.len() + 1)
.sum::<usize>()
+ line
.grapheme_indices(true)
.nth(position.column)
.map_or(line.len(), |(index, _)| index)
};
&self.content[offset(self.start)..offset(self.end)]
}
/// Map an unchanged selection through edits without changing its saved snapshot.
pub fn mapped_range(
&self,
revision: &str,
path: &Path,
side: Side,
lines: &[String],
eof: bool,
) -> Option<std::ops::Range<crate::plugin::TextPosition>> {
use crate::plugin::TextPosition;
if self.path != path
|| self.side != side
|| (self.revision != revision
&& !(self.revision.starts_with("working:") && revision.starts_with("working:")))
{
return None;
}
if self.content.split('\n').eq(lines
.iter()
.map(String::as_str)
.chain((eof || lines.is_empty()).then_some("")))
{
return Some(self.start..self.end);
}
let mut content = lines.join("\n");
if eof && !lines.is_empty() {
content.push('\n');
}
// Split LF explicitly: editor columns count a retained CR separately.
fn tokens(text: &str) -> Vec<&str> {
text.split_inclusive('\n')
.flat_map(|line| {
line.strip_suffix('\n')
.unwrap_or(line)
.graphemes(true)
.chain(line.ends_with('\n').then_some("\n"))
})
.collect()
}
let before = tokens(&self.content);
let after = tokens(&content);
let index = |position: TextPosition| {
self.content
.split('\n')
.take(position.row)
.map(|line| line.graphemes(true).count() + 1)
.sum::<usize>()
+ position.column
};
let start = index(self.start);
let end = index(self.end);
if start == end {
return None;
}
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(10);
let (mut old, mut new) = (0, 0);
for op in similar::capture_diff_slices_deadline(
similar::Algorithm::Myers,
&before,
&after,
Some(deadline),
) {
let (tag, old_range, new_range) = op.as_tag_tuple();
let old_end = old + old_range.len();
if tag == similar::DiffTag::Equal && start >= old && end <= old_end {
// Use the unchanged context, not just potentially repeated selected text.
let byte_start: usize = before[..old].iter().map(|text| text.len()).sum();
let byte_len: usize = before[old..old_end].iter().map(|text| text.len()).sum();
let context = &self.content[byte_start..byte_start + byte_len];
if self.content.match_indices(context).take(2).count() != 1
|| content.match_indices(context).take(2).count() != 1
{
return None;
}
let mapped = new + start - old;
let position = |offset: usize| {
let mut position = TextPosition { row: 0, column: 0 };
for grapheme in &after[..offset] {
if *grapheme == "\n" {
position.row += 1;
position.column = 0;
} else {
position.column += 1;
}
}
position
};
return Some(position(mapped)..position(mapped + end - start));
}
old = old_end;
new += new_range.len();
}
None
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Comment {
pub id: u64,
pub anchor: Anchor,
pub body: String,
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 {
pub version: u32,
pub root: PathBuf,
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)]
pub storage_revision: i64,
#[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 initialized: bool,
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: 4,
root,
summary: String::new(),
verdict: None,
comments: Vec::new(),
reviewed_files: Vec::new(),
storage_id: None,
storage_revision: 0,
saved: RefCell::new(None),
}
}
pub fn summary_text(&self) -> String {
let resolved = self
.comments
.iter()
.filter(|comment| comment.resolved)
.count();
format!(
"Review ID: {}{}{}\n Verdict: {}; {} unresolved, {} resolved ({} total)\n",
self.storage_id
.map_or_else(|| "null".into(), |id| id.to_string()),
if self.summary.is_empty() { "" } else { ": " },
self.summary,
match self.verdict {
Some(Verdict::LooksGood) => "looks_good",
Some(Verdict::ChangesRequested) => "changes_requested",
None => "none",
},
self.comments.len() - resolved,
resolved,
self.comments.len(),
)
}
/// CLI show format, including the repository, summary, and selected comments.
pub fn format_text(&self, all: bool) -> String {
let mut text = format!(
"Repository: {}\n{}",
self.root.display(),
self.summary_text()
);
for comment in self
.comments
.iter()
.filter(|comment| all || !comment.resolved)
{
let anchor = &comment.anchor;
writeln!(
text,
"\nComment {} [{}] {} ({}) {}:{}–{}:{} @ {}\n{}\n{}",
comment.id,
if comment.resolved {
"resolved"
} else {
"unresolved"
},
anchor.path.display(),
match anchor.side {
Side::Before => "before",
Side::After => "after",
},
anchor.start.row,
anchor.start.column,
anchor.end.row,
anchor.end.column,
anchor.revision,
anchor.excerpt(),
comment.body,
)
.unwrap();
}
text
}
fn validate(&self) -> Result<(), String> {
if self.version != 4 {
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;
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());
}
if anchor.start > anchor.end {
return Err("Review ranges must be ordered and end-exclusive".into());
}
let mut end_exists = false;
for (row, line) in anchor.content.split('\n').enumerate() {
if row == anchor.start.row || row == anchor.end.row {
let columns = line.graphemes(true).count();
if (row == anchor.start.row && anchor.start.column > columns)
|| (row == anchor.end.row && anchor.end.column > columns)
{
return Err(
"Review columns must be within the anchor's grapheme text".into()
);
}
}
if row == anchor.end.row {
end_exists = true;
break;
}
}
if !end_exists {
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(())
}
fn from_json(bytes: &[u8]) -> Result<Self, String> {
#[derive(Deserialize)]
struct Header {
version: u32,
}
let header: Header = serde_json::from_slice(bytes)
.map_err(|error| format!("Invalid review JSON: {error}"))?;
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}"))
}
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::from_json(&bytes)?;
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 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(),
std::env::var_os("HOME").as_deref(),
)
}
fn directory_from(xdg: Option<&OsStr>, home: Option<&OsStr>) -> Result<PathBuf, String> {
if let Some(path) = xdg.map(Path::new).filter(|path| path.is_absolute()) {
return Ok(path.join("vamp/reviews"));
}
home.map(Path::new)
.filter(|path| path.is_absolute())
.map(|path| path.join(".local/share/vamp/reviews"))
.ok_or_else(|| "Cannot locate review storage: HOME must be an absolute directory".into())
}
const REVIEWS_SCHEMA: &str = "CREATE TABLE reviews (
id INTEGER PRIMARY KEY CHECK (id > 0),
root TEXT NOT NULL,
revision INTEGER NOT NULL CHECK (revision > 0),
summary TEXT NOT NULL,
verdict TEXT CHECK (verdict IN ('looks_good', 'changes_requested'))
) STRICT";
// SQLite integers are signed; decimal text preserves the model's full u64 comment IDs.
const COMMENTS_SCHEMA: &str = "CREATE TABLE comments (
review_id INTEGER NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
id TEXT NOT NULL CHECK (
length(id) BETWEEN 1 AND 20 AND id NOT GLOB '*[^0-9]*'
AND substr(id, 1, 1) BETWEEN '1' AND '9'
AND (length(id) < 20 OR id <= '18446744073709551615')
),
position INTEGER NOT NULL CHECK (position >= 0),
body TEXT NOT NULL,
resolved INTEGER NOT NULL CHECK (resolved IN (0, 1)),
anchor_revision TEXT NOT NULL CHECK (length(anchor_revision) > 0),
path TEXT NOT NULL,
side TEXT NOT NULL CHECK (side IN ('before', 'after')),
start_row INTEGER NOT NULL CHECK (start_row >= 0),
start_column INTEGER NOT NULL CHECK (start_column >= 0),
end_row INTEGER NOT NULL CHECK (end_row >= 0),
end_column INTEGER NOT NULL CHECK (end_column >= 0),
content TEXT NOT NULL,
PRIMARY KEY (review_id, id),
UNIQUE (review_id, position),
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,
}
impl Store {
/// Open existing storage without creating files, changing permissions, or migrating schemas.
pub fn open_read_only(directory: &Path) -> Result<Option<Self>, String> {
let path = directory.join("reviews.sqlite3");
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 database must be a regular file, not a symbolic link".into());
}
let path = fs::canonicalize(directory)
.map_err(|error| format!("Cannot resolve review directory: {error}"))?
.join("reviews.sqlite3");
let connection = Connection::open_with_flags(
&path,
OpenFlags::SQLITE_OPEN_READ_ONLY
| OpenFlags::SQLITE_OPEN_NO_MUTEX
| OpenFlags::SQLITE_OPEN_NOFOLLOW,
)
.map_err(database_error)?;
Self::check_schema(&connection)?;
connection
.pragma_update(None, "foreign_keys", true)
.map_err(database_error)?;
Ok(Some(Self { connection }))
}
fn check_schema(connection: &Connection) -> Result<(), String> {
let version: i64 = connection
.pragma_query_value(None, "user_version", |row| row.get(0))
.map_err(database_error)?;
let schema: Vec<String> = connection
.prepare("SELECT sql FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*' ORDER BY name")
.map_err(database_error)?
.query_map([], |row| row.get(0))
.map_err(database_error)?
.collect::<Result<_, _>>()
.map_err(database_error)?;
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"
));
}
Ok(())
}
pub fn open(directory: &Path) -> Result<Self, String> {
let mut builder = fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
builder.create(directory).map_err(|error| {
format!(
"Cannot create review directory {}: {error}",
directory.display()
)
})?;
let directory = fs::canonicalize(directory)
.map_err(|error| format!("Cannot resolve review directory: {error}"))?;
let path = directory.join("reviews.sqlite3");
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let created = match options.open(&path) {
Ok(_) => true,
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => false,
Err(error) => return Err(format!("Cannot create review database: {error}")),
};
let metadata = fs::symlink_metadata(&path).map_err(|error| error.to_string())?;
if !metadata.is_file() || metadata.file_type().is_symlink() {
return Err("Review database must be a regular file, not a symbolic link".into());
}
let mut connection = Connection::open_with_flags(
&path,
OpenFlags::SQLITE_OPEN_READ_WRITE
| OpenFlags::SQLITE_OPEN_NO_MUTEX
| OpenFlags::SQLITE_OPEN_NOFOLLOW,
)
.map_err(database_error)?;
connection
.pragma_update(None, "foreign_keys", true)
.map_err(database_error)?;
connection
.busy_timeout(std::time::Duration::ZERO)
.map_err(database_error)?;
if created {
let transaction = connection
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(database_error)?;
transaction
.execute_batch(REVIEWS_SCHEMA)
.map_err(database_error)?;
transaction
.execute_batch(COMMENTS_SCHEMA)
.map_err(database_error)?;
transaction
.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 {
Self::check_schema(&connection)?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))
.map_err(|error| format!("Cannot secure review database: {error}"))?;
}
Ok(Self { connection })
}
pub fn list(&self, root: &Path) -> Result<Vec<Review>, String> {
let root = fs::canonicalize(root).map_err(|error| error.to_string())?;
let root_text = root
.to_str()
.ok_or("Review repository root must be UTF-8")?;
let transaction = self
.connection
.unchecked_transaction()
.map_err(database_error)?;
let reviews = {
let mut statement = transaction
.prepare(
"SELECT id, root, revision, summary, verdict FROM reviews WHERE root = ?1 ORDER BY id DESC",
)
.map_err(database_error)?;
let mut rows = statement.query([root_text]).map_err(database_error)?;
let mut reviews = Vec::new();
while let Some(row) = rows.next().map_err(database_error)? {
reviews.push(stored_review(&transaction, row, &root)?);
}
reviews
};
transaction.commit().map_err(database_error)?;
Ok(reviews)
}
pub fn load(&self, root: &Path, id: i64) -> Result<Review, String> {
let root = fs::canonicalize(root).map_err(|error| error.to_string())?;
let root_text = root
.to_str()
.ok_or("Review repository root must be UTF-8")?;
let transaction = self
.connection
.unchecked_transaction()
.map_err(database_error)?;
let review = {
let mut statement = transaction
.prepare("SELECT id, root, revision, summary, verdict FROM reviews WHERE id = ?1 AND root = ?2")
.map_err(database_error)?;
let mut rows = statement
.query(params![id, root_text])
.map_err(database_error)?;
let row = rows
.next()
.map_err(database_error)?
.ok_or_else(|| format!(
"Review ID {id} not found in {}.\nRun `vamp --review list` to find review IDs for this repository.",
root.display()
))?;
stored_review(&transaction, row, &root)?
};
transaction.commit().map_err(database_error)?;
Ok(review)
}
pub fn save(&mut self, review: &mut Review) -> Result<(), String> {
review.validate()?;
let root = fs::canonicalize(&review.root).map_err(|error| error.to_string())?;
if root != review.root {
return Err("Review repository root must be canonical".into());
}
let root = root
.to_str()
.ok_or("Review repository root must be UTF-8")?;
let revision = match review.storage_id {
Some(id) if id > 0 && review.storage_revision > 0 => review
.storage_revision
.checked_add(1)
.ok_or("Review storage revision is exhausted")?,
None if review.storage_revision == 0 => 1,
_ => return Err("Invalid review storage identity".into()),
};
let mut size = review.root.as_os_str().len() + review.summary.len();
for comment in &review.comments {
size = size
.checked_add(
comment.body.len()
+ comment.anchor.revision.len()
+ comment.anchor.path.as_os_str().len()
+ comment.anchor.content.len()
+ 64,
)
.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());
}
let verdict = match review.verdict {
Some(Verdict::LooksGood) => Some("looks_good"),
Some(Verdict::ChangesRequested) => Some("changes_requested"),
None => None,
};
let transaction = self
.connection
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(database_error)?;
let id = if let Some(id) = review.storage_id {
let changed = transaction.execute(
"UPDATE reviews SET revision = ?1, summary = ?2, verdict = ?3 WHERE id = ?4 AND root = ?5 AND revision = ?6",
params![revision, review.summary, verdict, id, root, review.storage_revision],
).map_err(database_error)?;
if changed != 1 {
return Err(
"Review changed in another session; draft kept, reload before saving".into(),
);
}
id
} else {
transaction
.execute(
"INSERT INTO reviews (root, revision, summary, verdict) VALUES (?1, ?2, ?3, ?4)",
params![root, revision, review.summary, verdict],
)
.map_err(database_error)?;
transaction.last_insert_rowid()
};
// 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)?;
{
let mut insert = transaction.prepare(
"INSERT INTO comments (review_id, id, position, body, resolved, anchor_revision, path, side,
start_row, start_column, end_row, end_column, content)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
).map_err(database_error)?;
for (position, comment) in review.comments.iter().enumerate() {
let anchor = &comment.anchor;
insert
.execute(params![
id,
comment.id.to_string(),
sql_index(position)?,
comment.body,
comment.resolved,
anchor.revision,
anchor.path.to_str().unwrap(),
match anchor.side {
Side::Before => "before",
Side::After => "after",
},
sql_index(anchor.start.row)?,
sql_index(anchor.start.column)?,
sql_index(anchor.end.row)?,
sql_index(anchor.end.column)?,
anchor.content,
])
.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;
Ok(())
}
}
fn database_error(error: rusqlite::Error) -> String {
format!("Review database: {error}")
}
fn sql_index(value: usize) -> Result<i64, String> {
i64::try_from(value).map_err(|_| "Comment coordinate exceeds SQLite's integer range".into())
}
fn stored_index(row: &rusqlite::Row<'_>, index: usize) -> Result<usize, String> {
usize::try_from(row.get::<_, i64>(index).map_err(database_error)?)
.map_err(|_| "Stored comment coordinate exceeds this platform's range".into())
}
fn stored_review(
connection: &Connection,
row: &rusqlite::Row<'_>,
root: &Path,
) -> Result<Review, String> {
let id: i64 = row.get(0).map_err(database_error)?;
let stored_root = row
.get_ref(1)
.map_err(database_error)?
.as_str()
.map_err(|error| error.to_string())?;
let revision: i64 = row.get(2).map_err(database_error)?;
if id <= 0 || revision <= 0 || Path::new(stored_root) != root {
return Err("Invalid review database identity".into());
}
let summary = row
.get_ref(3)
.map_err(database_error)?
.as_str()
.map_err(|error| error.to_string())?;
let stored_bytes: i64 = connection
.query_row(
"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 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());
}
let mut review = Review {
root: root.to_owned(),
..Review::default()
};
review.summary = summary.to_owned();
review.verdict = match row
.get::<_, Option<String>>(4)
.map_err(database_error)?
.as_deref()
{
Some("looks_good") => Some(Verdict::LooksGood),
Some("changes_requested") => Some(Verdict::ChangesRequested),
None => None,
_ => return Err("Invalid stored review verdict".into()),
};
let mut statement = connection
.prepare(
"SELECT id, position, body, resolved, anchor_revision, path, side,
start_row, start_column, end_row, end_column, content
FROM comments 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, 1)? != review.comments.len() {
return Err("Invalid stored comment order".into());
}
let side = match row.get::<_, String>(6).map_err(database_error)?.as_str() {
"before" => Side::Before,
"after" => Side::After,
_ => return Err("Invalid stored comment side".into()),
};
let resolved = match row.get::<_, i64>(3).map_err(database_error)? {
0 => false,
1 => true,
_ => return Err("Invalid stored comment resolution".into()),
};
review.comments.push(Comment {
id: row
.get::<_, String>(0)
.map_err(database_error)?
.parse::<u64>()
.map_err(|_| "Invalid stored comment ID")?,
body: row.get(2).map_err(database_error)?,
resolved,
anchor: Anchor {
revision: row.get(4).map_err(database_error)?,
path: PathBuf::from(row.get::<_, String>(5).map_err(database_error)?),
side,
start: crate::plugin::TextPosition {
row: stored_index(row, 7)?,
column: stored_index(row, 8)?,
},
end: crate::plugin::TextPosition {
row: stored_index(row, 9)?,
column: stored_index(row, 10)?,
},
content: row.get(11).map_err(database_error)?,
},
});
}
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;
Ok(review)
}
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)]
#[path = "../tests/unit/review.rs"]
mod tests;