11use std:: path:: Path ;
22
33use anyhow:: Result ;
4+ use regex:: Regex ;
45use serde:: Serialize ;
5- use tantivy:: collector:: TopDocs ;
6+ use tantivy:: collector:: { Count , TopDocs } ;
67use tantivy:: query:: { BooleanQuery , Occur , QueryParser , TermQuery } ;
78use tantivy:: schema:: { IndexRecordOption , STORED , STRING , Schema , TEXT , Value } ;
89use tantivy:: { Index , IndexWriter , ReloadPolicy , TantivyDocument , Term , doc} ;
@@ -28,7 +29,6 @@ pub struct SearchResult {
2829pub 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+
258289pub 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