Skip to content
This repository was archived by the owner on Aug 18, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 153 additions & 9 deletions src/freshness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,43 +121,65 @@ fn refresh_walked_file(
) -> Result<()> {
if file.indexable {
let metadata_matches = indexed.is_some_and(|meta| {
meta.modified_ms == file.modified_ms && meta.byte_size == file.byte_size && meta.indexed
meta.modified_ms == file.modified_ms && meta.byte_size == file.byte_size
});
if metadata_matches && engine.content_unchanged_since_snapshot(file.change_ns) {
return Ok(());
}

let path = root.join(&file.path);
let walked = walker::walk_single_file(root, &path)
.with_context(|| format!("failed to read {}", path.display()))?;
let content = walked.content;
debug_assert_eq!(walked.path, file.path);
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
remove_indexed_path(engine, &file.path, work);
return Ok(());
}
Err(error) => {
return Err(error).with_context(|| format!("failed to read {}", path.display()));
}
};
let content = match String::from_utf8(bytes) {
Ok(content) => content,
Err(_) => {
refresh_metadata_only_file(engine, file, indexed, work);
return Ok(());
}
};
if engine.content(&file.path) == Some(content.as_str()) && indexed.is_some() {
if !metadata_matches {
engine.update_file_metadata(&file.path, content.len() as u64, walked.modified_ms);
engine.update_file_metadata(&file.path, content.len() as u64, file.modified_ms);
work.summary.indexed += 1;
}
return Ok(());
}

engine.index_file_with_modified_no_rebuild(&file.path, &content, walked.modified_ms);
engine.index_file_with_modified_no_rebuild(&file.path, &content, file.modified_ms);
work.summary.indexed += 1;
work.dependencies_changed = true;
return Ok(());
}

refresh_metadata_only_file(engine, file, indexed, work);
Ok(())
}

fn refresh_metadata_only_file(
engine: &mut Engine,
file: &walker::WalkedFileMeta,
indexed: Option<&crate::types::FileMeta>,
work: &mut RefreshWork,
) {
let metadata_matches = indexed.is_some_and(|meta| {
meta.modified_ms == file.modified_ms && meta.byte_size == file.byte_size && !meta.indexed
});

if metadata_matches {
return Ok(());
return;
}

engine.index_file_meta_only_no_dep_rebuild(&file.path, file.byte_size, file.modified_ms);
work.summary.indexed += 1;
work.dependencies_changed |= indexed.is_none_or(|meta| meta.indexed);
Ok(())
}

