use std::path::{Path, PathBuf};
use rusqlite::Connection;
use crate::error::{Error, Result};
use crate::removal::RemovalMethod;
const SCHEMA_VERSION: i64 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RootStatConfig {
pub root_id: i64,
pub expiration_days: u32,
pub warning_days: u32,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Root {
pub id: i64,
pub path: PathBuf,
pub added_at: i64,
pub last_scanned: Option<i64>,
pub target_bytes: Option<i64>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub id: i64,
pub root_id: i64,
pub path: PathBuf,
pub parent_path: PathBuf,
pub is_dir: bool,
pub size_bytes: i64,
pub mtime: Option<i64>,
pub tracked_since: Option<i64>,
pub countdown_start: Option<i64>,
pub status: String,
pub deferred_until: Option<i64>,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct Stats {
pub total_files: i64,
pub total_size_bytes: i64,
pub files_within_warning: i64,
pub files_pending_approval: i64,
pub files_overdue: i64,
pub last_scan_completed: Option<i64>,
pub files_healthy: i64,
pub bytes_healthy: i64,
pub bytes_within_warning: i64,
pub bytes_pending_approval: i64,
pub bytes_overdue: i64,
pub files_ignored: i64,
pub bytes_ignored: i64,
}
pub struct Database {
conn: Connection,
}
impl Database {
pub fn open(path: &Path) -> Result<Self> {
let conn = Connection::open(path)?;
let db = Self { conn };
db.initialize()?;
Ok(db)
}
fn initialize(&self) -> Result<()> {
self.conn.pragma_update(None, "journal_mode", "WAL")?;
let journal_mode: String = self
.conn
.pragma_query_value(None, "journal_mode", |row| row.get(0))?;
if !journal_mode.eq_ignore_ascii_case("wal") {
return Err(Error::Config(format!(
"Failed to enable WAL mode (got '{journal_mode}'). \
The database may be on a filesystem that doesn't support it."
)));
}
self.conn.pragma_update(None, "foreign_keys", "ON")?;
self.conn.execute_batch(include_str!("schema.sql"))?;
self.migrate_schema()?;
Ok(())
}
fn migrate_schema(&self) -> Result<()> {
let version = self.user_version()?;
match version {
0 => {
self.migrate_v0_to_v1()?;
self.set_user_version(1)?;
self.migrate_v1_to_v2()?;
self.set_user_version(SCHEMA_VERSION)?;
Ok(())
}
1 => {
self.migrate_v1_to_v2()?;
self.set_user_version(SCHEMA_VERSION)?;
Ok(())
}
SCHEMA_VERSION => Ok(()),
other => Err(Error::Config(format!(
"Unsupported database schema version {other}; expected {SCHEMA_VERSION}"
))),
}
}
fn migrate_v0_to_v1(&self) -> Result<()> {
self.migrate_audit_log_if_needed()
}
fn migrate_v1_to_v2(&self) -> Result<()> {
tracing::info!("Migrating entries table to root-scoped uniqueness");
let tx = self.conn.unchecked_transaction()?;
tx.execute_batch(
"DROP INDEX IF EXISTS idx_entries_root_id;
DROP INDEX IF EXISTS idx_entries_parent_path;
DROP INDEX IF EXISTS idx_entries_status;
DROP INDEX IF EXISTS idx_entries_mtime;
CREATE TABLE entries_new (
id INTEGER PRIMARY KEY,
root_id INTEGER NOT NULL REFERENCES roots(id) ON DELETE CASCADE,
path TEXT NOT NULL,
parent_path TEXT NOT NULL,
is_dir INTEGER NOT NULL DEFAULT 0,
size_bytes INTEGER NOT NULL DEFAULT 0,
mtime INTEGER,
tracked_since INTEGER,
countdown_start INTEGER,
status TEXT NOT NULL DEFAULT 'tracked'
CHECK (status IN ('tracked', 'pending', 'approved', 'deferred', 'ignored', 'removed', 'blocked')),
deferred_until INTEGER,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
UNIQUE(root_id, path)
);
INSERT INTO entries_new (
id, root_id, path, parent_path, is_dir, size_bytes, mtime,
tracked_since, countdown_start, status, deferred_until, created_at, updated_at
)
SELECT
id, root_id, path, parent_path, is_dir, size_bytes, mtime,
tracked_since, countdown_start, status, deferred_until, created_at, updated_at
FROM entries;
DROP TABLE entries;
ALTER TABLE entries_new RENAME TO entries;
CREATE INDEX IF NOT EXISTS idx_entries_root_id ON entries(root_id);
CREATE INDEX IF NOT EXISTS idx_entries_root_parent_path ON entries(root_id, parent_path);
CREATE INDEX IF NOT EXISTS idx_entries_root_status ON entries(root_id, status);
CREATE INDEX IF NOT EXISTS idx_entries_root_is_dir_status ON entries(root_id, is_dir, status);
CREATE INDEX IF NOT EXISTS idx_entries_mtime ON entries(mtime);
CREATE INDEX IF NOT EXISTS idx_entries_path ON entries(path);",
)?;
tx.commit()?;
tracing::info!("entries table migration complete");
Ok(())
}
fn user_version(&self) -> Result<i64> {
self.conn
.pragma_query_value(None, "user_version", |row| row.get(0))
.map_err(Into::into)
}
fn set_user_version(&self, version: i64) -> Result<()> {
self.conn.pragma_update(None, "user_version", version)?;
Ok(())
}
fn migrate_audit_log_if_needed(&self) -> Result<()> {
let table_exists: bool = self.conn.query_row(
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='audit_log'",
[],
|row| row.get(0),
)?;
if !table_exists {
return Ok(());
}
let has_actor_source: bool = self.conn.query_row(
"SELECT COUNT(*) > 0 FROM pragma_table_info('audit_log') WHERE name='actor_source'",
[],
|row| row.get(0),
)?;
if has_actor_source {
return Ok(());
}
tracing::info!("Migrating audit_log table to expanded schema");
let tx = self.conn.unchecked_transaction()?;
tx.execute_batch(
"CREATE TABLE audit_log_new (
id INTEGER PRIMARY KEY,
timestamp INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
user TEXT NOT NULL,
action TEXT NOT NULL
CHECK (action IN ('approve', 'unapprove', 'defer', 'ignore', 'unignore', 'remove', 'scan', 'undo', 'config_change')),
target_path TEXT,
details TEXT,
entry_id INTEGER REFERENCES entries(id) ON DELETE SET NULL,
actor_source TEXT,
root_id INTEGER,
outcome TEXT,
status_before TEXT,
status_after TEXT
);
INSERT INTO audit_log_new (id, timestamp, user, action, target_path, details, entry_id)
SELECT id, timestamp, user, action, target_path, details, entry_id
FROM audit_log;
DROP TABLE audit_log;
ALTER TABLE audit_log_new RENAME TO audit_log;
CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action);"
)?;
tx.commit()?;
tracing::info!("audit_log migration complete");
Ok(())
}
pub fn conn(&self) -> &Connection {
&self.conn
}
pub fn insert_root(&self, path: &Path) -> Result<i64> {
let path_str = path.to_string_lossy();
self.conn.execute(
"INSERT OR IGNORE INTO roots (path) VALUES (?1)",
[&*path_str],
)?;
let id: i64 = self.conn.query_row(
"SELECT id FROM roots WHERE path = ?1",
[&*path_str],
|row| row.get(0),
)?;
Ok(id)
}
#[allow(dead_code)]
pub fn get_root(&self, id: i64) -> Result<Option<Root>> {
let mut stmt = self.conn.prepare(
"SELECT id, path, added_at, last_scanned, target_bytes FROM roots WHERE id = ?1",
)?;
let mut rows = stmt.query([id])?;
if let Some(row) = rows.next()? {
Ok(Some(Root {
id: row.get(0)?,
path: PathBuf::from(row.get::<_, String>(1)?),
added_at: row.get(2)?,
last_scanned: row.get(3)?,
target_bytes: row.get(4)?,
}))
} else {
Ok(None)
}
}
#[allow(dead_code)]
pub fn get_root_by_path(&self, path: &Path) -> Result<Option<Root>> {
let path_str = path.to_string_lossy();
let mut stmt = self.conn.prepare(
"SELECT id, path, added_at, last_scanned, target_bytes FROM roots WHERE path = ?1",
)?;
let mut rows = stmt.query([&*path_str])?;
if let Some(row) = rows.next()? {
Ok(Some(Root {
id: row.get(0)?,
path: PathBuf::from(row.get::<_, String>(1)?),
added_at: row.get(2)?,
last_scanned: row.get(3)?,
target_bytes: row.get(4)?,
}))
} else {
Ok(None)
}
}
pub fn list_roots(&self) -> Result<Vec<Root>> {
let mut stmt = self.conn.prepare(
"SELECT id, path, added_at, last_scanned, target_bytes FROM roots ORDER BY path",
)?;
let rows = stmt.query_map([], |row| {
Ok(Root {
id: row.get(0)?,
path: PathBuf::from(row.get::<_, String>(1)?),
added_at: row.get(2)?,
last_scanned: row.get(3)?,
target_bytes: row.get(4)?,
})
})?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Into::into)
}
#[allow(dead_code)]
pub fn delete_root(&self, root_id: i64) -> Result<()> {
let rows_affected = self
.conn
.execute("DELETE FROM roots WHERE id = ?1", [root_id])?;
if rows_affected == 0 {
return Err(Error::Config(format!("Root with id {root_id} not found")));
}
Ok(())
}
pub fn update_root_last_scanned(&self, root_id: i64, timestamp: i64) -> Result<()> {
let rows_affected = self.conn.execute(
"UPDATE roots SET last_scanned = ?1 WHERE id = ?2",
(timestamp, root_id),
)?;
if rows_affected == 0 {
return Err(Error::Config(format!("Root with id {root_id} not found")));
}
Ok(())
}
#[allow(dead_code)]
pub fn set_root_target_bytes(&self, root_id: i64, target: Option<i64>) -> Result<()> {
let target = target.filter(|&t| t > 0);
let rows_affected = self.conn.execute(
"UPDATE roots SET target_bytes = ?1 WHERE id = ?2",
(target, root_id),
)?;
if rows_affected == 0 {
return Err(Error::Config(format!("Root with id {root_id} not found")));
}
Ok(())
}
#[allow(dead_code)]
pub fn upsert_entry(
&self,
root_id: i64,
path: &Path,
parent_path: &Path,
is_dir: bool,
size_bytes: i64,
mtime: Option<i64>,
) -> Result<i64> {
self.upsert_entry_internal(root_id, path, parent_path, is_dir, size_bytes, mtime)?;
let path_str = path.to_string_lossy();
let id: i64 = self.conn.query_row(
"SELECT id FROM entries WHERE root_id = ?1 AND path = ?2",
rusqlite::params![root_id, &*path_str],
|row| row.get(0),
)?;
Ok(id)
}
pub fn upsert_entry_no_return(
&self,
root_id: i64,
path: &Path,
parent_path: &Path,
is_dir: bool,
size_bytes: i64,
mtime: Option<i64>,
) -> Result<()> {
self.upsert_entry_internal(root_id, path, parent_path, is_dir, size_bytes, mtime)
}
fn upsert_entry_internal(
&self,
root_id: i64,
path: &Path,
parent_path: &Path,
is_dir: bool,
size_bytes: i64,
mtime: Option<i64>,
) -> Result<()> {
let path_str = path.to_string_lossy();
let parent_path_str = parent_path.to_string_lossy();
self.conn.execute(
"INSERT INTO entries (root_id, path, parent_path, is_dir, size_bytes, mtime, tracked_since, countdown_start)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, strftime('%s', 'now'), strftime('%s', 'now'))
ON CONFLICT(root_id, path) DO UPDATE SET
parent_path = excluded.parent_path,
is_dir = excluded.is_dir,
size_bytes = excluded.size_bytes,
mtime = excluded.mtime,
status = CASE
WHEN entries.status = 'removed' THEN 'tracked'
ELSE entries.status
END,
tracked_since = CASE
WHEN entries.status = 'removed' THEN strftime('%s', 'now')
ELSE entries.tracked_since
END,
countdown_start = CASE
WHEN entries.status = 'removed' THEN strftime('%s', 'now')
ELSE entries.countdown_start
END,
deferred_until = CASE
WHEN entries.status = 'removed' THEN NULL
ELSE entries.deferred_until
END,
updated_at = strftime('%s', 'now')",
(root_id, &*path_str, &*parent_path_str, is_dir, size_bytes, mtime),
)?;
Ok(())
}
#[allow(dead_code)]
pub fn get_entry_by_path(&self, path: &Path) -> Result<Option<Entry>> {
let path_str = path.to_string_lossy();
let mut stmt = self.conn.prepare(
"SELECT id, root_id, path, parent_path, is_dir, size_bytes, mtime,
tracked_since, countdown_start, status, deferred_until, created_at, updated_at
FROM entries
WHERE path = ?1",
)?;
let mut rows = stmt.query([&*path_str])?;
if let Some(row) = rows.next()? {
Ok(Some(Entry {
id: row.get(0)?,
root_id: row.get(1)?,
path: PathBuf::from(row.get::<_, String>(2)?),
parent_path: PathBuf::from(row.get::<_, String>(3)?),
is_dir: row.get(4)?,
size_bytes: row.get(5)?,
mtime: row.get(6)?,
tracked_since: row.get(7)?,
countdown_start: row.get(8)?,
status: row.get(9)?,
deferred_until: row.get(10)?,
created_at: row.get(11)?,
updated_at: row.get(12)?,
}))
} else {
Ok(None)
}
}
#[allow(dead_code)]
pub fn get_entry_by_root_and_path(&self, root_id: i64, path: &Path) -> Result<Option<Entry>> {
let path_str = path.to_string_lossy();
let mut stmt = self.conn.prepare(
"SELECT id, root_id, path, parent_path, is_dir, size_bytes, mtime,
tracked_since, countdown_start, status, deferred_until, created_at, updated_at
FROM entries
WHERE root_id = ?1 AND path = ?2",
)?;
let mut rows = stmt.query(rusqlite::params![root_id, &*path_str])?;
if let Some(row) = rows.next()? {
Ok(Some(Entry {
id: row.get(0)?,
root_id: row.get(1)?,
path: PathBuf::from(row.get::<_, String>(2)?),
parent_path: PathBuf::from(row.get::<_, String>(3)?),
is_dir: row.get(4)?,
size_bytes: row.get(5)?,
mtime: row.get(6)?,
tracked_since: row.get(7)?,
countdown_start: row.get(8)?,
status: row.get(9)?,
deferred_until: row.get(10)?,
created_at: row.get(11)?,
updated_at: row.get(12)?,
}))
} else {
Ok(None)
}
}
pub fn list_entries_by_parent(&self, root_id: i64, parent_path: &Path) -> Result<Vec<Entry>> {
let parent_path_str = parent_path.to_string_lossy();
let mut stmt = self.conn.prepare(
"SELECT id, root_id, path, parent_path, is_dir, size_bytes, mtime,
tracked_since, countdown_start, status, deferred_until, created_at, updated_at
FROM entries
WHERE root_id = ?1 AND parent_path = ?2 AND status != 'removed'
ORDER BY path",
)?;
let rows = stmt.query_map(rusqlite::params![root_id, &*parent_path_str], |row| {
Ok(Entry {
id: row.get(0)?,
root_id: row.get(1)?,
path: PathBuf::from(row.get::<_, String>(2)?),
parent_path: PathBuf::from(row.get::<_, String>(3)?),
is_dir: row.get(4)?,
size_bytes: row.get(5)?,
mtime: row.get(6)?,
tracked_since: row.get(7)?,
countdown_start: row.get(8)?,
status: row.get(9)?,
deferred_until: row.get(10)?,
created_at: row.get(11)?,
updated_at: row.get(12)?,
})
})?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Into::into)
}
pub fn list_entries_by_root(&self, root_id: i64) -> Result<Vec<Entry>> {
let mut stmt = self.conn.prepare(
"SELECT id, root_id, path, parent_path, is_dir, size_bytes, mtime,
tracked_since, countdown_start, status, deferred_until, created_at, updated_at
FROM entries
WHERE root_id = ?1 AND status != 'removed'
ORDER BY path",
)?;
let rows = stmt.query_map([root_id], |row| {
Ok(Entry {
id: row.get(0)?,
root_id: row.get(1)?,
path: PathBuf::from(row.get::<_, String>(2)?),
parent_path: PathBuf::from(row.get::<_, String>(3)?),
is_dir: row.get(4)?,
size_bytes: row.get(5)?,
mtime: row.get(6)?,
tracked_since: row.get(7)?,
countdown_start: row.get(8)?,
status: row.get(9)?,
deferred_until: row.get(10)?,
created_at: row.get(11)?,
updated_at: row.get(12)?,
})
})?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Into::into)
}
pub fn list_entries_by_root_and_status(
&self,
root_id: i64,
status: &str,
) -> Result<Vec<Entry>> {
let mut stmt = self.conn.prepare(
"SELECT id, root_id, path, parent_path, is_dir, size_bytes, mtime,
tracked_since, countdown_start, status, deferred_until, created_at, updated_at
FROM entries
WHERE root_id = ?1 AND status = ?2
ORDER BY path",
)?;
let rows = stmt.query_map((root_id, status), |row| {
Ok(Entry {
id: row.get(0)?,
root_id: row.get(1)?,
path: PathBuf::from(row.get::<_, String>(2)?),
parent_path: PathBuf::from(row.get::<_, String>(3)?),
is_dir: row.get(4)?,
size_bytes: row.get(5)?,
mtime: row.get(6)?,
tracked_since: row.get(7)?,
countdown_start: row.get(8)?,
status: row.get(9)?,
deferred_until: row.get(10)?,
created_at: row.get(11)?,
updated_at: row.get(12)?,
})
})?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Into::into)
}
pub fn list_entries(&self, status_filter: Option<&str>) -> Result<Vec<Entry>> {
let row_to_entry = |row: &rusqlite::Row<'_>| -> rusqlite::Result<Entry> {
Ok(Entry {
id: row.get(0)?,
root_id: row.get(1)?,
path: PathBuf::from(row.get::<_, String>(2)?),
parent_path: PathBuf::from(row.get::<_, String>(3)?),
is_dir: row.get(4)?,
size_bytes: row.get(5)?,
mtime: row.get(6)?,
tracked_since: row.get(7)?,
countdown_start: row.get(8)?,
status: row.get(9)?,
deferred_until: row.get(10)?,
created_at: row.get(11)?,
updated_at: row.get(12)?,
})
};
let query = if status_filter.is_some() {
"SELECT id, root_id, path, parent_path, is_dir, size_bytes, mtime,
tracked_since, countdown_start, status, deferred_until, created_at, updated_at
FROM entries
WHERE status = ?1
ORDER BY path"
} else {
"SELECT id, root_id, path, parent_path, is_dir, size_bytes, mtime,
tracked_since, countdown_start, status, deferred_until, created_at, updated_at
FROM entries
ORDER BY path"
};
let mut stmt = self.conn.prepare(query)?;
let rows = if let Some(status) = status_filter {
stmt.query_map([status], row_to_entry)?
} else {
stmt.query_map([], row_to_entry)?
};
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Into::into)
}
pub fn nearest_expiration(&self, expiration_days: u32) -> Result<Option<i64>> {
let expiration_seconds = i64::from(expiration_days) * 86400;
let result: Option<i64> = self.conn.query_row(
"SELECT MIN(
CASE
WHEN status = 'deferred' AND deferred_until IS NOT NULL
THEN deferred_until
WHEN countdown_start IS NOT NULL
THEN countdown_start + ?1
ELSE NULL
END
)
FROM entries
WHERE is_dir = 0
AND status NOT IN ('ignored', 'removed', 'blocked')",
[expiration_seconds],
|row| row.get(0),
)?;
Ok(result)
}
pub fn update_entry_status(&self, entry_id: i64, new_status: &str) -> Result<()> {
let rows_affected = self.conn.execute(
"UPDATE entries
SET status = ?1, updated_at = strftime('%s', 'now')
WHERE id = ?2",
(new_status, entry_id),
)?;
if rows_affected == 0 {
return Err(Error::Config(format!("Entry with id {entry_id} not found")));
}
Ok(())
}
pub fn restore_entry_state(
&self,
entry_id: i64,
status: &str,
countdown_start: Option<i64>,
deferred_until: Option<i64>,
) -> Result<()> {
let rows = self.conn.execute(
"UPDATE entries
SET status = ?1,
countdown_start = ?2,
deferred_until = ?3,
updated_at = strftime('%s', 'now')
WHERE id = ?4",
rusqlite::params![status, countdown_start, deferred_until, entry_id],
)?;
if rows == 0 {
return Err(crate::error::Error::Config(format!(
"Entry {entry_id} not found for undo"
)));
}
Ok(())
}
pub fn update_entries_by_path_prefix(
&self,
root_id: i64,
path_prefix: &Path,
new_status: &str,
) -> Result<usize> {
let prefix_str = path_prefix.to_string_lossy();
let rows_affected = self.conn.execute(
"UPDATE entries
SET status = ?1, updated_at = strftime('%s', 'now')
WHERE root_id = ?2 AND (path = ?3 OR path LIKE ?4)",
rusqlite::params![new_status, root_id, &*prefix_str, format!("{prefix_str}/%")],
)?;
Ok(rows_affected)
}
pub fn enforce_ignored_directory_inheritance(&self, root_id: i64) -> Result<usize> {
let rows_affected = self.conn.execute(
"UPDATE entries AS e
SET status = 'ignored',
updated_at = strftime('%s', 'now')
WHERE e.root_id = ?1
AND e.status != 'removed'
AND EXISTS (
SELECT 1
FROM entries AS d
WHERE d.root_id = e.root_id
AND d.is_dir = 1
AND d.status = 'ignored'
AND (e.path = d.path OR e.path LIKE d.path || '/%')
)",
[root_id],
)?;
Ok(rows_affected)
}
pub fn defer_entry(&self, entry_id: i64, deferred_until: i64) -> Result<()> {
let rows_affected = self.conn.execute(
"UPDATE entries
SET status = 'deferred', deferred_until = ?1, updated_at = strftime('%s', 'now')
WHERE id = ?2",
(deferred_until, entry_id),
)?;
if rows_affected == 0 {
return Err(Error::Config(format!("Entry with id {entry_id} not found")));
}
Ok(())
}
pub fn defer_entries_by_path_prefix(
&self,
root_id: i64,
path_prefix: &Path,
deferred_until: i64,
) -> Result<usize> {
let prefix_str = path_prefix.to_string_lossy();
let rows_affected = self.conn.execute(
"UPDATE entries
SET status = 'deferred', deferred_until = ?1, updated_at = strftime('%s', 'now')
WHERE root_id = ?2 AND (path = ?3 OR path LIKE ?4)",
rusqlite::params![
deferred_until,
root_id,
&*prefix_str,
format!("{prefix_str}/%")
],
)?;
Ok(rows_affected)
}
pub fn delete_entry(
&self,
entry_id: i64,
path: &Path,
is_dir: bool,
method: RemovalMethod,
) -> Result<()> {
match method {
RemovalMethod::Trash => {
trash::delete(path).map_err(|e| Error::Trash {
path: path.to_path_buf(),
message: e.to_string(),
})?;
}
RemovalMethod::PermanentDelete => {
let fs_result = if is_dir {
std::fs::remove_dir_all(path)
} else {
std::fs::remove_file(path)
};
if let Err(e) = fs_result {
return Err(match e.kind() {
std::io::ErrorKind::PermissionDenied => {
Error::PermissionDenied(path.to_path_buf())
}
std::io::ErrorKind::NotFound => Error::PathNotFound(path.to_path_buf()),
_ => Error::Io(e),
});
}
}
}
self.update_entry_status(entry_id, "removed")?;
if is_dir {
let root_id: i64 = self.conn.query_row(
"SELECT root_id FROM entries WHERE id = ?1",
[entry_id],
|row| row.get(0),
)?;
self.update_entries_by_path_prefix(root_id, path, "removed")?;
}
Ok(())
}
pub fn reset_root_countdowns(&self, root_id: i64) -> Result<usize> {
let now = jiff::Timestamp::now().as_second();
let rows_affected = self.conn.execute(
"UPDATE entries
SET countdown_start = ?1,
status = CASE
WHEN status IN ('pending', 'approved', 'deferred') THEN 'tracked'
ELSE status
END,
deferred_until = CASE
WHEN status = 'deferred' THEN NULL
ELSE deferred_until
END,
updated_at = strftime('%s', 'now')
WHERE root_id = ?2 AND status NOT IN ('ignored', 'removed')",
(now, root_id),
)?;
Ok(rows_affected)
}
pub fn get_stats(&self) -> Result<Stats> {
self.conn
.query_row(
"SELECT total_files, total_size_bytes, files_within_warning,
files_pending_approval, files_overdue, last_scan_completed
FROM stats WHERE id = 1",
[],
|row| {
Ok(Stats {
total_files: row.get(0)?,
total_size_bytes: row.get(1)?,
files_within_warning: row.get(2)?,
files_pending_approval: row.get(3)?,
files_overdue: row.get(4)?,
last_scan_completed: row.get(5)?,
files_healthy: 0,
bytes_healthy: 0,
bytes_within_warning: 0,
bytes_pending_approval: 0,
bytes_overdue: 0,
files_ignored: 0,
bytes_ignored: 0,
})
},
)
.map_err(Into::into)
}
#[allow(dead_code)]
pub fn compute_live_stats(&self, expiration_days: u32, warning_days: u32) -> Result<Stats> {
let root_configs = self
.list_roots()?
.into_iter()
.map(|root| RootStatConfig {
root_id: root.id,
expiration_days,
warning_days,
})
.collect::<Vec<_>>();
self.compute_live_stats_with_root_configs(&root_configs)
}
pub fn compute_live_stats_with_root_configs(
&self,
root_configs: &[RootStatConfig],
) -> Result<Stats> {
let now = jiff::Timestamp::now().as_second();
let config_map: std::collections::HashMap<i64, (u32, u32)> = root_configs
.iter()
.map(|cfg| (cfg.root_id, (cfg.expiration_days, cfg.warning_days)))
.collect();
let last_scan_completed: Option<i64> = self.conn.query_row(
"SELECT last_scan_completed FROM stats WHERE id = 1",
[],
|row| row.get(0),
)?;
let mut stmt = self.conn.prepare(
"SELECT path, root_id, size_bytes, countdown_start, deferred_until, status
FROM entries
WHERE is_dir = 0
ORDER BY path, root_id",
)?;
let rows = stmt.query_map([], |row| {
Ok(DedupedStatRow {
path: row.get(0)?,
root_id: row.get(1)?,
size_bytes: row.get(2)?,
countdown_start: row.get(3)?,
deferred_until: row.get(4)?,
status: row.get(5)?,
})
})?;
let mut aggregate = DedupedStatAggregate::default();
let mut current_path: Option<String> = None;
let mut current_bucket = PathStatBucket::default();
for row in rows {
let row = row?;
if current_path.as_deref() != Some(&row.path) {
if current_path.is_some() {
aggregate.observe(¤t_bucket);
}
current_path = Some(row.path.clone());
current_bucket = PathStatBucket::default();
}
let (expiration_days, warning_days) =
config_map.get(&row.root_id).copied().unwrap_or((90, 14));
current_bucket.observe_row(&row, expiration_days, warning_days, now);
}
if current_path.is_some() {
aggregate.observe(¤t_bucket);
}
Ok(Stats {
total_files: aggregate.total_files,
total_size_bytes: aggregate.total_size_bytes,
files_within_warning: aggregate.files_within_warning,
files_pending_approval: aggregate.files_pending_approval,
files_overdue: aggregate.files_overdue,
last_scan_completed,
files_healthy: aggregate.files_healthy,
bytes_healthy: aggregate.bytes_healthy,
bytes_within_warning: aggregate.bytes_within_warning,
bytes_pending_approval: aggregate.bytes_pending_approval,
bytes_overdue: aggregate.bytes_overdue,
files_ignored: aggregate.files_ignored,
bytes_ignored: aggregate.bytes_ignored,
})
}
}
#[derive(Debug)]
struct DedupedStatRow {
path: String,
root_id: i64,
size_bytes: i64,
countdown_start: Option<i64>,
deferred_until: Option<i64>,
status: String,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum PathUrgency {
#[default]
None,
Healthy,
Warning,
Pending,
Overdue,
}
#[derive(Debug, Default)]
struct PathStatBucket {
size_bytes: i64,
has_active: bool,
has_ignored_only: bool,
urgency: PathUrgency,
}
impl PathStatBucket {
fn observe_row(
&mut self,
row: &DedupedStatRow,
expiration_days: u32,
warning_days: u32,
now: i64,
) {
self.size_bytes = self.size_bytes.max(row.size_bytes);
if row.status != "removed" && row.status != "ignored" {
self.has_active = true;
self.has_ignored_only = false;
} else if row.status == "ignored" && !self.has_active {
self.has_ignored_only = true;
}
let days_remaining = if row.status == "deferred" {
row.deferred_until.map(|until| (until - now) / 86400)
} else {
row.countdown_start.map(|countdown_start| {
let expiration_timestamp = countdown_start + (i64::from(expiration_days) * 86400);
(expiration_timestamp - now) / 86400
})
};
if row.status == "pending" {
self.urgency = self.urgency.max(PathUrgency::Pending);
}
if row.status == "tracked"
&& let Some(days) = days_remaining
{
if days <= 0 {
self.urgency = self.urgency.max(PathUrgency::Overdue);
} else if days <= i64::from(warning_days) {
self.urgency = self.urgency.max(PathUrgency::Warning);
} else {
self.urgency = self.urgency.max(PathUrgency::Healthy);
}
}
if (row.status == "approved" || row.status == "pending")
&& let Some(days) = days_remaining
&& days <= 0
{
self.urgency = self.urgency.max(PathUrgency::Overdue);
}
}
}
#[derive(Debug, Default)]
struct DedupedStatAggregate {
total_files: i64,
total_size_bytes: i64,
files_within_warning: i64,
files_pending_approval: i64,
files_overdue: i64,
files_healthy: i64,
bytes_healthy: i64,
bytes_within_warning: i64,
bytes_pending_approval: i64,
bytes_overdue: i64,
files_ignored: i64,
bytes_ignored: i64,
}
impl DedupedStatAggregate {
fn observe(&mut self, bucket: &PathStatBucket) {
if bucket.has_active {
self.total_files += 1;
self.total_size_bytes += bucket.size_bytes;
match bucket.urgency {
PathUrgency::Overdue => {
self.files_overdue += 1;
self.bytes_overdue += bucket.size_bytes;
}
PathUrgency::Pending => {
self.files_pending_approval += 1;
self.bytes_pending_approval += bucket.size_bytes;
}
PathUrgency::Warning => {
self.files_within_warning += 1;
self.bytes_within_warning += bucket.size_bytes;
}
PathUrgency::Healthy => {
self.files_healthy += 1;
self.bytes_healthy += bucket.size_bytes;
}
PathUrgency::None => {}
}
} else if bucket.has_ignored_only {
self.files_ignored += 1;
self.bytes_ignored += bucket.size_bytes;
}
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use tempfile::NamedTempFile;
use super::*;
const LEGACY_V1_SCHEMA: &str = "
CREATE TABLE roots (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
added_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
last_scanned INTEGER,
target_bytes INTEGER
);
CREATE TABLE entries (
id INTEGER PRIMARY KEY,
root_id INTEGER NOT NULL REFERENCES roots(id) ON DELETE CASCADE,
path TEXT NOT NULL UNIQUE,
parent_path TEXT NOT NULL,
is_dir INTEGER NOT NULL DEFAULT 0,
size_bytes INTEGER NOT NULL DEFAULT 0,
mtime INTEGER,
tracked_since INTEGER,
countdown_start INTEGER,
status TEXT NOT NULL DEFAULT 'tracked'
CHECK (status IN ('tracked', 'pending', 'approved', 'deferred', 'ignored', 'removed', 'blocked')),
deferred_until INTEGER,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
CREATE TABLE audit_log (
id INTEGER PRIMARY KEY,
timestamp INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
user TEXT NOT NULL,
action TEXT NOT NULL
CHECK (action IN ('approve', 'unapprove', 'defer', 'ignore', 'unignore', 'remove', 'scan', 'undo', 'config_change')),
target_path TEXT,
details TEXT,
entry_id INTEGER REFERENCES entries(id) ON DELETE SET NULL,
actor_source TEXT,
root_id INTEGER,
outcome TEXT,
status_before TEXT,
status_after TEXT
);
CREATE INDEX idx_entries_root_id ON entries(root_id);
CREATE INDEX idx_entries_parent_path ON entries(parent_path);
CREATE INDEX idx_entries_status ON entries(status);
CREATE INDEX idx_entries_mtime ON entries(mtime);
CREATE INDEX idx_audit_log_timestamp ON audit_log(timestamp);
CREATE INDEX idx_audit_log_action ON audit_log(action);
CREATE TABLE stats (
id INTEGER PRIMARY KEY CHECK (id = 1),
total_files INTEGER NOT NULL DEFAULT 0,
total_size_bytes INTEGER NOT NULL DEFAULT 0,
files_within_warning INTEGER NOT NULL DEFAULT 0,
files_pending_approval INTEGER NOT NULL DEFAULT 0,
files_overdue INTEGER NOT NULL DEFAULT 0,
last_scan_completed INTEGER,
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
INSERT OR IGNORE INTO stats (id) VALUES (1);
";
fn temp_database() -> (NamedTempFile, Database) {
let temp_file = NamedTempFile::new().expect("failed to create temp file");
let db = Database::open(temp_file.path()).expect("failed to open database");
(temp_file, db)
}
#[test]
fn database_creates_file_and_schema() {
let (_temp, db) = temp_database();
let tables: Vec<String> = db
.conn
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.expect("failed to prepare query")
.query_map([], |row| row.get(0))
.expect("failed to query")
.collect::<std::result::Result<Vec<_>, _>>()
.expect("failed to collect");
assert!(
tables.contains(&"roots".to_string()),
"missing 'roots' table, found: {tables:?}"
);
assert!(
tables.contains(&"entries".to_string()),
"missing 'entries' table, found: {tables:?}"
);
assert!(
tables.contains(&"audit_log".to_string()),
"missing 'audit_log' table, found: {tables:?}"
);
assert!(
tables.contains(&"stats".to_string()),
"missing 'stats' table, found: {tables:?}"
);
}
#[test]
fn database_enables_wal_mode() {
let (_temp, db) = temp_database();
let journal_mode: String = db
.conn
.pragma_query_value(None, "journal_mode", |row| row.get(0))
.expect("failed to query journal_mode");
assert_eq!(journal_mode.to_lowercase(), "wal");
}
#[test]
fn database_enables_foreign_keys() {
let (_temp, db) = temp_database();
let foreign_keys: i32 = db
.conn
.pragma_query_value(None, "foreign_keys", |row| row.get(0))
.expect("failed to query foreign_keys");
assert_eq!(foreign_keys, 1);
}
#[test]
fn database_creates_indexes() {
let (_temp, db) = temp_database();
let indexes: Vec<String> = db
.conn
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'")
.expect("failed to prepare query")
.query_map([], |row| row.get(0))
.expect("failed to query")
.collect::<std::result::Result<Vec<_>, _>>()
.expect("failed to collect");
let expected = [
"idx_entries_root_id",
"idx_entries_root_parent_path",
"idx_entries_root_status",
"idx_entries_root_is_dir_status",
"idx_entries_mtime",
"idx_entries_path",
"idx_audit_log_timestamp",
"idx_audit_log_action",
];
for idx in expected {
assert!(
indexes.contains(&idx.to_string()),
"missing index '{idx}', found: {indexes:?}"
);
}
}
#[test]
fn database_initializes_stats_singleton() {
let (_temp, db) = temp_database();
let count: i32 = db
.conn
.query_row("SELECT COUNT(*) FROM stats WHERE id = 1", [], |row| {
row.get(0)
})
.expect("failed to query stats");
assert_eq!(
count, 1,
"stats table should have exactly one row with id=1"
);
}
#[test]
fn database_sets_schema_user_version() {
let (_temp, db) = temp_database();
let user_version: i64 = db
.conn
.pragma_query_value(None, "user_version", |row| row.get(0))
.expect("failed to query user_version");
assert_eq!(user_version, SCHEMA_VERSION);
}
#[test]
fn database_schema_is_idempotent() {
let temp_file = NamedTempFile::new().expect("failed to create temp file");
{
let db = Database::open(temp_file.path()).expect("first open");
let root_id = db.insert_root(Path::new("/test")).expect("insert root");
db.upsert_entry(
root_id,
Path::new("/test/file.txt"),
Path::new("/test"),
false,
100,
Some(1000),
)
.expect("insert entry");
}
let db = Database::open(temp_file.path()).expect("second open");
let roots = db.list_roots().expect("list roots");
assert_eq!(roots.len(), 1, "root should persist across opens");
assert_eq!(roots[0].path, PathBuf::from("/test"));
let entries = db
.list_entries_by_parent(roots[0].id, Path::new("/test"))
.expect("list entries");
assert_eq!(entries.len(), 1, "entry should persist across opens");
let stats_count: i32 = db
.conn
.query_row("SELECT COUNT(*) FROM stats", [], |row| row.get(0))
.expect("query stats");
assert_eq!(
stats_count, 1,
"stats table should still have exactly one row"
);
let user_version: i64 = db
.conn
.pragma_query_value(None, "user_version", |row| row.get(0))
.expect("failed to query user_version after reopen");
assert_eq!(user_version, SCHEMA_VERSION);
}
#[test]
fn database_open_rejects_future_schema_version() {
let temp_file = NamedTempFile::new().expect("failed to create temp file");
{
let conn = Connection::open(temp_file.path()).expect("open raw sqlite db");
conn.pragma_update(None, "journal_mode", "WAL")
.expect("set wal mode");
conn.pragma_update(None, "foreign_keys", "ON")
.expect("enable foreign keys");
conn.execute_batch(include_str!("schema.sql"))
.expect("create schema");
conn.pragma_update(None, "user_version", SCHEMA_VERSION + 1)
.expect("set future user_version");
}
let err = Database::open(temp_file.path())
.err()
.expect("future schema version should fail");
let msg = err.to_string();
assert!(
msg.contains("Unsupported database schema version"),
"unexpected error message: {msg}"
);
}
#[test]
fn database_open_upgrades_legacy_unversioned_schema() {
let temp_file = NamedTempFile::new().expect("failed to create temp file");
{
let conn = Connection::open(temp_file.path()).expect("open raw sqlite db");
conn.pragma_update(None, "journal_mode", "WAL")
.expect("set wal mode");
conn.pragma_update(None, "foreign_keys", "ON")
.expect("enable foreign keys");
conn.execute_batch(LEGACY_V1_SCHEMA)
.expect("create legacy schema");
conn.execute("INSERT INTO roots (path) VALUES (?1)", ["/legacy-root"])
.expect("insert legacy root");
conn.pragma_update(None, "user_version", 0)
.expect("clear user_version");
}
let db = Database::open(temp_file.path()).expect("open should upgrade legacy schema");
let roots = db.list_roots().expect("list roots after upgrade");
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].path, PathBuf::from("/legacy-root"));
let unique_indexes: i64 = db
.conn
.query_row(
"SELECT COUNT(*)
FROM pragma_index_list('entries')
WHERE name LIKE 'sqlite_autoindex_entries_%' AND [unique] = 1",
[],
|row| row.get(0),
)
.expect("query entries unique indexes");
assert_eq!(
unique_indexes, 1,
"entries should have one unique autoindex"
);
let user_version: i64 = db
.conn
.pragma_query_value(None, "user_version", |row| row.get(0))
.expect("failed to query upgraded user_version");
assert_eq!(user_version, SCHEMA_VERSION);
}
#[test]
fn database_open_fails_on_invalid_path() {
let result = Database::open(Path::new("/nonexistent/deeply/nested/path/db.sqlite"));
assert!(result.is_err(), "should fail on invalid path");
}
#[test]
fn foreign_key_constraint_prevents_orphan_entries() {
let (_temp, db) = temp_database();
let result = db.conn.execute(
"INSERT INTO entries (root_id, path, parent_path, size_bytes) VALUES (999, '/orphan', '/', 100)",
[],
);
assert!(
result.is_err(),
"foreign key should prevent inserting entry with invalid root_id"
);
}
#[test]
fn entries_rejects_invalid_status() {
let (_temp, db) = temp_database();
let root_id = db.insert_root(Path::new("/test")).expect("insert root");
let result = db.conn.execute(
"INSERT INTO entries (root_id, path, parent_path, status) VALUES (?1, '/test/file', '/test', 'invalid_status')",
[root_id],
);
assert!(
result.is_err(),
"CHECK constraint should reject invalid status value"
);
}
#[test]
fn stats_enforces_singleton_constraint() {
let (_temp, db) = temp_database();
let result = db.conn.execute("INSERT INTO stats (id) VALUES (2)", []);
assert!(
result.is_err(),
"CHECK (id = 1) should reject stats row with id != 1"
);
}
#[test]
fn insert_root_creates_new() {
let (_temp, db) = temp_database();
let id = db
.insert_root(Path::new("/data/project1"))
.expect("insert should succeed");
let root = db
.get_root_by_path(Path::new("/data/project1"))
.expect("query should succeed")
.expect("root should exist");
assert_eq!(root.id, id);
assert_eq!(root.path, PathBuf::from("/data/project1"));
assert!(root.added_at > 0);
assert_eq!(root.last_scanned, None);
}
#[test]
fn insert_root_is_idempotent() {
let (_temp, db) = temp_database();
let id1 = db
.insert_root(Path::new("/data/project1"))
.expect("first insert");
let id2 = db
.insert_root(Path::new("/data/project1"))
.expect("second insert");
assert_eq!(id1, id2, "inserting same path should return same ID");
let roots = db.list_roots().expect("list roots");
assert_eq!(roots.len(), 1, "should only have one root");
}
#[test]
fn get_root_by_path_returns_none_when_not_found() {
let (_temp, db) = temp_database();
let result = db
.get_root_by_path(Path::new("/nonexistent"))
.expect("query should succeed");
assert_eq!(result, None);
}
#[test]
fn list_roots_returns_all_ordered_by_path() {
let (_temp, db) = temp_database();
db.insert_root(Path::new("/data/zebra")).expect("insert");
db.insert_root(Path::new("/data/alpha")).expect("insert");
db.insert_root(Path::new("/data/middle")).expect("insert");
let roots = db.list_roots().expect("list roots");
assert_eq!(roots.len(), 3);
assert_eq!(roots[0].path, PathBuf::from("/data/alpha"));
assert_eq!(roots[1].path, PathBuf::from("/data/middle"));
assert_eq!(roots[2].path, PathBuf::from("/data/zebra"));
}
#[test]
fn delete_root_removes_root() {
let (_temp, db) = temp_database();
let id = db.insert_root(Path::new("/data/project1")).expect("insert");
db.delete_root(id).expect("delete should succeed");
let root = db
.get_root_by_path(Path::new("/data/project1"))
.expect("query");
assert_eq!(root, None, "root should be deleted");
}
#[test]
fn delete_root_cascades_to_entries() {
let (_temp, db) = temp_database();
let root_id = db
.insert_root(Path::new("/data/project1"))
.expect("insert root");
db.upsert_entry(
root_id,
Path::new("/data/project1/file.txt"),
Path::new("/data/project1"),
false,
100,
Some(1000),
)
.expect("insert entry");
let entries_before = db
.list_entries_by_parent(root_id, Path::new("/data/project1"))
.expect("list");
assert_eq!(entries_before.len(), 1);
db.delete_root(root_id).expect("delete root");
let entries_after = db
.list_entries_by_parent(root_id, Path::new("/data/project1"))
.expect("list");
assert_eq!(entries_after.len(), 0, "entries should be cascaded");
}
#[test]
fn delete_root_fails_on_nonexistent() {
let (_temp, db) = temp_database();
let result = db.delete_root(999);
assert!(result.is_err(), "should fail on nonexistent root");
}
#[test]
fn update_root_last_scanned_works() {
let (_temp, db) = temp_database();
let id = db.insert_root(Path::new("/data/project1")).expect("insert");
let root_before = db
.get_root_by_path(Path::new("/data/project1"))
.expect("query")
.expect("root should exist");
assert_eq!(root_before.last_scanned, None);
db.update_root_last_scanned(id, 1_700_000_000)
.expect("update");
let root_after = db
.get_root_by_path(Path::new("/data/project1"))
.expect("query")
.expect("root should exist");
assert_eq!(root_after.last_scanned, Some(1_700_000_000));
}
#[test]
fn upsert_entry_creates_new_file() {
let (_temp, db) = temp_database();
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
let entry_id = db
.upsert_entry(
root_id,
Path::new("/data/file.txt"),
Path::new("/data"),
false,
512,
Some(1_700_000_000),
)
.expect("insert entry");
let entry = db
.get_entry_by_path(Path::new("/data/file.txt"))
.expect("query")
.expect("entry should exist");
assert_eq!(entry.id, entry_id);
assert_eq!(entry.root_id, root_id);
assert_eq!(entry.path, PathBuf::from("/data/file.txt"));
assert_eq!(entry.parent_path, PathBuf::from("/data"));
assert!(!entry.is_dir);
assert_eq!(entry.size_bytes, 512);
assert_eq!(entry.mtime, Some(1_700_000_000));
assert_eq!(entry.status, "tracked");
assert!(entry.tracked_since.is_some());
}
#[test]
fn upsert_entry_creates_new_directory() {
let (_temp, db) = temp_database();
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
let entry_id = db
.upsert_entry(
root_id,
Path::new("/data/subdir"),
Path::new("/data"),
true,
0,
None,
)
.expect("insert entry");
let entry = db
.get_entry_by_path(Path::new("/data/subdir"))
.expect("query")
.expect("entry should exist");
assert_eq!(entry.id, entry_id);
assert!(entry.is_dir);
assert_eq!(entry.size_bytes, 0);
assert_eq!(entry.mtime, None);
}
#[test]
fn upsert_entry_allows_same_path_in_multiple_roots() {
let (_temp, db) = temp_database();
let parent_root_id = db
.insert_root(Path::new("/data"))
.expect("insert parent root");
let nested_root_id = db
.insert_root(Path::new("/data/project"))
.expect("insert nested root");
db.upsert_entry(
parent_root_id,
Path::new("/data/project/file.txt"),
Path::new("/data/project"),
false,
100,
Some(1000),
)
.expect("insert parent-root entry");
db.upsert_entry(
nested_root_id,
Path::new("/data/project/file.txt"),
Path::new("/data/project"),
false,
100,
Some(1000),
)
.expect("insert nested-root entry");
let parent_entry = db
.get_entry_by_root_and_path(parent_root_id, Path::new("/data/project/file.txt"))
.expect("query parent-root entry")
.expect("parent-root entry should exist");
let nested_entry = db
.get_entry_by_root_and_path(nested_root_id, Path::new("/data/project/file.txt"))
.expect("query nested-root entry")
.expect("nested-root entry should exist");
assert_eq!(parent_entry.root_id, parent_root_id);
assert_eq!(nested_entry.root_id, nested_root_id);
assert_ne!(parent_entry.id, nested_entry.id);
}
#[test]
fn upsert_entry_updates_existing() {
let (_temp, db) = temp_database();
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
let id1 = db
.upsert_entry(
root_id,
Path::new("/data/file.txt"),
Path::new("/data"),
false,
512,
Some(1_700_000_000),
)
.expect("first insert");
let id2 = db
.upsert_entry(
root_id,
Path::new("/data/file.txt"),
Path::new("/data"),
false,
1024,
Some(1_700_050_000),
)
.expect("second insert");
assert_eq!(id1, id2, "upsert should return same ID");
let entry = db
.get_entry_by_path(Path::new("/data/file.txt"))
.expect("query")
.expect("entry should exist");
assert_eq!(entry.size_bytes, 1024);
assert_eq!(entry.mtime, Some(1_700_050_000));
}
#[test]
fn upsert_entry_no_return_writes_entry() {
let (_temp, db) = temp_database();
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
db.upsert_entry_no_return(
root_id,
Path::new("/data/file.txt"),
Path::new("/data"),
false,
512,
Some(1_700_000_000),
)
.expect("upsert without ID return should succeed");
let entry = db
.get_entry_by_path(Path::new("/data/file.txt"))
.expect("query")
.expect("entry should exist");
assert_eq!(entry.root_id, root_id);
assert_eq!(entry.size_bytes, 512);
assert_eq!(entry.mtime, Some(1_700_000_000));
}
#[test]
fn upsert_entry_preserves_tracked_since() {
let (_temp, db) = temp_database();
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
db.upsert_entry(
root_id,
Path::new("/data/file.txt"),
Path::new("/data"),
false,
512,
Some(1_700_000_000),
)
.expect("first insert");
let entry_before = db
.get_entry_by_path(Path::new("/data/file.txt"))
.expect("query")
.expect("entry should exist");
let tracked_since = entry_before.tracked_since;
std::thread::sleep(std::time::Duration::from_secs(1));
db.upsert_entry(
root_id,
Path::new("/data/file.txt"),
Path::new("/data"),
false,
1024,
Some(1_700_050_000),
)
.expect("update");
let entry_after = db
.get_entry_by_path(Path::new("/data/file.txt"))
.expect("query")
.expect("entry should exist");
assert_eq!(
entry_after.tracked_since, tracked_since,
"tracked_since should be preserved"
);
}
#[test]
fn upsert_entry_revives_removed_path_as_tracked() {
let (_temp, db) = temp_database();
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
let entry_id = db
.upsert_entry(
root_id,
Path::new("/data/test"),
Path::new("/data"),
true,
0,
None,
)
.expect("first insert");
db.update_entry_status(entry_id, "removed")
.expect("mark removed");
let removed_entry = db
.get_entry_by_path(Path::new("/data/test"))
.expect("query")
.expect("entry should exist");
let removed_tracked_since = removed_entry.tracked_since;
std::thread::sleep(std::time::Duration::from_secs(1));
db.upsert_entry(
root_id,
Path::new("/data/test"),
Path::new("/data"),
true,
0,
None,
)
.expect("upsert should revive removed entry");
let revived_entry = db
.get_entry_by_path(Path::new("/data/test"))
.expect("query")
.expect("entry should exist");
assert_eq!(revived_entry.status, "tracked");
assert_ne!(revived_entry.tracked_since, removed_tracked_since);
assert!(revived_entry.countdown_start.is_some());
assert_eq!(revived_entry.deferred_until, None);
}
#[test]
fn upsert_entry_fails_with_invalid_root_id() {
let (_temp, db) = temp_database();
let result = db.upsert_entry(
999,
Path::new("/data/file.txt"),
Path::new("/data"),
false,
512,
Some(1_700_000_000),
);
assert!(result.is_err(), "should fail due to foreign key constraint");
}
#[test]
fn get_entry_by_path_returns_none_when_not_found() {
let (_temp, db) = temp_database();
let result = db
.get_entry_by_path(Path::new("/nonexistent"))
.expect("query");
assert_eq!(result, None);
}
#[test]
fn list_entries_by_parent_returns_children() {
let (_temp, db) = temp_database();
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
db.upsert_entry(
root_id,
Path::new("/data/a.txt"),
Path::new("/data"),
false,
100,
Some(1000),
)
.expect("insert");
db.upsert_entry(
root_id,
Path::new("/data/b.txt"),
Path::new("/data"),
false,
200,
Some(1000),
)
.expect("insert");
db.upsert_entry(
root_id,
Path::new("/data/subdir"),
Path::new("/data"),
true,
0,
None,
)
.expect("insert");
db.upsert_entry(
root_id,
Path::new("/data/subdir/nested.txt"),
Path::new("/data/subdir"),
false,
50,
Some(1000),
)
.expect("insert");
let entries = db
.list_entries_by_parent(root_id, Path::new("/data"))
.expect("list");
assert_eq!(entries.len(), 3, "should return only immediate children");
assert_eq!(entries[0].path, PathBuf::from("/data/a.txt"));
assert_eq!(entries[1].path, PathBuf::from("/data/b.txt"));
assert_eq!(entries[2].path, PathBuf::from("/data/subdir"));
}
#[test]
fn list_entries_by_parent_excludes_removed() {
let (_temp, db) = temp_database();
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
let entry_id = db
.upsert_entry(
root_id,
Path::new("/data/file.txt"),
Path::new("/data"),
false,
100,
Some(1000),
)
.expect("insert");
db.update_entry_status(entry_id, "removed")
.expect("update status");
let entries = db
.list_entries_by_parent(root_id, Path::new("/data"))
.expect("list");
assert_eq!(entries.len(), 0, "removed entries should be excluded");
}
#[test]
fn list_entries_by_parent_is_root_scoped_for_overlapping_paths() {
let (_temp, db) = temp_database();
let parent_root_id = db
.insert_root(Path::new("/data"))
.expect("insert parent root");
let nested_root_id = db
.insert_root(Path::new("/data/project"))
.expect("insert nested root");
db.upsert_entry(
parent_root_id,
Path::new("/data/project/file.txt"),
Path::new("/data/project"),
false,
100,
Some(1000),
)
.expect("insert parent-root entry");
db.upsert_entry(
nested_root_id,
Path::new("/data/project/file.txt"),
Path::new("/data/project"),
false,
100,
Some(1000),
)
.expect("insert nested-root entry");
let parent_entries = db
.list_entries_by_parent(parent_root_id, Path::new("/data/project"))
.expect("list parent entries");
let nested_entries = db
.list_entries_by_parent(nested_root_id, Path::new("/data/project"))
.expect("list nested entries");
assert_eq!(parent_entries.len(), 1);
assert_eq!(nested_entries.len(), 1);
assert_eq!(parent_entries[0].root_id, parent_root_id);
assert_eq!(nested_entries[0].root_id, nested_root_id);
}
#[test]
fn list_entries_by_root_returns_all_descendants() {
let (_temp, db) = temp_database();
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
db.upsert_entry(
root_id,
Path::new("/data/a.txt"),
Path::new("/data"),
false,
100,
Some(1000),
)
.expect("insert");
db.upsert_entry(
root_id,
Path::new("/data/subdir"),
Path::new("/data"),
true,
0,
None,
)
.expect("insert");
db.upsert_entry(
root_id,
Path::new("/data/subdir/b.txt"),
Path::new("/data/subdir"),
false,
200,
Some(1000),
)
.expect("insert");
let other_root_id = db
.insert_root(Path::new("/other"))
.expect("insert other root");
db.upsert_entry(
other_root_id,
Path::new("/other/c.txt"),
Path::new("/other"),
false,
300,
Some(1000),
)
.expect("insert");
let entries = db.list_entries_by_root(root_id).expect("list");
assert_eq!(entries.len(), 3, "should return all entries for the root");
assert_eq!(entries[0].path, PathBuf::from("/data/a.txt"));
assert_eq!(entries[1].path, PathBuf::from("/data/subdir"));
assert_eq!(entries[2].path, PathBuf::from("/data/subdir/b.txt"));
}
#[test]
fn list_entries_by_root_excludes_removed() {
let (_temp, db) = temp_database();
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
let entry_id = db
.upsert_entry(
root_id,
Path::new("/data/file.txt"),
Path::new("/data"),
false,
100,
Some(1000),
)
.expect("insert");
db.update_entry_status(entry_id, "removed")
.expect("update status");
let entries = db.list_entries_by_root(root_id).expect("list");
assert_eq!(entries.len(), 0, "removed entries should be excluded");
}
#[test]
fn list_entries_filters_by_status() {
let (_temp, db) = temp_database();
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
let id1 = db
.upsert_entry(
root_id,
Path::new("/data/a.txt"),
Path::new("/data"),
false,
100,
Some(1000),
)
.expect("insert");
let id2 = db
.upsert_entry(
root_id,
Path::new("/data/b.txt"),
Path::new("/data"),
false,
200,
Some(1000),
)
.expect("insert");
db.upsert_entry(
root_id,
Path::new("/data/c.txt"),
Path::new("/data"),
false,
300,
Some(1000),
)
.expect("insert");
db.update_entry_status(id1, "pending").expect("update");
db.update_entry_status(id2, "pending").expect("update");
let pending = db.list_entries(Some("pending")).expect("list");
assert_eq!(pending.len(), 2);
let tracked = db.list_entries(Some("tracked")).expect("list");
assert_eq!(tracked.len(), 1);
assert_eq!(tracked[0].path, PathBuf::from("/data/c.txt"));
}
#[test]
fn update_entry_status_works() {
let (_temp, db) = temp_database();
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
let entry_id = db
.upsert_entry(
root_id,
Path::new("/data/file.txt"),
Path::new("/data"),
false,
100,
Some(1000),
)
.expect("insert");
db.update_entry_status(entry_id, "approved")
.expect("update");
let entry = db
.get_entry_by_path(Path::new("/data/file.txt"))
.expect("query")
.expect("entry should exist");
assert_eq!(entry.status, "approved");
}
#[test]
fn update_entry_status_fails_on_nonexistent() {
let (_temp, db) = temp_database();
let result = db.update_entry_status(999, "approved");
assert!(result.is_err());
}
#[test]
fn update_entry_status_rejects_invalid_status() {
let (_temp, db) = temp_database();
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
let entry_id = db
.upsert_entry(
root_id,
Path::new("/data/file.txt"),
Path::new("/data"),
false,
100,
Some(1000),
)
.expect("insert");
let result = db.update_entry_status(entry_id, "invalid_status");
assert!(
result.is_err(),
"CHECK constraint should reject invalid status"
);
}
#[test]
fn update_entries_by_path_prefix_updates_all_matching() {
let (_temp, db) = temp_database();
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
db.upsert_entry(
root_id,
Path::new("/data/archive"),
Path::new("/data"),
true,
0,
None,
)
.expect("insert");
db.upsert_entry(
root_id,
Path::new("/data/archive/old.txt"),
Path::new("/data/archive"),
false,
100,
Some(1000),
)
.expect("insert");
db.upsert_entry(
root_id,
Path::new("/data/archive/older.txt"),
Path::new("/data/archive"),
false,
200,
Some(1000),
)
.expect("insert");
db.upsert_entry(
root_id,
Path::new("/data/keep.txt"),
Path::new("/data"),
false,
300,
Some(1000),
)
.expect("insert");
let other_root_id = db
.insert_root(Path::new("/data/archive"))
.expect("insert overlapping root");
db.upsert_entry(
other_root_id,
Path::new("/data/archive/old.txt"),
Path::new("/data/archive"),
false,
999,
Some(2000),
)
.expect("insert overlapping entry");
let count = db
.update_entries_by_path_prefix(root_id, Path::new("/data/archive"), "ignored")
.expect("update");
assert_eq!(count, 3, "should update directory and its children");
let archive = db
.get_entry_by_root_and_path(root_id, Path::new("/data/archive"))
.expect("query")
.expect("entry should exist");
assert_eq!(archive.status, "ignored");
let old = db
.get_entry_by_root_and_path(root_id, Path::new("/data/archive/old.txt"))
.expect("query")
.expect("entry should exist");
assert_eq!(old.status, "ignored");
let keep = db
.get_entry_by_root_and_path(root_id, Path::new("/data/keep.txt"))
.expect("query")
.expect("entry should exist");
assert_eq!(
keep.status, "tracked",
"unrelated entry should not be affected"
);
let overlapping = db
.list_entries_by_parent(other_root_id, Path::new("/data/archive"))
.expect("query overlapping root entries");
assert_eq!(overlapping.len(), 1);
assert_eq!(overlapping[0].status, "tracked");
}
#[test]
fn enforce_ignored_directory_inheritance_ignores_descendants() {
let (_temp, db) = temp_database();
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
db.upsert_entry(
root_id,
Path::new("/data/archive"),
Path::new("/data"),
true,
0,
None,
)
.expect("insert ignored dir");
let ignored_dir_id = db
.get_entry_by_path(Path::new("/data/archive"))
.expect("query ignored dir")
.expect("ignored dir should exist")
.id;
db.update_entry_status(ignored_dir_id, "ignored")
.expect("mark dir ignored");
db.upsert_entry(
root_id,
Path::new("/data/archive/new.txt"),
Path::new("/data/archive"),
false,
100,
Some(1000),
)
.expect("insert descendant file");
db.upsert_entry(
root_id,
Path::new("/data/keep.txt"),
Path::new("/data"),
false,
200,
Some(1000),
)
.expect("insert unrelated file");
let removed_id = db
.upsert_entry(
root_id,
Path::new("/data/archive/removed.txt"),
Path::new("/data/archive"),
false,
50,
Some(1000),
)
.expect("insert removable file");
db.update_entry_status(removed_id, "removed")
.expect("mark removed");
let updated = db
.enforce_ignored_directory_inheritance(root_id)
.expect("enforce inheritance");
assert!(updated >= 2, "directory and descendant should be ignored");
let descendant = db
.get_entry_by_path(Path::new("/data/archive/new.txt"))
.expect("query descendant")
.expect("descendant should exist");
assert_eq!(descendant.status, "ignored");
let unrelated = db
.get_entry_by_path(Path::new("/data/keep.txt"))
.expect("query unrelated")
.expect("unrelated should exist");
assert_eq!(unrelated.status, "tracked");
let removed = db
.get_entry_by_path(Path::new("/data/archive/removed.txt"))
.expect("query removed")
.expect("removed should exist");
assert_eq!(removed.status, "removed");
}
#[test]
fn defer_entry_sets_status_and_timestamp() {
let (_temp, db) = temp_database();
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
let entry_id = db
.upsert_entry(
root_id,
Path::new("/data/file.txt"),
Path::new("/data"),
false,
100,
Some(1000),
)
.expect("insert");
db.defer_entry(entry_id, 1_700_500_000).expect("defer");
let entry = db
.get_entry_by_path(Path::new("/data/file.txt"))
.expect("query")
.expect("entry should exist");
assert_eq!(entry.status, "deferred");
assert_eq!(entry.deferred_until, Some(1_700_500_000));
}
#[test]
fn defer_entry_fails_on_nonexistent() {
let (_temp, db) = temp_database();
let result = db.defer_entry(999, 1_700_500_000);
assert!(result.is_err());
}
#[test]
fn get_stats_returns_defaults() {
let (_temp, db) = temp_database();
let stats = db.get_stats().expect("get stats");
assert_eq!(stats.total_files, 0);
assert_eq!(stats.total_size_bytes, 0);
assert_eq!(stats.files_within_warning, 0);
assert_eq!(stats.files_pending_approval, 0);
assert_eq!(stats.files_overdue, 0);
assert_eq!(stats.last_scan_completed, None);
}
#[test]
fn compute_live_stats_counts_entries() {
let (_temp, db) = temp_database();
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
let entry_id = db
.upsert_entry(
root_id,
Path::new("/data/file1.txt"),
Path::new("/data"),
false,
1024,
Some(1_700_000_000),
)
.expect("upsert");
db.conn()
.execute(
"UPDATE entries SET countdown_start = ?1 WHERE id = ?2",
(1_700_000_000_i64, entry_id),
)
.expect("backdate countdown_start");
let stats = db.compute_live_stats(90, 14).expect("compute_live_stats");
assert_eq!(stats.total_files, 1, "should count one file");
assert_eq!(stats.total_size_bytes, 1024, "should sum size");
assert_eq!(stats.files_overdue, 1, "old file should be overdue");
}
#[test]
fn compute_live_stats_cross_connection_visibility() {
let temp_file = NamedTempFile::new().expect("create temp file");
let path = temp_file.path();
let reader = Database::open(path).expect("open reader");
let before = reader.compute_live_stats(90, 14).expect("stats before");
assert_eq!(before.total_files, 0);
{
let writer = Database::open(path).expect("open writer");
let root_id = writer.insert_root(Path::new("/data")).expect("insert root");
writer
.upsert_entry(
root_id,
Path::new("/data/file1.txt"),
Path::new("/data"),
false,
1024,
Some(1_700_000_000),
)
.expect("upsert");
}
let after = reader.compute_live_stats(90, 14).expect("stats after");
eprintln!("AFTER: total_files={}", after.total_files);
assert_eq!(
after.total_files, 1,
"reader should see writer's committed data"
);
}
#[test]
fn compute_live_stats_deduplicates_overlapping_paths() {
let (_temp, db) = temp_database();
let parent_root_id = db
.insert_root(Path::new("/data"))
.expect("insert parent root");
let nested_root_id = db
.insert_root(Path::new("/data/project"))
.expect("insert nested root");
db.upsert_entry(
parent_root_id,
Path::new("/data/top.bin"),
Path::new("/data"),
false,
10,
Some(1000),
)
.expect("insert parent-only file");
let parent_shared_id = db
.upsert_entry(
parent_root_id,
Path::new("/data/project/shared.bin"),
Path::new("/data/project"),
false,
20,
Some(1000),
)
.expect("insert parent shared file");
let nested_shared_id = db
.upsert_entry(
nested_root_id,
Path::new("/data/project/shared.bin"),
Path::new("/data/project"),
false,
20,
Some(1000),
)
.expect("insert nested shared file");
let old_countdown = jiff::Timestamp::now().as_second() - (100 * 86400);
db.conn()
.execute(
"UPDATE entries SET countdown_start = ?1 WHERE id = ?2",
rusqlite::params![old_countdown, parent_shared_id],
)
.expect("backdate parent shared countdown");
db.conn()
.execute(
"UPDATE entries SET countdown_start = ?1 WHERE id = ?2",
rusqlite::params![old_countdown, nested_shared_id],
)
.expect("backdate nested shared countdown");
let stats = db
.compute_live_stats_with_root_configs(&[
RootStatConfig {
root_id: parent_root_id,
expiration_days: 90,
warning_days: 14,
},
RootStatConfig {
root_id: nested_root_id,
expiration_days: 90,
warning_days: 14,
},
])
.expect("compute deduped live stats");
assert_eq!(
stats.total_files, 2,
"shared path should count once globally"
);
assert_eq!(
stats.total_size_bytes, 30,
"shared bytes should count once globally"
);
assert_eq!(
stats.files_overdue, 1,
"shared overdue file should count once globally"
);
}
#[test]
fn compute_live_stats_prefers_active_rows_over_ignored_overlap_rows() {
let (_temp, db) = temp_database();
let parent_root_id = db
.insert_root(Path::new("/data"))
.expect("insert parent root");
let nested_root_id = db
.insert_root(Path::new("/data/project"))
.expect("insert nested root");
db.upsert_entry(
parent_root_id,
Path::new("/data/project/shared.bin"),
Path::new("/data/project"),
false,
20,
Some(1000),
)
.expect("insert parent shared file");
let nested_shared_id = db
.upsert_entry(
nested_root_id,
Path::new("/data/project/shared.bin"),
Path::new("/data/project"),
false,
20,
Some(1000),
)
.expect("insert nested shared file");
db.update_entry_status(nested_shared_id, "ignored")
.expect("ignore nested shared file");
let stats = db
.compute_live_stats_with_root_configs(&[
RootStatConfig {
root_id: parent_root_id,
expiration_days: 90,
warning_days: 14,
},
RootStatConfig {
root_id: nested_root_id,
expiration_days: 90,
warning_days: 14,
},
])
.expect("compute deduped live stats");
assert_eq!(
stats.total_files, 1,
"active overlapping file should remain globally tracked"
);
assert_eq!(
stats.files_ignored, 0,
"ignored duplicate should not hide active path"
);
}
#[test]
fn list_entries_by_root_and_status_filters_correctly() {
let (_temp, db) = temp_database();
let now = jiff::Timestamp::now().as_second();
let root_id = db.insert_root(Path::new("/data")).expect("insert root");
let other_root_id = db
.insert_root(Path::new("/other"))
.expect("insert other root");
let id1 = db
.upsert_entry(
root_id,
Path::new("/data/approved1.txt"),
Path::new("/data"),
false,
100,
Some(now),
)
.expect("upsert entry");
let id2 = db
.upsert_entry(
root_id,
Path::new("/data/approved2.txt"),
Path::new("/data"),
false,
200,
Some(now),
)
.expect("upsert entry");
let id3 = db
.upsert_entry(
root_id,
Path::new("/data/tracked.txt"),
Path::new("/data"),
false,
300,
Some(now),
)
.expect("upsert entry");
let id4 = db
.upsert_entry(
other_root_id,
Path::new("/other/approved.txt"),
Path::new("/other"),
false,
400,
Some(now),
)
.expect("upsert entry");
db.update_entry_status(id1, "approved")
.expect("update status");
db.update_entry_status(id2, "approved")
.expect("update status");
let _ = id3;
db.update_entry_status(id4, "approved")
.expect("update status");
let approved = db
.list_entries_by_root_and_status(root_id, "approved")
.expect("query should succeed");
assert_eq!(
approved.len(),
2,
"Should return only approved entries for the specified root"
);
assert!(approved.iter().all(|e| e.status == "approved"));
assert!(approved.iter().all(|e| e.root_id == root_id));
let tracked = db
.list_entries_by_root_and_status(root_id, "tracked")
.expect("query should succeed");
assert_eq!(tracked.len(), 1);
assert_eq!(tracked[0].path, Path::new("/data/tracked.txt"));
let other_approved = db
.list_entries_by_root_and_status(other_root_id, "approved")
.expect("query should succeed");
assert_eq!(other_approved.len(), 1);
assert_eq!(other_approved[0].path, Path::new("/other/approved.txt"));
let empty = db
.list_entries_by_root_and_status(root_id, "ignored")
.expect("query should succeed");
assert!(empty.is_empty(), "No ignored entries should exist");
}
}