Format codebase with rustfmt

This commit is contained in:
thePR0M3TH3AN
2025-05-21 11:24:49 -04:00
parent 9360efee2a
commit 567f1cd1a5
33 changed files with 780 additions and 481 deletions

View File

@@ -1,7 +1,7 @@
// libmarlin/src/backup.rs
use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Local, NaiveDateTime, Utc, TimeZone};
use chrono::{DateTime, Local, NaiveDateTime, TimeZone, Utc};
use rusqlite;
use std::fs;
use std::path::{Path, PathBuf};
@@ -30,7 +30,10 @@ pub struct BackupManager {
}
impl BackupManager {
pub fn new<P1: AsRef<Path>, P2: AsRef<Path>>(live_db_path: P1, backups_dir: P2) -> Result<Self> {
pub fn new<P1: AsRef<Path>, P2: AsRef<Path>>(
live_db_path: P1,
backups_dir: P2,
) -> Result<Self> {
let backups_dir_path = backups_dir.as_ref().to_path_buf();
if !backups_dir_path.exists() {
fs::create_dir_all(&backups_dir_path).with_context(|| {
@@ -40,7 +43,10 @@ impl BackupManager {
)
})?;
} else if !backups_dir_path.is_dir() {
return Err(anyhow!("Backups path exists but is not a directory: {}", backups_dir_path.display()));
return Err(anyhow!(
"Backups path exists but is not a directory: {}",
backups_dir_path.display()
));
}
Ok(Self {
live_db_path: live_db_path.as_ref().to_path_buf(),
@@ -54,10 +60,14 @@ impl BackupManager {
let backup_file_path = self.backups_dir.join(&backup_file_name);
if !self.live_db_path.exists() {
return Err(anyhow::Error::new(std::io::Error::new(
return Err(anyhow::Error::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Live DB path does not exist: {}", self.live_db_path.display()),
)).context("Cannot create backup from non-existent live DB"));
format!(
"Live DB path does not exist: {}",
self.live_db_path.display()
),
))
.context("Cannot create backup from non-existent live DB"));
}
let src_conn = rusqlite::Connection::open_with_flags(
@@ -108,8 +118,8 @@ impl BackupManager {
pub fn list_backups(&self) -> Result<Vec<BackupInfo>> {
let mut backup_infos = Vec::new();
if !self.backups_dir.exists() {
if !self.backups_dir.exists() {
return Ok(backup_infos);
}
@@ -129,28 +139,42 @@ impl BackupManager {
let ts_str = filename
.trim_start_matches("backup_")
.trim_end_matches(".db");
let naive_dt = match NaiveDateTime::parse_from_str(ts_str, "%Y-%m-%d_%H-%M-%S_%f") {
Ok(dt) => dt,
Err(_) => match NaiveDateTime::parse_from_str(ts_str, "%Y-%m-%d_%H-%M-%S") {
let naive_dt =
match NaiveDateTime::parse_from_str(ts_str, "%Y-%m-%d_%H-%M-%S_%f")
{
Ok(dt) => dt,
Err(_) => {
let metadata = fs::metadata(&path).with_context(|| format!("Failed to get metadata for {}", path.display()))?;
DateTime::<Utc>::from(metadata.modified()?).naive_utc()
}
}
};
Err(_) => match NaiveDateTime::parse_from_str(
ts_str,
"%Y-%m-%d_%H-%M-%S",
) {
Ok(dt) => dt,
Err(_) => {
let metadata =
fs::metadata(&path).with_context(|| {
format!(
"Failed to get metadata for {}",
path.display()
)
})?;
DateTime::<Utc>::from(metadata.modified()?).naive_utc()
}
},
};
let local_dt_result = Local.from_local_datetime(&naive_dt);
let local_dt = match local_dt_result {
chrono::LocalResult::Single(dt) => dt,
chrono::LocalResult::Ambiguous(dt1, _dt2) => {
eprintln!("Warning: Ambiguous local time for backup {}, taking first interpretation.", filename);
dt1
},
}
chrono::LocalResult::None => {
eprintln!("Warning: Invalid local time for backup {}, skipping.", filename);
continue;
eprintln!(
"Warning: Invalid local time for backup {}, skipping.",
filename
);
continue;
}
};
let timestamp_utc = DateTime::<Utc>::from(local_dt);
@@ -172,12 +196,12 @@ impl BackupManager {
}
pub fn prune(&self, keep_count: usize) -> Result<PruneResult> {
let all_backups = self.list_backups()?;
let all_backups = self.list_backups()?;
let mut kept = Vec::new();
let mut removed = Vec::new();
if keep_count >= all_backups.len() {
if keep_count >= all_backups.len() {
kept = all_backups;
} else {
for (index, backup_info) in all_backups.into_iter().enumerate() {
@@ -185,7 +209,7 @@ impl BackupManager {
kept.push(backup_info);
} else {
let backup_file_path = self.backups_dir.join(&backup_info.id);
if backup_file_path.exists() {
if backup_file_path.exists() {
fs::remove_file(&backup_file_path).with_context(|| {
format!(
"Failed to remove old backup file: {}",
@@ -223,16 +247,22 @@ impl BackupManager {
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
use crate::db::open as open_marlin_db;
use tempfile::tempdir;
fn create_valid_live_db(path: &Path) -> rusqlite::Connection {
let conn = open_marlin_db(path)
.unwrap_or_else(|e| panic!("Failed to open/create test DB at {}: {:?}", path.display(), e));
let conn = open_marlin_db(path).unwrap_or_else(|e| {
panic!(
"Failed to open/create test DB at {}: {:?}",
path.display(),
e
)
});
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS test_table (id INTEGER PRIMARY KEY, data TEXT);
INSERT INTO test_table (data) VALUES ('initial_data');"
).expect("Failed to initialize test table");
INSERT INTO test_table (data) VALUES ('initial_data');",
)
.expect("Failed to initialize test table");
conn
}
@@ -246,7 +276,7 @@ mod tests {
assert!(!backups_dir.exists());
let manager = BackupManager::new(&live_db_path, &backups_dir).unwrap();
assert!(manager.backups_dir.exists());
assert!(manager.backups_dir.exists());
assert!(backups_dir.exists());
}
@@ -257,7 +287,7 @@ mod tests {
let _conn = create_valid_live_db(&live_db_path);
let backups_dir = base_tmp.path().join("my_backups_existing_test");
std::fs::create_dir_all(&backups_dir).unwrap();
std::fs::create_dir_all(&backups_dir).unwrap();
assert!(backups_dir.exists());
let manager_res = BackupManager::new(&live_db_path, &backups_dir);
@@ -265,7 +295,7 @@ mod tests {
let manager = manager_res.unwrap();
assert_eq!(manager.backups_dir, backups_dir);
}
#[test]
fn test_backup_manager_new_fails_if_backup_path_is_file() {
let base_tmp = tempdir().unwrap();
@@ -276,20 +306,26 @@ mod tests {
let manager_res = BackupManager::new(&live_db_path, &file_as_backups_dir);
assert!(manager_res.is_err());
assert!(manager_res.unwrap_err().to_string().contains("Backups path exists but is not a directory"));
assert!(manager_res
.unwrap_err()
.to_string()
.contains("Backups path exists but is not a directory"));
}
#[test]
fn test_create_backup_failure_non_existent_live_db() {
let base_tmp = tempdir().unwrap();
let live_db_path = base_tmp.path().join("non_existent_live.db");
let live_db_path = base_tmp.path().join("non_existent_live.db");
let backups_dir = base_tmp.path().join("backups_fail_test");
let manager = BackupManager::new(&live_db_path, &backups_dir).unwrap();
let backup_result = manager.create_backup();
assert!(backup_result.is_err());
let err_str = backup_result.unwrap_err().to_string();
assert!(err_str.contains("Cannot create backup from non-existent live DB") || err_str.contains("Failed to open source DB"));
assert!(
err_str.contains("Cannot create backup from non-existent live DB")
|| err_str.contains("Failed to open source DB")
);
}
#[test]
@@ -299,11 +335,14 @@ mod tests {
let _conn_live = create_valid_live_db(&live_db_file);
let backups_storage_dir = tmp.path().join("backups_clp_storage_test");
let manager = BackupManager::new(&live_db_file, &backups_storage_dir).unwrap();
let initial_list = manager.list_backups().unwrap();
assert!(initial_list.is_empty(), "Backup list should be empty initially");
assert!(
initial_list.is_empty(),
"Backup list should be empty initially"
);
let prune_empty_result = manager.prune(2).unwrap();
assert!(prune_empty_result.kept.is_empty());
@@ -314,7 +353,7 @@ mod tests {
let info = manager
.create_backup()
.unwrap_or_else(|e| panic!("Failed to create backup {}: {:?}", i, e));
created_backup_ids.push(info.id.clone());
created_backup_ids.push(info.id.clone());
std::thread::sleep(std::time::Duration::from_millis(30));
}
@@ -323,7 +362,8 @@ mod tests {
for id in &created_backup_ids {
assert!(
listed_backups.iter().any(|b| &b.id == id),
"Backup ID {} not found in list", id
"Backup ID {} not found in list",
id
);
}
if listed_backups.len() >= 2 {
@@ -337,7 +377,7 @@ mod tests {
assert!(listed_after_prune_zero.is_empty());
created_backup_ids.clear();
for i in 0..5 {
for i in 0..5 {
let info = manager
.create_backup()
.unwrap_or_else(|e| panic!("Failed to create backup {}: {:?}", i, e));
@@ -360,31 +400,34 @@ mod tests {
assert_eq!(listed_after_prune[0].id, created_backup_ids[4]);
assert_eq!(listed_after_prune[1].id, created_backup_ids[3]);
for removed_info in prune_result.removed {
assert!(
!backups_storage_dir.join(&removed_info.id).exists(),
"Removed backup file {} should not exist", removed_info.id
"Removed backup file {} should not exist",
removed_info.id
);
}
for kept_info in prune_result.kept {
assert!(
backups_storage_dir.join(&kept_info.id).exists(),
"Kept backup file {} should exist", kept_info.id
"Kept backup file {} should exist",
kept_info.id
);
}
}
#[test]
#[test]
fn test_restore_backup() {
let tmp = tempdir().unwrap();
let live_db_path = tmp.path().join("live_for_restore_test.db");
let initial_value = "initial_data_for_restore";
{
let conn = create_valid_live_db(&live_db_path);
conn.execute("DELETE FROM test_table", []).unwrap();
conn.execute("INSERT INTO test_table (data) VALUES (?1)", [initial_value]).unwrap();
conn.execute("DELETE FROM test_table", []).unwrap();
conn.execute("INSERT INTO test_table (data) VALUES (?1)", [initial_value])
.unwrap();
}
let backups_dir = tmp.path().join("backups_for_restore_test_dir");
@@ -403,7 +446,7 @@ mod tests {
.unwrap();
assert_eq!(modified_check, modified_value);
}
manager.restore_from_backup(&backup_info.id).unwrap();
{
@@ -428,7 +471,11 @@ mod tests {
let result = manager.restore_from_backup("non_existent_backup.db");
assert!(result.is_err());
let err_string = result.unwrap_err().to_string();
assert!(err_string.contains("Backup file not found"), "Error string was: {}", err_string);
assert!(
err_string.contains("Backup file not found"),
"Error string was: {}",
err_string
);
}
#[test]
@@ -437,17 +484,13 @@ mod tests {
let live_db_file = tmp.path().join("live_for_list_test.db");
let _conn = create_valid_live_db(&live_db_file);
let backups_dir = tmp.path().join("backups_list_mixed_files_test");
let manager = BackupManager::new(&live_db_file, &backups_dir).unwrap();
manager.create_backup().unwrap();
manager.create_backup().unwrap();
std::fs::write(backups_dir.join("not_a_backup.txt"), "hello").unwrap();
std::fs::write(
backups_dir.join("backup_malformed.db.tmp"),
"temp data",
)
.unwrap();
std::fs::write(backups_dir.join("backup_malformed.db.tmp"), "temp data").unwrap();
std::fs::create_dir(backups_dir.join("a_subdir")).unwrap();
let listed_backups = manager.list_backups().unwrap();
@@ -460,15 +503,16 @@ mod tests {
assert!(listed_backups[0].id.ends_with(".db"));
}
#[test]
#[test]
fn list_backups_handles_io_error_on_read_dir() {
let tmp = tempdir().unwrap();
let live_db_file = tmp.path().join("live_for_list_io_error.db");
let _conn = create_valid_live_db(&live_db_file);
let backups_dir_for_deletion = tmp.path().join("backups_dir_to_delete_test");
let manager_for_deletion = BackupManager::new(&live_db_file, &backups_dir_for_deletion).unwrap();
std::fs::remove_dir_all(&backups_dir_for_deletion).unwrap();
let manager_for_deletion =
BackupManager::new(&live_db_file, &backups_dir_for_deletion).unwrap();
std::fs::remove_dir_all(&backups_dir_for_deletion).unwrap();
let list_res = manager_for_deletion.list_backups().unwrap();
assert!(list_res.is_empty());

View File

@@ -1,21 +1,21 @@
//! Database abstraction for Marlin
//!
//!
//! This module provides a database abstraction layer that wraps the SQLite connection
//! and provides methods for common database operations.
use anyhow::Result;
use rusqlite::Connection;
use std::path::PathBuf;
use anyhow::Result;
/// Options for indexing files
#[derive(Debug, Clone)]
pub struct IndexOptions {
/// Only update files marked as dirty
pub dirty_only: bool,
/// Index file contents (not just metadata)
pub index_contents: bool,
/// Maximum file size to index (in bytes)
pub max_size: Option<u64>,
}
@@ -41,32 +41,34 @@ impl Database {
pub fn new(conn: Connection) -> Self {
Self { conn }
}
/// Get a reference to the underlying connection
pub fn conn(&self) -> &Connection {
&self.conn
}
/// Get a mutable reference to the underlying connection
pub fn conn_mut(&mut self) -> &mut Connection {
&mut self.conn
}
/// Index one or more files
pub fn index_files(&mut self, paths: &[PathBuf], _options: &IndexOptions) -> Result<usize> {
// In a real implementation, this would index the files
// For now, we just return the number of files "indexed"
if paths.is_empty() { // Add a branch for coverage
if paths.is_empty() {
// Add a branch for coverage
return Ok(0);
}
Ok(paths.len())
}
/// Remove files from the index
pub fn remove_files(&mut self, paths: &[PathBuf]) -> Result<usize> {
// In a real implementation, this would remove the files
// For now, we just return the number of files "removed"
if paths.is_empty() { // Add a branch for coverage
if paths.is_empty() {
// Add a branch for coverage
return Ok(0);
}
Ok(paths.len())
@@ -77,8 +79,8 @@ impl Database {
mod tests {
use super::*;
use crate::db::open as open_marlin_db; // Use your project's DB open function
use tempfile::tempdir;
use std::fs::File;
use tempfile::tempdir;
fn setup_db() -> Database {
let conn = open_marlin_db(":memory:").expect("Failed to open in-memory DB");
@@ -102,7 +104,7 @@ mod tests {
let paths = vec![file1.to_path_buf()];
let options = IndexOptions::default();
assert_eq!(db.index_files(&paths, &options).unwrap(), 1);
assert_eq!(db.index_files(&[], &options).unwrap(), 0); // Test empty case
}
@@ -115,7 +117,7 @@ mod tests {
File::create(&file1).unwrap(); // File doesn't need to be in DB for this stub
let paths = vec![file1.to_path_buf()];
assert_eq!(db.remove_files(&paths).unwrap(), 1);
assert_eq!(db.remove_files(&[]).unwrap(), 0); // Test empty case
}

View File

@@ -9,27 +9,38 @@ use std::{
path::{Path, PathBuf},
};
use std::result::Result as StdResult;
use anyhow::{Context, Result};
use chrono::Local;
use rusqlite::{
backup::{Backup, StepResult},
params,
Connection,
OpenFlags,
OptionalExtension,
TransactionBehavior,
params, Connection, OpenFlags, OptionalExtension, TransactionBehavior,
};
use std::result::Result as StdResult;
use tracing::{debug, info, warn};
/* ─── embedded migrations ─────────────────────────────────────────── */
const MIGRATIONS: &[(&str, &str)] = &[
("0001_initial_schema.sql", include_str!("migrations/0001_initial_schema.sql")),
("0002_update_fts_and_triggers.sql", include_str!("migrations/0002_update_fts_and_triggers.sql")),
("0003_create_links_collections_views.sql", include_str!("migrations/0003_create_links_collections_views.sql")),
("0004_fix_hierarchical_tags_fts.sql", include_str!("migrations/0004_fix_hierarchical_tags_fts.sql")),
("0005_add_dirty_table.sql", include_str!("migrations/0005_add_dirty_table.sql")),
(
"0001_initial_schema.sql",
include_str!("migrations/0001_initial_schema.sql"),
),
(
"0002_update_fts_and_triggers.sql",
include_str!("migrations/0002_update_fts_and_triggers.sql"),
),
(
"0003_create_links_collections_views.sql",
include_str!("migrations/0003_create_links_collections_views.sql"),
),
(
"0004_fix_hierarchical_tags_fts.sql",
include_str!("migrations/0004_fix_hierarchical_tags_fts.sql"),
),
(
"0005_add_dirty_table.sql",
include_str!("migrations/0005_add_dirty_table.sql"),
),
];
/* ─── connection bootstrap ────────────────────────────────────────── */
@@ -237,10 +248,7 @@ pub fn list_links(
Ok(out)
}
pub fn find_backlinks(
conn: &Connection,
pattern: &str,
) -> Result<Vec<(String, Option<String>)>> {
pub fn find_backlinks(conn: &Connection, pattern: &str) -> Result<Vec<(String, Option<String>)>> {
let like = pattern.replace('*', "%");
let mut stmt = conn.prepare(
@@ -318,11 +326,9 @@ pub fn list_views(conn: &Connection) -> Result<Vec<(String, String)>> {
}
pub fn view_query(conn: &Connection, name: &str) -> Result<String> {
conn.query_row(
"SELECT query FROM views WHERE name = ?1",
[name],
|r| r.get::<_, String>(0),
)
conn.query_row("SELECT query FROM views WHERE name = ?1", [name], |r| {
r.get::<_, String>(0)
})
.context(format!("no view called '{}'", name))
}

View File

@@ -90,7 +90,9 @@ fn file_id_returns_id_and_errors_on_missing() {
// fetch its id via raw SQL
let fid: i64 = conn
.query_row("SELECT id FROM files WHERE path='exist.txt'", [], |r| r.get(0))
.query_row("SELECT id FROM files WHERE path='exist.txt'", [], |r| {
r.get(0)
})
.unwrap();
// db::file_id should return the same id for existing paths
@@ -116,10 +118,14 @@ fn add_and_remove_links_and_backlinks() {
)
.unwrap();
let src: i64 = conn
.query_row("SELECT id FROM files WHERE path='one.txt'", [], |r| r.get(0))
.query_row("SELECT id FROM files WHERE path='one.txt'", [], |r| {
r.get(0)
})
.unwrap();
let dst: i64 = conn
.query_row("SELECT id FROM files WHERE path='two.txt'", [], |r| r.get(0))
.query_row("SELECT id FROM files WHERE path='two.txt'", [], |r| {
r.get(0)
})
.unwrap();
// add a link of type "ref"
@@ -193,8 +199,11 @@ fn backup_and_restore_cycle() {
// reopen and check that x.bin survived
let conn2 = db::open(&db_path).unwrap();
let cnt: i64 =
conn2.query_row("SELECT COUNT(*) FROM files WHERE path='x.bin'", [], |r| r.get(0)).unwrap();
let cnt: i64 = conn2
.query_row("SELECT COUNT(*) FROM files WHERE path='x.bin'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(cnt, 1);
}
@@ -210,7 +219,9 @@ mod dirty_helpers {
)
.unwrap();
let fid: i64 = conn
.query_row("SELECT id FROM files WHERE path='dummy.txt'", [], |r| r.get(0))
.query_row("SELECT id FROM files WHERE path='dummy.txt'", [], |r| {
r.get(0)
})
.unwrap();
db::mark_dirty(&conn, fid).unwrap();

View File

@@ -1,7 +1,7 @@
// libmarlin/src/error.rs
use std::io;
use std::fmt;
use std::io;
// Ensure these are present if Error enum variants use them directly
// use rusqlite;
// use notify;
@@ -11,8 +11,8 @@ pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug)]
pub enum Error {
Io(io::Error),
Database(rusqlite::Error),
Watch(notify::Error),
Database(rusqlite::Error),
Watch(notify::Error),
InvalidState(String),
NotFound(String),
Config(String),
@@ -65,12 +65,13 @@ impl From<notify::Error> for Error {
#[cfg(test)]
mod tests {
use super::*;
use std::error::Error as StdError;
use std::error::Error as StdError;
#[test]
fn test_error_display_and_from() {
// Test Io variant
let io_err_inner_for_source_check = io::Error::new(io::ErrorKind::NotFound, "test io error");
let io_err_inner_for_source_check =
io::Error::new(io::ErrorKind::NotFound, "test io error");
let io_err_marlin = Error::from(io::Error::new(io::ErrorKind::NotFound, "test io error"));
assert_eq!(io_err_marlin.to_string(), "IO error: test io error");
let source = io_err_marlin.source();
@@ -82,33 +83,44 @@ mod tests {
// Test Database variant
let rusqlite_err_inner_for_source_check = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR),
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR),
Some("test db error".to_string()),
);
// We need to create the error again for the From conversion if we want to compare the source
let db_err_marlin = Error::from(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR),
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR),
Some("test db error".to_string()),
));
assert!(db_err_marlin.to_string().contains("Database error: test db error"));
assert!(db_err_marlin
.to_string()
.contains("Database error: test db error"));
let source = db_err_marlin.source();
assert!(source.is_some(), "Database error should have a source");
if let Some(s) = source {
assert_eq!(s.to_string(), rusqlite_err_inner_for_source_check.to_string());
assert_eq!(
s.to_string(),
rusqlite_err_inner_for_source_check.to_string()
);
}
// Test Watch variant
let notify_raw_err_inner_for_source_check = notify::Error::new(notify::ErrorKind::Generic("test watch error".to_string()));
let watch_err_marlin = Error::from(notify::Error::new(notify::ErrorKind::Generic("test watch error".to_string())));
assert!(watch_err_marlin.to_string().contains("Watch error: test watch error"));
let notify_raw_err_inner_for_source_check =
notify::Error::new(notify::ErrorKind::Generic("test watch error".to_string()));
let watch_err_marlin = Error::from(notify::Error::new(notify::ErrorKind::Generic(
"test watch error".to_string(),
)));
assert!(watch_err_marlin
.to_string()
.contains("Watch error: test watch error"));
let source = watch_err_marlin.source();
assert!(source.is_some(), "Watch error should have a source");
if let Some(s) = source {
assert_eq!(s.to_string(), notify_raw_err_inner_for_source_check.to_string());
assert_eq!(
s.to_string(),
notify_raw_err_inner_for_source_check.to_string()
);
}
let invalid_state_err = Error::InvalidState("bad state".to_string());
assert_eq!(invalid_state_err.to_string(), "Invalid state: bad state");
assert!(invalid_state_err.source().is_none());
@@ -133,24 +145,25 @@ mod tests {
None,
);
let db_err_no_msg = Error::from(sqlite_busy_error);
let expected_rusqlite_msg = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY),
None,
).to_string();
)
.to_string();
let expected_marlin_msg = format!("Database error: {}", expected_rusqlite_msg);
// Verify the string matches the expected format
assert_eq!(db_err_no_msg.to_string(), expected_marlin_msg);
// Check the error code directly instead of the string
if let Error::Database(rusqlite::Error::SqliteFailure(err, _)) = &db_err_no_msg {
assert_eq!(err.code, rusqlite::ffi::ErrorCode::DatabaseBusy);
} else {
panic!("Expected Error::Database variant");
}
// Verify the source exists
assert!(db_err_no_msg.source().is_some());
}

View File

@@ -1,6 +1,6 @@
// libmarlin/src/facade_tests.rs
use super::*; // brings Marlin, config, etc.
use super::*; // brings Marlin, config, etc.
use std::{env, fs};
use tempfile::tempdir;
@@ -71,4 +71,3 @@ fn open_default_fallback_config() {
// Clean up
env::remove_var("HOME");
}

View File

@@ -16,24 +16,28 @@ pub mod scan;
pub mod utils;
pub mod watcher;
#[cfg(test)]
mod utils_tests;
#[cfg(test)]
mod config_tests;
#[cfg(test)]
mod scan_tests;
#[cfg(test)]
mod logging_tests;
#[cfg(test)]
mod db_tests;
#[cfg(test)]
mod facade_tests;
#[cfg(test)]
mod logging_tests;
#[cfg(test)]
mod scan_tests;
#[cfg(test)]
mod utils_tests;
#[cfg(test)]
mod watcher_tests;
use anyhow::{Context, Result};
use rusqlite::Connection;
use std::{fs, path::Path, sync::{Arc, Mutex}};
use std::{
fs,
path::Path,
sync::{Arc, Mutex},
};
/// Main handle for interacting with a Marlin database.
pub struct Marlin {
@@ -66,10 +70,12 @@ impl Marlin {
fs::create_dir_all(parent)?;
}
// Build a minimal Config so callers can still inspect cfg.db_path
let cfg = config::Config { db_path: db_path.to_path_buf() };
let cfg = config::Config {
db_path: db_path.to_path_buf(),
};
// Open the database and run migrations
let conn = db::open(db_path)
.context(format!("opening database at {}", db_path.display()))?;
let conn =
db::open(db_path).context(format!("opening database at {}", db_path.display()))?;
Ok(Marlin { cfg, conn })
}
@@ -95,11 +101,11 @@ impl Marlin {
let mut cur = Some(leaf);
while let Some(id) = cur {
tag_ids.push(id);
cur = self.conn.query_row(
"SELECT parent_id FROM tags WHERE id = ?1",
[id],
|r| r.get::<_, Option<i64>>(0),
)?;
cur = self
.conn
.query_row("SELECT parent_id FROM tags WHERE id = ?1", [id], |r| {
r.get::<_, Option<i64>>(0)
})?;
}
// 3) match files by glob against stored paths
@@ -110,9 +116,9 @@ impl Marlin {
let mut stmt_all = self.conn.prepare("SELECT id, path FROM files")?;
let rows = stmt_all.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
let mut stmt_ins = self.conn.prepare(
"INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?1, ?2)",
)?;
let mut stmt_ins = self
.conn
.prepare("INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?1, ?2)")?;
let mut changed = 0;
for row in rows {
@@ -148,7 +154,8 @@ impl Marlin {
let mut stmt = self.conn.prepare(
"SELECT f.path FROM files_fts JOIN files f ON f.rowid = files_fts.rowid WHERE files_fts MATCH ?1 ORDER BY rank",
)?;
let mut hits = stmt.query_map([query], |r| r.get(0))?
let mut hits = stmt
.query_map([query], |r| r.get(0))?
.collect::<std::result::Result<Vec<_>, rusqlite::Error>>()?;
if hits.is_empty() && !query.contains(':') {
@@ -169,7 +176,7 @@ impl Marlin {
continue;
}
if let Ok(meta) = fs::metadata(&p) {
if meta.len() <= 65_536 {
if meta.len() <= 65_536 {
if let Ok(body) = fs::read_to_string(&p) {
if body.to_lowercase().contains(&needle) {
out.push(p.clone());
@@ -194,14 +201,13 @@ impl Marlin {
) -> Result<watcher::FileWatcher> {
let cfg = config.unwrap_or_default();
let p = path.as_ref().to_path_buf();
let new_conn = db::open(&self.cfg.db_path)
.context("opening database for watcher")?;
let new_conn = db::open(&self.cfg.db_path).context("opening database for watcher")?;
let watcher_db = Arc::new(Mutex::new(db::Database::new(new_conn)));
let mut owned_w = watcher::FileWatcher::new(vec![p], cfg)?;
owned_w.with_database(watcher_db)?; // Modifies owned_w in place
owned_w.start()?; // Start the watcher after it has been fully configured
Ok(owned_w) // Return the owned FileWatcher
}
}

View File

@@ -9,9 +9,9 @@ pub fn init() {
// All tracing output (INFO, WARN, ERROR …) now goes to *stderr* so the
// integration tests can assert on warnings / errors reliably.
fmt()
.with_target(false) // hide module targets
.with_level(true) // include log level
.with_env_filter(filter) // respect RUST_LOG
.with_target(false) // hide module targets
.with_level(true) // include log level
.with_env_filter(filter) // respect RUST_LOG
.with_writer(std::io::stderr) // <-- NEW: send to stderr
.init();
}

View File

@@ -1,9 +1,9 @@
// libmarlin/src/scan_tests.rs
use super::scan::scan_directory;
use super::db;
use tempfile::tempdir;
use super::scan::scan_directory;
use std::fs::File;
use tempfile::tempdir;
#[test]
fn scan_directory_counts_files() {

View File

@@ -21,7 +21,10 @@ pub fn determine_scan_root(pattern: &str) -> PathBuf {
// If there were NO wildcards at all, just return the parent directory
if first_wild == pattern.len() {
return root.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));
return root
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
}
// Otherwise, if the prefix still has any wildcards (e.g. "foo*/bar"),

View File

@@ -6,8 +6,8 @@
//! (create, modify, delete) using the `notify` crate. It implements event debouncing,
//! batch processing, and a state machine for robust lifecycle management.
use anyhow::{Result, Context};
use crate::db::Database;
use anyhow::{Context, Result};
use crossbeam_channel::{bounded, Receiver};
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcherTrait};
use std::collections::HashMap;
@@ -98,9 +98,11 @@ impl EventDebouncer {
fn add_event(&mut self, event: ProcessedEvent) {
let path = event.path.clone();
if path.is_dir() { // This relies on the PathBuf itself knowing if it's a directory
// or on the underlying FS. For unit tests, ensure paths are created.
self.events.retain(|file_path, _| !file_path.starts_with(&path) || file_path == &path );
if path.is_dir() {
// This relies on the PathBuf itself knowing if it's a directory
// or on the underlying FS. For unit tests, ensure paths are created.
self.events
.retain(|file_path, _| !file_path.starts_with(&path) || file_path == &path);
}
match self.events.get_mut(&path) {
Some(existing) => {
@@ -137,12 +139,12 @@ mod event_debouncer_tests {
use super::*;
use notify::event::{CreateKind, DataChange, ModifyKind, RemoveKind, RenameMode};
use std::fs; // fs is needed for these tests to create dirs/files
use tempfile;
use tempfile;
#[test]
fn debouncer_add_and_flush() {
let mut debouncer = EventDebouncer::new(100);
std::thread::sleep(Duration::from_millis(110));
std::thread::sleep(Duration::from_millis(110));
assert!(debouncer.is_ready_to_flush());
assert_eq!(debouncer.len(), 0);
@@ -154,8 +156,8 @@ mod event_debouncer_tests {
timestamp: Instant::now(),
});
assert_eq!(debouncer.len(), 1);
debouncer.last_flush = Instant::now();
debouncer.last_flush = Instant::now();
assert!(!debouncer.is_ready_to_flush());
std::thread::sleep(Duration::from_millis(110));
@@ -165,7 +167,7 @@ mod event_debouncer_tests {
assert_eq!(flushed.len(), 1);
assert_eq!(flushed[0].path, path1);
assert_eq!(debouncer.len(), 0);
assert!(!debouncer.is_ready_to_flush());
assert!(!debouncer.is_ready_to_flush());
}
#[test]
@@ -188,15 +190,15 @@ mod event_debouncer_tests {
priority: EventPriority::Modify,
timestamp: t2,
});
assert_eq!(debouncer.len(), 1);
std::thread::sleep(Duration::from_millis(110));
let flushed = debouncer.flush();
assert_eq!(flushed.len(), 1);
assert_eq!(flushed[0].path, path1);
assert_eq!(flushed[0].priority, EventPriority::Create);
assert_eq!(
assert_eq!(flushed[0].priority, EventPriority::Create);
assert_eq!(
flushed[0].kind,
EventKind::Modify(ModifyKind::Data(DataChange::Any))
);
@@ -207,9 +209,9 @@ mod event_debouncer_tests {
fn debouncer_hierarchical() {
let mut debouncer_h = EventDebouncer::new(100);
let temp_dir_obj = tempfile::tempdir().expect("Failed to create temp dir");
let p_dir = temp_dir_obj.path().to_path_buf();
let p_dir = temp_dir_obj.path().to_path_buf();
let p_file = p_dir.join("file.txt");
fs::File::create(&p_file).expect("Failed to create test file for hierarchical debounce");
debouncer_h.add_event(ProcessedEvent {
@@ -219,15 +221,19 @@ mod event_debouncer_tests {
timestamp: Instant::now(),
});
assert_eq!(debouncer_h.len(), 1);
debouncer_h.add_event(ProcessedEvent {
path: p_dir.clone(),
kind: EventKind::Remove(RemoveKind::Folder),
path: p_dir.clone(),
kind: EventKind::Remove(RemoveKind::Folder),
priority: EventPriority::Delete,
timestamp: Instant::now(),
});
assert_eq!(debouncer_h.len(), 1, "Hierarchical debounce should remove child event, leaving only parent dir event");
assert_eq!(
debouncer_h.len(),
1,
"Hierarchical debounce should remove child event, leaving only parent dir event"
);
std::thread::sleep(Duration::from_millis(110));
let flushed = debouncer_h.flush();
assert_eq!(flushed.len(), 1);
@@ -261,20 +267,35 @@ mod event_debouncer_tests {
#[test]
fn debouncer_priority_sorting_on_flush() {
let mut debouncer = EventDebouncer::new(100);
let path1 = PathBuf::from("file1.txt");
let path2 = PathBuf::from("file2.txt");
let path3 = PathBuf::from("file3.txt");
let path1 = PathBuf::from("file1.txt");
let path2 = PathBuf::from("file2.txt");
let path3 = PathBuf::from("file3.txt");
debouncer.add_event(ProcessedEvent {
path: path1,
kind: EventKind::Modify(ModifyKind::Name(RenameMode::To)),
priority: EventPriority::Modify,
timestamp: Instant::now(),
});
debouncer.add_event(ProcessedEvent {
path: path2,
kind: EventKind::Create(CreateKind::File),
priority: EventPriority::Create,
timestamp: Instant::now(),
});
debouncer.add_event(ProcessedEvent {
path: path3,
kind: EventKind::Remove(RemoveKind::File),
priority: EventPriority::Delete,
timestamp: Instant::now(),
});
debouncer.add_event(ProcessedEvent { path: path1, kind: EventKind::Modify(ModifyKind::Name(RenameMode::To)), priority: EventPriority::Modify, timestamp: Instant::now() });
debouncer.add_event(ProcessedEvent { path: path2, kind: EventKind::Create(CreateKind::File), priority: EventPriority::Create, timestamp: Instant::now() });
debouncer.add_event(ProcessedEvent { path: path3, kind: EventKind::Remove(RemoveKind::File), priority: EventPriority::Delete, timestamp: Instant::now() });
std::thread::sleep(Duration::from_millis(110));
let flushed = debouncer.flush();
assert_eq!(flushed.len(), 3);
assert_eq!(flushed[0].priority, EventPriority::Create);
assert_eq!(flushed[1].priority, EventPriority::Delete);
assert_eq!(flushed[2].priority, EventPriority::Modify);
assert_eq!(flushed[0].priority, EventPriority::Create);
assert_eq!(flushed[1].priority, EventPriority::Delete);
assert_eq!(flushed[2].priority, EventPriority::Modify);
}
#[test]
@@ -314,7 +335,6 @@ mod event_debouncer_tests {
}
}
pub struct FileWatcher {
state: Arc<Mutex<WatcherState>>,
_config: WatcherConfig,
@@ -359,7 +379,7 @@ impl FileWatcher {
let events_processed_clone = events_processed.clone();
let queue_size_clone = queue_size.clone();
let state_clone = state.clone();
let receiver_clone = rx.clone();
let receiver_clone = rx.clone();
let db_shared_for_thread = Arc::new(Mutex::new(None::<Arc<Mutex<Database>>>));
let db_captured_for_thread = db_shared_for_thread.clone();
@@ -367,7 +387,7 @@ impl FileWatcher {
let processor_thread = thread::spawn(move || {
let mut debouncer = EventDebouncer::new(config_clone.debounce_ms);
while !stop_flag_clone.load(Ordering::Relaxed) {
while !stop_flag_clone.load(Ordering::Relaxed) {
let current_state = match state_clone.lock() {
Ok(g) => g.clone(),
Err(_) => {
@@ -380,13 +400,15 @@ impl FileWatcher {
thread::sleep(Duration::from_millis(100));
continue;
}
if current_state == WatcherState::ShuttingDown || current_state == WatcherState::Stopped {
if current_state == WatcherState::ShuttingDown
|| current_state == WatcherState::Stopped
{
break;
}
let mut received_in_batch = 0;
while let Ok(evt_res) = receiver_clone.try_recv() {
received_in_batch +=1;
received_in_batch += 1;
match evt_res {
Ok(event) => {
for path in event.paths {
@@ -431,7 +453,7 @@ impl FileWatcher {
if let Some(db_mutex) = &*db_guard_option {
if let Ok(mut _db_instance_guard) = db_mutex.lock() {
for event_item in &evts_to_process {
info!(
info!(
"Processing event (DB available): {:?} for path {:?}",
event_item.kind, event_item.path
);
@@ -441,7 +463,7 @@ impl FileWatcher {
}
} else {
for event_item in &evts_to_process {
info!(
info!(
"Processing event (no DB): {:?} for path {:?}",
event_item.kind, event_item.path
);
@@ -504,12 +526,18 @@ impl FileWatcher {
return Err(anyhow::anyhow!("Watcher thread not available to start."));
}
if *state_guard == WatcherState::Initializing {
*state_guard = WatcherState::Watching;
*state_guard = WatcherState::Watching;
}
return Ok(());
}
if *state_guard != WatcherState::Initializing && *state_guard != WatcherState::Stopped && *state_guard != WatcherState::Paused {
return Err(anyhow::anyhow!(format!("Cannot start watcher from state {:?}", *state_guard)));
if *state_guard != WatcherState::Initializing
&& *state_guard != WatcherState::Stopped
&& *state_guard != WatcherState::Paused
{
return Err(anyhow::anyhow!(format!(
"Cannot start watcher from state {:?}",
*state_guard
)));
}
*state_guard = WatcherState::Watching;
@@ -526,8 +554,11 @@ impl FileWatcher {
*state_guard = WatcherState::Paused;
Ok(())
}
WatcherState::Paused => Ok(()),
_ => Err(anyhow::anyhow!(format!("Watcher not in watching state to pause (current: {:?})", *state_guard))),
WatcherState::Paused => Ok(()),
_ => Err(anyhow::anyhow!(format!(
"Watcher not in watching state to pause (current: {:?})",
*state_guard
))),
}
}
@@ -541,8 +572,11 @@ impl FileWatcher {
*state_guard = WatcherState::Watching;
Ok(())
}
WatcherState::Watching => Ok(()),
_ => Err(anyhow::anyhow!(format!("Watcher not in paused state to resume (current: {:?})", *state_guard))),
WatcherState::Watching => Ok(()),
_ => Err(anyhow::anyhow!(format!(
"Watcher not in paused state to resume (current: {:?})",
*state_guard
))),
}
}
@@ -551,7 +585,9 @@ impl FileWatcher {
.state
.lock()
.map_err(|_| anyhow::anyhow!("state mutex poisoned"))?;
if *current_state_guard == WatcherState::Stopped || *current_state_guard == WatcherState::ShuttingDown {
if *current_state_guard == WatcherState::Stopped
|| *current_state_guard == WatcherState::ShuttingDown
{
return Ok(());
}
*current_state_guard = WatcherState::ShuttingDown;
@@ -567,7 +603,7 @@ impl FileWatcher {
}
}
}
let mut final_state_guard = self
.state
.lock()
@@ -600,12 +636,11 @@ impl Drop for FileWatcher {
}
}
#[cfg(test)]
mod file_watcher_state_tests {
mod file_watcher_state_tests {
use super::*;
use tempfile::tempdir;
use std::fs as FsMod; // Alias to avoid conflict with local `fs` module name if any
use std::fs as FsMod;
use tempfile::tempdir; // Alias to avoid conflict with local `fs` module name if any
#[test]
fn test_watcher_pause_resume_stop() {
@@ -615,7 +650,8 @@ mod file_watcher_state_tests {
let config = WatcherConfig::default();
let mut watcher = FileWatcher::new(vec![watch_path], config).expect("Failed to create watcher");
let mut watcher =
FileWatcher::new(vec![watch_path], config).expect("Failed to create watcher");
assert_eq!(watcher.status().unwrap().state, WatcherState::Initializing);
@@ -630,7 +666,7 @@ mod file_watcher_state_tests {
watcher.resume().expect("Resume failed");
assert_eq!(watcher.status().unwrap().state, WatcherState::Watching);
watcher.resume().expect("Second resume failed");
assert_eq!(watcher.status().unwrap().state, WatcherState::Watching);
@@ -645,37 +681,43 @@ mod file_watcher_state_tests {
fn test_watcher_start_errors() {
let tmp_dir = tempdir().unwrap();
FsMod::create_dir_all(tmp_dir.path()).expect("Failed to create temp dir for watching");
let mut watcher = FileWatcher::new(vec![tmp_dir.path().to_path_buf()], WatcherConfig::default()).unwrap();
let mut watcher =
FileWatcher::new(vec![tmp_dir.path().to_path_buf()], WatcherConfig::default()).unwrap();
{
let mut state_guard = watcher
.state
.lock()
.expect("state mutex poisoned");
let mut state_guard = watcher.state.lock().expect("state mutex poisoned");
*state_guard = WatcherState::Watching;
}
assert!(watcher.start().is_ok(), "Should be able to call start when already Watching (idempotent state change)");
assert!(
watcher.start().is_ok(),
"Should be able to call start when already Watching (idempotent state change)"
);
assert_eq!(watcher.status().unwrap().state, WatcherState::Watching);
{
let mut state_guard = watcher
.state
.lock()
.expect("state mutex poisoned");
let mut state_guard = watcher.state.lock().expect("state mutex poisoned");
*state_guard = WatcherState::ShuttingDown;
}
assert!(watcher.start().is_err(), "Should not be able to start from ShuttingDown");
assert!(
watcher.start().is_err(),
"Should not be able to start from ShuttingDown"
);
}
#[test]
#[test]
fn test_new_watcher_with_nonexistent_path() {
let non_existent_path = PathBuf::from("/path/that/REALLY/does/not/exist/for/sure/and/cannot/be/created");
let non_existent_path =
PathBuf::from("/path/that/REALLY/does/not/exist/for/sure/and/cannot/be/created");
let config = WatcherConfig::default();
let watcher_result = FileWatcher::new(vec![non_existent_path], config);
assert!(watcher_result.is_err());
if let Err(e) = watcher_result {
let err_string = e.to_string();
assert!(err_string.contains("Failed to watch path") || err_string.contains("os error 2"), "Error was: {}", err_string);
assert!(
err_string.contains("Failed to watch path") || err_string.contains("os error 2"),
"Error was: {}",
err_string
);
}
}
@@ -696,7 +738,8 @@ mod file_watcher_state_tests {
let config = WatcherConfig::default();
let mut watcher = FileWatcher::new(vec![watch_path], config).expect("Failed to create watcher");
let mut watcher =
FileWatcher::new(vec![watch_path], config).expect("Failed to create watcher");
let state_arc = watcher.state.clone();
let _ = std::thread::spawn(move || {

View File

@@ -5,9 +5,8 @@ mod tests {
// Updated import for BackupManager from the new backup module
use crate::backup::BackupManager;
// These are still from the watcher module
use crate::watcher::{FileWatcher, WatcherConfig, WatcherState};
use crate::db::open as open_marlin_db; // Use your project's DB open function
use crate::db::open as open_marlin_db;
use crate::watcher::{FileWatcher, WatcherConfig, WatcherState}; // Use your project's DB open function
use std::fs::{self, File};
use std::io::Write;
@@ -54,7 +53,8 @@ mod tests {
.append(true)
.open(&test_file_path)
.expect("Failed to open test file for modification");
writeln!(existing_file_handle, "Additional content").expect("Failed to append to test file");
writeln!(existing_file_handle, "Additional content")
.expect("Failed to append to test file");
drop(existing_file_handle);
thread::sleep(Duration::from_millis(200));
@@ -64,49 +64,84 @@ mod tests {
watcher.stop().expect("Failed to stop watcher");
assert_eq!(watcher.status().unwrap().state, WatcherState::Stopped);
assert!(watcher.status().unwrap().events_processed > 0, "Expected some file events to be processed");
assert!(
watcher.status().unwrap().events_processed > 0,
"Expected some file events to be processed"
);
}
#[test]
fn test_backup_manager_related_functionality() {
let live_db_tmp_dir = tempdir().expect("Failed to create temp directory for live DB");
let backups_storage_tmp_dir = tempdir().expect("Failed to create temp directory for backups storage");
let backups_storage_tmp_dir =
tempdir().expect("Failed to create temp directory for backups storage");
let live_db_path = live_db_tmp_dir.path().join("test_live_watcher.db"); // Unique name
let backups_actual_dir = backups_storage_tmp_dir.path().join("my_backups_watcher"); // Unique name
// Initialize a proper SQLite DB for the "live" database
let _conn = open_marlin_db(&live_db_path).expect("Failed to open test_live_watcher.db for backup test");
let _conn = open_marlin_db(&live_db_path)
.expect("Failed to open test_live_watcher.db for backup test");
let backup_manager = BackupManager::new(&live_db_path, &backups_actual_dir)
.expect("Failed to create BackupManager instance");
let backup_info = backup_manager.create_backup().expect("Failed to create first backup");
assert!(backups_actual_dir.join(&backup_info.id).exists(), "Backup file should exist");
assert!(backup_info.size_bytes > 0, "Backup size should be greater than 0");
let backup_info = backup_manager
.create_backup()
.expect("Failed to create first backup");
assert!(
backups_actual_dir.join(&backup_info.id).exists(),
"Backup file should exist"
);
assert!(
backup_info.size_bytes > 0,
"Backup size should be greater than 0"
);
for i in 0..3 {
std::thread::sleep(std::time::Duration::from_millis(30)); // Ensure timestamp difference
backup_manager.create_backup().unwrap_or_else(|e| panic!("Failed to create additional backup {}: {:?}", i, e));
backup_manager
.create_backup()
.unwrap_or_else(|e| panic!("Failed to create additional backup {}: {:?}", i, e));
}
let backups = backup_manager.list_backups().expect("Failed to list backups");
let backups = backup_manager
.list_backups()
.expect("Failed to list backups");
assert_eq!(backups.len(), 4, "Should have 4 backups listed");
let prune_result = backup_manager.prune(2).expect("Failed to prune backups");
assert_eq!(prune_result.kept.len(), 2, "Should have kept 2 backups");
assert_eq!(prune_result.removed.len(), 2, "Should have removed 2 backups (4 initial - 2 kept)");
let remaining_backups = backup_manager.list_backups().expect("Failed to list backups after prune");
assert_eq!(remaining_backups.len(), 2, "Should have 2 backups remaining after prune");
assert_eq!(
prune_result.removed.len(),
2,
"Should have removed 2 backups (4 initial - 2 kept)"
);
let remaining_backups = backup_manager
.list_backups()
.expect("Failed to list backups after prune");
assert_eq!(
remaining_backups.len(),
2,
"Should have 2 backups remaining after prune"
);
for removed_info in prune_result.removed {
assert!(!backups_actual_dir.join(&removed_info.id).exists(), "Removed backup file {} should not exist", removed_info.id);
assert!(
!backups_actual_dir.join(&removed_info.id).exists(),
"Removed backup file {} should not exist",
removed_info.id
);
}
for kept_info in prune_result.kept {
assert!(backups_actual_dir.join(&kept_info.id).exists(), "Kept backup file {} should exist", kept_info.id);
assert!(
backups_actual_dir.join(&kept_info.id).exists(),
"Kept backup file {} should exist",
kept_info.id
);
}
}
}