feat: store local reviews transactionally in SQLite
This commit is contained in:
Generated
+82
@@ -385,6 +385,18 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-iterator"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-streaming-iterator"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fancy-regex"
|
name = "fancy-regex"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
@@ -539,6 +551,15 @@ dependencies = [
|
|||||||
"foldhash",
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashlink"
|
||||||
|
version = "0.12.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.17.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -666,6 +687,17 @@ version = "0.2.16"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libsqlite3-sys"
|
||||||
|
version = "0.38.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "line-clipping"
|
name = "line-clipping"
|
||||||
version = "0.3.8"
|
version = "0.3.8"
|
||||||
@@ -1009,6 +1041,12 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkg-config"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portable-atomic"
|
name = "portable-atomic"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
@@ -1215,6 +1253,31 @@ version = "0.8.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rsqlite-vfs"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.16.1",
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusqlite"
|
||||||
|
version = "0.40.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"fallible-iterator",
|
||||||
|
"fallible-streaming-iterator",
|
||||||
|
"hashlink",
|
||||||
|
"libsqlite3-sys",
|
||||||
|
"smallvec",
|
||||||
|
"sqlite-wasm-rs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -1427,6 +1490,18 @@ version = "1.16.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
|
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlite-wasm-rs"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"js-sys",
|
||||||
|
"rsqlite-vfs",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stacker"
|
name = "stacker"
|
||||||
version = "0.1.25"
|
version = "0.1.25"
|
||||||
@@ -1923,6 +1998,7 @@ dependencies = [
|
|||||||
"crossterm",
|
"crossterm",
|
||||||
"ignore",
|
"ignore",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"similar",
|
"similar",
|
||||||
@@ -1948,6 +2024,12 @@ dependencies = [
|
|||||||
"unicode-width",
|
"unicode-width",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ edition = "2024"
|
|||||||
crossterm = "0.29"
|
crossterm = "0.29"
|
||||||
ignore = "0.4"
|
ignore = "0.4"
|
||||||
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "underline-color", "unstable-backend-writer", "unstable-rendered-line-info"] }
|
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "underline-color", "unstable-backend-writer", "unstable-rendered-line-info"] }
|
||||||
|
rusqlite = { version = "0.40.2", features = ["bundled"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
similar = { version = "2", default-features = false }
|
similar = { version = "2", default-features = false }
|
||||||
|
|||||||
+525
@@ -1,7 +1,9 @@
|
|||||||
|
use rusqlite::{Connection, OpenFlags, TransactionBehavior, params};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{
|
use std::{
|
||||||
cell::RefCell,
|
cell::RefCell,
|
||||||
collections::BTreeSet,
|
collections::BTreeSet,
|
||||||
|
ffi::OsStr,
|
||||||
fs::{self, OpenOptions},
|
fs::{self, OpenOptions},
|
||||||
io::{Read, Write},
|
io::{Read, Write},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
@@ -96,6 +98,10 @@ pub struct Review {
|
|||||||
pub verdict: Option<Verdict>,
|
pub verdict: Option<Verdict>,
|
||||||
pub comments: Vec<Comment>,
|
pub comments: Vec<Comment>,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
|
pub storage_id: Option<i64>,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub storage_revision: i64,
|
||||||
|
#[serde(skip)]
|
||||||
saved: RefCell<Option<(PathBuf, Arc<Vec<u8>>)>>,
|
saved: RefCell<Option<(PathBuf, Arc<Vec<u8>>)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +133,8 @@ impl Review {
|
|||||||
summary: String::new(),
|
summary: String::new(),
|
||||||
verdict: None,
|
verdict: None,
|
||||||
comments: Vec::new(),
|
comments: Vec::new(),
|
||||||
|
storage_id: None,
|
||||||
|
storage_revision: 0,
|
||||||
saved: RefCell::new(None),
|
saved: RefCell::new(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,6 +254,244 @@ impl Review {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: whole-review JSON commits; normalize comment rows if large reviews make writes costly.
|
||||||
|
const SCHEMA: &str = "CREATE TABLE reviews (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id > 0),
|
||||||
|
root TEXT NOT NULL,
|
||||||
|
revision INTEGER NOT NULL CHECK (revision > 0),
|
||||||
|
document TEXT NOT NULL CHECK (length(CAST(document AS BLOB)) <= 67108864)
|
||||||
|
) STRICT";
|
||||||
|
|
||||||
|
pub struct Store {
|
||||||
|
connection: Connection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Store {
|
||||||
|
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
|
||||||
|
.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(SCHEMA).map_err(database_error)?;
|
||||||
|
transaction
|
||||||
|
.pragma_update(None, "user_version", 1)
|
||||||
|
.map_err(database_error)?;
|
||||||
|
transaction.commit().map_err(database_error)?;
|
||||||
|
} else {
|
||||||
|
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 != 1 || schema != [SCHEMA] {
|
||||||
|
return Err(format!(
|
||||||
|
"Unsupported review database schema (version {version}); existing data was not changed"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[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<(i64, String)>, 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 mut statement = self
|
||||||
|
.connection
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, root, revision, document 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 choices = Vec::new();
|
||||||
|
while let Some(row) = rows.next().map_err(database_error)? {
|
||||||
|
let review = stored_review(row, &root)?;
|
||||||
|
let id = review.storage_id.unwrap();
|
||||||
|
let label = review
|
||||||
|
.summary
|
||||||
|
.lines()
|
||||||
|
.find(|line| !line.trim().is_empty())
|
||||||
|
.map_or_else(|| format!("Review {id}"), |line| line.trim().to_owned());
|
||||||
|
choices.push((id, label));
|
||||||
|
}
|
||||||
|
Ok(choices)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 mut statement = self
|
||||||
|
.connection
|
||||||
|
.prepare("SELECT id, root, revision, document 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("Review does not exist in this repository")?;
|
||||||
|
stored_review(row, &root)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 bytes = serde_json::to_vec(review).map_err(|error| error.to_string())?;
|
||||||
|
if bytes.len() > LIMIT {
|
||||||
|
return Err("Review exceeds the 64 MiB limit".into());
|
||||||
|
}
|
||||||
|
let document = std::str::from_utf8(&bytes).map_err(|error| error.to_string())?;
|
||||||
|
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, document = ?2 WHERE id = ?3 AND root = ?4 AND revision = ?5",
|
||||||
|
params![revision, document, 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, document) VALUES (?1, ?2, ?3)",
|
||||||
|
params![root, revision, document],
|
||||||
|
)
|
||||||
|
.map_err(database_error)?;
|
||||||
|
transaction.last_insert_rowid()
|
||||||
|
};
|
||||||
|
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 stored_review(row: &rusqlite::Row<'_>, root: &Path) -> Result<Review, String> {
|
||||||
|
let id: i64 = row.get(0).map_err(database_error)?;
|
||||||
|
let stored_root: String = row.get(1).map_err(database_error)?;
|
||||||
|
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 document = row.get_ref(3).map_err(database_error)?;
|
||||||
|
let bytes = document
|
||||||
|
.as_str()
|
||||||
|
.map_err(|error| format!("Invalid stored review: {error}"))?
|
||||||
|
.as_bytes();
|
||||||
|
if bytes.len() > LIMIT {
|
||||||
|
return Err("Review exceeds the 64 MiB limit".into());
|
||||||
|
}
|
||||||
|
let mut review: Review = serde_json::from_slice(bytes)
|
||||||
|
.map_err(|error| format!("Invalid stored review JSON: {error}"))?;
|
||||||
|
review.validate()?;
|
||||||
|
if review.root != root {
|
||||||
|
return Err("Stored review belongs to a different repository".into());
|
||||||
|
}
|
||||||
|
review.storage_id = Some(id);
|
||||||
|
review.storage_revision = revision;
|
||||||
|
Ok(review)
|
||||||
|
}
|
||||||
|
|
||||||
fn identity(path: &Path) -> Result<PathBuf, String> {
|
fn identity(path: &Path) -> Result<PathBuf, String> {
|
||||||
// Resolve the parent only: never silently follow a target symlink.
|
// Resolve the parent only: never silently follow a target symlink.
|
||||||
let parent = path
|
let parent = path
|
||||||
@@ -311,6 +557,285 @@ mod tests {
|
|||||||
directory
|
directory
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn database_restarts_preserve_identity_and_exact_stale_anchors() {
|
||||||
|
let directory = directory();
|
||||||
|
let root = directory.0.canonicalize().unwrap();
|
||||||
|
let storage = root.join("storage");
|
||||||
|
let mut review = Review::new(root.clone());
|
||||||
|
review.summary = "Unicode review\nMore context".into();
|
||||||
|
review.verdict = Some(Verdict::ChangesRequested);
|
||||||
|
review.comments.push(Comment {
|
||||||
|
id: 7,
|
||||||
|
anchor: Anchor {
|
||||||
|
revision: "working:old-snapshot".into(),
|
||||||
|
path: "日本語.rs".into(),
|
||||||
|
side: Side::Before,
|
||||||
|
start: crate::plugin::TextPosition { row: 0, column: 1 },
|
||||||
|
end: crate::plugin::TextPosition { row: 1, column: 1 },
|
||||||
|
content: "a👩🔬e\u{301}\r\n界x\n".into(),
|
||||||
|
},
|
||||||
|
body: "Please preserve 👩🔬".into(),
|
||||||
|
});
|
||||||
|
{
|
||||||
|
let mut store = Store::open(&storage).unwrap();
|
||||||
|
store.save(&mut review).unwrap();
|
||||||
|
assert!(review.storage_id.is_some());
|
||||||
|
assert_eq!(review.storage_revision, 1);
|
||||||
|
}
|
||||||
|
let mut store = Store::open(&storage).unwrap();
|
||||||
|
let id = review.storage_id.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.list(&root).unwrap(),
|
||||||
|
vec![(id, "Unicode review".into())]
|
||||||
|
);
|
||||||
|
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.verdict, review.verdict);
|
||||||
|
assert!(!loaded.comments[0].anchor.matches(
|
||||||
|
"working:new-snapshot",
|
||||||
|
Path::new("日本語.rs"),
|
||||||
|
Side::Before,
|
||||||
|
"changed\n",
|
||||||
|
));
|
||||||
|
let path = root.join("export.json");
|
||||||
|
loaded.save(&path).unwrap();
|
||||||
|
let exported = fs::read(&path).unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&exported).unwrap();
|
||||||
|
assert_eq!(json["version"], 2);
|
||||||
|
assert!(json.get("storage_id").is_none());
|
||||||
|
assert!(json.get("storage_revision").is_none());
|
||||||
|
loaded.summary = "Database only".into();
|
||||||
|
store.save(&mut loaded).unwrap();
|
||||||
|
assert_eq!(loaded.storage_id, Some(id));
|
||||||
|
assert_eq!(loaded.storage_revision, 2);
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), exported);
|
||||||
|
let mut imported = Review::load(&path, &root).unwrap();
|
||||||
|
assert_eq!(imported.storage_id, None);
|
||||||
|
assert_eq!(imported.storage_revision, 0);
|
||||||
|
store.save(&mut imported).unwrap();
|
||||||
|
assert_ne!(imported.storage_id, Some(id));
|
||||||
|
assert_eq!(store.list(&root).unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn database_repository_identity_is_canonical_and_independent() {
|
||||||
|
let first = directory();
|
||||||
|
let second = directory();
|
||||||
|
let first_root = first.0.canonicalize().unwrap();
|
||||||
|
let second_root = second.0.canonicalize().unwrap();
|
||||||
|
let mut store = Store::open(&first_root.join("storage")).unwrap();
|
||||||
|
let mut a = Review::new(first_root.clone());
|
||||||
|
let mut b = Review::new(second_root.clone());
|
||||||
|
store.save(&mut a).unwrap();
|
||||||
|
store.save(&mut b).unwrap();
|
||||||
|
assert_ne!(a.storage_id, b.storage_id);
|
||||||
|
assert_eq!(store.list(&first_root).unwrap().len(), 1);
|
||||||
|
assert_eq!(store.list(&second_root).unwrap().len(), 1);
|
||||||
|
assert!(store.load(&second_root, a.storage_id.unwrap()).is_err());
|
||||||
|
let id = a.storage_id;
|
||||||
|
let revision = a.storage_revision;
|
||||||
|
a.root = second_root;
|
||||||
|
assert!(store.save(&mut a).is_err());
|
||||||
|
assert_eq!((a.storage_id, a.storage_revision), (id, revision));
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let alias = first_root.join("alias");
|
||||||
|
std::os::unix::fs::symlink(&first_root, &alias).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.list(&alias).unwrap(),
|
||||||
|
store.list(&first_root).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn database_conflicts_and_locked_writes_keep_drafts_and_identity() {
|
||||||
|
let directory = directory();
|
||||||
|
let root = directory.0.canonicalize().unwrap();
|
||||||
|
let storage = root.join("storage");
|
||||||
|
let mut first = Store::open(&storage).unwrap();
|
||||||
|
let mut second = Store::open(&storage).unwrap();
|
||||||
|
let mut review = Review::new(root.clone());
|
||||||
|
first.save(&mut review).unwrap();
|
||||||
|
let id = review.storage_id.unwrap();
|
||||||
|
let mut draft = second.load(&root, id).unwrap();
|
||||||
|
review.summary = "First writer".into();
|
||||||
|
first.save(&mut review).unwrap();
|
||||||
|
draft.summary = "Second writer draft".into();
|
||||||
|
assert!(
|
||||||
|
second
|
||||||
|
.save(&mut draft)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("another session")
|
||||||
|
);
|
||||||
|
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");
|
||||||
|
first.connection.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||||
|
let mut fresh = Review::new(root.clone());
|
||||||
|
assert!(second.save(&mut fresh).unwrap_err().contains("locked"));
|
||||||
|
assert_eq!((fresh.storage_id, fresh.storage_revision), (None, 0));
|
||||||
|
first.connection.execute_batch("ROLLBACK").unwrap();
|
||||||
|
second.save(&mut fresh).unwrap();
|
||||||
|
assert!(fresh.storage_id.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incompatible_database_schemas_are_preserved() {
|
||||||
|
for schema in [
|
||||||
|
"CREATE TABLE other (value TEXT); PRAGMA user_version = 42",
|
||||||
|
"CREATE TABLE other (value TEXT); PRAGMA user_version = 1",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
let directory = directory();
|
||||||
|
let path = directory.0.join("reviews.sqlite3");
|
||||||
|
let connection = Connection::open(&path).unwrap();
|
||||||
|
connection.execute_batch(schema).unwrap();
|
||||||
|
drop(connection);
|
||||||
|
let original = fs::read(&path).unwrap();
|
||||||
|
assert!(Store::open(&directory.0).err().unwrap().contains("schema"));
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), original);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_or_mismatched_database_documents_are_rejected() {
|
||||||
|
let other = directory();
|
||||||
|
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());
|
||||||
|
store.save(&mut review).unwrap();
|
||||||
|
let id = review.storage_id.unwrap();
|
||||||
|
let mut wrong_root = serde_json::to_value(&review).unwrap();
|
||||||
|
wrong_root["root"] = serde_json::json!(other.0.canonicalize().unwrap());
|
||||||
|
let mut invalid_version = serde_json::to_value(&review).unwrap();
|
||||||
|
invalid_version["version"] = 1.into();
|
||||||
|
let mut inherited_identity = serde_json::to_value(&review).unwrap();
|
||||||
|
inherited_identity["storage_id"] = id.into();
|
||||||
|
for document in [
|
||||||
|
"{".to_owned(),
|
||||||
|
serde_json::to_string(&wrong_root).unwrap(),
|
||||||
|
serde_json::to_string(&invalid_version).unwrap(),
|
||||||
|
serde_json::to_string(&inherited_identity).unwrap(),
|
||||||
|
] {
|
||||||
|
store
|
||||||
|
.connection
|
||||||
|
.execute(
|
||||||
|
"UPDATE reviews SET document = ?1 WHERE id = ?2",
|
||||||
|
params![document, id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(store.load(&root, id).is_err());
|
||||||
|
assert!(store.list(&root).is_err());
|
||||||
|
}
|
||||||
|
store
|
||||||
|
.connection
|
||||||
|
.execute_batch("PRAGMA ignore_check_constraints = ON")
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.connection
|
||||||
|
.execute(
|
||||||
|
"UPDATE reviews SET document = CAST(zeroblob(?1) AS TEXT) WHERE 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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_database_and_export_writes_preserve_metadata_and_documents() {
|
||||||
|
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());
|
||||||
|
store.save(&mut review).unwrap();
|
||||||
|
let identity = (review.storage_id, review.storage_revision);
|
||||||
|
store
|
||||||
|
.connection
|
||||||
|
.execute_batch("PRAGMA query_only = ON")
|
||||||
|
.unwrap();
|
||||||
|
review.summary = "Unsaved draft".into();
|
||||||
|
assert!(store.save(&mut review).is_err());
|
||||||
|
assert_eq!((review.storage_id, review.storage_revision), identity);
|
||||||
|
assert_eq!(review.summary, "Unsaved draft");
|
||||||
|
assert_eq!(store.load(&root, identity.0.unwrap()).unwrap().summary, "");
|
||||||
|
let mut fresh = Review::new(root.clone());
|
||||||
|
assert!(store.save(&mut fresh).is_err());
|
||||||
|
assert_eq!((fresh.storage_id, fresh.storage_revision), (None, 0));
|
||||||
|
store
|
||||||
|
.connection
|
||||||
|
.execute_batch("PRAGMA query_only = OFF")
|
||||||
|
.unwrap();
|
||||||
|
review.summary = "x".repeat(LIMIT);
|
||||||
|
assert!(store.save(&mut review).unwrap_err().contains("64 MiB"));
|
||||||
|
assert!(
|
||||||
|
review
|
||||||
|
.save(&root.join("oversized.json"))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("64 MiB")
|
||||||
|
);
|
||||||
|
assert!(!root.join("oversized.json").exists());
|
||||||
|
assert_eq!((review.storage_id, review.storage_revision), identity);
|
||||||
|
assert_eq!(store.load(&root, identity.0.unwrap()).unwrap().summary, "");
|
||||||
|
let blocked = root.join("not-a-directory");
|
||||||
|
fs::write(&blocked, b"preserve").unwrap();
|
||||||
|
assert!(Store::open(&blocked).is_err());
|
||||||
|
assert_eq!(fs::read(&blocked).unwrap(), b"preserve");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn database_files_are_private_and_target_symlinks_are_refused() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let directory = directory();
|
||||||
|
let storage = directory.0.join("storage");
|
||||||
|
drop(Store::open(&storage).unwrap());
|
||||||
|
let path = storage.join("reviews.sqlite3");
|
||||||
|
assert_eq!(
|
||||||
|
fs::metadata(&storage).unwrap().permissions().mode() & 0o777,
|
||||||
|
0o700
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||||
|
0o600
|
||||||
|
);
|
||||||
|
let original = fs::read(&path).unwrap();
|
||||||
|
let links = directory.0.join("links");
|
||||||
|
fs::create_dir(&links).unwrap();
|
||||||
|
std::os::unix::fs::symlink(&path, links.join("reviews.sqlite3")).unwrap();
|
||||||
|
assert!(Store::open(&links).err().unwrap().contains("symbolic link"));
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_directory_uses_absolute_xdg_then_absolute_home() {
|
||||||
|
let resolve = |xdg: Option<&str>, home: Option<&str>| {
|
||||||
|
directory_from(xdg.map(OsStr::new), home.map(OsStr::new))
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
resolve(Some("/data"), Some("/home/me")).unwrap(),
|
||||||
|
Path::new("/data/vamp/reviews")
|
||||||
|
);
|
||||||
|
for xdg in [None, Some("relative"), Some("")] {
|
||||||
|
assert_eq!(
|
||||||
|
resolve(xdg, Some("/home/me")).unwrap(),
|
||||||
|
Path::new("/home/me/.local/share/vamp/reviews")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
resolve(Some("/data"), None).unwrap(),
|
||||||
|
Path::new("/data/vamp/reviews")
|
||||||
|
);
|
||||||
|
assert!(resolve(None, None).is_err());
|
||||||
|
assert!(resolve(None, Some("relative")).is_err());
|
||||||
|
assert!(resolve(None, Some("")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn persistence_validates_anchors_and_preserves_files_on_failure() {
|
fn persistence_validates_anchors_and_preserves_files_on_failure() {
|
||||||
let directory = directory();
|
let directory = directory();
|
||||||
|
|||||||
Reference in New Issue
Block a user