This commit is contained in:
thePR0M3TH3AN
2025-05-18 16:02:48 -04:00
parent 6157ac5233
commit f6fca2c0dd
44 changed files with 492 additions and 508 deletions

127
cli-bin/src/cli.rs Normal file
View File

@@ -0,0 +1,127 @@
// src/cli.rs
pub mod link;
pub mod coll;
pub mod view;
pub mod state;
pub mod task;
pub mod remind;
pub mod annotate;
pub mod version;
pub mod event;
use clap::{Parser, Subcommand, ValueEnum};
use clap_complete::Shell;
/// Output format for commands.
#[derive(ValueEnum, Clone, Copy, Debug)]
pub enum Format {
Text,
Json,
}
/// Marlin metadata-driven file explorer (CLI utilities)
#[derive(Parser, Debug)]
#[command(author, version, about, propagate_version = true)]
pub struct Cli {
/// Enable debug logging and extra output
#[arg(long)]
pub verbose: bool,
/// Output format (text or JSON)
#[arg(long, default_value = "text", value_enum, global = true)]
pub format: Format,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
/// Initialise the database (idempotent)
Init,
/// Scan one or more directories and populate the file index
Scan {
/// Directories to scan (defaults to cwd)
paths: Vec<std::path::PathBuf>,
},
/// Tag files matching a glob pattern (hierarchical tags use `/`)
Tag {
/// Glob or path pattern
pattern: String,
/// Hierarchical tag name (`foo/bar`)
tag_path: String,
},
/// Manage custom attributes
Attr {
#[command(subcommand)]
action: AttrCmd,
},
/// Full-text search; `--exec CMD` runs CMD on each hit (`{}` placeholder)
Search {
query: String,
#[arg(long)]
exec: Option<String>,
},
/// Create a timestamped backup of the database
Backup,
/// Restore from a backup file (overwrites current DB)
Restore {
backup_path: std::path::PathBuf,
},
/// Generate shell completions (hidden)
#[command(hide = true)]
Completions {
/// Which shell to generate for
#[arg(value_enum)]
shell: Shell,
},
/// File-to-file links
#[command(subcommand)]
Link(link::LinkCmd),
/// Collections (groups) of files
#[command(subcommand)]
Coll(coll::CollCmd),
/// Smart views (saved queries)
#[command(subcommand)]
View(view::ViewCmd),
/// Workflow states on files
#[command(subcommand)]
State(state::StateCmd),
/// TODO/tasks management
#[command(subcommand)]
Task(task::TaskCmd),
/// Reminders on files
#[command(subcommand)]
Remind(remind::RemindCmd),
/// File annotations and highlights
#[command(subcommand)]
Annotate(annotate::AnnotateCmd),
/// Version diffs
#[command(subcommand)]
Version(version::VersionCmd),
/// Calendar events & timelines
#[command(subcommand)]
Event(event::EventCmd),
}
#[derive(Subcommand, Debug)]
pub enum AttrCmd {
Set { pattern: String, key: String, value: String },
Ls { path: std::path::PathBuf },
}

View File

@@ -0,0 +1,28 @@
// src/cli/annotate.rs
use clap::{Subcommand, Args};
use rusqlite::Connection;
use crate::cli::Format;
#[derive(Subcommand, Debug)]
pub enum AnnotateCmd {
Add (ArgsAdd),
List(ArgsList),
}
#[derive(Args, Debug)]
pub struct ArgsAdd {
pub file: String,
pub note: String,
#[arg(long)] pub range: Option<String>,
#[arg(long)] pub highlight: bool,
}
#[derive(Args, Debug)]
pub struct ArgsList { pub file_pattern: String }
pub fn run(cmd: &AnnotateCmd, _conn: &mut Connection, _format: Format) -> anyhow::Result<()> {
match cmd {
AnnotateCmd::Add(a) => todo!("annotate add {:?}", a),
AnnotateCmd::List(a) => todo!("annotate list {:?}", a),
}
}

106
cli-bin/src/cli/coll.rs Normal file
View File

