Skip to content

Commit 4ee4937

Browse files
committed
fix(search): honor file and internal role filters
1 parent 3c2696b commit 4ee4937

1 file changed

Lines changed: 105 additions & 5 deletions

File tree

grapha/src/search.rs

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
use std::path::Path;
22

33
use anyhow::Result;
4+
use regex::Regex;
45
use serde::Serialize;
5-
use tantivy::collector::TopDocs;
6+
use tantivy::collector::{Count, TopDocs};
67
use tantivy::query::{BooleanQuery, Occur, QueryParser, TermQuery};
78
use tantivy::schema::{IndexRecordOption, STORED, STRING, Schema, TEXT, Value};
89
use tantivy::{Index, IndexWriter, ReloadPolicy, TantivyDocument, Term, doc};
@@ -28,7 +29,6 @@ pub struct SearchResult {
2829
pub struct SearchOptions {
2930
pub kind: Option<String>,
3031
pub module: Option<String>,
31-
#[allow(dead_code)] // Populated by CLI/MCP; filtering not yet implemented
3232
pub file_glob: Option<String>,
3333
pub role: Option<String>,
3434
pub fuzzy: bool,
@@ -110,7 +110,7 @@ fn role_to_string(role: &Option<NodeRole>) -> String {
110110
match role {
111111
Some(NodeRole::EntryPoint) => "entry_point".to_string(),
112112
Some(NodeRole::Terminal { .. }) => "terminal".to_string(),
113-
_ => String::new(),
113+
Some(NodeRole::Internal) | None => "internal".to_string(),
114114
}
115115
}
116116

@@ -255,6 +255,37 @@ fn build_fuzzy_regex(query: &str) -> String {
255255
pattern
256256
}
257257

258+
fn normalize_file_match_input(input: &str) -> String {
259+
input.replace('\\', "/").to_lowercase()
260+
}
261+
262+
fn has_glob_metacharacters(pattern: &str) -> bool {
263+
pattern.contains('*') || pattern.contains('?')
264+
}
265+
266+
fn build_file_filter_regex(pattern: &str) -> Result<Regex> {
267+
let normalized = normalize_file_match_input(pattern);
268+
let mut regex = String::new();
269+
270+
if has_glob_metacharacters(&normalized) {
271+
regex.push('^');
272+
for ch in normalized.chars() {
273+
match ch {
274+
'*' => regex.push_str(".*"),
275+
'?' => regex.push('.'),
276+
_ => regex.push_str(&regex::escape(&ch.to_string())),
277+
}
278+
}
279+
regex.push('$');
280+
} else {
281+
regex.push_str("^.*");
282+
regex.push_str(&regex::escape(&normalized));
283+
regex.push('$');
284+
}
285+
286+
Ok(Regex::new(&regex)?)
287+
}
288+
258289
pub fn search_filtered(
259290
index: &Index,
260291
query_str: &str,
@@ -312,7 +343,22 @@ pub fn search_filtered(
312343
}
313344

314345
let final_query = BooleanQuery::new(clauses);
315-
let top_docs = searcher.search(&final_query, &TopDocs::with_limit(limit))?;
346+
let file_filter = options
347+
.file_glob
348+
.as_deref()
349+
.map(build_file_filter_regex)
350+
.transpose()?;
351+
let candidate_limit = if file_filter.is_some() {
352+
searcher.search(&final_query, &Count)?
353+
} else {
354+
limit
355+
};
356+
357+
if candidate_limit == 0 {
358+
return Ok(Vec::new());
359+
}
360+
361+
let top_docs = searcher.search(&final_query, &TopDocs::with_limit(candidate_limit))?;
316362

317363
let mut results = Vec::new();
318364
for (score, doc_address) in top_docs {
@@ -323,13 +369,19 @@ pub fn search_filtered(
323369
.unwrap_or("")
324370
.to_string()
325371
};
372+
let file_val = get_str(fields.file);
373+
if let Some(regex) = &file_filter
374+
&& !regex.is_match(&normalize_file_match_input(&file_val))
375+
{
376+
continue;
377+
}
326378
let module_val = get_str(fields.module);
327379
let role_val = get_str(fields.role);
328380
results.push(SearchResult {
329381
id: get_str(fields.id),
330382
name: get_str(fields.name),
331383
kind: get_str(fields.kind),
332-
file: get_str(fields.file),
384+
file: file_val,
333385
score,
334386
module: if module_val.is_empty() {
335387
None
@@ -342,6 +394,9 @@ pub fn search_filtered(
342394
Some(role_val)
343395
},
344396
});
397+
if results.len() == limit {
398+
break;
399+
}
345400
}
346401

347402
Ok(results)
@@ -654,6 +709,51 @@ mod tests {
654709
assert_eq!(results[0].role.as_deref(), Some("terminal"));
655710
}
656711

712+
#[test]
713+
fn filter_by_role_internal() {
714+
let dir = tempfile::tempdir().unwrap();
715+
let graph = make_rich_test_graph();
716+
let index = build_index(&graph, dir.path()).unwrap();
717+
let options = SearchOptions {
718+
role: Some("internal".into()),
719+
..Default::default()
720+
};
721+
let results = search_filtered(&index, "Config", 10, &options).unwrap();
722+
assert_eq!(results.len(), 1);
723+
assert_eq!(results[0].name, "Config");
724+
assert_eq!(results[0].role.as_deref(), Some("internal"));
725+
}
726+
727+
#[test]
728+
fn filter_by_file_suffix() {
729+
let dir = tempfile::tempdir().unwrap();
730+
let graph = make_rich_test_graph();
731+
let index = build_index(&graph, dir.path()).unwrap();
732+
let options = SearchOptions {
733+
file_glob: Some("Config.swift".into()),
734+
..Default::default()
735+
};
736+
let results = search_filtered(&index, "Config", 10, &options).unwrap();
737+
assert_eq!(results.len(), 1);
738+
assert_eq!(results[0].name, "Config");
739+
assert_eq!(results[0].file, "Sources/Core/Config.swift");
740+
}
741+
742+
#[test]
743+
fn filter_by_file_glob() {
744+
let dir = tempfile::tempdir().unwrap();
745+
let graph = make_rich_test_graph();
746+
let index = build_index(&graph, dir.path()).unwrap();
747+
let options = SearchOptions {
748+
file_glob: Some("Sources/*/Persist.swift".into()),
749+
..Default::default()
750+
};
751+
let results = search_filtered(&index, "save_config", 10, &options).unwrap();
752+
assert_eq!(results.len(), 1);
753+
assert_eq!(results[0].name, "save_config");
754+
assert_eq!(results[0].file, "Sources/Core/Persist.swift");
755+
}
756+
657757
#[test]
658758
fn fuzzy_search_finds_misspelled() {
659759
let dir = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)