1use crate::core::knowledge::ProjectKnowledge;
2use crate::core::memory_policy::MemoryPolicy;
3use crate::core::property_graph::{CodeGraph, Edge, EdgeKind, Node, NodeKind};
4
5use super::composition;
6use super::content::{GraphLayer, KnowledgeLayer, PackageContent, PatternsLayer, SessionLayer};
7use super::graph_model::ContextGraph;
8use super::manifest::PackageManifest;
9
10#[derive(Debug, Clone, Default)]
11pub struct LoadReport {
12 pub package_name: String,
13 pub package_version: String,
14 pub knowledge_facts_merged: u32,
15 pub knowledge_facts_skipped: u32,
16 pub knowledge_patterns_merged: u32,
17 pub knowledge_insights_merged: u32,
18 pub graph_nodes_imported: u32,
19 pub graph_edges_imported: u32,
20 pub gotchas_imported: u32,
21 pub patterns_imported: u32,
22 pub session_findings_merged: u32,
23 pub session_decisions_merged: u32,
24 pub v2_nodes_added: u32,
25 pub v2_nodes_updated: u32,
26 pub v2_edges_added: u32,
27 pub v2_edges_merged: u32,
28 pub v2_conflicts: Vec<String>,
29 pub warnings: Vec<String>,
30}
31
32impl std::fmt::Display for LoadReport {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 writeln!(
35 f,
36 "Package: {} v{}",
37 self.package_name, self.package_version
38 )?;
39 if self.knowledge_facts_merged > 0 || self.knowledge_facts_skipped > 0 {
40 writeln!(
41 f,
42 " Knowledge: {} facts merged, {} skipped (duplicates)",
43 self.knowledge_facts_merged, self.knowledge_facts_skipped
44 )?;
45 }
46 if self.knowledge_patterns_merged > 0 {
47 writeln!(
48 f,
49 " Patterns: {} imported",
50 self.knowledge_patterns_merged
51 )?;
52 }
53 if self.knowledge_insights_merged > 0 {
54 writeln!(
55 f,
56 " Insights: {} imported",
57 self.knowledge_insights_merged
58 )?;
59 }
60 if self.graph_nodes_imported > 0 || self.graph_edges_imported > 0 {
61 writeln!(
62 f,
63 " Graph: {} nodes, {} edges imported",
64 self.graph_nodes_imported, self.graph_edges_imported
65 )?;
66 }
67 if self.patterns_imported > 0 {
68 writeln!(
69 f,
70 " Patterns: {} imported (standalone)",
71 self.patterns_imported
72 )?;
73 }
74 if self.gotchas_imported > 0 {
75 writeln!(f, " Gotchas: {} imported", self.gotchas_imported)?;
76 }
77 if self.session_findings_merged > 0 || self.session_decisions_merged > 0 {
78 writeln!(
79 f,
80 " Session: {} findings, {} decisions imported",
81 self.session_findings_merged, self.session_decisions_merged
82 )?;
83 }
84 if self.v2_nodes_added > 0 || self.v2_nodes_updated > 0 {
85 writeln!(
86 f,
87 " Graph v2: {} nodes added, {} updated, {} edges added, {} merged",
88 self.v2_nodes_added,
89 self.v2_nodes_updated,
90 self.v2_edges_added,
91 self.v2_edges_merged,
92 )?;
93 }
94 for c in &self.v2_conflicts {
95 writeln!(f, " CONFLICT: {c}")?;
96 }
97 for w in &self.warnings {
98 writeln!(f, " WARNING: {w}")?;
99 }
100 Ok(())
101 }
102}
103
104pub fn load_package(
105 manifest: &PackageManifest,
106 content: &PackageContent,
107 project_root: &str,
108) -> Result<LoadReport, String> {
109 let mut report = LoadReport {
110 package_name: manifest.name.clone(),
111 package_version: manifest.version.clone(),
112 ..Default::default()
113 };
114
115 if let Some(ref min_ver) = manifest.compatibility.min_lean_ctx_version {
116 let current = env!("CARGO_PKG_VERSION");
117 if version_lt(current, min_ver) {
118 report.warnings.push(format!(
119 "package requires lean-ctx >= {min_ver}, current is {current}"
120 ));
121 }
122 }
123
124 if !manifest.dependencies.is_empty() {
125 for dep in &manifest.dependencies {
126 if !dep.optional {
127 report.warnings.push(format!(
128 "unresolved dependency: {} {}",
129 dep.name, dep.version_req
130 ));
131 }
132 }
133 }
134
135 if let Some(ref kl) = content.knowledge {
136 if let Err(e) = merge_knowledge(kl, project_root, manifest, &mut report) {
137 report
138 .warnings
139 .push(format!("knowledge import failed: {e}"));
140 }
141 }
142
143 if let Some(ref gl) = content.graph {
144 if let Err(e) = import_graph(gl, project_root, &mut report) {
145 report.warnings.push(format!("graph import failed: {e}"));
146 }
147 }
148
149 if let Some(ref patterns) = content.patterns {
150 if let Err(e) = import_patterns(patterns, project_root, manifest, &mut report) {
151 report.warnings.push(format!("patterns import failed: {e}"));
152 }
153 }
154
155 if let Some(ref gotchas) = content.gotchas {
156 import_gotchas(gotchas, project_root, &mut report);
157 }
158
159 if let Some(ref session) = content.session {
160 import_session(session, project_root, manifest, &mut report);
161 }
162
163 if let Some(ref incoming_graph) = content.context_graph {
164 import_v2_graph(incoming_graph, project_root, &mut report);
165 }
166
167 Ok(report)
168}
169
170fn import_v2_graph(incoming: &ContextGraph, project_root: &str, report: &mut LoadReport) {
171 let project_hash = crate::core::project_hash::hash_project_root(project_root);
172 let data_dir = match crate::core::data_dir::lean_ctx_data_dir() {
173 Ok(d) => d,
174 Err(e) => {
175 report
176 .warnings
177 .push(format!("v2 graph: data dir unavailable: {e}"));
178 return;
179 }
180 };
181 let graph_path = data_dir
182 .join("context_graph")
183 .join(format!("{project_hash}.json"));
184
185 let graph_path_str = graph_path.to_string_lossy().to_string();
186 let mut local_graph = if let Ok(data) = std::fs::read_to_string(&graph_path_str) {
187 serde_json::from_str::<ContextGraph>(&data).unwrap_or_default()
188 } else {
189 ContextGraph::default()
190 };
191
192 let merge_report = composition::merge_graphs(&mut local_graph, incoming);
193
194 report.v2_nodes_added = merge_report.nodes_added;
195 report.v2_nodes_updated = merge_report.nodes_updated;
196 report.v2_edges_added = merge_report.edges_added;
197 report.v2_edges_merged = merge_report.edges_merged;
198 report.v2_conflicts = merge_report.conflicts;
199
200 if merge_report.nodes_added > 0
201 || merge_report.nodes_updated > 0
202 || merge_report.edges_added > 0
203 {
204 match serde_json::to_string_pretty(&local_graph) {
205 Ok(json) => {
206 if let Some(parent) = graph_path.parent() {
207 let _ = std::fs::create_dir_all(parent);
208 }
209 if let Err(e) = std::fs::write(&graph_path_str, json) {
210 report.warnings.push(format!("v2 graph save failed: {e}"));
211 }
212 }
213 Err(e) => {
214 report
215 .warnings
216 .push(format!("v2 graph serialize failed: {e}"));
217 }
218 }
219 }
220}
221
222fn merge_knowledge(
223 layer: &KnowledgeLayer,
224 project_root: &str,
225 manifest: &PackageManifest,
226 report: &mut LoadReport,
227) -> Result<(), String> {
228 let mut knowledge = ProjectKnowledge::load_or_create(project_root);
229 let policy = MemoryPolicy::default();
230 let source_tag = format!("{}@{}", manifest.name, manifest.version);
231
232 for fact in &layer.facts {
233 let exists = knowledge
234 .facts
235 .iter()
236 .any(|f| f.category == fact.category && f.key == fact.key && f.value == fact.value);
237
238 if exists {
239 report.knowledge_facts_skipped += 1;
240 continue;
241 }
242
243 knowledge.remember(
244 &fact.category,
245 &fact.key,
246 &fact.value,
247 &fact.source_session,
248 fact.confidence.min(0.8),
249 &policy,
250 );
251 if let Some(last) = knowledge.facts.last_mut() {
252 last.imported_from = Some(source_tag.clone());
253 }
254 report.knowledge_facts_merged += 1;
255 }
256
257 for pattern in &layer.patterns {
258 let exists = knowledge.patterns.iter().any(|p| {
259 p.pattern_type == pattern.pattern_type && p.description == pattern.description
260 });
261
262 if !exists {
263 knowledge.patterns.push(pattern.clone());
264 report.knowledge_patterns_merged += 1;
265 }
266 }
267
268 for insight in &layer.insights {
269 let exists = knowledge
270 .history
271 .iter()
272 .any(|h| h.summary == insight.summary);
273
274 if !exists {
275 knowledge.history.push(insight.clone());
276 report.knowledge_insights_merged += 1;
277 }
278 }
279
280 knowledge.save()?;
281 Ok(())
282}
283
284fn import_graph(
285 layer: &GraphLayer,
286 project_root: &str,
287 report: &mut LoadReport,
288) -> Result<(), String> {
289 let graph = CodeGraph::open(project_root).map_err(|e| format!("graph open: {e}"))?;
290
291 for node_export in &layer.nodes {
292 let node = Node {
293 id: None,
294 kind: NodeKind::parse(&node_export.kind),
295 name: node_export.name.clone(),
296 file_path: node_export.file_path.clone(),
297 line_start: node_export.line_start,
298 line_end: node_export.line_end,
299 metadata: node_export.metadata.clone(),
300 };
301
302 match graph.upsert_node(&node) {
303 Ok(_) => report.graph_nodes_imported += 1,
304 Err(e) => {
305 report
306 .warnings
307 .push(format!("node import failed ({}): {e}", node_export.name));
308 }
309 }
310 }
311
312 for edge_export in &layer.edges {
313 let source = find_node_for_edge(&graph, &edge_export.source_path, &edge_export.source_name);
314 let target = find_node_for_edge(&graph, &edge_export.target_path, &edge_export.target_name);
315
316 match (source, target) {
317 (Some(src), Some(tgt)) => {
318 let Some(src_id) = src.id else {
319 report.warnings.push(format!(
320 "edge skipped: source node has no id ({}:{})",
321 edge_export.source_path, edge_export.source_name
322 ));
323 continue;
324 };
325 let Some(tgt_id) = tgt.id else {
326 report.warnings.push(format!(
327 "edge skipped: target node has no id ({}:{})",
328 edge_export.target_path, edge_export.target_name
329 ));
330 continue;
331 };
332
333 let edge = Edge {
334 id: None,
335 source_id: src_id,
336 target_id: tgt_id,
337 kind: EdgeKind::parse(&edge_export.kind),
338 metadata: edge_export.metadata.clone(),
339 };
340
341 match graph.upsert_edge(&edge) {
342 Ok(()) => report.graph_edges_imported += 1,
343 Err(e) => {
344 report.warnings.push(format!(
345 "edge import failed ({} -> {}): {e}",
346 edge_export.source_name, edge_export.target_name
347 ));
348 }
349 }
350 }
351 _ => {
352 report.warnings.push(format!(
353 "edge skipped: node not found ({} -> {})",
354 edge_export.source_name, edge_export.target_name
355 ));
356 }
357 }
358 }
359
360 Ok(())
361}
362
363fn find_node_for_edge(graph: &CodeGraph, file_path: &str, name: &str) -> Option<Node> {
365 if let Ok(Some(node)) = graph.get_node_by_symbol(name, file_path) {
366 return Some(node);
367 }
368 if let Ok(Some(node)) = graph.get_node_by_path(file_path) {
369 return Some(node);
370 }
371 None
372}
373
374fn import_patterns(
375 layer: &PatternsLayer,
376 project_root: &str,
377 _manifest: &PackageManifest,
378 report: &mut LoadReport,
379) -> Result<(), String> {
380 let mut knowledge = ProjectKnowledge::load_or_create(project_root);
381
382 for pattern in &layer.patterns {
383 let exists = knowledge.patterns.iter().any(|p| {
384 p.pattern_type == pattern.pattern_type && p.description == pattern.description
385 });
386
387 if !exists {
388 knowledge.patterns.push(pattern.clone());
389 report.patterns_imported += 1;
390 }
391 }
392
393 if report.patterns_imported > 0 {
394 knowledge.save()?;
395 }
396 Ok(())
397}
398
399fn import_gotchas(
400 layer: &super::content::GotchasLayer,
401 project_root: &str,
402 report: &mut LoadReport,
403) {
404 use crate::core::gotcha_tracker::{
405 Gotcha, GotchaCategory, GotchaSeverity, GotchaSource, GotchaStore,
406 };
407
408 let mut store = GotchaStore::load(project_root);
409 let before = store.gotchas.len();
410
411 for g in &layer.gotchas {
412 let dup = store.gotchas.iter().any(|e| e.id == g.id);
413 if dup {
414 continue;
415 }
416
417 let category = GotchaCategory::from_str_loose(&g.category);
418 let severity = match g.severity.as_str() {
419 "critical" => GotchaSeverity::Critical,
420 "warning" => GotchaSeverity::Warning,
421 _ => GotchaSeverity::Info,
422 };
423
424 let mut gotcha = Gotcha::new(
425 category,
426 severity,
427 &g.trigger,
428 &g.resolution,
429 GotchaSource::AgentReported {
430 session_id: "package-import".into(),
431 },
432 "package-import",
433 );
434 g.id.clone_into(&mut gotcha.id);
435 g.file_patterns.clone_into(&mut gotcha.file_patterns);
436 gotcha.confidence = g.confidence.min(0.8);
437
438 store.gotchas.push(gotcha);
439 }
440
441 report.gotchas_imported = (store.gotchas.len() - before) as u32;
442 if let Err(e) = store.save(project_root) {
443 report.warnings.push(format!("gotcha save failed: {e}"));
444 }
445}
446
447fn version_lt(current: &str, required: &str) -> bool {
448 let parse = |v: &str| -> Vec<u32> {
449 v.split('.')
450 .map(|s| s.parse::<u32>().unwrap_or(0))
451 .collect()
452 };
453 let c = parse(current);
454 let r = parse(required);
455 for i in 0..c.len().max(r.len()) {
456 let cv = c.get(i).copied().unwrap_or(0);
457 let rv = r.get(i).copied().unwrap_or(0);
458 if cv < rv {
459 return true;
460 }
461 if cv > rv {
462 return false;
463 }
464 }
465 false
466}
467
468fn import_session(
469 layer: &SessionLayer,
470 project_root: &str,
471 manifest: &PackageManifest,
472 report: &mut LoadReport,
473) {
474 let mut knowledge = ProjectKnowledge::load_or_create(project_root);
475 let policy = MemoryPolicy::default();
476 let source_tag = format!("{}@{} (session)", manifest.name, manifest.version);
477
478 for finding in &layer.findings {
479 let key = finding.file.as_deref().unwrap_or("general");
480 let exists = knowledge
481 .facts
482 .iter()
483 .any(|f| f.category == "session_finding" && f.value == finding.summary);
484 if !exists {
485 knowledge.remember(
486 "session_finding",
487 key,
488 &finding.summary,
489 &source_tag,
490 0.6,
491 &policy,
492 );
493 report.session_findings_merged += 1;
494 }
495 }
496
497 for decision in &layer.decisions {
498 let value = if let Some(ref rationale) = decision.rationale {
499 format!("{} (rationale: {})", decision.summary, rationale)
500 } else {
501 decision.summary.clone()
502 };
503 let exists = knowledge
504 .facts
505 .iter()
506 .any(|f| f.category == "session_decision" && f.value == decision.summary);
507 if !exists {
508 knowledge.remember(
509 "session_decision",
510 "decision",
511 &value,
512 &source_tag,
513 0.7,
514 &policy,
515 );
516 report.session_decisions_merged += 1;
517 }
518 }
519
520 if report.session_findings_merged > 0 || report.session_decisions_merged > 0 {
521 if let Err(e) = knowledge.save() {
522 report
523 .warnings
524 .push(format!("session knowledge save failed: {e}"));
525 }
526 }
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532 use crate::core::context_package::content::*;
533 use crate::core::context_package::manifest::*;
534 use chrono::Utc;
535
536 fn test_manifest(layers: Vec<PackageLayer>) -> PackageManifest {
537 PackageManifest {
538 schema_version: 1,
539 conformance_level: None,
540 name: "test-pkg".into(),
541 version: "1.0.0".into(),
542 description: "test".into(),
543 author: None,
544 scope: None,
545 created_at: Utc::now(),
546 updated_at: None,
547 layers,
548 dependencies: vec![],
549 tags: vec![],
550 integrity: PackageIntegrity {
551 sha256: "a".repeat(64),
552 content_hash: "b".repeat(64),
553 byte_size: 100,
554 },
555 provenance: PackageProvenance {
556 tool: "test".into(),
557 tool_version: "0.0.1".into(),
558 project_hash: None,
559 source_session_id: None,
560 },
561 compatibility: CompatibilitySpec::default(),
562 stats: PackageStats::default(),
563 signature: None,
564 graph_summary: None,
565 marketplace: None,
566 }
567 }
568
569 #[test]
570 fn version_lt_basic_comparisons() {
571 assert!(version_lt("3.5.0", "3.6.0"));
572 assert!(!version_lt("3.6.0", "3.5.0"));
573 assert!(!version_lt("3.6.0", "3.6.0"));
574 assert!(version_lt("3.6.14", "3.6.15"));
575 assert!(version_lt("2.0.0", "3.0.0"));
576 }
577
578 #[test]
579 fn compatibility_warning_when_version_too_low() {
580 let mut manifest = test_manifest(vec![PackageLayer::Knowledge]);
581 manifest.compatibility.min_lean_ctx_version = Some("99.0.0".into());
582
583 let content = PackageContent {
584 knowledge: Some(KnowledgeLayer {
585 facts: vec![],
586 patterns: vec![],
587 insights: vec![],
588 exported_at: Utc::now(),
589 }),
590 ..Default::default()
591 };
592
593 let dir = tempfile::tempdir().unwrap();
594 let report = load_package(&manifest, &content, dir.path().to_str().unwrap()).unwrap();
595 assert!(report
596 .warnings
597 .iter()
598 .any(|w| w.contains("requires lean-ctx >= 99.0.0")));
599 }
600
601 #[test]
602 fn dependency_warning_for_required_deps() {
603 let mut manifest = test_manifest(vec![PackageLayer::Knowledge]);
604 manifest.dependencies.push(PackageDependency {
605 name: "missing-pkg".into(),
606 version_req: "^1.0".into(),
607 optional: false,
608 });
609
610 let content = PackageContent {
611 knowledge: Some(KnowledgeLayer {
612 facts: vec![],
613 patterns: vec![],
614 insights: vec![],
615 exported_at: Utc::now(),
616 }),
617 ..Default::default()
618 };
619
620 let dir = tempfile::tempdir().unwrap();
621 let report = load_package(&manifest, &content, dir.path().to_str().unwrap()).unwrap();
622 assert!(report
623 .warnings
624 .iter()
625 .any(|w| w.contains("unresolved dependency: missing-pkg")));
626 }
627
628 #[test]
629 fn optional_dependency_no_warning() {
630 let mut manifest = test_manifest(vec![PackageLayer::Knowledge]);
631 manifest.dependencies.push(PackageDependency {
632 name: "optional-pkg".into(),
633 version_req: "^1.0".into(),
634 optional: true,
635 });
636
637 let content = PackageContent {
638 knowledge: Some(KnowledgeLayer {
639 facts: vec![],
640 patterns: vec![],
641 insights: vec![],
642 exported_at: Utc::now(),
643 }),
644 ..Default::default()
645 };
646
647 let dir = tempfile::tempdir().unwrap();
648 let report = load_package(&manifest, &content, dir.path().to_str().unwrap()).unwrap();
649 assert!(!report.warnings.iter().any(|w| w.contains("optional-pkg")));
650 }
651
652 #[test]
653 fn load_report_display_format() {
654 let report = LoadReport {
655 package_name: "my-pkg".into(),
656 package_version: "1.0.0".into(),
657 knowledge_facts_merged: 5,
658 knowledge_facts_skipped: 2,
659 knowledge_patterns_merged: 3,
660 knowledge_insights_merged: 1,
661 graph_nodes_imported: 10,
662 graph_edges_imported: 8,
663 gotchas_imported: 4,
664 patterns_imported: 2,
665 session_findings_merged: 3,
666 session_decisions_merged: 1,
667 v2_nodes_added: 0,
668 v2_nodes_updated: 0,
669 v2_edges_added: 0,
670 v2_edges_merged: 0,
671 v2_conflicts: vec![],
672 warnings: vec!["test warning".into()],
673 };
674
675 let display = format!("{report}");
676 assert!(display.contains("my-pkg v1.0.0"));
677 assert!(display.contains("5 facts merged"));
678 assert!(display.contains("10 nodes"));
679 assert!(display.contains("2 imported (standalone)"));
680 assert!(display.contains("WARNING: test warning"));
681 }
682
683 #[test]
684 fn v2_graph_import_creates_local_graph() {
685 use crate::core::context_package::graph_model::{ContextEdge, ContextGraph, ContextNode};
686
687 let mut graph = ContextGraph::new();
688 graph.add_node(ContextNode::fact("n1", "test fact", "arch"));
689 graph.add_node(ContextNode::gotcha("n2", "trigger", "resolution"));
690 graph.add_edge(ContextEdge {
691 from: "n1".into(),
692 to: "n2".into(),
693 edge_type: "has_gotcha".into(),
694 weight: 0.9,
695 coactivations: 5,
696 metadata: None,
697 });
698
699 let content = PackageContent {
700 context_graph: Some(graph),
701 ..Default::default()
702 };
703
704 let mut manifest = test_manifest(vec![]);
705 manifest.schema_version = 2;
706 manifest.conformance_level = Some(2);
707
708 let dir = tempfile::tempdir().unwrap();
709 let report = load_package(&manifest, &content, dir.path().to_str().unwrap()).unwrap();
710
711 assert_eq!(report.v2_nodes_added, 2);
712 assert_eq!(report.v2_edges_added, 1);
713 }
714
715 #[test]
716 fn v2_load_report_display_includes_graph() {
717 let report = LoadReport {
718 package_name: "v2-pkg".into(),
719 package_version: "2.0.0".into(),
720 v2_nodes_added: 15,
721 v2_nodes_updated: 3,
722 v2_edges_added: 20,
723 v2_edges_merged: 5,
724 v2_conflicts: vec!["conflict A".into()],
725 ..Default::default()
726 };
727
728 let display = format!("{report}");
729 assert!(display.contains("15 nodes added"));
730 assert!(display.contains("3 updated"));
731 assert!(display.contains("20 edges added"));
732 assert!(display.contains("5 merged"));
733 assert!(display.contains("CONFLICT: conflict A"));
734 }
735
736 #[test]
737 fn v2_graph_import_merges_with_existing() {
738 use crate::core::context_package::graph_model::{ContextGraph, ContextNode};
739
740 let mut first_graph = ContextGraph::new();
741 first_graph.add_node(ContextNode::fact("shared", "original", "cat"));
742
743 let first_content = PackageContent {
744 context_graph: Some(first_graph),
745 ..Default::default()
746 };
747
748 let mut manifest = test_manifest(vec![]);
749 manifest.schema_version = 2;
750
751 let dir = tempfile::tempdir().unwrap();
752 let r1 = load_package(&manifest, &first_content, dir.path().to_str().unwrap()).unwrap();
753 assert_eq!(r1.v2_nodes_added, 1);
754
755 let mut second_graph = ContextGraph::new();
756 let mut node = ContextNode::fact("shared", "updated", "cat");
757 node.activation = 0.9;
758 second_graph.add_node(node);
759 second_graph.add_node(ContextNode::fact("new_node", "new content", "cat"));
760
761 let second_content = PackageContent {
762 context_graph: Some(second_graph),
763 ..Default::default()
764 };
765
766 let r2 = load_package(&manifest, &second_content, dir.path().to_str().unwrap()).unwrap();
767 assert_eq!(r2.v2_nodes_added, 1);
768 assert_eq!(r2.v2_nodes_updated, 1);
769 }
770}