Sorted keys

This commit is contained in:
Joe 2025-12-09 00:28:13 -06:00
parent d08780fc14
commit 929bc254fa
16 changed files with 145 additions and 36 deletions

View file

@ -3,23 +3,23 @@ source: tests/tests/syncback.rs
expression: container.model.json
---
{
"className": "Folder",
"children": [
{
"name": "value_1",
"className": "ObjectValue",
"attributes": {
"Rojo_Id": "value_1",
"Rojo_Target_Value": "value_1"
}
},
"className": "ObjectValue",
"name": "value_1"
},
{
"name": "value_2",
"className": "ObjectValue",
"attributes": {
"Rojo_Id": "72bc28150ada2e6206442ee300004084",
"Rojo_Target_Value": "72bc28150ada2e6206442ee300004084"
},
"className": "ObjectValue",
"name": "value_2"
}
}
]
],
"className": "Folder"
}

View file

@ -3,10 +3,10 @@ source: tests/tests/syncback.rs
expression: src/dir_with_meta/init.meta.json
---
{
"className": "Configuration",
"properties": {
"Tags": [
"This tag on dir_with_meta"
]
},
"className": "Configuration"
}
}

View file

@ -3,8 +3,8 @@ source: tests/tests/syncback.rs
expression: src/pointer.model.json
---
{
"className": "ObjectValue",
"attributes": {
"Rojo_Target_Value": "test referent id"
}
},
"className": "ObjectValue"
}

View file

@ -3,8 +3,8 @@ source: tests/tests/syncback.rs
expression: src/target.model.json
---
{
"className": "Folder",
"attributes": {
"Rojo_Id": "test referent id"
}
},
"className": "Folder"
}

View file

@ -3,8 +3,8 @@ source: tests/tests/syncback.rs
expression: src/pointer.model.json
---
{
"className": "ObjectValue",
"attributes": {
"Rojo_Target_Value": "62e89c49e4f800c20629c71b00003fc0"
}
},
"className": "ObjectValue"
}

View file

@ -1,11 +1,10 @@
---
source: tests/tests/syncback.rs
assertion_line: 28
expression: src/Pointer_2.model.json
---
{
"className": "ObjectValue",
"attributes": {
"Rojo_Target_Value": "0bd3e9e11879191708a2bc4a00000c20"
}
},
"className": "ObjectValue"
}

View file

@ -1,11 +1,10 @@
---
source: tests/tests/syncback.rs
assertion_line: 28
expression: src/Target_2.model.json
---
{
"className": "Folder",
"attributes": {
"Rojo_Id": "0bd3e9e11879191708a2bc4a00000c20"
}
},
"className": "Folder"
}

View file

@ -5,12 +5,12 @@ expression: default.project.json
{
"name": "respect_old_middleware",
"tree": {
"$path": "src",
"project_node": {
"$className": "BoolValue",
"$properties": {
"Value": true
}
},
"$path": "src"
}
}
}

View file

@ -4,6 +4,9 @@ expression: default.project.json
---
{
"name": "unscriptable_properties",
"syncbackRules": {
"syncUnscriptable": true
},
"tree": {
"$className": "BinaryStringValue",
"$properties": {
@ -11,8 +14,5 @@ expression: default.project.json
"BinaryString": "Rojo/is/cool"
}
}
},
"syncbackRules": {
"syncUnscriptable": true
}
}

View file

