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}