1use crate::schema::Component;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::collections::{HashMap, HashSet};
12
13pub const MAX_APPLICATION_PAGES: usize = 24;
14pub const MAX_PAGE_REGIONS: usize = 16;
15
16#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
17pub struct UiApplication {
18 pub id: String,
19 pub name: String,
20 #[serde(default = "default_application_version")]
21 pub version: u64,
22 #[serde(default)]
23 pub owner: String,
24 #[serde(default)]
25 pub updated_at: String,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub kit_id: Option<String>,
28 pub initial_route: String,
29 #[serde(default)]
30 pub navigation: Vec<UiNavigationItem>,
31 pub pages: Vec<UiApplicationPage>,
32 #[serde(default)]
33 pub state: HashMap<String, Value>,
34}
35
36fn default_application_version() -> u64 {
37 1
38}
39
40fn timestamp() -> String {
41 chrono::Utc::now().to_rfc3339()
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
45pub struct UiNavigationItem {
46 pub label: String,
47 pub route: String,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub icon: Option<String>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
53pub struct UiApplicationPage {
54 pub id: String,
55 pub route: String,
56 pub title: String,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub description: Option<String>,
59 #[serde(default)]
60 pub template: UiPageTemplate,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub eyebrow: Option<String>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub hero: Option<UiPageHero>,
65 pub regions: Vec<UiPageRegion>,
66 #[serde(default)]
67 pub aside: Vec<Component>,
68 #[serde(default)]
69 pub footer: Vec<Component>,
70 #[serde(default)]
71 pub atmosphere: UiPageAtmosphere,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
75#[serde(rename_all = "snake_case")]
76pub enum UiPageTemplate {
77 Marketing,
78 #[default]
79 Dashboard,
80 Workspace,
81 Detail,
82 Form,
83 Immersive,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
87pub struct UiPageHero {
88 pub title: String,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub description: Option<String>,
91 #[serde(default)]
92 pub content: Vec<Component>,
93 #[serde(default)]
94 pub actions: Vec<Component>,
95 #[serde(default)]
96 pub visual: Vec<Component>,
97 #[serde(default)]
98 pub alignment: UiHeroAlignment,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
102#[serde(rename_all = "snake_case")]
103pub enum UiHeroAlignment {
104 #[default]
105 Split,
106 Center,
107 Editorial,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
111pub struct UiPageRegion {
112 pub id: String,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub title: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub description: Option<String>,
117 #[serde(default)]
118 pub layout: UiRegionLayout,
119 #[serde(default = "default_region_columns")]
120 pub columns: u8,
121 #[serde(default)]
122 pub tone: UiRegionTone,
123 pub components: Vec<Component>,
124}
125
126fn default_region_columns() -> u8 {
127 3
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
131#[serde(rename_all = "snake_case")]
132pub enum UiRegionLayout {
133 #[default]
134 Stack,
135 Grid,
136 Bento,
137 Split,
138 Editorial,
139 Stats,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
143#[serde(rename_all = "snake_case")]
144pub enum UiRegionTone {
145 #[default]
146 Default,
147 Muted,
148 Accent,
149 Contrast,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
153#[serde(rename_all = "snake_case")]
154pub enum UiPageAtmosphere {
155 #[default]
156 Clean,
157 Grid,
158 Glow,
159 Gradient,
160 Spatial,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
164#[serde(tag = "operation", rename_all = "snake_case")]
165pub enum UiApplicationUpdate {
166 Navigate { route: String },
167 Replace { application: UiApplication },
168 UpsertPage { page: UiApplicationPage },
169 RemovePage { page_id: String },
170 PatchState { values: HashMap<String, Value> },
171}
172
173impl UiApplication {
174 pub fn normalize_metadata(&mut self, owner: impl Into<String>) {
175 if self.version == 0 {
176 self.version = 1;
177 }
178 if self.owner.trim().is_empty() {
179 self.owner = owner.into();
180 }
181 if self.updated_at.trim().is_empty() {
182 self.updated_at = timestamp();
183 }
184 }
185
186 fn touch(&mut self) {
187 self.version = self.version.saturating_add(1).max(1);
188 self.updated_at = timestamp();
189 }
190
191 pub fn validate(&self) -> Result<(), Vec<String>> {
192 let mut errors = Vec::new();
193 if self.id.trim().is_empty() {
194 errors.push("id must not be empty".to_string());
195 }
196 if self.name.trim().is_empty() {
197 errors.push("name must not be empty".to_string());
198 }
199 if self.pages.is_empty() {
200 errors.push("pages must contain at least one page".to_string());
201 }
202 if self.pages.len() > MAX_APPLICATION_PAGES {
203 errors.push(format!("pages must not exceed {MAX_APPLICATION_PAGES}"));
204 }
205
206 let mut ids = HashSet::new();
207 let mut routes = HashSet::new();
208 for (page_index, page) in self.pages.iter().enumerate() {
209 if page.id.trim().is_empty() || !ids.insert(page.id.as_str()) {
210 errors.push(format!(
211 "pages[{page_index}].id must be non-empty and unique"
212 ));
213 }
214 if !is_route(&page.route) || !routes.insert(page.route.as_str()) {
215 errors.push(format!(
216 "pages[{page_index}].route must be a unique absolute application route"
217 ));
218 }
219 if page.title.trim().is_empty() {
220 errors.push(format!("pages[{page_index}].title must not be empty"));
221 }
222 if page.regions.len() > MAX_PAGE_REGIONS {
223 errors.push(format!(
224 "pages[{page_index}].regions must not exceed {MAX_PAGE_REGIONS}"
225 ));
226 }
227 let mut region_ids = HashSet::new();
228 for (region_index, region) in page.regions.iter().enumerate() {
229 if region.id.trim().is_empty() || !region_ids.insert(region.id.as_str()) {
230 errors.push(format!("pages[{page_index}].regions[{region_index}].id must be non-empty and unique"));
231 }
232 if !(1..=6).contains(®ion.columns) {
233 errors.push(format!("pages[{page_index}].regions[{region_index}].columns must be between 1 and 6"));
234 }
235 }
236 }
237
238 if !routes.contains(self.initial_route.as_str()) {
239 errors.push("initial_route must reference an application page".to_string());
240 }
241 for (index, item) in self.navigation.iter().enumerate() {
242 if item.label.trim().is_empty() {
243 errors.push(format!("navigation[{index}].label must not be empty"));
244 }
245 if !routes.contains(item.route.as_str()) {
246 errors.push(format!(
247 "navigation[{index}].route must reference an application page"
248 ));
249 }
250 }
251
252 if errors.is_empty() {
253 Ok(())
254 } else {
255 Err(errors)
256 }
257 }
258
259 pub fn apply_update(
260 &mut self,
261 update: UiApplicationUpdate,
262 ) -> Result<Option<String>, Vec<String>> {
263 match update {
264 UiApplicationUpdate::Navigate { route } => {
265 if self.pages.iter().any(|page| page.route == route) {
266 Ok(Some(route))
267 } else {
268 Err(vec![
269 "navigate route must reference an application page".to_string(),
270 ])
271 }
272 }
273 UiApplicationUpdate::Replace { application } => {
274 let mut application = application;
275 application.validate()?;
276 application.version = self.version.saturating_add(1).max(1);
277 if application.owner.trim().is_empty() {
278 application.owner = self.owner.clone();
279 }
280 application.updated_at = timestamp();
281 *self = application;
282 Ok(Some(self.initial_route.clone()))
283 }
284 UiApplicationUpdate::UpsertPage { page } => {
285 let mut candidate = self.clone();
286 if let Some(index) = candidate
287 .pages
288 .iter()
289 .position(|candidate| candidate.id == page.id)
290 {
291 let previous_route = candidate.pages[index].route.clone();
292 candidate.pages[index] = page;
293 let next_route = candidate.pages[index].route.clone();
294 if previous_route != next_route {
295 if candidate.initial_route == previous_route {
296 candidate.initial_route = next_route.clone();
297 }
298 for item in &mut candidate.navigation {
299 if item.route == previous_route {
300 item.route = next_route.clone();
301 }
302 }
303 }
304 } else {
305 candidate.pages.push(page);
306 }
307 candidate.validate()?;
308 candidate.touch();
309 *self = candidate;
310 Ok(None)
311 }
312 UiApplicationUpdate::RemovePage { page_id } => {
313 let mut candidate = self.clone();
314 candidate.pages.retain(|page| page.id != page_id);
315 candidate
316 .navigation
317 .retain(|item| candidate.pages.iter().any(|page| page.route == item.route));
318 candidate.validate()?;
319 candidate.touch();
320 *self = candidate;
321 Ok(None)
322 }
323 UiApplicationUpdate::PatchState { values } => {
324 self.state.extend(values);
325 self.touch();
326 Ok(None)
327 }
328 }
329 }
330}
331
332pub async fn upsert_persisted_application_page(
334 store: &dyn crate::persistence::SurfaceStore,
335 owner: &str,
336 application_id: &str,
337 expected_version: u64,
338 page: UiApplicationPage,
339) -> Result<UiApplication, crate::persistence::SurfaceStoreError> {
340 let saved = store.load(owner, application_id).await?;
341 if saved.version != expected_version {
342 return Err(crate::persistence::SurfaceStoreError::VersionConflict {
343 expected: expected_version,
344 actual: saved.version,
345 });
346 }
347 let mut application: UiApplication = serde_json::from_value(saved.payload)
348 .map_err(|error| crate::persistence::SurfaceStoreError::Json(error.to_string()))?;
349 application
350 .apply_update(UiApplicationUpdate::UpsertPage { page })
351 .map_err(|errors| crate::persistence::SurfaceStoreError::Json(errors.join("; ")))?;
352 application.owner = owner.to_string();
353 let payload = serde_json::to_value(&application)
354 .map_err(|error| crate::persistence::SurfaceStoreError::Json(error.to_string()))?;
355 let persisted = store
356 .save(
357 owner,
358 application_id,
359 &saved.name,
360 payload,
361 Some(expected_version),
362 )
363 .await?;
364 application.version = persisted.version;
365 application.updated_at = persisted.updated_at;
366 Ok(application)
367}
368
369fn is_route(route: &str) -> bool {
370 route.starts_with('/')
371 && !route.starts_with("//")
372 && !route.contains("..")
373 && !route.contains(['?', '#', '\\'])
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 fn page(id: &str, route: &str) -> UiApplicationPage {
381 UiApplicationPage {
382 id: id.to_string(),
383 route: route.to_string(),
384 title: id.to_string(),
385 description: None,
386 template: UiPageTemplate::Dashboard,
387 eyebrow: None,
388 hero: None,
389 regions: vec![],
390 aside: vec![],
391 footer: vec![],
392 atmosphere: UiPageAtmosphere::Clean,
393 }
394 }
395
396 #[test]
397 fn validates_multi_page_application() {
398 let app = UiApplication {
399 id: "ops".into(),
400 name: "Operations".into(),
401 version: 1,
402 owner: "test".into(),
403 updated_at: timestamp(),
404 kit_id: None,
405 initial_route: "/".into(),
406 navigation: vec![UiNavigationItem {
407 label: "Home".into(),
408 route: "/".into(),
409 icon: None,
410 }],
411 pages: vec![page("home", "/"), page("detail", "/detail")],
412 state: HashMap::new(),
413 };
414 assert!(app.validate().is_ok());
415 }
416
417 #[test]
418 fn rejects_unknown_navigation_route() {
419 let mut app = UiApplication {
420 id: "ops".into(),
421 name: "Operations".into(),
422 version: 1,
423 owner: "test".into(),
424 updated_at: timestamp(),
425 kit_id: None,
426 initial_route: "/".into(),
427 navigation: vec![UiNavigationItem {
428 label: "Missing".into(),
429 route: "/missing".into(),
430 icon: None,
431 }],
432 pages: vec![page("home", "/")],
433 state: HashMap::new(),
434 };
435 assert!(app.validate().is_err());
436 assert!(
437 app.apply_update(UiApplicationUpdate::Navigate {
438 route: "/missing".into()
439 })
440 .is_err()
441 );
442 }
443
444 #[test]
445 fn upsert_page_rewrites_routes_and_increments_version() {
446 let mut app = UiApplication {
447 id: "ops".into(),
448 name: "Operations".into(),
449 version: 3,
450 owner: "agent".into(),
451 updated_at: timestamp(),
452 kit_id: None,
453 initial_route: "/".into(),
454 navigation: vec![UiNavigationItem {
455 label: "Home".into(),
456 route: "/".into(),
457 icon: None,
458 }],
459 pages: vec![page("home", "/")],
460 state: HashMap::new(),
461 };
462 app.apply_update(UiApplicationUpdate::UpsertPage {
463 page: page("home", "/home"),
464 })
465 .unwrap();
466 assert_eq!(app.version, 4);
467 assert_eq!(app.initial_route, "/home");
468 assert_eq!(app.navigation[0].route, "/home");
469 }
470
471 #[tokio::test]
472 async fn persisted_upsert_uses_optimistic_versioning() {
473 let store = crate::persistence::InMemorySurfaceStore::new();
474 let app = UiApplication {
475 id: "ops".into(),
476 name: "Operations".into(),
477 version: 1,
478 owner: "agent".into(),
479 updated_at: timestamp(),
480 kit_id: None,
481 initial_route: "/".into(),
482 navigation: vec![],
483 pages: vec![page("home", "/")],
484 state: HashMap::new(),
485 };
486 let payload = serde_json::to_value(&app).unwrap();
487 crate::persistence::SurfaceStore::save(
488 &store,
489 "agent",
490 "ops",
491 "Operations",
492 payload,
493 Some(0),
494 )
495 .await
496 .unwrap();
497 let updated =
498 upsert_persisted_application_page(&store, "agent", "ops", 1, page("detail", "/detail"))
499 .await
500 .unwrap();
501 assert_eq!(updated.version, 2);
502 assert_eq!(updated.pages.len(), 2);
503 }
504}