Assay
An application config file
A config error at start-up should read to you like a compiler error, because that is exactly what it is: a static mistake in a file, found before anything of yours runs.
@Schema(keys: .snakeCase, unknownKeys: .warn, formats: .all)struct AppConfig { @Validate(.min(1)) var serviceName: String var port: Int = 8080 var logLevel: String = "info" @Validate(.range(1...64)) var workers: Int = 4 @Validate(.url) var upstream: String?}
func loadConfig(_ text: String, named name: String) -> (AppConfig?, String) { let d = AppConfig.diagnose(toml: text, sourceName: name) // `.plain` renders warnings alongside errors already. Looping over `d.warnings` and // appending them is the obvious next line and it prints every warning twice. return (d.isValid ? d.value : nil, d.render(.plain))}Three decisions in that declaration, and they are the whole recipe.
It is also quick enough that you can do it on every boot without thinking about it: that
struct comes out of TOML at 6.92× TOMLKit’s Codable decoder, and the tree underneath it
at 3.69× toml++, which is C.
Defaults live on the property. port is 8080 when the file does not mention it. You
keep no separate defaults table in step, and you unwrap no optional at every use site. See presence for the five states and when each applies.
unknownKeys: .warn rather than the default .ignore. For an API response, ignoring
unknown keys is right — the server adds fields without asking you. For your own config file it is
the opposite: an unknown key is almost always a typo, and silence means somebody’s setting
quietly does nothing for a week.
formats: .all, so the same struct reads YAML and JSON too. Your users have opinions
about config formats. Accommodating all of them costs you one attribute.
A file that is fine
Section titled “A file that is fine”service_name = "checkout"port = 9000upstream = "https://inventory.internal"AppConfig(serviceName: "checkout", port: 9000, logLevel: "info", workers: 4, upstream: Optional("https://inventory.internal"))port came from the file, log_level and workers from the declaration.
A file that is not
Section titled “A file that is not”service_name = ""prot = 9000workers = 900upstream = "inventory"app.toml:1:16: error: service_name must be at least 1 character 1 │ service_name = "" │ ^^ 2 │ prot = 9000
app.toml:3:11: error: workers must be between 1 and 64 1 │ service_name = "" 2 │ prot = 9000 3 │ workers = 900 │ ^^^ 4 │ upstream = "inventory"
app.toml:4:12: error: upstream must be a valid URL 2 │ prot = 9000 3 │ workers = 900 4 │ upstream = "inventory" │ ^^^^^^^^^^^
app.toml:2:8: warning: unknown key "prot"; did you mean "port"? 1 │ service_name = "" 2 │ prot = 9000 │ ^^^^ 3 │ workers = 900
3 errors, 1 warningEvery problem at once, each one pointing at the line and the bytes, so you fix the file in one pass rather than one error per run.
The last one is the reason for .warn. prot is not a key this schema knows, and rather
than being dropped it is named, with a suggestion worked out by edit distance. That is
did-you-mean, and for whoever wrote the file it is
the difference between a five-second fix and an afternoon.
Printing it
Section titled “Printing it”.plain is the render above: no colour, suitable for a log or a file. .terminal is the
same thing with colour, and it turns colour off by itself when output is not a terminal, so
you do not need to check.
let (config, report) = loadConfig(try String(contentsOf: url, encoding: .utf8), named: url.lastPathComponent)guard let config else { FileHandle.standardError.write(Data(report.utf8)) exit(78) // EX_CONFIG}Print the report for a valid file as well. It is empty when there is nothing to say, and it carries your warnings when there is.
Environment overrides
Section titled “Environment overrides”Assay does not read the environment, and should not: the merge order between file, flags and environment is your policy, not a decoder’s. Decode the file, then overlay:
var config = try AppConfig.parse(toml: text)if let p = ProcessInfo.processInfo.environment["PORT"], let n = Int(p) { config.port = n }If you want the overlay validated too, try AppConfig.validate(config) runs the same rules
against the finished value without decoding anything. It is a different
function from the parse verbs, and
the law it holds is that a value that came out of parse never fails it.