@@ -0,0 +1,106 @@
//! `marlin coll …` named collections of files (simple “playlists”).
use clap::{Args, Subcommand};
use rusqlite::Connection;
use crate::cli::Format; // local enum for text / json output
use libmarlin::db; // core DB helpers from the library crate
#[derive(Subcommand, Debug)]
pub enum CollCmd {
/// Create an empty collection
Create(CreateArgs),
/// Add files (glob) to a collection
Add(AddArgs),
/// List files inside a collection
List(ListArgs),
}
#[derive(Args, Debug)]
pub struct CreateArgs {
pub name: String,
}
#[derive(Args, Debug)]
pub struct AddArgs {
pub name: String,
pub file_pattern: String,
}
#[derive(Args, Debug)]
pub struct ListArgs {
pub name: String,
}
/// Look-up an existing collection **without** implicitly creating it.
///
/// Returns the collection ID or an error if it doesnt exist.
fn lookup_collection_id(conn: &Connection, name: &str) -> anyhow::Result<i64> {
conn.query_row(
"SELECT id FROM collections WHERE name = ?1",
[name],
|r| r.get(0),
)
.map_err(|_| anyhow::anyhow!("collection not found: {}", name))
}
pub fn run(cmd: &CollCmd, conn: &mut Connection, fmt: Format) -> anyhow::Result<()> {
match cmd {
/* ── coll create ──────────────────────────────────────────── */
CollCmd::Create(a) => {
db::ensure_collection(conn, &a.name)?;
if matches!(fmt, Format::Text) {
println!("Created collection '{}'", a.name);
}
}
/* ── coll add ─────────────────────────────────────────────── */
CollCmd::Add(a) => {
// Fail if the target collection does not yet exist
let coll_id = lookup_collection_id(conn, &a.name)?;
let like = a.file_pattern.replace('*', "%");
let mut stmt = conn.prepare("SELECT id FROM files WHERE path LIKE ?1")?;
let ids: Vec<i64> = stmt
.query_map([&like], |r| r.get::<_, i64>(0))?
.collect::<Result<_, _>>()?;
for fid in &ids {
db::add_file_to_collection(conn, coll_id, *fid)?;
}
match fmt {
Format::Text => println!("Added {} file(s) → '{}'", ids.len(), a.name),
Format::Json => {
#[cfg(feature = "json")]
{
println!(
"{{\"collection\":\"{}\",\"added\":{}}}",
a.name,
ids.len()
);
}
}
}
}
/* ── coll list ────────────────────────────────────────────── */
CollCmd::List(a) => {
let files = db::list_collection(conn, &a.name)?;
match fmt {
Format::Text => {
for f in files {
println!("{f}");
}
}
Format::Json => {
#[cfg(feature = "json")]
{
println!("{}", serde_json::to_string(&files)?);
}
}
}
}
}
Ok(())
}

View File

@@ -0,0 +1,81 @@
# cli/commands.yaml
# Philosophy: one canonical spec stops drift between docs & code.
link:
description: "Manage typed relationships between files"
actions:
add:
args: [from, to]
flags: ["--type"]
rm:
args: [from, to]
flags: ["--type"]
list:
args: [pattern]
flags: ["--direction", "--type"]
backlinks:
args: [pattern]
coll:
description: "Manage named collections of files"
actions:
create:
args: [name]
add:
args: [name, file_pattern]
list:
args: [name]
view:
description: "Save and use smart views (saved queries)"
actions:
save:
args: [view_name, query]
list: {}
exec:
args: [view_name]
state:
description: "Track workflow states on files"
actions:
set:
args: [file_pattern, new_state]
transitions-add:
args: [from_state, to_state]
log:
args: [file_pattern]
task:
description: "Extract TODOs and manage tasks"
actions:
scan:
args: [directory]
list:
flags: ["--due-today"]
remind:
description: "Attach reminders to files"
actions:
set:
args: [file_pattern, timestamp, message]
annotate:
description: "Add notes or highlights to files"
actions:
add:
args: [file, note]
flags: ["--range", "--highlight"]
list:
args: [file_pattern]
version:
description: "Versioning and diffs"
actions:
diff:
args: [file]
event:
description: "Link files to dates/events"
actions:
add:
args: [file, date, description]
timeline: {}

24
cli-bin/src/cli/event.rs Normal file
View File

