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