leindex 2.0.3

LeIndex MCP and semantic code search engine for AI tools and large codebases
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
use super::helpers::{
    extract_bool, extract_string, extract_usize, get_direct_callees, get_direct_callers,
    node_type_str, read_source_snippet_resolved, resolve_scope, wrap_with_meta,
};
use super::protocol::JsonRpcError;
use super::request_meta::WorkBudget;
use crate::cli::registry::ProjectRegistry;
use serde_json::Value;
use std::sync::Arc;
use std::time::Instant;

/// Handler for LeIndex [symbol_lookup — full call graph for any symbol.
#[derive(Clone)]
pub struct SymbolLookupHandler;

/// Per-call lookup context shared by every symbol in one request.
struct LookupEnv<'a> {
    pdg: &'a crate::graph::pdg::ProgramDependenceGraph,
    project_root: &'a std::path::Path,
    include_source: bool,
    include_callers: bool,
    include_callees: bool,
    depth: usize,
    started: Instant,
    budget: WorkBudget,
    index_freshness: &'static str,
    index_stale: bool,
    graph_has_call_edges: bool,
}

#[allow(missing_docs)]
impl SymbolLookupHandler {
    pub fn name(&self) -> &str {
        "leindex_symbol_lookup"
    }

    pub fn title(&self) -> &str {
        "LeIndex [Symbol Lookup]"
    }

    pub fn description(&self) -> &str {
        "Symbol relationship lookup: callers, callees, data dependencies, and impact radius. \
Use for understanding how a symbol connects to the rest of the codebase. \
For the exact source implementation use LeIndex [Read Symbol]."
    }

