Skip to main content

zenith_cli/commands/
schema.rs

1//! Pure logic for `zenith schema`.
2//!
3//! The public entry points operate entirely on static schema data — no
4//! filesystem I/O.  The caller (dispatch) is responsible for printing the
5//! returned string and mapping the exit code.
6
7use zenith_core::schema as core_schema;
8use zenith_tx::schema as tx_schema;
9
10use crate::commands::serialize_pretty;
11use crate::json_types::{
12    SchemaAttr, SchemaBrandChildNode, SchemaBrandDiagCode, SchemaBrandOutput, SchemaDiagnosticCode,
13    SchemaDiagnosticsOutput, SchemaNodeContent, SchemaNodeDetail, SchemaNodeEntry,
14    SchemaNodeOutput, SchemaNodesOutput, SchemaOpDetail, SchemaOpEntry, SchemaOpFieldEntry,
15    SchemaOpOutput, SchemaOpsOutput, SchemaOverridePropEntry, SchemaOverviewOutput,
16    SchemaSurfaceOutput, SchemaTokenDetail, SchemaTokenEntry, SchemaTokenOutput,
17    SchemaTokensOutput, SchemaVariantOutput,
18};
19
20/// Precedence note shown on the `schema diagnostics` surface.
21const DIAGNOSTICS_PRECEDENCE: &str = "Resolution today is the in-file `diagnostics { … }` \
22block (last-wins per code); CLI flags and config-file overrides resolve in a later unit.";
23
24// ── Public entry points ───────────────────────────────────────────────────────
25
26/// Bare `zenith schema`: short overview with counts and drill-in hints.
27///
28/// Returns `(stdout, exit_code)`.
29pub fn overview(json: bool) -> (String, u8) {
30    let node_count = core_schema::node_kinds().len();
31    let op_count = tx_schema::op_names().len();
32    let token_type_count = core_schema::token_types().len();
33
34    if json {
35        let out = SchemaOverviewOutput {
36            schema: "zenith-schema-v1",
37            node_kinds: node_count,
38            tx_ops: op_count,
39            token_types: token_type_count,
40        };
41        (serialize_pretty(&out), 0)
42    } else {
43        let diag_count = core_schema::diagnostic_codes().len();
44        let text = format!(
45            "Zenith schema — {node_count} node kinds, {op_count} tx ops, \
46             {token_type_count} token types, 7 non-node surfaces, \
47             {diag_count} diagnostic codes\n\n\
48             Drill in:\n  \
49             zenith schema nodes              # list all node kinds\n  \
50             zenith schema node <kind>        # attributes for one kind\n  \
51             zenith schema ops                # list all tx ops\n  \
52             zenith schema op <name>          # fields + example for one op\n  \
53             zenith schema tokens             # list all token types\n  \
54             zenith schema token <type>       # value form + children + example for one type\n  \
55             zenith schema page               # page declaration attributes\n  \
56             zenith schema asset              # asset declaration attributes\n  \
57             zenith schema document           # document root attributes\n  \
58             zenith schema variant            # variants block + override entry structure\n  \
59             zenith schema diagnostics        # diagnostic-policy verbs + codes\n  \
60             zenith schema brand              # brand-contract block (allowed colors/fonts/weights)\n  \
61             zenith schema block              # block role declaration: vocab, props, scopes\n\n\
62             Attribute types, required-ness, and valid values are enforced by \
63             `zenith validate`."
64        );
65        (text, 0)
66    }
67}
68
69/// `zenith schema nodes`: list all node kinds with their summaries.
70///
71/// Returns `(stdout, exit_code)`.
72pub fn nodes(json: bool) -> (String, u8) {
73    let kinds = core_schema::node_kinds();
74
75    if json {
76        let entries: Vec<SchemaNodeEntry> = kinds
77            .iter()
78            .map(|&kind| SchemaNodeEntry {
79                kind: kind.to_owned(),
80                // node_summary is always Some for every kind in node_kinds().
81                summary: core_schema::node_summary(kind).unwrap_or("").to_owned(),
82            })
83            .collect();
84        let out = SchemaNodesOutput {
85            schema: "zenith-schema-v1",
86            nodes: entries,
87        };
88        (serialize_pretty(&out), 0)
89    } else {
90        let mut text = String::from("node kinds:\n");
91        for &kind in kinds {
92            let summary = core_schema::node_summary(kind).unwrap_or("");
93            text.push_str(&format!("  {kind:<12}  {summary}\n"));
94        }
95        (text.trim_end().to_owned(), 0)
96    }
97}
98
99/// `zenith schema node <kind>`: detail for one node kind.
100///
101/// Returns `(stdout, exit_code)`. On unknown kind, exit_code is 1 and stdout
102/// contains the error message (suitable for printing via the normal `println!`
103/// path so the caller need not special-case stderr).
104pub fn node_detail(kind: &str, json: bool) -> (String, u8) {
105    let summary = match core_schema::node_summary(kind) {
106        Some(s) => s,
107        None => {
108            let valid = core_schema::node_kinds().join(", ");
109            // Provide a targeted hint for the two most-common near-misses.
110            let hint = match kind {
111                "override" | "variant" => {
112                    "\nhint: 'override' and 'variant' are not node kinds — \
113                     see `zenith schema variant` for the variants block and override entry."
114                }
115                _ => "",
116            };
117            let msg = format!("error: unknown node kind '{kind}'\nvalid kinds: {valid}{hint}");
118            return (msg, 1);
119        }
120    };
121
122    let attrs: Vec<SchemaAttr> = core_schema::node_attributes(kind)
123        .iter()
124        .map(|&a| SchemaAttr {
125            name: a.to_owned(),
126            ty: core_schema::attribute_type_for_kind(kind, a).to_owned(),
127        })
128        .collect();
129
130    let content_desc = core_schema::node_content(kind);
131
132    if json {
133        let content = content_desc.map(|d| SchemaNodeContent {
134            description: d.description.to_owned(),
135            example: d.example.to_owned(),
136        });
137        let out = SchemaNodeOutput {
138            schema: "zenith-schema-v1",
139            node: SchemaNodeDetail {
140                kind: kind.to_owned(),
141                summary: summary.to_owned(),
142                attributes: attrs,
143                content,
144            },
145        };
146        (serialize_pretty(&out), 0)
147    } else {
148        let mut text = format!("{kind}: {summary}\n");
149        if attrs.is_empty() {
150            text.push_str("  (no fixed attribute list)\n");
151        } else {
152            text.push_str("Attributes:\n");
153            text.push_str(&format_attr_table(&attrs));
154        }
155        if let Some(d) = content_desc {
156            text.push_str("\nContent:\n");
157            text.push_str(&format!("  {}\n", d.description));
158            text.push_str("  Example:\n");
159            for line in d.example.lines() {
160                text.push_str(&format!("    {line}\n"));
161            }
162        }
163        text.push_str(
164            "\nNote: required-ness and full valid values are enforced by\n\
165             `zenith validate` (the authoritative diagnostic loop).",
166        );
167        (text.trim_end().to_owned(), 0)
168    }
169}
170
171/// `zenith schema ops`: list all tx ops with their summaries.
172///
173/// Returns `(stdout, exit_code)`.
174pub fn ops(json: bool) -> (String, u8) {
175    let names = tx_schema::op_names();
176
177    if json {
178        let entries: Vec<SchemaOpEntry> = names
179            .iter()
180            .map(|&name| SchemaOpEntry {
181                op: name.to_owned(),
182                summary: tx_schema::op_summary(name).unwrap_or("").to_owned(),
183            })
184            .collect();
185        let out = SchemaOpsOutput {
186            schema: "zenith-schema-v1",
187            ops: entries,
188        };
189        (serialize_pretty(&out), 0)
190    } else {
191        let mut text = String::from("tx ops:\n");
192        for &name in names {
193            let summary = tx_schema::op_summary(name).unwrap_or("");
194            text.push_str(&format!("  {name:<24}  {summary}\n"));
195        }
196        (text.trim_end().to_owned(), 0)
197    }
198}
199
200/// `zenith schema op <name>`: full detail for one tx op (summary + fields + example).
201///
202/// Returns `(stdout, exit_code)`. On unknown name, exit_code is 1.
203pub fn op_detail(name: &str, json: bool) -> (String, u8) {
204    let summary = match tx_schema::op_summary(name) {
205        Some(s) => s,
206        None => {
207            let valid = tx_schema::op_names().join(", ");
208            let msg = format!("error: unknown op '{name}'\nvalid ops: {valid}");
209            return (msg, 1);
210        }
211    };
212
213    // op_fields and op_example are always Some when op_summary is Some
214    // (enforced by the drift-guard tests in zenith-tx).
215    let fields = tx_schema::op_fields(name).unwrap_or(&[]);
216    let example = tx_schema::op_example(name).unwrap_or("");
217
218    if json {
219        let field_entries: Vec<SchemaOpFieldEntry> = fields
220            .iter()
221            .map(|f| SchemaOpFieldEntry {
222                name: f.name.to_owned(),
223                ty: f.ty.to_owned(),
224                required: f.required,
225            })
226            .collect();
227        let out = SchemaOpOutput {
228            schema: "zenith-schema-v1",
229            op: SchemaOpDetail {
230                op: name.to_owned(),
231                summary: summary.to_owned(),
232                fields: field_entries,
233                example: example.to_owned(),
234            },
235        };
236        (serialize_pretty(&out), 0)
237    } else {
238        let mut text = format!("{name}: {summary}\n");
239        if fields.is_empty() {
240            text.push_str("\nFields: (none — this op carries no fields beyond the \"op\" tag)\n");
241        } else {
242            text.push_str("\nFields:\n");
243            for f in fields {
244                let req = if f.required { ", required" } else { "" };
245                text.push_str(&format!("  {:<20}  ({}{req})\n", f.name, f.ty));
246            }
247        }
248        text.push_str(&format!("\nExample:\n  {example}"));
249        (text, 0)
250    }
251}
252
253// ── Token type formatters ─────────────────────────────────────────────────────
254
255/// `zenith schema tokens`: list all token types with their summaries.
256///
257/// Returns `(stdout, exit_code)`.
258pub fn tokens(json: bool) -> (String, u8) {
259    let types = core_schema::token_types();
260
261    if json {
262        let entries: Vec<SchemaTokenEntry> = types
263            .iter()
264            .map(|&ty| SchemaTokenEntry {
265                ty: ty.to_owned(),
266                // token_type_summary is always Some for every type in token_types().
267                summary: core_schema::token_type_summary(ty).unwrap_or("").to_owned(),
268            })
269            .collect();
270        let out = SchemaTokensOutput {
271            schema: "zenith-schema-v1",
272            token_types: entries,
273        };
274        (serialize_pretty(&out), 0)
275    } else {
276        let mut text = String::from("token types:\n");
277        for &ty in types {
278            let summary = core_schema::token_type_summary(ty).unwrap_or("");
279            text.push_str(&format!("  {ty:<12}  {summary}\n"));
280        }
281        (text.trim_end().to_owned(), 0)
282    }
283}
284
285/// `zenith schema token <type>`: full detail for one token type.
286///
287/// Returns `(stdout, exit_code)`. On unknown type, exit_code is 1 and stdout
288/// contains the error message.
289pub fn token_detail(ty: &str, json: bool) -> (String, u8) {
290    let desc = match core_schema::token_type_descriptor(ty) {
291        Some(d) => d,
292        None => {
293            let valid = core_schema::token_types().join(", ");
294            let msg = format!("error: unknown token type '{ty}'\nvalid types: {valid}");
295            return (msg, 1);
296        }
297    };
298
299    if json {
300        let out = SchemaTokenOutput {
301            schema: "zenith-schema-v1",
302            token: SchemaTokenDetail {
303                ty: desc.type_name.to_owned(),
304                summary: desc.summary.to_owned(),
305                value_form: desc.value_form.to_owned(),
306                child_nodes: desc.child_nodes.to_owned(),
307                example: desc.example.to_owned(),
308            },
309        };
310        (serialize_pretty(&out), 0)
311    } else {
312        let mut text = format!("{}: {}\n", desc.type_name, desc.summary);
313        if !desc.value_form.is_empty() {
314            text.push_str(&format!("\nValue form:\n  {}\n", desc.value_form));
315        }
316        if !desc.child_nodes.is_empty() {
317            text.push_str(&format!("\nChild nodes:\n  {}\n", desc.child_nodes));
318        }
319        text.push_str(&format!(
320            "\nExample:\n  {}",
321            desc.example.replace('\n', "\n  ")
322        ));
323        (text, 0)
324    }
325}
326
327// ── Non-node surface formatters ───────────────────────────────────────────────
328
329/// `zenith schema page`: summary + recognized attributes for a page declaration.
330///
331/// Returns `(stdout, exit_code)`.
332pub fn page(json: bool) -> (String, u8) {
333    surface_detail(
334        "page",
335        core_schema::page_summary(),
336        core_schema::page_attributes(),
337        json,
338    )
339}
340
341/// `zenith schema asset`: summary + recognized attributes for an asset declaration.
342///
343/// Returns `(stdout, exit_code)`.
344pub fn asset(json: bool) -> (String, u8) {
345    surface_detail(
346        "asset",
347        core_schema::asset_summary(),
348        core_schema::asset_attributes(),
349        json,
350    )
351}
352
353/// `zenith schema document`: summary + recognized attributes for the document root.
354///
355/// Returns `(stdout, exit_code)`.
356pub fn document(json: bool) -> (String, u8) {
357    surface_detail(
358        "document",
359        core_schema::document_summary(),
360        core_schema::document_attributes(),
361        json,
362    )
363}
364
365/// Shared formatter for non-node surfaces (page / asset / document).
366fn surface_detail(
367    surface: &'static str,
368    summary: &'static str,
369    raw_attrs: Vec<&'static str>,
370    json: bool,
371) -> (String, u8) {
372    let attrs: Vec<SchemaAttr> = raw_attrs
373        .iter()
374        .map(|&a| SchemaAttr {
375            name: a.to_owned(),
376            ty: core_schema::attribute_type(a).to_owned(),
377        })
378        .collect();
379
380    if json {
381        let out = SchemaSurfaceOutput {
382            schema: "zenith-schema-v1",
383            surface,
384            summary: summary.to_owned(),
385            attributes: attrs,
386        };
387        (serialize_pretty(&out), 0)
388    } else {
389        let mut text = format!("{surface}: {summary}\n");
390        if attrs.is_empty() {
391            text.push_str("  (no fixed attribute list)\n");
392        } else {
393            text.push_str("Attributes:\n");
394            text.push_str(&format_attr_table(&attrs));
395        }
396        text.push_str(
397            "\nNote: required-ness and full valid values are enforced by\n\
398             `zenith validate` (the authoritative diagnostic loop).",
399        );
400        (text.trim_end().to_owned(), 0)
401    }
402}
403
404/// `zenith schema variant`: descriptor for the `variants` block and `override` entry.
405///
406/// Returns `(stdout, exit_code)`.
407pub fn variant(json: bool) -> (String, u8) {
408    let desc = core_schema::variant_descriptor();
409
410    if json {
411        let props: Vec<SchemaOverridePropEntry> = desc
412            .override_props
413            .iter()
414            .map(|&(name, ty, required)| SchemaOverridePropEntry {
415                name: name.to_owned(),
416                ty: ty.to_owned(),
417                required,
418            })
419            .collect();
420        let out = SchemaVariantOutput {
421            schema: "zenith-schema-v1",
422            summary: desc.summary.to_owned(),
423            block_structure: desc.block_structure.to_owned(),
424            variant_node: desc.variant_node.to_owned(),
425            override_entry: desc.override_entry.to_owned(),
426            override_props: props,
427            example: desc.example.to_owned(),
428        };
429        (serialize_pretty(&out), 0)
430    } else {
431        let mut text = format!("variant: {}\n", desc.summary);
432
433        text.push_str(&format!("\nBlock structure:\n  {}\n", desc.block_structure));
434        text.push_str(&format!(
435            "\nvariant node:\n  {}\n",
436            desc.variant_node.replace('\n', "\n  ")
437        ));
438        text.push_str(&format!(
439            "\noverride entry:\n  {}\n",
440            desc.override_entry.replace('\n', "\n  ")
441        ));
442
443        text.push_str("\nOverride properties:\n");
444        let col_width = desc
445            .override_props
446            .iter()
447            .map(|(n, _, _)| n.len())
448            .max()
449            .unwrap_or(0);
450        for &(name, ty, required) in desc.override_props {
451            let req = if required { ", required" } else { "" };
452            text.push_str(&format!(
453                "  {:<col_width$}  —  ({ty}{req})\n",
454                name,
455                col_width = col_width,
456            ));
457        }
458
459        text.push_str(&format!(
460            "\nExample:\n  {}",
461            desc.example.replace('\n', "\n  ")
462        ));
463        (text, 0)
464    }
465}
466
467/// `zenith schema diagnostics`: the in-file diagnostic-policy verbs and the
468/// governable diagnostic codes.
469///
470/// Returns `(stdout, exit_code)`.
471pub fn diagnostics(json: bool) -> (String, u8) {
472    let summary = core_schema::diagnostics_summary();
473    let verbs = core_schema::diagnostics_verbs();
474    let catalog = core_schema::diagnostic_codes();
475
476    if json {
477        let codes: Vec<SchemaDiagnosticCode> = catalog
478            .iter()
479            .map(|info| SchemaDiagnosticCode {
480                code: info.code.to_owned(),
481                severity: crate::json_types::severity_str(&info.severity).to_owned(),
482                summary: info.summary.to_owned(),
483                governable: info.is_governable(),
484            })
485            .collect();
486        let out = SchemaDiagnosticsOutput {
487            schema: "zenith-schema-v1",
488            summary: summary.to_owned(),
489            verbs: verbs.iter().map(|&v| v.to_owned()).collect(),
490            precedence: DIAGNOSTICS_PRECEDENCE,
491            codes,
492        };
493        (serialize_pretty(&out), 0)
494    } else {
495        let mut text = format!("diagnostics: {summary}\n\n");
496
497        text.push_str("Policy verbs (in a root `diagnostics { … }` block):\n");
498        text.push_str("  allow \"<code>\"  —  suppress this advisory/warning\n");
499        text.push_str("  deny  \"<code>\"  —  elevate to a blocking Error (CI gate)\n");
500        text.push_str("  warn  \"<code>\"  —  force to a Warning\n\n");
501
502        text.push_str("Precedence: ");
503        text.push_str(DIAGNOSTICS_PRECEDENCE);
504        text.push_str("\n\n");
505
506        // Governable codes (Warning/Advisory) — what a policy can actually adjust.
507        text.push_str("Governable codes (code · severity · summary):\n");
508        let governable: Vec<&_> = catalog.iter().filter(|i| i.is_governable()).collect();
509        let col_width = governable.iter().map(|i| i.code.len()).max().unwrap_or(0);
510        for info in &governable {
511            text.push_str(&format!(
512                "  {:<col_width$}  {:<9}  {}\n",
513                info.code,
514                crate::json_types::severity_str(&info.severity),
515                info.summary,
516                col_width = col_width,
517            ));
518        }
519
520        text.push_str(
521            "\nNote: integrity Errors cannot be suppressed or weakened — `allow`/`warn` on an \
522             Error code is reported as `policy.ineffective_on_error`. Only governable \
523             (Warning/Advisory) codes are listed above; for the COMPLETE catalog including the \
524             always-enforced Error codes (e.g. `token.raw_visual_literal`), run \
525             `zenith schema diagnostics --json`.",
526        );
527        (text.trim_end().to_owned(), 0)
528    }
529}
530
531/// `zenith schema brand`: structure and semantics of the top-level `brand { … }` block.
532///
533/// Returns `(stdout, exit_code)`.
534pub fn brand(json: bool) -> (String, u8) {
535    const SUMMARY: &str = "Declare the allowed palette, fonts, and weights for this document; \
536        resolved token values outside the contract emit Warnings that can be elevated to \
537        blocking Errors for a CI gate.";
538
539    const PLACEMENT: &str = "Top-level child of the root `zenith version=1 { … }` node, \
540        sibling of `tokens`, `assets`, and `document`. At most one `brand { … }` block \
541        per document.";
542
543    const ABSENT_MEANS: &str = "An absent child node means that category is UNCONSTRAINED — \
544        omitting `colors` allows any color; omitting `fonts` allows any font family; \
545        omitting `weights` allows any weight. A completely empty `brand {}` block constrains \
546        nothing.";
547
548    const CHILD_NODES: &[SchemaBrandChildNode] = &[
549        SchemaBrandChildNode {
550            node: "colors",
551            syntax: r##"colors "#rrggbb" "#rrggbb" …"##,
552            description: "Allowed sRGB hex colors (case-insensitive). Color tokens and the \
553                sRGB-equivalent of CMYK tokens are compared against this list. Any resolved \
554                color token whose value is absent from this set emits `brand.color_off_palette`.",
555        },
556        SchemaBrandChildNode {
557            node: "fonts",
558            syntax: r#"fonts "Family Name" "Another Family" …"#,
559            description: "Allowed font family names. Any resolved fontFamily token whose value \
560                is not in this set emits `brand.font_not_allowed`.",
561        },
562        SchemaBrandChildNode {
563            node: "weights",
564            syntax: "weights 400 700 …",
565            description: "Allowed font weights as bare integers (100–900 in multiples of 100). \
566                Any resolved fontWeight token whose value is not in this set emits \
567                `brand.weight_not_allowed`.",
568        },
569    ];
570
571    const DIAG_CODES: &[SchemaBrandDiagCode] = &[
572        SchemaBrandDiagCode {
573            code: "brand.color_off_palette",
574            severity: "warning",
575            summary: "Resolved color token value is not in the declared brand palette.",
576        },
577        SchemaBrandDiagCode {
578            code: "brand.font_not_allowed",
579            severity: "warning",
580            summary: "Resolved fontFamily token value is not in the declared brand font list.",
581        },
582        SchemaBrandDiagCode {
583            code: "brand.weight_not_allowed",
584            severity: "warning",
585            summary: "Resolved fontWeight token value is not in the declared brand weight list.",
586        },
587    ];
588
589    const EXAMPLE: &str = concat!(
590        "zenith version=1 {\n",
591        "  brand {\n",
592        "    colors \"#0b1f33\" \"#1b6cf0\" \"#ffffff\"\n",
593        "    fonts \"Noto Sans\"\n",
594        "    weights 400 700\n",
595        "  }\n",
596        "  tokens format=\"zenith-token-v1\" {\n",
597        "    token id=\"color.primary\" type=\"color\" value=\"#1b6cf0\"\n",
598        "    token id=\"color.bg\"      type=\"color\" value=\"#ffffff\"\n",
599        "    token id=\"font.body\"     type=\"fontFamily\" value=\"Noto Sans\"\n",
600        "    token id=\"weight.bold\"   type=\"fontWeight\" value=700\n",
601        "  }\n",
602        "  document id=\"doc\" title=\"Brand demo\" {}\n",
603        "}\n",
604        "\n",
605        "# CI gate — make off-contract values block the build:\n",
606        "#   zenith validate doc.zen --deny brand.color_off_palette\n",
607        "#\n",
608        "# Or declare the policy in-file:\n",
609        "#   diagnostics { deny \"brand.color_off_palette\" }"
610    );
611
612    if json {
613        let child_nodes: Vec<SchemaBrandChildNode> = CHILD_NODES
614            .iter()
615            .map(|n| SchemaBrandChildNode {
616                node: n.node,
617                syntax: n.syntax,
618                description: n.description,
619            })
620            .collect();
621        let diag_codes: Vec<SchemaBrandDiagCode> = DIAG_CODES
622            .iter()
623            .map(|d| SchemaBrandDiagCode {
624                code: d.code,
625                severity: d.severity,
626                summary: d.summary,
627            })
628            .collect();
629        let out = SchemaBrandOutput {
630            schema: "zenith-schema-v1",
631            summary: SUMMARY.to_owned(),
632            placement: PLACEMENT,
633            child_nodes,
634            absent_means: ABSENT_MEANS,
635            diagnostic_codes: diag_codes,
636            example: EXAMPLE,
637        };
638        (serialize_pretty(&out), 0)
639    } else {
640        let mut text = format!("brand: {SUMMARY}\n");
641
642        text.push_str(&format!("\nPlacement:\n  {PLACEMENT}\n"));
643
644        text.push_str("\nChild nodes (all optional):\n");
645        for node in CHILD_NODES {
646            text.push_str(&format!(
647                "  {:<8}  syntax:  {}\n           {}\n",
648                node.node, node.syntax, node.description
649            ));
650        }
651
652        text.push_str(&format!("\nAbsent-child rule:\n  {ABSENT_MEANS}\n"));
653
654        text.push_str("\nDiagnostic codes (Warning by default):\n");
655        let col = DIAG_CODES.iter().map(|d| d.code.len()).max().unwrap_or(0);
656        for d in DIAG_CODES {
657            text.push_str(&format!(
658                "  {:<col$}  —  {}\n",
659                d.code,
660                d.summary,
661                col = col,
662            ));
663        }
664
665        text.push_str(
666            "\nCI gate:\n  \
667            Elevate to blocking Errors with `--deny <code>` on the CLI:\n    \
668            zenith validate doc.zen --deny brand.color_off_palette\n  \
669            Or declare the policy in-file (cross-reference `zenith schema diagnostics`):\n    \
670            diagnostics { deny \"brand.color_off_palette\" }\n",
671        );
672
673        text.push_str(&format!("\nExample:\n  {}", EXAMPLE.replace('\n', "\n  ")));
674        (text, 0)
675    }
676}
677
678/// `zenith schema block`: role vocabulary, properties, scopes, and cascade for
679/// the `block role="…"` declaration.
680///
681/// Returns `(stdout, exit_code)`.
682pub fn block(json: bool) -> (String, u8) {
683    // Single source of truth lives in zenith-core; no need to duplicate it here.
684    let role_vocab = zenith_core::BLOCK_ROLE_VOCAB;
685
686    const PROPS: &[(&str, &str)] = &[
687        (
688            "role",
689            "string — required; the markdown block role to target (see vocab above)",
690        ),
691        (
692            "font-family",
693            "token ref or literal string — override font family for this role",
694        ),
695        (
696            "font-size",
697            "token ref, (px) literal, or dimension — override font size",
698        ),
699        (
700            "font-weight",
701            "token ref or literal — override font weight (100–900)",
702        ),
703        (
704            "fill",
705            "token ref or color literal — override text fill color",
706        ),
707        (
708            "align",
709            r#"string — text alignment: "left", "center", "right", "justify""#,
710        ),
711        ("italic", "#true / #false — override italic rendering"),
712        (
713            "space-before",
714            "(px) or other dimension — extra space above the block",
715        ),
716        (
717            "space-after",
718            "(px) or other dimension — extra space below the block",
719        ),
720    ];
721
722    const SCOPES: &[(&str, &str)] = &[
723        (
724            "document",
725            "Declared as a direct child of the `document id=… { … }` block. \
726          Lowest cascade precedence — applies when neither the page nor the text node \
727          declares a matching role.",
728        ),
729        (
730            "page",
731            "Declared as a child of a `page id=… { … }` block (alongside `safe-zone` and `fold`). \
732          Middle cascade precedence — overrides the document scope for this page's text nodes.",
733        ),
734        (
735            "text",
736            "Declared as a child of a `text id=… { … }` block (before `span` children). \
737          Highest cascade precedence — overrides both document and page scope for this node.",
738        ),
739    ];
740
741    const CASCADE_NOTE: &str = "Cascade precedence: text > page > document. \
742        When the same `role` is declared at multiple scopes, the most-specific scope wins \
743        property-by-property (fine-grained merging is a later unit; in this unit the whole \
744        `BlockStyle` struct is stored per scope and the layout engine merges at consume time). \
745        Block decls are consumed ONLY on text nodes with `format=\"markdown\"`; they have no \
746        effect on plain-text or non-markdown nodes.";
747
748    // Source syntax that PRODUCES each block role (for agent discoverability).
749    // Uses r##"..."## because headings contain '#'.
750    const SOURCE_SYNTAX: &[(&str, &str)] = &[
751        (
752            "h1..h6",
753            r##"# H1  ## H2  ### H3  #### H4  ##### H5  ###### H6  (ATX headings)"##,
754        ),
755        ("p", "blank line between paragraphs"),
756        ("blockquote", "> text on its own line"),
757        (
758            "li",
759            "- item  or  * item  or  + item  (unordered);  1. item  (ordered)",
760        ),
761        (
762            "code-block",
763            "``` (optional lang)\ncode lines\n```  (fenced; lang after opening fence is optional)",
764        ),
765        ("hr", "--- or *** or ___ on its own line"),
766    ];
767    // Inline marks (not block roles, but shown here for completeness since block decls
768    // apply to the same format=\"markdown\" nodes).
769    const INLINE_SYNTAX: &str =
770        "**bold**  *italic*  ~~strike~~  ==highlight==  ++underline++  `code`  [label](url)";
771
772    const V1_LIMITS: &str = "v1 limitation: in a chain flow, code-block backgrounds and --- rules are not drawn \
773         and blockquote/list indent is not applied. These render fully only in a single \
774         non-chained text box.";
775
776    const EXAMPLE: &str = concat!(
777        "document id=\"doc.main\" {\n",
778        "  block role=\"h1\" font-size=(token)\"size.h1\" font-weight=(token)\"weight.bold\" space-after=(px)16\n",
779        "  block role=\"p\"  space-after=(px)8\n",
780        "  page id=\"pg.cover\" w=(px)1280 h=(px)720 {\n",
781        "    block role=\"h1\" fill=(token)\"color.accent\"\n",
782        "    text id=\"body\" format=\"markdown\" src=\"article.md\" x=(px)80 y=(px)80 w=(px)1120 h=(px)560 {\n",
783        "      block role=\"p\" space-after=(px)4\n",
784        "    }\n",
785        "  }\n",
786        "}",
787    );
788
789    if json {
790        use serde_json::{json, to_string_pretty};
791        let roles: Vec<&str> = role_vocab.to_vec();
792        let props: Vec<serde_json::Value> = PROPS
793            .iter()
794            .map(|(name, desc)| json!({ "name": name, "description": desc }))
795            .collect();
796        let scopes: Vec<serde_json::Value> = SCOPES
797            .iter()
798            .map(|(name, desc)| json!({ "scope": name, "description": desc }))
799            .collect();
800        let source_syntax: Vec<serde_json::Value> = SOURCE_SYNTAX
801            .iter()
802            .map(|(role, syntax)| json!({ "role": role, "source_syntax": syntax }))
803            .collect();
804        let out = json!({
805            "schema": "zenith-schema-v1",
806            "surface": "block",
807            "role_vocabulary": roles,
808            "markdown_source_syntax": source_syntax,
809            "markdown_inline_syntax": INLINE_SYNTAX,
810            "v1_limitations": V1_LIMITS,
811            "properties": props,
812            "scopes": scopes,
813            "cascade": CASCADE_NOTE,
814            "example": EXAMPLE,
815        });
816        (to_string_pretty(&out).unwrap_or_else(|e| e.to_string()), 0)
817    } else {
818        let mut text = String::new();
819        text.push_str("block role=\"…\" — per-role markdown block style declaration\n");
820        text.push_str("\nRole vocabulary and markdown source syntax:\n");
821        let col = SOURCE_SYNTAX
822            .iter()
823            .map(|(r, _)| r.len())
824            .max()
825            .unwrap_or(0);
826        for (role, syntax) in SOURCE_SYNTAX {
827            text.push_str(&format!("  {role:<col$}  {syntax}\n", col = col));
828        }
829        text.push_str(&format!(
830            "\nInline marks (format=\"markdown\"):\n  {INLINE_SYNTAX}\n"
831        ));
832        text.push_str(&format!("\nv1 limitations:\n  {V1_LIMITS}\n"));
833        text.push_str("\nProperties (on block role=\"…\" declarations):\n");
834        for (name, desc) in PROPS {
835            text.push_str(&format!("  {name:<16}  {desc}\n"));
836        }
837        text.push_str("\nScopes:\n");
838        for (scope, desc) in SCOPES {
839            text.push_str(&format!("  {scope:<12}  {desc}\n"));
840        }
841        text.push_str(&format!("\nCascade:\n  {CASCADE_NOTE}\n"));
842        text.push_str(&format!("\nExample:\n  {}", EXAMPLE.replace('\n', "\n  ")));
843        (text, 0)
844    }
845}
846
847// ── Internal helpers ──────────────────────────────────────────────────────────
848
849/// Format a list of attributes as a two-column table: `name  —  type`.
850///
851/// The name column is left-padded by 2 spaces and right-padded to the longest
852/// name in the list so the `—` separators align vertically.
853fn format_attr_table(attrs: &[SchemaAttr]) -> String {
854    let col_width = attrs.iter().map(|a| a.name.len()).max().unwrap_or(0);
855
856    let mut out = String::new();
857    for attr in attrs {
858        out.push_str(&format!(
859            "  {:<col_width$}  —  {}\n",
860            attr.name,
861            attr.ty,
862            col_width = col_width,
863        ));
864    }
865    out
866}
867
868// ── Tests ─────────────────────────────────────────────────────────────────────
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873
874    #[test]
875    fn overview_human_contains_counts() {
876        let (text, code) = overview(false);
877        assert_eq!(code, 0);
878        assert!(text.contains("node kinds"), "must mention node kinds");
879        assert!(text.contains("tx ops"), "must mention tx ops");
880    }
881
882    #[test]
883    fn overview_json_schema_field() {
884        let (text, code) = overview(true);
885        assert_eq!(code, 0);
886        assert!(
887            text.contains("zenith-schema-v1"),
888            "JSON must carry schema field"
889        );
890        assert!(
891            text.contains("node_kinds"),
892            "JSON must carry node_kinds count"
893        );
894    }
895
896    #[test]
897    fn nodes_human_contains_rect() {
898        let (text, code) = nodes(false);
899        assert_eq!(code, 0);
900        assert!(text.contains("rect"), "must list rect kind");
901        assert!(text.contains("Rectangle"), "must include rect summary");
902    }
903
904    #[test]
905    fn nodes_json_schema_field() {
906        let (text, code) = nodes(true);
907        assert_eq!(code, 0);
908        assert!(text.contains("zenith-schema-v1"));
909        assert!(text.contains("\"kind\""));
910    }
911
912    #[test]
913    fn node_detail_known_kind() {
914        let (text, code) = node_detail("rect", false);
915        assert_eq!(code, 0);
916        assert!(text.contains("rect"), "must name the kind");
917        assert!(text.contains("Attributes:"), "must list attributes");
918        assert!(text.contains("fill"), "rect must have a fill attribute");
919        assert!(
920            text.contains("token ref"),
921            "fill must show its type hint (token ref)"
922        );
923        assert!(text.contains("—"), "attributes must use — separator");
924        assert!(
925            text.contains("zenith validate"),
926            "must mention zenith validate for types"
927        );
928    }
929
930    #[test]
931    fn node_detail_human_shows_name_and_type() {
932        // Human output: each attribute line is "  <name>  —  <type>"
933        let (text, code) = node_detail("rect", false);
934        assert_eq!(code, 0);
935        // x is a px literal, fill is a token ref.
936        assert!(text.contains("x  "), "must list x attribute; got:\n{text}");
937        assert!(
938            text.contains("px literal"),
939            "x must show px literal type; got:\n{text}"
940        );
941        assert!(
942            text.contains("token ref: color/gradient"),
943            "fill must show token ref type; got:\n{text}"
944        );
945    }
946
947    #[test]
948    fn node_detail_json_known_kind() {
949        let (text, code) = node_detail("pattern", true);
950        assert_eq!(code, 0);
951        assert!(text.contains("zenith-schema-v1"));
952        assert!(text.contains("\"kind\""));
953        assert!(text.contains("\"attributes\""));
954        // New shape: attributes is an array of {name, ty} objects.
955        assert!(
956            text.contains("\"name\""),
957            "attribute objects must have name field"
958        );
959        assert!(
960            text.contains("\"ty\""),
961            "attribute objects must have ty field"
962        );
963    }
964
965    #[test]
966    fn node_detail_json_attr_has_type_hint() {
967        let (text, code) = node_detail("rect", true);
968        assert_eq!(code, 0);
969        // fill must appear with its type.
970        assert!(
971            text.contains("\"fill\""),
972            "fill attribute must appear; got:\n{text}"
973        );
974        assert!(
975            text.contains("token ref"),
976            "fill type must be a token ref; got:\n{text}"
977        );
978        // x must appear with px literal type.
979        assert!(
980            text.contains("px literal"),
981            "x must have px literal type; got:\n{text}"
982        );
983    }
984
985    #[test]
986    fn node_detail_unknown_kind_returns_error() {
987        let (text, code) = node_detail("not-a-kind", false);
988        assert_eq!(code, 1);
989        assert!(
990            text.contains("unknown node kind"),
991            "must report unknown kind"
992        );
993        assert!(text.contains("valid kinds"), "must list valid kinds");
994    }
995
996    #[test]
997    fn ops_human_contains_set_fill() {
998        let (text, code) = ops(false);
999        assert_eq!(code, 0);
1000        assert!(text.contains("set_fill"), "must list set_fill op");
1001    }
1002
1003    #[test]
1004    fn ops_json_schema_field() {
1005        let (text, code) = ops(true);
1006        assert_eq!(code, 0);
1007        assert!(text.contains("zenith-schema-v1"));
1008        assert!(text.contains("\"op\""));
1009    }
1010
1011    #[test]
1012    fn op_detail_known_op() {
1013        let (text, code) = op_detail("set_fill", false);
1014        assert_eq!(code, 0);
1015        assert!(text.contains("set_fill"), "must name the op");
1016        assert!(text.contains("fill"), "must mention the fill field");
1017        assert!(text.contains("Fields:"), "must include Fields section");
1018        assert!(text.contains("Example:"), "must include Example section");
1019    }
1020
1021    #[test]
1022    fn op_detail_json_known_op() {
1023        let (text, code) = op_detail("add_node", true);
1024        assert_eq!(code, 0);
1025        assert!(text.contains("zenith-schema-v1"));
1026        assert!(text.contains("\"op\""));
1027        assert!(
1028            text.contains("\"fields\""),
1029            "JSON must include fields array"
1030        );
1031        assert!(
1032            text.contains("\"example\""),
1033            "JSON must include example string"
1034        );
1035    }
1036
1037    #[test]
1038    fn op_detail_detach_pattern_human() {
1039        let (text, code) = op_detail("detach_pattern", false);
1040        assert_eq!(code, 0);
1041        assert!(text.contains("detach_pattern"));
1042        assert!(text.contains("Fields:"));
1043        assert!(text.contains("node"));
1044        assert!(text.contains("Example:"));
1045    }
1046
1047    #[test]
1048    fn op_detail_set_fill_json_has_node_and_fill_fields() {
1049        let (text, code) = op_detail("set_fill", true);
1050        assert_eq!(code, 0);
1051        assert!(text.contains("\"node\""), "fields must include node");
1052        assert!(text.contains("\"fill\""), "fields must include fill");
1053        assert!(text.contains("token ref"), "fill type must be token ref");
1054        assert!(
1055            text.contains("color.brand"),
1056            "example must use realistic value"
1057        );
1058    }
1059
1060    #[test]
1061    fn op_detail_unknown_op_returns_error() {
1062        let (text, code) = op_detail("not_an_op", false);
1063        assert_eq!(code, 1);
1064        assert!(text.contains("unknown op"), "must report unknown op");
1065        assert!(text.contains("valid ops"), "must list valid ops");
1066    }
1067
1068    #[test]
1069    fn overview_mentions_new_surfaces() {
1070        let (text, code) = overview(false);
1071        assert_eq!(code, 0);
1072        assert!(text.contains("page"), "overview must mention page surface");
1073        assert!(
1074            text.contains("asset"),
1075            "overview must mention asset surface"
1076        );
1077        assert!(
1078            text.contains("document"),
1079            "overview must mention document surface"
1080        );
1081    }
1082
1083    #[test]
1084    fn page_human_contains_geometry_attrs() {
1085        let (text, code) = page(false);
1086        assert_eq!(code, 0);
1087        assert!(text.contains("page"), "must name the surface");
1088        assert!(text.contains("Attributes:"), "must list attributes");
1089        assert!(text.contains("w"), "page must have w attribute");
1090        assert!(text.contains("h"), "page must have h attribute");
1091        assert!(text.contains("—"), "attributes must use — separator");
1092        assert!(
1093            text.contains("px literal"),
1094            "w/h must show px literal type hint"
1095        );
1096        assert!(
1097            text.contains("zenith validate"),
1098            "must mention zenith validate"
1099        );
1100    }
1101
1102    #[test]
1103    fn page_json_schema_field() {
1104        let (text, code) = page(true);
1105        assert_eq!(code, 0);
1106        assert!(text.contains("zenith-schema-v1"));
1107        assert!(text.contains("\"surface\""));
1108        assert!(text.contains("\"attributes\""));
1109        assert!(text.contains("\"page\""));
1110        // New shape: attributes is an array of {name, ty} objects.
1111        assert!(
1112            text.contains("\"name\""),
1113            "attribute objects must have name field"
1114        );
1115        assert!(
1116            text.contains("\"ty\""),
1117            "attribute objects must have ty field"
1118        );
1119    }
1120
1121    #[test]
1122    fn asset_human_contains_provenance_attrs() {
1123        let (text, code) = asset(false);
1124        assert_eq!(code, 0);
1125        assert!(text.contains("asset"), "must name the surface");
1126        assert!(text.contains("sha256"), "asset must include sha256");
1127        assert!(text.contains("ai-prompt"), "asset must include ai-prompt");
1128    }
1129
1130    #[test]
1131    fn asset_json_schema_field() {
1132        let (text, code) = asset(true);
1133        assert_eq!(code, 0);
1134        assert!(text.contains("zenith-schema-v1"));
1135        assert!(text.contains("\"asset\""));
1136    }
1137
1138    #[test]
1139    fn document_human_contains_root_attrs() {
1140        let (text, code) = document(false);
1141        assert_eq!(code, 0);
1142        assert!(text.contains("document"), "must name the surface");
1143        assert!(
1144            text.contains("colorspace"),
1145            "document must include colorspace"
1146        );
1147        assert!(text.contains("doc-id"), "document must include doc-id");
1148    }
1149
1150    #[test]
1151    fn document_json_schema_field() {
1152        let (text, code) = document(true);
1153        assert_eq!(code, 0);
1154        assert!(text.contains("zenith-schema-v1"));
1155        assert!(text.contains("\"document\""));
1156    }
1157
1158    #[test]
1159    fn overview_mentions_token_types() {
1160        let (text, code) = overview(false);
1161        assert_eq!(code, 0);
1162        assert!(
1163            text.contains("token types"),
1164            "overview must mention token types; got:\n{text}"
1165        );
1166        assert!(
1167            text.contains("zenith schema tokens"),
1168            "overview must mention 'zenith schema tokens'; got:\n{text}"
1169        );
1170        assert!(
1171            text.contains("zenith schema token"),
1172            "overview must mention 'zenith schema token <type>'; got:\n{text}"
1173        );
1174    }
1175
1176    #[test]
1177    fn overview_json_has_token_types_count() {
1178        let (text, code) = overview(true);
1179        assert_eq!(code, 0);
1180        assert!(
1181            text.contains("token_types"),
1182            "JSON overview must carry token_types count; got:\n{text}"
1183        );
1184    }
1185
1186    #[test]
1187    fn tokens_human_lists_all_types() {
1188        let (text, code) = tokens(false);
1189        assert_eq!(code, 0);
1190        assert!(text.contains("color"), "must list color type");
1191        assert!(text.contains("gradient"), "must list gradient type");
1192        assert!(text.contains("shadow"), "must list shadow type");
1193        assert!(text.contains("filter"), "must list filter type");
1194        assert!(text.contains("mask"), "must list mask type");
1195        assert!(text.contains("dimension"), "must list dimension type");
1196        assert!(text.contains("number"), "must list number type");
1197        assert!(text.contains("fontFamily"), "must list fontFamily type");
1198        assert!(text.contains("fontWeight"), "must list fontWeight type");
1199    }
1200
1201    #[test]
1202    fn tokens_json_schema_field() {
1203        let (text, code) = tokens(true);
1204        assert_eq!(code, 0);
1205        assert!(text.contains("zenith-schema-v1"));
1206        assert!(text.contains("\"token_types\""));
1207        assert!(text.contains("\"ty\""));
1208        assert!(text.contains("\"summary\""));
1209    }
1210
1211    #[test]
1212    fn token_detail_color_human() {
1213        let (text, code) = token_detail("color", false);
1214        assert_eq!(code, 0);
1215        assert!(text.contains("color"), "must name the type");
1216        assert!(
1217            text.contains("Value form:"),
1218            "must include Value form section"
1219        );
1220        assert!(text.contains("#rrggbb"), "must describe hex color form");
1221        assert!(text.contains("Example:"), "must include Example section");
1222    }
1223
1224    #[test]
1225    fn token_detail_gradient_human() {
1226        let (text, code) = token_detail("gradient", false);
1227        assert_eq!(code, 0);
1228        assert!(text.contains("gradient"), "must name the type");
1229        assert!(
1230            text.contains("Child nodes:"),
1231            "gradient must include Child nodes section"
1232        );
1233        assert!(text.contains("stop"), "gradient must describe stop child");
1234        assert!(text.contains("Example:"), "must include Example section");
1235    }
1236
1237    #[test]
1238    fn token_detail_shadow_human() {
1239        let (text, code) = token_detail("shadow", false);
1240        assert_eq!(code, 0);
1241        assert!(text.contains("shadow"), "must name the type");
1242        assert!(
1243            text.contains("Child nodes:"),
1244            "shadow must include Child nodes section"
1245        );
1246        assert!(text.contains("layer"), "shadow must describe layer child");
1247    }
1248
1249    #[test]
1250    fn token_detail_json_has_all_fields() {
1251        let (text, code) = token_detail("gradient", true);
1252        assert_eq!(code, 0);
1253        assert!(text.contains("zenith-schema-v1"));
1254        assert!(text.contains("\"token\""));
1255        assert!(text.contains("\"ty\""));
1256        assert!(text.contains("\"summary\""));
1257        assert!(text.contains("\"value_form\""));
1258        assert!(text.contains("\"child_nodes\""));
1259        assert!(text.contains("\"example\""));
1260    }
1261
1262    #[test]
1263    fn token_detail_unknown_type_returns_error() {
1264        let (text, code) = token_detail("bogus", false);
1265        assert_eq!(code, 1);
1266        assert!(
1267            text.contains("unknown token type"),
1268            "must report unknown type"
1269        );
1270        assert!(text.contains("valid types"), "must list valid types");
1271    }
1272
1273    #[test]
1274    fn node_detail_override_kind_hints_variant_surface() {
1275        // "override" is not a node kind; the error must hint at `zenith schema variant`.
1276        let (text, code) = node_detail("override", false);
1277        assert_eq!(code, 1);
1278        assert!(
1279            text.contains("unknown node kind"),
1280            "must report unknown kind"
1281        );
1282        assert!(
1283            text.contains("zenith schema variant"),
1284            "error for 'override' must hint at `zenith schema variant`; got:\n{text}"
1285        );
1286    }
1287
1288    #[test]
1289    fn node_detail_variant_kind_hints_variant_surface() {
1290        // "variant" is also not a node kind; same hint applies.
1291        let (text, code) = node_detail("variant", false);
1292        assert_eq!(code, 1);
1293        assert!(
1294            text.contains("unknown node kind"),
1295            "must report unknown kind"
1296        );
1297        assert!(
1298            text.contains("zenith schema variant"),
1299            "error for 'variant' must hint at `zenith schema variant`; got:\n{text}"
1300        );
1301    }
1302
1303    #[test]
1304    fn node_detail_other_unknown_no_variant_hint() {
1305        // Truly unknown kinds get no variant hint.
1306        let (text, code) = node_detail("frobnicate", false);
1307        assert_eq!(code, 1);
1308        assert!(
1309            text.contains("unknown node kind"),
1310            "must report unknown kind"
1311        );
1312        assert!(
1313            !text.contains("zenith schema variant"),
1314            "generic unknown kind must not mention variant surface; got:\n{text}"
1315        );
1316    }
1317
1318    #[test]
1319    fn variant_human_contains_key_sections() {
1320        let (text, code) = variant(false);
1321        assert_eq!(code, 0);
1322        assert!(text.contains("variant"), "must name the surface");
1323        assert!(
1324            text.contains("Override properties:"),
1325            "must list override properties"
1326        );
1327        assert!(
1328            text.contains("node"),
1329            "override properties must include 'node' selector"
1330        );
1331        assert!(
1332            text.contains("visible"),
1333            "override properties must include 'visible'"
1334        );
1335        assert!(
1336            text.contains("x") && text.contains("y") && text.contains("w") && text.contains("h"),
1337            "override properties must include geometry keys x/y/w/h; got:\n{text}"
1338        );
1339        assert!(
1340            text.contains("Example:"),
1341            "must include a worked example section"
1342        );
1343        assert!(
1344            text.contains("source="),
1345            "example must show the source= attribute on a variant node"
1346        );
1347    }
1348
1349    #[test]
1350    fn variant_human_override_node_selector_note() {
1351        let (text, code) = variant(false);
1352        assert_eq!(code, 0);
1353        // The override entry description must emphasise that the key is `node`, not `id`.
1354        assert!(
1355            text.contains("node"),
1356            "override entry must describe the 'node' selector key; got:\n{text}"
1357        );
1358        // Must warn about the wrong key.
1359        assert!(
1360            text.to_lowercase().contains("not") || text.contains("NOT"),
1361            "override entry should warn that 'id' is the wrong key; got:\n{text}"
1362        );
1363    }
1364
1365    #[test]
1366    fn variant_json_schema_field() {
1367        let (text, code) = variant(true);
1368        assert_eq!(code, 0);
1369        assert!(
1370            text.contains("zenith-schema-v1"),
1371            "JSON must carry schema field"
1372        );
1373        assert!(
1374            text.contains("\"summary\""),
1375            "JSON must carry summary field"
1376        );
1377        assert!(
1378            text.contains("\"override_props\""),
1379            "JSON must carry override_props array"
1380        );
1381        assert!(
1382            text.contains("\"example\""),
1383            "JSON must carry example field"
1384        );
1385    }
1386
1387    #[test]
1388    fn variant_json_override_props_have_geometry() {
1389        let (text, code) = variant(true);
1390        assert_eq!(code, 0);
1391        // x, y, w, h must all appear as override prop names.
1392        for key in &["\"x\"", "\"y\"", "\"w\"", "\"h\""] {
1393            assert!(
1394                text.contains(key),
1395                "variant JSON override_props must include {key}; got:\n{text}"
1396            );
1397        }
1398        // node must be required.
1399        assert!(
1400            text.contains("\"node\""),
1401            "variant JSON override_props must include node; got:\n{text}"
1402        );
1403    }
1404
1405    #[test]
1406    fn op_detail_add_node_position_describes_id_field() {
1407        // Regression: before/after variants use `id` (sibling id), not `sibling`.
1408        let (text, code) = op_detail("add_node", false);
1409        assert_eq!(code, 0);
1410        assert!(
1411            text.contains("id"),
1412            "add_node position description must mention the 'id' field; got:\n{text}"
1413        );
1414        assert!(
1415            text.contains("before") && text.contains("after"),
1416            "add_node position description must mention before/after variants; got:\n{text}"
1417        );
1418        assert!(
1419            text.contains("index"),
1420            "add_node position description must mention index variant; got:\n{text}"
1421        );
1422    }
1423
1424    #[test]
1425    fn op_detail_add_node_position_json_has_correct_shape() {
1426        let (text, code) = op_detail("add_node", true);
1427        assert_eq!(code, 0);
1428        // The ty string must contain "id" to describe the before/after sibling key.
1429        assert!(
1430            text.contains("sibling-id") || text.contains("\"id\""),
1431            "add_node position field ty must describe the sibling id key; got:\n{text}"
1432        );
1433    }
1434
1435    #[test]
1436    fn token_detail_fontweight_no_value_form_confusion() {
1437        // fontWeight must explicitly say bare integer, NOT a string or dimension.
1438        let (text, code) = token_detail("fontWeight", false);
1439        assert_eq!(code, 0);
1440        assert!(
1441            text.contains("700"),
1442            "fontWeight example must use a bare integer"
1443        );
1444        // The value form must not suggest string or dimension syntax.
1445        assert!(
1446            !text.contains("\"700\""),
1447            "fontWeight must not suggest string form"
1448        );
1449    }
1450
1451    // ── Content section tests ─────────────────────────────────────────────────
1452
1453    #[test]
1454    fn node_detail_shape_human_shows_content_section() {
1455        let (text, code) = node_detail("shape", false);
1456        assert_eq!(code, 0);
1457        assert!(
1458            text.contains("Content:"),
1459            "shape detail must include Content section; got:\n{text}"
1460        );
1461        assert!(
1462            text.contains("span"),
1463            "shape Content section must mention span children; got:\n{text}"
1464        );
1465        assert!(
1466            text.contains("label") || text.contains("centered"),
1467            "shape Content section must describe the owned label behaviour; got:\n{text}"
1468        );
1469        assert!(
1470            text.contains("Example:"),
1471            "shape Content section must include an example; got:\n{text}"
1472        );
1473    }
1474
1475    #[test]
1476    fn node_detail_shape_json_has_content_field() {
1477        let (text, code) = node_detail("shape", true);
1478        assert_eq!(code, 0);
1479        assert!(
1480            text.contains("\"content\""),
1481            "shape JSON must carry a content field; got:\n{text}"
1482        );
1483        assert!(
1484            text.contains("\"description\""),
1485            "shape JSON content must carry a description; got:\n{text}"
1486        );
1487        assert!(
1488            text.contains("\"example\""),
1489            "shape JSON content must carry an example; got:\n{text}"
1490        );
1491        // content must be non-null
1492        assert!(
1493            !text.contains("\"content\": null"),
1494            "shape JSON content must be non-null; got:\n{text}"
1495        );
1496    }
1497
1498    #[test]
1499    fn node_detail_polygon_human_shows_content_section() {
1500        let (text, code) = node_detail("polygon", false);
1501        assert_eq!(code, 0);
1502        assert!(
1503            text.contains("Content:"),
1504            "polygon detail must include Content section; got:\n{text}"
1505        );
1506        assert!(
1507            text.contains("point"),
1508            "polygon Content section must mention point children; got:\n{text}"
1509        );
1510    }
1511
1512    #[test]
1513    fn node_detail_text_human_shows_content_section() {
1514        let (text, code) = node_detail("text", false);
1515        assert_eq!(code, 0);
1516        assert!(
1517            text.contains("Content:"),
1518            "text detail must include Content section; got:\n{text}"
1519        );
1520        assert!(
1521            text.contains("span"),
1522            "text Content section must mention span children; got:\n{text}"
1523        );
1524    }
1525
1526    #[test]
1527    fn node_detail_rect_human_no_content_section() {
1528        // rect has no child content; the Content section must be absent.
1529        let (text, code) = node_detail("rect", false);
1530        assert_eq!(code, 0);
1531        assert!(
1532            !text.contains("Content:"),
1533            "rect detail must NOT include a Content section; got:\n{text}"
1534        );
1535    }
1536
1537    #[test]
1538    fn node_detail_rect_json_content_is_absent() {
1539        let (text, code) = node_detail("rect", true);
1540        assert_eq!(code, 0);
1541        // For a leaf node with no child content, the content field must be absent entirely.
1542        assert!(
1543            !text.contains("\"content\""),
1544            "rect JSON must not carry a content field (skip_serializing_if = None); got:\n{text}"
1545        );
1546    }
1547
1548    // ── Brand surface tests ───────────────────────────────────────────────────
1549
1550    #[test]
1551    fn brand_human_contains_key_sections() {
1552        let (text, code) = brand(false);
1553        assert_eq!(code, 0);
1554        assert!(
1555            text.contains("brand {"),
1556            "human output must include worked example with 'brand {{'; got:\n{text}"
1557        );
1558        assert!(
1559            text.contains("colors"),
1560            "human output must describe the colors child node; got:\n{text}"
1561        );
1562        assert!(
1563            text.contains("fonts"),
1564            "human output must describe the fonts child node; got:\n{text}"
1565        );
1566        assert!(
1567            text.contains("weights"),
1568            "human output must describe the weights child node; got:\n{text}"
1569        );
1570        assert!(
1571            text.contains("brand.color_off_palette"),
1572            "human output must list brand.color_off_palette diagnostic code; got:\n{text}"
1573        );
1574        assert!(
1575            text.contains("brand.font_not_allowed"),
1576            "human output must list brand.font_not_allowed diagnostic code; got:\n{text}"
1577        );
1578        assert!(
1579            text.contains("brand.weight_not_allowed"),
1580            "human output must list brand.weight_not_allowed diagnostic code; got:\n{text}"
1581        );
1582        assert!(
1583            text.contains("--deny"),
1584            "human output must mention --deny for CI gate; got:\n{text}"
1585        );
1586    }
1587
1588    #[test]
1589    fn brand_json_schema_field() {
1590        let (text, code) = brand(true);
1591        assert_eq!(code, 0);
1592        assert!(
1593            text.contains("zenith-schema-v1"),
1594            "JSON must carry schema field; got:\n{text}"
1595        );
1596        assert!(
1597            text.contains("\"summary\""),
1598            "JSON must carry summary field; got:\n{text}"
1599        );
1600        assert!(
1601            text.contains("\"child_nodes\""),
1602            "JSON must carry child_nodes array; got:\n{text}"
1603        );
1604        assert!(
1605            text.contains("\"diagnostic_codes\""),
1606            "JSON must carry diagnostic_codes array; got:\n{text}"
1607        );
1608    }
1609
1610    #[test]
1611    fn overview_mentions_brand_surface() {
1612        let (text, code) = overview(false);
1613        assert_eq!(code, 0);
1614        assert!(
1615            text.contains("zenith schema brand"),
1616            "overview must mention 'zenith schema brand'; got:\n{text}"
1617        );
1618        assert!(
1619            text.contains("zenith schema block"),
1620            "overview must mention 'zenith schema block'; got:\n{text}"
1621        );
1622        assert!(
1623            text.contains("7 non-node surfaces"),
1624            "overview must count 5 non-node surfaces after adding block; got:\n{text}"
1625        );
1626    }
1627}