1use std::collections::BTreeMap;
2
3use kmp_domain::{GraphNeighborhoodReader, NodeNeighborhood, RelationExplanation};
4
5use crate::ApplicationError;
6use crate::queries::clamp_native_graph_traversal_depth;
7use crate::queries::ordered_neighborhood::ordered_neighborhood;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct GetGraphRelationshipsQuery {
11 pub node_id: String,
12 pub node_kind: Option<String>,
13 pub depth: u32,
14 pub include_reverse_edges: bool,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct GraphNodeView {
19 pub node_id: String,
20 pub node_kind: String,
21 pub title: String,
22 pub summary: String,
23 pub status: String,
24 pub labels: Vec<String>,
25 pub properties: BTreeMap<String, String>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct GraphRelationshipView {
30 pub source_node_id: String,
31 pub target_node_id: String,
32 pub relationship_type: String,
33 pub explanation: RelationExplanation,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct GetGraphRelationshipsResult {
38 pub root: GraphNodeView,
39 pub neighbors: Vec<GraphNodeView>,
40 pub relationships: Vec<GraphRelationshipView>,
41 pub observed_at: std::time::SystemTime,
42}
43
44#[derive(Debug)]
45pub struct GetGraphRelationshipsUseCase<G> {
46 graph_reader: G,
47}
48
49impl<G> GetGraphRelationshipsUseCase<G>
50where
51 G: GraphNeighborhoodReader + Send + Sync,
52{
53 pub fn new(graph_reader: G) -> Self {
54 Self { graph_reader }
55 }
56
57 pub async fn execute(
58 &self,
59 query: GetGraphRelationshipsQuery,
60 ) -> Result<GetGraphRelationshipsResult, ApplicationError> {
61 let node_id = trim_to_option(&query.node_id)
62 .ok_or_else(|| ApplicationError::Validation("node_id cannot be empty".to_string()))?;
63 let neighborhood = ordered_neighborhood(
64 load_existing_neighborhood(
65 &self.graph_reader,
66 &node_id,
67 clamp_native_graph_traversal_depth(query.depth),
68 )
69 .await?,
70 );
71
72 Ok(GetGraphRelationshipsResult {
73 root: map_node(&neighborhood.root),
74 neighbors: neighborhood.neighbors.iter().map(map_node).collect(),
75 relationships: neighborhood
76 .relations
77 .iter()
78 .map(|relation| 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 .collect(),
85 observed_at: std::time::SystemTime::now(),
86 })
87 }
88}
89
90async fn load_existing_neighborhood<G>(
91 graph_reader: &G,
92 node_id: &str,
93 depth: u32,
94) -> Result<NodeNeighborhood, ApplicationError>
95where
96 G: GraphNeighborhoodReader + Send + Sync,
97{
98 graph_reader
99 .load_neighborhood(node_id, depth)
100 .await?
101 .ok_or_else(|| ApplicationError::Validation(format!("Node not found: {node_id}")))
102}
103
104fn map_node(node: &kmp_domain::NodeProjection) -> GraphNodeView {
105 GraphNodeView {
106 node_id: node.node_id.clone(),
107 node_kind: node.node_kind.clone(),
108 title: node.title.clone(),
109 summary: node.summary.clone(),
110 status: node.status.clone(),
111 labels: node.labels.clone(),
112 properties: node.properties.clone(),
113 }
114}
115
116fn trim_to_option(value: &str) -> Option<String> {
117 let trimmed = value.trim();
118 if trimmed.is_empty() {
119 None
120 } else {
121 Some(trimmed.to_string())
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use std::collections::BTreeMap;
128 use std::sync::Arc;
129
130 use kmp_domain::{
131 ContextPathNeighborhood, NodeNeighborhood, NodeProjection, NodeRelationProjection,
132 PortError, RelationExplanation, RelationSemanticClass,
133 };
134 use tokio::sync::Mutex;
135
136 use super::{GetGraphRelationshipsQuery, GetGraphRelationshipsUseCase};
137 use crate::ApplicationError;
138 use crate::queries::MAX_NATIVE_GRAPH_TRAVERSAL_DEPTH;
139
140 struct MissingGraphReader;
141
142 impl kmp_domain::GraphNeighborhoodReader for MissingGraphReader {
143 async fn load_neighborhood(
144 &self,
145 _root_node_id: &str,
146 _depth: u32,
147 ) -> Result<Option<NodeNeighborhood>, PortError> {
148 Ok(None)
149 }
150
151 async fn load_context_path(
152 &self,
153 _root_node_id: &str,
154 _target_node_id: &str,
155 _subtree_depth: u32,
156 ) -> Result<Option<ContextPathNeighborhood>, PortError> {
157 Ok(None)
158 }
159 }
160
161 struct SeededGraphReader;
162
163 impl kmp_domain::GraphNeighborhoodReader for SeededGraphReader {
164 async fn load_neighborhood(
165 &self,
166 root_node_id: &str,
167 _depth: u32,
168 ) -> Result<Option<NodeNeighborhood>, PortError> {
169 Ok(Some(NodeNeighborhood {
170 root: NodeProjection {
171 node_id: root_node_id.to_string(),
172 node_kind: "story".to_string(),
173 title: "Root".to_string(),
174 summary: "Root summary".to_string(),
175 status: "ACTIVE".to_string(),
176 labels: vec!["Story".to_string()],
177 properties: BTreeMap::new(),
178 provenance: None,
179 },
180 neighbors: vec![NodeProjection {
181 node_id: "neighbor-1".to_string(),
182 node_kind: "task".to_string(),
183 title: "Neighbor".to_string(),
184 summary: "Neighbor summary".to_string(),
185 status: "OPEN".to_string(),
186 labels: vec!["Task".to_string()],
187 properties: BTreeMap::new(),
188 provenance: None,
189 }],
190 relations: vec![NodeRelationProjection {
191 source_node_id: root_node_id.to_string(),
192 target_node_id: "neighbor-1".to_string(),
193 relation_type: "RELATES_TO".to_string(),
194 explanation: RelationExplanation::new(RelationSemanticClass::Motivational)
195 .with_rationale("neighbor-1 is the next actionable item"),
196 }],
197 }))
198 }
199
200 async fn load_context_path(
201 &self,
202 _root_node_id: &str,
203 _target_node_id: &str,
204 _subtree_depth: u32,
205 ) -> Result<Option<ContextPathNeighborhood>, PortError> {
206 Ok(None)
207 }
208 }
209
210 #[tokio::test]
211 async fn execute_rejects_missing_node() {
212 let use_case = GetGraphRelationshipsUseCase::new(MissingGraphReader);
213
214 let error = use_case
215 .execute(GetGraphRelationshipsQuery {
216 node_id: "missing-123".to_string(),
217 node_kind: Some("Story".to_string()),
218 depth: 2,
219 include_reverse_edges: false,
220 })
221 .await
222 .expect_err("missing node should be rejected");
223
224 match error {
225 ApplicationError::Validation(message) => {
226 assert_eq!(message, "Node not found: missing-123")
227 }
228 other => panic!("unexpected error: {other}"),
229 }
230 }
231
232 #[tokio::test]
233 async fn execute_returns_graph_views_for_existing_node() {
234 let use_case = GetGraphRelationshipsUseCase::new(SeededGraphReader);
235
236 let result = use_case
237 .execute(GetGraphRelationshipsQuery {
238 node_id: "story-123".to_string(),
239 node_kind: Some("Story".to_string()),
240 depth: 2,
241 include_reverse_edges: false,
242 })
243 .await
244 .expect("existing node should succeed");
245
246 assert_eq!(result.root.node_id, "story-123");
247 assert_eq!(result.neighbors.len(), 1);
248 assert_eq!(result.relationships.len(), 1);
249 assert_eq!(result.relationships[0].target_node_id, "neighbor-1");
250 assert_eq!(
251 result.relationships[0].explanation.rationale(),
252 Some("neighbor-1 is the next actionable item")
253 );
254 }
255
256 struct RecordingGraphReader {
257 depths: Arc<Mutex<Vec<u32>>>,
258 }
259
260 impl kmp_domain::GraphNeighborhoodReader for RecordingGraphReader {
261 async fn load_neighborhood(
262 &self,
263 root_node_id: &str,
264 depth: u32,
265 ) -> Result<Option<NodeNeighborhood>, PortError> {
266 self.depths.lock().await.push(depth);
267 Ok(Some(NodeNeighborhood {
268 root: NodeProjection {
269 node_id: root_node_id.to_string(),
270 node_kind: "story".to_string(),
271 title: "Root".to_string(),
272 summary: "Root summary".to_string(),
273 status: "ACTIVE".to_string(),
274 labels: vec!["Story".to_string()],
275 properties: BTreeMap::new(),
276 provenance: None,
277 },
278 neighbors: Vec::new(),
279 relations: Vec::new(),
280 }))
281 }
282
283 async fn load_context_path(
284 &self,
285 _root_node_id: &str,
286 _target_node_id: &str,
287 _subtree_depth: u32,
288 ) -> Result<Option<ContextPathNeighborhood>, PortError> {
289 Ok(None)
290 }
291 }
292
293 #[tokio::test]
294 async fn execute_clamps_and_forwards_depth_to_graph_reader() {
295 let depths = Arc::new(Mutex::new(Vec::new()));
296 let use_case = GetGraphRelationshipsUseCase::new(RecordingGraphReader {
297 depths: Arc::clone(&depths),
298 });
299
300 use_case
301 .execute(GetGraphRelationshipsQuery {
302 node_id: "story-123".to_string(),
303 node_kind: None,
304 depth: 99,
305 include_reverse_edges: false,
306 })
307 .await
308 .expect("existing node should succeed");
309
310 assert_eq!(&*depths.lock().await, &[MAX_NATIVE_GRAPH_TRAVERSAL_DEPTH]);
311 }
312}