TOML to Rust struct
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.
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 tof64yourself 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 whattoml::from_strinto 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.