Skip to main content

lean_ctx/core/context_package/
builder.rs

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    /// Mark the package `private` for the hosted registry (GL #524).
82    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        // Build the knowledge layer from the shared `KnowledgeSnapshot` so ctxpkg
94        // and the OKF exporter render the same source of truth (no parallel
95        // extractor). ctxpkg keeps the full fact history for fidelity.
96        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 kind, name, file_path, line_start, line_end, metadata FROM nodes")
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 n1.file_path, n1.name, n2.file_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    ";
487    let Ok(mut stmt) = conn.prepare(sql) else {
488        tracing::warn!("ctxpkg: failed to prepare graph edges query");
489        return Vec::new();
490    };
491
492    let Ok(rows) = stmt.query_map([], |row| {
493        Ok(GraphEdgeExport {
494            source_path: row.get(0)?,
495            source_name: row.get(1)?,
496            target_path: row.get(2)?,
497            target_name: row.get(3)?,
498            kind: row.get(4)?,
499            metadata: row.get(5)?,
500        })
501    }) else {
502        tracing::warn!("ctxpkg: failed to query graph edges");
503        return Vec::new();
504    };
505
506    let mut edges = Vec::new();
507    for row in rows {
508        match row {
509            Ok(e) => edges.push(e),
510            Err(e) => tracing::warn!("ctxpkg: skipping graph edge: {e}"),
511        }
512    }
513    edges
514}
515
516fn compute_stats(content: &PackageContent) -> PackageStats {
517    let knowledge_facts = content
518        .knowledge
519        .as_ref()
520        .map_or(0, |k| k.facts.len() as u32);
521    let graph_nodes = content.graph.as_ref().map_or(0, |g| g.nodes.len() as u32);
522    let graph_edges = content.graph.as_ref().map_or(0, |g| g.edges.len() as u32);
523    let pattern_count = content
524        .patterns
525        .as_ref()
526        .map_or(0, |p| p.patterns.len() as u32);
527    let gotcha_count = content
528        .gotchas
529        .as_ref()
530        .map_or(0, |g| g.gotchas.len() as u32);
531
532    let raw_json = serde_json::to_string(content).unwrap_or_default();
533    let compression_ratio = {
534        use flate2::Compression;
535        use flate2::write::GzEncoder;
536        use std::io::Write;
537        let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
538        let _ = encoder.write_all(raw_json.as_bytes());
539        let compressed = encoder.finish().unwrap_or_default();
540        if raw_json.is_empty() {
541            1.0
542        } else {
543            compressed.len() as f64 / raw_json.len() as f64
544        }
545    };
546
547    PackageStats {
548        knowledge_facts,
549        graph_nodes,
550        graph_edges,
551        pattern_count,
552        gotcha_count,
553        compression_ratio,
554    }
555}
556
557fn sha256_hex(data: &[u8]) -> String {
558    let mut hasher = Sha256::new();
559    hasher.update(data);
560    crate::core::agent_identity::hex_encode(&hasher.finalize())
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566    use crate::core::context_package::graph_model::{ContextEdge, ContextGraph, ContextNode};
567
568    #[test]
569    fn empty_builder_fails() {
570        let result = PackageBuilder::new("test", "1.0.0").build();
571        assert!(result.is_err());
572        assert!(result.unwrap_err().contains("no content"));
573    }
574
575    #[test]
576    fn sha256_is_deterministic() {
577        let a = sha256_hex(b"hello world");
578        let b = sha256_hex(b"hello world");
579        assert_eq!(a, b);
580        assert_eq!(a.len(), 64);
581    }
582
583    #[test]
584    fn v2_build_with_context_graph() {
585        let mut graph = ContextGraph::new();
586        graph.add_node(ContextNode::fact("n1", "test fact", "architecture"));
587        graph.add_node(ContextNode::gotcha("n2", "trigger", "resolution"));
588        graph.add_edge(ContextEdge {
589            from: "n1".into(),
590            to: "n2".into(),
591            edge_type: "has_gotcha".into(),
592            weight: 0.9,
593            coactivations: 3,
594            metadata: None,
595        });
596
597        let mut builder = PackageBuilder::new("v2-test", "1.0.0")
598            .description("v2 test package")
599            .level(2)
600            .scope("@test");
601
602        builder.content.context_graph = Some(graph);
603
604        let (manifest, content) = builder.build().unwrap();
605
606        assert_eq!(
607            manifest.schema_version,
608            crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION
609        );
610        assert_eq!(manifest.conformance_level, Some(2));
611        assert_eq!(manifest.scope.as_deref(), Some("@test"));
612        assert!(content.context_graph.is_some());
613
614        let gs = manifest.graph_summary.unwrap();
615        assert_eq!(gs.node_count, 2);
616        assert_eq!(gs.edge_count, 1);
617        assert!(gs.activation_mean.is_some());
618        assert_eq!(gs.node_types, vec!["fact", "gotcha"]);
619    }
620
621    #[test]
622    fn v1_build_without_context_graph() {
623        let mut builder = PackageBuilder::new("v1-test", "1.0.0").description("v1 test");
624
625        builder.content.knowledge = Some(KnowledgeLayer {
626            facts: vec![],
627            patterns: vec![],
628            insights: vec![],
629            exported_at: chrono::Utc::now(),
630        });
631
632        let (manifest, _content) = builder.build().unwrap();
633        assert_eq!(
634            manifest.schema_version,
635            crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION
636        );
637        assert!(manifest.conformance_level.is_none());
638        assert!(manifest.graph_summary.is_none());
639    }
640
641    #[test]
642    fn level_clamped_to_valid_range() {
643        let b = PackageBuilder::new("t", "1.0.0").level(99);
644        assert_eq!(b.level, 3);
645        let b = PackageBuilder::new("t", "1.0.0").level(0);
646        assert_eq!(b.level, 1);
647    }
648
649    #[test]
650    fn scoped_name_in_v2_build() {
651        let mut builder = PackageBuilder::new("@company/auth", "2.0.0")
652            .level(2)
653            .scope("@company");
654
655        let mut graph = ContextGraph::new();
656        graph.add_node(ContextNode::fact("n1", "jwt auth", "security"));
657        builder.content.context_graph = Some(graph);
658
659        let (manifest, _) = builder.build().unwrap();
660        assert_eq!(manifest.name, "@company/auth");
661        assert_eq!(manifest.scope.as_deref(), Some("@company"));
662    }
663
664    #[test]
665    fn v2_manifest_roundtrip_json() {
666        let mut graph = ContextGraph::new();
667        graph.add_node(ContextNode::fact("n1", "fact", "cat"));
668        graph.add_edge(ContextEdge {
669            from: "n1".into(),
670            to: "n1".into(),
671            edge_type: "self".into(),
672            weight: 1.0,
673            coactivations: 0,
674            metadata: None,
675        });
676
677        let mut builder = PackageBuilder::new("roundtrip-test", "1.0.0")
678            .description("round trip")
679            .level(3)
680            .scope("@local");
681
682        builder.content.context_graph = Some(graph);
683
684        let (manifest, content) = builder.build().unwrap();
685
686        let manifest_json = serde_json::to_string(&manifest).unwrap();
687        let content_json = serde_json::to_string(&content).unwrap();
688
689        let decoded_manifest: crate::core::context_package::manifest::PackageManifest =
690            serde_json::from_str(&manifest_json).unwrap();
691        let decoded_content: PackageContent = serde_json::from_str(&content_json).unwrap();
692
693        assert_eq!(decoded_manifest.schema_version, 2);
694        assert_eq!(decoded_manifest.conformance_level, Some(3));
695        assert!(decoded_content.context_graph.is_some());
696        let dg = decoded_content.context_graph.unwrap();
697        assert_eq!(dg.nodes.len(), 1);
698        assert_eq!(dg.edges.len(), 1);
699    }
700}