This commit is contained in:
thePR0M3TH3AN
2025-05-14 17:51:17 -04:00
parent 9d3e0ffef7
commit fe8aa18803
14 changed files with 646 additions and 328 deletions

View File

@@ -1,3 +1,4 @@
// src/main.rs
mod cli;
mod config;
mod db;
@@ -6,7 +7,7 @@ mod scan;
use anyhow::Result;
use clap::Parser;
use cli::{Cli, Commands};
use cli::{AttrCmd, Cli, Commands};
use glob::glob;
use rusqlite::params;
use tracing::{error, info};
@@ -16,6 +17,13 @@ fn main() -> Result<()> {
let args = Cli::parse();
let cfg = config::Config::load()?;
// snapshot unless doing an explicit backup / restore
if !matches!(args.command, Commands::Backup | Commands::Restore { .. }) {
let _ = db::backup(&cfg.db_path);
}
// open database (runs migrations / dynamic column adds)
let mut conn = db::open(&cfg.db_path)?;
match args.command {
@@ -27,22 +35,41 @@ fn main() -> Result<()> {
if paths.is_empty() {
anyhow::bail!("At least one directory must be supplied to `scan`");
}
for path in paths {
scan::scan_directory(&mut conn, &path)?;
for p in paths {
scan::scan_directory(&mut conn, &p)?;
}
}
Commands::Tag { pattern, tag } => {
apply_tag(&conn, &pattern, &tag)?;
Commands::Tag { pattern, tag_path } => apply_tag(&conn, &pattern, &tag_path)?,
Commands::Attr { action } => match action {
// borrow the Strings so attr_set gets &str
AttrCmd::Set { pattern, key, value } => {
attr_set(&conn, &pattern, &key, &value)?
}
AttrCmd::Ls { path } => attr_ls(&conn, &path)?,
},
Commands::Search { query, exec } => run_search(&conn, &query, exec)?,
Commands::Backup => {
let path = db::backup(&cfg.db_path)?;
println!("Backup created: {}", path.display());
}
Commands::Restore { backup_path } => {
drop(conn); // close handle
db::restore(&backup_path, &cfg.db_path)?;
println!("Restored from {}", backup_path.display());
}
}
Ok(())
}
/// Apply `tag` to every file that matches `pattern`.
fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag: &str) -> Result<()> {
let tag_id = db::ensure_tag(conn, tag)?;
/* ─── tagging ────────────────────────────────────────────────────────── */
fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag_path: &str) -> Result<()> {
let tag_id = db::ensure_tag_path(conn, tag_path)?;
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)")?;
@@ -55,7 +82,7 @@ fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag: &str) -> Result<()
stmt_file.query_row(params![path_str], |row| row.get::<_, i64>(0))
{
stmt_insert.execute(params![file_id, tag_id])?;
info!(file = %path_str, tag = tag, "tagged");
info!(file = %path_str, tag = tag_path, "tagged");
} else {
error!(file = %path_str, "file not in index run `marlin scan` first");
}
@@ -65,3 +92,84 @@ fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag: &str) -> Result<()
}
Ok(())
}
/* ─── attributes ─────────────────────────────────────────────────────── */
fn attr_set(conn: &rusqlite::Connection, pattern: &str, key: &str, value: &str) -> Result<()> {
for entry in glob(pattern)? {
match entry {
Ok(path) => {
let path_str = path.to_string_lossy();
let file_id = db::file_id(conn, &path_str)?;
db::upsert_attr(conn, file_id, key, value)?;
info!(file = %path_str, key = key, value = value, "attr set");
}
Err(e) => error!(error = %e, "glob error"),
}
}
Ok(())
}
fn attr_ls(conn: &rusqlite::Connection, path: &std::path::Path) -> Result<()> {
let file_id = db::file_id(conn, &path.to_string_lossy())?;
let mut stmt = conn.prepare("SELECT key, value FROM attributes WHERE file_id = ?1")?;
let rows = stmt.query_map([file_id], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))?;
for row in rows {
let (k, v) = row?;
println!("{k} = {v}");
}
Ok(())
}
/* ─── search helpers ─────────────────────────────────────────────────── */
fn run_search(conn: &rusqlite::Connection, raw: &str, exec: Option<String>) -> Result<()> {
let hits = search(conn, raw)?;
if hits.is_empty() && exec.is_none() {
eprintln!("No matches for `{}`", raw);
return Ok(());
}
if let Some(cmd_tpl) = exec {
for path in hits {
let cmd_final = if cmd_tpl.contains("{}") {
cmd_tpl.replace("{}", &path)
} else {
format!("{cmd_tpl} \"{path}\"")
};
let mut parts = cmd_final.splitn(2, ' ');
let prog = parts.next().unwrap();
let args = parts.next().unwrap_or("");
let status = std::process::Command::new(prog)
.args(shlex::split(args).unwrap_or_default())
.status()?;
if !status.success() {
error!(file = %path, "command failed");
}
}
} else {
for p in hits {
println!("{p}");
}
}
Ok(())
}
fn search(conn: &rusqlite::Connection, raw: &str) -> Result<Vec<String>> {
let q = if raw.split_ascii_whitespace().count() == 1
&& !raw.contains(&['"', '\'', ':', '*', '(', ')', '~', '+', '-'][..])
{
format!("{raw}*")
} else {
raw.to_string()
};
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
"#,
)?;
let rows = stmt.query_map([&q], |row| row.get::<_, String>(0))?;
Ok(rows.filter_map(Result::ok).collect())
}