kmp-application 0.1.8

Application services of the KMP kernel: the use cases behind ingest, wake, ask, near, rewind and trace
Documentation
use std::collections::BTreeMap;

use kmp_domain::{GraphNeighborhoodReader, NodeNeighborhood, RelationExplanation};

use crate::ApplicationError;
use crate::queries::clamp_native_graph_traversal_depth;
use crate::queries::ordered_neighborhood::ordered_neighborhood;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GetGraphRelationshipsQuery {
    pub node_id: String,
    pub node_kind: Option<String>,
    pub depth: u32,
    pub include_reverse_edges: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GraphNodeView {
    pub node_id: String,
    pub node_kind: String,
    pub title: String,
    pub summary: String,
    pub status: String,
    pub labels: Vec<String>,
    pub properties: BTreeMap<String, String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GraphRelationshipView {
    pub source_node_id: String,
    pub target_node_id: String,
    pub relationship_type: String,
    pub explanation: RelationExplanation,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GetGraphRelationshipsResult {
    pub root: GraphNodeView,
    pub neighbors: Vec<GraphNodeView>,
    pub relationships: Vec<GraphRelationshipView>,
    pub observed_at: std::time::SystemTime,
}

#[derive(Debug)]
pub struct GetGraphRelationshipsUseCase<G> {
    graph_reader: G,
}

impl<G> GetGraphRelationshipsUseCase<G>
where
    G: GraphNeighborhoodReader + Send + Sync,
{
    pub fn new(graph_reader: G) -> Self {
        Self { graph_reader }
    }

    pub async fn execute(
        &self,
        query: GetGraphRelationshipsQuery,
    ) -> Result<GetGraphRelationshipsResult, ApplicationError> {
        let node_id = trim_to_option(&query.node_id)
            .ok_or_else(|| ApplicationError::Validation("node_id cannot be empty".to_string()))?;
        let neighborhood = ordered_neighborhood(
            load_existing_neighborhood(
                &self.graph_reader,
                &node_id,
                clamp_native_graph_traversal_depth(query.depth),
            )
            .await?,
        );

        Ok(GetGraphRelationshipsResult {
            root: map_node(&neighborhood.root),
            neighbors: neighborhood.neighbors.iter().map(map_node).collect(),
            relationships: neighborhood
                .relations
                .iter()
                .map(|relation| GraphRelationshipView {
                    source_node_id: relation.source_node_id.clone(),
                    target_node_id: relation.target_node_id.clone(),
                    relationship_type: relation.relation_type.clone(),
                    explanation: relation.explanation.clone(),
                })
                .collect(),
            observed_at: std::time::SystemTime::now(),
        })
    }
}

async fn load_existing_neighborhood<G>(
    graph_reader: &G,
    node_id: &str,
    depth: u32,
) -> Result<NodeNeighborhood, ApplicationError>
where
    G: GraphNeighborhoodReader + Send + Sync,
{
    graph_reader
        .load_neighborhood(node_id, depth)
        .await?
        .ok_or_else(|| ApplicationError::Validation(format!("Node not found: {node_id}")))
}

fn map_node(node: &kmp_domain::NodeProjection) -> GraphNodeView {
    GraphNodeView {
        node_id: node.node_id.clone(),
        node_kind: node.node_kind.clone(),
        title: node.title.clone(),
        summary: node.summary.clone(),
        status: node.status.clone(),
        labels: node.labels.clone(),
        properties: node.properties.clone(),
    }
}

fn trim_to_option(value: &str) -> Option<String> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed.to_string())
    }
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeMap;
    use std::sync::Arc;

    use kmp_domain::{
        ContextPathNeighborhood, NodeNeighborhood, NodeProjection, NodeRelationProjection,
        PortError, RelationExplanation, RelationSemanticClass,
    };
    use tokio::sync::Mutex;

    use super::{GetGraphRelationshipsQuery, GetGraphRelationshipsUseCase};
    use crate::ApplicationError;
    use crate::queries::MAX_NATIVE_GRAPH_TRAVERSAL_DEPTH;

    struct MissingGraphReader;

    impl kmp_domain::GraphNeighborhoodReader for MissingGraphReader {
        async fn load_neighborhood(
            &self,
            _root_node_id: &str,
            _depth: u32,
        ) -> Result<Option<NodeNeighborhood>, PortError> {
            Ok(None)
        }

        async fn load_context_path(
            &self,
            _root_node_id: &str,
            _target_node_id: &str,
            _subtree_depth: u32,
        ) -> Result<Option<ContextPathNeighborhood>, PortError> {
            Ok(None)
        }
    }

    struct SeededGraphReader;

    impl kmp_domain::GraphNeighborhoodReader for SeededGraphReader {
        async fn load_neighborhood(
            &self,
            root_node_id: &str,
            _depth: u32,
        ) -> Result<Option<NodeNeighborhood>, PortError> {
            Ok(Some(NodeNeighborhood {
                root: NodeProjection {
                    node_id: root_node_id.to_string(),
                    node_kind: "story".to_string(),
                    title: "Root".to_string(),
                    summary: "Root summary".to_string(),
                    status: "ACTIVE".to_string(),
                    labels: vec!["Story".to_string()],
                    properties: BTreeMap::new(),
                    provenance: None,
                },
                neighbors: vec![NodeProjection {
                    node_id: "neighbor-1".to_string(),
                    node_kind: "task".to_string(),
                    title: "Neighbor".to_string(),
                    summary: "Neighbor summary".to_string(),
                    status: "OPEN".to_string(),
                    labels: vec!["Task".to_string()],
                    properties: BTreeMap::new(),
                    provenance: None,
                }],
                relations: vec![NodeRelationProjection {
                    source_node_id: root_node_id.to_string(),
                    target_node_id: "neighbor-1".to_string(),
                    relation_type: "RELATES_TO".to_string(),
                    explanation: RelationExplanation::new(RelationSemanticClass::Motivational)
                        .with_rationale("neighbor-1 is the next actionable item"),
                }],
            }))
        }

        async fn load_context_path(
            &self,
            _root_node_id: &str,
            _target_node_id: &str,
            _subtree_depth: u32,
        ) -> Result<Option<ContextPathNeighborhood>, PortError> {
            Ok(None)
        }
    }

    #[tokio::test]
    async fn execute_rejects_missing_node() {
        let use_case = GetGraphRelationshipsUseCase::new(MissingGraphReader);

        let error = use_case
            .execute(GetGraphRelationshipsQuery {
                node_id: "missing-123".to_string(),
                node_kind: Some("Story".to_string()),
                depth: 2,
                include_reverse_edges: false,
            })
            .await
            .expect_err("missing node should be rejected");

        match error {
            ApplicationError::Validation(message) => {
                assert_eq!(message, "Node not found: missing-123")
            }
            other => panic!("unexpected error: {other}"),
        }
    }

    #[tokio::test]
    async fn execute_returns_graph_views_for_existing_node() {
        let use_case = GetGraphRelationshipsUseCase::new(SeededGraphReader);

        let result = use_case
            .execute(GetGraphRelationshipsQuery {
                node_id: "story-123".to_string(),
                node_kind: Some("Story".to_string()),
                depth: 2,
                include_reverse_edges: false,
            })
            .await
            .expect("existing node should succeed");

        assert_eq!(result.root.node_id, "story-123");
        assert_eq!(result.neighbors.len(), 1);
        assert_eq!(result.relationships.len(), 1);
        assert_eq!(result.relationships[0].target_node_id, "neighbor-1");
        assert_eq!(
            result.relationships[0].explanation.rationale(),
            Some("neighbor-1 is the next actionable item")
        );
    }

    struct RecordingGraphReader {
        depths: Arc<Mutex<Vec<u32>>>,
    }

    impl kmp_domain::GraphNeighborhoodReader for RecordingGraphReader {
        async fn load_neighborhood(
            &self,
            root_node_id: &str,
            depth: u32,
        ) -> Result<Option<NodeNeighborhood>, PortError> {
            self.depths.lock().await.push(depth);
            Ok(Some(NodeNeighborhood {
                root: NodeProjection {
                    node_id: root_node_id.to_string(),
                    node_kind: "story".to_string(),
                    title: "Root".to_string(),
                    summary: "Root summary".to_string(),
                    status: "ACTIVE".to_string(),
                    labels: vec!["Story".to_string()],
                    properties: BTreeMap::new(),
                    provenance: None,
                },
                neighbors: Vec::new(),
                relations: Vec::new(),
            }))
        }

        async fn load_context_path(
            &self,
            _root_node_id: &str,
            _target_node_id: &str,
            _subtree_depth: u32,
        ) -> Result<Option<ContextPathNeighborhood>, PortError> {
            Ok(None)
        }
    }

    #[tokio::test]
    async fn execute_clamps_and_forwards_depth_to_graph_reader() {
        let depths = Arc::new(Mutex::new(Vec::new()));
        let use_case = GetGraphRelationshipsUseCase::new(RecordingGraphReader {
            depths: Arc::clone(&depths),
        });

        use_case
            .execute(GetGraphRelationshipsQuery {
                node_id: "story-123".to_string(),
                node_kind: None,
                depth: 99,
                include_reverse_edges: false,
            })
            .await
            .expect("existing node should succeed");

        assert_eq!(&*depths.lock().await, &[MAX_NATIVE_GRAPH_TRAVERSAL_DEPTH]);
    }
}