#![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
})
);
}
}