Skip to main content

kmp_application/queries/
get_node_detail.rs

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}