Skip to main content

nusy_codegraph/
search.rs

1//! Code search — graph-aware querying of CodeNodes and CodeEdges.
2//!
3//! Extends `mcp_tools::codegraph_query_objects` with graph-traversal capabilities:
4//! - **Edge-based search**: find callers of, callees of, importers of, tests for
5//! - **Pattern search**: case-insensitive substring match on name, signature, docstring
6//! - **Dependency search**: find all transitive dependencies (callers N levels deep)
7//! - **Composite search**: combine structural + graph criteria
8
9use crate::schema::{
10    CodeEdgePredicate, CodeNode, CodeNodeKind, edge_col, extract_file_path, node_col,
11};
12use arrow::array::{Array, Float32Array, Float64Array, Int32Array, RecordBatch, StringArray};
13use std::collections::{HashMap, HashSet, VecDeque};
14
15/// A search query that combines structural and graph criteria.
16#[derive(Debug, Default, Clone)]
17pub struct CodeSearch {
18    /// Filter by node kind.
19    pub kind: Option<CodeNodeKind>,
20    /// Regex pattern to match against name.
21    pub name_pattern: Option<String>,
22    /// Regex pattern to match against signature.
23    pub signature_pattern: Option<String>,
24    /// Regex pattern to match against docstring.
25    pub docstring_pattern: Option<String>,
26    /// Substring pattern to match against body text.
27    pub body_pattern: Option<String>,
28    /// Filter by file path prefix.
29    pub file_prefix: Option<String>,
30    /// Filter by minimum LOC.
31    pub min_loc: Option<i32>,
32    /// Filter by minimum cyclomatic complexity.
33    pub min_complexity: Option<i32>,
34    /// Filter by maximum coverage percentage.
35    pub max_coverage: Option<f64>,
36    /// Maximum number of results.
37    pub limit: Option<usize>,
38}
39
40/// Result of a code search.
41#[derive(Debug)]
42pub struct SearchResult {
43    /// Matching nodes.
44    pub nodes: Vec<CodeNode>,
45    /// Total nodes scanned.
46    pub total_scanned: usize,
47}
48
49/// Find nodes matching a `CodeSearch` query.
50///
51/// This is a richer alternative to `codegraph_query_objects` that supports
52/// regex patterns and file path filtering.
53pub fn search_nodes(batch: &RecordBatch, query: &CodeSearch) -> SearchResult {
54    if batch.num_rows() == 0 {
55        return SearchResult {
56            nodes: Vec::new(),
57            total_scanned: 0,
58        };
59    }
60
61    let ids = col_str(batch, node_col::ID);
62    let names = col_str(batch, node_col::NAME);
63    let parent_ids = col_str(batch, node_col::PARENT_ID);
64    let signatures = col_str(batch, node_col::SIGNATURE);
65    let docstrings = col_str(batch, node_col::DOCSTRING);
66    let body_hashes = col_str(batch, node_col::BODY_HASH);
67    let bodies = batch
68        .column(node_col::BODY)
69        .as_any()
70        .downcast_ref::<arrow::array::LargeStringArray>()
71        .expect("body column");
72    let locs = col_i32(batch, node_col::LOC);
73    let complexities = col_i32(batch, node_col::CYCLOMATIC_COMPLEXITY);
74    let coverages = col_f64(batch, node_col::COVERAGE_PCT);
75    let (kind_dict, kind_values) = col_dict_str(batch, node_col::KIND);
76
77    let total_scanned = batch.num_rows();
78    let mut matched = Vec::new();
79
80    for i in 0..total_scanned {
81        let kind_key = kind_dict.keys().value(i) as usize;
82        let kind_str = kind_values.value(kind_key);
83        let name = names.value(i);
84        let id = ids.value(i);
85
86        // Kind filter
87        if let Some(ref k) = query.kind
88            && kind_str != k.as_str()
89        {
90            continue;
91        }
92
93        // Name pattern (case-insensitive substring)
94        if let Some(ref pat) = query.name_pattern
95            && !name.to_lowercase().contains(&pat.to_lowercase())
96        {
97            continue;
98        }
99
100        // Signature pattern
101        if let Some(ref pat) = query.signature_pattern
102            && (signatures.is_null(i)
103                || !signatures
104                    .value(i)
105                    .to_lowercase()
106                    .contains(&pat.to_lowercase()))
107        {
108            continue;
109        }
110
111        // Docstring pattern
112        if let Some(ref pat) = query.docstring_pattern
113            && (docstrings.is_null(i)
114                || !docstrings
115                    .value(i)
116                    .to_lowercase()
117                    .contains(&pat.to_lowercase()))
118        {
119            continue;
120        }
121
122        // Body pattern (case-insensitive substring on body text)
123        if let Some(ref pat) = query.body_pattern
124            && (bodies.is_null(i) || !bodies.value(i).to_lowercase().contains(&pat.to_lowercase()))
125        {
126            continue;
127        }
128
129        // File prefix (extracted from node ID)
130        if let Some(ref prefix) = query.file_prefix {
131            let file_path = extract_file_path(id);
132            match file_path {
133                Some(fp) if fp.starts_with(prefix.as_str()) => {}
134                _ => continue,
135            }
136        }
137
138        // Metric filters
139        if let Some(min) = query.min_loc
140            && (locs.is_null(i) || locs.value(i) < min)
141        {
142            continue;
143        }
144        if let Some(min) = query.min_complexity
145            && (complexities.is_null(i) || complexities.value(i) < min)
146        {
147            continue;
148        }
149        if let Some(max) = query.max_coverage
150            && (coverages.is_null(i) || coverages.value(i) > max)
151        {
152            continue;
153        }
154
155        let node = extract_node(
156            i,
157            ids,
158            kind_str,
159            parent_ids,
160            names,
161            signatures,
162            docstrings,
163            body_hashes,
164            locs,
165            complexities,
166            coverages,
167        );
168        matched.push(node);
169
170        if let Some(limit) = query.limit
171            && matched.len() >= limit
172        {
173            break;
174        }
175    }
176
177    SearchResult {
178        nodes: matched,
179        total_scanned,
180    }
181}
182
183/// Find all nodes that have an edge of `predicate` type pointing TO `target_id`.
184///
185/// Example: `find_sources("func:a.py::foo", Calls, ...)` finds all callers of foo.
186pub fn find_sources(
187    target_id: &str,
188    predicate: CodeEdgePredicate,
189    nodes_batch: &RecordBatch,
190    edges_batch: &RecordBatch,
191) -> Vec<CodeNode> {
192    let source_ids =
193        collect_edge_endpoints(edges_batch, target_id, predicate, EdgeDirection::Incoming);
194    let refs: Vec<&str> = source_ids.iter().map(|s| s.as_str()).collect();
195    resolve_node_ids(&refs, nodes_batch)
196}
197
198/// Find all nodes that `source_id` has an edge of `predicate` type pointing TO.
199///
200/// Example: `find_targets("func:a.py::foo", Calls, ...)` finds all functions foo calls.
201pub fn find_targets(
202    source_id: &str,
203    predicate: CodeEdgePredicate,
204    nodes_batch: &RecordBatch,
205    edges_batch: &RecordBatch,
206) -> Vec<CodeNode> {
207    let target_ids =
208        collect_edge_endpoints(edges_batch, source_id, predicate, EdgeDirection::Outgoing);
209    let refs: Vec<&str> = target_ids.iter().map(|s| s.as_str()).collect();
210    resolve_node_ids(&refs, nodes_batch)
211}
212
213/// Find all callers of a function/method (convenience wrapper).
214pub fn callers(
215    node_id: &str,
216    nodes_batch: &RecordBatch,
217    edges_batch: &RecordBatch,
218) -> Vec<CodeNode> {
219    find_sources(node_id, CodeEdgePredicate::Calls, nodes_batch, edges_batch)
220}
221
222/// Find all callees of a function/method (convenience wrapper).
223pub fn callees(
224    node_id: &str,
225    nodes_batch: &RecordBatch,
226    edges_batch: &RecordBatch,
227) -> Vec<CodeNode> {
228    find_targets(node_id, CodeEdgePredicate::Calls, nodes_batch, edges_batch)
229}
230
231/// Find tests that cover a node (convenience wrapper).
232pub fn tests_for(
233    node_id: &str,
234    nodes_batch: &RecordBatch,
235    edges_batch: &RecordBatch,
236) -> Vec<CodeNode> {
237    find_sources(node_id, CodeEdgePredicate::Tests, nodes_batch, edges_batch)
238}
239
240/// Find all children (contained nodes) of a given node.
241pub fn children_of(
242    node_id: &str,
243    nodes_batch: &RecordBatch,
244    edges_batch: &RecordBatch,
245) -> Vec<CodeNode> {
246    find_targets(
247        node_id,
248        CodeEdgePredicate::Contains,
249        nodes_batch,
250        edges_batch,
251    )
252}
253
254/// Transitive dependency search — find all nodes reachable from `start_id`
255/// following edges of type `predicate`, up to `max_depth` hops.
256///
257/// Returns nodes in breadth-first order (excluding the start node).
258pub fn transitive_deps(
259    start_id: &str,
260    predicate: CodeEdgePredicate,
261    max_depth: usize,
262    nodes_batch: &RecordBatch,
263    edges_batch: &RecordBatch,
264) -> Vec<CodeNode> {
265    // Build adjacency list from edges
266    let adj = build_adjacency(edges_batch, predicate, EdgeDirection::Outgoing);
267
268    // BFS
269    let mut visited: HashSet<String> = HashSet::new();
270    visited.insert(start_id.to_string());
271    let mut queue: VecDeque<(String, usize)> = VecDeque::new();
272    queue.push_back((start_id.to_string(), 0));
273    let mut reachable: Vec<String> = Vec::new();
274
275    while let Some((current, depth)) = queue.pop_front() {
276        if depth >= max_depth {
277            continue;
278        }
279        if let Some(neighbors) = adj.get(&current) {
280            for neighbor in neighbors {
281                if visited.insert(neighbor.clone()) {
282                    reachable.push(neighbor.clone());
283                    queue.push_back((neighbor.clone(), depth + 1));
284                }
285            }
286        }
287    }
288
289    resolve_node_ids(
290        &reachable.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
291        nodes_batch,
292    )
293}
294
295/// Reverse transitive search — find all nodes that can reach `target_id`
296/// following edges of type `predicate` in reverse, up to `max_depth` hops.
297///
298/// Example: `transitive_callers("func:a.py::foo", 3, ...)` finds all functions
299/// that directly or indirectly call foo, up to 3 levels deep.
300pub fn transitive_callers(
301    target_id: &str,
302    predicate: CodeEdgePredicate,
303    max_depth: usize,
304    nodes_batch: &RecordBatch,
305    edges_batch: &RecordBatch,
306) -> Vec<CodeNode> {
307    let adj = build_adjacency(edges_batch, predicate, EdgeDirection::Incoming);
308
309    let mut visited: HashSet<String> = HashSet::new();
310    visited.insert(target_id.to_string());
311    let mut queue: VecDeque<(String, usize)> = VecDeque::new();
312    queue.push_back((target_id.to_string(), 0));
313    let mut reachable: Vec<String> = Vec::new();
314
315    while let Some((current, depth)) = queue.pop_front() {
316        if depth >= max_depth {
317            continue;
318        }
319        if let Some(neighbors) = adj.get(&current) {
320            for neighbor in neighbors {
321                if visited.insert(neighbor.clone()) {
322                    reachable.push(neighbor.clone());
323                    queue.push_back((neighbor.clone(), depth + 1));
324                }
325            }
326        }
327    }
328
329    resolve_node_ids(
330        &reachable.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
331        nodes_batch,
332    )
333}
334
335// ── Internal helpers ────────────────────────────────────────────────────────
336
337#[derive(Clone, Copy)]
338enum EdgeDirection {
339    /// Follow edges where the node is the target (find sources).
340    Incoming,
341    /// Follow edges where the node is the source (find targets).
342    Outgoing,
343}
344
345/// Collect endpoint IDs from edges matching a specific node and predicate.
346fn collect_edge_endpoints(
347    edges_batch: &RecordBatch,
348    node_id: &str,
349    predicate: CodeEdgePredicate,
350    direction: EdgeDirection,
351) -> Vec<String> {
352    if edges_batch.num_rows() == 0 {
353        return Vec::new();
354    }
355
356    let sources = col_str(edges_batch, edge_col::SOURCE_ID);
357    let targets = col_str(edges_batch, edge_col::TARGET_ID);
358    let (pred_dict, pred_values) = col_dict_str(edges_batch, edge_col::PREDICATE);
359    let weights = edges_batch
360        .column(edge_col::WEIGHT)
361        .as_any()
362        .downcast_ref::<Float32Array>()
363        .expect("weight");
364
365    let pred_str = predicate.as_str();
366    let mut results = Vec::new();
367
368    for i in 0..edges_batch.num_rows() {
369        // Skip tombstoned edges
370        if !weights.is_null(i) && weights.value(i) < 0.0 {
371            continue;
372        }
373
374        let pred_key = pred_dict.keys().value(i) as usize;
375        if pred_values.value(pred_key) != pred_str {
376            continue;
377        }
378
379        match direction {
380            EdgeDirection::Incoming => {
381                if targets.value(i) == node_id {
382                    results.push(sources.value(i).to_string());
383                }
384            }
385            EdgeDirection::Outgoing => {
386                if sources.value(i) == node_id {
387                    results.push(targets.value(i).to_string());
388                }
389            }
390        }
391    }
392
393    results
394}
395
396/// Build an adjacency list from edges of a given predicate.
397fn build_adjacency(
398    edges_batch: &RecordBatch,
399    predicate: CodeEdgePredicate,
400    direction: EdgeDirection,
401) -> HashMap<String, Vec<String>> {
402    let mut adj: HashMap<String, Vec<String>> = HashMap::new();
403
404    if edges_batch.num_rows() == 0 {
405        return adj;
406    }
407
408    let sources = col_str(edges_batch, edge_col::SOURCE_ID);
409    let targets = col_str(edges_batch, edge_col::TARGET_ID);
410    let (pred_dict, pred_values) = col_dict_str(edges_batch, edge_col::PREDICATE);
411    let weights = edges_batch
412        .column(edge_col::WEIGHT)
413        .as_any()
414        .downcast_ref::<Float32Array>()
415        .expect("weight");
416
417    let pred_str = predicate.as_str();
418
419    for i in 0..edges_batch.num_rows() {
420        if !weights.is_null(i) && weights.value(i) < 0.0 {
421            continue;
422        }
423
424        let pred_key = pred_dict.keys().value(i) as usize;
425        if pred_values.value(pred_key) != pred_str {
426            continue;
427        }
428
429        match direction {
430            EdgeDirection::Outgoing => {
431                adj.entry(sources.value(i).to_string())
432                    .or_default()
433                    .push(targets.value(i).to_string());
434            }
435            EdgeDirection::Incoming => {
436                adj.entry(targets.value(i).to_string())
437                    .or_default()
438                    .push(sources.value(i).to_string());
439            }
440        }
441    }
442
443    adj
444}
445
446/// Resolve a list of node IDs to CodeNode structs from the batch.
447fn resolve_node_ids(ids: &[&str], batch: &RecordBatch) -> Vec<CodeNode> {
448    if batch.num_rows() == 0 || ids.is_empty() {
449        return Vec::new();
450    }
451
452    let id_set: HashSet<&str> = ids.iter().copied().collect();
453    let batch_ids = col_str(batch, node_col::ID);
454    let names = col_str(batch, node_col::NAME);
455    let parent_ids = col_str(batch, node_col::PARENT_ID);
456    let signatures = col_str(batch, node_col::SIGNATURE);
457    let docstrings = col_str(batch, node_col::DOCSTRING);
458    let body_hashes = col_str(batch, node_col::BODY_HASH);
459    let locs = col_i32(batch, node_col::LOC);
460    let complexities = col_i32(batch, node_col::CYCLOMATIC_COMPLEXITY);
461    let coverages = col_f64(batch, node_col::COVERAGE_PCT);
462    let (kind_dict, kind_values) = col_dict_str(batch, node_col::KIND);
463
464    let mut result = Vec::new();
465    for i in 0..batch.num_rows() {
466        let id = batch_ids.value(i);
467        if id_set.contains(id) {
468            let kind_key = kind_dict.keys().value(i) as usize;
469            let kind_str = kind_values.value(kind_key);
470            result.push(extract_node(
471                i,
472                batch_ids,
473                kind_str,
474                parent_ids,
475                names,
476                signatures,
477                docstrings,
478                body_hashes,
479                locs,
480                complexities,
481                coverages,
482            ));
483        }
484    }
485    result
486}
487
488// ── Column accessor helpers ─────────────────────────────────────────────────
489
490fn col_str(batch: &RecordBatch, idx: usize) -> &StringArray {
491    batch
492        .column(idx)
493        .as_any()
494        .downcast_ref::<StringArray>()
495        .expect("string column")
496}
497
498fn col_i32(batch: &RecordBatch, idx: usize) -> &Int32Array {
499    batch
500        .column(idx)
501        .as_any()
502        .downcast_ref::<Int32Array>()
503        .expect("int32 column")
504}
505
506fn col_f64(batch: &RecordBatch, idx: usize) -> &Float64Array {
507    batch
508        .column(idx)
509        .as_any()
510        .downcast_ref::<Float64Array>()
511        .expect("float64 column")
512}
513
514fn col_dict_str(
515    batch: &RecordBatch,
516    idx: usize,
517) -> (&arrow::array::Int8DictionaryArray, &StringArray) {
518    let dict = batch
519        .column(idx)
520        .as_any()
521        .downcast_ref::<arrow::array::Int8DictionaryArray>()
522        .expect("dict column");
523    let values = dict
524        .values()
525        .as_any()
526        .downcast_ref::<StringArray>()
527        .expect("dict values");
528    (dict, values)
529}
530
531#[allow(clippy::too_many_arguments)]
532fn extract_node(
533    i: usize,
534    ids: &StringArray,
535    kind_str: &str,
536    parent_ids: &StringArray,
537    names: &StringArray,
538    signatures: &StringArray,
539    docstrings: &StringArray,
540    body_hashes: &StringArray,
541    locs: &Int32Array,
542    complexities: &Int32Array,
543    coverages: &Float64Array,
544) -> CodeNode {
545    CodeNode {
546        id: ids.value(i).to_string(),
547        kind: CodeNodeKind::parse(kind_str).unwrap_or(CodeNodeKind::Function),
548        parent_id: if parent_ids.is_null(i) {
549            None
550        } else {
551            Some(parent_ids.value(i).to_string())
552        },
553        name: names.value(i).to_string(),
554        signature: if signatures.is_null(i) {
555            None
556        } else {
557            Some(signatures.value(i).to_string())
558        },
559        docstring: if docstrings.is_null(i) {
560            None
561        } else {
562            Some(docstrings.value(i).to_string())
563        },
564        body_hash: if body_hashes.is_null(i) {
565            None
566        } else {
567            Some(body_hashes.value(i).to_string())
568        },
569        body: None, // Body not extracted in search (use dedicated body query)
570        loc: if locs.is_null(i) {
571            None
572        } else {
573            Some(locs.value(i))
574        },
575        cyclomatic_complexity: if complexities.is_null(i) {
576            None
577        } else {
578            Some(complexities.value(i))
579        },
580        coverage_pct: if coverages.is_null(i) {
581            None
582        } else {
583            Some(coverages.value(i))
584        },
585        last_modified: None,
586        ..Default::default()
587    }
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593    use crate::schema::{
594        CodeEdge, CodeEdgePredicate, CodeNode, CodeNodeKind, build_code_edges_batch,
595        build_code_nodes_batch,
596    };
597
598    fn sample_nodes() -> Vec<CodeNode> {
599        vec![
600            CodeNode {
601                id: "mod:brain/signal.py".into(),
602                kind: CodeNodeKind::Module,
603                parent_id: None,
604                name: "signal".into(),
605                signature: None,
606                docstring: Some("Signal processing module.".into()),
607                body_hash: Some("mod_h".into()),
608                body: None,
609                loc: Some(200),
610                cyclomatic_complexity: None,
611                coverage_pct: None,
612                last_modified: None,
613                ..Default::default()
614            },
615            CodeNode {
616                id: "func:brain/signal.py::fuse".into(),
617                kind: CodeNodeKind::Function,
618                parent_id: Some("mod:brain/signal.py".into()),
619                name: "fuse".into(),
620                signature: Some("def fuse(signals: List) -> Decision".into()),
621                docstring: Some("Fuse cognitive signals.".into()),
622                body_hash: Some("fuse_h".into()),
623                body: None,
624                loc: Some(42),
625                cyclomatic_complexity: Some(8),
626                coverage_pct: Some(0.85),
627                last_modified: None,
628                ..Default::default()
629            },
630            CodeNode {
631                id: "func:brain/signal.py::validate".into(),
632                kind: CodeNodeKind::Function,
633                parent_id: Some("mod:brain/signal.py".into()),
634                name: "validate".into(),
635                signature: Some("def validate(sig: Signal) -> bool".into()),
636                docstring: None,
637                body_hash: Some("val_h".into()),
638                body: None,
639                loc: Some(15),
640                cyclomatic_complexity: Some(2),
641                coverage_pct: Some(0.95),
642                last_modified: None,
643                ..Default::default()
644            },
645            CodeNode {
646                id: "func:brain/store.py::promote".into(),
647                kind: CodeNodeKind::Function,
648                parent_id: None,
649                name: "promote".into(),
650                signature: Some("def promote(item) -> None".into()),
651                docstring: Some("Promote to long-term.".into()),
652                body_hash: Some("prom_h".into()),
653                body: None,
654                loc: Some(30),
655                cyclomatic_complexity: Some(4),
656                coverage_pct: Some(0.60),
657                last_modified: None,
658                ..Default::default()
659            },
660            CodeNode {
661                id: "test:brain/signal.py::test_fuse".into(),
662                kind: CodeNodeKind::Test,
663                parent_id: None,
664                name: "test_fuse".into(),
665                signature: None,
666                docstring: None,
667                body_hash: Some("test_h".into()),
668                body: None,
669                loc: Some(10),
670                cyclomatic_complexity: Some(1),
671                coverage_pct: None,
672                last_modified: None,
673                ..Default::default()
674            },
675            CodeNode {
676                id: "func:brain/nn.py::activate".into(),
677                kind: CodeNodeKind::Function,
678                parent_id: None,
679                name: "activate".into(),
680                signature: Some("def activate(x: Tensor) -> Tensor".into()),
681                docstring: Some("Apply activation function.".into()),
682                body_hash: Some("act_h".into()),
683                body: Some("def activate(x):\n    return softmax(x)".into()),
684                loc: Some(5),
685                cyclomatic_complexity: Some(1),
686                coverage_pct: Some(0.90),
687                last_modified: None,
688                ..Default::default()
689            },
690        ]
691    }
692
693    fn sample_edges() -> Vec<CodeEdge> {
694        vec![
695            // fuse calls validate
696            CodeEdge {
697                source_id: "func:brain/signal.py::fuse".into(),
698                target_id: "func:brain/signal.py::validate".into(),
699                predicate: CodeEdgePredicate::Calls,
700                weight: Some(2.0),
701                commit_id: None,
702            },
703            // fuse calls promote
704            CodeEdge {
705                source_id: "func:brain/signal.py::fuse".into(),
706                target_id: "func:brain/store.py::promote".into(),
707                predicate: CodeEdgePredicate::Calls,
708                weight: Some(1.0),
709                commit_id: None,
710            },
711            // validate calls promote
712            CodeEdge {
713                source_id: "func:brain/signal.py::validate".into(),
714                target_id: "func:brain/store.py::promote".into(),
715                predicate: CodeEdgePredicate::Calls,
716                weight: Some(1.0),
717                commit_id: None,
718            },
719            // test_fuse tests fuse
720            CodeEdge {
721                source_id: "test:brain/signal.py::test_fuse".into(),
722                target_id: "func:brain/signal.py::fuse".into(),
723                predicate: CodeEdgePredicate::Tests,
724                weight: Some(1.0),
725                commit_id: None,
726            },
727            // module contains fuse
728            CodeEdge {
729                source_id: "mod:brain/signal.py".into(),
730                target_id: "func:brain/signal.py::fuse".into(),
731                predicate: CodeEdgePredicate::Contains,
732                weight: None,
733                commit_id: None,
734            },
735            // module contains validate
736            CodeEdge {
737                source_id: "mod:brain/signal.py".into(),
738                target_id: "func:brain/signal.py::validate".into(),
739                predicate: CodeEdgePredicate::Contains,
740                weight: None,
741                commit_id: None,
742            },
743        ]
744    }
745
746    // ── search_nodes tests ──────────────────────────────────────────────
747
748    #[test]
749    fn test_search_all() {
750        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
751        let result = search_nodes(&nodes, &CodeSearch::default());
752        assert_eq!(result.total_scanned, 6);
753        assert_eq!(result.nodes.len(), 6);
754    }
755
756    #[test]
757    fn test_search_by_kind() {
758        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
759        let result = search_nodes(
760            &nodes,
761            &CodeSearch {
762                kind: Some(CodeNodeKind::Function),
763                ..Default::default()
764            },
765        );
766        assert_eq!(result.nodes.len(), 4); // fuse, validate, promote, activate
767    }
768
769    #[test]
770    fn test_search_by_name_pattern() {
771        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
772        let result = search_nodes(
773            &nodes,
774            &CodeSearch {
775                name_pattern: Some("fus".into()),
776                ..Default::default()
777            },
778        );
779        assert_eq!(result.nodes.len(), 2); // fuse + test_fuse
780    }
781
782    #[test]
783    fn test_search_by_signature_pattern() {
784        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
785        let result = search_nodes(
786            &nodes,
787            &CodeSearch {
788                signature_pattern: Some("-> bool".into()),
789                ..Default::default()
790            },
791        );
792        assert_eq!(result.nodes.len(), 1); // only validate returns bool
793        assert_eq!(result.nodes[0].name, "validate");
794    }
795
796    #[test]
797    fn test_search_by_docstring_pattern() {
798        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
799        let result = search_nodes(
800            &nodes,
801            &CodeSearch {
802                docstring_pattern: Some("cognitive".into()),
803                ..Default::default()
804            },
805        );
806        assert_eq!(result.nodes.len(), 1);
807        assert_eq!(result.nodes[0].name, "fuse");
808    }
809
810    #[test]
811    fn test_search_by_body_pattern() {
812        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
813        // "softmax" appears in the body of the activate function
814        let result = search_nodes(
815            &nodes,
816            &CodeSearch {
817                body_pattern: Some("softmax".into()),
818                ..Default::default()
819            },
820        );
821        assert_eq!(result.nodes.len(), 1);
822        assert_eq!(result.nodes[0].name, "activate");
823
824        // Nonexistent body text matches nothing
825        let result = search_nodes(
826            &nodes,
827            &CodeSearch {
828                body_pattern: Some("nonexistent".into()),
829                ..Default::default()
830            },
831        );
832        assert!(result.nodes.is_empty());
833
834        // Nodes with body: None are excluded by body_pattern filter
835        let result = search_nodes(
836            &nodes,
837            &CodeSearch {
838                body_pattern: Some("fuse".into()),
839                ..Default::default()
840            },
841        );
842        // "fuse" appears in names/docstrings but no node has it in body text
843        assert!(result.nodes.is_empty());
844    }
845
846    #[test]
847    fn test_search_by_file_prefix() {
848        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
849        let result = search_nodes(
850            &nodes,
851            &CodeSearch {
852                file_prefix: Some("brain/store".into()),
853                ..Default::default()
854            },
855        );
856        assert_eq!(result.nodes.len(), 1);
857        assert_eq!(result.nodes[0].name, "promote");
858    }
859
860    #[test]
861    fn test_search_combined() {
862        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
863        let result = search_nodes(
864            &nodes,
865            &CodeSearch {
866                kind: Some(CodeNodeKind::Function),
867                min_complexity: Some(5),
868                ..Default::default()
869            },
870        );
871        assert_eq!(result.nodes.len(), 1); // only fuse has complexity >= 5
872        assert_eq!(result.nodes[0].name, "fuse");
873    }
874
875    #[test]
876    fn test_search_with_limit() {
877        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
878        let result = search_nodes(
879            &nodes,
880            &CodeSearch {
881                limit: Some(2),
882                ..Default::default()
883            },
884        );
885        assert_eq!(result.nodes.len(), 2);
886    }
887
888    #[test]
889    fn test_search_empty_batch() {
890        let nodes = build_code_nodes_batch(&[]).unwrap();
891        let result = search_nodes(&nodes, &CodeSearch::default());
892        assert_eq!(result.nodes.len(), 0);
893        assert_eq!(result.total_scanned, 0);
894    }
895
896    // ── Edge-based search tests ─────────────────────────────────────────
897
898    #[test]
899    fn test_callers_of_validate() {
900        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
901        let edges = build_code_edges_batch(&sample_edges()).unwrap();
902
903        let result = callers("func:brain/signal.py::validate", &nodes, &edges);
904        assert_eq!(result.len(), 1);
905        assert_eq!(result[0].name, "fuse");
906    }
907
908    #[test]
909    fn test_callees_of_fuse() {
910        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
911        let edges = build_code_edges_batch(&sample_edges()).unwrap();
912
913        let result = callees("func:brain/signal.py::fuse", &nodes, &edges);
914        assert_eq!(result.len(), 2); // validate + promote
915        let names: HashSet<&str> = result.iter().map(|n| n.name.as_str()).collect();
916        assert!(names.contains("validate"));
917        assert!(names.contains("promote"));
918    }
919
920    #[test]
921    fn test_tests_for_fuse() {
922        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
923        let edges = build_code_edges_batch(&sample_edges()).unwrap();
924
925        let result = tests_for("func:brain/signal.py::fuse", &nodes, &edges);
926        assert_eq!(result.len(), 1);
927        assert_eq!(result[0].name, "test_fuse");
928    }
929
930    #[test]
931    fn test_children_of_module() {
932        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
933        let edges = build_code_edges_batch(&sample_edges()).unwrap();
934
935        let result = children_of("mod:brain/signal.py", &nodes, &edges);
936        assert_eq!(result.len(), 2); // fuse + validate
937    }
938
939    #[test]
940    fn test_callers_of_promote() {
941        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
942        let edges = build_code_edges_batch(&sample_edges()).unwrap();
943
944        let result = callers("func:brain/store.py::promote", &nodes, &edges);
945        assert_eq!(result.len(), 2); // fuse + validate
946    }
947
948    #[test]
949    fn test_no_callers() {
950        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
951        let edges = build_code_edges_batch(&sample_edges()).unwrap();
952
953        let result = callers("func:brain/signal.py::fuse", &nodes, &edges);
954        // fuse has no callers (only tested, not called)
955        assert!(result.is_empty());
956    }
957
958    // ── Transitive search tests ─────────────────────────────────────────
959
960    #[test]
961    fn test_transitive_callees_depth_1() {
962        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
963        let edges = build_code_edges_batch(&sample_edges()).unwrap();
964
965        let result = transitive_deps(
966            "func:brain/signal.py::fuse",
967            CodeEdgePredicate::Calls,
968            1,
969            &nodes,
970            &edges,
971        );
972        // Depth 1: fuse→validate, fuse→promote
973        assert_eq!(result.len(), 2);
974    }
975
976    #[test]
977    fn test_transitive_callees_depth_2() {
978        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
979        let edges = build_code_edges_batch(&sample_edges()).unwrap();
980
981        let result = transitive_deps(
982            "func:brain/signal.py::fuse",
983            CodeEdgePredicate::Calls,
984            2,
985            &nodes,
986            &edges,
987        );
988        // Depth 2: fuse→validate, fuse→promote, validate→promote (already visited)
989        assert_eq!(result.len(), 2); // validate + promote (promote not double-counted)
990    }
991
992    #[test]
993    fn test_transitive_callers() {
994        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
995        let edges = build_code_edges_batch(&sample_edges()).unwrap();
996
997        let result = transitive_callers(
998            "func:brain/store.py::promote",
999            CodeEdgePredicate::Calls,
1000            2,
1001            &nodes,
1002            &edges,
1003        );
1004        // promote is called by fuse and validate, fuse is called by nobody
1005        assert_eq!(result.len(), 2); // fuse + validate
1006    }
1007
1008    #[test]
1009    fn test_transitive_depth_0_returns_nothing() {
1010        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
1011        let edges = build_code_edges_batch(&sample_edges()).unwrap();
1012
1013        let result = transitive_deps(
1014            "func:brain/signal.py::fuse",
1015            CodeEdgePredicate::Calls,
1016            0,
1017            &nodes,
1018            &edges,
1019        );
1020        assert!(result.is_empty());
1021    }
1022
1023    #[test]
1024    fn test_find_sources_empty_edges() {
1025        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
1026        let edges = build_code_edges_batch(&[]).unwrap();
1027
1028        let result = find_sources(
1029            "func:brain/signal.py::fuse",
1030            CodeEdgePredicate::Calls,
1031            &nodes,
1032            &edges,
1033        );
1034        assert!(result.is_empty());
1035    }
1036
1037    #[test]
1038    fn test_extract_file_path() {
1039        assert_eq!(
1040            extract_file_path("func:brain/signal.py::fuse"),
1041            Some("brain/signal.py".to_string())
1042        );
1043        assert_eq!(
1044            extract_file_path("mod:brain/signal.py"),
1045            Some("brain/signal.py".to_string())
1046        );
1047        assert_eq!(extract_file_path("no-prefix"), None);
1048    }
1049}