1use std::collections::BTreeSet;
2use std::time::Instant;
3
4use kmp_domain::{
5 BundleMetadata, BundleNode, BundleNodeDetail, BundleRelationship, CaseId,
6 GraphNeighborhoodReader, KmpBundle, NeighborhoodRequest, NodeDetailReader, NodeNeighborhood,
7 NodeProjection, Role,
8};
9
10use crate::ApplicationError;
11use crate::queries::QueryTimingBreakdown;
12use crate::queries::ordered_neighborhood::ordered_neighborhood;
13use crate::queries::{
14 DEFAULT_NATIVE_GRAPH_TRAVERSAL_DEPTH, clamp_native_graph_subtree_depth,
15 clamp_native_graph_traversal_depth,
16};
17
18#[derive(Debug, Clone)]
19pub struct NodeCentricProjectionReader<G, D> {
20 graph_reader: G,
21 detail_reader: D,
22}
23
24impl<G, D> NodeCentricProjectionReader<G, D> {
25 pub fn new(graph_reader: G, detail_reader: D) -> Self {
26 Self {
27 graph_reader,
28 detail_reader,
29 }
30 }
31}
32
33impl<G, D> NodeCentricProjectionReader<G, D>
34where
35 G: GraphNeighborhoodReader + Send + Sync,
36 D: NodeDetailReader + Send + Sync,
37{
38 pub async fn load_bundle(
39 &self,
40 root_node_id: &str,
41 role: &str,
42 generator_version: &str,
43 ) -> Result<(Option<KmpBundle>, QueryTimingBreakdown), ApplicationError> {
44 self.load_bundle_with_depth(
45 root_node_id,
46 role,
47 generator_version,
48 DEFAULT_NATIVE_GRAPH_TRAVERSAL_DEPTH,
49 )
50 .await
51 }
52
53 pub async fn load_bundle_with_depth(
54 &self,
55 root_node_id: &str,
56 role: &str,
57 generator_version: &str,
58 depth: u32,
59 ) -> Result<(Option<KmpBundle>, QueryTimingBreakdown), ApplicationError> {
60 self.load_bundle_for(
61 &NeighborhoodRequest::new(root_node_id, clamp_native_graph_traversal_depth(depth)),
62 role,
63 generator_version,
64 )
65 .await
66 }
67
68 pub async fn load_bundle_for(
72 &self,
73 request: &NeighborhoodRequest,
74 role: &str,
75 generator_version: &str,
76 ) -> Result<(Option<KmpBundle>, QueryTimingBreakdown), ApplicationError> {
77 self.load_bundle_parts_for(request, role, generator_version, true)
78 .await
79 }
80
81 pub(crate) async fn load_catalogue_for(
82 &self,
83 request: &NeighborhoodRequest,
84 role: &str,
85 generator_version: &str,
86 ) -> Result<(Option<KmpBundle>, QueryTimingBreakdown), ApplicationError> {
87 self.load_bundle_parts_for(request, role, generator_version, false)
88 .await
89 }
90
91 async fn load_bundle_parts_for(
92 &self,
93 request: &NeighborhoodRequest,
94 role: &str,
95 generator_version: &str,
96 include_details: bool,
97 ) -> Result<(Option<KmpBundle>, QueryTimingBreakdown), ApplicationError> {
98 let root_node_id = request.root_node_id();
99 let graph_start = Instant::now();
100 let neighborhood = if include_details {
101 self.graph_reader.load_scoped_neighborhood(request).await?
102 } else {
103 self.graph_reader.load_neighborhood_headers(request).await?
104 };
105 let Some(neighborhood) = neighborhood else {
106 return Ok((None, QueryTimingBreakdown::not_found(graph_start.elapsed())));
107 };
108 if is_placeholder_projection_node(&neighborhood.root) {
109 return Ok((None, QueryTimingBreakdown::not_found(graph_start.elapsed())));
110 }
111 let neighborhood = ordered_neighborhood(filter_placeholder_nodes(neighborhood));
112 let graph_load = graph_start.elapsed();
113
114 let batch_size = 1 + neighborhood.neighbors.len();
115
116 let detail_start = Instant::now();
117 let node_details = if include_details {
118 load_node_details(&self.detail_reader, &neighborhood).await?
119 } else {
120 Vec::new()
121 };
122 let detail_load = detail_start.elapsed();
123
124 let assembly_start = Instant::now();
125 let bundle = build_bundle(
126 root_node_id,
127 role,
128 generator_version,
129 neighborhood,
130 node_details,
131 )?;
132 let bundle_assembly = assembly_start.elapsed();
133
134 let timing = QueryTimingBreakdown {
135 graph_load,
136 detail_load,
137 bundle_assembly,
138 role_count: 1,
139 batch_size,
140 };
141
142 Ok((Some(bundle), timing))
143 }
144
145 pub async fn load_bundles_for_roles(
146 &self,
147 root_node_id: &str,
148 roles: &[String],
149 generator_version: &str,
150 depth: u32,
151 ) -> Result<(Option<Vec<KmpBundle>>, QueryTimingBreakdown), ApplicationError> {
152 let graph_start = Instant::now();
153 let Some(neighborhood) = self
154 .graph_reader
155 .load_neighborhood(root_node_id, clamp_native_graph_traversal_depth(depth))
156 .await?
157 else {
158 return Ok((None, QueryTimingBreakdown::not_found(graph_start.elapsed())));
159 };
160 if is_placeholder_projection_node(&neighborhood.root) {
161 return Ok((None, QueryTimingBreakdown::not_found(graph_start.elapsed())));
162 }
163 let neighborhood = ordered_neighborhood(filter_placeholder_nodes(neighborhood));
164 let graph_load = graph_start.elapsed();
165
166 let batch_size = 1 + neighborhood.neighbors.len();
167
168 let detail_start = Instant::now();
169 let node_details = load_node_details(&self.detail_reader, &neighborhood).await?;
170 let detail_load = detail_start.elapsed();
171
172 let assembly_start = Instant::now();
173 let mut bundles = Vec::with_capacity(roles.len());
174 for role in roles {
175 bundles.push(build_bundle(
176 root_node_id,
177 role,
178 generator_version,
179 neighborhood.clone(),
180 node_details.clone(),
181 )?);
182 }
183 let bundle_assembly = assembly_start.elapsed();
184
185 let timing = QueryTimingBreakdown {
186 graph_load,
187 detail_load,
188 bundle_assembly,
189 role_count: roles.len(),
190 batch_size,
191 };
192
193 Ok((Some(bundles), timing))
194 }
195
196 pub async fn load_context_path_bundle_with_depth(
197 &self,
198 root_node_id: &str,
199 target_node_id: &str,
200 role: &str,
201 generator_version: &str,
202 subtree_depth: u32,
203 ) -> Result<(Option<KmpBundle>, QueryTimingBreakdown), ApplicationError> {
204 let graph_start = Instant::now();
205 let Some(path_neighborhood) = self
206 .graph_reader
207 .load_context_path(
208 root_node_id,
209 target_node_id,
210 clamp_native_graph_subtree_depth(subtree_depth),
211 )
212 .await?
213 else {
214 return Ok((None, QueryTimingBreakdown::not_found(graph_start.elapsed())));
215 };
216
217 if is_placeholder_projection_node(&path_neighborhood.root) {
218 return Ok((None, QueryTimingBreakdown::not_found(graph_start.elapsed())));
219 }
220
221 let neighborhood = filter_placeholder_nodes(NodeNeighborhood {
222 root: path_neighborhood.root,
223 neighbors: path_neighborhood.neighbors,
224 relations: path_neighborhood.relations,
225 });
226 let allowed_node_ids: BTreeSet<String> = std::iter::once(neighborhood.root.node_id.clone())
227 .chain(
228 neighborhood
229 .neighbors
230 .iter()
231 .map(|node| node.node_id.clone()),
232 )
233 .collect();
234 let path_node_ids = path_neighborhood
235 .path_node_ids
236 .into_iter()
237 .filter(|node_id| allowed_node_ids.contains(node_id))
238 .collect::<Vec<_>>();
239 let neighborhood = ordered_neighborhood(neighborhood);
240 let graph_load = graph_start.elapsed();
241
242 let batch_size = path_node_ids.len();
243
244 let detail_start = Instant::now();
245 let node_details = load_node_details_for_ids(&self.detail_reader, path_node_ids).await?;
246 let detail_load = detail_start.elapsed();
247
248 let assembly_start = Instant::now();
249 let bundle = build_bundle(
250 root_node_id,
251 role,
252 generator_version,
253 neighborhood,
254 node_details,
255 )?;
256 let bundle_assembly = assembly_start.elapsed();
257
258 let timing = QueryTimingBreakdown {
259 graph_load,
260 detail_load,
261 bundle_assembly,
262 role_count: 1,
263 batch_size,
264 };
265
266 Ok((Some(bundle), timing))
267 }
268}
269
270fn filter_placeholder_nodes(neighborhood: NodeNeighborhood) -> NodeNeighborhood {
271 let placeholder_ids: BTreeSet<String> = neighborhood
272 .neighbors
273 .iter()
274 .filter(|node| is_placeholder_projection_node(node))
275 .map(|node| node.node_id.clone())
276 .collect();
277
278 if placeholder_ids.is_empty() {
279 return neighborhood;
280 }
281
282 NodeNeighborhood {
283 root: neighborhood.root,
284 neighbors: neighborhood
285 .neighbors
286 .into_iter()
287 .filter(|node| !placeholder_ids.contains(&node.node_id))
288 .collect(),
289 relations: neighborhood
290 .relations
291 .into_iter()
292 .filter(|relation| {
293 !placeholder_ids.contains(&relation.source_node_id)
294 && !placeholder_ids.contains(&relation.target_node_id)
295 })
296 .collect(),
297 }
298}
299
300fn is_placeholder_projection_node(node: &NodeProjection) -> bool {
301 node.node_kind == "placeholder"
302 || node.labels.iter().any(|label| label == "placeholder")
303 || node
304 .properties
305 .get("placeholder")
306 .is_some_and(|value| value == "true")
307}
308
309async fn load_node_details<D>(
310 detail_reader: &D,
311 neighborhood: &NodeNeighborhood,
312) -> Result<Vec<BundleNodeDetail>, kmp_domain::PortError>
313where
314 D: NodeDetailReader + Send + Sync,
315{
316 load_node_details_for_ids(
317 detail_reader,
318 std::iter::once(neighborhood.root.node_id.clone())
319 .chain(
320 neighborhood
321 .neighbors
322 .iter()
323 .map(|node| node.node_id.clone()),
324 )
325 .collect::<Vec<_>>(),
326 )
327 .await
328}
329
330async fn load_node_details_for_ids<D, I>(
331 detail_reader: &D,
332 node_ids: I,
333) -> Result<Vec<BundleNodeDetail>, kmp_domain::PortError>
334where
335 D: NodeDetailReader + Send + Sync,
336 I: IntoIterator<Item = String>,
337{
338 let mut seen = BTreeSet::new();
339 let unique_ids: Vec<String> = node_ids
340 .into_iter()
341 .filter(|id| seen.insert(id.clone()))
342 .collect();
343
344 let batch_results = detail_reader.load_node_details_batch(unique_ids).await?;
345
346 Ok(batch_results
347 .into_iter()
348 .flatten()
349 .map(|detail| BundleNodeDetail::from_projection(&detail))
350 .collect())
351}
352
353fn build_bundle(
354 root_node_id: &str,
355 role: &str,
356 generator_version: &str,
357 neighborhood: NodeNeighborhood,
358 node_details: Vec<BundleNodeDetail>,
359) -> Result<KmpBundle, ApplicationError> {
360 let root_node_id = CaseId::new(root_node_id)?;
361 let role = Role::new(role)?;
362
363 Ok(KmpBundle::new(
364 root_node_id,
365 role,
366 BundleNode::from_projection(&neighborhood.root),
367 neighborhood
368 .neighbors
369 .iter()
370 .map(BundleNode::from_projection)
371 .collect(),
372 neighborhood
373 .relations
374 .iter()
375 .map(BundleRelationship::from_projection)
376 .collect(),
377 node_details,
378 BundleMetadata::initial(generator_version),
379 )?)
380}
381
382#[cfg(test)]
383mod tests {
384 use std::collections::BTreeMap;
385 use std::sync::Arc;
386
387 use kmp_domain::{
388 ContextPathNeighborhood, NodeDetailProjection, NodeNeighborhood, NodeProjection,
389 NodeRelationProjection, PortError, RelationExplanation, RelationSemanticClass,
390 };
391 use tokio::sync::Mutex;
392
393 use super::NodeCentricProjectionReader;
394 use crate::queries::DEFAULT_NATIVE_GRAPH_TRAVERSAL_DEPTH;
395
396 struct StubGraphReader;
397
398 impl kmp_domain::GraphNeighborhoodReader for StubGraphReader {
399 async fn load_neighborhood(
400 &self,
401 _root_node_id: &str,
402 _depth: u32,
403 ) -> Result<Option<NodeNeighborhood>, PortError> {
404 Ok(Some(NodeNeighborhood {
405 root: NodeProjection {
406 node_id: "node-root".to_string(),
407 node_kind: "case".to_string(),
408 title: "Root".to_string(),
409 summary: "Root summary".to_string(),
410 status: "ACTIVE".to_string(),
411 labels: vec!["ProjectionNode".to_string()],
412 properties: BTreeMap::new(),
413 provenance: None,
414 },
415 neighbors: vec![NodeProjection {
416 node_id: "node-1".to_string(),
417 node_kind: "decision".to_string(),
418 title: "Neighbor".to_string(),
419 summary: "Neighbor summary".to_string(),
420 status: "ACTIVE".to_string(),
421 labels: vec!["ProjectionNode".to_string()],
422 properties: BTreeMap::new(),
423 provenance: None,
424 }],
425 relations: vec![NodeRelationProjection {
426 source_node_id: "node-root".to_string(),
427 target_node_id: "node-1".to_string(),
428 relation_type: "RELATES_TO".to_string(),
429 explanation: structural_explanation(),
430 }],
431 }))
432 }
433
434 async fn load_context_path(
435 &self,
436 root_node_id: &str,
437 target_node_id: &str,
438 _subtree_depth: u32,
439 ) -> Result<Option<ContextPathNeighborhood>, PortError> {
440 Ok(
441 (root_node_id == "node-root" && target_node_id == "node-1").then_some(
442 ContextPathNeighborhood {
443 root: NodeProjection {
444 node_id: "node-root".to_string(),
445 node_kind: "case".to_string(),
446 title: "Root".to_string(),
447 summary: "Root summary".to_string(),
448 status: "ACTIVE".to_string(),
449 labels: vec!["ProjectionNode".to_string()],
450 properties: BTreeMap::new(),
451 provenance: None,
452 },
453 neighbors: vec![
454 NodeProjection {
455 node_id: "node-1".to_string(),
456 node_kind: "decision".to_string(),
457 title: "Neighbor".to_string(),
458 summary: "Neighbor summary".to_string(),
459 status: "ACTIVE".to_string(),
460 labels: vec!["ProjectionNode".to_string()],
461 properties: BTreeMap::new(),
462 provenance: None,
463 },
464 NodeProjection {
465 node_id: "node-2".to_string(),
466 node_kind: "artifact".to_string(),
467 title: "Leaf".to_string(),
468 summary: "Leaf summary".to_string(),
469 status: "READY".to_string(),
470 labels: vec!["ProjectionNode".to_string()],
471 properties: BTreeMap::new(),
472 provenance: None,
473 },
474 ],
475 relations: vec![
476 NodeRelationProjection {
477 source_node_id: "node-root".to_string(),
478 target_node_id: "node-1".to_string(),
479 relation_type: "RELATES_TO".to_string(),
480 explanation: structural_explanation(),
481 },
482 NodeRelationProjection {
483 source_node_id: "node-1".to_string(),
484 target_node_id: "node-2".to_string(),
485 relation_type: "HAS_ARTIFACT".to_string(),
486 explanation: structural_explanation(),
487 },
488 ],
489 path_node_ids: vec!["node-root".to_string(), "node-1".to_string()],
490 },
491 ),
492 )
493 }
494 }
495
496 struct StubDetailReader;
497
498 impl kmp_domain::NodeDetailReader for StubDetailReader {
499 async fn load_node_detail(
500 &self,
501 node_id: &str,
502 ) -> Result<Option<NodeDetailProjection>, PortError> {
503 Ok((node_id == "node-root").then(|| NodeDetailProjection {
504 node_id: node_id.to_string(),
505 detail: "Expanded detail".to_string(),
506 content_hash: "hash-1".to_string(),
507 revision: 2,
508 }))
509 }
510
511 async fn load_node_details_batch(
512 &self,
513 node_ids: Vec<String>,
514 ) -> Result<Vec<Option<NodeDetailProjection>>, PortError> {
515 let mut results = Vec::with_capacity(node_ids.len());
516 for node_id in &node_ids {
517 results.push(self.load_node_detail(node_id).await?);
518 }
519 Ok(results)
520 }
521 }
522
523 #[tokio::test]
524 async fn load_bundle_returns_graph_native_bundle() {
525 let reader = NodeCentricProjectionReader::new(StubGraphReader, StubDetailReader);
526 let (bundle, _timing) = reader
527 .load_bundle("node-root", "developer", "0.1.0")
528 .await
529 .expect("bundle load should succeed");
530 let bundle = bundle.expect("bundle should exist");
531
532 assert_eq!(bundle.root_node().node_id(), "node-root");
533 assert_eq!(bundle.neighbor_nodes().len(), 1);
534 assert_eq!(bundle.relationships().len(), 1);
535 assert_eq!(bundle.node_details().len(), 1);
536 }
537
538 struct PlaceholderRootGraphReader;
539
540 impl kmp_domain::GraphNeighborhoodReader for PlaceholderRootGraphReader {
541 async fn load_neighborhood(
542 &self,
543 _root_node_id: &str,
544 _depth: u32,
545 ) -> Result<Option<NodeNeighborhood>, PortError> {
546 Ok(Some(NodeNeighborhood {
547 root: NodeProjection {
548 node_id: "node-root".to_string(),
549 node_kind: "placeholder".to_string(),
550 title: "[unmaterialized node]".to_string(),
551 summary: "Referenced by relation before node materialization".to_string(),
552 status: "UNMATERIALIZED".to_string(),
553 labels: vec!["placeholder".to_string()],
554 properties: BTreeMap::from([("placeholder".to_string(), "true".to_string())]),
555 provenance: None,
556 },
557 neighbors: Vec::new(),
558 relations: Vec::new(),
559 }))
560 }
561
562 async fn load_context_path(
563 &self,
564 _root_node_id: &str,
565 _target_node_id: &str,
566 _subtree_depth: u32,
567 ) -> Result<Option<ContextPathNeighborhood>, PortError> {
568 Ok(None)
569 }
570 }
571
572 #[tokio::test]
573 async fn load_bundle_returns_none_for_placeholder_root() {
574 let reader = NodeCentricProjectionReader::new(PlaceholderRootGraphReader, StubDetailReader);
575 let (bundle, _timing) = reader
576 .load_bundle("node-root", "developer", "0.1.0")
577 .await
578 .expect("bundle load should succeed");
579
580 assert!(bundle.is_none());
581 }
582
583 struct PlaceholderNeighborGraphReader;
584
585 impl kmp_domain::GraphNeighborhoodReader for PlaceholderNeighborGraphReader {
586 async fn load_neighborhood(
587 &self,
588 _root_node_id: &str,
589 _depth: u32,
590 ) -> Result<Option<NodeNeighborhood>, PortError> {
591 Ok(Some(NodeNeighborhood {
592 root: NodeProjection {
593 node_id: "node-root".to_string(),
594 node_kind: "incident".to_string(),
595 title: "Incident".to_string(),
596 summary: "Root summary".to_string(),
597 status: "ACTIVE".to_string(),
598 labels: vec!["incident".to_string()],
599 properties: BTreeMap::new(),
600 provenance: None,
601 },
602 neighbors: vec![
603 NodeProjection {
604 node_id: "node-real".to_string(),
605 node_kind: "decision".to_string(),
606 title: "Real node".to_string(),
607 summary: "Real summary".to_string(),
608 status: "ACTIVE".to_string(),
609 labels: vec!["decision".to_string()],
610 properties: BTreeMap::new(),
611 provenance: None,
612 },
613 NodeProjection {
614 node_id: "node-placeholder".to_string(),
615 node_kind: "placeholder".to_string(),
616 title: "[unmaterialized node]".to_string(),
617 summary: "Referenced by relation before node materialization".to_string(),
618 status: "UNMATERIALIZED".to_string(),
619 labels: vec!["placeholder".to_string()],
620 properties: BTreeMap::from([(
621 "placeholder".to_string(),
622 "true".to_string(),
623 )]),
624 provenance: None,
625 },
626 ],
627 relations: vec![
628 NodeRelationProjection {
629 source_node_id: "node-root".to_string(),
630 target_node_id: "node-real".to_string(),
631 relation_type: "RELATES_TO".to_string(),
632 explanation: structural_explanation(),
633 },
634 NodeRelationProjection {
635 source_node_id: "node-root".to_string(),
636 target_node_id: "node-placeholder".to_string(),
637 relation_type: "RELATES_TO".to_string(),
638 explanation: structural_explanation(),
639 },
640 ],
641 }))
642 }
643
644 async fn load_context_path(
645 &self,
646 _root_node_id: &str,
647 _target_node_id: &str,
648 _subtree_depth: u32,
649 ) -> Result<Option<ContextPathNeighborhood>, PortError> {
650 Ok(None)
651 }
652 }
653
654 struct PlaceholderDetailReader;
655
656 impl kmp_domain::NodeDetailReader for PlaceholderDetailReader {
657 async fn load_node_detail(
658 &self,
659 node_id: &str,
660 ) -> Result<Option<NodeDetailProjection>, PortError> {
661 Ok(match node_id {
662 "node-root" => Some(NodeDetailProjection {
663 node_id: node_id.to_string(),
664 detail: "Expanded detail".to_string(),
665 content_hash: "hash-root".to_string(),
666 revision: 1,
667 }),
668 "node-real" => Some(NodeDetailProjection {
669 node_id: node_id.to_string(),
670 detail: "Real detail".to_string(),
671 content_hash: "hash-real".to_string(),
672 revision: 1,
673 }),
674 "node-placeholder" => Some(NodeDetailProjection {
675 node_id: node_id.to_string(),
676 detail: "Placeholder detail".to_string(),
677 content_hash: "hash-placeholder".to_string(),
678 revision: 1,
679 }),
680 _ => None,
681 })
682 }
683
684 async fn load_node_details_batch(
685 &self,
686 node_ids: Vec<String>,
687 ) -> Result<Vec<Option<NodeDetailProjection>>, PortError> {
688 let mut results = Vec::with_capacity(node_ids.len());
689 for node_id in &node_ids {
690 results.push(self.load_node_detail(node_id).await?);
691 }
692 Ok(results)
693 }
694 }
695
696 #[tokio::test]
697 async fn load_bundle_filters_placeholder_neighbors_relations_and_details() {
698 let reader = NodeCentricProjectionReader::new(
699 PlaceholderNeighborGraphReader,
700 PlaceholderDetailReader,
701 );
702 let (bundle, _timing) = reader
703 .load_bundle("node-root", "developer", "0.1.0")
704 .await
705 .expect("bundle load should succeed");
706 let bundle = bundle.expect("bundle should exist");
707
708 assert_eq!(bundle.neighbor_nodes().len(), 1);
709 assert_eq!(bundle.neighbor_nodes()[0].node_id(), "node-real");
710 assert_eq!(bundle.relationships().len(), 1);
711 assert_eq!(bundle.relationships()[0].target_node_id(), "node-real");
712 assert_eq!(
713 bundle
714 .node_details()
715 .iter()
716 .map(|detail| detail.node_id())
717 .collect::<Vec<_>>(),
718 vec!["node-root", "node-real"]
719 );
720 }
721
722 struct RecordingGraphReader {
723 depths: Arc<Mutex<Vec<u32>>>,
724 }
725
726 impl kmp_domain::GraphNeighborhoodReader for RecordingGraphReader {
727 async fn load_neighborhood(
728 &self,
729 _root_node_id: &str,
730 depth: u32,
731 ) -> Result<Option<NodeNeighborhood>, PortError> {
732 self.depths.lock().await.push(depth);
733 Ok(None)
734 }
735
736 async fn load_context_path(
737 &self,
738 _root_node_id: &str,
739 _target_node_id: &str,
740 subtree_depth: u32,
741 ) -> Result<Option<ContextPathNeighborhood>, PortError> {
742 self.depths.lock().await.push(subtree_depth);
743 Ok(None)
744 }
745 }
746
747 #[tokio::test]
748 async fn load_bundle_uses_default_graph_traversal_depth() {
749 let depths = Arc::new(Mutex::new(Vec::new()));
750 let reader = NodeCentricProjectionReader::new(
751 RecordingGraphReader {
752 depths: Arc::clone(&depths),
753 },
754 StubDetailReader,
755 );
756
757 let (bundle, _timing) = reader
758 .load_bundle("node-root", "developer", "0.1.0")
759 .await
760 .expect("bundle load should succeed");
761
762 assert!(bundle.is_none());
763 assert_eq!(
764 &*depths.lock().await,
765 &[DEFAULT_NATIVE_GRAPH_TRAVERSAL_DEPTH]
766 );
767 }
768
769 #[tokio::test]
770 async fn load_context_path_bundle_allows_zero_subtree_depth() {
771 let depths = Arc::new(Mutex::new(Vec::new()));
772 let reader = NodeCentricProjectionReader::new(
773 RecordingGraphReader {
774 depths: Arc::clone(&depths),
775 },
776 StubDetailReader,
777 );
778
779 let (bundle, _timing) = reader
780 .load_context_path_bundle_with_depth("node-root", "node-1", "developer", "0.1.0", 0)
781 .await
782 .expect("path bundle load should not reject zero subtree depth");
783
784 assert!(bundle.is_none());
785 assert_eq!(&*depths.lock().await, &[0]);
786 }
787
788 #[tokio::test]
789 async fn load_context_path_bundle_only_includes_details_for_path_nodes() {
790 let reader = NodeCentricProjectionReader::new(StubGraphReader, StubDetailReader);
791 let (bundle, _timing) = reader
792 .load_context_path_bundle_with_depth("node-root", "node-1", "developer", "0.1.0", 4)
793 .await
794 .expect("path bundle load should succeed");
795 let bundle = bundle.expect("bundle should exist");
796
797 assert_eq!(bundle.root_node().node_id(), "node-root");
798 assert_eq!(bundle.neighbor_nodes().len(), 2);
799 assert_eq!(bundle.relationships().len(), 2);
800 assert_eq!(
801 bundle
802 .node_details()
803 .iter()
804 .map(|detail| detail.node_id())
805 .collect::<Vec<_>>(),
806 vec!["node-root"]
807 );
808 }
809
810 fn structural_explanation() -> RelationExplanation {
811 RelationExplanation::new(RelationSemanticClass::Structural)
812 }
813
814 #[tokio::test]
815 async fn load_bundles_for_roles_returns_none_when_node_missing() {
816 let reader = NodeCentricProjectionReader::new(
817 RecordingGraphReader {
818 depths: Arc::new(Mutex::new(Vec::new())),
819 },
820 StubDetailReader,
821 );
822
823 let (result, _timing) = reader
824 .load_bundles_for_roles("nonexistent", &["dev".to_string()], "0.1.0", 3)
825 .await
826 .expect("should not error");
827
828 assert!(result.is_none());
829 }
830
831 #[tokio::test]
832 async fn load_bundles_for_roles_single_role_matches_load_bundle() {
833 let reader = NodeCentricProjectionReader::new(StubGraphReader, StubDetailReader);
834
835 let (single, _timing) = reader
836 .load_bundle("node-root", "developer", "0.1.0")
837 .await
838 .expect("single load should succeed");
839 let single = single.expect("bundle should exist");
840
841 let (multi, _timing) = reader
842 .load_bundles_for_roles(
843 "node-root",
844 &["developer".to_string()],
845 "0.1.0",
846 DEFAULT_NATIVE_GRAPH_TRAVERSAL_DEPTH,
847 )
848 .await
849 .expect("multi load should succeed");
850 let multi = multi.expect("bundles should exist");
851
852 assert_eq!(multi.len(), 1);
853 assert_eq!(multi[0].root_node().node_id(), single.root_node().node_id());
854 assert_eq!(
855 multi[0].neighbor_nodes().len(),
856 single.neighbor_nodes().len()
857 );
858 assert_eq!(multi[0].relationships().len(), single.relationships().len());
859 assert_eq!(multi[0].node_details().len(), single.node_details().len());
860 }
861
862 #[tokio::test]
863 async fn load_bundles_for_roles_produces_correct_count() {
864 let reader = NodeCentricProjectionReader::new(
865 StubGraphReader,
866 CountingDetailReader {
867 call_count: Arc::new(Mutex::new(0)),
868 },
869 );
870
871 let (bundles, _timing) = reader
872 .load_bundles_for_roles(
873 "node-root",
874 &["dev".to_string(), "reviewer".to_string(), "ops".to_string()],
875 "0.1.0",
876 DEFAULT_NATIVE_GRAPH_TRAVERSAL_DEPTH,
877 )
878 .await
879 .expect("should succeed");
880 let bundles = bundles.expect("bundles should exist");
881
882 assert_eq!(bundles.len(), 3);
883 }
884
885 struct CountingDetailReader {
886 call_count: Arc<Mutex<u32>>,
887 }
888
889 impl kmp_domain::NodeDetailReader for CountingDetailReader {
890 async fn load_node_detail(
891 &self,
892 node_id: &str,
893 ) -> Result<Option<NodeDetailProjection>, PortError> {
894 Ok(Some(NodeDetailProjection {
895 node_id: node_id.to_string(),
896 detail: "detail".to_string(),
897 content_hash: "hash".to_string(),
898 revision: 1,
899 }))
900 }
901
902 async fn load_node_details_batch(
903 &self,
904 node_ids: Vec<String>,
905 ) -> Result<Vec<Option<NodeDetailProjection>>, PortError> {
906 *self.call_count.lock().await += 1;
907 let mut results = Vec::with_capacity(node_ids.len());
908 for node_id in &node_ids {
909 results.push(self.load_node_detail(node_id).await?);
910 }
911 Ok(results)
912 }
913 }
914
915 #[tokio::test]
916 async fn batch_detail_reader_called_once_for_multi_role() {
917 let call_count = Arc::new(Mutex::new(0u32));
918 let reader = NodeCentricProjectionReader::new(
919 StubGraphReader,
920 CountingDetailReader {
921 call_count: Arc::clone(&call_count),
922 },
923 );
924
925 let (bundles, timing) = reader
926 .load_bundles_for_roles(
927 "node-root",
928 &["dev".to_string(), "reviewer".to_string()],
929 "0.1.0",
930 DEFAULT_NATIVE_GRAPH_TRAVERSAL_DEPTH,
931 )
932 .await
933 .expect("should succeed");
934 let bundles = bundles.expect("bundles should exist");
935
936 assert_eq!(bundles.len(), 2);
937 assert_eq!(*call_count.lock().await, 1);
939 assert_eq!(timing.role_count, 2);
940 assert!(timing.batch_size > 0);
941 }
942
943 struct NoDetailReader;
944
945 impl kmp_domain::NodeDetailReader for NoDetailReader {
946 async fn load_node_detail(
947 &self,
948 _node_id: &str,
949 ) -> Result<Option<NodeDetailProjection>, PortError> {
950 Ok(None)
951 }
952
953 async fn load_node_details_batch(
954 &self,
955 node_ids: Vec<String>,
956 ) -> Result<Vec<Option<NodeDetailProjection>>, PortError> {
957 Ok(vec![None; node_ids.len()])
958 }
959 }
960
961 #[tokio::test]
962 async fn load_bundle_with_no_details_returns_empty_details() {
963 let reader = NodeCentricProjectionReader::new(StubGraphReader, NoDetailReader);
964
965 let (bundle, _timing) = reader
966 .load_bundle("node-root", "developer", "0.1.0")
967 .await
968 .expect("should succeed");
969 let bundle = bundle.expect("bundle should exist");
970
971 assert_eq!(bundle.root_node().node_id(), "node-root");
972 assert_eq!(bundle.neighbor_nodes().len(), 1);
973 assert!(bundle.node_details().is_empty());
974 }
975}