lean_ctx/core/context_package/
builder.rs1use chrono::Utc;
2use sha2::{Digest, Sha256};
3
4use super::content::{
5 GotchaExport, GotchasLayer, GraphEdgeExport, GraphLayer, GraphNodeExport, KnowledgeLayer,
6 PackageContent, SessionDecision, SessionFinding, SessionLayer,
7};
8use super::manifest::{
9 CompatibilitySpec, PackageIntegrity, PackageLayer, PackageManifest, PackageProvenance,
10 PackageStats,
11};
12
13pub struct PackageBuilder {
14 name: String,
15 version: String,
16 description: String,
17 author: Option<String>,
18 tags: Vec<String>,
19 compatibility: CompatibilitySpec,
20 content: PackageContent,
21 project_hash: Option<String>,
22 session_id: Option<String>,
23}
24
25impl PackageBuilder {
26 pub fn new(name: &str, version: &str) -> Self {
27 Self {
28 name: name.to_string(),
29 version: version.to_string(),
30 description: String::new(),
31 author: None,
32 tags: Vec::new(),
33 compatibility: CompatibilitySpec::default(),
34 content: PackageContent::default(),
35 project_hash: None,
36 session_id: None,
37 }
38 }
39
40 pub fn description(mut self, desc: &str) -> Self {
41 self.description = desc.to_string();
42 self
43 }
44
45 pub fn author(mut self, author: &str) -> Self {
46 self.author = Some(author.to_string());
47 self
48 }
49
50 pub fn tags(mut self, tags: Vec<String>) -> Self {
51 self.tags = tags;
52 self
53 }
54
55 pub fn compatibility(mut self, spec: CompatibilitySpec) -> Self {
56 self.compatibility = spec;
57 self
58 }
59
60 pub fn project_hash(mut self, hash: &str) -> Self {
61 self.project_hash = Some(hash.to_string());
62 self
63 }
64
65 pub fn session_id(mut self, id: &str) -> Self {
66 self.session_id = Some(id.to_string());
67 self
68 }
69
70 pub fn add_knowledge_from_project(mut self, project_root: &str) -> Self {
71 let knowledge = crate::core::knowledge::ProjectKnowledge::load_or_create(project_root);
72
73 if knowledge.facts.is_empty()
74 && knowledge.patterns.is_empty()
75 && knowledge.history.is_empty()
76 {
77 return self;
78 }
79
80 self.content.knowledge = Some(KnowledgeLayer {
81 facts: knowledge.facts,
82 patterns: knowledge.patterns,
83 insights: knowledge.history,
84 exported_at: Utc::now(),
85 });
86
87 self
88 }
89
90 pub fn add_graph_from_project(mut self, project_root: &str) -> Self {
91 let Ok(graph) = crate::core::property_graph::CodeGraph::open(project_root) else {
92 return self;
93 };
94
95 let nodes = export_graph_nodes(&graph);
96 let edges = export_graph_edges(&graph);
97
98 if nodes.is_empty() && edges.is_empty() {
99 return self;
100 }
101
102 self.content.graph = Some(GraphLayer {
103 nodes,
104 edges,
105 exported_at: Utc::now(),
106 });
107
108 self
109 }
110
111 pub fn add_session(mut self, session: &crate::core::session::SessionState) -> Self {
112 let has_content = session.task.is_some()
113 || !session.findings.is_empty()
114 || !session.decisions.is_empty()
115 || !session.next_steps.is_empty()
116 || !session.files_touched.is_empty();
117
118 if !has_content {
119 return self;
120 }
121
122 let layer = SessionLayer {
123 task_description: session.task.as_ref().map(|t| t.description.clone()),
124 findings: session
125 .findings
126 .iter()
127 .map(|f| SessionFinding {
128 summary: f.summary.clone(),
129 file: f.file.clone(),
130 line: f.line,
131 })
132 .collect(),
133 decisions: session
134 .decisions
135 .iter()
136 .map(|d| SessionDecision {
137 summary: d.summary.clone(),
138 rationale: d.rationale.clone(),
139 })
140 .collect(),
141 next_steps: session.next_steps.clone(),
142 files_touched: session
143 .files_touched
144 .iter()
145 .map(|f| f.path.clone())
146 .collect(),
147 exported_at: Utc::now(),
148 };
149
150 self.content.session = Some(layer);
151 self
152 }
153
154 pub fn add_gotchas_from_project(mut self, project_root: &str) -> Self {
155 let store = crate::core::gotcha_tracker::GotchaStore::load(project_root);
156 if store.gotchas.is_empty() {
157 return self;
158 }
159
160 self.content.gotchas = Some(GotchasLayer {
161 gotchas: store
162 .gotchas
163 .iter()
164 .map(|g| GotchaExport {
165 id: g.id.clone(),
166 category: g.category.short_label().to_string(),
167 severity: match g.severity {
168 crate::core::gotcha_tracker::GotchaSeverity::Critical => "critical".into(),
169 crate::core::gotcha_tracker::GotchaSeverity::Warning => "warning".into(),
170 crate::core::gotcha_tracker::GotchaSeverity::Info => "info".into(),
171 },
172 trigger: g.trigger.clone(),
173 resolution: g.resolution.clone(),
174 file_patterns: g.file_patterns.clone(),
175 confidence: g.confidence,
176 })
177 .collect(),
178 exported_at: Utc::now(),
179 });
180
181 self
182 }
183
184 pub fn build(self) -> Result<(PackageManifest, PackageContent), String> {
185 if self.name.is_empty() {
186 return Err("package name is required".into());
187 }
188 if self.version.is_empty() {
189 return Err("package version is required".into());
190 }
191 if self.content.is_empty() {
192 return Err("package has no content — add at least one layer".into());
193 }
194
195 let mut layers = Vec::new();
196 if self.content.knowledge.is_some() {
197 layers.push(PackageLayer::Knowledge);
198 }
199 if self.content.graph.is_some() {
200 layers.push(PackageLayer::Graph);
201 }
202 if self.content.session.is_some() {
203 layers.push(PackageLayer::Session);
204 }
205 if self.content.patterns.is_some() {
206 layers.push(PackageLayer::Patterns);
207 }
208 if self.content.gotchas.is_some() {
209 layers.push(PackageLayer::Gotchas);
210 }
211
212 let content_json = serde_json::to_string(&self.content).map_err(|e| e.to_string())?;
213 let content_bytes = content_json.as_bytes();
214
215 let content_hash = sha256_hex(content_bytes);
216 let sha256 =
217 sha256_hex(format!("{}:{}:{}", self.name, self.version, content_hash).as_bytes());
218
219 let stats = compute_stats(&self.content);
220
221 let manifest = PackageManifest {
222 schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
223 name: self.name,
224 version: self.version,
225 description: self.description,
226 author: self.author,
227 created_at: Utc::now(),
228 updated_at: None,
229 layers,
230 dependencies: Vec::new(),
231 tags: self.tags,
232 integrity: PackageIntegrity {
233 sha256,
234 content_hash,
235 byte_size: content_bytes.len() as u64,
236 },
237 provenance: PackageProvenance {
238 tool: "lean-ctx".into(),
239 tool_version: env!("CARGO_PKG_VERSION").into(),
240 project_hash: self.project_hash,
241 source_session_id: self.session_id,
242 },
243 compatibility: self.compatibility,
244 stats,
245 };
246
247 manifest.validate().map_err(|errs| errs.join("; "))?;
248
249 Ok((manifest, self.content))
250 }
251}
252
253fn export_graph_nodes(graph: &crate::core::property_graph::CodeGraph) -> Vec<GraphNodeExport> {
254 let conn = graph.connection();
255 let Ok(mut stmt) =
256 conn.prepare("SELECT kind, name, file_path, line_start, line_end, metadata FROM nodes")
257 else {
258 tracing::warn!("ctxpkg: failed to prepare graph nodes query");
259 return Vec::new();
260 };
261
262 let Ok(rows) = stmt.query_map([], |row| {
263 let line_start: Option<i64> = row.get(3)?;
264 let line_end: Option<i64> = row.get(4)?;
265 Ok(GraphNodeExport {
266 kind: row.get(0)?,
267 name: row.get(1)?,
268 file_path: row.get(2)?,
269 line_start: line_start.map(|v| v as usize),
270 line_end: line_end.map(|v| v as usize),
271 metadata: row.get(5)?,
272 })
273 }) else {
274 tracing::warn!("ctxpkg: failed to query graph nodes");
275 return Vec::new();
276 };
277
278 let mut nodes = Vec::new();
279 for row in rows {
280 match row {
281 Ok(n) => nodes.push(n),
282 Err(e) => tracing::warn!("ctxpkg: skipping graph node: {e}"),
283 }
284 }
285 nodes
286}
287
288fn export_graph_edges(graph: &crate::core::property_graph::CodeGraph) -> Vec<GraphEdgeExport> {
289 let conn = graph.connection();
290 let sql = "
291 SELECT n1.file_path, n1.name, n2.file_path, n2.name, e.kind, e.metadata
292 FROM edges e
293 JOIN nodes n1 ON e.source_id = n1.id
294 JOIN nodes n2 ON e.target_id = n2.id
295 ";
296 let Ok(mut stmt) = conn.prepare(sql) else {
297 tracing::warn!("ctxpkg: failed to prepare graph edges query");
298 return Vec::new();
299 };
300
301 let Ok(rows) = stmt.query_map([], |row| {
302 Ok(GraphEdgeExport {
303 source_path: row.get(0)?,
304 source_name: row.get(1)?,
305 target_path: row.get(2)?,
306 target_name: row.get(3)?,
307 kind: row.get(4)?,
308 metadata: row.get(5)?,
309 })
310 }) else {
311 tracing::warn!("ctxpkg: failed to query graph edges");
312 return Vec::new();
313 };
314
315 let mut edges = Vec::new();
316 for row in rows {
317 match row {
318 Ok(e) => edges.push(e),
319 Err(e) => tracing::warn!("ctxpkg: skipping graph edge: {e}"),
320 }
321 }
322 edges
323}
324
325fn compute_stats(content: &PackageContent) -> PackageStats {
326 let knowledge_facts = content
327 .knowledge
328 .as_ref()
329 .map_or(0, |k| k.facts.len() as u32);
330 let graph_nodes = content.graph.as_ref().map_or(0, |g| g.nodes.len() as u32);
331 let graph_edges = content.graph.as_ref().map_or(0, |g| g.edges.len() as u32);
332 let pattern_count = content
333 .patterns
334 .as_ref()
335 .map_or(0, |p| p.patterns.len() as u32);
336 let gotcha_count = content
337 .gotchas
338 .as_ref()
339 .map_or(0, |g| g.gotchas.len() as u32);
340
341 let raw_json = serde_json::to_string(content).unwrap_or_default();
342 let compression_ratio = {
343 use flate2::write::GzEncoder;
344 use flate2::Compression;
345 use std::io::Write;
346 let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
347 let _ = encoder.write_all(raw_json.as_bytes());
348 let compressed = encoder.finish().unwrap_or_default();
349 if raw_json.is_empty() {
350 1.0
351 } else {
352 compressed.len() as f64 / raw_json.len() as f64
353 }
354 };
355
356 PackageStats {
357 knowledge_facts,
358 graph_nodes,
359 graph_edges,
360 pattern_count,
361 gotcha_count,
362 compression_ratio,
363 }
364}
365
366fn sha256_hex(data: &[u8]) -> String {
367 let mut hasher = Sha256::new();
368 hasher.update(data);
369 format!("{:x}", hasher.finalize())
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[test]
377 fn empty_builder_fails() {
378 let result = PackageBuilder::new("test", "1.0.0").build();
379 assert!(result.is_err());
380 assert!(result.unwrap_err().contains("no content"));
381 }
382
383 #[test]
384 fn sha256_is_deterministic() {
385 let a = sha256_hex(b"hello world");
386 let b = sha256_hex(b"hello world");
387 assert_eq!(a, b);
388 assert_eq!(a.len(), 64);
389 }
390}