Merge pull request #37 from PR0M3TH3AN/codex/format-and-commit-codebase-using-rustfmt

Format codebase with rustfmt
This commit is contained in:
thePR0M3TH3AN
2025-05-21 11:25:12 -04:00
committed by GitHub
33 changed files with 780 additions and 481 deletions

View File

@@ -28,7 +28,9 @@ fn generate_cheatsheet() -> Result<(), Box<dyn std::error::Error>> {
for (cmd_name_val, cmd_details_val) in cmds { for (cmd_name_val, cmd_details_val) in cmds {
let cmd_name = cmd_name_val.as_str().unwrap_or(""); let cmd_name = cmd_name_val.as_str().unwrap_or("");
if let Value::Mapping(cmd_details) = cmd_details_val { if let Value::Mapping(cmd_details) = cmd_details_val {
if let Some(Value::Mapping(actions)) = cmd_details.get(&Value::String("actions".into())) { if let Some(Value::Mapping(actions)) =
cmd_details.get(&Value::String("actions".into()))
{
for (action_name_val, action_body_val) in actions { for (action_name_val, action_body_val) in actions {
let action_name = action_name_val.as_str().unwrap_or(""); let action_name = action_name_val.as_str().unwrap_or("");
let flags = if let Value::Mapping(action_map) = action_body_val { let flags = if let Value::Mapping(action_map) = action_body_val {
@@ -45,7 +47,11 @@ fn generate_cheatsheet() -> Result<(), Box<dyn std::error::Error>> {
}; };
let flags_disp = if flags.is_empty() { "" } else { &flags }; let flags_disp = if flags.is_empty() { "" } else { &flags };
table.push_str(&format!("| `{}` | {} |\n", format!("{} {}", cmd_name, action_name), flags_disp)); table.push_str(&format!(
"| `{}` | {} |\n",
format!("{} {}", cmd_name, action_name),
flags_disp
));
} }
} }
} }

View File

@@ -1,14 +1,14 @@
// src/cli.rs // src/cli.rs
pub mod link; pub mod annotate;
pub mod coll; pub mod coll;
pub mod view; pub mod event;
pub mod link;
pub mod remind;
pub mod state; pub mod state;
pub mod task; pub mod task;
pub mod remind;
pub mod annotate;
pub mod version; pub mod version;
pub mod event; pub mod view;
pub mod watch; pub mod watch;
use clap::{Parser, Subcommand, ValueEnum}; use clap::{Parser, Subcommand, ValueEnum};
@@ -77,9 +77,7 @@ pub enum Commands {
Backup, Backup,
/// Restore from a backup file (overwrites current DB) /// Restore from a backup file (overwrites current DB)
Restore { Restore { backup_path: std::path::PathBuf },
backup_path: std::path::PathBuf,
},
/// Generate shell completions (hidden) /// Generate shell completions (hidden)
#[command(hide = true)] #[command(hide = true)]
@@ -132,6 +130,12 @@ pub enum Commands {
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
pub enum AttrCmd { pub enum AttrCmd {
Set { pattern: String, key: String, value: String }, Set {
Ls { path: std::path::PathBuf }, pattern: String,
key: String,
value: String,
},
Ls {
path: std::path::PathBuf,
},
} }

View File

@@ -1,7 +1,7 @@
// src/cli/annotate.rs // src/cli/annotate.rs
use clap::{Subcommand, Args};
use rusqlite::Connection;
use crate::cli::Format; use crate::cli::Format;
use clap::{Args, Subcommand};
use rusqlite::Connection;
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
pub enum AnnotateCmd { pub enum AnnotateCmd {
@@ -13,12 +13,16 @@ pub enum AnnotateCmd {
pub struct ArgsAdd { pub struct ArgsAdd {
pub file: String, pub file: String,
pub note: String, pub note: String,
#[arg(long)] pub range: Option<String>, #[arg(long)]
#[arg(long)] pub highlight: bool, pub range: Option<String>,
#[arg(long)]
pub highlight: bool,
} }
#[derive(Args, Debug)] #[derive(Args, Debug)]
pub struct ArgsList { pub file_pattern: String } pub struct ArgsList {
pub file_pattern: String,
}
pub fn run(cmd: &AnnotateCmd, _conn: &mut Connection, _format: Format) -> anyhow::Result<()> { pub fn run(cmd: &AnnotateCmd, _conn: &mut Connection, _format: Format) -> anyhow::Result<()> {
match cmd { match cmd {

View File

@@ -36,11 +36,9 @@ pub struct ListArgs {
/// ///
/// Returns the collection ID or an error if it doesnt exist. /// Returns the collection ID or an error if it doesnt exist.
fn lookup_collection_id(conn: &Connection, name: &str) -> anyhow::Result<i64> { fn lookup_collection_id(conn: &Connection, name: &str) -> anyhow::Result<i64> {
conn.query_row( conn.query_row("SELECT id FROM collections WHERE name = ?1", [name], |r| {
"SELECT id FROM collections WHERE name = ?1", r.get(0)
[name], })
|r| r.get(0),
)
.map_err(|_| anyhow::anyhow!("collection not found: {}", name)) .map_err(|_| anyhow::anyhow!("collection not found: {}", name))
} }
@@ -74,11 +72,7 @@ pub fn run(cmd: &CollCmd, conn: &mut Connection, fmt: Format) -> anyhow::Result<
Format::Json => { Format::Json => {
#[cfg(feature = "json")] #[cfg(feature = "json")]
{ {
println!( println!("{{\"collection\":\"{}\",\"added\":{}}}", a.name, ids.len());
"{{\"collection\":\"{}\",\"added\":{}}}",
a.name,
ids.len()
);
} }
} }
} }

View File

@@ -1,7 +1,7 @@
// src/cli/event.rs // src/cli/event.rs
use clap::{Subcommand, Args};
use rusqlite::Connection;
use crate::cli::Format; use crate::cli::Format;
use clap::{Args, Subcommand};
use rusqlite::Connection;
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
pub enum EventCmd { pub enum EventCmd {

View File

@@ -1,6 +1,6 @@
//! src/cli/link.rs manage typed relationships between files //! src/cli/link.rs manage typed relationships between files
use clap::{Subcommand, Args}; use clap::{Args, Subcommand};
use rusqlite::Connection; use rusqlite::Connection;
use crate::cli::Format; // output selector use crate::cli::Format; // output selector
@@ -70,7 +70,10 @@ pub fn run(cmd: &LinkCmd, conn: &mut Connection, format: Format) -> anyhow::Resu
match format { match format {
Format::Text => { Format::Text => {
if let Some(t) = &args.r#type { if let Some(t) = &args.r#type {
println!("Removed link '{}' → '{}' [type='{}']", args.from, args.to, t); println!(
"Removed link '{}' → '{}' [type='{}']",
args.from, args.to, t
);
} else { } else {
println!("Removed link '{}' → '{}'", args.from, args.to); println!("Removed link '{}' → '{}'", args.from, args.to);
} }

View File

@@ -1,7 +1,7 @@
// src/cli/remind.rs // src/cli/remind.rs
use clap::{Subcommand, Args};
use rusqlite::Connection;
use crate::cli::Format; use crate::cli::Format;
use clap::{Args, Subcommand};
use rusqlite::Connection;
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
pub enum RemindCmd { pub enum RemindCmd {

View File

@@ -1,7 +1,7 @@
// src/cli/state.rs // src/cli/state.rs
use clap::{Subcommand, Args};
use rusqlite::Connection;
use crate::cli::Format; use crate::cli::Format;
use clap::{Args, Subcommand};
use rusqlite::Connection;
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
pub enum StateCmd { pub enum StateCmd {
@@ -11,11 +11,19 @@ pub enum StateCmd {
} }
#[derive(Args, Debug)] #[derive(Args, Debug)]
pub struct ArgsSet { pub file_pattern: String, pub new_state: String } pub struct ArgsSet {
pub file_pattern: String,
pub new_state: String,
}
#[derive(Args, Debug)] #[derive(Args, Debug)]
pub struct ArgsTrans { pub from_state: String, pub to_state: String } pub struct ArgsTrans {
pub from_state: String,
pub to_state: String,
}
#[derive(Args, Debug)] #[derive(Args, Debug)]
pub struct ArgsLog { pub file_pattern: String } pub struct ArgsLog {
pub file_pattern: String,
}
pub fn run(cmd: &StateCmd, _conn: &mut Connection, _format: Format) -> anyhow::Result<()> { pub fn run(cmd: &StateCmd, _conn: &mut Connection, _format: Format) -> anyhow::Result<()> {
match cmd { match cmd {

View File

@@ -1,7 +1,7 @@
// src/cli/task.rs // src/cli/task.rs
use clap::{Subcommand, Args};
use rusqlite::Connection;
use crate::cli::Format; use crate::cli::Format;
use clap::{Args, Subcommand};
use rusqlite::Connection;
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
pub enum TaskCmd { pub enum TaskCmd {
@@ -10,9 +10,14 @@ pub enum TaskCmd {
} }
#[derive(Args, Debug)] #[derive(Args, Debug)]
pub struct ArgsScan { pub directory: String } pub struct ArgsScan {
pub directory: String,
}
#[derive(Args, Debug)] #[derive(Args, Debug)]
pub struct ArgsList { #[arg(long)] pub due_today: bool } pub struct ArgsList {
#[arg(long)]
pub due_today: bool,
}
pub fn run(cmd: &TaskCmd, _conn: &mut Connection, _format: Format) -> anyhow::Result<()> { pub fn run(cmd: &TaskCmd, _conn: &mut Connection, _format: Format) -> anyhow::Result<()> {
match cmd { match cmd {

View File

@@ -1,7 +1,7 @@
// src/cli/version.rs // src/cli/version.rs
use clap::{Subcommand, Args};
use rusqlite::Connection;
use crate::cli::Format; use crate::cli::Format;
use clap::{Args, Subcommand};
use rusqlite::Connection;
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
pub enum VersionCmd { pub enum VersionCmd {
@@ -9,7 +9,9 @@ pub enum VersionCmd {
} }
#[derive(Args, Debug)] #[derive(Args, Debug)]
pub struct ArgsDiff { pub file: String } pub struct ArgsDiff {
pub file: String,
}
pub fn run(cmd: &VersionCmd, _conn: &mut Connection, _format: Format) -> anyhow::Result<()> { pub fn run(cmd: &VersionCmd, _conn: &mut Connection, _format: Format) -> anyhow::Result<()> {
match cmd { match cmd {

View File

@@ -5,8 +5,8 @@ use clap::Subcommand;
use libmarlin::watcher::{WatcherConfig, WatcherState}; use libmarlin::watcher::{WatcherConfig, WatcherState};
use rusqlite::Connection; use rusqlite::Connection;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread; use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tracing::info; use tracing::info;
@@ -104,7 +104,9 @@ pub fn run(cmd: &WatchCmd, _conn: &mut Connection, _format: super::Format) -> Re
Ok(()) Ok(())
} }
WatchCmd::Status => { WatchCmd::Status => {
info!("Status command: No active watcher process to query in this CLI invocation model."); info!(
"Status command: No active watcher process to query in this CLI invocation model."
);
info!("To see live status, run 'marlin watch start' which prints periodic updates."); info!("To see live status, run 'marlin watch start' which prints periodic updates.");
Ok(()) Ok(())
} }

View File

@@ -9,14 +9,8 @@
mod cli; // sub-command definitions and argument structs mod cli; // sub-command definitions and argument structs
/* ── shared modules re-exported from libmarlin ─────────────────── */ /* ── shared modules re-exported from libmarlin ─────────────────── */
use libmarlin::{
config,
db,
logging,
scan,
utils::determine_scan_root,
};
use libmarlin::db::take_dirty; use libmarlin::db::take_dirty;
use libmarlin::{config, db, logging, scan, utils::determine_scan_root};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::{CommandFactory, Parser}; use clap::{CommandFactory, Parser};
@@ -24,13 +18,7 @@ use clap_complete::generate;
use glob::Pattern; use glob::Pattern;
use shellexpand; use shellexpand;
use shlex; use shlex;
use std::{ use std::{env, fs, io, path::Path, process::Command};
env,
fs,
io,
path::Path,
process::Command,
};
use tracing::{debug, error, info}; use tracing::{debug, error, info};
use walkdir::WalkDir; use walkdir::WalkDir;
@@ -73,8 +61,7 @@ fn main() -> Result<()> {
Commands::Init => { Commands::Init => {
info!("Database initialised at {}", cfg.db_path.display()); info!("Database initialised at {}", cfg.db_path.display());
let cwd = env::current_dir().context("getting current directory")?; let cwd = env::current_dir().context("getting current directory")?;
let count = scan::scan_directory(&mut conn, &cwd) let count = scan::scan_directory(&mut conn, &cwd).context("initial scan failed")?;
.context("initial scan failed")?;
info!("Initial scan complete indexed/updated {count} files"); info!("Initial scan complete indexed/updated {count} files");
} }
@@ -89,11 +76,8 @@ fn main() -> Result<()> {
if dirty { if dirty {
let dirty_ids = take_dirty(&conn)?; let dirty_ids = take_dirty(&conn)?;
for id in dirty_ids { for id in dirty_ids {
let path: String = conn.query_row( let path: String =
"SELECT path FROM files WHERE id = ?1", conn.query_row("SELECT path FROM files WHERE id = ?1", [id], |r| r.get(0))?;
[id],
|r| r.get(0),
)?;
scan::scan_directory(&mut conn, Path::new(&path))?; scan::scan_directory(&mut conn, Path::new(&path))?;
} }
} else { } else {
@@ -104,18 +88,18 @@ fn main() -> Result<()> {
} }
/* ---- tag / attribute / search --------------------------- */ /* ---- tag / attribute / search --------------------------- */
Commands::Tag { pattern, tag_path } => Commands::Tag { pattern, tag_path } => apply_tag(&conn, &pattern, &tag_path)?,
apply_tag(&conn, &pattern, &tag_path)?,
Commands::Attr { action } => match action { Commands::Attr { action } => match action {
cli::AttrCmd::Set { pattern, key, value } => cli::AttrCmd::Set {
attr_set(&conn, &pattern, &key, &value)?, pattern,
cli::AttrCmd::Ls { path } => key,
attr_ls(&conn, &path)?, value,
} => attr_set(&conn, &pattern, &key, &value)?,
cli::AttrCmd::Ls { path } => attr_ls(&conn, &path)?,
}, },
Commands::Search { query, exec } => Commands::Search { query, exec } => run_search(&conn, &query, exec)?,
run_search(&conn, &query, exec)?,
/* ---- maintenance ---------------------------------------- */ /* ---- maintenance ---------------------------------------- */
Commands::Backup => { Commands::Backup => {
@@ -125,9 +109,8 @@ fn main() -> Result<()> {
Commands::Restore { backup_path } => { Commands::Restore { backup_path } => {
drop(conn); drop(conn);
db::restore(&backup_path, &cfg.db_path).with_context(|| { db::restore(&backup_path, &cfg.db_path)
format!("Failed to restore DB from {}", backup_path.display()) .with_context(|| format!("Failed to restore DB from {}", backup_path.display()))?;
})?;
println!("Restored DB from {}", backup_path.display()); println!("Restored DB from {}", backup_path.display());
db::open(&cfg.db_path).with_context(|| { db::open(&cfg.db_path).with_context(|| {
format!("Could not open restored DB at {}", cfg.db_path.display()) format!("Could not open restored DB at {}", cfg.db_path.display())
@@ -160,22 +143,19 @@ fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag_path: &str) -> Resu
let mut current = Some(leaf_tag_id); let mut current = Some(leaf_tag_id);
while let Some(id) = current { while let Some(id) = current {
tag_ids.push(id); tag_ids.push(id);
current = conn.query_row( current = conn.query_row("SELECT parent_id FROM tags WHERE id=?1", [id], |r| {
"SELECT parent_id FROM tags WHERE id=?1", r.get::<_, Option<i64>>(0)
[id], })?;
|r| r.get::<_, Option<i64>>(0),
)?;
} }
let expanded = shellexpand::tilde(pattern).into_owned(); let expanded = shellexpand::tilde(pattern).into_owned();
let pat = Pattern::new(&expanded) let pat =
.with_context(|| format!("Invalid glob pattern `{expanded}`"))?; Pattern::new(&expanded).with_context(|| format!("Invalid glob pattern `{expanded}`"))?;
let root = determine_scan_root(&expanded); let root = determine_scan_root(&expanded);
let mut stmt_file = conn.prepare("SELECT id FROM files WHERE path=?1")?; let mut stmt_file = conn.prepare("SELECT id FROM files WHERE path=?1")?;
let mut stmt_insert = conn.prepare( let mut stmt_insert =
"INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?1, ?2)", conn.prepare("INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?1, ?2)")?;
)?;
let mut count = 0usize; let mut count = 0usize;
for entry in WalkDir::new(&root) for entry in WalkDir::new(&root)
@@ -184,7 +164,9 @@ fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag_path: &str) -> Resu
.filter(|e| e.file_type().is_file()) .filter(|e| e.file_type().is_file())
{ {
let p = entry.path().to_string_lossy(); let p = entry.path().to_string_lossy();
if !pat.matches(&p) { continue; } if !pat.matches(&p) {
continue;
}
match stmt_file.query_row([p.as_ref()], |r| r.get::<_, i64>(0)) { match stmt_file.query_row([p.as_ref()], |r| r.get::<_, i64>(0)) {
Ok(fid) => { Ok(fid) => {
@@ -199,10 +181,10 @@ fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag_path: &str) -> Resu
count += 1; count += 1;
} }
} }
Err(rusqlite::Error::QueryReturnedNoRows) => Err(rusqlite::Error::QueryReturnedNoRows) => {
error!(file=%p, "not indexed run `marlin scan` first"), error!(file=%p, "not indexed run `marlin scan` first")
Err(e) => }
error!(file=%p, error=%e, "could not lookup file ID"), Err(e) => error!(file=%p, error=%e, "could not lookup file ID"),
} }
} }
@@ -213,8 +195,8 @@ fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag_path: &str) -> Resu
/* ---------- ATTRIBUTES ---------- */ /* ---------- ATTRIBUTES ---------- */
fn attr_set(conn: &rusqlite::Connection, pattern: &str, key: &str, value: &str) -> Result<()> { fn attr_set(conn: &rusqlite::Connection, pattern: &str, key: &str, value: &str) -> Result<()> {
let expanded = shellexpand::tilde(pattern).into_owned(); let expanded = shellexpand::tilde(pattern).into_owned();
let pat = Pattern::new(&expanded) let pat =
.with_context(|| format!("Invalid glob pattern `{expanded}`"))?; Pattern::new(&expanded).with_context(|| format!("Invalid glob pattern `{expanded}`"))?;
let root = determine_scan_root(&expanded); let root = determine_scan_root(&expanded);
let mut stmt_file = conn.prepare("SELECT id FROM files WHERE path=?1")?; let mut stmt_file = conn.prepare("SELECT id FROM files WHERE path=?1")?;
@@ -226,7 +208,9 @@ fn attr_set(conn: &rusqlite::Connection, pattern: &str, key: &str, value: &str)
.filter(|e| e.file_type().is_file()) .filter(|e| e.file_type().is_file())
{ {
let p = entry.path().to_string_lossy(); let p = entry.path().to_string_lossy();
if !pat.matches(&p) { continue; } if !pat.matches(&p) {
continue;
}
match stmt_file.query_row([p.as_ref()], |r| r.get::<_, i64>(0)) { match stmt_file.query_row([p.as_ref()], |r| r.get::<_, i64>(0)) {
Ok(fid) => { Ok(fid) => {
@@ -234,10 +218,10 @@ fn attr_set(conn: &rusqlite::Connection, pattern: &str, key: &str, value: &str)
info!(file=%p, key, value, "attr set"); info!(file=%p, key, value, "attr set");
count += 1; count += 1;
} }
Err(rusqlite::Error::QueryReturnedNoRows) => Err(rusqlite::Error::QueryReturnedNoRows) => {
error!(file=%p, "not indexed run `marlin scan` first"), error!(file=%p, "not indexed run `marlin scan` first")
Err(e) => }
error!(file=%p, error=%e, "could not lookup file ID"), Err(e) => error!(file=%p, error=%e, "could not lookup file ID"),
} }
} }
@@ -247,12 +231,11 @@ fn attr_set(conn: &rusqlite::Connection, pattern: &str, key: &str, value: &str)
fn attr_ls(conn: &rusqlite::Connection, path: &Path) -> Result<()> { fn attr_ls(conn: &rusqlite::Connection, path: &Path) -> Result<()> {
let fid = db::file_id(conn, &path.to_string_lossy())?; let fid = db::file_id(conn, &path.to_string_lossy())?;
let mut stmt = conn.prepare( let mut stmt =
"SELECT key, value FROM attributes WHERE file_id=?1 ORDER BY key" conn.prepare("SELECT key, value FROM attributes WHERE file_id=?1 ORDER BY key")?;
)?; for row in stmt.query_map([fid], |r| {
for row in stmt Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
.query_map([fid], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))? })? {
{
let (k, v) = row?; let (k, v) = row?;
println!("{k} = {v}"); println!("{k} = {v}");
} }
@@ -268,7 +251,9 @@ fn run_search(conn: &rusqlite::Connection, raw_query: &str, exec: Option<String>
parts.push(tok); parts.push(tok);
} else if let Some(tag) = tok.strip_prefix("tag:") { } else if let Some(tag) = tok.strip_prefix("tag:") {
for (i, seg) in tag.split('/').filter(|s| !s.is_empty()).enumerate() { for (i, seg) in tag.split('/').filter(|s| !s.is_empty()).enumerate() {
if i > 0 { parts.push("AND".into()); } if i > 0 {
parts.push("AND".into());
}
parts.push(format!("tags_text:{}", escape_fts(seg))); parts.push(format!("tags_text:{}", escape_fts(seg)));
} }
} else if let Some(attr) = tok.strip_prefix("attr:") { } else if let Some(attr) = tok.strip_prefix("attr:") {
@@ -310,11 +295,11 @@ fn run_search(conn: &rusqlite::Connection, raw_query: &str, exec: Option<String>
run_exec(&hits, &cmd_tpl)?; run_exec(&hits, &cmd_tpl)?;
} else { } else {
if hits.is_empty() { if hits.is_empty() {
eprintln!( eprintln!("No matches for query: `{raw_query}` (FTS expr: `{fts_expr}`)");
"No matches for query: `{raw_query}` (FTS expr: `{fts_expr}`)"
);
} else { } else {
for p in hits { println!("{p}"); } for p in hits {
println!("{p}");
}
} }
} }
Ok(()) Ok(())
@@ -333,7 +318,9 @@ fn naive_substring_search(conn: &rusqlite::Connection, term: &str) -> Result<Vec
continue; continue;
} }
if let Ok(meta) = fs::metadata(&p) { if let Ok(meta) = fs::metadata(&p) {
if meta.len() > 65_536 { continue; } if meta.len() > 65_536 {
continue;
}
} }
if let Ok(body) = fs::read_to_string(&p) { if let Ok(body) = fs::read_to_string(&p) {
if body.to_lowercase().contains(&needle) { if body.to_lowercase().contains(&needle) {
@@ -369,7 +356,9 @@ fn run_exec(paths: &[String], cmd_tpl: &str) -> Result<()> {
format!("{cmd_tpl} {quoted}") format!("{cmd_tpl} {quoted}")
}; };
if let Some(mut parts) = shlex::split(&final_cmd) { if let Some(mut parts) = shlex::split(&final_cmd) {
if parts.is_empty() { continue; } if parts.is_empty() {
continue;
}
let prog = parts.remove(0); let prog = parts.remove(0);
let status = Command::new(&prog).args(parts).status()?; let status = Command::new(&prog).args(parts).status()?;
if !status.success() { if !status.success() {
@@ -393,9 +382,9 @@ fn escape_fts(term: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{apply_tag, attr_set, escape_fts, naive_substring_search, run_exec};
use assert_cmd::Command; use assert_cmd::Command;
use tempfile::tempdir; use tempfile::tempdir;
use super::{apply_tag, attr_set, naive_substring_search, run_exec, escape_fts};
#[test] #[test]
fn test_help_command() { fn test_help_command() {
@@ -483,7 +472,10 @@ mod tests {
cmd_scan.env("MARLIN_DB_PATH", &db_path); cmd_scan.env("MARLIN_DB_PATH", &db_path);
cmd_scan.arg("scan"); cmd_scan.arg("scan");
cmd_scan.assert().success(); cmd_scan.assert().success();
assert!(backups_dir.exists(), "Backups directory should exist after scan"); assert!(
backups_dir.exists(),
"Backups directory should exist after scan"
);
let backups: Vec<_> = backups_dir.read_dir().unwrap().collect(); let backups: Vec<_> = backups_dir.read_dir().unwrap().collect();
assert_eq!(backups.len(), 1, "One backup should be created for scan"); assert_eq!(backups.len(), 1, "One backup should be created for scan");
} }
@@ -504,7 +496,11 @@ mod tests {
let tmp = tempdir().unwrap(); let tmp = tempdir().unwrap();
let mut cmd = Command::cargo_bin("marlin").unwrap(); let mut cmd = Command::cargo_bin("marlin").unwrap();
cmd.env("MARLIN_DB_PATH", tmp.path().join("index.db")); cmd.env("MARLIN_DB_PATH", tmp.path().join("index.db"));
cmd.arg("event").arg("add").arg("file.txt").arg("2025-05-20").arg("desc"); cmd.arg("event")
.arg("add")
.arg("file.txt")
.arg("2025-05-20")
.arg("desc");
cmd.assert() cmd.assert()
.failure() .failure()
.stderr(predicates::str::contains("not yet implemented")); .stderr(predicates::str::contains("not yet implemented"));
@@ -516,8 +512,8 @@ mod tests {
#[test] #[test]
fn test_tagging_and_attributes_update_db() { fn test_tagging_and_attributes_update_db() {
use std::fs::File;
use libmarlin::scan::scan_directory; use libmarlin::scan::scan_directory;
use std::fs::File;
let tmp = tempdir().unwrap(); let tmp = tempdir().unwrap();
let file_path = tmp.path().join("a.txt"); let file_path = tmp.path().join("a.txt");
@@ -567,7 +563,11 @@ mod tests {
fs::write(&script, "#!/bin/sh\necho $1 >> $LOGFILE\n").unwrap(); fs::write(&script, "#!/bin/sh\necho $1 >> $LOGFILE\n").unwrap();
std::env::set_var("LOGFILE", &log); std::env::set_var("LOGFILE", &log);
run_exec(&[f1.to_string_lossy().to_string()], &format!("sh {} {{}}", script.display())).unwrap(); run_exec(
&[f1.to_string_lossy().to_string()],
&format!("sh {} {{}}", script.display()),
)
.unwrap();
let logged = fs::read_to_string(&log).unwrap(); let logged = fs::read_to_string(&log).unwrap();
assert!(logged.contains("hello.txt")); assert!(logged.contains("hello.txt"));
} }

View File

@@ -1,6 +1,9 @@
mod cli { mod cli {
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub enum Format { Text, Json } pub enum Format {
Text,
Json,
}
} }
#[path = "../src/cli/coll.rs"] #[path = "../src/cli/coll.rs"]
@@ -11,18 +14,39 @@ use libmarlin::db;
#[test] #[test]
fn coll_run_creates_and_adds() { fn coll_run_creates_and_adds() {
let mut conn = db::open(":memory:").unwrap(); let mut conn = db::open(":memory:").unwrap();
conn.execute("INSERT INTO files(path,size,mtime) VALUES ('a.txt',0,0)", []).unwrap(); conn.execute(
conn.execute("INSERT INTO files(path,size,mtime) VALUES ('b.txt',0,0)", []).unwrap(); "INSERT INTO files(path,size,mtime) VALUES ('a.txt',0,0)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO files(path,size,mtime) VALUES ('b.txt',0,0)",
[],
)
.unwrap();
let create = coll::CollCmd::Create(coll::CreateArgs { name: "Set".into() }); let create = coll::CollCmd::Create(coll::CreateArgs { name: "Set".into() });
coll::run(&create, &mut conn, cli::Format::Text).unwrap(); coll::run(&create, &mut conn, cli::Format::Text).unwrap();
let coll_id: i64 = conn.query_row("SELECT id FROM collections WHERE name='Set'", [], |r| r.get(0)).unwrap(); let coll_id: i64 = conn
.query_row("SELECT id FROM collections WHERE name='Set'", [], |r| {
r.get(0)
})
.unwrap();
let add = coll::CollCmd::Add(coll::AddArgs{ name: "Set".into(), file_pattern: "*.txt".into() }); let add = coll::CollCmd::Add(coll::AddArgs {
name: "Set".into(),
file_pattern: "*.txt".into(),
});
coll::run(&add, &mut conn, cli::Format::Text).unwrap(); coll::run(&add, &mut conn, cli::Format::Text).unwrap();
let cnt: i64 = conn.query_row("SELECT COUNT(*) FROM collection_files WHERE collection_id=?1", [coll_id], |r| r.get(0)).unwrap(); let cnt: i64 = conn
.query_row(
"SELECT COUNT(*) FROM collection_files WHERE collection_id=?1",
[coll_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(cnt, 2); assert_eq!(cnt, 2);
let list = coll::CollCmd::List(coll::ListArgs { name: "Set".into() }); let list = coll::CollCmd::List(coll::ListArgs { name: "Set".into() });

View File

@@ -1,6 +1,9 @@
mod cli { mod cli {
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub enum Format { Text, Json } pub enum Format {
Text,
Json,
}
} }
#[path = "../src/cli/link.rs"] #[path = "../src/cli/link.rs"]
@@ -11,8 +14,16 @@ use libmarlin::db;
#[test] #[test]
fn link_run_add_and_rm() { fn link_run_add_and_rm() {
let mut conn = db::open(":memory:").unwrap(); let mut conn = db::open(":memory:").unwrap();
conn.execute("INSERT INTO files(path,size,mtime) VALUES ('foo.txt',0,0)", []).unwrap(); conn.execute(
conn.execute("INSERT INTO files(path,size,mtime) VALUES ('bar.txt',0,0)", []).unwrap(); "INSERT INTO files(path,size,mtime) VALUES ('foo.txt',0,0)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO files(path,size,mtime) VALUES ('bar.txt',0,0)",
[],
)
.unwrap();
let add = link::LinkCmd::Add(link::LinkArgs { let add = link::LinkCmd::Add(link::LinkArgs {
from: "foo.txt".into(), from: "foo.txt".into(),
@@ -20,10 +31,16 @@ fn link_run_add_and_rm() {
r#type: None, r#type: None,
}); });
link::run(&add, &mut conn, cli::Format::Text).unwrap(); link::run(&add, &mut conn, cli::Format::Text).unwrap();
let count: i64 = conn.query_row("SELECT COUNT(*) FROM links", [], |r| r.get(0)).unwrap(); let count: i64 = conn
.query_row("SELECT COUNT(*) FROM links", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 1); assert_eq!(count, 1);
let list = link::LinkCmd::List(link::ListArgs { pattern: "foo.txt".into(), direction: None, r#type: None }); let list = link::LinkCmd::List(link::ListArgs {
pattern: "foo.txt".into(),
direction: None,
r#type: None,
});
link::run(&list, &mut conn, cli::Format::Text).unwrap(); link::run(&list, &mut conn, cli::Format::Text).unwrap();
let rm = link::LinkCmd::Rm(link::LinkArgs { let rm = link::LinkCmd::Rm(link::LinkArgs {
@@ -32,6 +49,8 @@ fn link_run_add_and_rm() {
r#type: None, r#type: None,
}); });
link::run(&rm, &mut conn, cli::Format::Text).unwrap(); link::run(&rm, &mut conn, cli::Format::Text).unwrap();
let remaining: i64 = conn.query_row("SELECT COUNT(*) FROM links", [], |r| r.get(0)).unwrap(); let remaining: i64 = conn
.query_row("SELECT COUNT(*) FROM links", [], |r| r.get(0))
.unwrap();
assert_eq!(remaining, 0); assert_eq!(remaining, 0);
} }

View File

@@ -1,6 +1,9 @@
mod cli { mod cli {
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub enum Format { Text, Json } pub enum Format {
Text,
Json,
}
} }
#[path = "../src/cli/view.rs"] #[path = "../src/cli/view.rs"]
@@ -11,17 +14,30 @@ use libmarlin::db;
#[test] #[test]
fn view_run_save_and_exec() { fn view_run_save_and_exec() {
let mut conn = db::open(":memory:").unwrap(); let mut conn = db::open(":memory:").unwrap();
conn.execute("INSERT INTO files(path,size,mtime) VALUES ('TODO.txt',0,0)", []).unwrap(); conn.execute(
"INSERT INTO files(path,size,mtime) VALUES ('TODO.txt',0,0)",
[],
)
.unwrap();
let save = view::ViewCmd::Save(view::ArgsSave { view_name: "tasks".into(), query: "TODO".into() }); let save = view::ViewCmd::Save(view::ArgsSave {
view_name: "tasks".into(),
query: "TODO".into(),
});
view::run(&save, &mut conn, cli::Format::Text).unwrap(); view::run(&save, &mut conn, cli::Format::Text).unwrap();
let stored: String = conn.query_row("SELECT query FROM views WHERE name='tasks'", [], |r| r.get(0)).unwrap(); let stored: String = conn
.query_row("SELECT query FROM views WHERE name='tasks'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(stored, "TODO"); assert_eq!(stored, "TODO");
let list = view::ViewCmd::List; let list = view::ViewCmd::List;
view::run(&list, &mut conn, cli::Format::Text).unwrap(); view::run(&list, &mut conn, cli::Format::Text).unwrap();
let exec = view::ViewCmd::Exec(view::ArgsExec { view_name: "tasks".into() }); let exec = view::ViewCmd::Exec(view::ArgsExec {
view_name: "tasks".into(),
});
view::run(&exec, &mut conn, cli::Format::Text).unwrap(); view::run(&exec, &mut conn, cli::Format::Text).unwrap();
} }

View File

@@ -53,9 +53,7 @@ fn full_cli_flow() -> Result<(), Box<dyn std::error::Error>> {
/* ── 2 ░ init ( auto-scan cwd ) ───────────────────────────── */ /* ── 2 ░ init ( auto-scan cwd ) ───────────────────────────── */
ok(marlin() ok(marlin().current_dir(&demo_dir).arg("init"));
.current_dir(&demo_dir)
.arg("init"));
/* ── 3 ░ tag & attr demos ─────────────────────────────────── */ /* ── 3 ░ tag & attr demos ─────────────────────────────────── */
@@ -74,12 +72,14 @@ fn full_cli_flow() -> Result<(), Box<dyn std::error::Error>> {
/* ── 4 ░ quick search sanity checks ───────────────────────── */ /* ── 4 ░ quick search sanity checks ───────────────────────── */
marlin() marlin()
.arg("search").arg("TODO") .arg("search")
.arg("TODO")
.assert() .assert()
.stdout(predicate::str::contains("TODO.txt")); .stdout(predicate::str::contains("TODO.txt"));
marlin() marlin()
.arg("search").arg("attr:reviewed=yes") .arg("search")
.arg("attr:reviewed=yes")
.assert() .assert()
.stdout(predicate::str::contains("Q1.pdf")); .stdout(predicate::str::contains("Q1.pdf"));
@@ -92,20 +92,18 @@ fn full_cli_flow() -> Result<(), Box<dyn std::error::Error>> {
ok(marlin().arg("scan").arg(&demo_dir)); ok(marlin().arg("scan").arg(&demo_dir));
ok(marlin() ok(marlin().arg("link").arg("add").arg(&foo).arg(&bar));
.arg("link").arg("add")
.arg(&foo).arg(&bar));
marlin() marlin()
.arg("link").arg("backlinks").arg(&bar) .arg("link")
.arg("backlinks")
.arg(&bar)
.assert() .assert()
.stdout(predicate::str::contains("foo.txt")); .stdout(predicate::str::contains("foo.txt"));
/* ── 6 ░ backup → delete DB → restore ────────────────────── */ /* ── 6 ░ backup → delete DB → restore ────────────────────── */
let backup_path = String::from_utf8( let backup_path = String::from_utf8(marlin().arg("backup").output()?.stdout)?;
marlin().arg("backup").output()?.stdout
)?;
let backup_file = backup_path.split_whitespace().last().unwrap(); let backup_file = backup_path.split_whitespace().last().unwrap();
fs::remove_file(&db_path)?; // simulate corruption fs::remove_file(&db_path)?; // simulate corruption
@@ -113,10 +111,10 @@ fn full_cli_flow() -> Result<(), Box<dyn std::error::Error>> {
// Search must still work afterwards // Search must still work afterwards
marlin() marlin()
.arg("search").arg("TODO") .arg("search")
.arg("TODO")
.assert() .assert()
.stdout(predicate::str::contains("TODO.txt")); .stdout(predicate::str::contains("TODO.txt"));
Ok(()) Ok(())
} }

View File

@@ -13,7 +13,11 @@ use util::marlin;
fn link_non_indexed_should_fail() { fn link_non_indexed_should_fail() {
let tmp = tempdir().unwrap(); let tmp = tempdir().unwrap();
marlin(&tmp).current_dir(tmp.path()).arg("init").assert().success(); marlin(&tmp)
.current_dir(tmp.path())
.arg("init")
.assert()
.success();
std::fs::write(tmp.path().join("foo.txt"), "").unwrap(); std::fs::write(tmp.path().join("foo.txt"), "").unwrap();
std::fs::write(tmp.path().join("bar.txt"), "").unwrap(); std::fs::write(tmp.path().join("bar.txt"), "").unwrap();
@@ -21,9 +25,10 @@ fn link_non_indexed_should_fail() {
marlin(&tmp) marlin(&tmp)
.current_dir(tmp.path()) .current_dir(tmp.path())
.args([ .args([
"link", "add", "link",
"add",
&tmp.path().join("foo.txt").to_string_lossy(), &tmp.path().join("foo.txt").to_string_lossy(),
&tmp.path().join("bar.txt").to_string_lossy() &tmp.path().join("bar.txt").to_string_lossy(),
]) ])
.assert() .assert()
.failure() .failure()
@@ -35,14 +40,17 @@ fn link_non_indexed_should_fail() {
#[test] #[test]
fn attr_set_on_non_indexed_file_should_warn() { fn attr_set_on_non_indexed_file_should_warn() {
let tmp = tempdir().unwrap(); let tmp = tempdir().unwrap();
marlin(&tmp).current_dir(tmp.path()).arg("init").assert().success(); marlin(&tmp)
.current_dir(tmp.path())
.arg("init")
.assert()
.success();
let ghost = tmp.path().join("ghost.txt"); let ghost = tmp.path().join("ghost.txt");
std::fs::write(&ghost, "").unwrap(); std::fs::write(&ghost, "").unwrap();
marlin(&tmp) marlin(&tmp)
.args(["attr","set", .args(["attr", "set", &ghost.to_string_lossy(), "foo", "bar"])
&ghost.to_string_lossy(),"foo","bar"])
.assert() .assert()
.success() // exits 0 .success() // exits 0
.stderr(str::contains("not indexed")); .stderr(str::contains("not indexed"));
@@ -56,7 +64,11 @@ fn coll_add_unknown_collection_should_fail() {
let file = tmp.path().join("doc.txt"); let file = tmp.path().join("doc.txt");
std::fs::write(&file, "").unwrap(); std::fs::write(&file, "").unwrap();
marlin(&tmp).current_dir(tmp.path()).arg("init").assert().success(); marlin(&tmp)
.current_dir(tmp.path())
.arg("init")
.assert()
.success();
marlin(&tmp) marlin(&tmp)
.args(["coll", "add", "nope", &file.to_string_lossy()]) .args(["coll", "add", "nope", &file.to_string_lossy()])
@@ -79,4 +91,3 @@ fn restore_with_nonexistent_backup_should_fail() {
.failure() .failure()
.stderr(str::contains("Failed to restore")); .stderr(str::contains("Failed to restore"));
} }

View File

@@ -17,11 +17,16 @@ fn tag_should_add_hierarchical_tag_and_search_finds_it() {
let file = tmp.path().join("foo.md"); let file = tmp.path().join("foo.md");
fs::write(&file, "# test\n").unwrap(); fs::write(&file, "# test\n").unwrap();
marlin(&tmp).current_dir(tmp.path()).arg("init").assert().success(); marlin(&tmp)
.current_dir(tmp.path())
.arg("init")
.assert()
.success();
marlin(&tmp) marlin(&tmp)
.args(["tag", file.to_str().unwrap(), "project/md"]) .args(["tag", file.to_str().unwrap(), "project/md"])
.assert().success(); .assert()
.success();
marlin(&tmp) marlin(&tmp)
.args(["search", "tag:project/md"]) .args(["search", "tag:project/md"])
@@ -38,11 +43,16 @@ fn attr_set_then_ls_roundtrip() {
let file = tmp.path().join("report.pdf"); let file = tmp.path().join("report.pdf");
fs::write(&file, "%PDF-1.4\n").unwrap(); fs::write(&file, "%PDF-1.4\n").unwrap();
marlin(&tmp).current_dir(tmp.path()).arg("init").assert().success(); marlin(&tmp)
.current_dir(tmp.path())
.arg("init")
.assert()
.success();
marlin(&tmp) marlin(&tmp)
.args(["attr", "set", file.to_str().unwrap(), "reviewed", "yes"]) .args(["attr", "set", file.to_str().unwrap(), "reviewed", "yes"])
.assert().success(); .assert()
.success();
marlin(&tmp) marlin(&tmp)
.args(["attr", "ls", file.to_str().unwrap()]) .args(["attr", "ls", file.to_str().unwrap()])
@@ -62,11 +72,21 @@ fn coll_create_add_and_list() {
fs::write(&a, "").unwrap(); fs::write(&a, "").unwrap();
fs::write(&b, "").unwrap(); fs::write(&b, "").unwrap();
marlin(&tmp).current_dir(tmp.path()).arg("init").assert().success(); marlin(&tmp)
.current_dir(tmp.path())
.arg("init")
.assert()
.success();
marlin(&tmp).args(["coll", "create", "Set"]).assert().success(); marlin(&tmp)
.args(["coll", "create", "Set"])
.assert()
.success();
for f in [&a, &b] { for f in [&a, &b] {
marlin(&tmp).args(["coll", "add", "Set", f.to_str().unwrap()]).assert().success(); marlin(&tmp)
.args(["coll", "add", "Set", f.to_str().unwrap()])
.assert()
.success();
} }
marlin(&tmp) marlin(&tmp)
@@ -85,10 +105,17 @@ fn view_save_list_and_exec() {
let todo = tmp.path().join("TODO.txt"); let todo = tmp.path().join("TODO.txt");
fs::write(&todo, "remember the milk\n").unwrap(); fs::write(&todo, "remember the milk\n").unwrap();
marlin(&tmp).current_dir(tmp.path()).arg("init").assert().success(); marlin(&tmp)
.current_dir(tmp.path())
.arg("init")
.assert()
.success();
// save & list // save & list
marlin(&tmp).args(["view", "save", "tasks", "milk"]).assert().success(); marlin(&tmp)
.args(["view", "save", "tasks", "milk"])
.assert()
.success();
marlin(&tmp) marlin(&tmp)
.args(["view", "list"]) .args(["view", "list"])
.assert() .assert()
@@ -118,24 +145,30 @@ fn link_add_rm_and_list() {
let mc = || marlin(&tmp); let mc = || marlin(&tmp);
mc().current_dir(tmp.path()).arg("init").assert().success(); mc().current_dir(tmp.path()).arg("init").assert().success();
mc().args(["scan", tmp.path().to_str().unwrap()]).assert().success(); mc().args(["scan", tmp.path().to_str().unwrap()])
.assert()
.success();
// add // add
mc().args(["link", "add", foo.to_str().unwrap(), bar.to_str().unwrap()]) mc().args(["link", "add", foo.to_str().unwrap(), bar.to_str().unwrap()])
.assert().success(); .assert()
.success();
// list (outgoing default) // list (outgoing default)
mc().args(["link", "list", foo.to_str().unwrap()]) mc().args(["link", "list", foo.to_str().unwrap()])
.assert().success() .assert()
.success()
.stdout(str::contains("foo.txt").and(str::contains("bar.txt"))); .stdout(str::contains("foo.txt").and(str::contains("bar.txt")));
// remove // remove
mc().args(["link", "rm", foo.to_str().unwrap(), bar.to_str().unwrap()]) mc().args(["link", "rm", foo.to_str().unwrap(), bar.to_str().unwrap()])
.assert().success(); .assert()
.success();
// list now empty // list now empty
mc().args(["link", "list", foo.to_str().unwrap()]) mc().args(["link", "list", foo.to_str().unwrap()])
.assert().success() .assert()
.success()
.stdout(str::is_empty()); .stdout(str::is_empty());
} }
@@ -154,19 +187,24 @@ fn scan_with_multiple_paths_indexes_all() {
fs::write(&f1, "").unwrap(); fs::write(&f1, "").unwrap();
fs::write(&f2, "").unwrap(); fs::write(&f2, "").unwrap();
marlin(&tmp).current_dir(tmp.path()).arg("init").assert().success(); marlin(&tmp)
.current_dir(tmp.path())
.arg("init")
.assert()
.success();
// multi-path scan // multi-path scan
marlin(&tmp) marlin(&tmp)
.args(["scan", dir_a.to_str().unwrap(), dir_b.to_str().unwrap()]) .args(["scan", dir_a.to_str().unwrap(), dir_b.to_str().unwrap()])
.assert().success(); .assert()
.success();
// both files findable // both files findable
for term in ["one.txt", "two.txt"] { for term in ["one.txt", "two.txt"] {
marlin(&tmp).args(["search", term]) marlin(&tmp)
.args(["search", term])
.assert() .assert()
.success() .success()
.stdout(str::contains(term)); .stdout(str::contains(term));
} }
} }

View File

@@ -1,9 +1,9 @@
//! tests/util.rs //! tests/util.rs
//! Small helpers shared across integration tests. //! Small helpers shared across integration tests.
use assert_cmd::Command;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tempfile::TempDir; use tempfile::TempDir;
use assert_cmd::Command;
/// Absolute path to the freshly-built `marlin` binary. /// Absolute path to the freshly-built `marlin` binary.
pub fn bin() -> PathBuf { pub fn bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_marlin")) PathBuf::from(env!("CARGO_BIN_EXE_marlin"))

View File

@@ -2,11 +2,11 @@ use std::thread;
use std::time::Duration; use std::time::Duration;
use tempfile::tempdir; use tempfile::tempdir;
use marlin_cli::cli::{watch, Format}; use libc;
use marlin_cli::cli::watch::WatchCmd;
use libmarlin::watcher::WatcherState; use libmarlin::watcher::WatcherState;
use libmarlin::{self as marlin, db}; use libmarlin::{self as marlin, db};
use libc; use marlin_cli::cli::watch::WatchCmd;
use marlin_cli::cli::{watch, Format};
#[test] #[test]
fn watch_start_and_stop_quickly() { fn watch_start_and_stop_quickly() {
@@ -20,7 +20,10 @@ fn watch_start_and_stop_quickly() {
let mut conn = db::open(&db_path).unwrap(); let mut conn = db::open(&db_path).unwrap();
let path = tmp.path().to_path_buf(); let path = tmp.path().to_path_buf();
let cmd = WatchCmd::Start { path: path.clone(), debounce_ms: 50 }; let cmd = WatchCmd::Start {
path: path.clone(),
debounce_ms: 50,
};
// send SIGINT shortly after watcher starts // send SIGINT shortly after watcher starts
let t = thread::spawn(|| { let t = thread::spawn(|| {

View File

@@ -1,7 +1,7 @@
// libmarlin/src/backup.rs // libmarlin/src/backup.rs
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Local, NaiveDateTime, Utc, TimeZone}; use chrono::{DateTime, Local, NaiveDateTime, TimeZone, Utc};
use rusqlite; use rusqlite;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -30,7 +30,10 @@ pub struct BackupManager {
} }
impl 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(); let backups_dir_path = backups_dir.as_ref().to_path_buf();
if !backups_dir_path.exists() { if !backups_dir_path.exists() {
fs::create_dir_all(&backups_dir_path).with_context(|| { fs::create_dir_all(&backups_dir_path).with_context(|| {
@@ -40,7 +43,10 @@ impl BackupManager {
) )
})?; })?;
} else if !backups_dir_path.is_dir() { } 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 { Ok(Self {
live_db_path: live_db_path.as_ref().to_path_buf(), live_db_path: live_db_path.as_ref().to_path_buf(),
@@ -56,8 +62,12 @@ impl BackupManager {
if !self.live_db_path.exists() { 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, std::io::ErrorKind::NotFound,
format!("Live DB path does not exist: {}", self.live_db_path.display()), format!(
)).context("Cannot create backup from non-existent live DB")); "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( let src_conn = rusqlite::Connection::open_with_flags(
@@ -130,15 +140,26 @@ impl BackupManager {
.trim_start_matches("backup_") .trim_start_matches("backup_")
.trim_end_matches(".db"); .trim_end_matches(".db");
let naive_dt = match NaiveDateTime::parse_from_str(ts_str, "%Y-%m-%d_%H-%M-%S_%f") { let naive_dt =
match NaiveDateTime::parse_from_str(ts_str, "%Y-%m-%d_%H-%M-%S_%f")
{
Ok(dt) => dt, Ok(dt) => dt,
Err(_) => match NaiveDateTime::parse_from_str(ts_str, "%Y-%m-%d_%H-%M-%S") { Err(_) => match NaiveDateTime::parse_from_str(
ts_str,
"%Y-%m-%d_%H-%M-%S",
) {
Ok(dt) => dt, Ok(dt) => dt,
Err(_) => { Err(_) => {
let metadata = fs::metadata(&path).with_context(|| format!("Failed to get metadata for {}", path.display()))?; let metadata =
fs::metadata(&path).with_context(|| {
format!(
"Failed to get metadata for {}",
path.display()
)
})?;
DateTime::<Utc>::from(metadata.modified()?).naive_utc() DateTime::<Utc>::from(metadata.modified()?).naive_utc()
} }
} },
}; };
let local_dt_result = Local.from_local_datetime(&naive_dt); let local_dt_result = Local.from_local_datetime(&naive_dt);
@@ -147,9 +168,12 @@ impl BackupManager {
chrono::LocalResult::Ambiguous(dt1, _dt2) => { chrono::LocalResult::Ambiguous(dt1, _dt2) => {
eprintln!("Warning: Ambiguous local time for backup {}, taking first interpretation.", filename); eprintln!("Warning: Ambiguous local time for backup {}, taking first interpretation.", filename);
dt1 dt1
}, }
chrono::LocalResult::None => { chrono::LocalResult::None => {
eprintln!("Warning: Invalid local time for backup {}, skipping.", filename); eprintln!(
"Warning: Invalid local time for backup {}, skipping.",
filename
);
continue; continue;
} }
}; };
@@ -223,16 +247,22 @@ impl BackupManager {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use tempfile::tempdir;
use crate::db::open as open_marlin_db; use crate::db::open as open_marlin_db;
use tempfile::tempdir;
fn create_valid_live_db(path: &Path) -> rusqlite::Connection { fn create_valid_live_db(path: &Path) -> rusqlite::Connection {
let conn = open_marlin_db(path) let conn = open_marlin_db(path).unwrap_or_else(|e| {
.unwrap_or_else(|e| panic!("Failed to open/create test DB at {}: {:?}", path.display(), e)); panic!(
"Failed to open/create test DB at {}: {:?}",
path.display(),
e
)
});
conn.execute_batch( conn.execute_batch(
"CREATE TABLE IF NOT EXISTS test_table (id INTEGER PRIMARY KEY, data TEXT); "CREATE TABLE IF NOT EXISTS test_table (id INTEGER PRIMARY KEY, data TEXT);
INSERT INTO test_table (data) VALUES ('initial_data');" INSERT INTO test_table (data) VALUES ('initial_data');",
).expect("Failed to initialize test table"); )
.expect("Failed to initialize test table");
conn conn
} }
@@ -276,7 +306,10 @@ mod tests {
let manager_res = BackupManager::new(&live_db_path, &file_as_backups_dir); let manager_res = BackupManager::new(&live_db_path, &file_as_backups_dir);
assert!(manager_res.is_err()); 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] #[test]
@@ -289,7 +322,10 @@ mod tests {
let backup_result = manager.create_backup(); let backup_result = manager.create_backup();
assert!(backup_result.is_err()); assert!(backup_result.is_err());
let err_str = backup_result.unwrap_err().to_string(); 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] #[test]
@@ -303,7 +339,10 @@ mod tests {
let manager = BackupManager::new(&live_db_file, &backups_storage_dir).unwrap(); let manager = BackupManager::new(&live_db_file, &backups_storage_dir).unwrap();
let initial_list = manager.list_backups().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(); let prune_empty_result = manager.prune(2).unwrap();
assert!(prune_empty_result.kept.is_empty()); assert!(prune_empty_result.kept.is_empty());
@@ -323,7 +362,8 @@ mod tests {
for id in &created_backup_ids { for id in &created_backup_ids {
assert!( assert!(
listed_backups.iter().any(|b| &b.id == id), 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 { if listed_backups.len() >= 2 {
@@ -364,13 +404,15 @@ mod tests {
for removed_info in prune_result.removed { for removed_info in prune_result.removed {
assert!( assert!(
!backups_storage_dir.join(&removed_info.id).exists(), !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 { for kept_info in prune_result.kept {
assert!( assert!(
backups_storage_dir.join(&kept_info.id).exists(), backups_storage_dir.join(&kept_info.id).exists(),
"Kept backup file {} should exist", kept_info.id "Kept backup file {} should exist",
kept_info.id
); );
} }
} }
@@ -384,7 +426,8 @@ mod tests {
{ {
let conn = create_valid_live_db(&live_db_path); let conn = create_valid_live_db(&live_db_path);
conn.execute("DELETE FROM test_table", []).unwrap(); conn.execute("DELETE FROM test_table", []).unwrap();
conn.execute("INSERT INTO test_table (data) VALUES (?1)", [initial_value]).unwrap(); conn.execute("INSERT INTO test_table (data) VALUES (?1)", [initial_value])
.unwrap();
} }
let backups_dir = tmp.path().join("backups_for_restore_test_dir"); let backups_dir = tmp.path().join("backups_for_restore_test_dir");
@@ -428,7 +471,11 @@ mod tests {
let result = manager.restore_from_backup("non_existent_backup.db"); let result = manager.restore_from_backup("non_existent_backup.db");
assert!(result.is_err()); assert!(result.is_err());
let err_string = result.unwrap_err().to_string(); 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] #[test]
@@ -443,11 +490,7 @@ mod tests {
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("not_a_backup.txt"), "hello").unwrap();
std::fs::write( std::fs::write(backups_dir.join("backup_malformed.db.tmp"), "temp data").unwrap();
backups_dir.join("backup_malformed.db.tmp"),
"temp data",
)
.unwrap();
std::fs::create_dir(backups_dir.join("a_subdir")).unwrap(); std::fs::create_dir(backups_dir.join("a_subdir")).unwrap();
let listed_backups = manager.list_backups().unwrap(); let listed_backups = manager.list_backups().unwrap();
@@ -467,7 +510,8 @@ mod tests {
let _conn = create_valid_live_db(&live_db_file); let _conn = create_valid_live_db(&live_db_file);
let backups_dir_for_deletion = tmp.path().join("backups_dir_to_delete_test"); 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(); let manager_for_deletion =
BackupManager::new(&live_db_file, &backups_dir_for_deletion).unwrap();
std::fs::remove_dir_all(&backups_dir_for_deletion).unwrap(); std::fs::remove_dir_all(&backups_dir_for_deletion).unwrap();
let list_res = manager_for_deletion.list_backups().unwrap(); let list_res = manager_for_deletion.list_backups().unwrap();

View File

@@ -3,9 +3,9 @@
//! This module provides a database abstraction layer that wraps the SQLite connection //! This module provides a database abstraction layer that wraps the SQLite connection
//! and provides methods for common database operations. //! and provides methods for common database operations.
use anyhow::Result;
use rusqlite::Connection; use rusqlite::Connection;
use std::path::PathBuf; use std::path::PathBuf;
use anyhow::Result;
/// Options for indexing files /// Options for indexing files
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -56,7 +56,8 @@ impl Database {
pub fn index_files(&mut self, paths: &[PathBuf], _options: &IndexOptions) -> Result<usize> { pub fn index_files(&mut self, paths: &[PathBuf], _options: &IndexOptions) -> Result<usize> {
// In a real implementation, this would index the files // In a real implementation, this would index the files
// For now, we just return the number of files "indexed" // 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); return Ok(0);
} }
Ok(paths.len()) Ok(paths.len())
@@ -66,7 +67,8 @@ impl Database {
pub fn remove_files(&mut self, paths: &[PathBuf]) -> Result<usize> { pub fn remove_files(&mut self, paths: &[PathBuf]) -> Result<usize> {
// In a real implementation, this would remove the files // In a real implementation, this would remove the files
// For now, we just return the number of files "removed" // 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); return Ok(0);
} }
Ok(paths.len()) Ok(paths.len())
@@ -77,8 +79,8 @@ impl Database {
mod tests { mod tests {
use super::*; use super::*;
use crate::db::open as open_marlin_db; // Use your project's DB open function use crate::db::open as open_marlin_db; // Use your project's DB open function
use tempfile::tempdir;
use std::fs::File; use std::fs::File;
use tempfile::tempdir;
fn setup_db() -> Database { fn setup_db() -> Database {
let conn = open_marlin_db(":memory:").expect("Failed to open in-memory DB"); let conn = open_marlin_db(":memory:").expect("Failed to open in-memory DB");

View File

@@ -9,27 +9,38 @@ use std::{
path::{Path, PathBuf}, path::{Path, PathBuf},
}; };
use std::result::Result as StdResult;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use chrono::Local; use chrono::Local;
use rusqlite::{ use rusqlite::{
backup::{Backup, StepResult}, backup::{Backup, StepResult},
params, params, Connection, OpenFlags, OptionalExtension, TransactionBehavior,
Connection,
OpenFlags,
OptionalExtension,
TransactionBehavior,
}; };
use std::result::Result as StdResult;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
/* ─── embedded migrations ─────────────────────────────────────────── */ /* ─── embedded migrations ─────────────────────────────────────────── */
const MIGRATIONS: &[(&str, &str)] = &[ 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")), "0001_initial_schema.sql",
("0003_create_links_collections_views.sql", include_str!("migrations/0003_create_links_collections_views.sql")), include_str!("migrations/0001_initial_schema.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")), (
"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 ────────────────────────────────────────── */ /* ─── connection bootstrap ────────────────────────────────────────── */
@@ -237,10 +248,7 @@ pub fn list_links(
Ok(out) Ok(out)
} }
pub fn find_backlinks( pub fn find_backlinks(conn: &Connection, pattern: &str) -> Result<Vec<(String, Option<String>)>> {
conn: &Connection,
pattern: &str,
) -> Result<Vec<(String, Option<String>)>> {
let like = pattern.replace('*', "%"); let like = pattern.replace('*', "%");
let mut stmt = conn.prepare( 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> { pub fn view_query(conn: &Connection, name: &str) -> Result<String> {
conn.query_row( conn.query_row("SELECT query FROM views WHERE name = ?1", [name], |r| {
"SELECT query FROM views WHERE name = ?1", r.get::<_, String>(0)
[name], })
|r| r.get::<_, String>(0),
)
.context(format!("no view called '{}'", name)) .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 // fetch its id via raw SQL
let fid: i64 = conn 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(); .unwrap();
// db::file_id should return the same id for existing paths // db::file_id should return the same id for existing paths
@@ -116,10 +118,14 @@ fn add_and_remove_links_and_backlinks() {
) )
.unwrap(); .unwrap();
let src: i64 = conn 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(); .unwrap();
let dst: i64 = conn 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(); .unwrap();
// add a link of type "ref" // add a link of type "ref"
@@ -193,8 +199,11 @@ fn backup_and_restore_cycle() {
// reopen and check that x.bin survived // reopen and check that x.bin survived
let conn2 = db::open(&db_path).unwrap(); let conn2 = db::open(&db_path).unwrap();
let cnt: i64 = let cnt: i64 = conn2
conn2.query_row("SELECT COUNT(*) FROM files WHERE path='x.bin'", [], |r| r.get(0)).unwrap(); .query_row("SELECT COUNT(*) FROM files WHERE path='x.bin'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(cnt, 1); assert_eq!(cnt, 1);
} }
@@ -210,7 +219,9 @@ mod dirty_helpers {
) )
.unwrap(); .unwrap();
let fid: i64 = conn 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(); .unwrap();
db::mark_dirty(&conn, fid).unwrap(); db::mark_dirty(&conn, fid).unwrap();

View File

@@ -1,7 +1,7 @@
// libmarlin/src/error.rs // libmarlin/src/error.rs
use std::io;
use std::fmt; use std::fmt;
use std::io;
// Ensure these are present if Error enum variants use them directly // Ensure these are present if Error enum variants use them directly
// use rusqlite; // use rusqlite;
// use notify; // use notify;
@@ -70,7 +70,8 @@ mod tests {
#[test] #[test]
fn test_error_display_and_from() { fn test_error_display_and_from() {
// Test Io variant // 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")); 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"); assert_eq!(io_err_marlin.to_string(), "IO error: test io error");
let source = io_err_marlin.source(); let source = io_err_marlin.source();
@@ -90,25 +91,36 @@ mod tests {
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR), rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR),
Some("test db error".to_string()), 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(); let source = db_err_marlin.source();
assert!(source.is_some(), "Database error should have a source"); assert!(source.is_some(), "Database error should have a source");
if let Some(s) = 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 // Test Watch variant
let notify_raw_err_inner_for_source_check = notify::Error::new(notify::ErrorKind::Generic("test watch error".to_string())); let notify_raw_err_inner_for_source_check =
let watch_err_marlin = Error::from(notify::Error::new(notify::ErrorKind::Generic("test watch error".to_string()))); notify::Error::new(notify::ErrorKind::Generic("test watch error".to_string()));
assert!(watch_err_marlin.to_string().contains("Watch error: test watch error")); 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(); let source = watch_err_marlin.source();
assert!(source.is_some(), "Watch error should have a source"); assert!(source.is_some(), "Watch error should have a source");
if let Some(s) = 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()); let invalid_state_err = Error::InvalidState("bad state".to_string());
assert_eq!(invalid_state_err.to_string(), "Invalid state: bad state"); assert_eq!(invalid_state_err.to_string(), "Invalid state: bad state");
assert!(invalid_state_err.source().is_none()); assert!(invalid_state_err.source().is_none());
@@ -137,7 +149,8 @@ mod tests {
let expected_rusqlite_msg = rusqlite::Error::SqliteFailure( let expected_rusqlite_msg = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY), rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY),
None, None,
).to_string(); )
.to_string();
let expected_marlin_msg = format!("Database error: {}", expected_rusqlite_msg); let expected_marlin_msg = format!("Database error: {}", expected_rusqlite_msg);

View File

@@ -71,4 +71,3 @@ fn open_default_fallback_config() {
// Clean up // Clean up
env::remove_var("HOME"); env::remove_var("HOME");
} }

View File

@@ -16,24 +16,28 @@ pub mod scan;
pub mod utils; pub mod utils;
pub mod watcher; pub mod watcher;
#[cfg(test)]
mod utils_tests;
#[cfg(test)] #[cfg(test)]
mod config_tests; mod config_tests;
#[cfg(test)] #[cfg(test)]
mod scan_tests;
#[cfg(test)]
mod logging_tests;
#[cfg(test)]
mod db_tests; mod db_tests;
#[cfg(test)] #[cfg(test)]
mod facade_tests; mod facade_tests;
#[cfg(test)] #[cfg(test)]
mod logging_tests;
#[cfg(test)]
mod scan_tests;
#[cfg(test)]
mod utils_tests;
#[cfg(test)]
mod watcher_tests; mod watcher_tests;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use rusqlite::Connection; 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. /// Main handle for interacting with a Marlin database.
pub struct Marlin { pub struct Marlin {
@@ -66,10 +70,12 @@ impl Marlin {
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
} }
// Build a minimal Config so callers can still inspect cfg.db_path // 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 // Open the database and run migrations
let conn = db::open(db_path) let conn =
.context(format!("opening database at {}", db_path.display()))?; db::open(db_path).context(format!("opening database at {}", db_path.display()))?;
Ok(Marlin { cfg, conn }) Ok(Marlin { cfg, conn })
} }
@@ -95,11 +101,11 @@ impl Marlin {
let mut cur = Some(leaf); let mut cur = Some(leaf);
while let Some(id) = cur { while let Some(id) = cur {
tag_ids.push(id); tag_ids.push(id);
cur = self.conn.query_row( cur = self
"SELECT parent_id FROM tags WHERE id = ?1", .conn
[id], .query_row("SELECT parent_id FROM tags WHERE id = ?1", [id], |r| {
|r| r.get::<_, Option<i64>>(0), r.get::<_, Option<i64>>(0)
)?; })?;
} }
// 3) match files by glob against stored paths // 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 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 rows = stmt_all.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
let mut stmt_ins = self.conn.prepare( let mut stmt_ins = self
"INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?1, ?2)", .conn
)?; .prepare("INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?1, ?2)")?;
let mut changed = 0; let mut changed = 0;
for row in rows { for row in rows {
@@ -148,7 +154,8 @@ impl Marlin {
let mut stmt = self.conn.prepare( 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", "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>>()?; .collect::<std::result::Result<Vec<_>, rusqlite::Error>>()?;
if hits.is_empty() && !query.contains(':') { if hits.is_empty() && !query.contains(':') {
@@ -194,8 +201,7 @@ impl Marlin {
) -> Result<watcher::FileWatcher> { ) -> Result<watcher::FileWatcher> {
let cfg = config.unwrap_or_default(); let cfg = config.unwrap_or_default();
let p = path.as_ref().to_path_buf(); let p = path.as_ref().to_path_buf();
let new_conn = db::open(&self.cfg.db_path) let new_conn = db::open(&self.cfg.db_path).context("opening database for watcher")?;
.context("opening database for watcher")?;
let watcher_db = Arc::new(Mutex::new(db::Database::new(new_conn))); let watcher_db = Arc::new(Mutex::new(db::Database::new(new_conn)));
let mut owned_w = watcher::FileWatcher::new(vec![p], cfg)?; let mut owned_w = watcher::FileWatcher::new(vec![p], cfg)?;

View File

@@ -1,9 +1,9 @@
// libmarlin/src/scan_tests.rs // libmarlin/src/scan_tests.rs
use super::scan::scan_directory;
use super::db; use super::db;
use tempfile::tempdir; use super::scan::scan_directory;
use std::fs::File; use std::fs::File;
use tempfile::tempdir;
#[test] #[test]
fn scan_directory_counts_files() { 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 there were NO wildcards at all, just return the parent directory
if first_wild == pattern.len() { 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"), // 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, //! (create, modify, delete) using the `notify` crate. It implements event debouncing,
//! batch processing, and a state machine for robust lifecycle management. //! batch processing, and a state machine for robust lifecycle management.
use anyhow::{Result, Context};
use crate::db::Database; use crate::db::Database;
use anyhow::{Context, Result};
use crossbeam_channel::{bounded, Receiver}; use crossbeam_channel::{bounded, Receiver};
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcherTrait}; use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcherTrait};
use std::collections::HashMap; use std::collections::HashMap;
@@ -98,9 +98,11 @@ impl EventDebouncer {
fn add_event(&mut self, event: ProcessedEvent) { fn add_event(&mut self, event: ProcessedEvent) {
let path = event.path.clone(); let path = event.path.clone();
if path.is_dir() { // This relies on the PathBuf itself knowing if it's a directory 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. // 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 ); self.events
.retain(|file_path, _| !file_path.starts_with(&path) || file_path == &path);
} }
match self.events.get_mut(&path) { match self.events.get_mut(&path) {
Some(existing) => { Some(existing) => {
@@ -226,7 +228,11 @@ mod event_debouncer_tests {
priority: EventPriority::Delete, priority: EventPriority::Delete,
timestamp: Instant::now(), 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)); std::thread::sleep(Duration::from_millis(110));
let flushed = debouncer_h.flush(); let flushed = debouncer_h.flush();
@@ -265,9 +271,24 @@ mod event_debouncer_tests {
let path2 = PathBuf::from("file2.txt"); let path2 = PathBuf::from("file2.txt");
let path3 = PathBuf::from("file3.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 {
debouncer.add_event(ProcessedEvent { path: path2, kind: EventKind::Create(CreateKind::File), priority: EventPriority::Create, timestamp: Instant::now() }); path: path1,
debouncer.add_event(ProcessedEvent { path: path3, kind: EventKind::Remove(RemoveKind::File), priority: EventPriority::Delete, timestamp: Instant::now() }); 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)); std::thread::sleep(Duration::from_millis(110));
let flushed = debouncer.flush(); let flushed = debouncer.flush();
@@ -314,7 +335,6 @@ mod event_debouncer_tests {
} }
} }
pub struct FileWatcher { pub struct FileWatcher {
state: Arc<Mutex<WatcherState>>, state: Arc<Mutex<WatcherState>>,
_config: WatcherConfig, _config: WatcherConfig,
@@ -380,7 +400,9 @@ impl FileWatcher {
thread::sleep(Duration::from_millis(100)); thread::sleep(Duration::from_millis(100));
continue; continue;
} }
if current_state == WatcherState::ShuttingDown || current_state == WatcherState::Stopped { if current_state == WatcherState::ShuttingDown
|| current_state == WatcherState::Stopped
{
break; break;
} }
@@ -508,8 +530,14 @@ impl FileWatcher {
} }
return Ok(()); return Ok(());
} }
if *state_guard != WatcherState::Initializing && *state_guard != WatcherState::Stopped && *state_guard != WatcherState::Paused { if *state_guard != WatcherState::Initializing
return Err(anyhow::anyhow!(format!("Cannot start watcher from state {:?}", *state_guard))); && *state_guard != WatcherState::Stopped
&& *state_guard != WatcherState::Paused
{
return Err(anyhow::anyhow!(format!(
"Cannot start watcher from state {:?}",
*state_guard
)));
} }
*state_guard = WatcherState::Watching; *state_guard = WatcherState::Watching;
@@ -527,7 +555,10 @@ impl FileWatcher {
Ok(()) Ok(())
} }
WatcherState::Paused => Ok(()), WatcherState::Paused => Ok(()),
_ => Err(anyhow::anyhow!(format!("Watcher not in watching state to pause (current: {:?})", *state_guard))), _ => Err(anyhow::anyhow!(format!(
"Watcher not in watching state to pause (current: {:?})",
*state_guard
))),
} }
} }
@@ -542,7 +573,10 @@ impl FileWatcher {
Ok(()) Ok(())
} }
WatcherState::Watching => Ok(()), WatcherState::Watching => Ok(()),
_ => Err(anyhow::anyhow!(format!("Watcher not in paused state to resume (current: {:?})", *state_guard))), _ => Err(anyhow::anyhow!(format!(
"Watcher not in paused state to resume (current: {:?})",
*state_guard
))),
} }
} }
@@ -551,7 +585,9 @@ impl FileWatcher {
.state .state
.lock() .lock()
.map_err(|_| anyhow::anyhow!("state mutex poisoned"))?; .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(()); return Ok(());
} }
*current_state_guard = WatcherState::ShuttingDown; *current_state_guard = WatcherState::ShuttingDown;
@@ -600,12 +636,11 @@ impl Drop for FileWatcher {
} }
} }
#[cfg(test)] #[cfg(test)]
mod file_watcher_state_tests { mod file_watcher_state_tests {
use super::*; use super::*;
use tempfile::tempdir; use std::fs as FsMod;
use std::fs as FsMod; // Alias to avoid conflict with local `fs` module name if any use tempfile::tempdir; // Alias to avoid conflict with local `fs` module name if any
#[test] #[test]
fn test_watcher_pause_resume_stop() { fn test_watcher_pause_resume_stop() {
@@ -615,7 +650,8 @@ mod file_watcher_state_tests {
let config = WatcherConfig::default(); 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); assert_eq!(watcher.status().unwrap().state, WatcherState::Initializing);
@@ -645,37 +681,43 @@ mod file_watcher_state_tests {
fn test_watcher_start_errors() { fn test_watcher_start_errors() {
let tmp_dir = tempdir().unwrap(); let tmp_dir = tempdir().unwrap();
FsMod::create_dir_all(tmp_dir.path()).expect("Failed to create temp dir for watching"); 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 let mut state_guard = watcher.state.lock().expect("state mutex poisoned");
.state
.lock()
.expect("state mutex poisoned");
*state_guard = WatcherState::Watching; *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); assert_eq!(watcher.status().unwrap().state, WatcherState::Watching);
{ {
let mut state_guard = watcher let mut state_guard = watcher.state.lock().expect("state mutex poisoned");
.state
.lock()
.expect("state mutex poisoned");
*state_guard = WatcherState::ShuttingDown; *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() { 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 config = WatcherConfig::default();
let watcher_result = FileWatcher::new(vec![non_existent_path], config); let watcher_result = FileWatcher::new(vec![non_existent_path], config);
assert!(watcher_result.is_err()); assert!(watcher_result.is_err());
if let Err(e) = watcher_result { if let Err(e) = watcher_result {
let err_string = e.to_string(); 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 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 state_arc = watcher.state.clone();
let _ = std::thread::spawn(move || { let _ = std::thread::spawn(move || {

View File

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