use semlith::filter::Filter;
use semlith::fleet::Fleet;
use std::fs;
use std::io::{BufRead, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
const BREAD: &str = "Sourdough rises because a starter of flour and water ferments. \
Hydration is the ratio of water to flour by weight, and a wetter \
dough gives a more open crumb after baking.";
const RUST: &str = "The borrow checker proves at compile time that no value has two \
mutable aliases. Ownership means each value has a single owner, and \
the compiler frees it when that owner leaves scope.";
const ONLY_IN_RUST: &str = "aliasing is rejected before the program ever runs";
#[test]
#[ignore = "downloads an embedding model on first run"]
fn one_query_answers_from_both_stores_and_labels_each_hit() {
let bread = corpus("bread", &[("sourdough.md", BREAD)]);
let rust = corpus("rust", &[("ownership.md", RUST)]);
let a = store_for(&bread);
let b = store_for(&rust);
let mut fleet = open(&[a.path().to_path_buf(), b.path().to_path_buf()]);
let hits = fleet
.search_filtered("how does this work", 6, &Filter::default())
.unwrap();
let labels: Vec<String> = hits.iter().filter_map(|h| h.store.clone()).collect();
assert_eq!(
labels.len(),
hits.len(),
"every hit must name its store when several were searched: {hits:#?}"
);
assert!(
labels.iter().any(|l| l == "bread") && labels.iter().any(|l| l == "rust"),
"one query must reach both stores, got {labels:?}"
);
}
#[test]
#[ignore = "downloads an embedding model on first run"]
fn a_definition_lifted_in_one_store_still_leads_a_fleet_answer() {
let code = corpus(
"code",
&[(
"limits.rs",
"/// How many nodes a traversal may visit.\n\
pub const HYDRATION_CEILING: usize = 4000;\n",
)],
);
let prose = corpus(
"prose",
&[(
"baking.md",
"Hydration and the hydration ceiling are discussed here. A hydration ceiling \
is talked about at length, and hydration ceiling appears again below. \
Hydration ceiling, hydration ceiling, hydration ceiling.",
)],
);
let a = store_for(&code);
let b = store_for(&prose);
let mut fleet = open(&[a.path().to_path_buf(), b.path().to_path_buf()]);
let hits = fleet
.search_filtered("HYDRATION_CEILING", 6, &Filter::default())
.unwrap();
let first = hits.first().expect("the query returns something");
assert!(
first.path.ends_with("limits.rs"),
"the definition did not lead the merged answer; got {} with lists {:?}\nall: {:#?}",
first.path,
first.lists,
hits.iter()
.map(|h| (&h.path, &h.lists, h.score))
.collect::<Vec<_>>()
);
assert!(
first.lists.contains(&"definition"),
"the leading hit does not carry the definition badge: {:?}",
first.lists
);
}
#[test]
#[ignore = "downloads an embedding model on first run"]
fn a_phrase_only_in_one_store_keeps_its_rank_when_another_is_added() {
let bread = corpus("bread", &[("sourdough.md", BREAD)]);
let rust = corpus("rust", &[("ownership.md", RUST)]);
let a = store_for(&bread);
let b = store_for(&rust);
let mut alone = open(&[b.path().to_path_buf()]);
let solo = alone
.search_filtered(ONLY_IN_RUST, 5, &Filter::default())
.unwrap();
let rank = solo
.iter()
.position(|h| h.path.ends_with("ownership.md"))
.expect("the store that holds the phrase did not return it on its own");
let mut both = open(&[a.path().to_path_buf(), b.path().to_path_buf()]);
let fused = both
.search_filtered(ONLY_IN_RUST, 5, &Filter::default())
.unwrap();
let fused_rank = fused
.iter()
.position(|h| h.path.ends_with("ownership.md"))
.expect("the answer disappeared once a second store was named");
assert_eq!(
fused_rank, rank,
"adding a store moved the answer from rank {rank} to rank {fused_rank}: {fused:#?}"
);
}
#[test]
#[ignore = "downloads an embedding model on first run"]
fn k_is_global_across_stores() {
let bread = corpus("bread", &[("sourdough.md", BREAD)]);
let rust = corpus("rust", &[("ownership.md", RUST)]);
let notes = corpus(
"notes",
&[(
"notes.md",
"Assorted notes about nothing in particular. \
A second sentence so the file chunks into more than one piece of text.",
)],
);
let a = store_for(&bread);
let b = store_for(&rust);
let c = store_for(¬es);
let mut fleet = open(&[
a.path().to_path_buf(),
b.path().to_path_buf(),
c.path().to_path_buf(),
]);
let hits = fleet
.search_filtered("anything at all", 5, &Filter::default())
.unwrap();
assert!(
hits.len() <= 5,
"three stores returned {} hits for k=5: {hits:#?}",
hits.len()
);
}
#[test]
fn a_store_path_that_does_not_exist_is_an_error_and_creates_nothing() {
let parent = tempfile::tempdir().unwrap();
let missing = parent.path().join("typo").join(".semlith");
let err = match Fleet::open(std::slice::from_ref(&missing)) {
Ok(_) => panic!("a store that does not exist opened anyway"),
Err(e) => e.to_string(),
};
assert!(
err.contains("typo"),
"the error does not name the path that was wrong: {err}"
);
assert!(
!missing.exists(),
"a read command created {}",
missing.display()
);
assert!(
!parent.path().join("typo").exists(),
"a read command created the parent directory too"
);
}
#[test]
#[ignore = "downloads an embedding model on first run"]
fn the_same_store_named_twice_is_searched_once() {
let bread = corpus("bread", &[("sourdough.md", BREAD)]);
let a = store_for(&bread);
let cwd = inner_of(&bread);
let once = search_cli(&cwd, &[a.path().to_str().unwrap()], "hydration and crumb");
let twice = search_cli(
&cwd,
&[a.path().to_str().unwrap(), ".semlith"],
"hydration and crumb",
);
assert_eq!(once, twice, "naming a store twice changed the result list");
}
#[test]
#[ignore = "downloads an embedding model on first run"]
fn a_filter_that_matches_only_one_store_still_returns_that_store() {
let docs = corpus("docs", &[("sourdough.md", BREAD)]);
let code = corpus("code", &[("lib.rs", RUST)]);
let a = store_for(&docs);
let b = store_for(&code);
let mut fleet = open(&[a.path().to_path_buf(), b.path().to_path_buf()]);
let filter = Filter::new(&[], &["rs".into()], &[]).unwrap();
assert!(
fleet.matching_files(&filter).unwrap() > 0,
"the filter matches a file in one of the two stores"
);
let hits = fleet
.search_filtered("aliases and ownership", 5, &filter)
.unwrap();
assert!(
!hits.is_empty(),
"a filter matching one store returned nothing"
);
assert!(
hits.iter().all(|h| h.path.ends_with(".rs")),
"the filter leaked past one store: {hits:#?}"
);
}
#[test]
#[ignore = "downloads two embedding models on first run"]
fn stores_built_with_different_models_are_searched_together() {
let bread = corpus("bread", &[("sourdough.md", BREAD)]);
let rust = corpus("rust", &[("ownership.md", RUST)]);
let a = store_with_model(&bread, None);
let b = store_with_model(&rust, Some("BGESmallENV15"));
let mut fleet = open(&[a.path().to_path_buf(), b.path().to_path_buf()]);
let hits = fleet
.search_filtered("how does this work", 6, &Filter::default())
.unwrap();
let labels: Vec<String> = hits.iter().filter_map(|h| h.store.clone()).collect();
assert!(
labels.iter().any(|l| l == "bread") && labels.iter().any(|l| l == "rust"),
"a store whose model differs contributed nothing: {labels:?}"
);
}
#[test]
#[ignore = "downloads an embedding model on first run"]
fn an_unrelated_store_does_not_degrade_the_store_that_has_the_answer() {
let rust = corpus("rust", &[("ownership.md", RUST)]);
let bread = corpus("bread", &[("sourdough.md", BREAD)]);
let b = store_for(&rust);
let a = store_for(&bread);
let mut alone = open(&[b.path().to_path_buf()]);
let solo = alone
.search_filtered(ONLY_IN_RUST, 3, &Filter::default())
.unwrap();
assert!(!solo.is_empty(), "the store answered nothing on its own");
let mut both = open(&[b.path().to_path_buf(), a.path().to_path_buf()]);
let fused = both
.search_filtered(ONLY_IN_RUST, 6, &Filter::default())
.unwrap();
for hit in &solo {
let at = fused
.iter()
.position(|h| h.path == hit.path && h.start_line == hit.start_line);
let at =
at.unwrap_or_else(|| panic!("adding a store dropped {}:{}", hit.path, hit.start_line));
let unrelated_above = fused[..at]
.iter()
.filter(|h| h.store.as_deref() == Some("bread"))
.count();
assert_eq!(
unrelated_above, 0,
"{} unrelated hits outranked the answer: {fused:#?}",
unrelated_above
);
}
}
#[test]
#[ignore = "downloads an embedding model on first run"]
fn one_mcp_server_searches_several_stores_and_can_be_narrowed_to_one() {
let bread = corpus("bread", &[("sourdough.md", BREAD)]);
let rust = corpus("rust", &[("ownership.md", RUST)]);
let a = store_for(&bread);
let b = store_for(&rust);
let mut server = Command::new(env!("CARGO_BIN_EXE_semlith"))
.arg("--store")
.arg(a.path())
.arg("--store")
.arg(b.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 both = rpc(
&mut stdin,
&mut stdout,
2,
"tools/call",
r#"{"name":"semlith_search","arguments":{"query":"how does this work","k":6}}"#,
);
assert!(
both.contains("bread") && both.contains("rust"),
"one call did not reach both stores: {both}"
);
let narrowed = rpc(
&mut stdin,
&mut stdout,
3,
"tools/call",
r#"{"name":"semlith_search","arguments":{"query":"how does this work","k":6,"store":["rust"]}}"#,
);
assert!(
narrowed.contains("ownership.md"),
"narrowing to a store lost its own hits: {narrowed}"
);
assert!(
!narrowed.contains("sourdough.md"),
"the store argument did not narrow anything: {narrowed}"
);
let unknown = rpc(
&mut stdin,
&mut stdout,
4,
"tools/call",
r#"{"name":"semlith_search","arguments":{"query":"anything","store":["nope"]}}"#,
);
assert!(
unknown.contains("isError"),
"an unknown store was not an error: {unknown}"
);
assert!(
unknown.contains("bread") && unknown.contains("rust"),
"the error does not say which stores are open: {unknown}"
);
let stats = rpc(
&mut stdin,
&mut stdout,
5,
"tools/call",
r#"{"name":"semlith_stats","arguments":{}}"#,
);
assert!(
stats.contains("bread") && stats.contains("rust"),
"stats over several stores must report each: {stats}"
);
drop(stdin);
let _ = server.wait();
}
#[test]
#[ignore = "downloads an embedding model on first run"]
fn stats_and_files_report_every_store() {
let bread = corpus("bread", &[("sourdough.md", BREAD)]);
let rust = corpus("rust", &[("ownership.md", RUST)]);
let a = store_for(&bread);
let b = store_for(&rust);
let stats = cli(
bread.path(),
&[
"--store",
a.path().to_str().unwrap(),
"--store",
b.path().to_str().unwrap(),
"stats",
],
);
assert!(
stats.contains("bread") && stats.contains("rust"),
"stats did not label both stores: {stats}"
);
let files = cli(
bread.path(),
&[
"--store",
a.path().to_str().unwrap(),
"--store",
b.path().to_str().unwrap(),
"files",
],
);
assert!(
files.contains("sourdough.md") && files.contains("ownership.md"),
"files did not list both stores: {files}"
);
let single = cli(
bread.path(),
&["--store", a.path().to_str().unwrap(), "stats"],
);
assert!(
single.starts_with("store "),
"single-store stats changed shape: {single}"
);
assert!(
!single.contains("bread\n"),
"single-store stats gained a label: {single}"
);
}
#[test]
#[ignore = "downloads an embedding model on first run"]
fn a_watched_store_stays_searchable_and_fresh_inside_a_fleet() {
let bread = corpus("bread", &[("sourdough.md", BREAD)]);
let rust = corpus("rust", &[("ownership.md", RUST)]);
let a = store_for(&bread);
let b = store_for(&rust);
let watched = inner_of(&rust);
let mut watcher = Command::new(env!("CARGO_BIN_EXE_semlith"))
.arg("--store")
.arg(b.path())
.arg("watch")
.arg(&watched)
.arg("--debounce")
.arg("200")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut fleet = open(&[a.path().to_path_buf(), b.path().to_path_buf()]);
let before = fleet
.search_filtered("lifetimes outlive their references", 5, &Filter::default())
.unwrap();
assert!(
!before.iter().any(|h| h.path.ends_with("lifetimes.md")),
"the corpus already held the edit"
);
fs::write(
watched.join("lifetimes.md"),
"A lifetime annotation states how long a reference stays valid, so the \
compiler can reject one that outlives what it points at.",
)
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
let mut found = false;
while std::time::Instant::now() < deadline {
let hits = fleet
.search_filtered("lifetime annotation validity", 5, &Filter::default())
.unwrap();
if hits.iter().any(|h| h.path.ends_with("lifetimes.md")) {
found = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(300));
}
let _ = watcher.kill();
let _ = watcher.wait();
assert!(
found,
"a fleet reader never saw what the watcher wrote to one of its stores"
);
}
fn open(dirs: &[PathBuf]) -> Fleet {
let mut fleet = Fleet::open(dirs).unwrap();
fleet.quiet = true;
fleet
}
fn corpus(name: &str, files: &[(&str, &str)]) -> tempfile::TempDir {
let dir = tempfile::Builder::new().prefix(name).tempdir().unwrap();
let inner = dir.path().join(name);
fs::create_dir_all(&inner).unwrap();
for (file, body) in files {
fs::write(inner.join(file), body).unwrap();
}
dir
}
fn store_for(corpus: &tempfile::TempDir) -> StoreDir {
store_with_model(corpus, None)
}
fn store_with_model(corpus: &tempfile::TempDir, model: Option<&str>) -> StoreDir {
let inner = inner_of(corpus);
let store = inner.join(".semlith");
let mut cmd = Command::new(env!("CARGO_BIN_EXE_semlith"));
cmd.arg("--store")
.arg(&store)
.arg("index")
.arg(&inner)
.arg("--quiet");
if let Some(m) = model {
cmd.arg("--model").arg(m);
}
let out = cmd.output().unwrap();
assert!(
out.status.success(),
"indexing failed: {}",
String::from_utf8_lossy(&out.stderr)
);
StoreDir(store)
}
fn inner_of(corpus: &tempfile::TempDir) -> PathBuf {
fs::read_dir(corpus.path())
.unwrap()
.map(|e| e.unwrap().path())
.find(|p| p.is_dir())
.expect("corpus has an inner directory")
}
struct StoreDir(PathBuf);
impl StoreDir {
fn path(&self) -> &Path {
&self.0
}
}
fn cli(cwd: &Path, args: &[&str]) -> String {
let out = Command::new(env!("CARGO_BIN_EXE_semlith"))
.current_dir(cwd)
.args(args)
.output()
.unwrap();
format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
)
}
fn search_cli(cwd: &Path, stores: &[&str], query: &str) -> String {
let mut args: Vec<&str> = Vec::new();
for s in stores {
args.push("--store");
args.push(s);
}
args.extend_from_slice(&["search", query, "--json", "-k", "5"]);
let out = Command::new(env!("CARGO_BIN_EXE_semlith"))
.current_dir(cwd)
.args(&args)
.output()
.unwrap();
assert!(
out.status.success(),
"search failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
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
}