Quickstart: Rust

Compile a Candid interface into a validated Contract graph and read its identities.

This page takes a Candid .did interface and turns it into a Contract: the validated, canonical type graph this project is built around, carrying content-addressed identities that let you tell two interfaces apart — or prove they are the same — without diffing text. Cargo is the only tool you need.

Parsing and type checking are not reimplemented here. The crate delegates both to the official candid_parser, then projects the checked result into its own arena of type nodes, sorts and canonicalizes it, and hashes the canonical bytes. Everything downstream — the TypeScript generator, the schema runtime, the CLI — consumes that graph rather than the source text.

This is a prerelease, and the requirement must say so

candid-core is at 0.1.0-beta.3. Cargo only selects a prerelease when the requirement mentions one, so a caret requirement such as candid-core = "0.1" resolves to nothing. Every example below pins the exact version. Until 1.0, any release may change the public API, the serialized shapes, the canonical bytes, and therefore every identity computed over them.

From .did source to a Contract#

  1. Add the dependency#

    tomlCargo.toml
    [dependencies]
    candid-core = "=0.1.0-beta.3"

    Or from the command line, cargo add candid-core@=0.1.0-beta.3. The MSRV is Rust 1.78 and the licence is Apache-2.0.

    That line gives you the whole surface, because all three Cargo features are on by default: compiler (the candid_parser-backed source compiler), filesystem-compiler (reading .did files, and the candid-core binary) and host-value (the tagged JSON value ABI). A consumer that only reads Contracts someone else produced can switch all three off with default-features = false and keep the model, the validation and the identities; Install and features lays out the four surfaces and what each one pulls into the dependency graph.

  2. Compile an inline source and read its identities#

    compile_did takes Candid source as a &str and returns a Compilation: the Contract itself plus an optional provenance sidecar. It needs no filesystem, which is what makes it the entry point a browser or WASM host uses.

    rust
    use candid_core::compile_did;
    use std::error::Error;
    
    fn main() -> Result<(), Box<dyn Error>> {
        let compilation = compile_did(
            r#"
            type Account = record { owner: principal; balance: nat };
    
            service : {
              balance_of: (Account) -> (nat) query;
            };
            "#,
        )?;
    
        let contract = compilation.contract();
        println!("contract:  {}", contract.contract_id());
        println!("interface: {}", contract.interface_id().unwrap_or("(none)"));
        Ok(())
    }
    text
    contract:  candid-core:contract:v1:sha256:<64 lowercase hex digits>
    interface: candid-core:interface:v1:sha256:<64 lowercase hex digits>

    The digests are elided above because the exact bytes depend on the release you build against — that is the point of the beta warning, not a coyness about the values. The shape is fixed and is checked on every Contract that loads: a domain prefix, then sha256:, then exactly 64 lowercase hex characters.

    The two identities answer different questions. contract_id covers the complete canonical Contract payload: the format markers, both profile markers, every retained type node, the declarations and their names, and the actor when there is one. interface_id covers only the type graph reachable from the actor plus the profile markers, so declaration names and actor-unreachable declarations do not move it — rename Account to Acct and the interface identity holds while the contract identity changes. It is Option<&str> because a declaration-only Contract with no service block has no interface to identify.

    Neither one covers producer, source text, comments or formatting. What identifies raw source bytes is a third value, source_bundle_id, which lives on the provenance sidecar; what commits to the exact octets of a serialized document is a fourth, artifact_id, computed on demand and never written into the document. Content-addressed identities is the page that pulls the four apart.

    From the same Compilation you can walk the graph itself — contract.types() is the arena, contract.declarations() the named entries, contract.actor() the root — and compilation.source_info() gives the provenance sidecar with the field labels, documentation comments and source list. The runnable walkthrough does exactly that:

    bash
    cargo run --example contract_walkthrough
  3. Compile a file instead#

    compile_did_file is the same pipeline over a path, and it is the one entry point that resolves import statements: it roots a WorkspaceResolver at the entry file's parent directory and reads the bundle through it, so nothing outside that directory is reachable.

    rust
    use candid_core::compile_did_file;
    use std::error::Error;
    
    fn main() -> Result<(), Box<dyn Error>> {
        let compilation = compile_did_file("./service.did")?;
        let contract = compilation.contract();
    
        println!("{} type node(s)", contract.types().len());
        println!("{} declaration(s)", contract.declarations().len());
        println!("{}", contract.to_json_pretty()?);
        Ok(())
    }

    This needs the filesystem-compiler feature, which is on by default and pulls in cap-std. Turn default features off and the item is absent at compile time — a build error naming compile_did_file, not a runtime stub.

    compile_did refuses imports by design

    Hand compile_did a source containing import and it fails with a did_import_requires_file diagnostic listing each import as a note, rather than resolving anything. That is the boundary being explicit: an in-memory entry point never touches a filesystem. Supply the bundle yourself through a SourceResolver and call compile_with_resolver, or read it from disk with compile_did_file. See Sources, imports and provenance.

  4. Tighten the limits and read the diagnostic#

    Every entry point runs under a resource budget, so .did source you did not write cannot hang the process or allocate without bound. Limits::default() is the frozen interactive_v1 profile — 27 values, including max_source_bytes at 1 MiB and max_input_bytes at 4 MiB. Fields are private; you start from a profile and override with with_* builders, then wrap the result in a RuntimeContext and pass it to a _with_context entry point.

    rust
    use candid_core::{compile_did_with_context, CompileOptions, Limits, RuntimeContext};
    
    fn main() {
        // 38 bytes of source, and a 16-byte ceiling.
        let source = "service : { ping: () -> (nat) query };";
        let context = RuntimeContext::new(Limits::default().with_max_source_bytes(16));
    
        let error = compile_did_with_context(source, CompileOptions::default(), &context)
            .expect_err("the source is larger than the limit");
    
        for diagnostic in &error.diagnostics {
            println!("code:    {}", diagnostic.code);
            println!("phase:   {:?}", diagnostic.phase);
            println!("message: {}", diagnostic.message);
            if let Some(limit) = &diagnostic.resource_limit {
                println!(
                    "budget:  resource={} limit={} observed={}",
                    limit.resource, limit.limit, limit.observed
                );
            }
        }
    }
    text
    code:    resource_limit_exceeded
    phase:   Some(Load)
    message: source "memory:/inline.did" uses 38 bytes; limit is 16
    budget:  resource=source_bytes limit=16 observed=38

    That is the whole failure contract. A CompileError is a struct with one public field, diagnostics: Vec<Diagnostic>, and a Diagnostic carries a stable code, a message, and optionally a phase (parse, type_check, load or lower), a severity, a path, a span naming the logical source, ordered related locations, notes, and the {resource, limit, observed} triple above. The codes, the paths and the resource metadata are the machine surface and are stable; the message text is not. limit and observed are fixed-width u64, so the serialized triple means the same thing on a 32-bit and a 64-bit target.

    Raising a limit does not raise the others: the budgets are charged separately, so a caller who raised max_input_bytes enough to parse a document may still fail to render it against max_canonicalization_work. cargo run --example bounded_parsing demonstrates exactly that, and the limits reference lists all 27 values with their defaults.

  5. Skip the Rust and use the binary#

    The crate ships one binary, also called candid-core, gated on the same filesystem-compiler feature. It accepts exactly this grammar and nothing else.

    text
    candid-core compile <path> [--no-source-info | --envelope]
    candid-core validate <path>
    bash
    cargo install candid-core --version 0.1.0-beta.3 --locked
    
    candid-core compile ./service.did              # contract + provenance sidecar
    candid-core compile ./service.did --envelope   # one self-describing document
    candid-core validate ./contract.json           # re-check a document you were given

    compile prints {"contract": …, "ok": true, "source_info": …}. The Contract inside has nine top-level keys when the interface has an actor, in this order. Digests and arena indices are elided below — the indices a real run assigns depend on the canonical ordering of your own type graph:

    json
    {
      "actor": { "kind": "service", "service": N },
      "canonicalization_profile": "candid-core-canon-1",
      "declarations": [{ "name": "Account", "type": N }],
      "format": "candid-core",
      "format_version": 1,
      "identities": {
        "contract": "candid-core:contract:v1:sha256:…",
        "interface": "candid-core:interface:v1:sha256:…"
      },
      "producer": {
        "candid_parser_version": "0.4.0",
        "candid_version": "0.10.30",
        "name": "candid-core",
        "version": "0.1.0-beta.3"
      },
      "semantics_profile": "candid-1",
      "types": [ "…the arena, one node per entry…" ]
    }

    A declaration-only interface omits the "actor" key entirely, leaving eight, and the "interface" identity is likewise absent. "actor": null is not part of the v1 wire format at all: serialization never emits it, the identity payload never hashes it, and decoding rejects it. producer is unverified provenance held outside both semantic identities: rewriting it leaves contract_id and interface_id byte-identical, which is why a version bump does not move them.

    --envelope is the flag to reach for when the consumer is TypeScript: it prints a ContractEnvelope — {"contract": …, "extensions": {…}} — whose org.candid-core.field-names/v1 extension carries the field-name table, because a semantic Contract stores label ids and not text. That document is exactly what schemaFromContract in @candid-core/schema consumes whole. Extensions live outside the canonical identities, so carrying them never moves a contract_id.

    Failures print to stdout, not stderr

    Every non-usage outcome is one pretty-printed JSON document on stdout with an empty stderr — success and failure alike. Branch on the exit status or on the ok key, never on which stream produced the output. Success exits 0; a read, parse, validation or resource-limit failure exits 1; a usage error writes nothing to stdout, prints the usage text on stderr, and exits 64. Flags go after the path, and a token beginning with - is always an option in the path position — spell a dash-leading file as ./-service.did.

    The candid-core binary has the full output contract, including the two different failure keys validate uses depending on how far the read got.

Next#

You now have a Contract and know what its identities do and do not cover. The two directions from here are downward, into the model itself, and outward, into the TypeScript that consumes it.