Skip to main content

quillmark_core/document/
emit.rs

1//! Canonical Markdown emission for [`Document`].
2//!
3//! This module implements [`Document::to_markdown`], which converts a typed
4//! in-memory `Document` back into canonical Quillmark Markdown.
5//!
6//! ## YAML emission strategy
7//!
8//! `serde-saphyr::SerializerOptions::quote_all` was evaluated (spike, 2026-04-21)
9//! and found to emit single-quoted strings for ordinary scalars like `"on"` and
10//! `"01234"` — switching to double quotes only when the string contains a single
11//! quote, backslash, or control character.  That behaviour is correct for
12//! round-trip type-fidelity (single-quoted YAML strings are re-parsed as strings),
13//! but the Quillmark spec (§5.2) requires **always double-quoted, JSON-style
14//! escaping**.  Because `SerializerOptions` provides no "force double-quote" knob,
15//! the YAML value block is generated by a hand-written emitter in this module.
16//!
17//! The hand-written emitter is small (< 120 lines), covers exactly the
18//! `QuillValue` type variants, and gives complete control over quoting style and
19//! indentation without pulling in additional abstractions.
20
21use serde_json::Value as JsonValue;
22
23use super::frontmatter::FrontmatterItem;
24use super::prescan::{CommentPathSegment, NestedComment};
25use super::{Leaf, Document, Sentinel};
26
27// ── Public entry point ────────────────────────────────────────────────────────
28
29impl Document {
30    /// Emit canonical Quillmark Markdown from this document.
31    ///
32    /// # Contract
33    ///
34    /// 1. **Type-fidelity round-trip.** `Document::from_markdown(&doc.to_markdown())`
35    ///    returns a `Document` equal to `doc` by value *and* by type variant.
36    ///    `QuillValue::String("on")` round-trips as a string, never as a bool.
37    ///    `QuillValue::String("01234")` round-trips as a string, never as an
38    ///    integer.  This guarantee is the whole point of owning emission.
39    ///
40    /// 2. **Emit-idempotent.** `to_markdown` is a pure function of `doc`; two
41    ///    calls on the same `doc` return byte-equal strings.
42    ///
43    /// Byte-equality with the *original source* is **not** guaranteed.
44    ///
45    /// # Emission rules (§5.2)
46    ///
47    /// - Line endings: `\n` only.  CRLF normalization happens on import.
48    /// - Frontmatter: `---\n`, `QUILL: <ref>` first, remaining fields in
49    ///   `IndexMap` insertion order, `---\n`, blank line.
50    /// - Leaves: one blank line before each, fence `` ```leaf\nKIND: <tag>\n<fields>\n```\n<body> ``.
51    /// - Body: emitted verbatim after frontmatter (and leaves).
52    /// - Mappings and sequences: **block style** at every nesting level.
53    /// - Booleans: `true` / `false`.
54    /// - Null: `null`.
55    /// - Numbers: bare literals (integer or float as stored in `serde_json::Value`).
56    /// - **Strings: always double-quoted**, JSON-style escaping
57    ///   (`\"`, `\\`, `\n`, `\t`, `\uXXXX` for control chars).  This is the
58    ///   load-bearing rule that guarantees type fidelity.
59    /// - Multi-line strings: double-quoted with `\n` escape sequences.  No block
60    ///   scalars (`|`, `>`) in v1.
61    ///
62    /// # Open decisions (resolved)
63    ///
64    /// - **Nested-map order.** `QuillValue` is backed by `serde_json::Value`
65    ///   whose object type (`serde_json::Map`) preserves insertion order when the
66    ///   `serde_json/preserve_order` feature is enabled (it is in this workspace).
67    ///   Insertion order is therefore preserved for nested maps at emit time.
68    ///
69    /// - **Empty containers.**
70    ///   - Empty object (`{}`) → the key is **omitted** from emit entirely.
71    ///   - Empty array (`[]`) → emitted as `key: []\n`.
72    ///
73    /// # What is preserved
74    ///
75    /// - **YAML comments**: own-line and inline trailing comments round-trip
76    ///   at their source position. Inline comments on sentinel lines
77    ///   (`QUILL: r # …` / `KIND: t # …`) round-trip too. Comments whose
78    ///   host disappears at emit time (empty-mapping omission, programmatic
79    ///   field removal) degrade to own-line comments at the same indent so
80    ///   the comment text is preserved even when its position shifts.
81    /// - **`!fill` tags**: round-trip via the `fill` flag on `FrontmatterItem::Field`.
82    ///
83    /// # What is lost
84    ///
85    /// - **Other custom tags** (`!include`, `!env`, …): the tag is dropped;
86    ///   the scalar value is preserved.
87    /// - **Original quoting style**: all strings are re-emitted double-quoted
88    ///   regardless of how they were written in the source.
89    pub fn to_markdown(&self) -> String {
90        let mut out = String::new();
91
92        // ── Main leaf (first fence + global body) ─────────────────────────────
93        emit_leaf_fence(&mut out, self.main());
94        out.push_str(self.main().body());
95
96        // ── Composable leaves ──────────────────────────────────────────────────
97        // `emit_leaf` normalises the separator before each fence, so edited
98        // bodies (which may lack a trailing blank line) still round-trip.
99        for leaf in self.leaves() {
100            ensure_f2_before_fence(&mut out);
101            emit_leaf_fence(&mut out, leaf);
102            if !leaf.body().is_empty() {
103                out.push_str(leaf.body());
104            }
105        }
106
107        out
108    }
109}
110
111// ── Leaf emission ─────────────────────────────────────────────────────────────
112
113/// Emit a leaf's metadata fence (between `---\n` markers), including the
114/// sentinel line and every frontmatter item.
115///
116/// ## Inline-comment handling
117///
118/// - **Sentinel-inline preview.** If `items[0]` is a `Comment{inline:true}`,
119///   its text is appended to the sentinel line (`QUILL: r # text` /
120///   `KIND: tag # text`) and the item is skipped. This is the only way to
121///   round-trip a source-level inline comment on the sentinel line.
122/// - **Field + trailing inline.** When iterating items, a `Field` peeks at
123///   its successor: if the next item is `Comment{inline:true}`, the comment
124///   text is passed to `emit_field` as a trailer and consumed here. The
125///   trailer lands on the field's key/value line.
126/// - **Orphan inline.** A `Comment{inline:true}` that is *not* consumed by
127///   either the sentinel preview or a field's lookahead has no host. It is
128///   emitted as an own-line `# text` comment instead. This is also the
129///   degrade path for empty-object fields (whose key is omitted) — the
130///   trailer becomes an own-line comment at the same indent.
131fn emit_leaf_fence(out: &mut String, leaf: &Leaf) {
132    let (open_fence, close_fence) = match leaf.sentinel() {
133        Sentinel::Main(_) => ("---\n", "---\n"),
134        Sentinel::Leaf(_) => ("```leaf\n", "```\n"),
135    };
136    out.push_str(open_fence);
137
138    // Sentinel line.
139    match leaf.sentinel() {
140        Sentinel::Main(r) => {
141            out.push_str("QUILL: ");
142            out.push_str(&r.to_string());
143            out.push('\n');
144        }
145        Sentinel::Leaf(tag) => {
146            out.push_str("KIND: ");
147            out.push_str(tag);
148            out.push('\n');
149        }
150    }
151
152    let nested = leaf.frontmatter().nested_comments();
153    let items = leaf.frontmatter().items();
154    let mut i = 0;
155
156    // Sentinel-inline preview.
157    if let Some(FrontmatterItem::Comment { text, inline: true }) = items.first() {
158        attach_inline_to_last_line(out, text);
159        i = 1;
160    }
161
162    while i < items.len() {
163        match &items[i] {
164            FrontmatterItem::Field { key, value, fill } => {
165                let trailer = items.get(i + 1).and_then(|next| match next {
166                    FrontmatterItem::Comment { text, inline: true } => Some(text.as_str()),
167                    _ => None,
168                });
169                let path = vec![CommentPathSegment::Key(key.clone())];
170                emit_field(out, key, value.as_json(), 0, *fill, &path, nested, trailer);
171                i += if trailer.is_some() { 2 } else { 1 };
172            }
173            FrontmatterItem::Comment { text, .. } => {
174                // Either own-line, or an inline orphan (lookahead would have
175                // consumed any inline whose predecessor was a Field). Both
176                // render as own-line.
177                out.push_str("# ");
178                out.push_str(text);
179                out.push('\n');
180                i += 1;
181            }
182        }
183    }
184
185    out.push_str(close_fence);
186}
187
188/// Ensures `out` ends with a `\n\n` suffix suitable for the F2 precondition
189/// of the next metadata fence.
190///
191/// Under the F2-separator-never-stored invariant, stored bodies may end with
192/// their content (no newline), a content line terminator (`\n`), or an
193/// author-intended blank line (`\n\n`, `\n\n\n`, …). In every case we append
194/// exactly one `\n` to produce the F2 blank line. If the body doesn't already
195/// end in `\n`, we also append a line terminator first so content lines are
196/// terminated in the emitted markdown.
197///
198/// Empty `out` satisfies F2 via the "line 1" clause (MARKDOWN.md §3 F2) and
199/// needs no separator.
200fn ensure_f2_before_fence(out: &mut String) {
201    if out.is_empty() {
202        return;
203    }
204    if !out.ends_with('\n') {
205        out.push('\n');
206    }
207    out.push('\n');
208}
209
210// ── YAML value emission ───────────────────────────────────────────────────────
211
212/// Strip the trailing `\n` from `out`, append ` # text`, and restore `\n`.
213///
214/// The caller is responsible for ensuring the previous line is a valid host
215/// for an inline comment (a field/sequence-item line, not a fence or another
216/// comment line).
217fn attach_inline_to_last_line(out: &mut String, text: &str) {
218    if !out.ends_with('\n') {
219        // Defensive: shouldn't happen given how this is called.
220        out.push_str(" # ");
221        out.push_str(text);
222        out.push('\n');
223        return;
224    }
225    out.pop();
226    out.push_str(" # ");
227    out.push_str(text);
228    out.push('\n');
229}
230
231/// Emit own-line nested comments at `position` in `path` as `# text` lines
232/// indented by `indent` spaces. Inline comments are skipped here — they are
233/// consumed by `find_inline_trailer` at the host's emission site.
234fn emit_own_line_pending(
235    out: &mut String,
236    path: &[CommentPathSegment],
237    position: usize,
238    indent: usize,
239    nested: &[NestedComment],
240) {
241    for c in nested {
242        if c.position == position && !c.inline && c.container_path.as_slice() == path {
243            push_indent(out, indent);
244            out.push_str("# ");
245            out.push_str(&c.text);
246            out.push('\n');
247        }
248    }
249}
250
251/// Look up the inline trailer for the child at `position` in `path`. If
252/// multiple inline comments share this slot (programmatic edge case), the
253/// first one is returned and the rest are emitted as own-line comments at
254/// `indent` to preserve their text.
255fn find_inline_trailer<'a>(
256    out: &mut String,
257    path: &[CommentPathSegment],
258    position: usize,
259    indent: usize,
260    nested: &'a [NestedComment],
261) -> Option<&'a str> {
262    let mut chosen: Option<&str> = None;
263    for c in nested {
264        if c.position == position && c.inline && c.container_path.as_slice() == path {
265            if chosen.is_none() {
266                chosen = Some(c.text.as_str());
267            } else {
268                push_indent(out, indent);
269                out.push_str("# ");
270                out.push_str(&c.text);
271                out.push('\n');
272            }
273        }
274    }
275    chosen
276}
277
278/// Emit any orphan inline comments (`inline=true` with `position >=
279/// container_len`) as own-line comments at the trailing slot. These are
280/// programmatic edge cases — well-formed prescan output never produces them.
281fn emit_orphan_inlines(
282    out: &mut String,
283    path: &[CommentPathSegment],
284    container_len: usize,
285    indent: usize,
286    nested: &[NestedComment],
287) {
288    for c in nested {
289        if c.inline && c.position >= container_len && c.container_path.as_slice() == path {
290            push_indent(out, indent);
291            out.push_str("# ");
292            out.push_str(&c.text);
293            out.push('\n');
294        }
295    }
296}
297
298/// Append ` # trailer` to `out` if `trailer` is `Some`. Caller writes the
299/// terminating `\n` afterwards.
300fn push_trailer(out: &mut String, trailer: Option<&str>) {
301    if let Some(t) = trailer {
302        out.push_str(" # ");
303        out.push_str(t);
304    }
305}
306
307/// Emit a `key: <value>\n` pair at `indent` spaces.
308///
309/// `path` is the path to *this* field (parent path + this key). It's used as
310/// the *container* path when recursing into the value: nested comments
311/// captured at this path are interleaved between the value's children.
312///
313/// `inline_trailer`, when `Some`, is rendered as ` # text` on the field's
314/// key/value line. For scalars this trails the value; for containers it
315/// trails the `key:` line (before the indented children).
316///
317/// - Empty objects are **omitted** (caller skips them). An empty-object
318///   field with an inline trailer degrades the trailer to an own-line
319///   comment at `indent`, so the comment text is preserved even though its
320///   host disappears.
321/// - Empty arrays emit `key: []\n`.
322/// - All other values follow the block-style rules.
323/// - When `fill` is `true`, the emitted form is `key: !fill <value>` for
324///   scalars, `key: !fill\n  - …` for non-empty sequences,
325///   `key: !fill []` for empty sequences, and `key: !fill` for null.
326///   Mappings are rejected at parse and never reach this path.
327#[allow(clippy::too_many_arguments)]
328fn emit_field(
329    out: &mut String,
330    key: &str,
331    value: &JsonValue,
332    indent: usize,
333    fill: bool,
334    path: &[CommentPathSegment],
335    nested: &[NestedComment],
336    inline_trailer: Option<&str>,
337) {
338    if fill {
339        push_indent(out, indent);
340        out.push_str(key);
341        match value {
342            JsonValue::Null => {
343                out.push_str(": !fill");
344                push_trailer(out, inline_trailer);
345                out.push('\n');
346            }
347            JsonValue::Bool(_) | JsonValue::Number(_) | JsonValue::String(_) => {
348                out.push_str(": !fill ");
349                emit_scalar(out, value);
350                push_trailer(out, inline_trailer);
351                out.push('\n');
352            }
353            JsonValue::Array(items) if items.is_empty() => {
354                out.push_str(": !fill []");
355                push_trailer(out, inline_trailer);
356                out.push('\n');
357            }
358            JsonValue::Array(items) => {
359                out.push_str(": !fill");
360                push_trailer(out, inline_trailer);
361                out.push('\n');
362                emit_sequence_children(out, items, indent + 2, path, nested);
363            }
364            JsonValue::Object(_) => {
365                // Parser rejects !fill on mappings; recovery path only.
366                out.push_str(": ");
367                emit_scalar(out, value);
368                push_trailer(out, inline_trailer);
369                out.push('\n');
370            }
371        }
372        return;
373    }
374    match value {
375        JsonValue::Object(map) if map.is_empty() => {
376            // Empty object → omit the key entirely. If there's an inline
377            // trailer, degrade it to an own-line comment so its text isn't
378            // lost.
379            if let Some(t) = inline_trailer {
380                push_indent(out, indent);
381                out.push_str("# ");
382                out.push_str(t);
383                out.push('\n');
384            }
385        }
386        JsonValue::Object(map) => {
387            push_indent(out, indent);
388            out.push_str(key);
389            out.push(':');
390            push_trailer(out, inline_trailer);
391            out.push('\n');
392            emit_mapping_children(out, map, indent + 2, path, nested);
393        }
394        JsonValue::Array(items) if items.is_empty() => {
395            push_indent(out, indent);
396            out.push_str(key);
397            out.push_str(": []");
398            push_trailer(out, inline_trailer);
399            out.push('\n');
400        }
401        JsonValue::Array(items) => {
402            push_indent(out, indent);
403            out.push_str(key);
404            out.push(':');
405            push_trailer(out, inline_trailer);
406            out.push('\n');
407            emit_sequence_children(out, items, indent + 2, path, nested);
408        }
409        _ => {
410            push_indent(out, indent);
411            out.push_str(key);
412            out.push_str(": ");
413            emit_scalar(out, value);
414            push_trailer(out, inline_trailer);
415            out.push('\n');
416        }
417    }
418}
419
420/// Emit the children of a mapping value with comment interleaving.
421///
422/// `child_indent` is the indent at which each child key sits; nested
423/// comments inside this mapping are emitted at the same indent. `path` is
424/// the path to the mapping container (its key in the parent).
425fn emit_mapping_children(
426    out: &mut String,
427    map: &serde_json::Map<String, JsonValue>,
428    child_indent: usize,
429    path: &[CommentPathSegment],
430    nested: &[NestedComment],
431) {
432    for (i, (k, v)) in map.iter().enumerate() {
433        emit_own_line_pending(out, path, i, child_indent, nested);
434        let trailer = find_inline_trailer(out, path, i, child_indent, nested);
435        let mut child_path = path.to_vec();
436        child_path.push(CommentPathSegment::Key(k.clone()));
437        emit_field(out, k, v, child_indent, false, &child_path, nested, trailer);
438    }
439    emit_own_line_pending(out, path, map.len(), child_indent, nested);
440    emit_orphan_inlines(out, path, map.len(), child_indent, nested);
441}
442
443/// Emit the children of a sequence value with comment interleaving.
444///
445/// `base_indent` is the indent at which each `- ` sits; nested comments
446/// inside this sequence are emitted at the same indent.
447fn emit_sequence_children(
448    out: &mut String,
449    items: &[JsonValue],
450    base_indent: usize,
451    path: &[CommentPathSegment],
452    nested: &[NestedComment],
453) {
454    for (i, item) in items.iter().enumerate() {
455        emit_own_line_pending(out, path, i, base_indent, nested);
456        let trailer = find_inline_trailer(out, path, i, base_indent, nested);
457        let mut child_path = path.to_vec();
458        child_path.push(CommentPathSegment::Index(i));
459        emit_sequence_item(out, item, base_indent, &child_path, nested, trailer);
460    }
461    emit_own_line_pending(out, path, items.len(), base_indent, nested);
462    emit_orphan_inlines(out, path, items.len(), base_indent, nested);
463}
464
465/// Emit a single `- <value>\n` sequence item at `base_indent` spaces.
466///
467/// `path` is the path to *this* item (parent path + item index).
468///
469/// `inline_trailer`, when `Some`, is rendered as ` # text` on the `-` line.
470/// For mapping items the trailer co-exists with any inline trailer at index
471/// 0 of the inner mapping (the latter would be on the same physical line);
472/// in well-formed input only one of them is present, but if both appear
473/// the inner one degrades to an own-line comment beneath the `- ` line.
474fn emit_sequence_item(
475    out: &mut String,
476    value: &JsonValue,
477    base_indent: usize,
478    path: &[CommentPathSegment],
479    nested: &[NestedComment],
480    inline_trailer: Option<&str>,
481) {
482    match value {
483        JsonValue::Object(map) if map.is_empty() => {
484            // Empty nested object in a sequence: emit as `- {}`
485            push_indent(out, base_indent);
486            out.push_str("- {}");
487            push_trailer(out, inline_trailer);
488            out.push('\n');
489        }
490        JsonValue::Object(map) => {
491            // Block mapping inside a sequence. First key on the same line
492            // as `- `; subsequent keys indented by 2. Comments inside this
493            // mapping use this item's path as the container.
494            emit_own_line_pending(out, path, 0, base_indent, nested);
495
496            let mut first = true;
497            for (i, (k, v)) in map.iter().enumerate() {
498                if !first {
499                    emit_own_line_pending(out, path, i, base_indent + 2, nested);
500                }
501                let inner_trailer = find_inline_trailer(out, path, i, base_indent + 2, nested);
502                let mut child_path = path.to_vec();
503                child_path.push(CommentPathSegment::Key(k.clone()));
504                if first {
505                    // The seq-item's trailer and the first key's trailer
506                    // both target the `- key: ...` line. Prefer the
507                    // seq-item's; degrade the loser to own-line.
508                    let line_trailer = inline_trailer.or(inner_trailer);
509                    push_indent(out, base_indent);
510                    out.push_str("- ");
511                    emit_field_inline(
512                        out,
513                        k,
514                        v,
515                        base_indent + 2,
516                        &child_path,
517                        nested,
518                        line_trailer,
519                    );
520                    if let (Some(_), Some(loser)) = (inline_trailer, inner_trailer) {
521                        push_indent(out, base_indent + 2);
522                        out.push_str("# ");
523                        out.push_str(loser);
524                        out.push('\n');
525                    }
526                    first = false;
527                } else {
528                    emit_field(
529                        out,
530                        k,
531                        v,
532                        base_indent + 2,
533                        false,
534                        &child_path,
535                        nested,
536                        inner_trailer,
537                    );
538                }
539            }
540            emit_own_line_pending(out, path, map.len(), base_indent + 2, nested);
541            emit_orphan_inlines(out, path, map.len(), base_indent + 2, nested);
542        }
543        JsonValue::Array(inner) if inner.is_empty() => {
544            push_indent(out, base_indent);
545            out.push_str("- []");
546            push_trailer(out, inline_trailer);
547            out.push('\n');
548        }
549        JsonValue::Array(inner) => {
550            // Nested sequence: `-` line then recurse.
551            push_indent(out, base_indent);
552            out.push('-');
553            push_trailer(out, inline_trailer);
554            out.push('\n');
555            emit_sequence_children(out, inner, base_indent + 2, path, nested);
556        }
557        _ => {
558            push_indent(out, base_indent);
559            out.push_str("- ");
560            emit_scalar(out, value);
561            push_trailer(out, inline_trailer);
562            out.push('\n');
563        }
564    }
565}
566
567/// Emit a `key: <value>\n` pair where the key is already on a `- ` line.
568/// The key/value go on the same line as the `- ` prefix (caller already wrote it).
569fn emit_field_inline(
570    out: &mut String,
571    key: &str,
572    value: &JsonValue,
573    child_indent: usize,
574    path: &[CommentPathSegment],
575    nested: &[NestedComment],
576    inline_trailer: Option<&str>,
577) {
578    match value {
579        JsonValue::Object(map) if map.is_empty() => {
580            out.push_str(key);
581            out.push_str(": {}");
582            push_trailer(out, inline_trailer);
583            out.push('\n');
584        }
585        JsonValue::Object(map) => {
586            out.push_str(key);
587            out.push(':');
588            push_trailer(out, inline_trailer);
589            out.push('\n');
590            emit_mapping_children(out, map, child_indent, path, nested);
591        }
592        JsonValue::Array(items) if items.is_empty() => {
593            out.push_str(key);
594            out.push_str(": []");
595            push_trailer(out, inline_trailer);
596            out.push('\n');
597        }
598        JsonValue::Array(items) => {
599            out.push_str(key);
600            out.push(':');
601            push_trailer(out, inline_trailer);
602            out.push('\n');
603            emit_sequence_children(out, items, child_indent + 2, path, nested);
604        }
605        _ => {
606            out.push_str(key);
607            out.push_str(": ");
608            emit_scalar(out, value);
609            push_trailer(out, inline_trailer);
610            out.push('\n');
611        }
612    }
613}
614
615/// Emit a scalar value (no key, no newline) onto `out`.
616fn emit_scalar(out: &mut String, value: &JsonValue) {
617    match value {
618        JsonValue::Null => out.push_str("null"),
619        JsonValue::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
620        JsonValue::Number(n) => out.push_str(&n.to_string()),
621        JsonValue::String(s) => emit_double_quoted(out, s),
622        // Arrays/objects should not reach here via emit_field — handled above.
623        // As a fallback, emit JSON representation.
624        other => out.push_str(&other.to_string()),
625    }
626}
627
628/// Emit a string as a JSON-style double-quoted YAML scalar.
629///
630/// Escape rules (same as JSON string encoding):
631/// - `\` → `\\`
632/// - `"` → `\"`
633/// - `\n` → `\n`
634/// - `\r` → `\r`
635/// - `\t` → `\t`
636/// - Other control characters (U+0000–U+001F, U+007F–U+009F) → `\uXXXX`
637pub(crate) fn emit_double_quoted(out: &mut String, s: &str) {
638    out.push('"');
639    for ch in s.chars() {
640        match ch {
641            '\\' => out.push_str("\\\\"),
642            '"' => out.push_str("\\\""),
643            '\n' => out.push_str("\\n"),
644            '\r' => out.push_str("\\r"),
645            '\t' => out.push_str("\\t"),
646            c if (c as u32) < 0x20 || (0x7F..=0x9F).contains(&(c as u32)) => {
647                out.push_str(&format!("\\u{:04X}", c as u32));
648            }
649            c => out.push(c),
650        }
651    }
652    out.push('"');
653}
654
655// ── Utilities ─────────────────────────────────────────────────────────────────
656
657fn push_indent(out: &mut String, spaces: usize) {
658    for _ in 0..spaces {
659        out.push(' ');
660    }
661}
662
663// ── Unit tests ────────────────────────────────────────────────────────────────
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668    use crate::value::QuillValue;
669
670    #[test]
671    fn double_quoted_basic() {
672        let mut s = String::new();
673        emit_double_quoted(&mut s, "hello");
674        assert_eq!(s, r#""hello""#);
675    }
676
677    #[test]
678    fn double_quoted_ambiguous_strings() {
679        // These must remain strings on re-parse — the double-quoting is the guarantee.
680        for ambiguous in &[
681            "on", "off", "yes", "no", "true", "false", "null", "~", "01234", "1e10",
682        ] {
683            let mut s = String::new();
684            emit_double_quoted(&mut s, ambiguous);
685            assert!(
686                s.starts_with('"') && s.ends_with('"'),
687                "should be double-quoted: {}",
688                s
689            );
690            // Verify the content is correct (no extra escaping for these).
691            assert_eq!(&s[1..s.len() - 1], *ambiguous);
692        }
693    }
694
695    #[test]
696    fn double_quoted_escapes() {
697        let mut s = String::new();
698        emit_double_quoted(&mut s, "a\\b\"c\nd\te");
699        assert_eq!(s, r#""a\\b\"c\nd\te""#);
700    }
701
702    #[test]
703    fn double_quoted_control_chars() {
704        let mut s = String::new();
705        emit_double_quoted(&mut s, "\x01\x1F");
706        assert_eq!(s, "\"\\u0001\\u001F\"");
707    }
708
709    fn p(key: &str) -> Vec<CommentPathSegment> {
710        vec![CommentPathSegment::Key(key.to_string())]
711    }
712
713    #[test]
714    fn empty_object_omitted() {
715        let value = QuillValue::from_json(serde_json::json!({}));
716        let mut out = String::new();
717        emit_field(
718            &mut out,
719            "empty_map",
720            value.as_json(),
721            0,
722            false,
723            &p("empty_map"),
724            &[],
725            None,
726        );
727        assert_eq!(out, ""); // omitted
728    }
729
730    #[test]
731    fn empty_object_with_inline_trailer_degrades() {
732        let value = QuillValue::from_json(serde_json::json!({}));
733        let mut out = String::new();
734        emit_field(
735            &mut out,
736            "empty_map",
737            value.as_json(),
738            0,
739            false,
740            &p("empty_map"),
741            &[],
742            Some("orphan"),
743        );
744        // Host omitted; trailer survives as own-line at the same indent.
745        assert_eq!(out, "# orphan\n");
746    }
747
748    #[test]
749    fn empty_array_emitted() {
750        let value = QuillValue::from_json(serde_json::json!([]));
751        let mut out = String::new();
752        emit_field(
753            &mut out,
754            "empty_seq",
755            value.as_json(),
756            0,
757            false,
758            &p("empty_seq"),
759            &[],
760            None,
761        );
762        assert_eq!(out, "empty_seq: []\n");
763    }
764
765    #[test]
766    fn scalar_field_with_inline_trailer() {
767        let value = QuillValue::from_json(serde_json::json!("Hello"));
768        let mut out = String::new();
769        emit_field(
770            &mut out,
771            "title",
772            value.as_json(),
773            0,
774            false,
775            &p("title"),
776            &[],
777            Some("greeting"),
778        );
779        assert_eq!(out, "title: \"Hello\" # greeting\n");
780    }
781
782    #[test]
783    fn container_field_with_inline_trailer_lands_on_key_line() {
784        let value = QuillValue::from_json(serde_json::json!({"inner": 1}));
785        let mut out = String::new();
786        emit_field(
787            &mut out,
788            "outer",
789            value.as_json(),
790            0,
791            false,
792            &p("outer"),
793            &[],
794            Some("note"),
795        );
796        // Trailer lands on the key line, not after the children.
797        assert_eq!(out, "outer: # note\n  inner: 1\n");
798    }
799
800    #[test]
801    fn fill_null_emits_bare_tag() {
802        let value = QuillValue::from_json(serde_json::Value::Null);
803        let mut out = String::new();
804        emit_field(
805            &mut out,
806            "recipient",
807            value.as_json(),
808            0,
809            true,
810            &p("recipient"),
811            &[],
812            None,
813        );
814        assert_eq!(out, "recipient: !fill\n");
815    }
816
817    #[test]
818    fn fill_string_emits_tag_with_value() {
819        let value = QuillValue::from_json(serde_json::json!("placeholder"));
820        let mut out = String::new();
821        emit_field(
822            &mut out,
823            "dept",
824            value.as_json(),
825            0,
826            true,
827            &p("dept"),
828            &[],
829            None,
830        );
831        assert_eq!(out, "dept: !fill \"placeholder\"\n");
832    }
833
834    #[test]
835    fn fill_with_inline_trailer() {
836        let value = QuillValue::from_json(serde_json::json!("placeholder"));
837        let mut out = String::new();
838        emit_field(
839            &mut out,
840            "dept",
841            value.as_json(),
842            0,
843            true,
844            &p("dept"),
845            &[],
846            Some("note"),
847        );
848        assert_eq!(out, "dept: !fill \"placeholder\" # note\n");
849    }
850
851    #[test]
852    fn fill_integer_emits_tag_with_value() {
853        let value = QuillValue::from_json(serde_json::json!(42));
854        let mut out = String::new();
855        emit_field(
856            &mut out,
857            "count",
858            value.as_json(),
859            0,
860            true,
861            &p("count"),
862            &[],
863            None,
864        );
865        assert_eq!(out, "count: !fill 42\n");
866    }
867}