Skip to main content

kmp_application/queries/
graph_relationships.rs

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}