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}