Skip to main content

lino_objects_codec/
readable.rs

1//! Readable, indented Links Notation representation.
2//!
3//! This module implements the default output of [`crate::encode`]: a plain-text,
4//! indented projection where keys and values are written as they are, so the file
5//! can be read, grepped and reviewed without decoding anything.
6//!
7//! # Shape
8//!
9//! One construct — `( )` — is used for both objects and arrays, at every level
10//! including the root. What distinguishes them is the content of the lines:
11//! `key value` pairs make an object, bare values make an array.
12//!
13//! ```text
14//! (
15//!   type "RouterState"
16//!   server (
17//!     host "127.0.0.1"
18//!     port 18878
19//!   )
20//!   models (
21//!     "claude-haiku"
22//!     "claude-opus"
23//!   )
24//! )
25//! ```
26//!
27//! # Value mapping
28//!
29//! | `LinoValue`                | Readable form                                  |
30//! |----------------------------|------------------------------------------------|
31//! | `Object`                   | `( )` with one `key value` pair per line        |
32//! | `Array`                    | `( )` with one value per line                   |
33//! | `String`                   | quoted, never encoded                           |
34//! | `Int` / `Float` / `Bool` / `Null` | bare, so the type survives the round trip |
35//!
36//! Empty containers keep their type: an empty array is `()` on one line, while an
37//! empty object is written as `(` and `)` on two lines.
38//!
39//! Only values that cannot be written as plain text are encoded: strings holding
40//! control characters (including newlines and tabs, which line-based tooling and
41//! CRLF normalisation would corrupt) are marked individually as
42//! `(base64 "…")` instead of encoding the whole document.
43//!
44//! # Single-line form
45//!
46//! [`encode_line`] writes the same document on one line, so one record is one
47//! line and an append-only log stays greppable, tailable and countable by
48//! `wc -l`. Rows can no longer be told apart by line breaks there, so an object
49//! names itself with the `o` link id the notation already has, and its pairs are
50//! written as their own links:
51//!
52//! ```text
53//! (o: (type "RouterState") (server (o: (host "127.0.0.1") (port 18878))) (models ("claude-haiku" "claude-opus")))
54//! ```
55//!
56//! | Value            | Single-line form              |
57//! |------------------|-------------------------------|
58//! | `Object`         | `(o: (key value) …)`          |
59//! | empty `Object`   | `(o:)`                        |
60//! | `Array`          | `(value …)`                   |
61//! | empty `Array`    | `()`                          |
62//! | scalars          | exactly as in the indented form |
63//!
64//! The marker is what answers the ambiguity a flat layout otherwise has: without
65//! it `((key value))` reads both as the one-pair object and as the array holding
66//! the two-element array, and an empty key makes it worse. With it, a bare `( )`
67//! is always an array and a marked one is always an object, so every value —
68//! empty key included — survives the round trip. Consequently a *hand-written*
69//! one-line link such as `(a 1)` is the two-element array, not the one-pair
70//! object: on one line, objects say so.
71
72use crate::debug::trace;
73use crate::{CodecError, LinoValue};
74use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
75
76/// Default indentation used by [`encode`].
77pub const DEFAULT_INDENT: &str = "  ";
78
79/// Marker used for values that cannot be represented as plain text.
80pub const BASE64_MARKER: &str = "base64";
81
82/// Link id naming an object in the single-line form, written as `(o: …)`.
83pub const OBJECT_MARKER: &str = "o";
84
85/// Encode a value into the readable, indented Links Notation form.
86pub fn encode(value: &LinoValue, indent: &str) -> String {
87    let mut out = String::new();
88    write_value(value, indent, 0, &mut out);
89    out
90}
91
92/// Encode a value into the readable, single-line Links Notation form.
93///
94/// The result never contains a newline, so one value is one line of an
95/// append-only log. See the module documentation for the shape.
96pub fn encode_line(value: &LinoValue) -> String {
97    let mut out = String::new();
98    write_line_value(value, &mut out);
99    out
100}
101
102/// Decode the readable, single-line Links Notation form back into a value.
103///
104/// This is the exact inverse of [`encode_line`]. Input spanning more than one
105/// line is rejected: a line-based reader hands over one record at a time, and
106/// silently accepting several would merge two records into one value.
107pub fn decode_line(text: &str) -> Result<LinoValue, CodecError> {
108    let line = text.trim_matches(|c: char| c == '\n' || c == '\r');
109    if line.contains('\n') || line.contains('\r') {
110        return Err(CodecError::ParseError(
111            "a single-line document cannot contain a line break".to_string(),
112        ));
113    }
114    decode(line)
115}
116
117/// Decode the readable, indented Links Notation form back into a value.
118pub fn decode(text: &str) -> Result<LinoValue, CodecError> {
119    let tokens = tokenize(text)?;
120    trace("readable.decode", || format!("{} tokens", tokens.len()));
121    let mut cursor = Cursor { tokens, pos: 0 };
122    let rows = cursor.parse_rows(true)?;
123
124    if cursor.pos < cursor.tokens.len() {
125        return Err(CodecError::ParseError(
126            "unexpected ')' in readable notation".to_string(),
127        ));
128    }
129
130    // A document holding a single value (for example `42`) is that value.
131    if rows.len() == 1 && rows[0].len() == 1 {
132        return node_to_value(&rows[0][0]);
133    }
134
135    rows_to_value(&rows, true, false)
136}
137
138// === Encoding ===
139
140fn write_value(value: &LinoValue, indent: &str, level: usize, out: &mut String) {
141    match value {
142        LinoValue::Object(pairs) => {
143            if pairs.is_empty() {
144                // An empty object spans two lines; `()` on one line is an empty array.
145                out.push_str("(\n");
146                push_indent(indent, level, out);
147                out.push(')');
148                return;
149            }
150
151            out.push('(');
152            for (key, child) in pairs {
153                out.push('\n');
154                push_indent(indent, level + 1, out);
155                out.push_str(&format_key(key));
156                out.push(' ');
157                write_value(child, indent, level + 1, out);
158            }
159            out.push('\n');
160            push_indent(indent, level, out);
161            out.push(')');
162        }
163
164        LinoValue::Array(items) => {
165            if items.is_empty() {
166                out.push_str("()");
167                return;
168            }
169
170            out.push('(');
171            for item in items {
172                out.push('\n');
173                push_indent(indent, level + 1, out);
174                write_value(item, indent, level + 1, out);
175            }
176            out.push('\n');
177            push_indent(indent, level, out);
178            out.push(')');
179        }
180
181        scalar => out.push_str(&format_scalar(scalar)),
182    }
183}
184
185/// Write a value on one line. Objects name themselves with the `o` link id and
186/// write each pair as its own link, so nothing depends on where lines break.
187fn write_line_value(value: &LinoValue, out: &mut String) {
188    match value {
189        LinoValue::Object(pairs) => {
190            if pairs.is_empty() {
191                // `()` is the empty array, so the empty object keeps its marker.
192                out.push('(');
193                out.push_str(OBJECT_MARKER);
194                out.push_str(":)");
195                return;
196            }
197
198            out.push('(');
199            out.push_str(OBJECT_MARKER);
200            out.push(':');
201            for (key, child) in pairs {
202                out.push_str(" (");
203                out.push_str(&format_key(key));
204                out.push(' ');
205                write_line_value(child, out);
206                out.push(')');
207            }
208            out.push(')');
209        }
210
211        LinoValue::Array(items) => {
212            out.push('(');
213            for (index, item) in items.iter().enumerate() {
214                if index > 0 {
215                    out.push(' ');
216                }
217                write_line_value(item, out);
218            }
219            out.push(')');
220        }
221
222        scalar => out.push_str(&format_scalar(scalar)),
223    }
224}
225
226fn push_indent(indent: &str, level: usize, out: &mut String) {
227    for _ in 0..level {
228        out.push_str(indent);
229    }
230}
231
232/// Format a scalar value. Strings are quoted, everything else stays bare so that
233/// its type is recoverable when reading the document back.
234fn format_scalar(value: &LinoValue) -> String {
235    match value {
236        LinoValue::Null => "null".to_string(),
237        LinoValue::Bool(b) => b.to_string(),
238        LinoValue::Int(i) => i.to_string(),
239        LinoValue::Float(f) => format_float(*f),
240        LinoValue::String(s) => format_string(s),
241        // Containers are handled by write_value.
242        LinoValue::Array(_) | LinoValue::Object(_) => String::new(),
243    }
244}
245
246fn format_float(f: f64) -> String {
247    if f.is_nan() {
248        "NaN".to_string()
249    } else if f.is_infinite() {
250        if f.is_sign_positive() {
251            "Infinity".to_string()
252        } else {
253            "-Infinity".to_string()
254        }
255    } else {
256        // `{:?}` keeps the decimal point for whole floats (`1.0`), which is what
257        // tells a float apart from an integer when reading the document back.
258        format!("{:?}", f)
259    }
260}
261
262/// Format a string value: quoted plain text, or an individually marked
263/// base64 payload when the text cannot be written literally.
264fn format_string(value: &str) -> String {
265    if needs_encoding(value) {
266        return format!(
267            "({} {})",
268            BASE64_MARKER,
269            quote(&BASE64.encode(value.as_bytes()))
270        );
271    }
272    quote(value)
273}
274
275/// A value can be written as text unless it contains control characters:
276/// newlines break the line structure and CRLF normalisation would rewrite them.
277fn needs_encoding(value: &str) -> bool {
278    value.chars().any(char::is_control)
279}
280
281fn quote(value: &str) -> String {
282    let has_double = value.contains('"');
283    let has_single = value.contains('\'');
284
285    if !has_double {
286        return format!("\"{}\"", value);
287    }
288    if !has_single {
289        return format!("'{}'", value);
290    }
291    // Both quote styles are present: double the double quotes, as the parser expects.
292    format!("\"{}\"", value.replace('"', "\"\""))
293}
294
295/// Format an object key. Keys are bare when they read as plain identifiers.
296fn format_key(key: &str) -> String {
297    let plain = !key.is_empty()
298        && key != BASE64_MARKER
299        && !needs_encoding(key)
300        && !key
301            .chars()
302            .any(|c| c.is_whitespace() || matches!(c, '(' | ')' | '\'' | '"' | ':' | '`'));
303
304    if plain {
305        key.to_string()
306    } else {
307        format_string(key)
308    }
309}
310
311// === Decoding ===
312
313#[derive(Debug, Clone, PartialEq, Eq)]
314enum Token {
315    Open,
316    Close,
317    Newline,
318    Ref { value: String, quoted: bool },
319}
320
321/// A parsed element of the readable form: either a reference (remembering whether
322/// it was quoted, which is what distinguishes a string from a number) or a link.
323#[derive(Debug, Clone)]
324enum Node {
325    Ref {
326        value: String,
327        quoted: bool,
328    },
329    Link {
330        rows: Vec<Vec<Node>>,
331        multiline: bool,
332        /// Whether the link named itself an object with the `o:` marker.
333        object: bool,
334    },
335}
336
337fn tokenize(text: &str) -> Result<Vec<Token>, CodecError> {
338    let chars: Vec<char> = text.chars().collect();
339    let mut tokens = Vec::new();
340    let mut i = 0;
341
342    while i < chars.len() {
343        let c = chars[i];
344
345        if c == '\n' {
346            tokens.push(Token::Newline);
347            i += 1;
348        } else if c.is_whitespace() {
349            i += 1;
350        } else if c == '(' {
351            tokens.push(Token::Open);
352            i += 1;
353        } else if c == ')' {
354            tokens.push(Token::Close);
355            i += 1;
356        } else if matches!(c, '"' | '\'' | '`') {
357            let (value, next) = read_quoted(&chars, i, c)?;
358            tokens.push(Token::Ref {
359                value,
360                quoted: true,
361            });
362            i = next;
363        } else {
364            let start = i;
365            while i < chars.len()
366                && !chars[i].is_whitespace()
367                && !matches!(chars[i], '(' | ')' | '"' | '\'' | '`')
368            {
369                i += 1;
370            }
371            tokens.push(Token::Ref {
372                value: chars[start..i].iter().collect(),
373                quoted: false,
374            });
375        }
376    }
377
378    Ok(tokens)
379}
380
381/// Read a quoted reference, where a doubled quote character means a literal one.
382fn read_quoted(
383    chars: &[char],
384    start: usize,
385    quote_char: char,
386) -> Result<(String, usize), CodecError> {
387    let mut value = String::new();
388    let mut i = start + 1;
389
390    while i < chars.len() {
391        if chars[i] == quote_char {
392            if chars.get(i + 1) == Some(&quote_char) {
393                value.push(quote_char);
394                i += 2;
395                continue;
396            }
397            return Ok((value, i + 1));
398        }
399        value.push(chars[i]);
400        i += 1;
401    }
402
403    Err(CodecError::ParseError(format!(
404        "unterminated quoted value starting at character {}",
405        start
406    )))
407}
408
409struct Cursor {
410    tokens: Vec<Token>,
411    pos: usize,
412}
413
414impl Cursor {
415    /// Parse rows until the matching `)` (or the end of input at the top level).
416    /// A row is one line: the values written between two newlines.
417    fn parse_rows(&mut self, top_level: bool) -> Result<Vec<Vec<Node>>, CodecError> {
418        let mut rows: Vec<Vec<Node>> = Vec::new();
419        let mut row: Vec<Node> = Vec::new();
420
421        while self.pos < self.tokens.len() {
422            match &self.tokens[self.pos] {
423                Token::Close => {
424                    if top_level {
425                        break;
426                    }
427                    self.pos += 1;
428                    if !row.is_empty() {
429                        rows.push(row);
430                    }
431                    return Ok(rows);
432                }
433                Token::Newline => {
434                    self.pos += 1;
435                    if !row.is_empty() {
436                        rows.push(std::mem::take(&mut row));
437                    }
438                }
439                _ => row.push(self.parse_node()?),
440            }
441        }
442
443        if !top_level {
444            return Err(CodecError::ParseError(
445                "unterminated '(' in readable notation".to_string(),
446            ));
447        }
448
449        if !row.is_empty() {
450            rows.push(row);
451        }
452        Ok(rows)
453    }
454
455    fn parse_node(&mut self) -> Result<Node, CodecError> {
456        match self.tokens[self.pos].clone() {
457            Token::Ref { value, quoted } => {
458                self.pos += 1;
459                Ok(Node::Ref { value, quoted })
460            }
461            Token::Open => {
462                self.pos += 1;
463                let object = self.take_object_marker();
464                let multiline = self.link_is_multiline();
465                let rows = self.parse_rows(false)?;
466                Ok(Node::Link {
467                    rows,
468                    multiline,
469                    object,
470                })
471            }
472            Token::Close | Token::Newline => Err(CodecError::ParseError(
473                "unexpected token in readable notation".to_string(),
474            )),
475        }
476    }
477
478    /// Consume the `o:` marker if the link that just opened carries one, which is
479    /// how the single-line form says "this link is an object, not an array".
480    fn take_object_marker(&mut self) -> bool {
481        let marker = format!("{}:", OBJECT_MARKER);
482        let is_marker = matches!(
483            self.tokens.get(self.pos),
484            Some(Token::Ref { value, quoted: false }) if *value == marker
485        );
486        if is_marker {
487            self.pos += 1;
488        }
489        is_marker
490    }
491
492    /// Whether the link that just opened spans more than one line, which is what
493    /// tells an empty object (`(\n)`) from an empty array (`()`).
494    fn link_is_multiline(&self) -> bool {
495        self.tokens[self.pos..]
496            .iter()
497            .take_while(|t| **t != Token::Close)
498            .any(|t| *t == Token::Newline)
499    }
500}
501
502fn node_to_value(node: &Node) -> Result<LinoValue, CodecError> {
503    match node {
504        Node::Ref { value, quoted } => Ok(ref_to_value(value, *quoted)),
505        Node::Link {
506            rows,
507            multiline,
508            object,
509        } => rows_to_value(rows, *multiline, *object),
510    }
511}
512
513fn rows_to_value(
514    rows: &[Vec<Node>],
515    multiline: bool,
516    object_marker: bool,
517) -> Result<LinoValue, CodecError> {
518    if object_marker {
519        return marked_object_to_value(rows);
520    }
521
522    if rows.is_empty() {
523        return Ok(if multiline {
524            LinoValue::Object(vec![])
525        } else {
526            LinoValue::Array(vec![])
527        });
528    }
529
530    if let Some(marked) = decode_marked_value(rows) {
531        return marked;
532    }
533
534    // Written on one line, a link is a list of values: an object on one line says
535    // so with the `o:` marker, which is what keeps `(key value)` unambiguous.
536    if !multiline {
537        let mut items = Vec::new();
538        for row in rows {
539            for node in row {
540                items.push(node_to_value(node)?);
541            }
542        }
543        return Ok(LinoValue::Array(items));
544    }
545
546    // `key value` on every line makes an object; anything else is a list of values.
547    let is_object = rows
548        .iter()
549        .all(|row| row.len() == 2 && matches!(row[0], Node::Ref { .. }));
550
551    if is_object {
552        let mut pairs = Vec::with_capacity(rows.len());
553        for row in rows {
554            let Node::Ref { value: key, .. } = &row[0] else {
555                unreachable!("checked by is_object")
556            };
557            pairs.push((key.clone(), node_to_value(&row[1])?));
558        }
559        return Ok(LinoValue::Object(pairs));
560    }
561
562    let mut items = Vec::new();
563    for row in rows {
564        for node in row {
565            items.push(node_to_value(node)?);
566        }
567    }
568    Ok(LinoValue::Array(items))
569}
570
571/// Build the object a `(o: (key value) …)` link describes. Every value in it is
572/// a pair, so anything else is a malformed document rather than a silent array.
573fn marked_object_to_value(rows: &[Vec<Node>]) -> Result<LinoValue, CodecError> {
574    let mut pairs = Vec::new();
575
576    for node in rows.iter().flatten() {
577        let Node::Link {
578            rows: pair,
579            object: false,
580            ..
581        } = node
582        else {
583            return Err(CodecError::ParseError(format!(
584                "an object marked '{}:' holds (key value) pairs, found a value that is not a pair",
585                OBJECT_MARKER
586            )));
587        };
588
589        let [row] = pair.as_slice() else {
590            return Err(CodecError::ParseError(format!(
591                "an object marked '{}:' holds (key value) pairs, found a link of {} lines",
592                OBJECT_MARKER,
593                pair.len()
594            )));
595        };
596
597        let [Node::Ref { value: key, .. }, value] = row.as_slice() else {
598            return Err(CodecError::ParseError(format!(
599                "an object marked '{}:' holds (key value) pairs, found a link of {} values",
600                OBJECT_MARKER,
601                row.len()
602            )));
603        };
604
605        pairs.push((key.clone(), node_to_value(value)?));
606    }
607
608    Ok(LinoValue::Object(pairs))
609}
610
611/// Recognise `(base64 "…")`, the individual marker for values that could not be
612/// written as text. A quoted `base64` key is an ordinary object key, not a marker.
613fn decode_marked_value(rows: &[Vec<Node>]) -> Option<Result<LinoValue, CodecError>> {
614    if rows.len() != 1 || rows[0].len() != 2 {
615        return None;
616    }
617
618    let Node::Ref {
619        value: marker,
620        quoted: false,
621    } = &rows[0][0]
622    else {
623        return None;
624    };
625    if marker != BASE64_MARKER {
626        return None;
627    }
628
629    let Node::Ref {
630        value: payload,
631        quoted: true,
632    } = &rows[0][1]
633    else {
634        return None;
635    };
636
637    Some(
638        BASE64
639            .decode(payload)
640            .map_err(|e| CodecError::DecodeError(format!("invalid base64 value: {}", e)))
641            .and_then(|bytes| {
642                String::from_utf8(bytes)
643                    .map(LinoValue::String)
644                    .map_err(|e| CodecError::DecodeError(format!("invalid UTF-8 value: {}", e)))
645            }),
646    )
647}
648
649/// Convert a reference to a value. Quoted references are always strings; bare
650/// references keep the type they were written with.
651fn ref_to_value(value: &str, quoted: bool) -> LinoValue {
652    if quoted {
653        return LinoValue::String(value.to_string());
654    }
655
656    match value {
657        "null" => return LinoValue::Null,
658        "true" => return LinoValue::Bool(true),
659        "false" => return LinoValue::Bool(false),
660        "NaN" => return LinoValue::Float(f64::NAN),
661        "Infinity" => return LinoValue::Float(f64::INFINITY),
662        "-Infinity" => return LinoValue::Float(f64::NEG_INFINITY),
663        _ => {}
664    }
665
666    if let Ok(i) = value.parse::<i64>() {
667        return LinoValue::Int(i);
668    }
669    if value.contains(['.', 'e', 'E']) {
670        if let Ok(f) = value.parse::<f64>() {
671            return LinoValue::Float(f);
672        }
673    }
674
675    LinoValue::String(value.to_string())
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681
682    fn roundtrip(value: &LinoValue) -> LinoValue {
683        let text = encode(value, DEFAULT_INDENT);
684        decode(&text).unwrap_or_else(|e| panic!("failed to decode {:?}: {}", text, e))
685    }
686
687    #[test]
688    fn empty_containers_keep_their_type() {
689        assert_eq!(encode(&LinoValue::Array(vec![]), DEFAULT_INDENT), "()");
690        assert_eq!(encode(&LinoValue::Object(vec![]), DEFAULT_INDENT), "(\n)");
691        assert_eq!(
692            roundtrip(&LinoValue::Array(vec![])),
693            LinoValue::Array(vec![])
694        );
695        assert_eq!(
696            roundtrip(&LinoValue::Object(vec![])),
697            LinoValue::Object(vec![])
698        );
699    }
700
701    #[test]
702    fn single_pair_object_is_not_an_array() {
703        let value = LinoValue::object([("a", LinoValue::Int(1))]);
704        assert_eq!(encode(&value, DEFAULT_INDENT), "(\n  a 1\n)");
705        assert_eq!(roundtrip(&value), value);
706    }
707
708    #[test]
709    fn numeric_looking_strings_stay_strings() {
710        let value = LinoValue::object([
711            ("count", LinoValue::Int(42)),
712            ("zip", LinoValue::String("10001".to_string())),
713            ("flag", LinoValue::String("true".to_string())),
714        ]);
715        assert_eq!(roundtrip(&value), value);
716    }
717
718    #[test]
719    fn whole_floats_stay_floats() {
720        let value = LinoValue::Float(1.0);
721        assert_eq!(encode(&value, DEFAULT_INDENT), "1.0");
722        assert!(matches!(roundtrip(&value), LinoValue::Float(f) if (f - 1.0).abs() < f64::EPSILON));
723    }
724
725    #[test]
726    fn base64_key_is_quoted_so_it_is_not_a_marker() {
727        let value = LinoValue::object([("base64", LinoValue::String("plain".to_string()))]);
728        assert_eq!(
729            encode(&value, DEFAULT_INDENT),
730            "(\n  \"base64\" \"plain\"\n)"
731        );
732        assert_eq!(roundtrip(&value), value);
733    }
734
735    #[test]
736    fn control_characters_are_marked_individually() {
737        let value = LinoValue::object([
738            ("plain", LinoValue::String("visible".to_string())),
739            ("raw", LinoValue::String("line1\nline2".to_string())),
740        ]);
741        let text = encode(&value, DEFAULT_INDENT);
742        assert!(text.contains("plain \"visible\""), "{}", text);
743        assert!(
744            text.contains("raw (base64 \"bGluZTEKbGluZTI=\")"),
745            "{}",
746            text
747        );
748        assert_eq!(roundtrip(&value), value);
749    }
750
751    #[test]
752    fn custom_indent_is_used() {
753        let value = LinoValue::object([("a", LinoValue::Int(1))]);
754        assert_eq!(encode(&value, "    "), "(\n    a 1\n)");
755    }
756
757    #[test]
758    fn handwritten_document_without_root_parentheses_decodes() {
759        let decoded = decode("a 1\nb \"two\"").unwrap();
760        assert_eq!(
761            decoded,
762            LinoValue::object([
763                ("a", LinoValue::Int(1)),
764                ("b", LinoValue::String("two".into()))
765            ])
766        );
767    }
768
769    fn line_roundtrip(value: &LinoValue) -> LinoValue {
770        let text = encode_line(value);
771        assert!(
772            !text.contains('\n'),
773            "line form must hold no newline: {:?}",
774            text
775        );
776        decode_line(&text).unwrap_or_else(|e| panic!("failed to decode {:?}: {}", text, e))
777    }
778
779    #[test]
780    fn line_form_marks_objects_and_leaves_arrays_bare() {
781        assert_eq!(encode_line(&LinoValue::Array(vec![])), "()");
782        assert_eq!(encode_line(&LinoValue::Object(vec![])), "(o:)");
783        assert_eq!(
784            encode_line(&LinoValue::object([("a", LinoValue::Int(1))])),
785            "(o: (a 1))"
786        );
787        assert_eq!(
788            encode_line(&LinoValue::array([
789                LinoValue::String("key".into()),
790                LinoValue::String("value".into()),
791            ])),
792            "(\"key\" \"value\")"
793        );
794    }
795
796    #[test]
797    fn line_form_tells_a_one_pair_object_from_a_two_element_array() {
798        let object = LinoValue::object([("key", LinoValue::String("value".into()))]);
799        let array = LinoValue::array([
800            LinoValue::String("key".into()),
801            LinoValue::String("value".into()),
802        ]);
803        assert_ne!(encode_line(&object), encode_line(&array));
804        assert_eq!(line_roundtrip(&object), object);
805        assert_eq!(line_roundtrip(&array), array);
806    }
807
808    #[test]
809    fn line_form_roundtrips_nested_and_empty_containers() {
810        let value = LinoValue::object([
811            ("empty_array", LinoValue::Array(vec![])),
812            ("empty_object", LinoValue::Object(vec![])),
813            (
814                "records",
815                LinoValue::array([
816                    LinoValue::object([("id", LinoValue::Int(1))]),
817                    LinoValue::object([("id", LinoValue::Int(2))]),
818                ]),
819            ),
820        ]);
821        assert_eq!(
822            encode_line(&value),
823            "(o: (empty_array ()) (empty_object (o:)) (records ((o: (id 1)) (o: (id 2)))))"
824        );
825        assert_eq!(line_roundtrip(&value), value);
826    }
827
828    #[test]
829    fn line_form_keeps_the_empty_key() {
830        let value = LinoValue::object([("", LinoValue::Int(2))]);
831        assert_eq!(encode_line(&value), "(o: (\"\" 2))");
832        assert_eq!(line_roundtrip(&value), value);
833    }
834
835    #[test]
836    fn line_form_keeps_strings_on_one_line() {
837        let value = LinoValue::object([
838            (
839                "quotes",
840                LinoValue::String("both \"kinds\" of 'quotes'".into()),
841            ),
842            ("unicode", LinoValue::String("héllo 世界 🌍".into())),
843            ("multiline", LinoValue::String("line1\nline2".into())),
844        ]);
845        let text = encode_line(&value);
846        assert!(!text.contains('\n'), "{}", text);
847        assert_eq!(line_roundtrip(&value), value);
848    }
849
850    #[test]
851    fn a_line_form_object_holds_pairs_only() {
852        assert!(decode_line("(o: 1 2)").is_err());
853        assert!(decode_line("(o: (a 1 2))").is_err());
854        assert!(decode_line("(o: (a))").is_err());
855    }
856
857    #[test]
858    fn decode_line_rejects_a_document_of_several_lines() {
859        assert!(decode_line("(o: (a 1))\n(o: (a 2))").is_err());
860        // A trailing line break is what a line-based reader hands over, so it is
861        // stripped rather than rejected.
862        assert_eq!(
863            decode_line("(o: (a 1))\n").unwrap(),
864            LinoValue::object([("a", LinoValue::Int(1))])
865        );
866    }
867
868    #[test]
869    fn unterminated_input_is_an_error() {
870        assert!(decode("(\n  a 1\n").is_err());
871        assert!(decode("(\n  a \"unterminated\n").is_err());
872        assert!(decode("a 1)").is_err());
873    }
874}