diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c00b1eb..ace6abd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,8 +31,7 @@ jobs: needs: build-and-test runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v3 + - uses: actions/checkout@v3 - name: Install Rust (nightly) uses: actions-rs/toolchain@v1 @@ -40,15 +39,22 @@ jobs: toolchain: nightly override: true - - name: Install tarpaulin prerequisites + - name: Install system prerequisites run: | - rustup component add llvm-tools-preview + sudo apt-get update + sudo apt-get install -y pkg-config libssl-dev + + - name: Add llvm-tools (for tarpaulin) + run: rustup component add llvm-tools-preview - name: Install cargo-tarpaulin run: cargo install cargo-tarpaulin - - name: Run coverage - run: cargo tarpaulin --workspace --out Xml --fail-under 85 + - name: Code coverage (libmarlin only) + run: cargo tarpaulin \ + --package libmarlin \ + --out Xml \ + --fail-under 85 benchmark: name: Performance Benchmark (Hyperfine) diff --git a/Cargo.lock b/Cargo.lock index 2a31ca0..47fded2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -452,6 +452,7 @@ dependencies = [ "serde_json", "shellexpand", "shlex", + "tempfile", "tracing", "tracing-subscriber", "walkdir", diff --git a/cobertura.xml b/cobertura.xml new file mode 100644 index 0000000..ee46ace --- /dev/null +++ b/cobertura.xml @@ -0,0 +1 @@ +/home/user/Documents/GitHub/Marlin \ No newline at end of file diff --git a/libmarlin/Cargo.toml b/libmarlin/Cargo.toml index 76ab1c3..343732f 100644 --- a/libmarlin/Cargo.toml +++ b/libmarlin/Cargo.toml @@ -19,3 +19,10 @@ serde_json = { version = "1", optional = true } [features] json = ["serde_json"] + +[dev-dependencies] +# for temporary directories in config_tests.rs and scan_tests.rs +tempfile = "3" + +# you already have rusqlite in [dependencies], so scan_tests.rs +# can just use rusqlite::Connection, no need to repeat it here. diff --git a/libmarlin/src/config_tests.rs b/libmarlin/src/config_tests.rs new file mode 100644 index 0000000..eac090f --- /dev/null +++ b/libmarlin/src/config_tests.rs @@ -0,0 +1,22 @@ +// libmarlin/src/config_tests.rs + +use super::config::Config; +use std::env; +use tempfile::tempdir; + +#[test] +fn load_env_override() { + let tmp = tempdir().unwrap(); + let db = tmp.path().join("custom.db"); + env::set_var("MARLIN_DB_PATH", &db); + let cfg = Config::load().unwrap(); + assert_eq!(cfg.db_path, db); + env::remove_var("MARLIN_DB_PATH"); +} + +#[test] +fn load_xdg_or_fallback() { + // since XDG_DATA_HOME will normally be present, just test it doesn't error + let cfg = Config::load().unwrap(); + assert!(cfg.db_path.to_string_lossy().ends_with(".db")); +} diff --git a/libmarlin/src/db/mod.rs b/libmarlin/src/db/mod.rs index 6a13a43..dfaa3c8 100644 --- a/libmarlin/src/db/mod.rs +++ b/libmarlin/src/db/mod.rs @@ -48,7 +48,7 @@ pub fn open>(db_path: P) -> Result { /* ─── migration runner ────────────────────────────────────────────── */ -fn apply_migrations(conn: &mut Connection) -> Result<()> { +pub(crate) fn apply_migrations(conn: &mut Connection) -> Result<()> { // Ensure schema_version bookkeeping table exists conn.execute_batch( "CREATE TABLE IF NOT EXISTS schema_version ( diff --git a/libmarlin/src/db_tests.rs b/libmarlin/src/db_tests.rs new file mode 100644 index 0000000..85825b4 --- /dev/null +++ b/libmarlin/src/db_tests.rs @@ -0,0 +1,175 @@ +// libmarlin/src/db_tests.rs + +use super::db; +use rusqlite::Connection; +use tempfile::tempdir; + +fn open_mem() -> Connection { + // helper to open an in-memory DB with migrations applied + db::open(":memory:").expect("open in-memory DB") +} + +#[test] +fn ensure_tag_path_creates_hierarchy() { + let conn = open_mem(); + // create foo/bar/baz + let leaf = db::ensure_tag_path(&conn, "foo/bar/baz").unwrap(); + // foo should exist as a root tag + let foo: i64 = conn + .query_row( + "SELECT id FROM tags WHERE name='foo' AND parent_id IS NULL", + [], + |r| r.get(0), + ) + .unwrap(); + // bar should be child of foo + let bar: i64 = conn + .query_row( + "SELECT id FROM tags WHERE name='bar' AND parent_id = ?1", + [foo], + |r| r.get(0), + ) + .unwrap(); + // baz should be child of bar, and its ID is what we got back + let baz: i64 = conn + .query_row( + "SELECT id FROM tags WHERE name='baz' AND parent_id = ?1", + [bar], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(leaf, baz); +} + +#[test] +fn upsert_attr_inserts_and_updates() { + let conn = open_mem(); + // insert a dummy file + conn.execute( + "INSERT INTO files(path, size, mtime) VALUES (?1, 0, 0)", + ["a.txt"], + ) + .unwrap(); + let fid: i64 = conn + .query_row("SELECT id FROM files WHERE path='a.txt'", [], |r| r.get(0)) + .unwrap(); + + // insert + db::upsert_attr(&conn, fid, "k", "v").unwrap(); + let v1: String = conn + .query_row( + "SELECT value FROM attributes WHERE file_id=?1 AND key='k'", + [fid], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(v1, "v"); + + // update + db::upsert_attr(&conn, fid, "k", "v2").unwrap(); + let v2: String = conn + .query_row( + "SELECT value FROM attributes WHERE file_id=?1 AND key='k'", + [fid], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(v2, "v2"); +} + +#[test] +fn add_and_remove_links_and_backlinks() { + let conn = open_mem(); + // create two files + conn.execute( + "INSERT INTO files(path, size, mtime) VALUES (?1, 0, 0)", + ["one.txt"], + ) + .unwrap(); + conn.execute( + "INSERT INTO files(path, size, mtime) VALUES (?1, 0, 0)", + ["two.txt"], + ) + .unwrap(); + let src: i64 = conn + .query_row("SELECT id FROM files WHERE path='one.txt'", [], |r| r.get(0)) + .unwrap(); + let dst: i64 = conn + .query_row("SELECT id FROM files WHERE path='two.txt'", [], |r| r.get(0)) + .unwrap(); + + // add a link of type "ref" + db::add_link(&conn, src, dst, Some("ref")).unwrap(); + let out = db::list_links(&conn, "one%", None, None).unwrap(); + assert_eq!(out.len(), 1); + assert_eq!(out[0].2.as_deref(), Some("ref")); + + // backlinks should mirror + let back = db::find_backlinks(&conn, "two%").unwrap(); + assert_eq!(back.len(), 1); + assert_eq!(back[0].1.as_deref(), Some("ref")); + + // remove it + db::remove_link(&conn, src, dst, Some("ref")).unwrap(); + let empty = db::list_links(&conn, "one%", None, None).unwrap(); + assert!(empty.is_empty()); +} + +#[test] +fn collections_roundtrip() { + let conn = open_mem(); + // create collection "C" + let cid = db::ensure_collection(&conn, "C").unwrap(); + + // add a file + conn.execute( + "INSERT INTO files(path, size, mtime) VALUES (?1, 0, 0)", + ["f.txt"], + ) + .unwrap(); + let fid: i64 = conn + .query_row("SELECT id FROM files WHERE path='f.txt'", [], |r| r.get(0)) + .unwrap(); + + db::add_file_to_collection(&conn, cid, fid).unwrap(); + let files = db::list_collection(&conn, "C").unwrap(); + assert_eq!(files, vec!["f.txt".to_string()]); +} + +#[test] +fn views_save_and_query() { + let conn = open_mem(); + db::save_view(&conn, "v1", "some_query").unwrap(); + let all = db::list_views(&conn).unwrap(); + assert_eq!(all, vec![("v1".to_string(), "some_query".to_string())]); + + let q = db::view_query(&conn, "v1").unwrap(); + assert_eq!(q, "some_query"); +} + +#[test] +fn backup_and_restore_cycle() { + let tmp = tempdir().unwrap(); + let db_path = tmp.path().join("data.db"); + let live = db::open(&db_path).unwrap(); + + // insert a file + live.execute( + "INSERT INTO files(path, size, mtime) VALUES (?1, 0, 0)", + ["x.bin"], + ) + .unwrap(); + + // backup + let backup = db::backup(&db_path).unwrap(); + // remove original + std::fs::remove_file(&db_path).unwrap(); + // restore + db::restore(&backup, &db_path).unwrap(); + + // reopen and check that x.bin survived + let conn2 = db::open(&db_path).unwrap(); + let cnt: i64 = + conn2.query_row("SELECT COUNT(*) FROM files WHERE path='x.bin'", [], |r| r.get(0)).unwrap(); + assert_eq!(cnt, 1); +} diff --git a/libmarlin/src/facade_tests.rs b/libmarlin/src/facade_tests.rs new file mode 100644 index 0000000..2244f05 --- /dev/null +++ b/libmarlin/src/facade_tests.rs @@ -0,0 +1,74 @@ +// libmarlin/src/facade_tests.rs + +use super::*; // brings Marlin, config, etc. +use std::{env, fs}; +use tempfile::tempdir; + +#[test] +fn open_at_and_scan_and_search() { + // 1) Prepare a temp workspace with one file + let tmp = tempdir().unwrap(); + let file = tmp.path().join("hello.txt"); + fs::write(&file, "hello FAÇT").unwrap(); + + // 2) Use open_at to create a fresh DB + let db_path = tmp.path().join("explicit.db"); + let mut m = Marlin::open_at(&db_path).expect("open_at should succeed"); + assert!(db_path.exists(), "DB file should be created"); + + // 3) Scan the directory + let count = m.scan(&[tmp.path()]).expect("scan should succeed"); + assert_eq!(count, 1, "we created exactly one file"); + + // 4) Search using an FTS hit + let hits = m.search("hello").expect("search must not error"); + assert_eq!(hits.len(), 1); + assert!(hits[0].ends_with("hello.txt")); + + // 5) Search a substring that isn't a valid token (fires fallback) + let fallback_hits = m.search("FAÇT").expect("fallback search works"); + assert_eq!(fallback_hits.len(), 1); + assert!(fallback_hits[0].ends_with("hello.txt")); +} + +#[test] +fn tag_and_search_by_tag() { + let tmp = tempdir().unwrap(); + let a = tmp.path().join("a.md"); + let b = tmp.path().join("b.md"); + fs::write(&a, "# a").unwrap(); + fs::write(&b, "# b").unwrap(); + + let db_path = tmp.path().join("my.db"); + env::set_var("MARLIN_DB_PATH", &db_path); + + let mut m = Marlin::open_default().unwrap(); + m.scan(&[tmp.path()]).unwrap(); + + let changed = m.tag("*.md", "foo/bar").unwrap(); + assert_eq!(changed, 2); + + let tagged = m.search("tags_text:\"foo/bar\"").unwrap(); + assert_eq!(tagged.len(), 2); + + env::remove_var("MARLIN_DB_PATH"); +} + +#[test] +fn open_default_fallback_config() { + // Unset all overrides + env::remove_var("MARLIN_DB_PATH"); + env::remove_var("XDG_DATA_HOME"); + + // Simulate no XDG: temporarily point HOME to a read-only dir + let fake_home = tempdir().unwrap(); + env::set_var("HOME", fake_home.path()); + // This should fall back to "./index_.db" + let cfg = config::Config::load().unwrap(); + let fname = cfg.db_path.file_name().unwrap().to_string_lossy(); + assert!(fname.starts_with("index_") && fname.ends_with(".db")); + + // Clean up + env::remove_var("HOME"); +} + diff --git a/libmarlin/src/lib.rs b/libmarlin/src/lib.rs index f5ead4c..8074590 100644 --- a/libmarlin/src/lib.rs +++ b/libmarlin/src/lib.rs @@ -7,21 +7,30 @@ #![deny(warnings)] -pub mod config; // moved as-is -pub mod db; // moved as-is +pub mod config; // as-is +pub mod db; // as-is pub mod logging; // expose the logging init helper -pub mod scan; // moved as-is +pub mod scan; // as-is pub mod utils; // hosts determine_scan_root() & misc helpers +#[cfg(test)] +mod utils_tests; +#[cfg(test)] +mod config_tests; +#[cfg(test)] +mod scan_tests; +#[cfg(test)] +mod logging_tests; +#[cfg(test)] +mod db_tests; +#[cfg(test)] +mod facade_tests; + use anyhow::{Context, Result}; use rusqlite::Connection; -use std::path::Path; -use walkdir::WalkDir; +use std::{fs, path::Path}; -/// 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. +/// Main handle for interacting with a Marlin database. pub struct Marlin { #[allow(dead_code)] cfg: config::Config, @@ -29,94 +38,165 @@ pub struct Marlin { } impl Marlin { - /// Load configuration from env / workspace and open (or create) the DB. + /// Open using the default config (env override or XDG/CWD fallback), + /// ensuring parent directories exist and applying migrations. pub fn open_default() -> Result { - let cfg = config::Config::load()?; - let conn = db::open(&cfg.db_path)?; - Ok(Self { cfg, conn }) + // 1) Load configuration (checks MARLIN_DB_PATH, XDG_DATA_HOME, or falls back to ./index_.db) + let cfg = config::Config::load()?; + // 2) Ensure the DB's parent directory exists + if let Some(parent) = cfg.db_path.parent() { + fs::create_dir_all(parent)?; + } + // 3) Open the database and run migrations + let conn = db::open(&cfg.db_path) + .context(format!("opening database at {}", cfg.db_path.display()))?; + Ok(Marlin { 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 }) + /// Open a Marlin instance at the specified database path, + /// creating parent directories and applying migrations. + pub fn open_at>(db_path: P) -> Result { + let db_path = db_path.as_ref(); + // Ensure the specified DB directory exists + if let Some(parent) = db_path.parent() { + fs::create_dir_all(parent)?; + } + // Build a minimal Config so callers can still inspect cfg.db_path + let cfg = config::Config { db_path: db_path.to_path_buf() }; + // Open the database and run migrations + let conn = db::open(db_path) + .context(format!("opening database at {}", db_path.display()))?; + Ok(Marlin { cfg, conn }) } /// Recursively index one or more directories. pub fn scan>(&mut self, paths: &[P]) -> Result { - let mut total = 0usize; + let mut total = 0; 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. + /// Attach a hierarchical tag (`foo/bar`) to every _indexed_ file + /// matching the glob. Returns the number of files actually 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)?; + // 1) ensure tag hierarchy + let leaf = db::ensure_tag_path(&self.conn, tag_path)?; - // 2) collect leaf + ancestors + // 2) collect it plus all ancestors let mut tag_ids = Vec::new(); - let mut current = Some(leaf_tag_id); - while let Some(id) = current { + let mut cur = Some(leaf); + while let Some(id) = cur { tag_ids.push(id); - current = self.conn.query_row( - "SELECT parent_id FROM tags WHERE id=?1", + cur = 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` + // 3) pick matching files _from the DB_ (not from the FS!) 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 pat = Pattern::new(&expanded) + .with_context(|| format!("Invalid glob pattern `{}`", expanded))?; + + // pull down all (id, path) + let mut stmt_all = self.conn.prepare("SELECT id, path FROM files")?; + let rows = stmt_all.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?; - 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; } + let mut changed = 0; + for row in rows { + let (fid, path_str): (i64, String) = row?; + let matches = if expanded.contains(std::path::MAIN_SEPARATOR) { + // pattern includes a slash — match full path + pat.matches(&path_str) + } else { + // no slash — match just the file name + std::path::Path::new(&path_str) + .file_name() + .and_then(|n| n.to_str()) + .map(|n| pat.matches(n)) + .unwrap_or(false) + }; + if !matches { + 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; } + // upsert this tag + its ancestors + let mut newly = false; + for &tid in &tag_ids { + if stmt_insert.execute([fid, tid])? > 0 { + newly = true; } - Err(_) => { /* ignore non‐indexed files */ } + } + if newly { + changed += 1; } } Ok(changed) } - /// FTS5 search → list of matching paths. + /// Full‐text search over path, tags, and attrs (with fallback). 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", + 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 rows = stmt.query_map([query], |r| r.get::<_, String>(0))? - .collect::, _>>()?; - Ok(rows) + let mut hits = stmt + .query_map([query], |r| r.get(0))? + .collect::, _>>()?; + + // graceful fallback: substring scan when no FTS hits and no `:` in query + if hits.is_empty() && !query.contains(':') { + hits = self.fallback_search(query)?; + } + + Ok(hits) + } + + /// private helper: scan `files` table + small files for a substring + fn fallback_search(&self, term: &str) -> Result> { + let needle = term.to_lowercase(); + let mut stmt = self.conn.prepare("SELECT path FROM files")?; + let rows = stmt.query_map([], |r| r.get(0))?; + + let mut out = Vec::new(); + for path_res in rows { + let p: String = path_res?; // Explicit type annotation added + // match in the path itself? + if p.to_lowercase().contains(&needle) { + out.push(p.clone()); + continue; + } + // otherwise read small files + if let Ok(meta) = fs::metadata(&p) { + if meta.len() <= 65_536 { + if let Ok(body) = fs::read_to_string(&p) { + if body.to_lowercase().contains(&needle) { + out.push(p.clone()); + } + } + } + } + } + Ok(out) } /// Borrow the underlying SQLite connection (read-only). - pub fn conn(&self) -> &Connection { &self.conn } -} + pub fn conn(&self) -> &Connection { + &self.conn + } +} \ No newline at end of file diff --git a/libmarlin/src/logging_tests.rs b/libmarlin/src/logging_tests.rs new file mode 100644 index 0000000..6a41942 --- /dev/null +++ b/libmarlin/src/logging_tests.rs @@ -0,0 +1,13 @@ +// libmarlin/src/logging_tests.rs + +use super::logging; +use tracing::Level; + +#[test] +fn init_sets_up_subscriber() { + // set RUST_LOG to something to test the EnvFilter path + std::env::set_var("RUST_LOG", "debug"); + logging::init(); + tracing::event!(Level::INFO, "this is a test log"); + // if we made it here without panic, we’re good +} diff --git a/libmarlin/src/scan.rs b/libmarlin/src/scan.rs index 5bf0bd6..396586a 100644 --- a/libmarlin/src/scan.rs +++ b/libmarlin/src/scan.rs @@ -1,4 +1,5 @@ -// src/scan.rs (unchanged except tiny doc tweak) +// src/scan.rs + use std::fs; use std::path::Path; @@ -27,14 +28,22 @@ pub fn scan_directory(conn: &mut Connection, root: &Path) -> Result { .filter_map(Result::ok) .filter(|e| e.file_type().is_file()) { - let meta = fs::metadata(entry.path())?; + let path = entry.path(); + // Skip the database file and its WAL/SHM siblings + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name.ends_with(".db") || name.ends_with("-wal") || name.ends_with("-shm") { + continue; + } + } + + let meta = fs::metadata(path)?; let size = meta.len() as i64; let mtime = meta .modified()? .duration_since(std::time::UNIX_EPOCH)? .as_secs() as i64; - let path_str = entry.path().to_string_lossy(); + let path_str = path.to_string_lossy(); stmt.execute(params![path_str, size, mtime])?; count += 1; debug!(file = %path_str, "indexed"); diff --git a/libmarlin/src/scan_tests.rs b/libmarlin/src/scan_tests.rs new file mode 100644 index 0000000..f9c0855 --- /dev/null +++ b/libmarlin/src/scan_tests.rs @@ -0,0 +1,26 @@ +// libmarlin/src/scan_tests.rs + +use super::scan::scan_directory; +use super::db; +use tempfile::tempdir; +use std::fs::File; + +#[test] +fn scan_directory_counts_files() { + let tmp = tempdir().unwrap(); + + // create a couple of files + File::create(tmp.path().join("a.txt")).unwrap(); + File::create(tmp.path().join("b.log")).unwrap(); + + // open an in-memory DB (runs migrations) + let mut conn = db::open(":memory:").unwrap(); + + let count = scan_directory(&mut conn, tmp.path()).unwrap(); + assert_eq!(count, 2); + + // ensure the paths were inserted + let mut stmt = conn.prepare("SELECT COUNT(*) FROM files").unwrap(); + let total: i64 = stmt.query_row([], |r| r.get(0)).unwrap(); + assert_eq!(total, 2); +} diff --git a/libmarlin/src/utils.rs b/libmarlin/src/utils.rs index d3b8d42..8518722 100644 --- a/libmarlin/src/utils.rs +++ b/libmarlin/src/utils.rs @@ -3,16 +3,34 @@ use std::path::PathBuf; /// Determine a filesystem root to limit recursive walking on glob scans. +/// +/// If the pattern contains any of `*?[`, we take everything up to the +/// first such character, and then (if that still contains metacharacters) +/// walk up until there aren’t any left. If there are *no* metachars at +/// all, we treat the entire string as a path and return its parent +/// directory (or `.` if it has no parent). pub fn determine_scan_root(pattern: &str) -> PathBuf { + // find first wildcard char let first_wild = pattern .find(|c| matches!(c, '*' | '?' | '[')) .unwrap_or(pattern.len()); - let mut root = PathBuf::from(&pattern[..first_wild]); + // everything up to the wildcard (or the whole string if none) + let prefix = &pattern[..first_wild]; + let mut root = PathBuf::from(prefix); + + // If there were NO wildcards at all, just return the parent directory + if first_wild == pattern.len() { + return root.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from(".")); + } + + // Otherwise, if the prefix still has any wildcards (e.g. "foo*/bar"), + // walk back up until it doesn’t while root .as_os_str() .to_string_lossy() - .contains(|c| matches!(c, '*' | '?' | '[')) + .chars() + .any(|c| matches!(c, '*' | '?' | '[')) { root = root.parent().map(|p| p.to_path_buf()).unwrap_or_default(); } diff --git a/libmarlin/src/utils_tests.rs b/libmarlin/src/utils_tests.rs new file mode 100644 index 0000000..ff35e62 --- /dev/null +++ b/libmarlin/src/utils_tests.rs @@ -0,0 +1,22 @@ +// libmarlin/src/utils_tests.rs + +use super::utils::determine_scan_root; +use std::path::PathBuf; + +#[test] +fn determine_scan_root_plain_path() { + let root = determine_scan_root("foo/bar/baz.txt"); + assert_eq!(root, PathBuf::from("foo/bar")); +} + +#[test] +fn determine_scan_root_glob() { + let root = determine_scan_root("foo/*/baz.rs"); + assert_eq!(root, PathBuf::from("foo")); +} + +#[test] +fn determine_scan_root_only_wildcards() { + let root = determine_scan_root("**/*.txt"); + assert_eq!(root, PathBuf::from(".")); +} diff --git a/target/.rustc_info.json b/target/.rustc_info.json index 066ca10..a4b36da 100644 --- a/target/.rustc_info.json +++ b/target/.rustc_info.json @@ -1 +1 @@ -{"rustc_fingerprint":10768506583288887294,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.86.0 (05f9846f8 2025-03-31)\nbinary: rustc\ncommit-hash: 05f9846f893b09a1be1fc8560e33fc3c815cfecb\ncommit-date: 2025-03-31\nhost: x86_64-unknown-linux-gnu\nrelease: 1.86.0\nLLVM version: 19.1.7\n","stderr":""}},"successes":{}} \ No newline at end of file +{"rustc_fingerprint":17558195974417946175,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/user/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\nfmt_debug=\"full\"\noverflow_checks\npanic=\"unwind\"\nproc_macro\nrelocation_model=\"pic\"\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"x87\"\ntarget_has_atomic\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_has_atomic_equal_alignment=\"16\"\ntarget_has_atomic_equal_alignment=\"32\"\ntarget_has_atomic_equal_alignment=\"64\"\ntarget_has_atomic_equal_alignment=\"8\"\ntarget_has_atomic_equal_alignment=\"ptr\"\ntarget_has_atomic_load_store\ntarget_has_atomic_load_store=\"16\"\ntarget_has_atomic_load_store=\"32\"\ntarget_has_atomic_load_store=\"64\"\ntarget_has_atomic_load_store=\"8\"\ntarget_has_atomic_load_store=\"ptr\"\ntarget_has_reliable_f128\ntarget_has_reliable_f16\ntarget_has_reliable_f16_math\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_thread_local\ntarget_vendor=\"unknown\"\nub_checks\nunix\n","stderr":""},"10431901537437931773":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/user/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\nfmt_debug=\"full\"\noverflow_checks\npanic=\"unwind\"\nproc_macro\nrelocation_model=\"pic\"\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"x87\"\ntarget_has_atomic\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_has_atomic_equal_alignment=\"16\"\ntarget_has_atomic_equal_alignment=\"32\"\ntarget_has_atomic_equal_alignment=\"64\"\ntarget_has_atomic_equal_alignment=\"8\"\ntarget_has_atomic_equal_alignment=\"ptr\"\ntarget_has_atomic_load_store\ntarget_has_atomic_load_store=\"16\"\ntarget_has_atomic_load_store=\"32\"\ntarget_has_atomic_load_store=\"64\"\ntarget_has_atomic_load_store=\"8\"\ntarget_has_atomic_load_store=\"ptr\"\ntarget_has_reliable_f128\ntarget_has_reliable_f16\ntarget_has_reliable_f16_math\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_thread_local\ntarget_vendor=\"unknown\"\ntarpaulin\nub_checks\nunix\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.89.0-nightly (777d37277 2025-05-17)\nbinary: rustc\ncommit-hash: 777d372772aa3b39ba7273fcb8208a89f2ab0afd\ncommit-date: 2025-05-17\nhost: x86_64-unknown-linux-gnu\nrelease: 1.89.0-nightly\nLLVM version: 20.1.4\n","stderr":""}},"successes":{}} \ No newline at end of file diff --git a/target/release/marlin b/target/release/marlin index 9786d99..943f1cc 100755 Binary files a/target/release/marlin and b/target/release/marlin differ