use serde_json::Value;
#[must_use]
pub fn canonicalize_json(value: &Value) -> String {
let mut buf = String::new();
write_canonical(value, &mut buf);
buf
}
pub fn canonicalize_json_str(json: &str) -> Result<String, serde_json::Error> {
let value: Value = serde_json::from_str(json)?;
Ok(canonicalize_json(&value))
}
fn write_canonical(value: &Value, buf: &mut String) {
match value {
Value::Null => buf.push_str("null"),
Value::Bool(b) => {
if *b {
buf.push_str("true");
} else {
buf.push_str("false");
}
}
Value::Number(n) => {
buf.push_str(&n.to_string());
}
Value::String(s) => {
buf.push_str(&serde_json::to_string(s).expect("string serialization cannot fail"));
}
Value::Array(arr) => {
buf.push('[');
for (i, item) in arr.iter().enumerate() {
if i > 0 {
buf.push(',');
}
write_canonical(item, buf);
}
buf.push(']');
}
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
buf.push('{');
for (i, key) in keys.iter().enumerate() {
if i > 0 {
buf.push(',');
}
buf.push_str(
&serde_json::to_string(key).expect("string serialization cannot fail"),
);
buf.push(':');
if let Some(val) = map.get(*key) {
write_canonical(val, buf);
}
}
buf.push('}');
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn null_value() {
assert_eq!(canonicalize_json(&json!(null)), "null");
}
#[test]
fn boolean_values() {
assert_eq!(canonicalize_json(&json!(true)), "true");
assert_eq!(canonicalize_json(&json!(false)), "false");
}
#[test]
fn integer_value() {
assert_eq!(canonicalize_json(&json!(42)), "42");
}
#[test]
fn float_value() {
assert_eq!(canonicalize_json(&json!(3.14)), "3.14");
}
#[test]
fn string_value() {
assert_eq!(canonicalize_json(&json!("hello")), "\"hello\"");
}
#[test]
fn string_with_escapes() {
assert_eq!(
canonicalize_json(&json!("he said \"hi\"")),
"\"he said \\\"hi\\\"\""
);
}
#[test]
fn empty_array() {
assert_eq!(canonicalize_json(&json!([])), "[]");
}
#[test]
fn array_preserves_order() {
assert_eq!(canonicalize_json(&json!([3, 1, 2])), "[3,1,2]");
}
#[test]
fn empty_object() {
assert_eq!(canonicalize_json(&json!({})), "{}");
}
#[test]
fn object_keys_sorted() {
let val = json!({"z": 1, "a": 2, "m": 3});
assert_eq!(canonicalize_json(&val), r#"{"a":2,"m":3,"z":1}"#);
}
#[test]
fn nested_object_keys_sorted() {
let val = json!({"z": 1, "a": {"c": 3, "b": 2}});
assert_eq!(canonicalize_json(&val), r#"{"a":{"b":2,"c":3},"z":1}"#);
}
#[test]
fn deeply_nested_sorting() {
let val = json!({
"b": {
"d": {
"f": 1,
"e": 2
},
"c": 3
},
"a": 4
});
assert_eq!(
canonicalize_json(&val),
r#"{"a":4,"b":{"c":3,"d":{"e":2,"f":1}}}"#
);
}
#[test]
fn array_of_objects_sorted() {
let val = json!([{"b": 1, "a": 2}, {"d": 3, "c": 4}]);
assert_eq!(canonicalize_json(&val), r#"[{"a":2,"b":1},{"c":4,"d":3}]"#);
}
#[test]
fn mixed_types() {
let val = json!({"num": 42, "str": "hello", "arr": [1, "two"], "nil": null, "bool": true});
assert_eq!(
canonicalize_json(&val),
r#"{"arr":[1,"two"],"bool":true,"nil":null,"num":42,"str":"hello"}"#
);
}
#[test]
fn no_whitespace() {
let val = json!({"key": "value"});
let result = canonicalize_json(&val);
assert!(!result.contains(' '));
assert!(!result.contains('\n'));
assert!(!result.contains('\t'));
}
#[test]
fn create_event_payload_canonical() {
let val = json!({
"title": "Fix auth retry",
"kind": "task",
"size": "m",
"labels": ["backend"]
});
assert_eq!(
canonicalize_json(&val),
r#"{"kind":"task","labels":["backend"],"size":"m","title":"Fix auth retry"}"#
);
}
#[test]
fn canonicalize_json_str_valid() {
let input = r#"{"z":1,"a":2}"#;
let result = canonicalize_json_str(input).expect("valid JSON");
assert_eq!(result, r#"{"a":2,"z":1}"#);
}
#[test]
fn canonicalize_json_str_invalid() {
let result = canonicalize_json_str("not json");
assert!(result.is_err());
}
#[test]
fn idempotent() {
let val = json!({"b": 1, "a": {"d": 2, "c": 3}});
let first = canonicalize_json(&val);
let reparsed: serde_json::Value = serde_json::from_str(&first).expect("parse");
let second = canonicalize_json(&reparsed);
assert_eq!(first, second);
}
#[test]
fn unicode_string() {
let val = json!({"emoji": "🎉", "cjk": "日本語"});
let result = canonicalize_json(&val);
assert!(result.contains("🎉"));
assert!(result.contains("日本語"));
}
}