Skip to content
Select theme

How to write a config file from a struct

Short answer: Give the struct one method that formats every field as a key = value line — strings quoted and escaped, numbers through Display — in declaration order, and hand the result to std::fs::write. The order is the part that matters: a struct’s fields are written in the order the method lists them, and that order is fixed at compile time, so a diff between two versions of the file shows exactly the fields that changed and nothing else. A HashMap written the same way shuffles its lines between runs and turns every diff into noise.

The previous article merged config from file, environment and flags into one struct. The reverse direction shows up just as often: a config save command, a first run that drops a default file next to the binary, a settings screen that persists what the user changed. Writing the file is easy. Writing one that a human can diff — and every config file gets diffed, in a pull request if nowhere else — is where the work is.

host = "127.0.0.1"
port = 8080
workers = 4
log_level = "info"

to_toml is one format! call with a line per field, and fs::write puts the string on disk as-is — no trailing newline added, none missing, because the format string ends with \n. The program reads the file back and prints it only so the output here is the file’s contents, not a second copy of the values.

The lines come out in the order the method lists them, which is the order the fields are declared in. Nothing enforces that at runtime — it is true because the method is a fixed piece of text decided at compile time. The same binary, run twice, writes byte-identical files. Keep that property in mind; the rest of this page is about what it buys.

The \" around {} in the format string is doing more work than it looks like. A value that itself contains a quote character terminates the string early, and everything after that quote stops being part of the value — the line is no longer valid TOML, and nothing in the program notices. The same goes for a backslash, which starts an escape sequence wherever it appears.

escape handles the two characters that can break a basic string, and nothing else:

host = "db \"primary\", \\backup\\"

Every " in the value became \" and every \ became \\, so the line round-trips through any TOML parser: what comes back is the original value, quotes and backslashes included. The raw string r#"..."# in the test is itself the escape-free way to write a value full of quotes — the irony is deliberate, and it keeps the expected value readable next to the escaped one.

Here is what the declaration order buys. Change one field — the port, 8080 to 9000 — and diff the two files. The program now takes its output path and port from the command line so the same binary can write both versions:

src/main.rs
use std::fs;
fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
_ => out.push(c),
}
}
out
}
struct Server {
host: String,
port: u16,
workers: u32,
log_level: String,
}
impl Server {
fn to_toml(&self) -> String {
format!(
"host = \"{}\"\nport = {}\nworkers = {}\nlog_level = \"{}\"\n",
escape(&self.host),
self.port,
self.workers,
escape(&self.log_level),
)
}
}
fn main() {
let mut args = std::env::args().skip(1);
let Some(path) = args.next() else {
eprintln!("usage: write-config <path> <port>");
std::process::exit(2);
};
let Ok(port) = args.next().unwrap_or_default().parse::<u16>() else {
eprintln!("usage: write-config <path> <port>");
std::process::exit(2);
};
let config = Server {
host: "127.0.0.1".to_string(),
port,
workers: 4,
log_level: "info".to_string(),
};
fs::write(&path, config.to_toml()).unwrap();
println!("wrote {path}");
}
Terminal window
cargo run -q -- settings-a.toml 8080
cargo run -q -- settings-b.toml 9000
diff settings-a.toml settings-b.toml
wrote settings-a.toml
wrote settings-b.toml
2c2
< port = 8080
---
> port = 9000

Six settings, one changed, and the diff is two lines. Whoever reviews that change — the user six months later, a colleague, the pull request — sees port moved and nothing else. That is the whole deliverable: not “a config file”, but a config file whose changes are legible.

Now the same six settings in a HashMap<String, String>, which is how a first draft often stores them because it avoids writing the struct. Each file is written from a fresh map built from the same six pairs, inserted in the same order:

Terminal window
cargo run -q
diff map-a.toml map-b.toml
1d0
< workers = 4
2a2,4
> timeout_secs = 30
> port = 8080
> workers = 4
5,6d6
< port = 8080
< timeout_secs = 30

Nothing changed — the two maps hold identical entries — and the diff still reports four lines deleted and four added. HashMap iteration order is deliberately unspecified: it is seeded per map, so the order differs between the two maps here and will differ again on the reader’s machine, producing a different scramble. There is no fixing this by sorting in one place, either — any code that forgets to sort undoes it. The struct does not have the problem because its order is not data-dependent at all.

Nothing here argues against serde. For a real configuration format, the toml crate plus #[derive(Serialize)] on the struct is the right tool: it handles nesting ([server] tables), optional fields, and the escaping above, all of which grow tedious by hand. The property this page built manually — fields written out in declaration order — is the same property a derived Serialize implementation has, because the derive expands to code that visits the struct’s fields in the order they are declared. Replace the hand-rolled to_toml with toml::to_string and the diffs stay legible.

What serde does not fix is a config that lives in a map. The struct is the source of the ordering, so the struct has to stay: HashMap<String, serde_json::Value> serialises in whatever order its iterator yields, and the diff problem comes straight back.

The struct worth writing out is the merged Settings from the previous article — give it a save method and the file/environment/flags layers finally have somewhere to persist. One warning before relying on that file, though: once tokens and passwords start living in that struct, the file on disk is not the only place they surface. The next article is about the debug print that leaks them into the log.