Tighten fennel tests and trim policy from the loader
Drop `FennelError::Empty` along with the blank-source and nil-result
early returns in `load_string`. Whether an empty config is acceptable
is the caller's call — a `T` like `()` or `Option<X>` will succeed
now, anything stricter still gets a `TypeMismatch`. Document the
display caveat on `Eval` and `TypeMismatch` (top message is just the
source name; the underlying error is in the `#[source]` chain).

Tests: pin variants in the Eval/TypeMismatch rejection tests, add
direct unit tests for `extract_line_offset` (now `&str`-typed for
testability), and cover `eval_raw`'s `setup` callback. Drop the
redundant `line == 0` early return in `line_offset` (the loop
already returns `None` for that case) and its test.

Assisted-by: Claude Opus 4.7 (1M context) via Claude Code
change xwspzmxulknmuomslzwqxxplxpwzktzv
commit e90a6d50e134186c4499ebf6f6beab511d451dfd
author Alpha Chen <alpha@kejadlen.dev>
date
parent nytqsyou
diff --git a/quire-core/src/fennel.rs b/quire-core/src/fennel.rs
index 359ef9d..74048fc 100644
--- a/quire-core/src/fennel.rs
+++ b/quire-core/src/fennel.rs
@@ -2,11 +2,12 @@ use std::path::Path;
 
 use miette::{Diagnostic, SourceOffset};
 use mlua::{Lua, LuaSerdeExt};
+use thiserror::Error;
 
 const FENNEL_LUA: &str = include_str!("../vendor/fennel.lua");
 
 /// Error kinds from the Fennel loader.
