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 expression: container.model.json
--- ---
{ {
"className": "Folder",
"children": [ "children": [
{ {
"name": "value_1",
"className": "ObjectValue",
"attributes": { "attributes": {
"Rojo_Id": "value_1", "Rojo_Id": "value_1",
"Rojo_Target_Value": "value_1" "Rojo_Target_Value": "value_1"
} },
"className": "ObjectValue",
"name": "value_1"
}, },
{ {
"name": "value_2",
"className": "ObjectValue",
"attributes": { "attributes": {
"Rojo_Id": "72bc28150ada2e6206442ee300004084", "Rojo_Id": "72bc28150ada2e6206442ee300004084",
"Rojo_Target_Value": "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 expression: src/dir_with_meta/init.meta.json
--- ---
{ {
"className": "Configuration",
"properties": { "properties": {
"Tags": [ "Tags": [
"This tag on dir_with_meta" "This tag on dir_with_meta"
] ]
}, }
"className": "Configuration"
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,7 +4,7 @@
//! to reduce boilerplate and improve ergonomics when working with JSONC files. //! to reduce boilerplate and improve ergonomics when working with JSONC files.
use anyhow::Context as _; use anyhow::Context as _;
use serde::de::DeserializeOwned; use serde::{de::DeserializeOwned, Serialize};
/// Parse JSONC text into a `serde_json::Value`. /// 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) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -310,4 +359,65 @@ mod tests {
assert!(err.to_string().contains("config.json")); assert!(err.to_string().contains("config.json"));
assert!(err.to_string().contains("UTF-8")); 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()?; let parent = snapshot.path.parent_err()?;
fs_snapshot.add_file( fs_snapshot.add_file(
parent.join(format!("{}.meta.json", new_inst.name)), 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() { if !meta.is_empty() {
dir_syncback.fs_snapshot.add_file( dir_syncback.fs_snapshot.add_file(
snapshot.path.join("init.meta.json"), 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")?, .context("could not serialize new init.meta.json")?,
); );
} }

View file

@ -106,7 +106,7 @@ pub fn syncback_dir<'sync>(
if !meta.is_empty() { if !meta.is_empty() {
dir_syncback.fs_snapshot.add_file( dir_syncback.fs_snapshot.add_file(
snapshot.path.join("init.meta.json"), 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")?, .context("could not serialize new init.meta.json")?,
); );
} }

View file

@ -86,7 +86,8 @@ pub fn syncback_json_model<'sync>(
Ok(SyncbackReturn { Ok(SyncbackReturn {
fs_snapshot: FsSnapshot::new().with_added_file( fs_snapshot: FsSnapshot::new().with_added_file(
&snapshot.path, &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(), children: Vec::new(),
removed_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()?; let parent_location = snapshot.path.parent_err()?;
fs_snapshot.add_file( fs_snapshot.add_file(
parent_location.join(format!("{}.meta.json", new_inst.name)), 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() { if !meta.is_empty() {
dir_syncback.fs_snapshot.add_file( dir_syncback.fs_snapshot.add_file(
snapshot.path.join("init.meta.json"), 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")?, .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 { for (node_properties, node_attributes, old_inst) in node_changed_map {
if project_node_should_reserialize(node_properties, node_attributes, old_inst)? { 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; break;
} }
} }

View file

@ -60,7 +60,7 @@ pub fn syncback_txt<'sync>(
let parent = snapshot.path.parent_err()?; let parent = snapshot.path.parent_err()?;
fs_snapshot.add_file( fs_snapshot.add_file(
parent.join(format!("{}.meta.json", new_inst.name)), 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")?,
); );
} }
} }