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