1use 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 scope: Option<String>,
19 tags: Vec<String>,
20 compatibility: CompatibilitySpec,
21 content: PackageContent,
22 project_hash: Option<String>,
23 session_id: Option<String>,
24 level: u32,
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 scope: None,
35 tags: Vec::new(),
36 compatibility: CompatibilitySpec::default(),
37 content: PackageContent::default(),
38 project_hash: None,
39 session_id: None,
40 level: 1,
41 }
42 }
43
44 pub fn description(mut self, desc: &str) -> Self {
45 self.description = desc.to_string();
46 self
47 }
48
49 pub fn author(mut self, author: &str) -> Self {
50 self.author = Some(author.to_string());
51 self
52 }
53
54 pub fn tags(mut self, tags: Vec<String>) -> Self {
55 self.tags = tags;
56 self
57 }
58
59 pub fn compatibility(mut self, spec: CompatibilitySpec) -> Self {
60 self.compatibility = spec;
61 self
62 }
63
64 pub fn project_hash(mut self, hash: &str) -> Self {
65 self.project_hash = Some(hash.to_string());
66 self
67 }
68
69 pub fn session_id(mut self, id: &str) -> Self {
70 self.session_id = Some(id.to_string());
71 self
72 }
73
74 pub fn scope(mut self, scope: &str) -> Self {
75 self.scope = Some(scope.to_string());
76 self
77 }
78
79 pub fn level(mut self, level: u32) -> Self {
80 self.level = level.clamp(1, 3);
81 self
82 }
83
84 pub fn add_knowledge_from_project(mut self, project_root: &str) -> Self {
85 let knowledge = crate::core::knowledge::ProjectKnowledge::load_or_create(project_root);
86
87 if knowledge.facts.is_empty()
88 && knowledge.patterns.is_empty()
89 && knowledge.history.is_empty()
90 {
91 return self;
92 }
93
94 self.content.knowledge = Some(KnowledgeLayer {
95 facts: knowledge.facts,
96 patterns: knowledge.patterns,
97 insights: knowledge.history,
98 exported_at: Utc::now(),
99 });
100
101 self
102 }
103
104 pub fn add_graph_from_project(mut self, project_root: &str) -> Self {
105 let Ok(graph) = crate::core::property_graph::CodeGraph::open(project_root) else {
106 return self;
107 };
108
109 let nodes = export_graph_nodes(&graph);
110 let edges = export_graph_edges(&graph);
111
112 if nodes.is_empty() && edges.is_empty() {
113 return self;
114 }
115
116 self.content.graph = Some(GraphLayer {
117 nodes,
118 edges,
119 exported_at: Utc::now(),
120 });
121
122 self
123 }
124
125 pub fn add_session(mut self, session: &crate::core::session::SessionState) -> Self {
126 let has_content = session.task.is_some()
127 || !session.findings.is_empty()
128 || !session.decisions.is_empty()
129 || !session.next_steps.is_empty()
130 || !session.files_touched.is_empty();
131
132 if !has_content {
133 return self;
134 }
135
136 let layer = SessionLayer {
137 task_description: session.task.as_ref().map(|t| t.description.clone()),
138 findings: session
139 .findings
140 .iter()
141 .map(|f| SessionFinding {
142 summary: f.summary.clone(),
143 file: f.file.clone(),
144 line: f.line,
145 })
146 .collect(),
147 decisions: session
148 .decisions
149 .iter()
150 .map(|d| SessionDecision {
151 summary: d.summary.clone(),
152 rationale: d.rationale.clone(),
153 })
154 .collect(),
155 next_steps: session.next_steps.clone(),
156 files_touched: session
157 .files_touched
158 .iter()
159 .map(|f| f.path.clone())
160 .collect(),
161 exported_at: Utc::now(),
162 };
163
164 self.content.session = Some(layer);
165 self
166 }
167
168 pub fn add_patterns_from_project(mut self, project_root: &str) -> Self {
169 let knowledge = crate::core::knowledge::ProjectKnowledge::load_or_create(project_root);
170
171 if knowledge.patterns.is_empty() {
172 return self;
173 }
174
175 self.content.patterns = Some(PatternsLayer {
176 patterns: knowledge.patterns,
177 exported_at: Utc::now(),
178 });
179
180 self
181 }
182
183 pub fn build_context_graph(&mut self, project_root: &str) {
184 use super::graph_model::{ContextEdge, ContextGraph, ContextNode};
185
186 let mut graph = ContextGraph::new();
187 let mut node_count: u32 = 0;
188
189 let mut next_id = || -> String {
190 node_count += 1;
191 format!("N{node_count}")
192 };
193
194 let knowledge = crate::core::knowledge::ProjectKnowledge::load_or_create(project_root);
195 let mut fact_id_map: std::collections::HashMap<String, String> =
196 std::collections::HashMap::new();
197
198 for fact in &knowledge.facts {
199 let id = next_id();
200 let mut node = ContextNode::fact(&id, &fact.value, &fact.category);
201 node.confidence = Some(fact.confidence);
202 node.source = Some(fact.source_session.clone());
203 node.created_at = Some(fact.created_at);
204 if let Some(ref s) = fact.supersedes {
205 node.supersedes = fact_id_map.get(s).cloned();
206 }
207 let map_key = format!("{}/{}", fact.category, fact.key);
208 fact_id_map.insert(map_key, id.clone());
209 graph.add_node(node);
210 }
211
212 for pattern in &knowledge.patterns {
213 let id = next_id();
214 let mut node = ContextNode::fact(&id, &pattern.description, "pattern");
215 node.node_type = "pattern".into();
216 node.created_at = Some(pattern.created_at);
217 graph.add_node(node);
218 }
219
220 for insight in &knowledge.history {
221 let id = next_id();
222 let mut node = ContextNode::fact(&id, &insight.summary, "insight");
223 node.node_type = "insight".into();
224 node.created_at = Some(insight.timestamp);
225 graph.add_node(node);
226 }
227
228 let gotcha_store = crate::core::gotcha_tracker::GotchaStore::load(project_root);
229 for g in &gotcha_store.gotchas {
230 let id = next_id();
231 let mut node = ContextNode::gotcha(&id, &g.trigger, &g.resolution);
232 node.category = Some(g.category.short_label().to_string());
233 node.confidence = Some(g.confidence);
234 node.created_at = Some(g.first_seen);
235 graph.add_node(node);
236 }
237
238 if self.level >= 2 {
239 if let Ok(code_graph) = crate::core::property_graph::CodeGraph::open(project_root) {
240 let v1_nodes = export_graph_nodes(&code_graph);
241 let v1_edges = export_graph_edges(&code_graph);
242
243 let mut code_node_map: std::collections::HashMap<(String, String), String> =
244 std::collections::HashMap::new();
245
246 for n in &v1_nodes {
247 let id = next_id();
248 let node = ContextNode::code_symbol(&id, &n.kind, &n.name, &n.file_path);
249 code_node_map.insert((n.file_path.clone(), n.name.clone()), id.clone());
250 graph.add_node(node);
251 }
252
253 for e in &v1_edges {
254 let src_key = (e.source_path.clone(), e.source_name.clone());
255 let tgt_key = (e.target_path.clone(), e.target_name.clone());
256 if let (Some(from), Some(to)) =
257 (code_node_map.get(&src_key), code_node_map.get(&tgt_key))
258 {
259 graph.add_edge(ContextEdge {
260 from: from.clone(),
261 to: to.clone(),
262 edge_type: e.kind.clone(),
263 weight: 1.0,
264 coactivations: 0,
265 metadata: e.metadata.clone(),
266 });
267 }
268 }
269 }
270
271 let phash = crate::core::project_hash::hash_project_root(project_root);
272 if let Some(rel_graph) =
273 crate::core::knowledge_relations::KnowledgeRelationGraph::load(&phash)
274 {
275 for edge in &rel_graph.edges {
276 let from_key = edge.from.id();
277 let to_key = edge.to.id();
278 if let (Some(from_id), Some(to_id)) =
279 (fact_id_map.get(&from_key), fact_id_map.get(&to_key))
280 {
281 let edge_type = match edge.kind {
282 crate::core::knowledge_relations::KnowledgeEdgeKind::DependsOn => {
283 "depends_on"
284 }
285 crate::core::knowledge_relations::KnowledgeEdgeKind::RelatedTo => {
286 "related_to"
287 }
288 crate::core::knowledge_relations::KnowledgeEdgeKind::Supports => {
289 "supports"
290 }
291 crate::core::knowledge_relations::KnowledgeEdgeKind::Contradicts => {
292 "contradicts"
293 }
294 crate::core::knowledge_relations::KnowledgeEdgeKind::Supersedes => {
295 "supersedes"
296 }
297 };
298 graph.add_edge(ContextEdge {
299 from: from_id.clone(),
300 to: to_id.clone(),
301 edge_type: edge_type.into(),
302 weight: edge.strength,
303 coactivations: edge.count,
304 metadata: None,
305 });
306 }
307 }
308 }
309 }
310
311 if !graph.nodes.is_empty() {
312 self.content.context_graph = Some(graph);
313 }
314 }
315
316 pub fn add_gotchas_from_project(mut self, project_root: &str) -> Self {
317 let store = crate::core::gotcha_tracker::GotchaStore::load(project_root);
318 if store.gotchas.is_empty() {
319 return self;
320 }
321
322 self.content.gotchas = Some(GotchasLayer {
323 gotchas: store
324 .gotchas
325 .iter()
326 .map(|g| GotchaExport {
327 id: g.id.clone(),
328 category: g.category.short_label().to_string(),
329 severity: match g.severity {
330 crate::core::gotcha_tracker::GotchaSeverity::Critical => "critical".into(),
331 crate::core::gotcha_tracker::GotchaSeverity::Warning => "warning".into(),
332 crate::core::gotcha_tracker::GotchaSeverity::Info => "info".into(),
333 },
334 trigger: g.trigger.clone(),
335 resolution: g.resolution.clone(),
336 file_patterns: g.file_patterns.clone(),
337 confidence: g.confidence,
338 })
339 .collect(),
340 exported_at: Utc::now(),
341 });
342
343 self
344 }
345
346 pub fn build(self) -> Result<(PackageManifest, PackageContent), String> {
347 if self.name.is_empty() {
348 return Err("package name is required".into());
349 }
350 if self.version.is_empty() {
351 return Err("package version is required".into());
352 }
353 if self.content.is_empty() {
354 return Err("package has no content — add at least one layer".into());
355 }
356
357 let is_v2 = self.content.context_graph.is_some();
358
359 let mut layers = Vec::new();
360 if self.content.knowledge.is_some() {
361 layers.push(PackageLayer::Knowledge);
362 }
363 if self.content.graph.is_some() {
364 layers.push(PackageLayer::Graph);
365 }
366 if self.content.session.is_some() {
367 layers.push(PackageLayer::Session);
368 }
369 if self.content.patterns.is_some() {
370 layers.push(PackageLayer::Patterns);
371 }
372 if self.content.gotchas.is_some() {
373 layers.push(PackageLayer::Gotchas);
374 }
375
376 let content_json = serde_json::to_string(&self.content).map_err(|e| e.to_string())?;
377 let content_bytes = content_json.as_bytes();
378
379 let content_hash = sha256_hex(content_bytes);
380 let sha256 =
381 sha256_hex(format!("{}:{}:{}", self.name, self.version, content_hash).as_bytes());
382
383 let stats = compute_stats(&self.content);
384
385 let schema_version = if is_v2 {
386 crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION
387 } else {
388 crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION
389 };
390
391 let graph_summary = self
392 .content
393 .context_graph
394 .as_ref()
395 .map(super::graph_model::ContextGraph::summary);
396
397 let manifest = PackageManifest {
398 schema_version,
399 conformance_level: if is_v2 { Some(self.level) } else { None },
400 name: self.name,
401 version: self.version,
402 description: self.description,
403 author: self.author,
404 scope: self.scope,
405 created_at: Utc::now(),
406 updated_at: None,
407 layers,
408 dependencies: Vec::new(),
409 tags: self.tags,
410 integrity: PackageIntegrity {
411 sha256,
412 content_hash,
413 byte_size: content_bytes.len() as u64,
414 },
415 provenance: PackageProvenance {
416 tool: "lean-ctx".into(),
417 tool_version: env!("CARGO_PKG_VERSION").into(),
418 project_hash: self.project_hash,
419 source_session_id: self.session_id,
420 },
421 compatibility: self.compatibility,
422 stats,
423 signature: None,
424 graph_summary,
425 marketplace: None,
426 };
427
428 manifest.validate().map_err(|errs| errs.join("; "))?;
429
430 Ok((manifest, self.content))
431 }
432}
433
434fn export_graph_nodes(graph: &crate::core::property_graph::CodeGraph) -> Vec<GraphNodeExport> {
435 let conn = graph.connection();
436 let Ok(mut stmt) =
437 conn.prepare("SELECT kind, name, file_path, line_start, line_end, metadata FROM nodes")
438 else {
439 tracing::warn!("ctxpkg: failed to prepare graph nodes query");
440 return Vec::new();
441 };
442
443 let Ok(rows) = stmt.query_map([], |row| {
444 let line_start: Option<i64> = row.get(3)?;
445 let line_end: Option<i64> = row.get(4)?;
446 Ok(GraphNodeExport {
447 kind: row.get(0)?,
448 name: row.get(1)?,
449 file_path: row.get(2)?,
450 line_start: line_start.map(|v| v as usize),
451 line_end: line_end.map(|v| v as usize),
452 metadata: row.get(5)?,
453 })
454 }) else {
455 tracing::warn!("ctxpkg: failed to query graph nodes");
456 return Vec::new();
457 };
458
459 let mut nodes = Vec::new();
460 for row in rows {
461 match row {
462 Ok(n) => nodes.push(n),
463 Err(e) => tracing::warn!("ctxpkg: skipping graph node: {e}"),
464 }
465 }
466 nodes
467}
468
469fn export_graph_edges(graph: &crate::core::property_graph::CodeGraph) -> Vec<GraphEdgeExport> {
470 let conn = graph.connection();
471 let sql = "
472 SELECT n1.file_path, n1.name, n2.file_path, n2.name, e.kind, e.metadata
473 FROM edges e
474 JOIN nodes n1 ON e.source_id = n1.id
475 JOIN nodes n2 ON e.target_id = n2.id
476 ";
477 let Ok(mut stmt) = conn.prepare(sql) else {
478 tracing::warn!("ctxpkg: failed to prepare graph edges query");
479 return Vec::new();
480 };
481
482 let Ok(rows) = stmt.query_map([], |row| {
483 Ok(GraphEdgeExport {
484 source_path: row.get(0)?,
485 source_name: row.get(1)?,
486 target_path: row.get(2)?,
487 target_name: row.get(3)?,
488 kind: row.get(4)?,
489 metadata: row.get(5)?,
490 })
491 }) else {
492 tracing::warn!("ctxpkg: failed to query graph edges");
493 return Vec::new();
494 };
495
496 let mut edges = Vec::new();
497 for row in rows {
498 match row {
499 Ok(e) => edges.push(e),
500 Err(e) => tracing::warn!("ctxpkg: skipping graph edge: {e}"),
501 }
502 }
503 edges
504}
505
506fn compute_stats(content: &PackageContent) -> PackageStats {
507 let knowledge_facts = content
508 .knowledge
509 .as_ref()
510 .map_or(0, |k| k.facts.len() as u32);
511 let graph_nodes = content.graph.as_ref().map_or(0, |g| g.nodes.len() as u32);
512 let graph_edges = content.graph.as_ref().map_or(0, |g| g.edges.len() as u32);
513 let pattern_count = content
514 .patterns
515 .as_ref()
516 .map_or(0, |p| p.patterns.len() as u32);
517 let gotcha_count = content
518 .gotchas
519 .as_ref()
520 .map_or(0, |g| g.gotchas.len() as u32);
521
522 let raw_json = serde_json::to_string(content).unwrap_or_default();
523 let compression_ratio = {
524 use flate2::write::GzEncoder;
525 use flate2::Compression;
526 use std::io::Write;
527 let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
528 let _ = encoder.write_all(raw_json.as_bytes());
529 let compressed = encoder.finish().unwrap_or_default();
530 if raw_json.is_empty() {
531 1.0
532 } else {
533 compressed.len() as f64 / raw_json.len() as f64
534 }
535 };
536
537 PackageStats {
538 knowledge_facts,
539 graph_nodes,
540 graph_edges,
541 pattern_count,
542 gotcha_count,
543 compression_ratio,
544 }
545}
546
547fn sha256_hex(data: &[u8]) -> String {
548 let mut hasher = Sha256::new();
549 hasher.update(data);
550 format!("{:x}", hasher.finalize())
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556 use crate::core::context_package::graph_model::{ContextEdge, ContextGraph, ContextNode};
557
558 #[test]
559 fn empty_builder_fails() {
560 let result = PackageBuilder::new("test", "1.0.0").build();
561 assert!(result.is_err());
562 assert!(result.unwrap_err().contains("no content"));
563 }
564
565 #[test]
566 fn sha256_is_deterministic() {
567 let a = sha256_hex(b"hello world");
568 let b = sha256_hex(b"hello world");
569 assert_eq!(a, b);
570 assert_eq!(a.len(), 64);
571 }
572
573 #[test]
574 fn v2_build_with_context_graph() {
575 let mut graph = ContextGraph::new();
576 graph.add_node(ContextNode::fact("n1", "test fact", "architecture"));
577 graph.add_node(ContextNode::gotcha("n2", "trigger", "resolution"));
578 graph.add_edge(ContextEdge {
579 from: "n1".into(),
580 to: "n2".into(),
581 edge_type: "has_gotcha".into(),
582 weight: 0.9,
583 coactivations: 3,
584 metadata: None,
585 });
586
587 let mut builder = PackageBuilder::new("v2-test", "1.0.0")
588 .description("v2 test package")
589 .level(2)
590 .scope("@test");
591
592 builder.content.context_graph = Some(graph);
593
594 let (manifest, content) = builder.build().unwrap();
595
596 assert_eq!(
597 manifest.schema_version,
598 crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION
599 );
600 assert_eq!(manifest.conformance_level, Some(2));
601 assert_eq!(manifest.scope.as_deref(), Some("@test"));
602 assert!(content.context_graph.is_some());
603
604 let gs = manifest.graph_summary.unwrap();
605 assert_eq!(gs.node_count, 2);
606 assert_eq!(gs.edge_count, 1);
607 assert!(gs.activation_mean.is_some());
608 assert_eq!(gs.node_types, vec!["fact", "gotcha"]);
609 }
610
611 #[test]
612 fn v1_build_without_context_graph() {
613 let mut builder = PackageBuilder::new("v1-test", "1.0.0").description("v1 test");
614
615 builder.content.knowledge = Some(KnowledgeLayer {
616 facts: vec![],
617 patterns: vec![],
618 insights: vec![],
619 exported_at: chrono::Utc::now(),
620 });
621
622 let (manifest, _content) = builder.build().unwrap();
623 assert_eq!(
624 manifest.schema_version,
625 crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION
626 );
627 assert!(manifest.conformance_level.is_none());
628 assert!(manifest.graph_summary.is_none());
629 }
630
631 #[test]
632 fn level_clamped_to_valid_range() {
633 let b = PackageBuilder::new("t", "1.0.0").level(99);
634 assert_eq!(b.level, 3);
635 let b = PackageBuilder::new("t", "1.0.0").level(0);
636 assert_eq!(b.level, 1);
637 }
638
639 #[test]
640 fn scoped_name_in_v2_build() {
641 let mut builder = PackageBuilder::new("@company/auth", "2.0.0")
642 .level(2)
643 .scope("@company");
644
645 let mut graph = ContextGraph::new();
646 graph.add_node(ContextNode::fact("n1", "jwt auth", "security"));
647 builder.content.context_graph = Some(graph);
648
649 let (manifest, _) = builder.build().unwrap();
650 assert_eq!(manifest.name, "@company/auth");
651 assert_eq!(manifest.scope.as_deref(), Some("@company"));
652 }
653
654 #[test]
655 fn v2_manifest_roundtrip_json() {
656 let mut graph = ContextGraph::new();
657 graph.add_node(ContextNode::fact("n1", "fact", "cat"));
658 graph.add_edge(ContextEdge {
659 from: "n1".into(),
660 to: "n1".into(),
661 edge_type: "self".into(),
662 weight: 1.0,
663 coactivations: 0,
664 metadata: None,
665 });
666
667 let mut builder = PackageBuilder::new("roundtrip-test", "1.0.0")
668 .description("round trip")
669 .level(3)
670 .scope("@local");
671
672 builder.content.context_graph = Some(graph);
673
674 let (manifest, content) = builder.build().unwrap();
675
676 let manifest_json = serde_json::to_string(&manifest).unwrap();
677 let content_json = serde_json::to_string(&content).unwrap();
678
679 let decoded_manifest: crate::core::context_package::manifest::PackageManifest =
680 serde_json::from_str(&manifest_json).unwrap();
681 let decoded_content: PackageContent = serde_json::from_str(&content_json).unwrap();
682
683 assert_eq!(decoded_manifest.schema_version, 2);
684 assert_eq!(decoded_manifest.conformance_level, Some(3));
685 assert!(decoded_content.context_graph.is_some());
686 let dg = decoded_content.context_graph.unwrap();
687 assert_eq!(dg.nodes.len(), 1);
688 assert_eq!(dg.edges.len(), 1);
689 }
690}