kcode_kennedy_session_kweb_contracts/
lib.rs1#![forbid(unsafe_code)]
4
5use std::collections::HashSet;
6
7use anyhow::Context as _;
8use kcode_kweb_db::NodeId;
9use kcode_session_history::chatend::PendingId;
10use serde_json::Value;
11
12const MIN_NODE_SHORT_NAME_CHARACTERS: usize = 4;
13const MAX_NODE_SHORT_NAME_CHARACTERS: usize = 50;
14const MAX_NODE_SHORT_DESCRIPTION_CHARACTERS: usize = 200;
15const MAX_NODE_LONG_DESCRIPTION_CHARACTERS: usize = 5_000;
16
17#[derive(Clone, Debug, PartialEq)]
18pub enum DecodedKwebTool {
19 ConnectNodes(Vec<String>),
20 ConsolidateFanout {
21 parent: String,
22 fanout: Vec<String>,
23 aggregator: String,
24 },
25 SetFixedConnection {
26 parent: String,
27 child: Option<String>,
28 slot: usize,
29 },
30 CreateNode {
31 parents: Vec<String>,
32 owner: String,
33 short_name: String,
34 short_description: String,
35 long_description: String,
36 },
37 UpdateNode {
38 id: String,
39 owner: String,
40 short_name: String,
41 short_description: String,
42 long_description: String,
43 },
44}
45
46pub fn decode(tool: &str, value: &Value) -> anyhow::Result<Option<DecodedKwebTool>> {
47 let decoded = match tool {
48 "ConnectNodes" => {
49 exact(value, &["identifiers"])?;
50 DecodedKwebTool::ConnectNodes(resource_ids(value, "identifiers", 2)?)
51 }
52 "ConsolidateFanout" => {
53 exact(
54 value,
55 &[
56 "parentIdentifier",
57 "fanoutIdentifiers",
58 "aggregatorIdentifier",
59 ],
60 )?;
61 let parent = resource_id(value, "parentIdentifier")?;
62 let aggregator = resource_id(value, "aggregatorIdentifier")?;
63 let fanout = resource_ids(value, "fanoutIdentifiers", 1)?;
64 DecodedKwebTool::ConsolidateFanout {
65 parent,
66 fanout,
67 aggregator,
68 }
69 }
70 "SetFixedConnection" => {
71 exact(value, &["parentIdentifier", "childIdentifier", "slot"])?;
72 let parent = resource_id(value, "parentIdentifier")?;
73 let child = value
74 .get("childIdentifier")
75 .and_then(Value::as_str)
76 .filter(|value| *value != "blank")
77 .map(parse_resource_id)
78 .transpose()?;
79 DecodedKwebTool::SetFixedConnection {
80 parent,
81 child,
82 slot: positive_integer(value, "slot")? as usize,
83 }
84 }
85 "CreateNode" => {
86 exact(
87 value,
88 &[
89 "parentIdentifiers",
90 "ownerIdentifier",
91 "shortName",
92 "shortDescription",
93 "longDescription",
94 ],
95 )?;
96 let (short_name, short_description, long_description) =
97 node_text(value, "shortName", "shortDescription", "longDescription")?;
98 DecodedKwebTool::CreateNode {
99 parents: resource_ids(value, "parentIdentifiers", 1)?,
100 owner: resource_id(value, "ownerIdentifier")?,
101 short_name,
102 short_description,
103 long_description,
104 }
105 }
106 "UpdateNode" => {
107 exact(
108 value,
109 &[
110 "identifier",
111 "ownerIdentifier",
112 "newShortName",
113 "newShortDescription",
114 "newLongDescription",
115 ],
116 )?;
117 let (short_name, short_description, long_description) = node_text(
118 value,
119 "newShortName",
120 "newShortDescription",
121 "newLongDescription",
122 )?;
123 DecodedKwebTool::UpdateNode {
124 id: resource_id(value, "identifier")?,
125 owner: resource_id(value, "ownerIdentifier")?,
126 short_name,
127 short_description,
128 long_description,
129 }
130 }
131 _ => return Ok(None),
132 };
133 Ok(Some(decoded))
134}
135
136pub fn canonical_node_ids(
137 value: &Value,
138 key: &str,
139 maximum: Option<usize>,
140 require_nonempty: bool,
141) -> anyhow::Result<Vec<String>> {
142 let ids = value
143 .get(key)
144 .and_then(Value::as_array)
145 .with_context(|| format!("{key} must be an array"))?
146 .iter()
147 .map(|value| {
148 let id = value
149 .as_str()
150 .with_context(|| format!("{key} entries must be canonical node IDs"))?;
151 canonical_id(id)?;
152 Ok(id.to_owned())
153 })
154 .collect::<anyhow::Result<Vec<_>>>()?;
155 anyhow::ensure!(
156 ids.iter().collect::<HashSet<_>>().len() == ids.len(),
157 "{key} must not contain duplicate identifiers"
158 );
159 if let Some(maximum) = maximum {
160 anyhow::ensure!(
161 ids.len() <= maximum,
162 "{key} must contain at most {maximum} identifiers"
163 );
164 }
165 if require_nonempty {
166 anyhow::ensure!(
167 !ids.is_empty(),
168 "{key} must contain at least one identifier"
169 );
170 }
171 Ok(ids)
172}
173
174fn exact(value: &Value, required: &[&str]) -> anyhow::Result<()> {
175 let map = value
176 .as_object()
177 .context("arguments must be a JSON object")?;
178 let allowed = required.iter().copied().collect::<HashSet<_>>();
179 anyhow::ensure!(
180 required.iter().all(|key| map.contains_key(*key))
181 && map.keys().all(|key| allowed.contains(key.as_str())),
182 "expected exactly: {}",
183 required.join(", ")
184 );
185 Ok(())
186}
187
188fn positive_integer(value: &Value, key: &str) -> anyhow::Result<u64> {
189 value
190 .get(key)
191 .and_then(Value::as_u64)
192 .filter(|value| *value > 0)
193 .with_context(|| format!("{key} must be a positive integer"))
194}
195
196fn canonical_id(value: &str) -> anyhow::Result<String> {
197 value
198 .parse::<NodeId>()
199 .with_context(|| format!("{value:?} is not a canonical node ID"))?;
200 Ok(value.into())
201}
202
203fn parse_resource_id(value: &str) -> anyhow::Result<String> {
204 if value.starts_with("pending:") {
205 PendingId::parse(value.to_owned())?;
206 Ok(value.into())
207 } else if matches!(value, "self" | "unowned") {
208 Ok(value.into())
209 } else {
210 canonical_id(value)
211 }
212}
213
214fn resource_id(value: &Value, key: &str) -> anyhow::Result<String> {
215 parse_resource_id(
216 value
217 .get(key)
218 .and_then(Value::as_str)
219 .with_context(|| format!("{key} must be a node identifier"))?,
220 )
221}
222
223fn resource_ids(value: &Value, key: &str, minimum: usize) -> anyhow::Result<Vec<String>> {
224 let ids = value
225 .get(key)
226 .and_then(Value::as_array)
227 .with_context(|| format!("{key} must be an array"))?
228 .iter()
229 .map(|value| parse_resource_id(value.as_str().context("node identifier must be a string")?))
230 .collect::<anyhow::Result<Vec<_>>>()?;
231 anyhow::ensure!(
232 ids.len() >= minimum && ids.iter().collect::<HashSet<_>>().len() == ids.len(),
233 "{key} has invalid length or duplicate identifiers"
234 );
235 Ok(ids)
236}
237
238fn string(value: &Value, key: &str) -> anyhow::Result<String> {
239 value
240 .get(key)
241 .and_then(Value::as_str)
242 .map(str::to_owned)
243 .with_context(|| format!("{key} must be a string"))
244}
245
246fn node_text(
247 value: &Value,
248 short_name_key: &str,
249 short_description_key: &str,
250 long_description_key: &str,
251) -> anyhow::Result<(String, String, String)> {
252 let short_name = string(value, short_name_key)?;
253 let short_description = string(value, short_description_key)?;
254 let long_description = string(value, long_description_key)?;
255 let short_name_characters = short_name.chars().count();
256 let short_description_characters = short_description.chars().count();
257 let long_description_characters = long_description.chars().count();
258 anyhow::ensure!(
259 (MIN_NODE_SHORT_NAME_CHARACTERS..=MAX_NODE_SHORT_NAME_CHARACTERS)
260 .contains(&short_name_characters),
261 "{short_name_key} must contain between {MIN_NODE_SHORT_NAME_CHARACTERS} and \
262 {MAX_NODE_SHORT_NAME_CHARACTERS} characters; received {short_name_characters}. \
263 Correct it and retry."
264 );
265 anyhow::ensure!(
266 short_description_characters <= MAX_NODE_SHORT_DESCRIPTION_CHARACTERS,
267 "{short_description_key} must be at most {MAX_NODE_SHORT_DESCRIPTION_CHARACTERS} \
268 characters; received {short_description_characters}. Shorten it and retry."
269 );
270 anyhow::ensure!(
271 long_description_characters <= MAX_NODE_LONG_DESCRIPTION_CHARACTERS,
272 "{long_description_key} must be at most {MAX_NODE_LONG_DESCRIPTION_CHARACTERS} \
273 characters; received {long_description_characters}. Shorten it and retry."
274 );
275 Ok((short_name, short_description, long_description))
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use serde_json::json;
282
283 #[test]
284 fn decodes_kweb_mutations_and_ignores_other_tools() {
285 assert_eq!(
286 decode(
287 "ConnectNodes",
288 &json!({"identifiers":["self", "pending:1"]})
289 )
290 .unwrap(),
291 Some(DecodedKwebTool::ConnectNodes(vec![
292 "self".into(),
293 "pending:1".into()
294 ]))
295 );
296 assert_eq!(decode("LoadNodes", &json!({})).unwrap(), None);
297 }
298
299 #[test]
300 fn canonical_lists_reject_duplicates_and_obey_bounds() {
301 let value = json!({"identifiers":["AAECAwQF"]});
302 assert_eq!(
303 canonical_node_ids(&value, "identifiers", Some(1), true).unwrap(),
304 vec!["AAECAwQF"]
305 );
306 assert!(
307 canonical_node_ids(
308 &json!({"identifiers":["AAECAwQF","AAECAwQF"]}),
309 "identifiers",
310 None,
311 false
312 )
313 .is_err()
314 );
315 }
316
317 #[test]
318 fn node_text_limits_count_characters() {
319 let error = decode(
320 "CreateNode",
321 &json!({
322 "parentIdentifiers":["self"],
323 "ownerIdentifier":"self",
324 "shortName":"abc",
325 "shortDescription":"",
326 "longDescription":""
327 }),
328 )
329 .unwrap_err()
330 .to_string();
331 assert!(error.contains("received 3"));
332 }
333
334 #[test]
335 fn fixed_connection_blank_clears_the_slot() {
336 assert_eq!(
337 decode(
338 "SetFixedConnection",
339 &json!({
340 "parentIdentifier":"self",
341 "childIdentifier":"blank",
342 "slot":1
343 })
344 )
345 .unwrap(),
346 Some(DecodedKwebTool::SetFixedConnection {
347 parent: "self".into(),
348 child: None,
349 slot: 1
350 })
351 );
352 }
353}