//! Utilities for parsing JSON with comments (JSONC) and deserializing to Rust types. //! //! This module provides convenient wrappers around `jsonc_parser` and `serde_json` //! to reduce boilerplate and improve ergonomics when working with JSONC files. use anyhow::Context as _; use serde::{de::DeserializeOwned, Serialize}; /// Parse JSONC text into a `serde_json::Value`. /// /// This handles the common pattern of calling `jsonc_parser::parse_to_serde_value` /// and unwrapping the `Option` with a clear error message. /// /// # Errors /// /// Returns an error if: /// - The text is not valid JSONC /// - The text contains no JSON value pub fn parse_value(text: &str) -> anyhow::Result { jsonc_parser::parse_to_serde_value(text, &Default::default()) .context("Failed to parse JSONC")? .ok_or_else(|| anyhow::anyhow!("File contains no JSON value")) } /// Parse JSONC text into a `serde_json::Value` with a custom context message. /// /// This is useful when you want to provide a specific error message that includes /// additional information like the file path. /// /// # Errors /// /// Returns an error if: /// - The text is not valid JSONC /// - The text contains no JSON value pub fn parse_value_with_context( text: &str, context: impl Fn() -> String, ) -> anyhow::Result { jsonc_parser::parse_to_serde_value(text, &Default::default()) .with_context(|| format!("{}: JSONC parse error", context()))? .ok_or_else(|| anyhow::anyhow!("{}: File contains no JSON value", context())) } /// Parse JSONC text and deserialize it into a specific type. /// /// This combines parsing JSONC and deserializing into a single operation, /// eliminating the need to manually chain `parse_to_serde_value` and `from_value`. /// /// # Errors /// /// Returns an error if: /// - The text is not valid JSONC /// - The text contains no JSON value /// - The value cannot be deserialized into type `T` pub fn from_str(text: &str) -> anyhow::Result { let value = parse_value(text)?; serde_json::from_value(value).context("Failed to deserialize JSON") } /// Parse JSONC text and deserialize it into a specific type with a custom context message. /// /// This is useful when you want to provide a specific error message that includes /// additional information like the file path. /// /// # Errors /// /// Returns an error if: /// - The text is not valid JSONC /// - The text contains no JSON value /// - The value cannot be deserialized into type `T` pub fn from_str_with_context( text: &str, context: impl Fn() -> String, ) -> anyhow::Result { let value = parse_value_with_context(text, &context)?; serde_json::from_value(value).with_context(|| format!("{}: Invalid JSON structure", context())) } /// Parse JSONC bytes into a `serde_json::Value` with a custom context message. /// /// This handles UTF-8 conversion and JSONC parsing in one step. /// /// # Errors /// /// Returns an error if: /// - The bytes are not valid UTF-8 /// - The text is not valid JSONC /// - The text contains no JSON value pub fn parse_value_from_slice_with_context( slice: &[u8], context: impl Fn() -> String, ) -> anyhow::Result { let text = std::str::from_utf8(slice) .with_context(|| format!("{}: File is not valid UTF-8", context()))?; parse_value_with_context(text, context) } /// Parse JSONC bytes and deserialize it into a specific type. /// /// This handles UTF-8 conversion, JSONC parsing, and deserialization in one step. /// /// # Errors /// /// Returns an error if: /// - The bytes are not valid UTF-8 /// - The text is not valid JSONC /// - The text contains no JSON value /// - The value cannot be deserialized into type `T` pub fn from_slice(slice: &[u8]) -> anyhow::Result { let text = std::str::from_utf8(slice).context("File is not valid UTF-8")?; from_str(text) } /// Parse JSONC bytes and deserialize it into a specific type with a custom context message. /// /// This handles UTF-8 conversion, JSONC parsing, and deserialization in one step. /// /// # Errors /// /// Returns an error if: /// - The bytes are not valid UTF-8 /// - The text is not valid JSONC /// - The text contains no JSON value /// - The value cannot be deserialized into type `T` pub fn from_slice_with_context( slice: &[u8], context: impl Fn() -> String, ) -> anyhow::Result { let text = std::str::from_utf8(slice) .with_context(|| format!("{}: File is not valid UTF-8", 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") } #[cfg(test)] mod tests { use super::*; use serde::Deserialize; #[test] fn test_parse_value() { let value = parse_value(r#"{"foo": "bar"}"#).unwrap(); assert_eq!(value["foo"], "bar"); } #[test] fn test_parse_value_with_comments() { let value = parse_value( r#"{ // This is a comment "foo": "bar" // Inline comment }"#, ) .unwrap(); assert_eq!(value["foo"], "bar"); } #[test] fn test_parse_value_with_trailing_comma() { let value = parse_value( r#"{ "foo": "bar", "baz": 123, }"#, ) .unwrap(); assert_eq!(value["foo"], "bar"); assert_eq!(value["baz"], 123); } #[test] fn test_parse_value_empty() { let err = parse_value("").unwrap_err(); assert!(err.to_string().contains("no JSON value")); } #[test] fn test_parse_value_invalid() { let err = parse_value("{invalid}").unwrap_err(); assert!(err.to_string().contains("parse")); } #[test] fn test_parse_value_with_context() { let err = parse_value_with_context("{invalid}", || "test.json".to_string()).unwrap_err(); assert!(err.to_string().contains("test.json")); assert!(err.to_string().contains("parse")); } #[derive(Debug, Deserialize, PartialEq)] struct TestStruct { foo: String, bar: i32, } #[test] fn test_from_str() { let result: TestStruct = from_str(r#"{"foo": "hello", "bar": 42}"#).unwrap(); assert_eq!( result, TestStruct { foo: "hello".to_string(), bar: 42 } ); } #[test] fn test_from_str_with_comments() { let result: TestStruct = from_str( r#"{ // Comment "foo": "hello", "bar": 42, // Trailing comma is fine }"#, ) .unwrap(); assert_eq!( result, TestStruct { foo: "hello".to_string(), bar: 42 } ); } #[test] fn test_from_str_invalid_type() { let err = from_str::(r#"{"foo": "hello"}"#).unwrap_err(); assert!(err.to_string().contains("deserialize")); } #[test] fn test_from_str_with_context() { let err = from_str_with_context::(r#"{"foo": "hello"}"#, || { "config.json".to_string() }) .unwrap_err(); assert!(err.to_string().contains("config.json")); assert!(err.to_string().contains("Invalid JSON structure")); } #[test] fn test_parse_value_from_slice_with_context() { let err = parse_value_from_slice_with_context(b"{invalid}", || "test.json".to_string()) .unwrap_err(); assert!(err.to_string().contains("test.json")); assert!(err.to_string().contains("parse")); } #[test] fn test_parse_value_from_slice_with_context_invalid_utf8() { let err = parse_value_from_slice_with_context(&[0xFF, 0xFF], || "test.json".to_string()) .unwrap_err(); assert!(err.to_string().contains("test.json")); assert!(err.to_string().contains("UTF-8")); } #[test] fn test_from_slice() { let result: TestStruct = from_slice(br#"{"foo": "hello", "bar": 42}"#).unwrap(); assert_eq!( result, TestStruct { foo: "hello".to_string(), bar: 42 } ); } #[test] fn test_from_slice_with_comments() { let result: TestStruct = from_slice( br#"{ // Comment "foo": "hello", "bar": 42, // Trailing comma is fine }"#, ) .unwrap(); assert_eq!( result, TestStruct { foo: "hello".to_string(), bar: 42 } ); } #[test] fn test_from_slice_invalid_utf8() { let err = from_slice::(&[0xFF, 0xFF]).unwrap_err(); assert!(err.to_string().contains("UTF-8")); } #[test] fn test_from_slice_with_context() { let err = from_slice_with_context::(br#"{"foo": "hello"}"#, || { "config.json".to_string() }) .unwrap_err(); assert!(err.to_string().contains("config.json")); assert!(err.to_string().contains("Invalid JSON structure")); } #[test] fn test_from_slice_with_context_invalid_utf8() { let err = from_slice_with_context::(&[0xFF, 0xFF], || "config.json".to_string()) .unwrap_err(); 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_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 = String::from_utf8(to_vec_pretty_sorted(&nested).unwrap()).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); } }