Skip to main content

kmp_application/queries/
node_centric_projection_reader.rs

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    /// Loads the bundle for a request that already carries the axes the caller
69    /// resolved, so a store able to narrow on them does not have to be asked
70    /// for what will be thrown away.
71    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        // Batch is called once (shared load), not once per role.
938        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}