1use std::collections::BTreeMap;
9use std::fmt::Write as _;
10
11use cratefield_core::SurfaceDocument;
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14
15pub const UI_SPEC_VERSION: u32 = 1;
17
18pub const UI_SPEC_KEY: &str = "UI_SPEC";
23
24#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
25#[serde(deny_unknown_fields)]
26pub struct UiSpec {
27 #[serde(default = "default_version")]
29 pub version: u32,
30 #[serde(default)]
32 pub theme: Theme,
33 #[serde(default)]
35 pub modules: BTreeMap<String, ModuleSpec>,
36}
37
38fn default_version() -> u32 {
39 UI_SPEC_VERSION
40}
41
42#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
43#[serde(deny_unknown_fields)]
44pub struct Theme {
45 #[serde(default)]
48 pub tokens: BTreeMap<String, String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub css_url: Option<String>,
53}
54
55#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
56#[serde(deny_unknown_fields)]
57pub struct ModuleSpec {
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub title: Option<String>,
61 #[serde(default)]
63 pub actions: BTreeMap<String, ActionSpec>,
64}
65
66#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
67#[serde(deny_unknown_fields)]
68pub struct ActionSpec {
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub title: Option<String>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub intro: Option<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub submit: Option<String>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub success: Option<String>,
82 #[serde(default)]
84 pub fields: BTreeMap<String, FieldSpec>,
85 #[serde(default, skip_serializing_if = "Vec::is_empty")]
87 pub order: Vec<String>,
88 #[serde(default)]
90 pub pages: BTreeMap<String, PageCopy>,
91}
92
93#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
94#[serde(deny_unknown_fields)]
95pub struct FieldSpec {
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub label: Option<String>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub placeholder: Option<String>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub help: Option<String>,
102 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
104 pub hidden: bool,
105}
106
107#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
108#[serde(deny_unknown_fields)]
109pub struct PageCopy {
110 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub title: Option<String>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub message: Option<String>,
114}
115
116impl UiSpec {
117 pub fn parse(json: &str) -> Result<Self, String> {
123 let spec: Self = serde_json::from_str(json).map_err(|err| format!("ui spec: {err}"))?;
124 if spec.version != UI_SPEC_VERSION {
125 return Err(format!(
126 "ui spec: version {} is not supported; this renderer reads version {UI_SPEC_VERSION}",
127 spec.version
128 ));
129 }
130 Ok(spec)
131 }
132
133 pub fn validate(&self, surface: &SurfaceDocument) -> Result<(), Vec<String>> {
141 let mut errors = Vec::new();
142 for key in self.theme.tokens.keys() {
143 if !key.starts_with("--cf-") {
144 errors.push(format!(
145 "theme.tokens.{key}: custom property names must start with --cf-"
146 ));
147 }
148 }
149 if let Some(url) = &self.theme.css_url
150 && !(url.starts_with("https://") || url.starts_with('/'))
151 {
152 errors.push(format!(
153 "theme.css_url: {url:?} must be an https:// URL or a path on the API origin"
154 ));
155 }
156 for (module_name, module) in &self.modules {
157 let Some(entry) = surface.modules.iter().find(|m| &m.name == module_name) else {
158 errors.push(format!(
159 "modules.{module_name}: no module with that name declares a surface"
160 ));
161 continue;
162 };
163 for (action_name, action) in &module.actions {
164 let path = format!("modules.{module_name}.actions.{action_name}");
165 let Some(declared) = entry
166 .surface
167 .actions
168 .iter()
169 .find(|a| &a.name == action_name)
170 else {
171 errors.push(format!(
172 "{path}: module `{module_name}` declares no such action"
173 ));
174 continue;
175 };
176 let known: Vec<String> = declared
177 .input
178 .as_ref()
179 .and_then(|s| s.as_value().get("properties"))
180 .and_then(serde_json::Value::as_object)
181 .map(|p| p.keys().cloned().collect())
182 .unwrap_or_default();
183 for field in action.fields.keys() {
184 if !known.contains(field) {
185 errors.push(format!(
186 "{path}.fields.{field}: action `{action_name}` has no field with that \
187 name (it has: {})",
188 known.join(", ")
189 ));
190 }
191 }
192 for field in &action.order {
193 if !known.contains(field) {
194 errors.push(format!(
195 "{path}.order: `{field}` is not a field of `{action_name}`"
196 ));
197 }
198 }
199 for page in action.pages.keys() {
200 if !matches!(page.as_str(), "done" | "expired") {
201 errors.push(format!(
202 "{path}.pages.{page}: landing pages are `done` and `expired`"
203 ));
204 }
205 }
206 }
207 }
208 if errors.is_empty() {
209 Ok(())
210 } else {
211 Err(errors)
212 }
213 }
214
215 #[must_use]
217 pub fn action(&self, module: &str, action: &str) -> Option<&ActionSpec> {
218 self.modules.get(module)?.actions.get(action)
219 }
220
221 #[must_use]
223 pub fn theme_css(&self) -> String {
224 if self.theme.tokens.is_empty() {
225 return String::new();
226 }
227 let mut css = String::from(":root {\n");
228 for (key, value) in &self.theme.tokens {
229 let value: String = value
230 .chars()
231 .filter(|c| !matches!(c, ';' | '{' | '}'))
232 .collect();
233 let _ = writeln!(css, " {key}: {value};");
234 }
235 css.push_str("}\n");
236 css
237 }
238
239 #[must_use]
241 pub fn schema() -> serde_json::Value {
242 let mut settings = schemars::generate::SchemaSettings::draft2020_12();
243 settings.inline_subschemas = true;
244 let mut schema = schemars::SchemaGenerator::new(settings)
245 .into_root_schema_for::<UiSpec>()
246 .to_value();
247 if let Some(root) = schema.as_object_mut() {
248 root.insert(
249 "$id".into(),
250 "https://cratefield.com/schemas/ui-spec-v1.schema.json".into(),
251 );
252 root.insert("title".into(), "UiSpec v1".into());
253 root.insert(
254 "description".into(),
255 "Copy, field order, hidden fields and theme tokens for a Cratefield harness UI \
256 (ADR 0010). Everything a prompt may change; nothing that is code."
257 .into(),
258 );
259 }
260 schema
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn parse_rejects_unknown_keys_and_versions() {
270 assert!(UiSpec::parse(r#"{"version":1}"#).is_ok());
271 assert!(UiSpec::parse("{}").is_ok(), "version defaults to 1");
272 let err = UiSpec::parse(r#"{"version":2}"#).unwrap_err();
273 assert!(err.contains("version 2"), "{err}");
274 let err = UiSpec::parse(r#"{"modules":{"x":{"colour":"red"}}}"#).unwrap_err();
275 assert!(err.contains("unknown field `colour`"), "{err}");
276 }
277
278 #[test]
279 fn theme_css_is_root_tokens_with_dangerous_chars_dropped() {
280 let json = "{\"theme\":{\"tokens\":{\"--cf-accent\":\"#0a7\",\"--cf-radius\":\"0px;} body{display:none\"}}}";
281 let spec = UiSpec::parse(json).unwrap();
282 let css = spec.theme_css();
283 assert_eq!(
284 css,
285 ":root {\n --cf-accent: #0a7;\n --cf-radius: 0px bodydisplay:none;\n}\n"
286 );
287 assert!(UiSpec::default().theme_css().is_empty());
288 }
289}