use std::collections::HashMap;
use anyhow::Result;
use rusqlite::types::Value;
use rusqlite::OptionalExtension;
use super::db::Connection;
use crate::domain::scan::{FileObservation, Reconciliation};
use crate::domain::source::{NewSource, Source};
pub const BATCH_SIZE: usize = 1000;
const SOURCE_COLUMNS: &str = r#"
s.id,
s.root_id,
r.path as root_path,
s.rel_path,
s.object_id,
s.size,
s.mtime,
s.excluded,
o.excluded as object_excluded,
s.device,
s.inode,
s.partial_hash,
s.basis_rev,
r.role as root_role,
r.suspended as root_suspended
"#;
const SOURCE_FROM: &str = r#"
FROM sources s
JOIN roots r ON s.root_id = r.id
LEFT JOIN objects o ON s.object_id = o.id
"#;
fn source_from_row(row: &rusqlite::Row) -> rusqlite::Result<Source> {
Ok(Source {
id: row.get(0)?,
root_id: row.get(1)?,
root_path: row.get(2)?,
rel_path: row.get(3)?,
object_id: row.get(4)?,
size: row.get(5)?,
mtime: row.get(6)?,
excluded: row.get(7)?,
object_excluded: row.get(8)?,
device: row.get(9)?,
inode: row.get(10)?,
partial_hash: row.get(11)?,
basis_rev: row.get(12)?,
root_role: row.get(13)?,
root_suspended: row.get(14)?,
})
}
pub fn batch_fetch_by_roots(conn: &Connection, root_ids: &[i64]) -> Result<Vec<Source>> {
if root_ids.is_empty() {
return Ok(Vec::new());
}
let mut sources = Vec::new();
for chunk in root_ids.chunks(BATCH_SIZE) {
let placeholders: Vec<&str> = chunk.iter().map(|_| "?").collect();
let sql = format!(
"SELECT {} {} WHERE s.present = 1 AND s.root_id IN ({})",
SOURCE_COLUMNS,
SOURCE_FROM,
placeholders.join(",")
);
let params: Vec<Value> = chunk.iter().map(|&id| Value::from(id)).collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params), source_from_row)?;
for row in rows {
sources.push(row?);
}
}
Ok(sources)
}
pub fn batch_fetch_by_ids(conn: &Connection, source_ids: &[i64]) -> Result<HashMap<i64, Source>> {
if source_ids.is_empty() {
return Ok(HashMap::new());
}
let mut sources = HashMap::with_capacity(source_ids.len());
for chunk in source_ids.chunks(BATCH_SIZE) {
let placeholders: Vec<&str> = chunk.iter().map(|_| "?").collect();
let sql = format!(
"SELECT {} {} WHERE s.present = 1 AND s.id IN ({})",
SOURCE_COLUMNS,
SOURCE_FROM,
placeholders.join(",")
);
let params: Vec<Value> = chunk.iter().map(|&id| Value::from(id)).collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params), source_from_row)?;
for row in rows {
let source = row?;
sources.insert(source.id, source);
}
}
Ok(sources)
}
pub fn fetch_sources_by_object_ids(
conn: &Connection,
object_ids: &[i64],
) -> Result<HashMap<i64, Vec<Source>>> {
if object_ids.is_empty() {
return Ok(HashMap::new());
}
let mut result: HashMap<i64, Vec<Source>> = HashMap::new();
for chunk in object_ids.chunks(BATCH_SIZE) {
let placeholders: Vec<&str> = chunk.iter().map(|_| "?").collect();
let sql = format!(
"SELECT {} {} WHERE s.present = 1 AND s.object_id IN ({})",
SOURCE_COLUMNS,
SOURCE_FROM,
placeholders.join(",")
);
let params: Vec<Value> = chunk.iter().map(|&id| Value::from(id)).collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params), source_from_row)?;
for row in rows {
let source = row?;
if let Some(object_id) = source.object_id {
result.entry(object_id).or_default().push(source);
}
}
}
Ok(result)
}
pub fn fetch_by_path(conn: &Connection, root_id: i64, rel_path: &str) -> Result<Option<Source>> {
let sql = format!(
"SELECT {SOURCE_COLUMNS} {SOURCE_FROM} WHERE s.present = 1 AND s.root_id = ? AND s.rel_path = ?",
);
let result = conn
.query_row(&sql, rusqlite::params![root_id, rel_path], source_from_row)
.optional()?;
Ok(result)
}
pub fn fetch_by_id(conn: &Connection, source_id: i64) -> Result<Option<Source>> {
let sql = format!(
"SELECT {SOURCE_COLUMNS} {SOURCE_FROM} WHERE s.present = 1 AND s.id = ?",
);
let result = conn
.query_row(&sql, rusqlite::params![source_id], source_from_row)
.optional()?;
Ok(result)
}
pub fn fetch_by_inode(conn: &Connection, device: u64, inode: u64) -> Result<Option<Source>> {
let sql = format!(
"SELECT {SOURCE_COLUMNS} {SOURCE_FROM} WHERE s.present = 1 AND s.device = ? AND s.inode = ?",
);
let result = conn
.query_row(
&sql,
rusqlite::params![device as i64, inode as i64],
source_from_row,
)
.optional()?;
Ok(result)
}
pub fn batch_check_paths_exist(
conn: &Connection,
archive_root_id: i64,
rel_paths: &[&str],
) -> Result<std::collections::HashSet<String>> {
use std::collections::HashSet;
if rel_paths.is_empty() {
return Ok(HashSet::new());
}
let mut result = HashSet::new();
for chunk in rel_paths.chunks(BATCH_SIZE) {
let placeholders: Vec<&str> = chunk.iter().map(|_| "?").collect();
let sql = format!(
"SELECT rel_path FROM sources WHERE root_id = ? AND present = 1 AND rel_path IN ({})",
placeholders.join(", ")
);
let mut params: Vec<&dyn rusqlite::ToSql> = Vec::with_capacity(chunk.len() + 1);
params.push(&archive_root_id);
for path in chunk {
params.push(path);
}
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params.as_slice(), |row| row.get::<_, String>(0))?;
for row in rows {
result.insert(row?);
}
}
Ok(result)
}
pub fn insert_destination(conn: &Connection, new: &NewSource) -> Result<Source> {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_secs() as i64;
let updated = conn.execute(
"UPDATE sources SET
device = COALESCE(?, device),
inode = COALESCE(?, inode),
size = ?,
mtime = ?,
partial_hash = ?,
object_id = ?,
basis_rev = basis_rev + 1,
scanned_at = ?,
last_seen_at = ?,
present = 1,
excluded = 0
WHERE root_id = ? AND rel_path = ? AND present = 0",
rusqlite::params![
new.device,
new.inode,
new.size,
new.mtime,
new.partial_hash,
new.object_id,
now,
now,
new.root_id,
new.rel_path,
],
)?;
if updated == 0 {
conn.execute(
"INSERT INTO sources (
root_id, rel_path, device, inode, size, mtime, partial_hash,
object_id, basis_rev, scanned_at, last_seen_at, present, excluded
) VALUES (?, ?, COALESCE(?, 0), COALESCE(?, 0), ?, ?, ?, ?, 0, ?, ?, 1, 0)",
rusqlite::params![
new.root_id,
new.rel_path,
new.device,
new.inode,
new.size,
new.mtime,
new.partial_hash,
new.object_id,
now,
now,
],
)?;
}
fetch_by_path(conn, new.root_id, &new.rel_path)?.ok_or_else(|| {
anyhow::anyhow!(
"Failed to fetch source after insert: root_id={}, rel_path={}",
new.root_id,
new.rel_path
)
})
}
pub fn apply_reconciliation(
conn: &Connection,
observation: &FileObservation,
reconciliation: &Reconciliation,
now: i64,
) -> Result<Source> {
match reconciliation {
Reconciliation::New => {
let partial_hash = observation
.partial_hash
.as_ref()
.ok_or_else(|| anyhow::anyhow!("partial_hash required for New reconciliation"))?;
let updated = conn.execute(
"UPDATE sources SET
device = ?, inode = ?, size = ?, mtime = ?, partial_hash = ?,
basis_rev = 0, scanned_at = ?, last_seen_at = ?,
present = 1, excluded = 0, object_id = NULL
WHERE root_id = ? AND rel_path = ?",
rusqlite::params![
observation.device as i64,
observation.inode as i64,
observation.size,
observation.mtime,
partial_hash,
now,
now,
observation.root_id,
observation.rel_path,
],
)?;
if updated == 0 {
conn.execute(
"INSERT INTO sources (
root_id, rel_path, device, inode, size, mtime, partial_hash,
basis_rev, scanned_at, last_seen_at, present, excluded
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 1, 0)",
rusqlite::params![
observation.root_id,
observation.rel_path,
observation.device as i64,
observation.inode as i64,
observation.size,
observation.mtime,
partial_hash,
now,
now,
],
)?;
}
fetch_by_path(conn, observation.root_id, &observation.rel_path)?
.ok_or_else(|| anyhow::anyhow!("Failed to fetch source after insert"))
}
Reconciliation::Unchanged { source_id } => {
conn.execute(
"UPDATE sources SET device = ?, inode = ?, last_seen_at = ? WHERE id = ?",
rusqlite::params![
observation.device as i64,
observation.inode as i64,
now,
source_id
],
)?;
fetch_by_id(conn, *source_id)?
.ok_or_else(|| anyhow::anyhow!("Failed to fetch source after update"))
}
Reconciliation::Modified { source_id, .. } => {
let partial_hash = observation.partial_hash.as_ref().ok_or_else(|| {
anyhow::anyhow!("partial_hash required for Modified reconciliation")
})?;
conn.execute(
"UPDATE sources SET
device = ?, inode = ?, size = ?, mtime = ?,
partial_hash = ?, basis_rev = basis_rev + 1,
last_seen_at = ?, present = 1
WHERE id = ?",
rusqlite::params![
observation.device as i64,
observation.inode as i64,
observation.size,
observation.mtime,
partial_hash,
now,
source_id,
],
)?;
fetch_by_id(conn, *source_id)?
.ok_or_else(|| anyhow::anyhow!("Failed to fetch source after update"))
}
Reconciliation::Moved { source_id, .. } => {
conn.execute(
"UPDATE sources SET
root_id = ?, rel_path = ?,
device = ?, inode = ?, size = ?, mtime = ?,
last_seen_at = ?, present = 1
WHERE id = ?",
rusqlite::params![
observation.root_id,
observation.rel_path,
observation.device as i64,
observation.inode as i64,
observation.size,
observation.mtime,
now,
source_id,
],
)?;
fetch_by_id(conn, *source_id)?
.ok_or_else(|| anyhow::anyhow!("Failed to fetch source after update"))
}
}
}
pub fn mark_missing(conn: &Connection, source_ids: &[i64], now: i64) -> Result<u64> {
if source_ids.is_empty() {
return Ok(0);
}
let mut total_updated = 0u64;
for chunk in source_ids.chunks(BATCH_SIZE) {
let placeholders: Vec<&str> = chunk.iter().map(|_| "?").collect();
let sql = format!(
"UPDATE sources SET present = 0, last_seen_at = ? WHERE present = 1 AND id IN ({})",
placeholders.join(",")
);
let mut params: Vec<rusqlite::types::Value> = Vec::with_capacity(chunk.len() + 1);
params.push(rusqlite::types::Value::from(now));
for &id in chunk {
params.push(rusqlite::types::Value::from(id));
}
let updated = conn.execute(&sql, rusqlite::params_from_iter(params))?;
total_updated += updated as u64;
}
Ok(total_updated)
}
pub fn fetch_source_ids_for_root(
conn: &Connection,
root_id: i64,
scan_prefix: Option<&str>,
) -> Result<Vec<i64>> {
let ids: Vec<i64> = match scan_prefix {
Some(prefix) => {
let pattern = format!("{prefix}%");
conn.prepare(
"SELECT id FROM sources WHERE root_id = ? AND present = 1 AND rel_path LIKE ?",
)?
.query_map(rusqlite::params![root_id, pattern], |row| row.get(0))?
.collect::<Result<Vec<_>, _>>()?
}
None => conn
.prepare("SELECT id FROM sources WHERE root_id = ? AND present = 1")?
.query_map(rusqlite::params![root_id], |row| row.get(0))?
.collect::<Result<Vec<_>, _>>()?,
};
Ok(ids)
}
pub fn set_excluded(conn: &Connection, source_id: i64, excluded: bool) -> Result<()> {
conn.execute(
"UPDATE sources SET excluded = ? WHERE id = ?",
rusqlite::params![excluded as i64, source_id],
)?;
Ok(())
}
#[allow(dead_code)] pub fn batch_set_excluded(conn: &Connection, source_ids: &[i64], excluded: bool) -> Result<u64> {
if source_ids.is_empty() {
return Ok(0);
}
let mut total_updated = 0u64;
for chunk in source_ids.chunks(BATCH_SIZE) {
let placeholders: Vec<&str> = chunk.iter().map(|_| "?").collect();
let sql = format!(
"UPDATE sources SET excluded = ? WHERE id IN ({})",
placeholders.join(",")
);
let mut params: Vec<rusqlite::types::Value> = Vec::with_capacity(chunk.len() + 1);
params.push(rusqlite::types::Value::from(excluded as i64));
for &id in chunk {
params.push(rusqlite::types::Value::from(id));
}
let updated = conn.execute(&sql, rusqlite::params_from_iter(params))?;
total_updated += updated as u64;
}
Ok(total_updated)
}
pub fn count_unhashed_for_root(conn: &Connection, root_id: i64) -> Result<(i64, i64)> {
let (total, unhashed): (i64, i64) = conn.query_row(
"SELECT COUNT(*), COALESCE(SUM(CASE WHEN object_id IS NULL THEN 1 ELSE 0 END), 0)
FROM sources WHERE root_id = ? AND present = 1",
[root_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
Ok((total, unhashed))
}
pub fn update_location(
conn: &Connection,
source_id: i64,
new_root_id: i64,
new_rel_path: &str,
now: i64,
) -> Result<()> {
conn.execute(
"UPDATE sources SET root_id = ?, rel_path = ?, scanned_at = ?, last_seen_at = ?
WHERE id = ?",
rusqlite::params![new_root_id, new_rel_path, now, now, source_id],
)?;
Ok(())
}
pub fn fetch_device_info_by_prefix(
conn: &Connection,
root_id: i64,
rel_prefix: &str,
) -> Result<Vec<(i64, Option<i64>)>> {
let prefix_pattern = if rel_prefix.is_empty() {
"%".to_string()
} else {
format!("{rel_prefix}/%")
};
let mut stmt = conn.prepare(
"SELECT id, device FROM sources WHERE root_id = ? AND rel_path LIKE ? AND present = 1",
)?;
let rows = stmt.query_map(rusqlite::params![root_id, prefix_pattern], |row| {
Ok((row.get(0)?, row.get(1)?))
})?;
let mut results = Vec::new();
for row in rows {
results.push(row?);
}
Ok(results)
}
pub fn set_object_id(conn: &Connection, source_id: i64, object_id: i64) -> Result<()> {
conn.execute(
"UPDATE sources SET object_id = ? WHERE id = ?",
rusqlite::params![object_id, source_id],
)?;
Ok(())
}
#[cfg(test)]
pub fn insert_test_source(
conn: &Connection,
root_id: i64,
rel_path: &str,
device: i64,
inode: i64,
size: i64,
mtime: i64,
) -> i64 {
conn.execute(
"INSERT INTO sources (root_id, rel_path, device, inode, size, mtime, partial_hash, scanned_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, 'testhash', 0, 0)",
rusqlite::params![root_id, rel_path, device, inode, size, mtime],
)
.unwrap();
conn.last_insert_rowid()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repo::open_in_memory_for_test;
use rusqlite::Connection as RusqliteConnection;
fn setup_test_db() -> RusqliteConnection {
open_in_memory_for_test()
}
fn insert_object(conn: &RusqliteConnection, hash: &str, excluded: bool) -> i64 {
conn.execute(
"INSERT INTO objects (hash_type, hash_value, excluded) VALUES ('sha256', ?, ?)",
rusqlite::params![hash, excluded as i64],
)
.unwrap();
conn.last_insert_rowid()
}
fn insert_source(
conn: &RusqliteConnection,
root_id: i64,
rel_path: &str,
object_id: Option<i64>,
present: bool,
excluded: bool,
) -> i64 {
conn.execute(
"INSERT INTO sources (root_id, rel_path, object_id, device, inode, size, mtime, partial_hash, scanned_at, last_seen_at, present, excluded)
VALUES (?, ?, ?, 0, 0, 1000, 1704067200, 'hash', 0, 0, ?, ?)",
rusqlite::params![root_id, rel_path, object_id, present as i64, excluded as i64],
)
.unwrap();
conn.last_insert_rowid()
}
#[test]
fn batch_fetch_by_roots_empty_ids() {
let conn = setup_test_db();
let result = batch_fetch_by_roots(&conn, &[]).unwrap();
assert!(result.is_empty());
}
#[test]
fn batch_fetch_by_roots_no_matching_roots() {
let conn = setup_test_db();
let result = batch_fetch_by_roots(&conn, &[999, 1000]).unwrap();
assert!(result.is_empty());
}
#[test]
fn batch_fetch_by_roots_single_root() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
insert_source(&conn, root_id, "a.jpg", None, true, false);
insert_source(&conn, root_id, "b.jpg", None, true, false);
let sources = batch_fetch_by_roots(&conn, &[root_id]).unwrap();
assert_eq!(sources.len(), 2);
let source = sources.iter().find(|s| s.rel_path == "a.jpg").unwrap();
assert_eq!(source.root_path, "/photos");
assert_eq!(source.root_role, "source");
assert!(!source.root_suspended);
}
#[test]
fn batch_fetch_by_roots_multiple_roots() {
let conn = setup_test_db();
let root1 = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let root2 = crate::repo::insert_test_root(&conn, "/archive", "archive", false);
insert_source(&conn, root1, "photo.jpg", None, true, false);
insert_source(&conn, root2, "backup.jpg", None, true, false);
let sources = batch_fetch_by_roots(&conn, &[root1, root2]).unwrap();
assert_eq!(sources.len(), 2);
let photo = sources.iter().find(|s| s.rel_path == "photo.jpg").unwrap();
assert_eq!(photo.root_role, "source");
let backup = sources.iter().find(|s| s.rel_path == "backup.jpg").unwrap();
assert_eq!(backup.root_role, "archive");
}
#[test]
fn batch_fetch_by_roots_excludes_non_present() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
insert_source(&conn, root_id, "present.jpg", None, true, false);
insert_source(&conn, root_id, "deleted.jpg", None, false, false);
let sources = batch_fetch_by_roots(&conn, &[root_id]).unwrap();
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].rel_path, "present.jpg");
}
#[test]
fn batch_fetch_by_roots_includes_excluded_sources() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
insert_source(&conn, root_id, "normal.jpg", None, true, false);
insert_source(&conn, root_id, "excluded.jpg", None, true, true);
let sources = batch_fetch_by_roots(&conn, &[root_id]).unwrap();
assert_eq!(sources.len(), 2);
let excluded = sources
.iter()
.find(|s| s.rel_path == "excluded.jpg")
.unwrap();
assert!(excluded.excluded);
}
#[test]
fn batch_fetch_by_roots_includes_object_excluded() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let obj_id = insert_object(&conn, "abc123", true); insert_source(&conn, root_id, "file.jpg", Some(obj_id), true, false);
let sources = batch_fetch_by_roots(&conn, &[root_id]).unwrap();
assert_eq!(sources.len(), 1);
let source = &sources[0];
assert!(!source.excluded); assert_eq!(source.object_excluded, Some(true)); assert!(source.is_excluded()); }
#[test]
fn batch_fetch_by_roots_suspended_root() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", true); insert_source(&conn, root_id, "file.jpg", None, true, false);
let sources = batch_fetch_by_roots(&conn, &[root_id]).unwrap();
assert_eq!(sources.len(), 1);
assert!(sources[0].root_suspended);
assert!(!sources[0].is_active()); }
#[test]
fn batch_fetch_by_ids_empty_ids() {
let conn = setup_test_db();
let result = batch_fetch_by_ids(&conn, &[]).unwrap();
assert!(result.is_empty());
}
#[test]
fn batch_fetch_by_ids_no_matching_ids() {
let conn = setup_test_db();
let result = batch_fetch_by_ids(&conn, &[999, 1000]).unwrap();
assert!(result.is_empty());
}
#[test]
fn batch_fetch_by_ids_returns_hashmap() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let id1 = insert_source(&conn, root_id, "a.jpg", None, true, false);
let id2 = insert_source(&conn, root_id, "b.jpg", None, true, false);
let sources = batch_fetch_by_ids(&conn, &[id1, id2]).unwrap();
assert_eq!(sources.len(), 2);
assert_eq!(sources.get(&id1).unwrap().rel_path, "a.jpg");
assert_eq!(sources.get(&id2).unwrap().rel_path, "b.jpg");
}
#[test]
fn batch_fetch_by_ids_excludes_non_present() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let present_id = insert_source(&conn, root_id, "present.jpg", None, true, false);
let deleted_id = insert_source(&conn, root_id, "deleted.jpg", None, false, false);
let sources = batch_fetch_by_ids(&conn, &[present_id, deleted_id]).unwrap();
assert_eq!(sources.len(), 1);
assert!(sources.contains_key(&present_id));
assert!(!sources.contains_key(&deleted_id));
}
#[test]
fn batch_fetch_by_ids_partial_match() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let id1 = insert_source(&conn, root_id, "exists.jpg", None, true, false);
let sources = batch_fetch_by_ids(&conn, &[id1, 999, 1000]).unwrap();
assert_eq!(sources.len(), 1);
assert!(sources.contains_key(&id1));
}
#[test]
fn fetch_by_id_returns_source() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let obj_id = insert_object(&conn, "abc123", false);
let source_id = insert_source(&conn, root_id, "photo.jpg", Some(obj_id), true, false);
let result = fetch_by_id(&conn, source_id).unwrap();
assert!(result.is_some());
let source = result.unwrap();
assert_eq!(source.id, source_id);
assert_eq!(source.root_id, root_id);
assert_eq!(source.root_path, "/photos");
assert_eq!(source.rel_path, "photo.jpg");
assert_eq!(source.object_id, Some(obj_id));
assert_eq!(source.root_role, "source");
}
#[test]
fn fetch_by_id_not_found() {
let conn = setup_test_db();
let result = fetch_by_id(&conn, 99999).unwrap();
assert!(result.is_none());
}
#[test]
fn fetch_by_id_excludes_non_present() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let source_id = insert_source(&conn, root_id, "deleted.jpg", None, false, false);
let result = fetch_by_id(&conn, source_id).unwrap();
assert!(result.is_none());
}
#[test]
fn fetch_by_id_includes_excluded_source() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let source_id = insert_source(&conn, root_id, "excluded.jpg", None, true, true);
let result = fetch_by_id(&conn, source_id).unwrap();
assert!(result.is_some());
let source = result.unwrap();
assert!(source.excluded);
}
#[test]
fn fetch_sources_by_object_ids_empty_input() {
let conn = setup_test_db();
let result = fetch_sources_by_object_ids(&conn, &[]).unwrap();
assert!(result.is_empty());
}
#[test]
fn fetch_sources_by_object_ids_returns_grouped() {
let conn = setup_test_db();
let root1 = crate::repo::insert_test_root(&conn, "/source", "source", false);
let root2 = crate::repo::insert_test_root(&conn, "/archive", "archive", false);
let obj1 = insert_object(&conn, "content_hash_1", false);
let obj2 = insert_object(&conn, "content_hash_2", false);
let _src1a = insert_source(&conn, root1, "photo.jpg", Some(obj1), true, false);
let _src1b = insert_source(&conn, root2, "photo.jpg", Some(obj1), true, false);
let _src2 = insert_source(&conn, root1, "unique.jpg", Some(obj2), true, false);
let result = fetch_sources_by_object_ids(&conn, &[obj1, obj2]).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result.get(&obj1).map(|v| v.len()), Some(2));
assert_eq!(result.get(&obj2).map(|v| v.len()), Some(1));
}
#[test]
fn fetch_sources_by_object_ids_includes_root_path() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/my/archive", "archive", false);
let obj = insert_object(&conn, "test_hash", false);
let _src = insert_source(&conn, root_id, "subdir/file.txt", Some(obj), true, false);
let result = fetch_sources_by_object_ids(&conn, &[obj]).unwrap();
let sources = result.get(&obj).unwrap();
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].root_path, "/my/archive");
assert_eq!(sources[0].rel_path, "subdir/file.txt");
assert_eq!(sources[0].path(), "/my/archive/subdir/file.txt");
}
#[test]
fn fetch_sources_by_object_ids_excludes_non_present() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/source", "source", false);
let obj = insert_object(&conn, "test_hash", false);
let _present = insert_source(&conn, root_id, "present.jpg", Some(obj), true, false);
let _deleted = insert_source(&conn, root_id, "deleted.jpg", Some(obj), false, false);
let result = fetch_sources_by_object_ids(&conn, &[obj]).unwrap();
let sources = result.get(&obj).unwrap();
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].rel_path, "present.jpg");
}
#[test]
fn fetch_sources_by_object_ids_handles_large_batch() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/source", "source", false);
let mut object_ids = Vec::new();
for i in 0..1050 {
let obj = insert_object(&conn, &format!("hash_{i}"), false);
insert_source(
&conn,
root_id,
&format!("file_{i}.jpg"),
Some(obj),
true,
false,
);
object_ids.push(obj);
}
let result = fetch_sources_by_object_ids(&conn, &object_ids).unwrap();
assert_eq!(result.len(), 1050);
assert!(result.contains_key(&object_ids[0]));
assert!(result.contains_key(&object_ids[500]));
assert!(result.contains_key(&object_ids[1049]));
}
#[test]
fn insert_destination_fresh_insert() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/archive", "archive", false);
let obj_id = insert_object(&conn, "abc123hash", false);
let new = NewSource {
root_id,
rel_path: "2024/photo.jpg".to_string(),
size: 1024,
mtime: 1704067200,
partial_hash: "partial123".to_string(),
object_id: Some(obj_id),
device: Some(65024),
inode: Some(12345),
};
let source = insert_destination(&conn, &new).unwrap();
assert_eq!(source.root_id, root_id);
assert_eq!(source.rel_path, "2024/photo.jpg");
assert_eq!(source.size, 1024);
assert_eq!(source.mtime, 1704067200);
assert_eq!(source.partial_hash, "partial123");
assert_eq!(source.object_id, Some(obj_id));
assert_eq!(source.device, 65024);
assert_eq!(source.inode, 12345);
assert_eq!(source.basis_rev, 0);
assert!(!source.excluded);
}
#[test]
fn insert_destination_stale_record_update() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/archive", "archive", false);
let obj_id = insert_object(&conn, "abc123hash", false);
conn.execute(
"INSERT INTO sources (root_id, rel_path, object_id, size, mtime, partial_hash,
basis_rev, scanned_at, last_seen_at, present, excluded, device, inode)
VALUES (?, ?, ?, 500, 1700000000, 'oldhash', 5, 0, 0, 0, 1, 100, 200)",
rusqlite::params![root_id, "revived.jpg", obj_id],
)
.unwrap();
let new = NewSource {
root_id,
rel_path: "revived.jpg".to_string(),
size: 2048,
mtime: 1704067200,
partial_hash: "newhash".to_string(),
object_id: Some(obj_id),
device: Some(65024),
inode: Some(99999),
};
let source = insert_destination(&conn, &new).unwrap();
assert_eq!(source.rel_path, "revived.jpg");
assert_eq!(source.size, 2048);
assert_eq!(source.mtime, 1704067200);
assert_eq!(source.partial_hash, "newhash");
assert_eq!(source.basis_rev, 6);
assert_eq!(source.device, 65024);
assert_eq!(source.inode, 99999);
assert!(!source.excluded);
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sources WHERE root_id = ? AND rel_path = ?",
rusqlite::params![root_id, "revived.jpg"],
|row| row.get(0),
)
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn insert_destination_null_device_inode() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/archive", "archive", false);
let obj_id = insert_object(&conn, "abc123hash", false);
let new = NewSource {
root_id,
rel_path: "nonunix.jpg".to_string(),
size: 1024,
mtime: 1704067200,
partial_hash: "partial123".to_string(),
object_id: Some(obj_id),
device: None, inode: None, };
let source = insert_destination(&conn, &new).unwrap();
assert_eq!(source.rel_path, "nonunix.jpg");
assert_eq!(source.device, 0);
assert_eq!(source.inode, 0);
assert_eq!(source.size, 1024);
}
#[test]
fn insert_destination_already_present_fails() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/archive", "archive", false);
let obj_id = insert_object(&conn, "abc123hash", false);
insert_source(&conn, root_id, "existing.jpg", Some(obj_id), true, false);
let new = NewSource {
root_id,
rel_path: "existing.jpg".to_string(),
size: 2048,
mtime: 1704067200,
partial_hash: "newhash".to_string(),
object_id: Some(obj_id),
device: Some(65024),
inode: Some(12345),
};
let result = insert_destination(&conn, &new);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("UNIQUE") || err_msg.contains("constraint"));
}
#[test]
fn insert_destination_returns_complete_source() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/archive", "archive", false);
let obj_id = insert_object(&conn, "abc123hash", true);
let new = NewSource {
root_id,
rel_path: "complete.jpg".to_string(),
size: 1024,
mtime: 1704067200,
partial_hash: "partial123".to_string(),
object_id: Some(obj_id),
device: Some(65024),
inode: Some(12345),
};
let source = insert_destination(&conn, &new).unwrap();
assert_eq!(source.root_path, "/archive");
assert_eq!(source.root_role, "archive");
assert!(!source.root_suspended);
assert_eq!(source.object_id, Some(obj_id));
assert_eq!(source.object_excluded, Some(true));
assert!(source.is_excluded()); assert!(source.is_active()); assert!(source.is_from_role("archive"));
assert_eq!(source.path(), "/archive/complete.jpg");
}
#[test]
fn fetch_by_path_exists() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
insert_source(&conn, root_id, "found.jpg", None, true, false);
let result = fetch_by_path(&conn, root_id, "found.jpg").unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().rel_path, "found.jpg");
}
#[test]
fn fetch_by_path_not_present() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
insert_source(&conn, root_id, "deleted.jpg", None, false, false);
let result = fetch_by_path(&conn, root_id, "deleted.jpg").unwrap();
assert!(result.is_none());
}
#[test]
fn fetch_by_path_not_found() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let result = fetch_by_path(&conn, root_id, "nonexistent.jpg").unwrap();
assert!(result.is_none());
}
#[test]
fn fetch_by_path_wrong_root() {
let conn = setup_test_db();
let root1 = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let root2 = crate::repo::insert_test_root(&conn, "/archive", "archive", false);
insert_source(&conn, root1, "file.jpg", None, true, false);
let result = fetch_by_path(&conn, root2, "file.jpg").unwrap();
assert!(result.is_none());
}
#[test]
fn fetch_by_inode_exists() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
conn.execute(
"INSERT INTO sources (root_id, rel_path, device, inode, size, mtime, partial_hash, scanned_at, last_seen_at, present)
VALUES (?, 'file.jpg', 100, 12345, 1000, 1700000000, 'hash', 0, 0, 1)",
rusqlite::params![root_id],
).unwrap();
let result = fetch_by_inode(&conn, 100, 12345).unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().rel_path, "file.jpg");
}
#[test]
fn fetch_by_inode_cross_root() {
let conn = setup_test_db();
let root1 = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let _root2 = crate::repo::insert_test_root(&conn, "/archive", "archive", false);
conn.execute(
"INSERT INTO sources (root_id, rel_path, device, inode, size, mtime, partial_hash, scanned_at, last_seen_at, present)
VALUES (?, 'original.jpg', 100, 12345, 1000, 1700000000, 'hash', 0, 0, 1)",
rusqlite::params![root1],
).unwrap();
let result = fetch_by_inode(&conn, 100, 12345).unwrap();
assert!(result.is_some());
let source = result.unwrap();
assert_eq!(source.rel_path, "original.jpg");
assert_eq!(source.root_id, root1);
}
#[test]
fn fetch_by_inode_not_found() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
insert_source(&conn, root_id, "file.jpg", None, true, false);
let result = fetch_by_inode(&conn, 999, 999).unwrap();
assert!(result.is_none());
}
#[test]
fn fetch_by_inode_not_present() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
conn.execute(
"INSERT INTO sources (root_id, rel_path, device, inode, size, mtime, partial_hash, scanned_at, last_seen_at, present)
VALUES (?, 'deleted.jpg', 100, 12345, 1000, 1700000000, 'hash', 0, 0, 0)",
rusqlite::params![root_id],
).unwrap();
let result = fetch_by_inode(&conn, 100, 12345).unwrap();
assert!(result.is_none());
}
#[test]
fn apply_reconciliation_new() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let observation = FileObservation {
root_id,
rel_path: "new_file.jpg".to_string(),
device: 100,
inode: 12345,
size: 2048,
mtime: 1700000000,
partial_hash: Some("abc123".to_string()),
};
let reconciliation = Reconciliation::New;
let now = 1700000001;
let source = apply_reconciliation(&conn, &observation, &reconciliation, now).unwrap();
assert_eq!(source.rel_path, "new_file.jpg");
assert_eq!(source.size, 2048);
assert_eq!(source.mtime, 1700000000);
assert_eq!(source.device, 100);
assert_eq!(source.inode, 12345);
assert_eq!(source.partial_hash, "abc123");
assert_eq!(source.basis_rev, 0);
}
#[test]
fn apply_reconciliation_new_revives_stale_record() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
conn.execute(
"INSERT INTO sources (root_id, rel_path, device, inode, size, mtime, partial_hash, basis_rev, scanned_at, last_seen_at, present, excluded)
VALUES (?, 'revived.jpg', 1, 1, 500, 1600000000, 'oldhash', 5, 0, 0, 0, 0)",
rusqlite::params![root_id],
).unwrap();
let old_id = conn.last_insert_rowid();
let observation = FileObservation {
root_id,
rel_path: "revived.jpg".to_string(),
device: 100,
inode: 12345,
size: 2048,
mtime: 1700000000,
partial_hash: Some("newhash".to_string()),
};
let reconciliation = Reconciliation::New;
let now = 1700000001;
let source = apply_reconciliation(&conn, &observation, &reconciliation, now).unwrap();
assert_eq!(source.id, old_id);
assert_eq!(source.rel_path, "revived.jpg");
assert_eq!(source.device, 100);
assert_eq!(source.inode, 12345);
assert_eq!(source.size, 2048);
assert_eq!(source.mtime, 1700000000);
assert_eq!(source.partial_hash, "newhash");
assert_eq!(source.basis_rev, 0);
assert_eq!(source.object_id, None);
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sources WHERE root_id = ? AND rel_path = ?",
rusqlite::params![root_id, "revived.jpg"],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn apply_reconciliation_unchanged() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let source_id = insert_source(&conn, root_id, "existing.jpg", None, true, false);
let observation = FileObservation {
root_id,
rel_path: "existing.jpg".to_string(),
device: 0,
inode: 0,
size: 1000,
mtime: 1704067200,
partial_hash: None,
};
let reconciliation = Reconciliation::Unchanged { source_id };
let now = 1700000001;
let source = apply_reconciliation(&conn, &observation, &reconciliation, now).unwrap();
assert_eq!(source.id, source_id);
assert_eq!(source.rel_path, "existing.jpg");
let last_seen: i64 = conn
.query_row(
"SELECT last_seen_at FROM sources WHERE id = ?",
rusqlite::params![source_id],
|row| row.get(0),
)
.unwrap();
assert_eq!(last_seen, now);
}
#[test]
fn apply_reconciliation_modified() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
conn.execute(
"INSERT INTO sources (root_id, rel_path, device, inode, size, mtime, partial_hash, basis_rev, scanned_at, last_seen_at, present)
VALUES (?, 'modified.jpg', 100, 12345, 1000, 1700000000, 'oldhash', 2, 0, 0, 1)",
rusqlite::params![root_id],
).unwrap();
let source_id = conn.last_insert_rowid();
let observation = FileObservation {
root_id,
rel_path: "modified.jpg".to_string(),
device: 100,
inode: 12345,
size: 2048, mtime: 1700000100, partial_hash: Some("newhash".to_string()),
};
let reconciliation = Reconciliation::Modified {
source_id,
old_object_id: None,
};
let now = 1700000101;
let source = apply_reconciliation(&conn, &observation, &reconciliation, now).unwrap();
assert_eq!(source.id, source_id);
assert_eq!(source.size, 2048);
assert_eq!(source.mtime, 1700000100);
assert_eq!(source.partial_hash, "newhash");
assert_eq!(source.basis_rev, 3); }
#[test]
fn apply_reconciliation_moved() {
let conn = setup_test_db();
let root1 = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let root2 = crate::repo::insert_test_root(&conn, "/archive", "archive", false);
conn.execute(
"INSERT INTO sources (root_id, rel_path, device, inode, size, mtime, partial_hash, basis_rev, scanned_at, last_seen_at, present)
VALUES (?, 'old_location.jpg', 100, 12345, 1000, 1700000000, 'hash123', 1, 0, 0, 1)",
rusqlite::params![root1],
).unwrap();
let source_id = conn.last_insert_rowid();
let observation = FileObservation {
root_id: root2,
rel_path: "new_location.jpg".to_string(),
device: 100,
inode: 12345,
size: 1000,
mtime: 1700000000,
partial_hash: None,
};
let reconciliation = Reconciliation::Moved {
source_id,
from_root_id: root1,
from_path: "old_location.jpg".to_string(),
old_object_id: None,
};
let now = 1700000001;
let source = apply_reconciliation(&conn, &observation, &reconciliation, now).unwrap();
assert_eq!(source.id, source_id);
assert_eq!(source.root_id, root2); assert_eq!(source.rel_path, "new_location.jpg"); assert_eq!(source.root_path, "/archive"); }
#[test]
fn apply_reconciliation_new_requires_partial_hash() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let observation = FileObservation {
root_id,
rel_path: "new_file.jpg".to_string(),
device: 100,
inode: 12345,
size: 2048,
mtime: 1700000000,
partial_hash: None, };
let reconciliation = Reconciliation::New;
let now = 1700000001;
let result = apply_reconciliation(&conn, &observation, &reconciliation, now);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("partial_hash"));
}
#[test]
fn mark_missing_sets_present_zero() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let id1 = insert_source(&conn, root_id, "missing1.jpg", None, true, false);
let id2 = insert_source(&conn, root_id, "missing2.jpg", None, true, false);
let _id3 = insert_source(&conn, root_id, "present.jpg", None, true, false);
let now = 1700000001;
let count = mark_missing(&conn, &[id1, id2], now).unwrap();
assert_eq!(count, 2);
let present1: i64 = conn
.query_row(
"SELECT present FROM sources WHERE id = ?",
rusqlite::params![id1],
|row| row.get(0),
)
.unwrap();
assert_eq!(present1, 0);
let present3: i64 = conn
.query_row(
"SELECT present FROM sources WHERE rel_path = 'present.jpg'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(present3, 1);
}
#[test]
fn mark_missing_empty_list() {
let conn = setup_test_db();
let count = mark_missing(&conn, &[], 1700000001).unwrap();
assert_eq!(count, 0);
}
#[test]
fn mark_missing_returns_count() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let id1 = insert_source(&conn, root_id, "file1.jpg", None, true, false);
let id2 = insert_source(&conn, root_id, "file2.jpg", None, false, false);
let count = mark_missing(&conn, &[id1, id2], 1700000001).unwrap();
assert_eq!(count, 1);
}
#[test]
fn mark_missing_updates_last_seen_at() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let id1 = insert_source(&conn, root_id, "file.jpg", None, true, false);
let now = 1700000001;
mark_missing(&conn, &[id1], now).unwrap();
let last_seen: i64 = conn
.query_row(
"SELECT last_seen_at FROM sources WHERE id = ?",
rusqlite::params![id1],
|row| row.get(0),
)
.unwrap();
assert_eq!(last_seen, now);
}
#[test]
fn fetch_source_ids_for_root_returns_present_only() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let id1 = insert_source(&conn, root_id, "present1.jpg", None, true, false);
let id2 = insert_source(&conn, root_id, "present2.jpg", None, true, false);
let _id3 = insert_source(&conn, root_id, "deleted.jpg", None, false, false);
let ids = fetch_source_ids_for_root(&conn, root_id, None).unwrap();
assert_eq!(ids.len(), 2);
assert!(ids.contains(&id1));
assert!(ids.contains(&id2));
}
#[test]
fn fetch_source_ids_for_root_empty() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let ids = fetch_source_ids_for_root(&conn, root_id, None).unwrap();
assert!(ids.is_empty());
}
#[test]
fn fetch_source_ids_for_root_with_prefix() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let id1 = insert_source(&conn, root_id, "2024/photo1.jpg", None, true, false);
let id2 = insert_source(&conn, root_id, "2024/photo2.jpg", None, true, false);
let id3 = insert_source(&conn, root_id, "2023/old.jpg", None, true, false);
let _id4 = insert_source(&conn, root_id, "2024/deleted.jpg", None, false, false);
let ids = fetch_source_ids_for_root(&conn, root_id, Some("2024/")).unwrap();
assert_eq!(ids.len(), 2);
assert!(ids.contains(&id1));
assert!(ids.contains(&id2));
let all_ids = fetch_source_ids_for_root(&conn, root_id, None).unwrap();
assert_eq!(all_ids.len(), 3);
assert!(all_ids.contains(&id3));
}
#[test]
fn set_excluded_marks_source() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let source_id = insert_source(&conn, root_id, "file.jpg", None, true, false);
let excluded: i64 = conn
.query_row(
"SELECT excluded FROM sources WHERE id = ?",
rusqlite::params![source_id],
|row| row.get(0),
)
.unwrap();
assert_eq!(excluded, 0);
set_excluded(&conn, source_id, true).unwrap();
let excluded: i64 = conn
.query_row(
"SELECT excluded FROM sources WHERE id = ?",
rusqlite::params![source_id],
|row| row.get(0),
)
.unwrap();
assert_eq!(excluded, 1);
}
#[test]
fn set_excluded_clears_source() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let source_id = insert_source(&conn, root_id, "file.jpg", None, true, true);
let excluded: i64 = conn
.query_row(
"SELECT excluded FROM sources WHERE id = ?",
rusqlite::params![source_id],
|row| row.get(0),
)
.unwrap();
assert_eq!(excluded, 1);
set_excluded(&conn, source_id, false).unwrap();
let excluded: i64 = conn
.query_row(
"SELECT excluded FROM sources WHERE id = ?",
rusqlite::params![source_id],
|row| row.get(0),
)
.unwrap();
assert_eq!(excluded, 0);
}
#[test]
fn set_excluded_nonexistent_source() {
let conn = setup_test_db();
let result = set_excluded(&conn, 99999, true);
assert!(result.is_ok());
}
#[test]
fn batch_set_excluded_empty_list() {
let conn = setup_test_db();
let count = batch_set_excluded(&conn, &[], true).unwrap();
assert_eq!(count, 0);
}
#[test]
fn batch_set_excluded_multiple() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let id1 = insert_source(&conn, root_id, "file1.jpg", None, true, false);
let id2 = insert_source(&conn, root_id, "file2.jpg", None, true, false);
let id3 = insert_source(&conn, root_id, "file3.jpg", None, true, false);
let count = batch_set_excluded(&conn, &[id1, id2], true).unwrap();
assert_eq!(count, 2);
let excluded1: i64 = conn
.query_row(
"SELECT excluded FROM sources WHERE id = ?",
rusqlite::params![id1],
|row| row.get(0),
)
.unwrap();
let excluded2: i64 = conn
.query_row(
"SELECT excluded FROM sources WHERE id = ?",
rusqlite::params![id2],
|row| row.get(0),
)
.unwrap();
let excluded3: i64 = conn
.query_row(
"SELECT excluded FROM sources WHERE id = ?",
rusqlite::params![id3],
|row| row.get(0),
)
.unwrap();
assert_eq!(excluded1, 1);
assert_eq!(excluded2, 1);
assert_eq!(excluded3, 0); }
#[test]
fn batch_set_excluded_returns_count() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let id1 = insert_source(&conn, root_id, "file1.jpg", None, true, false);
let _id2 = insert_source(&conn, root_id, "file2.jpg", None, true, false);
let count = batch_set_excluded(&conn, &[id1, 99999], true).unwrap();
assert_eq!(count, 1);
}
#[test]
fn batch_set_excluded_skips_nonexistent() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let id1 = insert_source(&conn, root_id, "file.jpg", None, true, false);
let count = batch_set_excluded(&conn, &[id1, 99998, 99999], true).unwrap();
assert_eq!(count, 1);
let excluded: i64 = conn
.query_row(
"SELECT excluded FROM sources WHERE id = ?",
rusqlite::params![id1],
|row| row.get(0),
)
.unwrap();
assert_eq!(excluded, 1);
}
#[test]
fn batch_set_excluded_handles_large_batch() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let mut source_ids = Vec::new();
for i in 0..1050 {
let id = insert_source(
&conn,
root_id,
&format!("file_{i}.jpg"),
None,
true,
false,
);
source_ids.push(id);
}
let count = batch_set_excluded(&conn, &source_ids, true).unwrap();
assert_eq!(count, 1050);
let excluded_first: i64 = conn
.query_row(
"SELECT excluded FROM sources WHERE id = ?",
rusqlite::params![source_ids[0]],
|row| row.get(0),
)
.unwrap();
let excluded_mid: i64 = conn
.query_row(
"SELECT excluded FROM sources WHERE id = ?",
rusqlite::params![source_ids[500]],
|row| row.get(0),
)
.unwrap();
let excluded_last: i64 = conn
.query_row(
"SELECT excluded FROM sources WHERE id = ?",
rusqlite::params![source_ids[1049]],
|row| row.get(0),
)
.unwrap();
assert_eq!(excluded_first, 1);
assert_eq!(excluded_mid, 1);
assert_eq!(excluded_last, 1);
}
#[test]
fn batch_check_paths_exist_empty_input() {
let conn = setup_test_db();
let _root_id = crate::repo::insert_test_root(&conn, "/archive", "archive", false);
let result = batch_check_paths_exist(&conn, 1, &[]).unwrap();
assert!(result.is_empty());
}
#[test]
fn batch_check_paths_exist_none_found() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/archive", "archive", false);
let result = batch_check_paths_exist(&conn, root_id, &["a.jpg", "b.jpg"]).unwrap();
assert!(result.is_empty());
}
#[test]
fn batch_check_paths_exist_all_found() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/archive", "archive", false);
insert_source(&conn, root_id, "a.jpg", None, true, false);
insert_source(&conn, root_id, "b.jpg", None, true, false);
let result = batch_check_paths_exist(&conn, root_id, &["a.jpg", "b.jpg"]).unwrap();
assert_eq!(result.len(), 2);
assert!(result.contains("a.jpg"));
assert!(result.contains("b.jpg"));
}
#[test]
fn batch_check_paths_exist_mixed() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/archive", "archive", false);
insert_source(&conn, root_id, "exists.jpg", None, true, false);
let result =
batch_check_paths_exist(&conn, root_id, &["exists.jpg", "missing.jpg"]).unwrap();
assert_eq!(result.len(), 1);
assert!(result.contains("exists.jpg"));
assert!(!result.contains("missing.jpg"));
}
#[test]
fn batch_check_paths_exist_ignores_not_present() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/archive", "archive", false);
insert_source(&conn, root_id, "present.jpg", None, true, false);
insert_source(&conn, root_id, "deleted.jpg", None, false, false);
let result =
batch_check_paths_exist(&conn, root_id, &["present.jpg", "deleted.jpg"]).unwrap();
assert_eq!(result.len(), 1);
assert!(result.contains("present.jpg"));
assert!(!result.contains("deleted.jpg"));
}
#[test]
fn batch_check_paths_exist_different_root() {
let conn = setup_test_db();
let root1 = crate::repo::insert_test_root(&conn, "/archive1", "archive", false);
let root2 = crate::repo::insert_test_root(&conn, "/archive2", "archive", false);
insert_source(&conn, root1, "file.jpg", None, true, false);
let result = batch_check_paths_exist(&conn, root2, &["file.jpg"]).unwrap();
assert!(result.is_empty());
let result = batch_check_paths_exist(&conn, root1, &["file.jpg"]).unwrap();
assert_eq!(result.len(), 1);
assert!(result.contains("file.jpg"));
}
#[test]
fn batch_check_paths_exist_handles_999_paths() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/archive", "archive", false);
let mut paths = Vec::new();
for i in 0..999 {
let path = format!("file_{i}.jpg");
insert_source(&conn, root_id, &path, None, true, false);
paths.push(path);
}
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
let result = batch_check_paths_exist(&conn, root_id, &path_refs).unwrap();
assert_eq!(result.len(), 999);
}
#[test]
fn batch_check_paths_exist_handles_1000_paths() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/archive", "archive", false);
let mut paths = Vec::new();
for i in 0..1000 {
let path = format!("file_{i}.jpg");
insert_source(&conn, root_id, &path, None, true, false);
paths.push(path);
}
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
let result = batch_check_paths_exist(&conn, root_id, &path_refs).unwrap();
assert_eq!(result.len(), 1000);
}
#[test]
fn batch_check_paths_exist_handles_1001_paths() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/archive", "archive", false);
let mut paths = Vec::new();
for i in 0..1001 {
let path = format!("file_{i}.jpg");
insert_source(&conn, root_id, &path, None, true, false);
paths.push(path);
}
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
let result = batch_check_paths_exist(&conn, root_id, &path_refs).unwrap();
assert_eq!(result.len(), 1001);
assert!(result.contains("file_0.jpg"));
assert!(result.contains("file_999.jpg"));
assert!(result.contains("file_1000.jpg"));
}
#[test]
fn count_unhashed_for_root_empty() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/archive", "archive", false);
let (total, unhashed) = count_unhashed_for_root(&conn, root_id).unwrap();
assert_eq!(total, 0);
assert_eq!(unhashed, 0);
}
#[test]
fn count_unhashed_for_root_all_hashed() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/archive", "archive", false);
let obj_id = insert_object(&conn, "abc123", false);
insert_source(&conn, root_id, "a.jpg", Some(obj_id), true, false);
insert_source(&conn, root_id, "b.jpg", Some(obj_id), true, false);
insert_source(&conn, root_id, "c.jpg", Some(obj_id), true, false);
let (total, unhashed) = count_unhashed_for_root(&conn, root_id).unwrap();
assert_eq!(total, 3);
assert_eq!(unhashed, 0);
}
#[test]
fn count_unhashed_for_root_some_unhashed() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/archive", "archive", false);
let obj_id = insert_object(&conn, "abc123", false);
insert_source(&conn, root_id, "a.jpg", Some(obj_id), true, false);
insert_source(&conn, root_id, "b.jpg", Some(obj_id), true, false);
insert_source(&conn, root_id, "c.jpg", None, true, false);
let (total, unhashed) = count_unhashed_for_root(&conn, root_id).unwrap();
assert_eq!(total, 3);
assert_eq!(unhashed, 1);
}
#[test]
fn count_unhashed_for_root_excludes_not_present() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/archive", "archive", false);
insert_source(&conn, root_id, "present.jpg", None, true, false);
insert_source(&conn, root_id, "deleted.jpg", None, false, false);
let (total, unhashed) = count_unhashed_for_root(&conn, root_id).unwrap();
assert_eq!(total, 1); assert_eq!(unhashed, 1);
}
#[test]
fn update_location_updates_fields() {
let conn = setup_test_db();
let source_root = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let archive_root = crate::repo::insert_test_root(&conn, "/archive", "archive", false);
let source_id = insert_source(&conn, source_root, "original.jpg", None, true, false);
let now = 1700000001i64;
update_location(&conn, source_id, archive_root, "new/path.jpg", now).unwrap();
let (root_id, rel_path, scanned_at, last_seen_at): (i64, String, i64, i64) = conn
.query_row(
"SELECT root_id, rel_path, scanned_at, last_seen_at FROM sources WHERE id = ?",
rusqlite::params![source_id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.unwrap();
assert_eq!(root_id, archive_root);
assert_eq!(rel_path, "new/path.jpg");
assert_eq!(scanned_at, now);
assert_eq!(last_seen_at, now);
}
#[test]
fn update_location_nonexistent_source() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/archive", "archive", false);
let result = update_location(&conn, 99999, root_id, "path.jpg", 1700000001);
assert!(result.is_ok());
}
#[test]
fn fetch_device_info_by_prefix_empty_root() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let results = fetch_device_info_by_prefix(&conn, root_id, "").unwrap();
assert!(results.is_empty());
}
#[test]
fn fetch_device_info_by_prefix_matches_all() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
insert_test_source(&conn, root_id, "a/1.jpg", 100, 1, 1000, 1700000000);
insert_test_source(&conn, root_id, "a/2.jpg", 100, 2, 1000, 1700000000);
insert_test_source(&conn, root_id, "b/3.jpg", 200, 3, 1000, 1700000000);
let results = fetch_device_info_by_prefix(&conn, root_id, "").unwrap();
assert_eq!(results.len(), 3);
}
#[test]
fn fetch_device_info_by_prefix_matches_prefix() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
insert_test_source(&conn, root_id, "a/1.jpg", 100, 1, 1000, 1700000000);
insert_test_source(&conn, root_id, "a/2.jpg", 100, 2, 1000, 1700000000);
insert_test_source(&conn, root_id, "b/3.jpg", 200, 3, 1000, 1700000000);
let results = fetch_device_info_by_prefix(&conn, root_id, "a").unwrap();
assert_eq!(results.len(), 2);
}
#[test]
fn fetch_device_info_by_prefix_excludes_not_present() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
insert_test_source(&conn, root_id, "a/1.jpg", 100, 1, 1000, 1700000000);
conn.execute(
"UPDATE sources SET present = 0 WHERE rel_path = 'a/1.jpg'",
[],
)
.unwrap();
let results = fetch_device_info_by_prefix(&conn, root_id, "").unwrap();
assert!(results.is_empty());
}
#[test]
fn fetch_device_info_by_prefix_returns_device() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
insert_test_source(&conn, root_id, "a/1.jpg", 12345, 1, 1000, 1700000000);
let results = fetch_device_info_by_prefix(&conn, root_id, "").unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].1, Some(12345));
}
#[test]
fn set_object_id_links_source() {
let conn = setup_test_db();
let root_id = crate::repo::insert_test_root(&conn, "/photos", "source", false);
let source_id = insert_source(&conn, root_id, "photo.jpg", None, true, false);
let object_id = insert_object(&conn, "abc123", false);
set_object_id(&conn, source_id, object_id).unwrap();
let stored: i64 = conn
.query_row(
"SELECT object_id FROM sources WHERE id = ?",
[source_id],
|row| row.get(0),
)
.unwrap();
assert_eq!(stored, object_id);
}
#[test]
fn set_object_id_nonexistent_source() {
let conn = setup_test_db();
let object_id = insert_object(&conn, "abc123", false);
let result = set_object_id(&conn, 99999, object_id);
assert!(result.is_ok());
}
}