@@ -0,0 +1,24 @@
// src/cli/event.rs
use clap::{Subcommand, Args};
use rusqlite::Connection;
use crate::cli::Format;
#[derive(Subcommand, Debug)]
pub enum EventCmd {
Add (ArgsAdd),
Timeline,
}
#[derive(Args, Debug)]
pub struct ArgsAdd {
pub file: String,
pub date: String,
pub description: String,
}
pub fn run(cmd: &EventCmd, _conn: &mut Connection, _format: Format) -> anyhow::Result<()> {
match cmd {
EventCmd::Add(a) => todo!("event add {:?}", a),
EventCmd::Timeline => todo!("event timeline"),
}
}

156
cli-bin/src/cli/link.rs Normal file
View File

@@ -0,0 +1,156 @@
//! src/cli/link.rs manage typed relationships between files
use clap::{Subcommand, Args};
use rusqlite::Connection;
use crate::cli::Format; // output selector
use libmarlin::db; // ← switched from `crate::db`
#[derive(Subcommand, Debug)]
pub enum LinkCmd {
Add(LinkArgs),
Rm (LinkArgs),
List(ListArgs),
Backlinks(BacklinksArgs),
}
#[derive(Args, Debug)]
pub struct LinkArgs {
pub from: String,
pub to: String,
#[arg(long)]
pub r#type: Option<String>,
}
#[derive(Args, Debug)]
pub struct ListArgs {
pub pattern: String,
#[arg(long)]
pub direction: Option<String>,
#[arg(long)]
pub r#type: Option<String>,
}
#[derive(Args, Debug)]
pub struct BacklinksArgs {
pub pattern: String,
}
pub fn run(cmd: &LinkCmd, conn: &mut Connection, format: Format) -> anyhow::Result<()> {
match cmd {
LinkCmd::Add(args) => {
let src_id = db::file_id(conn, &args.from)?;
let dst_id = db::file_id(conn, &args.to)?;
db::add_link(conn, src_id, dst_id, args.r#type.as_deref())?;
match format {
Format::Text => {
if let Some(t) = &args.r#type {
println!("Linked '{}' → '{}' [type='{}']", args.from, args.to, t);
} else {
println!("Linked '{}' → '{}'", args.from, args.to);
}
}
Format::Json => {
let typ = args
.r#type
.as_ref()
.map(|s| format!("\"{}\"", s))
.unwrap_or_else(|| "null".into());
println!(
"{{\"from\":\"{}\",\"to\":\"{}\",\"type\":{}}}",
args.from, args.to, typ
);
}
}
}
LinkCmd::Rm(args) => {
let src_id = db::file_id(conn, &args.from)?;
let dst_id = db::file_id(conn, &args.to)?;
db::remove_link(conn, src_id, dst_id, args.r#type.as_deref())?;
match format {
Format::Text => {
if let Some(t) = &args.r#type {
println!("Removed link '{}' → '{}' [type='{}']", args.from, args.to, t);
} else {
println!("Removed link '{}' → '{}'", args.from, args.to);
}
}
Format::Json => {
let typ = args
.r#type
.as_ref()
.map(|s| format!("\"{}\"", s))
.unwrap_or_else(|| "null".into());
println!(
"{{\"from\":\"{}\",\"to\":\"{}\",\"type\":{}}}",
args.from, args.to, typ
);
}
}
}
LinkCmd::List(args) => {
let results = db::list_links(
conn,
&args.pattern,
args.direction.as_deref(),
args.r#type.as_deref(),
)?;
match format {
Format::Json => {
let items: Vec<String> = results
.into_iter()
.map(|(src, dst, t)| {
let typ = t
.as_ref()
.map(|s| format!("\"{}\"", s))
.unwrap_or_else(|| "null".into());
format!(
"{{\"from\":\"{}\",\"to\":\"{}\",\"type\":{}}}",
src, dst, typ
)
})
.collect();
println!("[{}]", items.join(","));
}
Format::Text => {
for (src, dst, t) in results {
if let Some(t) = t {
println!("{}{} [type='{}']", src, dst, t);
} else {
println!("{}{}", src, dst);
}
}
}
}
}
LinkCmd::Backlinks(args) => {
let results = db::find_backlinks(conn, &args.pattern)?;
match format {
Format::Json => {
let items: Vec<String> = results
.into_iter()
.map(|(src, t)| {
let typ = t
.as_ref()
.map(|s| format!("\"{}\"", s))
.unwrap_or_else(|| "null".into());
format!("{{\"from\":\"{}\",\"type\":{}}}", src, typ)
})
.collect();
println!("[{}]", items.join(","));
}
Format::Text => {
for (src, t) in results {
if let Some(t) = t {
println!("{} [type='{}']", src, t);
} else {
println!("{}", src);
}
}
}
}
}
}
Ok(())
}

