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    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}