use serde::de::DeserializeOwned;
use serde_json::Value;
use crate::diagnostic::{Diagnostic, DiagnosticKind, RiskLevel};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum CoercionLevel {
Exact,
SafeWidening,
StringCoercion,
BestEffort,
}
#[derive(Debug)]
pub struct CoercionResult {
pub value: Value,
pub coerced: bool,
pub diagnostic: Option<Diagnostic>,
}
fn coerced(
value: Value,
path: &str,
from: &str,
to: &str,
risk: RiskLevel,
suggestion: Option<&str>,
) -> CoercionResult {
CoercionResult {
value,
coerced: true,
diagnostic: Some(Diagnostic {
path: path.to_string(),
kind: DiagnosticKind::Coerced {
from: from.into(),
to: to.into(),
},
risk,
suggestion: suggestion.map(|s| s.to_string()),
}),
}
}
fn defaulted(value: Value, path: &str, desc: &str, suggestion: Option<&str>) -> CoercionResult {
CoercionResult {
value,
coerced: true,
diagnostic: Some(Diagnostic {
path: path.to_string(),
kind: DiagnosticKind::Defaulted {
field: path.to_string(),
value: desc.into(),
},
risk: RiskLevel::Warning,
suggestion: suggestion.map(|s| s.to_string()),
}),
}
}
fn integer_fits_target(i: i64, target: &str) -> bool {
match target {
"i8" => i >= i8::MIN as i64 && i <= i8::MAX as i64,
"i16" => i >= i16::MIN as i64 && i <= i16::MAX as i64,
"i32" => i >= i32::MIN as i64 && i <= i32::MAX as i64,
"i64" => true,
"u8" => i >= 0 && i <= u8::MAX as i64,
"u16" => i >= 0 && i <= u16::MAX as i64,
"u32" => i >= 0 && i <= u32::MAX as i64,
"u64" => i >= 0,
"isize" => true,
"usize" => i >= 0,
_ => true,
}
}
fn try_parse_radix_int(s: &str) -> Option<i64> {
let s = s.trim();
if s.len() < 3 || !s.is_char_boundary(2) {
return None;
}
let (prefix, digits) = s.split_at(2);
match prefix {
"0x" | "0X" => i64::from_str_radix(digits, 16).ok(),
"0o" | "0O" => i64::from_str_radix(digits, 8).ok(),
"0b" | "0B" => i64::from_str_radix(digits, 2).ok(),
_ => None,
}
}
fn try_strip_comma_thousands(s: &str) -> Option<String> {
if !s.contains(',') {
return None;
}
if let Some(dot_pos) = s.find('.') {
if let Some(comma_pos) = s.rfind(',') {
if comma_pos > dot_pos {
return None;
}
}
}
if s.contains(",,") {
return None;
}
let numeric_part = s.trim_start_matches('-');
let integer_part = numeric_part.split('.').next().unwrap_or(numeric_part);
let groups: Vec<&str> = integer_part.split(',').collect();
if groups.len() > 1 {
for group in &groups[1..] {
if group.len() != 3 || !group.chars().all(|c| c.is_ascii_digit()) {
return None;
}
}
if groups[0].is_empty()
|| groups[0].len() > 3
|| !groups[0].chars().all(|c| c.is_ascii_digit())
{
return None;
}
}
let stripped: String = s.chars().filter(|c| *c != ',').collect();
if stripped.parse::<f64>().is_ok() && !stripped.is_empty() {
Some(stripped)
} else {
None
}
}
fn try_parse_european(s: &str) -> Option<String> {
let has_comma = s.contains(',');
let has_dot = s.contains('.');
if has_comma && has_dot {
let last_dot = s.rfind('.').expect("guarded by has_dot");
let last_comma = s.rfind(',').expect("guarded by has_comma");
if last_comma > last_dot {
let normalized = s.replace('.', "").replace(',', ".");
if normalized.parse::<f64>().is_ok() {
return Some(normalized);
}
}
}
if !has_comma && has_dot && s.matches('.').count() > 1 {
let stripped = s.replace('.', "");
if stripped.parse::<f64>().is_ok() {
return Some(stripped);
}
}
None
}
fn try_strip_alt_thousands(s: &str) -> Option<String> {
let has_apostrophe = s.contains('\'');
let has_space = s.contains(' ');
if !has_apostrophe && !has_space {
return None;
}
let sep = if has_apostrophe { '\'' } else { ' ' };
let numeric_part = s.trim_start_matches('-');
let integer_part = numeric_part
.split(['.', ','])
.next()
.unwrap_or(numeric_part);
let groups: Vec<&str> = integer_part.split(sep).collect();
if groups.len() <= 1 {
return None;
}
for group in &groups[1..] {
if group.len() != 3 || !group.chars().all(|c| c.is_ascii_digit()) {
return None;
}
}
if groups[0].is_empty() || groups[0].len() > 3 || !groups[0].chars().all(|c| c.is_ascii_digit())
{
return None;
}
let stripped: String = s
.chars()
.filter(|c| *c != sep)
.map(|c| if c == ',' { '.' } else { c })
.collect();
if stripped.parse::<f64>().is_ok() && !stripped.is_empty() {
Some(stripped)
} else {
None
}
}
fn try_strip_underscores(s: &str) -> Option<String> {
if !s.contains('_') {
return None;
}
let numeric_start = s.strip_prefix('-').unwrap_or(s);
if numeric_start.starts_with('_') || s.ends_with('_') {
return None;
}
if s.contains("__") {
return None;
}
let stripped: String = s.chars().filter(|c| *c != '_').collect();
if stripped.is_empty() {
return None;
}
Some(stripped)
}
fn is_null_sentinel(s: &str) -> bool {
matches!(
s.to_lowercase().as_str(),
"null" | "none" | "n/a" | "na" | "nil" | "nan" | "unknown" | "undefined" | "-"
)
}
fn try_null_sentinel_or_passthrough(
value: &Value,
s: &str,
_target_type: &str,
level: CoercionLevel,
path: &str,
) -> CoercionResult {
if level >= CoercionLevel::BestEffort && is_null_sentinel(s) {
coerced(
Value::Null,
path,
"null-sentinel string",
"null",
RiskLevel::Warning,
Some(
"string contains a null-sentinel value; consider using actual null in the source data",
),
)
} else {
no_coercion(value)
}
}
fn no_coercion(value: &Value) -> CoercionResult {
CoercionResult {
value: value.clone(),
coerced: false,
diagnostic: None,
}
}
fn flagged_no_coerce(
value: &Value,
path: &str,
from: &str,
to: &str,
risk: RiskLevel,
suggestion: &str,
) -> CoercionResult {
CoercionResult {
value: value.clone(),
coerced: false,
diagnostic: Some(Diagnostic {
path: path.to_string(),
kind: DiagnosticKind::Coerced {
from: from.into(),
to: to.into(),
},
risk,
suggestion: Some(suggestion.into()),
}),
}
}
pub trait CoercionDataSource: Send + Sync + std::fmt::Debug {
fn exchange_rate(&self, from: &str, to: &str) -> Option<f64> {
let _ = (from, to);
None
}
fn conversion_factor(&self, from_unit: &str, to_unit: &str) -> Option<f64> {
let _ = (from_unit, to_unit);
None
}
fn lookup(&self, domain: &str, key: &str) -> Option<serde_json::Value> {
let _ = (domain, key);
None
}
}
#[derive(Debug, Default)]
pub struct NoDataSource;
impl CoercionDataSource for NoDataSource {}
#[derive(Debug, Default)]
pub struct StaticDataSource {
pub exchange_rates: std::collections::HashMap<(String, String), f64>,
pub conversion_factors: std::collections::HashMap<(String, String), f64>,
}
impl CoercionDataSource for StaticDataSource {
fn exchange_rate(&self, from: &str, to: &str) -> Option<f64> {
self.exchange_rates
.get(&(from.to_string(), to.to_string()))
.copied()
}
fn conversion_factor(&self, from_unit: &str, to_unit: &str) -> Option<f64> {
self.conversion_factors
.get(&(from_unit.to_string(), to_unit.to_string()))
.copied()
}
}
pub trait Coercible: DeserializeOwned {
fn coercion_hint() -> Option<&'static str> {
None
}
fn is_optional() -> bool {
false
}
fn element_hint() -> Option<&'static str> {
None
}
fn is_element_optional() -> bool {
false
}
}
impl Coercible for i8 {
fn coercion_hint() -> Option<&'static str> {
Some("i8")
}
}
impl Coercible for i16 {
fn coercion_hint() -> Option<&'static str> {
Some("i16")
}
}
impl Coercible for i32 {
fn coercion_hint() -> Option<&'static str> {
Some("i32")
}
}
impl Coercible for i64 {
fn coercion_hint() -> Option<&'static str> {
Some("i64")
}
}
impl Coercible for u8 {
fn coercion_hint() -> Option<&'static str> {
Some("u8")
}
}
impl Coercible for u16 {
fn coercion_hint() -> Option<&'static str> {
Some("u16")
}
}
impl Coercible for u32 {
fn coercion_hint() -> Option<&'static str> {
Some("u32")
}
}
impl Coercible for u64 {
fn coercion_hint() -> Option<&'static str> {
Some("u64")
}
}
impl Coercible for usize {
fn coercion_hint() -> Option<&'static str> {
Some("usize")
}
}
impl Coercible for isize {
fn coercion_hint() -> Option<&'static str> {
Some("isize")
}
}
impl Coercible for f32 {
fn coercion_hint() -> Option<&'static str> {
Some("f32")
}
}
impl Coercible for f64 {
fn coercion_hint() -> Option<&'static str> {
Some("f64")
}
}
impl Coercible for bool {
fn coercion_hint() -> Option<&'static str> {
Some("bool")
}
}
impl Coercible for String {
fn coercion_hint() -> Option<&'static str> {
Some("String")
}
}
impl<T: Coercible> Coercible for Option<T> {
fn coercion_hint() -> Option<&'static str> {
T::coercion_hint()
}
fn is_optional() -> bool {
true
}
}
impl<T: Coercible> Coercible for Vec<T> {
fn coercion_hint() -> Option<&'static str> {
None
}
fn element_hint() -> Option<&'static str> {
T::coercion_hint()
}
fn is_element_optional() -> bool {
T::is_optional()
}
}
impl Coercible for serde_json::Value {
fn coercion_hint() -> Option<&'static str> {
None
}
}
pub fn coerce_for<T: Coercible>(value: &Value, level: CoercionLevel, path: &str) -> CoercionResult {
if T::is_optional() && value.is_null() {
return no_coercion(value);
}
match T::coercion_hint() {
Some(hint) => {
let result = coerce_value(value, hint, level, path);
if result.value.is_null() && result.coerced && !T::is_optional() {
let default_result = coerce_value(&Value::Null, hint, level, path);
if default_result.coerced {
return CoercionResult {
value: default_result.value,
coerced: true,
diagnostic: result.diagnostic, };
}
}
result
}
None => {
if let (Some(elem_hint), Value::Array(arr)) = (T::element_hint(), value) {
let elem_optional = T::is_element_optional();
let mut coerced_any = false;
let mut all_diagnostics: Vec<Diagnostic> = Vec::new();
let coerced_elements: Vec<Value> = arr
.iter()
.enumerate()
.map(|(idx, elem)| {
let elem_path = format!("{}[{}]", path, idx);
if elem_optional && elem.is_null() {
return elem.clone();
}
let result = coerce_value(elem, elem_hint, level, &elem_path);
if result.value.is_null() && result.coerced && !elem_optional {
let default_result =
coerce_value(&Value::Null, elem_hint, level, &elem_path);
if default_result.coerced {
coerced_any = true;
if let Some(d) = result.diagnostic {
all_diagnostics.push(d);
}
return default_result.value;
}
}
if result.coerced {
coerced_any = true;
}
if let Some(d) = result.diagnostic {
all_diagnostics.push(d);
}
result.value
})
.collect();
if coerced_any {
let first = all_diagnostics.first().cloned();
if all_diagnostics.len() > 1 {
let paths: Vec<String> =
all_diagnostics.iter().map(|d| d.path.clone()).collect();
CoercionResult {
value: Value::Array(coerced_elements),
coerced: true,
diagnostic: Some(Diagnostic {
path: path.to_string(),
kind: DiagnosticKind::Coerced {
from: "mixed array".into(),
to: elem_hint.into(),
},
risk: RiskLevel::Warning,
suggestion: Some(format!(
"{} elements coerced at: {}",
paths.len(),
paths.join(", ")
)),
}),
}
} else {
CoercionResult {
value: Value::Array(coerced_elements),
coerced: true,
diagnostic: first,
}
}
} else {
no_coercion(value)
}
} else {
no_coercion(value)
}
}
}
}
pub fn coerce_value(
value: &Value,
target_type: &str,
level: CoercionLevel,
path: &str,
) -> CoercionResult {
if level == CoercionLevel::Exact {
if let Value::Number(n) = value {
match target_type {
"f32" | "f64" if n.is_i64() || n.is_u64() => {
return CoercionResult {
value: Value::Null,
coerced: false,
diagnostic: Some(Diagnostic {
path: path.to_string(),
kind: DiagnosticKind::Coerced {
from: "integer".into(),
to: target_type.into(),
},
risk: RiskLevel::Warning,
suggestion: Some(
"integer → float requires SafeWidening coercion level or higher"
.into(),
),
}),
};
}
_ => {}
}
}
return no_coercion(value);
}
match (value, target_type) {
(
Value::String(s),
"i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "isize" | "usize",
) if level >= CoercionLevel::StringCoercion => {
let s = s.trim();
if let Ok(n) = s.parse::<i64>() {
if integer_fits_target(n, target_type) {
coerced(
Value::Number(n.into()),
path,
"string",
target_type,
RiskLevel::Info,
Some("use an integer type in the source data"),
)
} else {
flagged_no_coerce(
value,
path,
"string",
target_type,
RiskLevel::Risky,
"integer value from string overflows the target type",
)
}
} else if let Ok(n) = s.parse::<u64>() {
let fits = match target_type {
"u64" => true,
"usize" => true,
_ => false,
};
if fits {
coerced(
Value::Number(n.into()),
path,
"string",
target_type,
RiskLevel::Info,
Some("use an integer type in the source data"),
)
} else {
flagged_no_coerce(
value,
path,
"string",
target_type,
RiskLevel::Risky,
"integer value from string overflows the target type",
)
}
} else if let Some(n) = try_parse_radix_int(s) {
if integer_fits_target(n, target_type) {
coerced(
Value::Number(n.into()),
path,
"radix-prefixed string",
target_type,
RiskLevel::Warning,
Some("use a decimal integer in the source data"),
)
} else {
flagged_no_coerce(
value,
path,
"radix-prefixed string",
target_type,
RiskLevel::Risky,
"radix-prefixed integer overflows the target type",
)
}
} else if level >= CoercionLevel::BestEffort {
if let Some(stripped) = try_strip_comma_thousands(s) {
if let Ok(n) = stripped.parse::<i64>() {
return if integer_fits_target(n, target_type) {
coerced(
Value::Number(n.into()),
path,
"comma-formatted string",
target_type,
RiskLevel::Warning,
Some("remove thousands separators from numeric strings"),
)
} else {
flagged_no_coerce(
value,
path,
"comma-formatted string",
target_type,
RiskLevel::Risky,
"comma-formatted integer overflows the target type",
)
};
}
}
if let Some(normalized) = try_parse_european(s) {
if let Ok(n) = normalized.parse::<i64>() {
return if integer_fits_target(n, target_type) {
coerced(
Value::Number(n.into()),
path,
"European-formatted string",
target_type,
RiskLevel::Warning,
Some("use US number format (dot for decimal, comma for thousands)"),
)
} else {
flagged_no_coerce(
value,
path,
"European-formatted string",
target_type,
RiskLevel::Risky,
"European-formatted integer overflows the target type",
)
};
}
}
if let Some(stripped) = try_strip_alt_thousands(s) {
if let Ok(n) = stripped.parse::<i64>() {
return if integer_fits_target(n, target_type) {
coerced(
Value::Number(n.into()),
path,
"locale-formatted string",
target_type,
RiskLevel::Warning,
Some("remove thousands separators from numeric strings"),
)
} else {
flagged_no_coerce(
value,
path,
"locale-formatted string",
target_type,
RiskLevel::Risky,
"locale-formatted integer overflows the target type",
)
};
}
}
if let Some(stripped) = try_strip_underscores(s) {
if let Ok(n) = stripped.parse::<i64>() {
return if integer_fits_target(n, target_type) {
coerced(
Value::Number(n.into()),
path,
"underscore-formatted string",
target_type,
RiskLevel::Warning,
Some("remove underscores from numeric strings"),
)
} else {
flagged_no_coerce(
value,
path,
"underscore-formatted string",
target_type,
RiskLevel::Risky,
"underscore-formatted integer overflows the target type",
)
};
}
if let Some(n) = try_parse_radix_int(&stripped) {
return if integer_fits_target(n, target_type) {
coerced(
Value::Number(n.into()),
path,
"underscore-formatted radix string",
target_type,
RiskLevel::Warning,
Some("remove underscores and use decimal integers"),
)
} else {
flagged_no_coerce(
value,
path,
"underscore-formatted radix string",
target_type,
RiskLevel::Risky,
"underscore-formatted radix integer overflows the target type",
)
};
}
}
try_null_sentinel_or_passthrough(value, s, target_type, level, path)
} else {
try_null_sentinel_or_passthrough(value, s, target_type, level, path)
}
}
(Value::String(s), "f32" | "f64") if level >= CoercionLevel::StringCoercion => {
let s = s.trim();
if let Ok(n) = s.parse::<f64>() {
if n.is_nan() {
if level >= CoercionLevel::BestEffort {
coerced(
Value::Null,
path,
"NaN string",
"null",
RiskLevel::Warning,
Some(
"\"NaN\" represents missing/undefined data; consider using null instead",
),
)
} else {
no_coercion(value)
}
} else if n.is_infinite() {
flagged_no_coerce(
value,
path,
"string",
target_type,
RiskLevel::Risky,
"\"Infinity\" cannot be represented in JSON; use a sentinel value or null",
)
} else if target_type == "f32" {
let as_f32 = n as f32;
if as_f32.is_infinite() {
CoercionResult {
value: Value::Null,
coerced: false,
diagnostic: Some(Diagnostic {
path: path.to_string(),
kind: DiagnosticKind::Coerced {
from: "string".into(),
to: "f32".into(),
},
risk: RiskLevel::Risky,
suggestion: Some(format!(
"value {:.6e} overflows f32 range (max ~3.4e38); use f64",
n
)),
}),
}
} else if as_f32 == 0.0 && n != 0.0 {
CoercionResult {
value: Value::Null,
coerced: false,
diagnostic: Some(Diagnostic {
path: path.to_string(),
kind: DiagnosticKind::Coerced {
from: "string".into(),
to: "f32".into(),
},
risk: RiskLevel::Warning,
suggestion: Some(format!(
"value {:.6e} underflows f32 (too small to represent); use f64",
n
)),
}),
}
} else {
match serde_json::Number::from_f64(n) {
Some(num) => coerced(
Value::Number(num),
path,
"string",
target_type,
RiskLevel::Warning,
Some(
"string-to-float coercion may lose decimal precision; consider a Decimal type for financial data",
),
),
None => no_coercion(value),
}
}
} else {
match serde_json::Number::from_f64(n) {
Some(num) => coerced(
Value::Number(num),
path,
"string",
target_type,
RiskLevel::Warning,
Some(
"string-to-float coercion may lose decimal precision; consider a Decimal type for financial data",
),
),
None => no_coercion(value),
}
}
} else if level >= CoercionLevel::BestEffort {
if let Some(stripped) = try_strip_comma_thousands(s) {
if let Ok(n) = stripped.parse::<f64>() {
if !n.is_nan() && !n.is_infinite() {
if let Some(num) = serde_json::Number::from_f64(n) {
return coerced(
Value::Number(num),
path,
"comma-formatted string",
target_type,
RiskLevel::Warning,
Some("remove thousands separators from numeric strings"),
);
}
}
}
}
if let Some(normalized) = try_parse_european(s) {
if let Ok(n) = normalized.parse::<f64>() {
if !n.is_nan() && !n.is_infinite() {
if let Some(num) = serde_json::Number::from_f64(n) {
return coerced(
Value::Number(num),
path,
"European-formatted string",
target_type,
RiskLevel::Warning,
Some(
"use US number format (dot for decimal, comma for thousands)",
),
);
}
}
}
}
if let Some(stripped) = try_strip_alt_thousands(s) {
if let Ok(n) = stripped.parse::<f64>() {
if !n.is_nan() && !n.is_infinite() {
if let Some(num) = serde_json::Number::from_f64(n) {
return coerced(
Value::Number(num),
path,
"locale-formatted string",
target_type,
RiskLevel::Warning,
Some("remove thousands separators from numeric strings"),
);
}
}
}
}
if let Some(stripped) = try_strip_underscores(s) {
if let Ok(n) = stripped.parse::<f64>() {
if !n.is_nan() && !n.is_infinite() {
if let Some(num) = serde_json::Number::from_f64(n) {
return coerced(
Value::Number(num),
path,
"underscore-formatted string",
target_type,
RiskLevel::Warning,
Some("remove underscores from numeric strings"),
);
}
}
}
}
try_null_sentinel_or_passthrough(value, s, target_type, level, path)
} else {
try_null_sentinel_or_passthrough(value, s, target_type, level, path)
}
}
(Value::String(s), "bool") if level >= CoercionLevel::StringCoercion => {
match s.trim().to_lowercase().as_str() {
"true" | "1" | "yes" | "on" | "y" | "t" => coerced(
Value::Bool(true),
path,
"string",
"bool",
RiskLevel::Info,
None,
),
"false" | "0" | "no" | "off" | "n" | "f" => coerced(
Value::Bool(false),
path,
"string",
"bool",
RiskLevel::Info,
None,
),
_ => try_null_sentinel_or_passthrough(value, s, target_type, level, path),
}
}
(Value::Number(n), "bool") if level >= CoercionLevel::SafeWidening => {
if let Some(i) = n.as_i64() {
match i {
0 => coerced(
Value::Bool(false),
path,
"integer",
"bool",
RiskLevel::Info,
Some("use a boolean type in the source data"),
),
1 => coerced(
Value::Bool(true),
path,
"integer",
"bool",
RiskLevel::Info,
Some("use a boolean type in the source data"),
),
_ => flagged_no_coerce(
value,
path,
"integer",
"bool",
RiskLevel::Risky,
"integer value other than 0/1 cannot be coerced to bool",
),
}
} else {
no_coercion(value)
}
}
(Value::Number(n), "String" | "string") if level >= CoercionLevel::StringCoercion => {
coerced(
Value::String(n.to_string()),
path,
"number",
"string",
RiskLevel::Info,
None,
)
}
(Value::Bool(b), "String" | "string") if level >= CoercionLevel::StringCoercion => coerced(
Value::String(b.to_string()),
path,
"bool",
"string",
RiskLevel::Info,
None,
),
(
Value::Bool(b),
"i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "isize" | "usize",
) if level >= CoercionLevel::SafeWidening => {
let n = if *b { 1i64 } else { 0i64 };
coerced(
Value::Number(n.into()),
path,
"bool",
target_type,
RiskLevel::Info,
Some("use an integer type in the source data"),
)
}
(Value::Bool(b), "f32" | "f64") if level >= CoercionLevel::SafeWidening => {
let n = if *b { 1.0 } else { 0.0 };
match serde_json::Number::from_f64(n) {
Some(num) => coerced(
Value::Number(num),
path,
"bool",
target_type,
RiskLevel::Info,
Some("use a numeric type in the source data"),
),
None => no_coercion(value),
}
}
(
Value::Number(n),
"i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "isize" | "usize",
) if level >= CoercionLevel::SafeWidening => {
if let Some(i) = n.as_i64() {
if integer_fits_target(i, target_type) {
no_coercion(value)
} else {
flagged_no_coerce(
value,
path,
"integer",
target_type,
RiskLevel::Risky,
&format!("value {} overflows {} range", i, target_type),
)
}
} else if let Some(u) = n.as_u64() {
match target_type {
"u64" | "usize" => no_coercion(value),
_ => flagged_no_coerce(
value,
path,
"integer",
target_type,
RiskLevel::Risky,
&format!("value {} overflows {} range", u, target_type),
),
}
} else if let Some(f) = n.as_f64() {
if f.fract() == 0.0 && f >= i64::MIN as f64 && f <= i64::MAX as f64 {
let i = f as i64;
if integer_fits_target(i, target_type) {
coerced(
Value::Number(i.into()),
path,
"float",
target_type,
RiskLevel::Info,
Some("use an integer in the source data"),
)
} else {
flagged_no_coerce(
value,
path,
"float",
target_type,
RiskLevel::Risky,
&format!("value {} overflows {} range", i, target_type),
)
}
} else {
flagged_no_coerce(
value,
path,
"float",
target_type,
RiskLevel::Risky,
"float has fractional part; truncation would lose data",
)
}
} else {
no_coercion(value)
}
}
(Value::Number(n), "f32" | "f64") if level >= CoercionLevel::SafeWidening => {
if let Some(i) = n.as_i64() {
let f = i as f64;
match serde_json::Number::from_f64(f) {
Some(num) => {
let lossless = f == (i as f64) && (i.unsigned_abs() <= (1_u64 << 53));
let risk = if lossless {
RiskLevel::Info
} else {
RiskLevel::Warning
};
let suggestion = if lossless {
None
} else {
Some("integer exceeds f64 exact range (2^53); precision may be lost")
};
coerced(
Value::Number(num),
path,
"integer",
target_type,
risk,
suggestion,
)
}
None => no_coercion(value),
}
} else if let Some(u) = n.as_u64() {
let f = u as f64;
match serde_json::Number::from_f64(f) {
Some(num) => {
let lossless = u <= (1_u64 << 53);
let risk = if lossless {
RiskLevel::Info
} else {
RiskLevel::Warning
};
let suggestion = if lossless {
None
} else {
Some("integer exceeds f64 exact range (2^53); precision may be lost")
};
coerced(
Value::Number(num),
path,
"integer",
target_type,
risk,
suggestion,
)
}
None => no_coercion(value),
}
} else if target_type == "f32" {
if let Some(f) = n.as_f64() {
let as_f32 = f as f32;
if as_f32.is_infinite() && f.is_finite() {
CoercionResult {
value: Value::Null,
coerced: false,
diagnostic: Some(Diagnostic {
path: path.to_string(),
kind: DiagnosticKind::Coerced {
from: "f64".into(),
to: "f32".into(),
},
risk: RiskLevel::Risky,
suggestion: Some(format!(
"value {:.6e} overflows f32 range (max ~3.4e38); use f64",
f
)),
}),
}
} else if as_f32 == 0.0 && f != 0.0 {
CoercionResult {
value: Value::Null,
coerced: false,
diagnostic: Some(Diagnostic {
path: path.to_string(),
kind: DiagnosticKind::Coerced {
from: "f64".into(),
to: "f32".into(),
},
risk: RiskLevel::Warning,
suggestion: Some(format!(
"value {:.6e} underflows f32 (too small to represent); use f64",
f
)),
}),
}
} else {
no_coercion(value)
}
} else {
no_coercion(value)
}
} else {
no_coercion(value)
}
}
(Value::Object(_) | Value::Array(_), "String" | "string")
if level >= CoercionLevel::BestEffort =>
{
let json_str = serde_json::to_string(value).unwrap_or_default();
coerced(
Value::String(json_str),
path,
"object/array",
"String",
RiskLevel::Warning,
Some("complex value serialized to JSON string; consider using a structured type"),
)
}
(Value::Null, target) if level >= CoercionLevel::BestEffort => {
let default_val = match target {
"i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "isize" | "usize" => {
Value::Number(0.into())
}
"f32" | "f64" => match serde_json::Number::from_f64(0.0) {
Some(n) => Value::Number(n),
None => return no_coercion(value),
},
"bool" => Value::Bool(false),
"String" | "string" => Value::String(String::new()),
_ => return no_coercion(value),
};
defaulted(
default_val,
path,
"null → default",
Some("null was replaced with a default value; consider making this field Optional"),
)
}
(Value::String(s), target)
if level >= CoercionLevel::BestEffort && target != "String" && target != "string" =>
{
if let Ok(parsed) = serde_json::from_str::<Value>(s) {
if parsed.is_object() || parsed.is_array() {
return coerced(
parsed,
path,
"stringified JSON",
"parsed value",
RiskLevel::Warning,
Some(
"source embeds JSON as a string; consider fixing the upstream to send structured data",
),
);
}
}
no_coercion(value)
}
(Value::Array(arr), _) if arr.len() == 1 && level >= CoercionLevel::BestEffort => coerced(
arr[0].clone(),
path,
"single-element array",
"scalar",
RiskLevel::Warning,
Some("array with one element was unwrapped to scalar; verify this is intentional"),
),
(Value::Array(arr), target) if target.starts_with("Vec<") && target.ends_with('>') => {
let inner_type = &target[4..target.len() - 1];
let mut coerced_any = false;
let mut all_diagnostics: Vec<Diagnostic> = Vec::new();
let coerced_elements: Vec<Value> = arr
.iter()
.enumerate()
.map(|(idx, elem)| {
let elem_path = format!("{}[{}]", path, idx);
let result = coerce_value(elem, inner_type, level, &elem_path);
if result.coerced {
coerced_any = true;
}
if let Some(d) = result.diagnostic {
all_diagnostics.push(d);
}
result.value
})
.collect();
if coerced_any {
let first = all_diagnostics.first().cloned();
if all_diagnostics.len() > 1 {
let paths: Vec<String> =
all_diagnostics.iter().map(|d| d.path.clone()).collect();
CoercionResult {
value: Value::Array(coerced_elements),
coerced: true,
diagnostic: Some(Diagnostic {
path: path.to_string(),
kind: DiagnosticKind::Coerced {
from: "mixed array".into(),
to: inner_type.into(),
},
risk: RiskLevel::Warning,
suggestion: Some(format!(
"{} elements coerced at: {}",
paths.len(),
paths.join(", ")
)),
}),
}
} else {
CoercionResult {
value: Value::Array(coerced_elements),
coerced: true,
diagnostic: first,
}
}
} else {
no_coercion(value)
}
}
_ => no_coercion(value),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn string_to_integer() {
let result = coerce_value(
&Value::String("42".into()),
"i64",
CoercionLevel::StringCoercion,
"test",
);
assert!(result.coerced);
assert_eq!(result.value, Value::Number(42.into()));
}
#[test]
fn string_to_float() {
let result = coerce_value(
&Value::String("3.14".into()),
"f64",
CoercionLevel::StringCoercion,
"test",
);
assert!(result.coerced);
}
#[test]
fn string_to_bool_true() {
let result = coerce_value(
&Value::String("true".into()),
"bool",
CoercionLevel::StringCoercion,
"test",
);
assert!(result.coerced);
assert_eq!(result.value, Value::Bool(true));
}
#[test]
fn string_to_bool_yes() {
let result = coerce_value(
&Value::String("yes".into()),
"bool",
CoercionLevel::StringCoercion,
"test",
);
assert!(result.coerced);
assert_eq!(result.value, Value::Bool(true));
}
#[test]
fn number_to_string() {
let result = coerce_value(
&Value::Number(42.into()),
"String",
CoercionLevel::StringCoercion,
"test",
);
assert!(result.coerced);
assert_eq!(result.value, Value::String("42".into()));
}
#[test]
fn lossless_float_to_int() {
let v = serde_json::Number::from_f64(3.0).unwrap();
let result = coerce_value(
&Value::Number(v),
"i64",
CoercionLevel::SafeWidening,
"test",
);
assert!(result.coerced);
assert_eq!(result.value, Value::Number(3.into()));
}
#[test]
fn lossy_float_to_int_flagged() {
let v = serde_json::Number::from_f64(3.7).unwrap();
let result = coerce_value(
&Value::Number(v),
"i64",
CoercionLevel::SafeWidening,
"test",
);
assert!(!result.coerced);
assert!(result.diagnostic.is_some());
assert_eq!(result.diagnostic.unwrap().risk, RiskLevel::Risky);
}
#[test]
fn null_to_default_int() {
let result = coerce_value(&Value::Null, "i64", CoercionLevel::BestEffort, "test");
assert!(result.coerced);
assert_eq!(result.value, Value::Number(0.into()));
}
#[test]
fn null_to_default_string() {
let result = coerce_value(&Value::Null, "String", CoercionLevel::BestEffort, "test");
assert!(result.coerced);
assert_eq!(result.value, Value::String(String::new()));
}
#[test]
fn stringified_json() {
let result = coerce_value(
&Value::String(r#"{"a":1}"#.into()),
"object",
CoercionLevel::BestEffort,
"test",
);
assert!(result.coerced);
assert!(result.value.is_object());
}
#[test]
fn single_element_array() {
let result = coerce_value(
&Value::Array(vec![Value::Number(42.into())]),
"i64",
CoercionLevel::BestEffort,
"test",
);
assert!(result.coerced);
assert_eq!(result.value, Value::Number(42.into()));
}
#[test]
fn exact_mode_no_coercion() {
let result = coerce_value(
&Value::String("42".into()),
"i64",
CoercionLevel::Exact,
"test",
);
assert!(!result.coerced);
assert_eq!(result.value, Value::String("42".into()));
}
#[test]
fn int_one_to_bool_true() {
let result = coerce_value(
&Value::Number(1.into()),
"bool",
CoercionLevel::SafeWidening,
"test",
);
assert!(result.coerced);
assert_eq!(result.value, Value::Bool(true));
}
#[test]
fn int_zero_to_bool_false() {
let result = coerce_value(
&Value::Number(0.into()),
"bool",
CoercionLevel::SafeWidening,
"test",
);
assert!(result.coerced);
assert_eq!(result.value, Value::Bool(false));
}
#[test]
fn int_other_to_bool_risky() {
let result = coerce_value(
&Value::Number(42.into()),
"bool",
CoercionLevel::SafeWidening,
"test",
);
assert!(!result.coerced);
assert!(result.diagnostic.is_some());
assert_eq!(result.diagnostic.unwrap().risk, RiskLevel::Risky);
}
#[test]
fn bool_to_string() {
let result = coerce_value(
&Value::Bool(true),
"String",
CoercionLevel::StringCoercion,
"test",
);
assert!(result.coerced);
assert_eq!(result.value, Value::String("true".into()));
}
#[test]
fn coerce_for_primitive() {
let result = coerce_for::<u16>(
&Value::String("8080".into()),
CoercionLevel::BestEffort,
"port",
);
assert!(result.coerced);
assert_eq!(result.value, Value::Number(8080.into()));
}
#[test]
fn coerce_for_bool() {
let result = coerce_for::<bool>(
&Value::String("yes".into()),
CoercionLevel::BestEffort,
"flag",
);
assert!(result.coerced);
assert_eq!(result.value, Value::Bool(true));
}
#[test]
fn coerce_for_option_uses_inner_hint() {
let result = coerce_for::<Option<u32>>(
&Value::String("42".into()),
CoercionLevel::BestEffort,
"val",
);
assert!(result.coerced);
assert_eq!(result.value, Value::Number(42.into()));
}
#[test]
fn coerce_for_vec_no_coercion() {
let arr = Value::Array(vec![Value::String("a".into())]);
let result = coerce_for::<Vec<String>>(&arr, CoercionLevel::BestEffort, "val");
assert!(!result.coerced);
}
#[test]
fn coerce_for_value_no_coercion() {
let result = coerce_for::<serde_json::Value>(
&Value::Number(42.into()),
CoercionLevel::BestEffort,
"val",
);
assert!(!result.coerced);
}
#[test]
fn coerce_for_string_from_number() {
let result =
coerce_for::<String>(&Value::Number(42.into()), CoercionLevel::BestEffort, "val");
assert!(result.coerced);
assert_eq!(result.value, Value::String("42".into()));
}
}