    pub fn argument_schema(&self) -> Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "symbol": {
                    "type": "string",
                    "description": "Symbol name to look up (single symbol)"
                },
                "symbols": {
                    "type": "array",
                    "items": { "type": "string" },
                    "description": "Batch mode: look up multiple symbols in one call (max 20)"
                },
                "project_path": {
                    "type": "string",
                    "description": "Project directory (auto-indexes on first use; omit to use current project)"
                },
                "token_budget": {
                    "type": "integer",
                    "description": "Max tokens for response (default: 1500)",
                    "default": 1500
                },
                "scope": {
                    "type": "string",
                    "description": "Optional path to limit lookup (absolute or relative to project root)"
                },
                "include_source": {
                    "type": "boolean",
                    "description": "Include source code of definition (default: false). \
        Also accepts compatibility strings: 'true'/'false', '1'/'0', 'yes'/'no'.",
                    "default": false
                },
                "include_callers": {
                    "type": "boolean",
                    "description": "Include callers (default: true). \
        Also accepts compatibility strings: 'true'/'false', '1'/'0', 'yes'/'no'.",
                    "default": true
                },
                "include_callees": {
                    "type": "boolean",
                    "description": "Include callees (default: true). \
        Also accepts compatibility strings: 'true'/'false', '1'/'0', 'yes'/'no'.",
                    "default": true
                },
                "depth": {
                    "type": "integer",
                    "description": "Call graph traversal depth (default: 2, max: 5)",
                    "default": 2,
                    "minimum": 1,
                    "maximum": 5
                },
                "max_latency_ms": {
                    "type": "integer",
                    "description": "Optional caller/callee enrichment budget (default: 250)",
                    "default": 250,
                    "minimum": 0,
                    "maximum": 60000
                },
                "allow_partial": {
                    "type": "boolean",
                    "default": true
                }
            },
            "required": []
        })
    }

    pub async fn execute(
        &self,
        registry: &Arc<ProjectRegistry>,
        args: Value,
    ) -> Result<Value, JsonRpcError> {
        let is_batch = args
            .get("symbols")
            .and_then(|v| v.as_array())
            .is_some_and(|a| a.len() > 1);
        let include_source = extract_bool(&args, "include_source", !is_batch);
        let include_callers = extract_bool(&args, "include_callers", true);
        let include_callees = extract_bool(&args, "include_callees", true);
        let depth = extract_usize(&args, "depth", 2)?.min(5);
        let token_budget = extract_usize(&args, "token_budget", 1500)?;
        let budget = WorkBudget {
            max_latency_ms: extract_usize(&args, "max_latency_ms", 250)?.min(60000) as u64,
            allow_partial: extract_bool(&args, "allow_partial", true),
        };
        let started = Instant::now();

        // Resolve scope and get project handle
        let project_path = args.get("project_path").and_then(|v| v.as_str());
        let handle = registry.get_or_create(project_path).await?;
        let scope = {
            let guard = handle.read().await;
            resolve_scope(&args, guard.project_path())?
        };

        let symbols = parse_symbols(&args)?;

        let mut guard = handle.write().await;

        // Graph-only hydration: this tool never queries the search engine,
        // so skip snapshot/embedding-mmap restoration (~1s per cold call).
        guard
            .ensure_pdg_loaded_graph_only()
            .map_err(|e| JsonRpcError::indexing_failed(format!("Failed to load PDG: {}", e)))?;

        if guard.pdg().is_none() {
            return Err(JsonRpcError::project_not_indexed(
                guard.project_path().display().to_string(),
            ));
        }

        let pdg = guard.pdg().unwrap();
        let project_root = guard.project_path().to_path_buf();

        // Index-freshness context for honest degradation (N-15): a zero-impact
        // result served from a stale index or a degraded graph must say so,
        // not present "0 symbols / 0 files" as fact.
        let storage_root = crate::cli::leindex::resolve_existing_storage_path(&project_root)
            .unwrap_or_else(|| project_root.join(".leindex"));
        let health = crate::cli::index_freshness::load_health(&storage_root);
        let index_stale = super::helpers::is_index_genuinely_stale(&health, &project_root);
        let index_freshness = match health.as_ref().map(|h| h.status) {
            Some(crate::cli::leindex::ComponentStatus::Fresh) => "fresh",
            Some(crate::cli::leindex::ComponentStatus::Stale) => "stale",
            Some(crate::cli::leindex::ComponentStatus::Failed) => "failed",
            Some(crate::cli::leindex::ComponentStatus::Partial) => "partial",
            _ => "unknown",
        };
        let graph_has_call_edges = pdg.edge_indices().any(|edge| {
            pdg.get_edge(edge)
                .is_some_and(|e| e.edge_type == crate::graph::pdg::EdgeType::Call)
        });
        let env = LookupEnv {
            pdg,
            project_root: &project_root,
            include_source,
            include_callers,
            include_callees,
            depth,
            started,
            budget,
            index_freshness,
            index_stale,
            graph_has_call_edges,
        };

        // For batch mode, collect results for each symbol
        if symbols.len() > 1 {
            let char_budget = token_budget * 4;
            let per_symbol_budget = char_budget / symbols.len();
            let mut results: Vec<Value> = Vec::new();

            for symbol in &symbols {
                match self.lookup_single_symbol(&env, symbol, &scope, per_symbol_budget) {
                    Ok(mut val) => {
                        add_retrieval_meta(&mut val, budget, started);
                        results.push(val);
                    }
                    Err(e) => results.push(serde_json::json!({
                        "symbol": symbol,
                        "error": format!("{}", e)
                    })),
                }
            }

            return Ok(wrap_with_meta(
                serde_json::json!({
                    "batch": true,
                    "count": results.len(),
                    "results": results,
                    "retrieval": retrieval_meta(budget, started)
                }),
                &guard,
            ));
        }

        // Single symbol mode
        let char_budget = token_budget * 4;
        let single = self.lookup_single_symbol(&env, &symbols[0], &scope, char_budget)?;

        let mut single = single;
        add_retrieval_meta(&mut single, budget, started);
        Ok(wrap_with_meta(single, &guard))
    }

    /// Resolve and return full structural context for a single symbol.
    fn lookup_single_symbol(
        &self,
        env: &LookupEnv<'_>,
        symbol: &str,
        scope: &Option<String>,
        char_budget: usize,
    ) -> Result<Value, JsonRpcError> {
        let node_id = resolve_symbol_node(env.pdg, symbol, scope)?;

        let node = env
            .pdg
            .get_node(node_id)
            .ok_or_else(|| JsonRpcError::internal_error("PDG node disappeared after lookup"))?;

        let relations = collect_relations(
            env.pdg,
            node_id,
            env.depth,
            env.include_callers,
            env.include_callees,
            env.started,
            env.budget,
        );
        let partial = relations.partial;

        let affected_files: std::collections::HashSet<&str> = relations
            .forward
            .iter()
            .filter_map(|&nid| env.pdg.get_node(nid).map(|n| n.file_path.as_ref()))
            .collect();
        let impact_radius = serde_json::json!({
            // Direction label: the audit flagged 351-vs-9 confusion against
            // impact-analysis; this figure is forward (dependents) reach.
            "direction": "forward (symbols that depend on this one)",
            "affected_symbols": relations.forward.len(),
            "affected_files": affected_files.len()
        });

        let impact_note = impact_degradation_note(
            relations_are_empty(&relations),
            env.index_stale,
            env.graph_has_call_edges,
            partial,
        );
        let mut result = serde_json::json!({
            "symbol": node.name,
            "type": node_type_str(&node.node_type),
            "file": node.file_path,
            "byte_range": node.byte_range,
            "complexity": node.complexity,
            "language": node.language,
            "callers": relations.callers,
            "callees": relations.callees,
            "callers_truncated": relations.callers_truncated,
            "callees_truncated": relations.callees_truncated,
            "impact_radius": impact_radius,
            "pdg_status": if partial { "partial" } else { "fresh" },
            "index_freshness": env.index_freshness,
            "retrieval": retrieval_meta_with_partial(env.budget, partial)
        });
        if let Some(note) = impact_note {
            result["impact_note"] = Value::String(note.to_string());
        }

        if env.include_source && !partial {
            attach_source(&mut result, node, env.project_root, char_budget);
        }

        Ok(result)
    }
}

