Skip to main content

lean_ctx/core/context_package/
loader.rs

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