T tomlkit·org
Inspect Formatter Validator Lint Stats Keys Query Convert TOMLJSON JSONTOML TOMLYAML YAMLTOML INITOML TOMLINI .envTOML TOML.env TOMLTS TOMLCSV CSVTOML TOMLXML TOML.properties .propertiesTOML Transform Sort keys Flatten Unflatten Redact Minify Generate Go struct Rust struct Python types JSON Schema Compare Diff Merge

TOML to Rust struct

updated 20 August 2026

Paste a config, get the #[derive(Deserialize)] structs that read it. Tables become structs, arrays of tables become Vec<T>, absent fields become Option<T> with #[serde(default)], and keys that are not valid Rust identifiers get a rename attribute.

serde-ready, snake_case fields

What this tool does

It infers a Rust type for every key in the document and prints the struct definitions with serde derives attached. Strings become String, integers i64, floats f64, booleans bool, tables named structs, and arrays Vec<T>. Datetimes map to whichever type you pick — toml::value::Datetime keeps the dependency footprint at just toml.

The intent it closes: "I have a config file and I want the Deserialize types for it." That includes Cargo.toml itself, which is a common thing to want to read programmatically.

Field names are converted to snake_case, and when that differs from the original key a #[serde(rename = "…")] attribute is emitted — which is exactly what a key like auth-required needs, since a hyphen cannot appear in a Rust identifier.

When you'd reach for it

  • Write a config loader. Generate the types, then toml::from_str::<Config>(&text).
  • Read Cargo.toml or a manifest. Paste it in and get types for the parts you care about.
  • Keep structs current. After a config change, regenerate and diff to see what fields to add.
  • Handle hyphenated keys correctly. The rename attributes are written for you, which is where hand-written structs usually break.
  • Model optional settings. Fields absent from some entries come out as Option<T> with a default.

How it works

The same inference pass as the Go generator, printed as Rust.

1. Infer types

Every key's value is classified. For an array of tables, all entries are compared and the struct gets the union of their fields; a field missing from any entry is optional. Mixed integer and float values unify to f64; genuinely mixed types fall back to toml::Value, which deserialises anything.

2. Name and rename

Struct names are the PascalCase form of the key, with a numeric suffix if two tables would collide. Field names are snake_case; when that does not match the TOML key exactly, a rename attribute preserves the mapping. Nothing relies on serde's field guessing.

3. Emit with attributes

Optional fields become Option<T> with #[serde(default)], so a missing key deserialises to None rather than erroring, and with skip_serializing_if so re-serialising does not write nulls. The use serde::{Deserialize, Serialize}; line is included when the derive list needs it.

Options reference

Derives

A free-text list, written verbatim into #[derive(...)]. The default covers reading and writing a config. Drop Serialize if you only ever read; add PartialEq for tests, or Default if you construct the struct in code.

Visibility

pub for a config module other modules read — the usual case. private when the types live in the same module as their only consumer.

Skip None

With skip_serializing_if = "Option::is_none", re-serialising the struct omits absent keys entirely instead of writing an empty value. Turn it off if the consumer needs every key present.

Datetimes

toml::value::Datetime needs no extra crate and round-trips TOML's own date types exactly. chrono is the choice if you do date arithmetic (add the chrono dependency with serde support). String keeps the value opaque.

Example

Input:

[[route]]
path = "/health"
method = "GET"

[[route]]
path = "/v1/items"
method = "POST"
auth-required = true

Output:

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
    pub route: Vec<Route>,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Route {
    pub path: String,
    pub method: String,
    #[serde(rename = "auth-required", default, skip_serializing_if = "Option::is_none")]
    pub auth_required: Option<bool>,
}

Two things worth noticing: auth-required could not be a Rust identifier, so the field is auth_required with a rename; and because it is missing from the first route, it is Option<bool> with a default rather than a required field that would fail to deserialise.

Limits and notes

  • One sample, one type. A value that happens to be integral becomes i64. Change it to f64 yourself if the field can be fractional.
  • Empty containers are generic. An empty array becomes Vec<toml::Value>; add a representative element to the input for a real type.
  • No enums are inferred. A string field with a small set of values stays String. Promoting it to an enum is a judgement call the tool will not make.
  • Lifetimes are not used. Everything is owned (String, not &str), which is what toml::from_str into an owned struct needs.

FAQ

Does the output need any crate other than serde and toml?

No, unless you pick chrono for datetimes. The default mapping uses toml::value::Datetime, which comes with the toml crate you are already using to parse.

Why are optional fields marked #[serde(default)]?

Without it, a missing key is a deserialisation error even for an Option in some serde configurations. With it, absent means None, which is what a config loader should do.

How do hyphenated keys work?

The field is renamed to snake_case and a #[serde(rename = "original-key")] attribute keeps the wire name. This is the single most common bug in hand-written config structs.

Can I generate types for Cargo.toml?

Yes — paste it in. You will get structs for [package], [dependencies], and the rest. Note that dependency tables are heterogeneous (a string or an inline table per crate), so those fields come out as toml::Value, which is the honest mapping.

Is the config uploaded?

No. Inference and printing both happen in your browser.