//! End-to-end phase-1 tests over a real tree and a real database. //! //! These cover the failure mode that unit tests structurally cannot: a full //! run deletes index rows for every path it did not see, so any walk that //! quietly reports less than it should destroys data. That damage is //! invisible on a first index — `existing_files` is empty, so nothing is //! stale — and only appears on the second run. use std::path::{Path, PathBuf}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use quicksearch_core::config::Config; use quicksearch_core::indexing::{IndexingService, IndexingStatus}; fn tmp_dir(tag: &str) -> PathBuf { let mut p = std::env::temp_dir(); p.push(format!( "quicksearch-e2e-{}-{}-{}", tag, std::process::id(), SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() )); std::fs::create_dir_all(&p).unwrap(); p } fn touch(p: &Path, body: &[u8]) { std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(p, body).unwrap(); } /// Run one full index and wait for it to finish. /// /// Completion is detected via the `last_full_index` marker, which /// `run_indexing` writes only on a successful finish. Polling the status /// enum instead would race: a small tree finishes between two polls, so /// `Idle` is ambiguous between "not started yet" and "already done". fn index_once(root: &Path, db: &Path, config: &Config) { if db.exists() { let conn = rusqlite::Connection::open(db).unwrap(); conn.execute("DELETE FROM schema_info WHERE key = 'last_full_index'", []) .unwrap(); } let service = IndexingService::new(); service .start_indexing( vec![root.to_string_lossy().into_owned()], db.to_string_lossy().into_owned(), config.clone(), ) .unwrap(); let deadline = Instant::now() + Duration::from_secs(120); let mut done = false; while Instant::now() < deadline { if let IndexingStatus::Error(e) = service.get_status() { panic!("indexing failed: {}", e); } if db.exists() { if let Ok(conn) = rusqlite::Connection::open(db) { if quicksearch_core::db::repo::get_last_full_index(&conn).is_some() { done = true; break; } } } std::thread::sleep(Duration::from_millis(10)); } assert!(done, "indexing did not finish within the timeout"); service.stop_indexing().unwrap(); } /// (path, mtime, content_state) for every indexed row, ordered by path. fn rows(db: &Path) -> Vec<(String, i64, i64)> { let conn = rusqlite::Connection::open(db).unwrap(); let mut stmt = conn .prepare("SELECT path, mtime, content_state FROM files ORDER BY path") .unwrap(); let out = stmt .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))) .unwrap() .map(|r| r.unwrap()) .collect(); out } fn test_config() -> Config { let config = Config::default(); // Keep the run to phase 1 semantics we're asserting on; extraction is // covered elsewhere. config } #[test] fn reindexing_an_unchanged_tree_changes_nothing() { let root = tmp_dir("stable"); let db_dir = tmp_dir("stable-db"); let db = db_dir.join("index.sqlite"); let config = test_config(); touch(&root.join("a.txt"), b"alpha"); touch(&root.join("sub/b.txt"), b"bravo"); touch(&root.join("sub/deep/c.txt"), b"charlie"); touch(&root.join("other/d.md"), b"delta"); index_once(&root, &db, &config); let first = rows(&db); assert_eq!(first.len(), 4, "all four files indexed"); index_once(&root, &db, &config); let second = rows(&db); // The whole point: a second run over an unchanged tree must not delete // and re-insert anything. A wiped-and-rebuilt row would come back with // content_state reset, throwing away extracted text for no reason. assert_eq!(first, second, "an unchanged tree must re-index to an identical set"); std::fs::remove_dir_all(&root).ok(); std::fs::remove_dir_all(&db_dir).ok(); } #[test] fn deleted_files_are_removed_and_new_ones_added() { let root = tmp_dir("churn"); let db_dir = tmp_dir("churn-db"); let db = db_dir.join("index.sqlite"); let config = test_config(); touch(&root.join("keep.txt"), b"keep"); touch(&root.join("remove.txt"), b"remove"); index_once(&root, &db, &config); assert_eq!(rows(&db).len(), 2); std::fs::remove_file(root.join("remove.txt")).unwrap(); touch(&root.join("added.txt"), b"added"); index_once(&root, &db, &config); let names: Vec = rows(&db) .into_iter() .map(|(p, _, _)| Path::new(&p).file_name().unwrap().to_string_lossy().into_owned()) .collect(); assert_eq!(names, vec!["added.txt", "keep.txt"], "stale cleanup still works"); std::fs::remove_dir_all(&root).ok(); std::fs::remove_dir_all(&db_dir).ok(); } #[test] fn a_modified_file_is_updated_in_place() { let root = tmp_dir("modify"); let db_dir = tmp_dir("modify-db"); let db = db_dir.join("index.sqlite"); let config = test_config(); let target = root.join("doc.txt"); touch(&target, b"first"); index_once(&root, &db, &config); let before = rows(&db); assert_eq!(before.len(), 1); // Filesystem mtime has one-second granularity in the stored value, so // move it decisively rather than racing it. touch(&target, b"second body, clearly different"); let later = SystemTime::now() + Duration::from_secs(5); filetime_set(&target, later); index_once(&root, &db, &config); let after = rows(&db); assert_eq!(after.len(), 1, "still exactly one row"); assert_ne!(before[0].1, after[0].1, "mtime was refreshed"); assert_eq!(before[0].0, after[0].0, "same path"); std::fs::remove_dir_all(&root).ok(); std::fs::remove_dir_all(&db_dir).ok(); } /// Set a file's mtime without pulling in a dependency for it. fn filetime_set(path: &Path, when: SystemTime) { let f = std::fs::OpenOptions::new().write(true).open(path).unwrap(); f.set_modified(when).unwrap(); f.sync_all().unwrap(); } #[test] #[cfg(unix)] fn an_unreadable_directory_does_not_delete_its_rows() { // The scenario this guards: a network share or removable drive that is // briefly unavailable. The walk sees nothing beneath it, which must not // be read as "every file under here was deleted". use std::os::unix::fs::PermissionsExt; let root = tmp_dir("blip"); let db_dir = tmp_dir("blip-db"); let db = db_dir.join("index.sqlite"); let config = test_config(); touch(&root.join("visible.txt"), b"visible"); let vault = root.join("vault"); touch(&vault.join("secret.txt"), b"secret"); touch(&vault.join("nested/deeper.txt"), b"deeper"); index_once(&root, &db, &config); assert_eq!(rows(&db).len(), 3, "all three indexed while readable"); std::fs::set_permissions(&vault, std::fs::Permissions::from_mode(0o000)).unwrap(); index_once(&root, &db, &config); let during = rows(&db); std::fs::set_permissions(&vault, std::fs::Permissions::from_mode(0o755)).unwrap(); assert_eq!( during.len(), 3, "rows under an unreadable directory must survive, not be deleted" ); // And once it is readable again, everything still lines up. index_once(&root, &db, &config); assert_eq!(rows(&db).len(), 3); std::fs::remove_dir_all(&root).ok(); std::fs::remove_dir_all(&db_dir).ok(); } #[test] fn stopping_mid_run_deletes_nothing() { // Pins the end-to-end property: an interrupted run must never delete the // rows it did not reach. // // Two independent guards currently provide it — `run_indexing` skips // cleanup when the walk did not complete, and `cleanup_stale_index_entries` // re-checks the stop flag before its first delete. This test passes with // either one alone, so it does not prove the former is present; it is here // to catch the day someone removes the last of them. let root = tmp_dir("stop"); let db_dir = tmp_dir("stop-db"); let db = db_dir.join("index.sqlite"); let config = test_config(); for i in 0..1500 { touch(&root.join(format!("d{}/f{:04}.txt", i % 25, i)), b"body"); } index_once(&root, &db, &config); let full = rows(&db); assert_eq!(full.len(), 1500); // Start again and stop almost immediately, so the walk is cut short. let service = IndexingService::new(); service .start_indexing( vec![root.to_string_lossy().into_owned()], db.to_string_lossy().into_owned(), config.clone(), ) .unwrap(); std::thread::sleep(Duration::from_millis(15)); service.stop_indexing().unwrap(); drop(service); std::thread::sleep(Duration::from_millis(250)); let after = rows(&db); assert_eq!( after.len(), 1500, "an interrupted run must not delete the rows it never got to" ); std::fs::remove_dir_all(&root).ok(); std::fs::remove_dir_all(&db_dir).ok(); } #[test] fn a_wide_tree_indexes_every_file_exactly_once() { // Exercises the parallel walk's chunking and termination against a real // database, where a duplicate path would be a UNIQUE violation and a // dropped path would be a missing row. let root = tmp_dir("wide"); let db_dir = tmp_dir("wide-db"); let db = db_dir.join("index.sqlite"); let config = test_config(); let count = 900; for i in 0..count { touch(&root.join(format!("d{}/f{:04}.txt", i % 13, i)), b"body"); } index_once(&root, &db, &config); assert_eq!(rows(&db).len(), count, "every file indexed exactly once"); index_once(&root, &db, &config); assert_eq!(rows(&db).len(), count, "and the second run is stable"); std::fs::remove_dir_all(&root).ok(); std::fs::remove_dir_all(&db_dir).ok(); } /// Like `index_once`, but over several roots at once — the per-root /// pipeline path. fn index_roots_once(roots: &[&Path], db: &Path, config: &Config) { if db.exists() { let conn = rusqlite::Connection::open(db).unwrap(); conn.execute("DELETE FROM schema_info WHERE key = 'last_full_index'", []) .unwrap(); } let service = IndexingService::new(); service .start_indexing( roots.iter().map(|r| r.to_string_lossy().into_owned()).collect(), db.to_string_lossy().into_owned(), config.clone(), ) .unwrap(); let deadline = Instant::now() + Duration::from_secs(120); let mut done = false; while Instant::now() < deadline { if let IndexingStatus::Error(e) = service.get_status() { panic!("indexing failed: {}", e); } if db.exists() { if let Ok(conn) = rusqlite::Connection::open(db) { if quicksearch_core::db::repo::get_last_full_index(&conn).is_some() { done = true; break; } } } std::thread::sleep(Duration::from_millis(10)); } assert!(done, "indexing did not finish within the timeout"); service.stop_indexing().unwrap(); } #[test] fn two_roots_walk_extract_and_clean_independently() { let root_a = tmp_dir("multi-a"); let root_b = tmp_dir("multi-b"); let db_dir = tmp_dir("multi-db"); let db = db_dir.join("index.sqlite"); let config = test_config(); // Imbalanced roots so the round-robin writer sees a firehose and a // trickle in the same run. for i in 0..60 { touch(&root_a.join(format!("a{:03}.txt", i)), b"alpha corpus xylophone"); } for i in 0..5 { touch(&root_b.join(format!("b{:03}.txt", i)), b"bravo corpus quagmire"); } index_roots_once(&[&root_a, &root_b], &db, &config); let conn = rusqlite::Connection::open(&db).unwrap(); let total: i64 = conn .query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0)) .unwrap(); assert_eq!(total, 65, "both roots fully walked"); let pending: i64 = conn .query_row("SELECT COUNT(*) FROM files WHERE content_state = 0", [], |r| r.get(0)) .unwrap(); assert_eq!(pending, 0, "per-root extraction drained both roots"); // Content from EACH root is searchable. for term in ["\"xylophone\"", "\"quagmire\""] { let hits: i64 = conn .query_row( "SELECT COUNT(*) FROM searchabletext WHERE searchabletext MATCH ?1", [term], |r| r.get(0), ) .unwrap(); assert!(hits > 0, "content from both roots must be indexed ({})", term); } drop(conn); // Stale cleanup is global: deleting a file from the trickle root must // remove exactly that row on the next multi-root run. std::fs::remove_file(root_b.join("b000.txt")).unwrap(); index_roots_once(&[&root_a, &root_b], &db, &config); let conn = rusqlite::Connection::open(&db).unwrap(); let total: i64 = conn .query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0)) .unwrap(); assert_eq!(total, 64, "stale row swept across roots"); std::fs::remove_dir_all(&root_a).ok(); std::fs::remove_dir_all(&root_b).ok(); std::fs::remove_dir_all(&db_dir).ok(); } // --------------------------------------------------------------------------- // Reconciliation without a global path set. // // Classification and stale detection are per-directory: a worker diffs one // directory's listing against that directory's index rows. These cover the // cases that arrangement cannot see from inside a single directory read. // --------------------------------------------------------------------------- /// A directory deleted wholesale is never read, so per-directory /// reconciliation never runs for it. Only the sweep over stored parents finds /// the rows underneath. #[test] fn a_deleted_directory_takes_its_whole_subtree_out_of_the_index() { let root = tmp_dir("gone-dir"); let db_dir = tmp_dir("gone-dir-db"); let db = db_dir.join("index.sqlite"); let config = test_config(); touch(&root.join("keep.txt"), b"stays"); touch(&root.join("doomed/a.txt"), b"goes"); touch(&root.join("doomed/b.txt"), b"goes"); // Nested, so the sweep has to reach a parent two levels below the root. touch(&root.join("doomed/deeper/c.txt"), b"goes too"); index_once(&root, &db, &config); assert_eq!(rows(&db).len(), 4, "all four indexed"); std::fs::remove_dir_all(root.join("doomed")).unwrap(); index_once(&root, &db, &config); let names: Vec = rows(&db) .into_iter() .map(|(p, _, _)| Path::new(&p).file_name().unwrap().to_string_lossy().into_owned()) .collect(); assert_eq!(names, vec!["keep.txt"], "the whole subtree is swept"); std::fs::remove_dir_all(&root).ok(); std::fs::remove_dir_all(&db_dir).ok(); } /// A symlink target whose own directory the walk never enters. /// /// Two flavours, and only one of them exercises the alias exemption: /// /// - A target *outside* every root is already safe, because the sweep only /// scans parents within a root's path range. /// - A target inside the root but under a *pruned* directory — hidden here — /// has a parent that is in range and legitimately absent from `seen_dirs`. /// Nothing but the record that the file itself was seen distinguishes it /// from a row whose directory was deleted. #[test] #[cfg(unix)] fn a_symlink_target_in_an_unwalked_directory_survives_reindexing() { let root = tmp_dir("alias-root"); let outside = tmp_dir("alias-outside"); let db_dir = tmp_dir("alias-db"); let db = db_dir.join("index.sqlite"); let config = test_config(); touch(&root.join("normal.txt"), b"inside the root"); // In range, but under a hidden directory the walk prunes. let hidden_target = root.join(".pruned/inner.txt"); touch(&hidden_target, b"only reachable through the link"); std::os::unix::fs::symlink(&hidden_target, root.join("hidden_link.txt")).unwrap(); // Out of range entirely. let outer_target = outside.join("target.txt"); touch(&outer_target, b"outside the root entirely"); std::os::unix::fs::symlink(&outer_target, root.join("outside_link.txt")).unwrap(); index_once(&root, &db, &config); let first = rows(&db); assert_eq!(first.len(), 3, "both targets indexed under their own paths"); assert!( first.iter().any(|(p, _, _)| p.ends_with(".pruned/inner.txt")), "the pruned-directory target is stored under its canonical path" ); // The second run is where a sweep keyed only on "was this parent // visited?" deletes the pruned-directory row. index_once(&root, &db, &config); assert_eq!(rows(&db), first, "an aliased row must survive a re-index"); std::fs::remove_dir_all(&root).ok(); std::fs::remove_dir_all(&outside).ok(); std::fs::remove_dir_all(&db_dir).ok(); } /// A file reached only through a symlink must still be *updated* when it /// changes. Classifying it against the linking directory's rows would miss, /// read as Insert, and `INSERT OR IGNORE` would then silently do nothing. #[test] #[cfg(unix)] fn a_modified_symlink_target_is_updated_not_silently_ignored() { let root = tmp_dir("alias-mod-root"); let outside = tmp_dir("alias-mod-outside"); let db_dir = tmp_dir("alias-mod-db"); let db = db_dir.join("index.sqlite"); let config = test_config(); let target = outside.join("target.txt"); touch(&target, b"first body"); std::os::unix::fs::symlink(&target, root.join("link.txt")).unwrap(); index_once(&root, &db, &config); let before = rows(&db); assert_eq!(before.len(), 1); std::fs::write(&target, b"second body, quite different").unwrap(); filetime_set(&target, SystemTime::now() + Duration::from_secs(120)); index_once(&root, &db, &config); let after = rows(&db); assert_eq!(after.len(), 1, "still exactly one row"); assert_eq!(after[0].0, before[0].0, "same path"); assert_ne!(after[0].1, before[0].1, "mtime was refreshed, so it was re-read"); std::fs::remove_dir_all(&root).ok(); std::fs::remove_dir_all(&outside).ok(); std::fs::remove_dir_all(&db_dir).ok(); } /// Overlapping roots reach the same files twice. The writer's digest set is /// the only thing left that collapses those visits. #[test] fn overlapping_roots_index_each_file_exactly_once() { let outer = tmp_dir("overlap-outer"); let db_dir = tmp_dir("overlap-db"); let db = db_dir.join("index.sqlite"); let config = test_config(); let inner = outer.join("inner"); touch(&outer.join("top.txt"), b"in the outer root only"); touch(&inner.join("shared.txt"), b"reachable from both roots"); touch(&inner.join("also.txt"), b"likewise"); index_roots_once(&[&outer, &inner], &db, &config); let all = rows(&db); assert_eq!(all.len(), 3, "three files, however many roots reach them"); let shared: Vec<&(String, i64, i64)> = all .iter() .filter(|(p, _, _)| p.ends_with("shared.txt")) .collect(); assert_eq!(shared.len(), 1, "the doubly-reachable file has exactly one row"); // And the overlap must not make anything look stale on a second pass. index_roots_once(&[&outer, &inner], &db, &config); assert_eq!(rows(&db), all, "a second overlapping run changes nothing"); std::fs::remove_dir_all(&outer).ok(); std::fs::remove_dir_all(&db_dir).ok(); } /// A directory that becomes unreadable between runs must not read as empty. /// Per-directory reconciliation returns before diffing when the read fails, /// and the sweep skips parents beneath it. #[test] #[cfg(unix)] fn a_directory_that_becomes_unreadable_deletes_nothing() { use std::os::unix::fs::PermissionsExt; let root = tmp_dir("locked-later"); let db_dir = tmp_dir("locked-later-db"); let db = db_dir.join("index.sqlite"); let config = test_config(); touch(&root.join("open.txt"), b"always readable"); let vault = root.join("vault"); touch(&vault.join("secret.txt"), b"readable for now"); touch(&vault.join("deeper/also.txt"), b"and this one"); index_once(&root, &db, &config); let before = rows(&db); assert_eq!(before.len(), 3, "all three indexed while readable"); std::fs::set_permissions(&vault, std::fs::Permissions::from_mode(0o000)).unwrap(); index_once(&root, &db, &config); let after = rows(&db); std::fs::set_permissions(&vault, std::fs::Permissions::from_mode(0o755)).ok(); assert_eq!(after, before, "an unreadable directory is not an empty one"); std::fs::remove_dir_all(&root).ok(); std::fs::remove_dir_all(&db_dir).ok(); } // --------------------------------------------------------------------------- // Inline extraction: the walk finishes files whose head is the whole file. // // `hash_length` is what decides how much of a file the walk reads, so setting // it to 0 leaves an empty head, nothing can be extracted inline, and the run // degrades to the pure two-pass behaviour. That makes it the control against // which the optimised path must produce an identical index. // --------------------------------------------------------------------------- /// Everything about a file's indexed content that a user can observe: its /// state, the stored snippet body, and its property rows. fn content_rows(db: &Path) -> Vec<(String, i64, Option, Option, String)> { let conn = rusqlite::Connection::open(db).unwrap(); let mut stmt = conn .prepare( "SELECT f.path, f.content_state, f.failure_msg, d.text_len, COALESCE(GROUP_CONCAT(p.key || '=' || p.value, ','), '') FROM files f LEFT JOIN documents_text d ON d.file_id = f.id LEFT JOIN properties p ON p.file_id = f.id GROUP BY f.id ORDER BY f.path", ) .unwrap(); let out = stmt .query_map([], |r| { Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)) }) .unwrap() .map(|r| r.unwrap()) .collect(); out } /// The decompressed body stored for a file, if any. fn stored_text(db: &Path, suffix: &str) -> Option { let conn = rusqlite::Connection::open(db).unwrap(); let blob: Option> = conn .query_row( "SELECT d.text_zstd FROM documents_text d JOIN files f ON f.id = d.file_id WHERE f.path LIKE '%' || ?1", [suffix], |r| r.get(0), ) .ok(); blob.map(|b| String::from_utf8(zstd::decode_all(&b[..]).unwrap()).unwrap()) } /// A tree that exercises every branch of the inline decision at once. fn seed_mixed_tree(root: &Path) { let big = "lorem ipsum dolor sit amet ".repeat(600); // ~16 KiB, past any head touch(&root.join("small.txt"), b"a small plaintext body with xylophone in it"); touch(&root.join("large.txt"), big.as_bytes()); touch(&root.join("empty.txt"), b""); // Invalid UTF-8 with a .txt extension: claimed by the plaintext extractor, // but not decodable, so it must be reported as a failure either way. touch(&root.join("bad.txt"), &[0x68, 0x69, 0xff, 0xfe, 0x00, 0x41]); // No extension `infer` or `mime_guess` recognises: no extractor claims it. touch(&root.join("blob.bin"), &[0x00, 0x01, 0x02, 0xfd, 0xfe, 0xff]); touch(&root.join("nested/deep/note.md"), b"# heading\n\nquagmire body text\n"); } #[test] fn inline_extraction_produces_an_identical_index_to_the_two_pass_path() { let root = tmp_dir("inline-equiv"); let db_dir = tmp_dir("inline-equiv-db"); seed_mixed_tree(&root); // Control: hash_length 0 => empty head => nothing can be inlined. let mut control = Config::default(); control.processing.hash_length = 0; let db_control = db_dir.join("control.sqlite"); index_once(&root, &db_control, &control); // Optimised: the default head covers every small file in the tree. let optimised = Config::default(); let db_opt = db_dir.join("optimised.sqlite"); index_once(&root, &db_opt, &optimised); assert_eq!( content_rows(&db_control), content_rows(&db_opt), "inlining during the walk must not change a single indexed byte" ); // And the bodies themselves round-trip identically, not just their lengths. for f in ["small.txt", "large.txt", "note.md"] { assert_eq!( stored_text(&db_control, f), stored_text(&db_opt, f), "stored body differs for {}", f ); } std::fs::remove_dir_all(&root).ok(); std::fs::remove_dir_all(&db_dir).ok(); } #[test] fn the_head_boundary_decides_inlining_without_changing_the_result() { let root = tmp_dir("inline-boundary"); let db_dir = tmp_dir("inline-boundary-db"); // Exactly at the limit, and one byte past it. let mut config = Config::default(); config.processing.hash_length = 64; let at = "x".repeat(64); let past = "y".repeat(65); touch(&root.join("at.txt"), at.as_bytes()); touch(&root.join("past.txt"), past.as_bytes()); let db = db_dir.join("index.sqlite"); index_once(&root, &db, &config); // Both are fully extracted; the boundary only decides *which pass* did it. let conn = rusqlite::Connection::open(&db).unwrap(); let pending: i64 = conn .query_row("SELECT COUNT(*) FROM files WHERE content_state != 1", [], |r| r.get(0)) .unwrap(); assert_eq!(pending, 0, "both sides of the boundary end up extracted"); drop(conn); assert_eq!(stored_text(&db, "at.txt").as_deref(), Some(at.as_str())); assert_eq!(stored_text(&db, "past.txt").as_deref(), Some(past.as_str())); std::fs::remove_dir_all(&root).ok(); std::fs::remove_dir_all(&db_dir).ok(); } #[test] fn undecodable_small_files_are_reported_as_failures_not_silently_skipped() { let root = tmp_dir("inline-badutf8"); let db_dir = tmp_dir("inline-badutf8-db"); let db = db_dir.join("index.sqlite"); touch(&root.join("bad.txt"), &[0x68, 0x69, 0xff, 0xfe]); index_once(&root, &db, &Config::default()); let conn = rusqlite::Connection::open(&db).unwrap(); let (state, msg): (i64, Option) = conn .query_row( "SELECT content_state, failure_msg FROM files WHERE path LIKE '%bad.txt'", [], |r| Ok((r.get(0)?, r.get(1)?)), ) .unwrap(); // Inlining must not swallow the error: the walk declines to record it, so // the content pass still opens the file and stores a reason. assert_eq!(state, 2, "undecodable content is FAILED, not DONE or NA"); assert!( msg.unwrap_or_default().contains("bad.txt"), "the failure names the file" ); std::fs::remove_dir_all(&root).ok(); std::fs::remove_dir_all(&db_dir).ok(); } #[test] fn an_empty_file_is_done_with_no_snippet_sidecar() { let root = tmp_dir("inline-empty"); let db_dir = tmp_dir("inline-empty-db"); let db = db_dir.join("index.sqlite"); touch(&root.join("empty.txt"), b""); index_once(&root, &db, &Config::default()); let conn = rusqlite::Connection::open(&db).unwrap(); let (state, sidecars): (i64, i64) = conn .query_row( "SELECT f.content_state, (SELECT COUNT(*) FROM documents_text d WHERE d.file_id = f.id) FROM files f WHERE f.path LIKE '%empty.txt'", [], |r| Ok((r.get(0)?, r.get(1)?)), ) .unwrap(); assert_eq!(state, 1, "an empty file is extracted, not failed"); assert_eq!(sidecars, 0, "no zstd frame for an empty body"); std::fs::remove_dir_all(&root).ok(); std::fs::remove_dir_all(&db_dir).ok(); } #[test] fn the_content_extension_filter_still_excludes_small_text_files() { let root = tmp_dir("inline-filter"); let db_dir = tmp_dir("inline-filter-db"); let db = db_dir.join("index.sqlite"); let mut config = Config::default(); config.indexing.content_extensions = vec!["md".into()]; touch(&root.join("kept.md"), b"kept quagmire body"); touch(&root.join("skipped.txt"), b"skipped xylophone body"); index_once(&root, &db, &config); let conn = rusqlite::Connection::open(&db).unwrap(); let states: Vec<(String, i64)> = conn .prepare("SELECT path, content_state FROM files ORDER BY path") .unwrap() .query_map([], |r| Ok((r.get(0)?, r.get(1)?))) .unwrap() .map(|r| r.unwrap()) .collect(); for (path, state) in &states { if path.ends_with("kept.md") { assert_eq!(*state, 1, "an allowed extension is extracted"); } else { assert_eq!(*state, 3, "a filtered extension is NA, never inlined"); } } drop(conn); assert_eq!(stored_text(&db, "skipped.txt"), None, "no body stored for a filtered file"); std::fs::remove_dir_all(&root).ok(); std::fs::remove_dir_all(&db_dir).ok(); } #[test] fn contentless_mode_still_indexes_inlined_files_without_storing_bodies() { let root = tmp_dir("inline-contentless"); let db_dir = tmp_dir("inline-contentless-db"); let db = db_dir.join("index.sqlite"); let mut config = Config::default(); config.processing.store_text_for_snippets = false; touch(&root.join("small.txt"), b"searchable xylophone body"); index_once(&root, &db, &config); let conn = rusqlite::Connection::open(&db).unwrap(); let sidecars: i64 = conn .query_row("SELECT COUNT(*) FROM documents_text", [], |r| r.get(0)) .unwrap(); assert_eq!(sidecars, 0, "contentless mode stores no bodies"); let hits: i64 = conn .query_row( "SELECT COUNT(*) FROM searchabletext WHERE searchabletext MATCH '\"xylophone\"'", [], |r| r.get(0), ) .unwrap(); assert_eq!(hits, 1, "an inlined file is still searchable in contentless mode"); std::fs::remove_dir_all(&root).ok(); std::fs::remove_dir_all(&db_dir).ok(); }