use std::collections::HashMap;
use anyhow::Result;
use super::db::Connection;
use crate::domain::root::Root;
const ROOT_COLUMNS: &str = "id, path, role, comment, last_scanned_at, suspended";
fn root_from_row(row: &rusqlite::Row) -> rusqlite::Result<Root> {
Ok(Root {
id: row.get(0)?,
path: row.get(1)?,
role: row.get(2)?,
comment: row.get(3)?,
last_scanned_at: row.get(4)?,
suspended: row.get(5)?,
})
}
pub fn fetch_all(conn: &Connection) -> Result<Vec<Root>> {
let sql = format!("SELECT {ROOT_COLUMNS} FROM roots ORDER BY id");
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map([], root_from_row)?;
let mut roots = Vec::new();
for row in rows {
roots.push(row?);
}
Ok(roots)
}
#[allow(dead_code)]
pub fn batch_fetch_by_ids(conn: &Connection, root_ids: &[i64]) -> Result<HashMap<i64, Root>> {
if root_ids.is_empty() {
return Ok(HashMap::new());
}
let placeholders: Vec<&str> = root_ids.iter().map(|_| "?").collect();
let sql = format!(
"SELECT {} FROM roots WHERE id IN ({})",
ROOT_COLUMNS,
placeholders.join(",")
);
let params: Vec<rusqlite::types::Value> = root_ids
.iter()
.map(|&id| rusqlite::types::Value::from(id))
.collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params), root_from_row)?;
let mut roots = HashMap::with_capacity(root_ids.len());
for row in rows {
let root = row?;
roots.insert(root.id, root);
}
Ok(roots)
}
pub fn create(conn: &Connection, path: &str, role: &str, comment: Option<&str>) -> Result<Root> {
conn.execute(
"INSERT INTO roots (path, role, comment) VALUES (?, ?, ?)",
rusqlite::params![path, role, comment],
)?;
let id = conn.last_insert_rowid();
let sql = format!("SELECT {ROOT_COLUMNS} FROM roots WHERE id = ?");
let root = conn.query_row(&sql, [id], root_from_row)?;
Ok(root)
}
pub fn update_last_scanned_at(conn: &Connection, root_id: i64, timestamp: i64) -> Result<()> {
conn.execute(
"UPDATE roots SET last_scanned_at = ? WHERE id = ?",
rusqlite::params![timestamp, root_id],
)?;
Ok(())
}
pub fn fetch_file_counts(conn: &Connection, root_ids: &[i64]) -> Result<HashMap<i64, i64>> {
if root_ids.is_empty() {
return Ok(HashMap::new());
}
let placeholders: Vec<&str> = root_ids.iter().map(|_| "?").collect();
let sql = format!(
"SELECT root_id, COUNT(*) FROM sources WHERE present = 1 AND root_id IN ({}) GROUP BY root_id",
placeholders.join(",")
);
let params: Vec<rusqlite::types::Value> = root_ids
.iter()
.map(|&id| rusqlite::types::Value::from(id))
.collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params), |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
})?;
let mut counts = HashMap::new();
for row in rows {
let (root_id, count) = row?;
counts.insert(root_id, count);
}
Ok(counts)
}
pub fn set_suspended(conn: &Connection, root_id: i64, suspended: bool) -> Result<()> {
conn.execute(
"UPDATE roots SET suspended = ? WHERE id = ?",
rusqlite::params![suspended as i64, root_id],
)?;
Ok(())
}
pub fn set_comment(conn: &Connection, root_id: i64, comment: Option<&str>) -> Result<()> {
conn.execute(
"UPDATE roots SET comment = ? WHERE id = ?",
rusqlite::params![comment, root_id],
)?;
Ok(())
}
pub fn remove(conn: &Connection, root_id: i64) -> Result<i64> {
conn.execute(
"DELETE FROM facts WHERE entity_type = 'source' AND entity_id IN (
SELECT id FROM sources WHERE root_id = ?
)",
[root_id],
)?;
let deleted_sources = conn.execute("DELETE FROM sources WHERE root_id = ?", [root_id])?;
conn.execute("DELETE FROM roots WHERE id = ?", [root_id])?;
Ok(deleted_sources as i64)
}
#[cfg(test)]
pub fn insert_test_root(conn: &Connection, path: &str, role: &str, suspended: bool) -> i64 {
conn.execute(
"INSERT INTO roots (path, role, suspended) VALUES (?, ?, ?)",
rusqlite::params![path, role, suspended as i64],
)
.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_root(
conn: &RusqliteConnection,
path: &str,
role: &str,
comment: Option<&str>,
last_scanned_at: Option<i64>,
suspended: bool,
) -> i64 {
conn.execute(
"INSERT INTO roots (path, role, comment, last_scanned_at, suspended) VALUES (?, ?, ?, ?, ?)",
rusqlite::params![path, role, comment, last_scanned_at, suspended as i64],
)
.unwrap();
conn.last_insert_rowid()
}
#[test]
fn fetch_all_empty() {
let conn = setup_test_db();
let roots = fetch_all(&conn).unwrap();
assert!(roots.is_empty());
}
#[test]
fn fetch_all_returns_all() {
let conn = setup_test_db();
insert_root(&conn, "/photos", "source", None, None, false);
insert_root(
&conn,
"/archive",
"archive",
Some("backup"),
Some(1704067200),
false,
);
let roots = fetch_all(&conn).unwrap();
assert_eq!(roots.len(), 2);
assert_eq!(roots[0].path, "/photos");
assert_eq!(roots[1].path, "/archive");
let archive = &roots[1];
assert_eq!(archive.role, "archive");
assert_eq!(archive.comment, Some("backup".to_string()));
assert_eq!(archive.last_scanned_at, Some(1704067200));
assert!(!archive.suspended);
}
#[test]
fn fetch_all_includes_suspended() {
let conn = setup_test_db();
insert_root(&conn, "/active", "source", None, None, false);
insert_root(&conn, "/suspended", "source", None, None, true);
let roots = fetch_all(&conn).unwrap();
assert_eq!(roots.len(), 2);
let suspended = roots.iter().find(|r| r.path == "/suspended").unwrap();
assert!(suspended.is_suspended());
}
#[test]
fn fetch_all_with_domain_predicates() {
let conn = setup_test_db();
insert_root(&conn, "/photos", "source", None, None, false);
insert_root(&conn, "/archive", "archive", None, None, false);
insert_root(&conn, "/suspended", "source", None, None, true);
let roots = fetch_all(&conn).unwrap();
let active_sources: Vec<_> = roots
.iter()
.filter(|r| r.is_active())
.filter(|r| r.is_source())
.collect();
assert_eq!(active_sources.len(), 1);
assert_eq!(active_sources[0].path, "/photos");
}
#[test]
fn batch_fetch_by_ids_empty() {
let conn = setup_test_db();
let roots = batch_fetch_by_ids(&conn, &[]).unwrap();
assert!(roots.is_empty());
}
#[test]
fn batch_fetch_by_ids_found() {
let conn = setup_test_db();
let id1 = insert_root(&conn, "/photos", "source", None, None, false);
let id2 = insert_root(&conn, "/archive", "archive", None, None, false);
let roots = batch_fetch_by_ids(&conn, &[id1, id2]).unwrap();
assert_eq!(roots.len(), 2);
assert_eq!(roots.get(&id1).unwrap().path, "/photos");
assert_eq!(roots.get(&id2).unwrap().path, "/archive");
}
#[test]
fn batch_fetch_by_ids_partial() {
let conn = setup_test_db();
let id1 = insert_root(&conn, "/photos", "source", None, None, false);
let roots = batch_fetch_by_ids(&conn, &[id1, 999, 1000]).unwrap();
assert_eq!(roots.len(), 1);
assert!(roots.contains_key(&id1));
assert!(!roots.contains_key(&999));
}
#[test]
fn batch_fetch_by_ids_no_matching() {
let conn = setup_test_db();
insert_root(&conn, "/photos", "source", None, None, false);
let roots = batch_fetch_by_ids(&conn, &[999, 1000]).unwrap();
assert!(roots.is_empty());
}
#[test]
fn create_returns_complete_root() {
let conn = setup_test_db();
let root = create(&conn, "/photos", "source", None).unwrap();
assert!(root.id > 0);
assert_eq!(root.path, "/photos");
assert_eq!(root.role, "source");
assert_eq!(root.comment, None);
assert_eq!(root.last_scanned_at, None);
assert!(!root.suspended);
let roots = fetch_all(&conn).unwrap();
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].id, root.id);
}
#[test]
fn create_with_comment() {
let conn = setup_test_db();
let root = create(&conn, "/archive", "archive", Some("My archive")).unwrap();
assert_eq!(root.path, "/archive");
assert_eq!(root.role, "archive");
assert_eq!(root.comment, Some("My archive".to_string()));
let roots = fetch_all(&conn).unwrap();
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].id, root.id);
assert_eq!(roots[0].comment, Some("My archive".to_string()));
}
#[test]
fn create_multiple_roots() {
let conn = setup_test_db();
let root1 = create(&conn, "/photos", "source", None).unwrap();
let root2 = create(&conn, "/archive", "archive", None).unwrap();
assert_ne!(root1.id, root2.id);
assert_eq!(root1.path, "/photos");
assert_eq!(root2.path, "/archive");
let roots = fetch_all(&conn).unwrap();
assert_eq!(roots.len(), 2);
}
#[test]
fn update_last_scanned_at_sets_timestamp() {
let conn = setup_test_db();
let id = insert_root(&conn, "/photos", "source", None, None, false);
let roots = fetch_all(&conn).unwrap();
assert!(roots[0].last_scanned_at.is_none());
update_last_scanned_at(&conn, id, 1700000001).unwrap();
let roots = fetch_all(&conn).unwrap();
assert_eq!(roots[0].last_scanned_at, Some(1700000001));
}
#[test]
fn update_last_scanned_at_overwrites() {
let conn = setup_test_db();
let id = insert_root(&conn, "/photos", "source", None, Some(1700000000), false);
update_last_scanned_at(&conn, id, 1700000001).unwrap();
let roots = fetch_all(&conn).unwrap();
assert_eq!(roots[0].last_scanned_at, Some(1700000001));
}
#[test]
fn update_last_scanned_at_nonexistent_root() {
let conn = setup_test_db();
let result = update_last_scanned_at(&conn, 99999, 1700000001);
assert!(result.is_ok());
}
fn insert_source(
conn: &RusqliteConnection,
root_id: i64,
rel_path: &str,
present: bool,
) -> i64 {
conn.execute(
"INSERT INTO sources (root_id, rel_path, present, device, inode, size, mtime, partial_hash, scanned_at, last_seen_at)
VALUES (?, ?, ?, 1, 1, 100, 1700000000, 'testhash', 0, 0)",
rusqlite::params![root_id, rel_path, present as i64],
)
.unwrap();
conn.last_insert_rowid()
}
#[test]
fn fetch_file_counts_empty_ids() {
let conn = setup_test_db();
let counts = fetch_file_counts(&conn, &[]).unwrap();
assert!(counts.is_empty());
}
#[test]
fn fetch_file_counts_root_with_sources() {
let conn = setup_test_db();
let root_id = insert_root(&conn, "/photos", "source", None, None, false);
insert_source(&conn, root_id, "a.jpg", true);
insert_source(&conn, root_id, "b.jpg", true);
insert_source(&conn, root_id, "c.jpg", true);
let counts = fetch_file_counts(&conn, &[root_id]).unwrap();
assert_eq!(counts.get(&root_id), Some(&3));
}
#[test]
fn fetch_file_counts_root_no_sources() {
let conn = setup_test_db();
let root_id = insert_root(&conn, "/photos", "source", None, None, false);
let counts = fetch_file_counts(&conn, &[root_id]).unwrap();
assert!(counts.get(&root_id).is_none());
}
#[test]
fn fetch_file_counts_multiple_roots() {
let conn = setup_test_db();
let root1 = insert_root(&conn, "/photos", "source", None, None, false);
let root2 = insert_root(&conn, "/archive", "archive", None, None, false);
insert_source(&conn, root1, "a.jpg", true);
insert_source(&conn, root1, "b.jpg", true);
insert_source(&conn, root2, "c.jpg", true);
let counts = fetch_file_counts(&conn, &[root1, root2]).unwrap();
assert_eq!(counts.get(&root1), Some(&2));
assert_eq!(counts.get(&root2), Some(&1));
}
#[test]
fn fetch_file_counts_only_present() {
let conn = setup_test_db();
let root_id = insert_root(&conn, "/photos", "source", None, None, false);
insert_source(&conn, root_id, "a.jpg", true);
insert_source(&conn, root_id, "b.jpg", true);
insert_source(&conn, root_id, "missing.jpg", false);
let counts = fetch_file_counts(&conn, &[root_id]).unwrap();
assert_eq!(counts.get(&root_id), Some(&2)); }
#[test]
fn set_suspended_activates() {
let conn = setup_test_db();
let root_id = insert_root(&conn, "/photos", "source", None, None, false);
set_suspended(&conn, root_id, true).unwrap();
let roots = fetch_all(&conn).unwrap();
assert!(roots[0].is_suspended());
}
#[test]
fn set_suspended_deactivates() {
let conn = setup_test_db();
let root_id = insert_root(&conn, "/photos", "source", None, None, true);
set_suspended(&conn, root_id, false).unwrap();
let roots = fetch_all(&conn).unwrap();
assert!(!roots[0].is_suspended());
}
#[test]
fn set_suspended_idempotent() {
let conn = setup_test_db();
let root_id = insert_root(&conn, "/photos", "source", None, None, true);
set_suspended(&conn, root_id, true).unwrap();
let roots = fetch_all(&conn).unwrap();
assert!(roots[0].is_suspended());
}
#[test]
fn set_comment_adds() {
let conn = setup_test_db();
let root_id = insert_root(&conn, "/photos", "source", None, None, false);
set_comment(&conn, root_id, Some("My photos")).unwrap();
let roots = fetch_all(&conn).unwrap();
assert_eq!(roots[0].comment, Some("My photos".to_string()));
}
#[test]
fn set_comment_updates() {
let conn = setup_test_db();
let root_id = insert_root(&conn, "/photos", "source", Some("Old comment"), None, false);
set_comment(&conn, root_id, Some("New comment")).unwrap();
let roots = fetch_all(&conn).unwrap();
assert_eq!(roots[0].comment, Some("New comment".to_string()));
}
#[test]
fn set_comment_clears() {
let conn = setup_test_db();
let root_id = insert_root(&conn, "/photos", "source", Some("Comment"), None, false);
set_comment(&conn, root_id, None).unwrap();
let roots = fetch_all(&conn).unwrap();
assert_eq!(roots[0].comment, None);
}
#[test]
fn remove_empty_root() {
let conn = setup_test_db();
let root_id = insert_root(&conn, "/photos", "source", None, None, false);
let deleted = remove(&conn, root_id).unwrap();
assert_eq!(deleted, 0);
let roots = fetch_all(&conn).unwrap();
assert!(roots.is_empty());
}
#[test]
fn remove_with_sources() {
let conn = setup_test_db();
let root_id = insert_root(&conn, "/photos", "source", None, None, false);
insert_source(&conn, root_id, "a.jpg", true);
insert_source(&conn, root_id, "b.jpg", true);
insert_source(&conn, root_id, "c.jpg", true);
let deleted = remove(&conn, root_id).unwrap();
assert_eq!(deleted, 3);
let roots = fetch_all(&conn).unwrap();
assert!(roots.is_empty());
}
#[test]
fn remove_deletes_facts() {
let conn = setup_test_db();
let root_id = insert_root(&conn, "/photos", "source", None, None, false);
let source_id = insert_source(&conn, root_id, "a.jpg", true);
conn.execute(
"INSERT INTO facts (entity_type, entity_id, key, value_text, observed_at, observed_basis_rev)
VALUES ('source', ?, 'test.key', 'test value', 0, 1)",
[source_id],
)
.unwrap();
let fact_count: i64 = conn
.query_row("SELECT COUNT(*) FROM facts", [], |row| row.get(0))
.unwrap();
assert_eq!(fact_count, 1);
remove(&conn, root_id).unwrap();
let fact_count: i64 = conn
.query_row("SELECT COUNT(*) FROM facts", [], |row| row.get(0))
.unwrap();
assert_eq!(fact_count, 0);
}
#[test]
fn remove_deletes_root() {
let conn = setup_test_db();
let root_id = insert_root(&conn, "/photos", "source", None, None, false);
remove(&conn, root_id).unwrap();
let roots = fetch_all(&conn).unwrap();
assert!(roots.is_empty());
}
}