Skip to main content

lean_ctx/core/context_package/
builder.rs

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