lean-ctx 3.6.6

Context Runtime for AI Agents with CCP. 51 MCP tools, 10 read modes, 60+ compression patterns, cross-session memory (CCP), persistent AI knowledge with temporal facts + contradiction detection, multi-agent context sharing, 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::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

use serde::Serialize;

use crate::core::bm25_index::BM25Index;
use crate::core::graph_index;

#[derive(Debug, Clone, Copy)]
pub struct ExportOptions {
    pub include_deps_graph: bool,
    pub max_nodes: usize,
    pub max_edges: usize,
}

#[derive(Debug, Serialize)]
pub struct ContextArtifacts {
    pub generated_at_ms: u64,
    pub project_root: String,
    pub git: GitInfo,
    pub index: IndexSummary,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub deps_graph: Option<DepsGraph>,
}

#[derive(Debug, Serialize)]
pub struct GitInfo {
    pub head: Option<String>,
    pub branch: Option<String>,
    pub dirty: bool,
}

#[derive(Debug, Serialize)]
pub struct IndexSummary {
    pub graph_index: GraphIndexSummary,
    pub bm25_index: Bm25IndexSummary,
    pub property_graph: PropertyGraphSummary,
}

#[derive(Debug, Serialize)]
pub struct GraphIndexSummary {
    pub files: usize,
    pub symbols: usize,
    pub edges: usize,
    pub last_scan: String,
    pub index_dir: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct Bm25IndexSummary {
    pub files: usize,
    pub chunks: usize,
    pub index_file: String,
}

#[derive(Debug, Serialize)]
pub struct PropertyGraphSummary {
    pub exists: bool,
    pub db_path: String,
    pub nodes: Option<usize>,
    pub edges: Option<usize>,
}

#[derive(Debug, Serialize)]
pub struct DepsGraph {
    pub nodes: Vec<String>,
    pub edges: Vec<DepsEdge>,
    pub truncated: bool,
}

#[derive(Debug, Serialize)]
pub struct DepsEdge {
    pub from: String,
    pub to: String,
    pub kind: String,
}

pub fn export_json(project_root: &Path, opts: &ExportOptions) -> Result<String, String> {
    let artifacts = build(project_root, opts)?;
    serde_json::to_string_pretty(&artifacts).map_err(|e| e.to_string())
}

pub fn build(project_root: &Path, opts: &ExportOptions) -> Result<ContextArtifacts, String> {
    let root_s = project_root.to_string_lossy().to_string();

    let git = git_info(project_root);

    let graph = graph_index::load_or_build(&root_s);
    let graph_summary = GraphIndexSummary {
        files: graph.file_count(),
        symbols: graph.symbol_count(),
        edges: graph.edge_count(),
        last_scan: graph.last_scan.clone(),
        index_dir: graph_index::ProjectIndex::index_dir(&root_s)
            .map(|p| p.to_string_lossy().to_string()),
    };

    let bm25 = BM25Index::load_or_build(project_root);
    let bm25_summary = Bm25IndexSummary {
        files: bm25.files.len(),
        chunks: bm25.doc_count,
        index_file: BM25Index::index_file_path(project_root)
            .to_string_lossy()
            .to_string(),
    };

    let pg = property_graph_summary(project_root);

    let deps_graph = if opts.include_deps_graph {
        Some(build_deps_graph(&graph, opts.max_nodes, opts.max_edges))
    } else {
        None
    };

    Ok(ContextArtifacts {
        generated_at_ms: now_ms(),
        project_root: root_s,
        git,
        index: IndexSummary {
            graph_index: graph_summary,
            bm25_index: bm25_summary,
            property_graph: pg,
        },
        deps_graph,
    })
}

fn build_deps_graph(
    idx: &graph_index::ProjectIndex,
    max_nodes: usize,
    max_edges: usize,
) -> DepsGraph {
    let max_nodes = max_nodes.max(1);
    let max_edges = max_edges.max(1);

    let mut nodes: Vec<String> = idx.files.keys().cloned().collect();
    nodes.sort();

    let truncated_nodes = nodes.len() > max_nodes;
    if truncated_nodes {
        nodes.truncate(max_nodes);
    }
    let node_set: std::collections::HashSet<&str> = nodes.iter().map(String::as_str).collect();

    let mut edges: Vec<DepsEdge> = Vec::new();
    for e in &idx.edges {
        if edges.len() >= max_edges {
            break;
        }
        if !node_set.contains(e.from.as_str()) || !node_set.contains(e.to.as_str()) {
            continue;
        }
        edges.push(DepsEdge {
            from: e.from.clone(),
            to: e.to.clone(),
            kind: e.kind.clone(),
        });
    }

    let truncated_edges = idx.edges.len() > edges.len() && edges.len() >= max_edges;
    DepsGraph {
        nodes,
        edges,
        truncated: truncated_nodes || truncated_edges,
    }
}

fn property_graph_summary(project_root: &Path) -> PropertyGraphSummary {
    let root_str = project_root.to_string_lossy();
    let db_path = crate::core::property_graph::graph_dir(&root_str).join("graph.db");
    let db_path_s = db_path.to_string_lossy().to_string();
    if !db_path.exists() {
        return PropertyGraphSummary {
            exists: false,
            db_path: db_path_s,
            nodes: None,
            edges: None,
        };
    }

    match crate::core::property_graph::CodeGraph::open(&root_str) {
        Ok(g) => PropertyGraphSummary {
            exists: true,
            db_path: g.db_path().to_string_lossy().to_string(),
            nodes: g.node_count().ok(),
            edges: g.edge_count().ok(),
        },
        Err(_) => PropertyGraphSummary {
            exists: true,
            db_path: db_path_s,
            nodes: None,
            edges: None,
        },
    }
}

fn git_info(project_root: &Path) -> GitInfo {
    let head = git_out(project_root, &["rev-parse", "--short", "HEAD"]);
    let branch = git_out(project_root, &["rev-parse", "--abbrev-ref", "HEAD"]);
    let dirty = git_dirty(project_root);
    GitInfo {
        head,
        branch,
        dirty,
    }
}

fn git_dirty(project_root: &Path) -> bool {
    let out = std::process::Command::new("git")
        .args(["status", "--porcelain"])
        .current_dir(project_root)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::null())
        .output();
    match out {
        Ok(o) if o.status.success() => !o.stdout.is_empty(),
        _ => false,
    }
}

fn git_out(project_root: &Path, args: &[&str]) -> Option<String> {
    let out = std::process::Command::new("git")
        .args(args)
        .current_dir(project_root)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::null())
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    let s = String::from_utf8(out.stdout).ok()?;
    let s = s.trim().to_string();
    if s.is_empty() {
        None
    } else {
        Some(s)
    }
}

fn now_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis() as u64
}