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.
323fn emit_field(
324    out: &mut String,
325    key: &str,
326    value: &JsonValue,
327    indent: usize,
328    fill: bool,
329    path: &[CommentPathSegment],
330    nested: &[NestedComment],
331    inline_trailer: Option<&str>,
332) {
333    if fill {
334        push_indent(out, indent);
335        out.push_str(key);
336        match value {
337            JsonValue::Null => {
338                out.push_str(": !fill");
339                push_trailer(out, inline_trailer);
340                out.push('\n');
341            }
342            JsonValue::Bool(_) | JsonValue::Number(_) | JsonValue::String(_) => {
343                out.push_str(": !fill ");
344                emit_scalar(out, value);
345                push_trailer(out, inline_trailer);
346                out.push('\n');
347            }
348            JsonValue::Array(items) if items.is_empty() => {
349                out.push_str(": !fill []");
350                push_trailer(out, inline_trailer);
351                out.push('\n');
352            }
353            JsonValue::Array(items) => {
354                out.push_str(": !fill");
355                push_trailer(out, inline_trailer);
356                out.push('\n');
357                emit_sequence_children(out, items, indent + 2, path, nested);
358            }
359            JsonValue::Object(_) => {
360                // Parser rejects !fill on mappings; recovery path only.
361                out.push_str(": ");
362                emit_scalar(out, value);
363                push_trailer(out, inline_trailer);
364                out.push('\n');
365            }
366        }
367        return;
368    }
369    match value {
370        JsonValue::Object(map) if map.is_empty() => {
371            // Empty object → omit the key entirely. If there's an inline
372            // trailer, degrade it to an own-line comment so its text isn't
373            // lost.
374            if let Some(t) = inline_trailer {
375                push_indent(out, indent);
376                out.push_str("# ");
377                out.push_str(t);
378                out.push('\n');
379            }
380        }
381        JsonValue::Object(map) => {
382            push_indent(out, indent);
383            out.push_str(key);
384            out.push(':');
385            push_trailer(out, inline_trailer);
386            out.push('\n');
387            emit_mapping_children(out, map, indent + 2, path, nested);
388        }
389        JsonValue::Array(items) if items.is_empty() => {
390            push_indent(out, indent);
391            out.push_str(key);
392            out.push_str(": []");
393            push_trailer(out, inline_trailer);
394            out.push('\n');
395        }
396        JsonValue::Array(items) => {
397            push_indent(out, indent);
398            out.push_str(key);
399            out.push(':');
400            push_trailer(out, inline_trailer);
401            out.push('\n');
402            emit_sequence_children(out, items, indent + 2, path, nested);
403        }
404        _ => {
405            push_indent(out, indent);
406            out.push_str(key);
407            out.push_str(": ");
408            emit_scalar(out, value);
409            push_trailer(out, inline_trailer);
410            out.push('\n');
411        }
412    }
413}
414
415/// Emit the children of a mapping value with comment interleaving.
416///
417/// `child_indent` is the indent at which each child key sits; nested
418/// comments inside this mapping are emitted at the same indent. `path` is
419/// the path to the mapping container (its key in the parent).
420fn emit_mapping_children(
421    out: &mut String,
422    map: &serde_json::Map<String, JsonValue>,
423    child_indent: usize,
424    path: &[CommentPathSegment],
425    nested: &[NestedComment],
426) {
427    for (i, (k, v)) in map.iter().enumerate() {
428        emit_own_line_pending(out, path, i, child_indent, nested);
429        let trailer = find_inline_trailer(out, path, i, child_indent, nested);
430        let mut child_path = path.to_vec();
431        child_path.push(CommentPathSegment::Key(k.clone()));
432        emit_field(out, k, v, child_indent, false, &child_path, nested, trailer);
433    }
434    emit_own_line_pending(out, path, map.len(), child_indent, nested);
435    emit_orphan_inlines(out, path, map.len(), child_indent, nested);
436}
437
438/// Emit the children of a sequence value with comment interleaving.
439///
440/// `base_indent` is the indent at which each `- ` sits; nested comments
441/// inside this sequence are emitted at the same indent.
442fn emit_sequence_children(
443    out: &mut String,
444    items: &[JsonValue],
445    base_indent: usize,
446    path: &[CommentPathSegment],
447    nested: &[NestedComment],
448) {
449    for (i, item) in items.iter().enumerate() {
450        emit_own_line_pending(out, path, i, base_indent, nested);
451        let trailer = find_inline_trailer(out, path, i, base_indent, nested);
452        let mut child_path = path.to_vec();
453        child_path.push(CommentPathSegment::Index(i));
454        emit_sequence_item(out, item, base_indent, &child_path, nested, trailer);
455    }
456    emit_own_line_pending(out, path, items.len(), base_indent, nested);
457    emit_orphan_inlines(out, path, items.len(), base_indent, nested);
458}
459
460/// Emit a single `- <value>\n` sequence item at `base_indent` spaces.
461///
462/// `path` is the path to *this* item (parent path + item index).
463///
464/// `inline_trailer`, when `Some`, is rendered as ` # text` on the `-` line.
465/// For mapping items the trailer co-exists with any inline trailer at index
466/// 0 of the inner mapping (the latter would be on the same physical line);
467/// in well-formed input only one of them is present, but if both appear
468/// the inner one degrades to an own-line comment beneath the `- ` line.
469fn emit_sequence_item(
470    out: &mut String,
471    value: &JsonValue,
472    base_indent: usize,
473    path: &[CommentPathSegment],
474    nested: &[NestedComment],
475    inline_trailer: Option<&str>,
476) {
477    match value {
478        JsonValue::Object(map) if map.is_empty() => {
479            // Empty nested object in a sequence: emit as `- {}`
480            push_indent(out, base_indent);
481            out.push_str("- {}");
482            push_trailer(out, inline_trailer);
483            out.push('\n');
484        }
485        JsonValue::Object(map) => {
486            // Block mapping inside a sequence. First key on the same line
487            // as `- `; subsequent keys indented by 2. Comments inside this
488            // mapping use this item's path as the container.
489            emit_own_line_pending(out, path, 0, base_indent, nested);
490
491            let mut first = true;
492            for (i, (k, v)) in map.iter().enumerate() {
493                if !first {
494                    emit_own_line_pending(out, path, i, base_indent + 2, nested);
495                }
496                let inner_trailer = find_inline_trailer(out, path, i, base_indent + 2, nested);
497                let mut child_path = path.to_vec();
498                child_path.push(CommentPathSegment::Key(k.clone()));
499                if first {
500                    // The seq-item's trailer and the first key's trailer
501                    // both target the `- key: ...` line. Prefer the
502                    // seq-item's; degrade the loser to own-line.
503                    let line_trailer = inline_trailer.or(inner_trailer);
504                    push_indent(out, base_indent);
505                    out.push_str("- ");
506                    emit_field_inline(
507                        out,
508                        k,
509                        v,
510                        base_indent + 2,
511                        &child_path,
512                        nested,
513                        line_trailer,
514                    );
515                    if let (Some(_), Some(loser)) = (inline_trailer, inner_trailer) {
516                        push_indent(out, base_indent + 2);
517                        out.push_str("# ");
518                        out.push_str(loser);
519                        out.push('\n');
520                    }
521                    first = false;
522                } else {
523                    emit_field(
524                        out,
525                        k,
526                        v,
527                        base_indent + 2,
528                        false,
529                        &child_path,
530                        nested,
531                        inner_trailer,
532                    );
533                }
534            }
535            emit_own_line_pending(out, path, map.len(), base_indent + 2, nested);
536            emit_orphan_inlines(out, path, map.len(), base_indent + 2, nested);
537        }
538        JsonValue::Array(inner) if inner.is_empty() => {
539            push_indent(out, base_indent);
540            out.push_str("- []");
541            push_trailer(out, inline_trailer);
542            out.push('\n');
543        }
544        JsonValue::Array(inner) => {
545            // Nested sequence: `-` line then recurse.
546            push_indent(out, base_indent);
547            out.push('-');
548            push_trailer(out, inline_trailer);
549            out.push('\n');
550            emit_sequence_children(out, inner, base_indent + 2, path, nested);
551        }
552        _ => {
553            push_indent(out, base_indent);
554            out.push_str("- ");
555            emit_scalar(out, value);
556            push_trailer(out, inline_trailer);
557            out.push('\n');
558        }
559    }
560}
561
562/// Emit a `key: <value>\n` pair where the key is already on a `- ` line.
563/// The key/value go on the same line as the `- ` prefix (caller already wrote it).
564fn emit_field_inline(
565    out: &mut String,
566    key: &str,
567    value: &JsonValue,
568    child_indent: usize,
569    path: &[CommentPathSegment],
570    nested: &[NestedComment],
571    inline_trailer: Option<&str>,
572) {
573    match value {
574        JsonValue::Object(map) if map.is_empty() => {
575            out.push_str(key);
576            out.push_str(": {}");
577            push_trailer(out, inline_trailer);
578            out.push('\n');
579        }
580        JsonValue::Object(map) => {
581            out.push_str(key);
582            out.push(':');
583            push_trailer(out, inline_trailer);
584            out.push('\n');
585            emit_mapping_children(out, map, child_indent, path, nested);
586        }
587        JsonValue::Array(items) if items.is_empty() => {
588            out.push_str(key);
589            out.push_str(": []");
590            push_trailer(out, inline_trailer);
591            out.push('\n');
592        }
593        JsonValue::Array(items) => {
594            out.push_str(key);
595            out.push(':');
596            push_trailer(out, inline_trailer);
597            out.push('\n');
598            emit_sequence_children(out, items, child_indent + 2, path, nested);
599        }
600        _ => {
601            out.push_str(key);
602            out.push_str(": ");
603            emit_scalar(out, value);
604            push_trailer(out, inline_trailer);
605            out.push('\n');
606        }
607    }
608}
609
610/// Emit a scalar value (no key, no newline) onto `out`.
611fn emit_scalar(out: &mut String, value: &JsonValue) {
612    match value {
613        JsonValue::Null => out.push_str("null"),
614        JsonValue::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
615        JsonValue::Number(n) => out.push_str(&n.to_string()),
616        JsonValue::String(s) => emit_double_quoted(out, s),
617        // Arrays/objects should not reach here via emit_field — handled above.
618        // As a fallback, emit JSON representation.
619        other => out.push_str(&other.to_string()),
620    }
621}
622
623/// Emit a string as a JSON-style double-quoted YAML scalar.
624///
625/// Escape rules (same as JSON string encoding):
626/// - `\` → `\\`
627/// - `"` → `\"`
628/// - `\n` → `\n`
629/// - `\r` → `\r`
630/// - `\t` → `\t`
631/// - Other control characters (U+0000–U+001F, U+007F–U+009F) → `\uXXXX`
632pub(crate) fn emit_double_quoted(out: &mut String, s: &str) {
633    out.push('"');
634    for ch in s.chars() {
635        match ch {
636            '\\' => out.push_str("\\\\"),
637            '"' => out.push_str("\\\""),
638            '\n' => out.push_str("\\n"),
639            '\r' => out.push_str("\\r"),
640            '\t' => out.push_str("\\t"),
641            c if (c as u32) < 0x20 || (0x7F..=0x9F).contains(&(c as u32)) => {
642                out.push_str(&format!("\\u{:04X}", c as u32));
643            }
644            c => out.push(c),
645        }
646    }
647    out.push('"');
648}
649
650// ── Utilities ─────────────────────────────────────────────────────────────────
651
652fn push_indent(out: &mut String, spaces: usize) {
653    for _ in 0..spaces {
654        out.push(' ');
655    }
656}
657
658// ── Unit tests ────────────────────────────────────────────────────────────────
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663    use crate::value::QuillValue;
664
665    #[test]
666    fn double_quoted_basic() {
667        let mut s = String::new();
668        emit_double_quoted(&mut s, "hello");
669        assert_eq!(s, r#""hello""#);
670    }
671
672    #[test]
673    fn double_quoted_ambiguous_strings() {
674        // These must remain strings on re-parse — the double-quoting is the guarantee.
675        for ambiguous in &[
676            "on", "off", "yes", "no", "true", "false", "null", "~", "01234", "1e10",
677        ] {
678            let mut s = String::new();
679            emit_double_quoted(&mut s, ambiguous);
680            assert!(
681                s.starts_with('"') && s.ends_with('"'),
682                "should be double-quoted: {}",
683                s
684            );
685            // Verify the content is correct (no extra escaping for these).
686            assert_eq!(&s[1..s.len() - 1], *ambiguous);
687        }
688    }
689
690    #[test]
691    fn double_quoted_escapes() {
692        let mut s = String::new();
693        emit_double_quoted(&mut s, "a\\b\"c\nd\te");
694        assert_eq!(s, r#""a\\b\"c\nd\te""#);
695    }
696
697    #[test]
698    fn double_quoted_control_chars() {
699        let mut s = String::new();
700        emit_double_quoted(&mut s, "\x01\x1F");
701        assert_eq!(s, "\"\\u0001\\u001F\"");
702    }
703
704    fn p(key: &str) -> Vec<CommentPathSegment> {
705        vec![CommentPathSegment::Key(key.to_string())]
706    }
707
708    #[test]
709    fn empty_object_omitted() {
710        let value = QuillValue::from_json(serde_json::json!({}));
711        let mut out = String::new();
712        emit_field(
713            &mut out,
714            "empty_map",
715            value.as_json(),
716            0,
717            false,
718            &p("empty_map"),
719            &[],
720            None,
721        );
722        assert_eq!(out, ""); // omitted
723    }
724
725    #[test]
726    fn empty_object_with_inline_trailer_degrades() {
727        let value = QuillValue::from_json(serde_json::json!({}));
728        let mut out = String::new();
729        emit_field(
730            &mut out,
731            "empty_map",
732            value.as_json(),
733            0,
734            false,
735            &p("empty_map"),
736            &[],
737            Some("orphan"),
738        );
739        // Host omitted; trailer survives as own-line at the same indent.
740        assert_eq!(out, "# orphan\n");
741    }
742
743    #[test]
744    fn empty_array_emitted() {
745        let value = QuillValue::from_json(serde_json::json!([]));
746        let mut out = String::new();
747        emit_field(
748            &mut out,
749            "empty_seq",
750            value.as_json(),
751            0,
752            false,
753            &p("empty_seq"),
754            &[],
755            None,
756        );
757        assert_eq!(out, "empty_seq: []\n");
758    }
759
760    #[test]
761    fn scalar_field_with_inline_trailer() {
762        let value = QuillValue::from_json(serde_json::json!("Hello"));
763        let mut out = String::new();
764        emit_field(
765            &mut out,
766            "title",
767            value.as_json(),
768            0,
769            false,
770            &p("title"),
771            &[],
772            Some("greeting"),
773        );
774        assert_eq!(out, "title: \"Hello\" # greeting\n");
775    }
776
777    #[test]
778    fn container_field_with_inline_trailer_lands_on_key_line() {
779        let value = QuillValue::from_json(serde_json::json!({"inner": 1}));
780        let mut out = String::new();
781        emit_field(
782            &mut out,
783            "outer",
784            value.as_json(),
785            0,
786            false,
787            &p("outer"),
788            &[],
789            Some("note"),
790        );
791        // Trailer lands on the key line, not after the children.
792        assert_eq!(out, "outer: # note\n  inner: 1\n");
793    }
794
795    #[test]
796    fn fill_null_emits_bare_tag() {
797        let value = QuillValue::from_json(serde_json::Value::Null);
798        let mut out = String::new();
799        emit_field(
800            &mut out,
801            "recipient",
802            value.as_json(),
803            0,
804            true,
805            &p("recipient"),
806            &[],
807            None,
808        );
809        assert_eq!(out, "recipient: !fill\n");
810    }
811
812    #[test]
813    fn fill_string_emits_tag_with_value() {
814        let value = QuillValue::from_json(serde_json::json!("placeholder"));
815        let mut out = String::new();
816        emit_field(
817            &mut out,
818            "dept",
819            value.as_json(),
820            0,
821            true,
822            &p("dept"),
823            &[],
824            None,
825        );
826        assert_eq!(out, "dept: !fill \"placeholder\"\n");
827    }
828
829    #[test]
830    fn fill_with_inline_trailer() {
831        let value = QuillValue::from_json(serde_json::json!("placeholder"));
832        let mut out = String::new();
833        emit_field(
834            &mut out,
835            "dept",
836            value.as_json(),
837            0,
838            true,
839            &p("dept"),
840            &[],
841            Some("note"),
842        );
843        assert_eq!(out, "dept: !fill \"placeholder\" # note\n");
844    }
845
846    #[test]
847    fn fill_integer_emits_tag_with_value() {
848        let value = QuillValue::from_json(serde_json::json!(42));
849        let mut out = String::new();
850        emit_field(
851            &mut out,
852            "count",
853            value.as_json(),
854            0,
855            true,
856            &p("count"),
857            &[],
858            None,
859        );
860        assert_eq!(out, "count: !fill 42\n");
861    }
862}