kmp-application 0.1.4

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

use kmp_domain::{
    GraphNeighborhoodReader, NodeDetailProjection, NodeDetailReader, NodeNeighborhood,
};

use crate::ApplicationError;
use crate::queries::{GraphNodeView, QueryApplicationService};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GetNodeDetailQuery {
    pub node_id: String,
}

#[derive(Clone, PartialEq, Eq)]
pub struct NodeDetailView {
    pub node_id: String,
    pub detail: String,
    pub content_hash: String,
    pub revision: u64,
}

#[derive(Clone, PartialEq, Eq)]
pub struct GetNodeDetailResult {
    pub node: GraphNodeView,
    pub detail: Option<NodeDetailView>,
}

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

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

    pub async fn execute(&self, node_id: &str) -> Result<GetNodeDetailResult, ApplicationError> {
        let node_id = trim_to_option(node_id)
            .ok_or_else(|| ApplicationError::Validation("node_id cannot be empty".to_string()))?;
        let neighborhood = self.graph_reader.load_neighborhood(&node_id, 1).await?;
        let Some(neighborhood) = neighborhood else {
            return Err(ApplicationError::NotFound(format!(
                "Node not found: {node_id}"
            )));
        };
        #[allow(clippy::manual_map)]
        let node_detail = match self.detail_reader.load_node_detail(&node_id).await? {
            Some(projection) => Some(map_node_detail(projection)),
            None => None,
        };

        Ok(GetNodeDetailResult {
            node: map_root_node(&neighborhood),
            detail: node_detail,
        })
    }
}

impl<G, D, S> QueryApplicationService<G, D, S>
where
    G: GraphNeighborhoodReader + Send + Sync,
    D: NodeDetailReader + Send + Sync,
{
    pub async fn get_node_detail(
        &self,
        query: GetNodeDetailQuery,
    ) -> Result<GetNodeDetailResult, ApplicationError> {
        GetNodeDetailUseCase::new(
            Arc::clone(&self.graph_reader),
            Arc::clone(&self.detail_reader),
        )
        .execute(&query.node_id)
        .await
    }
}

fn map_root_node(neighborhood: &NodeNeighborhood) -> GraphNodeView {
    GraphNodeView {
        node_id: neighborhood.root.node_id.clone(),
        node_kind: neighborhood.root.node_kind.clone(),
        title: neighborhood.root.title.clone(),
        summary: neighborhood.root.summary.clone(),
        status: neighborhood.root.status.clone(),
        labels: neighborhood.root.labels.clone(),
        properties: neighborhood.root.properties.clone(),
    }
}

