Skip to main content

kcode_kennedy_session_kweb_contracts/
lib.rs

1//! Strict, effect-free decoding for Kennedy session Kweb contracts.
2
3#![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}