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, 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    tags: Vec<String>,
19    compatibility: CompatibilitySpec,
20    content: PackageContent,
21    project_hash: Option<String>,
22    session_id: Option<String>,
23}
24
25impl PackageBuilder {
26    pub fn new(name: &str, version: &str) -> Self {
27        Self {
28            name: name.to_string(),
29            version: version.to_string(),
30            description: String::new(),
31            author: None,
32            tags: Vec::new(),
33            compatibility: CompatibilitySpec::default(),
34            content: PackageContent::default(),
35            project_hash: None,
36            session_id: None,
37        }
38    }
39
40    pub fn description(mut self, desc: &str) -> Self {
41        self.description = desc.to_string();
42        self
43    }
44
45    pub fn author(mut self, author: &str) -> Self {
46        self.author = Some(author.to_string());
47        self
48    }
49
50    pub fn tags(mut self, tags: Vec<String>) -> Self {
51        self.tags = tags;
52        self
53    }
54
55    pub fn compatibility(mut self, spec: CompatibilitySpec) -> Self {
56        self.compatibility = spec;
57        self
58    }
59
60    pub fn project_hash(mut self, hash: &str) -> Self {
61        self.project_hash = Some(hash.to_string());
62        self
63    }
64
65    pub fn session_id(mut self, id: &str) -> Self {
66        self.session_id = Some(id.to_string());
67        self
68    }
69
70    pub fn add_knowledge_from_project(mut self, project_root: &str) -> Self {
71        let knowledge = crate::core::knowledge::ProjectKnowledge::load_or_create(project_root);
72
73        if knowledge.facts.is_empty()
74            && knowledge.patterns.is_empty()
75            && knowledge.history.is_empty()
76        {
77            return self;
78        }
79
80        self.content.knowledge = Some(KnowledgeLayer {
81            facts: knowledge.facts,
82            patterns: knowledge.patterns,
83            insights: knowledge.history,
84            exported_at: Utc::now(),
85        });
86
87        self
88    }
89
90    pub fn add_graph_from_project(mut self, project_root: &str) -> Self {
91        let Ok(graph) = crate::core::property_graph::CodeGraph::open(project_root) else {
92            return self;
93        };
94
95        let nodes = export_graph_nodes(&graph);
96        let edges = export_graph_edges(&graph);
97
98        if nodes.is_empty() && edges.is_empty() {
99            return self;
100        }
101
102        self.content.graph = Some(GraphLayer {
103            nodes,
104            edges,
105            exported_at: Utc::now(),
106        });
107
108        self
109    }
110
111    pub fn add_session(mut self, session: &crate::core::session::SessionState) -> Self {
112        let has_content = session.task.is_some()
113            || !session.findings.is_empty()
114            || !session.decisions.is_empty()
115            || !session.next_steps.is_empty()
116            || !session.files_touched.is_empty();
117
118        if !has_content {
119            return self;
120        }
121
122        let layer = SessionLayer {
123            task_description: session.task.as_ref().map(|t| t.description.clone()),
124            findings: session
125                .findings
126                .iter()
127                .map(|f| SessionFinding {
128                    summary: f.summary.clone(),
129                    file: f.file.clone(),
130                    line: f.line,
131                })
132                .collect(),
133            decisions: session
134                .decisions
135                .iter()
136                .map(|d| SessionDecision {
137                    summary: d.summary.clone(),
138                    rationale: d.rationale.clone(),
139                })
140                .collect(),
141            next_steps: session.next_steps.clone(),
142            files_touched: session
143                .files_touched
144                .iter()
145                .map(|f| f.path.clone())
146                .collect(),
147            exported_at: Utc::now(),
148        };
149
150        self.content.session = Some(layer);
151        self
152    }
153
154    pub fn add_gotchas_from_project(mut self, project_root: &str) -> Self {
155        let store = crate::core::gotcha_tracker::GotchaStore::load(project_root);
156        if store.gotchas.is_empty() {
157            return self;
158        }
159
160        self.content.gotchas = Some(GotchasLayer {
161            gotchas: store
162                .gotchas
163                .iter()
164                .map(|g| GotchaExport {
165                    id: g.id.clone(),
166                    category: g.category.short_label().to_string(),
167                    severity: match g.severity {
168                        crate::core::gotcha_tracker::GotchaSeverity::Critical => "critical".into(),
169                        crate::core::gotcha_tracker::GotchaSeverity::Warning => "warning".into(),
170                        crate::core::gotcha_tracker::GotchaSeverity::Info => "info".into(),
171                    },
172                    trigger: g.trigger.clone(),
173                    resolution: g.resolution.clone(),
174                    file_patterns: g.file_patterns.clone(),
175                    confidence: g.confidence,
176                })
177                .collect(),
178            exported_at: Utc::now(),
179        });
180
181        self
182    }
183
184    pub fn build(self) -> Result<(PackageManifest, PackageContent), String> {
185        if self.name.is_empty() {
186            return Err("package name is required".into());
187        }
188        if self.version.is_empty() {
189            return Err("package version is required".into());
190        }
191        if self.content.is_empty() {
192            return Err("package has no content — add at least one layer".into());
193        }
194
195        let mut layers = Vec::new();
196        if self.content.knowledge.is_some() {
197            layers.push(PackageLayer::Knowledge);
198        }
199        if self.content.graph.is_some() {
200            layers.push(PackageLayer::Graph);
201        }
202        if self.content.session.is_some() {
203            layers.push(PackageLayer::Session);
204        }
205        if self.content.patterns.is_some() {
206            layers.push(PackageLayer::Patterns);
207        }
208        if self.content.gotchas.is_some() {
209            layers.push(PackageLayer::Gotchas);
210        }
211
212        let content_json = serde_json::to_string(&self.content).map_err(|e| e.to_string())?;
213        let content_bytes = content_json.as_bytes();
214
215        let content_hash = sha256_hex(content_bytes);
216        let sha256 =
217            sha256_hex(format!("{}:{}:{}", self.name, self.version, content_hash).as_bytes());
218
219        let stats = compute_stats(&self.content);
220
221        let manifest = PackageManifest {
222            schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
223            name: self.name,
224            version: self.version,
225            description: self.description,
226            author: self.author,
227            created_at: Utc::now(),
228            updated_at: None,
229            layers,
230            dependencies: Vec::new(),
231            tags: self.tags,
232            integrity: PackageIntegrity {
233                sha256,
234                content_hash,
235                byte_size: content_bytes.len() as u64,
236            },
237            provenance: PackageProvenance {
238                tool: "lean-ctx".into(),
239                tool_version: env!("CARGO_PKG_VERSION").into(),
240                project_hash: self.project_hash,
241                source_session_id: self.session_id,
242            },
243            compatibility: self.compatibility,
244            stats,
245        };
246
247        manifest.validate().map_err(|errs| errs.join("; "))?;
248
249        Ok((manifest, self.content))
250    }
251}
252
253fn export_graph_nodes(graph: &crate::core::property_graph::CodeGraph) -> Vec<GraphNodeExport> {
254    let conn = graph.connection();
255    let Ok(mut stmt) =
256        conn.prepare("SELECT kind, name, file_path, line_start, line_end, metadata FROM nodes")
257    else {
258        tracing::warn!("ctxpkg: failed to prepare graph nodes query");
259        return Vec::new();
260    };
261
262    let Ok(rows) = stmt.query_map([], |row| {
263        let line_start: Option<i64> = row.get(3)?;
264        let line_end: Option<i64> = row.get(4)?;
265        Ok(GraphNodeExport {
266            kind: row.get(0)?,
267            name: row.get(1)?,
268            file_path: row.get(2)?,
269            line_start: line_start.map(|v| v as usize),
270            line_end: line_end.map(|v| v as usize),
271            metadata: row.get(5)?,
272        })
273    }) else {
274        tracing::warn!("ctxpkg: failed to query graph nodes");
275        return Vec::new();
276    };
277
278    let mut nodes = Vec::new();
279    for row in rows {
280        match row {
281            Ok(n) => nodes.push(n),
282            Err(e) => tracing::warn!("ctxpkg: skipping graph node: {e}"),
283        }
284    }
285    nodes
286}
287
288fn export_graph_edges(graph: &crate::core::property_graph::CodeGraph) -> Vec<GraphEdgeExport> {
289    let conn = graph.connection();
290    let sql = "
291        SELECT n1.file_path, n1.name, n2.file_path, n2.name, e.kind, e.metadata
292        FROM edges e
293        JOIN nodes n1 ON e.source_id = n1.id
294        JOIN nodes n2 ON e.target_id = n2.id
295    ";
296    let Ok(mut stmt) = conn.prepare(sql) else {
297        tracing::warn!("ctxpkg: failed to prepare graph edges query");
298        return Vec::new();
299    };
300
301    let Ok(rows) = stmt.query_map([], |row| {
302        Ok(GraphEdgeExport {
303            source_path: row.get(0)?,
304            source_name: row.get(1)?,
305            target_path: row.get(2)?,
306            target_name: row.get(3)?,
307            kind: row.get(4)?,
308            metadata: row.get(5)?,
309        })
310    }) else {
311        tracing::warn!("ctxpkg: failed to query graph edges");
312        return Vec::new();
313    };
314
315    let mut edges = Vec::new();
316    for row in rows {
317        match row {
318            Ok(e) => edges.push(e),
319            Err(e) => tracing::warn!("ctxpkg: skipping graph edge: {e}"),
320        }
321    }
322    edges
323}
324
325fn compute_stats(content: &PackageContent) -> PackageStats {
326    let knowledge_facts = content
327        .knowledge
328        .as_ref()
329        .map_or(0, |k| k.facts.len() as u32);
330    let graph_nodes = content.graph.as_ref().map_or(0, |g| g.nodes.len() as u32);
331    let graph_edges = content.graph.as_ref().map_or(0, |g| g.edges.len() as u32);
332    let pattern_count = content
333        .patterns
334        .as_ref()
335        .map_or(0, |p| p.patterns.len() as u32);
336    let gotcha_count = content
337        .gotchas
338        .as_ref()
339        .map_or(0, |g| g.gotchas.len() as u32);
340
341    let raw_json = serde_json::to_string(content).unwrap_or_default();
342    let compression_ratio = {
343        use flate2::write::GzEncoder;
344        use flate2::Compression;
345        use std::io::Write;
346        let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
347        let _ = encoder.write_all(raw_json.as_bytes());
348        let compressed = encoder.finish().unwrap_or_default();
349        if raw_json.is_empty() {
350            1.0
351        } else {
352            compressed.len() as f64 / raw_json.len() as f64
353        }
354    };
355
356    PackageStats {
357        knowledge_facts,
358        graph_nodes,
359        graph_edges,
360        pattern_count,
361        gotcha_count,
362        compression_ratio,
363    }
364}
365
366fn sha256_hex(data: &[u8]) -> String {
367    let mut hasher = Sha256::new();
368    hasher.update(data);
369    format!("{:x}", hasher.finalize())
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn empty_builder_fails() {
378        let result = PackageBuilder::new("test", "1.0.0").build();
379        assert!(result.is_err());
380        assert!(result.unwrap_err().contains("no content"));
381    }
382
383    #[test]
384    fn sha256_is_deterministic() {
385        let a = sha256_hex(b"hello world");
386        let b = sha256_hex(b"hello world");
387        assert_eq!(a, b);
388        assert_eq!(a.len(), 64);
389    }
390}