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