fn map_node_detail(projection: NodeDetailProjection) -> NodeDetailView {
    NodeDetailView {
        node_id: projection.node_id,
        detail: projection.detail,
        content_hash: projection.content_hash,
        revision: projection.revision,
    }
}

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, KmpBundle, NodeDetailProjection, NodeNeighborhood, NodeProjection,
        PortError, SnapshotSaveOptions, SnapshotStore,
    };
    use tokio::sync::Mutex;

    use super::{GetNodeDetailQuery, GetNodeDetailUseCase};
    use crate::ApplicationError;

    struct SeededGraphReader;

    impl kmp_domain::GraphNeighborhoodReader for SeededGraphReader {
        async fn load_neighborhood(
            &self,
            root_node_id: &str,
            _depth: u32,
        ) -> Result<Option<NodeNeighborhood>, PortError> {
            match root_node_id {
                "node-123" => Ok(Some(sample_neighborhood("node-123", "ACTIVE"))),
                "graph-only" => Ok(Some(sample_neighborhood("graph-only", "READY"))),
                _ => 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 SeededDetailReader;

    impl kmp_domain::NodeDetailReader for SeededDetailReader {
        async fn load_node_detail(
            &self,
            node_id: &str,
        ) -> Result<Option<NodeDetailProjection>, PortError> {
            Ok(match node_id {
                "node-123" => Some(NodeDetailProjection {
                    node_id: "node-123".to_string(),
                    detail: "Expanded node detail".to_string(),
                    content_hash: "hash-123".to_string(),
                    revision: 2,
                }),
                "orphan-detail" => Some(NodeDetailProjection {
                    node_id: "orphan-detail".to_string(),
                    detail: "orphaned".to_string(),
                    content_hash: "hash-orphan".to_string(),
                    revision: 1,
                }),
                _ => None,
            })
        }

        async fn load_node_details_batch(
            &self,
            node_ids: Vec<String>,
        ) -> Result<Vec<Option<NodeDetailProjection>>, PortError> {
            let mut results = Vec::with_capacity(node_ids.len());
            for node_id in &node_ids {
                results.push(self.load_node_detail(node_id).await?);
            }
            Ok(results)
        }
    }

    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(sample_neighborhood("node-123", "ACTIVE")))
        }

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

    struct EmptyDetailReader;

    impl kmp_domain::NodeDetailReader for EmptyDetailReader {
        async fn load_node_detail(
            &self,
            _node_id: &str,
        ) -> Result<Option<NodeDetailProjection>, PortError> {
            Ok(None)
        }

        async fn load_node_details_batch(
            &self,
            node_ids: Vec<String>,
        ) -> Result<Vec<Option<NodeDetailProjection>>, PortError> {
            let mut results = Vec::with_capacity(node_ids.len());
            for node_id in &node_ids {
                results.push(self.load_node_detail(node_id).await?);
            }
            Ok(results)
        }
    }

    #[derive(Debug, Default, Clone, Copy)]
    struct NoopSnapshotStore;

    impl SnapshotStore for NoopSnapshotStore {
        async fn save_bundle_with_options(
            &self,
            _bundle: &KmpBundle,
            _options: SnapshotSaveOptions,
        ) -> Result<(), PortError> {
            Ok(())
        }
    }

    #[tokio::test]
    async fn execute_returns_graph_node_and_detail_when_both_exist() {
        let use_case = GetNodeDetailUseCase::new(SeededGraphReader, SeededDetailReader);

        let result = use_case
            .execute("node-123")
            .await
            .expect("node detail should load");

        assert_eq!(result.node.node_id, "node-123");
        assert_eq!(result.node.node_kind, "task");
        assert_eq!(result.node.title, "Node node-123");
        assert_eq!(
            result
                .detail
                .as_ref()
                .expect("detail should exist")
                .content_hash,
            "hash-123"
        );
    }

    #[tokio::test]
    async fn execute_returns_node_metadata_when_detail_is_missing() {
        let use_case = GetNodeDetailUseCase::new(SeededGraphReader, EmptyDetailReader);

        let result = use_case
            .execute("graph-only")
            .await
            .expect("graph-only node should load");

        assert_eq!(result.node.node_id, "graph-only");
        assert_eq!(result.node.status, "READY");
        assert!(result.detail.is_none());
    }

    #[tokio::test]
    async fn execute_returns_not_found_when_graph_node_is_missing() {
        let use_case = GetNodeDetailUseCase::new(SeededGraphReader, SeededDetailReader);

        let error = match use_case.execute("orphan-detail").await {
            Ok(_) => panic!("orphan detail should not be enough"),
            Err(error) => error,
        };

        assert!(matches!(
            error,
            ApplicationError::NotFound(message) if message == "Node not found: orphan-detail"
        ));
    }

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

        let result = use_case
            .execute("node-123")
            .await
            .expect("node detail should load");

        assert_eq!(result.node.node_id, "node-123");
        assert_eq!(&*depths.lock().await, &[1]);
    }

    #[tokio::test]
    async fn execute_rejects_blank_node_id() {
        let use_case = GetNodeDetailUseCase::new(SeededGraphReader, EmptyDetailReader);

        let error = match use_case.execute("   ").await {
            Ok(_) => panic!("blank node id must be rejected"),
            Err(error) => error,
        };

        assert!(matches!(
            error,
            ApplicationError::Validation(message) if message == "node_id cannot be empty"
        ));
    }

    fn sample_neighborhood(node_id: &str, status: &str) -> NodeNeighborhood {
        NodeNeighborhood {
            root: NodeProjection {
                node_id: node_id.to_string(),
                node_kind: "task".to_string(),
                title: format!("Node {node_id}"),
                summary: format!("Summary for {node_id}"),
                status: status.to_string(),
                labels: vec!["Task".to_string()],
                properties: BTreeMap::from([("owner".to_string(), "ops".to_string())]),
                provenance: None,
            },
            neighbors: Vec::new(),
            relations: Vec::new(),
        }
    }

    #[tokio::test]
    async fn query_application_service_routes_get_node_detail() {
        let application = crate::queries::QueryApplicationService::new(
            Arc::new(SeededGraphReader),
            Arc::new(SeededDetailReader),
            Arc::new(NoopSnapshotStore),
            "0.1.0",
        );

        let result = application
            .get_node_detail(GetNodeDetailQuery {
                node_id: "node-123".to_string(),
            })
            .await
            .expect("application should route node detail query");

        assert_eq!(result.node.node_id, "node-123");
        assert_eq!(
            result
                .detail
                .as_ref()
                .expect("detail should exist")
                .revision,
            2
        );
    }
}