use serde_json::{Map, Value};
use crate::tools::merge_default_tools;
pub type JsonObject = Map<String, Value>;
#[must_use]
pub fn deep_merge_objects(base: &JsonObject, overrides: &JsonObject) -> JsonObject {
let mut result = base.clone();
for (key, override_value) in overrides {
let merged = match (base.get(key), override_value) {
(Some(Value::Object(base_obj)), Value::Object(override_obj)) => {
Value::Object(deep_merge_objects(base_obj, override_obj))
}
_ => override_value.clone(),
};
result.insert(key.clone(), merged);
}
result
}
#[must_use]
pub fn deep_merge_settings(base: &JsonObject, overrides: &JsonObject) -> JsonObject {
let mut merged = deep_merge_objects(base, overrides);
if let Some(tools) =
merge_default_tools(base.get("defaultTools"), overrides.get("defaultTools"))
{
merged.insert("defaultTools".to_owned(), tools);
}
merged
}
pub fn migrate_settings(settings: &mut JsonObject) {
if !settings.contains_key("steeringMode")
&& let Some(mode) = settings.shift_remove("queueMode")
{
settings.insert("steeringMode".to_owned(), mode);
}
if !settings.contains_key("transport")
&& let Some(Value::Bool(websockets)) = settings.get("websockets").cloned()
{
let transport = if websockets { "websocket" } else { "sse" };
settings.insert("transport".to_owned(), Value::from(transport));
settings.shift_remove("websockets");
}
if let Some(Value::Object(skills)) = settings.get("skills").cloned() {
if let Some(enable) = skills.get("enableSkillCommands")
&& !settings.contains_key("enableSkillCommands")
{
settings.insert("enableSkillCommands".to_owned(), enable.clone());
}
match skills.get("customDirectories") {
Some(Value::Array(dirs)) if !dirs.is_empty() => {
settings.insert("skills".to_owned(), Value::Array(dirs.clone()));
}
_ => {
settings.shift_remove("skills");
}
}
}
if let Some(Value::Object(retry)) = settings.get_mut("retry") {
let provider_missing_delay = match retry.get("provider") {
Some(Value::Object(provider)) => {
matches!(provider.get("maxRetryDelayMs"), None | Some(Value::Null))
}
_ => true,
};
if let Some(delay @ Value::Number(_)) = retry.get("maxDelayMs").cloned()
&& provider_missing_delay
{
let mut provider = match retry.get("provider") {
Some(Value::Object(provider)) => provider.clone(),
_ => JsonObject::new(),
};
provider.insert("maxRetryDelayMs".to_owned(), delay);
retry.insert("provider".to_owned(), Value::Object(provider));
}
retry.shift_remove("maxDelayMs");
}
}
#[must_use]
pub fn get_path<'a>(object: &'a JsonObject, path: &[&str]) -> Option<&'a Value> {
let (first, rest) = path.split_first()?;
let mut current = object.get(*first)?;
for key in rest {
current = current.as_object()?.get(*key)?;
}
Some(current)
}
pub fn set_path(object: &mut JsonObject, path: &[&str], value: Option<Value>) {
let Some((last, parents)) = path.split_last() else {
return;
};
let mut current = object;
for key in parents {
let entry = current
.entry((*key).to_owned())
.or_insert_with(|| Value::Object(JsonObject::new()));
if !entry.is_object() {
if value.is_none() {
return;
}
*entry = Value::Object(JsonObject::new());
}
let Value::Object(next) = entry else {
return;
};
current = next;
}
match value {
Some(value) => {
current.insert((*last).to_owned(), value);
}
None => {
current.shift_remove(*last);
}
}
}
#[must_use]
pub fn strip_bom(content: &str) -> &str {
content.strip_prefix('\u{feff}').unwrap_or(content)
}
#[must_use]
pub fn js_string(value: &Value) -> String {
match value {
Value::Null => "null".to_owned(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => js_number(n),
Value::String(s) => s.clone(),
Value::Array(items) => items
.iter()
.map(|item| match item {
Value::Null => String::new(),
other => js_string(other),
})
.collect::<Vec<_>>()
.join(","),
Value::Object(_) => "[object Object]".to_owned(),
}
}
fn js_number(n: &serde_json::Number) -> String {
if let Some(i) = n.as_i64() {
return i.to_string();
}
if let Some(u) = n.as_u64() {
return u.to_string();
}
match n.as_f64() {
#[allow(
clippy::cast_possible_truncation,
reason = "integral f64 within i64 range is printed like JS"
)]
Some(f) if f.fract() == 0.0 && f.abs() < 1e21 => format!("{}", f as i64),
Some(f) => f.to_string(),
None => n.to_string(),
}
}
#[must_use]
pub fn as_non_negative_u64(value: &Value) -> Option<u64> {
if let Some(u) = value.as_u64() {
return Some(u);
}
let f = value.as_f64()?;
if f.is_finite() && f >= 0.0 {
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "finite, non-negative, floored; saturates above u64::MAX"
)]
return Some(f.floor() as u64);
}
None
}
#[must_use]
pub fn as_safe_non_negative_integer(value: &Value) -> Option<u64> {
const MAX_SAFE_INTEGER: u64 = (1 << 53) - 1;
if let Some(u) = value.as_u64() {
return (u <= MAX_SAFE_INTEGER).then_some(u);
}
let f = value.as_f64()?;
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "range-checked against MAX_SAFE_INTEGER before the cast"
)]
if f.is_finite() && f >= 0.0 && f.fract() == 0.0 && f <= MAX_SAFE_INTEGER as f64 {
return Some(f as u64);
}
None
}
#[must_use]
pub fn string_array(value: &Value) -> Option<Vec<String>> {
value.as_array().map(|items| {
items
.iter()
.filter_map(|item| item.as_str().map(str::to_owned))
.collect()
})
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn obj(value: Value) -> JsonObject {
match value {
Value::Object(map) => map,
_ => unreachable!("test literal is an object"),
}
}
#[test]
fn deep_merge_recurses_objects_and_replaces_arrays() {
let base = obj(json!({"a": {"x": 1, "y": 2}, "arr": [1], "k": 1}));
let over = obj(json!({"a": {"y": 3, "z": 4}, "arr": [2], "n": null}));
let merged = deep_merge_objects(&base, &over);
assert_eq!(
Value::Object(merged),
json!({"a": {"x": 1, "y": 3, "z": 4}, "arr": [2], "k": 1, "n": null})
);
}
#[test]
fn migrations_match_pi() {
let mut s = obj(json!({
"queueMode": "all",
"websockets": true,
"skills": {"enableSkillCommands": false, "customDirectories": ["a"]},
"retry": {"maxDelayMs": 5, "provider": {"timeoutMs": 1}}
}));
migrate_settings(&mut s);
assert_eq!(
Value::Object(s),
json!({
"skills": ["a"],
"retry": {"provider": {"timeoutMs": 1, "maxRetryDelayMs": 5}},
"steeringMode": "all",
"transport": "websocket",
"enableSkillCommands": false
})
);
let mut kept =
obj(json!({"queueMode": "all", "steeringMode": "one-at-a-time", "skills": {}}));
migrate_settings(&mut kept);
assert_eq!(
Value::Object(kept),
json!({"queueMode": "all", "steeringMode": "one-at-a-time"})
);
}
#[test]
fn set_path_creates_and_removes() {
let mut s = obj(json!({"a": 1, "b": {"c": 2, "d": 3}}));
set_path(&mut s, &["b", "c"], None);
set_path(&mut s, &["x", "y"], Some(json!(true)));
set_path(&mut s, &["a"], None);
assert_eq!(Value::Object(s), json!({"b": {"d": 3}, "x": {"y": true}}));
}
#[test]
fn js_string_matches_javascript() {
assert_eq!(js_string(&json!(1.0)), "1");
assert_eq!(js_string(&json!(-1.5)), "-1.5");
assert_eq!(js_string(&json!({"a": 1})), "[object Object]");
assert_eq!(js_string(&json!([1, null, "x"])), "1,,x");
}
}