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
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 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 name: "test-pkg".into(),
542 version: "1.0.0".into(),
543 description: "test".into(),
544 author: None,
545 scope: None,
546 created_at: Utc::now(),
547 updated_at: None,
548 layers,
549 dependencies: vec![],
550 tags: vec![],
551 visibility: None,
552 integrity: PackageIntegrity {
553 sha256: "a".repeat(64),
554 content_hash: "b".repeat(64),
555 byte_size: 100,
556 },
557 provenance: PackageProvenance {
558 tool: "test".into(),
559 tool_version: "0.0.1".into(),
560 project_hash: None,
561 source_session_id: None,
562 },
563 compatibility: CompatibilitySpec::default(),
564 stats: PackageStats::default(),
565 signature: None,
566 graph_summary: None,
567 marketplace: None,
568 }
569 }
570
571 #[test]
572 fn version_lt_basic_comparisons() {
573 assert!(version_lt("3.5.0", "3.6.0"));
574 assert!(!version_lt("3.6.0", "3.5.0"));
575 assert!(!version_lt("3.6.0", "3.6.0"));
576 assert!(version_lt("3.6.14", "3.6.15"));
577 assert!(version_lt("2.0.0", "3.0.0"));
578 }
579
580 #[test]
581 fn compatibility_warning_when_version_too_low() {
582 let mut manifest = test_manifest(vec![PackageLayer::Knowledge]);
583 manifest.compatibility.min_lean_ctx_version = Some("99.0.0".into());
584
585 let content = PackageContent {
586 knowledge: Some(KnowledgeLayer {
587 facts: vec![],
588 patterns: vec![],
589 insights: vec![],
590 exported_at: Utc::now(),
591 }),
592 ..Default::default()
593 };
594
595 let dir = tempfile::tempdir().unwrap();
596 let report = load_package(&manifest, &content, dir.path().to_str().unwrap()).unwrap();
597 assert!(
598 report
599 .warnings
600 .iter()
601 .any(|w| w.contains("requires lean-ctx >= 99.0.0"))
602 );
603 }
604
605 #[test]
606 fn dependency_warning_for_required_deps() {
607 let mut manifest = test_manifest(vec![PackageLayer::Knowledge]);
608 manifest.dependencies.push(PackageDependency {
609 name: "missing-pkg".into(),
610 version_req: "^1.0".into(),
611 optional: false,
612 });
613
614 let content = PackageContent {
615 knowledge: Some(KnowledgeLayer {
616 facts: vec![],
617 patterns: vec![],
618 insights: vec![],
619 exported_at: Utc::now(),
620 }),
621 ..Default::default()
622 };
623
624 let dir = tempfile::tempdir().unwrap();
625 let report = load_package(&manifest, &content, dir.path().to_str().unwrap()).unwrap();
626 assert!(
627 report
628 .warnings
629 .iter()
630 .any(|w| w.contains("unresolved dependency: missing-pkg"))
631 );
632 }
633
634 #[test]
635 fn optional_dependency_no_warning() {
636 let mut manifest = test_manifest(vec![PackageLayer::Knowledge]);
637 manifest.dependencies.push(PackageDependency {
638 name: "optional-pkg".into(),
639 version_req: "^1.0".into(),
640 optional: true,
641 });
642
643 let content = PackageContent {
644 knowledge: Some(KnowledgeLayer {
645 facts: vec![],
646 patterns: vec![],
647 insights: vec![],
648 exported_at: Utc::now(),
649 }),
650 ..Default::default()
651 };
652
653 let dir = tempfile::tempdir().unwrap();
654 let report = load_package(&manifest, &content, dir.path().to_str().unwrap()).unwrap();
655 assert!(!report.warnings.iter().any(|w| w.contains("optional-pkg")));
656 }
657
658 #[test]
659 fn load_report_display_format() {
660 let report = LoadReport {
661 package_name: "my-pkg".into(),
662 package_version: "1.0.0".into(),
663 knowledge_facts_merged: 5,
664 knowledge_facts_skipped: 2,
665 knowledge_patterns_merged: 3,
666 knowledge_insights_merged: 1,
667 graph_nodes_imported: 10,
668 graph_edges_imported: 8,
669 gotchas_imported: 4,
670 patterns_imported: 2,
671 session_findings_merged: 3,
672 session_decisions_merged: 1,
673 v2_nodes_added: 0,
674 v2_nodes_updated: 0,
675 v2_edges_added: 0,
676 v2_edges_merged: 0,
677 v2_conflicts: vec![],
678 warnings: vec!["test warning".into()],
679 };
680
681 let display = format!("{report}");
682 assert!(display.contains("my-pkg v1.0.0"));
683 assert!(display.contains("5 facts merged"));
684 assert!(display.contains("10 nodes"));
685 assert!(display.contains("2 imported (standalone)"));
686 assert!(display.contains("WARNING: test warning"));
687 }
688
689 #[test]
690 fn v2_graph_import_creates_local_graph() {
691 use crate::core::context_package::graph_model::{ContextEdge, ContextGraph, ContextNode};
692
693 let mut graph = ContextGraph::new();
694 graph.add_node(ContextNode::fact("n1", "test fact", "arch"));
695 graph.add_node(ContextNode::gotcha("n2", "trigger", "resolution"));
696 graph.add_edge(ContextEdge {
697 from: "n1".into(),
698 to: "n2".into(),
699 edge_type: "has_gotcha".into(),
700 weight: 0.9,
701 coactivations: 5,
702 metadata: None,
703 });
704
705 let content = PackageContent {
706 context_graph: Some(graph),
707 ..Default::default()
708 };
709
710 let mut manifest = test_manifest(vec![]);
711 manifest.schema_version = 2;
712 manifest.conformance_level = Some(2);
713
714 let dir = tempfile::tempdir().unwrap();
715 let report = load_package(&manifest, &content, dir.path().to_str().unwrap()).unwrap();
716
717 assert_eq!(report.v2_nodes_added, 2);
718 assert_eq!(report.v2_edges_added, 1);
719 }
720
721 #[test]
722 fn v2_load_report_display_includes_graph() {
723 let report = LoadReport {
724 package_name: "v2-pkg".into(),
725 package_version: "2.0.0".into(),
726 v2_nodes_added: 15,
727 v2_nodes_updated: 3,
728 v2_edges_added: 20,
729 v2_edges_merged: 5,
730 v2_conflicts: vec!["conflict A".into()],
731 ..Default::default()
732 };
733
734 let display = format!("{report}");
735 assert!(display.contains("15 nodes added"));
736 assert!(display.contains("3 updated"));
737 assert!(display.contains("20 edges added"));
738 assert!(display.contains("5 merged"));
739 assert!(display.contains("CONFLICT: conflict A"));
740 }
741
742 #[test]
743 fn v2_graph_import_merges_with_existing() {
744 use crate::core::context_package::graph_model::{ContextGraph, ContextNode};
745
746 let mut first_graph = ContextGraph::new();
747 first_graph.add_node(ContextNode::fact("shared", "original", "cat"));
748
749 let first_content = PackageContent {
750 context_graph: Some(first_graph),
751 ..Default::default()
752 };
753
754 let mut manifest = test_manifest(vec![]);
755 manifest.schema_version = 2;
756
757 let dir = tempfile::tempdir().unwrap();
758 let r1 = load_package(&manifest, &first_content, dir.path().to_str().unwrap()).unwrap();
759 assert_eq!(r1.v2_nodes_added, 1);
760
761 let mut second_graph = ContextGraph::new();
762 let mut node = ContextNode::fact("shared", "updated", "cat");
763 node.activation = 0.9;
764 second_graph.add_node(node);
765 second_graph.add_node(ContextNode::fact("new_node", "new content", "cat"));
766
767 let second_content = PackageContent {
768 context_graph: Some(second_graph),
769 ..Default::default()
770 };
771
772 let r2 = load_package(&manifest, &second_content, dir.path().to_str().unwrap()).unwrap();
773 assert_eq!(r2.v2_nodes_added, 1);
774 assert_eq!(r2.v2_nodes_updated, 1);
775 }
776}