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

33
cli-bin/Cargo.toml Normal file
View File

@@ -0,0 +1,33 @@
[package]
name = "marlin-cli"
version = "0.1.0"
edition = "2021"
publish = false # binary crate, not meant for crates.io
[[bin]]
name = "marlin" # cargo install/run -> `marlin`
path = "src/main.rs"
[dependencies]
libmarlin = { path = "../libmarlin" } # ← core library
anyhow = "1"
clap = { version = "4", features = ["derive"] }
clap_complete = "4.1"
glob = "0.3"
rusqlite = { version = "0.31", features = ["bundled", "backup"] }
shellexpand = "3.1"
shlex = "1.3"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
walkdir = "2.5"
serde_json = { version = "1", optional = true }
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3"
dirs = "5"
[features]
# Enable JSON output with `--features json`
json = ["serde_json"]

11
cli-bin/build.rs Normal file
View File

@@ -0,0 +1,11 @@
// cli-bin/build.rs
//
// The CLI currently needs no build-time code-generation, but Cargo
// insists on rerunning any build-script each compile. Tell it to
// rebuild only if this file itself changes.
fn main() {
// If you later add code-gen (e.g. embed completions or YAML), add
// further `cargo:rerun-if-changed=<path>` lines here.
println!("cargo:rerun-if-changed=build.rs");
}

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() }
}

122
cli-bin/tests/e2e.rs Normal file
View File

@@ -0,0 +1,122 @@
//! tests e2e.rs
//! End-to-end “happy path” smoke-tests for the `marlin` binary.
//!
//! Run with `cargo test --test e2e` (CI does) or `cargo test`.
use assert_cmd::prelude::*;
use predicates::prelude::*;
use std::{fs, path::PathBuf, process::Command};
use tempfile::tempdir;
/// Absolute path to the freshly-built `marlin` binary.
fn marlin_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_marlin"))
}
/// Create the demo directory structure and seed files.
fn spawn_demo_tree(root: &PathBuf) {
fs::create_dir_all(root.join("Projects/Alpha")).unwrap();
fs::create_dir_all(root.join("Projects/Beta")).unwrap();
fs::create_dir_all(root.join("Projects/Gamma")).unwrap();
fs::create_dir_all(root.join("Logs")).unwrap();
fs::create_dir_all(root.join("Reports")).unwrap();
fs::write(root.join("Projects/Alpha/draft1.md"), "- [ ] 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/Gamma/TODO.txt"), "TODO bar\n").unwrap();
fs::write(root.join("Logs/app.log"), "ERROR omg\n").unwrap();
fs::write(root.join("Reports/Q1.pdf"), "PDF\n").unwrap();
}
/// Shorthand for “run and must succeed”.
fn ok(cmd: &mut Command) -> assert_cmd::assert::Assert {
cmd.assert().success()
}
#[test]
fn full_cli_flow() -> Result<(), Box<dyn std::error::Error>> {
/* ── 1 ░ sandbox ───────────────────────────────────────────── */
let tmp = tempdir()?; // wiped on drop
let demo_dir = tmp.path().join("marlin_demo");
spawn_demo_tree(&demo_dir);
let db_path = demo_dir.join("index.db");
// Helper to spawn a fresh `marlin` Command with the DB env-var set
let marlin = || {
let mut c = Command::new(marlin_bin());
c.env("MARLIN_DB_PATH", &db_path);
c
};
/* ── 2 ░ init ( auto-scan cwd ) ───────────────────────────── */
ok(marlin()
.current_dir(&demo_dir)
.arg("init"));
/* ── 3 ░ tag & attr demos ─────────────────────────────────── */
ok(marlin()
.arg("tag")
.arg(format!("{}/Projects/**/*.md", demo_dir.display()))
.arg("project/md"));
ok(marlin()
.arg("attr")
.arg("set")
.arg(format!("{}/Reports/*.pdf", demo_dir.display()))
.arg("reviewed")
.arg("yes"));
/* ── 4 ░ quick search sanity checks ───────────────────────── */
marlin()
.arg("search").arg("TODO")
.assert()
.stdout(predicate::str::contains("TODO.txt"));
marlin()
.arg("search").arg("attr:reviewed=yes")
.assert()
.stdout(predicate::str::contains("Q1.pdf"));
/* ── 5 ░ link flow & backlinks ────────────────────────────── */
let foo = demo_dir.join("foo.txt");
let bar = demo_dir.join("bar.txt");
fs::write(&foo, "")?;
fs::write(&bar, "")?;
ok(marlin().arg("scan").arg(&demo_dir));
ok(marlin()
.arg("link").arg("add")
.arg(&foo).arg(&bar));
marlin()
.arg("link").arg("backlinks").arg(&bar)
.assert()
.stdout(predicate::str::contains("foo.txt"));
/* ── 6 ░ backup → delete DB → restore ────────────────────── */
let backup_path = String::from_utf8(
marlin().arg("backup").output()?.stdout
)?;
let backup_file = backup_path.split_whitespace().last().unwrap();
fs::remove_file(&db_path)?; // simulate corruption
ok(marlin().arg("restore").arg(backup_file)); // restore
// Search must still work afterwards
marlin()
.arg("search").arg("TODO")
.assert()
.stdout(predicate::str::contains("TODO.txt"));
Ok(())
}

