use std::path::Path;
use std::time::Duration;
use serde_json::{Value, json};
use crate::config::{self, Config};
use crate::enumeration::WatchFilter;
use crate::error::Result;
use crate::prompts;
use crate::search::neighbors::neighbors;
use crate::store::Store;
use crate::store::marker::change_neighbors;
use crate::types::RelativePath;
use super::payload::{HookPayload, ToolInput};
use super::ready_check::check_index_ready;
use super::spawn::spawn_background_reindex_file;
use crate::store::marker::WATCH_MARKER_STALE_SECS;
const READ_SURFACING_TIMEOUT_MS: u64 = 2_000;
const READ_SURFACING_MAX_CHUNKS: usize = 50_000;
pub(super) async fn handle_post_tool_use(
project_root: &Path,
payload: HookPayload,
) -> Result<Option<Value>> {
let config = config::load(project_root).ok();
let tool_name = payload.tool_name.as_deref();
if tool_name == Some("Read")
&& !config
.as_ref()
.is_some_and(|cfg| cfg.hooks.surface_related_on_read)
{
return Ok(None);
}
let read_input = if let Some(name) = tool_name
&& matches!(name, "Edit" | "Write" | "NotebookEdit" | "MultiEdit")
&& let Some(cfg) = config.as_ref()
&& cfg.hooks.auto_reembed_on_edit
&& !watcher_alive(project_root, cfg)
&& let Some(input) = payload.tool_input.as_ref()
{
let paths = reindex_paths_from_input(project_root, input);
let spawned = paths
.iter()
.filter(|p| reindex_target_is_watchable(project_root, p))
.inspect(|p| spawn_background_reindex_file(project_root, p))
.count();
if spawned > 0 {
None
} else {
payload.tool_input
}
} else {
payload.tool_input
};
let index_ready = config
.as_ref()
.and_then(|cfg| check_index_ready(project_root, cfg, "PostToolUse"));
let change_neighbors =
take_change_neighbors_context(project_root, config.as_ref(), "PostToolUse");
let read_neighbors = read_surfacing_context(
project_root,
config.as_ref(),
tool_name,
read_input.as_ref(),
"PostToolUse",
)
.await;
Ok(combine_hook_responses(
"PostToolUse",
[index_ready, change_neighbors, read_neighbors],
))
}
async fn read_surfacing_context(
project_root: &Path,
config: Option<&Config>,
tool_name: Option<&str>,
tool_input: Option<&ToolInput>,
event_name: &str,
) -> Option<Value> {
if tool_name != Some("Read") {
return None;
}
let cfg = config?;
if !cfg.hooks.surface_related_on_read {
return None;
}
let input = tool_input?;
let file_path = input.file_path.as_deref()?;
if input.offset.is_none() && input.limit.is_none() {
return None;
}
let raw = Path::new(file_path);
let relative = if raw.is_absolute() {
let raw_canonical = raw.canonicalize();
let raw_absolute = raw_canonical.as_deref().unwrap_or(raw);
let root_canonical = project_root.canonicalize();
let root_absolute = root_canonical.as_deref().unwrap_or(project_root);
raw_absolute.strip_prefix(root_absolute).ok()?.to_path_buf()
} else {
raw.to_path_buf()
};
let read_path = RelativePath::from_path(&relative);
read_path
.reject_escape(prompts::hints::READ_INSIDE_PROJECT_DIR)
.ok()?;
let start = input.offset.unwrap_or(1);
let end = input
.limit
.map(|count| start.saturating_add(count.saturating_sub(1)));
let store = Store::new(project_root, cfg).ok()?;
let all_rows = match tokio::time::timeout(
Duration::from_millis(READ_SURFACING_TIMEOUT_MS),
store.read_chunks(),
)
.await
{
Ok(Ok(rows)) => rows,
_ => return None,
};
if all_rows.len() > READ_SURFACING_MAX_CHUNKS {
return None;
}
let query_vectors: Vec<Vec<f32>> = all_rows
.iter()
.filter(|row| row.file_path == read_path.as_str())
.filter(|row| chunk_overlaps_window(row.line_start, row.line_end, start, end))
.map(|row| row.vector.clone())
.collect();
if query_vectors.is_empty() {
return None;
}
let exclude = read_path.clone();
let top_k = cfg.hooks.related_top_k;
let min_similarity = cfg.hooks.related_min_similarity;
let hits = match tokio::time::timeout(
Duration::from_millis(READ_SURFACING_TIMEOUT_MS),
tokio::task::spawn_blocking(move || {
neighbors(&all_rows, &query_vectors, &exclude, top_k, min_similarity)
}),
)
.await
{
Ok(Ok(hits)) => hits,
_ => return None,
};
let hits: Vec<_> = hits
.into_iter()
.filter(|n| neighbor_file_exists(project_root, &n.file_path))
.collect();
if hits.is_empty() {
return None;
}
let locations: Vec<String> = hits
.iter()
.map(|n| {
prompts::hooks::read_neighbor_line(
&n.file_path,
n.line_start,
n.line_end,
n.name.as_deref(),
n.score,
)
})
.collect();
let context = prompts::hooks::read_related_context(read_path.as_str(), start, end, &locations);
Some(json!({
"hookSpecificOutput": {
"hookEventName": event_name,
"additionalContext": context,
}
}))
}
fn neighbor_file_exists(project_root: &Path, relative_path: &str) -> bool {
let relative = RelativePath::new(relative_path);
if relative
.reject_escape(prompts::hints::READ_INSIDE_PROJECT_DIR)
.is_err()
{
return false;
}
project_root.join(relative.as_str()).exists()
}
fn chunk_overlaps_window(
chunk_start: u32,
chunk_end: u32,
window_start: u32,
window_end: Option<u32>,
) -> bool {
let starts_in_range = window_end.is_none_or(|end| chunk_start <= end);
starts_in_range && chunk_end >= window_start
}
pub(super) fn take_change_neighbors_context(
project_root: &Path,
config: Option<&Config>,
event_name: &str,
) -> Option<Value> {
let cfg = config?;
if !cfg.hooks.surface_related_on_edit {
return None;
}
let store = Store::new(project_root, cfg).ok()?;
let marker_path = store.change_neighbors_marker_path();
let marker = change_neighbors::read_and_remove(&marker_path)?;
let seen_path = store.change_neighbors_seen_path();
let seen = change_neighbors::read_seen(&seen_path);
let mut fresh_keys: Vec<String> = Vec::new();
let hits: Vec<String> = marker
.neighbors
.iter()
.filter(|n| n.file_path != marker.edited_path)
.filter(|n| neighbor_file_exists(project_root, &n.file_path))
.filter(|n| {
let key =
change_neighbors::seen_key(&marker.edited_path, &n.file_path, n.name.as_deref());
if seen.contains(&key) {
return false;
}
fresh_keys.push(key);
true
})
.map(|n| {
prompts::hooks::edit_neighbor_line(
&n.file_path,
n.line_start,
n.line_end,
n.name.as_deref(),
n.score,
)
})
.collect();
if hits.is_empty() {
return None;
}
change_neighbors::append_seen(&seen_path, &fresh_keys);
let context = prompts::hooks::edit_related_context(&marker.edited_path, &hits);
Some(json!({
"hookSpecificOutput": {
"hookEventName": event_name,
"additionalContext": context,
}
}))
}
pub(super) fn combine_hook_responses(
event_name: &str,
sources: impl IntoIterator<Item = Option<Value>>,
) -> Option<Value> {
let present: Vec<Value> = sources.into_iter().flatten().collect();
match present.as_slice() {
[] => None,
[single] => Some(single.clone()),
many => {
let combined = many
.iter()
.map(|value| {
value["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap_or("")
})
.collect::<Vec<_>>()
.join("\n");
Some(json!({
"hookSpecificOutput": {
"hookEventName": event_name,
"additionalContext": combined,
}
}))
}
}
}
pub(super) fn watcher_alive(project_root: &Path, config: &Config) -> bool {
let Ok(store) = Store::new(project_root, config) else {
return false;
};
crate::store::marker::is_alive(
&store.watch_marker_path(),
Duration::from_secs(WATCH_MARKER_STALE_SECS),
)
}
fn reindex_target_is_watchable(project_root: &Path, file_path: &str) -> bool {
let raw = Path::new(file_path);
let relative = if raw.is_absolute() {
let raw_canonical = raw.canonicalize();
let raw_absolute = raw_canonical.as_deref().unwrap_or(raw);
let root_canonical = project_root.canonicalize();
let root_absolute = root_canonical.as_deref().unwrap_or(project_root);
match raw_absolute.strip_prefix(root_absolute) {
Ok(relative) => relative.to_path_buf(),
Err(_) => return false,
}
} else {
raw.to_path_buf()
};
if relative.as_os_str().is_empty() {
return false;
}
match WatchFilter::load(project_root) {
Ok(filter) => filter.is_watchable(&relative),
Err(_) => true,
}
}
fn reindex_paths_from_input(project_root: &Path, input: &ToolInput) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut paths = Vec::new();
let singles = input
.file_path
.as_deref()
.into_iter()
.chain(input.notebook_path.as_deref());
let multi = input
.files_modified
.as_deref()
.unwrap_or(&[])
.iter()
.map(String::as_str);
for p in singles.chain(multi) {
if seen.insert(canonical_dedup_key(project_root, p)) {
paths.push(p.to_owned());
}
}
paths
}
fn canonical_dedup_key(project_root: &Path, file_path: &str) -> String {
let raw = Path::new(file_path);
let relative = if raw.is_absolute() {
let raw_canonical = raw.canonicalize();
let raw_absolute = raw_canonical.as_deref().unwrap_or(raw);
let root_canonical = project_root.canonicalize();
let root_absolute = root_canonical.as_deref().unwrap_or(project_root);
raw_absolute
.strip_prefix(root_absolute)
.map(Path::to_path_buf)
.unwrap_or_else(|_| raw.to_path_buf())
} else {
raw.to_path_buf()
};
RelativePath::from_path(&relative).as_str().to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use serde_json::json;
use crate::config::Config;
use crate::hooks::{HookEvent, run};
use crate::store::{Manifest, Store};
mod fixture {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/common/fixture.rs"
));
}
mod config_support {
use crate as claudix;
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/common/config_support.rs"
));
}
use config_support::stub_config;
use fixture::TestFixture;
fn write_config(project_root: &Path, config: &Config) {
let claude_dir = project_root.join(".claude");
assert!(fs::create_dir_all(&claude_dir).is_ok());
let config_text = toml::to_string(config);
assert!(config_text.is_ok());
assert!(
fs::write(
claude_dir.join("claudix.toml"),
config_text.ok().unwrap_or_default()
)
.is_ok()
);
}
fn make_tool_input(file_path: Option<&str>, files_modified: Option<Vec<&str>>) -> ToolInput {
let json = serde_json::json!({
"file_path": file_path,
"files_modified": files_modified.map(|v| v.into_iter().collect::<Vec<_>>()),
});
serde_json::from_value(json).expect("must parse")
}
#[test]
fn reindex_paths_only_file_path() {
let input = make_tool_input(Some("src/lib.rs"), None);
assert_eq!(
reindex_paths_from_input(Path::new("/proj"), &input),
vec!["src/lib.rs"]
);
}
#[test]
fn reindex_paths_only_files_modified() {
let input = make_tool_input(None, Some(vec!["src/lib.rs", "src/main.rs"]));
assert_eq!(
reindex_paths_from_input(Path::new("/proj"), &input),
vec!["src/lib.rs", "src/main.rs"]
);
}
#[test]
fn reindex_paths_both_deduped() {
let input = make_tool_input(Some("src/lib.rs"), Some(vec!["src/lib.rs", "src/main.rs"]));
let paths = reindex_paths_from_input(Path::new("/proj"), &input);
assert_eq!(paths, vec!["src/lib.rs", "src/main.rs"]);
}
#[test]
fn reindex_paths_neither_field_is_empty() {
let input = make_tool_input(None, None);
assert!(reindex_paths_from_input(Path::new("/proj"), &input).is_empty());
}
#[test]
fn reindex_paths_abs_and_relative_spelling_dedup_to_one() {
let fixture = TestFixture::new("small_rust").unwrap_or_else(|_| unreachable!());
let abs = fixture.root().join("src/math.rs");
let abs = abs.to_string_lossy().into_owned();
let input = make_tool_input(Some(&abs), Some(vec!["src/math.rs"]));
let paths = reindex_paths_from_input(fixture.root(), &input);
assert_eq!(
paths.len(),
1,
"abs + project-relative spelling of one file must dedup, got: {paths:?}"
);
}
#[tokio::test]
async fn post_tool_use_spawns_background_reindex_and_returns_none() {
let fixture = TestFixture::new("small_rust");
assert!(fixture.is_ok());
let fixture = fixture.ok().unwrap_or_else(|| unreachable!());
write_config(fixture.root(), &stub_config());
let payload = json!({
"tool_name": "Write",
"tool_input": {
"file_path": fixture.root().join("src/math.rs"),
}
});
let response = run(fixture.root(), HookEvent::PostToolUse, &payload.to_string()).await;
assert!(response.is_ok());
assert!(response.ok().unwrap_or_else(|| unreachable!()).is_none());
}
#[test]
fn reindex_target_is_watchable_rejects_index_internal_paths() {
let fixture = TestFixture::new("small_rust").unwrap_or_else(|_| unreachable!());
assert!(reindex_target_is_watchable(fixture.root(), "src/math.rs"));
assert!(!reindex_target_is_watchable(
fixture.root(),
".claudix/manifest.json"
));
assert!(!reindex_target_is_watchable(fixture.root(), ".git/HEAD"));
let absolute_outside = std::env::temp_dir().join("nope.rs");
assert!(!reindex_target_is_watchable(
fixture.root(),
&absolute_outside.to_string_lossy(),
));
}
#[tokio::test]
async fn post_tool_use_ignores_read_tool() {
let fixture = TestFixture::new("small_rust");
assert!(fixture.is_ok());
let fixture = fixture.ok().unwrap_or_else(|| unreachable!());
write_config(fixture.root(), &stub_config());
let payload = json!({
"tool_name": "Read",
"tool_input": {
"file_path": fixture.root().join("src/math.rs"),
}
});
let response = run(fixture.root(), HookEvent::PostToolUse, &payload.to_string()).await;
assert!(response.is_ok());
assert!(
response.ok().unwrap_or_else(|| unreachable!()).is_none(),
"Read tool must not trigger reindex"
);
}
#[tokio::test]
async fn post_tool_use_triggers_reindex_for_notebook_edit() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
write_config(fixture.root(), &stub_config());
let payload = json!({
"tool_name": "NotebookEdit",
"tool_input": {
"notebook_path": fixture.root().join("analysis.ipynb"),
}
});
let response = run(fixture.root(), HookEvent::PostToolUse, &payload.to_string()).await?;
assert!(
response.is_none(),
"NotebookEdit must trigger reindex and return None"
);
Ok(())
}
#[tokio::test]
async fn post_tool_use_passes_through_when_auto_reembed_disabled() {
let fixture = TestFixture::new("small_rust");
assert!(fixture.is_ok());
let fixture = fixture.ok().unwrap_or_else(|| unreachable!());
let mut config = stub_config();
config.hooks.auto_reembed_on_edit = false;
write_config(fixture.root(), &config);
let payload = json!({
"tool_name": "Write",
"tool_input": {
"file_path": fixture.root().join("src/math.rs"),
}
});
let response = run(fixture.root(), HookEvent::PostToolUse, &payload.to_string()).await;
assert!(response.is_ok());
assert!(response.ok().unwrap_or_else(|| unreachable!()).is_none());
}
#[tokio::test]
async fn user_prompt_submit_surfaces_indexing_completion() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = stub_config();
write_config(fixture.root(), &config);
let store = Store::new(fixture.root(), &config)?;
store.ensure_layout()?;
let stale_created_at = "2025-01-01T00:00:00Z";
let payload = format!("none\n{stale_created_at}\n0\n");
fs::write(store.pending_index_marker_path(), payload)?;
let mut manifest = Manifest::new(config.embedding.model.clone(), 8);
manifest.file_count = 3;
manifest.chunk_count = 12;
manifest.last_full_index_at = Some(crate::util::now_rfc3339());
store.write_manifest(&manifest)?;
let response = run(fixture.root(), HookEvent::UserPromptSubmit, "{}").await?;
let response = response.unwrap_or(Value::Null);
assert_eq!(
response["hookSpecificOutput"]["hookEventName"].as_str(),
Some("UserPromptSubmit"),
"response must carry the firing event name"
);
let context = response["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap_or_default();
assert!(
context.contains("indexing complete"),
"expected completion context, got: {context}"
);
Ok(())
}
#[tokio::test]
async fn watcher_alive_reports_live_marker() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = stub_config();
let store = Store::new(fixture.root(), &config)?;
store.ensure_layout()?;
let marker_path = store.watch_marker_path();
fs::write(&marker_path, std::process::id().to_string())?;
assert!(
watcher_alive(fixture.root(), &config),
"current-PID watch marker must register as alive"
);
Ok(())
}
#[tokio::test]
async fn watcher_alive_returns_false_without_marker() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = stub_config();
assert!(!watcher_alive(fixture.root(), &config));
Ok(())
}
fn write_neighbors_marker(
store: &Store,
edited_path: &str,
neighbors: Vec<crate::store::marker::change_neighbors::NeighborEntry>,
) {
use crate::store::marker::change_neighbors::{ChangeNeighborsMarker, write};
let marker = ChangeNeighborsMarker {
edited_path: edited_path.to_owned(),
neighbors,
};
write(&store.change_neighbors_marker_path(), &marker);
}
fn make_neighbor_entry(
file_path: &str,
name: &str,
score: f32,
) -> crate::store::marker::change_neighbors::NeighborEntry {
crate::store::marker::change_neighbors::NeighborEntry {
file_path: file_path.to_owned(),
line_start: 10,
line_end: 25,
name: Some(name.to_owned()),
score,
}
}
#[tokio::test]
async fn neighbors_marker_surfaces_related_file_in_additional_context() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = stub_config();
write_config(fixture.root(), &config);
let store = Store::new(fixture.root(), &config)?;
store.ensure_layout()?;
write_neighbors_marker(
&store,
"src/lib.rs",
vec![make_neighbor_entry("src/math.rs", "add", 0.82)],
);
let response = take_change_neighbors_context(fixture.root(), Some(&config), "PostToolUse");
let response = response.unwrap_or(serde_json::Value::Null);
let context = response["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap_or_default();
assert!(
context.contains("src/math.rs"),
"neighbor file must appear in additionalContext, got: {context}"
);
assert!(
!context.contains("src/lib.rs:") || context.contains("edit of `src/lib.rs`"),
"edited file must not appear as a hit in context, got: {context}"
);
Ok(())
}
#[tokio::test]
async fn neighbors_marker_acked_on_read() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = stub_config();
write_config(fixture.root(), &config);
let store = Store::new(fixture.root(), &config)?;
store.ensure_layout()?;
write_neighbors_marker(
&store,
"src/lib.rs",
vec![make_neighbor_entry("src/math.rs", "add", 0.80)],
);
assert!(
store.change_neighbors_marker_path().exists(),
"marker must exist before read"
);
let _ = take_change_neighbors_context(fixture.root(), Some(&config), "PostToolUse");
assert!(
!store.change_neighbors_marker_path().exists(),
"marker must be removed after being read (ack)"
);
Ok(())
}
#[tokio::test]
async fn no_neighbors_marker_produces_no_context() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = stub_config();
write_config(fixture.root(), &config);
let store = Store::new(fixture.root(), &config)?;
store.ensure_layout()?;
let response = take_change_neighbors_context(fixture.root(), Some(&config), "PostToolUse");
assert!(
response.is_none(),
"absent marker must produce no additionalContext"
);
Ok(())
}
#[tokio::test]
async fn surface_related_on_edit_false_suppresses_context() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let mut config = stub_config();
config.hooks.surface_related_on_edit = false;
write_config(fixture.root(), &config);
let store = Store::new(fixture.root(), &config)?;
store.ensure_layout()?;
write_neighbors_marker(
&store,
"src/lib.rs",
vec![make_neighbor_entry("src/math.rs", "add", 0.82)],
);
let response = take_change_neighbors_context(fixture.root(), Some(&config), "PostToolUse");
assert!(
response.is_none(),
"surface_related_on_edit = false must suppress output even when marker is present"
);
Ok(())
}
#[tokio::test]
async fn edited_file_never_surfaced_as_own_neighbor() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = stub_config();
write_config(fixture.root(), &config);
let store = Store::new(fixture.root(), &config)?;
store.ensure_layout()?;
write_neighbors_marker(
&store,
"src/lib.rs",
vec![
make_neighbor_entry("src/lib.rs", "greet", 0.99), make_neighbor_entry("src/math.rs", "add", 0.80),
],
);
let response = take_change_neighbors_context(fixture.root(), Some(&config), "PostToolUse");
let response = response.unwrap_or(serde_json::Value::Null);
let context = response["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap_or_default();
assert!(
context.contains("src/math.rs"),
"non-edited neighbor must be in context, got: {context}"
);
assert!(
!context.contains("src/lib.rs:"),
"edited file must not appear as a hit in context, got: {context}"
);
Ok(())
}
#[tokio::test]
async fn repeated_identical_neighbor_pair_is_suppressed() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = stub_config();
write_config(fixture.root(), &config);
let store = Store::new(fixture.root(), &config)?;
store.ensure_layout()?;
write_neighbors_marker(
&store,
"src/lib.rs",
vec![make_neighbor_entry("src/math.rs", "add", 0.82)],
);
let response = take_change_neighbors_context(fixture.root(), Some(&config), "PostToolUse");
let response = response.unwrap_or(serde_json::Value::Null);
let context = response["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap_or_default();
assert!(
context.contains("src/math.rs"),
"first take must surface the neighbor, got: {context}"
);
write_neighbors_marker(
&store,
"src/lib.rs",
vec![make_neighbor_entry("src/math.rs", "add", 0.82)],
);
let response = take_change_neighbors_context(fixture.root(), Some(&config), "PostToolUse");
assert!(
response.is_none(),
"identical (edited → neighbor) pair already surfaced this session must be suppressed"
);
Ok(())
}
#[tokio::test]
async fn same_neighbor_via_different_edited_file_still_surfaces() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = stub_config();
write_config(fixture.root(), &config);
let store = Store::new(fixture.root(), &config)?;
store.ensure_layout()?;
write_neighbors_marker(
&store,
"src/lib.rs",
vec![make_neighbor_entry("src/math.rs", "add", 0.82)],
);
let _ = take_change_neighbors_context(fixture.root(), Some(&config), "PostToolUse");
write_neighbors_marker(
&store,
"src/other.rs",
vec![make_neighbor_entry("src/math.rs", "add", 0.82)],
);
let response = take_change_neighbors_context(fixture.root(), Some(&config), "PostToolUse");
let response = response.unwrap_or(serde_json::Value::Null);
let context = response["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap_or_default();
assert!(
context.contains("src/math.rs"),
"same neighbor via a different edited file must still surface, got: {context}"
);
Ok(())
}
#[tokio::test]
async fn session_start_clears_seen_ledger() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = stub_config();
write_config(fixture.root(), &config);
let store = Store::new(fixture.root(), &config)?;
store.ensure_layout()?;
let seen_path = store.change_neighbors_seen_path();
fs::write(&seen_path, "src/lib.rs\tsrc/math.rs\tadd\n")?;
assert!(seen_path.exists(), "ledger must exist before SessionStart");
run(fixture.root(), HookEvent::SessionStart, "{}").await?;
assert!(
!seen_path.exists(),
"SessionStart must reset the dedup ledger"
);
Ok(())
}
#[tokio::test]
async fn combine_both_messages_when_both_present() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = stub_config();
write_config(fixture.root(), &config);
let store = Store::new(fixture.root(), &config)?;
store.ensure_layout()?;
let stale_created_at = "2025-01-01T00:00:00Z";
let payload = format!("none\n{stale_created_at}\n0\n");
fs::write(store.pending_index_marker_path(), payload)?;
let mut manifest = Manifest::new(config.embedding.model.clone(), 8);
manifest.file_count = 1;
manifest.chunk_count = 2;
manifest.last_full_index_at = Some(crate::util::now_rfc3339());
store.write_manifest(&manifest)?;
write_neighbors_marker(
&store,
"src/lib.rs",
vec![make_neighbor_entry("src/math.rs", "add", 0.80)],
);
let response = run(fixture.root(), HookEvent::PostToolUse, "{}").await?;
let response = response.unwrap_or(serde_json::Value::Null);
let context = response["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap_or_default();
assert!(
context.contains("indexing complete"),
"combined context must include index-ready message, got: {context}"
);
assert!(
context.contains("src/math.rs"),
"combined context must include neighbor file, got: {context}"
);
Ok(())
}
async fn seed_rows(store: &Store, config: &Config, rows: &[(&str, &str, u32, u32, Vec<f32>)]) {
use crate::types::{
ByteRange, Chunk, ChunkId, ChunkKind, EmbeddedChunk, FileHash, Language, LineRange,
RelativePath,
};
for (path, ..) in rows {
let abs = store.project_root().join(path);
if let Some(parent) = abs.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&abs, "// seeded\n");
}
let embedded: Vec<EmbeddedChunk> = rows
.iter()
.enumerate()
.map(|(i, (path, name, start, end, vector))| {
let chunk = Chunk {
id: ChunkId(i as u64 + 1),
file_path: RelativePath::new(*path),
language: Language::Rust,
kind: ChunkKind::Function,
name: Some((*name).to_owned()),
line_range: LineRange {
start: *start,
end: *end,
},
byte_range: ByteRange { start: 0, end: 50 },
file_hash: FileHash([0u8; 16]),
content: format!("pub fn {name}() {{}}"),
};
EmbeddedChunk {
chunk,
vector: vector.clone(),
}
})
.collect();
assert!(store.replace_chunks(&embedded, config).await.is_ok());
}
fn read_config_on() -> Config {
let mut config = stub_config();
config.hooks.surface_related_on_read = true;
config.hooks.related_top_k = 5;
config.hooks.related_min_similarity = 0.5;
config
}
#[test]
fn chunk_overlaps_window_respects_bounds() {
assert!(
chunk_overlaps_window(8, 12, 10, Some(20)),
"straddles start"
);
assert!(chunk_overlaps_window(15, 18, 10, Some(20)), "inside window");
assert!(chunk_overlaps_window(18, 25, 10, Some(20)), "straddles end");
assert!(!chunk_overlaps_window(1, 9, 10, Some(20)), "ends before");
assert!(!chunk_overlaps_window(21, 30, 10, Some(20)), "starts after");
assert!(chunk_overlaps_window(50, 60, 10, None), "far below EOF");
assert!(!chunk_overlaps_window(1, 9, 10, None), "ends before start");
assert!(
chunk_overlaps_window(u32::MAX, u32::MAX, u32::MAX, Some(u32::MAX)),
"saturating read windows still match the last line"
);
}
#[test]
fn read_surfacing_defaults_off() {
assert!(!Config::default().hooks.surface_related_on_read);
}
#[tokio::test]
async fn ranged_read_surfaces_related_neighbor_naming_region() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = read_config_on();
write_config(fixture.root(), &config);
let store = Store::new(fixture.root(), &config)?;
store.ensure_layout()?;
seed_rows(
&store,
&config,
&[
(
"src/foo.rs",
"target",
10,
20,
vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
),
(
"src/bar.rs",
"twin",
40,
58,
vec![0.99, 0.01, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
),
(
"src/other.rs",
"unrelated",
1,
5,
vec![0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
),
],
)
.await;
let payload = json!({
"tool_name": "Read",
"tool_input": { "file_path": "src/foo.rs", "offset": 12, "limit": 6 }
});
let response = run(fixture.root(), HookEvent::PostToolUse, &payload.to_string()).await?;
let response = response.unwrap_or(Value::Null);
let context = response["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap_or_default();
assert!(
context.contains("src/bar.rs"),
"neighbor file must be surfaced, got: {context}"
);
assert!(
context.contains("lines 12-17"),
"context must name the read region, got: {context}"
);
assert!(
!context.contains("src/foo.rs:"),
"read file must not be listed as its own neighbor, got: {context}"
);
Ok(())
}
#[tokio::test]
async fn ranged_read_absolute_path_surfaces_neighbor() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = read_config_on();
write_config(fixture.root(), &config);
let store = Store::new(fixture.root(), &config)?;
store.ensure_layout()?;
seed_rows(
&store,
&config,
&[
(
"src/foo.rs",
"target",
10,
20,
vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
),
(
"src/bar.rs",
"twin",
40,
58,
vec![0.99, 0.01, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
),
],
)
.await;
let abs_path = fixture.root().join("src/foo.rs");
let payload = json!({
"tool_name": "Read",
"tool_input": { "file_path": abs_path, "offset": 12, "limit": 6 }
});
let response = run(fixture.root(), HookEvent::PostToolUse, &payload.to_string()).await?;
let response = response.unwrap_or(Value::Null);
let context = response["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap_or_default();
assert!(
context.contains("src/bar.rs"),
"absolute-path read must still surface the neighbor, got: {context}"
);
Ok(())
}
#[tokio::test]
async fn deleted_neighbor_is_not_surfaced_on_read() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = read_config_on();
write_config(fixture.root(), &config);
let store = Store::new(fixture.root(), &config)?;
store.ensure_layout()?;
seed_rows(
&store,
&config,
&[
(
"src/foo.rs",
"target",
10,
20,
vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
),
(
"src/bar.rs",
"twin",
40,
58,
vec![0.99, 0.01, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
),
],
)
.await;
assert!(fs::remove_file(store.project_root().join("src/bar.rs")).is_ok());
let payload = json!({
"tool_name": "Read",
"tool_input": { "file_path": "src/foo.rs", "offset": 12, "limit": 6 }
});
let response = run(fixture.root(), HookEvent::PostToolUse, &payload.to_string()).await?;
assert!(
response.is_none(),
"a neighbor whose file was deleted on disk must not be surfaced"
);
Ok(())
}
#[tokio::test]
async fn full_file_read_is_noop() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = read_config_on();
write_config(fixture.root(), &config);
let store = Store::new(fixture.root(), &config)?;
store.ensure_layout()?;
seed_rows(
&store,
&config,
&[
(
"src/foo.rs",
"target",
10,
20,
vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
),
(
"src/bar.rs",
"twin",
40,
58,
vec![0.99, 0.01, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
),
],
)
.await;
let payload = json!({
"tool_name": "Read",
"tool_input": { "file_path": "src/foo.rs" }
});
let response = run(fixture.root(), HookEvent::PostToolUse, &payload.to_string()).await?;
assert!(
response.is_none(),
"full-file read must not surface neighbors"
);
Ok(())
}
#[tokio::test]
async fn ranged_read_with_nothing_related_is_noop() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = read_config_on();
write_config(fixture.root(), &config);
let store = Store::new(fixture.root(), &config)?;
store.ensure_layout()?;
seed_rows(
&store,
&config,
&[
(
"src/foo.rs",
"target",
10,
20,
vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
),
(
"src/bar.rs",
"stranger",
40,
58,
vec![0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
),
],
)
.await;
let payload = json!({
"tool_name": "Read",
"tool_input": { "file_path": "src/foo.rs", "offset": 12, "limit": 6 }
});
let response = run(fixture.root(), HookEvent::PostToolUse, &payload.to_string()).await?;
assert!(response.is_none(), "no neighbor clears the floor → noop");
Ok(())
}
#[tokio::test]
async fn window_missing_chunk_span_yields_no_query_vectors() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = read_config_on();
write_config(fixture.root(), &config);
let store = Store::new(fixture.root(), &config)?;
store.ensure_layout()?;
seed_rows(
&store,
&config,
&[
(
"src/foo.rs",
"target",
10,
20,
vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
),
(
"src/bar.rs",
"twin",
40,
58,
vec![0.99, 0.01, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
),
],
)
.await;
let payload = json!({
"tool_name": "Read",
"tool_input": { "file_path": "src/foo.rs", "offset": 1, "limit": 5 }
});
let response = run(fixture.root(), HookEvent::PostToolUse, &payload.to_string()).await?;
assert!(
response.is_none(),
"window missing the chunk span must surface nothing"
);
Ok(())
}
#[tokio::test]
async fn read_surfacing_disabled_skips_store() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let mut config = stub_config();
config.hooks.surface_related_on_read = false;
write_config(fixture.root(), &config);
let store = Store::new(fixture.root(), &config)?;
store.ensure_layout()?;
seed_rows(
&store,
&config,
&[
(
"src/foo.rs",
"target",
10,
20,
vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
),
(
"src/bar.rs",
"twin",
40,
58,
vec![0.99, 0.01, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
),
],
)
.await;
let payload = json!({
"tool_name": "Read",
"tool_input": { "file_path": "src/foo.rs", "offset": 12, "limit": 6 }
});
let response = run(fixture.root(), HookEvent::PostToolUse, &payload.to_string()).await?;
assert!(
response.is_none(),
"flag off must surface nothing on a ranged read"
);
assert!(
!store.change_neighbors_marker_path().exists(),
"Read must never trigger a reindex"
);
Ok(())
}
}