kcode-kweb-context-node 0.1.0

Typed Kweb context nodes and provider-facing full-node formatting
Documentation
//! Typed Kweb context nodes and provider-facing full-node formatting.

#![forbid(unsafe_code)]

use std::{
    collections::{HashMap, HashSet},
    error, fmt,
};

use kcode_kweb_db::NodeId;
use serde::{Deserialize, Serialize};
use serde_json::Value;

pub type Result<T> = std::result::Result<T, Error>;

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

impl Error {
    pub fn new(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
        }
    }
}

impl fmt::Display for Error {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(&self.message)
    }
}

impl error::Error for Error {}

#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Connection {
    pub id: String,
    pub short_name: String,
    pub short_description: String,
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Node {
    pub id: String,
    pub short_name: String,
    pub short_description: String,
    pub long_description: String,
    pub owner: String,
    #[serde(default)]
    pub fixed_connections: Vec<Connection>,
    #[serde(default)]
    pub recent_connections: Vec<Connection>,
    #[serde(default)]
    pub objects: Vec<String>,
    #[serde(default)]
    pub last_modified_by: String,
    #[serde(default)]
    pub last_modified_at: Option<String>,
}

impl Node {
    pub fn from_kweb_value(value: &Value) -> Result<Self> {
        let id = required_string(value, "id")?;
        canonical_node_id(&id)?;
        let owner = value
            .get("owner_node_id")
            .or_else(|| value.get("owner_root_node_id"))
            .and_then(Value::as_str)
            .unwrap_or("unowned")
            .to_owned();
        if !matches!(owner.as_str(), "self" | "unowned") {
            canonical_node_id(&owner)?;
        }
        let summaries = value
            .get("connection_summaries")
            .and_then(Value::as_array)
            .into_iter()
            .flatten()
            .filter_map(|summary| Some((summary.get("id")?.as_str()?.to_owned(), summary)))
            .collect::<HashMap<_, _>>();
        Ok(Self {
            id,
            short_name: optional_string(value, "short_name"),
            short_description: optional_string(value, "short_description"),
            long_description: optional_string(value, "long_description"),
            owner,
            fixed_connections: connections(
                value.get("fixed_connections"),
                &summaries,
                "fixed connection",
            )?,
            recent_connections: connections(
                value.get("recent_connections"),
                &summaries,
                "recent connection",
            )?,
            objects: string_ids(value.get("objects"), "object")?,
            last_modified_by: optional_string(value, "last_modified_by"),
            last_modified_at: value
                .get("last_modified_at")
                .and_then(Value::as_str)
                .map(str::to_owned),
        })
    }

