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