fn remove_indexed_path(engine: &mut Engine, relative: &str, work: &mut RefreshWork) {
Expand Down Expand Up @@ -255,6 +277,37 @@ mod tests {
assert!(loaded.find_symbol("old").is_empty());
}

#[test]
fn metadata_only_snapshot_fast_path_detects_restored_mtime_text_changes() {
let dir = tempdir().unwrap();
let project = dir.path().join("project");
std::fs::create_dir(&project).unwrap();
let path = project.join("source.rs");
let snapshot_path = dir.path().join("graph.lexa");
let source = "fn restored() {}\n";
std::fs::write(&path, vec![0xff; source.len()]).unwrap();
let original_modified = path.metadata().unwrap().modified().unwrap();

let mut indexed = Engine::new(16);
indexed.index_project(&project);
crate::snapshot::write_snapshot(&indexed, &snapshot_path).unwrap();

let mut loaded = Engine::new(16);
crate::snapshot::load_snapshot_into_engine(&mut loaded, &snapshot_path).unwrap();
assert_eq!(
refresh_project(&mut loaded, &project).unwrap(),
RefreshSummary::default()
);

std::fs::write(&path, source).unwrap();
let file = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
file.set_times(std::fs::FileTimes::new().set_modified(original_modified))
.unwrap();

assert_eq!(refresh_project(&mut loaded, &project).unwrap().indexed, 1);
assert!(!loaded.find_symbol("restored").is_empty());
}

#[test]
fn refresh_project_persists_metadata_only_changes_once() {
let dir = tempdir().unwrap();
Expand Down Expand Up @@ -298,6 +351,97 @@ mod tests {
assert!(!engine.find_symbol("added").is_empty());
}

#[test]
fn refresh_project_tracks_non_utf8_files_and_text_transitions() {
let dir = tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join("artifact.gpg"), [0xff, 0xfe]).unwrap();
std::fs::write(root.join("broken.rs"), [0xff, 0xfe]).unwrap();

let mut engine = Engine::new(16);
engine.index_project(root);

assert!(engine
.file_map()
.iter()
.filter(|(path, _)| path == "artifact.gpg" || path == "broken.rs")
.all(|(_, meta)| !meta.indexed));
assert_eq!(
refresh_project(&mut engine, root).unwrap(),
RefreshSummary::default()
);

std::fs::write(root.join("added.icc"), [0xff, 0xfe, 0xfd]).unwrap();
assert_eq!(refresh_project(&mut engine, root).unwrap().indexed, 1);
assert!(engine
.file_map()
.iter()
.find(|(path, _)| path == "added.icc")
.is_some_and(|(_, meta)| !meta.indexed));

std::fs::write(root.join("added.icc"), [0xff, 0xfe, 0xfd, 0xfc]).unwrap();
assert_eq!(refresh_project(&mut engine, root).unwrap().indexed, 1);

std::fs::write(root.join("broken.rs"), "fn recovered() {}\n").unwrap();
assert_eq!(refresh_project(&mut engine, root).unwrap().indexed, 1);
assert!(!engine.find_symbol("recovered").is_empty());

std::fs::write(root.join("broken.rs"), [0xff, 0xfe, 0xfd]).unwrap();
assert_eq!(refresh_project(&mut engine, root).unwrap().indexed, 1);
assert!(engine.find_symbol("recovered").is_empty());
assert!(engine
.file_map()
.iter()
.find(|(path, _)| path == "broken.rs")
.is_some_and(|(_, meta)| !meta.indexed));
assert_eq!(
refresh_project(&mut engine, root).unwrap(),
RefreshSummary::default()
);
}

#[test]
fn refresh_walked_file_handles_disappearance_but_keeps_other_read_errors() {
let dir = tempdir().unwrap();
let root = dir.path();
let path = root.join("race.rs");
std::fs::write(&path, "fn race() {}\n").unwrap();

let mut engine = Engine::new(16);
engine.index_project(root);
let walked = walker::walk_single_file_meta(root, &path).unwrap();
let indexed = engine
.file_map()
.into_iter()
.find_map(|(path, meta)| (path == "race.rs").then_some(meta));
std::fs::remove_file(&path).unwrap();

let mut work = RefreshWork::default();
refresh_walked_file(&mut engine, root, &walked, indexed.as_ref(), &mut work).unwrap();
assert_eq!(work.summary.removed, 1);
assert!(engine.file_map().is_empty());

std::fs::write(&path, "fn blocked() {}\n").unwrap();
engine.index_project(root);
let walked = walker::walk_single_file_meta(root, &path).unwrap();
let indexed = engine
.file_map()
.into_iter()
.find_map(|(path, meta)| (path == "race.rs").then_some(meta));
std::fs::remove_file(&path).unwrap();
std::fs::create_dir(&path).unwrap();

let error = refresh_walked_file(
&mut engine,
root,
&walked,
indexed.as_ref(),
&mut RefreshWork::default(),
)
.unwrap_err();
assert!(error.to_string().contains("failed to read"));
}

#[test]
fn refresh_project_removes_deleted_files() {
let dir = tempdir().unwrap();
Expand Down
21 changes: 0 additions & 21 deletions src/walker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,6 @@ const SKIP_FILES: &[&str] = &[
"go.sum",
];

pub struct WalkedFile {
pub path: String,
pub content: String,
pub modified_ms: u64,
}

pub struct WalkedFileMeta {
pub path: String,
pub modified_ms: u64,
Expand Down Expand Up @@ -98,21 +92,6 @@ pub fn walk_project_meta(root: impl AsRef<Path>) -> Vec<WalkedFileMeta> {
files
}

pub fn walk_single_file(root: impl AsRef<Path>, path: impl AsRef<Path>) -> Option<WalkedFile> {
let root = root.as_ref();
let path = path.as_ref();
let meta = walked_file_meta(root, path)?;
if !meta.indexable {
return None;
}
let content = std::fs::read_to_string(path).ok()?;
Some(WalkedFile {
path: meta.path,
content,
modified_ms: meta.modified_ms,
})
}

pub fn walk_single_file_meta(
root: impl AsRef<Path>,
path: impl AsRef<Path>,
Expand Down
108 changes: 107 additions & 1 deletion tests/cli_graph.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
#![allow(clippy::unwrap_used)]

use serde_json::{json, Value};
use std::io::{BufRead, BufReader, Write};
use std::path::Path;
use std::process::Command;
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};

fn lexa() -> Command {
Command::new(env!("CARGO_BIN_EXE_lexa"))
Expand Down Expand Up @@ -54,6 +58,108 @@ fn text_search_reports_regex_errors_as_structured_output() {
.is_some_and(|value| !value.is_empty()));
}

#[test]
fn persisted_graph_and_mcp_handle_non_utf8_files() {
let temp = tempfile::tempdir().unwrap();
let project = temp.path();
std::fs::write(project.join("main.rs"), "fn searchable() {}\n").unwrap();
for path in [
"key.gpg",
"sRGB.icc",
"cert.p12",
"form.fdf",
"font.z",
"font.ctg.z",
] {
std::fs::write(project.join(path), [0xff, 0xfe, 0xfd]).unwrap();
}

let indexed = lexa()
.current_dir(project)
.args(["index", "."])
.output()
.unwrap();
assert!(
indexed.status.success(),
"stderr: {}",
String::from_utf8_lossy(&indexed.stderr)
);

let mut child = lexa()
.current_dir(project)
.args(["mcp", "."])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
let stdin = child.stdin.as_mut().unwrap();
writeln!(
stdin,
"{}",
json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": { "name": "binary-regression", "version": "1" }
}
})
)
.unwrap();
stdin.flush().unwrap();

let (response_tx, response_rx) = mpsc::channel();
let stdout = child.stdout.take().unwrap();
let reader = thread::spawn(move || {
let mut response = String::new();
let result = BufReader::new(stdout).read_line(&mut response);
let _ = response_tx.send((result, response));
});
let response = match response_rx.recv_timeout(Duration::from_secs(10)) {
Ok((result, response)) => {
result.unwrap();
reader.join().unwrap();
response
}
Err(error) => {
let _ = child.kill();
let _ = child.wait();
reader.join().unwrap();
panic!("timed out waiting for MCP response: {error}");
}
};
drop(child.stdin.take());
let deadline = Instant::now() + Duration::from_secs(10);
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
break status;
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
panic!("timed out waiting for MCP process to exit");
}
thread::sleep(Duration::from_millis(10));
};
assert!(status.success());
let response: Value = serde_json::from_str(response.trim()).unwrap();
assert_eq!(response["id"], 1);
assert_eq!(response["result"]["serverInfo"]["name"], "lexa");

let status = lexa().current_dir(project).arg("status").output().unwrap();
assert!(status.status.success());

let search = lexa()
.current_dir(project)
.args(["symbol-search", "searchable", "--max", "5"])
.output()
.unwrap();
assert!(search.status.success());
assert!(String::from_utf8_lossy(&search.stdout).contains("searchable"));
}

#[test]
fn index_writes_default_graph_under_indexed_project_root() {
let temp = tempfile::tempdir().unwrap();
Expand Down
4 changes: 2 additions & 2 deletions www/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@
"@fontsource/geist": "^5.2.9",
"@fontsource/geist-mono": "^5.2.8",
"@tailwindcss/vite": "^4.3.2",
"astro": "^7.0.3",
"astro": "^7.2.0",
"marked": "^18.0.5",
"shiki": "^4.3.0",
"tailwindcss": "^4.3.2"
},
"devDependencies": {
"wrangler": "^4.105.0"
"wrangler": "^4.120.0"
}
}
Loading
Loading