Skip to main content

cratefield_ui/
spec.rs

1//! `UiSpec` (ADR 0010 §4, issue #75): everything about the UI that is
2//! copy, order or theme rather than code. JSON, validated against the
3//! module surface so a typo can never be silently ignored, applied by the
4//! renderer, and the object a prompt produces later. The schema is
5//! committed at `schemas/ui-spec-v1.schema.json` and a test keeps it in
6//! step with these types.
7
8use std::collections::BTreeMap;
9use std::fmt::Write as _;
10
11use cratefield_core::SurfaceDocument;
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14
15/// The spec format version this crate reads.
16pub const UI_SPEC_VERSION: u32 = 1;
17
18/// Config key the control plane sets to inject a spec per venture without
19/// a rebuild (a JSON document). Invalid JSON or a spec that fails
20/// validation makes every `/ui` request answer a problem naming the
21/// error, never a page rendered from a half-read spec.
22pub 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    /// Must be `1`.
28    #[serde(default = "default_version")]
29    pub version: u32,
30    /// Theme: `--cf-*` custom property values and an optional stylesheet.
31    #[serde(default)]
32    pub theme: Theme,
33    /// Per module, by module name.
34    #[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    /// Custom property values, keys starting with `--cf-` (`"--cf-accent":
46    /// "#0a7"`). Served as `/ui/theme.css` and linked after `cf.css`.
47    #[serde(default)]
48    pub tokens: BTreeMap<String, String>,
49    /// A stylesheet linked after the tokens: absolute `https://` URL or a
50    /// path on the API origin.
51    #[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    /// Shown instead of the humanized module name.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub title: Option<String>,
61    /// Per action, by action name.
62    #[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    /// Page title and heading.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub title: Option<String>,
72    /// A paragraph above the form.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub intro: Option<String>,
75    /// The submit button label.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub submit: Option<String>,
78    /// The notice after an accepted submit; overrides the module's
79    /// `Outcome::Accepted` message.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub success: Option<String>,
82    /// Per field, by property name.
83    #[serde(default)]
84    pub fields: BTreeMap<String, FieldSpec>,
85    /// Field order; unlisted fields follow in their declared order.
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub order: Vec<String>,
88    /// Landing pages of a link action: `done`, `expired`.
89    #[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    /// Never rendered as a control (the page or the embed supplies it).
103    #[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    /// Parses JSON. Unknown keys are errors at every level.
118    ///
119    /// # Errors
120    ///
121    /// The serde message, prefixed.
122    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    /// Checks every reference against the surface: modules, actions,
134    /// fields (in `fields` and `order`), page names, theme keys and the
135    /// stylesheet URL. Every problem is reported, with its JSON path.
136    ///
137    /// # Errors
138    ///
139    /// One line per problem.
140    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    /// The action's copy, if any.
216    #[must_use]
217    pub fn action(&self, module: &str, action: &str) -> Option<&ActionSpec> {
218        self.modules.get(module)?.actions.get(action)
219    }
220
221    /// `:root { --cf-…: …; }` from the theme tokens; empty when none.
222    #[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    /// The schema this crate's tests keep committed.
240    #[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}