1use serde_json::Value;
16
17use crate::constants::{BASIC_CATALOG_ID, DEFAULT_SURFACE_ID};
18use crate::message::{AgentMessage, Component};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum Intent {
23 #[default]
25 Create,
26 Update,
28}
29
30impl Intent {
31 pub fn from_wire(value: &str) -> Option<Self> {
36 match value.trim().to_ascii_lowercase().as_str() {
37 "create" | "new" => Some(Intent::Create),
38 "update" | "edit" => Some(Intent::Update),
39 _ => None,
40 }
41 }
42
43 pub fn as_str(self) -> &'static str {
45 match self {
46 Intent::Create => "create",
47 Intent::Update => "update",
48 }
49 }
50
51 pub fn needs_create_surface(self) -> bool {
53 matches!(self, Intent::Create)
54 }
55}
56
57impl std::fmt::Display for Intent {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 f.write_str(self.as_str())
60 }
61}
62
63#[derive(Debug, Clone, PartialEq)]
65pub struct SurfaceSpec {
66 pub surface_id: String,
68 pub catalog_id: String,
70 pub components: Vec<Component>,
72 pub data_model: Option<Value>,
74 pub data_path: String,
80 pub theme: Option<Value>,
82 pub send_data_model: Option<bool>,
84}
85
86impl Default for SurfaceSpec {
87 fn default() -> Self {
88 Self {
89 surface_id: DEFAULT_SURFACE_ID.to_string(),
90 catalog_id: BASIC_CATALOG_ID.to_string(),
91 components: Vec::new(),
92 data_model: None,
93 data_path: "/".to_string(),
94 theme: None,
95 send_data_model: None,
96 }
97 }
98}
99
100impl SurfaceSpec {
101 pub fn new(surface_id: impl Into<String>) -> Self {
103 Self {
104 surface_id: surface_id.into(),
105 ..Self::default()
106 }
107 }
108
109 #[must_use]
111 pub fn with_components(mut self, components: Vec<Component>) -> Self {
112 self.components = components;
113 self
114 }
115
116 #[must_use]
118 pub fn with_data_model(mut self, data_model: Value) -> Self {
119 self.data_model = Some(data_model);
120 self
121 }
122
123 #[must_use]
125 pub fn with_catalog_id(mut self, catalog_id: impl Into<String>) -> Self {
126 self.catalog_id = catalog_id.into();
127 self
128 }
129
130 #[must_use]
132 pub fn with_data_path(mut self, data_path: impl Into<String>) -> Self {
133 self.data_path = data_path.into();
134 self
135 }
136}
137
138pub fn create_surface(
140 surface_id: impl Into<String>,
141 catalog_id: impl Into<String>,
142 theme: Option<Value>,
143 send_data_model: Option<bool>,
144) -> AgentMessage {
145 let mut message = AgentMessage::create_surface(surface_id, catalog_id);
146 if let crate::message::AgentPayload::CreateSurface(payload) = &mut message.payload {
147 payload.theme = theme;
148 payload.send_data_model = send_data_model;
149 }
150 message
151}
152
153pub fn update_components(
155 surface_id: impl Into<String>,
156 components: Vec<Component>,
157) -> AgentMessage {
158 AgentMessage::update_components(surface_id, components)
159}
160
161pub fn update_data_model(
166 surface_id: impl Into<String>,
167 path: impl Into<String>,
168 value: Value,
169) -> AgentMessage {
170 AgentMessage::update_data_model(surface_id, path, value)
171}
172
173pub fn assemble_ops(intent: Intent, spec: &SurfaceSpec) -> Vec<AgentMessage> {
193 let mut ops = Vec::with_capacity(3);
194
195 if intent.needs_create_surface() {
196 ops.push(create_surface(
197 &spec.surface_id,
198 &spec.catalog_id,
199 spec.theme.clone(),
200 spec.send_data_model,
201 ));
202 }
203 if !spec.components.is_empty() {
204 ops.push(update_components(&spec.surface_id, spec.components.clone()));
205 }
206 if let Some(data_model) = &spec.data_model {
207 ops.push(update_data_model(
208 &spec.surface_id,
209 &spec.data_path,
210 data_model.clone(),
211 ));
212 }
213 ops
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use crate::message::AgentPayload;
220 use serde_json::json;
221
222 fn spec() -> SurfaceSpec {
223 SurfaceSpec::new("s1")
224 .with_components(vec![
225 Component::new("root", "Text").with("text", json!("hello")),
226 ])
227 .with_data_model(json!({"a": 1}))
228 }
229
230 #[test]
231 fn create_emits_all_three_operations_in_order() {
232 let ops = assemble_ops(Intent::Create, &spec());
233 let kinds: Vec<&str> = ops
234 .iter()
235 .map(|op| match op.payload {
236 AgentPayload::CreateSurface(_) => "create",
237 AgentPayload::UpdateComponents(_) => "components",
238 AgentPayload::UpdateDataModel(_) => "data",
239 _ => "other",
240 })
241 .collect();
242 assert_eq!(kinds, vec!["create", "components", "data"]);
243 }
244
245 #[test]
246 fn update_never_emits_create_surface() {
247 let ops = assemble_ops(Intent::Update, &spec());
248 assert!(
249 !ops.iter()
250 .any(|op| matches!(op.payload, AgentPayload::CreateSurface(_))),
251 "updating an existing surface must not re-create it"
252 );
253 assert_eq!(ops.len(), 2);
254 }
255
256 #[test]
257 fn empty_content_omits_its_operation() {
258 let bare = SurfaceSpec::new("s1");
259 assert_eq!(assemble_ops(Intent::Update, &bare).len(), 0);
260 assert_eq!(assemble_ops(Intent::Create, &bare).len(), 1);
261 }
262
263 #[test]
264 fn defaults_come_from_the_wire_constants() {
265 let spec = SurfaceSpec::default();
266 assert_eq!(spec.surface_id, DEFAULT_SURFACE_ID);
267 assert_eq!(spec.catalog_id, BASIC_CATALOG_ID);
268 assert_eq!(spec.data_path, "/");
269 }
270
271 #[test]
272 fn theme_and_send_data_model_ride_on_create_surface() {
273 let mut spec = spec();
274 spec.theme = Some(json!({"primaryColor": "#00BFFF"}));
275 spec.send_data_model = Some(true);
276 let ops = assemble_ops(Intent::Create, &spec);
277 let AgentPayload::CreateSurface(payload) = &ops[0].payload else {
278 panic!("expected createSurface");
279 };
280 assert_eq!(payload.theme, Some(json!({"primaryColor": "#00BFFF"})));
281 assert_eq!(payload.send_data_model, Some(true));
282 }
283
284 #[test]
285 fn a_narrower_data_path_is_preserved() {
286 let spec = SurfaceSpec::new("s1")
287 .with_data_model(json!("Ada"))
288 .with_data_path("/user/name");
289 let ops = assemble_ops(Intent::Update, &spec);
290 let AgentPayload::UpdateDataModel(payload) = &ops[0].payload else {
291 panic!("expected updateDataModel");
292 };
293 assert_eq!(payload.path, "/user/name");
294 }
295
296 #[test]
297 fn intent_parses_the_wire_spellings_and_rejects_the_rest() {
298 assert_eq!(Intent::from_wire("create"), Some(Intent::Create));
299 assert_eq!(Intent::from_wire(" UPDATE "), Some(Intent::Update));
300 assert_eq!(Intent::from_wire("edit"), Some(Intent::Update));
301 assert_eq!(Intent::from_wire("replace"), None);
302 assert_eq!(Intent::default(), Intent::Create);
303 }
304}