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