Skip to main content

kdl_codegen/emit/
rust.rs

1//! Rust emitter — renders [`ir::Schema`] into Rust source text.
2//!
3//! Ported from club-unison's `codegen/rust.rs`. The original used
4//! `proc_macro2` + `quote` to build a `TokenStream` and a hand-rolled
5//! `format_code` pass; this port writes pre-formatted Rust text directly so
6//! `club-kdl-codegen` stays dependency-free during Phase 1.
7//!
8//! ## What it emits
9//!
10//! - data dialect: every [`ir::TypeDef`] — `struct` (with fields) and `enum`
11//!   (string-valued variants).
12//! - entity dialect: every [`ir::Record`] as a `struct` carrying an `id`
13//!   field; every [`ir::Relation`] as an edge `struct` carrying `id` / `in` /
14//!   `out` fields plus its edge properties.
15//! - protocol dialect: for every [`ir::Channel`], a `struct` per request
16//!   payload, per `returns` message, and per event payload.
17//!
18//! ## Tier 1 type mapping
19//!
20//! - `link<Record>` → `String` (the linked record's id).
21//! - `'literal'` and unions of literals → a generated string-valued `enum`
22//!   is *not* produced (no schema name is available at the field site);
23//!   instead the field type degrades to `String`. A union of non-literal
24//!   types also degrades to `serde_json::Value` — Rust has no anonymous sum
25//!   type, and inventing names per field is out of Tier 1 scope.
26//!
27//! Each generated `struct` / `enum` carries `#[derive(...)]` attributes and
28//! `serde` annotations matching club-unison's generator. Optional fields
29//! become `Option<T>` with `#[serde(skip_serializing_if = "Option::is_none")]`.
30//!
31//! ## Differences from club-unison (IR-driven port)
32//!
33//! - The IR has no inline `_inline_*` messages and no `service` / `method` /
34//!   `stream` / `send` / `recv` legacy constructs — so the corresponding
35//!   branches are dropped.
36//! - The IR's [`ir::Prim::Datetime`] maps to `chrono::DateTime<Utc>`; named
37//!   type references emit the bare identifier (no `TypeRegistry` indirection).
38//!
39//! ## Tier 2 — description / constraints
40//!
41//! - A `description` on a `struct` / `enum` / `record` / `relation` or a
42//!   field becomes a `///` doc comment.
43//! - Field `constraints` (`min` / `max` / `min_length` / `max_length` /
44//!   `pattern`) are **not** emitted — Rust's type system cannot express them,
45//!   and JSDoc-style `@minimum` hacks are deliberately avoided.
46
47use crate::Emitter;
48use crate::ir;
49
50use super::case::{to_pascal_case, to_snake_case};
51
52/// The Rust code generation target.
53#[derive(Debug, Default, Clone, Copy)]
54pub struct RustEmitter;
55
56impl RustEmitter {
57    /// Create a new [`RustEmitter`].
58    pub fn new() -> Self {
59        Self
60    }
61}
62
63impl Emitter for RustEmitter {
64    fn emit(&self, schema: &ir::Schema) -> String {
65        let mut out = String::new();
66        out.push_str(IMPORTS);
67
68        // data dialect — standalone type definitions.
69        for ty in &schema.types {
70            out.push('\n');
71            out.push_str(&render_typedef(ty));
72        }
73
74        // entity dialect — records and relations.
75        for record in &schema.records {
76            out.push('\n');
77            out.push_str(&render_record(record));
78        }
79        for relation in &schema.relations {
80            out.push('\n');
81            out.push_str(&render_relation(relation));
82        }
83
84        // protocol dialect — channel payload structs.
85        if let Some(protocol) = &schema.protocol {
86            for channel in &protocol.channels {
87                out.push_str(&render_channel(channel));
88            }
89        }
90
91        out
92    }
93}
94
95/// Header import block, matching club-unison's `generate_imports`.
96const IMPORTS: &str = "\
97use serde::{Deserialize, Serialize};
98use anyhow::Result;
99use chrono::{DateTime, Utc};
100use uuid::Uuid;
101use std::collections::HashMap;
102";
103
104/// Render one standalone [`ir::TypeDef`].
105fn render_typedef(ty: &ir::TypeDef) -> String {
106    match ty {
107        ir::TypeDef::Struct {
108            name,
109            description,
110            fields,
111        } => render_struct(name, description.as_deref(), fields),
112        ir::TypeDef::Enum {
113            name,
114            description,
115            variants,
116        } => render_enum(name, description.as_deref(), variants),
117    }
118}
119
120/// Render a `///` doc comment block at the given indentation from an optional
121/// description. Each line of a multi-line description gets its own `///`.
122fn render_doc(description: Option<&str>, indent: &str) -> String {
123    match description {
124        Some(text) => text
125            .lines()
126            .map(|line| format!("{indent}/// {line}\n"))
127            .collect(),
128        None => String::new(),
129    }
130}
131
132/// Render a `struct` from a name and field list. A fieldless struct becomes a
133/// unit struct (`pub struct Name;`), matching club-unison.
134fn render_struct(name: &str, description: Option<&str>, fields: &[ir::Field]) -> String {
135    let derive = "#[derive(Debug, Clone, Serialize, Deserialize)]\n";
136    let doc = render_doc(description, "");
137    if fields.is_empty() {
138        return format!("{doc}{derive}pub struct {name};\n");
139    }
140    let mut out = String::new();
141    out.push_str(&doc);
142    out.push_str(derive);
143    out.push_str(&format!("pub struct {name} {{\n"));
144    for field in fields {
145        out.push_str(&render_field(field));
146    }
147    out.push_str("}\n");
148    out
149}
150
151/// Render an `enum` of string-valued variants.
152fn render_enum(name: &str, description: Option<&str>, variants: &[String]) -> String {
153    let mut out = String::new();
154    out.push_str(&render_doc(description, ""));
155    out.push_str("#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n");
156    out.push_str("#[serde(rename_all = \"snake_case\")]\n");
157    out.push_str(&format!("pub enum {name} {{\n"));
158    for v in variants {
159        out.push_str(&format!("    #[serde(rename = \"{v}\")]\n"));
160        out.push_str(&format!("    {},\n", to_pascal_case(v)));
161    }
162    out.push_str("}\n");
163    out
164}
165
166/// Render a single struct field with its `serde` attributes.
167fn render_field(field: &ir::Field) -> String {
168    let mut out = String::new();
169
170    // `///` doc comment from the field description.
171    out.push_str(&render_doc(field.description.as_deref(), "    "));
172
173    // `#[serde(rename = "...")]` when the source name is not snake_case.
174    let snake = to_snake_case(&field.name);
175    if field.name != snake {
176        out.push_str(&format!("    #[serde(rename = \"{}\")]\n", field.name));
177    }
178
179    let base = ty_to_rust(&field.ty);
180    let rust_ty = if field.required {
181        base
182    } else {
183        out.push_str("    #[serde(skip_serializing_if = \"Option::is_none\")]\n");
184        format!("Option<{base}>")
185    };
186
187    out.push_str(&format!(
188        "    pub {}: {rust_ty},\n",
189        field_ident(&field.name)
190    ));
191    out
192}
193
194/// Render a field name as a valid Rust identifier. A name that collides with
195/// a Rust keyword is escaped as a raw identifier (`r#type`) so the generated
196/// source compiles. `serde` strips the `r#` prefix, so the wire name is
197/// unaffected.
198///
199/// `crate` / `self` / `Self` / `super` cannot be raw identifiers; they are
200/// left as-is (a KDL schema field is extremely unlikely to use them).
201fn field_ident(name: &str) -> String {
202    const KEYWORDS: &[&str] = &[
203        "as", "break", "const", "continue", "dyn", "else", "enum", "extern", "false", "fn", "for",
204        "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", "return",
205        "static", "struct", "trait", "true", "type", "unsafe", "use", "where", "while", "async",
206        "await", "gen", "abstract", "become", "box", "do", "final", "macro", "override", "priv",
207        "try", "typeof", "unsized", "virtual", "yield",
208    ];
209    if KEYWORDS.contains(&name) {
210        format!("r#{name}")
211    } else {
212        name.to_string()
213    }
214}
215
216/// Map an [`ir::Ty`] to its Rust type expression.
217fn ty_to_rust(ty: &ir::Ty) -> String {
218    match ty {
219        ir::Ty::Primitive(p) => prim_to_rust(*p).to_string(),
220        ir::Ty::Array(inner) => format!("Vec<{}>", ty_to_rust(inner)),
221        ir::Ty::Named(name) => name.clone(),
222        // a link stores the target record's id — a plain string.
223        ir::Ty::Link(_) => "String".to_string(),
224        // a literal degrades to String (no per-field type name available).
225        ir::Ty::Literal(_) => "String".to_string(),
226        // a union of string literals stays a String. Otherwise, if every
227        // member maps to the same Rust type, collapse to it (`link<X> |
228        // string` → `String`); a genuinely heterogeneous union has no Rust
229        // anonymous-sum representation, so it degrades to a JSON value.
230        ir::Ty::Union(members) => {
231            if members.iter().all(|m| matches!(m, ir::Ty::Literal(_))) {
232                "String".to_string()
233            } else {
234                let mut mapped: Vec<String> = Vec::new();
235                for m in members {
236                    let t = ty_to_rust(m);
237                    if !mapped.contains(&t) {
238                        mapped.push(t);
239                    }
240                }
241                if mapped.len() == 1 {
242                    mapped.into_iter().next().unwrap()
243                } else {
244                    "serde_json::Value".to_string()
245                }
246            }
247        }
248    }
249}
250
251/// Map an [`ir::Prim`] to its Rust type.
252fn prim_to_rust(p: ir::Prim) -> &'static str {
253    match p {
254        ir::Prim::String => "String",
255        ir::Prim::Int => "i64",
256        ir::Prim::Float => "f64",
257        ir::Prim::Bool => "bool",
258        ir::Prim::Datetime => "DateTime<Utc>",
259        ir::Prim::Json => "serde_json::Value",
260    }
261}
262
263/// Render one [`ir::Record`] as a `struct`. The record's `id` becomes a
264/// leading `String` field; the remaining fields follow in source order. The
265/// struct name is PascalCased so a camelCase record name still compiles.
266fn render_record(record: &ir::Record) -> String {
267    let mut fields = Vec::with_capacity(record.fields.len() + 1);
268    fields.push(id_field());
269    fields.extend(record.fields.iter().cloned());
270    render_struct(
271        &to_pascal_case(&record.name),
272        record.description.as_deref(),
273        &fields,
274    )
275}
276
277/// Render one [`ir::Relation`] as an edge `struct` carrying `id` / `in` /
278/// `out` (the edge endpoints, as record ids) plus its edge-property fields.
279fn render_relation(relation: &ir::Relation) -> String {
280    let mut fields = Vec::with_capacity(relation.fields.len() + 3);
281    fields.push(id_field());
282    fields.push(ir::Field {
283        name: "in".to_string(),
284        ty: ir::Ty::Primitive(ir::Prim::String),
285        required: true,
286        flexible: false,
287        default: None,
288        description: None,
289        constraints: ir::Constraints::default(),
290    });
291    fields.push(ir::Field {
292        name: "out".to_string(),
293        ty: ir::Ty::Primitive(ir::Prim::String),
294        required: true,
295        flexible: false,
296        default: None,
297        description: None,
298        constraints: ir::Constraints::default(),
299    });
300    fields.extend(relation.fields.iter().cloned());
301    render_struct(
302        &to_pascal_case(&relation.name),
303        relation.description.as_deref(),
304        &fields,
305    )
306}
307
308/// The synthetic `id: String` field shared by records and relations.
309fn id_field() -> ir::Field {
310    ir::Field {
311        name: "id".to_string(),
312        ty: ir::Ty::Primitive(ir::Prim::String),
313        required: true,
314        flexible: false,
315        default: None,
316        description: None,
317        constraints: ir::Constraints::default(),
318    }
319}
320
321/// Render every payload struct for one channel: request payloads, `returns`
322/// messages, and event payloads.
323fn render_channel(channel: &ir::Channel) -> String {
324    let mut out = String::new();
325    for req in &channel.requests {
326        out.push('\n');
327        out.push_str(&render_struct(&req.name, None, &req.fields));
328        if let Some(returns) = &req.returns {
329            out.push('\n');
330            out.push_str(&render_struct(&returns.name, None, &returns.fields));
331        }
332    }
333    for evt in &channel.events {
334        out.push('\n');
335        out.push_str(&render_struct(&evt.name, None, &evt.fields));
336    }
337    out
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    fn field(name: &str, ty: ir::Ty, required: bool) -> ir::Field {
345        ir::Field {
346            name: name.to_string(),
347            ty,
348            required,
349            flexible: false,
350            default: None,
351            description: None,
352            constraints: ir::Constraints::default(),
353        }
354    }
355
356    #[test]
357    fn emits_import_header() {
358        let out = RustEmitter::new().emit(&ir::Schema::default());
359        assert!(out.contains("use serde::{Deserialize, Serialize};"));
360        assert!(out.contains("use chrono::{DateTime, Utc};"));
361    }
362
363    #[test]
364    fn emits_struct_with_required_field() {
365        let schema = ir::Schema {
366            types: vec![ir::TypeDef::Struct {
367                name: "User".to_string(),
368                description: None,
369                fields: vec![field("name", ir::Ty::Primitive(ir::Prim::String), true)],
370            }],
371            protocol: None,
372            ..Default::default()
373        };
374        let out = RustEmitter::new().emit(&schema);
375        assert!(out.contains("#[derive(Debug, Clone, Serialize, Deserialize)]"));
376        assert!(out.contains("pub struct User {"));
377        assert!(out.contains("    pub name: String,"));
378    }
379
380    #[test]
381    fn keyword_field_name_is_raw_identifier() {
382        let schema = ir::Schema {
383            types: vec![ir::TypeDef::Struct {
384                name: "Node".to_string(),
385                description: None,
386                fields: vec![field("type", ir::Ty::Primitive(ir::Prim::String), true)],
387            }],
388            protocol: None,
389            ..Default::default()
390        };
391        let out = RustEmitter::new().emit(&schema);
392        // `type` is a Rust keyword — it must be escaped as a raw identifier
393        // so the generated source compiles.
394        assert!(out.contains("pub r#type: String,"));
395    }
396
397    #[test]
398    fn optional_field_becomes_option_with_skip() {
399        let schema = ir::Schema {
400            types: vec![ir::TypeDef::Struct {
401                name: "User".to_string(),
402                description: None,
403                fields: vec![field("nick", ir::Ty::Primitive(ir::Prim::String), false)],
404            }],
405            protocol: None,
406            ..Default::default()
407        };
408        let out = RustEmitter::new().emit(&schema);
409        assert!(out.contains("#[serde(skip_serializing_if = \"Option::is_none\")]"));
410        assert!(out.contains("pub nick: Option<String>,"));
411    }
412
413    #[test]
414    fn non_snake_field_gets_serde_rename() {
415        let schema = ir::Schema {
416            types: vec![ir::TypeDef::Struct {
417                name: "User".to_string(),
418                description: None,
419                fields: vec![field(
420                    "displayName",
421                    ir::Ty::Primitive(ir::Prim::String),
422                    true,
423                )],
424            }],
425            protocol: None,
426            ..Default::default()
427        };
428        let out = RustEmitter::new().emit(&schema);
429        assert!(out.contains("#[serde(rename = \"displayName\")]"));
430        assert!(out.contains("pub displayName: String,"));
431    }
432
433    #[test]
434    fn fieldless_struct_is_unit() {
435        let schema = ir::Schema {
436            types: vec![ir::TypeDef::Struct {
437                name: "Empty".to_string(),
438                description: None,
439                fields: vec![],
440            }],
441            protocol: None,
442            ..Default::default()
443        };
444        let out = RustEmitter::new().emit(&schema);
445        assert!(out.contains("pub struct Empty;"));
446    }
447
448    #[test]
449    fn emits_enum_with_rename() {
450        let schema = ir::Schema {
451            types: vec![ir::TypeDef::Enum {
452                name: "Role".to_string(),
453                description: None,
454                variants: vec!["admin".to_string(), "guest_user".to_string()],
455            }],
456            protocol: None,
457            ..Default::default()
458        };
459        let out = RustEmitter::new().emit(&schema);
460        assert!(out.contains("#[serde(rename_all = \"snake_case\")]"));
461        assert!(out.contains("pub enum Role {"));
462        assert!(out.contains("#[serde(rename = \"admin\")]"));
463        assert!(out.contains("    Admin,"));
464        assert!(out.contains("#[serde(rename = \"guest_user\")]"));
465        assert!(out.contains("    GuestUser,"));
466    }
467
468    #[test]
469    fn maps_primitive_and_compound_types() {
470        let schema = ir::Schema {
471            types: vec![ir::TypeDef::Struct {
472                name: "T".to_string(),
473                description: None,
474                fields: vec![
475                    field("n", ir::Ty::Primitive(ir::Prim::Int), true),
476                    field("f", ir::Ty::Primitive(ir::Prim::Float), true),
477                    field("b", ir::Ty::Primitive(ir::Prim::Bool), true),
478                    field("at", ir::Ty::Primitive(ir::Prim::Datetime), true),
479                    field("blob", ir::Ty::Primitive(ir::Prim::Json), true),
480                    field(
481                        "tags",
482                        ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String))),
483                        true,
484                    ),
485                    field("owner", ir::Ty::Named("User".to_string()), true),
486                ],
487            }],
488            protocol: None,
489            ..Default::default()
490        };
491        let out = RustEmitter::new().emit(&schema);
492        assert!(out.contains("pub n: i64,"));
493        assert!(out.contains("pub f: f64,"));
494        assert!(out.contains("pub b: bool,"));
495        assert!(out.contains("pub at: DateTime<Utc>,"));
496        assert!(out.contains("pub blob: serde_json::Value,"));
497        assert!(out.contains("pub tags: Vec<String>,"));
498        assert!(out.contains("pub owner: User,"));
499    }
500
501    #[test]
502    fn emits_channel_request_returns_and_event_structs() {
503        let schema = ir::Schema {
504            types: vec![],
505            records: vec![],
506            relations: vec![],
507            protocol: Some(ir::Protocol {
508                name: "ping-pong".to_string(),
509                version: "2.0.0".to_string(),
510                namespace: None,
511                description: None,
512                channels: vec![ir::Channel {
513                    name: "ping-pong".to_string(),
514                    from: ir::ChannelFrom::Client,
515                    lifetime: ir::ChannelLifetime::Persistent,
516                    backend: ir::ChannelBackend::Stream,
517                    channel_id: None,
518                    requests: vec![ir::Request {
519                        name: "Ping".to_string(),
520                        fields: vec![field("seq", ir::Ty::Primitive(ir::Prim::Int), true)],
521                        returns: Some(ir::Message {
522                            name: "Pong".to_string(),
523                            fields: vec![field("seq", ir::Ty::Primitive(ir::Prim::Int), true)],
524                        }),
525                    }],
526                    events: vec![ir::Event {
527                        name: "Tick".to_string(),
528                        fields: vec![],
529                    }],
530                }],
531            }),
532        };
533        let out = RustEmitter::new().emit(&schema);
534        assert!(out.contains("pub struct Ping {"));
535        assert!(out.contains("pub struct Pong {"));
536        assert!(out.contains("pub struct Tick;"));
537    }
538
539    // -------------------------------------------------------------------------
540    // Tier 1 — record / relation / link / union
541    // -------------------------------------------------------------------------
542
543    #[test]
544    fn record_becomes_struct_with_id_field() {
545        let schema = ir::Schema {
546            records: vec![ir::Record {
547                name: "Atlas".to_string(),
548                description: None,
549                id_strategy: ir::IdStrategy::Uuidv7,
550                fields: vec![field("name", ir::Ty::Primitive(ir::Prim::String), true)],
551            }],
552            ..Default::default()
553        };
554        let out = RustEmitter::new().emit(&schema);
555        assert!(out.contains("pub struct Atlas {"));
556        assert!(out.contains("pub id: String,"), "record gets an id field");
557        assert!(out.contains("pub name: String,"));
558    }
559
560    #[test]
561    fn relation_becomes_edge_struct_with_in_out() {
562        let schema = ir::Schema {
563            relations: vec![ir::Relation {
564                name: "derivedFrom".to_string(),
565                description: None,
566                from: "Memory".to_string(),
567                to: "Memory".to_string(),
568                unique: true,
569                fields: vec![field("reason", ir::Ty::Primitive(ir::Prim::String), false)],
570            }],
571            ..Default::default()
572        };
573        let out = RustEmitter::new().emit(&schema);
574        assert!(out.contains("pub struct DerivedFrom {"));
575        assert!(out.contains("pub id: String,"));
576        // `in` is a Rust keyword → escaped as a raw identifier.
577        assert!(out.contains("pub r#in: String,"));
578        assert!(out.contains("pub out: String,"));
579        assert!(out.contains("pub reason: Option<String>,"));
580    }
581
582    #[test]
583    fn link_field_becomes_string() {
584        let schema = ir::Schema {
585            records: vec![ir::Record {
586                name: "Atlas".to_string(),
587                description: None,
588                id_strategy: ir::IdStrategy::Uuidv7,
589                fields: vec![field("parent", ir::Ty::Link("Atlas".to_string()), false)],
590            }],
591            ..Default::default()
592        };
593        let out = RustEmitter::new().emit(&schema);
594        assert!(out.contains("pub parent: Option<String>,"));
595    }
596
597    #[test]
598    fn literal_union_degrades_to_string() {
599        let schema = ir::Schema {
600            records: vec![ir::Record {
601                name: "Doc".to_string(),
602                description: None,
603                id_strategy: ir::IdStrategy::Uuidv7,
604                fields: vec![field(
605                    "visibility",
606                    ir::Ty::Union(vec![
607                        ir::Ty::Literal("public".to_string()),
608                        ir::Ty::Literal("private".to_string()),
609                    ]),
610                    true,
611                )],
612            }],
613            ..Default::default()
614        };
615        let out = RustEmitter::new().emit(&schema);
616        assert!(out.contains("pub visibility: String,"));
617    }
618
619    #[test]
620    fn mixed_union_degrades_to_json_value() {
621        let schema = ir::Schema {
622            types: vec![ir::TypeDef::Struct {
623                name: "T".to_string(),
624                description: None,
625                fields: vec![field(
626                    "v",
627                    ir::Ty::Union(vec![
628                        ir::Ty::Primitive(ir::Prim::String),
629                        ir::Ty::Primitive(ir::Prim::Int),
630                    ]),
631                    true,
632                )],
633            }],
634            ..Default::default()
635        };
636        let out = RustEmitter::new().emit(&schema);
637        assert!(out.contains("pub v: serde_json::Value,"));
638    }
639
640    // -------------------------------------------------------------------------
641    // Tier 2 — description → `///` doc comments (constraints are not emitted)
642    // -------------------------------------------------------------------------
643
644    #[test]
645    fn struct_and_field_descriptions_become_doc_comments() {
646        let mut content = field("content", ir::Ty::Primitive(ir::Prim::String), true);
647        content.description = Some("Memory content text".to_string());
648        let schema = ir::Schema {
649            types: vec![ir::TypeDef::Struct {
650                name: "Memory".to_string(),
651                description: Some("User memory".to_string()),
652                fields: vec![content],
653            }],
654            ..Default::default()
655        };
656        let out = RustEmitter::new().emit(&schema);
657        assert!(out.contains("/// User memory\n"), "struct doc comment");
658        assert!(
659            out.contains("    /// Memory content text\n"),
660            "field doc comment"
661        );
662    }
663
664    #[test]
665    fn enum_description_becomes_doc_comment() {
666        let schema = ir::Schema {
667            types: vec![ir::TypeDef::Enum {
668                name: "Role".to_string(),
669                description: Some("An access role".to_string()),
670                variants: vec!["admin".to_string()],
671            }],
672            ..Default::default()
673        };
674        let out = RustEmitter::new().emit(&schema);
675        assert!(out.contains("/// An access role\n"));
676    }
677
678    #[test]
679    fn constraints_do_not_appear_in_rust_output() {
680        // Rust's type system cannot express min/max/pattern — they must be
681        // dropped, not emitted as attributes or comments.
682        let mut f = field("confidence", ir::Ty::Primitive(ir::Prim::Float), true);
683        f.constraints = ir::Constraints {
684            min: Some(0),
685            max: Some(1),
686            pattern: Some("x".to_string()),
687            ..Default::default()
688        };
689        let schema = ir::Schema {
690            types: vec![ir::TypeDef::Struct {
691                name: "T".to_string(),
692                description: None,
693                fields: vec![f],
694            }],
695            ..Default::default()
696        };
697        let out = RustEmitter::new().emit(&schema);
698        assert!(out.contains("pub confidence: f64,"));
699        assert!(!out.contains("minimum"), "no constraint metadata leaks");
700    }
701}