From e38f372eeb0bc0ad90c54eea1caba133b529d1e5 Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Mon, 3 Aug 2026 19:17:03 -0400 Subject: [PATCH] Move input scalar coercion to shared types --- lib/components/fabro-tool/src/manifest.rs | 66 +++++--- .../fabro-types/src/input_scalar.rs | 154 ++++++++++++++++++ lib/foundation/fabro-types/src/lib.rs | 2 + 3 files changed, 196 insertions(+), 26 deletions(-) create mode 100644 lib/foundation/fabro-types/src/input_scalar.rs diff --git a/lib/components/fabro-tool/src/manifest.rs b/lib/components/fabro-tool/src/manifest.rs index 01473602c..fa58c75a2 100644 --- a/lib/components/fabro-tool/src/manifest.rs +++ b/lib/components/fabro-tool/src/manifest.rs @@ -1,32 +1,29 @@ +use fabro_types::JsonScalarToTomlError; use serde_json::Value; use super::common::{ToolError, ToolResult}; pub fn json_to_toml_value(key: &str, value: &Value) -> ToolResult { - match value { - Value::Null => Err(ToolError::message(format!( - "input `{key}` cannot be null; use a string, boolean, or number" - ))), - Value::Bool(value) => Ok(toml::Value::Boolean(*value)), - Value::Number(value) => { - if let Some(integer) = value.as_i64() { - Ok(toml::Value::Integer(integer)) - } else if let Some(float) = value.as_f64() { - Ok(toml::Value::Float(float)) - } else { - Err(ToolError::message(format!( - "input `{key}` contains a number outside TOML's supported range" - ))) - } + fabro_types::json_scalar_to_toml_value(value) + .map_err(|error| json_scalar_to_tool_error(key, error)) +} + +fn json_scalar_to_tool_error(key: &str, error: JsonScalarToTomlError) -> ToolError { + let message = match error { + JsonScalarToTomlError::Null => { + format!("input `{key}` cannot be null; use a string, boolean, or number") } - Value::String(value) => Ok(toml::Value::String(value.clone())), - Value::Array(_) => Err(ToolError::message(format!( - "input `{key}` does not support array values; use a string, boolean, or number", - ))), - Value::Object(_) => Err(ToolError::message(format!( + JsonScalarToTomlError::Array => { + format!("input `{key}` does not support array values; use a string, boolean, or number") + } + JsonScalarToTomlError::Object => format!( "input `{key}` does not support object values; use a string, boolean, or number", - ))), - } + ), + JsonScalarToTomlError::NumberOutOfRange => { + format!("input `{key}` contains a number outside TOML's supported range") + } + }; + ToolError::message(message) } #[cfg(test)] @@ -45,19 +42,25 @@ mod tests { ]; for (json, expected) in cases { - assert_eq!(json_to_toml_value("input", &json).unwrap(), expected); + assert_eq!( + json_to_toml_value("input", &json) + .expect("representative JSON scalar should convert"), + expected + ); } } #[test] fn json_input_arrays_and_objects_are_rejected() { - let array_err = json_to_toml_value("matrix", &json!(["a", 1])).unwrap_err(); + let array_err = json_to_toml_value("matrix", &json!(["a", 1])) + .expect_err("JSON arrays should be rejected"); assert_eq!( array_err.as_str(), "input `matrix` does not support array values; use a string, boolean, or number", ); - let object_err = json_to_toml_value("settings", &json!({ "enabled": true })).unwrap_err(); + let object_err = json_to_toml_value("settings", &json!({ "enabled": true })) + .expect_err("JSON objects should be rejected"); assert_eq!( object_err.as_str(), "input `settings` does not support object values; use a string, boolean, or number", @@ -66,11 +69,22 @@ mod tests { #[test] fn json_input_null_is_rejected_with_key_name() { - let err = json_to_toml_value("goal", &Value::Null).unwrap_err(); + let err = + json_to_toml_value("goal", &Value::Null).expect_err("JSON null should be rejected"); assert_eq!( err.as_str(), "input `goal` cannot be null; use a string, boolean, or number", ); } + + #[test] + fn json_input_out_of_range_number_preserves_tool_message() { + let err = json_scalar_to_tool_error("threshold", JsonScalarToTomlError::NumberOutOfRange); + + assert_eq!( + err.as_str(), + "input `threshold` contains a number outside TOML's supported range", + ); + } } diff --git a/lib/foundation/fabro-types/src/input_scalar.rs b/lib/foundation/fabro-types/src/input_scalar.rs new file mode 100644 index 000000000..512b9d407 --- /dev/null +++ b/lib/foundation/fabro-types/src/input_scalar.rs @@ -0,0 +1,154 @@ +use serde_json::Value; + +/// The reason a parsed JSON value cannot be represented as a TOML scalar. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum JsonScalarToTomlError { + /// JSON null has no TOML scalar representation. + #[error("JSON null is not a TOML scalar")] + Null, + /// JSON arrays are outside the scalar-only conversion contract. + #[error("JSON arrays are not TOML scalars")] + Array, + /// JSON objects are outside the scalar-only conversion contract. + #[error("JSON objects are not TOML scalars")] + Object, + /// The JSON number is not representable by the supported TOML number types. + #[error("JSON number is outside TOML's supported range")] + NumberOutOfRange, +} + +/// Converts an already-parsed JSON scalar into a TOML value. +/// +/// Numbers are converted to a signed integer first and then to a float. As a +/// result, nonnegative integers above `i64::MAX` become TOML floats and may +/// lose integer precision. JSON floats remain TOML floats. +/// +/// # Errors +/// +/// Returns [`JsonScalarToTomlError`] for JSON null, arrays, objects, or a +/// number that can be represented as neither an `i64` nor an `f64`. +pub fn json_scalar_to_toml_value(value: &Value) -> Result { + match value { + Value::Null => Err(JsonScalarToTomlError::Null), + Value::Bool(value) => Ok(toml::Value::Boolean(*value)), + Value::Number(value) => { + if let Some(integer) = value.as_i64() { + Ok(toml::Value::Integer(integer)) + } else if let Some(float) = value.as_f64() { + Ok(toml::Value::Float(float)) + } else { + Err(JsonScalarToTomlError::NumberOutOfRange) + } + } + Value::String(value) => Ok(toml::Value::String(value.clone())), + Value::Array(_) => Err(JsonScalarToTomlError::Array), + Value::Object(_) => Err(JsonScalarToTomlError::Object), + } +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use super::{JsonScalarToTomlError, json_scalar_to_toml_value}; + + #[test] + fn converts_strings_to_toml_strings() -> Result<(), JsonScalarToTomlError> { + for input in ["", "hello", "Grüße 世界"] { + let json = Value::String(input.to_string()); + + assert_eq!( + json_scalar_to_toml_value(&json)?, + toml::Value::String(input.to_string()), + "{input:?}" + ); + } + + Ok(()) + } + + #[test] + fn converts_booleans_to_toml_booleans() -> Result<(), JsonScalarToTomlError> { + for input in [false, true] { + assert_eq!( + json_scalar_to_toml_value(&Value::Bool(input))?, + toml::Value::Boolean(input) + ); + } + + Ok(()) + } + + #[test] + fn converts_signed_range_integers_to_toml_integers() -> Result<(), JsonScalarToTomlError> { + for input in [i64::MIN, -1, 0, i64::MAX] { + assert_eq!( + json_scalar_to_toml_value(&Value::from(input))?, + toml::Value::Integer(input) + ); + } + + let signed_max_as_u64 = + u64::try_from(i64::MAX).expect("i64::MAX should be representable as u64"); + assert_eq!( + json_scalar_to_toml_value(&Value::from(signed_max_as_u64))?, + toml::Value::Integer(i64::MAX) + ); + + Ok(()) + } + + #[test] + fn converts_large_unsigned_integers_to_toml_floats() -> Result<(), JsonScalarToTomlError> { + let signed_max_as_u64 = + u64::try_from(i64::MAX).expect("i64::MAX should be representable as u64"); + for input in [signed_max_as_u64 + 1, u64::MAX] { + assert_eq!( + json_scalar_to_toml_value(&Value::from(input))?, + toml::Value::Float(input as f64) + ); + } + + Ok(()) + } + + #[test] + fn preserves_finite_json_floats_as_toml_floats() -> Result<(), JsonScalarToTomlError> { + for input in [-0.0, 0.5, 1.0, f64::MAX] { + let converted = json_scalar_to_toml_value(&Value::from(input))?; + let toml::Value::Float(output) = converted else { + panic!("{input:?} should remain a TOML float"); + }; + + assert_eq!(output.to_bits(), input.to_bits(), "{input:?}"); + } + + Ok(()) + } + + #[test] + fn rejects_non_scalar_json_values_with_typed_errors() { + let cases = [ + (Value::Null, JsonScalarToTomlError::Null), + (json!(["value"]), JsonScalarToTomlError::Array), + (json!({ "key": "value" }), JsonScalarToTomlError::Object), + ]; + + for (input, expected) in cases { + assert_eq!(json_scalar_to_toml_value(&input), Err(expected)); + } + } + + #[test] + fn classifies_non_finite_float_values_at_the_json_boundary() { + for input in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let json = Value::from(input); + + assert_eq!(json, Value::Null); + assert_eq!( + json_scalar_to_toml_value(&json), + Err(JsonScalarToTomlError::Null) + ); + } + } +} diff --git a/lib/foundation/fabro-types/src/lib.rs b/lib/foundation/fabro-types/src/lib.rs index c77cc2cd5..4a36c659c 100644 --- a/lib/foundation/fabro-types/src/lib.rs +++ b/lib/foundation/fabro-types/src/lib.rs @@ -13,6 +13,7 @@ pub mod event_envelope; pub mod failure_signature; pub mod graph; mod id; +mod input_scalar; pub mod interview; pub mod llm_backend; pub mod manifest_path; @@ -75,6 +76,7 @@ pub use graph::{ AttrValue, ContextKeyAttr, Edge, Graph, KNOWN_HANDLER_TYPES, Node, is_known_handler_type, is_llm_handler_type, shape_to_handler_type, }; +pub use input_scalar::{JsonScalarToTomlError, json_scalar_to_toml_value}; pub use interview::{ InterviewQuestionRecord, QuestionType, ReviewTarget, ReviewTargetError, ReviewTargetKind, };