Pass on_unknown callback into load_string/load_file
load_string and load_file now take an on_unknown callback instead of
hard-wiring warning behaviour. load_config/load_config_str supply the
tracing::warn! closure; other callers pass |_| {}.

Tests are updated to capture unknown paths and assert on them rather
than just checking the result is ok.

https://claude.ai/code/session_015tdp3ahAmmdE69yCsV7J8b
change
commit 2b8fe41b903337c9669ac2fefe016e0a1821d545
author Claude <noreply@anthropic.com>
date
parent 6eccf183
diff --git a/quire-core/src/fennel.rs b/quire-core/src/fennel.rs
index 1b5a4f1..25e94d7 100644
--- a/quire-core/src/fennel.rs
+++ b/quire-core/src/fennel.rs
@@ -155,9 +155,14 @@ 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>
+    /// `on_unknown` is called for every field key present in the Fennel value
+    /// but not consumed by `T` at any nesting depth. Pass `|_| {}` to ignore.
+    pub fn load_string<T>(
+        &self,
+        source: &str,
+        name: &str,
+        mut on_unknown: impl FnMut(&serde_ignored::Path<'_>),
+    ) -> Result<T, FennelError>
     where
         T: serde::de::DeserializeOwned,
     {
@@ -165,45 +170,57 @@ impl Fennel {
 
         let de = mlua::serde::Deserializer::new(result);
 
-        serde_ignored::deserialize(de, |path| {
-            tracing::warn!(config = %name, field = %path, "unknown config field ignored");
-        })
-        .map_err(|e| FennelError::TypeMismatch {
-            message: format!("{name}: {e}"),
-            source: Box::new(e),
+        serde_ignored::deserialize(de, |path| on_unknown(&path)).map_err(|e| {
+            FennelError::TypeMismatch {
+                message: format!("{name}: {e}"),
+                source: Box::new(e),
+            }
         })
     }
 
     /// Load and evaluate a Fennel file from disk, deserializing the result
     /// into `T`.
-    pub fn load_file<T>(&self, path: &Path) -> Result<T, FennelError>
+    ///
+    /// `on_unknown` is forwarded to [`load_string`] — see its docs.
+    pub fn load_file<T>(
+        &self,
+        path: &Path,
+        on_unknown: impl FnMut(&serde_ignored::Path<'_>),
+    ) -> Result<T, FennelError>
     where
         T: serde::de::DeserializeOwned,
     {
         let source = fs_err::read_to_string(path)?;
-        self.load_string(&source, &path.display().to_string())
+        self.load_string(&source, &path.display().to_string(), on_unknown)
     }
 
     /// Create a fresh Fennel VM and load `path` into `T`.
     ///
     /// Convenience wrapper for callers that only need a one-shot config load
-    /// and don't need to reuse the VM.
+    /// and don't need to reuse the VM. Warns via `tracing::warn!` for any
+    /// unknown fields.
     pub fn load_config<T>(path: &Path) -> Result<T, FennelError>
     where
         T: serde::de::DeserializeOwned,
     {
-        Self::new()?.load_file(path)
+        let name = path.display().to_string();
+        Self::new()?.load_file(path, |path| {
+            tracing::warn!(config = %name, field = %path, "unknown config field ignored");
+        })
     }
 
     /// Create a fresh Fennel VM and parse `source` into `T`.
     ///
     /// Like [`load_config`] but reads from a string rather than a file.
     /// `name` is used in error messages (e.g. `".quire/config.fnl"`).
+    /// Warns via `tracing::warn!` for any unknown fields.
     pub fn load_config_str<T>(source: &str, name: &str) -> Result<T, FennelError>
     where
         T: serde::de::DeserializeOwned,
     {
-        Self::new()?.load_string(source, name)
+        Self::new()?.load_string(source, name, |path| {
+            tracing::warn!(config = %name, field = %path, "unknown config field ignored");
+        })
     }
 }
 
@@ -331,6 +348,7 @@ mod tests {
             .load_string(
                 r#"{:mirror {:url "https://github.com/owner/repo.git"}}"#,
                 "test",
+                |_| {},
             )
             .expect("load_string should succeed");
 
@@ -353,7 +371,7 @@ mod tests {
                  :on [:ci-failed :mirror-failed]}}
 "#;
         let config: FullConfig = f
-            .load_string(source, "config.fnl")
+            .load_string(source, "config.fnl", |_| {})
             .expect("load_string should succeed");
 
         assert_eq!(
@@ -374,7 +392,7 @@ mod tests {
     fn load_string_rejects_malformed_fennel() {
         let f = fennel();
         let source = "{:bad {:}";
-        let result: Result<MirrorConfig, _> = f.load_string(source, "bad.fnl");
+        let result: Result<MirrorConfig, _> = f.load_string(source, "bad.fnl", |_| {});
         let err = result.unwrap_err();
         let FennelError::Eval {
             message,
@@ -403,7 +421,8 @@ mod tests {
     #[test]
     fn load_string_rejects_type_mismatch() {
         let f = fennel();
-        let result: Result<MirrorConfig, _> = f.load_string("{:mirror {:url 42}}", "types.fnl");
+        let result: Result<MirrorConfig, _> =
+            f.load_string("{:mirror {:url 42}}", "types.fnl", |_| {});
         let err = result.unwrap_err();
         let FennelError::TypeMismatch { message, .. } = &err else {
             panic!("expected TypeMismatch, got {err:?}");
@@ -419,25 +438,29 @@ mod tests {
     }
 
     #[test]
-    fn load_string_warns_on_unknown_top_level_field() {
-        // tracing::warn! fires but we verify it doesn't cause an error.
+    fn load_string_callback_fires_for_unknown_top_level_field() {
         let f = fennel();
+        let mut unknown = Vec::new();
         let result: Result<MirrorConfig, _> = f.load_string(
             r#"{:mirror {:url "https://github.com/owner/repo.git"} :oops 1}"#,
             "warn.fnl",
+            |path| unknown.push(path.to_string()),
         );
-        // Unknown field must not be an error.
         assert!(result.is_ok(), "unexpected error: {:?}", result.err());
+        assert_eq!(unknown, ["oops"]);
     }
 
     #[test]
-    fn load_string_warns_on_unknown_nested_field() {
+    fn load_string_callback_fires_for_unknown_nested_field() {
         let f = fennel();
+        let mut unknown = Vec::new();
         let result: Result<MirrorConfig, _> = f.load_string(
             r#"{:mirror {:url "https://github.com/owner/repo.git" :extra "hi"}}"#,
             "warn-nested.fnl",
+            |path| unknown.push(path.to_string()),
         );
         assert!(result.is_ok(), "unexpected error: {:?}", result.err());
+        assert_eq!(unknown, ["mirror.extra"]);
     }
 
     #[test]
@@ -451,7 +474,9 @@ mod tests {
         .expect("write");
 
         let f = fennel();
-        let config: MirrorConfig = f.load_file(&path).expect("load_file should succeed");
+        let config: MirrorConfig = f
+            .load_file(&path, |_| {})
+            .expect("load_file should succeed");
         assert_eq!(
             config,
             MirrorConfig {
@@ -465,7 +490,7 @@ mod tests {
     #[test]
     fn load_file_rejects_missing_file() {
         let f = fennel();
-        let result: Result<MirrorConfig, _> = f.load_file(Path::new("/no/such/file.fnl"));
+        let result: Result<MirrorConfig, _> = f.load_file(Path::new("/no/such/file.fnl"), |_| {});
         let err = result.unwrap_err();
         assert!(
             matches!(&err, FennelError::Io(e) if e.kind() == std::io::ErrorKind::NotFound),
@@ -481,7 +506,8 @@ mod tests {
     fn error_label_works_with_colon_in_name() {
         let f = fennel();
         let source = "\n{:bad {:}";
-        let result: Result<MirrorConfig, _> = f.load_string(source, "HEAD:.quire/config.fnl");
+        let result: Result<MirrorConfig, _> =
+            f.load_string(source, "HEAD:.quire/config.fnl", |_| {});
         let err = result.unwrap_err();
         let FennelError::Eval { label, .. } = &err else {
             unreachable!()
diff --git a/quire-core/src/secret.rs b/quire-core/src/secret.rs
index 827c807..ee604de 100644
--- a/quire-core/src/secret.rs
+++ b/quire-core/src/secret.rs
@@ -496,7 +496,7 @@ mod tests {
 
         let fennel = Fennel::new().expect("fennel");
         let config: Config = fennel
-            .load_string(r#"{:token "hunter2"}"#, "test.fnl")
+            .load_string(r#"{:token "hunter2"}"#, "test.fnl", |_| {})
             .expect("deserialize from fennel");
         assert_eq!(config.token.reveal().unwrap(), "hunter2");
     }
@@ -516,7 +516,7 @@ mod tests {
         // Fennel table syntax: {:token {:file "/path"}}
         let source = format!("{{:token {{:file \"{}\"}}}}", path.display(),);
         let config: Config = fennel
-            .load_string(&source, "test.fnl")
+            .load_string(&source, "test.fnl", |_| {})
             .expect("deserialize file ref from fennel");
         assert_eq!(config.token.reveal().unwrap(), "secret_from_file");
     }
diff --git a/quire-server/src/error.rs b/quire-server/src/error.rs
index 944f6c4..c48adb5 100644
--- a/quire-server/src/error.rs
+++ b/quire-server/src/error.rs
@@ -77,7 +77,8 @@ mod tests {
         // walk the `#[source]` chain — so plain `%err` is enough for
         // tracing/Sentry. The chain is still preserved structurally.
         let f = quire_core::fennel::Fennel::new().expect("Fennel::new");
-        let result: std::result::Result<i32, _> = f.load_string("(this is not valid", "bad.fnl");
+        let result: std::result::Result<i32, _> =
+            f.load_string("(this is not valid", "bad.fnl", |_| {});
         let fennel_err = result.unwrap_err();
 
         let plain = fennel_err.to_string();