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