Skip to main content

canton_codegen/
lib.rs

1//! `canton-codegen` — generate typed Rust bindings from Daml packages.
2//!
3//! This crate turns a DAR into idiomatic Rust: templates and records into
4//! structs, choices into typed exercise impls, interfaces into typed markers
5//! with views, with JSON and gRPC codecs on every generated type. The
6//! command-line front-end is `canton-codegen-cli` (`dpm codegen-rust`); most
7//! users drive codegen through that.
8//!
9//! # Usage
10//!
11//! [`lower_dar`] turns a `.dar` into the [`ir`], and [`generate_crate`] turns
12//! that into the source of a self-contained bindings crate:
13//!
14//! ```no_run
15//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
16//! let dar = canton_lf::Dar::open("my-app-0.1.0.dar")?;
17//! let (krate, skipped) = canton_codegen::lower_dar(&dar)?;
18//! let source = canton_codegen::generate_crate(&krate)?;
19//! for skip in &skipped {
20//!     eprintln!("warning: skipped {}", skip);
21//! }
22//! std::fs::write("src/lib.rs", source)?;
23//! # Ok(()) }
24//! ```
25//!
26//! # Architecture
27//!
28//! A decoder-agnostic [`ir`] (intermediate representation) sits between
29//! decoding Daml-LF and emitting Rust:
30//!
31//! ```text
32//! DAR ──(canton-lf: decode)──▶ lower ──▶ ir ──(emit/map)──▶ Rust source
33//! ```
34//!
35//! The generator and the type mapping consume the IR and never touch Daml-LF;
36//! lowering is the one step that reads the decoded LF AST (from `canton-lf`,
37//! which is held to the official `daml-lf-archive` reader by a conformance
38//! oracle). Every generator checks its output parses as valid Rust before
39//! returning it. The IR is public so a caller can emit something other than
40//! Rust from it, or post-process before emission; the emitter and type mapper
41//! are private (their signatures are `proc-macro2` token streams, which would
42//! otherwise become part of this crate's SemVer surface).
43//!
44//! The human-readable specification of the type mapping lives in
45//! `docs/daml-lf-type-mapping.md`.
46
47pub mod ir;
48
49mod generate;
50
51pub(crate) mod emit;
52pub(crate) mod lower;
53pub(crate) mod map;
54
55/// The Daml-LF reader this crate is built on, re-exported because its types
56/// (`Dar`, `DarError`, `DecodeError`) appear in the signatures below — so a
57/// caller never has to add `canton-lf` separately and match its version.
58pub use canton_lf;
59
60pub use generate::{GenerateError, Options, Runtime, Stats, default_crate_name, generate};
61pub use lower::{
62    ExternalPackages, Selection, SkippedType, lower_dar, lower_dar_selecting, lower_dar_with,
63    lower_packages_selecting,
64};
65
66use proc_macro2::TokenStream;
67
68/// Emitting Rust from the [`ir`] failed.
69///
70/// This means the generator produced tokens that are not valid Rust — always a
71/// bug in `canton-codegen` (or in a hand-built [`ir`]), never bad user input.
72/// The parse error is the payload; it is deliberately opaque so the `syn`
73/// dependency stays out of this crate's public API.
74#[derive(Debug)]
75pub struct CodegenError(syn::Error);
76
77impl std::fmt::Display for CodegenError {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        write!(f, "generated source is not valid Rust: {}", self.0)
80    }
81}
82
83impl std::error::Error for CodegenError {}
84
85use crate::ir::{Crate, DataType, Module, Record, Template};
86
87/// The header prepended to every generated module: lints appropriate for
88/// generated code, plus the `use canton_daml as rt;` alias the emitted `rt::…`
89/// references resolve through.
90const MODULE_PREAMBLE: &str = concat!(
91    "#![allow(non_camel_case_types, non_snake_case, unused_imports, dead_code, unused_variables, clippy::all)]\n",
92    "//! Generated Daml bindings — do not edit by hand.\n\n",
93    "use canton_daml as rt;\n\n",
94);
95
96/// The lint header for a whole generated crate. Daml names are not Rust
97/// casing and the emitter deliberately emits some unused glue, so those two
98/// lints are silenced; `clippy::all` is off because generated code is not
99/// hand-maintained and its style is not the reader's business. `unsafe_code`
100/// is forbidden outright: the emitter never writes any, and a generated crate
101/// outside this workspace does not inherit the workspace's `forbid`, so the
102/// guarantee has to travel in the file.
103const CRATE_LINTS: &str = "#![forbid(unsafe_code)]\n\
104     #![allow(non_camel_case_types, non_snake_case, unused_imports, clippy::all)]\n";
105
106/// The crate-level docs for a generated crate: what it is, how it is used, and
107/// an index of the Daml packages inside it — otherwise a reader landing on
108/// docs.rs sees twenty-odd opaque module names and no entry point.
109fn crate_docs(krate: &Crate) -> String {
110    use std::fmt::Write as _;
111
112    let mut docs = String::new();
113    docs.push_str("//! Typed Rust bindings generated from a Daml archive (DAR).\n//!\n");
114    docs.push_str(
115        "//! **Generated file — do not edit by hand.** Regenerate with\n\
116         //! `dpm-codegen-rust` from the source this crate's `Cargo.toml` records\n\
117         //! (a DAR, or a participant's package payloads); edits are lost.\n//!\n\
118         //! Each Daml package in the DAR's dependency closure is one top-level\n\
119         //! module, and each Daml module a submodule under it, so cross-package\n\
120         //! references resolve and names never collide. Templates carry typed\n\
121         //! choices; the runtime traits they implement (`Template`, `Choice`,\n\
122         //! `Contract`) and the command builders live in `canton-daml`, which the\n\
123         //! generated code imports as `rt`.\n//!\n\
124         //! # Packages in this crate\n//!\n",
125    );
126    for package in &krate.packages {
127        let modules = package.modules.len();
128        let plural = if modules == 1 { "module" } else { "modules" };
129        let _ = writeln!(docs, "//! - [`{}`] — {modules} Daml {plural}", package.name);
130    }
131    docs.push('\n');
132    docs
133}
134
135/// Format a stream of generated items as Rust source, first checking it is
136/// syntactically valid Rust.
137///
138/// # Errors
139/// Returns [`CodegenError`] if the tokens are not valid Rust — a generator bug.
140fn format_items(tokens: TokenStream) -> Result<String, CodegenError> {
141    let file: syn::File = syn::parse2(tokens).map_err(CodegenError)?;
142    Ok(prettyplease::unparse(&file))
143}
144
145/// Generate formatted Rust source for a single record data type.
146///
147/// # Errors
148/// Returns [`CodegenError`] if the generated tokens are not valid Rust.
149pub fn generate_record(record: &Record) -> Result<String, CodegenError> {
150    format_items(emit::record_items(record))
151}
152
153/// Generate formatted Rust source for a named data type (record / variant / enum).
154///
155/// # Errors
156/// Returns [`CodegenError`] if the generated tokens are not valid Rust.
157pub fn generate_data_type(data_type: &DataType) -> Result<String, CodegenError> {
158    format_items(emit::data_type(data_type))
159}
160
161/// Generate formatted Rust source for a template: its payload struct plus the
162/// typed choice impls.
163///
164/// # Errors
165/// Returns [`CodegenError`] if the generated tokens are not valid Rust.
166pub fn generate_template(template: &Template) -> Result<String, CodegenError> {
167    format_items(emit::template(template))
168}
169
170/// Generate formatted Rust source for an interface's impls (its `Contract` /
171/// `Interface` identity and typed choice impls) on its marker type.
172///
173/// # Errors
174/// Returns [`CodegenError`] if the generated tokens are not valid Rust.
175pub fn generate_interface(interface: &crate::ir::Interface) -> Result<String, CodegenError> {
176    format_items(emit::interface(interface))
177}
178
179/// Generate a complete module file: the `use canton_daml as rt;` preamble plus
180/// every data type and template, ready to write to a `.rs` file in a generated
181/// crate.
182///
183/// # Errors
184/// Returns [`CodegenError`] if the generated tokens are not valid Rust.
185pub fn generate_module(module: &Module) -> Result<String, CodegenError> {
186    let body = format_items(emit::module_items(module))?;
187    Ok(format!("{MODULE_PREAMBLE}{body}"))
188}
189
190/// Generate a whole crate's `lib.rs` from a lowered DAR: a `pub mod` tree
191/// (package → module → types) where every cross-module and cross-package
192/// reference resolves through its qualified path.
193///
194/// # Errors
195/// Returns [`CodegenError`] if the generated tokens are not valid Rust.
196pub fn generate_crate(krate: &Crate) -> Result<String, CodegenError> {
197    let body = format_items(emit::crate_items(krate))?;
198    Ok(format!("{CRATE_LINTS}{}{body}", crate_docs(krate)))
199}
200
201#[cfg(test)]
202#[allow(clippy::unwrap_used, clippy::expect_used)]
203mod tests {
204    use super::*;
205    use crate::ir::{DamlType, Field, Record, TypeRef, Variant, VariantConstructor};
206
207    fn field(label: &str, ty: DamlType) -> Field {
208        Field {
209            label: label.to_string(),
210            ty,
211        }
212    }
213
214    #[test]
215    fn generates_valid_rust_for_a_record() {
216        let record = Record {
217            name: "AppInstallRequest".to_string(),
218            type_params: Vec::new(),
219            fields: vec![
220                field("provider", DamlType::Party),
221                field("installId", DamlType::Text),
222                field("amount", DamlType::Numeric(10)),
223                field(
224                    "cid",
225                    DamlType::ContractId(Box::new(DamlType::Ref(TypeRef::local(
226                        "Foo",
227                        Vec::new(),
228                    )))),
229                ),
230                field("tags", DamlType::List(Box::new(DamlType::Text))),
231                field("note", DamlType::Optional(Box::new(DamlType::Text))),
232                // A Daml field whose name collides with a Rust keyword.
233                field("type", DamlType::Text),
234            ],
235        };
236
237        let src = generate_record(&record).expect("generator emits valid Rust");
238        // The strongest guarantee: the output parses as a Rust file.
239        syn::parse_file(&src).expect("output must be valid Rust");
240
241        // Spot-check the type mapping, snake_case, and keyword escaping.
242        assert!(src.contains("pub struct AppInstallRequest"), "{src}");
243        assert!(src.contains("pub provider: rt::Party"), "{src}");
244        assert!(
245            src.contains("pub install_id: ::std::string::String"),
246            "{src}"
247        );
248        assert!(src.contains("pub amount: rt::Numeric"), "{src}");
249        assert!(src.contains("rt::ContractId<Foo>"), "{src}");
250        assert!(src.contains("Vec<::std::string::String>"), "{src}");
251        assert!(src.contains("Option<::std::string::String>"), "{src}");
252        assert!(src.contains("r#type: ::std::string::String"), "{src}");
253    }
254
255    #[test]
256    fn only_a_renamed_field_carries_a_doc() {
257        let record = Record {
258            name: "Payload".to_string(),
259            type_params: Vec::new(),
260            fields: vec![
261                field("owner", DamlType::Party),
262                field("acceptArg", DamlType::Text),
263                field("type", DamlType::Text),
264            ],
265        };
266
267        let src = generate_record(&record).unwrap();
268        syn::parse_file(&src).unwrap();
269        // Camel-cased on the wire, snake_cased in Rust: the doc is the only
270        // place a reader sees the label they have to send.
271        assert!(src.contains("Daml field `acceptArg`"), "{src}");
272        // Same spelling in both languages — a doc here would only restate the
273        // identifier under it, on every field of every record.
274        assert!(!src.contains("Daml field `owner`"), "{src}");
275        // A raw identifier is the same name, so it is not a rename either.
276        assert!(src.contains("r#type"), "{src}");
277        assert!(!src.contains("Daml field `type`"), "{src}");
278    }
279
280    #[test]
281    fn generated_decoders_attach_the_field_name_to_a_failure() {
282        // Without this a mismatch three records down arrives as a bare
283        // "expected Text" with nothing to locate it by. `ValueError::at`
284        // prepends, so each layer contributes its own label on the way up.
285        let record = Record {
286            name: "Payload".to_string(),
287            type_params: Vec::new(),
288            fields: vec![
289                field("owner", DamlType::Party),
290                field("note", DamlType::Optional(Box::new(DamlType::Text))),
291            ],
292        };
293        let src = generate_record(&record).unwrap();
294        syn::parse_file(&src).unwrap();
295
296        // Both the required and the optional path carry it.
297        assert!(src.contains(r#".map_err(|e| e.at("owner"))"#), "{src}");
298        assert!(src.contains(r#".map_err(|e| e.at("note"))"#), "{src}");
299        // …and it wraps the *decode*, not the lookup: `required_field` already
300        // names the field it could not find, so wrapping that too would say it
301        // twice.
302        assert!(
303            src.contains(r#"rt::required_field(value, 0usize, "owner")?)"#),
304            "{src}"
305        );
306    }
307
308    #[test]
309    fn record_emits_value_codecs() {
310        let record = Record {
311            name: "Payload".to_string(),
312            type_params: Vec::new(),
313            fields: vec![
314                field("owner", DamlType::Party),
315                field("count", DamlType::Int64),
316            ],
317        };
318
319        let src = generate_record(&record).unwrap();
320        syn::parse_file(&src).unwrap();
321        assert!(src.contains("impl rt::ToValue for Payload"), "{src}");
322        assert!(src.contains("impl rt::FromValue for Payload"), "{src}");
323        assert!(src.contains("rt::record("), "{src}");
324        // Fields decode by label *and* declaration index, so non-verbose
325        // (label-less) records bind positionally.
326        assert!(
327            src.contains("rt::required_field(value, 0usize, \"owner\")"),
328            "{src}"
329        );
330    }
331
332    #[test]
333    fn optional_fields_decode_as_absent_tolerant() {
334        // Canton normalizes records by dropping trailing empty-Optional fields;
335        // an Optional field must therefore decode through `optional_field` (an
336        // absent field is `None`), while required fields stay strict.
337        let record = Record {
338            name: "Payload".to_string(),
339            type_params: Vec::new(),
340            fields: vec![
341                field("owner", DamlType::Party),
342                field("note", DamlType::Optional(Box::new(DamlType::Text))),
343            ],
344        };
345
346        let src = generate_record(&record).unwrap();
347        syn::parse_file(&src).unwrap();
348        assert!(
349            src.contains("rt::optional_field(value, 1usize, \"note\")"),
350            "{src}"
351        );
352        assert!(
353            src.contains("rt::required_field(value, 0usize, \"owner\")"),
354            "{src}"
355        );
356    }
357
358    #[test]
359    fn nested_optional_maps_to_nested_opt() {
360        let record = Record {
361            name: "R".to_string(),
362            type_params: Vec::new(),
363            fields: vec![
364                field("single", DamlType::Optional(Box::new(DamlType::Text))),
365                field(
366                    "nested",
367                    DamlType::Optional(Box::new(DamlType::Optional(Box::new(DamlType::Text)))),
368                ),
369            ],
370        };
371
372        let src = generate_record(&record).unwrap();
373        syn::parse_file(&src).unwrap();
374        // A single Optional stays a plain Option.
375        assert!(
376            src.contains("pub single: ::core::option::Option<::std::string::String>"),
377            "{src}"
378        );
379        // A nested Optional wraps the inner layer in rt::NestedOpt (list JSON).
380        assert!(
381            src.contains(
382                "pub nested: ::core::option::Option<rt::NestedOpt<::std::string::String>>"
383            ),
384            "{src}"
385        );
386    }
387
388    #[test]
389    fn record_derives_the_json_codec() {
390        let record = Record {
391            name: "Payload".to_string(),
392            type_params: Vec::new(),
393            fields: vec![field("installId", DamlType::Text)],
394        };
395
396        let src = generate_record(&record).unwrap();
397        syn::parse_file(&src).unwrap();
398        assert!(src.contains("rt::serde::Serialize"), "{src}");
399        assert!(src.contains("rt::serde::Deserialize"), "{src}");
400        assert!(src.contains("serde(crate = \"rt::serde\")"), "{src}");
401        // The JSON key is the Daml label, not the snake_cased Rust field.
402        assert!(src.contains("serde(rename = \"installId\")"), "{src}");
403        assert!(
404            src.contains("pub install_id: ::std::string::String"),
405            "{src}"
406        );
407    }
408
409    #[test]
410    fn variant_generates_a_rust_enum() {
411        use crate::ir::{Variant, VariantConstructor};
412
413        let variant = Variant {
414            name: "Shape".to_string(),
415            type_params: Vec::new(),
416            constructors: vec![
417                VariantConstructor {
418                    name: "Circle".to_string(),
419                    payload: Some(DamlType::Numeric(10)),
420                },
421                VariantConstructor {
422                    name: "Point".to_string(),
423                    payload: None,
424                },
425            ],
426        };
427
428        let src = generate_data_type(&DataType::Variant(variant)).unwrap();
429        syn::parse_file(&src).unwrap();
430        assert!(src.contains("pub enum Shape"), "{src}");
431        assert!(src.contains("Circle(rt::Numeric)"), "{src}");
432        // A nullary constructor carries `rt::Unit` (LF-JSON `{}`), not nothing —
433        // so its wire form is `{"tag":"Point","value":{}}`, which the Ledger API
434        // sends and a bare unit variant would fail to parse.
435        assert!(src.contains("Point(rt::Unit)"), "{src}");
436    }
437
438    #[test]
439    fn enum_generates_a_c_like_enum() {
440        use crate::ir::Enum;
441
442        let enumeration = Enum {
443            name: "DayOfWeek".to_string(),
444            constructors: vec!["Monday".to_string(), "Tuesday".to_string()],
445        };
446
447        let src = generate_data_type(&DataType::Enum(enumeration)).unwrap();
448        syn::parse_file(&src).unwrap();
449        assert!(src.contains("pub enum DayOfWeek"), "{src}");
450        assert!(src.contains("Monday,"), "{src}");
451        // C-like enums are `Copy`.
452        assert!(src.contains("Copy"), "{src}");
453    }
454
455    #[test]
456    fn template_generates_payload_and_typed_choices() {
457        use crate::ir::{Choice, Template, TypeRef};
458
459        let template = Template {
460            name: "AppInstall".to_string(),
461            module_name: "Licensing.AppInstall".to_string(),
462            package_id: "abc123".to_string(),
463            package_name: "app-install".to_string(),
464            fields: vec![field("provider", DamlType::Party)],
465            choices: vec![Choice {
466                name: "Accept".to_string(),
467                consuming: true,
468                argument: DamlType::Ref(TypeRef::local("AppInstall_Accept", Vec::new())),
469                returns: DamlType::ContractId(Box::new(DamlType::Ref(TypeRef::local(
470                    "AppInstalled",
471                    Vec::new(),
472                )))),
473            }],
474            key: None,
475        };
476
477        let src = generate_template(&template).unwrap();
478        syn::parse_file(&src).unwrap();
479        // The payload struct…
480        assert!(src.contains("pub struct AppInstall"), "{src}");
481        assert!(src.contains("pub provider: rt::Party"), "{src}");
482        // …and the typed choice impl linking arg → template → return.
483        assert!(
484            src.contains("impl rt::Choice<AppInstall> for AppInstall_Accept"),
485            "{src}"
486        );
487        assert!(
488            src.contains("type Return = rt::ContractId<AppInstalled>"),
489            "{src}"
490        );
491        assert!(
492            src.contains("const NAME: &'static str = \"Accept\""),
493            "{src}"
494        );
495        assert!(src.contains("const CONSUMING: bool = true"), "{src}");
496        // …and the on-ledger template identity.
497        assert!(src.contains("impl rt::Template for AppInstall"), "{src}");
498        assert!(
499            src.contains("const MODULE_NAME: &'static str = \"Licensing.AppInstall\""),
500            "{src}"
501        );
502        assert!(
503            src.contains("const ENTITY_NAME: &'static str = \"AppInstall\""),
504            "{src}"
505        );
506        assert!(
507            src.contains("const PACKAGE_NAME: &'static str = \"app-install\""),
508            "{src}"
509        );
510    }
511
512    #[test]
513    fn a_template_documents_its_identity_and_its_choices() {
514        use crate::ir::{Choice, Template, TypeRef};
515
516        let template = Template {
517            name: "AppInstall".to_string(),
518            module_name: "Licensing.AppInstall".to_string(),
519            package_id: "abc123".to_string(),
520            package_name: "app-install".to_string(),
521            fields: vec![field("provider", DamlType::Party)],
522            choices: vec![
523                Choice {
524                    name: "Accept".to_string(),
525                    consuming: true,
526                    argument: DamlType::Ref(TypeRef::local("AppInstall_Accept", Vec::new())),
527                    returns: DamlType::Unit,
528                },
529                Choice {
530                    name: "Peek".to_string(),
531                    consuming: false,
532                    argument: DamlType::Ref(TypeRef::local("AppInstall_Peek", Vec::new())),
533                    returns: DamlType::Unit,
534                },
535            ],
536            key: None,
537        };
538
539        let src = generate_template(&template).unwrap();
540        syn::parse_file(&src).unwrap();
541        // rustdoc has no reverse index for `impl Choice<This> for That`, so the
542        // payload struct is the only place a reader can learn what it can do.
543        assert!(
544            src.contains("The Daml template `Licensing.AppInstall:AppInstall`"),
545            "{src}"
546        );
547        assert!(
548            src.contains("`#app-install:Licensing.AppInstall:AppInstall`"),
549            "{src}"
550        );
551        assert!(src.contains("- `Accept` — consuming"), "{src}");
552        assert!(src.contains("- `Peek` — non-consuming"), "{src}");
553        // One `#[doc]` per line, never a block comment: prettyplease indents a
554        // `/** */` continuation line to match the item, and rustdoc reads four
555        // spaces as a code block — which makes the prose a failing doctest in
556        // the user's crate.
557        assert!(!src.contains("/**"), "{src}");
558    }
559
560    #[test]
561    fn keyed_template_emits_with_key_impl() {
562        use crate::ir::{Template, TypeRef};
563
564        let template = Template {
565            name: "Account".to_string(),
566            module_name: "Bank.Account".to_string(),
567            package_id: "abc123".to_string(),
568            package_name: "bank".to_string(),
569            fields: vec![field("owner", DamlType::Party)],
570            choices: Vec::new(),
571            // A record key (owner + number) referenced by name.
572            key: Some(DamlType::Ref(TypeRef::local("AccountKey", Vec::new()))),
573        };
574
575        let src = generate_template(&template).unwrap();
576        syn::parse_file(&src).unwrap();
577        assert!(src.contains("impl rt::WithKey for Account"), "{src}");
578        assert!(src.contains("type Key = AccountKey"), "{src}");
579    }
580
581    #[test]
582    fn interface_emits_identity_view_and_choice_impls() {
583        use crate::ir::{Choice, Interface, TypeRef};
584
585        let interface = Interface {
586            name: "Holding".to_string(),
587            module_name: "Splice.Api.Token.HoldingV1".to_string(),
588            package_id: "abc123".to_string(),
589            package_name: "splice-api-token-holding".to_string(),
590            view: Some(DamlType::Ref(TypeRef::local("HoldingView", Vec::new()))),
591            choices: vec![Choice {
592                name: "Transfer".to_string(),
593                consuming: true,
594                argument: DamlType::Ref(TypeRef::local("Holding_Transfer", Vec::new())),
595                returns: DamlType::Unit,
596            }],
597        };
598
599        let src = generate_interface(&interface).unwrap();
600        syn::parse_file(&src).unwrap();
601        // Identity (Contract) + the interface's view type…
602        assert!(src.contains("impl rt::Contract for Holding"), "{src}");
603        assert!(src.contains("impl rt::Interface for Holding"), "{src}");
604        assert!(src.contains("type View = HoldingView"), "{src}");
605        // …and the choice, exercisable through the interface marker.
606        assert!(
607            src.contains("impl rt::Choice<Holding> for Holding_Transfer"),
608            "{src}"
609        );
610    }
611
612    #[test]
613    fn keyless_template_emits_no_with_key_impl() {
614        use crate::ir::Template;
615
616        let template = Template {
617            name: "Note".to_string(),
618            module_name: "M".to_string(),
619            package_id: "p".to_string(),
620            package_name: "m".to_string(),
621            fields: vec![field("text", DamlType::Text)],
622            choices: Vec::new(),
623            key: None,
624        };
625
626        let src = generate_template(&template).unwrap();
627        syn::parse_file(&src).unwrap();
628        assert!(
629            !src.contains("WithKey"),
630            "keyless template has no key impl: {src}"
631        );
632    }
633
634    #[test]
635    fn module_wraps_items_with_the_runtime_preamble() {
636        use crate::ir::{Choice, Enum, Module, Template};
637
638        let module = Module {
639            data_types: vec![DataType::Enum(Enum {
640                name: "Color".to_string(),
641                constructors: vec!["Red".to_string(), "Green".to_string()],
642            })],
643            templates: vec![Template {
644                name: "AppInstall".to_string(),
645                module_name: "Licensing.AppInstall".to_string(),
646                package_id: "abc123".to_string(),
647                package_name: "app-install".to_string(),
648                fields: vec![field("provider", DamlType::Party)],
649                choices: vec![Choice {
650                    name: "Accept".to_string(),
651                    consuming: true,
652                    argument: DamlType::Ref(TypeRef::local("AppInstall_Accept", Vec::new())),
653                    returns: DamlType::Unit,
654                }],
655                key: None,
656            }],
657            interfaces: Vec::new(),
658        };
659
660        let src = generate_module(&module).unwrap();
661        // The whole file (preamble + items) is valid Rust.
662        syn::parse_file(&src).unwrap();
663        assert!(src.contains("use canton_daml as rt;"), "{src}");
664        assert!(src.contains("#![allow(non_camel_case_types"), "{src}");
665        assert!(src.contains("pub enum Color"), "{src}");
666        assert!(src.contains("pub struct AppInstall"), "{src}");
667        assert!(
668            src.contains("impl rt::Choice<AppInstall> for AppInstall_Accept"),
669            "{src}"
670        );
671    }
672
673    #[test]
674    fn generic_record_carries_type_params() {
675        let record = Record {
676            name: "Pair".to_string(),
677            type_params: vec!["a".to_string(), "b".to_string()],
678            fields: vec![
679                field("fst", DamlType::Var("a".to_string())),
680                field("snd", DamlType::Var("b".to_string())),
681            ],
682        };
683
684        let src = generate_record(&record).unwrap();
685        syn::parse_file(&src).unwrap();
686        // Lowercase Daml vars are upper-camel-cased to Rust generics.
687        assert!(src.contains("pub struct Pair<A, B>"), "{src}");
688        assert!(src.contains("pub fst: A"), "{src}");
689    }
690    /// Every keyword the escape list claims to cover, checked by generating a
691    /// field named after it and looking for the escape.
692    ///
693    /// The list is only worth what it is complete for, and completeness is
694    /// exactly what reading it cannot establish — `gen` was missing until this
695    /// went looking. Generated crates declare edition 2021, where `gen` is an
696    /// ordinary identifier, so nothing would have failed until the same file
697    /// was compiled in a 2024 crate; `r#gen` is valid in both.
698    #[test]
699    fn every_keyword_in_the_list_is_escaped_in_a_field_name() {
700        // Spot-check across the three shapes the escape takes: raw identifiers,
701        // the four that cannot be raw and get a `_` suffix, and a 2024 addition.
702        for (daml, expected) in [
703            ("type", "r#type:"),
704            ("match", "r#match:"),
705            ("gen", "r#gen:"),
706            ("try", "r#try:"),
707            ("async", "r#async:"),
708            ("box", "r#box:"),
709            ("crate", "crate_:"),
710            ("self", "self_:"),
711        ] {
712            let record = Record {
713                name: "Keyworded".to_string(),
714                type_params: Vec::new(),
715                fields: vec![field(daml, DamlType::Text)],
716            };
717            let src = generate_record(&record).expect("generator emits valid Rust");
718            assert!(
719                src.contains(expected),
720                "a Daml field named `{daml}` should emit `{expected}`:\n{src}"
721            );
722            // And the wire label survives the escaping, or the field would be
723            // written to the ledger under the Rust spelling.
724            assert!(
725                src.contains(&format!("#[serde(rename = \"{daml}\")]")),
726                "the wire label `{daml}` must survive:\n{src}"
727            );
728        }
729    }
730    /// A Daml type parameter that no field uses must not be forced to carry a
731    /// codec.
732    ///
733    /// Daml permits a phantom parameter, and the natural thing to instantiate
734    /// one with is an interface marker — which by design has no codec at all,
735    /// because it exists only as the tag of a `ContractId`. Bounding every
736    /// declared parameter therefore emitted Rust that is valid until somebody
737    /// writes `Wrapper Holding`, and then does not compile, with the error
738    /// landing in generated code the reader did not write.
739    #[test]
740    fn a_phantom_type_parameter_is_not_forced_to_carry_a_codec() {
741        let mut record = Record::new("Wrapper");
742        record.type_params = vec!["a".to_string(), "b".to_string()];
743        // Only `b` is used by a field; `a` is phantom.
744        record.fields = vec![Field::new("value", DamlType::Var("b".to_string()))];
745
746        let src = generate_data_type(&DataType::Record(record)).unwrap();
747        assert!(
748            src.contains("B: rt::ToValue"),
749            "the used parameter is bounded: {src}"
750        );
751        assert!(
752            !src.contains("A: rt::ToValue"),
753            "the phantom parameter must not be bounded: {src}"
754        );
755        // It still appears as a parameter of the type, and in the PhantomData.
756        assert!(src.contains("pub struct Wrapper<A, B>"), "{src}");
757        assert!(src.contains("PhantomData"), "{src}");
758    }
759
760    /// The same for a variant: only the parameters a constructor payload
761    /// mentions get the bound.
762    #[test]
763    fn a_variants_phantom_parameter_is_not_bounded_either() {
764        let mut variant = Variant::new("Choice");
765        variant.type_params = vec!["a".to_string(), "b".to_string()];
766        variant.constructors = vec![
767            VariantConstructor::with_payload("Some", DamlType::Var("b".to_string())),
768            VariantConstructor::new("None"),
769        ];
770
771        let src = generate_data_type(&DataType::Variant(variant)).unwrap();
772        assert!(src.contains("B: rt::ToValue"), "{src}");
773        assert!(!src.contains("A: rt::ToValue"), "{src}");
774    }
775}