22
cli-bin/src/cli/remind.rs Normal file
View File

@@ -0,0 +1,22 @@
// src/cli/remind.rs
use clap::{Subcommand, Args};
use rusqlite::Connection;
use crate::cli::Format;
#[derive(Subcommand, Debug)]
pub enum RemindCmd {
Set(ArgsSet),
}
#[derive(Args, Debug)]
pub struct ArgsSet {
pub file_pattern: String,
pub timestamp: String,
pub message: String,
}
pub fn run(cmd: &RemindCmd, _conn: &mut Connection, _format: Format) -> anyhow::Result<()> {
match cmd {
RemindCmd::Set(a) => todo!("remind set {:?}", a),
}
}

26
cli-bin/src/cli/state.rs Normal file
View File

@@ -0,0 +1,26 @@
// src/cli/state.rs
use clap::{Subcommand, Args};
use rusqlite::Connection;
use crate::cli::Format;
#[derive(Subcommand, Debug)]
pub enum StateCmd {
Set(ArgsSet),
TransitionsAdd(ArgsTrans),
Log(ArgsLog),
}
#[derive(Args, Debug)]
pub struct ArgsSet { pub file_pattern: String, pub new_state: String }
#[derive(Args, Debug)]
pub struct ArgsTrans { pub from_state: String, pub to_state: String }
#[derive(Args, Debug)]
pub struct ArgsLog { pub file_pattern: String }
pub fn run(cmd: &StateCmd, _conn: &mut Connection, _format: Format) -> anyhow::Result<()> {
match cmd {
StateCmd::Set(a) => todo!("state set {:?}", a),
StateCmd::TransitionsAdd(a)=> todo!("state transitions-add {:?}", a),
StateCmd::Log(a) => todo!("state log {:?}", a),
}
}

22
cli-bin/src/cli/task.rs Normal file
View File

