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