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