use std::io::Write;
use std::process::{Command, Stdio};
#[cfg(unix)]
use std::{
fs,
io::{BufRead, BufReader},
path::{Path, PathBuf},
process::Child,
sync::atomic::{AtomicU64, Ordering},
time::{Duration, Instant},
};
fn run_hook(stdin_body: &str, cwd: &std::path::Path) -> (String, std::process::ExitStatus) {
let mut child = Command::new(env!("CARGO_BIN_EXE_rag-rat"))
.arg("agent-hook")
.current_dir(cwd)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let _ = child.stdin.as_mut().unwrap().write_all(stdin_body.as_bytes());
let out = child.wait_with_output().unwrap();
(String::from_utf8_lossy(&out.stdout).into_owned(), out.status)
}
#[test]
fn no_rag_rat_toml_means_silent_exit_zero() {
let dir = std::env::temp_dir().join(format!("ragrat-hook-noindex-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let input = serde_json::json!({
"session_id": "s1", "cwd": dir, "hook_event_name": "PreToolUse",
"tool_name": "Grep", "tool_input": {"pattern": "anything"}
});
let (stdout, status) = run_hook(&input.to_string(), &dir);
assert!(status.success());
assert!(stdout.is_empty(), "must print nothing without an index, got: {stdout}");
}
#[test]
fn garbage_stdin_means_silent_exit_zero() {
let dir = std::env::temp_dir().join(format!("ragrat-hook-garbage-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (stdout, status) = run_hook("this is not json", &dir);
assert!(status.success());
assert!(stdout.is_empty());
}
#[test]
fn non_search_tool_means_silent_exit_zero() {
let dir = std::env::temp_dir().join(format!("ragrat-hook-read-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let input = serde_json::json!({
"session_id": "s1", "cwd": dir, "hook_event_name": "PreToolUse",
"tool_name": "Read", "tool_input": {"path": "/x"}
});
let (stdout, status) = run_hook(&input.to_string(), &dir);
assert!(status.success());
assert!(stdout.is_empty());
}
#[cfg(unix)]
#[test]
fn socket_path_serves_dedupes_and_falls_back() {
let repo = TestRepo::indexed_with_symbol();
let mut server = repo.spawn_mcp_initialized();
let socket = repo.socket_path();
wait_for(&socket, Duration::from_secs(10));
let first = repo.run_hook_session("s1");
let context = additional_context(&first)
.unwrap_or_else(|| panic!("listener gave no additionalContext, got: {first}"));
assert!(
context.contains("frobnicate_xyz") && context.contains("src/lib.rs"),
"listener context must name the indexed symbol and file, got: {context}"
);
let repeat = repo.run_hook_session("s1");
assert!(
repeat.is_empty(),
"same-session repeat must be deduped to empty stdout, got: {repeat}"
);
let other = repo.run_hook_session("s2");
let other_context = additional_context(&other)
.unwrap_or_else(|| panic!("fresh session got no additionalContext, got: {other}"));
assert!(
other_context.contains("frobnicate_xyz"),
"fresh session must not be deduped, got: {other_context}"
);
server.kill_and_wait();
wait_until_gone(&socket, Duration::from_secs(10));
let fallback = repo.run_hook_session("s1");
let fallback_context = additional_context(&fallback).unwrap_or_else(|| {
panic!("fallback path gave no additionalContext after server death, got: {fallback}")
});
assert!(
fallback_context.contains("frobnicate_xyz") && fallback_context.contains("src/lib.rs"),
"fallback must compose from SQLite directly, got: {fallback_context}"
);
repo.cleanup();
}
#[cfg(unix)]
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
#[cfg(unix)]
struct TestRepo {
root: PathBuf,
config_path: PathBuf,
config: rag_rat_core::Config,
}
#[cfg(unix)]
impl TestRepo {
fn indexed_with_symbol() -> Self {
let id = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let root =
std::env::temp_dir().join(format!("ragrat-hook-e2e-{}-{id}", std::process::id()));
fs::create_dir_all(root.join("src")).unwrap();
fs::write(root.join("src/lib.rs"), "pub fn frobnicate_xyz() {}\n").unwrap();
let config_path = root.join("rag-rat.toml");
fs::write(
&config_path,
"[index]\nroot = \".\"\ndatabase = \
\".rag-rat/index.sqlite\"\n\n[target_bindings]\nrust = [\"src\"]\n",
)
.unwrap();
let config = rag_rat_core::Config::load(&config_path).unwrap();
rag_rat_core::IndexDatabase::rebuild(&config).unwrap();
assert_symbol_indexed(&config, "frobnicate_xyz");
Self { root, config_path, config }
}
fn socket_path(&self) -> PathBuf {
rag_rat_core::locks::hook_socket_path_for(&self.config)
}
fn spawn_mcp_initialized(&self) -> McpServer {
let mut child = Command::new(env!("CARGO_BIN_EXE_rag-rat"))
.arg("mcp")
.arg("--config")
.arg(&self.config_path)
.env("RAG_RAT_NO_WATCH", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut stdin = child.stdin.take().unwrap();
let mut reader = BufReader::new(child.stdout.take().unwrap());
writeln!(
stdin,
"{}",
serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2024-11-05", "capabilities": {},
"clientInfo": {"name": "rag-rat-hook-e2e", "version": "0.1"}
}
})
)
.unwrap();
stdin.flush().unwrap();
let mut line = String::new();
reader.read_line(&mut line).unwrap();
assert!(line.contains("\"id\":1"), "initialize response, got: {line}");
writeln!(
stdin,
"{}",
serde_json::json!({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})
)
.unwrap();
stdin.flush().unwrap();
McpServer { child, _stdin: stdin, _reader: reader }
}
fn run_hook_session(&self, session_id: &str) -> String {
let input = serde_json::json!({
"session_id": session_id, "cwd": self.root, "hook_event_name": "PreToolUse",
"tool_name": "Grep", "tool_input": {"pattern": "frobnicate_xyz"}
});
let (stdout, status) = run_hook(&input.to_string(), &self.root);
assert!(status.success(), "agent-hook must exit zero on every path");
stdout
}
fn cleanup(&self) {
let _ = fs::remove_dir_all(&self.root);
}
}
#[cfg(unix)]
struct McpServer {
child: Child,
_stdin: std::process::ChildStdin,
_reader: BufReader<std::process::ChildStdout>,
}
#[cfg(unix)]
impl McpServer {
fn kill_and_wait(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[cfg(unix)]
impl Drop for McpServer {
fn drop(&mut self) {
self.kill_and_wait();
}
}
#[cfg(unix)]
fn additional_context(stdout: &str) -> Option<String> {
let trimmed = stdout.trim();
if trimmed.is_empty() {
return None;
}
let value: serde_json::Value =
serde_json::from_str(trimmed).expect("client emitted valid JSON");
assert_eq!(value["hookSpecificOutput"]["hookEventName"], "PreToolUse");
value["hookSpecificOutput"]["additionalContext"].as_str().map(str::to_string)
}
#[cfg(unix)]
fn assert_symbol_indexed(config: &rag_rat_core::Config, symbol: &str) {
use rag_rat_core::storage::IndexConnection;
let conn = IndexConnection::open_read_only(&config.database).unwrap();
let count: i64 = conn
.connection()
.query_row("SELECT COUNT(*) FROM symbols WHERE name = ?1", [symbol], |row| row.get(0))
.unwrap();
assert!(count > 0, "index must contain the `{symbol}` symbol or the e2e is vacuous");
}
#[cfg(unix)]
fn wait_for(path: &Path, timeout: Duration) {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if path.exists() {
return;
}
std::thread::sleep(Duration::from_millis(50));
}
panic!("expected socket {} within {timeout:?}", path.display());
}
#[cfg(unix)]
fn wait_until_gone(path: &Path, timeout: Duration) {
use std::os::unix::net::UnixStream;
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if UnixStream::connect(path).is_err() {
return;
}
std::thread::sleep(Duration::from_millis(50));
}
panic!("listener at {} still accepting after {timeout:?}", path.display());
}
#[test]
fn session_start_warns_when_the_index_schema_is_newer() {
let dir = std::env::temp_dir().join(format!("ragrat-hook-newer-schema-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "pub fn skew_probe() {}\n").unwrap();
std::fs::write(
dir.join("rag-rat.toml"),
"[index]\nroot = \".\"\ndatabase = \".rag-rat/index.sqlite\"\n\n[target_bindings]\nrust = \
[\"src\"]\n",
)
.unwrap();
let config = rag_rat_core::Config::load(dir.join("rag-rat.toml")).unwrap();
rag_rat_core::IndexDatabase::rebuild(&config).unwrap();
let conn = rusqlite::Connection::open(&config.database).unwrap();
conn.execute_batch(
"INSERT INTO schema_version(id, applied_at_ms, checksum, description)
VALUES ('999_future_schema', 1, 'sha256:future', 'future schema');",
)
.unwrap();
drop(conn);
let input = serde_json::json!({
"session_id": "s-skew", "cwd": dir, "hook_event_name": "SessionStart",
"source": "startup"
});
let (stdout, status) = run_hook(&input.to_string(), &dir);
assert!(status.success(), "the hook must never block session start");
assert!(
stdout.contains("newer rag-rat") && stdout.contains("upgrade rag-rat"),
"expected an actionable version-skew notice, got: {stdout:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}