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//! Scalar emission (quoting, escaping, multi-line handling) is delegated to
9//! `serde-saphyr` — the same library used for parsing. This makes the emit
10//! and parse sides of the wire symmetric by construction: anything saphyr
11//! decides to quote on emit, saphyr will read back as a string on parse.
12//! The hand-written quoting heuristics that used to live here couldn't
13//! keep pace with YAML 1.1 edge cases (`on`/`yes`/`off`, leading-zero
14//! integers, `1.0`-style numerics) — saphyr already handles them all.
15//!
16//! `prefer_block_scalars: false` keeps multi-line strings inline as
17//! double-quoted scalars with `\n` escapes, so the emitter never produces
18//! `|` or `>` block forms in v1.
19//!
20//! This module owns the surrounding structure — `~~~` card-yaml fences,
21//! `$`-prefixed system-metadata lines, field ordering, indentation, comment
22//! interleaving — and calls saphyr only for the scalar leaves.
23
24use serde_json::Value as JsonValue;
25use serde_saphyr::{FlowMap, FlowSeq, SerializerOptions};
26
27use super::payload::PayloadItem;
28use super::prescan::{CommentPathSegment, NestedComment};
29use super::{Card, Document};
30
31// ── Public entry point ────────────────────────────────────────────────────────
32
33impl Document {
34    /// Emit canonical Quillmark Markdown from this document.
35    ///
36    /// # Contract
37    ///
38    /// 1. **Type-fidelity round-trip.** `Document::from_markdown(&doc.to_markdown())`
39    ///    returns a `Document` equal to `doc` by value *and* by type variant.
40    ///    `QuillValue::String("on")` round-trips as a string, never as a bool.
41    ///    `QuillValue::String("01234")` round-trips as a string, never as an
42    ///    integer.  This guarantee is the whole point of owning emission.
43    ///
44    /// 2. **Emit-idempotent.** `to_markdown` is a pure function of `doc`; two
45    ///    calls on the same `doc` return byte-equal strings.
46    ///
47    /// Byte-equality with the *original source* is **not** guaranteed.
48    ///
49    /// # Emission rules (§5.2)
50    ///
51    /// - Line endings: `\n` only.  CRLF normalization happens on import.
52    /// - Every block is emitted as a `~~~` card-yaml fence: a bare `~~~`
53    ///   opener, the `$`-prefixed system-metadata lines (`$quill: <ref>` for
54    ///   the root block, `$kind: <kind>` for composable cards) leading the
55    ///   YAML payload, the user-defined data fields, then a closing `~~~`.
56    /// - Cards: one blank line before each, then the block, then the card body.
57    /// - Body: emitted verbatim after the root block (and after each card).
58    /// - Mappings and sequences: **block style** at every nesting level.
59    /// - Scalars (booleans, null, numbers, strings): delegated to
60    ///   `serde-saphyr`, which emits the type-canonical form (`true`/
61    ///   `false`, `null`, bare numeric literal) and quotes strings only
62    ///   when the unquoted form would be misread (`on`/`yes`/`off`,
63    ///   `null`/`~`, numeric-looking strings, leading flow indicators,
64    ///   `: ` runs, …).  Quoting form is not stable — what matters is
65    ///   that the emitted scalar round-trips to the same `QuillValue`
66    ///   variant. This is the type-fidelity guarantee.
67    /// - Multi-line strings: emitted as inline double-quoted scalars with
68    ///   `\n` escapes; no `|` / `>` block forms.
69    ///
70    /// # Open decisions (resolved)
71    ///
72    /// - **Nested-map order.** `QuillValue` is backed by `serde_json::Value`
73    ///   whose object type (`serde_json::Map`) preserves insertion order when the
74    ///   `serde_json/preserve_order` feature is enabled (it is in this workspace).
75    ///   Insertion order is therefore preserved for nested maps at emit time.
76    ///
77    /// - **Empty containers.**
78    ///   - Empty object (`{}`) → the key is **omitted** from emit entirely.
79    ///   - Empty array (`[]`) → emitted as `key: []\n`.
80    ///
81    /// # What is preserved
82    ///
83    /// - **YAML comments**: own-line and inline trailing comments round-trip
84    ///   at their source position. Comments whose host disappears at emit time
85    ///   (empty-mapping omission, programmatic field removal) degrade to
86    ///   own-line comments at the same indent so the comment text is preserved
87    ///   even when its position shifts.
88    /// - **`!fill` tags**: round-trip via the `fill` flag on `PayloadItem::Field`.
89    ///
90    /// # What is lost
91    ///
92    /// - **Other custom tags** (`!include`, `!env`, …): the tag is dropped;
93    ///   the scalar value is preserved.
94    /// - **Original quoting style**: strings are re-emitted in saphyr's
95    ///   canonical form (plain when safe, quoted when ambiguous). The
96    ///   form chosen for emit may not match the form in the source.
97    pub fn to_markdown(&self) -> String {
98        let mut out = String::new();
99
100        // ── Root block (card-yaml fence + global body) ────────────────────────
101        emit_block(&mut out, self.main());
102        out.push_str(self.main().body());
103
104        // ── Composable cards ──────────────────────────────────────────────────
105        // `ensure_blank_before_fence` normalises the separator before each
106        // block, so edited bodies (which may lack a trailing blank line) still
107        // round-trip.
108        for card in self.cards() {
109            ensure_blank_before_fence(&mut out);
110            emit_block(&mut out, card);
111            if !card.body().is_empty() {
112                out.push_str(card.body());
113            }
114        }
115
116        out
117    }
118}
119
120// ── Block emission ────────────────────────────────────────────────────────────
121
122fn emit_meta_line(out: &mut String, key: &str, value: &str, trailer: Option<&str>) {
123    out.push('$');
124    out.push_str(key);
125    out.push_str(": ");
126    out.push_str(&saphyr_emit_scalar(&JsonValue::String(value.to_string())));
127    push_trailer(out, trailer);
128    out.push('\n');
129}
130
131/// Emit the `$ext: …` block. An empty map emits inline as `$ext: {}` so the
132/// declaration survives the round-trip; a non-empty map emits as a `$ext:`
133/// header followed by indented block-style children. `nested` carries
134/// comments with paths relative to the `$ext` value tree (the `$ext` key
135/// itself is not in the path) — the child mapping walker re-injects them
136/// at the matching positions.
137fn emit_ext_block(
138    out: &mut String,
139    value: &serde_json::Map<String, JsonValue>,
140    trailer: Option<&str>,
141    nested: &[NestedComment],
142) {
143    if value.is_empty() {
144        out.push_str("$ext: {}");
145        push_trailer(out, trailer);
146        out.push('\n');
147        return;
148    }
149    out.push_str("$ext:");
150    push_trailer(out, trailer);
151    out.push('\n');
152    let path: Vec<CommentPathSegment> = Vec::new();
153    emit_mapping_children(out, value, 2, &path, nested);
154}
155
156fn emit_block(out: &mut String, card: &Card) {
157    out.push_str("~~~\n");
158    emit_payload_items(out, card.payload().items());
159    out.push_str("~~~\n");
160}
161
162/// Walk the unified item list and emit each entry. An `inline: true` comment
163/// immediately following a non-comment item is consumed as that item's trailer.
164///
165/// Each `Field` / `Ext` item carries its own `nested_comments` slice with
166/// paths relative to the field's value tree, so emission of nested
167/// structures starts with an empty container path.
168fn emit_payload_items(out: &mut String, items: &[PayloadItem]) {
169    let mut i = 0;
170    while i < items.len() {
171        // Peek for a trailing inline comment to use as the line trailer.
172        let trailer = items.get(i + 1).and_then(|next| match next {
173            PayloadItem::Comment { text, inline: true } => Some(text.as_str()),
174            _ => None,
175        });
176        let mut consumed_trailer = trailer.is_some();
177
178        match &items[i] {
179            PayloadItem::Quill { reference } => {
180                emit_meta_line(out, "quill", &reference.to_string(), trailer);
181            }
182            PayloadItem::Kind { value } => {
183                emit_meta_line(out, "kind", value, trailer);
184            }
185            PayloadItem::Id { value } => {
186                emit_meta_line(out, "id", value, trailer);
187            }
188            PayloadItem::Ext {
189                value,
190                nested_comments,
191            } => {
192                emit_ext_block(out, value, trailer, nested_comments);
193            }
194            PayloadItem::Field {
195                key,
196                value,
197                fill,
198                nested_comments,
199            } => {
200                // Paths in `nested_comments` are relative to this field's
201                // value, so the container path starts empty.
202                let path: Vec<CommentPathSegment> = Vec::new();
203                emit_field(
204                    out,
205                    key,
206                    value.as_json(),
207                    0,
208                    *fill,
209                    &path,
210                    nested_comments,
211                    trailer,
212                );
213            }
214            PayloadItem::Comment { text, .. } => {
215                out.push_str("# ");
216                out.push_str(text);
217                out.push('\n');
218                consumed_trailer = false;
219            }
220        }
221        i += if consumed_trailer { 2 } else { 1 };
222    }
223}
224
225/// Ensure `out` ends with `\n\n` so the next fence has a blank line above it.
226/// Appends a line terminator first if `out` doesn't already end with `\n`.
227/// No-op on empty `out` (block at line 1 needs no separator).
228fn ensure_blank_before_fence(out: &mut String) {
229    if out.is_empty() {
230        return;
231    }
232    if !out.ends_with('\n') {
233        out.push('\n');
234    }
235    out.push('\n');
236}
237
238// ── YAML value emission ───────────────────────────────────────────────────────
239
240/// Emit own-line nested comments at `position` in `path` (inline comments are
241/// handled by `find_inline_trailer`).
242fn emit_own_line_pending(
243    out: &mut String,
244    path: &[CommentPathSegment],
245    position: usize,
246    indent: usize,
247    nested: &[NestedComment],
248) {
249    for c in nested {
250        if c.position == position && !c.inline && c.container_path.as_slice() == path {
251            push_indent(out, indent);
252            out.push_str("# ");
253            out.push_str(&c.text);
254            out.push('\n');
255        }
256    }
257}
258
259/// Return the inline trailer for `position` in `path`. If multiple inline
260/// comments share the slot, returns the first and emits the rest as own-line.
261fn find_inline_trailer<'a>(
262    out: &mut String,
263    path: &[CommentPathSegment],
264    position: usize,
265    indent: usize,
266    nested: &'a [NestedComment],
267) -> Option<&'a str> {
268    let mut chosen: Option<&str> = None;
269    for c in nested {
270        if c.position == position && c.inline && c.container_path.as_slice() == path {
271            if chosen.is_none() {
272                chosen = Some(c.text.as_str());
273            } else {
274                push_indent(out, indent);
275                out.push_str("# ");
276                out.push_str(&c.text);
277                out.push('\n');
278            }
279        }
280    }
281    chosen
282}
283
284/// Emit orphan inline comments (`position >= container_len`) as own-line.
285fn emit_orphan_inlines(
286    out: &mut String,
287    path: &[CommentPathSegment],
288    container_len: usize,
289    indent: usize,
290    nested: &[NestedComment],
291) {
292    for c in nested {
293        if c.inline && c.position >= container_len && c.container_path.as_slice() == path {
294            push_indent(out, indent);
295            out.push_str("# ");
296            out.push_str(&c.text);
297            out.push('\n');
298        }
299    }
300}
301
302fn push_trailer(out: &mut String, trailer: Option<&str>) {
303    if let Some(t) = trailer {
304        out.push_str(" # ");
305        out.push_str(t);
306    }
307}
308
309/// Emit a `key: <value>\n` pair at `indent` spaces.
310///
311/// `path` is the container path for nested-comment interleaving. Empty objects
312/// are omitted; their inline trailer degrades to an own-line comment to
313/// preserve the text. Empty arrays emit `key: []\n`. When `fill` is `true`:
314/// scalars → `key: !fill <value>`, empty seqs → `key: !fill []`, null →
315/// `key: !fill`, non-empty seqs → `key: !fill\n  - …`. Mappings with `fill`
316/// are rejected at parse and never reach this path.
317#[allow(clippy::too_many_arguments)]
318fn emit_field(
319    out: &mut String,
320    key: &str,
321    value: &JsonValue,
322    indent: usize,
323    fill: bool,
324    path: &[CommentPathSegment],
325    nested: &[NestedComment],
326    inline_trailer: Option<&str>,
327) {
328    if fill {
329        push_indent(out, indent);
330        out.push_str(key);
331        match value {
332            JsonValue::Null => {
333                out.push_str(": !fill");
334                push_trailer(out, inline_trailer);
335                out.push('\n');
336            }
337            JsonValue::Bool(_) | JsonValue::Number(_) | JsonValue::String(_) => {
338                out.push_str(": !fill ");
339                emit_scalar(out, value);
340                push_trailer(out, inline_trailer);
341                out.push('\n');
342            }
343            JsonValue::Array(items) if items.is_empty() => {
344                out.push_str(": !fill []");
345                push_trailer(out, inline_trailer);
346                out.push('\n');
347            }
348            JsonValue::Array(items) => {
349                out.push_str(": !fill");
350                push_trailer(out, inline_trailer);
351                out.push('\n');
352                emit_sequence_children(out, items, indent + 2, path, nested);
353            }
354            JsonValue::Object(_) => {
355                out.push_str(": ");
356                emit_scalar(out, value);
357                push_trailer(out, inline_trailer);
358                out.push('\n');
359            }
360        }
361        return;
362    }
363    match value {
364        JsonValue::Object(map) if map.is_empty() => {
365            if let Some(t) = inline_trailer {
366                push_indent(out, indent);
367                out.push_str("# ");
368                out.push_str(t);
369                out.push('\n');
370            }
371        }
372        JsonValue::Object(map) => {
373            push_indent(out, indent);
374            out.push_str(key);
375            out.push(':');
376            push_trailer(out, inline_trailer);
377            out.push('\n');
378            emit_mapping_children(out, map, indent + 2, path, nested);
379        }
380        JsonValue::Array(items) if items.is_empty() => {
381            push_indent(out, indent);
382            out.push_str(key);
383            out.push_str(": []");
384            push_trailer(out, inline_trailer);
385            out.push('\n');
386        }
387        JsonValue::Array(items) => {
388            push_indent(out, indent);
389            out.push_str(key);
390            out.push(':');
391            push_trailer(out, inline_trailer);
392            out.push('\n');
393            emit_sequence_children(out, items, indent + 2, path, nested);
394        }
395        _ => {
396            push_indent(out, indent);
397            out.push_str(key);
398            out.push_str(": ");
399            emit_scalar(out, value);
400            push_trailer(out, inline_trailer);
401            out.push('\n');
402        }
403    }
404}
405
406fn emit_mapping_children(
407    out: &mut String,
408    map: &serde_json::Map<String, JsonValue>,
409    child_indent: usize,
410    path: &[CommentPathSegment],
411    nested: &[NestedComment],
412) {
413    for (i, (k, v)) in map.iter().enumerate() {
414        emit_own_line_pending(out, path, i, child_indent, nested);
415        let trailer = find_inline_trailer(out, path, i, child_indent, nested);
416        let mut child_path = path.to_vec();
417        child_path.push(CommentPathSegment::Key(k.clone()));
418        emit_field(out, k, v, child_indent, false, &child_path, nested, trailer);
419    }
420    emit_own_line_pending(out, path, map.len(), child_indent, nested);
421    emit_orphan_inlines(out, path, map.len(), child_indent, nested);
422}
423
424fn emit_sequence_children(
425    out: &mut String,
426    items: &[JsonValue],
427    base_indent: usize,
428    path: &[CommentPathSegment],
429    nested: &[NestedComment],
430) {
431    for (i, item) in items.iter().enumerate() {
432        emit_own_line_pending(out, path, i, base_indent, nested);
433        let trailer = find_inline_trailer(out, path, i, base_indent, nested);
434        let mut child_path = path.to_vec();
435        child_path.push(CommentPathSegment::Index(i));
436        emit_sequence_item(out, item, base_indent, &child_path, nested, trailer);
437    }
438    emit_own_line_pending(out, path, items.len(), base_indent, nested);
439    emit_orphan_inlines(out, path, items.len(), base_indent, nested);
440}
441
442/// Emit a single `- <value>\n` sequence item. When the item is a mapping,
443/// if both the seq-item trailer and the first key's trailer are present,
444/// the inner one degrades to an own-line comment.
445fn emit_sequence_item(
446    out: &mut String,
447    value: &JsonValue,
448    base_indent: usize,
449    path: &[CommentPathSegment],
450    nested: &[NestedComment],
451    inline_trailer: Option<&str>,
452) {
453    match value {
454        JsonValue::Object(map) if map.is_empty() => {
455            push_indent(out, base_indent);
456            out.push_str("- {}");
457            push_trailer(out, inline_trailer);
458            out.push('\n');
459        }
460        JsonValue::Object(map) => {
461            emit_own_line_pending(out, path, 0, base_indent, nested);
462
463            let mut first = true;
464            for (i, (k, v)) in map.iter().enumerate() {
465                if !first {
466                    emit_own_line_pending(out, path, i, base_indent + 2, nested);
467                }
468                let inner_trailer = find_inline_trailer(out, path, i, base_indent + 2, nested);
469                let mut child_path = path.to_vec();
470                child_path.push(CommentPathSegment::Key(k.clone()));
471                if first {
472                    let line_trailer = inline_trailer.or(inner_trailer);
473                    push_indent(out, base_indent);
474                    out.push_str("- ");
475                    emit_field_inline(
476                        out,
477                        k,
478                        v,
479                        base_indent + 2,
480                        &child_path,
481                        nested,
482                        line_trailer,
483                    );
484                    if let (Some(_), Some(loser)) = (inline_trailer, inner_trailer) {
485                        push_indent(out, base_indent + 2);
486                        out.push_str("# ");
487                        out.push_str(loser);
488                        out.push('\n');
489                    }
490                    first = false;
491                } else {
492                    emit_field(
493                        out,
494                        k,
495                        v,
496                        base_indent + 2,
497                        false,
498                        &child_path,
499                        nested,
500                        inner_trailer,
501                    );
502                }
503            }
504            emit_own_line_pending(out, path, map.len(), base_indent + 2, nested);
505            emit_orphan_inlines(out, path, map.len(), base_indent + 2, nested);
506        }
507        JsonValue::Array(inner) if inner.is_empty() => {
508            push_indent(out, base_indent);
509            out.push_str("- []");
510            push_trailer(out, inline_trailer);
511            out.push('\n');
512        }
513        JsonValue::Array(inner) => {
514            push_indent(out, base_indent);
515            out.push('-');
516            push_trailer(out, inline_trailer);
517            out.push('\n');
518            emit_sequence_children(out, inner, base_indent + 2, path, nested);
519        }
520        _ => {
521            push_indent(out, base_indent);
522            out.push_str("- ");
523            emit_scalar(out, value);
524            push_trailer(out, inline_trailer);
525            out.push('\n');
526        }
527    }
528}
529
530/// Emit `key: <value>\n` where the caller already wrote `- ` on the current line.
531fn emit_field_inline(
532    out: &mut String,
533    key: &str,
534    value: &JsonValue,
535    child_indent: usize,
536    path: &[CommentPathSegment],
537    nested: &[NestedComment],
538    inline_trailer: Option<&str>,
539) {
540    match value {
541        JsonValue::Object(map) if map.is_empty() => {
542            out.push_str(key);
543            out.push_str(": {}");
544            push_trailer(out, inline_trailer);
545            out.push('\n');
546        }
547        JsonValue::Object(map) => {
548            out.push_str(key);
549            out.push(':');
550            push_trailer(out, inline_trailer);
551            out.push('\n');
552            emit_mapping_children(out, map, child_indent, path, nested);
553        }
554        JsonValue::Array(items) if items.is_empty() => {
555            out.push_str(key);
556            out.push_str(": []");
557            push_trailer(out, inline_trailer);
558            out.push('\n');
559        }
560        JsonValue::Array(items) => {
561            out.push_str(key);
562            out.push(':');
563            push_trailer(out, inline_trailer);
564            out.push('\n');
565            emit_sequence_children(out, items, child_indent + 2, path, nested);
566        }
567        _ => {
568            out.push_str(key);
569            out.push_str(": ");
570            emit_scalar(out, value);
571            push_trailer(out, inline_trailer);
572            out.push('\n');
573        }
574    }
575}
576
577fn emit_scalar(out: &mut String, value: &JsonValue) {
578    let s = saphyr_emit_scalar(value);
579    out.push_str(&s);
580}
581
582/// `prefer_block_scalars: false` forces multi-line strings to double-quoted
583/// inline scalars (no `|` / `>` block forms in v1).
584fn saphyr_opts() -> SerializerOptions {
585    SerializerOptions {
586        prefer_block_scalars: false,
587        ..SerializerOptions::default()
588    }
589}
590
591pub(crate) fn saphyr_emit_scalar(value: &JsonValue) -> String {
592    let mut buf = String::new();
593    serde_saphyr::to_fmt_writer_with_options(&mut buf, value, saphyr_opts())
594        .expect("saphyr scalar emission is infallible for JsonValue scalars");
595    while buf.ends_with('\n') {
596        buf.pop();
597    }
598
599    // Saphyr 0.0.23's plain-safety check inspects only the leading byte
600    // as ASCII whitespace, missing strings whose first or last char is
601    // Unicode whitespace (U+2000…) or whose last char is an ASCII space.
602    // YAML strips such whitespace from plain scalars on parse, losing
603    // the data. Detect and emit double-quoted ourselves so the round-
604    // trip is preserved.
605    if let JsonValue::String(s) = value {
606        let unquoted = !buf.starts_with('"')
607            && !buf.starts_with('\'')
608            && !buf.starts_with('|')
609            && !buf.starts_with('>');
610        let has_edge_whitespace = !s.is_empty()
611            && (s.starts_with(char::is_whitespace) || s.ends_with(char::is_whitespace));
612        if unquoted && has_edge_whitespace {
613            return double_quote_string(s);
614        }
615    }
616    buf
617}
618
619/// JSON-style double-quoted fallback for strings saphyr would emit in a form
620/// that loses bytes on parse (e.g. trailing-whitespace plain scalars).
621fn double_quote_string(s: &str) -> String {
622    let mut out = String::with_capacity(s.len() + 2);
623    out.push('"');
624    for ch in s.chars() {
625        match ch {
626            '\\' => out.push_str("\\\\"),
627            '"' => out.push_str("\\\""),
628            '\n' => out.push_str("\\n"),
629            '\r' => out.push_str("\\r"),
630            '\t' => out.push_str("\\t"),
631            c if (c as u32) < 0x20 || (0x7F..=0x9F).contains(&(c as u32)) => {
632                out.push_str(&format!("\\u{:04X}", c as u32));
633            }
634            c => out.push(c),
635        }
636    }
637    out.push('"');
638    out
639}
640
641/// Render a `JsonValue` as a one-line YAML flow form (`[a, b]` / `{k: v}` /
642/// flow-quoted scalar). Used for `# e.g.` hint lines in blueprint output.
643pub(crate) fn saphyr_emit_flow(value: &JsonValue) -> String {
644    let mut buf = String::new();
645    let opts = saphyr_opts();
646    match value {
647        JsonValue::Array(items) => {
648            let wrapped = FlowSeq(items.clone());
649            serde_saphyr::to_fmt_writer_with_options(&mut buf, &wrapped, opts)
650                .expect("saphyr flow seq emission");
651        }
652        JsonValue::Object(map) => {
653            let wrapped = FlowMap(map.clone());
654            serde_saphyr::to_fmt_writer_with_options(&mut buf, &wrapped, opts)
655                .expect("saphyr flow map emission");
656        }
657        scalar => {
658            // Wrap in FlowSeq so saphyr applies flow-context quoting, then strip `[`/`]`.
659            let wrapped = FlowSeq(vec![scalar.clone()]);
660            serde_saphyr::to_fmt_writer_with_options(&mut buf, &wrapped, opts)
661                .expect("saphyr flow scalar emission");
662            while buf.ends_with('\n') {
663                buf.pop();
664            }
665            return buf
666                .strip_prefix('[')
667                .and_then(|s| s.strip_suffix(']'))
668                .unwrap_or(&buf)
669                .to_string();
670        }
671    }
672    while buf.ends_with('\n') {
673        buf.pop();
674    }
675    buf
676}
677
678// ── Utilities ─────────────────────────────────────────────────────────────────
679
680fn push_indent(out: &mut String, spaces: usize) {
681    for _ in 0..spaces {
682        out.push(' ');
683    }
684}
685
686// ── Unit tests ────────────────────────────────────────────────────────────────
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691    use crate::value::QuillValue;
692
693    fn assert_scalar_round_trips(value: serde_json::Value) {
694        let mut yaml = String::from("~~~card-yaml\n$quill: q\n$kind: main\nv: ");
695        yaml.push_str(&saphyr_emit_scalar(&value));
696        yaml.push_str("\n~~~\n");
697        let doc = crate::document::Document::from_markdown(&yaml).unwrap_or_else(|e| {
698            panic!(
699                "failed to parse emitted scalar {:?}: {}\n{}",
700                value, e, yaml
701            )
702        });
703        let parsed = doc.main().payload().get("v").expect("field 'v'").as_json();
704        assert_eq!(
705            parsed, &value,
706            "scalar round-trip mismatch for {:?}: emitted as {:?}",
707            value, yaml
708        );
709    }
710
711    #[test]
712    fn saphyr_scalar_round_trips_plain_string() {
713        assert_scalar_round_trips(serde_json::json!("hello"));
714    }
715
716    #[test]
717    fn saphyr_scalar_round_trips_ambiguous_strings() {
718        for ambiguous in &[
719            "on", "off", "yes", "no", "true", "false", "null", "~", "01234", "1e10",
720        ] {
721            assert_scalar_round_trips(serde_json::json!(*ambiguous));
722        }
723    }
724
725    #[test]
726    fn saphyr_scalar_round_trips_escapes() {
727        assert_scalar_round_trips(serde_json::json!("a\\b\"c\nd\te"));
728    }
729
730    #[test]
731    fn saphyr_scalar_round_trips_control_chars() {
732        assert_scalar_round_trips(serde_json::json!("\x01\x1F"));
733    }
734
735    fn p(key: &str) -> Vec<CommentPathSegment> {
736        vec![CommentPathSegment::Key(key.to_string())]
737    }
738
739    #[test]
740    fn empty_object_omitted() {
741        let value = QuillValue::from_json(serde_json::json!({}));
742        let mut out = String::new();
743        emit_field(
744            &mut out,
745            "empty_map",
746            value.as_json(),
747            0,
748            false,
749            &p("empty_map"),
750            &[],
751            None,
752        );
753        assert_eq!(out, "");
754    }
755
756    #[test]
757    fn empty_object_with_inline_trailer_degrades() {
758        let value = QuillValue::from_json(serde_json::json!({}));
759        let mut out = String::new();
760        emit_field(
761            &mut out,
762            "empty_map",
763            value.as_json(),
764            0,
765            false,
766            &p("empty_map"),
767            &[],
768            Some("orphan"),
769        );
770        assert_eq!(out, "# orphan\n");
771    }
772
773    #[test]
774    fn empty_array_emitted() {
775        let value = QuillValue::from_json(serde_json::json!([]));
776        let mut out = String::new();
777        emit_field(
778            &mut out,
779            "empty_seq",
780            value.as_json(),
781            0,
782            false,
783            &p("empty_seq"),
784            &[],
785            None,
786        );
787        assert_eq!(out, "empty_seq: []\n");
788    }
789
790    #[test]
791    fn scalar_field_with_inline_trailer() {
792        let value = QuillValue::from_json(serde_json::json!("Hello"));
793        let mut out = String::new();
794        emit_field(
795            &mut out,
796            "title",
797            value.as_json(),
798            0,
799            false,
800            &p("title"),
801            &[],
802            Some("greeting"),
803        );
804        assert_eq!(out, "title: Hello # greeting\n");
805    }
806
807    #[test]
808    fn container_field_with_inline_trailer_lands_on_key_line() {
809        let value = QuillValue::from_json(serde_json::json!({"inner": 1}));
810        let mut out = String::new();
811        emit_field(
812            &mut out,
813            "outer",
814            value.as_json(),
815            0,
816            false,
817            &p("outer"),
818            &[],
819            Some("note"),
820        );
821        assert_eq!(out, "outer: # note\n  inner: 1\n");
822    }
823
824    #[test]
825    fn fill_null_emits_bare_tag() {
826        let value = QuillValue::from_json(serde_json::Value::Null);
827        let mut out = String::new();
828        emit_field(
829            &mut out,
830            "recipient",
831            value.as_json(),
832            0,
833            true,
834            &p("recipient"),
835            &[],
836            None,
837        );
838        assert_eq!(out, "recipient: !fill\n");
839    }
840
841    #[test]
842    fn fill_string_emits_tag_with_value() {
843        let value = QuillValue::from_json(serde_json::json!("placeholder"));
844        let mut out = String::new();
845        emit_field(
846            &mut out,
847            "dept",
848            value.as_json(),
849            0,
850            true,
851            &p("dept"),
852            &[],
853            None,
854        );
855        assert_eq!(out, "dept: !fill placeholder\n");
856    }
857
858    #[test]
859    fn fill_with_inline_trailer() {
860        let value = QuillValue::from_json(serde_json::json!("placeholder"));
861        let mut out = String::new();
862        emit_field(
863            &mut out,
864            "dept",
865            value.as_json(),
866            0,
867            true,
868            &p("dept"),
869            &[],
870            Some("note"),
871        );
872        assert_eq!(out, "dept: !fill placeholder # note\n");
873    }
874
875    #[test]
876    fn fill_integer_emits_tag_with_value() {
877        let value = QuillValue::from_json(serde_json::json!(42));
878        let mut out = String::new();
879        emit_field(
880            &mut out,
881            "count",
882            value.as_json(),
883            0,
884            true,
885            &p("count"),
886            &[],
887            None,
888        );
889        assert_eq!(out, "count: !fill 42\n");
890    }
891}