@@ -0,0 +1,22 @@
// src/cli/task.rs
use clap::{Subcommand, Args};
use rusqlite::Connection;
use crate::cli::Format;
#[derive(Subcommand, Debug)]
pub enum TaskCmd {
Scan(ArgsScan),
List(ArgsList),
}
#[derive(Args, Debug)]
pub struct ArgsScan { pub directory: String }
#[derive(Args, Debug)]
pub struct ArgsList { #[arg(long)] pub due_today: bool }
pub fn run(cmd: &TaskCmd, _conn: &mut Connection, _format: Format) -> anyhow::Result<()> {
match cmd {
TaskCmd::Scan(a) => todo!("task scan {:?}", a),
TaskCmd::List(a) => todo!("task list {:?}", a),
}
}

View File

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

169
cli-bin/src/cli/view.rs Normal file
View File

@@ -0,0 +1,169 @@
//! `marlin view …` save & use “smart folders” (named queries).
use std::fs;
use anyhow::Result;
use clap::{Args, Subcommand};
use rusqlite::Connection;
use crate::cli::Format; // output selector stays local
use libmarlin::db; // ← path switched from `crate::db`
#[derive(Subcommand, Debug)]
pub enum ViewCmd {
/// Save (or update) a view
Save(ArgsSave),
/// List all saved views
List,
/// Execute a view (print matching paths)
Exec(ArgsExec),
}
#[derive(Args, Debug)]
pub struct ArgsSave {
pub view_name: String,
pub query: String,
}
#[derive(Args, Debug)]
pub struct ArgsExec {
pub view_name: String,
}
pub fn run(cmd: &ViewCmd, conn: &mut Connection, fmt: Format) -> anyhow::Result<()> {
match cmd {
/* ── view save ───────────────────────────────────────────── */
ViewCmd::Save(a) => {
db::save_view(conn, &a.view_name, &a.query)?;
if matches!(fmt, Format::Text) {
println!("Saved view '{}' = {}", a.view_name, a.query);
}
}
/* ── view list ───────────────────────────────────────────── */
ViewCmd::List => {
let views = db::list_views(conn)?;
match fmt {
Format::Text => {
for (name, q) in views {
println!("{name}: {q}");
}
}
Format::Json => {
#[cfg(feature = "json")]
{
println!("{}", serde_json::to_string(&views)?);
}
}
}
}
/* ── view exec ───────────────────────────────────────────── */
ViewCmd::Exec(a) => {
let raw = db::view_query(conn, &a.view_name)?;
// Re-use the tiny parser from marlin search
let fts_expr = build_fts_match(&raw);
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 paths: Vec<String> = stmt
.query_map([fts_expr], |r| r.get::<_, String>(0))?
.collect::<Result<_, _>>()?;
/* ── NEW: graceful fallback when FTS finds nothing ───── */
if paths.is_empty() && !raw.contains(':') {
paths = naive_search(conn, &raw)?;
}
if paths.is_empty() && matches!(fmt, Format::Text) {
eprintln!("(view '{}' has no matches)", a.view_name);
} else {
for p in paths {
println!("{p}");
}
}
}
}
Ok(())
}
/* ─── naive substring path/content search (≤ 64 kB files) ───────── */
fn naive_search(conn: &Connection, term: &str) -> Result<Vec<String>> {
let term_lc = term.to_lowercase();
let mut stmt = conn.prepare("SELECT path FROM files")?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
let mut hits = Vec::new();
for p in rows {
let p = p?;
/* path match */
if p.to_lowercase().contains(&term_lc) {
hits.push(p);
continue;
}
/* small-file content match */
if let Ok(meta) = fs::metadata(&p) {
if meta.len() > 64_000 {
continue;
}
}
if let Ok(content) = fs::read_to_string(&p) {
if content.to_lowercase().contains(&term_lc) {
hits.push(p);
}
}
}
Ok(hits)
}
/* ─── minimal copy of search-string → FTS5 translator ───────────── */
fn build_fts_match(raw_query: &str) -> String {
use shlex;
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(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(key)));
parts.push("AND".into());
parts.push(format!("attrs_text:{}", escape(val)));
} else {
parts.push(format!("attrs_text:{}", escape(key)));
}
} else {
parts.push(escape(&tok));
}
}
parts.join(" ")
}
fn escape(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()
}
}

395
cli-bin/src/main.rs Normal file
View File

@@ -0,0 +1,395 @@
//! 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::{
config,
db,
logging,
scan,
utils::determine_scan_root,
};
use anyhow::{Context, Result};
use clap::{CommandFactory, Parser};
use clap_complete::generate;
use glob::Pattern;
use shellexpand;
use shlex;
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 { paths } => {
let scan_paths = if paths.is_empty() {
vec![env::current_dir()?]
} else { paths };
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); // close handle before overwrite
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)?,
}
Ok(())
}
/* ─────────────────── helpers & sub-routines ─────────────────── */
/* ---------- TAGS ---------- */
fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag_path: &str) -> Result<()> {
// ensure_tag_path returns ID of deepest node
let leaf_tag_id = db::ensure_tag_path(conn, tag_path)?;
// collect leaf + ancestors
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<()> {
/* ── build FTS expression -------------------------------- */
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}");
/* ── primary FTS query ---------------------------------- */
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();
/* ── graceful fallback (substring scan) ----------------- */
if hits.is_empty() && !raw_query.contains(':') {
hits = naive_substring_search(conn, raw_query)?;
}
/* ── output / exec -------------------------------------- */
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(())
}
/// Fallback: case-insensitive substring scan over paths *and* small file bodies.
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;
}
// Only scan files ≤ 64 kB
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)
}
/// Run external command template on every hit (`{}` placeholder supported).
fn run_exec(paths: &[String], cmd_tpl: &str) -> Result<()> {
let mut ran_without_placeholder = false;
// optimisation: if no hits and no placeholder, run once
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(())
}
/* ---------- misc helpers ---------- */
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() }
}