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 knowledge = crate::core::knowledge::ProjectKnowledge::load_or_create(project_root);
94
95 if knowledge.facts.is_empty()
96 && knowledge.patterns.is_empty()
97 && knowledge.history.is_empty()
98 {
99 return self;
100 }
101
102 self.content.knowledge = Some(KnowledgeLayer {
103 facts: knowledge.facts,
104 patterns: knowledge.patterns,
105 insights: knowledge.history,
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 name: self.name,
409 version: self.version,
410 description: self.description,
411 author: self.author,
412 scope: self.scope,
413 created_at: Utc::now(),
414 updated_at: None,
415 layers,
416 dependencies: Vec::new(),
417 tags: self.tags,
418 visibility: self.visibility,
419 integrity: PackageIntegrity {
420 sha256,
421 content_hash,
422 byte_size: content_bytes.len() as u64,
423 },
424 provenance: PackageProvenance {
425 tool: "lean-ctx".into(),
426 tool_version: env!("CARGO_PKG_VERSION").into(),
427 project_hash: self.project_hash,
428 source_session_id: self.session_id,
429 },
430 compatibility: self.compatibility,
431 stats,
432 signature: None,
433 graph_summary,
434 marketplace: None,
435 };
436
437 manifest.validate().map_err(|errs| errs.join("; "))?;
438
439 Ok((manifest, self.content))
440 }
441}
442
443fn export_graph_nodes(graph: &crate::core::property_graph::CodeGraph) -> Vec<GraphNodeExport> {
444 let conn = graph.connection();
445 let Ok(mut stmt) =
446 conn.prepare("SELECT kind, name, file_path, line_start, line_end, metadata FROM nodes")
447 else {
448 tracing::warn!("ctxpkg: failed to prepare graph nodes query");
449 return Vec::new();
450 };
451
452 let Ok(rows) = stmt.query_map([], |row| {
453 let line_start: Option<i64> = row.get(3)?;
454 let line_end: Option<i64> = row.get(4)?;
455 Ok(GraphNodeExport {
456 kind: row.get(0)?,
457 name: row.get(1)?,
458 file_path: row.get(2)?,
459 line_start: line_start.map(|v| v as usize),
460 line_end: line_end.map(|v| v as usize),
461 metadata: row.get(5)?,
462 })
463 }) else {
464 tracing::warn!("ctxpkg: failed to query graph nodes");
465 return Vec::new();
466 };
467
468 let mut nodes = Vec::new();
469 for row in rows {
470 match row {
471 Ok(n) => nodes.push(n),
472 Err(e) => tracing::warn!("ctxpkg: skipping graph node: {e}"),
473 }
474 }
475 nodes
476}
477
478fn export_graph_edges(graph: &crate::core::property_graph::CodeGraph) -> Vec<GraphEdgeExport> {
479 let conn = graph.connection();
480 let sql = "
481 SELECT n1.file_path, n1.name, n2.file_path, n2.name, e.kind, e.metadata
482 FROM edges e
483 JOIN nodes n1 ON e.source_id = n1.id
484 JOIN nodes n2 ON e.target_id = n2.id
485 ";
486 let Ok(mut stmt) = conn.prepare(sql) else {
487 tracing::warn!("ctxpkg: failed to prepare graph edges query");
488 return Vec::new();
489 };
490
491 let Ok(rows) = stmt.query_map([], |row| {
492 Ok(GraphEdgeExport {
493 source_path: row.get(0)?,
494 source_name: row.get(1)?,
495 target_path: row.get(2)?,
496 target_name: row.get(3)?,
497 kind: row.get(4)?,
498 metadata: row.get(5)?,
499 })
500 }) else {
501 tracing::warn!("ctxpkg: failed to query graph edges");
502 return Vec::new();
503 };
504
505 let mut edges = Vec::new();
506 for row in rows {
507 match row {
508 Ok(e) => edges.push(e),
509 Err(e) => tracing::warn!("ctxpkg: skipping graph edge: {e}"),
510 }
511 }
512 edges
513}
514
515fn compute_stats(content: &PackageContent) -> PackageStats {
516 let knowledge_facts = content
517 .knowledge
518 .as_ref()
519 .map_or(0, |k| k.facts.len() as u32);
520 let graph_nodes = content.graph.as_ref().map_or(0, |g| g.nodes.len() as u32);
521 let graph_edges = content.graph.as_ref().map_or(0, |g| g.edges.len() as u32);
522 let pattern_count = content
523 .patterns
524 .as_ref()
525 .map_or(0, |p| p.patterns.len() as u32);
526 let gotcha_count = content
527 .gotchas
528 .as_ref()
529 .map_or(0, |g| g.gotchas.len() as u32);
530
531 let raw_json = serde_json::to_string(content).unwrap_or_default();
532 let compression_ratio = {
533 use flate2::Compression;
534 use flate2::write::GzEncoder;
535 use std::io::Write;
536 let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
537 let _ = encoder.write_all(raw_json.as_bytes());
538 let compressed = encoder.finish().unwrap_or_default();
539 if raw_json.is_empty() {
540 1.0
541 } else {
542 compressed.len() as f64 / raw_json.len() as f64
543 }
544 };
545
546 PackageStats {
547 knowledge_facts,
548 graph_nodes,
549 graph_edges,
550 pattern_count,
551 gotcha_count,
552 compression_ratio,
553 }
554}
555
556fn sha256_hex(data: &[u8]) -> String {
557 let mut hasher = Sha256::new();
558 hasher.update(data);
559 crate::core::agent_identity::hex_encode(&hasher.finalize())
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565 use crate::core::context_package::graph_model::{ContextEdge, ContextGraph, ContextNode};
566
567 #[test]
568 fn empty_builder_fails() {
569 let result = PackageBuilder::new("test", "1.0.0").build();
570 assert!(result.is_err());
571 assert!(result.unwrap_err().contains("no content"));
572 }
573
574 #[test]
575 fn sha256_is_deterministic() {
576 let a = sha256_hex(b"hello world");
577 let b = sha256_hex(b"hello world");
578 assert_eq!(a, b);
579 assert_eq!(a.len(), 64);
580 }
581
582 #[test]
583 fn v2_build_with_context_graph() {
584 let mut graph = ContextGraph::new();
585 graph.add_node(ContextNode::fact("n1", "test fact", "architecture"));
586 graph.add_node(ContextNode::gotcha("n2", "trigger", "resolution"));
587 graph.add_edge(ContextEdge {
588 from: "n1".into(),
589 to: "n2".into(),
590 edge_type: "has_gotcha".into(),
591 weight: 0.9,
592 coactivations: 3,
593 metadata: None,
594 });
595
596 let mut builder = PackageBuilder::new("v2-test", "1.0.0")
597 .description("v2 test package")
598 .level(2)
599 .scope("@test");
600
601 builder.content.context_graph = Some(graph);
602
603 let (manifest, content) = builder.build().unwrap();
604
605 assert_eq!(
606 manifest.schema_version,
607 crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION
608 );
609 assert_eq!(manifest.conformance_level, Some(2));
610 assert_eq!(manifest.scope.as_deref(), Some("@test"));
611 assert!(content.context_graph.is_some());
612
613 let gs = manifest.graph_summary.unwrap();
614 assert_eq!(gs.node_count, 2);
615 assert_eq!(gs.edge_count, 1);
616 assert!(gs.activation_mean.is_some());
617 assert_eq!(gs.node_types, vec!["fact", "gotcha"]);
618 }
619
620 #[test]
621 fn v1_build_without_context_graph() {
622 let mut builder = PackageBuilder::new("v1-test", "1.0.0").description("v1 test");
623
624 builder.content.knowledge = Some(KnowledgeLayer {
625 facts: vec![],
626 patterns: vec![],
627 insights: vec![],
628 exported_at: chrono::Utc::now(),
629 });
630
631 let (manifest, _content) = builder.build().unwrap();
632 assert_eq!(
633 manifest.schema_version,
634 crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION
635 );
636 assert!(manifest.conformance_level.is_none());
637 assert!(manifest.graph_summary.is_none());
638 }
639
640 #[test]
641 fn level_clamped_to_valid_range() {
642 let b = PackageBuilder::new("t", "1.0.0").level(99);
643 assert_eq!(b.level, 3);
644 let b = PackageBuilder::new("t", "1.0.0").level(0);
645 assert_eq!(b.level, 1);
646 }
647
648 #[test]
649 fn scoped_name_in_v2_build() {
650 let mut builder = PackageBuilder::new("@company/auth", "2.0.0")
651 .level(2)
652 .scope("@company");
653
654 let mut graph = ContextGraph::new();
655 graph.add_node(ContextNode::fact("n1", "jwt auth", "security"));
656 builder.content.context_graph = Some(graph);
657
658 let (manifest, _) = builder.build().unwrap();
659 assert_eq!(manifest.name, "@company/auth");
660 assert_eq!(manifest.scope.as_deref(), Some("@company"));
661 }
662
663 #[test]
664 fn v2_manifest_roundtrip_json() {
665 let mut graph = ContextGraph::new();
666 graph.add_node(ContextNode::fact("n1", "fact", "cat"));
667 graph.add_edge(ContextEdge {
668 from: "n1".into(),
669 to: "n1".into(),
670 edge_type: "self".into(),
671 weight: 1.0,
672 coactivations: 0,
673 metadata: None,
674 });
675
676 let mut builder = PackageBuilder::new("roundtrip-test", "1.0.0")
677 .description("round trip")
678 .level(3)
679 .scope("@local");
680
681 builder.content.context_graph = Some(graph);
682
683 let (manifest, content) = builder.build().unwrap();
684
685 let manifest_json = serde_json::to_string(&manifest).unwrap();
686 let content_json = serde_json::to_string(&content).unwrap();
687
688 let decoded_manifest: crate::core::context_package::manifest::PackageManifest =
689 serde_json::from_str(&manifest_json).unwrap();
690 let decoded_content: PackageContent = serde_json::from_str(&content_json).unwrap();
691
692 assert_eq!(decoded_manifest.schema_version, 2);
693 assert_eq!(decoded_manifest.conformance_level, Some(3));
694 assert!(decoded_content.context_graph.is_some());
695 let dg = decoded_content.context_graph.unwrap();
696 assert_eq!(dg.nodes.len(), 1);
697 assert_eq!(dg.edges.len(), 1);
698 }
699}