1034 lines
39 KiB
Rust
1034 lines
39 KiB
Rust
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;
|