How to parse a TOML config file
Short answer: Read the file with fs::read_to_string, then hand the text to toml::from_str into a struct that derives Deserialize. The derive is the same one the JSON pages use; only the crate changes.
This continues the json-demo project from the earlier pages in this chapter — cargo new json-demo, with one more crate:
[dependencies]serde = { version = "1.0.229", features = ["derive"] }toml = "1.1.8"The exact versions depend on when you run cargo add; the derive feature is the part that matters. Each block below writes the file it reads before reading it, so it runs on its own — in your own code the file is already there and you delete that line.
A flat file is a struct
Section titled “A flat file is a struct”host = "localhost"port = 8080retries = 3use serde::Deserialize;use std::fs;
#[derive(Debug, Deserialize)]struct Config { host: String, port: u16, retries: u32,}
fn main() { fs::write( "config.toml", r#"host = "localhost"port = 8080retries = 3"#, ) .unwrap(); let text = fs::read_to_string("config.toml").unwrap(); let config: Config = toml::from_str(&text).unwrap(); println!("{config:?}");}Config { host: "localhost", port: 8080, retries: 3 }TOML spells out what JSON leaves to the parser: 8080 is an integer and "8080" is a string, and the field’s type decides which one parses. from_str returns a Result, and unwrap says the program should die rather than start on a config it couldn’t read — the same default as the JSON page, for the same reason.
Tables become nested structs
Section titled “Tables become nested structs”The [server] header opens a table that lasts until the next header, and each table maps to a struct:
[server]host = "localhost"port = 8080
[database]url = "postgres://localhost/app"pool_size = 10use serde::Deserialize;use std::fs;
#[derive(Debug, Deserialize)]struct Config { server: Server, database: Database,}
#[derive(Debug, Deserialize)]struct Server { host: String, port: u16,}
#[derive(Debug, Deserialize)]struct Database { url: String, pool_size: u32,}
fn main() { fs::write( "config.toml", r#"[server]host = "localhost"port = 8080
[database]url = "postgres://localhost/app"pool_size = 10"#, ) .unwrap(); let text = fs::read_to_string("config.toml").unwrap(); let config: Config = toml::from_str(&text).unwrap(); println!("{config:?}");}Config { server: Server { host: "localhost", port: 8080 }, database: Database { url: "postgres://localhost/app", pool_size: 10 } }The dotted-key form names the same place, so a file can stay flat if that reads better:
server.host = "localhost"server.port = 8080A key outside every table is a key on the root table, which is why the headerless file in the first block matched a flat struct.
Defaults that merge
Section titled “Defaults that merge”The point of a config file is that it doesn’t have to say everything. #[serde(default = "path")] names a function that supplies the value when the key is absent, so the file overrides only what it mentions:
use serde::Deserialize;use std::fs;
fn default_port() -> u16 { 8080}
fn default_retries() -> u32 { 5}
#[derive(Debug, Deserialize)]struct Config { host: String, #[serde(default = "default_port")] port: u16, #[serde(default = "default_retries")] retries: u32,}
fn main() { fs::write( "config.toml", r#"host = "localhost""#, ) .unwrap(); let text = fs::read_to_string("config.toml").unwrap(); let config: Config = toml::from_str(&text).unwrap(); println!("{config:?}");}Config { host: "localhost", port: 8080, retries: 5 }host has no default, so it is still required; port and retries fall back to their functions. #[serde(default)] with no path uses Default::default() instead — 0, false, an empty String — which is rarely the number you wanted, and How to handle a missing field in JSON covers the Option version of the same question.
What a broken file looks like
Section titled “What a broken file looks like”TOML errors quote the offending line. Two of the ones a config file actually produces:
use serde::Deserialize;use std::fs;
#[derive(Debug, Deserialize)]struct Config { host: String, port: u16,}
fn main() { fs::write("config.toml", "host = \"localhost\"\nport = \"8080\"\n").unwrap(); let text = fs::read_to_string("config.toml").unwrap(); match toml::from_str::<Config>(&text) { Ok(config) => println!("{config:?}"), Err(e) => println!("error: {e}"), }
// The port is fixed; now the host is missing. fs::write("config.toml", "port = 8080\n").unwrap(); let text = fs::read_to_string("config.toml").unwrap(); match toml::from_str::<Config>(&text) { Ok(config) => println!("{config:?}"), Err(e) => println!("error: {e}"), }}error: TOML parse error at line 2, column 8 |2 | port = "8080" | ^^^^^^invalid type: string "8080", expected u16
error: TOML parse error at line 1, column 1 |1 | port = 8080 | ^missing field `host`The type error underlines the value itself, so the caret lands on "8080". The missing-field error points at line 1, column 1 — the top of the file — because a key that isn’t there has no line to point at. A key the struct doesn’t know is ignored, exactly as in JSON, so a typo like prot = 8080 configures nothing at all and still parses; #[serde(deny_unknown_fields)] is the fix, and the JSON config page shows it.
- How to handle a missing field in JSON —
Option,default, and the difference between the two - How to read a JSON config file — the same derive against a different format
- How to let a CLI read a config file — TOML in a clap-based command-line tool
- Back to Working with JSON, TOML and CSV
The next page in this chapter reads CSV into structs, where the header row has to match the field names.