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