Skip to main content

kmp_application/queries/
get_node_relationships.rs

1use std::sync::Arc;
2
3use kmp_domain::{NodeRelationProjection, NodeRelationshipReader};
4
5use crate::ApplicationError;
6use crate::queries::{GraphRelationshipView, QueryApplicationService};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct GetNodeRelationshipsQuery {
10    pub node_id: String,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct GetNodeRelationshipsResult {
15    pub incoming: Vec<GraphRelationshipView>,
16    pub outgoing: Vec<GraphRelationshipView>,
17    pub observed_at: std::time::SystemTime,
18}
19
20#[derive(Debug)]
21pub struct GetNodeRelationshipsUseCase<G> {
22    relationship_reader: G,
23}
24
25impl<G> GetNodeRelationshipsUseCase<G>
26where
27    G: NodeRelationshipReader + Send + Sync,
28{
29    pub fn new(relationship_reader: G) -> Self {
30        Self {
31            relationship_reader,
32        }
33    }
34
35    pub async fn execute(
36        &self,
37        query: GetNodeRelationshipsQuery,
38    ) -> Result<GetNodeRelationshipsResult, ApplicationError> {
39        let node_id = trim_to_option(&query.node_id)
40            .ok_or_else(|| ApplicationError::Validation("node_id cannot be empty".to_string()))?;
41        let relationships = self
42            .relationship_reader
43            .load_node_relationships(&node_id)
44            .await?
45            .ok_or_else(|| ApplicationError::NotFound(format!("Node not found: {node_id}")))?;
46
47        Ok(GetNodeRelationshipsResult {
48            incoming: relationships
49                .incoming
50                .iter()
51                .map(map_relationship)
52                .collect(),
53            outgoing: relationships
54                .outgoing
55                .iter()
56                .map(map_relationship)
57                .collect(),
58            observed_at: std::time::SystemTime::now(),
59        })
60    }
61}
62
63impl<G, D, S> QueryApplicationService<G, D, S>
64where
65    G: NodeRelationshipReader + Send + Sync,
66{
67    pub async fn get_node_relationships(
68        &self,
69        query: GetNodeRelationshipsQuery,
70    ) -> Result<GetNodeRelationshipsResult, ApplicationError> {
71        GetNodeRelationshipsUseCase::new(Arc::clone(&self.graph_reader))
72            .execute(query)
73            .await
74    }
75}
76
77fn map_relationship(relation: &NodeRelationProjection) -> GraphRelationshipView {
78    GraphRelationshipView {
79        source_node_id: relation.source_node_id.clone(),
80        target_node_id: relation.target_node_id.clone(),
81        relationship_type: relation.relation_type.clone(),
82        explanation: relation.explanation.clone(),
83    }
84}
85
86fn trim_to_option(value: &str) -> Option<String> {
87    let trimmed = value.trim();
88    if trimmed.is_empty() {
89        None
90    } else {
91        Some(trimmed.to_string())
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use kmp_domain::{
98        NodeRelationProjection, NodeRelationshipReader, NodeRelationships, PortError,
99        RelationExplanation, RelationSemanticClass,
100    };
101
102    use super::{GetNodeRelationshipsQuery, GetNodeRelationshipsUseCase};
103    use crate::ApplicationError;
104
105    struct MissingRelationshipReader;
106
107    impl NodeRelationshipReader for MissingRelationshipReader {
108        async fn load_node_relationships(
109            &self,
110            _node_id: &str,
111        ) -> Result<Option<NodeRelationships>, PortError> {
112            Ok(None)
113        }
114    }
115
116    struct SeededRelationshipReader;
117
118    impl NodeRelationshipReader for SeededRelationshipReader {
119        async fn load_node_relationships(
120            &self,
121            node_id: &str,
122        ) -> Result<Option<NodeRelationships>, PortError> {
123            Ok(Some(NodeRelationships {
124                incoming: vec![NodeRelationProjection {
125                    source_node_id: "source-1".to_string(),
126                    target_node_id: node_id.to_string(),
127                    relation_type: "supports".to_string(),
128                    explanation: RelationExplanation::new(RelationSemanticClass::Evidential)
129                        .with_rationale("source supports inspected node"),
130                }],
131                outgoing: vec![NodeRelationProjection {
132                    source_node_id: node_id.to_string(),
133                    target_node_id: "target-1".to_string(),
134                    relation_type: "depends_on".to_string(),
135                    explanation: RelationExplanation::new(RelationSemanticClass::Constraint),
136                }],
137            }))
138        }
139    }
140
141    #[tokio::test]
142    async fn execute_returns_direct_incoming_and_outgoing_links() {
143        let result = GetNodeRelationshipsUseCase::new(SeededRelationshipReader)
144            .execute(GetNodeRelationshipsQuery {
145                node_id: "node-123".to_string(),
146            })
147            .await
148            .expect("relationships should load");
149
150        assert_eq!(result.incoming.len(), 1);
151        assert_eq!(result.incoming[0].target_node_id, "node-123");
152        assert_eq!(result.outgoing.len(), 1);
153        assert_eq!(result.outgoing[0].source_node_id, "node-123");
154    }
155
156    #[tokio::test]
157    async fn execute_fails_for_missing_node() {
158        let error = GetNodeRelationshipsUseCase::new(MissingRelationshipReader)
159            .execute(GetNodeRelationshipsQuery {
160                node_id: "missing".to_string(),
161            })
162            .await
163            .expect_err("missing node should fail");
164
165        match error {
166            ApplicationError::NotFound(message) => assert_eq!(message, "Node not found: missing"),
167            other => panic!("unexpected error: {other}"),
168        }
169    }
170}