use crate::server::helpers::{io_error_data, millis_to_u64};
use crate::server::types::{
GroupedKnownMatch, GroupedMatch, SearchCodebaseParams, SearchCodebaseResponse,
SearchResultGroup,
};
use crate::server::PathfinderServer;
use futures::StreamExt as _;
use pathfinder_common::types::{DegradedReason, FilterMode};
use pathfinder_search::{SearchMatch, SearchParams};
use pathfinder_treesitter::language::SupportedLanguage;
use rmcp::handler::server::wrapper::Json;
use rmcp::model::ErrorData;
use std::collections::HashMap;
use std::path::Path;
const ENRICHMENT_CONCURRENCY: usize = 32;
type EnrichResult = (Option<String>, String, Option<bool>);
impl PathfinderServer {
#[expect(
clippy::too_many_lines,
reason = "Sequential pipeline: ripgrep → TS enrichment → filter → group → response. Extraction done at helper level; remaining orchestration is linear."
)]
pub(crate) async fn search_codebase_impl(
&self,
params: SearchCodebaseParams,
) -> Result<Json<SearchCodebaseResponse>, ErrorData> {
let start = std::time::Instant::now();
tracing::info!(
tool = "search_codebase",
query = %params.query,
is_regex = params.is_regex,
path_glob = %params.path_glob,
exclude_glob = %params.exclude_glob,
known_files_count = params.known_files.len(),
group_by_file = params.group_by_file,
filter_mode = ?params.filter_mode,
"search_codebase: start"
);
if params.query.trim().is_empty() {
return Err(crate::server::helpers::io_error_data(
"query must not be empty",
));
}
let search_params = SearchParams {
workspace_root: self.workspace_root.path().to_path_buf(),
query: params.query.clone(),
is_regex: params.is_regex,
path_glob: params.path_glob.clone(),
exclude_glob: params.exclude_glob.clone(),
max_results: params.max_results as usize,
offset: params.offset as usize,
context_lines: params.context_lines as usize,
};
let ripgrep_start = std::time::Instant::now();
match self.scout.search(&search_params).await {
Ok(result) => {
let ripgrep_ms = ripgrep_start.elapsed().as_millis();
let mut enriched_matches = result.matches;
let ts_start = std::time::Instant::now();
let node_types = self.enrich_matches(&mut enriched_matches).await;
let tree_sitter_parse_ms = ts_start.elapsed().as_millis();
let degraded = enriched_matches
.iter()
.any(|m| SupportedLanguage::detect(Path::new(&m.file)).is_none());
let filter_was_bypassed = degraded && params.filter_mode != FilterMode::All;
let filtered_matches = if filter_was_bypassed {
enriched_matches
} else {
apply_filter_mode(enriched_matches, &node_types, params.filter_mode)
};
let degraded_reason = if filter_was_bypassed {
Some(DegradedReason::UnsupportedLanguageFilterBypassed)
} else if degraded {
Some(DegradedReason::UnsupportedLanguage)
} else {
None
};
let known_set: std::collections::HashSet<String> = params
.known_files
.iter()
.map(|p| normalize_path(p))
.collect();
let file_groups = if params.group_by_file {
Some(build_file_groups(&filtered_matches, &known_set))
} else {
None
};
let flat_matches: Vec<SearchMatch> = filtered_matches
.into_iter()
.map(|mut m| {
if known_set.contains(&normalize_path(&m.file)) {
m.content = String::default();
m.context_before = vec![];
m.context_after = vec![];
m.known = Some(true);
}
m
})
.collect();
let returned_count = flat_matches.len();
let duration_ms = start.elapsed().as_millis();
let files_searched = result.files_searched;
let files_in_scope = result.files_in_scope;
let coverage_percent: u8 = files_searched
.saturating_mul(100)
.checked_div(files_in_scope)
.and_then(|v| v.try_into().ok())
.unwrap_or(100);
let filter_mode_name = match params.filter_mode {
FilterMode::All => "all",
FilterMode::CodeOnly => "code_only",
FilterMode::CommentsOnly => "comments_only",
};
let hint = if returned_count == 0
&& result.total_matches > 0
&& params.filter_mode != FilterMode::All
{
Some(format!(
"0 matches with filter_mode={} but {} match(es) exist with filter_mode=all. Retry with filter_mode='all' to see all match types.",
filter_mode_name,
result.total_matches,
))
} else {
None
};
tracing::info!(
tool = "search_codebase",
total_matches = result.total_matches,
returned = returned_count,
truncated = result.truncated,
files_searched,
files_in_scope,
coverage_percent,
filter_mode = ?params.filter_mode,
filter_bypassed = filter_was_bypassed,
hint_emitted = hint.is_some(),
ripgrep_ms,
tree_sitter_parse_ms,
duration_ms,
engines_used = ?["ripgrep", "treesitter"],
"search_codebase: complete"
);
Ok(Json(SearchCodebaseResponse {
matches: flat_matches,
raw_match_count: result.total_matches,
total_matches: returned_count,
returned_count,
filtered_count: result.total_matches.saturating_sub(returned_count),
files_searched,
files_in_scope,
coverage_percent,
truncated: result.truncated,
file_groups,
degraded,
degraded_reason,
actionable_guidance: degraded_reason.as_ref().map(DegradedReason::guidance),
hint,
next_offset: if result.truncated {
#[allow(clippy::cast_possible_truncation)]
Some(params.offset + (returned_count as u32))
} else {
None
},
duration_ms: Some(millis_to_u64(duration_ms)),
binary_skipped: result.binary_skipped,
gitignored_skipped: result.gitignored_skipped,
other_skipped: result.other_skipped,
}))
}
Err(err) => {
let duration_ms = start.elapsed().as_millis();
tracing::warn!(
tool = "search_codebase",
error = %err,
error_code = "INTERNAL_ERROR",
error_message = %err,
duration_ms,
engines_used = ?["ripgrep"],
"search_codebase: failed"
);
Err(io_error_data(err.to_string()))
}
}
}
async fn enrich_matches(&self, matches: &mut [SearchMatch]) -> Vec<String> {
let snapshots: Vec<(String, u64, u64)> = matches
.iter()
.map(|m| (m.file.clone(), m.line, m.column))
.collect();
let enrichment: Vec<EnrichResult> = futures::stream::iter(snapshots)
.map(|(file, line_u64, column_u64)| async move {
let file_path = Path::new(&file);
let line = usize::try_from(line_u64).unwrap_or(usize::MAX);
let column = usize::try_from(column_u64).unwrap_or(0);
let detail = self
.surgeon
.enclosing_symbol_detail(self.workspace_root.path(), file_path, line)
.await
.ok()
.flatten();
let (symbol, is_definition) = if let Some(ref sym) = detail {
let path = format!("{file}::{}", sym.semantic_path);
debug_assert!(line > 0, "ripgrep line should be 1-indexed, got 0");
let is_def = sym.start_line == line.saturating_sub(1);
(Some(path), Some(is_def))
} else {
(None, None)
};
let node_type = self
.surgeon
.node_type_at_position(self.workspace_root.path(), file_path, line, column)
.await
.unwrap_or_else(|_| "code".to_owned());
(symbol, node_type, is_definition)
})
.buffered(ENRICHMENT_CONCURRENCY)
.collect()
.await;
enrichment
.into_iter()
.zip(matches.iter_mut())
.map(|((symbol, node_type, is_definition), m)| {
m.enclosing_semantic_path = symbol;
m.is_definition = is_definition;
node_type
})
.collect()
}
}
fn apply_filter_mode(
matches: Vec<SearchMatch>,
node_types: &[String],
mode: FilterMode,
) -> Vec<SearchMatch> {
match mode {
FilterMode::All => matches,
FilterMode::CodeOnly => matches
.into_iter()
.zip(node_types.iter())
.filter(|(_, t)| t.as_str() == "code")
.map(|(m, _)| m)
.collect(),
FilterMode::CommentsOnly => matches
.into_iter()
.zip(node_types.iter())
.filter(|(_, t)| t.as_str() == "comment" || t.as_str() == "string")
.map(|(m, _)| m)
.collect(),
}
}
fn normalize_path(p: &str) -> String {
p.strip_prefix("./").unwrap_or(p).to_owned()
}
fn build_file_groups(
matches: &[SearchMatch],
known_set: &std::collections::HashSet<String>,
) -> Vec<SearchResultGroup> {
let mut order: Vec<String> = Vec::new();
let mut groups: HashMap<String, SearchResultGroup> = HashMap::new();
for m in matches {
let key = normalize_path(&m.file);
if !groups.contains_key(&key) {
order.push(key.clone());
groups.insert(
key.clone(),
SearchResultGroup {
file: m.file.clone(),
version_hash: m.version_hash.clone(),
total_matches: 0,
matches: Vec::new(),
known_matches: Vec::new(),
},
);
}
if let Some(group) = groups.get_mut(&key) {
if known_set.contains(&key) {
group.known_matches.push(GroupedKnownMatch {
line: m.line,
column: m.column,
enclosing_semantic_path: m.enclosing_semantic_path.clone(),
is_definition: m.is_definition,
known: true,
});
} else {
group.matches.push(GroupedMatch {
line: m.line,
column: m.column,
content: m.content.clone(),
context_before: m.context_before.clone(),
context_after: m.context_after.clone(),
enclosing_semantic_path: m.enclosing_semantic_path.clone(),
is_definition: m.is_definition,
});
}
}
}
for group in groups.values_mut() {
group.total_matches = group.matches.len() + group.known_matches.len();
}
order
.into_iter()
.filter_map(|k| groups.remove(&k))
.collect()
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
use crate::server::PathfinderServer;
use pathfinder_common::config::PathfinderConfig;
use pathfinder_common::sandbox::Sandbox;
use pathfinder_common::types::WorkspaceRoot;
use pathfinder_search::RipgrepScout;
use pathfinder_treesitter::mock::MockSurgeon;
use std::sync::Arc;
#[tokio::test]
async fn test_search_codebase_degraded_on_unsupported_language() {
let ws_dir = tempfile::tempdir().unwrap();
let ws = WorkspaceRoot::new(ws_dir.path()).unwrap();
let config = PathfinderConfig::default();
let sandbox = Sandbox::new(ws.path(), &config.sandbox);
std::fs::create_dir_all(ws_dir.path().join("src")).unwrap();
std::fs::write(ws_dir.path().join("src/data.xyz"), "findme content").unwrap();
let scout = Arc::new(RipgrepScout);
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.enclosing_symbol_results
.lock()
.unwrap()
.push(Ok(None));
surgeon
.enclosing_symbol_detail_results
.lock()
.unwrap()
.push(Ok(None));
surgeon
.node_type_at_position_results
.lock()
.unwrap()
.push(Ok("code".to_string()));
let lawyer = Arc::new(pathfinder_lsp::NoOpLawyer);
let server =
PathfinderServer::with_all_engines(ws, config, sandbox, scout, surgeon, lawyer);
let params = SearchCodebaseParams {
query: "findme".to_owned(),
is_regex: false,
path_glob: "**/*.xyz".to_owned(),
exclude_glob: String::default(),
offset: 0,
max_results: 10,
context_lines: 0,
known_files: vec![],
group_by_file: false,
filter_mode: pathfinder_common::types::FilterMode::All,
};
let result = server.search_codebase_impl(params).await;
let response = result.expect("search should succeed");
assert!(
response.0.degraded,
"should be degraded for unsupported language"
);
assert_eq!(
response
.0
.degraded_reason
.as_ref()
.map(std::string::ToString::to_string),
Some("unsupported_language".to_string()),
"with FilterMode::All, filter is not bypassed so reason is unsupported_language"
);
}
#[tokio::test]
async fn test_search_group_by_file_with_known_files() {
let ws_dir = tempfile::tempdir().unwrap();
let ws = WorkspaceRoot::new(ws_dir.path()).unwrap();
let config = PathfinderConfig::default();
let sandbox = Sandbox::new(ws.path(), &config.sandbox);
std::fs::create_dir_all(ws_dir.path().join("src")).unwrap();
std::fs::write(
ws_dir.path().join("src/main.rs"),
"fn findme() {}\nfn other() { findme(); }\n",
)
.unwrap();
let scout = Arc::new(RipgrepScout);
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.enclosing_symbol_results
.lock()
.unwrap()
.push(Ok(None));
surgeon
.enclosing_symbol_detail_results
.lock()
.unwrap()
.push(Ok(None));
surgeon
.node_type_at_position_results
.lock()
.unwrap()
.push(Ok("code".to_string()));
surgeon
.enclosing_symbol_results
.lock()
.unwrap()
.push(Ok(None));
surgeon
.enclosing_symbol_detail_results
.lock()
.unwrap()
.push(Ok(None));
surgeon
.node_type_at_position_results
.lock()
.unwrap()
.push(Ok("code".to_string()));
let lawyer = Arc::new(pathfinder_lsp::NoOpLawyer);
let server =
PathfinderServer::with_all_engines(ws, config, sandbox, scout, surgeon, lawyer);
let params = SearchCodebaseParams {
query: "findme".to_owned(),
is_regex: false,
path_glob: "**/*.rs".to_owned(),
exclude_glob: String::default(),
offset: 0,
max_results: 10,
context_lines: 0,
known_files: vec!["src/main.rs".to_owned()],
group_by_file: true,
filter_mode: pathfinder_common::types::FilterMode::All,
};
let result = server.search_codebase_impl(params).await;
let response = result.expect("search should succeed");
assert_eq!(
response.0.total_matches, 2,
"total_matches should reflect actual number of matches"
);
let groups = response
.0
.file_groups
.expect("should have file_groups when group_by_file=true");
assert!(
!groups.is_empty(),
"file_groups should NOT be empty when matches exist — original bug: total_matches>0 but file_groups empty"
);
assert_eq!(
groups[0].total_matches, 2,
"group total_matches should show count even when all matches are known"
);
assert!(
groups[0].matches.is_empty(),
"matches array should be empty when all matches belong to known_files"
);
assert_eq!(
groups[0].known_matches.len(),
2,
"known_matches should contain the suppressed matches"
);
assert!(
groups[0].known_matches[0].known,
"known_matches should have known=true flag"
);
}
#[test]
fn test_apply_filter_mode_code_only() {
let matches = vec![
SearchMatch {
file: "src/main.rs".to_owned(),
line: 1,
column: 1,
content: "fn main() {}".to_owned(),
context_before: vec![],
context_after: vec![],
enclosing_semantic_path: None,
is_definition: None,
version_hash: "hash".to_owned(),
known: None,
},
SearchMatch {
file: "src/main.rs".to_owned(),
line: 2,
column: 1,
content: "// a comment".to_owned(),
context_before: vec![],
context_after: vec![],
enclosing_semantic_path: None,
is_definition: None,
version_hash: "hash".to_owned(),
known: None,
},
];
let node_types = vec!["code".to_owned(), "comment".to_owned()];
let filtered = apply_filter_mode(matches, &node_types, FilterMode::CodeOnly);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].content, "fn main() {}");
}
#[test]
fn test_apply_filter_mode_comments_only() {
let matches = vec![
SearchMatch {
file: "src/main.rs".to_owned(),
line: 1,
column: 1,
content: "fn main() {}".to_owned(),
context_before: vec![],
context_after: vec![],
enclosing_semantic_path: None,
is_definition: None,
version_hash: "hash".to_owned(),
known: None,
},
SearchMatch {
file: "src/main.rs".to_owned(),
line: 2,
column: 1,
content: "// a comment".to_owned(),
context_before: vec![],
context_after: vec![],
enclosing_semantic_path: None,
is_definition: None,
version_hash: "hash".to_owned(),
known: None,
},
SearchMatch {
file: "src/main.rs".to_owned(),
line: 3,
column: 1,
content: "\"a string\"".to_owned(),
context_before: vec![],
context_after: vec![],
enclosing_semantic_path: None,
is_definition: None,
version_hash: "hash".to_owned(),
known: None,
},
];
let node_types = vec!["code".to_owned(), "comment".to_owned(), "string".to_owned()];
let filtered = apply_filter_mode(matches, &node_types, FilterMode::CommentsOnly);
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0].content, "// a comment");
assert_eq!(filtered[1].content, "\"a string\"");
}
#[test]
fn test_apply_filter_mode_all() {
let matches = vec![
SearchMatch {
file: "src/main.rs".to_owned(),
line: 1,
column: 1,
content: "fn main() {}".to_owned(),
context_before: vec![],
context_after: vec![],
enclosing_semantic_path: None,
is_definition: None,
version_hash: "hash".to_owned(),
known: None,
},
SearchMatch {
file: "src/main.rs".to_owned(),
line: 2,
column: 1,
content: "// a comment".to_owned(),
context_before: vec![],
context_after: vec![],
enclosing_semantic_path: None,
is_definition: None,
version_hash: "hash".to_owned(),
known: None,
},
];
let node_types = vec!["code".to_owned(), "comment".to_owned()];
let filtered = apply_filter_mode(matches.clone(), &node_types, FilterMode::All);
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0].content, matches[0].content);
assert_eq!(filtered[1].content, matches[1].content);
}
#[tokio::test]
async fn test_search_degraded_filter_bypassed_returns_matches() {
let ws_dir = tempfile::tempdir().unwrap();
let ws = WorkspaceRoot::new(ws_dir.path()).unwrap();
let config = PathfinderConfig::default();
let sandbox = Sandbox::new(ws.path(), &config.sandbox);
std::fs::create_dir_all(ws_dir.path().join("src")).unwrap();
std::fs::write(ws_dir.path().join("src/data.xyz"), "// TODO: fix this hack").unwrap();
let scout = Arc::new(RipgrepScout);
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.enclosing_symbol_results
.lock()
.unwrap()
.push(Ok(None));
surgeon
.enclosing_symbol_detail_results
.lock()
.unwrap()
.push(Ok(None));
surgeon
.node_type_at_position_results
.lock()
.unwrap()
.push(Ok("code".to_string())); let lawyer = Arc::new(pathfinder_lsp::NoOpLawyer);
let server =
PathfinderServer::with_all_engines(ws, config, sandbox, scout, surgeon, lawyer);
let params = SearchCodebaseParams {
query: "TODO".to_owned(),
is_regex: false,
path_glob: "**/*.xyz".to_owned(),
exclude_glob: String::default(),
offset: 0,
max_results: 10,
context_lines: 0,
known_files: vec![],
group_by_file: false,
filter_mode: pathfinder_common::types::FilterMode::CommentsOnly,
};
let result = server.search_codebase_impl(params).await;
let response = result.expect("search should succeed");
assert!(
!response.0.matches.is_empty(),
"matches must not be empty when filter is bypassed"
);
assert!(response.0.degraded, "degraded must be true");
assert_eq!(
response
.0
.degraded_reason
.as_ref()
.map(std::string::ToString::to_string),
Some("unsupported_language_filter_bypassed".to_string()),
"degraded_reason must indicate filter was bypassed"
);
}
#[tokio::test]
async fn test_search_hint_populated_when_filter_removes_all_results() {
let ws_dir = tempfile::tempdir().unwrap();
let ws = WorkspaceRoot::new(ws_dir.path()).unwrap();
let config = PathfinderConfig::default();
let sandbox = Sandbox::new(ws.path(), &config.sandbox);
std::fs::create_dir_all(ws_dir.path().join("src")).unwrap();
std::fs::write(
ws_dir.path().join("src/lib.rs"),
"// TODO: implement find_me\nfn other() {}\n",
)
.unwrap();
let scout = Arc::new(RipgrepScout);
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.enclosing_symbol_results
.lock()
.unwrap()
.push(Ok(None));
surgeon
.enclosing_symbol_detail_results
.lock()
.unwrap()
.push(Ok(None));
surgeon
.node_type_at_position_results
.lock()
.unwrap()
.push(Ok("comment".to_string()));
let lawyer = Arc::new(pathfinder_lsp::NoOpLawyer);
let server =
PathfinderServer::with_all_engines(ws, config, sandbox, scout, surgeon, lawyer);
let params = SearchCodebaseParams {
query: "find_me".to_owned(),
is_regex: false,
path_glob: "**/*.rs".to_owned(),
exclude_glob: String::default(),
offset: 0,
max_results: 10,
context_lines: 0,
known_files: vec![],
group_by_file: false,
filter_mode: pathfinder_common::types::FilterMode::CodeOnly,
};
let result = server.search_codebase_impl(params).await;
let response = result.expect("search should succeed");
assert_eq!(
response.0.returned_count, 0,
"filter should remove comment match"
);
assert!(
response.0.raw_match_count > 0,
"raw_match_count must be positive"
);
assert!(
response.0.hint.is_some(),
"hint must be present when filter removed all results"
);
let hint = response.0.hint.as_ref().unwrap();
assert!(
hint.contains("filter_mode='all'"),
"hint must suggest filter_mode=all, got: {hint}"
);
}
#[tokio::test]
async fn test_search_hint_absent_when_no_filter_applied() {
let ws_dir = tempfile::tempdir().unwrap();
let ws = WorkspaceRoot::new(ws_dir.path()).unwrap();
let config = PathfinderConfig::default();
let sandbox = Sandbox::new(ws.path(), &config.sandbox);
std::fs::create_dir_all(ws_dir.path().join("src")).unwrap();
std::fs::write(ws_dir.path().join("src/lib.rs"), "fn find_me() {}\n").unwrap();
let scout = Arc::new(RipgrepScout);
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.enclosing_symbol_results
.lock()
.unwrap()
.push(Ok(None));
surgeon
.enclosing_symbol_detail_results
.lock()
.unwrap()
.push(Ok(None));
surgeon
.node_type_at_position_results
.lock()
.unwrap()
.push(Ok("code".to_string()));
let lawyer = Arc::new(pathfinder_lsp::NoOpLawyer);
let server =
PathfinderServer::with_all_engines(ws, config, sandbox, scout, surgeon, lawyer);
let params = SearchCodebaseParams {
query: "find_me".to_owned(),
is_regex: false,
path_glob: "**/*.rs".to_owned(),
exclude_glob: String::default(),
offset: 0,
max_results: 10,
context_lines: 0,
known_files: vec![],
group_by_file: false,
filter_mode: pathfinder_common::types::FilterMode::All,
};
let result = server.search_codebase_impl(params).await;
let response = result.expect("search should succeed");
assert!(response.0.returned_count > 0, "should have results");
assert!(
response.0.hint.is_none(),
"hint must be absent when results are present"
);
}
#[tokio::test]
async fn test_search_next_offset_populated_when_truncated() {
let ws_dir = tempfile::tempdir().unwrap();
let ws = WorkspaceRoot::new(ws_dir.path()).unwrap();
let config = PathfinderConfig::default();
let sandbox = Sandbox::new(ws.path(), &config.sandbox);
std::fs::create_dir_all(ws_dir.path().join("src")).unwrap();
for i in 0..5 {
std::fs::write(
ws_dir.path().join(format!("src/file{i}.rs")),
format!("fn findme_{i}() {{ findme(); }}\n"),
)
.unwrap();
}
let scout = Arc::new(RipgrepScout);
let surgeon = Arc::new(MockSurgeon::new());
for _ in 0..5 {
surgeon
.enclosing_symbol_results
.lock()
.unwrap()
.push(Ok(None));
surgeon
.enclosing_symbol_detail_results
.lock()
.unwrap()
.push(Ok(None));
surgeon
.node_type_at_position_results
.lock()
.unwrap()
.push(Ok("code".to_string()));
}
let lawyer = Arc::new(pathfinder_lsp::NoOpLawyer);
let server =
PathfinderServer::with_all_engines(ws, config, sandbox, scout, surgeon, lawyer);
let params = SearchCodebaseParams {
query: "findme".to_owned(),
is_regex: false,
path_glob: "**/*.rs".to_owned(),
exclude_glob: String::default(),
offset: 0,
max_results: 2, context_lines: 0,
known_files: vec![],
group_by_file: false,
filter_mode: pathfinder_common::types::FilterMode::All,
};
let result = server.search_codebase_impl(params).await;
let response = result.expect("search should succeed");
assert!(response.0.truncated, "should be truncated");
assert!(
response.0.next_offset.is_some(),
"next_offset must be present when truncated"
);
let next_offset = response.0.next_offset.unwrap();
assert_eq!(
next_offset, 2,
"next_offset should be offset + returned_count"
);
}
#[tokio::test]
async fn test_search_binary_skipped_counted() {
let ws_dir = tempfile::tempdir().unwrap();
let ws = WorkspaceRoot::new(ws_dir.path()).unwrap();
let config = PathfinderConfig::default();
let sandbox = Sandbox::new(ws.path(), &config.sandbox);
std::fs::create_dir_all(ws_dir.path().join("src")).unwrap();
std::fs::write(ws_dir.path().join("src/main.rs"), "fn findme() {}\n").unwrap();
std::fs::write(ws_dir.path().join("src/image.png"), "binary data").unwrap();
std::fs::write(ws_dir.path().join("src/archive.zip"), "zip data").unwrap();
let scout = Arc::new(RipgrepScout);
let surgeon = Arc::new(MockSurgeon::new());
surgeon
.enclosing_symbol_results
.lock()
.unwrap()
.push(Ok(None));
surgeon
.enclosing_symbol_detail_results
.lock()
.unwrap()
.push(Ok(None));
surgeon
.node_type_at_position_results
.lock()
.unwrap()
.push(Ok("code".to_string()));
let lawyer = Arc::new(pathfinder_lsp::NoOpLawyer);
let server =
PathfinderServer::with_all_engines(ws, config, sandbox, scout, surgeon, lawyer);
let params = SearchCodebaseParams {
query: "findme".to_owned(),
is_regex: false,
path_glob: "**/*".to_owned(),
exclude_glob: String::default(),
offset: 0,
max_results: 10,
context_lines: 0,
known_files: vec![],
group_by_file: false,
filter_mode: pathfinder_common::types::FilterMode::All,
};
let result = server.search_codebase_impl(params).await;
let response = result.expect("search should succeed");
assert_eq!(
response.0.binary_skipped, 2,
"binary_skipped should count .png and .zip files"
);
assert_eq!(
response.0.gitignored_skipped, 0,
"gitignored_skipped should be 0 when no .gitignore rules apply"
);
assert_eq!(
response.0.other_skipped, 0,
"other_skipped should be 0 when no I/O errors occur"
);
}
}