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 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}