    pub fn draft(&self) -> NodeDraft {
        NodeDraft {
            short_name: self.short_name.clone(),
            short_description: self.short_description.clone(),
            long_description: self.long_description.clone(),
            owner: self.owner.clone(),
            fixed_connections: self
                .fixed_connections
                .iter()
                .map(|connection| connection.id.clone())
                .collect(),
            recent_connections: self
                .recent_connections
                .iter()
                .map(|connection| connection.id.clone())
                .collect(),
            objects: self.objects.clone(),
        }
    }
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NodeDraft {
    pub short_name: String,
    pub short_description: String,
    pub long_description: String,
    pub owner: String,
    #[serde(default)]
    pub fixed_connections: Vec<String>,
    #[serde(default)]
    pub recent_connections: Vec<String>,
    #[serde(default)]
    pub objects: Vec<String>,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StagedCreate {
    pub pending_id: String,
    pub data: NodeDraft,
}

pub fn format_node(identifier: &str, node: &NodeDraft) -> String {
    [
        format!("Node ID: {identifier}"),
        format!("Node name: {}", fallback(&node.short_name)),
        format!("Node summary: {}", fallback(&node.short_description)),
        format!("Node owner ID: {}", fallback(&node.owner)),
        "Node long description:".into(),
        indent(&node.long_description),
        format!(
            "Fixed connection IDs: {}",
            list_or_none(&node.fixed_connections)
        ),
        format!(
            "Recent connection IDs: {}",
            list_or_none(&node.recent_connections)
        ),
    ]
    .join("\n")
}

fn connections(
    value: Option<&Value>,
    summaries: &HashMap<String, &Value>,
    label: &str,
) -> Result<Vec<Connection>> {
    let mut result = Vec::new();
    let mut seen = HashSet::new();
    for entry in value.and_then(Value::as_array).into_iter().flatten() {
        let id = entry
            .as_str()
            .or_else(|| entry.get("id").and_then(Value::as_str))
            .ok_or_else(|| Error::new(format!("{label} has no node ID")))?
            .to_owned();
        canonical_node_id(&id)?;
        if !seen.insert(id.clone()) {
            continue;
        }
        let summary = summaries.get(&id).copied();
        result.push(Connection {
            id,
            short_name: entry
                .get("short_name")
                .and_then(Value::as_str)
                .or_else(|| summary.and_then(|value| value.get("short_name")?.as_str()))
                .unwrap_or_default()
                .to_owned(),
            short_description: entry
                .get("short_description")
                .and_then(Value::as_str)
                .or_else(|| summary.and_then(|value| value.get("short_description")?.as_str()))
                .unwrap_or_default()
                .to_owned(),
        });
    }
    Ok(result)
}

fn string_ids(value: Option<&Value>, label: &str) -> Result<Vec<String>> {
    let mut result = Vec::new();
    let mut seen = HashSet::new();
    for entry in value.and_then(Value::as_array).into_iter().flatten() {
        let id = entry
            .as_str()
            .ok_or_else(|| Error::new(format!("{label} ID must be a string")))?
            .to_owned();
        if seen.insert(id.clone()) {
            result.push(id);
        }
    }
    Ok(result)
}

fn canonical_node_id(value: &str) -> Result<()> {
    value
        .parse::<NodeId>()
        .map(|_| ())
        .map_err(|_| Error::new(format!("{value:?} is not a canonical Kweb node ID")))
}

fn required_string(value: &Value, key: &str) -> Result<String> {
    value
        .get(key)
        .and_then(Value::as_str)
        .map(str::to_owned)
        .ok_or_else(|| Error::new(format!("Kweb node has no string {key}")))
}

fn optional_string(value: &Value, key: &str) -> String {
    value
        .get(key)
        .and_then(Value::as_str)
        .unwrap_or_default()
        .to_owned()
}

fn indent(value: &str) -> String {
    fallback(value)
        .lines()
        .map(|line| format!("  {line}"))
        .collect::<Vec<_>>()
        .join("\n")
}

fn fallback(value: &str) -> &str {
    if value.trim().is_empty() {
        "(none)"
    } else {
        value
    }
}

fn list_or_none(values: &[String]) -> String {
    if values.is_empty() {
        "none".into()
    } else {
        values.join(", ")
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn id(index: u8) -> String {
        NodeId::from_bytes([0, 0, 0, 0, 0, index])
            .unwrap()
            .to_string()
    }

    #[test]
    fn parses_the_kweb_wire_shape_into_typed_connections() {
        let parsed = Node::from_kweb_value(&json!({
            "id": id(1),
            "owner_node_id": id(1),
            "short_name": "Root",
            "short_description": "Root summary",
            "long_description": "Root details",
            "fixed_connections": [id(2)],
            "recent_connections": [id(3), id(3)],
            "objects": [],
            "connection_summaries": [
                {"id":id(2),"short_name":"Fixed","short_description":"Fixed summary"},
                {"id":id(3),"short_name":"Recent","short_description":"Recent summary"}
            ]
        }))
        .unwrap();
        assert_eq!(parsed.fixed_connections[0].short_name, "Fixed");
        assert_eq!(parsed.recent_connections.len(), 1);
        assert_eq!(parsed.recent_connections[0].short_name, "Recent");
    }

    #[test]
    fn full_node_format_lists_connection_categories() {
        let text = format_node(
            &id(1),
            &NodeDraft {
                short_name: "Root".into(),
                short_description: "Summary".into(),
                long_description: "Details".into(),
                owner: id(1),
                fixed_connections: vec![id(2)],
                recent_connections: vec![id(3)],
                objects: Vec::new(),
            },
        );
        assert!(text.contains(&format!("Fixed connection IDs: {}", id(2))));
        assert!(text.contains(&format!("Recent connection IDs: {}", id(3))));
    }
}