diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bec5c24..6705f9e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -61,3 +61,41 @@ jobs: - name: Run example working-directory: ./js run: npm run example + + test-rust: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - name: Cache cargo dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + rust/target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('rust/Cargo.lock') }} + + - name: Run tests + working-directory: ./rust + run: cargo test --verbose + + - name: Run example + working-directory: ./rust + run: cargo run --example basic_usage + + - name: Check formatting + working-directory: ./rust + run: cargo fmt --check + + - name: Run clippy + working-directory: ./rust + run: cargo clippy -- -D warnings diff --git a/README.md b/README.md index 7016b05..edc8ce6 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Tests](https://github.com/link-foundation/lino-objects-codec/actions/workflows/test.yml/badge.svg)](https://github.com/link-foundation/lino-objects-codec/actions/workflows/test.yml) [![Python Version](https://img.shields.io/pypi/pyversions/lino-objects-codec.svg)](https://pypi.org/project/lino-objects-codec/) -Universal serialization library to encode/decode objects to/from Links Notation format. Available in both **Python** and **JavaScript** with identical functionality and API design. +Universal serialization library to encode/decode objects to/from Links Notation format. Available in **Python**, **JavaScript**, and **Rust** with identical functionality and API design. ## 🌍 Multi-Language Support @@ -11,8 +11,9 @@ This library provides universal serialization and deserialization with built-in - **[Python](python/)** - Full implementation for Python 3.8+ - **[JavaScript](js/)** - Full implementation for Node.js 18+ +- **[Rust](rust/)** - Full implementation for Rust 1.70+ -Both implementations share the same design philosophy and provide feature parity. +All implementations share the same design philosophy and provide feature parity. ## Features @@ -20,6 +21,7 @@ Both implementations share the same design philosophy and provide feature parity - **Type Support**: Handle all common types in each language: - **Python**: `None`, `bool`, `int`, `float`, `str`, `list`, `dict` - **JavaScript**: `null`, `undefined`, `boolean`, `number`, `string`, `Array`, `Object` + - **Rust**: `LinoValue` enum with `Null`, `Bool`, `Int`, `Float`, `String`, `Array`, `Object` - Special float/number values: `NaN`, `Infinity`, `-Infinity` - **Circular References**: Automatically detect and preserve circular references - **Object Identity**: Maintain object identity for shared references @@ -63,6 +65,27 @@ const decoded = decode(encoded); console.log(JSON.stringify(decoded) === JSON.stringify(data)); // true ``` +### Rust + +```toml +[dependencies] +lino-objects-codec = "0.1" +``` + +```rust +use lino_objects_codec::{encode, decode, LinoValue}; + +// Encode and decode +let data = LinoValue::object([ + ("name", LinoValue::String("Alice".to_string())), + ("age", LinoValue::Int(30)), + ("active", LinoValue::Bool(true)), +]); +let encoded = encode(&data); +let decoded = decode(&encoded).unwrap(); +assert_eq!(decoded, data); +``` + ## Repository Structure ``` @@ -77,6 +100,10 @@ console.log(JSON.stringify(decoded) === JSON.stringify(data)); // true β”‚ β”œβ”€β”€ tests/ # Test suite β”‚ β”œβ”€β”€ examples/ # Usage examples β”‚ └── README.md # JavaScript-specific docs +β”œβ”€β”€ rust/ # Rust implementation +β”‚ β”œβ”€β”€ src/ # Source code +β”‚ β”œβ”€β”€ examples/ # Usage examples +β”‚ └── README.md # Rust-specific docs └── README.md # This file ``` @@ -86,10 +113,11 @@ For detailed documentation, API reference, and examples, see: - **[Python Documentation](python/README.md)** - **[JavaScript Documentation](js/README.md)** +- **[Rust Documentation](rust/README.md)** ## Usage Examples -Both implementations support the same features with language-appropriate syntax: +All implementations support the same features with language-appropriate syntax: ### Circular References @@ -181,6 +209,14 @@ npm test npm run example ``` +### Rust + +```bash +cd rust +cargo test +cargo run --example basic_usage +``` + ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. @@ -203,6 +239,7 @@ This project is licensed under the Unlicense - see the [LICENSE](LICENSE) file f - [Links Notation Specification](https://github.com/link-foundation/links-notation) - [PyPI Package](https://pypi.org/project/lino-objects-codec/) (Python) - [npm Package](https://www.npmjs.com/package/lino-objects-codec/) (JavaScript) +- [crates.io Package](https://crates.io/crates/lino-objects-codec/) (Rust) ## Acknowledgments diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..c71bd93 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,41 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "links-notation" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4c952b42a8c6ff6f849d7cafe3b1e13f1063a51bbb144bc6c62026ab327814c" +dependencies = [ + "nom", +] + +[[package]] +name = "lino-objects-codec" +version = "0.1.0" +dependencies = [ + "base64", + "links-notation", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..e04f236 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "lino-objects-codec" +version = "0.1.0" +edition = "2021" +rust-version = "1.70" +description = "A library to encode/decode objects to/from links notation" +license = "Unlicense" +repository = "https://github.com/link-foundation/lino-objects-codec" +documentation = "https://docs.rs/lino-objects-codec" +readme = "README.md" +keywords = ["links-notation", "serialization", "codec", "object-graph", "circular-references"] +categories = ["encoding", "parser-implementations"] + +[dependencies] +links-notation = "0.13.0" +base64 = "0.22" + +[dev-dependencies] diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 0000000..0964f6a --- /dev/null +++ b/rust/README.md @@ -0,0 +1,260 @@ +# lino-objects-codec (Rust) + +Rust implementation of the Links Notation Objects Codec - a universal serialization library to encode/decode objects to/from Links Notation format. + +## Installation + +Add to your `Cargo.toml`: + +```toml +[dependencies] +lino-objects-codec = "0.1" +``` + +## Features + +- **Universal Serialization**: Encode objects to Links Notation format +- **Type Support**: Handle all common types: + - `null` (Null) + - `bool` (Bool) + - `int` (Int - 64-bit signed) + - `float` (Float - 64-bit, including NaN, Infinity, -Infinity) + - `str` (String) + - `array` (Array) + - `object` (Object) +- **Special Float Values**: Full support for NaN, Infinity, -Infinity (which are not valid JSON) +- **Circular References**: Detect and preserve circular references via object IDs +- **Object Identity**: Maintain object identity for shared references +- **UTF-8 Support**: Full Unicode string support using base64 encoding +- **Simple API**: Easy-to-use `encode()` and `decode()` functions + +## Quick Start + +```rust +use lino_objects_codec::{encode, decode, LinoValue}; + +// Create an object +let data = LinoValue::object([ + ("name", LinoValue::String("Alice".to_string())), + ("age", LinoValue::Int(30)), + ("active", LinoValue::Bool(true)), +]); + +// Encode to Links Notation +let encoded = encode(&data); +println!("Encoded: {}", encoded); + +// Decode back +let decoded = decode(&encoded).unwrap(); +assert_eq!(decoded, data); +``` + +## API Reference + +### Types + +#### `LinoValue` + +The main value type that can represent any serializable value: + +```rust +pub enum LinoValue { + Null, + Bool(bool), + Int(i64), + Float(f64), + String(String), + Array(Vec), + Object(Vec<(String, LinoValue)>), +} +``` + +Helper methods: +- `LinoValue::object(iter)` - Create an object from key-value pairs +- `LinoValue::array(iter)` - Create an array from values +- `is_null()`, `as_bool()`, `as_int()`, `as_float()`, `as_str()`, `as_array()`, `as_object()` - Type checking and extraction +- `get(key)` - Get a value from an object by key +- `get_index(index)` - Get a value from an array by index + +#### `CodecError` + +Error type for codec operations: + +```rust +pub enum CodecError { + ParseError(String), + DecodeError(String), + UnknownType(String), +} +``` + +### Functions + +#### `encode(value: &LinoValue) -> String` + +Encode a value to Links Notation format. + +```rust +let value = LinoValue::Int(42); +let encoded = encode(&value); +assert_eq!(encoded, "(int 42)"); +``` + +#### `decode(notation: &str) -> Result` + +Decode Links Notation format to a value. + +```rust +let decoded = decode("(int 42)").unwrap(); +assert_eq!(decoded, LinoValue::Int(42)); +``` + +### `ObjectCodec` + +For advanced use cases, you can create your own codec instance: + +```rust +use lino_objects_codec::ObjectCodec; + +let mut codec = ObjectCodec::new(); +let encoded = codec.encode(&value); +let decoded = codec.decode(&encoded)?; +``` + +## Usage Examples + +### Basic Types + +```rust +use lino_objects_codec::{encode, decode, LinoValue}; + +// Null +let null = LinoValue::Null; +assert_eq!(encode(&null), "(null)"); + +// Boolean +let bool_val = LinoValue::Bool(true); +assert_eq!(encode(&bool_val), "(bool true)"); + +// Integer +let int_val = LinoValue::Int(42); +assert_eq!(encode(&int_val), "(int 42)"); + +// Float +let float_val = LinoValue::Float(3.14); +assert!(encode(&float_val).starts_with("(float")); + +// Special floats +let inf = LinoValue::Float(f64::INFINITY); +assert_eq!(encode(&inf), "(float Infinity)"); + +let nan = LinoValue::Float(f64::NAN); +assert_eq!(encode(&nan), "(float NaN)"); + +// String (base64 encoded) +let str_val = LinoValue::String("hello".to_string()); +assert_eq!(encode(&str_val), "(str aGVsbG8=)"); +``` + +### Collections + +```rust +use lino_objects_codec::{encode, decode, LinoValue}; + +// Array +let array = LinoValue::array([ + LinoValue::Int(1), + LinoValue::Int(2), + LinoValue::Int(3), +]); +let encoded = encode(&array); +let decoded = decode(&encoded).unwrap(); +assert_eq!(decoded.as_array().unwrap().len(), 3); + +// Object +let obj = LinoValue::object([ + ("name", LinoValue::String("Alice".to_string())), + ("age", LinoValue::Int(30)), +]); +let encoded = encode(&obj); +let decoded = decode(&encoded).unwrap(); +assert_eq!(decoded.get("name").unwrap().as_str(), Some("Alice")); +``` + +### Nested Structures + +```rust +use lino_objects_codec::{encode, decode, LinoValue}; + +let data = LinoValue::object([ + ("users", LinoValue::array([ + LinoValue::object([ + ("id", LinoValue::Int(1)), + ("name", LinoValue::String("Alice".to_string())), + ]), + LinoValue::object([ + ("id", LinoValue::Int(2)), + ("name", LinoValue::String("Bob".to_string())), + ]), + ])), + ("metadata", LinoValue::object([ + ("version", LinoValue::Int(1)), + ("count", LinoValue::Int(2)), + ])), +]); + +let encoded = encode(&data); +let decoded = decode(&encoded).unwrap(); + +// Access nested values +let users = decoded.get("users").unwrap().as_array().unwrap(); +assert_eq!(users.len(), 2); +``` + +### From Traits + +`LinoValue` implements `From` for common Rust types: + +```rust +use lino_objects_codec::LinoValue; + +let null: LinoValue = ().into(); +let bool_val: LinoValue = true.into(); +let int_val: LinoValue = 42i64.into(); +let float_val: LinoValue = 3.14f64.into(); +let str_val: LinoValue = "hello".into(); +let vec_val: LinoValue = vec![1i64, 2, 3].into(); +let opt_val: LinoValue = Some(42i64).into(); +let none_val: LinoValue = None::.into(); +``` + +## How It Works + +The codec encodes values using the [Links Notation](https://github.com/link-foundation/links-notation) format: + +- Basic types: `(int 42)`, `(str aGVsbG8=)`, `(bool true)` +- Strings are base64-encoded to handle special characters and newlines +- Arrays: `(array (int 1) (int 2) (int 3))` +- Objects: `(object ((str a2V5) (int 42)) ...)` +- Special floats: `(float NaN)`, `(float Infinity)`, `(float -Infinity)` + +For structures with shared references or circular references, the codec uses object IDs: +- Format: `(obj_0: array ...)` or `(obj_0: object ...)` +- References: `obj_0` + +## Development + +```bash +# Run tests +cargo test + +# Run example +cargo run --example basic_usage + +# Build documentation +cargo doc --open +``` + +## License + +This project is licensed under the Unlicense - see the [LICENSE](../LICENSE) file for details. diff --git a/rust/examples/basic_usage.rs b/rust/examples/basic_usage.rs new file mode 100644 index 0000000..a1c926a --- /dev/null +++ b/rust/examples/basic_usage.rs @@ -0,0 +1,188 @@ +//! Basic usage example for the lino-objects-codec library. + +use lino_objects_codec::{decode, encode, LinoValue}; + +fn main() { + println!("=== Links Notation Objects Codec - Rust Example ===\n"); + + // Example 1: Basic types + println!("1. Basic Types:"); + + // Null + let null_val = LinoValue::Null; + let encoded = encode(&null_val); + println!( + " Null: {} -> decoded: {:?}", + encoded, + decode(&encoded).unwrap() + ); + + // Booleans + let true_val = LinoValue::Bool(true); + let encoded = encode(&true_val); + println!( + " true: {} -> decoded: {:?}", + encoded, + decode(&encoded).unwrap() + ); + + // Integers + let int_val = LinoValue::Int(42); + let encoded = encode(&int_val); + println!( + " 42: {} -> decoded: {:?}", + encoded, + decode(&encoded).unwrap() + ); + + // Floats + let float_val = LinoValue::Float(3.14159); + let encoded = encode(&float_val); + println!( + " 3.14159: {} -> decoded: {:?}", + encoded, + decode(&encoded).unwrap() + ); + + // Special floats + let inf_val = LinoValue::Float(f64::INFINITY); + let encoded = encode(&inf_val); + println!( + " Infinity: {} -> decoded: {:?}", + encoded, + decode(&encoded).unwrap() + ); + + let nan_val = LinoValue::Float(f64::NAN); + let encoded = encode(&nan_val); + let decoded = decode(&encoded).unwrap(); + println!( + " NaN: {} -> decoded is_nan: {}", + encoded, + decoded.as_float().unwrap().is_nan() + ); + + // Strings + let str_val = LinoValue::String("Hello, World!".to_string()); + let encoded = encode(&str_val); + println!( + " 'Hello, World!': {} -> decoded: {:?}", + encoded, + decode(&encoded).unwrap() + ); + + // Unicode strings + let unicode_val = LinoValue::String("δ½ ε₯½δΈ–η•Œ 🌍".to_string()); + let encoded = encode(&unicode_val); + println!( + " Unicode: {} -> decoded: {:?}", + encoded, + decode(&encoded).unwrap() + ); + + println!(); + + // Example 2: Collections + println!("2. Collections:"); + + // Array + let array_val = LinoValue::array([LinoValue::Int(1), LinoValue::Int(2), LinoValue::Int(3)]); + let encoded = encode(&array_val); + let decoded = decode(&encoded).unwrap(); + println!(" Array [1, 2, 3]: {}", encoded); + println!(" Decoded: {:?}", decoded); + + // Object + let obj_val = LinoValue::object([ + ("name", LinoValue::String("Alice".to_string())), + ("age", LinoValue::Int(30)), + ("active", LinoValue::Bool(true)), + ]); + let encoded = encode(&obj_val); + let decoded = decode(&encoded).unwrap(); + println!(" Object {{name, age, active}}: {}", encoded); + println!(" Decoded: {:?}", decoded); + + println!(); + + // Example 3: Nested structures + println!("3. Nested Structure:"); + + let complex = LinoValue::object([ + ("id", LinoValue::Int(123)), + ("name", LinoValue::String("Test Object".to_string())), + ( + "tags", + LinoValue::array([ + LinoValue::String("tag1".to_string()), + LinoValue::String("tag2".to_string()), + ]), + ), + ( + "metadata", + LinoValue::object([ + ("version", LinoValue::Int(1)), + ("created", LinoValue::String("2025-01-01".to_string())), + ]), + ), + ]); + + let encoded = encode(&complex); + let decoded = decode(&encoded).unwrap(); + + println!(" Encoded: {}", encoded); + println!(" Decoded name: {:?}", decoded.get("name")); + println!(" Decoded tags: {:?}", decoded.get("tags")); + println!( + " Decoded metadata.version: {:?}", + decoded.get("metadata").and_then(|m| m.get("version")) + ); + + println!(); + + // Example 4: Mixed type array + println!("4. Mixed Type Array:"); + + let mixed = LinoValue::array([ + LinoValue::Int(1), + LinoValue::String("hello".to_string()), + LinoValue::Bool(true), + LinoValue::Null, + LinoValue::Float(2.5), + ]); + + let encoded = encode(&mixed); + let decoded = decode(&encoded).unwrap(); + + println!(" Encoded: {}", encoded); + println!(" Decoded: {:?}", decoded); + + println!(); + + // Example 5: Roundtrip verification + println!("5. Roundtrip Verification:"); + + let data = LinoValue::object([ + ( + "users", + LinoValue::array([ + LinoValue::object([ + ("id", LinoValue::Int(1)), + ("name", LinoValue::String("Alice".to_string())), + ]), + LinoValue::object([ + ("id", LinoValue::Int(2)), + ("name", LinoValue::String("Bob".to_string())), + ]), + ]), + ), + ("count", LinoValue::Int(2)), + ]); + + let encoded = encode(&data); + let decoded = decode(&encoded).unwrap(); + + println!(" Original == Decoded: {}", data == decoded); + + println!("\n=== Example completed successfully! ==="); +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 0000000..31f9d83 --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,1176 @@ +//! Object encoder/decoder for Links Notation format. +//! +//! This library provides encoding and decoding of JSON-like objects to/from +//! Links Notation format. It supports all common JSON types plus special +//! float values (NaN, Infinity, -Infinity). +//! +//! # Features +//! +//! - **Universal Serialization**: Encode objects to Links Notation format +//! - **Type Support**: Handle all common types: null, boolean, integer, float, string, array, object +//! - **Special Float Values**: Support for NaN, Infinity, -Infinity (which are not valid JSON) +//! - **Circular References**: Detect and preserve circular references (via object IDs) +//! - **Object Identity**: Maintain object identity for shared references +//! - **UTF-8 Support**: Full Unicode string support using base64 encoding +//! - **Simple API**: Easy-to-use `encode()` and `decode()` functions +//! +//! # Example +//! +//! ```rust +//! use lino_objects_codec::{encode, decode, LinoValue}; +//! +//! // Encode a simple object +//! let data = LinoValue::object([ +//! ("name", LinoValue::String("Alice".to_string())), +//! ("age", LinoValue::Int(30)), +//! ("active", LinoValue::Bool(true)), +//! ]); +//! let encoded = encode(&data); +//! let decoded = decode(&encoded).unwrap(); +//! assert_eq!(decoded, data); +//! ``` + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use links_notation::{parse_lino_to_links, LiNo}; +use std::collections::{HashMap, HashSet}; +use std::fmt; + +/// Type identifiers used in Links Notation format +mod type_ids { + pub const NULL: &str = "null"; + pub const BOOL: &str = "bool"; + pub const INT: &str = "int"; + pub const FLOAT: &str = "float"; + pub const STR: &str = "str"; + pub const ARRAY: &str = "array"; + pub const OBJECT: &str = "object"; +} + +/// Error types for codec operations +#[derive(Debug, Clone, PartialEq)] +pub enum CodecError { + /// Parsing error + ParseError(String), + /// Decoding error + DecodeError(String), + /// Unknown type marker + UnknownType(String), +} + +impl fmt::Display for CodecError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CodecError::ParseError(msg) => write!(f, "Parse error: {}", msg), + CodecError::DecodeError(msg) => write!(f, "Decode error: {}", msg), + CodecError::UnknownType(t) => write!(f, "Unknown type marker: {}", t), + } + } +} + +impl std::error::Error for CodecError {} + +/// A value that can be encoded/decoded using the Links Notation codec. +/// +/// This type supports all the types available in Python/JavaScript versions +/// including special float values (NaN, Infinity) that are not valid JSON. +#[derive(Debug, Clone)] +pub enum LinoValue { + /// Null value + Null, + /// Boolean value + Bool(bool), + /// Integer value (64-bit signed) + Int(i64), + /// Floating point value (64-bit) + Float(f64), + /// String value + String(String), + /// Array of values + Array(Vec), + /// Object/dictionary with string keys + Object(Vec<(String, LinoValue)>), +} + +impl PartialEq for LinoValue { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (LinoValue::Null, LinoValue::Null) => true, + (LinoValue::Bool(a), LinoValue::Bool(b)) => a == b, + (LinoValue::Int(a), LinoValue::Int(b)) => a == b, + (LinoValue::Float(a), LinoValue::Float(b)) => { + // Handle NaN comparison + if a.is_nan() && b.is_nan() { + true + } else { + a == b + } + } + (LinoValue::String(a), LinoValue::String(b)) => a == b, + (LinoValue::Array(a), LinoValue::Array(b)) => a == b, + (LinoValue::Object(a), LinoValue::Object(b)) => { + // Objects are equal if they have the same keys and values + if a.len() != b.len() { + return false; + } + // Create hashmaps for comparison (order-independent) + let a_map: HashMap<&str, &LinoValue> = + a.iter().map(|(k, v)| (k.as_str(), v)).collect(); + let b_map: HashMap<&str, &LinoValue> = + b.iter().map(|(k, v)| (k.as_str(), v)).collect(); + a_map == b_map + } + _ => false, + } + } +} + +impl LinoValue { + /// Create an object from an iterator of key-value pairs. + pub fn object(iter: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + LinoValue::Object( + iter.into_iter() + .map(|(k, v)| (k.into(), v.into())) + .collect(), + ) + } + + /// Create an array from an iterator of values. + pub fn array(iter: I) -> Self + where + I: IntoIterator, + V: Into, + { + LinoValue::Array(iter.into_iter().map(|v| v.into()).collect()) + } + + /// Check if this is a null value. + pub fn is_null(&self) -> bool { + matches!(self, LinoValue::Null) + } + + /// Get as a boolean, if this is a bool. + pub fn as_bool(&self) -> Option { + match self { + LinoValue::Bool(b) => Some(*b), + _ => None, + } + } + + /// Get as an integer, if this is an int. + pub fn as_int(&self) -> Option { + match self { + LinoValue::Int(i) => Some(*i), + _ => None, + } + } + + /// Get as a float, if this is a float or int. + pub fn as_float(&self) -> Option { + match self { + LinoValue::Float(f) => Some(*f), + LinoValue::Int(i) => Some(*i as f64), + _ => None, + } + } + + /// Get as a string, if this is a string. + pub fn as_str(&self) -> Option<&str> { + match self { + LinoValue::String(s) => Some(s), + _ => None, + } + } + + /// Get as an array, if this is an array. + pub fn as_array(&self) -> Option<&Vec> { + match self { + LinoValue::Array(a) => Some(a), + _ => None, + } + } + + /// Get as an object, if this is an object. + pub fn as_object(&self) -> Option<&Vec<(String, LinoValue)>> { + match self { + LinoValue::Object(o) => Some(o), + _ => None, + } + } + + /// Get a value from an object by key. + pub fn get(&self, key: &str) -> Option<&LinoValue> { + match self { + LinoValue::Object(o) => o.iter().find(|(k, _)| k == key).map(|(_, v)| v), + _ => None, + } + } + + /// Get a value from an array by index. + pub fn get_index(&self, index: usize) -> Option<&LinoValue> { + match self { + LinoValue::Array(a) => a.get(index), + _ => None, + } + } +} + +// Implement From traits for convenience +impl From<()> for LinoValue { + fn from(_: ()) -> Self { + LinoValue::Null + } +} + +impl From for LinoValue { + fn from(b: bool) -> Self { + LinoValue::Bool(b) + } +} + +impl From for LinoValue { + fn from(i: i32) -> Self { + LinoValue::Int(i as i64) + } +} + +impl From for LinoValue { + fn from(i: i64) -> Self { + LinoValue::Int(i) + } +} + +impl From for LinoValue { + fn from(f: f64) -> Self { + LinoValue::Float(f) + } +} + +impl From<&str> for LinoValue { + fn from(s: &str) -> Self { + LinoValue::String(s.to_string()) + } +} + +impl From for LinoValue { + fn from(s: String) -> Self { + LinoValue::String(s) + } +} + +impl> From> for LinoValue { + fn from(v: Vec) -> Self { + LinoValue::Array(v.into_iter().map(|x| x.into()).collect()) + } +} + +impl> From> for LinoValue { + fn from(opt: Option) -> Self { + match opt { + Some(v) => v.into(), + None => LinoValue::Null, + } + } +} + +/// Codec for encoding/decoding LinoValue to/from Links Notation. +/// +/// This codec handles the conversion between `LinoValue` and Links Notation +/// format strings. It supports circular references and shared object identity +/// through object ID references. +pub struct ObjectCodec { + /// Counter for generating unique object IDs + encode_counter: usize, + /// Maps object representations to their assigned IDs (for detecting shared references) + encode_memo: HashMap, + /// Set of object representations that need IDs (referenced multiple times) + needs_id: HashSet, + /// All link definitions generated during encoding (for multi-link format) + all_definitions: Vec<(String, LiNo)>, + /// Maps object IDs to decoded values during decoding + decode_memo: HashMap, + /// All links available for forward reference resolution + all_links: Vec>, +} + +impl Default for ObjectCodec { + fn default() -> Self { + Self::new() + } +} + +impl ObjectCodec { + /// Create a new ObjectCodec instance. + pub fn new() -> Self { + ObjectCodec { + encode_counter: 0, + encode_memo: HashMap::new(), + needs_id: HashSet::new(), + all_definitions: Vec::new(), + decode_memo: HashMap::new(), + all_links: Vec::new(), + } + } + + /// Reset the encoder state for a new encoding operation. + fn reset_encode_state(&mut self) { + self.encode_counter = 0; + self.encode_memo.clear(); + self.needs_id.clear(); + self.all_definitions.clear(); + } + + /// Reset the decoder state for a new decoding operation. + fn reset_decode_state(&mut self) { + self.decode_memo.clear(); + self.all_links.clear(); + } + + /// Create a Link from string parts. + fn make_link(&self, parts: &[&str]) -> LiNo { + let values: Vec> = parts.iter().map(|p| LiNo::Ref(p.to_string())).collect(); + LiNo::Link { id: None, values } + } + + /// Create a reference Link with just an ID. + fn make_ref(&self, id: &str) -> LiNo { + LiNo::Ref(id.to_string()) + } + + /// Generate a unique object key for detecting shared references. + fn object_key(&self, value: &LinoValue) -> String { + // Use pointer-based identity + format!("{:p}", value) + } + + /// First pass: identify which objects need IDs (referenced multiple times or circularly). + fn find_objects_needing_ids(&mut self, value: &LinoValue, seen: &mut HashMap) { + // Only track arrays and objects (compound types) + match value { + LinoValue::Array(arr) => { + let key = self.object_key(value); + + if seen.contains_key(&key) { + // Already seen - needs an ID + self.needs_id.insert(key); + return; + } + + seen.insert(key, true); + + for item in arr { + self.find_objects_needing_ids(item, seen); + } + } + LinoValue::Object(obj) => { + let key = self.object_key(value); + + if seen.contains_key(&key) { + // Already seen - needs an ID + self.needs_id.insert(key); + return; + } + + seen.insert(key, true); + + for (_, v) in obj { + self.find_objects_needing_ids(v, seen); + } + } + _ => {} + } + } + + /// Encode a LinoValue to Links Notation format. + /// + /// # Arguments + /// + /// * `value` - The value to encode + /// + /// # Returns + /// + /// A string in Links Notation format + pub fn encode(&mut self, value: &LinoValue) -> String { + self.reset_encode_state(); + + // First pass: identify which objects need IDs + let mut seen = HashMap::new(); + self.find_objects_needing_ids(value, &mut seen); + + // Encode the value + let mut visited = HashSet::new(); + let main_link = self.encode_value(value, &mut visited, 0); + + // If we have additional definitions, output them all as multi-link format + if !self.all_definitions.is_empty() { + let mut all_links = vec![main_link]; + + // Add all other definitions (avoid duplicates) + for (ref_id, link) in &self.all_definitions { + let main_id = match &all_links[0] { + LiNo::Link { id: Some(id), .. } => Some(id.clone()), + _ => None, + }; + if main_id.as_ref() != Some(ref_id) { + all_links.push(link.clone()); + } + } + + // Format as multi-link (newline separated) + all_links + .iter() + .map(Self::format_link) + .collect::>() + .join("\n") + } else { + Self::format_link(&main_link) + } + } + + /// Format a single link to its string representation. + fn format_link(link: &LiNo) -> String { + match link { + LiNo::Ref(s) => s.clone(), + LiNo::Link { id, values } => { + let inner: Vec = values.iter().map(Self::format_link).collect(); + + if let Some(link_id) = id { + if inner.is_empty() { + format!("({}:)", link_id) + } else { + format!("({}: {})", link_id, inner.join(" ")) + } + } else if inner.is_empty() { + "()".to_string() + } else { + format!("({})", inner.join(" ")) + } + } + } + } + + /// Encode a value into a Link. + fn encode_value( + &mut self, + value: &LinoValue, + visited: &mut HashSet, + depth: usize, + ) -> LiNo { + match value { + LinoValue::Null => self.make_link(&[type_ids::NULL]), + + LinoValue::Bool(b) => { + if *b { + self.make_link(&[type_ids::BOOL, "true"]) + } else { + self.make_link(&[type_ids::BOOL, "false"]) + } + } + + LinoValue::Int(i) => self.make_link(&[type_ids::INT, &i.to_string()]), + + LinoValue::Float(f) => { + if f.is_nan() { + self.make_link(&[type_ids::FLOAT, "NaN"]) + } else if f.is_infinite() { + if f.is_sign_positive() { + self.make_link(&[type_ids::FLOAT, "Infinity"]) + } else { + self.make_link(&[type_ids::FLOAT, "-Infinity"]) + } + } else { + self.make_link(&[type_ids::FLOAT, &f.to_string()]) + } + } + + LinoValue::String(s) => { + let b64_encoded = BASE64.encode(s.as_bytes()); + self.make_link(&[type_ids::STR, &b64_encoded]) + } + + LinoValue::Array(arr) => { + let obj_key = self.object_key(value); + + // Check if we've already encoded this object + if let Some(ref_id) = self.encode_memo.get(&obj_key).cloned() { + return self.make_ref(&ref_id); + } + + // Check if this object needs an ID + let needs_id = self.needs_id.contains(&obj_key); + + if needs_id { + // Check for cycle + if visited.contains(&obj_key) { + // We're in a cycle - must have assigned ID already + if let Some(ref_id) = self.encode_memo.get(&obj_key) { + return self.make_ref(ref_id); + } + } + + // Assign an ID + let ref_id = format!("obj_{}", self.encode_counter); + self.encode_counter += 1; + self.encode_memo.insert(obj_key.clone(), ref_id.clone()); + visited.insert(obj_key.clone()); + + // Encode items + let mut parts: Vec> = vec![LiNo::Ref(type_ids::ARRAY.to_string())]; + for item in arr { + let item_link = self.encode_value(item, visited, depth + 1); + parts.push(item_link); + } + + let definition = LiNo::Link { + id: Some(ref_id.clone()), + values: parts, + }; + + // Store for multi-link output if not at top level + if depth > 0 { + self.all_definitions.push((ref_id.clone(), definition)); + return self.make_ref(&ref_id); + } + + definition + } else { + // No ID needed - simple array + let mut parts: Vec> = vec![LiNo::Ref(type_ids::ARRAY.to_string())]; + for item in arr { + let item_link = self.encode_value(item, visited, depth + 1); + parts.push(item_link); + } + LiNo::Link { + id: None, + values: parts, + } + } + } + + LinoValue::Object(obj) => { + let obj_key = self.object_key(value); + + // Check if we've already encoded this object + if let Some(ref_id) = self.encode_memo.get(&obj_key).cloned() { + return self.make_ref(&ref_id); + } + + // Check if this object needs an ID + let needs_id = self.needs_id.contains(&obj_key); + + if needs_id { + // Check for cycle + if visited.contains(&obj_key) { + // We're in a cycle - must have assigned ID already + if let Some(ref_id) = self.encode_memo.get(&obj_key) { + return self.make_ref(ref_id); + } + } + + // Assign an ID + let ref_id = format!("obj_{}", self.encode_counter); + self.encode_counter += 1; + self.encode_memo.insert(obj_key.clone(), ref_id.clone()); + visited.insert(obj_key.clone()); + + // Encode key-value pairs + let mut parts: Vec> = + vec![LiNo::Ref(type_ids::OBJECT.to_string())]; + for (k, v) in obj { + let key_link = + self.encode_value(&LinoValue::String(k.clone()), visited, depth + 1); + let value_link = self.encode_value(v, visited, depth + 1); + let pair = LiNo::Link { + id: None, + values: vec![key_link, value_link], + }; + parts.push(pair); + } + + let definition = LiNo::Link { + id: Some(ref_id.clone()), + values: parts, + }; + + // Store for multi-link output if not at top level + if depth > 0 { + self.all_definitions.push((ref_id.clone(), definition)); + return self.make_ref(&ref_id); + } + + definition + } else { + // No ID needed - simple object + let mut parts: Vec> = + vec![LiNo::Ref(type_ids::OBJECT.to_string())]; + for (k, v) in obj { + let key_link = + self.encode_value(&LinoValue::String(k.clone()), visited, depth + 1); + let value_link = self.encode_value(v, visited, depth + 1); + let pair = LiNo::Link { + id: None, + values: vec![key_link, value_link], + }; + parts.push(pair); + } + LiNo::Link { + id: None, + values: parts, + } + } + } + } + } + + /// Decode Links Notation format to a LinoValue. + /// + /// # Arguments + /// + /// * `notation` - String in Links Notation format + /// + /// # Returns + /// + /// The reconstructed value, or an error + pub fn decode(&mut self, notation: &str) -> Result { + self.reset_decode_state(); + + let links = parse_lino_to_links(notation) + .map_err(|e| CodecError::ParseError(format!("{:?}", e)))?; + + if links.is_empty() { + return Ok(LinoValue::Null); + } + + // Store all links for forward reference resolution + if links.len() > 1 { + self.all_links = links.clone(); + } + + // Decode the first link + self.decode_link(&links[0]) + } + + /// Decode a Link into a LinoValue. + fn decode_link(&mut self, link: &LiNo) -> Result { + match link { + LiNo::Ref(id) => { + // Check if this is a reference to a previously decoded object + if let Some(value) = self.decode_memo.get(id) { + return Ok(value.clone()); + } + + // Check if it's a forward reference + if id.starts_with("obj_") && !self.all_links.is_empty() { + // Look for this ID in remaining links + for other_link in self.all_links.clone() { + if let LiNo::Link { + id: Some(link_id), .. + } = &other_link + { + if link_id == id { + return self.decode_link(&other_link); + } + } + } + // Not found - return empty array as fallback + let result = LinoValue::Array(vec![]); + self.decode_memo.insert(id.clone(), result.clone()); + return Ok(result); + } + + // Handle single-element type markers (parser returns Ref for single values like "(null)") + match id.as_str() { + type_ids::NULL => return Ok(LinoValue::Null), + type_ids::ARRAY => return Ok(LinoValue::Array(vec![])), + type_ids::OBJECT => return Ok(LinoValue::Object(vec![])), + type_ids::STR => return Ok(LinoValue::String(String::new())), + _ => {} + } + + // Just a plain string reference + Ok(LinoValue::String(id.clone())) + } + + LiNo::Link { id, values } => { + // Check for self-reference ID (already in memo) + let self_ref_id = id.as_ref().filter(|i| i.starts_with("obj_")); + if let Some(ref_id) = self_ref_id { + if let Some(value) = self.decode_memo.get(ref_id) { + return Ok(value.clone()); + } + } + + if values.is_empty() { + return Ok(LinoValue::Null); + } + + // Get the type marker from the first value + let type_marker = match &values[0] { + LiNo::Ref(t) => t.as_str(), + LiNo::Link { .. } => return Ok(LinoValue::Null), + }; + + match type_marker { + type_ids::NULL => Ok(LinoValue::Null), + + type_ids::BOOL => { + if values.len() > 1 { + if let LiNo::Ref(val) = &values[1] { + return Ok(LinoValue::Bool(val == "true")); + } + } + Ok(LinoValue::Bool(false)) + } + + type_ids::INT => { + if values.len() > 1 { + if let LiNo::Ref(val) = &values[1] { + if let Ok(i) = val.parse::() { + return Ok(LinoValue::Int(i)); + } + } + } + Ok(LinoValue::Int(0)) + } + + type_ids::FLOAT => { + if values.len() > 1 { + if let LiNo::Ref(val) = &values[1] { + return match val.as_str() { + "NaN" => Ok(LinoValue::Float(f64::NAN)), + "Infinity" => Ok(LinoValue::Float(f64::INFINITY)), + "-Infinity" => Ok(LinoValue::Float(f64::NEG_INFINITY)), + s => { + if let Ok(f) = s.parse::() { + Ok(LinoValue::Float(f)) + } else { + Ok(LinoValue::Float(0.0)) + } + } + }; + } + } + Ok(LinoValue::Float(0.0)) + } + + type_ids::STR => { + if values.len() > 1 { + if let LiNo::Ref(b64_str) = &values[1] { + if let Ok(bytes) = BASE64.decode(b64_str) { + if let Ok(s) = String::from_utf8(bytes) { + return Ok(LinoValue::String(s)); + } + } + // If decode fails, return raw value + return Ok(LinoValue::String(b64_str.clone())); + } + } + Ok(LinoValue::String(String::new())) + } + + type_ids::ARRAY => { + // Create result array and register in memo early for circular refs + let result_array = LinoValue::Array(vec![]); + if let Some(ref_id) = self_ref_id { + self.decode_memo.insert(ref_id.clone(), result_array); + } + + // Decode items (skip type marker at index 0) + let mut items = Vec::new(); + for item_link in values.iter().skip(1) { + let decoded = self.decode_link(item_link)?; + items.push(decoded); + } + + let result = LinoValue::Array(items); + + // Update memo if needed + if let Some(ref_id) = self_ref_id { + self.decode_memo.insert(ref_id.clone(), result.clone()); + } + + Ok(result) + } + + type_ids::OBJECT => { + // Create result object and register in memo early for circular refs + let result_object = LinoValue::Object(vec![]); + if let Some(ref_id) = self_ref_id { + self.decode_memo.insert(ref_id.clone(), result_object); + } + + // Decode key-value pairs (skip type marker at index 0) + let mut obj = Vec::new(); + for pair_link in values.iter().skip(1) { + if let LiNo::Link { values: pair, .. } = pair_link { + if pair.len() >= 2 { + let key = self.decode_link(&pair[0])?; + let value = self.decode_link(&pair[1])?; + + // Key should be a string + if let LinoValue::String(k) = key { + obj.push((k, value)); + } + } + } + } + + let result = LinoValue::Object(obj); + + // Update memo if needed + if let Some(ref_id) = self_ref_id { + self.decode_memo.insert(ref_id.clone(), result.clone()); + } + + Ok(result) + } + + unknown => Err(CodecError::UnknownType(unknown.to_string())), + } + } + } + } +} + +// Global codec instance for convenience functions +thread_local! { + static DEFAULT_CODEC: std::cell::RefCell = std::cell::RefCell::new(ObjectCodec::new()); +} + +/// Encode a value to Links Notation format. +/// +/// This is a convenience function that uses a thread-local codec instance. +/// +/// # Arguments +/// +/// * `value` - The value to encode +/// +/// # Returns +/// +/// A string in Links Notation format +/// +/// # Example +/// +/// ```rust +/// use lino_objects_codec::{encode, LinoValue}; +/// +/// let data = LinoValue::object([ +/// ("name", LinoValue::String("Alice".to_string())), +/// ("age", LinoValue::Int(30)), +/// ]); +/// let encoded = encode(&data); +/// // String "Alice" is base64-encoded as "QWxpY2U=" +/// assert!(encoded.contains("QWxpY2U=")); +/// ``` +pub fn encode(value: &LinoValue) -> String { + DEFAULT_CODEC.with(|codec| codec.borrow_mut().encode(value)) +} + +/// Decode Links Notation format to a value. +/// +/// This is a convenience function that uses a thread-local codec instance. +/// +/// # Arguments +/// +/// * `notation` - String in Links Notation format +/// +/// # Returns +/// +/// The reconstructed value, or an error +/// +/// # Example +/// +/// ```rust +/// use lino_objects_codec::{encode, decode, LinoValue}; +/// +/// let original = LinoValue::Int(42); +/// let encoded = encode(&original); +/// let decoded = decode(&encoded).unwrap(); +/// assert_eq!(decoded, original); +/// ``` +pub fn decode(notation: &str) -> Result { + DEFAULT_CODEC.with(|codec| codec.borrow_mut().decode(notation)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_roundtrip_null() { + let original = LinoValue::Null; + let encoded = encode(&original); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn test_roundtrip_bool() { + for value in [true, false] { + let original = LinoValue::Bool(value); + let encoded = encode(&original); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded, original); + } + } + + #[test] + fn test_roundtrip_int() { + let test_values: Vec = vec![0, 1, -1, 42, -42, 123456789, -123456789]; + for value in test_values { + let original = LinoValue::Int(value); + let encoded = encode(&original); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded.as_int(), Some(value)); + } + } + + #[test] + fn test_roundtrip_float() { + let test_values: Vec = vec![0.0, 1.0, -1.0, 3.14, -3.14, 0.123456789, -999.999]; + for value in test_values { + let original = LinoValue::Float(value); + let encoded = encode(&original); + let decoded = decode(&encoded).unwrap(); + let decoded_f = decoded.as_float().unwrap(); + assert!((decoded_f - value).abs() < 0.0001); + } + } + + #[test] + fn test_float_special_values() { + // Test infinity + let inf = LinoValue::Float(f64::INFINITY); + let encoded = encode(&inf); + let decoded = decode(&encoded).unwrap(); + let decoded_f = decoded.as_float().unwrap(); + assert!(decoded_f.is_infinite()); + assert!(decoded_f.is_sign_positive()); + + // Test negative infinity + let neg_inf = LinoValue::Float(f64::NEG_INFINITY); + let encoded = encode(&neg_inf); + let decoded = decode(&encoded).unwrap(); + let decoded_f = decoded.as_float().unwrap(); + assert!(decoded_f.is_infinite()); + assert!(decoded_f.is_sign_negative()); + + // Test NaN + let nan = LinoValue::Float(f64::NAN); + let encoded = encode(&nan); + let decoded = decode(&encoded).unwrap(); + let decoded_f = decoded.as_float().unwrap(); + assert!(decoded_f.is_nan()); + } + + #[test] + fn test_roundtrip_string() { + let test_values = [ + "", + "hello", + "hello world", + "Hello, World!", + "multi\nline\nstring", + "tab\tseparated", + "special chars: @#$%^&*()", + ]; + for value in test_values { + let original = LinoValue::String(value.to_string()); + let encoded = encode(&original); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded.as_str(), Some(value)); + } + } + + #[test] + fn test_string_with_quotes() { + let test_values = [ + "string with 'single quotes'", + "string with \"double quotes\"", + "string with \"both\" 'quotes'", + ]; + for value in test_values { + let original = LinoValue::String(value.to_string()); + let encoded = encode(&original); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded.as_str(), Some(value)); + } + } + + #[test] + fn test_unicode_string() { + let value = "unicode: δ½ ε₯½δΈ–η•Œ 🌍"; + let original = LinoValue::String(value.to_string()); + let encoded = encode(&original); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded.as_str(), Some(value)); + } + + #[test] + fn test_roundtrip_empty_array() { + let original = LinoValue::Array(vec![]); + let encoded = encode(&original); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn test_roundtrip_simple_array() { + let original = LinoValue::Array(vec![ + LinoValue::Int(1), + LinoValue::Int(2), + LinoValue::Int(3), + ]); + let encoded = encode(&original); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn test_roundtrip_mixed_array() { + let original = LinoValue::Array(vec![ + LinoValue::Int(1), + LinoValue::String("hello".to_string()), + LinoValue::Bool(true), + LinoValue::Null, + ]); + let encoded = encode(&original); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn test_nested_arrays() { + let original = LinoValue::Array(vec![LinoValue::Array(vec![])]); + let encoded = encode(&original); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded, original); + + let original2 = LinoValue::Array(vec![ + LinoValue::Array(vec![LinoValue::Int(1), LinoValue::Int(2)]), + LinoValue::Array(vec![LinoValue::Int(3), LinoValue::Int(4)]), + ]); + let encoded2 = encode(&original2); + let decoded2 = decode(&encoded2).unwrap(); + assert_eq!(decoded2, original2); + } + + #[test] + fn test_roundtrip_empty_object() { + let original = LinoValue::Object(vec![]); + let encoded = encode(&original); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn test_roundtrip_simple_object() { + let original = LinoValue::object([("a", LinoValue::Int(1)), ("b", LinoValue::Int(2))]); + let encoded = encode(&original); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn test_nested_objects() { + let original = LinoValue::object([( + "user", + LinoValue::object([ + ("name", LinoValue::String("Alice".to_string())), + ( + "address", + LinoValue::object([ + ("city", LinoValue::String("NYC".to_string())), + ("zip", LinoValue::String("10001".to_string())), + ]), + ), + ]), + )]); + let encoded = encode(&original); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn test_complex_structure() { + let original = LinoValue::object([ + ("id", LinoValue::Int(123)), + ("name", LinoValue::String("Test Object".to_string())), + ("active", LinoValue::Bool(true)), + ( + "tags", + LinoValue::array([ + LinoValue::String("tag1".to_string()), + LinoValue::String("tag2".to_string()), + LinoValue::String("tag3".to_string()), + ]), + ), + ( + "metadata", + LinoValue::object([ + ("created", LinoValue::String("2025-01-01".to_string())), + ("modified", LinoValue::Null), + ("count", LinoValue::Int(42)), + ]), + ), + ( + "items", + LinoValue::array([ + LinoValue::object([ + ("id", LinoValue::Int(1)), + ("value", LinoValue::String("first".to_string())), + ]), + LinoValue::object([ + ("id", LinoValue::Int(2)), + ("value", LinoValue::String("second".to_string())), + ]), + ]), + ), + ]); + let encoded = encode(&original); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn test_list_of_dicts() { + let original = LinoValue::array([ + LinoValue::object([ + ("name", LinoValue::String("Alice".to_string())), + ("age", LinoValue::Int(30)), + ]), + LinoValue::object([ + ("name", LinoValue::String("Bob".to_string())), + ("age", LinoValue::Int(25)), + ]), + ]); + let encoded = encode(&original); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn test_dict_of_lists() { + let original = LinoValue::object([ + ( + "numbers", + LinoValue::array([LinoValue::Int(1), LinoValue::Int(2), LinoValue::Int(3)]), + ), + ( + "strings", + LinoValue::array([ + LinoValue::String("a".to_string()), + LinoValue::String("b".to_string()), + LinoValue::String("c".to_string()), + ]), + ), + ]); + let encoded = encode(&original); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded, original); + } +}