Files
Marlin/cli-bin/src/main.rs
2025-05-21 13:10:25 -04:00

578 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<std::path::PathBuf> = 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<i64>>(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<String>) -> 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<String> = 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<Vec<String>> {
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("{}", &quoted)
} 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\"");
}
}