/// Honest-degradation note for empty lookup relations (N-15).
///
/// A zero-impact result is only a fact when the index is fresh AND the graph
/// actually carries call edges. Otherwise "Impact: 0 symbols / 0 files" is a
/// data-availability statement, and saying nothing silently misled callers
/// (the stress-test audit found ground-truth call sites rendered as zero
/// impact with no warning). Precedence: budget truncation > degraded graph >
/// stale index.
fn impact_degradation_note(
    relations_empty: bool,
    index_stale: bool,
    graph_has_call_edges: bool,
    partial: bool,
) -> Option<&'static str> {
    if !relations_empty {
        return None;
    }
    if partial {
        return Some(
            "relations were skipped because the latency budget was exhausted \
             (allow_partial); the zero impact figure is not authoritative — \
             retry with a larger max_latency_ms",
        );
    }
    if !graph_has_call_edges {
        return Some(
            "the loaded PDG has no call edges — the graph is degraded \
             (incomplete or corrupted index); the zero impact figure reflects \
             missing graph data, not isolation; re-index with force_reindex=true",
        );
    }
    if index_stale {
        return Some(
            "the index is stale relative to the worktree; the zero impact \
             figure may reflect missing data — re-index for authoritative impact",
        );
    }
    None
}

fn resolve_symbol_node(
    pdg: &crate::graph::pdg::ProgramDependenceGraph,
    symbol: &str,
    scope: &Option<String>,
) -> Result<crate::graph::pdg::NodeId, JsonRpcError> {
    let in_scope = |node: &crate::graph::pdg::Node| match scope {
        Some(s) => node.file_path.starts_with(s),
        None => true,
    };

    // 1. Exact symbol lookup (by node.id in symbol_index)
    let node_id = if let Some(nid) = pdg.find_by_symbol(symbol) {
        pdg.get_node(nid).filter(|n| in_scope(n)).map(|_| nid)
    } else {
        None
    }
    // 2. Exact name lookup (by node.name in name_index) — prefer non-module nodes
    .or_else(|| {
        let candidates = pdg.find_all_by_name(symbol);
        // Prefer class/function/method over module nodes
        candidates
            .iter()
            .copied()
            .find(|&nid| {
                pdg.get_node(nid)
                    .map(|n| n.node_type != crate::graph::pdg::NodeType::Module && in_scope(n))
                    .unwrap_or(false)
            })
            .or_else(|| {
                candidates
                    .iter()
                    .copied()
                    .find(|&nid| pdg.get_node(nid).is_some_and(in_scope))
            })
    })
    .or_else(|| find_fuzzy_node(pdg, symbol, &in_scope))
    .ok_or_else(|| {
        let total_symbols = pdg.node_count();
        let total_files = pdg.file_count();
        let suggestion = format!(
            "Symbol '{}' not found among {} indexed symbols across {} files. Try: \
            check spelling, use leindex_explore mode=find target=symbols for partial matches, \
            or LeIndex [Text Search] for raw content search.",
            symbol, total_symbols, total_files
        );
        JsonRpcError::invalid_params_with_suggestion(
            format!("Symbol '{}' not found in project index", symbol),
            &suggestion,
        )
    })?;

    Ok(node_id)
}

