Skip to main content

lean_ctx/tools/
ctx_overview.rs

1use crate::core::cache::SessionCache;
2use crate::core::graph_provider::{self, GraphProvider};
3use crate::core::task_relevance::{compute_relevance, parse_task_hints};
4use crate::core::tokens::count_tokens;
5use crate::tools::CrpMode;
6
7/// Multi-resolution context overview.
8///
9/// Provides a compact map of the entire project, organized by task relevance.
10/// Files are shown at different detail levels based on their relevance score:
11/// - Level 0 (full): directly task-relevant files → full content (use ctx_read)
12/// - Level 1 (signatures): graph neighbors → key signatures
13/// - Level 2 (reference): distant files → name + line count only
14///
15/// This implements lazy evaluation for context: start with the overview,
16/// then zoom into specific files as needed.
17/// Returns the rendered overview plus whether it is the complete graph-backed
18/// view (#1134). `false` means the partial, still-indexing fallback — callers
19/// must take it from here rather than pattern-matching the body text.
20pub fn handle(
21    _cache: &SessionCache,
22    task: Option<&str>,
23    path: Option<&str>,
24    _crp_mode: CrpMode,
25) -> (String, bool) {
26    let project_root = path.map_or_else(|| ".".to_string(), std::string::ToString::to_string);
27
28    let auto_loaded = crate::core::context_package::auto_load_packages(&project_root);
29
30    let Some(open) = graph_provider::open_or_build(&project_root) else {
31        crate::core::index_orchestrator::ensure_all_background(&project_root);
32        return (partial_overview(&project_root), false);
33    };
34    let gp = &open.provider;
35
36    let (task_files, task_keywords) = if let Some(task_desc) = task {
37        parse_task_hints(task_desc)
38    } else {
39        (vec![], vec![])
40    };
41
42    let has_task = !task_files.is_empty() || !task_keywords.is_empty();
43
44    let mut output = Vec::new();
45
46    if has_task {
47        let mut relevance = compute_relevance(gp, &task_files, &task_keywords);
48        crate::core::git_signals::apply_boost(&mut relevance, &project_root);
49        crate::core::diagnostics_store::apply_boost(&mut relevance);
50        crate::core::editor_signal::apply_boost(&mut relevance);
51
52        output.push(format!(
53            "PROJECT OVERVIEW  {} files  task-filtered",
54            gp.file_count()
55        ));
56        output.push(String::new());
57
58        let high: Vec<&_> = relevance.iter().filter(|r| r.score >= 0.8).collect();
59        let medium: Vec<&_> = relevance
60            .iter()
61            .filter(|r| r.score >= 0.3 && r.score < 0.8)
62            .collect();
63        let low: Vec<&_> = relevance.iter().filter(|r| r.score < 0.3).collect();
64
65        if !high.is_empty() {
66            use crate::core::context_field::{ContextItemId, ContextKind, ViewCosts};
67            use crate::core::context_handles::HandleRegistry;
68
69            let mut handle_reg = HandleRegistry::new();
70            output.push("▸ DIRECTLY RELEVANT (use ctx_read or ctx_expand @ref):".to_string());
71            for r in &high {
72                let line_count = file_line_count(&r.path);
73                let item_id = ContextItemId::from_file(&r.path);
74                let view_costs = ViewCosts::from_full_tokens(line_count * 5);
75                let handle = handle_reg.register(
76                    item_id,
77                    ContextKind::File,
78                    &r.path,
79                    &format!(
80                        "{} {}L score={:.1}",
81                        short_path(&r.path),
82                        line_count,
83                        r.score
84                    ),
85                    &view_costs,
86                    r.score,
87                    false,
88                );
89                output.push(format!(
90                    "  @{} {} {}L  phi={:.2}  mode={}",
91                    handle.ref_label,
92                    short_path(&r.path),
93                    line_count,
94                    r.score,
95                    r.recommended_mode
96                ));
97            }
98            output.push(String::new());
99        }
100
101        if !medium.is_empty() {
102            let knowledge = crate::core::knowledge::ProjectKnowledge::load(&project_root);
103            output.push("▸ CONTEXT (use ctx_read signatures/map):".to_string());
104            for r in medium.iter().take(20) {
105                let line_count = file_line_count(&r.path);
106                let doc = extract_module_doc(&r.path)
107                    .or_else(|| knowledge_doc_for_file(knowledge.as_ref(), &r.path))
108                    .map(|d| format!(" — {d}"))
109                    .unwrap_or_default();
110                output.push(format!(
111                    "  {} {line_count}L  mode={}{doc}",
112                    short_path(&r.path),
113                    r.recommended_mode
114                ));
115            }
116            if medium.len() > 20 {
117                output.push(format!("  ... +{} more", medium.len() - 20));
118            }
119            output.push(String::new());
120        }
121
122        if !low.is_empty() {
123            output.push(format!(
124                "▸ DISTANT ({} files, not loaded unless needed)",
125                low.len()
126            ));
127            for r in low.iter().take(10) {
128                output.push(format!("  {}", short_path(&r.path)));
129            }
130            if low.len() > 10 {
131                output.push(format!("  ... +{} more", low.len() - 10));
132            }
133        }
134
135        // Dynamic task-specific briefing last (prefix-cache-friendly)
136        if let Some(task_desc) = task {
137            let file_context: Vec<(String, usize)> = relevance
138                .iter()
139                .filter(|r| r.score >= 0.3)
140                .take(8)
141                .filter_map(|r| {
142                    std::fs::read_to_string(&r.path)
143                        .ok()
144                        .map(|c| (r.path.clone(), c.lines().count()))
145                })
146                .collect();
147            let briefing = crate::core::task_briefing::build_briefing(task_desc, &file_context);
148            output.push(String::new());
149            output.push(crate::core::task_briefing::format_briefing(&briefing));
150        }
151    } else {
152        // No task context: show project structure overview
153        let last_scan = gp.last_scan();
154        let scan_age = chrono::NaiveDateTime::parse_from_str(&last_scan, "%Y-%m-%d %H:%M:%S")
155            .ok()
156            .map(|t| {
157                let elapsed = chrono::Local::now().naive_local().signed_duration_since(t);
158                if elapsed.num_hours() < 1 {
159                    format!("{}m ago", elapsed.num_minutes())
160                } else if elapsed.num_hours() < 24 {
161                    format!("{}h ago", elapsed.num_hours())
162                } else {
163                    format!("{}d ago", elapsed.num_days())
164                }
165            })
166            .unwrap_or_default();
167        let scan_info = if scan_age.is_empty() {
168            String::new()
169        } else {
170            format!("  scanned {scan_age}")
171        };
172        // #658 F6: the header counts file-level (import/type_ref) edges; the
173        // function-level call graph is a separate artifact. When one has been
174        // built (ctx_callgraph persists lazily), surface it too — otherwise a
175        // single-file project reads "0 edges" while callgraph clearly has some.
176        let call_edges =
177            crate::core::call_graph::CallGraph::load(&project_root).map_or(0, |g| g.edges.len());
178        let call_info = if call_edges > 0 {
179            format!("  {call_edges} call edges")
180        } else {
181            String::new()
182        };
183        output.push(format!(
184            "PROJECT OVERVIEW  {} files  {} edges{call_info}{scan_info}",
185            gp.file_count(),
186            gp.edge_count().unwrap_or(0)
187        ));
188        output.push(String::new());
189
190        let mut by_dir: std::collections::BTreeMap<String, Vec<String>> =
191            std::collections::BTreeMap::new();
192
193        for path in gp.file_paths() {
194            let dir = std::path::Path::new(&path)
195                .parent()
196                .map_or_else(|| ".".to_string(), |p| p.to_string_lossy().to_string());
197            by_dir.entry(dir).or_default().push(short_path(&path));
198        }
199
200        for (dir, files) in &by_dir {
201            let dir_display = if dir.len() > 50 {
202                let start = truncate_start_char_boundary(dir, 47);
203                format!("...{}", &dir[start..])
204            } else {
205                dir.clone()
206            };
207
208            if files.len() <= 5 {
209                output.push(format!("{dir_display}/  {}", files.join(" ")));
210            } else {
211                output.push(format!(
212                    "{dir_display}/  {} +{} more",
213                    files[..3].join(" "),
214                    files.len() - 3
215                ));
216            }
217        }
218    }
219
220    if let Some(task_desc) = task {
221        append_knowledge_task_section(&mut output, &project_root, task_desc);
222    }
223    append_graph_hotspots_section(&mut output, &project_root, gp);
224
225    let cfg = crate::core::config::Config::load();
226    if cfg.enable_wakeup_ctx {
227        let wakeup = build_wakeup_briefing(&project_root, task);
228        if !wakeup.is_empty() {
229            output.push(String::new());
230            output.push(wakeup);
231        }
232    }
233
234    if !auto_loaded.is_empty() {
235        output.push(String::new());
236        output.push(format!(
237            "CONTEXT PACKAGES AUTO-LOADED: {}",
238            auto_loaded.join(", ")
239        ));
240    }
241
242    let fc = gp.file_count();
243    let original = count_tokens(&format!("{fc} files")) * fc;
244    let compressed = count_tokens(&output.join("\n"));
245    output.push(String::new());
246    output.push(crate::core::protocol::format_savings(original, compressed));
247
248    (output.join("\n"), true)
249}
250
251fn append_knowledge_task_section(output: &mut Vec<String>, project_root: &str, task: &str) {
252    let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(project_root) else {
253        return;
254    };
255    let hits: Vec<_> = knowledge.recall(task).into_iter().take(5).collect();
256    if hits.is_empty() {
257        return;
258    }
259    let n = hits.len();
260    output.push(String::new());
261    output.push(format!("[knowledge: {n} relevant facts]"));
262    for f in hits {
263        let text = compact_fact_phrase(f);
264        output.push(format!("  \"{text}\" (confidence: {:.1})", f.confidence));
265    }
266}
267
268fn compact_fact_phrase(f: &crate::core::knowledge::KnowledgeFact) -> String {
269    let v = f.value.trim();
270    let k = f.key.trim();
271    let raw = if !v.is_empty() && (k.is_empty() || v.contains(' ') || v.len() >= k.len()) {
272        v.to_string()
273    } else if !k.is_empty() && !v.is_empty() {
274        format!("{k}: {v}")
275    } else {
276        k.to_string()
277    };
278    let neutral = crate::core::sanitize::neutralize_metadata(&raw);
279    const MAX: usize = 100;
280    if neutral.chars().count() > MAX {
281        let trimmed: String = neutral.chars().take(MAX.saturating_sub(1)).collect();
282        format!("{trimmed}…")
283    } else {
284        neutral
285    }
286}
287
288fn append_graph_hotspots_section(output: &mut Vec<String>, project_root: &str, gp: &GraphProvider) {
289    let rows = graph_hotspot_rows(project_root, gp);
290    if rows.is_empty() {
291        return;
292    }
293    let n = rows.len();
294    output.push(String::new());
295    output.push(format!("[graph: {n} architectural hotspots]"));
296    for (path, imp, cal) in rows {
297        let p = short_path(&path);
298        if cal > 0 {
299            output.push(format!("  {p} ({imp} imports, {cal} calls)"));
300        } else {
301            output.push(format!("  {p} ({imp} imports)"));
302        }
303    }
304}
305
306fn graph_hotspot_rows(project_root: &str, gp: &GraphProvider) -> Vec<(String, usize, usize)> {
307    if let Ok(graph) = crate::core::property_graph::CodeGraph::open(project_root) {
308        let sql = "
309            WITH edge_files AS (
310              SELECT e.kind AS kind, ps.path AS fp
311              FROM edges e
312              JOIN nodes ns ON e.source_id = ns.id
313              JOIN paths ps ON ps.id = ns.file_id
314              WHERE e.kind IN ('imports', 'calls')
315              UNION ALL
316              SELECT e.kind, pt.path
317              FROM edges e
318              JOIN nodes nt ON e.target_id = nt.id
319              JOIN paths pt ON pt.id = nt.file_id
320              WHERE e.kind IN ('imports', 'calls')
321            )
322            SELECT fp,
323                   SUM(CASE WHEN kind = 'imports' THEN 1 ELSE 0 END) AS imp,
324                   SUM(CASE WHEN kind = 'calls' THEN 1 ELSE 0 END) AS cal
325            FROM edge_files
326            GROUP BY fp
327            ORDER BY (imp + cal) DESC
328            LIMIT 5
329        ";
330        let conn = graph.connection();
331        if let Ok(mut stmt) = conn.prepare(sql) {
332            let mapped = stmt.query_map([], |row| {
333                Ok((
334                    row.get::<_, String>(0)?,
335                    row.get::<_, i64>(1)? as usize,
336                    row.get::<_, i64>(2)? as usize,
337                ))
338            });
339            if let Ok(iter) = mapped {
340                let collected: Vec<_> = iter.filter_map(std::result::Result::ok).collect();
341                if !collected.is_empty() {
342                    return collected;
343                }
344            }
345        }
346    }
347    import_hotspots_from_edges(gp, 5)
348}
349
350fn import_hotspots_from_edges(gp: &GraphProvider, limit: usize) -> Vec<(String, usize, usize)> {
351    use std::collections::HashMap;
352
353    let mut imp: HashMap<String, usize> = HashMap::new();
354    for e in gp.edges_by_kind("import") {
355        *imp.entry(e.from.clone()).or_insert(0) += 1;
356        *imp.entry(e.to.clone()).or_insert(0) += 1;
357    }
358    let mut v: Vec<(String, usize, usize)> =
359        imp.into_iter().map(|(p, c)| (p, c, 0_usize)).collect();
360    v.sort_by_key(|x| std::cmp::Reverse(x.1 + x.2));
361    v.truncate(limit);
362    v
363}
364
365pub fn build_wakeup_briefing(project_root: &str, task: Option<&str>) -> String {
366    let mut parts = Vec::new();
367
368    if let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(project_root) {
369        let facts_line = knowledge.format_wakeup();
370        if !facts_line.is_empty() {
371            parts.push(facts_line);
372        }
373    }
374
375    if let Some(session) = crate::core::session::SessionState::load_latest() {
376        if let Some(ref task) = session.task {
377            parts.push(format!("LAST_TASK:{}", task.description));
378        }
379        if !session.decisions.is_empty() {
380            let recent: Vec<String> = session
381                .decisions
382                .iter()
383                .rev()
384                .take(3)
385                .map(|d| d.summary.clone())
386                .collect();
387            parts.push(format!("RECENT_DECISIONS:{}", recent.join("|")));
388        }
389    }
390
391    if let Some(t) = task {
392        for r in crate::core::prospective_memory::reminders_for_task(project_root, t) {
393            parts.push(r);
394        }
395    }
396
397    // Prune dead/stale agents before listing so the briefing never shows ghosts
398    // from crashed or exited MCP processes (#419). `ctx_agent list` and the
399    // dashboard already do this; the wake-up briefing must too. Scope to the
400    // current project root — the briefing is about peers on *this* project.
401    let registry = crate::core::agents::AgentRegistry::mutate_locked(|registry| {
402        registry.cleanup_stale(24);
403    })
404    .map(|(registry, ())| registry)
405    .unwrap_or_default();
406    let active_agents = registry.list_active(Some(project_root));
407    if !active_agents.is_empty() {
408        let agents: Vec<String> = active_agents
409            .iter()
410            .map(|a| format!("{}({})", a.agent_id, a.role.as_deref().unwrap_or("-")))
411            .collect();
412        parts.push(format!("AGENTS:{}", agents.join(",")));
413    }
414
415    if parts.is_empty() {
416        return String::new();
417    }
418
419    format!("WAKE-UP BRIEFING:\n{}", parts.join("\n"))
420}
421
422/// Extracts a 1-line module documentation from a file's first lines.
423/// Looks for Rust `//!`, Python `"""..."""` / `#`, JS/TS `/** ... */`, or generic `# description`.
424fn extract_module_doc(path: &str) -> Option<String> {
425    let content = std::fs::read_to_string(path).ok()?;
426    let mut lines = content.lines();
427
428    // Skip shebang
429    let first = lines.next()?.trim();
430    let search_start = if first.starts_with("#!") {
431        lines.next()
432    } else {
433        Some(first)
434    };
435
436    let first_meaningful = search_start?;
437
438    // Rust: //! module doc
439    if first_meaningful.starts_with("//!") {
440        let doc = first_meaningful.trim_start_matches("//!").trim();
441        if !doc.is_empty() {
442            return Some(truncate_doc(doc));
443        }
444    }
445
446    // Python: """ or '''
447    if first_meaningful.starts_with("\"\"\"") || first_meaningful.starts_with("'''") {
448        let doc = first_meaningful
449            .trim_start_matches("\"\"\"")
450            .trim_start_matches("'''")
451            .trim();
452        let doc = doc
453            .trim_end_matches("\"\"\"")
454            .trim_end_matches("'''")
455            .trim();
456        if !doc.is_empty() {
457            return Some(truncate_doc(doc));
458        }
459    }
460
461    // JS/TS: /** ... */
462    if first_meaningful.starts_with("/**") {
463        let doc = first_meaningful
464            .trim_start_matches("/**")
465            .trim_end_matches("*/")
466            .trim_start_matches('*')
467            .trim();
468        if !doc.is_empty() {
469            return Some(truncate_doc(doc));
470        }
471    }
472
473    // Generic: first # comment (markdown, python, shell)
474    if first_meaningful.starts_with("# ") && !first_meaningful.starts_with("# !") {
475        let doc = first_meaningful.trim_start_matches('#').trim();
476        if !doc.is_empty() {
477            return Some(truncate_doc(doc));
478        }
479    }
480
481    None
482}
483
484/// Falls back to Knowledge-Facts for a file description if no source-level doc found.
485fn knowledge_doc_for_file(
486    knowledge: Option<&crate::core::knowledge::ProjectKnowledge>,
487    path: &str,
488) -> Option<String> {
489    let knowledge = knowledge?;
490    let filename = std::path::Path::new(path).file_name()?.to_str()?;
491    let hits = knowledge.recall(filename);
492    let fact = hits.first()?;
493    let val = fact.value.trim();
494    if val.is_empty() || val.len() < 5 {
495        return None;
496    }
497    Some(truncate_doc(val))
498}
499
500fn truncate_doc(doc: &str) -> String {
501    if doc.len() > 80 {
502        let mut end = 77;
503        while end > 0 && !doc.is_char_boundary(end) {
504            end -= 1;
505        }
506        format!("{}...", &doc[..end])
507    } else {
508        doc.to_string()
509    }
510}
511
512fn short_path(path: &str) -> String {
513    let parts: Vec<&str> = path.split('/').collect();
514    if parts.len() <= 2 {
515        return path.to_string();
516    }
517    parts[parts.len() - 2..].join("/")
518}
519
520/// Find a byte offset at most `max_tail_bytes` from the end of `s`
521/// that falls on a valid UTF-8 char boundary.
522fn truncate_start_char_boundary(s: &str, max_tail_bytes: usize) -> usize {
523    if max_tail_bytes >= s.len() {
524        return 0;
525    }
526    let mut start = s.len() - max_tail_bytes;
527    while start < s.len() && !s.is_char_boundary(start) {
528        start += 1;
529    }
530    start
531}
532
533fn file_line_count(path: &str) -> usize {
534    std::fs::read_to_string(path).map_or(0, |c| c.lines().count())
535}
536
537/// Builds an immediately-useful overview while the knowledge graph is still
538/// being indexed in the background (#2365). Instead of only telling the user to
539/// "try again in 1-2 minutes", we return what is already available: a shallow
540/// directory tree, the detected project markers, and persistent project
541/// knowledge — plus a note that the richer graph-based view will follow.
542fn partial_overview(project_root: &str) -> String {
543    let mut out = Vec::new();
544    out.push("PROJECT OVERVIEW (partial — knowledge graph indexing in background)".to_string());
545    out.push(format!("Project: {project_root}"));
546
547    let markers = detected_markers(project_root);
548    if !markers.is_empty() {
549        out.push(format!("Markers: {}", markers.join(", ")));
550    }
551    out.push(String::new());
552
553    // Shallow tree (depth 2) of what's on disk right now.
554    let (tree, _) = crate::tools::ctx_tree::handle(project_root, 2, false, true);
555    if !tree.trim().is_empty() {
556        out.push("STRUCTURE (depth 2):".to_string());
557        out.push(tree);
558        out.push(String::new());
559    }
560
561    // Persistent knowledge is independent of the code graph and available now.
562    if let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(project_root) {
563        let mut facts: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
564        facts.sort_by_key(|f| std::cmp::Reverse(f.created_at));
565        if !facts.is_empty() {
566            out.push("KNOWN FACTS (from prior sessions):".to_string());
567            for f in facts.iter().take(5) {
568                let val: String = f.value.chars().take(80).collect();
569                out.push(format!("  • [{}] {}: {}", f.category, f.key, val));
570            }
571            out.push(String::new());
572        }
573    }
574
575    out.push(
576        "The full task-relevant graph view (signatures, neighbors, relevance) will be \
577         available shortly — re-run ctx_overview to get it."
578            .to_string(),
579    );
580    out.join("\n")
581}
582
583fn detected_markers(project_root: &str) -> Vec<String> {
584    const MARKERS: &[&str] = &[
585        ".git",
586        "Cargo.toml",
587        "package.json",
588        "go.mod",
589        "pyproject.toml",
590        "pom.xml",
591        "build.gradle",
592        ".lean-ctx.toml",
593    ];
594    let root = std::path::Path::new(project_root);
595    MARKERS
596        .iter()
597        .filter(|m| root.join(m).exists())
598        .map(|m| (*m).to_string())
599        .collect()
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605
606    #[test]
607    fn truncate_start_ascii() {
608        let s = "abcdefghij"; // 10 bytes
609        assert_eq!(truncate_start_char_boundary(s, 5), 5);
610        assert_eq!(&s[5..], "fghij");
611    }
612
613    #[test]
614    fn truncate_start_multibyte_chinese() {
615        // "文档/examples/extensions/custom-provider-anthropic" = multi-byte prefix
616        let s = "文档/examples/extensions/custom-provider-anthropic";
617        let start = truncate_start_char_boundary(s, 47);
618        assert!(s.is_char_boundary(start));
619        let tail = &s[start..];
620        assert!(tail.len() <= 47);
621    }
622
623    #[test]
624    fn truncate_start_all_multibyte() {
625        let s = "这是一个很长的中文目录路径用于测试字符边界处理";
626        let start = truncate_start_char_boundary(s, 20);
627        assert!(s.is_char_boundary(start));
628    }
629
630    #[test]
631    fn truncate_start_larger_than_string() {
632        let s = "short";
633        assert_eq!(truncate_start_char_boundary(s, 100), 0);
634    }
635
636    #[test]
637    fn truncate_start_emoji() {
638        let s = "/home/user/🎉🎉🎉/src/components/deeply/nested";
639        let start = truncate_start_char_boundary(s, 30);
640        assert!(s.is_char_boundary(start));
641    }
642
643    /// #658 F6: when a persisted call graph exists, the overview header must
644    /// surface its edge count — a single-file project has 0 file-level edges
645    /// but may well have call edges, and the two surfaces must not contradict.
646    #[test]
647    fn overview_header_surfaces_persisted_call_edges() {
648        use crate::core::call_graph::{CallEdge, CallGraph};
649        use crate::core::graph_index::{FileEntry, ProjectIndex};
650
651        let _lock = crate::core::data_dir::test_env_lock();
652        let tmp = tempfile::tempdir().expect("tempdir");
653        let data = tempfile::tempdir().expect("data dir");
654        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path().to_str().unwrap());
655
656        let root = tmp.path();
657        std::fs::write(root.join("Cargo.toml"), "[package]\nname = \"demo\"\n").unwrap();
658        std::fs::create_dir_all(root.join("src")).unwrap();
659        std::fs::write(
660            root.join("src/main.rs"),
661            "fn greet() {}\nfn main() { greet(); }\n",
662        )
663        .unwrap();
664        let root_str = root.to_str().unwrap();
665
666        // Pre-build a ProjectIndex so open_existing finds it immediately
667        // without needing the static BUILD_GATE (which parallel tests may hold).
668        let mut idx = ProjectIndex::new(root_str);
669        idx.files.insert(
670            "src/main.rs".to_string(),
671            FileEntry {
672                path: "src/main.rs".to_string(),
673                hash: "h".to_string(),
674                language: "rs".to_string(),
675                line_count: 2,
676                token_count: 10,
677                exports: vec![],
678                summary: String::new(),
679            },
680        );
681        idx.save().expect("save pre-built index");
682
683        let mut graph = CallGraph::new(root_str);
684        graph.edges.push(CallEdge {
685            caller_file: "src/main.rs".into(),
686            caller_symbol: "main".into(),
687            caller_line: 2,
688            callee_name: "greet".into(),
689        });
690        graph.save().expect("persist call graph");
691
692        let cache = SessionCache::new();
693        let (out, complete) = handle(&cache, None, Some(root_str), CrpMode::Off);
694        assert!(complete, "graph-backed overview must not report as partial");
695        assert!(
696            out.contains("1 call edges"),
697            "overview header must show persisted call edges, got:\n{out}"
698        );
699
700        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
701    }
702}