-#[derive(Debug, thiserror::Error, Diagnostic)]
+#[derive(Debug, Error, Diagnostic)]
 pub enum FennelError {
     #[error(transparent)]
     #[diagnostic(code(fennel::io))]
@@ -16,6 +17,11 @@ pub enum FennelError {
     #[diagnostic(code(fennel::internal))]
     Internal(#[from] mlua::Error),
 
+    /// Fennel/Lua evaluation failed. `message` is just the source
+    /// name so miette renders `× <name>`; the actual Lua error text
+    /// is reachable via the `#[source]` chain. Plain `Display` will
+    /// only show the name — walk the chain (e.g. via
+    /// `display_chain`) to surface the underlying error.
     #[error("{message}")]
     #[diagnostic(code(fennel::eval))]
     Eval {
@@ -28,10 +34,9 @@ pub enum FennelError {
         source: Box<mlua::Error>,
     },
 
-    #[error("empty config: {name}")]
-    #[diagnostic(code(fennel::empty))]
-    Empty { name: String },
-
+    /// Result couldn't be deserialized into the requested type.
+    /// Same display caveat as `Eval`: `message` is the source name,
+    /// the deser error is in the `#[source]` chain.
     #[error("{message}")]
     #[diagnostic(code(fennel::type_mismatch))]
     TypeMismatch {
@@ -93,12 +98,11 @@ impl Fennel {
         setup(&self.lua)?;
 
         let fennel: mlua::Table = self.lua.globals().get("fennel")?;
-
         let eval: mlua::Function = fennel.get("eval")?;
-
         let opts = self.lua.create_table()?;
 
         opts.set("filename", name)?;
+
         // Align Lua line numbers with Fennel source lines so debug
         // info points back at the user's `.fnl`.
         opts.set("correlate", true)?;
@@ -119,22 +123,8 @@ impl Fennel {
     where
         T: serde::de::DeserializeOwned,
     {
-        if source.trim().is_empty() {
-            return Err(FennelError::Empty {
-                name: name.to_string(),
-            });
-        }
-
         let result = self.eval_raw(source, name, |_| Ok(()))?;
 
-        // Reject nil results — a config file that evaluates to nothing is
-        // almost always a mistake.
-        if matches!(result, mlua::Value::Nil) {
-            return Err(FennelError::Empty {
-                name: name.to_string(),
-            });
-        }
-
         self.lua
             .from_value(result)
             .map_err(|e| FennelError::TypeMismatch {
@@ -166,7 +156,8 @@ impl FennelError {
         // Try to extract a line number from the Lua error for a label.
         // None when the error message doesn't carry a line — miette renders
         // the source block without an inline pointer in that case.
-        let label = extract_line_offset(&err).and_then(|line| line_offset(source, line));
+        let label =
+            extract_line_offset(&err.to_string()).and_then(|line| line_offset(source, line));
 
         FennelError::Eval {
             message,
@@ -183,11 +174,10 @@ impl FennelError {
 /// The name may contain colons (e.g. `HEAD:.quire/config.fnl`), so splitting
 /// from the left breaks. Match the first `:LINE:COLUMN: ` run, which is
 /// unambiguous — filenames don't end with `:digits:digits:`.
-fn extract_line_offset(err: &mlua::Error) -> Option<usize> {
-    let msg = err.to_string();
+fn extract_line_offset(msg: &str) -> Option<usize> {
     // Match `:LINE:COLUMN: ` (parse error) or `:LINE: ` (runtime error).
     let re = regex::Regex::new(r":(\d+)(?::\d+)?: ").ok()?;
-    let caps = re.captures(&msg)?;
+    let caps = re.captures(msg)?;
     caps.get(1)?
         .as_str()
         .parse::<usize>()
@@ -197,9 +187,6 @@ fn extract_line_offset(err: &mlua::Error) -> Option<usize> {
 
 /// Convert a 1-based line number to a byte offset in the source.
 fn line_offset(source: &str, line: usize) -> Option<SourceOffset> {
-    if line == 0 {
-        return None;
-    }
     let mut current_line = 1;
     for (i, ch) in source.char_indices() {
         if current_line == line {
@@ -289,31 +276,26 @@ mod tests {
         );
     }
 
-    #[test]
-    fn load_string_rejects_empty_source() {
-        let f = fennel();
-        let result: Result<MirrorConfig, _> = f.load_string("", "empty.fnl");
-        assert!(result.is_err());
-        assert!(matches!(result.unwrap_err(), FennelError::Empty { .. }));
-    }
-
-    #[test]
-    fn load_string_rejects_whitespace_only() {
-        let f = fennel();
-        let result: Result<MirrorConfig, _> = f.load_string("  \n  ", "blank.fnl");
-        assert!(result.is_err());
-        assert!(matches!(result.unwrap_err(), FennelError::Empty { .. }));
-    }
-
     #[test]
     fn load_string_rejects_malformed_fennel() {
         let f = fennel();
-        let result: Result<MirrorConfig, _> = f.load_string("{:bad {:}", "bad.fnl");
-        assert!(result.is_err());
+        let source = "{:bad {:}";
+        let result: Result<MirrorConfig, _> = f.load_string(source, "bad.fnl");
         let err = result.unwrap_err();
+        let FennelError::Eval {
+            message,
+            source_code,
+            label,
+            ..
+        } = &err
+        else {
+            panic!("expected Eval, got {err:?}");
+        };
+        assert_eq!(message, "bad.fnl");
+        assert_eq!(source_code, source);
         assert!(
-            err.to_string().contains("bad.fnl"),
-            "error should mention source name: {err}"
+            label.is_some(),
+            "label should be set for line-bearing error"
         );
     }
 
@@ -321,7 +303,11 @@ mod tests {
     fn load_string_rejects_type_mismatch() {
         let f = fennel();
         let result: Result<MirrorConfig, _> = f.load_string("{:mirror {:url 42}}", "types.fnl");
-        assert!(result.is_err());
+        let err = result.unwrap_err();
+        assert!(
+            matches!(&err, FennelError::TypeMismatch { message, .. } if message == "types.fnl"),
+            "expected TypeMismatch, got {err:?}",
+        );
     }
 
     #[test]
@@ -380,20 +366,43 @@ mod tests {
     }
 
     #[test]
-    fn load_string_rejects_nil_result() {
+    fn eval_raw_setup_can_inject_globals() {
         let f = fennel();
-        // A Fennel expression that evaluates to nil (not empty, but produces nil).
-        let result: Result<MirrorConfig, _> = f.load_string("nil", "nil.fnl");
-        assert!(result.is_err());
-        assert!(
-            matches!(result.unwrap_err(), FennelError::Empty { .. }),
-            "expected Empty error for nil result"
+        let result = f
+            .eval_raw("custom_var", "test", |lua| {
+                lua.globals().set("custom_var", 42)
+            })
+            .expect("eval_raw should succeed");
+        assert_eq!(result.as_integer(), Some(42));
+    }
+
+    #[test]
+    fn extract_line_offset_parses_line_and_column() {
+        assert_eq!(
+            super::extract_line_offset("name.fnl:5:12: parse error"),
+            Some(5)
+        );
+    }
+
+    #[test]
+    fn extract_line_offset_parses_line_only() {
+        assert_eq!(
+            super::extract_line_offset("name.fnl:7: runtime error"),
+            Some(7)
+        );
+    }
+
+    #[test]
+    fn extract_line_offset_handles_colon_in_name() {
+        assert_eq!(
+            super::extract_line_offset("HEAD:.quire/config.fnl:3:1: oops"),
+            Some(3)
         );
     }
 
     #[test]
-    fn line_offset_returns_none_for_line_zero() {
-        assert!(super::line_offset("hello", 0).is_none());
+    fn extract_line_offset_returns_none_without_location() {
+        assert!(super::extract_line_offset("no location info").is_none());
     }
 
     #[test]
diff --git a/quire-server/src/error.rs b/quire-server/src/error.rs
index dd6f57f..f39075c 100644
--- a/quire-server/src/error.rs
+++ b/quire-server/src/error.rs
@@ -81,9 +81,10 @@ mod tests {
 
     #[test]
     fn from_fennel_error() {
-        let fennel_err = FennelError::Empty {
-            name: "test.fnl".to_string(),
-        };
+        let fennel_err = FennelError::Io(std::io::Error::new(
+            std::io::ErrorKind::NotFound,
+            "test.fnl",
+        ));
         let err: Error = fennel_err.into();
         assert!(err.to_string().contains("test.fnl"));
     }