fn find_fuzzy_node(
    pdg: &crate::graph::pdg::ProgramDependenceGraph,
    symbol: &str,
    in_scope: &impl Fn(&crate::graph::pdg::Node) -> bool,
) -> Option<crate::graph::pdg::NodeId> {
    // 3. Fuzzy match: substring, case-insensitive — prefer non-module nodes
    let sym_lower = symbol.to_lowercase();
    let mut best: Option<crate::graph::pdg::NodeId> = None;
    let mut best_is_module = true;
    for nid in pdg.node_indices() {
        let Some(n) = pdg.get_node(nid) else {
            continue;
        };
        if !in_scope(n) {
            continue;
        }
        let matches =
            n.name.to_lowercase().contains(&sym_lower) || n.id.to_lowercase().contains(&sym_lower);
        if !matches {
            continue;
        }
        let is_module = n.node_type == crate::graph::pdg::NodeType::Module;
        // Always prefer non-module; only accept module if it's the first match
        if best.is_none() || (best_is_module && !is_module) {
            best = Some(nid);
            best_is_module = is_module;
            if !is_module {
                break;
            } // non-module is best, stop early
        }
    }
    best
}

/// Structural relations collected for one symbol lookup, plus the sticky
/// latency-budget flag observed while collecting them.
struct SymbolRelations {
    callers: Vec<Value>,
    callers_truncated: bool,
    callees: Vec<Value>,
    callees_truncated: bool,
    forward: Vec<crate::graph::pdg::NodeId>,
    /// Once the budget is exhausted every later relation step is skipped and
    /// the result is reported as partial.
    partial: bool,
}

/// Collect a symbol's direct callees, direct callers and depth-bounded forward
/// impact, each only while the latency budget still allows work. The flag is
/// re-evaluated between steps and ORs forward, so a step that runs long
/// suppresses the steps after it.
fn collect_relations(
    pdg: &crate::graph::pdg::ProgramDependenceGraph,
    node_id: crate::graph::pdg::NodeId,
    depth: usize,
    include_callers: bool,
    include_callees: bool,
    started: Instant,
    budget: WorkBudget,
) -> SymbolRelations {
    let mut partial = budget.elapsed(started);

    // Callees (direct)
    let (callees, callees_truncated) = if include_callees && !partial {
        summarize_nodes(pdg, get_direct_callees(pdg, node_id))
    } else {
        (Vec::new(), false)
    };
    partial |= budget.elapsed(started);

    // Callers (direct)
    let (callers, callers_truncated) = if include_callers && !partial {
        summarize_nodes(pdg, get_direct_callers(pdg, node_id))
    } else {
        (Vec::new(), false)
    };
    partial |= budget.elapsed(started);

    // Forward impact (depth-bounded transitive dependents)
    let forward = if partial {
        Vec::new()
    } else {
        pdg.forward_impact(
            node_id,
            &crate::graph::pdg::TraversalConfig {
                max_depth: Some(depth),
                ..crate::graph::pdg::TraversalConfig::for_impact_analysis()
            },
        )
    };
    partial |= budget.elapsed(started);

    SymbolRelations {
        callers,
        callers_truncated,
        callees,
        callees_truncated,
        forward,
        partial,
    }
}

/// No relations at all — the case where a zero impact figure must be disclosed
/// as a data-availability statement rather than a fact.
fn relations_are_empty(relations: &SymbolRelations) -> bool {
    relations.callers.is_empty() && relations.callees.is_empty() && relations.forward.is_empty()
}

