Skip to main content

kmp_application/queries/
get_node_detail.rs

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}