use std::path::{Path, PathBuf};
use sqlx::sqlite::SqlitePoolOptions;
pub async fn backup_db(src: &Path, dst: &Path) -> Result<u64, sqlx::Error> {
if dst.exists() {
return Err(sqlx::Error::Configuration(
format!(
"snapshot destination already exists: {} (VACUUM INTO refuses to overwrite)",
dst.display()
)
.into(),
));
}
let url = format!("sqlite:{}?mode=ro", src.display());
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect(&url)
.await?;
let dst_str = dst
.to_str()
.ok_or_else(|| sqlx::Error::Configuration("dst path is not valid UTF-8".into()))?;
let escaped = dst_str.replace('\'', "''");
let sql = format!("VACUUM INTO '{escaped}'");
sqlx::query(&sql).execute(&pool).await?;
pool.close().await;
let size = std::fs::metadata(dst)
.map_err(|e| {
sqlx::Error::Configuration(format!("metadata({}): {e}", dst.display()).into())
})?
.len();
Ok(size)
}
pub async fn backup_named(
src: &Path,
dst_dir: &Path,
name: &str,
) -> Result<(PathBuf, u64), sqlx::Error> {
let dst = dst_dir.join(format!("{name}.sqlite"));
let size = backup_db(src, &dst).await?;
Ok((dst, size))
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::sqlite::SqliteConnectOptions;
use sqlx::{ConnectOptions, Connection};
use std::str::FromStr;
async fn seed_db(path: &Path, rows: i64) {
let opts = SqliteConnectOptions::from_str(&format!("sqlite:{}", path.display()))
.unwrap()
.create_if_missing(true);
let mut conn = opts.connect().await.unwrap();
sqlx::query("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
.execute(&mut conn)
.await
.unwrap();
for i in 0..rows {
sqlx::query("INSERT INTO t (id, v) VALUES (?, ?)")
.bind(i)
.bind(format!("row-{i}"))
.execute(&mut conn)
.await
.unwrap();
}
conn.close().await.unwrap();
}
async fn count_rows(db: &Path) -> i64 {
let opts =
SqliteConnectOptions::from_str(&format!("sqlite:{}?mode=ro", db.display())).unwrap();
let mut conn = opts.connect().await.unwrap();
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM t")
.fetch_one(&mut conn)
.await
.unwrap();
conn.close().await.unwrap();
n
}
#[tokio::test]
async fn round_trip_preserves_row_count() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src.sqlite");
seed_db(&src, 100).await;
let dst = tmp.path().join("snap.sqlite");
let size = backup_db(&src, &dst).await.unwrap();
assert!(size > 0);
assert!(dst.exists());
assert_eq!(count_rows(&dst).await, 100);
}
#[tokio::test]
async fn round_trip_empty_db_is_valid() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src.sqlite");
seed_db(&src, 0).await;
let dst = tmp.path().join("snap.sqlite");
backup_db(&src, &dst).await.unwrap();
assert_eq!(count_rows(&dst).await, 0);
}
#[tokio::test]
async fn refuses_existing_destination() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src.sqlite");
seed_db(&src, 1).await;
let dst = tmp.path().join("snap.sqlite");
std::fs::write(&dst, b"existing").unwrap();
let err = backup_db(&src, &dst).await.unwrap_err();
assert!(format!("{err}").contains("already exists"));
}
#[tokio::test]
async fn backup_named_picks_filename() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src.sqlite");
seed_db(&src, 5).await;
let dst_dir = tmp.path().join("out");
std::fs::create_dir(&dst_dir).unwrap();
let (path, size) = backup_named(&src, &dst_dir, "long_term").await.unwrap();
assert_eq!(path, dst_dir.join("long_term.sqlite"));
assert!(size > 0);
assert_eq!(count_rows(&path).await, 5);
}
}