/// Attach the source snippet for a resolved node, truncated to half the char
/// budget (the other half is reserved for the structural payload).
fn attach_source(
    result: &mut Value,
    node: &crate::graph::pdg::Node,
    project_root: &std::path::Path,
    char_budget: usize,
) {
    if let Some(src) =
        read_source_snippet_resolved(&node.file_path, node.byte_range, Some(project_root))
    {
        let half = char_budget / 2;
        if half == 0 {
            // A zero-share batch (e.g. 20 symbols on a tiny token_budget) would
            // otherwise attach `"source": ""`, which reads as "empty file"
            // rather than "budget too thin" (same disclosure rule as N-15).
            result["source_omitted"] = Value::String(
                "per-symbol share of token_budget is too small for a source snippet; \
                 raise token_budget or look up symbols individually"
                    .to_string(),
            );
            return;
        }
        let truncated: String = src.chars().take(half).collect();
        result["source"] = Value::String(truncated);
    }
}

fn summarize_nodes(
    pdg: &crate::graph::pdg::ProgramDependenceGraph,
    node_ids: Vec<crate::graph::pdg::NodeId>,
) -> (Vec<Value>, bool) {
    // Neighbors/predecessors yield one entry per edge; a related node with
    // several edge types to the anchor must render once (N-01).
    let mut seen = std::collections::HashSet::new();
    let capped_nodes: Vec<Value> = node_ids
        .into_iter()
        .filter(|node_id| seen.insert(*node_id))
        .filter_map(|node_id| {
            pdg.get_node(node_id).map(|node| {
                serde_json::json!({
                    "name": node.name,
                    "file": node.file_path,
                    "type": node_type_str(&node.node_type)
                })
            })
        })
        .take(51)
        .collect();
    let truncated = capped_nodes.len() > 50;
    (capped_nodes.into_iter().take(50).collect(), truncated)
}

fn parse_symbols(args: &Value) -> Result<Vec<String>, JsonRpcError> {
    // Determine symbol list: single "symbol" or batch "symbols"
    let symbols = if let Some(arr) = args.get("symbols").and_then(|v| v.as_array()) {
        // The schema documents maxItems: 20 — enforce it with a clear error
        // instead of silently dropping the tail (a caller batching 21
        // symbols previously got 20 results with no indication anything was
        // truncated).
        if arr.len() > 20 {
            return Err(JsonRpcError::invalid_params(format!(
                "'symbols' accepts at most 20 entries; got {}. Split into multiple calls.",
                arr.len()
            )));
        }
        arr.iter()
            .filter_map(|v| v.as_str().map(str::to_owned))
            .filter(|s| !s.trim().is_empty())
            .collect()
    } else if let Ok(sym) = extract_string(args, "symbol") {
        if sym.trim().is_empty() {
            return Err(JsonRpcError::invalid_params(
                "'symbol' must be a non-empty string".to_string(),
            ));
        }
        vec![sym]
    } else {
        return Err(JsonRpcError::invalid_params(
            "Provide either 'symbol' (string) or 'symbols' (array of strings)".to_string(),
        ));
    };

    // Validate symbols is non-empty (after filtering blanks)
    if symbols.is_empty() {
        return Err(JsonRpcError::invalid_params(
            "'symbols' array must contain at least one non-blank string".to_string(),
        ));
    }

    Ok(symbols)
}

fn retrieval_meta(budget: WorkBudget, started: Instant) -> Value {
    retrieval_meta_with_partial(budget, budget.elapsed(started))
}

fn retrieval_meta_with_partial(budget: WorkBudget, partial: bool) -> Value {
    serde_json::json!({
        "tfidf_status": "not_used_exact",
        "pdg_status": if partial { "partial" } else { "fresh" },
        "neural_status": "not_used_exact",
        "partial": partial,
        "max_latency_ms": budget.max_latency_ms,
        "allow_partial": budget.allow_partial
    })
}

