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 && let Err(e) = merge_knowledge(kl, project_root, manifest, &mut report)
137 {
138 report
139 .warnings
140 .push(format!("knowledge import failed: {e}"));
141 }
142
143 if let Some(ref gl) = content.graph
144 && let Err(e) = import_graph(gl, project_root, &mut report)
145 {
146 report.warnings.push(format!("graph import failed: {e}"));
147 }
148
149 if let Some(ref patterns) = content.patterns
150 && let Err(e) = import_patterns(patterns, project_root, manifest, &mut report)
151 {
152 report.warnings.push(format!("patterns import failed: {e}"));
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 policy = MemoryPolicy::default();
229 let source_tag = format!("{}@{}", manifest.name, manifest.version);
230
231 ProjectKnowledge::mutate_locked(project_root, |knowledge| {
235 for fact in &layer.facts {
236 let exists = knowledge
237 .facts
238 .iter()
239 .any(|f| f.category == fact.category && f.key == fact.key && f.value == fact.value);
240
241 if exists {
242 report.knowledge_facts_skipped += 1;
243 continue;
244 }
245
246 knowledge.remember(
247 &fact.category,
248 &fact.key,
249 &fact.value,
250 &fact.source_session,
251 fact.confidence.min(0.8),
252 &policy,
253 );
254 if let Some(last) = knowledge.facts.last_mut() {
255 last.imported_from = Some(source_tag.clone());
256 }
257 report.knowledge_facts_merged += 1;
258 }
259
260 for pattern in &layer.patterns {
261 let exists = knowledge.patterns.iter().any(|p| {
262 p.pattern_type == pattern.pattern_type && p.description == pattern.description
263 });
264
265 if !exists {
266 knowledge.patterns.push(pattern.clone());
267 report.knowledge_patterns_merged += 1;
268 }
269 }
270
271 for insight in &layer.insights {
272 let exists = knowledge
273 .history
274 .iter()
275 .any(|h| h.summary == insight.summary);
276
277 if !exists {
278 knowledge.history.push(insight.clone());
279 report.knowledge_insights_merged += 1;
280 }
281 }
282 })
283 .map(|_| ())
284}
285
286fn import_graph(
287 layer: &GraphLayer,
288 project_root: &str,
289 report: &mut LoadReport,
290) -> Result<(), String> {
291 let graph = CodeGraph::open(project_root).map_err(|e| format!("graph open: {e}"))?;
292
293 for node_export in &layer.nodes {
294 let node = Node {
295 id: None,
296 kind: NodeKind::parse(&node_export.kind),
297 name: node_export.name.clone(),
298 file_path: node_export.file_path.clone(),
299 line_start: node_export.line_start,
300 line_end: node_export.line_end,
301 metadata: node_export.metadata.clone(),
302 };
303
304 match graph.upsert_node(&node) {
305 Ok(_) => report.graph_nodes_imported += 1,
306 Err(e) => {
307 report
308 .warnings
309 .push(format!("node import failed ({}): {e}", node_export.name));
310 }
311 }
312 }
313
314 for edge_export in &layer.edges {
315 let source = find_node_for_edge(&graph, &edge_export.source_path, &edge_export.source_name);
316 let target = find_node_for_edge(&graph, &edge_export.target_path, &edge_export.target_name);
317
318 match (source, target) {
319 (Some(src), Some(tgt)) => {
320 let Some(src_id) = src.id else {
321 report.warnings.push(format!(
322 "edge skipped: source node has no id ({}:{})",
323 edge_export.source_path, edge_export.source_name
324 ));
325 continue;
326 };
327 let Some(tgt_id) = tgt.id else {
328 report.warnings.push(format!(
329 "edge skipped: target node has no id ({}:{})",
330 edge_export.target_path, edge_export.target_name
331 ));
332 continue;
333 };
334
335 let edge = Edge {
336 id: None,
337 source_id: src_id,
338 target_id: tgt_id,
339 kind: EdgeKind::parse(&edge_export.kind),
340 metadata: edge_export.metadata.clone(),
341 };
342
343 match graph.upsert_edge(&edge) {
344 Ok(()) => report.graph_edges_imported += 1,
345 Err(e) => {
346 report.warnings.push(format!(
347 "edge import failed ({} -> {}): {e}",
348 edge_export.source_name, edge_export.target_name
349 ));
350 }
351 }
352 }
353 _ => {
354 report.warnings.push(format!(
355 "edge skipped: node not found ({} -> {})",
356 edge_export.source_name, edge_export.target_name
357 ));
358 }
359 }
360 }
361
362 Ok(())
363}
364
365fn find_node_for_edge(graph: &CodeGraph, file_path: &str, name: &str) -> Option<Node> {
367 if let Ok(Some(node)) = graph.get_node_by_symbol(name, file_path) {
368 return Some(node);
369 }
370 if let Ok(Some(node)) = graph.get_node_by_path(file_path) {
371 return Some(node);
372 }
373 None
374}
375
376fn import_patterns(
377 layer: &PatternsLayer,
378 project_root: &str,
379 _manifest: &PackageManifest,
380 report: &mut LoadReport,
381) -> Result<(), String> {
382 ProjectKnowledge::mutate_locked(project_root, |knowledge| {
385 for pattern in &layer.patterns {
386 let exists = knowledge.patterns.iter().any(|p| {
387 p.pattern_type == pattern.pattern_type && p.description == pattern.description
388 });
389
390 if !exists {
391 knowledge.patterns.push(pattern.clone());
392 report.patterns_imported += 1;
393 }
394 }
395 })
396 .map(|_| ())
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
447pub(crate) fn 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 policy = MemoryPolicy::default();
475 let source_tag = format!("{}@{} (session)", manifest.name, manifest.version);
476
477 let outcome = ProjectKnowledge::mutate_locked(project_root, |knowledge| {
480 for finding in &layer.findings {
481 let key = finding.file.as_deref().unwrap_or("general");
482 let exists = knowledge
483 .facts
484 .iter()
485 .any(|f| f.category == "session_finding" && f.value == finding.summary);
486 if !exists {
487 knowledge.remember(
488 "session_finding",
489 key,
490 &finding.summary,
491 &source_tag,
492 0.6,
493 &policy,
494 );
495 report.session_findings_merged += 1;
496 }
497 }
498
499 for decision in &layer.decisions {
500 let value = if let Some(ref rationale) = decision.rationale {
501 format!("{} (rationale: {})", decision.summary, rationale)
502 } else {
503 decision.summary.clone()
504 };
505 let exists = knowledge
506 .facts
507 .iter()
508 .any(|f| f.category == "session_decision" && f.value == decision.summary);
509 if !exists {
510 knowledge.remember(
511 "session_decision",
512 "decision",
513 &value,
514 &source_tag,
515 0.7,
516 &policy,
517 );
518 report.session_decisions_merged += 1;
519 }
520 }
521 });
522
523 if let Err(e) = outcome {
524 report
525 .warnings
526 .push(format!("session knowledge save failed: {e}"));
527 }
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533 use crate::core::context_package::content::*;
534 use crate::core::context_package::manifest::*;
535 use chrono::Utc;
536
537 fn test_manifest(layers: Vec<PackageLayer>) -> PackageManifest {
538 PackageManifest {
539 schema_version: 1,
540 conformance_level: None,
541 kind: crate::core::context_package::manifest::PackageKind::default(),
542 name: "test-pkg".into(),
543 version: "1.0.0".into(),
544 description: "test".into(),
545 author: None,
546 scope: None,
547 created_at: Utc::now(),
548 updated_at: None,
549 layers,
550 dependencies: vec![],
551 tags: vec![],
552 visibility: None,
553 integrity: PackageIntegrity {
554 sha256: "a".repeat(64),
555 content_hash: "b".repeat(64),
556 byte_size: 100,
557 },
558 provenance: PackageProvenance {
559 tool: "test".into(),
560 tool_version: "0.0.1".into(),
561 project_hash: None,
562 source_session_id: None,
563 },
564 compatibility: CompatibilitySpec::default(),
565 stats: PackageStats::default(),
566 signature: None,
567 graph_summary: None,
568 marketplace: None,
569 }
570 }
571
572 #[test]
573 fn version_lt_basic_comparisons() {
574 assert!(version_lt("3.5.0", "3.6.0"));
575 assert!(!version_lt("3.6.0", "3.5.0"));
576 assert!(!version_lt("3.6.0", "3.6.0"));
577 assert!(version_lt("3.6.14", "3.6.15"));
578 assert!(version_lt("2.0.0", "3.0.0"));
579 }
580
581 #[test]
582 fn compatibility_warning_when_version_too_low() {
583 let mut manifest = test_manifest(vec![PackageLayer::Knowledge]);
584 manifest.compatibility.min_lean_ctx_version = Some("99.0.0".into());
585
586 let content = PackageContent {
587 knowledge: Some(KnowledgeLayer {
588 facts: vec![],
589 patterns: vec![],
590 insights: vec![],
591 exported_at: Utc::now(),
592 }),
593 ..Default::default()
594 };
595
596 let dir = tempfile::tempdir().unwrap();
597 let report = load_package(&manifest, &content, dir.path().to_str().unwrap()).unwrap();
598 assert!(
599 report
600 .warnings
601 .iter()
602 .any(|w| w.contains("requires lean-ctx >= 99.0.0"))
603 );
604 }
605
606 #[test]
607 fn dependency_warning_for_required_deps() {
608 let mut manifest = test_manifest(vec![PackageLayer::Knowledge]);
609 manifest.dependencies.push(PackageDependency {
610 name: "missing-pkg".into(),
611 version_req: "^1.0".into(),
612 optional: false,
613 });
614
615 let content = PackageContent {
616 knowledge: Some(KnowledgeLayer {
617 facts: vec![],
618 patterns: vec![],
619 insights: vec![],
620 exported_at: Utc::now(),
621 }),
622 ..Default::default()
623 };
624
625 let dir = tempfile::tempdir().unwrap();
626 let report = load_package(&manifest, &content, dir.path().to_str().unwrap()).unwrap();
627 assert!(
628 report
629 .warnings
630 .iter()
631 .any(|w| w.contains("unresolved dependency: missing-pkg"))
632 );
633 }
634
635 #[test]
636 fn optional_dependency_no_warning() {
637 let mut manifest = test_manifest(vec![PackageLayer::Knowledge]);
638 manifest.dependencies.push(PackageDependency {
639 name: "optional-pkg".into(),
640 version_req: "^1.0".into(),
641 optional: true,
642 });
643
644 let content = PackageContent {
645 knowledge: Some(KnowledgeLayer {
646 facts: vec![],
647 patterns: vec![],
648 insights: vec![],
649 exported_at: Utc::now(),
650 }),
651 ..Default::default()
652 };
653
654 let dir = tempfile::tempdir().unwrap();
655 let report = load_package(&manifest, &content, dir.path().to_str().unwrap()).unwrap();
656 assert!(!report.warnings.iter().any(|w| w.contains("optional-pkg")));
657 }
658
659 #[test]
660 fn load_report_display_format() {
661 let report = LoadReport {
662 package_name: "my-pkg".into(),
663 package_version: "1.0.0".into(),
664 knowledge_facts_merged: 5,
665 knowledge_facts_skipped: 2,
666 knowledge_patterns_merged: 3,
667 knowledge_insights_merged: 1,
668 graph_nodes_imported: 10,
669 graph_edges_imported: 8,
670 gotchas_imported: 4,
671 patterns_imported: 2,
672 session_findings_merged: 3,
673 session_decisions_merged: 1,
674 v2_nodes_added: 0,
675 v2_nodes_updated: 0,
676 v2_edges_added: 0,
677 v2_edges_merged: 0,
678 v2_conflicts: vec![],
679 warnings: vec!["test warning".into()],
680 };
681
682 let display = format!("{report}");
683 assert!(display.contains("my-pkg v1.0.0"));
684 assert!(display.contains("5 facts merged"));
685 assert!(display.contains("10 nodes"));
686 assert!(display.contains("2 imported (standalone)"));
687 assert!(display.contains("WARNING: test warning"));
688 }
689
690 #[test]
691 fn v2_graph_import_creates_local_graph() {
692 use crate::core::context_package::graph_model::{ContextEdge, ContextGraph, ContextNode};
693
694 let mut graph = ContextGraph::new();
695 graph.add_node(ContextNode::fact("n1", "test fact", "arch"));
696 graph.add_node(ContextNode::gotcha("n2", "trigger", "resolution"));
697 graph.add_edge(ContextEdge {
698 from: "n1".into(),
699 to: "n2".into(),
700 edge_type: "has_gotcha".into(),
701 weight: 0.9,
702 coactivations: 5,
703 metadata: None,
704 });
705
706 let content = PackageContent {
707 context_graph: Some(graph),
708 ..Default::default()
709 };
710
711 let mut manifest = test_manifest(vec![]);
712 manifest.schema_version = 2;
713 manifest.conformance_level = Some(2);
714
715 let dir = tempfile::tempdir().unwrap();
716 let report = load_package(&manifest, &content, dir.path().to_str().unwrap()).unwrap();
717
718 assert_eq!(report.v2_nodes_added, 2);
719 assert_eq!(report.v2_edges_added, 1);
720 }
721
722 #[test]
723 fn v2_load_report_display_includes_graph() {
724 let report = LoadReport {
725 package_name: "v2-pkg".into(),
726 package_version: "2.0.0".into(),
727 v2_nodes_added: 15,
728 v2_nodes_updated: 3,
729 v2_edges_added: 20,
730 v2_edges_merged: 5,
731 v2_conflicts: vec!["conflict A".into()],
732 ..Default::default()
733 };
734
735 let display = format!("{report}");
736 assert!(display.contains("15 nodes added"));
737 assert!(display.contains("3 updated"));
738 assert!(display.contains("20 edges added"));
739 assert!(display.contains("5 merged"));
740 assert!(display.contains("CONFLICT: conflict A"));
741 }
742
743 #[test]
744 fn v2_graph_import_merges_with_existing() {
745 use crate::core::context_package::graph_model::{ContextGraph, ContextNode};
746
747 let mut first_graph = ContextGraph::new();
748 first_graph.add_node(ContextNode::fact("shared", "original", "cat"));
749
750 let first_content = PackageContent {
751 context_graph: Some(first_graph),
752 ..Default::default()
753 };
754
755 let mut manifest = test_manifest(vec![]);
756 manifest.schema_version = 2;
757
758 let dir = tempfile::tempdir().unwrap();
759 let r1 = load_package(&manifest, &first_content, dir.path().to_str().unwrap()).unwrap();
760 assert_eq!(r1.v2_nodes_added, 1);
761
762 let mut second_graph = ContextGraph::new();
763 let mut node = ContextNode::fact("shared", "updated", "cat");
764 node.activation = 0.9;
765 second_graph.add_node(node);
766 second_graph.add_node(ContextNode::fact("new_node", "new content", "cat"));
767
768 let second_content = PackageContent {
769 context_graph: Some(second_graph),
770 ..Default::default()
771 };
772
773 let r2 = load_package(&manifest, &second_content, dir.path().to_str().unwrap()).unwrap();
774 assert_eq!(r2.v2_nodes_added, 1);
775 assert_eq!(r2.v2_nodes_updated, 1);
776 }
777}