82
cli-bin/tests/neg.rs Normal file
View File

@@ -0,0 +1,82 @@
//! tests neg.rs
//! Negative-path integration tests (“should fail / warn”).
use predicates::str;
use tempfile::tempdir;
mod util;
use util::marlin;
/* ───────────────────────── LINKS ─────────────────────────────── */
#[test]
fn link_non_indexed_should_fail() {
let tmp = tempdir().unwrap();
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("bar.txt"), "").unwrap();
marlin(&tmp)
.current_dir(tmp.path())
.args([
"link", "add",
&tmp.path().join("foo.txt").to_string_lossy(),
&tmp.path().join("bar.txt").to_string_lossy()
])
.assert()
.failure()
.stderr(str::contains("file not indexed"));
}
/* ───────────────────────── ATTR ─────────────────────────────── */
#[test]
fn attr_set_on_non_indexed_file_should_warn() {
let tmp = tempdir().unwrap();
marlin(&tmp).current_dir(tmp.path()).arg("init").assert().success();
let ghost = tmp.path().join("ghost.txt");
std::fs::write(&ghost, "").unwrap();
marlin(&tmp)
.args(["attr","set",
&ghost.to_string_lossy(),"foo","bar"])
.assert()
.success() // exits 0
.stderr(str::contains("not indexed"));
}
/* ───────────────────── COLLECTIONS ───────────────────────────── */
#[test]
fn coll_add_unknown_collection_should_fail() {
let tmp = tempdir().unwrap();
let file = tmp.path().join("doc.txt");
std::fs::write(&file, "").unwrap();
marlin(&tmp).current_dir(tmp.path()).arg("init").assert().success();
marlin(&tmp)
.args(["coll","add","nope",&file.to_string_lossy()])
.assert()
.failure();
}
/* ───────────────────── RESTORE (bad file) ───────────────────── */
#[test]
fn restore_with_nonexistent_backup_should_fail() {
let tmp = tempdir().unwrap();
// create an empty DB first
marlin(&tmp).arg("init").assert().success();
marlin(&tmp)
.args(["restore", "/definitely/not/here.db"])
.assert()
.failure()
.stderr(str::contains("Failed to restore"));
}

172
cli-bin/tests/pos.rs Normal file
View File

@@ -0,0 +1,172 @@
//! tests pos.rs
//! Positive-path integration checks for every sub-command
//! that already has real logic behind it.
mod util;
use util::marlin;
use predicates::{prelude::*, str}; // brings `PredicateBooleanExt::and`
use std::fs;
use tempfile::tempdir;
/* ─────────────────────────── TAG ─────────────────────────────── */
#[test]
fn tag_should_add_hierarchical_tag_and_search_finds_it() {
let tmp = tempdir().unwrap();
let file = tmp.path().join("foo.md");
fs::write(&file, "# test\n").unwrap();
marlin(&tmp).current_dir(tmp.path()).arg("init").assert().success();
marlin(&tmp)
.args(["tag", file.to_str().unwrap(), "project/md"])
.assert().success();
marlin(&tmp)
.args(["search", "tag:project/md"])
.assert()
.success()
.stdout(str::contains("foo.md"));
}
/* ─────────────────────────── ATTR ────────────────────────────── */
#[test]
fn attr_set_then_ls_roundtrip() {
let tmp = tempdir().unwrap();
let file = tmp.path().join("report.pdf");
fs::write(&file, "%PDF-1.4\n").unwrap();
marlin(&tmp).current_dir(tmp.path()).arg("init").assert().success();
marlin(&tmp)
.args(["attr", "set", file.to_str().unwrap(), "reviewed", "yes"])
.assert().success();
marlin(&tmp)
.args(["attr", "ls", file.to_str().unwrap()])
.assert()
.success()
.stdout(str::contains("reviewed = yes"));
}
/* ─────────────────────── COLLECTIONS ────────────────────────── */
#[test]
fn coll_create_add_and_list() {
let tmp = tempdir().unwrap();
let a = tmp.path().join("a.txt");
let b = tmp.path().join("b.txt");
fs::write(&a, "").unwrap();
fs::write(&b, "").unwrap();
marlin(&tmp).current_dir(tmp.path()).arg("init").assert().success();
marlin(&tmp).args(["coll", "create", "Set"]).assert().success();
for f in [&a, &b] {
marlin(&tmp).args(["coll", "add", "Set", f.to_str().unwrap()]).assert().success();
}
marlin(&tmp)
.args(["coll", "list", "Set"])
.assert()
.success()
.stdout(str::contains("a.txt").and(str::contains("b.txt")));
}
/* ─────────────────────────── VIEWS ───────────────────────────── */
#[test]
fn view_save_list_and_exec() {
let tmp = tempdir().unwrap();
let todo = tmp.path().join("TODO.txt");
fs::write(&todo, "remember the milk\n").unwrap();
marlin(&tmp).current_dir(tmp.path()).arg("init").assert().success();
// save & list
marlin(&tmp).args(["view", "save", "tasks", "milk"]).assert().success();
marlin(&tmp)
.args(["view", "list"])
.assert()
.success()
.stdout(str::contains("tasks: milk"));
// exec
marlin(&tmp)
.args(["view", "exec", "tasks"])
.assert()
.success()
.stdout(str::contains("TODO.txt"));
}
/* ─────────────────────────── LINKS ───────────────────────────── */
#[test]
fn link_add_rm_and_list() {
let tmp = tempdir().unwrap();
let foo = tmp.path().join("foo.txt");
let bar = tmp.path().join("bar.txt");
fs::write(&foo, "").unwrap();
fs::write(&bar, "").unwrap();
// handy closure
let mc = || marlin(&tmp);
mc().current_dir(tmp.path()).arg("init").assert().success();
mc().args(["scan", tmp.path().to_str().unwrap()]).assert().success();
// add
mc().args(["link", "add", foo.to_str().unwrap(), bar.to_str().unwrap()])
.assert().success();
// list (outgoing default)
mc().args(["link", "list", foo.to_str().unwrap()])
.assert().success()
.stdout(str::contains("foo.txt").and(str::contains("bar.txt")));
// remove
mc().args(["link", "rm", foo.to_str().unwrap(), bar.to_str().unwrap()])
.assert().success();
// list now empty
mc().args(["link", "list", foo.to_str().unwrap()])
.assert().success()
.stdout(str::is_empty());
}
/* ─────────────────────── SCAN (multi-path) ───────────────────── */
#[test]
fn scan_with_multiple_paths_indexes_all() {
let tmp = tempdir().unwrap();
let dir_a = tmp.path().join("A");
let dir_b = tmp.path().join("B");
std::fs::create_dir_all(&dir_a).unwrap();
std::fs::create_dir_all(&dir_b).unwrap();
let f1 = dir_a.join("one.txt");
let f2 = dir_b.join("two.txt");
fs::write(&f1, "").unwrap();
fs::write(&f2, "").unwrap();
marlin(&tmp).current_dir(tmp.path()).arg("init").assert().success();
// multi-path scan
marlin(&tmp)
.args(["scan", dir_a.to_str().unwrap(), dir_b.to_str().unwrap()])
.assert().success();
// both files findable
for term in ["one.txt", "two.txt"] {
marlin(&tmp).args(["search", term])
.assert()
.success()
.stdout(str::contains(term));
}
}

