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