//! Marlin CLI entry-point (post crate-split) //! //! All heavy lifting now lives in the `libmarlin` crate; this file //! handles argument parsing, logging, orchestration and the few //! helpers that remain CLI-specific. #![deny(warnings)] mod cli; // sub-command definitions and argument structs /* ── shared modules re-exported from libmarlin ─────────────────── */ use libmarlin::db::take_dirty; use libmarlin::{config, db, logging, scan, utils::determine_scan_root}; use anyhow::{Context, Result}; use clap::{CommandFactory, Parser}; use clap_complete::generate; use glob::Pattern; use std::{env, fs, io, path::Path, process::Command}; use tracing::{debug, error, info}; use walkdir::WalkDir; use cli::{Cli, Commands}; fn main() -> Result<()> { /* ── CLI parsing & logging ────────────────────────────────── */ let args = Cli::parse(); if args.verbose { env::set_var("RUST_LOG", "debug"); } logging::init(); /* ── shell-completion shortcut ────────────────────────────── */ if let Commands::Completions { shell } = &args.command { let mut cmd = Cli::command(); generate(*shell, &mut cmd, "marlin", &mut io::stdout()); return Ok(()); } /* ── config & automatic backup ───────────────────────────── */ let cfg = config::Config::load()?; // resolves DB path match &args.command { Commands::Init | Commands::Backup | Commands::Restore { .. } => {} _ => match db::backup(&cfg.db_path) { Ok(p) => info!("Pre-command auto-backup created at {}", p.display()), Err(e) => error!("Failed to create pre-command auto-backup: {e}"), }, } /* ── open DB (runs migrations) ───────────────────────────── */ let mut conn = db::open(&cfg.db_path)?; /* ── command dispatch ────────────────────────────────────── */ match args.command { Commands::Completions { .. } => {} // handled above /* ---- init ------------------------------------------------ */ Commands::Init => { info!("Database initialised at {}", cfg.db_path.display()); let cwd = env::current_dir().context("getting current directory")?; let count = scan::scan_directory(&mut conn, &cwd).context("initial scan failed")?; info!("Initial scan complete – indexed/updated {count} files"); } /* ---- scan ------------------------------------------------ */ Commands::Scan { dirty, paths } => { let scan_paths: Vec = if paths.is_empty() { vec![env::current_dir()?] } else { paths.into_iter().collect() }; if dirty { let dirty_ids = take_dirty(&conn)?; for id in dirty_ids { let path: String = conn.query_row("SELECT path FROM files WHERE id = ?1", [id], |r| r.get(0))?; scan::scan_directory(&mut conn, Path::new(&path))?; } } else { for p in scan_paths { scan::scan_directory(&mut conn, &p)?; } } } /* ---- tag / attribute / search --------------------------- */ Commands::Tag { pattern, tag_path } => apply_tag(&conn, &pattern, &tag_path)?, Commands::Attr { action } => match action { cli::AttrCmd::Set { pattern, key, value, } => attr_set(&conn, &pattern, &key, &value)?, cli::AttrCmd::Ls { path } => attr_ls(&conn, &path)?, }, Commands::Search { query, exec } => run_search(&conn, &query, exec)?, /* ---- maintenance ---------------------------------------- */ Commands::Backup => { let p = db::backup(&cfg.db_path)?; println!("Backup created: {}", p.display()); } Commands::Restore { backup_path } => { drop(conn); db::restore(&backup_path, &cfg.db_path) .with_context(|| format!("Failed to restore DB from {}", backup_path.display()))?; println!("Restored DB from {}", backup_path.display()); db::open(&cfg.db_path).with_context(|| { format!("Could not open restored DB at {}", cfg.db_path.display()) })?; info!("Successfully opened restored database."); } /* ---- passthrough sub-modules (some still stubs) ---------- */ 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::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::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::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::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)?, } Ok(()) } /* ─────────────────── helpers & sub-routines ─────────────────── */ /* ---------- TAGS ---------- */ fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag_path: &str) -> Result<()> { let leaf_tag_id = db::ensure_tag_path(conn, tag_path)?; let mut tag_ids = Vec::new(); let mut current = Some(leaf_tag_id); while let Some(id) = current { tag_ids.push(id); current = conn.query_row("SELECT parent_id FROM tags WHERE id=?1", [id], |r| { r.get::<_, Option>(0) })?; } let expanded = shellexpand::tilde(pattern).into_owned(); let pat = Pattern::new(&expanded).with_context(|| format!("Invalid glob pattern `{expanded}`"))?; let root = determine_scan_root(&expanded); let mut stmt_file = conn.prepare("SELECT id FROM files WHERE path=?1")?; let mut stmt_insert = conn.prepare("INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?1, ?2)")?; let mut count = 0usize; for entry in WalkDir::new(&root) .into_iter() .filter_map(Result::ok) .filter(|e| e.file_type().is_file()) { let p = entry.path().to_string_lossy(); if !pat.matches(&p) { continue; } match stmt_file.query_row([p.as_ref()], |r| r.get::<_, i64>(0)) { Ok(fid) => { let mut newly = false; for &tid in &tag_ids { if stmt_insert.execute([fid, tid])? > 0 { newly = true; } } if newly { info!(file=%p, tag=tag_path, "tagged"); count += 1; } } Err(rusqlite::Error::QueryReturnedNoRows) => { error!(file=%p, "not indexed – run `marlin scan` first") } Err(e) => error!(file=%p, error=%e, "could not lookup file ID"), } } info!("Applied tag '{}' to {} file(s).", tag_path, count); Ok(()) } /* ---------- ATTRIBUTES ---------- */ fn attr_set(conn: &rusqlite::Connection, pattern: &str, key: &str, value: &str) -> Result<()> { let expanded = shellexpand::tilde(pattern).into_owned(); let pat = Pattern::new(&expanded).with_context(|| format!("Invalid glob pattern `{expanded}`"))?; let root = determine_scan_root(&expanded); let mut stmt_file = conn.prepare("SELECT id FROM files WHERE path=?1")?; let mut count = 0usize; for entry in WalkDir::new(&root) .into_iter() .filter_map(Result::ok) .filter(|e| e.file_type().is_file()) { let p = entry.path().to_string_lossy(); if !pat.matches(&p) { continue; } match stmt_file.query_row([p.as_ref()], |r| r.get::<_, i64>(0)) { Ok(fid) => { db::upsert_attr(conn, fid, key, value)?; info!(file=%p, key, value, "attr set"); count += 1; } Err(rusqlite::Error::QueryReturnedNoRows) => { error!(file=%p, "not indexed – run `marlin scan` first") } Err(e) => error!(file=%p, error=%e, "could not lookup file ID"), } } info!("Attribute '{}={}' set on {} file(s).", key, value, count); Ok(()) } fn attr_ls(conn: &rusqlite::Connection, path: &Path) -> Result<()> { let fid = db::file_id(conn, &path.to_string_lossy())?; let mut stmt = conn.prepare("SELECT key, value FROM attributes WHERE file_id=?1 ORDER BY key")?; for row in stmt.query_map([fid], |r| { Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)) })? { let (k, v) = row?; println!("{k} = {v}"); } Ok(()) } /* ---------- SEARCH ---------- */ fn run_search(conn: &rusqlite::Connection, raw_query: &str, exec: Option) -> Result<()> { let mut parts = Vec::new(); let toks = shlex::split(raw_query).unwrap_or_else(|| vec![raw_query.to_string()]); for tok in toks { if ["AND", "OR", "NOT"].contains(&tok.as_str()) { parts.push(tok); } else if let Some(tag) = tok.strip_prefix("tag:") { for (i, seg) in tag.split('/').filter(|s| !s.is_empty()).enumerate() { if i > 0 { parts.push("AND".into()); } parts.push(format!("tags_text:{}", escape_fts(seg))); } } else if let Some(attr) = tok.strip_prefix("attr:") { let mut kv = attr.splitn(2, '='); let key = kv.next().unwrap(); if let Some(val) = kv.next() { parts.push(format!("attrs_text:{}", escape_fts(key))); parts.push("AND".into()); parts.push(format!("attrs_text:{}", escape_fts(val))); } else { parts.push(format!("attrs_text:{}", escape_fts(key))); } } else { parts.push(escape_fts(&tok)); } } let fts_expr = parts.join(" "); debug!("FTS MATCH expression: {fts_expr}"); let mut stmt = conn.prepare( r#" 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: Vec = stmt .query_map([&fts_expr], |r| r.get::<_, String>(0))? .filter_map(Result::ok) .collect(); if hits.is_empty() && !raw_query.contains(':') { hits = naive_substring_search(conn, raw_query)?; } if let Some(cmd_tpl) = exec { run_exec(&hits, &cmd_tpl)?; } else if hits.is_empty() { eprintln!("No matches for query: `{raw_query}` (FTS expr: `{fts_expr}`)"); } else { for p in hits { println!("{p}"); } } Ok(()) } fn naive_substring_search(conn: &rusqlite::Connection, term: &str) -> Result> { let needle = term.to_lowercase(); let mut stmt = conn.prepare("SELECT path FROM files")?; let rows = stmt.query_map([], |r| r.get::<_, String>(0))?; let mut out = Vec::new(); for p in rows { let p = p?; if p.to_lowercase().contains(&needle) { out.push(p.clone()); continue; } if let Ok(meta) = fs::metadata(&p) { if meta.len() > 65_536 { continue; } } if let Ok(body) = fs::read_to_string(&p) { if body.to_lowercase().contains(&needle) { out.push(p); } } } Ok(out) } fn run_exec(paths: &[String], cmd_tpl: &str) -> Result<()> { let mut ran_without_placeholder = false; if paths.is_empty() && !cmd_tpl.contains("{}") { if let Some(mut parts) = shlex::split(cmd_tpl) { if !parts.is_empty() { let prog = parts.remove(0); let status = Command::new(&prog).args(parts).status()?; if !status.success() { error!(command=%cmd_tpl, code=?status.code(), "command failed"); } } } ran_without_placeholder = true; } if !ran_without_placeholder { for p in paths { let quoted = shlex::try_quote(p).unwrap_or_else(|_| p.into()); let final_cmd = if cmd_tpl.contains("{}") { cmd_tpl.replace("{}", "ed) } else { format!("{cmd_tpl} {quoted}") }; if let Some(mut parts) = shlex::split(&final_cmd) { if parts.is_empty() { continue; } let prog = parts.remove(0); let status = Command::new(&prog).args(parts).status()?; if !status.success() { error!(file=%p, command=%final_cmd, code=?status.code(), "command failed"); } } } } Ok(()) } fn escape_fts(term: &str) -> String { if term.contains(|c: char| c.is_whitespace() || "-:()\"".contains(c)) || ["AND", "OR", "NOT", "NEAR"].contains(&term.to_uppercase().as_str()) { format!("\"{}\"", term.replace('"', "\"\"")) } else { term.to_string() } } #[cfg(test)] mod tests { use super::{apply_tag, attr_set, escape_fts, naive_substring_search, run_exec}; use assert_cmd::Command; use tempfile::tempdir; #[test] fn test_help_command() { let mut cmd = Command::cargo_bin("marlin").unwrap(); cmd.arg("--help"); cmd.assert() .success() .stdout(predicates::str::contains("Usage: marlin")); } #[test] fn test_version_command() { let mut cmd = Command::cargo_bin("marlin").unwrap(); cmd.arg("--version"); cmd.assert() .success() .stdout(predicates::str::contains("marlin-cli 0.1.0")); } #[test] fn test_verbose_logging() { let tmp = tempdir().unwrap(); let mut cmd = Command::cargo_bin("marlin").unwrap(); cmd.env("MARLIN_DB_PATH", tmp.path().join("index.db")); cmd.arg("--verbose").arg("init"); let output = cmd.output().unwrap(); assert!(output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( stderr.contains("DEBUG"), "Expected debug logs in stderr, got: {}", stderr ); } #[test] fn test_shell_completions() { let mut cmd = Command::cargo_bin("marlin").unwrap(); cmd.arg("completions").arg("bash"); cmd.assert() .success() .stdout(predicates::str::contains("_marlin()")) .stdout(predicates::str::contains("init")) .stdout(predicates::str::contains("scan")); } #[test] fn test_invalid_subcommand() { let mut cmd = Command::cargo_bin("marlin").unwrap(); cmd.arg("invalid_cmd"); cmd.assert() .failure() .stderr(predicates::str::contains("error: unrecognized subcommand")); } #[test] fn test_init_command() { let tmp = tempdir().unwrap(); let db_path = tmp.path().join("index.db"); let mut cmd = Command::cargo_bin("marlin").unwrap(); cmd.env("MARLIN_DB_PATH", &db_path); cmd.arg("init"); cmd.assert().success(); assert!(db_path.exists(), "Database file should exist after init"); } #[test] fn test_automatic_backup() { let tmp = tempdir().unwrap(); let db_path = tmp.path().join("index.db"); let backups_dir = tmp.path().join("backups"); // Init: no backup let mut cmd_init = Command::cargo_bin("marlin").unwrap(); cmd_init.env("MARLIN_DB_PATH", &db_path); cmd_init.arg("init"); cmd_init.assert().success(); assert!( !backups_dir.exists() || backups_dir.read_dir().unwrap().next().is_none(), "No backup should be created for init" ); // Scan: backup created let mut cmd_scan = Command::cargo_bin("marlin").unwrap(); cmd_scan.env("MARLIN_DB_PATH", &db_path); cmd_scan.arg("scan"); cmd_scan.assert().success(); assert!( backups_dir.exists(), "Backups directory should exist after scan" ); let backups: Vec<_> = backups_dir.read_dir().unwrap().collect(); assert_eq!(backups.len(), 1, "One backup should be created for scan"); } #[test] fn test_annotate_stub() { let tmp = tempdir().unwrap(); let mut cmd = Command::cargo_bin("marlin").unwrap(); cmd.env("MARLIN_DB_PATH", tmp.path().join("index.db")); cmd.arg("annotate").arg("add").arg("file.txt").arg("note"); cmd.assert() .failure() .stderr(predicates::str::contains("not yet implemented")); } #[test] fn test_event_stub() { let tmp = tempdir().unwrap(); let mut cmd = Command::cargo_bin("marlin").unwrap(); 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.assert() .failure() .stderr(predicates::str::contains("not yet implemented")); } fn open_mem() -> rusqlite::Connection { libmarlin::db::open(":memory:").expect("open in-memory DB") } #[test] fn test_tagging_and_attributes_update_db() { use libmarlin::scan::scan_directory; use std::fs::File; let tmp = tempdir().unwrap(); let file_path = tmp.path().join("a.txt"); File::create(&file_path).unwrap(); let mut conn = open_mem(); scan_directory(&mut conn, tmp.path()).unwrap(); apply_tag(&conn, file_path.to_str().unwrap(), "foo/bar").unwrap(); attr_set(&conn, file_path.to_str().unwrap(), "k", "v").unwrap(); let tag: String = conn .query_row( "SELECT t.name FROM file_tags ft JOIN tags t ON t.id=ft.tag_id JOIN files f ON f.id=ft.file_id WHERE f.path=?1 AND t.name='bar'", [file_path.to_str().unwrap()], |r| r.get(0), ) .unwrap(); assert_eq!(tag, "bar"); let val: String = conn .query_row( "SELECT value FROM attributes a JOIN files f ON f.id=a.file_id WHERE f.path=?1 AND a.key='k'", [file_path.to_str().unwrap()], |r| r.get(0), ) .unwrap(); assert_eq!(val, "v"); } #[test] fn test_naive_search_and_run_exec() { use std::fs; let tmp = tempdir().unwrap(); let f1 = tmp.path().join("hello.txt"); fs::write(&f1, "hello world").unwrap(); let mut conn = open_mem(); libmarlin::scan::scan_directory(&mut conn, tmp.path()).unwrap(); let hits = naive_substring_search(&conn, "world").unwrap(); assert_eq!(hits, vec![f1.to_string_lossy().to_string()]); let log = tmp.path().join("log.txt"); let script = tmp.path().join("log.sh"); fs::write(&script, "#!/bin/sh\necho $1 >> $LOGFILE\n").unwrap(); std::env::set_var("LOGFILE", &log); run_exec( &[f1.to_string_lossy().to_string()], &format!("sh {} {{}}", script.display()), ) .unwrap(); let logged = fs::read_to_string(&log).unwrap(); assert!(logged.contains("hello.txt")); } #[test] fn test_escape_fts_quotes_terms() { assert_eq!(escape_fts("foo"), "foo"); assert_eq!(escape_fts("foo bar"), "\"foo bar\""); assert_eq!(escape_fts("AND"), "\"AND\""); } }