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_provider::{self, GraphProvider};
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 open = graph_provider::open_or_build(&root_s);
89    let (files, symbols, edges, last_scan) = if let Some(ref o) = open {
90        let gp = &o.provider;
91        (
92            gp.file_count(),
93            gp.symbol_count(),
94            gp.edge_count().unwrap_or(0),
95            gp.last_scan(),
96        )
97    } else {
98        (0, 0, 0, String::new())
99    };
100    let graph_summary = GraphIndexSummary {
101        files,
102        symbols,
103        edges,
104        last_scan,
105        index_dir: GraphProvider::index_dir(&root_s).map(|p| p.to_string_lossy().to_string()),
106    };
107
108    let bm25 = BM25Index::load_or_build(project_root);
109    let bm25_summary = Bm25IndexSummary {
110        files: bm25.files.len(),
111        chunks: bm25.doc_count,
112        index_file: BM25Index::index_file_path(project_root)
113            .to_string_lossy()
114            .to_string(),
115    };
116
117    let pg = property_graph_summary(project_root);
118
119    let deps_graph = if opts.include_deps_graph {
120        open.as_ref()
121            .map(|o| build_deps_graph(&o.provider, opts.max_nodes, opts.max_edges))
122    } else {
123        None
124    };
125
126    Ok(ContextArtifacts {
127        generated_at_ms: now_ms(),
128        project_root: root_s,
129        git,
130        index: IndexSummary {
131            graph_index: graph_summary,
132            bm25_index: bm25_summary,
133            property_graph: pg,
134        },
135        deps_graph,
136    })
137}
138
139fn build_deps_graph(gp: &GraphProvider, max_nodes: usize, max_edges: usize) -> DepsGraph {
140    let max_nodes = max_nodes.max(1);
141    let max_edges = max_edges.max(1);
142
143    let mut nodes = gp.file_paths();
144    nodes.sort();
145
146    let truncated_nodes = nodes.len() > max_nodes;
147    if truncated_nodes {
148        nodes.truncate(max_nodes);
149    }
150    let node_set: std::collections::HashSet<&str> = nodes.iter().map(String::as_str).collect();
151
152    let all_edges = gp.edges();
153    let mut edges: Vec<DepsEdge> = Vec::new();
154    for e in &all_edges {
155        if edges.len() >= max_edges {
156            break;
157        }
158        if !node_set.contains(e.from.as_str()) || !node_set.contains(e.to.as_str()) {
159            continue;
160        }
161        edges.push(DepsEdge {
162            from: e.from.clone(),
163            to: e.to.clone(),
164            kind: e.kind.clone(),
165        });
166    }
167
168    let truncated_edges = all_edges.len() > edges.len() && edges.len() >= max_edges;
169    DepsGraph {
170        nodes,
171        edges,
172        truncated: truncated_nodes || truncated_edges,
173    }
174}
175
176fn property_graph_summary(project_root: &Path) -> PropertyGraphSummary {
177    let root_str = project_root.to_string_lossy();
178    let db_path = crate::core::property_graph::graph_dir(&root_str).join("graph.db");
179    let db_path_s = db_path.to_string_lossy().to_string();
180    if !db_path.exists() {
181        return PropertyGraphSummary {
182            exists: false,
183            db_path: db_path_s,
184            nodes: None,
185            edges: None,
186        };
187    }
188
189    match crate::core::property_graph::CodeGraph::open(&root_str) {
190        Ok(g) => PropertyGraphSummary {
191            exists: true,
192            db_path: g.db_path().to_string_lossy().to_string(),
193            nodes: g.node_count().ok(),
194            edges: g.edge_count().ok(),
195        },
196        Err(_) => PropertyGraphSummary {
197            exists: true,
198            db_path: db_path_s,
199            nodes: None,
200            edges: None,
201        },
202    }
203}
204
205fn git_info(project_root: &Path) -> GitInfo {
206    let head = git_out(project_root, &["rev-parse", "--short", "HEAD"]);
207    let branch = git_out(project_root, &["rev-parse", "--abbrev-ref", "HEAD"]);
208    let dirty = git_dirty(project_root);
209    GitInfo {
210        head,
211        branch,
212        dirty,
213    }
214}
215
216fn git_dirty(project_root: &Path) -> bool {
217    let out = std::process::Command::new("git")
218        .args(["status", "--porcelain"])
219        .current_dir(project_root)
220        .stdout(std::process::Stdio::piped())
221        .stderr(std::process::Stdio::null())
222        .output();
223    match out {
224        Ok(o) if o.status.success() => !o.stdout.is_empty(),
225        _ => false,
226    }
227}
228
229fn git_out(project_root: &Path, args: &[&str]) -> Option<String> {
230    let out = std::process::Command::new("git")
231        .args(args)
232        .current_dir(project_root)
233        .stdout(std::process::Stdio::piped())
234        .stderr(std::process::Stdio::null())
235        .output()
236        .ok()?;
237    if !out.status.success() {
238        return None;
239    }
240    let s = String::from_utf8(out.stdout).ok()?;
241    let s = s.trim().to_string();
242    if s.is_empty() {
243        None
244    } else {
245        Some(s)
246    }
247}
248
249fn now_ms() -> u64 {
250    SystemTime::now()
251        .duration_since(UNIX_EPOCH)
252        .unwrap_or_default()
253        .as_millis() as u64
254}