kcode-kennedy-session-kweb-contracts 0.1.0

Strict Kweb mutation argument decoding for Kennedy sessions
Documentation
//! Strict, effect-free decoding for Kennedy session Kweb contracts.

#![forbid(unsafe_code)]

use std::collections::HashSet;

use anyhow::Context as _;
use kcode_kweb_db::NodeId;
use kcode_session_history::chatend::PendingId;
use serde_json::Value;

const MIN_NODE_SHORT_NAME_CHARACTERS: usize = 4;
const MAX_NODE_SHORT_NAME_CHARACTERS: usize = 50;
const MAX_NODE_SHORT_DESCRIPTION_CHARACTERS: usize = 200;
const MAX_NODE_LONG_DESCRIPTION_CHARACTERS: usize = 5_000;

#[derive(Clone, Debug, PartialEq)]
pub enum DecodedKwebTool {
    ConnectNodes(Vec<String>),
    ConsolidateFanout {
        parent: String,
        fanout: Vec<String>,
        aggregator: String,
    },
    SetFixedConnection {
        parent: String,
        child: Option<String>,
        slot: usize,
    },
    CreateNode {
        parents: Vec<String>,
        owner: String,
        short_name: String,
        short_description: String,
        long_description: String,
    },
    UpdateNode {
        id: String,
        owner: String,
        short_name: String,
        short_description: String,
        long_description: String,
    },
}

pub fn decode(tool: &str, value: &Value) -> anyhow::Result<Option<DecodedKwebTool>> {
    let decoded = match tool {
        "ConnectNodes" => {
            exact(value, &["identifiers"])?;
            DecodedKwebTool::ConnectNodes(resource_ids(value, "identifiers", 2)?)
        }
        "ConsolidateFanout" => {
            exact(
                value,
                &[
                    "parentIdentifier",
                    "fanoutIdentifiers",
                    "aggregatorIdentifier",
                ],
            )?;
            let parent = resource_id(value, "parentIdentifier")?;
            let aggregator = resource_id(value, "aggregatorIdentifier")?;
            let fanout = resource_ids(value, "fanoutIdentifiers", 1)?;
            DecodedKwebTool::ConsolidateFanout {
                parent,
                fanout,
                aggregator,
            }
        }
        "SetFixedConnection" => {
            exact(value, &["parentIdentifier", "childIdentifier", "slot"])?;
            let parent = resource_id(value, "parentIdentifier")?;
            let child = value
                .get("childIdentifier")
                .and_then(Value::as_str)
                .filter(|value| *value != "blank")
                .map(parse_resource_id)
                .transpose()?;
            DecodedKwebTool::SetFixedConnection {
                parent,
                child,
                slot: positive_integer(value, "slot")? as usize,
            }
        }
        "CreateNode" => {
            exact(
                value,
                &[
                    "parentIdentifiers",
                    "ownerIdentifier",
                    "shortName",
                    "shortDescription",
                    "longDescription",
                ],
            )?;
            let (short_name, short_description, long_description) =
                node_text(value, "shortName", "shortDescription", "longDescription")?;
            DecodedKwebTool::CreateNode {
                parents: resource_ids(value, "parentIdentifiers", 1)?,
                owner: resource_id(value, "ownerIdentifier")?,
                short_name,
                short_description,
                long_description,
            }
        }
        "UpdateNode" => {
            exact(
                value,
                &[
                    "identifier",
                    "ownerIdentifier",
                    "newShortName",
                    "newShortDescription",
                    "newLongDescription",
                ],
            )?;
            let (short_name, short_description, long_description) = node_text(
                value,
                "newShortName",
                "newShortDescription",
                "newLongDescription",
            )?;
            DecodedKwebTool::UpdateNode {
                id: resource_id(value, "identifier")?,
                owner: resource_id(value, "ownerIdentifier")?,
                short_name,
                short_description,
                long_description,
            }
        }
        _ => return Ok(None),
    };
    Ok(Some(decoded))
}

pub fn canonical_node_ids(
    value: &Value,
    key: &str,
    maximum: Option<usize>,
    require_nonempty: bool,
) -> anyhow::Result<Vec<String>> {
    let ids = value
        .get(key)
        .and_then(Value::as_array)
        .with_context(|| format!("{key} must be an array"))?
        .iter()
        .map(|value| {
            let id = value
                .as_str()
                .with_context(|| format!("{key} entries must be canonical node IDs"))?;
            canonical_id(id)?;
            Ok(id.to_owned())
        })
        .collect::<anyhow::Result<Vec<_>>>()?;
    anyhow::ensure!(
        ids.iter().collect::<HashSet<_>>().len() == ids.len(),
        "{key} must not contain duplicate identifiers"
    );
    if let Some(maximum) = maximum {
        anyhow::ensure!(
            ids.len() <= maximum,
            "{key} must contain at most {maximum} identifiers"
        );
    }
    if require_nonempty {
        anyhow::ensure!(
            !ids.is_empty(),
            "{key} must contain at least one identifier"
        );
    }
    Ok(ids)
}

fn exact(value: &Value, required: &[&str]) -> anyhow::Result<()> {
    let map = value
        .as_object()
        .context("arguments must be a JSON object")?;
    let allowed = required.iter().copied().collect::<HashSet<_>>();
    anyhow::ensure!(
        required.iter().all(|key| map.contains_key(*key))
            && map.keys().all(|key| allowed.contains(key.as_str())),
        "expected exactly: {}",
        required.join(", ")
    );
    Ok(())
}

