From 1b7193a2dcd4c1daf077a36dacd2c179aa90e115 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 05:25:16 +0000 Subject: [PATCH 1/2] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/link-foundation/lino-objects-codec/issues/37 --- .gitkeep | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitkeep b/.gitkeep index a6e2948..824bd1a 100644 --- a/.gitkeep +++ b/.gitkeep @@ -1 +1,2 @@ -# .gitkeep file auto-generated at 2026-05-10T19:22:27.543Z for PR creation at branch issue-35-03946ff48852 for issue https://github.com/link-foundation/lino-objects-codec/issues/35 \ No newline at end of file +# .gitkeep file auto-generated at 2026-05-10T19:22:27.543Z for PR creation at branch issue-35-03946ff48852 for issue https://github.com/link-foundation/lino-objects-codec/issues/35 +# Updated: 2026-08-20T05:25:16.696Z \ No newline at end of file From 29373e36e8aa7cf7122795894ce4cb817061ba01 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 05:41:54 +0000 Subject: [PATCH 2/2] feat(rust): make readable indented Links Notation the default encoding encode() now writes an indented, plain-text document that uses one ( ) construct for both objects and arrays at every level, including the root. Keys and values are written verbatim, strings are double-quoted, and numbers, true/false and null stay bare so types survive a round trip. A value is base64-encoded only when it genuinely cannot be written as text (it contains control characters), and each such value is marked individually as (base64 "...") while everything around it stays readable. The previous single-line base64 output is kept under the explicit names encode_compact()/encode_obfuscated(), and decode() accepts both forms, so existing files keep working and migrate on the next write. Reading the readable form back needs links-notation 0.14 semantics, where a parenthesis opens a nested indentation context, so the dependency is raised to 0.14. Closes #37 --- README.md | 19 + .../parenthesis-indentation/.gitignore | 2 + .../parenthesis-indentation/Cargo.toml | 8 + .../parenthesis-indentation/src/bin/shapes.rs | 25 + .../parenthesis-indentation/src/main.rs | 24 + rust/Cargo.lock | 51 +- rust/Cargo.toml | 2 +- rust/README.md | 120 +++- ...20260820_120000_readable_default_format.md | 14 + rust/examples/basic_usage.rs | 22 +- rust/src/lib.rs | 198 +++++- rust/src/readable.rs | 591 ++++++++++++++++++ rust/tests/documented_examples.rs | 52 ++ rust/tests/readable_format.rs | 368 +++++++++++ 14 files changed, 1464 insertions(+), 32 deletions(-) create mode 100644 experiments/issue-37/parenthesis-indentation/.gitignore create mode 100644 experiments/issue-37/parenthesis-indentation/Cargo.toml create mode 100644 experiments/issue-37/parenthesis-indentation/src/bin/shapes.rs create mode 100644 experiments/issue-37/parenthesis-indentation/src/main.rs create mode 100644 rust/changelog.d/20260820_120000_readable_default_format.md create mode 100644 rust/src/readable.rs create mode 100644 rust/tests/documented_examples.rs create mode 100644 rust/tests/readable_format.rs diff --git a/README.md b/README.md index 3a6c9ba..24908d2 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ All implementations share the same design philosophy and provide feature parity. - **Circular References**: Automatically detect and preserve circular references - **Object Identity**: Maintain object identity for shared references - **UTF-8 Support**: Full Unicode string support using base64 encoding +- **Readable by Default (Rust)**: `encode()` writes indented, plain-text Links Notation; the previous single-line base64 form stays available as `encode_compact()` - **Simple API**: Easy-to-use `encode()` and `decode()` functions - **JSON/Lino Conversion**: Convert between JSON and Links Notation (JavaScript) - **Reference Escaping**: Properly escape strings for Links Notation format (JavaScript) @@ -98,11 +99,25 @@ let data = LinoValue::object([ ("age", LinoValue::Int(30)), ("active", LinoValue::Bool(true)), ]); +// `encode` produces readable, indented Links Notation let encoded = encode(&data); +assert_eq!(encoded, "(\n name \"Alice\"\n age 30\n active true\n)"); + let decoded = decode(&encoded).unwrap(); assert_eq!(decoded, data); ``` +```lino +( + name "Alice" + age 30 + active true +) +``` + +The single-line base64 form is still available as `encode_compact()` (alias +`encode_obfuscated()`), and `decode()` accepts both forms. + ### C# ```bash @@ -354,6 +369,10 @@ The library uses the [links-notation](https://github.com/link-foundation/links-n - Basic types are encoded with type markers: `(int 42)`, `(str aGVsbG8=)`, `(bool True)` - Strings are base64-encoded to handle special characters and newlines +- **Rust exception**: `encode()` defaults to the readable indented form described in + [rust/README.md](rust/README.md), where strings are quoted rather than encoded and + only values containing control characters are marked as `(base64 "...")`; the form + above is what `encode_compact()` produces - Collections with self-references use built-in links notation self-reference syntax: - **Format**: `(obj_id: type content...)` - **Python example**: `(obj_0: dict ((str c2VsZg==) obj_0))` for `{"self": obj}` diff --git a/experiments/issue-37/parenthesis-indentation/.gitignore b/experiments/issue-37/parenthesis-indentation/.gitignore new file mode 100644 index 0000000..2c96eb1 --- /dev/null +++ b/experiments/issue-37/parenthesis-indentation/.gitignore @@ -0,0 +1,2 @@ +target/ +Cargo.lock diff --git a/experiments/issue-37/parenthesis-indentation/Cargo.toml b/experiments/issue-37/parenthesis-indentation/Cargo.toml new file mode 100644 index 0000000..5f426c9 --- /dev/null +++ b/experiments/issue-37/parenthesis-indentation/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "parenthesis-indentation-probe" +version = "0.1.0" +edition = "2021" + +# Switch this between "0.14.0" and "0.13.0" to compare parser behaviour. +[dependencies] +links-notation = "0.14.0" diff --git a/experiments/issue-37/parenthesis-indentation/src/bin/shapes.rs b/experiments/issue-37/parenthesis-indentation/src/bin/shapes.rs new file mode 100644 index 0000000..bba9bfe --- /dev/null +++ b/experiments/issue-37/parenthesis-indentation/src/bin/shapes.rs @@ -0,0 +1,25 @@ +use links_notation::parse_lino_to_links; + +fn show(name: &str, text: &str) { + println!("=== {} ===\n{}\n---", name, text); + match parse_lino_to_links(text) { + Ok(links) => { + for l in &links { + println!("{:?}", l); + } + } + Err(e) => println!("ERR: {:?}", e), + } + println!(); +} + +fn main() { + show("doc", "(\n type \"RouterState\"\n server (\n host \"127.0.0.1\"\n port 18878\n )\n models (\n \"claude-haiku\"\n \"claude-opus\"\n )\n)"); + show("array of objects", "(\n value (\n (\n id \"1\"\n label \"one\"\n )\n (\n id \"2\"\n label \"two\"\n )\n )\n)"); + show("empty link", "(\n a ()\n b ()\n)"); + show("scalar root", "42"); + show("string root", "(\n \"hello\"\n)"); + show("quotes", "(\n a \"say \"\"hi\"\"\"\n b 'it''s'\n)"); + show("newline in quotes", "(\n a \"line1\nline2\"\n)"); + show("single pair obj", "(\n a 1\n)"); +} diff --git a/experiments/issue-37/parenthesis-indentation/src/main.rs b/experiments/issue-37/parenthesis-indentation/src/main.rs new file mode 100644 index 0000000..3f9cc07 --- /dev/null +++ b/experiments/issue-37/parenthesis-indentation/src/main.rs @@ -0,0 +1,24 @@ +//! Experiment for issue #37: does `( )` open a nested indentation context? +//! +//! Run with links-notation 0.13 and 0.14 to compare: +//! cargo run # 0.14 (as pinned in Cargo.toml) +//! cargo add links-notation@0.13.0 && cargo run +//! +//! 0.13 ignores indentation inside `( )` and flattens every line into one list, +//! so record boundaries and nested objects cannot be recovered. 0.14 keeps them. + +use links_notation::parse_lino_to_links; + +const DOCUMENT: &str = "(\n server (\n host \"127.0.0.1\"\n port 18878\n )\n)"; + +fn main() { + println!("input:\n{DOCUMENT}\n"); + match parse_lino_to_links(DOCUMENT) { + Ok(links) => { + for link in &links { + println!("parsed: {link:?}"); + } + } + Err(e) => println!("parse error: {e:?}"), + } +} diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 459386d..a9d339b 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -10,13 +10,25 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "links-notation" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4c952b42a8c6ff6f849d7cafe3b1e13f1063a51bbb144bc6c62026ab327814c" +checksum = "9b6e5f36d99612ea82da43dbd5efb37b0b4e9c4b8197228e073b60ef5a0e78f2" dependencies = [ + "links-notation-macro", "nom", ] +[[package]] +name = "links-notation-macro" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f30ea96250240a92d69d45579dbd199a713e7a79acfa53033d24628dad23cff5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "lino-objects-codec" version = "0.2.1" @@ -39,3 +51,38 @@ checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" dependencies = [ "memchr", ] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index ac773b1..22e0b57 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -12,7 +12,7 @@ keywords = ["links-notation", "serialization", "codec", "object-graph", "circula categories = ["encoding", "parser-implementations"] [dependencies] -links-notation = "0.13.0" +links-notation = "0.14.0" base64 = "0.22" [dev-dependencies] diff --git a/rust/README.md b/rust/README.md index 251b405..8cf757a 100644 --- a/rust/README.md +++ b/rust/README.md @@ -30,7 +30,8 @@ lino-objects-codec = "0.1" - **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 +- **Readable by Default**: `encode()` writes indented, plain-text Links Notation; keys and values stay legible and diffable +- **UTF-8 Support**: Full Unicode string support written as text; only values that cannot be written as text (control characters) are base64-encoded, and each is marked individually - **Simple API**: Easy-to-use `encode()` and `decode()` functions ## Quick Start @@ -47,13 +48,35 @@ let data = LinoValue::object([ // Encode to Links Notation let encoded = encode(&data); -println!("Encoded: {}", encoded); +assert_eq!(encoded, "(\n name \"Alice\"\n age 30\n active true\n)"); // Decode back let decoded = decode(&encoded).unwrap(); assert_eq!(decoded, data); ``` +The encoded document reads as: + +```lino +( + name "Alice" + age 30 + active true +) +``` + +## Output Formats + +| Function | Output | +| --- | --- | +| `encode(value)` | Readable, indented Links Notation (the default) | +| `encode_with_indent(value, "\t")` | Same, with a custom indentation string | +| `encode_compact(value)` | The previous single-line base64 form | +| `encode_obfuscated(value)` | Alias of `encode_compact` | + +`decode()` accepts every one of them, so files written by older versions keep +working and are rewritten in the readable form the next time they are saved. + ## API Reference ### Types @@ -97,21 +120,42 @@ pub enum CodecError { #### `encode(value: &LinoValue) -> String` -Encode a value to Links Notation format. +Encode a value to the readable, indented Links Notation format. ```rust let value = LinoValue::Int(42); let encoded = encode(&value); -assert_eq!(encoded, "(int 42)"); +assert_eq!(encoded, "42"); ``` +#### `encode_with_indent(value: &LinoValue, indent: &str) -> String` + +Same as `encode()`, but with a custom indentation string (the default is two spaces). + +#### `encode_compact(value: &LinoValue) -> String` + +Encode a value to the single-line, base64 form used before version 0.3. + +```rust +let value = LinoValue::String("hello".to_string()); +assert_eq!(encode_compact(&value), "(str aGVsbG8=)"); +``` + +#### `encode_obfuscated(value: &LinoValue) -> String` + +Alias of `encode_compact()`, named after what the base64 form actually does to the text. + #### `decode(notation: &str) -> Result` -Decode Links Notation format to a value. +Decode Links Notation format to a value. Both the readable and the compact form +are accepted. ```rust -let decoded = decode("(int 42)").unwrap(); +let decoded = decode("42").unwrap(); assert_eq!(decoded, LinoValue::Int(42)); + +let legacy = decode("(int 42)").unwrap(); +assert_eq!(legacy, LinoValue::Int(42)); ``` ### `ObjectCodec` @@ -135,30 +179,34 @@ use lino_objects_codec::{encode, decode, LinoValue}; // Null let null = LinoValue::Null; -assert_eq!(encode(&null), "(null)"); +assert_eq!(encode(&null), "null"); // Boolean let bool_val = LinoValue::Bool(true); -assert_eq!(encode(&bool_val), "(bool true)"); +assert_eq!(encode(&bool_val), "true"); // Integer let int_val = LinoValue::Int(42); -assert_eq!(encode(&int_val), "(int 42)"); +assert_eq!(encode(&int_val), "42"); // Float let float_val = LinoValue::Float(3.14); -assert!(encode(&float_val).starts_with("(float")); +assert_eq!(encode(&float_val), "3.14"); // Special floats let inf = LinoValue::Float(f64::INFINITY); -assert_eq!(encode(&inf), "(float Infinity)"); +assert_eq!(encode(&inf), "Infinity"); let nan = LinoValue::Float(f64::NAN); -assert_eq!(encode(&nan), "(float NaN)"); +assert_eq!(encode(&nan), "NaN"); -// String (base64 encoded) +// Strings are quoted, not encoded let str_val = LinoValue::String("hello".to_string()); -assert_eq!(encode(&str_val), "(str aGVsbG8=)"); +assert_eq!(encode(&str_val), "\"hello\""); + +// Numbers written as strings stay strings +let numeric = LinoValue::String("42".to_string()); +assert_eq!(decode(&encode(&numeric)).unwrap(), numeric); ``` ### Collections @@ -235,7 +283,43 @@ 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: +The codec encodes values using the [Links Notation](https://github.com/link-foundation/links-notation) format. + +### Readable format (the default) + +One `( )` construct carries both objects and arrays, at every level including +the root. Lines of the form `key value` make an object, bare-value lines make an +array: + +```lino +( + type "RouterState" + server ( + host "127.0.0.1" + port 18878 + ) + models ( + "claude-haiku" + "claude-opus" + ) +) +``` + +- Strings are double-quoted and written as text: `name "Alice"` +- Numbers, `true`, `false` and `null` are bare, so types survive a round trip +- `NaN`, `Infinity` and `-Infinity` are written as such +- An empty array is `()`; an empty object is `(` + newline + `)` +- A value that cannot be written as text (one containing control characters) is + base64-encoded on its own and marked as `(base64 "bGluZTEKbGluZTI=")`; + everything around it stays readable + +Reading the format back requires `links-notation` 0.14 semantics, where a +parenthesis opens a nested indentation context. + +### Compact format (`encode_compact`) + +The previous single-line form, kept for compatibility and for cases where size +matters more than legibility: - Basic types: `(int 42)`, `(str aGVsbG8=)`, `(bool true)` - Strings are base64-encoded to handle special characters and newlines @@ -243,10 +327,14 @@ The codec encodes values using the [Links Notation](https://github.com/link-foun - 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: +For structures with shared references or circular references, the compact form +uses object IDs: - Format: `(obj_0: array ...)` or `(obj_0: object ...)` - References: `obj_0` +`decode()` detects which of the two forms it is given, so previously written +files keep decoding. + ## Development ```bash diff --git a/rust/changelog.d/20260820_120000_readable_default_format.md b/rust/changelog.d/20260820_120000_readable_default_format.md new file mode 100644 index 0000000..71872b3 --- /dev/null +++ b/rust/changelog.d/20260820_120000_readable_default_format.md @@ -0,0 +1,14 @@ +--- +bump: minor +--- + +### Added +- `encode_with_indent()` for choosing the indentation string of the readable format. +- `encode_compact()` (alias `encode_obfuscated()`) keeping the previous single-line base64 output under an explicit name. +- `readable` module with the indented encoder/decoder, plus `DEFAULT_INDENT` and `BASE64_MARKER` constants. + +### Changed +- `encode()` now produces indented, plain-text Links Notation by default: one `( )` construct for objects and arrays at every level, keys and values written verbatim, strings double-quoted, and numbers/`true`/`false`/`null` bare so types survive a round trip. +- Values are base64-encoded only when they cannot be written as text (control characters), and each such value is marked individually as `(base64 "...")`. +- `decode()` accepts both the readable and the previous compact form, so existing files keep working and migrate to the readable form on the next write. +- Raised the `links-notation` dependency to 0.14, where parentheses open a nested indentation context. diff --git a/rust/examples/basic_usage.rs b/rust/examples/basic_usage.rs index a1c926a..7f75c2c 100644 --- a/rust/examples/basic_usage.rs +++ b/rust/examples/basic_usage.rs @@ -1,6 +1,6 @@ //! Basic usage example for the lino-objects-codec library. -use lino_objects_codec::{decode, encode, LinoValue}; +use lino_objects_codec::{decode, encode, encode_compact, LinoValue}; fn main() { println!("=== Links Notation Objects Codec - Rust Example ===\n"); @@ -89,7 +89,7 @@ fn main() { 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!(" Array [1, 2, 3]:\n{}", encoded); println!(" Decoded: {:?}", decoded); // Object @@ -100,7 +100,7 @@ fn main() { ]); let encoded = encode(&obj_val); let decoded = decode(&encoded).unwrap(); - println!(" Object {{name, age, active}}: {}", encoded); + println!(" Object {{name, age, active}}:\n{}", encoded); println!(" Decoded: {:?}", decoded); println!(); @@ -130,7 +130,7 @@ fn main() { let encoded = encode(&complex); let decoded = decode(&encoded).unwrap(); - println!(" Encoded: {}", encoded); + println!(" Encoded:\n{}", encoded); println!(" Decoded name: {:?}", decoded.get("name")); println!(" Decoded tags: {:?}", decoded.get("tags")); println!( @@ -154,7 +154,7 @@ fn main() { let encoded = encode(&mixed); let decoded = decode(&encoded).unwrap(); - println!(" Encoded: {}", encoded); + println!(" Encoded:\n{}", encoded); println!(" Decoded: {:?}", decoded); println!(); @@ -184,5 +184,17 @@ fn main() { println!(" Original == Decoded: {}", data == decoded); + println!(); + + // Example 6: The compact (base64) form is still available under its own name + println!("6. Compact Form:"); + + let compact = encode_compact(&data); + println!(" Compact: {}", compact); + println!( + " Compact decodes back to the same value: {}", + decode(&compact).unwrap() == data + ); + println!("\n=== Example completed successfully! ==="); } diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 00f8b2b..7934c39 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -6,12 +6,15 @@ //! //! # Features //! +//! - **Readable by Default**: `encode()` writes plain, indented text that can be read and reviewed //! - **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 +//! - **UTF-8 Support**: Full Unicode string support, written as text; only values that cannot be +//! represented as text (strings holding control characters) are base64-encoded, and they are +//! marked individually as `(base64 "…")` //! - **Simple API**: Easy-to-use `encode()` and `decode()` functions //! //! # Example @@ -26,16 +29,32 @@ //! ("active", LinoValue::Bool(true)), //! ]); //! let encoded = encode(&data); +//! assert_eq!(encoded, "(\n name \"Alice\"\n age 30\n active true\n)"); //! let decoded = decode(&encoded).unwrap(); //! assert_eq!(decoded, data); //! ``` +//! +//! # Output formats +//! +//! | Function | Output | +//! |---|---| +//! | [`encode`] | readable, indented plain text (default) | +//! | [`encode_with_indent`] | the same, with a custom indentation string | +//! | [`encode_compact`] / [`encode_obfuscated`] | the previous single-line, base64 form | +//! +//! [`decode`] accepts both forms, so files written by earlier versions keep working +//! and migrate to the readable form on the next write. 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 +pub mod readable; + +pub use readable::{BASE64_MARKER, DEFAULT_INDENT}; + +/// Type identifiers used in the compact (base64) Links Notation format mod type_ids { pub const NULL: &str = "null"; pub const BOOL: &str = "bool"; @@ -385,7 +404,11 @@ impl ObjectCodec { } } - /// Encode a LinoValue to Links Notation format. + /// Encode a LinoValue to the readable, indented Links Notation format. + /// + /// This is the default representation: keys and values are written as plain + /// text, one per line, so the result can be read and reviewed directly. + /// See [`readable`] for the exact shape. /// /// # Arguments /// @@ -393,8 +416,35 @@ impl ObjectCodec { /// /// # Returns /// - /// A string in Links Notation format + /// A string in readable Links Notation format pub fn encode(&mut self, value: &LinoValue) -> String { + readable::encode(value, DEFAULT_INDENT) + } + + /// Encode a LinoValue to the readable format using a custom indentation string. + /// + /// # Arguments + /// + /// * `value` - The value to encode + /// * `indent` - The indentation string used per nesting level (for example `" "`) + pub fn encode_with_indent(&mut self, value: &LinoValue, indent: &str) -> String { + readable::encode(value, indent) + } + + /// Encode a LinoValue to the compact, single-line Links Notation format. + /// + /// Every value is tagged with its type and every string is base64-encoded, so + /// the whole document fits on one line and carries no readable text. This was + /// the default before the readable format; callers now opt into it explicitly. + /// + /// # Arguments + /// + /// * `value` - The value to encode + /// + /// # Returns + /// + /// A string in compact Links Notation format + pub fn encode_compact(&mut self, value: &LinoValue) -> String { self.reset_encode_state(); // First pass: identify which objects need IDs @@ -431,6 +481,14 @@ impl ObjectCodec { } } + /// Encode a LinoValue to the compact, base64 form. + /// + /// Alias of [`ObjectCodec::encode_compact`], named after what the form does to + /// its content: nothing in the output can be read without decoding it. + pub fn encode_obfuscated(&mut self, value: &LinoValue) -> String { + self.encode_compact(value) + } + /// Format a single link to its string representation. fn format_link(link: &LiNo) -> String { match link { @@ -635,7 +693,31 @@ impl ObjectCodec { /// # Returns /// /// The reconstructed value, or an error + /// + /// Both the readable format and the compact (base64) format are accepted, so + /// files written by earlier versions keep working and migrate on next write. pub fn decode(&mut self, notation: &str) -> Result { + if notation.trim().is_empty() { + return Ok(LinoValue::Null); + } + + if is_compact_notation(notation) { + return self.decode_compact(notation); + } + + readable::decode(notation) + } + + /// Decode the compact (base64) Links Notation format. + /// + /// # Arguments + /// + /// * `notation` - String in compact Links Notation format + /// + /// # Returns + /// + /// The reconstructed value, or an error + pub fn decode_compact(&mut self, notation: &str) -> Result { self.reset_decode_state(); let links = parse_lino_to_links(notation) @@ -836,12 +918,57 @@ impl ObjectCodec { } } +/// Detect the compact (base64) format. +/// +/// Compact output always starts a line with `(` immediately followed by a type +/// marker — optionally preceded by an object id, as in `(obj_0: object …)`. +/// Readable output never does: its first line is either a lone `(` or a scalar. +fn is_compact_notation(notation: &str) -> bool { + let Some(first_line) = notation.lines().map(str::trim).find(|l| !l.is_empty()) else { + return false; + }; + + let Some(rest) = first_line.strip_prefix('(') else { + return false; + }; + + let mut tokens = rest + .split(|c: char| c.is_whitespace() || c == '(' || c == ')') + .filter(|t| !t.is_empty()); + + let Some(mut marker) = tokens.next() else { + return false; + }; + + // Skip the `obj_N:` definition id, if present. + if let Some(id) = marker.strip_suffix(':') { + if !id.starts_with("obj_") { + return false; + } + let Some(next) = tokens.next() else { + return false; + }; + marker = next; + } + + matches!( + marker, + type_ids::NULL + | type_ids::BOOL + | type_ids::INT + | type_ids::FLOAT + | type_ids::STR + | type_ids::ARRAY + | type_ids::OBJECT + ) +} + // 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. +/// Encode a value to the readable, indented Links Notation format. /// /// This is a convenience function that uses a thread-local codec instance. /// @@ -851,7 +978,7 @@ thread_local! { /// /// # Returns /// -/// A string in Links Notation format +/// A string in readable Links Notation format /// /// # Example /// @@ -863,13 +990,68 @@ thread_local! { /// ("age", LinoValue::Int(30)), /// ]); /// let encoded = encode(&data); -/// // String "Alice" is base64-encoded as "QWxpY2U=" -/// assert!(encoded.contains("QWxpY2U=")); +/// // Names and values are written as they are, one per line +/// assert_eq!(encoded, "(\n name \"Alice\"\n age 30\n)"); /// ``` pub fn encode(value: &LinoValue) -> String { DEFAULT_CODEC.with(|codec| codec.borrow_mut().encode(value)) } +/// Encode a value to the readable format using a custom indentation string. +/// +/// # Arguments +/// +/// * `value` - The value to encode +/// * `indent` - The indentation string used per nesting level +/// +/// # Example +/// +/// ```rust +/// use lino_objects_codec::{encode_with_indent, LinoValue}; +/// +/// let data = LinoValue::object([("age", LinoValue::Int(30))]); +/// assert_eq!(encode_with_indent(&data, " "), "(\n age 30\n)"); +/// ``` +pub fn encode_with_indent(value: &LinoValue, indent: &str) -> String { + DEFAULT_CODEC.with(|codec| codec.borrow_mut().encode_with_indent(value, indent)) +} + +/// Encode a value to the compact, single-line Links Notation format. +/// +/// Every string is base64-encoded and the whole document is written on one line. +/// [`decode`] reads this form as well, so stored files remain readable by the +/// library after switching to the default readable output. +/// +/// # Arguments +/// +/// * `value` - The value to encode +/// +/// # Returns +/// +/// A string in compact Links Notation format +/// +/// # Example +/// +/// ```rust +/// use lino_objects_codec::{encode_compact, decode, LinoValue}; +/// +/// let data = LinoValue::object([("name", LinoValue::String("Alice".to_string()))]); +/// let encoded = encode_compact(&data); +/// // String "Alice" is base64-encoded as "QWxpY2U=" +/// assert!(encoded.contains("QWxpY2U=")); +/// assert_eq!(decode(&encoded).unwrap(), data); +/// ``` +pub fn encode_compact(value: &LinoValue) -> String { + DEFAULT_CODEC.with(|codec| codec.borrow_mut().encode_compact(value)) +} + +/// Encode a value to the compact, base64 form. +/// +/// Alias of [`encode_compact`], named after what the form does to its content. +pub fn encode_obfuscated(value: &LinoValue) -> String { + DEFAULT_CODEC.with(|codec| codec.borrow_mut().encode_obfuscated(value)) +} + /// Decode Links Notation format to a value. /// /// This is a convenience function that uses a thread-local codec instance. diff --git a/rust/src/readable.rs b/rust/src/readable.rs new file mode 100644 index 0000000..79af927 --- /dev/null +++ b/rust/src/readable.rs @@ -0,0 +1,591 @@ +//! Readable, indented Links Notation representation. +//! +//! This module implements the default output of [`crate::encode`]: a plain-text, +//! indented projection where keys and values are written as they are, so the file +//! can be read, grepped and reviewed without decoding anything. +//! +//! # Shape +//! +//! One construct — `( )` — is used for both objects and arrays, at every level +//! including the root. What distinguishes them is the content of the lines: +//! `key value` pairs make an object, bare values make an array. +//! +//! ```text +//! ( +//! type "RouterState" +//! server ( +//! host "127.0.0.1" +//! port 18878 +//! ) +//! models ( +//! "claude-haiku" +//! "claude-opus" +//! ) +//! ) +//! ``` +//! +//! # Value mapping +//! +//! | `LinoValue` | Readable form | +//! |----------------------------|------------------------------------------------| +//! | `Object` | `( )` with one `key value` pair per line | +//! | `Array` | `( )` with one value per line | +//! | `String` | quoted, never encoded | +//! | `Int` / `Float` / `Bool` / `Null` | bare, so the type survives the round trip | +//! +//! Empty containers keep their type: an empty array is `()` on one line, while an +//! empty object is written as `(` and `)` on two lines. +//! +//! Only values that cannot be written as plain text are encoded: strings holding +//! control characters (including newlines and tabs, which line-based tooling and +//! CRLF normalisation would corrupt) are marked individually as +//! `(base64 "…")` instead of encoding the whole document. + +use crate::{CodecError, LinoValue}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; + +/// Default indentation used by [`encode`]. +pub const DEFAULT_INDENT: &str = " "; + +/// Marker used for values that cannot be represented as plain text. +pub const BASE64_MARKER: &str = "base64"; + +/// Encode a value into the readable, indented Links Notation form. +pub fn encode(value: &LinoValue, indent: &str) -> String { + let mut out = String::new(); + write_value(value, indent, 0, &mut out); + out +} + +/// Decode the readable, indented Links Notation form back into a value. +pub fn decode(text: &str) -> Result { + let tokens = tokenize(text)?; + let mut cursor = Cursor { tokens, pos: 0 }; + let rows = cursor.parse_rows(true)?; + + if cursor.pos < cursor.tokens.len() { + return Err(CodecError::ParseError( + "unexpected ')' in readable notation".to_string(), + )); + } + + // A document holding a single value (for example `42`) is that value. + if rows.len() == 1 && rows[0].len() == 1 { + return node_to_value(&rows[0][0]); + } + + rows_to_value(&rows, true) +} + +// === Encoding === + +fn write_value(value: &LinoValue, indent: &str, level: usize, out: &mut String) { + match value { + LinoValue::Object(pairs) => { + if pairs.is_empty() { + // An empty object spans two lines; `()` on one line is an empty array. + out.push_str("(\n"); + push_indent(indent, level, out); + out.push(')'); + return; + } + + out.push('('); + for (key, child) in pairs { + out.push('\n'); + push_indent(indent, level + 1, out); + out.push_str(&format_key(key)); + out.push(' '); + write_value(child, indent, level + 1, out); + } + out.push('\n'); + push_indent(indent, level, out); + out.push(')'); + } + + LinoValue::Array(items) => { + if items.is_empty() { + out.push_str("()"); + return; + } + + out.push('('); + for item in items { + out.push('\n'); + push_indent(indent, level + 1, out); + write_value(item, indent, level + 1, out); + } + out.push('\n'); + push_indent(indent, level, out); + out.push(')'); + } + + scalar => out.push_str(&format_scalar(scalar)), + } +} + +fn push_indent(indent: &str, level: usize, out: &mut String) { + for _ in 0..level { + out.push_str(indent); + } +} + +/// Format a scalar value. Strings are quoted, everything else stays bare so that +/// its type is recoverable when reading the document back. +fn format_scalar(value: &LinoValue) -> String { + match value { + LinoValue::Null => "null".to_string(), + LinoValue::Bool(b) => b.to_string(), + LinoValue::Int(i) => i.to_string(), + LinoValue::Float(f) => format_float(*f), + LinoValue::String(s) => format_string(s), + // Containers are handled by write_value. + LinoValue::Array(_) | LinoValue::Object(_) => String::new(), + } +} + +fn format_float(f: f64) -> String { + if f.is_nan() { + "NaN".to_string() + } else if f.is_infinite() { + if f.is_sign_positive() { + "Infinity".to_string() + } else { + "-Infinity".to_string() + } + } else { + // `{:?}` keeps the decimal point for whole floats (`1.0`), which is what + // tells a float apart from an integer when reading the document back. + format!("{:?}", f) + } +} + +/// Format a string value: quoted plain text, or an individually marked +/// base64 payload when the text cannot be written literally. +fn format_string(value: &str) -> String { + if needs_encoding(value) { + return format!( + "({} {})", + BASE64_MARKER, + quote(&BASE64.encode(value.as_bytes())) + ); + } + quote(value) +} + +/// A value can be written as text unless it contains control characters: +/// newlines break the line structure and CRLF normalisation would rewrite them. +fn needs_encoding(value: &str) -> bool { + value.chars().any(char::is_control) +} + +fn quote(value: &str) -> String { + let has_double = value.contains('"'); + let has_single = value.contains('\''); + + if !has_double { + return format!("\"{}\"", value); + } + if !has_single { + return format!("'{}'", value); + } + // Both quote styles are present: double the double quotes, as the parser expects. + format!("\"{}\"", value.replace('"', "\"\"")) +} + +/// Format an object key. Keys are bare when they read as plain identifiers. +fn format_key(key: &str) -> String { + let plain = !key.is_empty() + && key != BASE64_MARKER + && !needs_encoding(key) + && !key + .chars() + .any(|c| c.is_whitespace() || matches!(c, '(' | ')' | '\'' | '"' | ':' | '`')); + + if plain { + key.to_string() + } else { + format_string(key) + } +} + +// === Decoding === + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Token { + Open, + Close, + Newline, + Ref { value: String, quoted: bool }, +} + +/// A parsed element of the readable form: either a reference (remembering whether +/// it was quoted, which is what distinguishes a string from a number) or a link. +#[derive(Debug, Clone)] +enum Node { + Ref { + value: String, + quoted: bool, + }, + Link { + rows: Vec>, + multiline: bool, + }, +} + +fn tokenize(text: &str) -> Result, CodecError> { + let chars: Vec = text.chars().collect(); + let mut tokens = Vec::new(); + let mut i = 0; + + while i < chars.len() { + let c = chars[i]; + + if c == '\n' { + tokens.push(Token::Newline); + i += 1; + } else if c.is_whitespace() { + i += 1; + } else if c == '(' { + tokens.push(Token::Open); + i += 1; + } else if c == ')' { + tokens.push(Token::Close); + i += 1; + } else if matches!(c, '"' | '\'' | '`') { + let (value, next) = read_quoted(&chars, i, c)?; + tokens.push(Token::Ref { + value, + quoted: true, + }); + i = next; + } else { + let start = i; + while i < chars.len() + && !chars[i].is_whitespace() + && !matches!(chars[i], '(' | ')' | '"' | '\'' | '`') + { + i += 1; + } + tokens.push(Token::Ref { + value: chars[start..i].iter().collect(), + quoted: false, + }); + } + } + + Ok(tokens) +} + +/// Read a quoted reference, where a doubled quote character means a literal one. +fn read_quoted( + chars: &[char], + start: usize, + quote_char: char, +) -> Result<(String, usize), CodecError> { + let mut value = String::new(); + let mut i = start + 1; + + while i < chars.len() { + if chars[i] == quote_char { + if chars.get(i + 1) == Some("e_char) { + value.push(quote_char); + i += 2; + continue; + } + return Ok((value, i + 1)); + } + value.push(chars[i]); + i += 1; + } + + Err(CodecError::ParseError(format!( + "unterminated quoted value starting at character {}", + start + ))) +} + +struct Cursor { + tokens: Vec, + pos: usize, +} + +impl Cursor { + /// Parse rows until the matching `)` (or the end of input at the top level). + /// A row is one line: the values written between two newlines. + fn parse_rows(&mut self, top_level: bool) -> Result>, CodecError> { + let mut rows: Vec> = Vec::new(); + let mut row: Vec = Vec::new(); + + while self.pos < self.tokens.len() { + match &self.tokens[self.pos] { + Token::Close => { + if top_level { + break; + } + self.pos += 1; + if !row.is_empty() { + rows.push(row); + } + return Ok(rows); + } + Token::Newline => { + self.pos += 1; + if !row.is_empty() { + rows.push(std::mem::take(&mut row)); + } + } + _ => row.push(self.parse_node()?), + } + } + + if !top_level { + return Err(CodecError::ParseError( + "unterminated '(' in readable notation".to_string(), + )); + } + + if !row.is_empty() { + rows.push(row); + } + Ok(rows) + } + + fn parse_node(&mut self) -> Result { + match self.tokens[self.pos].clone() { + Token::Ref { value, quoted } => { + self.pos += 1; + Ok(Node::Ref { value, quoted }) + } + Token::Open => { + self.pos += 1; + let multiline = self.link_is_multiline(); + let rows = self.parse_rows(false)?; + Ok(Node::Link { rows, multiline }) + } + Token::Close | Token::Newline => Err(CodecError::ParseError( + "unexpected token in readable notation".to_string(), + )), + } + } + + /// Whether the link that just opened spans more than one line, which is what + /// tells an empty object (`(\n)`) from an empty array (`()`). + fn link_is_multiline(&self) -> bool { + self.tokens[self.pos..] + .iter() + .take_while(|t| **t != Token::Close) + .any(|t| *t == Token::Newline) + } +} + +fn node_to_value(node: &Node) -> Result { + match node { + Node::Ref { value, quoted } => Ok(ref_to_value(value, *quoted)), + Node::Link { rows, multiline } => rows_to_value(rows, *multiline), + } +} + +fn rows_to_value(rows: &[Vec], multiline: bool) -> Result { + if rows.is_empty() { + return Ok(if multiline { + LinoValue::Object(vec![]) + } else { + LinoValue::Array(vec![]) + }); + } + + if let Some(marked) = decode_marked_value(rows) { + return marked; + } + + // `key value` on every line makes an object; anything else is a list of values. + let is_object = rows + .iter() + .all(|row| row.len() == 2 && matches!(row[0], Node::Ref { .. })); + + if is_object { + let mut pairs = Vec::with_capacity(rows.len()); + for row in rows { + let Node::Ref { value: key, .. } = &row[0] else { + unreachable!("checked by is_object") + }; + pairs.push((key.clone(), node_to_value(&row[1])?)); + } + return Ok(LinoValue::Object(pairs)); + } + + let mut items = Vec::new(); + for row in rows { + for node in row { + items.push(node_to_value(node)?); + } + } + Ok(LinoValue::Array(items)) +} + +/// Recognise `(base64 "…")`, the individual marker for values that could not be +/// written as text. A quoted `base64` key is an ordinary object key, not a marker. +fn decode_marked_value(rows: &[Vec]) -> Option> { + if rows.len() != 1 || rows[0].len() != 2 { + return None; + } + + let Node::Ref { + value: marker, + quoted: false, + } = &rows[0][0] + else { + return None; + }; + if marker != BASE64_MARKER { + return None; + } + + let Node::Ref { + value: payload, + quoted: true, + } = &rows[0][1] + else { + return None; + }; + + Some( + BASE64 + .decode(payload) + .map_err(|e| CodecError::DecodeError(format!("invalid base64 value: {}", e))) + .and_then(|bytes| { + String::from_utf8(bytes) + .map(LinoValue::String) + .map_err(|e| CodecError::DecodeError(format!("invalid UTF-8 value: {}", e))) + }), + ) +} + +/// Convert a reference to a value. Quoted references are always strings; bare +/// references keep the type they were written with. +fn ref_to_value(value: &str, quoted: bool) -> LinoValue { + if quoted { + return LinoValue::String(value.to_string()); + } + + match value { + "null" => return LinoValue::Null, + "true" => return LinoValue::Bool(true), + "false" => return LinoValue::Bool(false), + "NaN" => return LinoValue::Float(f64::NAN), + "Infinity" => return LinoValue::Float(f64::INFINITY), + "-Infinity" => return LinoValue::Float(f64::NEG_INFINITY), + _ => {} + } + + if let Ok(i) = value.parse::() { + return LinoValue::Int(i); + } + if value.contains(['.', 'e', 'E']) { + if let Ok(f) = value.parse::() { + return LinoValue::Float(f); + } + } + + LinoValue::String(value.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn roundtrip(value: &LinoValue) -> LinoValue { + let text = encode(value, DEFAULT_INDENT); + decode(&text).unwrap_or_else(|e| panic!("failed to decode {:?}: {}", text, e)) + } + + #[test] + fn empty_containers_keep_their_type() { + assert_eq!(encode(&LinoValue::Array(vec![]), DEFAULT_INDENT), "()"); + assert_eq!(encode(&LinoValue::Object(vec![]), DEFAULT_INDENT), "(\n)"); + assert_eq!( + roundtrip(&LinoValue::Array(vec![])), + LinoValue::Array(vec![]) + ); + assert_eq!( + roundtrip(&LinoValue::Object(vec![])), + LinoValue::Object(vec![]) + ); + } + + #[test] + fn single_pair_object_is_not_an_array() { + let value = LinoValue::object([("a", LinoValue::Int(1))]); + assert_eq!(encode(&value, DEFAULT_INDENT), "(\n a 1\n)"); + assert_eq!(roundtrip(&value), value); + } + + #[test] + fn numeric_looking_strings_stay_strings() { + let value = LinoValue::object([ + ("count", LinoValue::Int(42)), + ("zip", LinoValue::String("10001".to_string())), + ("flag", LinoValue::String("true".to_string())), + ]); + assert_eq!(roundtrip(&value), value); + } + + #[test] + fn whole_floats_stay_floats() { + let value = LinoValue::Float(1.0); + assert_eq!(encode(&value, DEFAULT_INDENT), "1.0"); + assert!(matches!(roundtrip(&value), LinoValue::Float(f) if (f - 1.0).abs() < f64::EPSILON)); + } + + #[test] + fn base64_key_is_quoted_so_it_is_not_a_marker() { + let value = LinoValue::object([("base64", LinoValue::String("plain".to_string()))]); + assert_eq!( + encode(&value, DEFAULT_INDENT), + "(\n \"base64\" \"plain\"\n)" + ); + assert_eq!(roundtrip(&value), value); + } + + #[test] + fn control_characters_are_marked_individually() { + let value = LinoValue::object([ + ("plain", LinoValue::String("visible".to_string())), + ("raw", LinoValue::String("line1\nline2".to_string())), + ]); + let text = encode(&value, DEFAULT_INDENT); + assert!(text.contains("plain \"visible\""), "{}", text); + assert!( + text.contains("raw (base64 \"bGluZTEKbGluZTI=\")"), + "{}", + text + ); + assert_eq!(roundtrip(&value), value); + } + + #[test] + fn custom_indent_is_used() { + let value = LinoValue::object([("a", LinoValue::Int(1))]); + assert_eq!(encode(&value, " "), "(\n a 1\n)"); + } + + #[test] + fn handwritten_document_without_root_parentheses_decodes() { + let decoded = decode("a 1\nb \"two\"").unwrap(); + assert_eq!( + decoded, + LinoValue::object([ + ("a", LinoValue::Int(1)), + ("b", LinoValue::String("two".into())) + ]) + ); + } + + #[test] + fn unterminated_input_is_an_error() { + assert!(decode("(\n a 1\n").is_err()); + assert!(decode("(\n a \"unterminated\n").is_err()); + assert!(decode("a 1)").is_err()); + } +} diff --git a/rust/tests/documented_examples.rs b/rust/tests/documented_examples.rs new file mode 100644 index 0000000..30d1e04 --- /dev/null +++ b/rust/tests/documented_examples.rs @@ -0,0 +1,52 @@ +//! Checks that the snippets shown in `README.md` and the crate docs stay true. + +use lino_objects_codec::{decode, encode, encode_compact, LinoValue}; + +#[test] +fn scalars_are_written_as_documented() { + assert_eq!(encode(&LinoValue::Null), "null"); + assert_eq!(encode(&LinoValue::Bool(true)), "true"); + assert_eq!(encode(&LinoValue::Int(42)), "42"); + assert_eq!(encode(&LinoValue::Float(3.14)), "3.14"); + assert_eq!(encode(&LinoValue::Float(f64::INFINITY)), "Infinity"); + assert_eq!(encode(&LinoValue::Float(f64::NAN)), "NaN"); + assert_eq!(encode(&LinoValue::String("hello".into())), "\"hello\""); +} + +#[test] +fn quick_start_object_matches_the_readme() { + let data = LinoValue::object([ + ("name", LinoValue::String("Alice".to_string())), + ("age", LinoValue::Int(30)), + ("active", LinoValue::Bool(true)), + ]); + + assert_eq!( + encode(&data), + "(\n name \"Alice\"\n age 30\n active true\n)" + ); +} + +#[test] +fn empty_containers_are_written_as_documented() { + assert_eq!(encode(&LinoValue::Array(vec![])), "()"); + assert_eq!(encode(&LinoValue::Object(vec![])), "(\n)"); +} + +#[test] +fn unrepresentable_values_use_the_documented_marker() { + assert_eq!( + encode(&LinoValue::String("line1\nline2".into())), + "(base64 \"bGluZTEKbGluZTI=\")" + ); +} + +#[test] +fn both_forms_decode_as_documented() { + assert_eq!( + encode_compact(&LinoValue::String("hello".into())), + "(str aGVsbG8=)" + ); + assert_eq!(decode("42").unwrap(), LinoValue::Int(42)); + assert_eq!(decode("(int 42)").unwrap(), LinoValue::Int(42)); +} diff --git a/rust/tests/readable_format.rs b/rust/tests/readable_format.rs new file mode 100644 index 0000000..4a2feed --- /dev/null +++ b/rust/tests/readable_format.rs @@ -0,0 +1,368 @@ +//! Tests for the readable, indented format produced by `encode()` (issue #37). + +use links_notation::{parse_lino_to_links, LiNo}; +use lino_objects_codec::{decode, encode, encode_compact, encode_obfuscated, LinoValue}; + +/// The document from the issue, as a `LinoValue`. +fn router_state() -> LinoValue { + LinoValue::object([ + ("type", LinoValue::String("RouterState".to_string())), + ( + "server", + LinoValue::object([ + ("host", LinoValue::String("127.0.0.1".to_string())), + ("port", LinoValue::Int(18878)), + ]), + ), + ( + "models", + LinoValue::array([ + LinoValue::String("claude-haiku".to_string()), + LinoValue::String("claude-opus".to_string()), + ]), + ), + ( + "value", + LinoValue::array([ + LinoValue::object([ + ("id", LinoValue::String("7cf7abf6".to_string())), + ("label", LinoValue::String("bootstrap-admin".to_string())), + ("ttl_hours", LinoValue::Int(24)), + ("revoked", LinoValue::Bool(false)), + ]), + LinoValue::object([ + ("id", LinoValue::String("94b36f7e".to_string())), + ("label", LinoValue::String("wrapper-run".to_string())), + ("ttl_hours", LinoValue::Int(720)), + ("revoked", LinoValue::Bool(false)), + ]), + ]), + ), + ]) +} + +#[test] +fn encode_writes_keys_and_values_verbatim() { + let encoded = encode(&router_state()); + + for expected in [ + "type \"RouterState\"", + "host \"127.0.0.1\"", + "port 18878", + "\"claude-haiku\"", + "label \"bootstrap-admin\"", + "revoked false", + ] { + assert!( + encoded.contains(expected), + "missing {expected} in:\n{encoded}" + ); + } + + // Nothing is base64-encoded any more. + assert!(!encoded.contains("Um91dGVyU3RhdGU="), "{encoded}"); +} + +#[test] +fn encode_spans_multiple_indented_lines() { + let encoded = encode(&router_state()); + let lines: Vec<&str> = encoded.lines().collect(); + + assert!( + lines.len() > 10, + "expected an indented document:\n{encoded}" + ); + assert_eq!(lines[0], "("); + assert_eq!(lines[1], " type \"RouterState\""); + assert_eq!(*lines.last().unwrap(), ")"); + // Nested values are indented deeper than their key. + assert!(encoded.contains("\n host \"127.0.0.1\""), "{encoded}"); +} + +#[test] +fn encode_matches_the_documented_shape() { + let value = LinoValue::object([ + ("type", LinoValue::String("RouterState".to_string())), + ( + "server", + LinoValue::object([ + ("host", LinoValue::String("127.0.0.1".to_string())), + ("port", LinoValue::Int(18878)), + ]), + ), + ( + "models", + LinoValue::array([ + LinoValue::String("claude-haiku".to_string()), + LinoValue::String("claude-opus".to_string()), + ]), + ), + ]); + + let expected = "(\n \ + type \"RouterState\"\n \ + server (\n \ + host \"127.0.0.1\"\n \ + port 18878\n \ + )\n \ + models (\n \ + \"claude-haiku\"\n \ + \"claude-opus\"\n \ + )\n\ + )"; + + assert_eq!(encode(&value), expected); +} + +#[test] +fn readable_output_is_valid_links_notation() { + let encoded = encode(&router_state()); + let links = parse_lino_to_links(&encoded) + .unwrap_or_else(|e| panic!("links-notation rejected the output: {e:?}\n{encoded}")); + assert_eq!( + links.len(), + 1, + "expected a single document link:\n{encoded}" + ); + + // Parentheses open a nested indentation context (links-notation >= 0.14): + // `server` holds one link of two pairs, not four loose references. + let LiNo::Link { values, .. } = &links[0] else { + panic!("expected a link:\n{encoded}"); + }; + let server = values + .iter() + .find_map(|v| match v { + LiNo::Link { values: pair, .. } + if matches!(pair.first(), Some(LiNo::Ref(k)) if k == "server") => + { + pair.get(1) + } + _ => None, + }) + .expect("server pair"); + + let LiNo::Link { values: fields, .. } = server else { + panic!("server should be a link:\n{encoded}"); + }; + assert_eq!(fields.len(), 2, "server fields were flattened: {fields:?}"); + assert!(fields.iter().all(LiNo::is_link), "{fields:?}"); +} + +#[test] +fn nested_structures_roundtrip() { + let value = router_state(); + assert_eq!(decode(&encode(&value)).unwrap(), value); +} + +#[test] +fn object_used_as_array_element_keeps_its_boundary() { + let value = LinoValue::array([ + LinoValue::object([ + ("id", LinoValue::String("1".to_string())), + ("label", LinoValue::String("one".to_string())), + ]), + LinoValue::object([ + ("id", LinoValue::String("2".to_string())), + ("label", LinoValue::String("two".to_string())), + ]), + ]); + + let decoded = decode(&encode(&value)).unwrap(); + assert_eq!(decoded, value); + + let items = decoded.as_array().expect("array"); + assert_eq!(items.len(), 2, "record boundaries were lost: {items:?}"); + assert_eq!(items[0].get("label").unwrap().as_str(), Some("one")); +} + +#[test] +fn numbers_and_booleans_keep_their_types() { + let value = LinoValue::object([ + ("int", LinoValue::Int(-7)), + ("float", LinoValue::Float(3.5)), + ("whole_float", LinoValue::Float(2.0)), + ("yes", LinoValue::Bool(true)), + ("no", LinoValue::Bool(false)), + ("nothing", LinoValue::Null), + ("numeric_string", LinoValue::String("18878".to_string())), + ("boolean_string", LinoValue::String("true".to_string())), + ]); + + let decoded = decode(&encode(&value)).unwrap(); + + assert!(matches!(decoded.get("int"), Some(LinoValue::Int(-7)))); + assert!(matches!(decoded.get("float"), Some(LinoValue::Float(_)))); + assert!(matches!( + decoded.get("whole_float"), + Some(LinoValue::Float(_)) + )); + assert!(matches!(decoded.get("yes"), Some(LinoValue::Bool(true)))); + assert!(matches!(decoded.get("no"), Some(LinoValue::Bool(false)))); + assert!(matches!(decoded.get("nothing"), Some(LinoValue::Null))); + assert_eq!( + decoded.get("numeric_string").and_then(LinoValue::as_str), + Some("18878") + ); + assert_eq!( + decoded.get("boolean_string").and_then(LinoValue::as_str), + Some("true") + ); +} + +#[test] +fn special_floats_roundtrip() { + let value = LinoValue::object([ + ("nan", LinoValue::Float(f64::NAN)), + ("inf", LinoValue::Float(f64::INFINITY)), + ("neg_inf", LinoValue::Float(f64::NEG_INFINITY)), + ]); + + let decoded = decode(&encode(&value)).unwrap(); + assert!(decoded.get("nan").unwrap().as_float().unwrap().is_nan()); + assert_eq!(decoded.get("inf").unwrap().as_float(), Some(f64::INFINITY)); + assert_eq!( + decoded.get("neg_inf").unwrap().as_float(), + Some(f64::NEG_INFINITY) + ); +} + +#[test] +fn quotes_and_unicode_roundtrip_as_text() { + let values = [ + "plain", + "", + "with spaces", + "it's", + "he said \"hello\"", + "both \"kinds\" of 'quotes'", + "unicode: 你好世界 🌍", + "parens (and) colons: yes", + ]; + + for text in values { + let value = LinoValue::object([("message", LinoValue::String(text.to_string()))]); + let encoded = encode(&value); + assert!(!encoded.contains("base64"), "{text} was encoded: {encoded}"); + assert_eq!( + decode(&encoded).unwrap(), + value, + "roundtrip failed for {text:?}: {encoded}" + ); + } +} + +#[test] +fn values_that_cannot_be_written_as_text_are_marked_individually() { + let value = LinoValue::object([ + ("readable", LinoValue::String("still visible".to_string())), + ("multiline", LinoValue::String("line1\nline2".to_string())), + ("tabbed", LinoValue::String("a\tb".to_string())), + ]); + + let encoded = encode(&value); + + // Only the values that need it are encoded; the rest stays readable. + assert!(encoded.contains("readable \"still visible\""), "{encoded}"); + assert!(encoded.contains("multiline (base64 \""), "{encoded}"); + assert!(encoded.contains("tabbed (base64 \""), "{encoded}"); + assert_eq!(decode(&encoded).unwrap(), value); +} + +#[test] +fn base64_key_is_not_mistaken_for_a_marker() { + let value = LinoValue::object([("base64", LinoValue::String("plain text".to_string()))]); + assert_eq!(decode(&encode(&value)).unwrap(), value); +} + +#[test] +fn empty_containers_keep_their_type() { + let value = LinoValue::object([ + ("empty_array", LinoValue::Array(vec![])), + ("empty_object", LinoValue::Object(vec![])), + ]); + assert_eq!(decode(&encode(&value)).unwrap(), value); +} + +#[test] +fn scalars_at_the_root_roundtrip() { + for value in [ + LinoValue::Null, + LinoValue::Bool(true), + LinoValue::Int(42), + LinoValue::Float(0.5), + LinoValue::String("root".to_string()), + LinoValue::String("multi\nline".to_string()), + ] { + assert_eq!(decode(&encode(&value)).unwrap(), value, "{value:?}"); + } +} + +#[test] +fn files_written_in_the_previous_base64_form_still_decode() { + // A real stored document, as quoted in issue #37. + let stored = "(object ((str dHlwZQ==) (str Um91dGVyU3RhdGU=)) ((str c3VidHlwZQ==) (str VG9rZW5TdG9yZQ==)))"; + + let decoded = decode(stored).unwrap(); + assert_eq!( + decoded, + LinoValue::object([ + ("type", LinoValue::String("RouterState".to_string())), + ("subtype", LinoValue::String("TokenStore".to_string())), + ]) + ); +} + +#[test] +fn compact_form_is_still_available_and_still_decodes() { + let value = router_state(); + + let compact = encode_compact(&value); + assert_eq!( + compact.lines().count(), + 1, + "compact output must be one line" + ); + assert!(compact.contains("Um91dGVyU3RhdGU="), "{compact}"); + assert_eq!(decode(&compact).unwrap(), value); + + // The obfuscated alias is the same encoder under an explicit name. + assert_eq!(encode_obfuscated(&value), compact); +} + +#[test] +fn compact_scalars_and_containers_still_decode() { + for value in [ + LinoValue::Null, + LinoValue::Bool(false), + LinoValue::Int(-1), + LinoValue::Float(f64::INFINITY), + LinoValue::String("hello".to_string()), + LinoValue::Array(vec![]), + LinoValue::Object(vec![]), + LinoValue::array([LinoValue::Int(1), LinoValue::String("two".to_string())]), + ] { + let compact = encode_compact(&value); + assert_eq!(decode(&compact).unwrap(), value, "compact: {compact}"); + } +} + +#[test] +fn hand_written_documents_are_accepted() { + let text = "(\n name \"Alice\"\n age 30\n tags (\n \"a\"\n \"b\"\n )\n)"; + let decoded = decode(text).unwrap(); + + assert_eq!( + decoded.get("name").and_then(LinoValue::as_str), + Some("Alice") + ); + assert_eq!(decoded.get("age").and_then(LinoValue::as_int), Some(30)); + assert_eq!( + decoded + .get("tags") + .and_then(LinoValue::as_array) + .unwrap() + .len(), + 2 + ); +}