diff --git a/bar.txt b/.github/workflows/ci.yml similarity index 100% rename from bar.txt rename to .github/workflows/ci.yml diff --git a/Cargo.lock b/Cargo.lock index edbd8f9..2a31ca0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -440,6 +440,23 @@ version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +[[package]] +name = "libmarlin" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "directories", + "glob", + "rusqlite", + "serde_json", + "shellexpand", + "shlex", + "tracing", + "tracing-subscriber", + "walkdir", +] + [[package]] name = "libredox" version = "0.1.3" @@ -474,7 +491,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" [[package]] -name = "marlin" +name = "marlin-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "assert_cmd", + "clap", + "clap_complete", + "dirs 5.0.1", + "glob", + "libmarlin", + "predicates", + "rusqlite", + "serde_json", + "shellexpand", + "shlex", + "tempfile", + "tracing", + "tracing-subscriber", + "walkdir", +] + +[[package]] +name = "marlin-tui" version = "0.1.0" dependencies = [ "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 6e3c586..d97463d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,31 +1,9 @@ -[package] -name = "marlin" -version = "0.1.0" -edition = "2021" +[workspace] +members = [ + "libmarlin", + "cli-bin", + "tui-bin", +] -[dependencies] -anyhow = "1" -clap = { version = "4", features = ["derive"] } -directories = "5" -glob = "0.3" -rusqlite = { version = "0.31", features = ["bundled", "backup"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } -walkdir = "2.5" -shlex = "1.3" -chrono = "0.4" -shellexpand = "3.1" -clap_complete = "4.1" -serde_json = { version = "1", optional = true } # <-- NEW - -[dev-dependencies] -assert_cmd = "2" -predicates = "3" -tempfile = "3" -dirs = "5" # cross-platform data dir helper - -[features] -# The CLI prints JSON only when this feature is enabled. -# Having the feature listed silences the `unexpected cfg` lint even -# when you don’t turn it on. -json = ["serde_json"] \ No newline at end of file +# optionally, share common dependency versions here: +# [workspace.dependencies] diff --git a/cli-bin/Cargo.toml b/cli-bin/Cargo.toml new file mode 100644 index 0000000..fc42bf1 --- /dev/null +++ b/cli-bin/Cargo.toml @@ -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"] diff --git a/cli-bin/build.rs b/cli-bin/build.rs new file mode 100644 index 0000000..8364828 --- /dev/null +++ b/cli-bin/build.rs @@ -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=` lines here. + println!("cargo:rerun-if-changed=build.rs"); +} diff --git a/src/cli.rs b/cli-bin/src/cli.rs similarity index 100% rename from src/cli.rs rename to cli-bin/src/cli.rs diff --git a/src/cli/annotate.rs b/cli-bin/src/cli/annotate.rs similarity index 100% rename from src/cli/annotate.rs rename to cli-bin/src/cli/annotate.rs diff --git a/src/cli/coll.rs b/cli-bin/src/cli/coll.rs similarity index 96% rename from src/cli/coll.rs rename to cli-bin/src/cli/coll.rs index 76a40f7..91a7545 100644 --- a/src/cli/coll.rs +++ b/cli-bin/src/cli/coll.rs @@ -3,10 +3,8 @@ use clap::{Args, Subcommand}; use rusqlite::Connection; -use crate::{ - cli::Format, - db, -}; +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 { diff --git a/src/cli/commands.yaml b/cli-bin/src/cli/commands.yaml similarity index 100% rename from src/cli/commands.yaml rename to cli-bin/src/cli/commands.yaml diff --git a/src/cli/event.rs b/cli-bin/src/cli/event.rs similarity index 100% rename from src/cli/event.rs rename to cli-bin/src/cli/event.rs diff --git a/src/cli/link.rs b/cli-bin/src/cli/link.rs similarity index 96% rename from src/cli/link.rs rename to cli-bin/src/cli/link.rs index 16c23c1..70f538b 100644 --- a/src/cli/link.rs +++ b/cli-bin/src/cli/link.rs @@ -1,9 +1,10 @@ -// src/cli/link.rs +//! src/cli/link.rs – manage typed relationships between files -use crate::db; use clap::{Subcommand, Args}; use rusqlite::Connection; -use crate::cli::Format; + +use crate::cli::Format; // output selector +use libmarlin::db; // ← switched from `crate::db` #[derive(Subcommand, Debug)] pub enum LinkCmd { diff --git a/src/cli/remind.rs b/cli-bin/src/cli/remind.rs similarity index 100% rename from src/cli/remind.rs rename to cli-bin/src/cli/remind.rs diff --git a/src/cli/state.rs b/cli-bin/src/cli/state.rs similarity index 100% rename from src/cli/state.rs rename to cli-bin/src/cli/state.rs diff --git a/src/cli/task.rs b/cli-bin/src/cli/task.rs similarity index 100% rename from src/cli/task.rs rename to cli-bin/src/cli/task.rs diff --git a/src/cli/version.rs b/cli-bin/src/cli/version.rs similarity index 100% rename from src/cli/version.rs rename to cli-bin/src/cli/version.rs diff --git a/src/cli/view.rs b/cli-bin/src/cli/view.rs similarity index 97% rename from src/cli/view.rs rename to cli-bin/src/cli/view.rs index 7f17ad7..46cf2c3 100644 --- a/src/cli/view.rs +++ b/cli-bin/src/cli/view.rs @@ -6,7 +6,8 @@ use anyhow::Result; use clap::{Args, Subcommand}; use rusqlite::Connection; -use crate::{cli::Format, db}; +use crate::cli::Format; // output selector stays local +use libmarlin::db; // ← path switched from `crate::db` #[derive(Subcommand, Debug)] pub enum ViewCmd { diff --git a/src/main.rs b/cli-bin/src/main.rs similarity index 58% rename from src/main.rs rename to cli-bin/src/main.rs index 984fbec..15a6a6d 100644 --- a/src/main.rs +++ b/cli-bin/src/main.rs @@ -1,24 +1,33 @@ -// src/main.rs +//! 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; -mod config; -mod db; -mod logging; -mod scan; +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 rusqlite::params; use shellexpand; use shlex; use std::{ env, fs, io, - path::{Path, PathBuf}, + path::Path, process::Command, }; use tracing::{debug, error, info}; @@ -27,7 +36,7 @@ use walkdir::WalkDir; use cli::{Cli, Commands}; fn main() -> Result<()> { - /* ── CLI parsing & logging ────────────────────────────────────── */ + /* ── CLI parsing & logging ────────────────────────────────── */ let args = Cli::parse(); if args.verbose { @@ -35,7 +44,7 @@ fn main() -> Result<()> { } logging::init(); - /* ── shell-completion shortcut ───────────────────────────────── */ + /* ── shell-completion shortcut ────────────────────────────── */ if let Commands::Completions { shell } = &args.command { let mut cmd = Cli::command(); @@ -43,63 +52,65 @@ fn main() -> Result<()> { return Ok(()); } - /* ── config & automatic backup ───────────────────────────────── */ + /* ── config & automatic backup ───────────────────────────── */ - let cfg = config::Config::load()?; // DB path, etc. + let cfg = config::Config::load()?; // resolves DB path match &args.command { Commands::Init | Commands::Backup | Commands::Restore { .. } => {} _ => match db::backup(&cfg.db_path) { - Ok(path) => info!("Pre-command auto-backup created at {}", path.display()), - Err(e) => error!("Failed to create pre-command auto-backup: {e}"), + 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 if needed) ─────────────────────── */ + /* ── open DB (runs migrations) ───────────────────────────── */ let mut conn = db::open(&cfg.db_path)?; - /* ── command dispatch ────────────────────────────────────────── */ + /* ── command dispatch ────────────────────────────────────── */ match args.command { - Commands::Completions { .. } => {} // already handled + Commands::Completions { .. } => {} // handled above + /* ---- init ------------------------------------------------ */ Commands::Init => { info!("Database initialised at {}", cfg.db_path.display()); - - // Always (re-)scan the current directory so even an existing DB - // picks up newly created files in the working tree. 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 - }; + } else { paths }; + for p in scan_paths { scan::scan_directory(&mut conn, &p)?; } } - Commands::Tag { pattern, tag_path } => apply_tag(&conn, &pattern, &tag_path)?, + /* ---- 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)?, + 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)?, + Commands::Search { query, exec } => + run_search(&conn, &query, exec)?, + /* ---- maintenance ---------------------------------------- */ Commands::Backup => { - let path = db::backup(&cfg.db_path)?; - println!("Backup created: {}", path.display()); + let p = db::backup(&cfg.db_path)?; + println!("Backup created: {}", p.display()); } Commands::Restore { backup_path } => { @@ -114,14 +125,14 @@ fn main() -> Result<()> { info!("Successfully opened restored database."); } - /* passthrough sub-modules that still stub out their logic */ + /* ---- 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(an_cmd) => cli::annotate::run(&an_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)?, } @@ -129,29 +140,24 @@ fn main() -> Result<()> { Ok(()) } -/* ───────────────────────── helpers & sub-routines ───────────────── */ +/* ─────────────────── helpers & sub-routines ─────────────────── */ /* ---------- TAGS ---------- */ -/// Apply a hierarchical tag to all files matching the glob pattern. fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag_path: &str) -> Result<()> { - // ensure_tag_path returns the deepest-node ID + // ensure_tag_path returns ID of deepest node let leaf_tag_id = db::ensure_tag_path(conn, tag_path)?; - // collect that tag and all its ancestors + // 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 = match conn.query_row( - "SELECT parent_id FROM tags WHERE id = ?1", - params![id], + current = conn.query_row( + "SELECT parent_id FROM tags WHERE id=?1", + [id], |r| r.get::<_, Option>(0), - ) { - Ok(parent_id) => parent_id, - Err(rusqlite::Error::QueryReturnedNoRows) => None, - Err(e) => return Err(e.into()), - }; + )?; } let expanded = shellexpand::tilde(pattern).into_owned(); @@ -159,102 +165,87 @@ fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag_path: &str) -> Resu .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_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 = 0; + let mut count = 0usize; for entry in WalkDir::new(&root) .into_iter() .filter_map(Result::ok) .filter(|e| e.file_type().is_file()) { - let path_str = entry.path().to_string_lossy(); - if !pat.matches(&path_str) { - continue; - } + let p = entry.path().to_string_lossy(); + if !pat.matches(&p) { continue; } - match stmt_file.query_row(params![path_str.as_ref()], |r| r.get::<_, i64>(0)) { - Ok(file_id) => { + 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(params![file_id, tid])? > 0 { + if stmt_insert.execute([fid, tid])? > 0 { newly = true; } } if newly { - info!(file = %path_str, tag = tag_path, "tagged"); + info!(file=%p, tag=tag_path, "tagged"); count += 1; } } - Err(rusqlite::Error::QueryReturnedNoRows) => { - error!(file = %path_str, "not indexed – run `marlin scan` first"); - } - Err(e) => { - error!(file = %path_str, error = %e, "could not lookup file ID"); - } + 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 - ); + info!("Applied tag '{}' to {} file(s).", tag_path, count); Ok(()) } /* ---------- ATTRIBUTES ---------- */ -/// Set a key=value attribute on all files matching the glob pattern. 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 = 0; + 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 path_str = entry.path().to_string_lossy(); - if !pat.matches(&path_str) { - continue; - } + let p = entry.path().to_string_lossy(); + if !pat.matches(&p) { continue; } - match stmt_file.query_row(params![path_str.as_ref()], |r| r.get::<_, i64>(0)) { - Ok(file_id) => { - db::upsert_attr(conn, file_id, key, value)?; - info!(file = %path_str, key, value, "attr set"); + 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 = %path_str, "not indexed – run `marlin scan` first"); - } - Err(e) => { - error!(file = %path_str, error = %e, "could not lookup file ID"); - } + 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 - ); + info!("Attribute '{}={}' set on {} file(s).", key, value, count); Ok(()) } -/// List attributes for a given file path. fn attr_ls(conn: &rusqlite::Connection, 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 ORDER BY key")?; + 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([file_id], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))? + .query_map([fid], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))? { let (k, v) = row?; println!("{k} = {v}"); @@ -264,11 +255,8 @@ fn attr_ls(conn: &rusqlite::Connection, path: &Path) -> Result<()> { /* ---------- SEARCH ---------- */ -/// Run an FTS5 search, optionally piping each hit through `exec`. -/// Falls back to a simple substring scan (path + ≤64 kB file contents) -/// when the FTS index yields no rows. fn run_search(conn: &rusqlite::Connection, raw_query: &str, exec: Option) -> Result<()> { - // Build the FTS MATCH expression + /* ── 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 { @@ -276,9 +264,7 @@ fn run_search(conn: &rusqlite::Connection, raw_query: &str, exec: Option 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()); - } + if i > 0 { parts.push("AND".into()); } parts.push(format!("tags_text:{}", escape_fts(seg))); } } else if let Some(attr) = tok.strip_prefix("attr:") { @@ -298,7 +284,7 @@ fn run_search(conn: &rusqlite::Connection, raw_query: &str, exec: Option let fts_expr = parts.join(" "); debug!("FTS MATCH expression: {fts_expr}"); - // ---------- primary FTS query ---------- + /* ── primary FTS query ---------------------------------- */ let mut stmt = conn.prepare( r#" SELECT f.path @@ -309,55 +295,49 @@ fn run_search(conn: &rusqlite::Connection, raw_query: &str, exec: Option "#, )?; let mut hits: Vec = stmt - .query_map(params![fts_expr], |r| r.get::<_, String>(0))? + .query_map([&fts_expr], |r| r.get::<_, String>(0))? .filter_map(Result::ok) .collect(); - // ---------- graceful fallback ---------- + /* ── graceful fallback (substring scan) ----------------- */ if hits.is_empty() && !raw_query.contains(':') { hits = naive_substring_search(conn, raw_query)?; } - // ---------- output / exec ---------- + /* ── 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 expression: `{fts_expr}`)" + "No matches for query: `{raw_query}` (FTS expr: `{fts_expr}`)" ); } else { - for p in hits { - println!("{p}"); - } + for p in hits { println!("{p}"); } } } - Ok(()) } -/// Simple, case-insensitive substring scan over paths and (small) file bodies. +/// Fallback: case-insensitive substring scan over paths *and* small file bodies. fn naive_substring_search(conn: &rusqlite::Connection, term: &str) -> Result> { - let term_lc = term.to_lowercase(); - + 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(&term_lc) { + if p.to_lowercase().contains(&needle) { out.push(p.clone()); continue; } - // Only inspect small files to stay fast + // Only scan files ≤ 64 kB if let Ok(meta) = fs::metadata(&p) { - if meta.len() > 64_000 { - continue; - } + if meta.len() > 65_536 { continue; } } - if let Ok(content) = fs::read_to_string(&p) { - if content.to_lowercase().contains(&term_lc) { + if let Ok(body) = fs::read_to_string(&p) { + if body.to_lowercase().contains(&needle) { out.push(p); } } @@ -365,17 +345,18 @@ fn naive_substring_search(conn: &rusqlite::Connection, term: &str) -> Result 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()?; + let status = Command::new(&prog).args(parts).status()?; if !status.success() { - error!(command = %cmd_tpl, code = ?status.code(), "command failed"); + error!(command=%cmd_tpl, code=?status.code(), "command failed"); } } } @@ -391,13 +372,11 @@ fn run_exec(paths: &[String], cmd_tpl: &str) -> Result<()> { format!("{cmd_tpl} {quoted}") }; if let Some(mut parts) = shlex::split(&final_cmd) { - if parts.is_empty() { - continue; - } + if parts.is_empty() { continue; } let prog = parts.remove(0); - let status = Command::new(&prog).args(&parts).status()?; + let status = Command::new(&prog).args(parts).status()?; if !status.success() { - error!(file = %p, command = %final_cmd, code = ?status.code(), "command failed"); + error!(file=%p, command=%final_cmd, code=?status.code(), "command failed"); } } } @@ -409,33 +388,8 @@ fn run_exec(paths: &[String], cmd_tpl: &str) -> Result<()> { 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()) + || ["AND", "OR", "NOT", "NEAR"].contains(&term.to_uppercase().as_str()) { format!("\"{}\"", term.replace('"', "\"\"")) - } else { - term.to_string() - } -} - -/// Determine a filesystem root to limit recursive walking. -fn determine_scan_root(pattern: &str) -> PathBuf { - let first_wild = pattern - .find(|c| matches!(c, '*' | '?' | '[')) - .unwrap_or(pattern.len()); - let mut root = PathBuf::from(&pattern[..first_wild]); - - while root - .as_os_str() - .to_string_lossy() - .contains(|c| matches!(c, '*' | '?' | '[')) - { - root = root.parent().map(Path::to_path_buf).unwrap_or_default(); - } - - if root.as_os_str().is_empty() { - PathBuf::from(".") - } else { - root - } + } else { term.to_string() } } diff --git a/tests/e2e.rs b/cli-bin/tests/e2e.rs similarity index 99% rename from tests/e2e.rs rename to cli-bin/tests/e2e.rs index 64fc8dc..e946dce 100644 --- a/tests/e2e.rs +++ b/cli-bin/tests/e2e.rs @@ -1,3 +1,4 @@ +//! 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`. diff --git a/tests/neg.rs b/cli-bin/tests/neg.rs similarity index 99% rename from tests/neg.rs rename to cli-bin/tests/neg.rs index 89af7f1..23105cd 100644 --- a/tests/neg.rs +++ b/cli-bin/tests/neg.rs @@ -1,3 +1,4 @@ +//! tests neg.rs //! Negative-path integration tests (“should fail / warn”). use predicates::str; diff --git a/tests/pos.rs b/cli-bin/tests/pos.rs similarity index 99% rename from tests/pos.rs rename to cli-bin/tests/pos.rs index 1d00659..a262652 100644 --- a/tests/pos.rs +++ b/cli-bin/tests/pos.rs @@ -1,3 +1,4 @@ +//! tests pos.rs //! Positive-path integration checks for every sub-command //! that already has real logic behind it. diff --git a/tests/test.md b/cli-bin/tests/test.md similarity index 97% rename from tests/test.md rename to cli-bin/tests/test.md index bc1abc1..1dd42d1 100644 --- a/tests/test.md +++ b/cli-bin/tests/test.md @@ -60,7 +60,7 @@ If you wire **“cargo test --all”** into CI (GitHub Actions, GitLab, etc.), p ### One-liner helper (copy/paste) ```bash -git pull && cargo build --release && +cargo build --release && sudo install -Dm755 target/release/marlin /usr/local/bin/marlin && cargo test --all -- --nocapture ``` diff --git a/tests/util.rs b/cli-bin/tests/util.rs similarity index 100% rename from tests/util.rs rename to cli-bin/tests/util.rs diff --git a/marlin_demo.md b/docs/marlin_demo.md similarity index 100% rename from marlin_demo.md rename to docs/marlin_demo.md diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..5d2f7f9 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,74 @@ +# Marlin ― Delivery Road‑map **v3** + +*Engineering‑ready version — updated 2025‑05‑17* + +> **Legend** +> **△** = engineering artefact (spec / ADR / perf target)  **✦** = user-visible deliverable + +--- + +## 0 · Methodology primer (what “Done” means) + +| Theme | Project rule-of-thumb | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| **Branching** | Trunk-based. Feature branches → PR → 2 reviews → squash-merge. | +| **Spec first** | Every epic starts with a **Design Proposal (DP-xxx)** in `/docs/adr/`. Include schema diffs, example CLI session, perf budget. | +| **Tests** | Unit + integration coverage ≥ 85 % on lines **touched in the sprint** (checked by Tarpaulin). | +| **Perf gate** | Cold start P95 ≤ 3 s on 100 k files **unless overridden in DP**. Regressions fail CI. | +| **Docs** | CLI flags & examples land in `README.md` **same PR** that ships the code. | +| **Demo** | Closing each epic produces a 2-min asciinema or gif in `docs/demos/`. | + +--- + +## 1 · Bird’s‑eye table (now includes engineering columns) + +| Phase / Sprint | Timeline | Focus & Rationale | ✦ Key UX Deliverables | △ Engineering artefacts / tasks | Definition of Done | +| --------------------------------------------- | -------- | ---------------------------------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| **Epic 1 — Scale & Reliability** | 2025-Q2 | Stay fast @ 100 k files | • `scan --dirty` (re-index touched rows only) | • DP-002 Dirty-flag design + FTS rebuild cadence
• Hyperfine benchmark script committed | Dirty scan vs full ≤ 15 % runtime on 100 k corpus; benchmark job passes | +| **Epic 2 — Live Mode & Self‑Pruning Backups** | 2025-Q2 | “Just works” indexing, DB never explodes | • `marlin watch ` (notify/FSEvents)
• `backup --prune N` & auto-prune | • DP-003 file-watcher life-cycle & debouncing
• Integration test with inotify-sim
• Cron-style GitHub job for nightly prune | 8 h stress-watch alters 10 k files < 1 % misses; backup dir ≤ N | +| **Phase 3 — Content FTS + Annotations** | 2025-Q3 | Search inside files, leave notes | • Grep-style snippet output (`-C3`)
• `marlin annotate add/list` | • DP-004 content-blob strategy (inline vs ext-table)
• Syntax-highlight via `syntect` PoC
• New FTS triggers unit-tested | Indexes 1 GB corpus in ≤ 30 min; snippet CLI passes golden-file tests | +| **Phase 4 — Versioning & Deduplication** | 2025-Q3 | Historic diffs, detect dupes | • `scan --rehash` (SHA-256)
• `version diff ` | • DP-005 hash column + Bloom-de-dupe
• Binary diff adapter research | Diff on 10 MB file ≤ 500 ms; dupes listed via CLI | +| **Phase 5 — Tag Aliases & Semantic Booster** | 2025-Q3 | Tame tag sprawl, start AI hints | • `tag alias add/ls/rm`
• `tag suggest`, `summary` | • DP-006 embeddings size & model choice
• Vector store schema + k-NN index bench | 95 % of “foo/bar\~foo” alias look-ups resolve in one hop; suggest CLI returns ≤ 150 ms | +| **Phase 6 — Search DSL v2 & Smart Views** | 2025-Q4 | Pro-grade query language | • New `nom` grammar: AND/OR, parentheses, ranges | • DP-007 BNF + 30 acceptance strings
• Lexer fuzz-tests with `cargo-fuzz` | Old queries keep working (migration shim); 0 crashes in fuzz run ≥ 1 M cases | +| **Phase 7 — Structured Workflows** | 2025-Q4 | Tasks, state, reminders, templates | • `state set/transitions add/log`
• `task scan/list`
• **NEW:** `template apply` | • DP-008 Workflow tables & validation
• Sample YAML template spec + CLI expansion tests | Create template, apply to 20 files → all attrs/link rows present; state graph denies illegal transitions | +| **Phase 8 — Lightweight Integrations** | 2026-Q1 | First “shell” GUIs | • VS Code side-bar (read-only)
• **TUI v1** (tag tree ▸ file list ▸ preview) | • DP-009 TUI key-map & redraw budget
• Crate split `marlin_core`, `marlin_tui` | TUI binary ≤ 2.0 MB; 10 k row scroll ≤ 4 ms redraw | +| **Phase 9 — Dolphin Sidebar (MVP)** | 2026-Q1 | Peek metadata in KDE file-manager | • Qt-plugin showing tags, attrs, links | • DP-010 DB/IP bridge (D‑Bus vs UNIX socket)
• CMake packaging script | Sidebar opens in ≤ 150 ms; passes KDE lint | +| **Phase 10 — Full GUI & Multi-device Sync** | 2026-Q2 | Edit metadata visually, sync option | • Electron/Qt hybrid explorer UI
• Pick & integrate sync backend | • DP-011 sync back-end trade-study
• UI e2e tests in Playwright | Round-trip CRUD between two nodes in < 2 s; 25 GUI tests green | + +--- + +### 2 · Feature cross-matrix (quick look-ups) + +| Capability | Sprint / Phase | CLI flag or GUI element | Linked DP | +| ------------------------------------- | -------------- | ---------------------------------- | --------- | +| Relationship **templates** | P7 | `template new`, `template apply` | DP-008 | +| Positive / negative filter combinator | P6 | DSL `+tag:foo -tag:bar date>=2025` | DP-007 | +| Dirty-scan optimisation | E1 | `scan --dirty` | DP-002 | +| Watch-mode | E2 | `marlin watch .` | DP-003 | +| Grep snippets | P3 | `search -C3 "foo"` | DP-004 | +| Hash / dedupe | P4 | `scan --rehash` | DP-005 | + +--- + +## 3 · Milestone acceptance checklist + +Before a milestone is declared “shipped”: + +* [ ] **Spec** merged (DP-xxx) with schema diff & example ASCII-cast +* [ ] **Unit & integration tests** ≥ 85 % coverage on changed lines +* [ ] **Perf guard-rail** script passes on CI matrix (Ubuntu 22, macOS 14) +* [ ] **Docs** — CLI man-page, README table row, roadmap ticked +* [ ] **Demo** uploaded to `docs/demos/` and linked in release notes +* [ ] **Release tag** pushed; Cargo binary on GitHub Releases + +--- + +### 4 · Next immediate actions + +1. **Write DP-001 (Schema v1.1)** — owner @alice, due 21 May +2. **Set up Tarpaulin & Hyperfine jobs** — @bob, due 23 May +3. **Spike dirty-flag logic** — @carol 2 days time-box, outcome in DP-002 + +--- + +> *This roadmap now contains both product-level “what” and engineering-level “how/when/prove it”. It should allow a new contributor to jump in, pick the matching DP, and know exactly the bar they must clear for their code to merge.* diff --git a/spec-details/TUI+Query-DSL+Templates.md b/docs/spec-details/TUI+Query-DSL+Templates.md similarity index 100% rename from spec-details/TUI+Query-DSL+Templates.md rename to docs/spec-details/TUI+Query-DSL+Templates.md diff --git a/vision.md b/docs/vision.md similarity index 100% rename from vision.md rename to docs/vision.md diff --git a/foo.txt b/foo.txt deleted file mode 100644 index e69de29..0000000 diff --git a/libmarlin/Cargo.toml b/libmarlin/Cargo.toml new file mode 100644 index 0000000..76ab1c3 --- /dev/null +++ b/libmarlin/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "libmarlin" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +anyhow = "1" +chrono = "0.4" +directories = "5" +glob = "0.3" +rusqlite = { version = "0.31", features = ["bundled", "backup"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +walkdir = "2.5" +shlex = "1.3" +shellexpand = "3.1" +serde_json = { version = "1", optional = true } + +[features] +json = ["serde_json"] diff --git a/src/config.rs b/libmarlin/src/config.rs similarity index 100% rename from src/config.rs rename to libmarlin/src/config.rs diff --git a/src/db/migrations/0001_initial_schema.sql b/libmarlin/src/db/migrations/0001_initial_schema.sql similarity index 100% rename from src/db/migrations/0001_initial_schema.sql rename to libmarlin/src/db/migrations/0001_initial_schema.sql diff --git a/src/db/migrations/0002_update_fts_and_triggers.sql b/libmarlin/src/db/migrations/0002_update_fts_and_triggers.sql similarity index 100% rename from src/db/migrations/0002_update_fts_and_triggers.sql rename to libmarlin/src/db/migrations/0002_update_fts_and_triggers.sql diff --git a/src/db/migrations/0003_create_links_collections_views.sql b/libmarlin/src/db/migrations/0003_create_links_collections_views.sql similarity index 100% rename from src/db/migrations/0003_create_links_collections_views.sql rename to libmarlin/src/db/migrations/0003_create_links_collections_views.sql diff --git a/src/db/migrations/0004_fix_hierarchical_tags_fts.sql b/libmarlin/src/db/migrations/0004_fix_hierarchical_tags_fts.sql similarity index 100% rename from src/db/migrations/0004_fix_hierarchical_tags_fts.sql rename to libmarlin/src/db/migrations/0004_fix_hierarchical_tags_fts.sql diff --git a/src/db/mod.rs b/libmarlin/src/db/mod.rs similarity index 100% rename from src/db/mod.rs rename to libmarlin/src/db/mod.rs diff --git a/libmarlin/src/lib.rs b/libmarlin/src/lib.rs new file mode 100644 index 0000000..f5ead4c --- /dev/null +++ b/libmarlin/src/lib.rs @@ -0,0 +1,122 @@ +//! libmarlin – public API surface for the Marlin core. +//! +//! Down-stream crates (`cli-bin`, `tui-bin`, tests, plugins) should depend +//! *only* on the helpers re-exported here, never on internal modules +//! directly. That gives us room to refactor internals without breaking +//! callers. + +#![deny(warnings)] + +pub mod config; // moved as-is +pub mod db; // moved as-is +pub mod logging; // expose the logging init helper +pub mod scan; // moved as-is +pub mod utils; // hosts determine_scan_root() & misc helpers + +use anyhow::{Context, Result}; +use rusqlite::Connection; +use std::path::Path; +use walkdir::WalkDir; + +/// Primary façade – open a workspace then call helper methods. +/// +/// Most methods simply wrap what the CLI used to do directly; more will be +/// filled in sprint-by-sprint. +pub struct Marlin { + #[allow(dead_code)] + cfg: config::Config, + conn: Connection, +} + +impl Marlin { + /// Load configuration from env / workspace and open (or create) the DB. + pub fn open_default() -> Result { + let cfg = config::Config::load()?; + let conn = db::open(&cfg.db_path)?; + Ok(Self { cfg, conn }) + } + + /// Open an explicit DB path – handy for tests or headless tools. + pub fn open_at>(path: P) -> Result { + let cfg = config::Config { db_path: path.as_ref().to_path_buf() }; + let conn = db::open(&cfg.db_path)?; + Ok(Self { cfg, conn }) + } + + /// Recursively index one or more directories. + pub fn scan>(&mut self, paths: &[P]) -> Result { + let mut total = 0usize; + for p in paths { + total += scan::scan_directory(&mut self.conn, p.as_ref())?; + } + Ok(total) + } + + /// Attach a hierarchical tag (`foo/bar`) to every file that matches the + /// glob pattern. Returns the number of files that actually got updated. + pub fn tag(&mut self, pattern: &str, tag_path: &str) -> Result { + use glob::Pattern; + + // 1) ensure tag hierarchy exists + let leaf_tag_id = db::ensure_tag_path(&self.conn, tag_path)?; + + // 2) 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 = self.conn.query_row( + "SELECT parent_id FROM tags WHERE id=?1", + [id], + |r| r.get::<_, Option>(0), + )?; + } + + // 3) walk the file tree and upsert `file_tags` + let expanded = shellexpand::tilde(pattern).into_owned(); + let pat = Pattern::new(&expanded) + .with_context(|| format!("Invalid glob pattern `{expanded}`"))?; + let root = utils::determine_scan_root(&expanded); + + let mut stmt_file = self.conn.prepare("SELECT id FROM files WHERE path=?1")?; + let mut stmt_insert = self.conn.prepare( + "INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?1, ?2)", + )?; + + let mut changed = 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 { changed += 1; } + } + Err(_) => { /* ignore non‐indexed files */ } + } + } + + Ok(changed) + } + + /// FTS5 search → list of matching paths. + pub fn search(&self, query: &str) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT path FROM files_fts WHERE files_fts MATCH ?1 ORDER BY rank", + )?; + let rows = stmt.query_map([query], |r| r.get::<_, String>(0))? + .collect::, _>>()?; + Ok(rows) + } + + /// Borrow the underlying SQLite connection (read-only). + pub fn conn(&self) -> &Connection { &self.conn } +} diff --git a/src/logging.rs b/libmarlin/src/logging.rs similarity index 100% rename from src/logging.rs rename to libmarlin/src/logging.rs diff --git a/src/scan.rs b/libmarlin/src/scan.rs similarity index 100% rename from src/scan.rs rename to libmarlin/src/scan.rs diff --git a/libmarlin/src/utils.rs b/libmarlin/src/utils.rs new file mode 100644 index 0000000..d3b8d42 --- /dev/null +++ b/libmarlin/src/utils.rs @@ -0,0 +1,25 @@ +//! Misc shared helpers. + +use std::path::PathBuf; + +/// Determine a filesystem root to limit recursive walking on glob scans. +pub fn determine_scan_root(pattern: &str) -> PathBuf { + let first_wild = pattern + .find(|c| matches!(c, '*' | '?' | '[')) + .unwrap_or(pattern.len()); + let mut root = PathBuf::from(&pattern[..first_wild]); + + while root + .as_os_str() + .to_string_lossy() + .contains(|c| matches!(c, '*' | '?' | '[')) + { + root = root.parent().map(|p| p.to_path_buf()).unwrap_or_default(); + } + + if root.as_os_str().is_empty() { + PathBuf::from(".") + } else { + root + } +} diff --git a/roadmap.md b/roadmap.md deleted file mode 100644 index 79a2c44..0000000 --- a/roadmap.md +++ /dev/null @@ -1,75 +0,0 @@ -# Marlin ― Delivery Road-map **v3** - -*Engineering-ready version — updated 2025-05-17* - -> **Legend** -> **△** = engineering artefact (spec / ADR / perf target)  **✦** = user-visible deliverable - ---- - -## 0 · Methodology primer (what “Done” means) - -| Theme | Project rule-of-thumb | -| -------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| **Branching** | Trunk-based. Feature branches → PR → 2 reviews → squash-merge. | -| **Spec first** | Every epic starts with a **Design Proposal (DP-xxx)** in `/docs/adr/`. Include schema diffs, example CLI session, perf budget. | -| **Tests** | Unit + integration coverage ≥ 85 % on lines **touched in the sprint** (checked by Tarpaulin). | -| **Perf gate** | Cold start P95 ≤ 3 s on 100 k files **unless overridden in DP**. Regressions fail CI. | -| **Docs** | CLI flags & examples land in `README.md` **same PR** that ships the code. | -| **Demo** | Closing each epic produces a 2-min asciinema or gif in `docs/demos/`. | - ---- - -## 1 · Bird’s-eye table (now includes engineering columns) - -| Phase / Sprint | Timeline | Focus & Rationale | ✦ Key UX Deliverables | △ Engineering artefacts / tasks | Definition of Done | | | | -| --------------------------------------------- | ----------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | -| **Sprint α — Bedrock & Metadata Domains** | **2025-Q2
(now → 06 Jun)** | Lock schema, smoking-fast CI, first metadata objects. | • CLI stubs: `marlin link / coll / view`
• `marlin demo` interactive tour | • DP-001 Schema v1.1 (ER + migration scripts)
• Unit tests (`escape_fts`, `determine_scan_root`)
• GitHub Action for SQL dry-run | 100 % migrations green on CI; demo command prints green tick | | | | -| **Epic 1 — Scale & Reliability** | 2025-Q2 | Stay fast @ 100 k files | • `scan --dirty` (re-index touched rows only) | • DP-002 Dirty-flag design + FTS rebuild cadence
• Hyperfine benchmark script committed | Dirty scan vs full ≤ 15 % runtime on 100 k corpus; benchmark job passes | | | | -| **Epic 2 — Live Mode & Self-Pruning Backups** | 2025-Q2 | “Just works” indexing, DB never explodes | • `marlin watch ` (notify/FSEvents)
• `backup --prune N` & auto-prune | • DP-003 file-watcher life-cycle & debouncing
• Integration test with inotify-sim
• Cron-style GitHub job for nightly prune | 8 h stress-watch alters 10 k files < 1 % misses; backup dir ≤ N | | | | -| **Phase 3 — Content FTS + Annotations** | 2025-Q3 | Search inside files, leave notes | • Grep-style snippet output (`-C3`)
• \`marlin annotate add | list\` | • DP-004 content-blob strategy (inline vs ext-table)
• Syntax-highlight via `syntect` PoC
• New FTS triggers unit-tested | Indexes 1 GB corpus in ≤ 30 min; snippet CLI passes golden-file tests | | | -| **Phase 4 — Versioning & Deduplication** | 2025-Q3 | Historic diffs, detect dupes | • `scan --rehash` (SHA-256)
• `version diff ` | • DP-005 hash column + Bloom-de-dupe
• Binary diff adapter research | Diff on 10 MB file ≤ 500 ms; dupes listed via CLI | | | | -| **Phase 5 — Tag Aliases & Semantic Booster** | 2025-Q3 | Tame tag sprawl, start AI hints | • \`tag alias add | ls | rm`
• `tag suggest`, `summary\` | • DP-006 embeddings size & model choice
• Vector store schema + k-NN index bench | 95 % of “foo/bar\~foo” alias look-ups resolve in one hop; suggest CLI returns ≤ 150 ms | | -| **Phase 6 — Search DSL v2 & Smart Views** | 2025-Q4 | Pro-grade query language | • New `nom` grammar: AND/OR, parentheses, ranges | • DP-007 BNF + 30 acceptance strings
• Lexer fuzz-tests with `cargo-fuzz` | Old queries keep working (migration shim); 0 crashes in fuzz run ≥ 1 M cases | | | | -| **Phase 7 — Structured Workflows** | 2025-Q4 | Tasks, state, reminders, templates | • \`state set | transitions add | log`
• `task scan | list`
• **NEW:** `template apply\` for relationship templates | • DP-008 Workflow tables & validation
• Sample YAML template spec + CLI expansion tests | Create template, apply to 20 files → all attrs/link rows present; state graph denies illegal transitions | -| **Phase 8 — Lightweight Integrations** | 2026-Q1 | First “shell” GUIs | • VS Code side-bar (read-only)
• **TUI v1** (tag tree ▸ file list ▸ preview) | • DP-009 TUI key-map & redraw budget
• Crate split `marlin_core`, `marlin_tui` | TUI binary ≤ 2.0 MB; 10 k row scroll ≤ 4 ms redraw | | | | -| **Phase 9 — Dolphin Sidebar (MVP)** | 2026-Q1 | Peek metadata in KDE file-manager | • Qt-plugin showing tags, attrs, links | • DP-010 DB/IP bridge (D-Bus vs UNIX socket)
• CMake packaging script | Sidebar opens in ≤ 150 ms; passes KDE lint | | | | -| **Phase 10 — Full GUI & Multi-device Sync** | 2026-Q2 | Edit metadata visually, sync option | • Electron/Qt hybrid explorer UI
• Pick & integrate sync backend | • DP-011 sync back-end trade-study
• UI e2e tests in Playwright | Round-trip CRUD between two nodes in < 2 s; 25 GUI tests green | | | | - ---- - -### 2 · Feature cross-matrix (quick look-ups) - -| Capability | Sprint / Phase | CLI flag or GUI element | Linked DP | -| ------------------------------------- | -------------- | ---------------------------------- | --------- | -| Relationship **templates** | P7 | `template new`, `template apply` | DP-008 | -| Positive / negative filter combinator | P6 | DSL `+tag:foo -tag:bar date>=2025` | DP-007 | -| Dirty-scan optimisation | E1 | `scan --dirty` | DP-002 | -| Watch-mode | E2 | `marlin watch .` | DP-003 | -| Grep snippets | P3 | `search -C3 "foo"` | DP-004 | -| Hash / dedupe | P4 | `scan --rehash` | DP-005 | - ---- - -## 3 · Milestone acceptance checklist - -Before a milestone is declared “shipped”: - -* [ ] **Spec** merged (DP-xxx) with schema diff & example ASCII-cast -* [ ] **Unit & integration tests** ≥ 85 % coverage on changed lines -* [ ] **Perf guard-rail** script passes on CI matrix (Ubuntu 22, macOS 14) -* [ ] **Docs** — CLI man-page, README table row, roadmap ticked -* [ ] **Demo** uploaded to `docs/demos/` and linked in release notes -* [ ] **Release tag** pushed; Cargo binary on GitHub Releases - ---- - -### 4 · Next immediate actions - -1. **Write DP-001 (Schema v1.1)** — owner @alice, due 21 May -2. **Set up Tarpaulin & Hyperfine jobs** — @bob, due 23 May -3. **Spike dirty-flag logic** — @carol 2 days time-box, outcome in DP-002 - ---- - -> *This roadmap now contains both product-level “what” and engineering-level “how/when/prove it”. It should allow a new contributor to jump in, pick the matching DP, and know exactly the bar they must clear for their code to merge.* diff --git a/src/test_hierarchical_tags.rs b/src/test_hierarchical_tags.rs deleted file mode 100644 index 5c36911..0000000 --- a/src/test_hierarchical_tags.rs +++ /dev/null @@ -1,240 +0,0 @@ -// Test script to validate hierarchical tag FTS fix -// This script demonstrates how the fix works with a simple test case - -use rusqlite::{Connection, params}; -use std::path::Path; -use std::fs; -use anyhow::Result; - -fn main() -> Result<()> { - // Create a test database in a temporary location - let db_path = Path::new("/tmp/marlin_test.db"); - if db_path.exists() { - fs::remove_file(db_path)?; - } - - println!("Creating test database at {:?}", db_path); - - // Initialize database with our schema and migrations - let conn = Connection::open(db_path)?; - - // Apply schema (simplified version of what's in the migrations) - println!("Applying schema..."); - conn.execute_batch( - "PRAGMA foreign_keys = ON; - PRAGMA journal_mode = WAL; - - CREATE TABLE files ( - id INTEGER PRIMARY KEY, - path TEXT NOT NULL UNIQUE, - size INTEGER, - mtime INTEGER, - hash TEXT - ); - - CREATE TABLE tags ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - parent_id INTEGER REFERENCES tags(id) ON DELETE CASCADE, - canonical_id INTEGER REFERENCES tags(id) ON DELETE SET NULL, - UNIQUE(name, parent_id) - ); - - CREATE TABLE file_tags ( - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY(file_id, tag_id) - ); - - CREATE TABLE attributes ( - id INTEGER PRIMARY KEY, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - key TEXT NOT NULL, - value TEXT, - UNIQUE(file_id, key) - ); - - CREATE VIRTUAL TABLE files_fts - USING fts5( - path, - tags_text, - attrs_text, - content='', - tokenize=\"unicode61 remove_diacritics 2\" - );" - )?; - - // Apply our fixed triggers - println!("Applying fixed FTS triggers..."); - conn.execute_batch( - "CREATE TRIGGER files_fts_ai_file - AFTER INSERT ON files - BEGIN - INSERT INTO files_fts(rowid, path, tags_text, attrs_text) - VALUES ( - NEW.id, - NEW.path, - (SELECT IFNULL(GROUP_CONCAT(tag_path, ' '), '') - FROM ( - WITH RECURSIVE tag_tree(id, name, parent_id, path) AS ( - SELECT t.id, t.name, t.parent_id, t.name - FROM tags t - WHERE t.parent_id IS NULL - - UNION ALL - - SELECT t.id, t.name, t.parent_id, tt.path || '/' || t.name - FROM tags t - JOIN tag_tree tt ON t.parent_id = tt.id - ) - SELECT DISTINCT tag_tree.path as tag_path - FROM file_tags ft - JOIN tag_tree ON ft.tag_id = tag_tree.id - WHERE ft.file_id = NEW.id - - UNION - - SELECT t.name as tag_path - FROM file_tags ft - JOIN tags t ON ft.tag_id = t.id - WHERE ft.file_id = NEW.id AND t.parent_id IS NULL - )), - (SELECT IFNULL(GROUP_CONCAT(a.key || '=' || a.value, ' '), '') - FROM attributes a - WHERE a.file_id = NEW.id) - ); - END; - - CREATE TRIGGER file_tags_fts_ai - AFTER INSERT ON file_tags - BEGIN - INSERT OR REPLACE INTO files_fts(rowid, path, tags_text, attrs_text) - SELECT f.id, f.path, - (SELECT IFNULL(GROUP_CONCAT(tag_path, ' '), '') - FROM ( - WITH RECURSIVE tag_tree(id, name, parent_id, path) AS ( - SELECT t.id, t.name, t.parent_id, t.name - FROM tags t - WHERE t.parent_id IS NULL - - UNION ALL - - SELECT t.id, t.name, t.parent_id, tt.path || '/' || t.name - FROM tags t - JOIN tag_tree tt ON t.parent_id = tt.id - ) - SELECT DISTINCT tag_tree.path as tag_path - FROM file_tags ft - JOIN tag_tree ON ft.tag_id = tag_tree.id - WHERE ft.file_id = f.id - - UNION - - SELECT t.name as tag_path - FROM file_tags ft - JOIN tags t ON ft.tag_id = t.id - WHERE ft.file_id = f.id AND t.parent_id IS NULL - )), - (SELECT IFNULL(GROUP_CONCAT(a.key || '=' || a.value, ' '), '') - FROM attributes a - WHERE a.file_id = f.id) - FROM files f - WHERE f.id = NEW.file_id; - END;" - )?; - - // Insert test data - println!("Inserting test data..."); - - // Insert a test file - conn.execute( - "INSERT INTO files (id, path) VALUES (1, '/test/document.md')", - [], - )?; - - // Create hierarchical tags: project/md - println!("Creating hierarchical tags: project/md"); - - // Insert parent tag 'project' - conn.execute( - "INSERT INTO tags (id, name, parent_id) VALUES (1, 'project', NULL)", - [], - )?; - - // Insert child tag 'md' under 'project' - conn.execute( - "INSERT INTO tags (id, name, parent_id) VALUES (2, 'md', 1)", - [], - )?; - - // Tag the file with the 'md' tag (which is under 'project') - conn.execute( - "INSERT INTO file_tags (file_id, tag_id) VALUES (1, 2)", - [], - )?; - - // Check what's in the FTS index - println!("\nChecking FTS index content:"); - let mut stmt = conn.prepare("SELECT rowid, path, tags_text, attrs_text FROM files_fts")?; - let rows = stmt.query_map([], |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - )) - })?; - - for row in rows { - let (id, path, tags, attrs) = row?; - println!("ID: {}, Path: {}, Tags: '{}', Attrs: '{}'", id, path, tags, attrs); - } - - // Test searching for the full hierarchical tag path - println!("\nTesting search for 'project/md':"); - let mut stmt = conn.prepare("SELECT f.path FROM files_fts JOIN files f ON f.id = files_fts.rowid WHERE files_fts MATCH 'project/md'")?; - let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; - - let mut found = false; - for row in rows { - found = true; - println!("Found file: {}", row?); - } - - if !found { - println!("No files found with tag 'project/md'"); - } - - // Test searching for just the parent tag - println!("\nTesting search for just 'project':"); - let mut stmt = conn.prepare("SELECT f.path FROM files_fts JOIN files f ON f.id = files_fts.rowid WHERE files_fts MATCH 'project'")?; - let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; - - let mut found = false; - for row in rows { - found = true; - println!("Found file: {}", row?); - } - - if !found { - println!("No files found with tag 'project'"); - } - - // Test searching for just the child tag - println!("\nTesting search for just 'md':"); - let mut stmt = conn.prepare("SELECT f.path FROM files_fts JOIN files f ON f.id = files_fts.rowid WHERE files_fts MATCH 'md'")?; - let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; - - let mut found = false; - for row in rows { - found = true; - println!("Found file: {}", row?); - } - - if !found { - println!("No files found with tag 'md'"); - } - - println!("\nTest completed successfully!"); - Ok(()) -} diff --git a/target/release/marlin b/target/release/marlin index a571766..9786d99 100755 Binary files a/target/release/marlin and b/target/release/marlin differ diff --git a/target/release/marlin.d b/target/release/marlin.d index 391de91..8b1db83 100644 --- a/target/release/marlin.d +++ b/target/release/marlin.d @@ -1 +1 @@ -/home/user/Documents/GitHub/Marlin/target/release/marlin: /home/user/Documents/GitHub/Marlin/src/cli/annotate.rs /home/user/Documents/GitHub/Marlin/src/cli/coll.rs /home/user/Documents/GitHub/Marlin/src/cli/event.rs /home/user/Documents/GitHub/Marlin/src/cli/link.rs /home/user/Documents/GitHub/Marlin/src/cli/remind.rs /home/user/Documents/GitHub/Marlin/src/cli/state.rs /home/user/Documents/GitHub/Marlin/src/cli/task.rs /home/user/Documents/GitHub/Marlin/src/cli/version.rs /home/user/Documents/GitHub/Marlin/src/cli/view.rs /home/user/Documents/GitHub/Marlin/src/cli.rs /home/user/Documents/GitHub/Marlin/src/config.rs /home/user/Documents/GitHub/Marlin/src/db/migrations/0001_initial_schema.sql /home/user/Documents/GitHub/Marlin/src/db/migrations/0002_update_fts_and_triggers.sql /home/user/Documents/GitHub/Marlin/src/db/migrations/0003_create_links_collections_views.sql /home/user/Documents/GitHub/Marlin/src/db/migrations/0004_fix_hierarchical_tags_fts.sql /home/user/Documents/GitHub/Marlin/src/db/mod.rs /home/user/Documents/GitHub/Marlin/src/logging.rs /home/user/Documents/GitHub/Marlin/src/main.rs /home/user/Documents/GitHub/Marlin/src/scan.rs +/home/user/Documents/GitHub/Marlin/target/release/marlin: /home/user/Documents/GitHub/Marlin/cli-bin/build.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/annotate.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/coll.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/event.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/link.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/remind.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/state.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/task.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/version.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/view.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/main.rs /home/user/Documents/GitHub/Marlin/libmarlin/src/config.rs /home/user/Documents/GitHub/Marlin/libmarlin/src/db/migrations/0001_initial_schema.sql /home/user/Documents/GitHub/Marlin/libmarlin/src/db/migrations/0002_update_fts_and_triggers.sql /home/user/Documents/GitHub/Marlin/libmarlin/src/db/migrations/0003_create_links_collections_views.sql /home/user/Documents/GitHub/Marlin/libmarlin/src/db/migrations/0004_fix_hierarchical_tags_fts.sql /home/user/Documents/GitHub/Marlin/libmarlin/src/db/mod.rs /home/user/Documents/GitHub/Marlin/libmarlin/src/lib.rs /home/user/Documents/GitHub/Marlin/libmarlin/src/logging.rs /home/user/Documents/GitHub/Marlin/libmarlin/src/scan.rs /home/user/Documents/GitHub/Marlin/libmarlin/src/utils.rs diff --git a/tui-bin/Cargo.toml b/tui-bin/Cargo.toml new file mode 100644 index 0000000..f49c264 --- /dev/null +++ b/tui-bin/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "marlin-tui" +version = "0.1.0" +edition = "2021" + +# Build a binary called `marlin-tui` from src/main.rs +[[bin]] +name = "marlin-tui" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } +directories = "5" +glob = "0.3" +rusqlite = { version = "0.31", features = ["bundled", "backup"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +walkdir = "2.5" +shlex = "1.3" +chrono = "0.4" +shellexpand = "3.1" +clap_complete = "4.1" +serde_json = { version = "1", optional = true } + +[dev-dependencies] +assert_cmd = "2" +predicates = "3" +tempfile = "3" +dirs = "5" + +[features] +# Enable JSON output when requested. +json = ["serde_json"] diff --git a/tui-bin/src/main.rs b/tui-bin/src/main.rs new file mode 100644 index 0000000..23f104c --- /dev/null +++ b/tui-bin/src/main.rs @@ -0,0 +1,5 @@ +// tui-bin/src/main.rs + +fn main() { + eprintln!("marlin-tui is not yet implemented. Stay tuned!"); +}