mirror of
https://github.com/PR0M3TH3AN/Marlin.git
synced 2025-09-08 07:08:44 +00:00
Format codebase with rustfmt
This commit is contained in:
@@ -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
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
@@ -1,11 +1,11 @@
|
|||||||
// 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 {
|
||||||
Add (ArgsAdd),
|
Add(ArgsAdd),
|
||||||
List(ArgsList),
|
List(ArgsList),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,16 +13,20 @@ 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 {
|
||||||
AnnotateCmd::Add(a) => todo!("annotate add {:?}", a),
|
AnnotateCmd::Add(a) => todo!("annotate add {:?}", a),
|
||||||
AnnotateCmd::List(a) => todo!("annotate list {:?}", a),
|
AnnotateCmd::List(a) => todo!("annotate list {:?}", a),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -3,8 +3,8 @@
|
|||||||
use clap::{Args, Subcommand};
|
use clap::{Args, Subcommand};
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
use crate::cli::Format; // local enum for text / json output
|
use crate::cli::Format; // local enum for text / json output
|
||||||
use libmarlin::db; // core DB helpers from the library crate
|
use libmarlin::db; // core DB helpers from the library crate
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
pub enum CollCmd {
|
pub enum CollCmd {
|
||||||
@@ -36,11 +36,9 @@ pub struct ListArgs {
|
|||||||
///
|
///
|
||||||
/// Returns the collection ID or an error if it doesn’t exist.
|
/// Returns the collection ID or an error if it doesn’t 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()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,11 +1,11 @@
|
|||||||
// 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 {
|
||||||
Add (ArgsAdd),
|
Add(ArgsAdd),
|
||||||
Timeline,
|
Timeline,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ pub struct ArgsAdd {
|
|||||||
|
|
||||||
pub fn run(cmd: &EventCmd, _conn: &mut Connection, _format: Format) -> anyhow::Result<()> {
|
pub fn run(cmd: &EventCmd, _conn: &mut Connection, _format: Format) -> anyhow::Result<()> {
|
||||||
match cmd {
|
match cmd {
|
||||||
EventCmd::Add(a) => todo!("event add {:?}", a),
|
EventCmd::Add(a) => todo!("event add {:?}", a),
|
||||||
EventCmd::Timeline => todo!("event timeline"),
|
EventCmd::Timeline => todo!("event timeline"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,15 +1,15 @@
|
|||||||
//! 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
|
||||||
use libmarlin::db; // ← switched from `crate::db`
|
use libmarlin::db; // ← switched from `crate::db`
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
pub enum LinkCmd {
|
pub enum LinkCmd {
|
||||||
Add(LinkArgs),
|
Add(LinkArgs),
|
||||||
Rm (LinkArgs),
|
Rm(LinkArgs),
|
||||||
List(ListArgs),
|
List(ListArgs),
|
||||||
Backlinks(BacklinksArgs),
|
Backlinks(BacklinksArgs),
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,7 @@ pub enum LinkCmd {
|
|||||||
#[derive(Args, Debug)]
|
#[derive(Args, Debug)]
|
||||||
pub struct LinkArgs {
|
pub struct LinkArgs {
|
||||||
pub from: String,
|
pub from: String,
|
||||||
pub to: String,
|
pub to: String,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub r#type: Option<String>,
|
pub r#type: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
@@ -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 {
|
||||||
@@ -11,8 +11,8 @@ pub enum RemindCmd {
|
|||||||
#[derive(Args, Debug)]
|
#[derive(Args, Debug)]
|
||||||
pub struct ArgsSet {
|
pub struct ArgsSet {
|
||||||
pub file_pattern: String,
|
pub file_pattern: String,
|
||||||
pub timestamp: String,
|
pub timestamp: String,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(cmd: &RemindCmd, _conn: &mut Connection, _format: Format) -> anyhow::Result<()> {
|
pub fn run(cmd: &RemindCmd, _conn: &mut Connection, _format: Format) -> anyhow::Result<()> {
|
||||||
|
@@ -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,16 +11,24 @@ 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 {
|
||||||
StateCmd::Set(a) => todo!("state set {:?}", a),
|
StateCmd::Set(a) => todo!("state set {:?}", a),
|
||||||
StateCmd::TransitionsAdd(a)=> todo!("state transitions-add {:?}", a),
|
StateCmd::TransitionsAdd(a) => todo!("state transitions-add {:?}", a),
|
||||||
StateCmd::Log(a) => todo!("state log {:?}", a),
|
StateCmd::Log(a) => todo!("state log {:?}", a),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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 {
|
||||||
|
@@ -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 {
|
||||||
|
@@ -6,8 +6,8 @@ use anyhow::Result;
|
|||||||
use clap::{Args, Subcommand};
|
use clap::{Args, Subcommand};
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
use crate::cli::Format; // output selector stays local
|
use crate::cli::Format; // output selector stays local
|
||||||
use libmarlin::db; // ← path switched from `crate::db`
|
use libmarlin::db; // ← path switched from `crate::db`
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
pub enum ViewCmd {
|
pub enum ViewCmd {
|
||||||
|
@@ -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;
|
||||||
@@ -30,15 +30,15 @@ pub enum WatchCmd {
|
|||||||
/// Directory to watch (defaults to current directory)
|
/// Directory to watch (defaults to current directory)
|
||||||
#[arg(default_value = ".")]
|
#[arg(default_value = ".")]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
|
|
||||||
/// Debounce window in milliseconds (default: 100ms)
|
/// Debounce window in milliseconds (default: 100ms)
|
||||||
#[arg(long, default_value = "100")]
|
#[arg(long, default_value = "100")]
|
||||||
debounce_ms: u64,
|
debounce_ms: u64,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Show status of currently active watcher
|
/// Show status of currently active watcher
|
||||||
Status,
|
Status,
|
||||||
|
|
||||||
/// Stop the currently running watcher
|
/// Stop the currently running watcher
|
||||||
Stop,
|
Stop,
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ pub fn run(cmd: &WatchCmd, _conn: &mut Connection, _format: super::Format) -> Re
|
|||||||
let status = watcher.status()?;
|
let status = watcher.status()?;
|
||||||
info!("Watcher started. Press Ctrl+C to stop watching.");
|
info!("Watcher started. Press Ctrl+C to stop watching.");
|
||||||
info!("Watching {} paths", status.watched_paths.len());
|
info!("Watching {} paths", status.watched_paths.len());
|
||||||
|
|
||||||
let start_time = Instant::now();
|
let start_time = Instant::now();
|
||||||
let mut last_status_time = Instant::now();
|
let mut last_status_time = Instant::now();
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
@@ -80,7 +80,7 @@ pub fn run(cmd: &WatchCmd, _conn: &mut Connection, _format: super::Format) -> Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Corrected line: removed the extra closing parenthesis
|
// Corrected line: removed the extra closing parenthesis
|
||||||
if last_status_time.elapsed() > Duration::from_secs(10) {
|
if last_status_time.elapsed() > Duration::from_secs(10) {
|
||||||
let uptime = start_time.elapsed();
|
let uptime = start_time.elapsed();
|
||||||
info!(
|
info!(
|
||||||
"Watcher running for {}s, processed {} events, queue: {}, state: {:?}",
|
"Watcher running for {}s, processed {} events, queue: {}, state: {:?}",
|
||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
@@ -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;
|
||||||
|
|
||||||
@@ -57,7 +45,7 @@ fn main() -> Result<()> {
|
|||||||
match &args.command {
|
match &args.command {
|
||||||
Commands::Init | Commands::Backup | Commands::Restore { .. } => {}
|
Commands::Init | Commands::Backup | Commands::Restore { .. } => {}
|
||||||
_ => match db::backup(&cfg.db_path) {
|
_ => match db::backup(&cfg.db_path) {
|
||||||
Ok(p) => info!("Pre-command auto-backup created at {}", p.display()),
|
Ok(p) => info!("Pre-command auto-backup created at {}", p.display()),
|
||||||
Err(e) => error!("Failed to create pre-command auto-backup: {e}"),
|
Err(e) => error!("Failed to create pre-command auto-backup: {e}"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -72,9 +60,8 @@ fn main() -> Result<()> {
|
|||||||
/* ---- init ------------------------------------------------ */
|
/* ---- init ------------------------------------------------ */
|
||||||
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())
|
||||||
@@ -136,15 +119,15 @@ fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---- passthrough sub-modules (some still stubs) ---------- */
|
/* ---- passthrough sub-modules (some still stubs) ---------- */
|
||||||
Commands::Link(link_cmd) => cli::link::run(&link_cmd, &mut conn, args.format)?,
|
Commands::Link(link_cmd) => cli::link::run(&link_cmd, &mut conn, args.format)?,
|
||||||
Commands::Coll(coll_cmd) => cli::coll::run(&coll_cmd, &mut conn, args.format)?,
|
Commands::Coll(coll_cmd) => cli::coll::run(&coll_cmd, &mut conn, args.format)?,
|
||||||
Commands::View(view_cmd) => cli::view::run(&view_cmd, &mut conn, args.format)?,
|
Commands::View(view_cmd) => cli::view::run(&view_cmd, &mut conn, args.format)?,
|
||||||
Commands::State(state_cmd) => cli::state::run(&state_cmd, &mut conn, args.format)?,
|
Commands::State(state_cmd) => cli::state::run(&state_cmd, &mut conn, args.format)?,
|
||||||
Commands::Task(task_cmd) => cli::task::run(&task_cmd, &mut conn, args.format)?,
|
Commands::Task(task_cmd) => cli::task::run(&task_cmd, &mut conn, args.format)?,
|
||||||
Commands::Remind(rm_cmd) => cli::remind::run(&rm_cmd, &mut conn, args.format)?,
|
Commands::Remind(rm_cmd) => cli::remind::run(&rm_cmd, &mut conn, args.format)?,
|
||||||
Commands::Annotate(a_cmd) => cli::annotate::run(&a_cmd, &mut conn, args.format)?,
|
Commands::Annotate(a_cmd) => cli::annotate::run(&a_cmd, &mut conn, args.format)?,
|
||||||
Commands::Version(v_cmd) => cli::version::run(&v_cmd, &mut conn, args.format)?,
|
Commands::Version(v_cmd) => cli::version::run(&v_cmd, &mut conn, args.format)?,
|
||||||
Commands::Event(e_cmd) => cli::event::run(&e_cmd, &mut conn, args.format)?,
|
Commands::Event(e_cmd) => cli::event::run(&e_cmd, &mut conn, args.format)?,
|
||||||
Commands::Watch(watch_cmd) => cli::watch::run(&watch_cmd, &mut conn, args.format)?,
|
Commands::Watch(watch_cmd) => cli::watch::run(&watch_cmd, &mut conn, args.format)?,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
|
@@ -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,20 +14,41 @@ 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() });
|
||||||
coll::run(&list, &mut conn, cli::Format::Text).unwrap();
|
coll::run(&list, &mut conn, cli::Format::Text).unwrap();
|
||||||
}
|
}
|
||||||
|
@@ -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,27 +14,43 @@ 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(),
|
||||||
to: "bar.txt".into(),
|
to: "bar.txt".into(),
|
||||||
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 {
|
||||||
from: "foo.txt".into(),
|
from: "foo.txt".into(),
|
||||||
to: "bar.txt".into(),
|
to: "bar.txt".into(),
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
@@ -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();
|
||||||
}
|
}
|
||||||
|
@@ -25,8 +25,8 @@ fn spawn_demo_tree(root: &PathBuf) {
|
|||||||
fs::write(root.join("Projects/Alpha/draft2.md"), "- [x] TODO foo\n").unwrap();
|
fs::write(root.join("Projects/Alpha/draft2.md"), "- [x] TODO foo\n").unwrap();
|
||||||
fs::write(root.join("Projects/Beta/final.md"), "done\n").unwrap();
|
fs::write(root.join("Projects/Beta/final.md"), "done\n").unwrap();
|
||||||
fs::write(root.join("Projects/Gamma/TODO.txt"), "TODO bar\n").unwrap();
|
fs::write(root.join("Projects/Gamma/TODO.txt"), "TODO bar\n").unwrap();
|
||||||
fs::write(root.join("Logs/app.log"), "ERROR omg\n").unwrap();
|
fs::write(root.join("Logs/app.log"), "ERROR omg\n").unwrap();
|
||||||
fs::write(root.join("Reports/Q1.pdf"), "PDF\n").unwrap();
|
fs::write(root.join("Reports/Q1.pdf"), "PDF\n").unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shorthand for “run and must succeed”.
|
/// Shorthand for “run and must succeed”.
|
||||||
@@ -38,7 +38,7 @@ fn ok(cmd: &mut Command) -> assert_cmd::assert::Assert {
|
|||||||
fn full_cli_flow() -> Result<(), Box<dyn std::error::Error>> {
|
fn full_cli_flow() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
/* ── 1 ░ sandbox ───────────────────────────────────────────── */
|
/* ── 1 ░ sandbox ───────────────────────────────────────────── */
|
||||||
|
|
||||||
let tmp = tempdir()?; // wiped on drop
|
let tmp = tempdir()?; // wiped on drop
|
||||||
let demo_dir = tmp.path().join("marlin_demo");
|
let demo_dir = tmp.path().join("marlin_demo");
|
||||||
spawn_demo_tree(&demo_dir);
|
spawn_demo_tree(&demo_dir);
|
||||||
|
|
||||||
@@ -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,31 +92,29 @@ 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
|
||||||
ok(marlin().arg("restore").arg(backup_file)); // restore
|
ok(marlin().arg("restore").arg(backup_file)); // restore
|
||||||
|
|
||||||
// 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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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,16 +40,19 @@ 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"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,14 +60,18 @@ fn attr_set_on_non_indexed_file_should_warn() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn coll_add_unknown_collection_should_fail() {
|
fn coll_add_unknown_collection_should_fail() {
|
||||||
let tmp = tempdir().unwrap();
|
let tmp = tempdir().unwrap();
|
||||||
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()])
|
||||||
.assert()
|
.assert()
|
||||||
.failure();
|
.failure();
|
||||||
}
|
}
|
||||||
@@ -68,7 +80,7 @@ fn coll_add_unknown_collection_should_fail() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn restore_with_nonexistent_backup_should_fail() {
|
fn restore_with_nonexistent_backup_should_fail() {
|
||||||
let tmp = tempdir().unwrap();
|
let tmp = tempdir().unwrap();
|
||||||
|
|
||||||
// create an empty DB first
|
// create an empty DB first
|
||||||
marlin(&tmp).arg("init").assert().success();
|
marlin(&tmp).arg("init").assert().success();
|
||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -5,7 +5,7 @@
|
|||||||
mod util;
|
mod util;
|
||||||
use util::marlin;
|
use util::marlin;
|
||||||
|
|
||||||
use predicates::{prelude::*, str}; // brings `PredicateBooleanExt::and`
|
use predicates::{prelude::*, str}; // brings `PredicateBooleanExt::and`
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
@@ -13,15 +13,20 @@ use tempfile::tempdir;
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tag_should_add_hierarchical_tag_and_search_finds_it() {
|
fn tag_should_add_hierarchical_tag_and_search_finds_it() {
|
||||||
let tmp = tempdir().unwrap();
|
let tmp = tempdir().unwrap();
|
||||||
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"])
|
||||||
@@ -34,15 +39,20 @@ fn tag_should_add_hierarchical_tag_and_search_finds_it() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn attr_set_then_ls_roundtrip() {
|
fn attr_set_then_ls_roundtrip() {
|
||||||
let tmp = tempdir().unwrap();
|
let tmp = tempdir().unwrap();
|
||||||
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)
|
||||||
@@ -80,15 +100,22 @@ fn coll_create_add_and_list() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn view_save_list_and_exec() {
|
fn view_save_list_and_exec() {
|
||||||
let tmp = tempdir().unwrap();
|
let tmp = tempdir().unwrap();
|
||||||
|
|
||||||
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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"))
|
||||||
|
@@ -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(|| {
|
||||||
|
@@ -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(),
|
||||||
@@ -54,10 +60,14 @@ impl BackupManager {
|
|||||||
let backup_file_path = self.backups_dir.join(&backup_file_name);
|
let backup_file_path = self.backups_dir.join(&backup_file_name);
|
||||||
|
|
||||||
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(
|
||||||
@@ -108,8 +118,8 @@ impl BackupManager {
|
|||||||
|
|
||||||
pub fn list_backups(&self) -> Result<Vec<BackupInfo>> {
|
pub fn list_backups(&self) -> Result<Vec<BackupInfo>> {
|
||||||
let mut backup_infos = Vec::new();
|
let mut backup_infos = Vec::new();
|
||||||
|
|
||||||
if !self.backups_dir.exists() {
|
if !self.backups_dir.exists() {
|
||||||
return Ok(backup_infos);
|
return Ok(backup_infos);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,28 +139,42 @@ impl BackupManager {
|
|||||||
let ts_str = filename
|
let ts_str = filename
|
||||||
.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 =
|
||||||
Ok(dt) => dt,
|
match NaiveDateTime::parse_from_str(ts_str, "%Y-%m-%d_%H-%M-%S_%f")
|
||||||
Err(_) => match NaiveDateTime::parse_from_str(ts_str, "%Y-%m-%d_%H-%M-%S") {
|
{
|
||||||
Ok(dt) => dt,
|
Ok(dt) => dt,
|
||||||
Err(_) => {
|
Err(_) => match NaiveDateTime::parse_from_str(
|
||||||
let metadata = fs::metadata(&path).with_context(|| format!("Failed to get metadata for {}", path.display()))?;
|
ts_str,
|
||||||
DateTime::<Utc>::from(metadata.modified()?).naive_utc()
|
"%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_result = Local.from_local_datetime(&naive_dt);
|
||||||
let local_dt = match local_dt_result {
|
let local_dt = match local_dt_result {
|
||||||
chrono::LocalResult::Single(dt) => dt,
|
chrono::LocalResult::Single(dt) => dt,
|
||||||
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!(
|
||||||
continue;
|
"Warning: Invalid local time for backup {}, skipping.",
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let timestamp_utc = DateTime::<Utc>::from(local_dt);
|
let timestamp_utc = DateTime::<Utc>::from(local_dt);
|
||||||
@@ -172,12 +196,12 @@ impl BackupManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn prune(&self, keep_count: usize) -> Result<PruneResult> {
|
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 kept = Vec::new();
|
||||||
let mut removed = Vec::new();
|
let mut removed = Vec::new();
|
||||||
|
|
||||||
if keep_count >= all_backups.len() {
|
if keep_count >= all_backups.len() {
|
||||||
kept = all_backups;
|
kept = all_backups;
|
||||||
} else {
|
} else {
|
||||||
for (index, backup_info) in all_backups.into_iter().enumerate() {
|
for (index, backup_info) in all_backups.into_iter().enumerate() {
|
||||||
@@ -185,7 +209,7 @@ impl BackupManager {
|
|||||||
kept.push(backup_info);
|
kept.push(backup_info);
|
||||||
} else {
|
} else {
|
||||||
let backup_file_path = self.backups_dir.join(&backup_info.id);
|
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(|| {
|
fs::remove_file(&backup_file_path).with_context(|| {
|
||||||
format!(
|
format!(
|
||||||
"Failed to remove old backup file: {}",
|
"Failed to remove old backup file: {}",
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +276,7 @@ mod tests {
|
|||||||
|
|
||||||
assert!(!backups_dir.exists());
|
assert!(!backups_dir.exists());
|
||||||
let manager = BackupManager::new(&live_db_path, &backups_dir).unwrap();
|
let manager = BackupManager::new(&live_db_path, &backups_dir).unwrap();
|
||||||
assert!(manager.backups_dir.exists());
|
assert!(manager.backups_dir.exists());
|
||||||
assert!(backups_dir.exists());
|
assert!(backups_dir.exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +287,7 @@ mod tests {
|
|||||||
let _conn = create_valid_live_db(&live_db_path);
|
let _conn = create_valid_live_db(&live_db_path);
|
||||||
|
|
||||||
let backups_dir = base_tmp.path().join("my_backups_existing_test");
|
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());
|
assert!(backups_dir.exists());
|
||||||
let manager_res = BackupManager::new(&live_db_path, &backups_dir);
|
let manager_res = BackupManager::new(&live_db_path, &backups_dir);
|
||||||
@@ -265,7 +295,7 @@ mod tests {
|
|||||||
let manager = manager_res.unwrap();
|
let manager = manager_res.unwrap();
|
||||||
assert_eq!(manager.backups_dir, backups_dir);
|
assert_eq!(manager.backups_dir, backups_dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_backup_manager_new_fails_if_backup_path_is_file() {
|
fn test_backup_manager_new_fails_if_backup_path_is_file() {
|
||||||
let base_tmp = tempdir().unwrap();
|
let base_tmp = tempdir().unwrap();
|
||||||
@@ -276,20 +306,26 @@ 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]
|
||||||
fn test_create_backup_failure_non_existent_live_db() {
|
fn test_create_backup_failure_non_existent_live_db() {
|
||||||
let base_tmp = tempdir().unwrap();
|
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 backups_dir = base_tmp.path().join("backups_fail_test");
|
||||||
|
|
||||||
let manager = BackupManager::new(&live_db_path, &backups_dir).unwrap();
|
let manager = BackupManager::new(&live_db_path, &backups_dir).unwrap();
|
||||||
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]
|
||||||
@@ -299,11 +335,14 @@ mod tests {
|
|||||||
let _conn_live = create_valid_live_db(&live_db_file);
|
let _conn_live = create_valid_live_db(&live_db_file);
|
||||||
|
|
||||||
let backups_storage_dir = tmp.path().join("backups_clp_storage_test");
|
let backups_storage_dir = tmp.path().join("backups_clp_storage_test");
|
||||||
|
|
||||||
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());
|
||||||
@@ -314,7 +353,7 @@ mod tests {
|
|||||||
let info = manager
|
let info = manager
|
||||||
.create_backup()
|
.create_backup()
|
||||||
.unwrap_or_else(|e| panic!("Failed to create backup {}: {:?}", i, e));
|
.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));
|
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 {
|
||||||
@@ -337,7 +377,7 @@ mod tests {
|
|||||||
assert!(listed_after_prune_zero.is_empty());
|
assert!(listed_after_prune_zero.is_empty());
|
||||||
|
|
||||||
created_backup_ids.clear();
|
created_backup_ids.clear();
|
||||||
for i in 0..5 {
|
for i in 0..5 {
|
||||||
let info = manager
|
let info = manager
|
||||||
.create_backup()
|
.create_backup()
|
||||||
.unwrap_or_else(|e| panic!("Failed to create backup {}: {:?}", i, e));
|
.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[0].id, created_backup_ids[4]);
|
||||||
assert_eq!(listed_after_prune[1].id, created_backup_ids[3]);
|
assert_eq!(listed_after_prune[1].id, created_backup_ids[3]);
|
||||||
|
|
||||||
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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_restore_backup() {
|
fn test_restore_backup() {
|
||||||
let tmp = tempdir().unwrap();
|
let tmp = tempdir().unwrap();
|
||||||
let live_db_path = tmp.path().join("live_for_restore_test.db");
|
let live_db_path = tmp.path().join("live_for_restore_test.db");
|
||||||
|
|
||||||
let initial_value = "initial_data_for_restore";
|
let initial_value = "initial_data_for_restore";
|
||||||
{
|
{
|
||||||
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");
|
||||||
@@ -403,7 +446,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(modified_check, modified_value);
|
assert_eq!(modified_check, modified_value);
|
||||||
}
|
}
|
||||||
|
|
||||||
manager.restore_from_backup(&backup_info.id).unwrap();
|
manager.restore_from_backup(&backup_info.id).unwrap();
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -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]
|
||||||
@@ -437,17 +484,13 @@ mod tests {
|
|||||||
let live_db_file = tmp.path().join("live_for_list_test.db");
|
let live_db_file = tmp.path().join("live_for_list_test.db");
|
||||||
let _conn = create_valid_live_db(&live_db_file);
|
let _conn = create_valid_live_db(&live_db_file);
|
||||||
let backups_dir = tmp.path().join("backups_list_mixed_files_test");
|
let backups_dir = tmp.path().join("backups_list_mixed_files_test");
|
||||||
|
|
||||||
let manager = BackupManager::new(&live_db_file, &backups_dir).unwrap();
|
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("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();
|
||||||
@@ -460,15 +503,16 @@ mod tests {
|
|||||||
assert!(listed_backups[0].id.ends_with(".db"));
|
assert!(listed_backups[0].id.ends_with(".db"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn list_backups_handles_io_error_on_read_dir() {
|
fn list_backups_handles_io_error_on_read_dir() {
|
||||||
let tmp = tempdir().unwrap();
|
let tmp = tempdir().unwrap();
|
||||||
let live_db_file = tmp.path().join("live_for_list_io_error.db");
|
let live_db_file = tmp.path().join("live_for_list_io_error.db");
|
||||||
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 =
|
||||||
std::fs::remove_dir_all(&backups_dir_for_deletion).unwrap();
|
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();
|
let list_res = manager_for_deletion.list_backups().unwrap();
|
||||||
assert!(list_res.is_empty());
|
assert!(list_res.is_empty());
|
||||||
|
@@ -1,21 +1,21 @@
|
|||||||
//! Database abstraction for Marlin
|
//! Database abstraction for Marlin
|
||||||
//!
|
//!
|
||||||
//! 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)]
|
||||||
pub struct IndexOptions {
|
pub struct IndexOptions {
|
||||||
/// Only update files marked as dirty
|
/// Only update files marked as dirty
|
||||||
pub dirty_only: bool,
|
pub dirty_only: bool,
|
||||||
|
|
||||||
/// Index file contents (not just metadata)
|
/// Index file contents (not just metadata)
|
||||||
pub index_contents: bool,
|
pub index_contents: bool,
|
||||||
|
|
||||||
/// Maximum file size to index (in bytes)
|
/// Maximum file size to index (in bytes)
|
||||||
pub max_size: Option<u64>,
|
pub max_size: Option<u64>,
|
||||||
}
|
}
|
||||||
@@ -41,32 +41,34 @@ impl Database {
|
|||||||
pub fn new(conn: Connection) -> Self {
|
pub fn new(conn: Connection) -> Self {
|
||||||
Self { conn }
|
Self { conn }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a reference to the underlying connection
|
/// Get a reference to the underlying connection
|
||||||
pub fn conn(&self) -> &Connection {
|
pub fn conn(&self) -> &Connection {
|
||||||
&self.conn
|
&self.conn
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a mutable reference to the underlying connection
|
/// Get a mutable reference to the underlying connection
|
||||||
pub fn conn_mut(&mut self) -> &mut Connection {
|
pub fn conn_mut(&mut self) -> &mut Connection {
|
||||||
&mut self.conn
|
&mut self.conn
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Index one or more files
|
/// Index one or more files
|
||||||
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())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove files from the index
|
/// Remove files from the index
|
||||||
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");
|
||||||
@@ -102,7 +104,7 @@ mod tests {
|
|||||||
|
|
||||||
let paths = vec![file1.to_path_buf()];
|
let paths = vec![file1.to_path_buf()];
|
||||||
let options = IndexOptions::default();
|
let options = IndexOptions::default();
|
||||||
|
|
||||||
assert_eq!(db.index_files(&paths, &options).unwrap(), 1);
|
assert_eq!(db.index_files(&paths, &options).unwrap(), 1);
|
||||||
assert_eq!(db.index_files(&[], &options).unwrap(), 0); // Test empty case
|
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
|
File::create(&file1).unwrap(); // File doesn't need to be in DB for this stub
|
||||||
|
|
||||||
let paths = vec![file1.to_path_buf()];
|
let paths = vec![file1.to_path_buf()];
|
||||||
|
|
||||||
assert_eq!(db.remove_files(&paths).unwrap(), 1);
|
assert_eq!(db.remove_files(&paths).unwrap(), 1);
|
||||||
assert_eq!(db.remove_files(&[]).unwrap(), 0); // Test empty case
|
assert_eq!(db.remove_files(&[]).unwrap(), 0); // Test empty case
|
||||||
}
|
}
|
||||||
|
@@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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();
|
||||||
|
@@ -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;
|
||||||
@@ -11,8 +11,8 @@ pub type Result<T> = std::result::Result<T, Error>;
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
Io(io::Error),
|
Io(io::Error),
|
||||||
Database(rusqlite::Error),
|
Database(rusqlite::Error),
|
||||||
Watch(notify::Error),
|
Watch(notify::Error),
|
||||||
InvalidState(String),
|
InvalidState(String),
|
||||||
NotFound(String),
|
NotFound(String),
|
||||||
Config(String),
|
Config(String),
|
||||||
@@ -65,12 +65,13 @@ impl From<notify::Error> for Error {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::error::Error as StdError;
|
use std::error::Error as StdError;
|
||||||
|
|
||||||
#[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();
|
||||||
@@ -82,33 +83,44 @@ mod tests {
|
|||||||
|
|
||||||
// Test Database variant
|
// Test Database variant
|
||||||
let rusqlite_err_inner_for_source_check = rusqlite::Error::SqliteFailure(
|
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()),
|
Some("test db error".to_string()),
|
||||||
);
|
);
|
||||||
// We need to create the error again for the From conversion if we want to compare the source
|
// 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(
|
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()),
|
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());
|
||||||
@@ -133,24 +145,25 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let db_err_no_msg = Error::from(sqlite_busy_error);
|
let db_err_no_msg = Error::from(sqlite_busy_error);
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
// Verify the string matches the expected format
|
// Verify the string matches the expected format
|
||||||
assert_eq!(db_err_no_msg.to_string(), expected_marlin_msg);
|
assert_eq!(db_err_no_msg.to_string(), expected_marlin_msg);
|
||||||
|
|
||||||
// Check the error code directly instead of the string
|
// Check the error code directly instead of the string
|
||||||
if let Error::Database(rusqlite::Error::SqliteFailure(err, _)) = &db_err_no_msg {
|
if let Error::Database(rusqlite::Error::SqliteFailure(err, _)) = &db_err_no_msg {
|
||||||
assert_eq!(err.code, rusqlite::ffi::ErrorCode::DatabaseBusy);
|
assert_eq!(err.code, rusqlite::ffi::ErrorCode::DatabaseBusy);
|
||||||
} else {
|
} else {
|
||||||
panic!("Expected Error::Database variant");
|
panic!("Expected Error::Database variant");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the source exists
|
// Verify the source exists
|
||||||
assert!(db_err_no_msg.source().is_some());
|
assert!(db_err_no_msg.source().is_some());
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
// libmarlin/src/facade_tests.rs
|
// libmarlin/src/facade_tests.rs
|
||||||
|
|
||||||
use super::*; // brings Marlin, config, etc.
|
use super::*; // brings Marlin, config, etc.
|
||||||
use std::{env, fs};
|
use std::{env, fs};
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
@@ -71,4 +71,3 @@ fn open_default_fallback_config() {
|
|||||||
// Clean up
|
// Clean up
|
||||||
env::remove_var("HOME");
|
env::remove_var("HOME");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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(':') {
|
||||||
@@ -169,7 +176,7 @@ impl Marlin {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Ok(meta) = fs::metadata(&p) {
|
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 let Ok(body) = fs::read_to_string(&p) {
|
||||||
if body.to_lowercase().contains(&needle) {
|
if body.to_lowercase().contains(&needle) {
|
||||||
out.push(p.clone());
|
out.push(p.clone());
|
||||||
@@ -194,14 +201,13 @@ 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)?;
|
||||||
owned_w.with_database(watcher_db)?; // Modifies owned_w in place
|
owned_w.with_database(watcher_db)?; // Modifies owned_w in place
|
||||||
owned_w.start()?; // Start the watcher after it has been fully configured
|
owned_w.start()?; // Start the watcher after it has been fully configured
|
||||||
|
|
||||||
Ok(owned_w) // Return the owned FileWatcher
|
Ok(owned_w) // Return the owned FileWatcher
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -9,9 +9,9 @@ pub fn init() {
|
|||||||
// All tracing output (INFO, WARN, ERROR …) now goes to *stderr* so the
|
// All tracing output (INFO, WARN, ERROR …) now goes to *stderr* so the
|
||||||
// integration tests can assert on warnings / errors reliably.
|
// integration tests can assert on warnings / errors reliably.
|
||||||
fmt()
|
fmt()
|
||||||
.with_target(false) // hide module targets
|
.with_target(false) // hide module targets
|
||||||
.with_level(true) // include log level
|
.with_level(true) // include log level
|
||||||
.with_env_filter(filter) // respect RUST_LOG
|
.with_env_filter(filter) // respect RUST_LOG
|
||||||
.with_writer(std::io::stderr) // <-- NEW: send to stderr
|
.with_writer(std::io::stderr) // <-- NEW: send to stderr
|
||||||
.init();
|
.init();
|
||||||
}
|
}
|
||||||
|
@@ -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() {
|
||||||
|
@@ -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"),
|
||||||
|
@@ -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() {
|
||||||
// or on the underlying FS. For unit tests, ensure paths are created.
|
// This relies on the PathBuf itself knowing if it's a directory
|
||||||
self.events.retain(|file_path, _| !file_path.starts_with(&path) || file_path == &path );
|
// 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) {
|
match self.events.get_mut(&path) {
|
||||||
Some(existing) => {
|
Some(existing) => {
|
||||||
@@ -137,12 +139,12 @@ mod event_debouncer_tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use notify::event::{CreateKind, DataChange, ModifyKind, RemoveKind, RenameMode};
|
use notify::event::{CreateKind, DataChange, ModifyKind, RemoveKind, RenameMode};
|
||||||
use std::fs; // fs is needed for these tests to create dirs/files
|
use std::fs; // fs is needed for these tests to create dirs/files
|
||||||
use tempfile;
|
use tempfile;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn debouncer_add_and_flush() {
|
fn debouncer_add_and_flush() {
|
||||||
let mut debouncer = EventDebouncer::new(100);
|
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!(debouncer.is_ready_to_flush());
|
||||||
assert_eq!(debouncer.len(), 0);
|
assert_eq!(debouncer.len(), 0);
|
||||||
|
|
||||||
@@ -154,8 +156,8 @@ mod event_debouncer_tests {
|
|||||||
timestamp: Instant::now(),
|
timestamp: Instant::now(),
|
||||||
});
|
});
|
||||||
assert_eq!(debouncer.len(), 1);
|
assert_eq!(debouncer.len(), 1);
|
||||||
|
|
||||||
debouncer.last_flush = Instant::now();
|
debouncer.last_flush = Instant::now();
|
||||||
assert!(!debouncer.is_ready_to_flush());
|
assert!(!debouncer.is_ready_to_flush());
|
||||||
|
|
||||||
std::thread::sleep(Duration::from_millis(110));
|
std::thread::sleep(Duration::from_millis(110));
|
||||||
@@ -165,7 +167,7 @@ mod event_debouncer_tests {
|
|||||||
assert_eq!(flushed.len(), 1);
|
assert_eq!(flushed.len(), 1);
|
||||||
assert_eq!(flushed[0].path, path1);
|
assert_eq!(flushed[0].path, path1);
|
||||||
assert_eq!(debouncer.len(), 0);
|
assert_eq!(debouncer.len(), 0);
|
||||||
assert!(!debouncer.is_ready_to_flush());
|
assert!(!debouncer.is_ready_to_flush());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -188,15 +190,15 @@ mod event_debouncer_tests {
|
|||||||
priority: EventPriority::Modify,
|
priority: EventPriority::Modify,
|
||||||
timestamp: t2,
|
timestamp: t2,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert_eq!(debouncer.len(), 1);
|
assert_eq!(debouncer.len(), 1);
|
||||||
|
|
||||||
std::thread::sleep(Duration::from_millis(110));
|
std::thread::sleep(Duration::from_millis(110));
|
||||||
let flushed = debouncer.flush();
|
let flushed = debouncer.flush();
|
||||||
assert_eq!(flushed.len(), 1);
|
assert_eq!(flushed.len(), 1);
|
||||||
assert_eq!(flushed[0].path, path1);
|
assert_eq!(flushed[0].path, path1);
|
||||||
assert_eq!(flushed[0].priority, EventPriority::Create);
|
assert_eq!(flushed[0].priority, EventPriority::Create);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
flushed[0].kind,
|
flushed[0].kind,
|
||||||
EventKind::Modify(ModifyKind::Data(DataChange::Any))
|
EventKind::Modify(ModifyKind::Data(DataChange::Any))
|
||||||
);
|
);
|
||||||
@@ -207,9 +209,9 @@ mod event_debouncer_tests {
|
|||||||
fn debouncer_hierarchical() {
|
fn debouncer_hierarchical() {
|
||||||
let mut debouncer_h = EventDebouncer::new(100);
|
let mut debouncer_h = EventDebouncer::new(100);
|
||||||
let temp_dir_obj = tempfile::tempdir().expect("Failed to create temp dir");
|
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");
|
let p_file = p_dir.join("file.txt");
|
||||||
|
|
||||||
fs::File::create(&p_file).expect("Failed to create test file for hierarchical debounce");
|
fs::File::create(&p_file).expect("Failed to create test file for hierarchical debounce");
|
||||||
|
|
||||||
debouncer_h.add_event(ProcessedEvent {
|
debouncer_h.add_event(ProcessedEvent {
|
||||||
@@ -219,15 +221,19 @@ mod event_debouncer_tests {
|
|||||||
timestamp: Instant::now(),
|
timestamp: Instant::now(),
|
||||||
});
|
});
|
||||||
assert_eq!(debouncer_h.len(), 1);
|
assert_eq!(debouncer_h.len(), 1);
|
||||||
|
|
||||||
debouncer_h.add_event(ProcessedEvent {
|
debouncer_h.add_event(ProcessedEvent {
|
||||||
path: p_dir.clone(),
|
path: p_dir.clone(),
|
||||||
kind: EventKind::Remove(RemoveKind::Folder),
|
kind: EventKind::Remove(RemoveKind::Folder),
|
||||||
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();
|
||||||
assert_eq!(flushed.len(), 1);
|
assert_eq!(flushed.len(), 1);
|
||||||
@@ -261,20 +267,35 @@ mod event_debouncer_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn debouncer_priority_sorting_on_flush() {
|
fn debouncer_priority_sorting_on_flush() {
|
||||||
let mut debouncer = EventDebouncer::new(100);
|
let mut debouncer = EventDebouncer::new(100);
|
||||||
let path1 = PathBuf::from("file1.txt");
|
let path1 = PathBuf::from("file1.txt");
|
||||||
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 {
|
||||||
|
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));
|
std::thread::sleep(Duration::from_millis(110));
|
||||||
let flushed = debouncer.flush();
|
let flushed = debouncer.flush();
|
||||||
assert_eq!(flushed.len(), 3);
|
assert_eq!(flushed.len(), 3);
|
||||||
assert_eq!(flushed[0].priority, EventPriority::Create);
|
assert_eq!(flushed[0].priority, EventPriority::Create);
|
||||||
assert_eq!(flushed[1].priority, EventPriority::Delete);
|
assert_eq!(flushed[1].priority, EventPriority::Delete);
|
||||||
assert_eq!(flushed[2].priority, EventPriority::Modify);
|
assert_eq!(flushed[2].priority, EventPriority::Modify);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -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,
|
||||||
@@ -359,7 +379,7 @@ impl FileWatcher {
|
|||||||
let events_processed_clone = events_processed.clone();
|
let events_processed_clone = events_processed.clone();
|
||||||
let queue_size_clone = queue_size.clone();
|
let queue_size_clone = queue_size.clone();
|
||||||
let state_clone = state.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_shared_for_thread = Arc::new(Mutex::new(None::<Arc<Mutex<Database>>>));
|
||||||
let db_captured_for_thread = db_shared_for_thread.clone();
|
let db_captured_for_thread = db_shared_for_thread.clone();
|
||||||
@@ -367,7 +387,7 @@ impl FileWatcher {
|
|||||||
let processor_thread = thread::spawn(move || {
|
let processor_thread = thread::spawn(move || {
|
||||||
let mut debouncer = EventDebouncer::new(config_clone.debounce_ms);
|
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() {
|
let current_state = match state_clone.lock() {
|
||||||
Ok(g) => g.clone(),
|
Ok(g) => g.clone(),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -380,13 +400,15 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut received_in_batch = 0;
|
let mut received_in_batch = 0;
|
||||||
while let Ok(evt_res) = receiver_clone.try_recv() {
|
while let Ok(evt_res) = receiver_clone.try_recv() {
|
||||||
received_in_batch +=1;
|
received_in_batch += 1;
|
||||||
match evt_res {
|
match evt_res {
|
||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
for path in event.paths {
|
for path in event.paths {
|
||||||
@@ -431,7 +453,7 @@ impl FileWatcher {
|
|||||||
if let Some(db_mutex) = &*db_guard_option {
|
if let Some(db_mutex) = &*db_guard_option {
|
||||||
if let Ok(mut _db_instance_guard) = db_mutex.lock() {
|
if let Ok(mut _db_instance_guard) = db_mutex.lock() {
|
||||||
for event_item in &evts_to_process {
|
for event_item in &evts_to_process {
|
||||||
info!(
|
info!(
|
||||||
"Processing event (DB available): {:?} for path {:?}",
|
"Processing event (DB available): {:?} for path {:?}",
|
||||||
event_item.kind, event_item.path
|
event_item.kind, event_item.path
|
||||||
);
|
);
|
||||||
@@ -441,7 +463,7 @@ impl FileWatcher {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for event_item in &evts_to_process {
|
for event_item in &evts_to_process {
|
||||||
info!(
|
info!(
|
||||||
"Processing event (no DB): {:?} for path {:?}",
|
"Processing event (no DB): {:?} for path {:?}",
|
||||||
event_item.kind, event_item.path
|
event_item.kind, event_item.path
|
||||||
);
|
);
|
||||||
@@ -504,12 +526,18 @@ impl FileWatcher {
|
|||||||
return Err(anyhow::anyhow!("Watcher thread not available to start."));
|
return Err(anyhow::anyhow!("Watcher thread not available to start."));
|
||||||
}
|
}
|
||||||
if *state_guard == WatcherState::Initializing {
|
if *state_guard == WatcherState::Initializing {
|
||||||
*state_guard = WatcherState::Watching;
|
*state_guard = WatcherState::Watching;
|
||||||
}
|
}
|
||||||
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;
|
||||||
@@ -526,8 +554,11 @@ impl FileWatcher {
|
|||||||
*state_guard = WatcherState::Paused;
|
*state_guard = WatcherState::Paused;
|
||||||
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
|
||||||
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,8 +572,11 @@ impl FileWatcher {
|
|||||||
*state_guard = WatcherState::Watching;
|
*state_guard = WatcherState::Watching;
|
||||||
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;
|
||||||
@@ -567,7 +603,7 @@ impl FileWatcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut final_state_guard = self
|
let mut final_state_guard = self
|
||||||
.state
|
.state
|
||||||
.lock()
|
.lock()
|
||||||
@@ -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);
|
||||||
|
|
||||||
@@ -630,7 +666,7 @@ mod file_watcher_state_tests {
|
|||||||
|
|
||||||
watcher.resume().expect("Resume failed");
|
watcher.resume().expect("Resume failed");
|
||||||
assert_eq!(watcher.status().unwrap().state, WatcherState::Watching);
|
assert_eq!(watcher.status().unwrap().state, WatcherState::Watching);
|
||||||
|
|
||||||
watcher.resume().expect("Second resume failed");
|
watcher.resume().expect("Second resume failed");
|
||||||
assert_eq!(watcher.status().unwrap().state, WatcherState::Watching);
|
assert_eq!(watcher.status().unwrap().state, WatcherState::Watching);
|
||||||
|
|
||||||
@@ -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 || {
|
||||||
|
@@ -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()
|
||||||
assert!(backups_actual_dir.join(&backup_info.id).exists(), "Backup file should exist");
|
.expect("Failed to create first backup");
|
||||||
assert!(backup_info.size_bytes > 0, "Backup size should be greater than 0");
|
|
||||||
|
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 {
|
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(),
|
||||||
let remaining_backups = backup_manager.list_backups().expect("Failed to list backups after prune");
|
2,
|
||||||
assert_eq!(remaining_backups.len(), 2, "Should have 2 backups remaining after prune");
|
"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 {
|
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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user