fn add_retrieval_meta(value: &mut Value, budget: WorkBudget, started: Instant) {
    if let Some(object) = value.as_object_mut() {
        object.insert("retrieval".to_string(), retrieval_meta(budget, started));
        object.insert(
            "pdg_status".to_string(),
            Value::String(if budget.elapsed(started) {
                "partial".to_string()
            } else {
                "fresh".to_string()
            }),
        );
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cli::mcp::helpers::test_registry_for;
    use tempfile::tempdir;

    #[test]
    fn test_batch_source_budget_remains_bounded_and_discloses_zero_share() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("source.rs");
        let source = "fn example() { println!(\"hello\"); }";
        std::fs::write(&path, source).unwrap();
        let mut node = crate::graph::pdg::Node::new_file_summary(path.to_str().unwrap(), "rust");
        node.byte_range = (0, source.len());
        for budget in [0, 1, 20] {
            let mut result = serde_json::json!({});
            attach_source(&mut result, &node, dir.path(), budget);
            if budget < 2 {
                assert!(result.get("source").is_none());
                assert!(
                    result["source_omitted"]
                        .as_str()
                        .unwrap()
                        .contains("raise token_budget")
                );
            } else {
                assert_eq!(
                    result["source"].as_str().unwrap().chars().count(),
                    budget / 2
                );
            }
        }
    }

    #[test]
    fn test_symbol_lookup_schema_supports_batch() {
        let handler = SymbolLookupHandler;
        let schema = handler.argument_schema();
        let props = schema.get("properties").unwrap();
        assert!(props.get("symbol").is_some());
        assert!(props.get("symbols").is_some());
    }

    #[test]
    fn test_impact_note_absent_when_relations_present() {
        assert!(impact_degradation_note(false, true, false, false).is_none());
    }

    #[test]
    fn test_impact_note_absent_when_index_fresh_and_graph_sound() {
        // Zero relations with a fresh index and a live call graph are a fact,
        // not degradation.
        assert!(impact_degradation_note(true, false, true, false).is_none());
    }

    #[test]
    fn test_impact_note_flags_degraded_graph_before_staleness() {
        let note = impact_degradation_note(true, true, false, false).unwrap();
        assert!(note.contains("no call edges"), "note: {note}");
    }

    #[test]
    fn test_impact_note_flags_stale_index() {
        let note = impact_degradation_note(true, true, true, false).unwrap();
        assert!(note.contains("stale"), "note: {note}");
    }

    #[test]
    fn test_impact_note_flags_budget_truncation_first() {
        let note = impact_degradation_note(true, true, false, true).unwrap();
        assert!(note.contains("latency budget"), "note: {note}");
    }

    #[tokio::test]
    async fn test_symbol_lookup_requires_indexed_project() {
        let dir = tempdir().unwrap();
        let registry = test_registry_for(dir.path());
        let args = serde_json::json!({ "symbol": "my_func" });
        let result = SymbolLookupHandler.execute(&registry, args).await;
        assert!(result.is_err());
    }

    #[tokio::test]
    async fn test_blank_single_symbol_rejected() {
        let dir = tempdir().unwrap();
        let registry = test_registry_for(dir.path());
        let args = serde_json::json!({ "symbol": "" });
        let result = SymbolLookupHandler.execute(&registry, args).await;
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(
            err.message.contains("non-empty"),
            "Expected 'non-empty' in error message, got: {}",
            err.message
        );
    }

    #[tokio::test]
    async fn test_whitespace_only_single_symbol_rejected() {
        let dir = tempdir().unwrap();
        let registry = test_registry_for(dir.path());
        let args = serde_json::json!({ "symbol": "   " });
        let result = SymbolLookupHandler.execute(&registry, args).await;
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(
            err.message.contains("non-empty"),
            "Expected 'non-empty' in error message, got: {}",
            err.message
        );
    }

    #[tokio::test]
    async fn test_all_blank_batch_symbols_rejected() {
        let dir = tempdir().unwrap();
        let registry = test_registry_for(dir.path());
        let args = serde_json::json!({ "symbols": ["", ""] });
        let result = SymbolLookupHandler.execute(&registry, args).await;
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(
            err.message.contains("non-blank"),
            "Expected 'non-blank' in error message, got: {}",
            err.message
        );
    }

    #[tokio::test]
    async fn test_batch_with_mixed_blank_and_valid_symbols() {
        // Blank strings should be filtered out; valid symbols should proceed
        let dir = tempdir().unwrap();
        let registry = test_registry_for(dir.path());
        let args = serde_json::json!({ "symbols": ["", "my_func", "  "] });
        let result = SymbolLookupHandler.execute(&registry, args).await;
        // Should not return invalid_params — the blank strings are filtered out,
        // leaving ["my_func"] which is a valid single-symbol lookup (just not indexed)
        assert!(result.is_err());
        // The error should NOT be about blank symbols — it should be about indexing
        let err = result.unwrap_err();
        assert!(
            !err.message.contains("non-blank"),
            "Should not reject for blank symbols when valid ones exist, got: {}",
            err.message
        );
    }
}