use semlith::Semlith;
use std::fs;
use std::io::{BufRead, Write};
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
const APPEAR_TIMEOUT: Duration = Duration::from_secs(60);
const DEBOUNCE: Duration = Duration::from_millis(200);
#[test]
#[ignore = "downloads an embedding model on first run"]
fn an_edit_is_searchable_without_running_index() {
let corpus = tempfile::tempdir().unwrap();
let store = tempfile::tempdir().unwrap();
write(
corpus.path(),
"bread.md",
"Sourdough needs flour and water.",
);
index(store.path(), corpus.path());
let watcher = spawn(store.path(), corpus.path());
write(
corpus.path(),
"rust.md",
"The borrow checker proves aliasing rules at compile time.",
);
let hit = wait_for(store.path(), "compile time aliasing proof", "rust.md");
assert!(
hit,
"the edit never became searchable within {APPEAR_TIMEOUT:?}"
);
watcher.stop();
}
#[test]
#[ignore = "downloads an embedding model on first run"]
fn indexing_a_watched_store_is_refused_and_names_the_watcher() {
let corpus = tempfile::tempdir().unwrap();
let store = tempfile::tempdir().unwrap();
write(
corpus.path(),
"bread.md",
"Sourdough needs flour and water.",
);
index(store.path(), corpus.path());
let watcher = spawn(store.path(), corpus.path());
let mut second = Semlith::open(store.path(), None).unwrap();
second.quiet = true;
let err = second
.index_paths(&[corpus.path().to_path_buf()], |_, _| {})
.unwrap_err()
.to_string();
assert!(err.contains("being indexed by"), "unhelpful error: {err}");
assert!(
err.contains(&format!("pid {}", std::process::id())),
"the error does not name the holder: {err}"
);
drop(second);
watcher.stop();
let mut after = Semlith::open(store.path(), None).unwrap();
after.quiet = true;
assert!(!after.search("sourdough flour", 3).unwrap().is_empty());
}
#[test]
#[ignore = "downloads an embedding model on first run"]
fn a_new_file_is_indexed_and_a_deleted_one_is_dropped() {
let corpus = tempfile::tempdir().unwrap();
let store = tempfile::tempdir().unwrap();
write(
corpus.path(),
"bread.md",
"Sourdough needs flour and water.",
);
index(store.path(), corpus.path());
let watcher = spawn(store.path(), corpus.path());
write(corpus.path(), "cells.md", "Mitochondria power the cell.");
assert!(
wait_until(store.path(), |p| holds(p, "cells.md")),
"a new file never reached the store"
);
fs::remove_file(corpus.path().join("bread.md")).unwrap();
assert!(
wait_until(store.path(), |p| !holds(p, "bread.md")),
"a deleted file is still indexed"
);
watcher.stop();
let mut s = Semlith::open(store.path(), None).unwrap();
s.quiet = true;
let hits = s.search("sourdough flour water", 5).unwrap();
assert!(
!hits.iter().any(|h| h.path.ends_with("bread.md")),
"the deleted file still has vectors: {hits:#?}"
);
}
#[test]
#[ignore = "downloads an embedding model on first run"]
fn a_rename_moves_the_file_in_the_store() {
let corpus = tempfile::tempdir().unwrap();
let store = tempfile::tempdir().unwrap();
write(
corpus.path(),
"before.md",
"Ferments need salt, time and a warm shelf.",
);
index(store.path(), corpus.path());
let watcher = spawn(store.path(), corpus.path());
fs::rename(
corpus.path().join("before.md"),
corpus.path().join("after.md"),
)
.unwrap();
assert!(
wait_until(store.path(), |p| holds(p, "after.md")
&& !holds(p, "before.md")),
"the rename did not move the file in the store"
);
watcher.stop();
let mut s = Semlith::open(store.path(), None).unwrap();
s.quiet = true;
let hits = s.search("salt time warm shelf ferment", 5).unwrap();
assert!(
hits.iter().any(|h| h.path.ends_with("after.md")),
"the renamed file is not searchable under its new path: {hits:#?}"
);
assert!(!hits.iter().any(|h| h.path.ends_with("before.md")));
}
#[test]
#[ignore = "downloads an embedding model on first run"]
fn an_atomic_save_is_an_edit_not_a_deletion() {
let corpus = tempfile::tempdir().unwrap();
let store = tempfile::tempdir().unwrap();
write(
corpus.path(),
"notes.md",
"Mitochondria are the powerhouse of the cell.",
);
index(store.path(), corpus.path());
let watcher = spawn(store.path(), corpus.path());
let tmp = corpus.path().join(".notes.md.swp");
fs::write(
&tmp,
"The borrow checker proves aliasing rules at compile time.",
)
.unwrap();
fs::rename(&tmp, corpus.path().join("notes.md")).unwrap();
assert!(
wait_for(store.path(), "compile time aliasing proof", "notes.md"),
"the atomically saved contents never became searchable"
);
watcher.stop();
let paths = {
let s = Semlith::open(store.path(), None).unwrap();
semlith::store::all_paths(s.db()).unwrap()
};
assert!(holds(&paths, "notes.md"), "the saved file was evicted");
assert!(
!holds(&paths, ".notes.md.swp"),
"the editor's temp file was indexed: {paths:#?}"
);
}
#[test]
#[ignore = "downloads an embedding model on first run"]
fn a_burst_of_saves_costs_one_re_embed() {
let corpus = tempfile::tempdir().unwrap();
let store = tempfile::tempdir().unwrap();
write(corpus.path(), "notes.md", "First draft of the notes.");
index(store.path(), corpus.path());
let debounce = Duration::from_secs(1);
let watcher = spawn_with(store.path(), corpus.path(), debounce);
for i in 0..10 {
write(
corpus.path(),
"notes.md",
&format!("Draft number {i} of the notes."),
);
}
assert!(
wait_for(store.path(), "draft number 9 of the notes", "notes.md"),
"the last save never became searchable"
);
settle(debounce);
assert_eq!(
watcher.embedded(),
1,
"ten saves inside one debounce window cost {} re-embeds",
watcher.embedded()
);
watcher.stop();
}
#[test]
#[ignore = "downloads an embedding model on first run"]
fn an_event_on_unchanged_contents_re_embeds_nothing() {
let corpus = tempfile::tempdir().unwrap();
let store = tempfile::tempdir().unwrap();
write(corpus.path(), "notes.md", "Mitochondria power the cell.");
index(store.path(), corpus.path());
let watcher = spawn(store.path(), corpus.path());
for _ in 0..3 {
write(corpus.path(), "notes.md", "Mitochondria power the cell.");
thread::sleep(DEBOUNCE * 3);
}
settle(DEBOUNCE);
assert_eq!(
watcher.embedded(),
0,
"unchanged contents were re-embedded {} times",
watcher.embedded()
);
watcher.stop();
}
#[test]
#[ignore = "downloads an embedding model on first run"]
fn ignored_hidden_and_store_paths_are_not_re_embedded() {
let corpus = tempfile::tempdir().unwrap();
write(corpus.path(), ".gitignore", "build/\n");
fs::create_dir(corpus.path().join("build")).unwrap();
write(corpus.path(), "notes.md", "Mitochondria power the cell.");
let store = corpus.path().join(".semlith");
index(&store, corpus.path());
let watcher = spawn(&store, corpus.path());
write(
&corpus.path().join("build"),
"artifact.md",
"Generated output nobody asked to search.",
);
write(
corpus.path(),
".hidden.md",
"A dotfile nobody asked to search.",
);
fs::write(store.join("scratch.txt"), "the store's own scribbles").unwrap();
settle(DEBOUNCE);
assert_eq!(
watcher.embedded(),
0,
"an ignored, hidden or store-internal path was embedded"
);
watcher.stop();
let s = Semlith::open(&store, None).unwrap();
let paths = semlith::store::all_paths(s.db()).unwrap();
assert!(holds(&paths, "notes.md"));
assert!(!holds(&paths, "artifact.md"), "gitignored: {paths:#?}");
assert!(!holds(&paths, ".hidden.md"), "hidden: {paths:#?}");
assert!(!holds(&paths, "scratch.txt"), "store internals: {paths:#?}");
}
#[test]
#[ignore = "downloads an embedding model on first run"]
fn a_running_mcp_server_sees_an_edit_without_restarting() {
let corpus = tempfile::tempdir().unwrap();
let store = tempfile::tempdir().unwrap();
write(
corpus.path(),
"bread.md",
"Sourdough needs flour and water.",
);
index(store.path(), corpus.path());
let watcher = spawn(store.path(), corpus.path());
let mut server = Command::new(env!("CARGO_BIN_EXE_semlith"))
.arg("--store")
.arg(store.path())
.arg("mcp")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut stdin = server.stdin.take().unwrap();
let mut stdout = std::io::BufReader::new(server.stdout.take().unwrap());
rpc(
&mut stdin,
&mut stdout,
1,
"initialize",
r#"{"protocolVersion":"2024-11-05"}"#,
);
let before = search_over_mcp(&mut stdin, &mut stdout, 2);
assert!(
!before.contains("ownership"),
"the corpus already contained the edit: {before}"
);
write(
corpus.path(),
"rust.md",
"Ownership means each value has a single owner, and the compiler \
frees it when that owner goes out of scope.",
);
assert!(
wait_for_vectors(store.path(), 2),
"the watcher never embedded the edit"
);
let after = search_over_mcp(&mut stdin, &mut stdout, 3);
assert!(
after.contains("ownership") || after.contains("Ownership"),
"the running server is still answering from the index it started with: {after}"
);
drop(stdin);
let _ = server.wait();
watcher.stop();
}
fn search_over_mcp(
stdin: &mut std::process::ChildStdin,
stdout: &mut impl BufRead,
id: u64,
) -> String {
rpc(
stdin,
stdout,
id,
"tools/call",
r#"{"name":"semlith_search","arguments":{"query":"automatic memory reclamation without garbage collection","k":3}}"#,
)
}
fn rpc(
stdin: &mut std::process::ChildStdin,
stdout: &mut impl BufRead,
id: u64,
method: &str,
params: &str,
) -> String {
writeln!(
stdin,
r#"{{"jsonrpc":"2.0","id":{id},"method":"{method}","params":{params}}}"#
)
.unwrap();
stdin.flush().unwrap();
let mut line = String::new();
stdout.read_line(&mut line).unwrap();
assert!(!line.is_empty(), "the server closed instead of answering");
line
}
#[cfg(unix)]
#[test]
#[ignore = "downloads an embedding model on first run"]
fn an_interrupted_watcher_leaves_the_store_whole() {
let corpus = tempfile::tempdir().unwrap();
let store = tempfile::tempdir().unwrap();
write(
corpus.path(),
"bread.md",
"Sourdough needs flour and water.",
);
index(store.path(), corpus.path());
for (label, busy) in [("idle", false), ("mid re-embed", true)] {
let mut child = Command::new(env!("CARGO_BIN_EXE_semlith"))
.arg("--store")
.arg(store.path())
.arg("watch")
.arg(corpus.path())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
assert!(
wait_for_vectors(store.path(), 1),
"{label}: the watcher never got going"
);
thread::sleep(Duration::from_secs(2));
if busy {
let big = "Ownership and borrowing, explained again and again. ".repeat(4000);
fs::write(corpus.path().join("long.md"), big).unwrap();
thread::sleep(Duration::from_millis(700));
}
let status = Command::new("kill")
.arg("-INT")
.arg(child.id().to_string())
.status()
.unwrap();
assert!(status.success(), "{label}: could not signal the watcher");
let exit = child.wait().unwrap();
assert!(exit.success(), "{label}: watcher exited {exit}");
assert!(
!store.path().join("index.tv.tmp").exists(),
"{label}: a half-written index was left behind"
);
let mut s = Semlith::open(store.path(), None).unwrap();
s.quiet = true;
s.index_paths(&[corpus.path().to_path_buf()], |_, _| {})
.unwrap();
let (_, chunks, _) = s.stats().unwrap();
assert_eq!(
chunks as usize,
s.len(),
"{label}: {chunks} chunks against {} vectors after re-indexing",
s.len()
);
}
}
struct Watcher {
stop: Arc<AtomicBool>,
embedded: Arc<AtomicUsize>,
handle: Option<thread::JoinHandle<()>>,
}
impl Watcher {
fn embedded(&self) -> usize {
self.embedded.load(Ordering::Relaxed)
}
fn stop(mut self) {
self.stop.store(true, Ordering::Relaxed);
self.handle.take().unwrap().join().unwrap();
}
}
fn spawn(store: &Path, corpus: &Path) -> Watcher {
spawn_with(store, corpus, DEBOUNCE)
}
fn spawn_with(store: &Path, corpus: &Path, debounce: Duration) -> Watcher {
let stop = Arc::new(AtomicBool::new(false));
let embedded = Arc::new(AtomicUsize::new(0));
let (ready_tx, ready_rx) = mpsc::channel();
let (store_path, roots, flag, counter) = (
store.to_path_buf(),
vec![corpus.to_path_buf()],
Arc::clone(&stop),
Arc::clone(&embedded),
);
let handle = thread::spawn(move || {
let mut s = Semlith::open(&store_path, None).unwrap();
s.quiet = true;
semlith::watch::run(&mut s, &roots, debounce, &flag, |progress| {
use semlith::watch::Progress;
match progress {
Progress::Ready { .. } => {
let _ = ready_tx.send(());
}
Progress::Batch(report, _) => {
counter.fetch_add(report.indexed, Ordering::Relaxed);
}
_ => {}
}
})
.unwrap();
});
ready_rx
.recv_timeout(APPEAR_TIMEOUT)
.expect("watcher never reported itself ready");
Watcher {
stop,
embedded,
handle: Some(handle),
}
}
fn settle(debounce: Duration) {
thread::sleep(debounce * 8 + Duration::from_secs(1));
}
fn wait_until(store: &Path, done: impl Fn(&[String]) -> bool) -> bool {
let deadline = Instant::now() + APPEAR_TIMEOUT;
while Instant::now() < deadline {
let s = Semlith::open(store, None).unwrap();
let paths = semlith::store::all_paths(s.db()).unwrap();
if done(&paths) {
return true;
}
drop(s);
thread::sleep(Duration::from_millis(300));
}
false
}
fn wait_for_vectors(store: &Path, at_least: usize) -> bool {
let deadline = Instant::now() + APPEAR_TIMEOUT;
while Instant::now() < deadline {
if Semlith::open(store, None).unwrap().len() >= at_least {
return true;
}
thread::sleep(Duration::from_millis(300));
}
false
}
fn holds(paths: &[String], name: &str) -> bool {
paths.iter().any(|p| p.ends_with(name))
}
fn wait_for(store: &Path, query: &str, name: &str) -> bool {
let deadline = Instant::now() + APPEAR_TIMEOUT;
while Instant::now() < deadline {
let mut s = Semlith::open(store, None).unwrap();
s.quiet = true;
if let Ok(hits) = s.search(query, 3)
&& hits.iter().any(|h| h.path.ends_with(name))
{
return true;
}
drop(s);
thread::sleep(Duration::from_millis(300));
}
false
}
fn index(store: &Path, corpus: &Path) {
let mut s = Semlith::open(store, None).unwrap();
s.quiet = true;
s.index_paths(&[corpus.to_path_buf()], |_, _| {}).unwrap();
}
fn write(dir: &Path, name: &str, body: &str) {
fs::write(dir.join(name), body).unwrap();
}