Skip to main content

kmp_application/queries/
get_context_path.rs

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