68
cli-bin/tests/test.md Normal file
View File

@@ -0,0 +1,68 @@
# Testing
Below is a **repeat-able 3-step flow** you can use **every time you pull fresh code**.
---
## 0 Prepare once
```bash
# Run once (or add to ~/.bashrc) so debug + release artefacts land
# in the same predictable place. Speeds-up future builds.
export CARGO_TARGET_DIR=target
```
---
## 1 Build the new binary
```bash
git pull # grab the latest commit
cargo build --release
sudo install -Dm755 target/release/marlin /usr/local/bin/marlin
```
* `cargo build --release` builds the optimised binary.
* `install …` copies it into your `$PATH` so `marlin` on the CLI is the fresh one.
---
## 2 Run the smoke-test suite
```bash
# Runs the end-to-end test we added in tests/e2e.rs
cargo test --test e2e -- --nocapture
```
* `--test e2e` compiles and runs **only** `tests/e2e.rs`; other unit-tests are skipped (add them later if you like).
* `--nocapture` streams stdout/stderr so you can watch each CLI step in real time.
* Exit-code **0** ➜ everything passed.
Any non-zero exit or a red ✗ line means a step failed; the asserts diff will show the command and its output.
---
## 3 (Optionally) run all tests
```bash
cargo test --all -- --nocapture
```
This will execute:
* unit tests in `src/**`
* every file in `tests/`
* doc-tests
If you wire **“cargo test --all”** into CI (GitHub Actions, GitLab, etc.), pushes that break a workflow will be rejected automatically.
---
### One-liner helper (copy/paste)
```bash
cargo build --release &&
sudo install -Dm755 target/release/marlin /usr/local/bin/marlin &&
cargo test --all -- --nocapture
```
Stick that in a shell alias (`alias marlin-ci='…'`) and youve got a 5-second upgrade-and-verify loop.

23
cli-bin/tests/util.rs Normal file
View File

@@ -0,0 +1,23 @@
//! tests/util.rs
//! Small helpers shared across integration tests.
use std::path::{Path, PathBuf};
use tempfile::TempDir;
use assert_cmd::Command;
/// Absolute path to the freshly-built `marlin` binary.
pub fn bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_marlin"))
}
/// Build a `Command` for `marlin` whose `MARLIN_DB_PATH` is
/// `<tmp>/index.db`.
///
/// Each call yields a brand-new `Command`, so callers can freely add
/// arguments, change the working directory, etc., without affecting
/// other invocations.
pub fn marlin(tmp: &TempDir) -> Command {
let db_path: &Path = &tmp.path().join("index.db");
let mut cmd = Command::new(bin());
cmd.env("MARLIN_DB_PATH", db_path);
cmd
}