From 929bc254face764f65ec8a014ee8df6858020ba2 Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 9 Dec 2025 00:28:13 -0600 Subject: [PATCH] Sorted keys --- ...uplicate_rojo_id-container.model.json.snap | 16 +-- ...es-src__dir_with_meta__init.meta.json.snap | 4 +- ...ef_properties-src__pointer.model.json.snap | 4 +- ...ref_properties-src__target.model.json.snap | 4 +- ...perties_blank-src__pointer.model.json.snap | 4 +- ...es_conflict-src__Pointer_2.model.json.snap | 5 +- ...ies_conflict-src__Target_2.model.json.snap | 5 +- ...t_old_middleware-default.project.json.snap | 4 +- ...table_properties-default.project.json.snap | 6 +- src/json.rs | 112 +++++++++++++++++- src/snapshot_middleware/csv.rs | 4 +- src/snapshot_middleware/dir.rs | 2 +- src/snapshot_middleware/json_model.rs | 3 +- src/snapshot_middleware/lua.rs | 4 +- src/snapshot_middleware/project.rs | 2 +- src/snapshot_middleware/txt.rs | 2 +- 16 files changed, 145 insertions(+), 36 deletions(-) diff --git a/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__duplicate_rojo_id-container.model.json.snap b/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__duplicate_rojo_id-container.model.json.snap index b15329a2..54cd1761 100644 --- a/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__duplicate_rojo_id-container.model.json.snap +++ b/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__duplicate_rojo_id-container.model.json.snap @@ -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" } diff --git a/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__json_middlewares-src__dir_with_meta__init.meta.json.snap b/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__json_middlewares-src__dir_with_meta__init.meta.json.snap index 8c56c92c..a4e8492e 100644 --- a/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__json_middlewares-src__dir_with_meta__init.meta.json.snap +++ b/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__json_middlewares-src__dir_with_meta__init.meta.json.snap @@ -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" + } } diff --git a/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties-src__pointer.model.json.snap b/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties-src__pointer.model.json.snap index 3108d5c0..9db37ba7 100644 --- a/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties-src__pointer.model.json.snap +++ b/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties-src__pointer.model.json.snap @@ -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" } diff --git a/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties-src__target.model.json.snap b/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties-src__target.model.json.snap index a3aa961b..71646539 100644 --- a/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties-src__target.model.json.snap +++ b/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties-src__target.model.json.snap @@ -3,8 +3,8 @@ source: tests/tests/syncback.rs expression: src/target.model.json --- { - "className": "Folder", "attributes": { "Rojo_Id": "test referent id" - } + }, + "className": "Folder" } diff --git a/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties_blank-src__pointer.model.json.snap b/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties_blank-src__pointer.model.json.snap index 76186e98..3adef467 100644 --- a/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties_blank-src__pointer.model.json.snap +++ b/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties_blank-src__pointer.model.json.snap @@ -3,8 +3,8 @@ source: tests/tests/syncback.rs expression: src/pointer.model.json --- { - "className": "ObjectValue", "attributes": { "Rojo_Target_Value": "62e89c49e4f800c20629c71b00003fc0" - } + }, + "className": "ObjectValue" } diff --git a/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties_conflict-src__Pointer_2.model.json.snap b/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties_conflict-src__Pointer_2.model.json.snap index 555c1851..0bce5aca 100644 --- a/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties_conflict-src__Pointer_2.model.json.snap +++ b/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties_conflict-src__Pointer_2.model.json.snap @@ -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" } diff --git a/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties_conflict-src__Target_2.model.json.snap b/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties_conflict-src__Target_2.model.json.snap index 2314c3d3..c64f9b31 100644 --- a/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties_conflict-src__Target_2.model.json.snap +++ b/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties_conflict-src__Target_2.model.json.snap @@ -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" } diff --git a/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__respect_old_middleware-default.project.json.snap b/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__respect_old_middleware-default.project.json.snap index 315941cd..968db59d 100644 --- a/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__respect_old_middleware-default.project.json.snap +++ b/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__respect_old_middleware-default.project.json.snap @@ -5,12 +5,12 @@ expression: default.project.json { "name": "respect_old_middleware", "tree": { + "$path": "src", "project_node": { "$className": "BoolValue", "$properties": { "Value": true } - }, - "$path": "src" + } } } diff --git a/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__unscriptable_properties-default.project.json.snap b/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__unscriptable_properties-default.project.json.snap index 236064e3..d7a1229d 100644 --- a/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__unscriptable_properties-default.project.json.snap +++ b/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__unscriptable_properties-default.project.json.snap @@ -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 } } diff --git a/src/json.rs b/src/json.rs index 5a48cfc4..317d477a 100644 --- a/src/json.rs +++ b/src/json.rs @@ -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( 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 = map + .into_iter() + .map(|(k, v)| (k, sort_json_value(v))) + .collect::>() + .into_iter() + .collect::>() + .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(value: &T) -> anyhow::Result> { + 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(value: &T) -> anyhow::Result { + 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 = 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 = 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, + } + + 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); + } } diff --git a/src/snapshot_middleware/csv.rs b/src/snapshot_middleware/csv.rs index 75a043d1..d55c0c55 100644 --- a/src/snapshot_middleware/csv.rs +++ b/src/snapshot_middleware/csv.rs @@ -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")?, ); } diff --git a/src/snapshot_middleware/dir.rs b/src/snapshot_middleware/dir.rs index b825542d..b3af1cb0 100644 --- a/src/snapshot_middleware/dir.rs +++ b/src/snapshot_middleware/dir.rs @@ -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")?, ); } diff --git a/src/snapshot_middleware/json_model.rs b/src/snapshot_middleware/json_model.rs index d19792cb..295921d3 100644 --- a/src/snapshot_middleware/json_model.rs +++ b/src/snapshot_middleware/json_model.rs @@ -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(), diff --git a/src/snapshot_middleware/lua.rs b/src/snapshot_middleware/lua.rs index eaa90952..5ed5fe35 100644 --- a/src/snapshot_middleware/lua.rs +++ b/src/snapshot_middleware/lua.rs @@ -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")?, ); } diff --git a/src/snapshot_middleware/project.rs b/src/snapshot_middleware/project.rs index c114ac5d..9540d764 100644 --- a/src/snapshot_middleware/project.rs +++ b/src/snapshot_middleware/project.rs @@ -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; } } diff --git a/src/snapshot_middleware/txt.rs b/src/snapshot_middleware/txt.rs index 715354e0..f935b09b 100644 --- a/src/snapshot_middleware/txt.rs +++ b/src/snapshot_middleware/txt.rs @@ -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")?, ); } }