Skip to content
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
10 changes: 10 additions & 0 deletions src/file/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,9 @@ mod tests {
}

#[test]
#[serial]
fn include_list_whitelists_matching_files_only() {
let _isolation = isolated_global_ignore_env();
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("keep.rs"), "fn a() {}").unwrap();
fs::write(dir.path().join("drop.py"), "print(1)").unwrap();
Expand All @@ -522,7 +524,9 @@ mod tests {
}

#[test]
#[serial]
fn include_list_negation_unincludes_again() {
let _isolation = isolated_global_ignore_env();
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("keep.rs"), "fn a() {}").unwrap();
fs::write(dir.path().join("gen.rs"), "generated").unwrap();
Expand All @@ -540,7 +544,9 @@ mod tests {
}

#[test]
#[serial]
fn no_include_file_indexes_everything() {
let _isolation = isolated_global_ignore_env();
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("a.rs"), "fn a() {}").unwrap();
fs::write(dir.path().join("b.py"), "print(1)").unwrap();
Expand All @@ -551,7 +557,9 @@ mod tests {
}

#[test]
#[serial]
fn include_list_ancestor_negation_unincludes_again() {
let _isolation = isolated_global_ignore_env();
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
fs::write(dir.path().join("src").join("keep.rs"), "fn a() {}").unwrap();
Expand Down Expand Up @@ -579,7 +587,9 @@ mod tests {
}

#[test]
#[serial]
fn include_list_present_but_empty_indexes_everything() {
let _isolation = isolated_global_ignore_env();
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("a.rs"), "fn a() {}").unwrap();
fs::write(dir.path().join("b.py"), "print(1)").unwrap();
Expand Down
4 changes: 2 additions & 2 deletions src/mcp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -975,8 +975,8 @@ fn parse_import_lines(content: &str, start_line: usize) -> Vec<ImportItem> {
if let Some((kind, imported)) = parsed {
items.push(ImportItem {
imported,
// `start_line` is the chunk's 0-based first line and `offset`
// is 0-based within it; the response emits 1-based lines.
// `start_line` is the chunk's 1-based first line and `offset`
// is 0-based within it; the sum is the match's editor line.
line: start_line + offset,
kind,
});
Expand Down
74 changes: 74 additions & 0 deletions src/serve/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5668,6 +5668,80 @@ async fn get_chunk_reports_the_real_chunker_editor_line_unchanged() {
}
}

/// The no-download model gate: with an empty model cache, a semantic search
/// must answer the explicit refusal (naming `codesearch setup`) instead of
/// turning the query into an unbounded network fetch — the whole-night serve
/// stall this gate exists to prevent.
#[tokio::test]
#[serial]
async fn semantic_search_refuses_when_the_model_is_not_cached() {
use rmcp::handler::server::wrapper::Parameters;

let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("solo");
std::fs::create_dir_all(&root).unwrap();
let db_path = root.join(DB_DIR_NAME);
std::fs::create_dir_all(&db_path).unwrap();
std::fs::write(
db_path.join("metadata.json"),
r#"{"schema_version":1,"dimensions":2,"model_short_name":"minilm-l6-q"}"#,
)
.unwrap();
let stores = crate::index::SharedStores::new(&db_path, 2).expect("shared stores");
drop(stores);

// Empty model cache: no model files anywhere under CODESEARCH_HOME.
let scratch = tempfile::tempdir().unwrap();
let cs_home = scratch.path().join("cs-home");
let bare_home = scratch.path().join("bare-home");
std::fs::create_dir_all(&cs_home).unwrap();
std::fs::create_dir_all(&bare_home).unwrap();
let _env = crate::testing::EnvRestore::set(&[
(
crate::constants::CODESEARCH_HOME_ENV,
cs_home.to_str().unwrap(),
),
("HOME", bare_home.to_str().unwrap()),
("USERPROFILE", bare_home.to_str().unwrap()),
]);

let mut config = ReposConfig::default();
config
.register_with_alias(root.clone(), Some("solo".to_string()))
.unwrap();
let config_file = tmp.path().join("repos.json");
config.save_to(&config_file).unwrap();
let state = std::sync::Arc::new(ServeState::new(config, Some(config_file)));
let service = crate::mcp::CodesearchService::new_for_serve(state).unwrap();

let req = crate::mcp::types::SearchRequest {
query: "anything".to_string(),
mode: Some("semantic".to_string()),
compact: None,
semantic_mode: None,
filter_path: None,
min_score: None,
regex: None,
phrase: None,
file_glob: None,
language: None,
format: None,
limit: None,
project: Some("solo".to_string()),
group: None,
};
let res = service
.search(Parameters(req))
.await
.expect("search must answer, not error");
let text = tool_text(&res);
assert!(
text.contains("not available locally") && text.contains("codesearch setup"),
"semantic search with an empty model cache must answer the no-download \
refusal naming the setup hint, got: {text}"
);
}

// ═══ Interactive fan-outs skip busy stores instead of queueing behind the
// write lock (review cluster B). The priming call opens the group's stores
// so `get_opened_stores` hands back the SAME SharedStores the handlers
Expand Down
5 changes: 3 additions & 2 deletions src/watch/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,7 @@ impl GitHeadWatcher {
mod tests {

#[test]
#[serial]
fn include_list_gates_the_watcher() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("keep.rs"), "fn a() {}").unwrap();
Expand All @@ -683,7 +684,7 @@ mod tests {
)
.unwrap();

let watcher = FileWatcher::new(dir.path().to_path_buf());
let watcher = watcher_with_isolated_home(dir.path().to_path_buf());
assert!(
watcher.is_watchable(&dir.path().join("keep.rs")),
"whitelisted file must be watchable"
Expand All @@ -696,7 +697,7 @@ mod tests {
// Absent include list: everything the ignore rules allow.
let dir2 = tempfile::TempDir::new().unwrap();
std::fs::write(dir2.path().join("drop.py"), "print(1)").unwrap();
let watcher2 = FileWatcher::new(dir2.path().to_path_buf());
let watcher2 = watcher_with_isolated_home(dir2.path().to_path_buf());
assert!(
watcher2.is_watchable(&dir2.path().join("drop.py")),
"no include list = historical behavior"
Expand Down
Loading