Skip to main content

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}