Skip to main content

kcode_kweb_context_node/
lib.rs

1//! Typed Kweb context nodes and provider-facing full-node formatting.
2
3#![forbid(unsafe_code)]
4
5use std::{
6    collections::{HashMap, HashSet},
7    error, fmt,
8};
9
10use kcode_kweb_db::NodeId;
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14pub type Result<T> = std::result::Result<T, Error>;
15
16#[derive(Clone, Debug, Eq, PartialEq)]
17pub struct Error {
18    message: String,
19}
20
21impl Error {
22    pub fn new(message: impl Into<String>) -> Self {
23        Self {
24            message: message.into(),
25        }
26    }
27}
28
29impl fmt::Display for Error {
30    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31        formatter.write_str(&self.message)
32    }
33}
34
35impl error::Error for Error {}
36
37#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
38#[serde(rename_all = "camelCase")]
39pub struct Connection {
40    pub id: String,
41    pub short_name: String,
42    pub short_description: String,
43}
44
45#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub struct Node {
48    pub id: String,
49    pub short_name: String,
50    pub short_description: String,
51    pub long_description: String,
52    pub owner: String,
53    #[serde(default)]
54    pub fixed_connections: Vec<Connection>,
55    #[serde(default)]
56    pub recent_connections: Vec<Connection>,
57    #[serde(default)]
58    pub objects: Vec<String>,
59    #[serde(default)]
60    pub last_modified_by: String,
61    #[serde(default)]
62    pub last_modified_at: Option<String>,
63}
64
65impl Node {
66    pub fn from_kweb_value(value: &Value) -> Result<Self> {
67        let id = required_string(value, "id")?;
68        canonical_node_id(&id)?;
69        let owner = value
70            .get("owner_node_id")
71            .or_else(|| value.get("owner_root_node_id"))
72            .and_then(Value::as_str)
73            .unwrap_or("unowned")
74            .to_owned();
75        if !matches!(owner.as_str(), "self" | "unowned") {
76            canonical_node_id(&owner)?;
77        }
78        let summaries = value
79            .get("connection_summaries")
80            .and_then(Value::as_array)
81            .into_iter()
82            .flatten()
83            .filter_map(|summary| Some((summary.get("id")?.as_str()?.to_owned(), summary)))
84            .collect::<HashMap<_, _>>();
85        Ok(Self {
86            id,
87            short_name: optional_string(value, "short_name"),
88            short_description: optional_string(value, "short_description"),
89            long_description: optional_string(value, "long_description"),
90            owner,
91            fixed_connections: connections(
92                value.get("fixed_connections"),
93                &summaries,
94                "fixed connection",
95            )?,
96            recent_connections: connections(
97                value.get("recent_connections"),
98                &summaries,
99                "recent connection",
100            )?,
101            objects: string_ids(value.get("objects"), "object")?,
102            last_modified_by: optional_string(value, "last_modified_by"),
103            last_modified_at: value
104                .get("last_modified_at")
105                .and_then(Value::as_str)
106                .map(str::to_owned),
107        })
108    }
109
110    pub fn draft(&self) -> NodeDraft {
111        NodeDraft {
112            short_name: self.short_name.clone(),
113            short_description: self.short_description.clone(),
114            long_description: self.long_description.clone(),
115            owner: self.owner.clone(),
116            fixed_connections: self
117                .fixed_connections
118                .iter()
119                .map(|connection| connection.id.clone())
120                .collect(),
121            recent_connections: self
122                .recent_connections
123                .iter()
124                .map(|connection| connection.id.clone())
125                .collect(),
126            objects: self.objects.clone(),
127        }
128    }
129}
130
131#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase")]
133pub struct NodeDraft {
134    pub short_name: String,
135    pub short_description: String,
136    pub long_description: String,
137    pub owner: String,
138    #[serde(default)]
139    pub fixed_connections: Vec<String>,
140    #[serde(default)]
141    pub recent_connections: Vec<String>,
142    #[serde(default)]
143    pub objects: Vec<String>,
144}
145
146#[derive(Clone, Debug, Eq, PartialEq)]
147pub struct StagedCreate {
148    pub pending_id: String,
149    pub data: NodeDraft,
150}
151
152pub fn format_node(identifier: &str, node: &NodeDraft) -> String {
153    [
154        format!("Node ID: {identifier}"),
155        format!("Node name: {}", fallback(&node.short_name)),
156        format!("Node summary: {}", fallback(&node.short_description)),
157        format!("Node owner ID: {}", fallback(&node.owner)),
158        "Node long description:".into(),
159        indent(&node.long_description),
160        format!(
161            "Fixed connection IDs: {}",
162            list_or_none(&node.fixed_connections)
163        ),
164        format!(
165            "Recent connection IDs: {}",
166            list_or_none(&node.recent_connections)
167        ),
168    ]
169    .join("\n")
170}
171
172fn connections(
173    value: Option<&Value>,
174    summaries: &HashMap<String, &Value>,
175    label: &str,
176) -> Result<Vec<Connection>> {
177    let mut result = Vec::new();
178    let mut seen = HashSet::new();
179    for entry in value.and_then(Value::as_array).into_iter().flatten() {
180        let id = entry
181            .as_str()
182            .or_else(|| entry.get("id").and_then(Value::as_str))
183            .ok_or_else(|| Error::new(format!("{label} has no node ID")))?
184            .to_owned();
185        canonical_node_id(&id)?;
186        if !seen.insert(id.clone()) {
187            continue;
188        }
189        let summary = summaries.get(&id).copied();
190        result.push(Connection {
191            id,
192            short_name: entry
193                .get("short_name")
194                .and_then(Value::as_str)
195                .or_else(|| summary.and_then(|value| value.get("short_name")?.as_str()))
196                .unwrap_or_default()
197                .to_owned(),
198            short_description: entry
199                .get("short_description")
200                .and_then(Value::as_str)
201                .or_else(|| summary.and_then(|value| value.get("short_description")?.as_str()))
202                .unwrap_or_default()
203                .to_owned(),
204        });
205    }
206    Ok(result)
207}
208
209fn string_ids(value: Option<&Value>, label: &str) -> Result<Vec<String>> {
210    let mut result = Vec::new();
211    let mut seen = HashSet::new();
212    for entry in value.and_then(Value::as_array).into_iter().flatten() {
213        let id = entry
214            .as_str()
215            .ok_or_else(|| Error::new(format!("{label} ID must be a string")))?
216            .to_owned();
217        if seen.insert(id.clone()) {
218            result.push(id);
219        }
220    }
221    Ok(result)
222}
223
224fn canonical_node_id(value: &str) -> Result<()> {
225    value
226        .parse::<NodeId>()
227        .map(|_| ())
228        .map_err(|_| Error::new(format!("{value:?} is not a canonical Kweb node ID")))
229}
230
231fn required_string(value: &Value, key: &str) -> Result<String> {
232    value
233        .get(key)
234        .and_then(Value::as_str)
235        .map(str::to_owned)
236        .ok_or_else(|| Error::new(format!("Kweb node has no string {key}")))
237}
238
239fn optional_string(value: &Value, key: &str) -> String {
240    value
241        .get(key)
242        .and_then(Value::as_str)
243        .unwrap_or_default()
244        .to_owned()
245}
246
247fn indent(value: &str) -> String {
248    fallback(value)
249        .lines()
250        .map(|line| format!("  {line}"))
251        .collect::<Vec<_>>()
252        .join("\n")
253}
254
255fn fallback(value: &str) -> &str {
256    if value.trim().is_empty() {
257        "(none)"
258    } else {
259        value
260    }
261}
262
263fn list_or_none(values: &[String]) -> String {
264    if values.is_empty() {
265        "none".into()
266    } else {
267        values.join(", ")
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use serde_json::json;
275
276    fn id(index: u8) -> String {
277        NodeId::from_bytes([0, 0, 0, 0, 0, index])
278            .unwrap()
279            .to_string()
280    }
281
282    #[test]
283    fn parses_the_kweb_wire_shape_into_typed_connections() {
284        let parsed = Node::from_kweb_value(&json!({
285            "id": id(1),
286            "owner_node_id": id(1),
287            "short_name": "Root",
288            "short_description": "Root summary",
289            "long_description": "Root details",
290            "fixed_connections": [id(2)],
291            "recent_connections": [id(3), id(3)],
292            "objects": [],
293            "connection_summaries": [
294                {"id":id(2),"short_name":"Fixed","short_description":"Fixed summary"},
295                {"id":id(3),"short_name":"Recent","short_description":"Recent summary"}
296            ]
297        }))
298        .unwrap();
299        assert_eq!(parsed.fixed_connections[0].short_name, "Fixed");
300        assert_eq!(parsed.recent_connections.len(), 1);
301        assert_eq!(parsed.recent_connections[0].short_name, "Recent");
302    }
303
304    #[test]
305    fn full_node_format_lists_connection_categories() {
306        let text = format_node(
307            &id(1),
308            &NodeDraft {
309                short_name: "Root".into(),
310                short_description: "Summary".into(),
311                long_description: "Details".into(),
312                owner: id(1),
313                fixed_connections: vec![id(2)],
314                recent_connections: vec![id(3)],
315                objects: Vec::new(),
316            },
317        );
318        assert!(text.contains(&format!("Fixed connection IDs: {}", id(2))));
319        assert!(text.contains(&format!("Recent connection IDs: {}", id(3))));
320    }
321}