polyspec_crudui_validator/list/mod.rs
1//! CRUDUI list-spec structure validation (SPEC §9).
2//!
3//! A list contains no row data, so this module performs only composition and
4//! forbidden-key scanning. The server supplies rows separately.
5//!
6//! Pass 1 — compose (G5): `compose_spec`/`compose_properties` expand
7//! `$ref`/`$patch` on the list root, the `columns` map, and the `search`
8//! sub-form. An unresolved composition is a LOAD failure (`ComposeLoadError`),
9//! never a silent pass — the same engine, the same error.
10//!
11//! Pass 2 — forbidden-scan (§6): `scan_forbidden_keys` walks the COMPOSED list
12//! tree (columns / each Column / open format-options bucket / actions / sort /
13//! pagination / design / search) to ARBITRARY depth and rejects any forbidden
14//! meta key (`display_switch`/`if`/`when`/`show_if`/`x{key}`…) as a LOAD failure.
15//!
16//! The meta-schema separately checks closed objects, required columns, enums and
17//! CellFormat polymorphism. This runtime does not repeat those shape checks. The
18//! shared fixture `tests/fixtures/list-validity/cases.json` declares both results.
19
20use serde_json::{Map, Value};
21
22use crate::compose::{compose_spec, ComposeLoadError, ComposeOptions, FileLoader};
23use crate::structure::{compose_map_slot, compose_root, validate_structure};
24
25/// Options for a list-spec validation run (the form `ValidateOptions` shape —
26/// no data, so no `data` field).
27#[derive(Default)]
28pub struct ValidateListOptions<'a> {
29 /// Virtual file set for `$ref` resolution (default empty — no `$ref`).
30 pub files: Option<Map<String, Value>>,
31 /// A custom loader (overrides `files`).
32 pub loader: Option<&'a dyn FileLoader>,
33 /// Basepath for relative `$ref` resolution.
34 pub basepath: Option<String>,
35}
36
37/// Validate a list-spec's STRUCTURE: compose (§5 G5) then forbidden-scan (§6).
38///
39/// Returns `Ok(())` when the composed list tree carries no forbidden meta key.
40/// Returns `Err(ComposeLoadError)` for an unresolved composition (`$ref`/`$patch`)
41/// OR a forbidden meta key at any depth (`FORBIDDEN_META_KEY`) — both are LOAD
42/// failures (the spec never comes into existence), never a `valid:false`.
43///
44/// The composed list tree is built field-by-field so each sub-tree composes with
45/// the form engine it shares. The list root may carry `$ref`/`$patch` →
46/// `compose_spec` (whole-spec inheritance), but the root is NOT a form Field, so
47/// its named slots (`columns`/`search`/…) are composed individually, NOT via the
48/// form `properties` recursion. `columns` is a properties-shaped map →
49/// `compose_properties` (the SAME code that composes form `properties`).
50/// `search` is a form-spec reference (input, §9.1) → `compose_spec`.
51pub fn validate_list(spec: &Value, options: &ValidateListOptions) -> Result<(), ComposeLoadError> {
52 // Load-path forbidden-scan (§6) runs over the WHOLE composed list tree, with
53 // the trace seeded at the list root (no `properties` prefix — a list is not a
54 // Field).
55 validate_structure(
56 spec,
57 options.files.as_ref(),
58 options.loader,
59 options.basepath.as_deref(),
60 compose_list,
61 )
62}
63
64/// Compose a list-spec into a single composition-free tree: expand the root
65/// `$ref`/`$patch`, then expand the `columns` map and the `search` sub-form.
66///
67/// A non-object spec composes to itself (the forbidden-scan then finds nothing —
68/// the meta-schema owns the "list must be an object/required columns" shape).
69fn compose_list(
70 spec: &Value,
71 loader: &dyn FileLoader,
72 opts: &ComposeOptions,
73) -> Result<Value, ComposeLoadError> {
74 // Root-level $ref/$patch: a list may inherit a whole base list. compose_spec
75 // expands $ref/$patch and recurses into a `properties` child — a list has no
76 // `properties`, so this only flattens the root composition keys.
77 let mut composed = match compose_root(spec, loader, opts)? {
78 Some(m) => m,
79 None => return Ok(spec.clone()),
80 };
81
82 // `columns` is a properties-shaped map (named Column entries, possibly a
83 // `$ref`/`$patch` compose entry) → the form properties engine, verbatim.
84 compose_map_slot(&mut composed, "columns", loader, opts)?;
85
86 // `search` is a form-spec reference (input, §9.1): a Field/group entry that
87 // may carry `$ref`/`$patch` → compose_spec (which recurses into its own
88 // `properties`, so the referenced sub-form composes fully).
89 if let Some(Value::Object(search)) = composed.get("search").cloned() {
90 let expanded = compose_spec(search, loader, opts)?;
91 composed.insert("search".to_string(), Value::Object(expanded));
92 }
93
94 Ok(Value::Object(composed))
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100 use crate::compose::ComposeErrorCode;
101 use serde_json::json;
102
103 fn run(spec: &Value) -> Result<(), ComposeLoadError> {
104 validate_list(spec, &ValidateListOptions::default())
105 }
106
107 fn run_with_files(spec: &Value, files: Value) -> Result<(), ComposeLoadError> {
108 let opts = ValidateListOptions {
109 files: files.as_object().cloned(),
110 loader: None,
111 basepath: None,
112 };
113 validate_list(spec, &opts)
114 }
115
116 #[test]
117 fn minimal_columns_passes() {
118 let v = json!({ "columns": { "name": { "field": "name" } } });
119 assert!(run(&v).is_ok());
120 }
121
122 #[test]
123 fn forbidden_column_key_is_load_error() {
124 let v = json!({
125 "columns": { "name": { "field": "name" }, "display_switch": { "field": "x" } }
126 });
127 let err = run(&v).unwrap_err();
128 assert_eq!(err.code, ComposeErrorCode::ForbiddenMetaKey);
129 assert_eq!(err.trace.join("."), "columns.display_switch");
130 }
131
132 #[test]
133 fn x_prefixed_column_key_is_load_error() {
134 let v = json!({ "columns": { "name": { "field": "name" }, "xclass": { "field": "x" } } });
135 assert_eq!(run(&v).unwrap_err().trace.join("."), "columns.xclass");
136 }
137
138 #[test]
139 fn forbidden_key_in_format_options_bucket_is_load_error() {
140 let v = json!({
141 "columns": { "name": { "field": "name", "format": { "type": "badge", "if": ".admin" } } }
142 });
143 assert_eq!(
144 run(&v).unwrap_err().trace.join("."),
145 "columns.name.format.if"
146 );
147 }
148
149 #[test]
150 fn columns_ref_patch_composes_with_supplied_files() {
151 // The columns map is composed by the FORM properties engine: $ref pulls a
152 // base file's `properties` layer (detectKey), $patch overlays.
153 let files = json!({
154 "base-columns.yml": { "properties": { "name": { "field": "name" } } }
155 });
156 let v = json!({
157 "columns": { "$ref": "base-columns.yml", "$patch": { "extra": { "field": "extra" } } }
158 });
159 assert!(run_with_files(&v, files).is_ok());
160 }
161
162 #[test]
163 fn unresolved_columns_ref_is_load_error() {
164 // No files → the columns $ref cannot resolve → REF_FILE_NOT_FOUND. An
165 // unresolved composition is a LOAD failure, never a silent pass.
166 let v = json!({ "columns": { "$ref": "missing.yml" } });
167 assert_eq!(run(&v).unwrap_err().code, ComposeErrorCode::RefFileNotFound);
168 }
169
170 #[test]
171 fn search_form_ref_composes_with_supplied_files() {
172 let files = json!({
173 "search-form.yml": { "properties": { "q": { "type": "text" } } }
174 });
175 let v = json!({
176 "columns": { "name": { "field": "name" } },
177 "search": { "$ref": "search-form.yml", "$patch": { "add": {} } }
178 });
179 assert!(run_with_files(&v, files).is_ok());
180 }
181
182 #[test]
183 fn forbidden_key_in_composed_search_subform_is_load_error() {
184 // The search sub-form composes, then forbidden-scan walks it: a meta key
185 // INHERITED through the search $ref base is caught at its composed depth.
186 let files = json!({
187 "search-form.yml": { "properties": { "q": { "type": "text", "show_if": ".admin" } } }
188 });
189 let v = json!({
190 "columns": { "name": { "field": "name" } },
191 "search": { "$ref": "search-form.yml" }
192 });
193 // compose_spec on the search $ref resolves the file's `properties` layer
194 // (detectKey) and flattens it onto `search` directly, so the offending key
195 // composes to `search.q.show_if` (NOT under a surviving `properties`).
196 let err = run_with_files(&v, files).unwrap_err();
197 assert_eq!(err.code, ComposeErrorCode::ForbiddenMetaKey);
198 assert_eq!(err.trace.join("."), "search.q.show_if");
199 }
200
201 #[test]
202 fn metaschema_only_shape_violations_pass_the_engine() {
203 // The runtime does not report meta-schema-only shape errors.
204 for v in [
205 json!({ "columns": { "name": { "field": "name" } }, "sort": { "dir": "sideways" } }),
206 json!({ "columns": { "name": { "field": "name" } }, "limit": 10 }),
207 json!({ "columns": { "name": { "field": "name", "format": ["date"] } } }),
208 json!({ "sort": { "field": "name" } }),
209 ] {
210 assert!(
211 run(&v).is_ok(),
212 "engine must not reject a meta-schema-only shape: {v}"
213 );
214 }
215 }
216}