@ -4,7 +4,7 @@
//! to reduce boilerplate and improve ergonomics when working with JSONC files.
use anyhow::Context as _;
use serde::de::DeserializeOwned;
use serde::{de::DeserializeOwned, Serialize};
/// Parse JSONC text into a `serde_json::Value`.
///
@ -131,6 +131,55 @@ pub fn from_slice_with_context<T: DeserializeOwned>(
from_str_with_context(text, context)
}
/// Recursively sorts all object keys in a JSON value for deterministic output.
fn sort_json_value(value: serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let sorted: serde_json::Map<String, serde_json::Value> = map
.into_iter()
.map(|(k, v)| (k, sort_json_value(v)))
.collect::<Vec<_>>()
.into_iter()
.collect::<std::collections::BTreeMap<_, _>>()
.into_iter()
.collect();
serde_json::Value::Object(sorted)
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.into_iter().map(sort_json_value).collect())
}
other => other,
}
}
/// Serialize a value to a pretty-printed JSON byte vector with sorted keys.
///
/// This ensures deterministic output by sorting all object keys alphabetically,
/// which is especially useful for version control and syncback operations.
///
/// # Errors
///
/// Returns an error if the value cannot be serialized to JSON.
pub fn to_vec_pretty_sorted<T: Serialize>(value: &T) -> anyhow::Result<Vec<u8>> {
let json_value = serde_json::to_value(value).context("Failed to convert to JSON value")?;
let sorted_value = sort_json_value(json_value);
serde_json::to_vec_pretty(&sorted_value).context("Failed to serialize to JSON")
}
/// Serialize a value to a pretty-printed JSON string with sorted keys.
///
/// This ensures deterministic output by sorting all object keys alphabetically,
/// which is especially useful for version control and syncback operations.
///
/// # Errors
///
/// Returns an error if the value cannot be serialized to JSON.
pub fn to_string_pretty_sorted<T: Serialize>(value: &T) -> anyhow::Result<String> {
let json_value = serde_json::to_value(value).context("Failed to convert to JSON value")?;
let sorted_value = sort_json_value(json_value);
serde_json::to_string_pretty(&sorted_value).context("Failed to serialize to JSON")
}
#[cfg(test)]
mod tests {
use super::*;
@ -310,4 +359,65 @@ mod tests {
assert!(err.to_string().contains("config.json"));
assert!(err.to_string().contains("UTF-8"));
}
#[test]
fn test_to_vec_pretty_sorted() {
use indexmap::IndexMap;
let mut map: IndexMap<String, i32> = IndexMap::new();
map.insert("zebra".to_string(), 1);
map.insert("apple".to_string(), 2);
map.insert("mango".to_string(), 3);
let result = to_vec_pretty_sorted(&map).unwrap();
let output = String::from_utf8(result).unwrap();
// Keys should be in alphabetical order
let apple_pos = output.find("apple").unwrap();
let mango_pos = output.find("mango").unwrap();
let zebra_pos = output.find("zebra").unwrap();
assert!(apple_pos < mango_pos);
assert!(mango_pos < zebra_pos);
}
#[test]
fn test_to_string_pretty_sorted() {
use indexmap::IndexMap;
let mut map: IndexMap<String, i32> = IndexMap::new();
map.insert("zebra".to_string(), 1);
map.insert("apple".to_string(), 2);
map.insert("mango".to_string(), 3);
let output = to_string_pretty_sorted(&map).unwrap();
// Keys should be in alphabetical order
let apple_pos = output.find("apple").unwrap();
let mango_pos = output.find("mango").unwrap();
let zebra_pos = output.find("zebra").unwrap();
assert!(apple_pos < mango_pos);
assert!(mango_pos < zebra_pos);
}
#[test]
fn test_sorted_nested_objects() {
use serde::Serialize;
#[derive(Serialize)]
struct Nested {
inner: std::collections::HashMap<String, i32>,
}
let mut inner = std::collections::HashMap::new();
inner.insert("z".to_string(), 1);
inner.insert("a".to_string(), 2);
let nested = Nested { inner };
let output = to_string_pretty_sorted(&nested).unwrap();
// Inner keys should also be sorted
let a_pos = output.find("\"a\"").unwrap();
let z_pos = output.find("\"z\"").unwrap();
assert!(a_pos < z_pos);
}
}

View file

@ -111,7 +111,7 @@ pub fn syncback_csv<'sync>(
let parent = snapshot.path.parent_err()?;
fs_snapshot.add_file(
parent.join(format!("{}.meta.json", new_inst.name)),
serde_json::to_vec_pretty(&meta).context("cannot serialize metadata")?,
crate::json::to_vec_pretty_sorted(&meta).context("cannot serialize metadata")?,
)
}
}
@ -149,7 +149,7 @@ pub fn syncback_csv_init<'sync>(
if !meta.is_empty() {
dir_syncback.fs_snapshot.add_file(
snapshot.path.join("init.meta.json"),
serde_json::to_vec_pretty(&meta)
crate::json::to_vec_pretty_sorted(&meta)
.context("could not serialize new init.meta.json")?,
);
}

View file

@ -106,7 +106,7 @@ pub fn syncback_dir<'sync>(
if !meta.is_empty() {
dir_syncback.fs_snapshot.add_file(
snapshot.path.join("init.meta.json"),
serde_json::to_vec_pretty(&meta)
crate::json::to_vec_pretty_sorted(&meta)
.context("could not serialize new init.meta.json")?,
);
}

View file

@ -86,7 +86,8 @@ pub fn syncback_json_model<'sync>(
Ok(SyncbackReturn {
fs_snapshot: FsSnapshot::new().with_added_file(
&snapshot.path,
serde_json::to_vec_pretty(&model).context("failed to serialize new JSON Model")?,
crate::json::to_vec_pretty_sorted(&model)
.context("failed to serialize new JSON Model")?,
),
children: Vec::new(),
removed_children: Vec::new(),

View file

@ -160,7 +160,7 @@ pub fn syncback_lua<'sync>(
let parent_location = snapshot.path.parent_err()?;
fs_snapshot.add_file(
parent_location.join(format!("{}.meta.json", new_inst.name)),
serde_json::to_vec_pretty(&meta).context("cannot serialize metadata")?,
crate::json::to_vec_pretty_sorted(&meta).context("cannot serialize metadata")?,
);
}
}
@ -203,7 +203,7 @@ pub fn syncback_lua_init<'sync>(
if !meta.is_empty() {
dir_syncback.fs_snapshot.add_file(
snapshot.path.join("init.meta.json"),
serde_json::to_vec_pretty(&meta)
crate::json::to_vec_pretty_sorted(&meta)
.context("could not serialize new init.meta.json")?,
);
}

View file

@ -509,7 +509,7 @@ pub fn syncback_project<'sync>(
for (node_properties, node_attributes, old_inst) in node_changed_map {
if project_node_should_reserialize(node_properties, node_attributes, old_inst)? {
fs_snapshot.add_file(project_path, serde_json::to_vec_pretty(&project)?);
fs_snapshot.add_file(project_path, crate::json::to_vec_pretty_sorted(&project)?);
break;
}
}

View file

@ -60,7 +60,7 @@ pub fn syncback_txt<'sync>(
let parent = snapshot.path.parent_err()?;
fs_snapshot.add_file(
parent.join(format!("{}.meta.json", new_inst.name)),
serde_json::to_vec_pretty(&meta).context("could not serialize metadata")?,
crate::json::to_vec_pretty_sorted(&meta).context("could not serialize metadata")?,
);
}
}