Skip to main content

kmp_application/queries/
get_context_path.rs

1use std::sync::Arc;
2
3use kmp_domain::{
4    BundleRelationship, GraphNeighborhoodReader, KmpBundle, NodeDetailReader, SnapshotSaveOptions,
5    SnapshotStore, directed_relationship_path,
6};
7
8use crate::ApplicationError;
9pub use crate::queries::render_graph_bundle::RenderedContext;
10use crate::queries::{
11    ContextRenderOptions, MAX_NATIVE_GRAPH_TRAVERSAL_DEPTH, NodeCentricProjectionReader,
12    QueryApplicationService, QueryTimingBreakdown, RehydrateSessionUseCase,
13    render_graph_bundle_with_options,
14};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct GetContextPathQuery {
18    pub root_node_id: String,
19    pub target_node_id: String,
20    pub role: String,
21    pub subtree_depth: Option<u32>,
22    pub render_options: ContextRenderOptions,
23}
24
25#[derive(Debug, Clone, PartialEq)]
26pub struct GetContextPathResult {
27    pub path_bundle: KmpBundle,
28    pub rendered: RenderedContext,
29    pub served_at: std::time::SystemTime,
30    pub timing: Option<QueryTimingBreakdown>,
31    root_node_id: String,
32    target_node_id: String,
33}
34
35impl GetContextPathResult {
36    /// The exact directed proof path, in hop order. `None` means the reader
37    /// explored the graph but never reached the requested target.
38    pub fn path_relationships(&self) -> Option<Vec<&BundleRelationship>> {
39        directed_relationship_path(
40            self.path_bundle.relationships(),
41            &self.root_node_id,
42            &self.target_node_id,
43        )
44    }
45
46    pub fn root_node_id(&self) -> &str {
47        &self.root_node_id
48    }
49
50    pub fn target_node_id(&self) -> &str {
51        &self.target_node_id
52    }
53}
54
55#[derive(Debug)]
56pub struct GetContextPathUseCase<G, D, S> {
57    graph_reader: G,
58    detail_reader: D,
59    snapshot_store: S,
60    generator_version: &'static str,
61}
62
63impl<G, D, S> GetContextPathUseCase<G, D, S>
64where
65    G: GraphNeighborhoodReader + Send + Sync,
66    D: NodeDetailReader + Send + Sync,
67    S: SnapshotStore + Send + Sync,
68{
69    pub fn new(
70        graph_reader: G,
71        detail_reader: D,
72        snapshot_store: S,
73        generator_version: &'static str,
74    ) -> Self {
75        Self {
76            graph_reader,
77            detail_reader,
78            snapshot_store,
79            generator_version,
80        }
81    }
82
83    pub async fn execute(
84        &self,
85        root_node_id: &str,
86        target_node_id: &str,
87        role: &str,
88        subtree_depth: Option<u32>,
89        render_options: &ContextRenderOptions,
90    ) -> Result<GetContextPathResult, ApplicationError> {
91        let root_node_id = trim_to_option(root_node_id).ok_or_else(|| {
92            ApplicationError::Validation("root_node_id cannot be empty".to_string())
93        })?;
94        let target_node_id = trim_to_option(target_node_id).ok_or_else(|| {
95            ApplicationError::Validation("target_node_id cannot be empty".to_string())
96        })?;
97        let render_options = focus_target(render_options, &target_node_id);
98        let bundle_reader =
99            NodeCentricProjectionReader::new(&self.graph_reader, &self.detail_reader);
100
101        let (bundle, timing) = match if root_node_id == target_node_id {
102            (None, None)
103        } else {
104            let (b, t) = bundle_reader
105                .load_context_path_bundle_with_depth(
106                    &root_node_id,
107                    &target_node_id,
108                    role,
109                    self.generator_version,
110                    subtree_depth.unwrap_or(MAX_NATIVE_GRAPH_TRAVERSAL_DEPTH),
111                )
112                .await?;
113            (b, Some(t))
114        } {
115            (Some(bundle), timing) => (bundle, timing),
116            (None, _) => {
117                let (bundle, timing) = RehydrateSessionUseCase::new(
118                    &self.graph_reader,
119                    &self.detail_reader,
120                    &self.snapshot_store,
121                    self.generator_version,
122                )
123                .execute(&target_node_id, role, false, SnapshotSaveOptions::default())
124                .await?;
125                (bundle, Some(timing))
126            }
127        };
128
129        let rendered = render_graph_bundle_with_options(&bundle, &render_options);
130
131        Ok(GetContextPathResult {
132            path_bundle: bundle,
133            rendered,
134            served_at: std::time::SystemTime::now(),
135            timing,
136            root_node_id,
137            target_node_id,
138        })
139    }
140}
141
142impl<G, D, S> QueryApplicationService<G, D, S>
143where
144    G: GraphNeighborhoodReader + Send + Sync,
145    D: NodeDetailReader + Send + Sync,
146    S: SnapshotStore + Send + Sync,
147{
148    pub async fn get_context_path(
149        &self,
150        query: GetContextPathQuery,
151    ) -> Result<GetContextPathResult, ApplicationError> {
152        GetContextPathUseCase::new(
153            Arc::clone(&self.graph_reader),
154            Arc::clone(&self.detail_reader),
155            Arc::clone(&self.snapshot_store),
156            self.generator_version,
157        )
158        .execute(
159            &query.root_node_id,
160            &query.target_node_id,
161            &query.role,
162            query.subtree_depth,
163            &query.render_options,
164        )
165        .await
166    }
167}
168
169fn trim_to_option(value: &str) -> Option<String> {
170    let trimmed = value.trim();
171    if trimmed.is_empty() {
172        None
173    } else {
174        Some(trimmed.to_string())
175    }
176}
177
178fn focus_target(options: &ContextRenderOptions, target_node_id: &str) -> ContextRenderOptions {
179    let mut focused = options.clone();
180    focused.focus_node_id = Some(target_node_id.to_string());
181    focused
182}
183
184#[cfg(test)]
185mod tests {
186    use std::collections::BTreeMap;
187    use std::sync::Arc;
188
189    use kmp_domain::{
190        ContextPathNeighborhood, KmpBundle, NodeDetailProjection, NodeNeighborhood, NodeProjection,
191        NodeRelationProjection, PortError, RelationExplanation, RelationSemanticClass,
192        SnapshotSaveOptions, SnapshotStore,
193    };
194    use tokio::sync::Mutex;
195
196    use super::GetContextPathUseCase;
197    use crate::ApplicationError;
198    use crate::queries::{ContextRenderOptions, DEFAULT_NATIVE_GRAPH_TRAVERSAL_DEPTH};
199
200    struct SeededGraphReader;
201
202    impl kmp_domain::GraphNeighborhoodReader for SeededGraphReader {
203        async fn load_neighborhood(
204            &self,
205            root_node_id: &str,
206            _depth: u32,
207        ) -> Result<Option<NodeNeighborhood>, PortError> {
208            Ok((root_node_id == "target-node").then_some(NodeNeighborhood {
209                root: sample_node("target-node", "task", "Target"),
210                neighbors: vec![sample_node("fallback-leaf", "task", "Fallback leaf")],
211                relations: vec![NodeRelationProjection {
212                    source_node_id: "target-node".to_string(),
213                    target_node_id: "fallback-leaf".to_string(),
214                    relation_type: "HAS_CHILD".to_string(),
215                    explanation: structural_explanation(),
216                }],
217            }))
218        }
219
220        async fn load_context_path(
221            &self,
222            root_node_id: &str,
223            target_node_id: &str,
224            _subtree_depth: u32,
225        ) -> Result<Option<ContextPathNeighborhood>, PortError> {
226            Ok(
227                (root_node_id == "root-node" && target_node_id == "target-node").then_some(
228                    ContextPathNeighborhood {
229                        root: sample_node("root-node", "mission", "Root"),
230                        neighbors: vec![
231                            sample_node("mid-node", "story", "Middle"),
232                            sample_node("target-node", "task", "Target"),
233                            sample_node("leaf-node", "artifact", "Leaf"),
234                        ],
235                        relations: vec![
236                            relation("root-node", "mid-node", "HAS_STORY"),
237                            relation("mid-node", "target-node", "HAS_TASK"),
238                            relation("target-node", "leaf-node", "HAS_ARTIFACT"),
239                        ],
240                        path_node_ids: vec![
241                            "root-node".to_string(),
242                            "mid-node".to_string(),
243                            "target-node".to_string(),
244                        ],
245                    },
246                ),
247            )
248        }
249    }
250
251    struct RecordingGraphReader {
252        neighborhood_calls: Arc<Mutex<Vec<(String, u32)>>>,
253    }
254
255    impl kmp_domain::GraphNeighborhoodReader for RecordingGraphReader {
256        async fn load_neighborhood(
257            &self,
258            root_node_id: &str,
259            depth: u32,
260        ) -> Result<Option<NodeNeighborhood>, PortError> {
261            self.neighborhood_calls
262                .lock()
263                .await
264                .push((root_node_id.to_string(), depth));
265
266            Ok((root_node_id == "target-node").then_some(NodeNeighborhood {
267                root: sample_node("target-node", "task", "Target"),
268                neighbors: vec![sample_node("fallback-leaf", "task", "Fallback leaf")],
269                relations: vec![relation("target-node", "fallback-leaf", "HAS_CHILD")],
270            }))
271        }
272
273        async fn load_context_path(
274            &self,
275            _root_node_id: &str,
276            _target_node_id: &str,
277            _subtree_depth: u32,
278        ) -> Result<Option<ContextPathNeighborhood>, PortError> {
279            Ok(None)
280        }
281    }
282
283    struct SeededDetailReader;
284
285    impl kmp_domain::NodeDetailReader for SeededDetailReader {
286        async fn load_node_detail(
287            &self,
288            node_id: &str,
289        ) -> Result<Option<NodeDetailProjection>, PortError> {
290            Ok(Some(NodeDetailProjection {
291                node_id: node_id.to_string(),
292                detail: format!("detail for {node_id}"),
293                content_hash: format!("hash-{node_id}"),
294                revision: 1,
295            }))
296        }
297
298        async fn load_node_details_batch(
299            &self,
300            node_ids: Vec<String>,
301        ) -> Result<Vec<Option<NodeDetailProjection>>, PortError> {
302            let mut results = Vec::with_capacity(node_ids.len());
303            for node_id in &node_ids {
304                results.push(self.load_node_detail(node_id).await?);
305            }
306            Ok(results)
307        }
308    }
309
310    #[derive(Debug, Default, Clone, Copy)]
311    struct NoopSnapshotStore;
312
313    impl SnapshotStore for NoopSnapshotStore {
314        async fn save_bundle_with_options(
315            &self,
316            _bundle: &KmpBundle,
317            _options: SnapshotSaveOptions,
318        ) -> Result<(), PortError> {
319            Ok(())
320        }
321    }
322
323    #[tokio::test]
324    async fn execute_builds_a_path_bundle_and_only_loads_path_details() {
325        let use_case = GetContextPathUseCase::new(
326            SeededGraphReader,
327            SeededDetailReader,
328            NoopSnapshotStore,
329            "0.1.0",
330        );
331
332        let result = use_case
333            .execute(
334                "root-node",
335                "target-node",
336                "developer",
337                None,
338                &ContextRenderOptions::default(),
339            )
340            .await
341            .expect("path context should load");
342
343        assert_eq!(result.path_bundle.root_node_id().as_str(), "root-node");
344        assert_eq!(result.path_bundle.neighbor_nodes().len(), 3);
345        assert_eq!(result.path_bundle.relationships().len(), 3);
346        assert_eq!(
347            result
348                .path_relationships()
349                .expect("directed proof path")
350                .iter()
351                .map(|edge| (edge.source_node_id(), edge.target_node_id()))
352                .collect::<Vec<_>>(),
353            vec![("root-node", "mid-node"), ("mid-node", "target-node")],
354            "the target subtree is context, not an extra proof hop"
355        );
356        assert_eq!(
357            result
358                .path_bundle
359                .node_details()
360                .iter()
361                .map(|detail| detail.node_id())
362                .collect::<Vec<_>>(),
363            vec!["root-node", "mid-node", "target-node"]
364        );
365        assert!(result.rendered.sections[1].content.contains("Target"));
366    }
367
368    #[tokio::test]
369    async fn execute_falls_back_to_target_context_when_no_path_exists() {
370        let neighborhood_calls = Arc::new(Mutex::new(Vec::new()));
371        let use_case = GetContextPathUseCase::new(
372            RecordingGraphReader {
373                neighborhood_calls: Arc::clone(&neighborhood_calls),
374            },
375            SeededDetailReader,
376            NoopSnapshotStore,
377            "0.1.0",
378        );
379
380        let result = use_case
381            .execute(
382                "root-node",
383                "target-node",
384                "developer",
385                None,
386                &ContextRenderOptions::default(),
387            )
388            .await
389            .expect("fallback context should load");
390
391        assert_eq!(result.path_bundle.root_node_id().as_str(), "target-node");
392        assert!(
393            result.path_relationships().is_none(),
394            "target fallback context must not masquerade as a path from the root"
395        );
396        assert_eq!(
397            &*neighborhood_calls.lock().await,
398            &[(
399                "target-node".to_string(),
400                DEFAULT_NATIVE_GRAPH_TRAVERSAL_DEPTH
401            )]
402        );
403    }
404
405    #[tokio::test]
406    async fn execute_rejects_blank_target_node_ids() {
407        let use_case = GetContextPathUseCase::new(
408            SeededGraphReader,
409            SeededDetailReader,
410            NoopSnapshotStore,
411            "0.1.0",
412        );
413
414        let error = use_case
415            .execute(
416                "root-node",
417                "   ",
418                "developer",
419                None,
420                &ContextRenderOptions::default(),
421            )
422            .await
423            .expect_err("blank target ids must fail");
424
425        assert!(matches!(
426            error,
427            ApplicationError::Validation(message) if message == "target_node_id cannot be empty"
428        ));
429    }
430
431    fn sample_node(node_id: &str, node_kind: &str, title: &str) -> NodeProjection {
432        NodeProjection {
433            node_id: node_id.to_string(),
434            node_kind: node_kind.to_string(),
435            title: title.to_string(),
436            summary: format!("{title} summary"),
437            status: "ACTIVE".to_string(),
438            labels: vec![node_kind.to_string()],
439            properties: BTreeMap::new(),
440            provenance: None,
441        }
442    }
443
444    fn relation(
445        source_node_id: &str,
446        target_node_id: &str,
447        relation_type: &str,
448    ) -> NodeRelationProjection {
449        NodeRelationProjection {
450            source_node_id: source_node_id.to_string(),
451            target_node_id: target_node_id.to_string(),
452            relation_type: relation_type.to_string(),
453            explanation: structural_explanation(),
454        }
455    }
456
457    fn structural_explanation() -> RelationExplanation {
458        RelationExplanation::new(RelationSemanticClass::Structural)
459    }
460}