lean-ctx 3.1.5

Context Runtime for AI Agents with CCP. 42 MCP tools, 10 read modes, 90+ compression patterns, cross-session memory (CCP), persistent AI knowledge with temporal facts + contradiction detection, multi-agent context sharing + diaries, LITM-aware positioning, AAAK compact format, adaptive compression with Thompson Sampling bandits. Supports 24 AI tools. Reduces LLM token consumption by up to 99%.
Documentation
use std::collections::HashMap;
use std::path::Path;

use serde::{Deserialize, Serialize};

use super::deep_queries;
use super::graph_index::{ProjectIndex, SymbolEntry};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CallGraph {
    pub project_root: String,
    pub edges: Vec<CallEdge>,
    pub file_hashes: HashMap<String, String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CallEdge {
    pub caller_file: String,
    pub caller_symbol: String,
    pub caller_line: usize,
    pub callee_name: String,
}

impl CallGraph {
    pub fn new(project_root: &str) -> Self {
        Self {
            project_root: project_root.to_string(),
            edges: Vec::new(),
            file_hashes: HashMap::new(),
        }
    }

    pub fn build(index: &ProjectIndex) -> Self {
        let project_root = &index.project_root;
        let mut graph = Self::new(project_root);

        let symbols_by_file = group_symbols_by_file(index);

        for rel_path in index.files.keys() {
            let abs_path = resolve_path(rel_path, project_root);
            let content = match std::fs::read_to_string(&abs_path) {
                Ok(c) => c,
                Err(_) => continue,
            };

            let hash = simple_hash(&content);
            graph.file_hashes.insert(rel_path.clone(), hash);

            let ext = Path::new(rel_path)
                .extension()
                .and_then(|e| e.to_str())
                .unwrap_or("");

            let analysis = deep_queries::analyze(&content, ext);
            let file_symbols = symbols_by_file.get(rel_path.as_str());

            for call in &analysis.calls {
                let caller_sym = find_enclosing_symbol(file_symbols, call.line + 1);
                graph.edges.push(CallEdge {
                    caller_file: rel_path.clone(),
                    caller_symbol: caller_sym,
                    caller_line: call.line + 1,
                    callee_name: call.callee.clone(),
                });
            }
        }

        graph
    }

    pub fn build_incremental(index: &ProjectIndex, previous: &CallGraph) -> Self {
        let project_root = &index.project_root;
        let mut graph = Self::new(project_root);
        let symbols_by_file = group_symbols_by_file(index);

        for rel_path in index.files.keys() {
            let abs_path = resolve_path(rel_path, project_root);
            let content = match std::fs::read_to_string(&abs_path) {
                Ok(c) => c,
                Err(_) => continue,
            };

            let hash = simple_hash(&content);
            let changed = previous
                .file_hashes
                .get(rel_path)
                .map(|old| old != &hash)
                .unwrap_or(true);

            graph.file_hashes.insert(rel_path.clone(), hash);

            if !changed {
                let old_edges: Vec<_> = previous
                    .edges
                    .iter()
                    .filter(|e| e.caller_file == rel_path.as_str())
                    .cloned()
                    .collect();
                graph.edges.extend(old_edges);
                continue;
            }

            let ext = Path::new(rel_path)
                .extension()
                .and_then(|e| e.to_str())
                .unwrap_or("");

            let analysis = deep_queries::analyze(&content, ext);
            let file_symbols = symbols_by_file.get(rel_path.as_str());

            for call in &analysis.calls {
                let caller_sym = find_enclosing_symbol(file_symbols, call.line + 1);
                graph.edges.push(CallEdge {
                    caller_file: rel_path.clone(),
                    caller_symbol: caller_sym,
                    caller_line: call.line + 1,
                    callee_name: call.callee.clone(),
                });
            }
        }

        graph
    }

    pub fn callers_of(&self, symbol: &str) -> Vec<&CallEdge> {
        let sym_lower = symbol.to_lowercase();
        self.edges
            .iter()
            .filter(|e| e.callee_name.to_lowercase() == sym_lower)
            .collect()
    }

    pub fn callees_of(&self, symbol: &str) -> Vec<&CallEdge> {
        let sym_lower = symbol.to_lowercase();
        self.edges
            .iter()
            .filter(|e| e.caller_symbol.to_lowercase() == sym_lower)
            .collect()
    }

    pub fn save(&self) -> Result<(), String> {
        let dir = call_graph_dir(&self.project_root)
            .ok_or_else(|| "Cannot determine home directory".to_string())?;
        std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
        let json = serde_json::to_string(self).map_err(|e| e.to_string())?;
        std::fs::write(dir.join("call_graph.json"), json).map_err(|e| e.to_string())
    }

    pub fn load(project_root: &str) -> Option<Self> {
        let dir = call_graph_dir(project_root)?;
        let path = dir.join("call_graph.json");
        let content = std::fs::read_to_string(path).ok()?;
        serde_json::from_str(&content).ok()
    }

    pub fn load_or_build(project_root: &str, index: &ProjectIndex) -> Self {
        if let Some(previous) = Self::load(project_root) {
            Self::build_incremental(index, &previous)
        } else {
            Self::build(index)
        }
    }
}

fn call_graph_dir(project_root: &str) -> Option<std::path::PathBuf> {
    ProjectIndex::index_dir(project_root)
}

fn group_symbols_by_file(index: &ProjectIndex) -> HashMap<&str, Vec<&SymbolEntry>> {
    let mut map: HashMap<&str, Vec<&SymbolEntry>> = HashMap::new();
    for sym in index.symbols.values() {
        map.entry(sym.file.as_str()).or_default().push(sym);
    }
    for syms in map.values_mut() {
        syms.sort_by_key(|s| s.start_line);
    }
    map
}

fn find_enclosing_symbol(file_symbols: Option<&Vec<&SymbolEntry>>, line: usize) -> String {
    let syms = match file_symbols {
        Some(s) => s,
        None => return "<module>".to_string(),
    };

    let mut best: Option<&SymbolEntry> = None;
    for sym in syms {
        if line >= sym.start_line && line <= sym.end_line {
            match best {
                None => best = Some(sym),
                Some(prev) => {
                    let prev_span = prev.end_line - prev.start_line;
                    let cur_span = sym.end_line - sym.start_line;
                    if cur_span < prev_span {
                        best = Some(sym);
                    }
                }
            }
        }
    }

    best.map(|s| s.name.clone())
        .unwrap_or_else(|| "<module>".to_string())
}

fn resolve_path(relative: &str, project_root: &str) -> String {
    let p = Path::new(relative);
    if p.is_absolute() && p.exists() {
        return relative.to_string();
    }
    let joined = Path::new(project_root).join(relative);
    joined.to_string_lossy().to_string()
}

fn simple_hash(content: &str) -> String {
    use std::hash::{Hash, Hasher};
    let mut hasher = std::collections::hash_map::DefaultHasher::new();
    content.hash(&mut hasher);
    format!("{:x}", hasher.finish())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn callers_of_empty_graph() {
        let graph = CallGraph::new("/tmp");
        assert!(graph.callers_of("foo").is_empty());
    }

    #[test]
    fn callers_of_finds_edges() {
        let mut graph = CallGraph::new("/tmp");
        graph.edges.push(CallEdge {
            caller_file: "a.rs".to_string(),
            caller_symbol: "bar".to_string(),
            caller_line: 10,
            callee_name: "foo".to_string(),
        });
        graph.edges.push(CallEdge {
            caller_file: "b.rs".to_string(),
            caller_symbol: "baz".to_string(),
            caller_line: 20,
            callee_name: "foo".to_string(),
        });
        graph.edges.push(CallEdge {
            caller_file: "c.rs".to_string(),
            caller_symbol: "qux".to_string(),
            caller_line: 30,
            callee_name: "other".to_string(),
        });
        let callers = graph.callers_of("foo");
        assert_eq!(callers.len(), 2);
    }

    #[test]
    fn callees_of_finds_edges() {
        let mut graph = CallGraph::new("/tmp");
        graph.edges.push(CallEdge {
            caller_file: "a.rs".to_string(),
            caller_symbol: "main".to_string(),
            caller_line: 5,
            callee_name: "init".to_string(),
        });
        graph.edges.push(CallEdge {
            caller_file: "a.rs".to_string(),
            caller_symbol: "main".to_string(),
            caller_line: 6,
            callee_name: "run".to_string(),
        });
        graph.edges.push(CallEdge {
            caller_file: "a.rs".to_string(),
            caller_symbol: "other".to_string(),
            caller_line: 15,
            callee_name: "init".to_string(),
        });
        let callees = graph.callees_of("main");
        assert_eq!(callees.len(), 2);
    }

    #[test]
    fn find_enclosing_picks_narrowest() {
        let outer = SymbolEntry {
            file: "a.rs".to_string(),
            name: "Outer".to_string(),
            kind: "struct".to_string(),
            start_line: 1,
            end_line: 50,
            is_exported: true,
        };
        let inner = SymbolEntry {
            file: "a.rs".to_string(),
            name: "inner_fn".to_string(),
            kind: "fn".to_string(),
            start_line: 10,
            end_line: 20,
            is_exported: false,
        };
        let syms = vec![&outer, &inner];
        let result = find_enclosing_symbol(Some(&syms), 15);
        assert_eq!(result, "inner_fn");
    }

    #[test]
    fn find_enclosing_returns_module_when_no_match() {
        let sym = SymbolEntry {
            file: "a.rs".to_string(),
            name: "foo".to_string(),
            kind: "fn".to_string(),
            start_line: 10,
            end_line: 20,
            is_exported: false,
        };
        let syms = vec![&sym];
        let result = find_enclosing_symbol(Some(&syms), 5);
        assert_eq!(result, "<module>");
    }
}