use std::path::{Path, PathBuf};
use std::time::Duration;
use hallouminate::app::config::Config;
use hallouminate::app::daemon::{
AddMarkdownRequest, DaemonRequest, DaemonRequestPayload, DaemonResponse, DaemonState,
DeleteMarkdownRequest, ErrorKind, GroundRequest, GroundResult, IndexRequest, LineRange,
ListFilesRequest, ListFilesResult, Position, ReadMarkdownRequest, connect_at, serve,
spawn_signal_handlers,
};
use hallouminate::domain::repository::{RepoCorpusKind, repo_corpus_name, wiki_directory};
use tokio::time::timeout;
mod common;
use common::daemon::DaemonHarness;
fn cfg_with_repository(ground_dir: &Path, repo_name: &str, repo_path: &Path) -> Config {
let toml = format!(
r#"
[[repository]]
name = "{repo_name}"
path = "{repo}"
[storage]
ground_dir = "{ground}"
"#,
repo = repo_path.display(),
ground = ground_dir.display(),
);
let cfg: Config = toml::from_str(&toml).expect("repository toml parses");
cfg
}
#[tokio::test]
async fn daemon_client_returns_clear_error_when_socket_missing() {
let tmp = tempfile::tempdir().expect("tempdir");
let missing = tmp.path().join("never-bound.sock");
let err = connect_at(&missing)
.await
.expect_err("missing socket must fail");
let msg = format!("{err:#}");
assert!(
msg.contains("daemon unavailable"),
"error must say `daemon unavailable`: {msg}"
);
assert!(
msg.contains(missing.to_string_lossy().as_ref()),
"error must name the socket path: {msg}"
);
}
#[tokio::test]
async fn daemon_client_helper_returns_clear_error_when_socket_missing() {
let tmp = tempfile::tempdir().expect("tempdir");
let missing = tmp.path().join("absent.sock");
let err = connect_at(&missing)
.await
.expect_err("missing socket must fail");
assert!(
format!("{err:#}").contains("daemon unavailable"),
"got: {err:#}"
);
}
#[tokio::test]
async fn daemon_client_reconnect_failure_carries_start_hint() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir corpus");
let toml = format!(
r#"
[[corpus]]
name = "docs"
paths = ["{c}"]
globs = ["**/*.md"]
[storage]
ground_dir = "{g}"
"#,
c = corpus_root.display(),
g = ground.display(),
);
let cfg: Config = toml::from_str(&toml).expect("parse cfg");
let harness = DaemonHarness::spawn(cfg).await;
let socket = harness.socket().to_path_buf();
let client = connect_at(&socket).await.expect("initial connect");
drop(harness);
let deadline = std::time::Instant::now() + Duration::from_secs(2);
while socket.exists() && std::time::Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(20)).await;
}
let err = client
.call_raw(DaemonRequest {
cwd: PathBuf::new(),
payload: DaemonRequestPayload::Ping,
})
.await
.expect_err("reconnect must fail after daemon shutdown");
let msg = format!("{err:#}");
assert!(
msg.contains("daemon unavailable"),
"reconnect error must say `daemon unavailable`: {msg}"
);
assert!(
msg.contains("hallouminate daemon"),
"reconnect error must include the start hint: {msg}"
);
}
#[tokio::test]
async fn daemon_serializes_concurrent_writes_to_the_same_corpus() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir corpus root");
let toml = format!(
r#"
[[corpus]]
name = "docs"
paths = ["{}"]
globs = ["**/*.md"]
[storage]
ground_dir = "{}"
"#,
corpus_root.display(),
ground.display(),
);
let cfg: Config = toml::from_str(&toml).expect("parse cfg");
let harness = DaemonHarness::spawn(cfg).await;
let cwd = harness.cwd().to_path_buf();
let make_req = || DaemonRequest {
cwd: cwd.clone(),
payload: DaemonRequestPayload::AddMarkdown(AddMarkdownRequest {
corpus: "docs".into(),
path: "race.md".into(),
content: "".into(),
overwrite: false,
..Default::default()
}),
};
let client_a = connect_at(harness.socket()).await.expect("client a");
let client_b = connect_at(harness.socket()).await.expect("client b");
let req_a = make_req();
let req_b = make_req();
let (res_a, res_b) = tokio::join!(client_a.call_raw(req_a), client_b.call_raw(req_b));
let res_a = res_a.expect("a transport ok");
let res_b = res_b.expect("b transport ok");
let ok_count = [&res_a, &res_b]
.iter()
.filter(|r| matches!(r, DaemonResponse::Ok { .. }))
.count();
let err_count = [&res_a, &res_b]
.iter()
.filter(|r| {
matches!(
r,
DaemonResponse::Err {
kind: ErrorKind::InvalidParams,
message,
} if message.contains("already exists")
)
})
.count();
assert_eq!(
(ok_count, err_count),
(1, 1),
"expected exactly one ok and one already-exists, got a={res_a:?} b={res_b:?}"
);
assert!(
corpus_root.join("race.md").exists(),
"winner must have left the file on disk",
);
}
#[tokio::test]
async fn per_corpus_mutex_does_not_block_writes_to_different_corpora() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_a = tmp.path().join("a");
let corpus_b = tmp.path().join("b");
std::fs::create_dir_all(&corpus_a).expect("a mkdir");
std::fs::create_dir_all(&corpus_b).expect("b mkdir");
let toml = format!(
r#"
[[corpus]]
name = "a"
paths = ["{a}"]
globs = ["**/*.md"]
[[corpus]]
name = "b"
paths = ["{b}"]
globs = ["**/*.md"]
[storage]
ground_dir = "{g}"
"#,
a = corpus_a.display(),
b = corpus_b.display(),
g = ground.display(),
);
let cfg: Config = toml::from_str(&toml).expect("parse cfg");
let harness = DaemonHarness::spawn(cfg).await;
let client_a = connect_at(harness.socket()).await.expect("client a");
let client_b = connect_at(harness.socket()).await.expect("client b");
let req_a = DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::AddMarkdown(AddMarkdownRequest {
corpus: "a".into(),
path: "alpha.md".into(),
content: "".into(),
overwrite: false,
..Default::default()
}),
};
let req_b = DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::AddMarkdown(AddMarkdownRequest {
corpus: "b".into(),
path: "beta.md".into(),
content: "".into(),
overwrite: false,
..Default::default()
}),
};
let (res_a, res_b) = tokio::join!(client_a.call_raw(req_a), client_b.call_raw(req_b));
let res_a = res_a.expect("a transport ok");
let res_b = res_b.expect("b transport ok");
assert!(
matches!(res_a, DaemonResponse::Ok { .. }),
"corpus a must succeed: {res_a:?}"
);
assert!(
matches!(res_b, DaemonResponse::Ok { .. }),
"corpus b must succeed: {res_b:?}"
);
}
#[tokio::test]
async fn daemon_resolves_repository_derived_corpora_in_list_corpora() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let repo = tmp.path().join("my-repo");
std::fs::create_dir_all(&repo).expect("mkdir repo");
let cfg = cfg_with_repository(&ground, "myrepo", &repo);
let harness = DaemonHarness::spawn(cfg).await;
let client = connect_at(harness.socket()).await.expect("connect");
let value: serde_json::Value = client
.call(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::ListCorpora,
})
.await
.expect("list_corpora ok");
let names: Vec<String> = value
.as_array()
.expect("array")
.iter()
.filter_map(|c| c["name"].as_str().map(str::to_string))
.collect();
assert!(
names.contains(&"repo:myrepo:wiki".to_string()),
"derived wiki corpus missing: {names:?}"
);
assert!(
!names.contains(&"repo:myrepo:corpus".to_string()),
"source corpus must be omitted when corpus_paths is empty: {names:?}"
);
}
#[tokio::test]
#[ignore = "downloads ~33MB embedding model on first run; opt-in via --ignored"]
async fn daemon_add_markdown_to_repository_wiki_writes_under_dot_hallouminate_wiki() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let repo = tmp.path().join("my-repo");
std::fs::create_dir_all(&repo).expect("mkdir repo");
let cfg = cfg_with_repository(&ground, "myrepo", &repo);
let harness = DaemonHarness::spawn(cfg).await;
let client = connect_at(harness.socket()).await.expect("connect");
let body = "# Cheese\n\nHalloumi grills better than most.\n";
let req = DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::AddMarkdown(AddMarkdownRequest {
corpus: repo_corpus_name("myrepo", RepoCorpusKind::Wiki).unwrap(),
path: "cheese.md".into(),
content: body.into(),
overwrite: false,
..Default::default()
}),
};
let value: serde_json::Value = timeout(Duration::from_secs(60), client.call(req))
.await
.expect("timeout")
.expect("add_markdown ok");
let wiki_dir = wiki_directory(&hallouminate::domain::repository::RepositoryConfig {
name: "myrepo".into(),
path: repo.to_string_lossy().into_owned(),
..Default::default()
});
let written = wiki_dir.join("cheese.md");
assert!(
written.exists(),
"wiki file must land at {} (got cwd-relative path?)",
written.display()
);
assert_eq!(std::fs::read_to_string(&written).unwrap(), body);
let corpora = value["indexed"]["corpora"]
.as_array()
.expect("indexed.corpora array");
assert_eq!(corpora.len(), 1, "one corpus report: {corpora:?}");
assert_eq!(
corpora[0]["files_upserted"].as_u64(),
Some(2),
"primary write + auto-built root index.md must both be upserted: {:?}",
corpora[0],
);
let read_value: serde_json::Value = client
.call(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::ReadMarkdown(ReadMarkdownRequest {
corpus: Some(repo_corpus_name("myrepo", RepoCorpusKind::Wiki).unwrap()),
path: "cheese.md".into(),
}),
})
.await
.expect("read_markdown ok");
assert_eq!(read_value["content"].as_str(), Some(body));
}
#[tokio::test]
async fn daemon_add_markdown_returns_lint_warnings_without_blocking_the_write() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir corpus");
let toml = format!(
r#"
[[corpus]]
name = "docs"
paths = ["{c}"]
globs = ["**/*.md"]
[storage]
ground_dir = "{g}"
[embeddings]
enabled = false
"#,
c = corpus_root.display(),
g = ground.display(),
);
let cfg: Config = toml::from_str(&toml).expect("parse cfg");
let harness = DaemonHarness::spawn(cfg).await;
let client = connect_at(harness.socket()).await.expect("connect");
let body = "# Notes\n\nSee [the spec]() for details.\n\n```mermaid\n```\n";
let value: serde_json::Value = client
.call(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::AddMarkdown(AddMarkdownRequest {
corpus: "docs".into(),
path: "notes.md".into(),
content: body.into(),
overwrite: false,
..Default::default()
}),
})
.await
.expect("add_markdown ok");
assert_eq!(
std::fs::read_to_string(corpus_root.join("notes.md")).unwrap(),
body,
"content must be stored verbatim, never rewritten by the linter"
);
let warnings = value["warnings"]
.as_array()
.expect("warnings array present when content has lint issues");
assert_eq!(warnings.len(), 2, "warnings: {warnings:?}");
let joined = warnings
.iter()
.filter_map(|w| w.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(joined.contains("empty destination"), "got: {joined}");
assert!(joined.contains("mermaid"), "got: {joined}");
let clean: serde_json::Value = client
.call(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::AddMarkdown(AddMarkdownRequest {
corpus: "docs".into(),
path: "clean.md".into(),
content: "# Clean\n\nNothing to flag here.\n".into(),
overwrite: false,
..Default::default()
}),
})
.await
.expect("add_markdown clean ok");
assert!(
clean["warnings"].as_array().is_none_or(|w| w.is_empty()),
"clean content must carry no warnings: {:?}",
clean["warnings"]
);
}
#[tokio::test]
async fn daemon_add_markdown_warns_on_malformed_frontmatter_block_and_stores_verbatim() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir corpus");
let toml = format!(
r#"
[[corpus]]
name = "docs"
paths = ["{c}"]
globs = ["**/*.md"]
[storage]
ground_dir = "{g}"
[embeddings]
enabled = false
"#,
c = corpus_root.display(),
g = ground.display(),
);
let cfg: Config = toml::from_str(&toml).expect("parse cfg");
let harness = DaemonHarness::spawn(cfg).await;
let client = connect_at(harness.socket()).await.expect("connect");
let malformed = "---\n: : : not valid : :\n---\n# Notes\n\nplain body text\n";
let value: serde_json::Value = client
.call(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::AddMarkdown(AddMarkdownRequest {
corpus: "docs".into(),
path: "bad-fm.md".into(),
content: malformed.into(),
overwrite: false,
..Default::default()
}),
})
.await
.expect("add_markdown ok despite malformed frontmatter");
assert_eq!(
std::fs::read_to_string(corpus_root.join("bad-fm.md")).unwrap(),
malformed,
"malformed frontmatter must be stored verbatim, never rewritten"
);
let warnings = value["warnings"]
.as_array()
.expect("warnings array present for a malformed frontmatter block");
let frontmatter_advisories = warnings
.iter()
.filter_map(|w| w.as_str())
.filter(|w| w.contains("frontmatter"))
.count();
assert_eq!(
frontmatter_advisories, 1,
"exactly one frontmatter advisory must ride back: {warnings:?}"
);
let clean: serde_json::Value = client
.call(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::AddMarkdown(AddMarkdownRequest {
corpus: "docs".into(),
path: "good-fm.md".into(),
content: "---\nstatus: reviewed\nowner: cheese-lord\n---\n# Notes\n\nbody\n".into(),
overwrite: false,
..Default::default()
}),
})
.await
.expect("add_markdown ok for well-formed frontmatter");
let clean_fm_advisories = clean["warnings"]
.as_array()
.map(|w| {
w.iter()
.filter_map(|x| x.as_str())
.filter(|x| x.contains("frontmatter"))
.count()
})
.unwrap_or(0);
assert_eq!(
clean_fm_advisories, 0,
"well-formed frontmatter must not warn: {:?}",
clean["warnings"]
);
}
#[tokio::test]
async fn daemon_add_markdown_warns_on_claim_marks_and_stores_verbatim() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir corpus");
let toml = format!(
r#"
[[corpus]]
name = "docs"
paths = ["{c}"]
globs = ["**/*.md"]
[storage]
ground_dir = "{g}"
[embeddings]
enabled = false
"#,
c = corpus_root.display(),
g = ground.display(),
);
let cfg: Config = toml::from_str(&toml).expect("parse cfg");
let harness = DaemonHarness::spawn(cfg).await;
let client = connect_at(harness.socket()).await.expect("connect");
let body = "# Notes\n\nA contradicted claim.<!--claim:contradicted-->\n\nBogus.<!--claim:bananas-->\n\nFine.<!--claim:confirmed-->\n\nPlain <!-- ordinary note -->\n";
let value: serde_json::Value = client
.call(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::AddMarkdown(AddMarkdownRequest {
corpus: "docs".into(),
path: "claims.md".into(),
content: body.into(),
overwrite: false,
..Default::default()
}),
})
.await
.expect("add_markdown ok despite claim advisories");
assert_eq!(
std::fs::read_to_string(corpus_root.join("claims.md")).unwrap(),
body,
"claim marks must be stored verbatim, never rewritten by the linter"
);
let warnings = value["warnings"]
.as_array()
.expect("warnings array present for claim-mark issues");
let claim_advisories: Vec<&str> = warnings
.iter()
.filter_map(|w| w.as_str())
.filter(|w| w.contains("claim"))
.collect();
assert_eq!(
claim_advisories.len(),
2,
"exactly two claim advisories must ride back: {warnings:?}"
);
let joined = claim_advisories.join("\n");
assert!(
joined.contains("contradicted") && joined.contains("ref="),
"missing-ref advisory expected: {joined}"
);
assert!(
joined.contains("unrecognized status"),
"malformed-status advisory expected: {joined}"
);
let clean: serde_json::Value = client
.call(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::AddMarkdown(AddMarkdownRequest {
corpus: "docs".into(),
path: "clean-claims.md".into(),
content: "# Notes\n\nGood.<!--claim:superseded ref=old.md-->\n".into(),
overwrite: false,
..Default::default()
}),
})
.await
.expect("add_markdown ok for well-formed claim marks");
let clean_claim_advisories = clean["warnings"]
.as_array()
.map(|w| {
w.iter()
.filter_map(|x| x.as_str())
.filter(|x| x.contains("claim"))
.count()
})
.unwrap_or(0);
assert_eq!(
clean_claim_advisories, 0,
"well-formed claim marks must not warn: {:?}",
clean["warnings"]
);
}
#[tokio::test]
async fn daemon_ping_returns_versioned_pong() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir corpus");
let toml = format!(
r#"
[[corpus]]
name = "docs"
paths = ["{c}"]
globs = ["**/*.md"]
[storage]
ground_dir = "{g}"
"#,
c = corpus_root.display(),
g = ground.display(),
);
let cfg: Config = toml::from_str(&toml).expect("parse cfg");
let harness = DaemonHarness::spawn(cfg).await;
let client = connect_at(harness.socket()).await.expect("connect");
let value: serde_json::Value = client
.call(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::Ping,
})
.await
.expect("ping ok");
assert_eq!(
value["version"].as_str(),
Some(env!("CARGO_PKG_VERSION")),
"ping must report the daemon binary version: {value}"
);
}
#[tokio::test]
async fn daemon_index_with_paths_from_returns_invalid_params() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir corpus");
let toml = format!(
r#"
[[corpus]]
name = "docs"
paths = ["{c}"]
globs = ["**/*.md"]
[storage]
ground_dir = "{g}"
"#,
c = corpus_root.display(),
g = ground.display(),
);
let cfg: Config = toml::from_str(&toml).expect("parse cfg");
let harness = DaemonHarness::spawn(cfg).await;
let client = connect_at(harness.socket()).await.expect("connect");
let response = client
.call_raw(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::Index(hallouminate::app::daemon::IndexRequest {
corpus: None,
paths_from: Some(PathBuf::from("/tmp/list.txt")),
strict: false,
}),
})
.await
.expect("transport ok");
match response {
DaemonResponse::Err {
kind: ErrorKind::InvalidParams,
message,
} => {
assert!(
message.contains("paths_from"),
"error must name the unsupported field: {message}"
);
}
other => panic!("expected InvalidParams for paths_from, got: {other:?}"),
}
}
#[tokio::test]
async fn daemon_malformed_json_request_returns_invalid_params() {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir corpus");
let toml = format!(
r#"
[[corpus]]
name = "docs"
paths = ["{c}"]
globs = ["**/*.md"]
[storage]
ground_dir = "{g}"
"#,
c = corpus_root.display(),
g = ground.display(),
);
let cfg: Config = toml::from_str(&toml).expect("parse cfg");
let harness = DaemonHarness::spawn(cfg).await;
let mut stream = UnixStream::connect(harness.socket())
.await
.expect("connect");
stream
.write_all(b"this is not json at all\n")
.await
.expect("write");
stream.flush().await.expect("flush");
let (read_half, _) = stream.into_split();
let mut reader = BufReader::new(read_half);
let mut line = String::new();
timeout(Duration::from_secs(5), reader.read_line(&mut line))
.await
.expect("server must respond before timeout")
.expect("read response");
let response: DaemonResponse =
serde_json::from_str(line.trim_end()).expect("server reply must be valid JSON");
match response {
DaemonResponse::Err {
kind: ErrorKind::InvalidParams,
message,
} => {
assert!(
message.contains("invalid request"),
"error must mention parse failure: {message}"
);
}
other => panic!("expected InvalidParams for garbage input, got: {other:?}"),
}
}
#[tokio::test]
async fn daemon_single_instance_lock_blocks_second_serve_on_same_socket() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir corpus");
let toml = format!(
r#"
[[corpus]]
name = "docs"
paths = ["{c}"]
globs = ["**/*.md"]
[storage]
ground_dir = "{g}"
"#,
c = corpus_root.display(),
g = ground.display(),
);
let cfg: Config = toml::from_str(&toml).expect("parse cfg");
let harness = DaemonHarness::spawn(cfg.clone()).await;
let state2 = DaemonState::open(cfg, None).await.expect("second open ok");
let socket2 = harness.socket().to_path_buf();
let result = timeout(Duration::from_secs(5), serve(&state2, &socket2))
.await
.expect("serve must return promptly");
let err = result.expect_err("second serve must fail");
let msg = format!("{err:#}");
assert!(
msg.contains("already holds") || msg.contains("lockfile"),
"single-instance error must mention the lock: {msg}"
);
let client = connect_at(harness.socket())
.await
.expect("first daemon alive");
let pong: serde_json::Value = client
.call(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::Ping,
})
.await
.expect("ping");
assert_eq!(pong["version"].as_str(), Some(env!("CARGO_PKG_VERSION")));
}
fn seed_cwd(tmp: &Path) -> PathBuf {
let cwd = tmp.to_path_buf();
let hallou = cwd.join(".hallouminate");
std::fs::create_dir_all(&hallou).expect("mkdir .hallouminate");
std::fs::write(hallou.join("config.toml"), "").expect("write repo config");
cwd
}
fn docs_cfg(ground_dir: &Path, corpus_root: &Path) -> Config {
let toml = format!(
"[[corpus]]\nname = \"docs\"\npaths = [\"{c}\"]\nglobs = [\"**/*.md\"]\n\n[storage]\nground_dir = \"{g}\"\n",
c = corpus_root.display(),
g = ground_dir.display(),
);
toml::from_str(&toml).expect("parse cfg")
}
#[tokio::test]
async fn ipc_shutdown_removes_socket_and_lockfile_and_refuses_new_connections() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir corpus");
let cwd = seed_cwd(tmp.path());
let socket = tmp.path().join("daemon.sock");
let lockfile = tmp.path().join("daemon.sock.lock");
let cfg = docs_cfg(&ground, &corpus_root);
let state = DaemonState::open(cfg, None).await.expect("open state");
let socket_clone = socket.clone();
let handle = tokio::spawn(async move { serve(&state, &socket_clone).await });
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while !socket.exists() {
assert!(
std::time::Instant::now() < deadline,
"socket never appeared"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(lockfile.exists(), "lockfile must exist while daemon runs");
let client = connect_at(&socket).await.expect("connect");
let resp = client
.call_raw(DaemonRequest {
cwd: cwd.clone(),
payload: DaemonRequestPayload::Shutdown,
})
.await
.expect("shutdown transport ok");
match resp {
DaemonResponse::Ok { result } => {
assert_eq!(result, serde_json::Value::String("stopping".to_string()));
}
other => panic!("shutdown must ack `stopping`, got {other:?}"),
}
let served = timeout(Duration::from_secs(5), handle)
.await
.expect("serve must return after shutdown")
.expect("join ok");
served.expect("serve returns Ok on graceful shutdown");
assert!(!socket.exists(), "socket file must be removed on shutdown");
let err = connect_at(&socket)
.await
.expect_err("connect must fail after shutdown");
assert!(
format!("{err:#}").contains("daemon unavailable"),
"post-shutdown connect must report daemon unavailable: {err:#}"
);
}
#[tokio::test]
async fn sigterm_removes_socket_and_refuses_new_connections() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir corpus");
let socket = tmp.path().join("daemon.sock");
let cfg = docs_cfg(&ground, &corpus_root);
let state = DaemonState::open(cfg, None).await.expect("open state");
spawn_signal_handlers(&state);
let serve_state = state.clone();
let socket_clone = socket.clone();
let handle = tokio::spawn(async move { serve(&serve_state, &socket_clone).await });
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while !socket.exists() {
assert!(
std::time::Instant::now() < deadline,
"socket never appeared"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
let client = connect_at(&socket).await.expect("connect before SIGTERM");
let pong: serde_json::Value = client
.call(DaemonRequest {
cwd: seed_cwd(tmp.path()),
payload: DaemonRequestPayload::Ping,
})
.await
.expect("ping before SIGTERM");
assert_eq!(pong["version"].as_str(), Some(env!("CARGO_PKG_VERSION")));
rustix::process::kill_process(rustix::process::getpid(), rustix::process::Signal::TERM)
.expect("kill_process(self, SIGTERM) must succeed");
let served = timeout(Duration::from_secs(5), handle)
.await
.expect("serve must return after SIGTERM")
.expect("join ok");
served.expect("serve returns Ok on SIGTERM-driven shutdown");
assert!(
!socket.exists(),
"socket must be removed on SIGTERM shutdown"
);
let err = connect_at(&socket)
.await
.expect_err("connect must fail after SIGTERM");
assert!(
format!("{err:#}").contains("daemon unavailable"),
"post-SIGTERM connect must report daemon unavailable: {err:#}"
);
}
#[tokio::test]
async fn status_reports_running_then_not_running_across_shutdown() {
let _env = EnvGuard::set("HALLOUMINATE_SOCKET");
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir corpus");
let socket = tmp.path().join("daemon.sock");
let cfg = docs_cfg(&ground, &corpus_root);
let state = DaemonState::open(cfg, None).await.expect("open state");
let serve_state = state.clone();
let socket_clone = socket.clone();
let handle = tokio::spawn(async move { serve(&serve_state, &socket_clone).await });
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while !socket.exists() {
assert!(
std::time::Instant::now() < deadline,
"socket never appeared"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
unsafe { std::env::set_var("HALLOUMINATE_SOCKET", &socket) };
assert_eq!(
hallouminate::app::daemon::status()
.await
.expect("status ok while running"),
hallouminate::app::daemon::DaemonStatus::Running,
"status must be Running against a live daemon"
);
let client = connect_at(&socket).await.expect("connect");
let _ = client
.call_raw(DaemonRequest {
cwd: seed_cwd(tmp.path()),
payload: DaemonRequestPayload::Shutdown,
})
.await;
let served = timeout(Duration::from_secs(5), handle)
.await
.expect("serve returns after shutdown")
.expect("join ok");
served.expect("serve Ok on shutdown");
assert_eq!(
hallouminate::app::daemon::status()
.await
.expect("status ok while stopped"),
hallouminate::app::daemon::DaemonStatus::NotRunning,
"status must be NotRunning once the socket is gone"
);
}
#[tokio::test]
async fn stop_is_a_noop_against_an_already_stopped_daemon() {
let _env = EnvGuard::set("HALLOUMINATE_SOCKET");
let tmp = tempfile::tempdir().expect("tempdir");
let socket = tmp.path().join("never-bound.sock");
unsafe { std::env::set_var("HALLOUMINATE_SOCKET", &socket) };
hallouminate::app::daemon::stop()
.await
.expect("stop against a stopped daemon must be Ok");
assert!(
!socket.exists(),
"stop must not create the socket it never connected to"
);
}
async fn spawn_serve(cfg: Config, socket: &Path) -> tokio::task::JoinHandle<anyhow::Result<()>> {
let state = DaemonState::open(cfg, None).await.expect("open state");
let socket_clone = socket.to_path_buf();
let handle = tokio::spawn(async move { serve(&state, &socket_clone).await });
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while !socket.exists() {
assert!(
std::time::Instant::now() < deadline,
"socket never appeared"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
handle
}
#[tokio::test]
async fn restart_stops_the_old_daemon_then_brings_up_a_reachable_one() {
let _env = EnvGuard::set("HALLOUMINATE_SOCKET");
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir corpus");
let socket = tmp.path().join("daemon.sock");
let cfg = docs_cfg(&ground, &corpus_root);
let first = spawn_serve(cfg.clone(), &socket).await;
unsafe { std::env::set_var("HALLOUMINATE_SOCKET", &socket) };
assert_eq!(
hallouminate::app::daemon::status()
.await
.expect("status ok while first daemon runs"),
hallouminate::app::daemon::DaemonStatus::Running,
"the first daemon must be reachable before restart",
);
let restarted_cfg = cfg.clone();
let restart_socket = socket.clone();
let second_handle: std::sync::Arc<
std::sync::Mutex<Option<tokio::task::JoinHandle<anyhow::Result<()>>>>,
> = std::sync::Arc::new(std::sync::Mutex::new(None));
let stash = second_handle.clone();
hallouminate::app::daemon::restart_with(|| async move {
assert_eq!(
hallouminate::app::daemon::status()
.await
.expect("status ok between stop and respawn"),
hallouminate::app::daemon::DaemonStatus::NotRunning,
"restart must stop the old daemon before respawning",
);
let handle = spawn_serve(restarted_cfg, &restart_socket).await;
*stash.lock().expect("stash lock") = Some(handle);
Ok(())
})
.await
.expect("restart_with ok");
let first_result = timeout(Duration::from_secs(5), first)
.await
.expect("first serve must return after restart's stop")
.expect("first serve join ok");
first_result.expect("first serve returns Ok on shutdown");
assert_eq!(
hallouminate::app::daemon::status()
.await
.expect("status ok after restart"),
hallouminate::app::daemon::DaemonStatus::Running,
"restart must leave a fresh, reachable daemon up",
);
let second = second_handle
.lock()
.expect("stash lock")
.take()
.expect("respawn must have stored the second serve handle");
let client = connect_at(&socket).await.expect("connect to second daemon");
let _ = client
.call_raw(DaemonRequest {
cwd: seed_cwd(tmp.path()),
payload: DaemonRequestPayload::Shutdown,
})
.await;
let second_result = timeout(Duration::from_secs(5), second)
.await
.expect("second serve must return after teardown shutdown")
.expect("second serve join ok");
second_result.expect("second serve returns Ok on shutdown");
}
#[tokio::test]
async fn restart_via_lifecycle_leaves_a_daemon_reporting_the_current_version() {
let _env = EnvGuard::set("HALLOUMINATE_SOCKET");
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir corpus");
let socket = tmp.path().join("daemon.sock");
let cfg = docs_cfg(&ground, &corpus_root);
let first = spawn_serve(cfg.clone(), &socket).await;
unsafe { std::env::set_var("HALLOUMINATE_SOCKET", &socket) };
let restarted_cfg = cfg.clone();
let restart_socket = socket.clone();
let stash: std::sync::Arc<
std::sync::Mutex<Option<tokio::task::JoinHandle<anyhow::Result<()>>>>,
> = std::sync::Arc::new(std::sync::Mutex::new(None));
let stash_inner = stash.clone();
hallouminate::app::daemon::restart_with(|| async move {
let handle = spawn_serve(restarted_cfg, &restart_socket).await;
*stash_inner.lock().expect("stash lock") = Some(handle);
Ok(())
})
.await
.expect("restart_with ok");
let first_result = timeout(Duration::from_secs(5), first)
.await
.expect("first serve must return after restart's stop")
.expect("first serve join ok");
first_result.expect("first serve returns Ok on shutdown");
let client = connect_at(&socket)
.await
.expect("connect to restarted daemon");
let pong: serde_json::Value = client
.call(DaemonRequest {
cwd: seed_cwd(tmp.path()),
payload: DaemonRequestPayload::Ping,
})
.await
.expect("ping restarted daemon");
assert_eq!(
pong["version"].as_str(),
Some(env!("CARGO_PKG_VERSION")),
"restarted daemon must report the current version: {pong}"
);
let second = stash
.lock()
.expect("stash lock")
.take()
.expect("respawn must have stored the second serve handle");
let _ = client
.call_raw(DaemonRequest {
cwd: seed_cwd(tmp.path()),
payload: DaemonRequestPayload::Shutdown,
})
.await;
let second_result = timeout(Duration::from_secs(5), second)
.await
.expect("second serve must return after teardown shutdown")
.expect("second serve join ok");
second_result.expect("second serve returns Ok on shutdown");
}
struct EnvGuard {
key: &'static str,
_lock: std::sync::MutexGuard<'static, ()>,
}
impl EnvGuard {
fn set(key: &'static str) -> Self {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());
EnvGuard { key, _lock }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe { std::env::remove_var(self.key) };
}
}
#[tokio::test]
async fn watcher_reindexes_then_prunes_file_in_baseline_corpus_root() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir corpus");
let toml = format!(
"[[corpus]]\nname = \"docs\"\npaths = [\"{c}\"]\nglobs = [\"**/*.md\"]\n\n[embeddings]\nenabled = false\n\n[watch]\ndebounce_ms = 100\n\n[storage]\nground_dir = \"{g}\"\n",
c = corpus_root.display(),
g = ground.display(),
);
let cfg: Config = toml::from_str(&toml).expect("parse cfg");
let harness = DaemonHarness::spawn(cfg).await;
let watched = corpus_root.join("watched.md");
std::fs::write(
&watched,
"# Spice\n\nthe rarespiceword melange flows here\n",
)
.expect("write watched file");
let ground_hits = |client: hallouminate::app::daemon::DaemonClient, cwd: PathBuf| async move {
let res: hallouminate::app::daemon::GroundResult = client
.call(DaemonRequest {
cwd,
payload: DaemonRequestPayload::Ground(hallouminate::app::daemon::GroundRequest {
query: "rarespiceword".into(),
corpus: Some("docs".into()),
top_files: None,
chunks_per_file: None,
limit: None,
snippet_chars: None,
}),
})
.await
.expect("ground ok");
res.response.docs.len()
};
let deadline = std::time::Instant::now() + Duration::from_secs(20);
let mut indexed = false;
while std::time::Instant::now() < deadline {
if ground_hits(
connect_at(harness.socket()).await.expect("connect"),
harness.cwd().to_path_buf(),
)
.await
>= 1
{
indexed = true;
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(
indexed,
"watcher must reindex watched.md so `ground` returns it (no manual index was issued)"
);
std::fs::remove_file(&watched).expect("remove watched file");
let deadline = std::time::Instant::now() + Duration::from_secs(20);
let mut pruned = false;
while std::time::Instant::now() < deadline {
if ground_hits(
connect_at(harness.socket()).await.expect("connect"),
harness.cwd().to_path_buf(),
)
.await
== 0
{
pruned = true;
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(
pruned,
"watcher must prune watched.md's rows on delete so `ground` no longer returns it"
);
}
fn cfg_two_root_corpus(ground: &Path, root_a: &Path, root_b: &Path) -> Config {
let toml = format!(
r#"
[[corpus]]
name = "multi"
paths = ["{a}", "{b}"]
globs = ["**/*.md"]
[storage]
ground_dir = "{g}"
[embeddings]
enabled = false
"#,
a = root_a.display(),
b = root_b.display(),
g = ground.display(),
);
toml::from_str(&toml).expect("two-root corpus toml parses")
}
#[tokio::test]
async fn daemon_read_markdown_resolves_file_under_a_non_first_root() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let root_a = tmp.path().join("a");
let root_b = tmp.path().join("b");
std::fs::create_dir_all(&root_a).expect("mkdir a");
std::fs::create_dir_all(&root_b).expect("mkdir b");
let body = "# Under second root\n\nReachable now.\n";
std::fs::write(root_b.join("only-b.md"), body).expect("write under b");
let body_a = "# Under first root\n";
std::fs::write(root_a.join("only-a.md"), body_a).expect("write under a");
let harness = DaemonHarness::spawn(cfg_two_root_corpus(&ground, &root_a, &root_b)).await;
let client = connect_at(harness.socket()).await.expect("connect");
let read_b: serde_json::Value = client
.call(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::ReadMarkdown(ReadMarkdownRequest {
corpus: Some("multi".into()),
path: "only-b.md".into(),
}),
})
.await
.expect("read of paths[1] file must succeed");
assert_eq!(read_b["content"].as_str(), Some(body));
let read_a: serde_json::Value = client
.call(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::ReadMarkdown(ReadMarkdownRequest {
corpus: Some("multi".into()),
path: "only-a.md".into(),
}),
})
.await
.expect("read of paths[0] file must succeed");
assert_eq!(read_a["content"].as_str(), Some(body_a));
}
#[tokio::test]
async fn daemon_read_markdown_missing_in_all_roots_reports_does_not_exist() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let root_a = tmp.path().join("a");
let root_b = tmp.path().join("b");
std::fs::create_dir_all(&root_a).expect("mkdir a");
std::fs::create_dir_all(&root_b).expect("mkdir b");
let harness = DaemonHarness::spawn(cfg_two_root_corpus(&ground, &root_a, &root_b)).await;
let client = connect_at(harness.socket()).await.expect("connect");
let resp = client
.call_raw(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::ReadMarkdown(ReadMarkdownRequest {
corpus: Some("multi".into()),
path: "nowhere.md".into(),
}),
})
.await
.expect("transport ok");
match resp {
DaemonResponse::Err { kind, message } => {
assert_eq!(kind, ErrorKind::InvalidParams, "{message}");
assert!(message.contains("does not exist"), "got: {message}");
}
DaemonResponse::Ok { result } => panic!("missing file must error; got Ok({result:?})"),
}
}
#[tokio::test]
async fn daemon_add_markdown_to_multi_root_corpus_is_rejected() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let root_a = tmp.path().join("a");
let root_b = tmp.path().join("b");
std::fs::create_dir_all(&root_a).expect("mkdir a");
std::fs::create_dir_all(&root_b).expect("mkdir b");
let harness = DaemonHarness::spawn(cfg_two_root_corpus(&ground, &root_a, &root_b)).await;
let client = connect_at(harness.socket()).await.expect("connect");
let resp = client
.call_raw(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::AddMarkdown(AddMarkdownRequest {
corpus: "multi".into(),
path: "new.md".into(),
content: "# nope\n".into(),
overwrite: false,
..Default::default()
}),
})
.await
.expect("transport ok");
match resp {
DaemonResponse::Err { kind, message } => {
assert_eq!(kind, ErrorKind::InvalidParams, "{message}");
assert!(
message.contains("roots"),
"must explain the reason: {message}"
);
}
DaemonResponse::Ok { result } => {
panic!("multi-root add must be rejected; got Ok({result:?})")
}
}
assert!(
!root_a.join("new.md").exists(),
"must not write to paths[0]"
);
assert!(
!root_b.join("new.md").exists(),
"must not write to paths[1]"
);
}
#[tokio::test]
async fn daemon_delete_markdown_from_multi_root_corpus_is_rejected() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let root_a = tmp.path().join("a");
let root_b = tmp.path().join("b");
std::fs::create_dir_all(&root_a).expect("mkdir a");
std::fs::create_dir_all(&root_b).expect("mkdir b");
std::fs::write(root_b.join("doomed.md"), b"# here\n").expect("seed file under b");
let harness = DaemonHarness::spawn(cfg_two_root_corpus(&ground, &root_a, &root_b)).await;
let client = connect_at(harness.socket()).await.expect("connect");
let resp = client
.call_raw(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::DeleteMarkdown(DeleteMarkdownRequest {
corpus: "multi".into(),
path: "doomed.md".into(),
}),
})
.await
.expect("transport ok");
match resp {
DaemonResponse::Err { kind, message } => {
assert_eq!(kind, ErrorKind::InvalidParams, "{message}");
assert!(
message.contains("roots"),
"must explain the reason: {message}"
);
}
DaemonResponse::Ok { result } => {
panic!("multi-root delete must be rejected; got Ok({result:?})")
}
}
assert!(
root_b.join("doomed.md").exists(),
"rejected delete must leave the file intact"
);
}
fn cfg_two_corpora_one_missing(
ground_dir: &Path,
healthy_root: &Path,
ghost_root: &Path,
) -> Config {
let toml = format!(
r#"
[[corpus]]
name = "healthy"
paths = ["{healthy}"]
globs = ["**/*.md"]
[[corpus]]
name = "ghost"
paths = ["{ghost}"]
globs = ["**/*.md"]
[storage]
ground_dir = "{ground}"
[embeddings]
enabled = false
"#,
healthy = healthy_root.display(),
ghost = ghost_root.display(),
ground = ground_dir.display(),
);
toml::from_str(&toml).expect("two-corpora toml parses")
}
#[tokio::test]
async fn index_skips_missing_corpus_root_and_indexes_the_rest() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let healthy_root = tmp.path().join("healthy-corpus");
std::fs::create_dir_all(&healthy_root).expect("mkdir healthy corpus");
let ghost_root = tmp.path().join("does-not-exist-xyz");
let cfg = cfg_two_corpora_one_missing(&ground, &healthy_root, &ghost_root);
let harness = DaemonHarness::spawn(cfg).await;
let client = connect_at(harness.socket()).await.expect("connect");
let resp = client
.call_raw(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::Index(hallouminate::app::daemon::IndexRequest {
corpus: None,
paths_from: None,
strict: false,
}),
})
.await
.expect("index transport ok");
let report: hallouminate::app::cli::IndexReport = match resp {
DaemonResponse::Ok { result } => {
serde_json::from_value(result).expect("index payload shape")
}
DaemonResponse::Err { kind, message } => {
panic!("missing root must NOT abort the run, got {kind:?}: {message}");
}
};
let indexed: Vec<&str> = report.corpora.iter().map(|c| c.name.as_str()).collect();
assert_eq!(
indexed,
vec!["healthy"],
"only the healthy corpus should be indexed; ghost must be skipped: {indexed:?}"
);
assert_eq!(
report.warnings.len(),
1,
"exactly one skip warning expected"
);
let w = &report.warnings[0];
assert!(
w.contains("ghost") && w.contains("skipped"),
"warning must name the skipped corpus: {w}"
);
assert!(
w.contains(&ghost_root.display().to_string()),
"warning must name the missing root path: {w}"
);
}
#[tokio::test]
async fn index_strict_aborts_on_missing_corpus_root() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let healthy_root = tmp.path().join("healthy-corpus");
std::fs::create_dir_all(&healthy_root).expect("mkdir healthy corpus");
let ghost_root = tmp.path().join("does-not-exist-xyz");
let cfg = cfg_two_corpora_one_missing(&ground, &healthy_root, &ghost_root);
let harness = DaemonHarness::spawn(cfg).await;
let client = connect_at(harness.socket()).await.expect("connect");
let resp = client
.call_raw(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::Index(hallouminate::app::daemon::IndexRequest {
corpus: None,
paths_from: None,
strict: true,
}),
})
.await
.expect("index transport ok");
match resp {
DaemonResponse::Err {
kind: ErrorKind::InvalidParams,
message,
} => {
assert!(
message.contains("does not exist")
&& message.contains(&ghost_root.display().to_string()),
"strict error must name the missing root: {message}"
);
}
other => panic!("strict mode must reject a missing root, got: {other:?}"),
}
}
fn cfg_stale_corpus(ground: &Path, corpus_root: &Path) -> Config {
let toml = format!(
"[[corpus]]\nname = \"docs\"\npaths = [\"{c}\"]\nglobs = [\"**/*.md\"]\n\n[storage]\nground_dir = \"{g}\"\n\n[embeddings]\nenabled = false\n",
c = corpus_root.display(),
g = ground.display(),
);
toml::from_str(&toml).expect("stale corpus toml parses")
}
#[tokio::test]
async fn ground_marks_stale_true_when_file_modified_after_index() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir corpus");
let stale_file = corpus_root.join("will-change.md");
let fresh_file = corpus_root.join("unchanged.md");
std::fs::write(&stale_file, "# Stale\n\nstaletoken9182 content here\n").expect("write stale");
std::fs::write(&fresh_file, "# Fresh\n\nfreshtoken7364 content here\n").expect("write fresh");
let cfg = cfg_stale_corpus(&ground, &corpus_root);
let harness = DaemonHarness::spawn(cfg).await;
let client = connect_at(harness.socket()).await.expect("connect");
let resp = client
.call_raw(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::Index(IndexRequest {
corpus: Some("docs".into()),
paths_from: None,
strict: false,
}),
})
.await
.expect("index transport ok");
match resp {
DaemonResponse::Ok { .. } => {}
DaemonResponse::Err { kind, message } => {
panic!("index failed ({kind:?}): {message}");
}
}
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
std::fs::write(&stale_file, "# Stale\n\nstaletoken9182 modified\n").expect("rewrite stale");
let client = connect_at(harness.socket()).await.expect("reconnect");
let result: GroundResult = client
.call(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::Ground(GroundRequest {
query: "staletoken9182".into(),
corpus: Some("docs".into()),
top_files: None,
chunks_per_file: None,
limit: None,
snippet_chars: None,
}),
})
.await
.expect("ground ok");
let abs_stale = std::fs::canonicalize(&stale_file).unwrap_or_else(|_| stale_file.clone());
let abs_stale_str = abs_stale.to_str().unwrap();
let stale_doc = result
.response
.docs
.get(abs_stale_str)
.expect("stale file must appear in ground results");
assert!(
stale_doc.stale,
"file modified after index must be marked stale by handle_ground"
);
let client = connect_at(harness.socket()).await.expect("reconnect2");
let result2: GroundResult = client
.call(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::Ground(GroundRequest {
query: "freshtoken7364".into(),
corpus: Some("docs".into()),
top_files: None,
chunks_per_file: None,
limit: None,
snippet_chars: None,
}),
})
.await
.expect("ground ok 2");
let abs_fresh = std::fs::canonicalize(&fresh_file).unwrap_or_else(|_| fresh_file.clone());
let abs_fresh_str = abs_fresh.to_str().unwrap();
let fresh_doc = result2
.response
.docs
.get(abs_fresh_str)
.expect("fresh file must appear in ground results");
assert!(
!fresh_doc.stale,
"file unchanged since index must NOT be marked stale"
);
}
#[tokio::test]
async fn t20_mode_exclusivity_under_heading_plus_replace_lines_rejected() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir");
let cfg: Config = toml::from_str(&format!(
"[[corpus]]\nname=\"docs\"\npaths=[\"{c}\"]\nglobs=[\"**/*.md\"]\n[storage]\nground_dir=\"{g}\"\n[embeddings]\nenabled=false",
c = corpus_root.display(),
g = ground.display()
))
.expect("cfg");
let harness = DaemonHarness::spawn(cfg).await;
let client = connect_at(harness.socket()).await.expect("connect");
client
.call::<serde_json::Value>(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::AddMarkdown(AddMarkdownRequest {
corpus: "docs".into(),
path: "page.md".into(),
content: "## Section\n\nBody.\n".into(),
overwrite: false,
..Default::default()
}),
})
.await
.expect("initial write");
let original = std::fs::read_to_string(corpus_root.join("page.md")).unwrap();
let resp = client
.call_raw(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::AddMarkdown(AddMarkdownRequest {
corpus: "docs".into(),
path: "page.md".into(),
content: "x".into(),
overwrite: false,
under_heading: Some("Section".into()),
replace_lines: Some(LineRange { start: 1, end: 1 }),
..Default::default()
}),
})
.await
.expect("transport ok");
match resp {
DaemonResponse::Err {
kind: ErrorKind::InvalidParams,
message,
} => {
assert!(
message.contains("at most one"),
"error must mention exclusivity: {message}"
);
}
other => panic!("expected InvalidParams, got: {other:?}"),
}
assert_eq!(
std::fs::read_to_string(corpus_root.join("page.md")).unwrap(),
original,
"file must be untouched after rejected multi-mode request"
);
}
#[tokio::test]
async fn t21_mode_exclusivity_replace_lines_plus_replace_match_rejected() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir");
let cfg: Config = toml::from_str(&format!(
"[[corpus]]\nname=\"docs\"\npaths=[\"{c}\"]\nglobs=[\"**/*.md\"]\n[storage]\nground_dir=\"{g}\"\n[embeddings]\nenabled=false",
c = corpus_root.display(),
g = ground.display()
))
.expect("cfg");
let harness = DaemonHarness::spawn(cfg).await;
let client = connect_at(harness.socket()).await.expect("connect");
client
.call::<serde_json::Value>(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::AddMarkdown(AddMarkdownRequest {
corpus: "docs".into(),
path: "page.md".into(),
content: "Line one.\nLine two.\n".into(),
overwrite: false,
..Default::default()
}),
})
.await
.expect("initial write");
let resp = client
.call_raw(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::AddMarkdown(AddMarkdownRequest {
corpus: "docs".into(),
path: "page.md".into(),
content: "new".into(),
overwrite: false,
replace_lines: Some(LineRange { start: 1, end: 1 }),
replace_match: Some("Line one".into()),
..Default::default()
}),
})
.await
.expect("transport ok");
match resp {
DaemonResponse::Err {
kind: ErrorKind::InvalidParams,
message,
} => {
assert!(
message.contains("at most one"),
"error must mention exclusivity: {message}"
);
}
other => panic!("expected InvalidParams, got: {other:?}"),
}
assert_eq!(
std::fs::read_to_string(corpus_root.join("page.md")).unwrap(),
"Line one.\nLine two.\n",
"file must be untouched after rejected multi-mode request"
);
}
#[tokio::test]
async fn t22_under_heading_on_missing_file_returns_invalid_params() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir");
let cfg: Config = toml::from_str(&format!(
"[[corpus]]\nname=\"docs\"\npaths=[\"{c}\"]\nglobs=[\"**/*.md\"]\n[storage]\nground_dir=\"{g}\"\n[embeddings]\nenabled=false",
c = corpus_root.display(),
g = ground.display()
))
.expect("cfg");
let harness = DaemonHarness::spawn(cfg).await;
let client = connect_at(harness.socket()).await.expect("connect");
let resp = client
.call_raw(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::AddMarkdown(AddMarkdownRequest {
corpus: "docs".into(),
path: "nonexistent.md".into(),
content: "stuff".into(),
overwrite: false,
under_heading: Some("Section".into()),
..Default::default()
}),
})
.await
.expect("transport ok");
assert!(
matches!(
resp,
DaemonResponse::Err {
kind: ErrorKind::InvalidParams,
..
}
),
"expected InvalidParams for missing file, got: {resp:?}"
);
assert!(
!corpus_root.join("nonexistent.md").exists(),
"file must not be created by under_heading on a missing file"
);
}
#[tokio::test]
async fn t23_replace_lines_on_missing_file_returns_invalid_params() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir");
let cfg: Config = toml::from_str(&format!(
"[[corpus]]\nname=\"docs\"\npaths=[\"{c}\"]\nglobs=[\"**/*.md\"]\n[storage]\nground_dir=\"{g}\"\n[embeddings]\nenabled=false",
c = corpus_root.display(),
g = ground.display()
))
.expect("cfg");
let harness = DaemonHarness::spawn(cfg).await;
let client = connect_at(harness.socket()).await.expect("connect");
let resp = client
.call_raw(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::AddMarkdown(AddMarkdownRequest {
corpus: "docs".into(),
path: "nonexistent.md".into(),
content: "replacement".into(),
overwrite: false,
replace_lines: Some(LineRange { start: 1, end: 1 }),
..Default::default()
}),
})
.await
.expect("transport ok");
assert!(
matches!(
resp,
DaemonResponse::Err {
kind: ErrorKind::InvalidParams,
..
}
),
"expected InvalidParams for missing file, got: {resp:?}"
);
assert!(
!corpus_root.join("nonexistent.md").exists(),
"file must not be created by replace_lines on a missing file"
);
}
#[tokio::test]
async fn t24_replace_match_on_missing_file_returns_invalid_params() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir");
let cfg: Config = toml::from_str(&format!(
"[[corpus]]\nname=\"docs\"\npaths=[\"{c}\"]\nglobs=[\"**/*.md\"]\n[storage]\nground_dir=\"{g}\"\n[embeddings]\nenabled=false",
c = corpus_root.display(),
g = ground.display()
))
.expect("cfg");
let harness = DaemonHarness::spawn(cfg).await;
let client = connect_at(harness.socket()).await.expect("connect");
let resp = client
.call_raw(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::AddMarkdown(AddMarkdownRequest {
corpus: "docs".into(),
path: "nonexistent.md".into(),
content: "replacement".into(),
overwrite: false,
replace_match: Some("needle".into()),
..Default::default()
}),
})
.await
.expect("transport ok");
assert!(
matches!(
resp,
DaemonResponse::Err {
kind: ErrorKind::InvalidParams,
..
}
),
"expected InvalidParams for missing file, got: {resp:?}"
);
assert!(
!corpus_root.join("nonexistent.md").exists(),
"file must not be created by replace_match on a missing file"
);
}
#[tokio::test]
async fn t25_section_write_reindexes_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir");
let cfg: Config = toml::from_str(&format!(
"[[corpus]]\nname=\"docs\"\npaths=[\"{c}\"]\nglobs=[\"**/*.md\"]\n[storage]\nground_dir=\"{g}\"\n[embeddings]\nenabled=false",
c = corpus_root.display(),
g = ground.display()
))
.expect("cfg");
let harness = DaemonHarness::spawn(cfg).await;
let client = connect_at(harness.socket()).await.expect("connect");
client
.call::<serde_json::Value>(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::AddMarkdown(AddMarkdownRequest {
corpus: "docs".into(),
path: "wiki.md".into(),
content: "## Notes\n\nOriginal content.\n".into(),
overwrite: false,
..Default::default()
}),
})
.await
.expect("initial write");
let resp: serde_json::Value = client
.call(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::AddMarkdown(AddMarkdownRequest {
corpus: "docs".into(),
path: "wiki.md".into(),
content: "Added bullet.".into(),
overwrite: false,
under_heading: Some("Notes".into()),
position: Position::Append,
..Default::default()
}),
})
.await
.expect("section write ok");
let upserted = resp["indexed"]["corpora"][0]["files_upserted"]
.as_u64()
.unwrap_or(0);
assert!(
upserted >= 1,
"section write must report files_upserted >= 1: {resp}"
);
let on_disk = std::fs::read_to_string(corpus_root.join("wiki.md")).unwrap();
assert!(
on_disk.contains("Added bullet."),
"spliced content must be on disk: {on_disk:?}"
);
assert!(
on_disk.contains("Original content."),
"original content must be preserved: {on_disk:?}"
);
}
#[tokio::test]
async fn t26_replace_lines_write_reindexes_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir");
let cfg: Config = toml::from_str(&format!(
"[[corpus]]\nname=\"docs\"\npaths=[\"{c}\"]\nglobs=[\"**/*.md\"]\n[storage]\nground_dir=\"{g}\"\n[embeddings]\nenabled=false",
c = corpus_root.display(),
g = ground.display()
))
.expect("cfg");
let harness = DaemonHarness::spawn(cfg).await;
let client = connect_at(harness.socket()).await.expect("connect");
client
.call::<serde_json::Value>(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::AddMarkdown(AddMarkdownRequest {
corpus: "docs".into(),
path: "page.md".into(),
content: "alpha\nbeta\ngamma\n".into(),
overwrite: false,
..Default::default()
}),
})
.await
.expect("initial write");
let resp: serde_json::Value = client
.call(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::AddMarkdown(AddMarkdownRequest {
corpus: "docs".into(),
path: "page.md".into(),
content: "REPLACED".into(),
overwrite: false,
replace_lines: Some(LineRange { start: 2, end: 2 }),
..Default::default()
}),
})
.await
.expect("replace_lines ok");
let upserted = resp["indexed"]["corpora"][0]["files_upserted"]
.as_u64()
.unwrap_or(0);
assert!(
upserted >= 1,
"replace_lines must report files_upserted >= 1: {resp}"
);
let on_disk = std::fs::read_to_string(corpus_root.join("page.md")).unwrap();
assert_eq!(
on_disk, "alpha\nREPLACED\ngamma\n",
"replace_lines must produce exact composed output: {on_disk:?}"
);
}
#[tokio::test]
async fn t27_whole_file_write_with_new_fields_omitted_is_unchanged() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir");
let cfg: Config = toml::from_str(&format!(
"[[corpus]]\nname=\"docs\"\npaths=[\"{c}\"]\nglobs=[\"**/*.md\"]\n[storage]\nground_dir=\"{g}\"\n[embeddings]\nenabled=false",
c = corpus_root.display(),
g = ground.display()
))
.expect("cfg");
let harness = DaemonHarness::spawn(cfg).await;
let client = connect_at(harness.socket()).await.expect("connect");
let body = "# Whole File\n\nContent unchanged.\n";
let resp: serde_json::Value = client
.call(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::AddMarkdown(AddMarkdownRequest {
corpus: "docs".into(),
path: "whole.md".into(),
content: body.into(),
overwrite: false,
..Default::default()
}),
})
.await
.expect("whole-file write ok");
assert_eq!(
std::fs::read_to_string(corpus_root.join("whole.md")).unwrap(),
body,
"whole-file write must be stored verbatim"
);
assert!(
resp["indexed"]["corpora"][0]["files_upserted"]
.as_u64()
.unwrap_or(0)
>= 1,
"whole-file write must report files_upserted >= 1: {resp}"
);
}
#[tokio::test]
async fn t28_edit_mode_lint_warnings_ride_back_on_composed_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir");
let cfg: Config = toml::from_str(&format!(
"[[corpus]]\nname=\"docs\"\npaths=[\"{c}\"]\nglobs=[\"**/*.md\"]\n[storage]\nground_dir=\"{g}\"\n[embeddings]\nenabled=false",
c = corpus_root.display(),
g = ground.display()
))
.expect("cfg");
let harness = DaemonHarness::spawn(cfg).await;
let client = connect_at(harness.socket()).await.expect("connect");
client
.call::<serde_json::Value>(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::AddMarkdown(AddMarkdownRequest {
corpus: "docs".into(),
path: "page.md".into(),
content: "## Section\n\nClean content.\n".into(),
overwrite: false,
..Default::default()
}),
})
.await
.expect("initial write");
let resp: serde_json::Value = client
.call(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::AddMarkdown(AddMarkdownRequest {
corpus: "docs".into(),
path: "page.md".into(),
content: "See [broken link]() for details.".into(),
overwrite: false,
under_heading: Some("Section".into()),
position: Position::Append,
..Default::default()
}),
})
.await
.expect("edit-mode write ok despite lint");
let on_disk = std::fs::read_to_string(corpus_root.join("page.md")).unwrap();
assert!(
on_disk.contains("broken link"),
"content must be on disk despite lint warning: {on_disk:?}"
);
let warnings = resp["warnings"].as_array();
assert!(
warnings.is_some_and(|w| !w.is_empty()),
"lint warnings must ride back on edit-mode write: {resp}"
);
let joined = warnings
.unwrap()
.iter()
.filter_map(|w| w.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(
joined.contains("empty destination"),
"empty-destination lint must be present: {joined}"
);
}
fn write_stale_meta(ground_dir: &Path, schema_version: u32) {
std::fs::create_dir_all(ground_dir).expect("mkdir ground");
let meta = format!(
"# auto-managed by hallouminate; do not edit\n\
embedding_model_name = \"BAAI/bge-small-en-v1.5\"\n\
quantized = false\n\
embeddings_enabled = false\n\
schema_version = {schema_version}\n"
);
std::fs::write(ground_dir.join("meta.toml"), meta).expect("write meta.toml");
}
fn cfg_with_corpus(ground_dir: &Path, corpus_root: &Path) -> Config {
let toml = format!(
"[[corpus]]\nname = \"docs\"\npaths = [\"{c}\"]\nglobs = [\"**/*.md\"]\n\n\
[storage]\nground_dir = \"{g}\"\n\n\
[embeddings]\nenabled = false\nmodel = \"BAAI/bge-small-en-v1.5\"\n",
c = corpus_root.display(),
g = ground_dir.display(),
);
toml::from_str(&toml).expect("cfg_with_corpus toml parses")
}
fn read_schema_version(ground_dir: &Path) -> u32 {
#[derive(serde::Deserialize)]
struct MetaVersion {
schema_version: u32,
}
let text = std::fs::read_to_string(ground_dir.join("meta.toml")).expect("read meta.toml");
let m: MetaVersion = toml::from_str(&text).expect("parse meta.toml");
m.schema_version
}
#[tokio::test]
async fn stale_store_auto_rebuilds_on_daemon_open() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir corpus");
std::fs::write(corpus_root.join("hello.md"), "# Hello\n\nworld\n").expect("seed");
let current = hallouminate::adapters::lance::default_schema_version_pub();
let stale = current - 1;
write_stale_meta(&ground, stale);
let cfg = cfg_with_corpus(&ground, &corpus_root);
DaemonState::open(cfg, None)
.await
.expect("stale store must be auto-rebuilt, not fatal");
let bak = ground.with_file_name(format!("ground.bak-v{stale}"));
assert!(
bak.exists(),
"backup ground.bak-v{stale} must exist after rebuild"
);
let bak_version = read_schema_version(&bak);
assert_eq!(
bak_version, stale,
"backup meta.toml must retain the stale schema_version so the data is recoverable"
);
let fresh_version = read_schema_version(&ground);
assert_eq!(
fresh_version, current,
"fresh meta.toml must record the current schema version"
);
}
#[tokio::test]
async fn downgrade_store_is_fatal_and_original_untouched() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir corpus");
let current = hallouminate::adapters::lance::default_schema_version_pub();
let newer = current + 1;
write_stale_meta(&ground, newer);
let cfg = cfg_with_corpus(&ground, &corpus_root);
let err = DaemonState::open(cfg, None)
.await
.expect_err("downgrade must be fatal");
let msg = err.to_string();
assert!(
msg.to_uppercase().contains("NEWER"),
"error must say NEWER (downgrade): {msg}"
);
assert!(
msg.to_lowercase().contains("upgrade"),
"error must advise upgrade: {msg}"
);
let bak = ground.with_file_name(format!("ground.bak-v{newer}"));
assert!(
!bak.exists(),
"no .bak must exist after downgrade rejection"
);
let stored = read_schema_version(&ground);
assert_eq!(stored, newer, "original meta.toml must be untouched");
}
#[tokio::test]
async fn matching_version_store_is_untouched() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir corpus");
let current = hallouminate::adapters::lance::default_schema_version_pub();
write_stale_meta(&ground, current);
let cfg = cfg_with_corpus(&ground, &corpus_root);
DaemonState::open(cfg, None)
.await
.expect("matching version must open cleanly");
let bak = ground.with_file_name(format!("ground.bak-v{current}"));
assert!(!bak.exists(), "no .bak must exist when versions match");
let still_current = read_schema_version(&ground);
assert_eq!(
still_current, current,
"matching-version open must not alter meta.toml"
);
}
#[tokio::test]
async fn stale_rebuild_reproduces_corpus_content() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir corpus");
let seeded = corpus_root.join("doc.md");
std::fs::write(&seeded, "# Doc\n\ncontent here\n").expect("seed doc");
let current = hallouminate::adapters::lance::default_schema_version_pub();
write_stale_meta(&ground, current - 1);
let cfg = cfg_with_corpus(&ground, &corpus_root);
let harness = DaemonHarness::spawn(cfg).await;
let client = connect_at(harness.socket()).await.expect("connect");
let result: ListFilesResult = client
.call(DaemonRequest {
cwd: harness.cwd().to_path_buf(),
payload: DaemonRequestPayload::ListFiles(ListFilesRequest {
corpus: Some("docs".into()),
}),
})
.await
.expect("list_files ok");
assert!(
!result.is_empty(),
"rebuilt corpus must contain the seeded file; list_files was empty"
);
let paths: Vec<&str> = result.iter().map(|e| e.path.as_str()).collect();
assert!(
paths.iter().any(|p| p.contains("doc.md")),
"seeded doc.md must appear in list_files after rebuild; got: {paths:?}"
);
}
#[tokio::test]
async fn stale_rebuild_overwrites_prior_backup() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir corpus");
std::fs::write(corpus_root.join("doc.md"), "# Doc\n\ncontent\n").expect("seed");
let current = hallouminate::adapters::lance::default_schema_version_pub();
let stale = current - 1;
write_stale_meta(&ground, stale);
let bak = ground.with_file_name(format!("ground.bak-v{stale}"));
std::fs::create_dir_all(&bak).expect("mkdir prior backup");
std::fs::write(bak.join("sentinel"), "old backup").expect("write sentinel");
let cfg = cfg_with_corpus(&ground, &corpus_root);
DaemonState::open(cfg, None)
.await
.expect("rebuild must succeed even when prior backup exists");
assert!(bak.exists(), "ground.bak-v{stale} must exist after rebuild");
assert!(
!bak.join("sentinel").exists(),
"prior backup must be replaced (sentinel file must be gone)"
);
}
#[tokio::test]
async fn stale_rebuild_failure_returns_err_and_preserves_backup() {
let tmp = tempfile::tempdir().expect("tempdir");
let ground = tmp.path().join("ground");
let corpus_root = tmp.path().join("corpus");
std::fs::create_dir_all(&corpus_root).expect("mkdir corpus");
std::fs::write(corpus_root.join("doc.md"), "# Doc\n\ncontent\n").expect("seed");
let current = hallouminate::adapters::lance::default_schema_version_pub();
let stale = current - 1;
write_stale_meta(&ground, stale);
let toml = format!(
"[[corpus]]\nname = \"docs\"\npaths = [\"{c}\"]\nglobs = [\"[invalid\"]\n\n\
[storage]\nground_dir = \"{g}\"\n\n\
[embeddings]\nenabled = false\nmodel = \"BAAI/bge-small-en-v1.5\"\n",
c = corpus_root.display(),
g = ground.display(),
);
let cfg: Config = toml::from_str(&toml).expect("toml parses");
let err = DaemonState::open(cfg, None)
.await
.expect_err("rebuild with scan error must fail");
assert!(
err.to_string().contains("rebuild"),
"error must mention rebuild: {err}"
);
let bak = ground.with_file_name(format!("ground.bak-v{stale}"));
assert!(
bak.exists(),
"stale store backup must be preserved after failed rebuild"
);
assert!(
!ground.exists(),
"partial fresh ground dir must be removed on rebuild failure"
);
}