@@ -9,6 +9,7 @@ use tantivy::schema::{IndexRecordOption, STORED, STRING, Schema, TEXT, Value};
99use tantivy:: { Index , IndexWriter , ReloadPolicy , TantivyDocument , Term , doc} ;
1010
1111use crate :: delta:: { EntitySyncStats , GraphDelta , SyncMode } ;
12+ use crate :: fields:: FieldSet ;
1213use grapha_core:: graph:: { EdgeKind , Graph } ;
1314use grapha_core:: graph:: { Node , NodeRole } ;
1415
@@ -402,12 +403,27 @@ pub fn search_filtered(
402403 Ok ( results)
403404}
404405
405- #[ derive( Debug , Serialize ) ]
406- pub struct EnrichedSearchResult {
407- #[ serde( flatten) ]
408- pub base : SearchResult ,
406+ #[ derive( Debug , Clone , Serialize ) ]
407+ pub struct SearchOutputResult {
408+ pub name : String ,
409+ pub kind : String ,
410+ pub score : f32 ,
411+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
412+ pub file : Option < String > ,
413+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
414+ pub id : Option < String > ,
415+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
416+ pub module : Option < String > ,
417+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
418+ pub span : Option < String > ,
409419 #[ serde( skip_serializing_if = "Option::is_none" ) ]
410420 pub snippet : Option < String > ,
421+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
422+ pub visibility : Option < String > ,
423+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
424+ pub signature : Option < String > ,
425+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
426+ pub role : Option < String > ,
411427 #[ serde( skip_serializing_if = "Vec::is_empty" ) ]
412428 pub calls : Vec < String > ,
413429 #[ serde( skip_serializing_if = "Vec::is_empty" ) ]
@@ -416,40 +432,156 @@ pub struct EnrichedSearchResult {
416432 pub type_refs : Vec < String > ,
417433}
418434
419- pub fn enrich_with_context ( results : & [ SearchResult ] , graph : & Graph ) -> Vec < EnrichedSearchResult > {
435+ struct GraphSearchDetails < ' a > {
436+ node : & ' a Node ,
437+ calls : Vec < String > ,
438+ called_by : Vec < String > ,
439+ type_refs : Vec < String > ,
440+ }
441+
442+ fn visibility_to_string ( node : & Node ) -> String {
443+ serde_json:: to_string ( & node. visibility )
444+ . unwrap_or_else ( |_| format ! ( "{:?}" , node. visibility) )
445+ . trim_matches ( '"' )
446+ . to_string ( )
447+ }
448+
449+ fn role_value ( role : & Option < NodeRole > ) -> Option < String > {
450+ role. as_ref ( ) . map ( |role| match role {
451+ NodeRole :: EntryPoint => "entry_point" . to_string ( ) ,
452+ NodeRole :: Terminal { .. } => "terminal" . to_string ( ) ,
453+ NodeRole :: Internal => "internal" . to_string ( ) ,
454+ } )
455+ }
456+
457+ fn node_span_string ( node : & Node ) -> String {
458+ format ! (
459+ "{}:{}-{}:{}" ,
460+ node. span. start[ 0 ] , node. span. start[ 1 ] , node. span. end[ 0 ] , node. span. end[ 1 ]
461+ )
462+ }
463+
464+ fn collect_graph_details < ' a > (
465+ results : & [ SearchResult ] ,
466+ graph : & ' a Graph ,
467+ ) -> Vec < Option < GraphSearchDetails < ' a > > > {
420468 results
421469 . iter ( )
422- . map ( |r| {
423- let node = graph. nodes . iter ( ) . find ( |n| n. id == r. id ) ;
424- let snippet = node. and_then ( |n| n. snippet . clone ( ) ) ;
425-
426- let calls: Vec < String > = graph
470+ . map ( |result| {
471+ let node = graph. nodes . iter ( ) . find ( |node| node. id == result. id ) ?;
472+ let calls = graph
427473 . edges
428474 . iter ( )
429- . filter ( |e| e. source == r . id && e. kind == EdgeKind :: Calls )
475+ . filter ( |e| e. source == result . id && e. kind == EdgeKind :: Calls )
430476 . map ( |e| e. target . clone ( ) )
431477 . collect ( ) ;
432-
433- let called_by: Vec < String > = graph
478+ let called_by = graph
434479 . edges
435480 . iter ( )
436- . filter ( |e| e. target == r . id && e. kind == EdgeKind :: Calls )
481+ . filter ( |e| e. target == result . id && e. kind == EdgeKind :: Calls )
437482 . map ( |e| e. source . clone ( ) )
438483 . collect ( ) ;
439-
440- let type_refs: Vec < String > = graph
484+ let type_refs = graph
441485 . edges
442486 . iter ( )
443- . filter ( |e| e. source == r . id && e. kind == EdgeKind :: TypeRef )
487+ . filter ( |e| e. source == result . id && e. kind == EdgeKind :: TypeRef )
444488 . map ( |e| e. target . clone ( ) )
445489 . collect ( ) ;
446-
447- EnrichedSearchResult {
448- base : r. clone ( ) ,
449- snippet,
490+ Some ( GraphSearchDetails {
491+ node,
450492 calls,
451493 called_by,
452494 type_refs,
495+ } )
496+ } )
497+ . collect ( )
498+ }
499+
500+ pub fn needs_graph_for_projection ( fields : FieldSet , include_context : bool ) -> bool {
501+ include_context
502+ || fields. span
503+ || fields. snippet
504+ || fields. visibility
505+ || fields. signature
506+ || fields. role
507+ }
508+
509+ pub fn project_results (
510+ results : & [ SearchResult ] ,
511+ graph : Option < & Graph > ,
512+ fields : FieldSet ,
513+ include_context : bool ,
514+ ) -> Vec < SearchOutputResult > {
515+ let graph_details = graph. map ( |graph| collect_graph_details ( results, graph) ) ;
516+
517+ results
518+ . iter ( )
519+ . enumerate ( )
520+ . map ( |( index, result) | {
521+ let details = graph_details
522+ . as_ref ( )
523+ . and_then ( |details| details. get ( index) )
524+ . and_then ( |details| details. as_ref ( ) ) ;
525+ let role = if fields. role {
526+ details
527+ . map ( |details| role_value ( & details. node . role ) )
528+ . unwrap_or_else ( || result. role . clone ( ) )
529+ } else {
530+ None
531+ } ;
532+ SearchOutputResult {
533+ name : result. name . clone ( ) ,
534+ kind : result. kind . clone ( ) ,
535+ score : result. score ,
536+ file : fields. file . then ( || result. file . clone ( ) ) ,
537+ id : fields. id . then ( || result. id . clone ( ) ) ,
538+ module : if fields. module {
539+ result. module . clone ( )
540+ } else {
541+ None
542+ } ,
543+ span : if fields. span {
544+ details. map ( |details| node_span_string ( details. node ) )
545+ } else {
546+ None
547+ } ,
548+ snippet : if fields. snippet {
549+ details. and_then ( |details| details. node . snippet . clone ( ) )
550+ } else {
551+ None
552+ } ,
553+ visibility : if fields. visibility {
554+ details. map ( |details| visibility_to_string ( details. node ) )
555+ } else {
556+ None
557+ } ,
558+ signature : if fields. signature {
559+ details. and_then ( |details| details. node . signature . clone ( ) )
560+ } else {
561+ None
562+ } ,
563+ role,
564+ calls : if include_context {
565+ details
566+ . map ( |details| details. calls . clone ( ) )
567+ . unwrap_or_default ( )
568+ } else {
569+ Vec :: new ( )
570+ } ,
571+ called_by : if include_context {
572+ details
573+ . map ( |details| details. called_by . clone ( ) )
574+ . unwrap_or_default ( )
575+ } else {
576+ Vec :: new ( )
577+ } ,
578+ type_refs : if include_context {
579+ details
580+ . map ( |details| details. type_refs . clone ( ) )
581+ . unwrap_or_default ( )
582+ } else {
583+ Vec :: new ( )
584+ } ,
453585 }
454586 } )
455587 . collect ( )
@@ -800,4 +932,86 @@ mod tests {
800932 let results = search_filtered ( & index, "AppView" , 10 , & options) . unwrap ( ) ;
801933 assert ! ( results. is_empty( ) ) ;
802934 }
935+
936+ #[ test]
937+ fn projection_respects_fields_and_context ( ) {
938+ let graph = Graph {
939+ version : "0.1.0" . to_string ( ) ,
940+ nodes : vec ! [
941+ Node {
942+ id: "app::main" . into( ) ,
943+ kind: NodeKind :: Function ,
944+ name: "main" . into( ) ,
945+ file: "src/main.rs" . into( ) ,
946+ span: Span {
947+ start: [ 1 , 0 ] ,
948+ end: [ 3 , 1 ] ,
949+ } ,
950+ visibility: Visibility :: Public ,
951+ metadata: HashMap :: new( ) ,
952+ role: Some ( NodeRole :: EntryPoint ) ,
953+ signature: Some ( "fn main()" . into( ) ) ,
954+ doc_comment: None ,
955+ module: Some ( "App" . into( ) ) ,
956+ snippet: Some ( "fn main() { helper(); }" . into( ) ) ,
957+ } ,
958+ Node {
959+ id: "app::helper" . into( ) ,
960+ kind: NodeKind :: Function ,
961+ name: "helper" . into( ) ,
962+ file: "src/main.rs" . into( ) ,
963+ span: Span {
964+ start: [ 5 , 0 ] ,
965+ end: [ 5 , 12 ] ,
966+ } ,
967+ visibility: Visibility :: Private ,
968+ metadata: HashMap :: new( ) ,
969+ role: None ,
970+ signature: Some ( "fn helper()" . into( ) ) ,
971+ doc_comment: None ,
972+ module: Some ( "App" . into( ) ) ,
973+ snippet: Some ( "fn helper() {}" . into( ) ) ,
974+ } ,
975+ ] ,
976+ edges : vec ! [ Edge {
977+ source: "app::main" . into( ) ,
978+ target: "app::helper" . into( ) ,
979+ kind: EdgeKind :: Calls ,
980+ confidence: 1.0 ,
981+ direction: None ,
982+ operation: None ,
983+ condition: None ,
984+ async_boundary: Some ( false ) ,
985+ provenance: Vec :: new( ) ,
986+ } ] ,
987+ } ;
988+ let results = vec ! [ SearchResult {
989+ id: "app::main" . into( ) ,
990+ name: "main" . into( ) ,
991+ kind: "function" . into( ) ,
992+ file: "src/main.rs" . into( ) ,
993+ score: 1.0 ,
994+ module: Some ( "App" . into( ) ) ,
995+ role: Some ( "entry_point" . into( ) ) ,
996+ } ] ;
997+
998+ let projected = project_results (
999+ & results,
1000+ Some ( & graph) ,
1001+ FieldSet :: parse ( "id,signature,role,snippet" ) ,
1002+ true ,
1003+ ) ;
1004+
1005+ assert_eq ! ( projected. len( ) , 1 ) ;
1006+ let result = & projected[ 0 ] ;
1007+ assert_eq ! ( result. name, "main" ) ;
1008+ assert_eq ! ( result. kind, "function" ) ;
1009+ assert_eq ! ( result. id. as_deref( ) , Some ( "app::main" ) ) ;
1010+ assert_eq ! ( result. signature. as_deref( ) , Some ( "fn main()" ) ) ;
1011+ assert_eq ! ( result. role. as_deref( ) , Some ( "entry_point" ) ) ;
1012+ assert_eq ! ( result. snippet. as_deref( ) , Some ( "fn main() { helper(); }" ) ) ;
1013+ assert ! ( result. file. is_none( ) ) ;
1014+ assert_eq ! ( result. calls, vec![ "app::helper" . to_string( ) ] ) ;
1015+ assert ! ( result. called_by. is_empty( ) ) ;
1016+ }
8031017}
0 commit comments