Warn on unknown config fields at any nesting depth
Uses serde_ignored to detect and log (tracing::warn!) unknown fields
during config.fnl deserialization without rejecting the config.

https://claude.ai/code/session_015tdp3ahAmmdE69yCsV7J8b
change
commit fb4569eb32f6d53a655f398d3f653d2771083d76
author Claude <noreply@anthropic.com>
date
parent a494e118
diff --git a/Cargo.lock b/Cargo.lock
index b5e10ca..3871690 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2586,6 +2586,7 @@ dependencies = [
  "sentry-opentelemetry",
  "sentry-tracing",
  "serde",
+ "serde_ignored",
  "serde_json",
  "tempfile",
  "thiserror",
@@ -3168,6 +3169,16 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "serde_ignored"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
 [[package]]
 name = "serde_json"
 version = "1.0.149"
diff --git a/Cargo.toml b/Cargo.toml
index 65d34ce..815c387 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -22,6 +22,7 @@ opentelemetry_sdk = "*"
 sentry-opentelemetry = "*"
 tracing-opentelemetry = "*"
 serde = { version = "*", features = ["derive"] }
+serde_ignored = "*"
 serde_json = "*"
 tempfile = "*"
 thiserror = "*"
diff --git a/quire-core/Cargo.toml b/quire-core/Cargo.toml
index c15a087..f7fd9d9 100644
--- a/quire-core/Cargo.toml
+++ b/quire-core/Cargo.toml
@@ -18,6 +18,7 @@ sentry-opentelemetry = { workspace = true }
 sentry-tracing = { workspace = true }
 tracing-opentelemetry = { workspace = true }
 serde = { workspace = true }
+serde_ignored = { workspace = true }
 serde_json = { workspace = true }
 thiserror = { workspace = true }
 tracing = { workspace = true }
diff --git a/quire-core/src/fennel.rs b/quire-core/src/fennel.rs
index 564ff99..ccce305 100644
--- a/quire-core/src/fennel.rs
+++ b/quire-core/src/fennel.rs
@@ -154,18 +154,31 @@ impl Fennel {
     ///
     /// `name` is used in error messages — typically a filename or a synthetic
     /// label like `HEAD:.quire/config.fnl`.
+    ///
+    /// Unknown fields at any nesting depth are logged as warnings via
+    /// `tracing::warn!` but do not cause a failure.
     pub fn load_string<T>(&self, source: &str, name: &str) -> Result<T, FennelError>
     where
         T: serde::de::DeserializeOwned,
     {
         let result = self.eval_raw(source, name, |_| Ok(()))?;
 
-        self.lua.from_value(result).map_err(|e| {
+        // Convert to a generic JSON value so serde_ignored can walk the tree
+        // and detect fields that no known struct key consumes.
+        let json_val: serde_json::Value = self.lua.from_value(result).map_err(|e| {
             let message = format!("{name}: {e}");
             FennelError::TypeMismatch {
                 message,
                 source: Box::new(e),
             }
+        })?;
+
+        serde_ignored::deserialize(&json_val, |path| {
+            tracing::warn!(config = %name, field = %path, "unknown config field ignored");
+        })
+        .map_err(|e| FennelError::TypeMismatch {
+            message: format!("{name}: {e}"),
+            source: Box::new(mlua::Error::external(e)),
         })
     }
 
@@ -413,6 +426,28 @@ mod tests {
         );
     }
 
+    #[test]
+    fn load_string_warns_on_unknown_top_level_field() {
+        // tracing::warn! fires but we verify it doesn't cause an error.
+        let f = fennel();
+        let result: Result<MirrorConfig, _> = f.load_string(
+            r#"{:mirror {:url "https://github.com/owner/repo.git"} :oops 1}"#,
+            "warn.fnl",
+        );
+        // Unknown field must not be an error.
+        assert!(result.is_ok(), "unexpected error: {:?}", result.err());
+    }
+
+    #[test]
+    fn load_string_warns_on_unknown_nested_field() {
+        let f = fennel();
+        let result: Result<MirrorConfig, _> = f.load_string(
+            r#"{:mirror {:url "https://github.com/owner/repo.git" :extra "hi"}}"#,
+            "warn-nested.fnl",
+        );
+        assert!(result.is_ok(), "unexpected error: {:?}", result.err());
+    }
+
     #[test]
     fn load_file_reads_from_disk() {
         let dir = tempfile::tempdir().expect("tempdir");