1use std::sync::Arc;
2
3use kmp_domain::{GraphNeighborhoodReader, NodeDetailProjection, NodeDetailReader, NodeProjection};
4
5use crate::ApplicationError;
6use crate::queries::{GraphNodeView, QueryApplicationService};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct GetNodeDetailQuery {
10 pub node_id: String,
11}
12
13#[derive(Clone, PartialEq, Eq)]
14pub struct NodeDetailView {
15 pub node_id: String,
16 pub detail: String,
17 pub content_hash: String,
18 pub revision: u64,
19}
20
21#[derive(Clone, PartialEq, Eq)]
22pub struct GetNodeDetailResult {
23 pub node: GraphNodeView,
24 pub detail: Option<NodeDetailView>,
25}
26
27#[derive(Debug)]
28pub struct GetNodeDetailUseCase<G, D> {
29 graph_reader: G,
30 detail_reader: D,
31}
32
33impl<G, D> GetNodeDetailUseCase<G, D>
34where
35 G: GraphNeighborhoodReader + Send + Sync,
36 D: NodeDetailReader + Send + Sync,
37{
38 pub fn new(graph_reader: G, detail_reader: D) -> Self {
39 Self {
40 graph_reader,
41 detail_reader,
42 }
43 }
44
45 pub async fn execute(&self, node_id: &str) -> Result<GetNodeDetailResult, ApplicationError> {
46 let node_id = trim_to_option(node_id)
47 .ok_or_else(|| ApplicationError::Validation("node_id cannot be empty".to_string()))?;
48 let neighborhood = self.graph_reader.load_neighborhood(&node_id, 1).await?;
49 let Some(neighborhood) = neighborhood else {
50 return Err(ApplicationError::NotFound(format!(
51 "Node not found: {node_id}"
52 )));
53 };
54 #[allow(clippy::manual_map)]
55 let node_detail = match self.detail_reader.load_node_detail(&node_id).await? {
56 Some(projection) => Some(map_node_detail(projection)),
57 None => None,
58 };
59
60 Ok(GetNodeDetailResult {
61 node: map_node(&neighborhood.root),
62 detail: node_detail,
63 })
64 }
65}
66
67impl<G, D, S> QueryApplicationService<G, D, S>
68where
69 G: GraphNeighborhoodReader + Send + Sync,
70 D: NodeDetailReader + Send + Sync,
71{
72 pub async fn get_node_detail(
73 &self,
74 query: GetNodeDetailQuery,
75 ) -> Result<GetNodeDetailResult, ApplicationError> {
76 GetNodeDetailUseCase::new(
77 Arc::clone(&self.graph_reader),
78 Arc::clone(&self.detail_reader),
79 )
80 .execute(&query.node_id)
81 .await
82 }
83}
84
85pub(super) fn map_node(node: &NodeProjection) -> GraphNodeView {
86 GraphNodeView {
87 node_id: node.node_id.clone(),
88 node_kind: node.node_kind.clone(),
89 title: node.title.clone(),
90 summary: node.summary.clone(),
91 status: node.status.clone(),
92 labels: node.labels.clone(),
93 properties: node.properties.clone(),
94 }
95}
96
97pub(super) fn map_node_detail(projection: NodeDetailProjection) -> NodeDetailView {
98 NodeDetailView {
99 node_id: projection.node_id,
100 detail: projection.detail,
101 content_hash: projection.content_hash,
102 revision: projection.revision,
103 }
104}
105
106pub(super) fn trim_to_option(value: &str) -> Option<String> {
107 let trimmed = value.trim();
108 if trimmed.is_empty() {
109 None
110 } else {
111 Some(trimmed.to_string())
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use std::collections::BTreeMap;
118 use std::sync::Arc;
119
120 use kmp_domain::{
121 ContextPathNeighborhood, KmpBundle, NodeDetailProjection, NodeNeighborhood, NodeProjection,
122 PortError, SnapshotSaveOptions, SnapshotStore,
123 };
124 use tokio::sync::Mutex;
125
126 use super::{GetNodeDetailQuery, GetNodeDetailUseCase};
127 use crate::ApplicationError;
128
129 struct SeededGraphReader;
130
131 impl kmp_domain::GraphNeighborhoodReader for SeededGraphReader {
132 async fn load_neighborhood(
133 &self,
134 root_node_id: &str,
135 _depth: u32,
136 ) -> Result<Option<NodeNeighborhood>, PortError> {
137 match root_node_id {
138 "node-123" => Ok(Some(sample_neighborhood("node-123", "ACTIVE"))),
139 "graph-only" => Ok(Some(sample_neighborhood("graph-only", "READY"))),
140 _ => Ok(None),
141 }
142 }
143
144 async fn load_context_path(
145 &self,
146 _root_node_id: &str,
147 _target_node_id: &str,
148 _subtree_depth: u32,
149 ) -> Result<Option<ContextPathNeighborhood>, PortError> {
150 Ok(None)
151 }
152 }
153
154 struct SeededDetailReader;
155
156 impl kmp_domain::NodeDetailReader for SeededDetailReader {
157 async fn load_node_detail(
158 &self,
159 node_id: &str,
160 ) -> Result<Option<NodeDetailProjection>, PortError> {
161 Ok(match node_id {
162 "node-123" => Some(NodeDetailProjection {
163 node_id: "node-123".to_string(),
164 detail: "Expanded node detail".to_string(),
165 content_hash: "hash-123".to_string(),
166 revision: 2,
167 }),
168 "orphan-detail" => Some(NodeDetailProjection {
169 node_id: "orphan-detail".to_string(),
170 detail: "orphaned".to_string(),
171 content_hash: "hash-orphan".to_string(),
172 revision: 1,
173 }),
174 _ => None,
175 })
176 }
177
178 async fn load_node_details_batch(
179 &self,
180 node_ids: Vec<String>,
181 ) -> Result<Vec<Option<NodeDetailProjection>>, PortError> {
182 let mut results = Vec::with_capacity(node_ids.len());
183 for node_id in &node_ids {
184 results.push(self.load_node_detail(node_id).await?);
185 }
186 Ok(results)
187 }
188 }
189
190 struct RecordingGraphReader {
191 depths: Arc<Mutex<Vec<u32>>>,
192 }
193
194 impl kmp_domain::GraphNeighborhoodReader for RecordingGraphReader {
195 async fn load_neighborhood(
196 &self,
197 _root_node_id: &str,
198 depth: u32,
199 ) -> Result<Option<NodeNeighborhood>, PortError> {
200 self.depths.lock().await.push(depth);
201 Ok(Some(sample_neighborhood("node-123", "ACTIVE")))
202 }
203
204 async fn load_context_path(
205 &self,
206 _root_node_id: &str,
207 _target_node_id: &str,
208 _subtree_depth: u32,
209 ) -> Result<Option<ContextPathNeighborhood>, PortError> {
210 Ok(None)
211 }
212 }
213
214 struct EmptyDetailReader;
215
216 impl kmp_domain::NodeDetailReader for EmptyDetailReader {
217 async fn load_node_detail(
218 &self,
219 _node_id: &str,
220 ) -> Result<Option<NodeDetailProjection>, PortError> {
221 Ok(None)
222 }
223
224 async fn load_node_details_batch(
225 &self,
226 node_ids: Vec<String>,
227 ) -> Result<Vec<Option<NodeDetailProjection>>, PortError> {
228 let mut results = Vec::with_capacity(node_ids.len());
229 for node_id in &node_ids {
230 results.push(self.load_node_detail(node_id).await?);
231 }
232 Ok(results)
233 }
234 }
235
236 #[derive(Debug, Default, Clone, Copy)]
237 struct NoopSnapshotStore;
238
239 impl SnapshotStore for NoopSnapshotStore {
240 async fn save_bundle_with_options(
241 &self,
242 _bundle: &KmpBundle,
243 _options: SnapshotSaveOptions,
244 ) -> Result<(), PortError> {
245 Ok(())
246 }
247 }
248
249 #[tokio::test]
250 async fn execute_returns_graph_node_and_detail_when_both_exist() {
251 let use_case = GetNodeDetailUseCase::new(SeededGraphReader, SeededDetailReader);
252
253 let result = use_case
254 .execute("node-123")
255 .await
256 .expect("node detail should load");
257
258 assert_eq!(result.node.node_id, "node-123");
259 assert_eq!(result.node.node_kind, "task");
260 assert_eq!(result.node.title, "Node node-123");
261 assert_eq!(
262 result
263 .detail
264 .as_ref()
265 .expect("detail should exist")
266 .content_hash,
267 "hash-123"
268 );
269 }
270
271 #[tokio::test]
272 async fn execute_returns_node_metadata_when_detail_is_missing() {
273 let use_case = GetNodeDetailUseCase::new(SeededGraphReader, EmptyDetailReader);
274
275 let result = use_case
276 .execute("graph-only")
277 .await
278 .expect("graph-only node should load");
279
280 assert_eq!(result.node.node_id, "graph-only");
281 assert_eq!(result.node.status, "READY");
282 assert!(result.detail.is_none());
283 }
284
285 #[tokio::test]
286 async fn execute_returns_not_found_when_graph_node_is_missing() {
287 let use_case = GetNodeDetailUseCase::new(SeededGraphReader, SeededDetailReader);
288
289 let error = match use_case.execute("orphan-detail").await {
290 Ok(_) => panic!("orphan detail should not be enough"),
291 Err(error) => error,
292 };
293
294 assert!(matches!(
295 error,
296 ApplicationError::NotFound(message) if message == "Node not found: orphan-detail"
297 ));
298 }
299
300 #[tokio::test]
301 async fn execute_uses_single_hop_graph_lookup() {
302 let depths = Arc::new(Mutex::new(Vec::new()));
303 let use_case = GetNodeDetailUseCase::new(
304 RecordingGraphReader {
305 depths: Arc::clone(&depths),
306 },
307 EmptyDetailReader,
308 );
309
310 let result = use_case
311 .execute("node-123")
312 .await
313 .expect("node detail should load");
314
315 assert_eq!(result.node.node_id, "node-123");
316 assert_eq!(&*depths.lock().await, &[1]);
317 }
318
319 #[tokio::test]
320 async fn execute_rejects_blank_node_id() {
321 let use_case = GetNodeDetailUseCase::new(SeededGraphReader, EmptyDetailReader);
322
323 let error = match use_case.execute(" ").await {
324 Ok(_) => panic!("blank node id must be rejected"),
325 Err(error) => error,
326 };
327
328 assert!(matches!(
329 error,
330 ApplicationError::Validation(message) if message == "node_id cannot be empty"
331 ));
332 }
333
334 fn sample_neighborhood(node_id: &str, status: &str) -> NodeNeighborhood {
335 NodeNeighborhood {
336 root: NodeProjection {
337 node_id: node_id.to_string(),
338 node_kind: "task".to_string(),
339 title: format!("Node {node_id}"),
340 summary: format!("Summary for {node_id}"),
341 status: status.to_string(),
342 labels: vec!["Task".to_string()],
343 properties: BTreeMap::from([("owner".to_string(), "ops".to_string())]),
344 provenance: None,
345 },
346 neighbors: Vec::new(),
347 relations: Vec::new(),
348 }
349 }
350
351 #[tokio::test]
352 async fn query_application_service_routes_get_node_detail() {
353 let application = crate::queries::QueryApplicationService::new(
354 Arc::new(SeededGraphReader),
355 Arc::new(SeededDetailReader),
356 Arc::new(NoopSnapshotStore),
357 "0.1.0",
358 );
359
360 let result = application
361 .get_node_detail(GetNodeDetailQuery {
362 node_id: "node-123".to_string(),
363 })
364 .await
365 .expect("application should route node detail query");
366
367 assert_eq!(result.node.node_id, "node-123");
368 assert_eq!(
369 result
370 .detail
371 .as_ref()
372 .expect("detail should exist")
373 .revision,
374 2
375 );
376 }
377}