kmp_application/queries/
get_node_relationships.rs1use 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}