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::{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}