config: read settings from a KDL file
Assisted-by: GLM-5.3 via pi
diff --git a/Cargo.lock b/Cargo.lock
index f8921d6..fbd105a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -217,6 +217,12 @@ version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+[[package]]
+name = "camino"
+version = "1.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591"
+
[[package]]
name = "cc"
version = "1.5.1"
@@ -405,10 +411,12 @@ name = "docket"
version = "0.1.0"
dependencies = [
"axum",
+ "camino",
"clap",
"fs-err",
"hegeltest",
"jiff",
+ "kdl",
"maud",
"miette",
"reqwest",
@@ -1039,6 +1047,18 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "kdl"
+version = "6.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "082ddf81b2acd76fe04412655d17befedfff1837db772f8e74c38050d25ed670"
+dependencies = [
+ "miette",
+ "num-traits",
+ "serde",
+ "winnow",
+]
+
[[package]]
name = "lazy_static"
version = "1.5.1"
@@ -1200,6 +1220,15 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
[[package]]
name = "object"
version = "0.37.3"
@@ -2462,6 +2491,15 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "writeable"
version = "0.6.4"
diff --git a/Cargo.toml b/Cargo.toml
index 32d110b..f217199 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,9 +9,11 @@ path = "src/bin/docket/main.rs"
[dependencies]
axum = "*"
+camino = "*"
clap = { version = "*", features = ["derive", "env"] }
fs-err = "*"
jiff = "*"
+kdl = "*"
maud = { version = "*", features = ["axum"] }
miette = { version = "*", features = ["fancy"] }
serde = { version = "*", features = ["derive"] }
diff --git a/Dockerfile b/Dockerfile
index 69ec0ad..cf9c05d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -36,6 +36,7 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/docket /usr/local/bin/docket
+COPY fly.kdl /etc/docket.kdl
EXPOSE 3000
ENTRYPOINT ["docket"]
diff --git a/docket.kdl b/docket.kdl
new file mode 100644
index 0000000..2c939c3
--- /dev/null
+++ b/docket.kdl
@@ -0,0 +1,5 @@
+// Docket's settings, read from this file (see `--help` for pointing
+// DOCKET_CONFIG elsewhere). This copy is the local-dev one: `dev` serves
+// fixture data without Tailscale, picking a user from the sidebar.
+bind "127.0.0.1:3000"
+dev
diff --git a/fly.kdl b/fly.kdl
new file mode 100644
index 0000000..23970e9
--- /dev/null
+++ b/fly.kdl
@@ -0,0 +1,8 @@
+// Config for the two Fly demo apps, baked into the image at
+// /etc/docket.kdl (fly.toml points DOCKET_CONFIG at it). Binds wide
+// because Fly's edge routes to the machine, not to localhost. `dev` is
+// the whole of what makes them demos: fixture data instead of JMAP, and
+// a stand-in identity in place of the Tailscale-User-Login header that
+// tailscale serve supplies in front of the real deployment.
+bind "0.0.0.0:3000"
+dev
diff --git a/fly.toml b/fly.toml
index b9fdb8d..7c67ca8 100644
--- a/fly.toml
+++ b/fly.toml
@@ -1,11 +1,11 @@
# The one Fly config, for the two demo apps: the app main deploys (the
# deploy job in .github/workflows/ci.yml) and the per-PR previews (the
# review-app action in .github/workflows/preview.yml, which overrides
-# the app name with the PR's). Both run the binary with --dev, which is
-# the whole of what makes them demos: fixture data instead of JMAP, and
-# a stand-in identity in place of the Tailscale-User-Login header that
-# tailscale serve supplies in front of the real deployment. No volume,
-# so every deploy starts from the fixtures again.
+# the app name with the PR's). Both read the config baked into the image
+# from fly.kdl: fixture data instead of JMAP, and a stand-in identity in
+# place of the Tailscale-User-Login header that tailscale serve supplies
+# in front of the real deployment. No volume, so every deploy starts
+# from the fixtures again.
#
# The app name is Fly's, not GitHub's, so it has to be unique across all
# of Fly and a taken one fails the deploy. ci.yml spells it twice more,
@@ -17,11 +17,7 @@ primary_region = 'sjc'
[build]
[env]
-# Fly's edge routes to the machine, not to localhost.
-DOCKET_BIND = '0.0.0.0:3000'
-
-[processes]
-app = '--dev'
+DOCKET_CONFIG = '/etc/docket.kdl'
[http_service]
internal_port = 3000
@@ -29,7 +25,6 @@ app = '--dev'
auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 0
- processes = ['app']
[[vm]]
memory = '256mb'
diff --git a/justfile b/justfile
index cc4f95c..0085625 100644
--- a/justfile
+++ b/justfile
@@ -28,7 +28,7 @@ all: fmt clippy coverage
# Serve the fixture-backed UI without Tailscale on http://127.0.0.1:3000,
# restarting on changes.
dev:
- { fd -t f . src assets; echo Cargo.toml; } | entr -r cargo run -- --dev
+ { fd -t f . src assets; echo Cargo.toml docket.kdl; } | entr -r cargo run
install:
cargo install --locked --path .
diff --git a/src/bin/docket/config.rs b/src/bin/docket/config.rs
index a3027ec..a37d493 100644
--- a/src/bin/docket/config.rs
+++ b/src/bin/docket/config.rs
@@ -1,15 +1,288 @@
+use std::net::IpAddr;
+use std::net::Ipv4Addr;
use std::net::SocketAddr;
-#[derive(Debug, clap::Parser)]
-#[command(version)]
+use camino::Utf8Path;
+use camino::Utf8PathBuf;
+use kdl::KdlDocument;
+use kdl::KdlError;
+use kdl::KdlNode;
+use kdl::KdlValue;
+use miette::Diagnostic;
+use miette::IntoDiagnostic as _;
+use miette::SourceSpan;
+use thiserror::Error;
+
+const DEFAULT_BIND: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3000);
+
+#[derive(Debug)]
pub struct Config {
/// Address to listen on. Keep it on localhost; `tailscale serve`
/// fronts it and supplies the user identity header.
- #[arg(long, env = "DOCKET_BIND", default_value = "127.0.0.1:3000")]
pub bind: SocketAddr,
/// Serve without Tailscale: requests with no identity header act as a
/// user picked from the sidebar. For local work against fixtures only.
- #[arg(long, env = "DOCKET_DEV")]
pub dev: bool,
}
+
+impl Config {
+ pub fn load(path: &Utf8Path) -> miette::Result<Self> {
+ let source = fs_err::read_to_string(path).into_diagnostic()?;
+ match parse(&source) {
+ Ok(config) => Ok(config),
+ // KDL syntax errors carry their own source and span labels;
+ // ours need the file attached to render theirs.
+ Err(Error::Kdl(err)) => Err(miette::Report::new(err)),
+ Err(err) => Err(miette::Report::new(err).with_source_code(source)),
+ }
+ }
+}
+
+/// Settings come from the KDL file; the CLI only says where to find it.
+#[derive(Debug, clap::Parser)]
+#[command(version, about = "Shared household email triage over JMAP")]
+pub struct Args {
+ /// KDL file to read settings from.
+ #[arg(long, env = "DOCKET_CONFIG", default_value = "docket.kdl")]
+ pub config: Utf8PathBuf,
+}
+
+fn parse(source: &str) -> Result<Config, Error> {
+ let document = KdlDocument::parse(source)?;
+ let mut bind = None;
+ let mut dev = None;
+
+ for node in document.nodes() {
+ match node.name().value() {
+ "bind" => {
+ if bind.is_some() {
+ return Err(Error::Duplicate {
+ name: "bind".to_owned(),
+ span: node.name().span(),
+ });
+ }
+ bind = Some(parse_bind(node)?);
+ }
+ "dev" => {
+ if dev.is_some() {
+ return Err(Error::Duplicate {
+ name: "dev".to_owned(),
+ span: node.name().span(),
+ });
+ }
+ dev = Some(parse_dev(node)?);
+ }
+ name => {
+ return Err(Error::Unknown {
+ name: name.to_owned(),
+ span: node.name().span(),
+ });
+ }
+ }
+ }
+
+ Ok(Config {
+ bind: bind.unwrap_or(DEFAULT_BIND),
+ dev: dev.unwrap_or(false),
+ })
+}
+
+fn parse_bind(node: &KdlNode) -> Result<SocketAddr, Error> {
+ let raw = single_value(node, "bind")?
+ .and_then(KdlValue::as_string)
+ .ok_or(Error::BindValue { span: node.span() })?;
+ raw.parse().map_err(|_| Error::BindParse {
+ value: raw.to_owned(),
+ span: node.span(),
+ })
+}
+
+fn parse_dev(node: &KdlNode) -> Result<bool, Error> {
+ match single_value(node, "dev")? {
+ None => Ok(true),
+ Some(value) => value.as_bool().ok_or(Error::DevValue { span: node.span() }),
+ }
+}
+
+/// Returns the node's single positional value, or `None` when the node
+/// is bare. Everything else a KDL node can carry — properties, type
+/// annotations, child blocks, extra values — is rejected rather than
+/// silently ignored.
+fn single_value<'a>(node: &'a KdlNode, setting: &str) -> Result<Option<&'a KdlValue>, Error> {
+ let shaped = node.ty().is_some()
+ || node.children().is_some()
+ || node.entries().iter().any(|entry| entry.name().is_some())
+ || node.entries().len() > 1;
+ if shaped {
+ return Err(Error::Shape {
+ setting: setting.to_owned(),
+ span: node.span(),
+ });
+ }
+
+ Ok(node.entries().first().map(|entry| entry.value()))
+}
+
+#[derive(Debug, Diagnostic, Error)]
+enum Error {
+ #[error(transparent)]
+ #[diagnostic(code(docket::config::kdl_syntax))]
+ Kdl(#[from] KdlError),
+
+ #[error("unknown setting `{name}`")]
+ #[diagnostic(code(docket::config::unknown), help("known settings: `bind`, `dev`"))]
+ Unknown {
+ name: String,
+ #[label]
+ span: SourceSpan,
+ },
+
+ #[error("duplicate setting `{name}`")]
+ #[diagnostic(code(docket::config::duplicate))]
+ Duplicate {
+ name: String,
+ #[label]
+ span: SourceSpan,
+ },
+
+ #[error(
+ "`{setting}` takes one value at most, with no properties, type annotations, or child blocks"
+ )]
+ #[diagnostic(code(docket::config::shape))]
+ Shape {
+ setting: String,
+ #[label]
+ span: SourceSpan,
+ },
+
+ #[error(r#"bind takes a quoted address, like bind "127.0.0.1:3000""#)]
+ #[diagnostic(code(docket::config::bind_value))]
+ BindValue {
+ #[label]
+ span: SourceSpan,
+ },
+
+ #[error("`{value}` is not a valid address")]
+ #[diagnostic(code(docket::config::bind_parse))]
+ BindParse {
+ value: String,
+ #[label]
+ span: SourceSpan,
+ },
+
+ #[error("dev takes #true or #false, or nothing at all (which means true)")]
+ #[diagnostic(code(docket::config::dev_value))]
+ DevValue {
+ #[label]
+ span: SourceSpan,
+ },
+}
+
+#[cfg(test)]
+mod tests {
+ use super::Error;
+ use super::parse;
+
+ #[test]
+ fn empty_document_uses_defaults() {
+ let config = parse("").unwrap();
+ assert_eq!(config.bind.to_string(), "127.0.0.1:3000");
+ assert!(!config.dev);
+ }
+
+ #[test]
+ fn comments_only_uses_defaults() {
+ let config = parse("// nothing configured\n").unwrap();
+ assert_eq!(config.bind.to_string(), "127.0.0.1:3000");
+ assert!(!config.dev);
+ }
+
+ #[test]
+ fn bind_parses_ipv4_ipv6_and_wildcard() {
+ for (source, addr) in [
+ (r#"bind "0.0.0.0:8080""#, "0.0.0.0:8080"),
+ (r#"bind "[::1]:3000""#, "[::1]:3000"),
+ (r#"bind "192.168.1.10:0""#, "192.168.1.10:0"),
+ ] {
+ assert_eq!(parse(source).unwrap().bind.to_string(), addr);
+ }
+ }
+
+ #[test]
+ fn bind_with_port_only_is_rejected() {
+ assert!(matches!(
+ parse(r#"bind "3000""#),
+ Err(Error::BindParse { .. })
+ ));
+ }
+
+ #[test]
+ fn bind_without_a_string_is_rejected() {
+ assert!(matches!(parse("bind 3000"), Err(Error::BindValue { .. })));
+ assert!(matches!(parse("bind"), Err(Error::BindValue { .. })));
+ }
+
+ #[test]
+ fn bare_dev_means_true() {
+ let config = parse("dev").unwrap();
+ assert!(config.dev);
+ }
+
+ #[test]
+ fn dev_accepts_explicit_booleans() {
+ assert!(parse("dev #true").unwrap().dev);
+ assert!(!parse("dev #false").unwrap().dev);
+ }
+
+ #[test]
+ fn dev_rejects_non_boolean_values() {
+ assert!(matches!(parse(r#"dev "yes""#), Err(Error::DevValue { .. })));
+ assert!(matches!(parse("dev 1"), Err(Error::DevValue { .. })));
+ assert!(matches!(parse("dev #null"), Err(Error::DevValue { .. })));
+ // Bare `true` never reaches us: KDL v2 rejects it at parse time.
+ assert!(matches!(parse("dev true"), Err(Error::Kdl(_))));
+ }
+
+ #[test]
+ fn settings_combine() {
+ let config = parse("bind \"0.0.0.0:3000\"\ndev\n").unwrap();
+ assert_eq!(config.bind.to_string(), "0.0.0.0:3000");
+ assert!(config.dev);
+ }
+
+ #[test]
+ fn unknown_setting_is_rejected() {
+ assert!(matches!(parse("bnd \"x\""), Err(Error::Unknown { .. })));
+ }
+
+ #[test]
+ fn duplicate_setting_is_rejected() {
+ assert!(matches!(
+ parse("dev\ndev #false"),
+ Err(Error::Duplicate { .. })
+ ));
+ let source = "bind \"127.0.0.1:1\"\nbind \"127.0.0.1:2\"";
+ assert!(matches!(parse(source), Err(Error::Duplicate { .. })));
+ }
+
+ #[test]
+ fn extra_syntax_is_rejected() {
+ assert!(matches!(
+ parse("dev #true #false"),
+ Err(Error::Shape { .. })
+ ));
+ assert!(matches!(parse("dev on=#false"), Err(Error::Shape { .. })));
+ assert!(matches!(parse("dev { inner }"), Err(Error::Shape { .. })));
+ assert!(matches!(
+ parse(r#"bind address="127.0.0.1:3000""#),
+ Err(Error::Shape { .. })
+ ));
+ }
+
+ #[test]
+ fn malformed_kdl_is_rejected() {
+ assert!(matches!(parse(r#"bind "unterminated"#), Err(Error::Kdl(_))));
+ assert!(matches!(parse("="), Err(Error::Kdl(_))));
+ }
+}
diff --git a/src/bin/docket/main.rs b/src/bin/docket/main.rs
index a8da089..47915bd 100644
--- a/src/bin/docket/main.rs
+++ b/src/bin/docket/main.rs
@@ -16,7 +16,8 @@ async fn main() -> miette::Result<()> {
.with(EnvFilter::from_default_env())
.init();
- let config = config::Config::parse();
+ let args = config::Args::parse();
+ let config = config::Config::load(&args.config)?;
// Fixtures stand in for the database and JMAP sync until they exist.
let state = docket::routes::AppState::new(docket::fixtures::store(), config.dev);
let app = docket::routes::router(state);