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