//! Small helpers that reproduce the JavaScript semantics the 0.1.0 disk model relies on //! (`Number()`, truthiness, `String()`), so the Rust port produces identical plans. use serde_json::Value; /// JavaScript `Number(value)`. pub fn number(value: &Value) -> f64 { match value { Value::Null => 0.0, Value::Bool(b) => f64::from(u8::from(*b)), Value::Number(n) => n.as_f64().unwrap_or(f64::NAN), Value::String(s) => number_str(s), Value::Array(a) if a.is_empty() => 0.0, Value::Array(a) if a.len() == 1 => number(&a[0]), _ => f64::NAN, } } /// JavaScript `Number(text)`. pub fn number_str(text: &str) -> f64 { let t = text.trim(); if t.is_empty() { return 0.0; } if let Some(hex) = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")) { return u64::from_str_radix(hex, 16).map(|v| v as f64).unwrap_or(f64::NAN); } if t == "Infinity" || t == "+Infinity" { return f64::INFINITY; } if t == "-Infinity" { return f64::NEG_INFINITY; } if t.chars().any(|c| !(c.is_ascii_digit() || matches!(c, '.' | 'e' | 'E' | '+' | '-'))) { return f64::NAN; } t.parse::().unwrap_or(f64::NAN) } /// Undefined-aware `Number(x)` for optional values (`undefined` → NaN). pub fn number_opt(value: Option<&Value>) -> f64 { value.map(number).unwrap_or(f64::NAN) } /// 0.1.0 `int()`: `""`, `null`, `undefined` and non-finite → null. pub fn int_opt(value: Option<&Value>) -> Value { match value { None | Some(Value::Null) => Value::Null, Some(Value::String(s)) if s.is_empty() => Value::Null, Some(v) => num(number(v)), } } pub fn int_str(text: Option<&str>) -> Value { match text { None => Value::Null, Some("") => Value::Null, Some(t) => num(number_str(t)), } } /// JSON number the way `JSON.stringify` writes it (integers without a fraction, NaN → null). pub fn num(value: f64) -> Value { if !value.is_finite() { return Value::Null; } if value.fract() == 0.0 && value.abs() < 9.007_199_254_740_992e15 { return Value::from(value as i64); } serde_json::Number::from_f64(value).map(Value::Number).unwrap_or(Value::Null) } pub fn truthy(value: &Value) -> bool { match value { Value::Null => false, Value::Bool(b) => *b, Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0 && !f.is_nan()), Value::String(s) => !s.is_empty(), _ => true, } } pub fn truthy_opt(value: Option<&Value>) -> bool { value.is_some_and(truthy) } /// JavaScript `String(value ?? "")`. pub fn string(value: Option<&Value>) -> String { match value { None | Some(Value::Null) => String::new(), Some(Value::String(s)) => s.clone(), Some(Value::Number(n)) => match n.as_f64() { Some(f) if f.fract() == 0.0 && f.abs() < 1e21 => format!("{}", f as i64), Some(f) => format!("{f}"), None => n.to_string(), }, Some(Value::Bool(b)) => b.to_string(), Some(Value::Array(a)) => a.iter().map(|v| string(Some(v))).collect::>().join(","), Some(Value::Object(_)) => "[object Object]".into(), } } /// `String(value)` for a step argument (numbers without trailing `.0`). pub fn arg(value: &Value) -> String { string(Some(value)) } pub fn num_string(value: f64) -> String { if value.is_nan() { return "NaN".into(); } if value.is_infinite() { return if value > 0.0 { "Infinity".into() } else { "-Infinity".into() }; } string(Some(&num(value))) } /// 0.1.0 `text(value, limit)`: control characters → space, trim, then cut to `limit` UTF-16 units. pub fn text(value: &str, limit: usize) -> String { let replaced: String = value.chars().map(|c| if (c as u32) < 0x20 || c as u32 == 0x7f { ' ' } else { c }).collect(); cut(replaced.trim(), limit) } pub fn cut(value: &str, limit: usize) -> String { let mut out = String::new(); let mut units = 0; for c in value.chars() { units += c.len_utf16(); if units > limit { break; } out.push(c); } out } /// `text(...) || null`. pub fn text_or_null(value: &str, limit: usize) -> Value { let t = text(value, limit); if t.is_empty() { Value::Null } else { Value::String(t) } } pub fn lines(value: &str) -> Vec<&str> { value.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).filter(|l| !l.is_empty()).collect() } /// `a.localeCompare(b, "en", { numeric: true })` for device names. pub fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering { fn chunks(s: &str) -> Vec<(bool, String)> { let mut out: Vec<(bool, String)> = Vec::new(); for c in s.chars() { let digit = c.is_ascii_digit(); match out.last_mut() { Some((d, chunk)) if *d == digit => chunk.push(c), _ => out.push((digit, c.to_string())), } } out } let (ca, cb) = (chunks(a), chunks(b)); for (x, y) in ca.iter().zip(cb.iter()) { let ordering = match (x.0, y.0) { (true, true) => { let (nx, ny) = (x.1.trim_start_matches('0'), y.1.trim_start_matches('0')); nx.len().cmp(&ny.len()).then_with(|| nx.cmp(ny)) } // ICU sorts digits before letters. (true, false) => std::cmp::Ordering::Less, (false, true) => std::cmp::Ordering::Greater, _ => x.1.to_lowercase().cmp(&y.1.to_lowercase()), }; if ordering != std::cmp::Ordering::Equal { return ordering; } } ca.len().cmp(&cb.len()).then_with(|| a.cmp(b)) } /// Deep equality where numbers compare by value (JSON written by JavaScript vs. Rust). pub fn json_equal(a: &Value, b: &Value) -> bool { match (a, b) { (Value::Number(x), Value::Number(y)) => x.as_f64() == y.as_f64(), (Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| json_equal(p, q)), (Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| json_equal(v, w))), _ => a == b, } } /// First path where two JSON values differ (test diagnostics). pub fn first_difference(a: &Value, b: &Value, path: &str) -> Option { match (a, b) { (Value::Object(x), Value::Object(y)) => { for (k, v) in x { match y.get(k) { None => return Some(format!("{path}.{k}: missing in second ({v})")), Some(w) => { if let Some(d) = first_difference(v, w, &format!("{path}.{k}")) { return Some(d); } } } } y.keys().find(|k| !x.contains_key(*k)).map(|k| format!("{path}.{k}: missing in first ({})", y[k])) } (Value::Array(x), Value::Array(y)) => { if x.len() != y.len() { return Some(format!("{path}: length {} vs {}", x.len(), y.len())); } x.iter().zip(y).enumerate().find_map(|(i, (p, q))| first_difference(p, q, &format!("{path}[{i}]"))) } _ if json_equal(a, b) => None, _ => Some(format!("{path}: {a} vs {b}")), } }