fn positive_integer(value: &Value, key: &str) -> anyhow::Result<u64> {
    value
        .get(key)
        .and_then(Value::as_u64)
        .filter(|value| *value > 0)
        .with_context(|| format!("{key} must be a positive integer"))
}

fn canonical_id(value: &str) -> anyhow::Result<String> {
    value
        .parse::<NodeId>()
        .with_context(|| format!("{value:?} is not a canonical node ID"))?;
    Ok(value.into())
}

fn parse_resource_id(value: &str) -> anyhow::Result<String> {
    if value.starts_with("pending:") {
        PendingId::parse(value.to_owned())?;
        Ok(value.into())
    } else if matches!(value, "self" | "unowned") {
        Ok(value.into())
    } else {
        canonical_id(value)
    }
}

fn resource_id(value: &Value, key: &str) -> anyhow::Result<String> {
    parse_resource_id(
        value
            .get(key)
            .and_then(Value::as_str)
            .with_context(|| format!("{key} must be a node identifier"))?,
    )
}

fn resource_ids(value: &Value, key: &str, minimum: usize) -> anyhow::Result<Vec<String>> {
    let ids = value
        .get(key)
        .and_then(Value::as_array)
        .with_context(|| format!("{key} must be an array"))?
        .iter()
        .map(|value| parse_resource_id(value.as_str().context("node identifier must be a string")?))
        .collect::<anyhow::Result<Vec<_>>>()?;
    anyhow::ensure!(
        ids.len() >= minimum && ids.iter().collect::<HashSet<_>>().len() == ids.len(),
        "{key} has invalid length or duplicate identifiers"
    );
    Ok(ids)
}

fn string(value: &Value, key: &str) -> anyhow::Result<String> {
    value
        .get(key)
        .and_then(Value::as_str)
        .map(str::to_owned)
        .with_context(|| format!("{key} must be a string"))
}

fn node_text(
    value: &Value,
    short_name_key: &str,
    short_description_key: &str,
    long_description_key: &str,
) -> anyhow::Result<(String, String, String)> {
    let short_name = string(value, short_name_key)?;
    let short_description = string(value, short_description_key)?;
    let long_description = string(value, long_description_key)?;
    let short_name_characters = short_name.chars().count();
    let short_description_characters = short_description.chars().count();
    let long_description_characters = long_description.chars().count();
    anyhow::ensure!(
        (MIN_NODE_SHORT_NAME_CHARACTERS..=MAX_NODE_SHORT_NAME_CHARACTERS)
            .contains(&short_name_characters),
        "{short_name_key} must contain between {MIN_NODE_SHORT_NAME_CHARACTERS} and \
         {MAX_NODE_SHORT_NAME_CHARACTERS} characters; received {short_name_characters}. \
         Correct it and retry."
    );
    anyhow::ensure!(
        short_description_characters <= MAX_NODE_SHORT_DESCRIPTION_CHARACTERS,
        "{short_description_key} must be at most {MAX_NODE_SHORT_DESCRIPTION_CHARACTERS} \
         characters; received {short_description_characters}. Shorten it and retry."
    );
    anyhow::ensure!(
        long_description_characters <= MAX_NODE_LONG_DESCRIPTION_CHARACTERS,
        "{long_description_key} must be at most {MAX_NODE_LONG_DESCRIPTION_CHARACTERS} \
         characters; received {long_description_characters}. Shorten it and retry."
    );
    Ok((short_name, short_description, long_description))
}

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

    #[test]
    fn decodes_kweb_mutations_and_ignores_other_tools() {
        assert_eq!(
            decode(
                "ConnectNodes",
                &json!({"identifiers":["self", "pending:1"]})
            )
            .unwrap(),
            Some(DecodedKwebTool::ConnectNodes(vec![
                "self".into(),
                "pending:1".into()
            ]))
        );
        assert_eq!(decode("LoadNodes", &json!({})).unwrap(), None);
    }

    #[test]
    fn canonical_lists_reject_duplicates_and_obey_bounds() {
        let value = json!({"identifiers":["AAECAwQF"]});
        assert_eq!(
            canonical_node_ids(&value, "identifiers", Some(1), true).unwrap(),
            vec!["AAECAwQF"]
        );
        assert!(
            canonical_node_ids(
                &json!({"identifiers":["AAECAwQF","AAECAwQF"]}),
                "identifiers",
                None,
                false
            )
            .is_err()
        );
    }

    #[test]
    fn node_text_limits_count_characters() {
        let error = decode(
            "CreateNode",
            &json!({
                "parentIdentifiers":["self"],
                "ownerIdentifier":"self",
                "shortName":"abc",
                "shortDescription":"",
                "longDescription":""
            }),
        )
        .unwrap_err()
        .to_string();
        assert!(error.contains("received 3"));
    }

    #[test]
    fn fixed_connection_blank_clears_the_slot() {
        assert_eq!(
            decode(
                "SetFixedConnection",
                &json!({
                    "parentIdentifier":"self",
                    "childIdentifier":"blank",
                    "slot":1
                })
            )
            .unwrap(),
            Some(DecodedKwebTool::SetFixedConnection {
                parent: "self".into(),
                child: None,
                slot: 1
            })
        );
    }
}