Skip to main content

lino_objects_codec/
lib.rs

1//! Object encoder/decoder for Links Notation format.
2//!
3//! This library provides encoding and decoding of JSON-like objects to/from
4//! Links Notation format. It supports all common JSON types plus special
5//! float values (NaN, Infinity, -Infinity).
6//!
7//! # Features
8//!
9//! - **Readable by Default**: `encode()` writes plain, indented text that can be read and reviewed
10//! - **Universal Serialization**: Encode objects to Links Notation format
11//! - **Type Support**: Handle all common types: null, boolean, integer, float, string, array, object
12//! - **Special Float Values**: Support for NaN, Infinity, -Infinity (which are not valid JSON)
13//! - **Circular References**: Detect and preserve circular references (via object IDs)
14//! - **Object Identity**: Maintain object identity for shared references
15//! - **UTF-8 Support**: Full Unicode string support, always written as text -- a string is never
16//!   base64-encoded, so every word of it stays greppable. A character the chosen form cannot hold
17//!   (a carriage return in either form, a newline on a single line) is percent-escaped, and only
18//!   that one value is marked, as `(escaped "line one%0Aline two")`
19//! - **Simple API**: Easy-to-use `encode()` and `decode()` functions
20//!
21//! # Example
22//!
23//! ```rust
24//! use lino_objects_codec::{encode, decode, LinoValue};
25//!
26//! // Encode a simple object
27//! let data = LinoValue::object([
28//!     ("name", LinoValue::String("Alice".to_string())),
29//!     ("age", LinoValue::Int(30)),
30//!     ("active", LinoValue::Bool(true)),
31//! ]);
32//! let encoded = encode(&data);
33//! assert_eq!(encoded, "(\n  name \"Alice\"\n  age 30\n  active true\n)");
34//! let decoded = decode(&encoded).unwrap();
35//! assert_eq!(decoded, data);
36//! ```
37//!
38//! # Output formats
39//!
40//! | Function | Output |
41//! |---|---|
42//! | [`encode`] | readable, indented plain text (default) |
43//! | [`encode_with_indent`] | the same, with a custom indentation string |
44//! | [`encode_line`] | readable plain text on a single line, one record per line |
45//! | [`encode_compact`] / [`encode_obfuscated`] | the previous single-line, base64 form |
46//!
47//! [`decode`] accepts all of them, so files written by earlier versions keep working
48//! and migrate to the readable form on the next write. [`decode_line`] is the exact
49//! inverse of [`encode_line`] and reads one record at a time.
50
51use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
52use links_notation::{LiNo, parse_lino_to_links};
53use std::collections::{HashMap, HashSet};
54use std::fmt;
55
56pub mod debug;
57#[cfg(feature = "serde_json")]
58pub mod json;
59pub mod readable;
60
61pub use readable::{BASE64_MARKER, DEFAULT_INDENT, ESCAPED_MARKER, OBJECT_MARKER};
62
63/// Type identifiers used in the compact (base64) Links Notation format
64mod type_ids {
65    pub const NULL: &str = "null";
66    pub const BOOL: &str = "bool";
67    pub const INT: &str = "int";
68    pub const FLOAT: &str = "float";
69    pub const STR: &str = "str";
70    pub const ARRAY: &str = "array";
71    pub const OBJECT: &str = "object";
72}
73
74/// Error types for codec operations
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum CodecError {
77    /// Parsing error
78    ParseError(String),
79    /// Decoding error
80    DecodeError(String),
81    /// Unknown type marker
82    UnknownType(String),
83}
84
85impl fmt::Display for CodecError {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        match self {
88            CodecError::ParseError(msg) => write!(f, "Parse error: {}", msg),
89            CodecError::DecodeError(msg) => write!(f, "Decode error: {}", msg),
90            CodecError::UnknownType(t) => write!(f, "Unknown type marker: {}", t),
91        }
92    }
93}
94
95impl std::error::Error for CodecError {}
96
97/// A value that can be encoded/decoded using the Links Notation codec.
98///
99/// This type supports all the types available in Python/JavaScript versions
100/// including special float values (NaN, Infinity) that are not valid JSON.
101#[derive(Debug, Clone)]
102pub enum LinoValue {
103    /// Null value
104    Null,
105    /// Boolean value
106    Bool(bool),
107    /// Integer value (64-bit signed)
108    Int(i64),
109    /// Floating point value (64-bit)
110    Float(f64),
111    /// A JSON number whose textual representation is retained exactly.
112    #[cfg(feature = "serde_json")]
113    JsonNumber(serde_json::Number),
114    /// String value
115    String(String),
116    /// Array of values
117    Array(Vec<LinoValue>),
118    /// Object/dictionary with string keys
119    Object(Vec<(String, LinoValue)>),
120}
121
122impl PartialEq for LinoValue {
123    fn eq(&self, other: &Self) -> bool {
124        match (self, other) {
125            (LinoValue::Null, LinoValue::Null) => true,
126            (LinoValue::Bool(a), LinoValue::Bool(b)) => a == b,
127            (LinoValue::Int(a), LinoValue::Int(b)) => a == b,
128            (LinoValue::Float(a), LinoValue::Float(b)) => {
129                // Handle NaN comparison
130                if a.is_nan() && b.is_nan() {
131                    true
132                } else {
133                    a == b
134                }
135            }
136            #[cfg(feature = "serde_json")]
137            (LinoValue::JsonNumber(a), LinoValue::JsonNumber(b)) => a == b,
138            (LinoValue::String(a), LinoValue::String(b)) => a == b,
139            (LinoValue::Array(a), LinoValue::Array(b)) => a == b,
140            (LinoValue::Object(a), LinoValue::Object(b)) => {
141                // Objects are equal if they have the same keys and values
142                if a.len() != b.len() {
143                    return false;
144                }
145                // Create hashmaps for comparison (order-independent)
146                let a_map: HashMap<&str, &LinoValue> =
147                    a.iter().map(|(k, v)| (k.as_str(), v)).collect();
148                let b_map: HashMap<&str, &LinoValue> =
149                    b.iter().map(|(k, v)| (k.as_str(), v)).collect();
150                a_map == b_map
151            }
152            _ => false,
153        }
154    }
155}
156
157impl LinoValue {
158    /// Create an object from an iterator of key-value pairs.
159    pub fn object<I, K, V>(iter: I) -> Self
160    where
161        I: IntoIterator<Item = (K, V)>,
162        K: Into<String>,
163        V: Into<LinoValue>,
164    {
165        LinoValue::Object(
166            iter.into_iter()
167                .map(|(k, v)| (k.into(), v.into()))
168                .collect(),
169        )
170    }
171
172    /// Create an array from an iterator of values.
173    pub fn array<I, V>(iter: I) -> Self
174    where
175        I: IntoIterator<Item = V>,
176        V: Into<LinoValue>,
177    {
178        LinoValue::Array(iter.into_iter().map(|v| v.into()).collect())
179    }
180
181    /// Check if this is a null value.
182    pub fn is_null(&self) -> bool {
183        matches!(self, LinoValue::Null)
184    }
185
186    /// Get as a boolean, if this is a bool.
187    pub fn as_bool(&self) -> Option<bool> {
188        match self {
189            LinoValue::Bool(b) => Some(*b),
190            _ => None,
191        }
192    }
193
194    /// Get as an integer, if this is an int.
195    pub fn as_int(&self) -> Option<i64> {
196        match self {
197            LinoValue::Int(i) => Some(*i),
198            _ => None,
199        }
200    }
201
202    /// Get as a float, if this is a float or int.
203    pub fn as_float(&self) -> Option<f64> {
204        match self {
205            LinoValue::Float(f) => Some(*f),
206            LinoValue::Int(i) => Some(*i as f64),
207            _ => None,
208        }
209    }
210
211    /// Get as a string, if this is a string.
212    pub fn as_str(&self) -> Option<&str> {
213        match self {
214            LinoValue::String(s) => Some(s),
215            _ => None,
216        }
217    }
218
219    /// Get as an array, if this is an array.
220    pub fn as_array(&self) -> Option<&Vec<LinoValue>> {
221        match self {
222            LinoValue::Array(a) => Some(a),
223            _ => None,
224        }
225    }
226
227    /// Get as an object, if this is an object.
228    pub fn as_object(&self) -> Option<&Vec<(String, LinoValue)>> {
229        match self {
230            LinoValue::Object(o) => Some(o),
231            _ => None,
232        }
233    }
234
235    /// Get a value from an object by key.
236    pub fn get(&self, key: &str) -> Option<&LinoValue> {
237        match self {
238            LinoValue::Object(o) => o.iter().find(|(k, _)| k == key).map(|(_, v)| v),
239            _ => None,
240        }
241    }
242
243    /// Get a value from an array by index.
244    pub fn get_index(&self, index: usize) -> Option<&LinoValue> {
245        match self {
246            LinoValue::Array(a) => a.get(index),
247            _ => None,
248        }
249    }
250}
251
252// Implement From traits for convenience
253impl From<()> for LinoValue {
254    fn from(_: ()) -> Self {
255        LinoValue::Null
256    }
257}
258
259impl From<bool> for LinoValue {
260    fn from(b: bool) -> Self {
261        LinoValue::Bool(b)
262    }
263}
264
265impl From<i32> for LinoValue {
266    fn from(i: i32) -> Self {
267        LinoValue::Int(i as i64)
268    }
269}
270
271impl From<i64> for LinoValue {
272    fn from(i: i64) -> Self {
273        LinoValue::Int(i)
274    }
275}
276
277impl From<f64> for LinoValue {
278    fn from(f: f64) -> Self {
279        LinoValue::Float(f)
280    }
281}
282
283impl From<&str> for LinoValue {
284    fn from(s: &str) -> Self {
285        LinoValue::String(s.to_string())
286    }
287}
288
289impl From<String> for LinoValue {
290    fn from(s: String) -> Self {
291        LinoValue::String(s)
292    }
293}
294
295impl<T: Into<LinoValue>> From<Vec<T>> for LinoValue {
296    fn from(v: Vec<T>) -> Self {
297        LinoValue::Array(v.into_iter().map(|x| x.into()).collect())
298    }
299}
300
301impl<T: Into<LinoValue>> From<Option<T>> for LinoValue {
302    fn from(opt: Option<T>) -> Self {
303        match opt {
304            Some(v) => v.into(),
305            None => LinoValue::Null,
306        }
307    }
308}
309
310/// Codec for encoding/decoding LinoValue to/from Links Notation.
311///
312/// This codec handles the conversion between `LinoValue` and Links Notation
313/// format strings. It supports circular references and shared object identity
314/// through object ID references.
315pub struct ObjectCodec {
316    /// Counter for generating unique object IDs
317    encode_counter: usize,
318    /// Maps object representations to their assigned IDs (for detecting shared references)
319    encode_memo: HashMap<String, String>,
320    /// Set of object representations that need IDs (referenced multiple times)
321    needs_id: HashSet<String>,
322    /// All link definitions generated during encoding (for multi-link format)
323    all_definitions: Vec<(String, LiNo<String>)>,
324    /// Maps object IDs to decoded values during decoding
325    decode_memo: HashMap<String, LinoValue>,
326    /// All links available for forward reference resolution
327    all_links: Vec<LiNo<String>>,
328}
329
330impl Default for ObjectCodec {
331    fn default() -> Self {
332        Self::new()
333    }
334}
335
336impl ObjectCodec {
337    /// Create a new ObjectCodec instance.
338    pub fn new() -> Self {
339        ObjectCodec {
340            encode_counter: 0,
341            encode_memo: HashMap::new(),
342            needs_id: HashSet::new(),
343            all_definitions: Vec::new(),
344            decode_memo: HashMap::new(),
345            all_links: Vec::new(),
346        }
347    }
348
349    /// Reset the encoder state for a new encoding operation.
350    fn reset_encode_state(&mut self) {
351        self.encode_counter = 0;
352        self.encode_memo.clear();
353        self.needs_id.clear();
354        self.all_definitions.clear();
355    }
356
357    /// Reset the decoder state for a new decoding operation.
358    fn reset_decode_state(&mut self) {
359        self.decode_memo.clear();
360        self.all_links.clear();
361    }
362
363    /// Create a Link from string parts.
364    fn make_link(&self, parts: &[&str]) -> LiNo<String> {
365        let values: Vec<LiNo<String>> = parts.iter().map(|p| LiNo::Ref(p.to_string())).collect();
366        LiNo::Link { id: None, values }
367    }
368
369    /// Create a reference Link with just an ID.
370    fn make_ref(&self, id: &str) -> LiNo<String> {
371        LiNo::Ref(id.to_string())
372    }
373
374    /// Generate a unique object key for detecting shared references.
375    fn object_key(&self, value: &LinoValue) -> String {
376        // Use pointer-based identity
377        format!("{:p}", value)
378    }
379
380    /// First pass: identify which objects need IDs (referenced multiple times or circularly).
381    fn find_objects_needing_ids(&mut self, value: &LinoValue, seen: &mut HashMap<String, bool>) {
382        // Only track arrays and objects (compound types)
383        match value {
384            LinoValue::Array(arr) => {
385                let key = self.object_key(value);
386
387                if seen.contains_key(&key) {
388                    // Already seen - needs an ID
389                    self.needs_id.insert(key);
390                    return;
391                }
392
393                seen.insert(key, true);
394
395                for item in arr {
396                    self.find_objects_needing_ids(item, seen);
397                }
398            }
399            LinoValue::Object(obj) => {
400                let key = self.object_key(value);
401
402                if seen.contains_key(&key) {
403                    // Already seen - needs an ID
404                    self.needs_id.insert(key);
405                    return;
406                }
407
408                seen.insert(key, true);
409
410                for (_, v) in obj {
411                    self.find_objects_needing_ids(v, seen);
412                }
413            }
414            _ => {}
415        }
416    }
417
418    /// Encode a LinoValue to the readable, indented Links Notation format.
419    ///
420    /// This is the default representation: keys and values are written as plain
421    /// text, one per line, so the result can be read and reviewed directly.
422    /// See [`readable`] for the exact shape.
423    ///
424    /// # Arguments
425    ///
426    /// * `value` - The value to encode
427    ///
428    /// # Returns
429    ///
430    /// A string in readable Links Notation format
431    pub fn encode(&mut self, value: &LinoValue) -> String {
432        readable::encode(value, DEFAULT_INDENT)
433    }
434
435    /// Encode a LinoValue to the readable format using a custom indentation string.
436    ///
437    /// # Arguments
438    ///
439    /// * `value` - The value to encode
440    /// * `indent` - The indentation string used per nesting level (for example `"    "`)
441    pub fn encode_with_indent(&mut self, value: &LinoValue, indent: &str) -> String {
442        readable::encode(value, indent)
443    }
444
445    /// Encode a LinoValue to the readable, single-line Links Notation format.
446    ///
447    /// The document is written as plain text on one line, so one value is one
448    /// line of an append-only log: appending is a single write, `grep`, `tail -f`
449    /// and `wc -l` keep working, and a compactor can cut at any newline without
450    /// splitting a record. See [`readable`] for the exact shape.
451    ///
452    /// # Arguments
453    ///
454    /// * `value` - The value to encode
455    ///
456    /// # Returns
457    ///
458    /// A string in readable Links Notation format, containing no newline
459    pub fn encode_line(&mut self, value: &LinoValue) -> String {
460        readable::encode_line(value)
461    }
462
463    /// Encode a LinoValue to the compact, single-line Links Notation format.
464    ///
465    /// Every value is tagged with its type and every string is base64-encoded, so
466    /// the whole document fits on one line and carries no readable text. This was
467    /// the default before the readable format; callers now opt into it explicitly.
468    ///
469    /// # Arguments
470    ///
471    /// * `value` - The value to encode
472    ///
473    /// # Returns
474    ///
475    /// A string in compact Links Notation format
476    pub fn encode_compact(&mut self, value: &LinoValue) -> String {
477        self.reset_encode_state();
478
479        // First pass: identify which objects need IDs
480        let mut seen = HashMap::new();
481        self.find_objects_needing_ids(value, &mut seen);
482
483        // Encode the value
484        let mut visited = HashSet::new();
485        let main_link = self.encode_value(value, &mut visited, 0);
486
487        // If we have additional definitions, output them all as multi-link format
488        if !self.all_definitions.is_empty() {
489            let mut all_links = vec![main_link];
490
491            // Add all other definitions (avoid duplicates)
492            for (ref_id, link) in &self.all_definitions {
493                let main_id = match &all_links[0] {
494                    LiNo::Link { id: Some(id), .. } => Some(id.clone()),
495                    _ => None,
496                };
497                if main_id.as_ref() != Some(ref_id) {
498                    all_links.push(link.clone());
499                }
500            }
501
502            // Format as multi-link (newline separated)
503            all_links
504                .iter()
505                .map(Self::format_link)
506                .collect::<Vec<_>>()
507                .join("\n")
508        } else {
509            Self::format_link(&main_link)
510        }
511    }
512
513    /// Encode a LinoValue to the compact, base64 form.
514    ///
515    /// Alias of [`ObjectCodec::encode_compact`], named after what the form does to
516    /// its content: nothing in the output can be read without decoding it.
517    pub fn encode_obfuscated(&mut self, value: &LinoValue) -> String {
518        self.encode_compact(value)
519    }
520
521    /// Format a single link to its string representation.
522    fn format_link(link: &LiNo<String>) -> String {
523        match link {
524            LiNo::Ref(s) => s.clone(),
525            LiNo::Link { id, values } => {
526                let inner: Vec<String> = values.iter().map(Self::format_link).collect();
527
528                if let Some(link_id) = id {
529                    if inner.is_empty() {
530                        format!("({}:)", link_id)
531                    } else {
532                        format!("({}: {})", link_id, inner.join(" "))
533                    }
534                } else if inner.is_empty() {
535                    "()".to_string()
536                } else {
537                    format!("({})", inner.join(" "))
538                }
539            }
540        }
541    }
542
543    /// Encode a value into a Link.
544    fn encode_value(
545        &mut self,
546        value: &LinoValue,
547        visited: &mut HashSet<String>,
548        depth: usize,
549    ) -> LiNo<String> {
550        match value {
551            LinoValue::Null => self.make_link(&[type_ids::NULL]),
552
553            LinoValue::Bool(b) => {
554                if *b {
555                    self.make_link(&[type_ids::BOOL, "true"])
556                } else {
557                    self.make_link(&[type_ids::BOOL, "false"])
558                }
559            }
560
561            LinoValue::Int(i) => self.make_link(&[type_ids::INT, &i.to_string()]),
562
563            LinoValue::Float(f) => {
564                if f.is_nan() {
565                    self.make_link(&[type_ids::FLOAT, "NaN"])
566                } else if f.is_infinite() {
567                    if f.is_sign_positive() {
568                        self.make_link(&[type_ids::FLOAT, "Infinity"])
569                    } else {
570                        self.make_link(&[type_ids::FLOAT, "-Infinity"])
571                    }
572                } else {
573                    self.make_link(&[type_ids::FLOAT, &f.to_string()])
574                }
575            }
576
577            #[cfg(feature = "serde_json")]
578            LinoValue::JsonNumber(n) => self.make_link(&["json_number", &n.to_string()]),
579
580            LinoValue::String(s) => {
581                let b64_encoded = BASE64.encode(s.as_bytes());
582                self.make_link(&[type_ids::STR, &b64_encoded])
583            }
584
585            LinoValue::Array(arr) => {
586                let obj_key = self.object_key(value);
587
588                // Check if we've already encoded this object
589                if let Some(ref_id) = self.encode_memo.get(&obj_key).cloned() {
590                    return self.make_ref(&ref_id);
591                }
592
593                // Check if this object needs an ID
594                let needs_id = self.needs_id.contains(&obj_key);
595
596                if needs_id {
597                    // Check for cycle
598                    if visited.contains(&obj_key) {
599                        // We're in a cycle - must have assigned ID already
600                        if let Some(ref_id) = self.encode_memo.get(&obj_key) {
601                            return self.make_ref(ref_id);
602                        }
603                    }
604
605                    // Assign an ID
606                    let ref_id = format!("obj_{}", self.encode_counter);
607                    self.encode_counter += 1;
608                    self.encode_memo.insert(obj_key.clone(), ref_id.clone());
609                    visited.insert(obj_key.clone());
610
611                    // Encode items
612                    let mut parts: Vec<LiNo<String>> = vec![LiNo::Ref(type_ids::ARRAY.to_string())];
613                    for item in arr {
614                        let item_link = self.encode_value(item, visited, depth + 1);
615                        parts.push(item_link);
616                    }
617
618                    let definition = LiNo::Link {
619                        id: Some(ref_id.clone()),
620                        values: parts,
621                    };
622
623                    // Store for multi-link output if not at top level
624                    if depth > 0 {
625                        self.all_definitions.push((ref_id.clone(), definition));
626                        return self.make_ref(&ref_id);
627                    }
628
629                    definition
630                } else {
631                    // No ID needed - simple array
632                    let mut parts: Vec<LiNo<String>> = vec![LiNo::Ref(type_ids::ARRAY.to_string())];
633                    for item in arr {
634                        let item_link = self.encode_value(item, visited, depth + 1);
635                        parts.push(item_link);
636                    }
637                    LiNo::Link {
638                        id: None,
639                        values: parts,
640                    }
641                }
642            }
643
644            LinoValue::Object(obj) => {
645                let obj_key = self.object_key(value);
646
647                // Check if we've already encoded this object
648                if let Some(ref_id) = self.encode_memo.get(&obj_key).cloned() {
649                    return self.make_ref(&ref_id);
650                }
651
652                // Check if this object needs an ID
653                let needs_id = self.needs_id.contains(&obj_key);
654
655                if needs_id {
656                    // Check for cycle
657                    if visited.contains(&obj_key) {
658                        // We're in a cycle - must have assigned ID already
659                        if let Some(ref_id) = self.encode_memo.get(&obj_key) {
660                            return self.make_ref(ref_id);
661                        }
662                    }
663
664                    // Assign an ID
665                    let ref_id = format!("obj_{}", self.encode_counter);
666                    self.encode_counter += 1;
667                    self.encode_memo.insert(obj_key.clone(), ref_id.clone());
668                    visited.insert(obj_key.clone());
669
670                    // Encode key-value pairs
671                    let mut parts: Vec<LiNo<String>> =
672                        vec![LiNo::Ref(type_ids::OBJECT.to_string())];
673                    for (k, v) in obj {
674                        let key_link =
675                            self.encode_value(&LinoValue::String(k.clone()), visited, depth + 1);
676                        let value_link = self.encode_value(v, visited, depth + 1);
677                        let pair = LiNo::Link {
678                            id: None,
679                            values: vec![key_link, value_link],
680                        };
681                        parts.push(pair);
682                    }
683
684                    let definition = LiNo::Link {
685                        id: Some(ref_id.clone()),
686                        values: parts,
687                    };
688
689                    // Store for multi-link output if not at top level
690                    if depth > 0 {
691                        self.all_definitions.push((ref_id.clone(), definition));
692                        return self.make_ref(&ref_id);
693                    }
694
695                    definition
696                } else {
697                    // No ID needed - simple object
698                    let mut parts: Vec<LiNo<String>> =
699                        vec![LiNo::Ref(type_ids::OBJECT.to_string())];
700                    for (k, v) in obj {
701                        let key_link =
702                            self.encode_value(&LinoValue::String(k.clone()), visited, depth + 1);
703                        let value_link = self.encode_value(v, visited, depth + 1);
704                        let pair = LiNo::Link {
705                            id: None,
706                            values: vec![key_link, value_link],
707                        };
708                        parts.push(pair);
709                    }
710                    LiNo::Link {
711                        id: None,
712                        values: parts,
713                    }
714                }
715            }
716        }
717    }
718
719    /// Decode Links Notation format to a LinoValue.
720    ///
721    /// # Arguments
722    ///
723    /// * `notation` - String in Links Notation format
724    ///
725    /// # Returns
726    ///
727    /// The reconstructed value, or an error
728    ///
729    /// Both the readable format and the compact (base64) format are accepted, so
730    /// files written by earlier versions keep working and migrate on next write.
731    pub fn decode(&mut self, notation: &str) -> Result<LinoValue, CodecError> {
732        if notation.trim().is_empty() {
733            return Ok(LinoValue::Null);
734        }
735
736        if is_compact_notation(notation) {
737            crate::debug::trace("codec.decode", || "compact notation detected".to_string());
738            return self.decode_compact(notation);
739        }
740
741        crate::debug::trace("codec.decode", || "readable notation detected".to_string());
742        readable::decode(notation)
743    }
744
745    /// Decode the readable, single-line Links Notation format.
746    ///
747    /// This is the exact inverse of [`ObjectCodec::encode_line`]. A document
748    /// spanning more than one line is rejected, so a reader that hands over one
749    /// record at a time cannot silently merge two records into one value.
750    ///
751    /// # Arguments
752    ///
753    /// * `notation` - One line in readable Links Notation format
754    ///
755    /// # Returns
756    ///
757    /// The reconstructed value, or an error
758    pub fn decode_line(&mut self, notation: &str) -> Result<LinoValue, CodecError> {
759        readable::decode_line(notation)
760    }
761
762    /// Decode the compact (base64) Links Notation format.
763    ///
764    /// # Arguments
765    ///
766    /// * `notation` - String in compact Links Notation format
767    ///
768    /// # Returns
769    ///
770    /// The reconstructed value, or an error
771    pub fn decode_compact(&mut self, notation: &str) -> Result<LinoValue, CodecError> {
772        self.reset_decode_state();
773
774        let links = parse_lino_to_links(notation)
775            .map_err(|e| CodecError::ParseError(format!("{:?}", e)))?;
776
777        if links.is_empty() {
778            return Ok(LinoValue::Null);
779        }
780
781        // Store all links for forward reference resolution
782        if links.len() > 1 {
783            self.all_links = links.clone();
784        }
785
786        // Decode the first link
787        self.decode_link(&links[0])
788    }
789
790    /// Decode a Link into a LinoValue.
791    fn decode_link(&mut self, link: &LiNo<String>) -> Result<LinoValue, CodecError> {
792        match link {
793            LiNo::Ref(id) => {
794                // Check if this is a reference to a previously decoded object
795                if let Some(value) = self.decode_memo.get(id) {
796                    return Ok(value.clone());
797                }
798
799                // Check if it's a forward reference
800                if id.starts_with("obj_") && !self.all_links.is_empty() {
801                    // Look for this ID in remaining links
802                    for other_link in self.all_links.clone() {
803                        if let LiNo::Link {
804                            id: Some(link_id), ..
805                        } = &other_link
806                        {
807                            if link_id == id {
808                                return self.decode_link(&other_link);
809                            }
810                        }
811                    }
812                    // Not found - return empty array as fallback
813                    let result = LinoValue::Array(vec![]);
814                    self.decode_memo.insert(id.clone(), result.clone());
815                    return Ok(result);
816                }
817
818                // Handle single-element type markers (parser returns Ref for single values like "(null)")
819                match id.as_str() {
820                    type_ids::NULL => return Ok(LinoValue::Null),
821                    type_ids::ARRAY => return Ok(LinoValue::Array(vec![])),
822                    type_ids::OBJECT => return Ok(LinoValue::Object(vec![])),
823                    type_ids::STR => return Ok(LinoValue::String(String::new())),
824                    _ => {}
825                }
826
827                // Just a plain string reference
828                Ok(LinoValue::String(id.clone()))
829            }
830
831            LiNo::Link { id, values } => {
832                // Check for self-reference ID (already in memo)
833                let self_ref_id = id.as_ref().filter(|i| i.starts_with("obj_"));
834                if let Some(ref_id) = self_ref_id {
835                    if let Some(value) = self.decode_memo.get(ref_id) {
836                        return Ok(value.clone());
837                    }
838                }
839
840                if values.is_empty() {
841                    return Ok(LinoValue::Null);
842                }
843
844                // Get the type marker from the first value
845                let type_marker = match &values[0] {
846                    LiNo::Ref(t) => t.as_str(),
847                    LiNo::Link { .. } => return Ok(LinoValue::Null),
848                };
849
850                match type_marker {
851                    #[cfg(feature = "serde_json")]
852                    "json_number" => crate::json::decode_compact_number(values),
853                    type_ids::NULL => Ok(LinoValue::Null),
854
855                    type_ids::BOOL => {
856                        if values.len() > 1 {
857                            if let LiNo::Ref(val) = &values[1] {
858                                return Ok(LinoValue::Bool(val.eq_ignore_ascii_case("true")));
859                            }
860                        }
861                        Ok(LinoValue::Bool(false))
862                    }
863
864                    type_ids::INT => {
865                        if values.len() > 1 {
866                            if let LiNo::Ref(val) = &values[1] {
867                                if let Ok(i) = val.parse::<i64>() {
868                                    return Ok(LinoValue::Int(i));
869                                }
870                            }
871                        }
872                        Ok(LinoValue::Int(0))
873                    }
874
875                    type_ids::FLOAT => {
876                        if values.len() > 1 {
877                            if let LiNo::Ref(val) = &values[1] {
878                                return match val.as_str() {
879                                    "NaN" => Ok(LinoValue::Float(f64::NAN)),
880                                    "Infinity" => Ok(LinoValue::Float(f64::INFINITY)),
881                                    "-Infinity" => Ok(LinoValue::Float(f64::NEG_INFINITY)),
882                                    s => {
883                                        if let Ok(f) = s.parse::<f64>() {
884                                            Ok(LinoValue::Float(f))
885                                        } else {
886                                            Ok(LinoValue::Float(0.0))
887                                        }
888                                    }
889                                };
890                            }
891                        }
892                        Ok(LinoValue::Float(0.0))
893                    }
894
895                    type_ids::STR => {
896                        if values.len() > 1 {
897                            if let LiNo::Ref(b64_str) = &values[1] {
898                                if let Ok(bytes) = BASE64.decode(b64_str) {
899                                    if let Ok(s) = String::from_utf8(bytes) {
900                                        return Ok(LinoValue::String(s));
901                                    }
902                                }
903                                // If decode fails, return raw value
904                                return Ok(LinoValue::String(b64_str.clone()));
905                            }
906                        }
907                        Ok(LinoValue::String(String::new()))
908                    }
909
910                    type_ids::ARRAY => {
911                        // Create result array and register in memo early for circular refs
912                        let result_array = LinoValue::Array(vec![]);
913                        if let Some(ref_id) = self_ref_id {
914                            self.decode_memo.insert(ref_id.clone(), result_array);
915                        }
916
917                        // Decode items (skip type marker at index 0)
918                        let mut items = Vec::new();
919                        for item_link in values.iter().skip(1) {
920                            let decoded = self.decode_link(item_link)?;
921                            items.push(decoded);
922                        }
923
924                        let result = LinoValue::Array(items);
925
926                        // Update memo if needed
927                        if let Some(ref_id) = self_ref_id {
928                            self.decode_memo.insert(ref_id.clone(), result.clone());
929                        }
930
931                        Ok(result)
932                    }
933
934                    type_ids::OBJECT => {
935                        // Create result object and register in memo early for circular refs
936                        let result_object = LinoValue::Object(vec![]);
937                        if let Some(ref_id) = self_ref_id {
938                            self.decode_memo.insert(ref_id.clone(), result_object);
939                        }
940
941                        // Decode key-value pairs (skip type marker at index 0)
942                        let mut obj = Vec::new();
943                        for pair_link in values.iter().skip(1) {
944                            if let LiNo::Link { values: pair, .. } = pair_link {
945                                if pair.len() >= 2 {
946                                    let key = self.decode_link(&pair[0])?;
947                                    let value = self.decode_link(&pair[1])?;
948
949                                    // Key should be a string
950                                    if let LinoValue::String(k) = key {
951                                        obj.push((k, value));
952                                    }
953                                }
954                            }
955                        }
956
957                        let result = LinoValue::Object(obj);
958
959                        // Update memo if needed
960                        if let Some(ref_id) = self_ref_id {
961                            self.decode_memo.insert(ref_id.clone(), result.clone());
962                        }
963
964                        Ok(result)
965                    }
966
967                    unknown => Err(CodecError::UnknownType(unknown.to_string())),
968                }
969            }
970        }
971    }
972}
973
974/// Detect the compact (base64) format.
975///
976/// Compact output always starts a line with `(` immediately followed by a type
977/// marker — optionally preceded by an object id, as in `(obj_0: object …)`.
978/// Readable output never does: its first line is either a lone `(` or a scalar.
979/// Type markers that open a compact document, across all implementations.
980///
981/// The languages historically disagreed on three of them -- Python writes
982/// `None`/`list`/`dict` where JavaScript and Rust write `null`/`array`/`object`
983/// -- so every implementation accepts the union and can read a compact document
984/// written by any of the others.
985const COMPACT_TYPE_MARKERS: &[&str] = &[
986    type_ids::NULL,
987    "None",
988    type_ids::BOOL,
989    type_ids::INT,
990    type_ids::FLOAT,
991    type_ids::STR,
992    type_ids::ARRAY,
993    "list",
994    type_ids::OBJECT,
995    "dict",
996    #[cfg(feature = "serde_json")]
997    "json_number",
998];
999
1000/// Markers a compact document writes without a payload, so `(null)` is a compact
1001/// null while `(null 1)` is a readable line holding two values.
1002const EMPTY_BODY_MARKERS: [&str; 2] = [type_ids::NULL, "None"];
1003
1004fn is_compact_notation(notation: &str) -> bool {
1005    let Some(first_line) = notation.lines().map(str::trim).find(|l| !l.is_empty()) else {
1006        return false;
1007    };
1008
1009    let Some(rest) = first_line.strip_prefix('(') else {
1010        return false;
1011    };
1012
1013    // A compact document names the type of its value first, so a link that opens
1014    // another link straight away is the readable form, whose links nest.
1015    let (mut marker, mut rest) = split_token(rest.trim_start());
1016
1017    // Skip the `obj_N:` definition id, if present.
1018    if let Some(id) = marker.strip_suffix(':') {
1019        if !id.starts_with("obj_") {
1020            return false;
1021        }
1022        (marker, rest) = split_token(rest.trim_start());
1023    }
1024
1025    if !COMPACT_TYPE_MARKERS.contains(&marker) {
1026        return false;
1027    }
1028
1029    // A compact null is the whole link: `(null)`. A link that holds more than the
1030    // marker is a readable line whose first value happens to be null.
1031    if EMPTY_BODY_MARKERS.contains(&marker) {
1032        return rest.trim_start().starts_with(')');
1033    }
1034
1035    true
1036}
1037
1038/// Split off the first token of a link body: the text up to the next whitespace
1039/// or parenthesis. A body that opens with a parenthesis has no token of its own.
1040fn split_token(input: &str) -> (&str, &str) {
1041    let end = input
1042        .find(|c: char| c.is_whitespace() || c == '(' || c == ')')
1043        .unwrap_or(input.len());
1044    input.split_at(end)
1045}
1046
1047// Global codec instance for convenience functions
1048thread_local! {
1049    static DEFAULT_CODEC: std::cell::RefCell<ObjectCodec> = std::cell::RefCell::new(ObjectCodec::new());
1050}
1051
1052/// Encode a value to the readable, indented Links Notation format.
1053///
1054/// This is a convenience function that uses a thread-local codec instance.
1055///
1056/// # Arguments
1057///
1058/// * `value` - The value to encode
1059///
1060/// # Returns
1061///
1062/// A string in readable Links Notation format
1063///
1064/// # Example
1065///
1066/// ```rust
1067/// use lino_objects_codec::{encode, LinoValue};
1068///
1069/// let data = LinoValue::object([
1070///     ("name", LinoValue::String("Alice".to_string())),
1071///     ("age", LinoValue::Int(30)),
1072/// ]);
1073/// let encoded = encode(&data);
1074/// // Names and values are written as they are, one per line
1075/// assert_eq!(encoded, "(\n  name \"Alice\"\n  age 30\n)");
1076/// ```
1077pub fn encode(value: &LinoValue) -> String {
1078    DEFAULT_CODEC.with(|codec| codec.borrow_mut().encode(value))
1079}
1080
1081/// Encode a value to the readable format using a custom indentation string.
1082///
1083/// # Arguments
1084///
1085/// * `value` - The value to encode
1086/// * `indent` - The indentation string used per nesting level
1087///
1088/// # Example
1089///
1090/// ```rust
1091/// use lino_objects_codec::{encode_with_indent, LinoValue};
1092///
1093/// let data = LinoValue::object([("age", LinoValue::Int(30))]);
1094/// assert_eq!(encode_with_indent(&data, "    "), "(\n    age 30\n)");
1095/// ```
1096pub fn encode_with_indent(value: &LinoValue, indent: &str) -> String {
1097    DEFAULT_CODEC.with(|codec| codec.borrow_mut().encode_with_indent(value, indent))
1098}
1099
1100/// Encode a value to the readable, single-line Links Notation format.
1101///
1102/// This is a convenience function that uses a thread-local codec instance.
1103///
1104/// The output holds no newline, so a record written with it is a line: an
1105/// append-only log stays greppable, tailable and countable by `wc -l`, and the
1106/// values in it can be read without decoding anything.
1107///
1108/// # Arguments
1109///
1110/// * `value` - The value to encode
1111///
1112/// # Returns
1113///
1114/// A string in readable Links Notation format, containing no newline
1115///
1116/// # Example
1117///
1118/// ```rust
1119/// use lino_objects_codec::{decode_line, encode_line, LinoValue};
1120///
1121/// let record = LinoValue::object([
1122///     ("bytes", LinoValue::Int(2827)),
1123///     ("complete", LinoValue::Bool(true)),
1124///     ("phase", LinoValue::String("stream_end".to_string())),
1125/// ]);
1126/// let line = encode_line(&record);
1127/// assert_eq!(line, r#"(o: (bytes 2827) (complete true) (phase "stream_end"))"#);
1128/// assert_eq!(decode_line(&line).unwrap(), record);
1129/// ```
1130pub fn encode_line(value: &LinoValue) -> String {
1131    DEFAULT_CODEC.with(|codec| codec.borrow_mut().encode_line(value))
1132}
1133
1134/// Decode one line of readable Links Notation back into a value.
1135///
1136/// This is a convenience function that uses a thread-local codec instance. It is
1137/// the exact inverse of [`encode_line`]; input spanning more than one line is an
1138/// error rather than a silently merged value.
1139///
1140/// # Arguments
1141///
1142/// * `notation` - One line in readable Links Notation format
1143///
1144/// # Returns
1145///
1146/// The reconstructed value, or an error
1147///
1148/// # Example
1149///
1150/// ```rust
1151/// use lino_objects_codec::{decode_line, LinoValue};
1152///
1153/// let decoded = decode_line("(o: (id 1) (tags (\"a\" \"b\")))").unwrap();
1154/// assert_eq!(
1155///     decoded,
1156///     LinoValue::object([
1157///         ("id", LinoValue::Int(1)),
1158///         (
1159///             "tags",
1160///             LinoValue::array([
1161///                 LinoValue::String("a".to_string()),
1162///                 LinoValue::String("b".to_string()),
1163///             ])
1164///         ),
1165///     ])
1166/// );
1167/// ```
1168pub fn decode_line(notation: &str) -> Result<LinoValue, CodecError> {
1169    DEFAULT_CODEC.with(|codec| codec.borrow_mut().decode_line(notation))
1170}
1171
1172/// Encode a value to the compact, single-line Links Notation format.
1173///
1174/// Every string is base64-encoded and the whole document is written on one line.
1175/// [`decode`] reads this form as well, so stored files remain readable by the
1176/// library after switching to the default readable output.
1177///
1178/// # Arguments
1179///
1180/// * `value` - The value to encode
1181///
1182/// # Returns
1183///
1184/// A string in compact Links Notation format
1185///
1186/// # Example
1187///
1188/// ```rust
1189/// use lino_objects_codec::{encode_compact, decode, LinoValue};
1190///
1191/// let data = LinoValue::object([("name", LinoValue::String("Alice".to_string()))]);
1192/// let encoded = encode_compact(&data);
1193/// // String "Alice" is base64-encoded as "QWxpY2U="
1194/// assert!(encoded.contains("QWxpY2U="));
1195/// assert_eq!(decode(&encoded).unwrap(), data);
1196/// ```
1197pub fn encode_compact(value: &LinoValue) -> String {
1198    DEFAULT_CODEC.with(|codec| codec.borrow_mut().encode_compact(value))
1199}
1200
1201/// Encode a value to the compact, base64 form.
1202///
1203/// Alias of [`encode_compact`], named after what the form does to its content.
1204pub fn encode_obfuscated(value: &LinoValue) -> String {
1205    DEFAULT_CODEC.with(|codec| codec.borrow_mut().encode_obfuscated(value))
1206}
1207
1208/// Decode Links Notation format to a value.
1209///
1210/// This is a convenience function that uses a thread-local codec instance.
1211///
1212/// # Arguments
1213///
1214/// * `notation` - String in Links Notation format
1215///
1216/// # Returns
1217///
1218/// The reconstructed value, or an error
1219///
1220/// # Example
1221///
1222/// ```rust
1223/// use lino_objects_codec::{encode, decode, LinoValue};
1224///
1225/// let original = LinoValue::Int(42);
1226/// let encoded = encode(&original);
1227/// let decoded = decode(&encoded).unwrap();
1228/// assert_eq!(decoded, original);
1229/// ```
1230pub fn decode(notation: &str) -> Result<LinoValue, CodecError> {
1231    DEFAULT_CODEC.with(|codec| codec.borrow_mut().decode(notation))
1232}
1233
1234/// Formatting utilities for indented Links Notation format.
1235pub mod format;
1236
1237#[cfg(test)]
1238mod tests {
1239    use super::*;
1240
1241    #[test]
1242    fn test_roundtrip_null() {
1243        let original = LinoValue::Null;
1244        let encoded = encode(&original);
1245        let decoded = decode(&encoded).unwrap();
1246        assert_eq!(decoded, original);
1247    }
1248
1249    #[test]
1250    fn test_roundtrip_bool() {
1251        for value in [true, false] {
1252            let original = LinoValue::Bool(value);
1253            let encoded = encode(&original);
1254            let decoded = decode(&encoded).unwrap();
1255            assert_eq!(decoded, original);
1256        }
1257    }
1258
1259    #[test]
1260    fn test_roundtrip_int() {
1261        let test_values: Vec<i64> = vec![0, 1, -1, 42, -42, 123456789, -123456789];
1262        for value in test_values {
1263            let original = LinoValue::Int(value);
1264            let encoded = encode(&original);
1265            let decoded = decode(&encoded).unwrap();
1266            assert_eq!(decoded.as_int(), Some(value));
1267        }
1268    }
1269
1270    #[test]
1271    fn test_roundtrip_float() {
1272        let test_values: Vec<f64> = vec![0.0, 1.0, -1.0, 3.14, -3.14, 0.123456789, -999.999];
1273        for value in test_values {
1274            let original = LinoValue::Float(value);
1275            let encoded = encode(&original);
1276            let decoded = decode(&encoded).unwrap();
1277            let decoded_f = decoded.as_float().unwrap();
1278            assert!((decoded_f - value).abs() < 0.0001);
1279        }
1280    }
1281
1282    #[test]
1283    fn test_float_special_values() {
1284        // Test infinity
1285        let inf = LinoValue::Float(f64::INFINITY);
1286        let encoded = encode(&inf);
1287        let decoded = decode(&encoded).unwrap();
1288        let decoded_f = decoded.as_float().unwrap();
1289        assert!(decoded_f.is_infinite());
1290        assert!(decoded_f.is_sign_positive());
1291
1292        // Test negative infinity
1293        let neg_inf = LinoValue::Float(f64::NEG_INFINITY);
1294        let encoded = encode(&neg_inf);
1295        let decoded = decode(&encoded).unwrap();
1296        let decoded_f = decoded.as_float().unwrap();
1297        assert!(decoded_f.is_infinite());
1298        assert!(decoded_f.is_sign_negative());
1299
1300        // Test NaN
1301        let nan = LinoValue::Float(f64::NAN);
1302        let encoded = encode(&nan);
1303        let decoded = decode(&encoded).unwrap();
1304        let decoded_f = decoded.as_float().unwrap();
1305        assert!(decoded_f.is_nan());
1306    }
1307
1308    #[test]
1309    fn test_roundtrip_string() {
1310        let test_values = [
1311            "",
1312            "hello",
1313            "hello world",
1314            "Hello, World!",
1315            "multi\nline\nstring",
1316            "tab\tseparated",
1317            "special chars: @#$%^&*()",
1318        ];
1319        for value in test_values {
1320            let original = LinoValue::String(value.to_string());
1321            let encoded = encode(&original);
1322            let decoded = decode(&encoded).unwrap();
1323            assert_eq!(decoded.as_str(), Some(value));
1324        }
1325    }
1326
1327    #[test]
1328    fn test_string_with_quotes() {
1329        let test_values = [
1330            "string with 'single quotes'",
1331            "string with \"double quotes\"",
1332            "string with \"both\" 'quotes'",
1333        ];
1334        for value in test_values {
1335            let original = LinoValue::String(value.to_string());
1336            let encoded = encode(&original);
1337            let decoded = decode(&encoded).unwrap();
1338            assert_eq!(decoded.as_str(), Some(value));
1339        }
1340    }
1341
1342    #[test]
1343    fn test_unicode_string() {
1344        let value = "unicode: 你好世界 🌍";
1345        let original = LinoValue::String(value.to_string());
1346        let encoded = encode(&original);
1347        let decoded = decode(&encoded).unwrap();
1348        assert_eq!(decoded.as_str(), Some(value));
1349    }
1350
1351    #[test]
1352    fn test_roundtrip_empty_array() {
1353        let original = LinoValue::Array(vec![]);
1354        let encoded = encode(&original);
1355        let decoded = decode(&encoded).unwrap();
1356        assert_eq!(decoded, original);
1357    }
1358
1359    #[test]
1360    fn test_roundtrip_simple_array() {
1361        let original = LinoValue::Array(vec![
1362            LinoValue::Int(1),
1363            LinoValue::Int(2),
1364            LinoValue::Int(3),
1365        ]);
1366        let encoded = encode(&original);
1367        let decoded = decode(&encoded).unwrap();
1368        assert_eq!(decoded, original);
1369    }
1370
1371    #[test]
1372    fn test_roundtrip_mixed_array() {
1373        let original = LinoValue::Array(vec![
1374            LinoValue::Int(1),
1375            LinoValue::String("hello".to_string()),
1376            LinoValue::Bool(true),
1377            LinoValue::Null,
1378        ]);
1379        let encoded = encode(&original);
1380        let decoded = decode(&encoded).unwrap();
1381        assert_eq!(decoded, original);
1382    }
1383
1384    #[test]
1385    fn test_nested_arrays() {
1386        let original = LinoValue::Array(vec![LinoValue::Array(vec![])]);
1387        let encoded = encode(&original);
1388        let decoded = decode(&encoded).unwrap();
1389        assert_eq!(decoded, original);
1390
1391        let original2 = LinoValue::Array(vec![
1392            LinoValue::Array(vec![LinoValue::Int(1), LinoValue::Int(2)]),
1393            LinoValue::Array(vec![LinoValue::Int(3), LinoValue::Int(4)]),
1394        ]);
1395        let encoded2 = encode(&original2);
1396        let decoded2 = decode(&encoded2).unwrap();
1397        assert_eq!(decoded2, original2);
1398    }
1399
1400    #[test]
1401    fn test_roundtrip_empty_object() {
1402        let original = LinoValue::Object(vec![]);
1403        let encoded = encode(&original);
1404        let decoded = decode(&encoded).unwrap();
1405        assert_eq!(decoded, original);
1406    }
1407
1408    #[test]
1409    fn test_roundtrip_simple_object() {
1410        let original = LinoValue::object([("a", LinoValue::Int(1)), ("b", LinoValue::Int(2))]);
1411        let encoded = encode(&original);
1412        let decoded = decode(&encoded).unwrap();
1413        assert_eq!(decoded, original);
1414    }
1415
1416    #[test]
1417    fn test_nested_objects() {
1418        let original = LinoValue::object([(
1419            "user",
1420            LinoValue::object([
1421                ("name", LinoValue::String("Alice".to_string())),
1422                (
1423                    "address",
1424                    LinoValue::object([
1425                        ("city", LinoValue::String("NYC".to_string())),
1426                        ("zip", LinoValue::String("10001".to_string())),
1427                    ]),
1428                ),
1429            ]),
1430        )]);
1431        let encoded = encode(&original);
1432        let decoded = decode(&encoded).unwrap();
1433        assert_eq!(decoded, original);
1434    }
1435
1436    #[test]
1437    fn test_complex_structure() {
1438        let original = LinoValue::object([
1439            ("id", LinoValue::Int(123)),
1440            ("name", LinoValue::String("Test Object".to_string())),
1441            ("active", LinoValue::Bool(true)),
1442            (
1443                "tags",
1444                LinoValue::array([
1445                    LinoValue::String("tag1".to_string()),
1446                    LinoValue::String("tag2".to_string()),
1447                    LinoValue::String("tag3".to_string()),
1448                ]),
1449            ),
1450            (
1451                "metadata",
1452                LinoValue::object([
1453                    ("created", LinoValue::String("2025-01-01".to_string())),
1454                    ("modified", LinoValue::Null),
1455                    ("count", LinoValue::Int(42)),
1456                ]),
1457            ),
1458            (
1459                "items",
1460                LinoValue::array([
1461                    LinoValue::object([
1462                        ("id", LinoValue::Int(1)),
1463                        ("value", LinoValue::String("first".to_string())),
1464                    ]),
1465                    LinoValue::object([
1466                        ("id", LinoValue::Int(2)),
1467                        ("value", LinoValue::String("second".to_string())),
1468                    ]),
1469                ]),
1470            ),
1471        ]);
1472        let encoded = encode(&original);
1473        let decoded = decode(&encoded).unwrap();
1474        assert_eq!(decoded, original);
1475    }
1476
1477    #[test]
1478    fn test_list_of_dicts() {
1479        let original = LinoValue::array([
1480            LinoValue::object([
1481                ("name", LinoValue::String("Alice".to_string())),
1482                ("age", LinoValue::Int(30)),
1483            ]),
1484            LinoValue::object([
1485                ("name", LinoValue::String("Bob".to_string())),
1486                ("age", LinoValue::Int(25)),
1487            ]),
1488        ]);
1489        let encoded = encode(&original);
1490        let decoded = decode(&encoded).unwrap();
1491        assert_eq!(decoded, original);
1492    }
1493
1494    #[test]
1495    fn test_dict_of_lists() {
1496        let original = LinoValue::object([
1497            (
1498                "numbers",
1499                LinoValue::array([LinoValue::Int(1), LinoValue::Int(2), LinoValue::Int(3)]),
1500            ),
1501            (
1502                "strings",
1503                LinoValue::array([
1504                    LinoValue::String("a".to_string()),
1505                    LinoValue::String("b".to_string()),
1506                    LinoValue::String("c".to_string()),
1507                ]),
1508            ),
1509        ]);
1510        let encoded = encode(&original);
1511        let decoded = decode(&encoded).unwrap();
1512        assert_eq!(decoded, original);
1513    }
1514}
1515
1516#[cfg(test)]
1517mod format_tests {
1518    use super::format::*;
1519    use std::collections::HashMap;
1520
1521    #[test]
1522    fn test_escape_reference_simple_string() {
1523        assert_eq!(escape_reference("hello"), "hello");
1524        assert_eq!(escape_reference("world"), "world");
1525    }
1526
1527    #[test]
1528    fn test_escape_reference_string_with_spaces() {
1529        let result = escape_reference("hello world");
1530        assert!(result.starts_with('\'') || result.starts_with('"'));
1531        assert!(result.contains("hello world"));
1532    }
1533
1534    #[test]
1535    fn test_escape_reference_string_with_single_quotes() {
1536        let result = escape_reference("it's");
1537        assert_eq!(result, "\"it's\"");
1538    }
1539
1540    #[test]
1541    fn test_escape_reference_string_with_double_quotes() {
1542        let result = escape_reference("he said \"hello\"");
1543        assert_eq!(result, "'he said \"hello\"'");
1544    }
1545
1546    #[test]
1547    fn test_unescape_reference_doubled_quotes() {
1548        assert_eq!(
1549            unescape_reference("he said \"\"hello\"\""),
1550            "he said \"hello\""
1551        );
1552        assert_eq!(unescape_reference("it''s"), "it's");
1553    }
1554
1555    #[test]
1556    fn test_format_indented_ordered_basic() {
1557        let pairs = [
1558            ("uuid", "6dcf4c1b-ff3f-482c-95ab-711ea7d1b019"),
1559            ("status", "executed"),
1560            ("command", "echo test"),
1561            ("exitCode", "0"),
1562        ];
1563        let result =
1564            format_indented_ordered("6dcf4c1b-ff3f-482c-95ab-711ea7d1b019", &pairs, "  ").unwrap();
1565        let lines: Vec<&str> = result.lines().collect();
1566        assert_eq!(lines[0], "6dcf4c1b-ff3f-482c-95ab-711ea7d1b019");
1567        assert_eq!(lines[1], "  uuid \"6dcf4c1b-ff3f-482c-95ab-711ea7d1b019\"");
1568        assert_eq!(lines[2], "  status \"executed\"");
1569        assert_eq!(lines[3], "  command \"echo test\"");
1570        assert_eq!(lines[4], "  exitCode \"0\"");
1571    }
1572
1573    #[test]
1574    fn test_format_indented_value_with_quotes() {
1575        // Values containing double quotes are wrapped in single quotes (links-notation style)
1576        let pairs = [("message", "He said \"hello\"")];
1577        let result = format_indented_ordered("test-id", &pairs, "  ").unwrap();
1578        let lines: Vec<&str> = result.lines().collect();
1579        assert_eq!(lines[1], "  message 'He said \"hello\"'");
1580    }
1581
1582    #[test]
1583    fn test_format_indented_requires_id() {
1584        let mut obj = HashMap::new();
1585        obj.insert("key".to_string(), "value".to_string());
1586        let result = format_indented("", &obj, "  ");
1587        assert!(result.is_err());
1588    }
1589
1590    #[test]
1591    fn test_parse_indented_basic() {
1592        let text = "6dcf4c1b-ff3f-482c-95ab-711ea7d1b019\n  uuid \"6dcf4c1b-ff3f-482c-95ab-711ea7d1b019\"\n  status \"executed\"\n  exitCode \"0\"";
1593        let (id, obj) = parse_indented(text).unwrap();
1594        assert_eq!(id, "6dcf4c1b-ff3f-482c-95ab-711ea7d1b019");
1595        assert_eq!(
1596            obj.get("uuid"),
1597            Some(&"6dcf4c1b-ff3f-482c-95ab-711ea7d1b019".to_string())
1598        );
1599        assert_eq!(obj.get("status"), Some(&"executed".to_string()));
1600        assert_eq!(obj.get("exitCode"), Some(&"0".to_string()));
1601    }
1602
1603    #[test]
1604    fn test_parse_indented_with_quotes() {
1605        // Links-notation style: use single quotes to wrap value containing double quotes
1606        let text = "test-id\n  message 'He said \"hello\"'";
1607        let (id, obj) = parse_indented(text).unwrap();
1608        assert_eq!(id, "test-id");
1609        assert_eq!(obj.get("message"), Some(&"He said \"hello\"".to_string()));
1610    }
1611
1612    #[test]
1613    fn test_parse_indented_empty_lines_skipped() {
1614        let text = "test-id\n\n  key \"value\"\n\n  another \"value2\"";
1615        let (id, obj) = parse_indented(text).unwrap();
1616        assert_eq!(id, "test-id");
1617        assert_eq!(obj.get("key"), Some(&"value".to_string()));
1618        assert_eq!(obj.get("another"), Some(&"value2".to_string()));
1619    }
1620
1621    #[test]
1622    fn test_parse_indented_requires_text() {
1623        let result = parse_indented("");
1624        assert!(result.is_err());
1625    }
1626
1627    #[test]
1628    fn test_roundtrip_format_indented() {
1629        let pairs = [
1630            ("uuid", "6dcf4c1b-ff3f-482c-95ab-711ea7d1b019"),
1631            ("status", "executed"),
1632            ("command", "echo test"),
1633            ("exitCode", "0"),
1634        ];
1635        let formatted =
1636            format_indented_ordered("6dcf4c1b-ff3f-482c-95ab-711ea7d1b019", &pairs, "  ").unwrap();
1637        let (parsed_id, parsed_obj) = parse_indented(&formatted).unwrap();
1638
1639        assert_eq!(parsed_id, "6dcf4c1b-ff3f-482c-95ab-711ea7d1b019");
1640        for (key, value) in pairs {
1641            assert_eq!(parsed_obj.get(key), Some(&value.to_string()));
1642        }
1643    }
1644
1645    #[test]
1646    fn test_roundtrip_with_quotes() {
1647        let pairs = [("message", "He said \"hello\"")];
1648        let formatted = format_indented_ordered("test-id", &pairs, "  ").unwrap();
1649        let (parsed_id, parsed_obj) = parse_indented(&formatted).unwrap();
1650
1651        assert_eq!(parsed_id, "test-id");
1652        assert_eq!(
1653            parsed_obj.get("message"),
1654            Some(&"He said \"hello\"".to_string())
1655        );
1656    }
1657}