1use std::path::Path;
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use serde::Serialize;
5
6use crate::core::graph_index;
7use crate::core::vector_index::BM25Index;
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 db_path = project_root.join(".lean-ctx").join("graph.db");
170 let db_path_s = db_path.to_string_lossy().to_string();
171 if !db_path.exists() {
172 return PropertyGraphSummary {
173 exists: false,
174 db_path: db_path_s,
175 nodes: None,
176 edges: None,
177 };
178 }
179
180 match crate::core::property_graph::CodeGraph::open(project_root) {
181 Ok(g) => PropertyGraphSummary {
182 exists: true,
183 db_path: g.db_path().to_string_lossy().to_string(),
184 nodes: g.node_count().ok(),
185 edges: g.edge_count().ok(),
186 },
187 Err(_) => PropertyGraphSummary {
188 exists: true,
189 db_path: db_path_s,
190 nodes: None,
191 edges: None,
192 },
193 }
194}
195
196fn git_info(project_root: &Path) -> GitInfo {
197 let head = git_out(project_root, &["rev-parse", "--short", "HEAD"]);
198 let branch = git_out(project_root, &["rev-parse", "--abbrev-ref", "HEAD"]);
199 let dirty = git_dirty(project_root);
200 GitInfo {
201 head,
202 branch,
203 dirty,
204 }
205}
206
207fn git_dirty(project_root: &Path) -> bool {
208 let out = std::process::Command::new("git")
209 .args(["status", "--porcelain"])
210 .current_dir(project_root)
211 .stdout(std::process::Stdio::piped())
212 .stderr(std::process::Stdio::null())
213 .output();
214 match out {
215 Ok(o) if o.status.success() => !o.stdout.is_empty(),
216 _ => false,
217 }
218}
219
220fn git_out(project_root: &Path, args: &[&str]) -> Option<String> {
221 let out = std::process::Command::new("git")
222 .args(args)
223 .current_dir(project_root)
224 .stdout(std::process::Stdio::piped())
225 .stderr(std::process::Stdio::null())
226 .output()
227 .ok()?;
228 if !out.status.success() {
229 return None;
230 }
231 let s = String::from_utf8(out.stdout).ok()?;
232 let s = s.trim().to_string();
233 if s.is_empty() {
234 None
235 } else {
236 Some(s)
237 }
238}
239
240fn now_ms() -> u64 {
241 SystemTime::now()
242 .duration_since(UNIX_EPOCH)
243 .unwrap_or_default()
244 .as_millis() as u64
245}