config: read settings from a KDL file
Assisted-by: GLM-5.3 via pi
change xqlylsynzxlwzplvsolzmwywvlpysptt
commit 4ffc15584a52f687f606b8c8bea40ae194117d3a
author Alpha Chen <alpha@kejadlen.dev>
date
parent pooyovqs
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);