Skip to main content

slop_ai/
types.rs

1use serde::{Deserialize, Serialize};
2use serde_json::{Map, Value};
3
4/// A single node in the SLOP state tree (wire format).
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
6pub struct SlopNode {
7    pub id: String,
8    #[serde(rename = "type")]
9    pub node_type: String,
10    #[serde(skip_serializing_if = "Option::is_none")]
11    pub properties: Option<Map<String, Value>>,
12    #[serde(skip_serializing_if = "Option::is_none")]
13    pub children: Option<Vec<SlopNode>>,
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub affordances: Option<Vec<Affordance>>,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub meta: Option<NodeMeta>,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub content_ref: Option<ContentRef>,
20}
21
22impl SlopNode {
23    pub fn new(id: impl Into<String>, node_type: impl Into<String>) -> Self {
24        Self {
25            id: id.into(),
26            node_type: node_type.into(),
27            properties: None,
28            children: None,
29            affordances: None,
30            meta: None,
31            content_ref: None,
32        }
33    }
34
35    pub fn root(id: impl Into<String>, name: impl Into<String>) -> Self {
36        let mut props = Map::new();
37        props.insert("label".into(), Value::String(name.into()));
38        Self {
39            id: id.into(),
40            node_type: "root".into(),
41            properties: Some(props),
42            children: Some(Vec::new()),
43            affordances: None,
44            meta: None,
45            content_ref: None,
46        }
47    }
48}
49
50/// An action available on a node.
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
52pub struct Affordance {
53    pub action: String,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub label: Option<String>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub description: Option<String>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub params: Option<Value>,
60    #[serde(default, skip_serializing_if = "is_false")]
61    pub dangerous: bool,
62    #[serde(default, skip_serializing_if = "is_false")]
63    pub idempotent: bool,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub estimate: Option<Estimate>,
66}
67
68impl Affordance {
69    pub fn new(action: impl Into<String>) -> Self {
70        Self {
71            action: action.into(),
72            label: None,
73            description: None,
74            params: None,
75            dangerous: false,
76            idempotent: false,
77            estimate: None,
78        }
79    }
80}
81
82/// Expected duration of an action.
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
84#[serde(rename_all = "lowercase")]
85pub enum Estimate {
86    Instant,
87    Fast,
88    Slow,
89    Async,
90}
91
92/// Attention and structural metadata for a node.
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
94pub struct NodeMeta {
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub summary: Option<String>,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub salience: Option<f64>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub pinned: Option<bool>,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub changed: Option<bool>,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub focus: Option<bool>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub urgency: Option<Urgency>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub reason: Option<String>,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub total_children: Option<usize>,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub window: Option<(usize, usize)>,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub created: Option<String>,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub updated: Option<String>,
117}
118
119impl NodeMeta {
120    pub fn new() -> Self {
121        Self {
122            summary: None,
123            salience: None,
124            pinned: None,
125            changed: None,
126            focus: None,
127            urgency: None,
128            reason: None,
129            total_children: None,
130            window: None,
131            created: None,
132            updated: None,
133        }
134    }
135
136    pub fn is_empty(&self) -> bool {
137        self.summary.is_none()
138            && self.salience.is_none()
139            && self.pinned.is_none()
140            && self.changed.is_none()
141            && self.focus.is_none()
142            && self.urgency.is_none()
143            && self.reason.is_none()
144            && self.total_children.is_none()
145            && self.window.is_none()
146            && self.created.is_none()
147            && self.updated.is_none()
148    }
149}
150
151impl Default for NodeMeta {
152    fn default() -> Self {
153        Self::new()
154    }
155}
156
157/// Time-sensitivity signal.
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
159#[serde(rename_all = "lowercase")]
160pub enum Urgency {
161    None,
162    Low,
163    Medium,
164    High,
165    Critical,
166}
167
168/// A single SLOP patch operation (modeled on RFC 6902).
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
170pub struct PatchOp {
171    pub op: PatchOpKind,
172    pub path: String,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub value: Option<Value>,
175    /// Zero-based destination index among siblings. Used by `move` (required)
176    /// and optionally by `add` when inserting a child at a specific position.
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub index: Option<usize>,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
182#[serde(rename_all = "lowercase")]
183pub enum PatchOpKind {
184    Add,
185    Remove,
186    Replace,
187    Move,
188}
189
190/// Reference to content that can be fetched on demand.
191#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
192pub struct ContentRef {
193    #[serde(rename = "type")]
194    pub content_type: ContentType,
195    pub mime: String,
196    pub summary: String,
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub size: Option<usize>,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub uri: Option<String>,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub preview: Option<String>,
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub encoding: Option<String>,
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub hash: Option<String>,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
210#[serde(rename_all = "lowercase")]
211pub enum ContentType {
212    Text,
213    Binary,
214    Stream,
215}
216
217fn is_false(v: &bool) -> bool {
218    !v
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use serde_json::json;
225
226    #[test]
227    fn test_slop_node_roundtrip() {
228        let node = SlopNode::root("app", "My App");
229        let json = serde_json::to_value(&node).unwrap();
230        assert_eq!(json["id"], "app");
231        assert_eq!(json["type"], "root");
232        assert_eq!(json["properties"]["label"], "My App");
233
234        let back: SlopNode = serde_json::from_value(json).unwrap();
235        assert_eq!(back.id, "app");
236        assert_eq!(back.node_type, "root");
237    }
238
239    #[test]
240    fn test_affordance_skip_false_fields() {
241        let aff = Affordance::new("toggle");
242        let json = serde_json::to_value(&aff).unwrap();
243        assert!(json.get("dangerous").is_none());
244        assert!(json.get("idempotent").is_none());
245    }
246
247    #[test]
248    fn test_affordance_with_dangerous() {
249        let json = json!({"action": "delete", "dangerous": true});
250        let aff: Affordance = serde_json::from_value(json).unwrap();
251        assert!(aff.dangerous);
252        assert!(!aff.idempotent);
253    }
254
255    #[test]
256    fn test_estimate_serialization() {
257        let est = Estimate::Async;
258        let json = serde_json::to_value(&est).unwrap();
259        assert_eq!(json, "async");
260    }
261
262    #[test]
263    fn test_patch_op() {
264        let op = PatchOp {
265            op: PatchOpKind::Replace,
266            path: "/properties/count".into(),
267            value: Some(json!(42)),
268            index: None,
269        };
270        let json = serde_json::to_value(&op).unwrap();
271        assert_eq!(json["op"], "replace");
272        assert_eq!(json["path"], "/properties/count");
273        assert_eq!(json["value"], 42);
274    }
275}