Skip to main content

lean_ctx/core/
context_artifacts.rs

1use std::path::Path;
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use serde::Serialize;
5
6use crate::core::bm25_index::BM25Index;
7use crate::core::graph_index;
8
9#[derive(Debug, Clone, Copy)]
10pub struct ExportOptions {
11    pub include_deps_graph: bool,
12    pub max_nodes: usize,
13    pub max_edges: usize,
14}
15
16#[derive(Debug, Serialize)]
17pub struct ContextArtifacts {
18    pub generated_at_ms: u64,
19    pub project_root: String,
20    pub git: GitInfo,
21    pub index: IndexSummary,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub deps_graph: Option<DepsGraph>,
24}
25
26#[derive(Debug, Serialize)]
27pub struct GitInfo {
28    pub head: Option<String>,
29    pub branch: Option<String>,
30    pub dirty: bool,
31}
32
33#[derive(Debug, Serialize)]
34pub struct IndexSummary {
35    pub graph_index: GraphIndexSummary,
36    pub bm25_index: Bm25IndexSummary,
37    pub property_graph: PropertyGraphSummary,
38}
39
40#[derive(Debug, Serialize)]
41pub struct GraphIndexSummary {
42    pub files: usize,
43    pub symbols: usize,
44    pub edges: usize,
45    pub last_scan: String,
46    pub index_dir: Option<String>,
47}
48
49#[derive(Debug, Serialize)]
50pub struct Bm25IndexSummary {
51    pub files: usize,
52    pub chunks: usize,
53    pub index_file: String,
54}
55
56#[derive(Debug, Serialize)]
57pub struct PropertyGraphSummary {
58    pub exists: bool,
59    pub db_path: String,
60    pub nodes: Option<usize>,
61    pub edges: Option<usize>,
62}
63
64#[derive(Debug, Serialize)]
65pub struct DepsGraph {
66    pub nodes: Vec<String>,
67    pub edges: Vec<DepsEdge>,
68    pub truncated: bool,
69}
70
71#[derive(Debug, Serialize)]
72pub struct DepsEdge {
73    pub from: String,
74    pub to: String,
75    pub kind: String,
76}
77
78pub fn export_json(project_root: &Path, opts: &ExportOptions) -> Result<String, String> {
79    let artifacts = build(project_root, opts)?;
80    serde_json::to_string_pretty(&artifacts).map_err(|e| e.to_string())
81}
82
83pub fn build(project_root: &Path, opts: &ExportOptions) -> Result<ContextArtifacts, String> {
84    let root_s = project_root.to_string_lossy().to_string();
85
86    let git = git_info(project_root);
87
88    let graph = graph_index::load_or_build(&root_s);
89    let graph_summary = GraphIndexSummary {
90        files: graph.file_count(),
91        symbols: graph.symbol_count(),
92        edges: graph.edge_count(),
93        last_scan: graph.last_scan.clone(),
94        index_dir: graph_index::ProjectIndex::index_dir(&root_s)
95            .map(|p| p.to_string_lossy().to_string()),
96    };
97
98    let bm25 = BM25Index::load_or_build(project_root);
99    let bm25_summary = Bm25IndexSummary {
100        files: bm25.files.len(),
101        chunks: bm25.doc_count,
102        index_file: BM25Index::index_file_path(project_root)
103            .to_string_lossy()
104            .to_string(),
105    };
106
107    let pg = property_graph_summary(project_root);
108
109    let deps_graph = if opts.include_deps_graph {
110        Some(build_deps_graph(&graph, opts.max_nodes, opts.max_edges))
111    } else {
112        None
113    };
114
115    Ok(ContextArtifacts {
116        generated_at_ms: now_ms(),
117        project_root: root_s,
118        git,
119        index: IndexSummary {
120            graph_index: graph_summary,
121            bm25_index: bm25_summary,
122            property_graph: pg,
123        },
124        deps_graph,
125    })
126}
127
128fn build_deps_graph(
129    idx: &graph_index::ProjectIndex,
130    max_nodes: usize,
131    max_edges: usize,
132) -> DepsGraph {
133    let max_nodes = max_nodes.max(1);
134    let max_edges = max_edges.max(1);
135
136    let mut nodes: Vec<String> = idx.files.keys().cloned().collect();
137    nodes.sort();
138
139    let truncated_nodes = nodes.len() > max_nodes;
140    if truncated_nodes {
141        nodes.truncate(max_nodes);
142    }
143    let node_set: std::collections::HashSet<&str> = nodes.iter().map(String::as_str).collect();
144
145    let mut edges: Vec<DepsEdge> = Vec::new();
146    for e in &idx.edges {
147        if edges.len() >= max_edges {
148            break;
149        }
150        if !node_set.contains(e.from.as_str()) || !node_set.contains(e.to.as_str()) {
151            continue;
152        }
153        edges.push(DepsEdge {
154            from: e.from.clone(),
155            to: e.to.clone(),
156            kind: e.kind.clone(),
157        });
158    }
159
160    let truncated_edges = idx.edges.len() > edges.len() && edges.len() >= max_edges;
161    DepsGraph {
162        nodes,
163        edges,
164        truncated: truncated_nodes || truncated_edges,
165    }
166}
167
168fn property_graph_summary(project_root: &Path) -> PropertyGraphSummary {
169    let root_str = project_root.to_string_lossy();
170    let db_path = crate::core::property_graph::graph_dir(&root_str).join("graph.db");
171    let db_path_s = db_path.to_string_lossy().to_string();
172    if !db_path.exists() {
173        return PropertyGraphSummary {
174            exists: false,
175            db_path: db_path_s,
176            nodes: None,
177            edges: None,
178        };
179    }
180
181    match crate::core::property_graph::CodeGraph::open(&root_str) {
182        Ok(g) => PropertyGraphSummary {
183            exists: true,
184            db_path: g.db_path().to_string_lossy().to_string(),
185            nodes: g.node_count().ok(),
186            edges: g.edge_count().ok(),
187        },
188        Err(_) => PropertyGraphSummary {
189            exists: true,
190            db_path: db_path_s,
191            nodes: None,
192            edges: None,
193        },
194    }
195}
196
197fn git_info(project_root: &Path) -> GitInfo {
198    let head = git_out(project_root, &["rev-parse", "--short", "HEAD"]);
199    let branch = git_out(project_root, &["rev-parse", "--abbrev-ref", "HEAD"]);
200    let dirty = git_dirty(project_root);
201    GitInfo {
202        head,
203        branch,
204        dirty,
205    }
206}
207
208fn git_dirty(project_root: &Path) -> bool {
209    let out = std::process::Command::new("git")
210        .args(["status", "--porcelain"])
211        .current_dir(project_root)
212        .stdout(std::process::Stdio::piped())
213        .stderr(std::process::Stdio::null())
214        .output();
215    match out {
216        Ok(o) if o.status.success() => !o.stdout.is_empty(),
217        _ => false,
218    }
219}
220
221fn git_out(project_root: &Path, args: &[&str]) -> Option<String> {
222    let out = std::process::Command::new("git")
223        .args(args)
224        .current_dir(project_root)
225        .stdout(std::process::Stdio::piped())
226        .stderr(std::process::Stdio::null())
227        .output()
228        .ok()?;
229    if !out.status.success() {
230        return None;
231    }
232    let s = String::from_utf8(out.stdout).ok()?;
233    let s = s.trim().to_string();
234    if s.is_empty() {
235        None
236    } else {
237        Some(s)
238    }
239}
240
241fn now_ms() -> u64 {
242    SystemTime::now()
243        .duration_since(UNIX_EPOCH)
244        .unwrap_or_default()
245        .as_millis() as u64
246}