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 `~~~card-yaml`
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
131fn emit_block(out: &mut String, card: &Card) {
132    out.push_str("~~~card-yaml\n");
133    emit_payload_items(
134        out,
135        card.payload().items(),
136        card.payload().nested_comments(),
137    );
138    out.push_str("~~~\n");
139}
140
141/// Walk the unified item list and emit each entry. An `inline: true` comment
142/// immediately following a non-comment item is consumed as that item's trailer.
143fn emit_payload_items(out: &mut String, items: &[PayloadItem], nested: &[NestedComment]) {
144    let mut i = 0;
145    while i < items.len() {
146        // Peek for a trailing inline comment to use as the line trailer.
147        let trailer = items.get(i + 1).and_then(|next| match next {
148            PayloadItem::Comment { text, inline: true } => Some(text.as_str()),
149            _ => None,
150        });
151        let mut consumed_trailer = trailer.is_some();
152
153        match &items[i] {
154            PayloadItem::Quill { reference } => {
155                emit_meta_line(out, "quill", &reference.to_string(), trailer);
156            }
157            PayloadItem::Kind { value } => {
158                emit_meta_line(out, "kind", value, trailer);
159            }
160            PayloadItem::Id { value } => {
161                emit_meta_line(out, "id", value, trailer);
162            }
163            PayloadItem::Field { key, value, fill } => {
164                let path = vec![CommentPathSegment::Key(key.clone())];
165                emit_field(out, key, value.as_json(), 0, *fill, &path, nested, trailer);
166            }
167            PayloadItem::Comment { text, .. } => {
168                out.push_str("# ");
169                out.push_str(text);
170                out.push('\n');
171                consumed_trailer = false;
172            }
173        }
174        i += if consumed_trailer { 2 } else { 1 };
175    }
176}
177
178/// Ensure `out` ends with `\n\n` so the next fence has a blank line above it.
179/// Appends a line terminator first if `out` doesn't already end with `\n`.
180/// No-op on empty `out` (block at line 1 needs no separator).
181fn ensure_blank_before_fence(out: &mut String) {
182    if out.is_empty() {
183        return;
184    }
185    if !out.ends_with('\n') {
186        out.push('\n');
187    }
188    out.push('\n');
189}
190
191// ── YAML value emission ───────────────────────────────────────────────────────
192
193/// Emit own-line nested comments at `position` in `path` (inline comments are
194/// handled by `find_inline_trailer`).
195fn emit_own_line_pending(
196    out: &mut String,
197    path: &[CommentPathSegment],
198    position: usize,
199    indent: usize,
200    nested: &[NestedComment],
201) {
202    for c in nested {
203        if c.position == position && !c.inline && c.container_path.as_slice() == path {
204            push_indent(out, indent);
205            out.push_str("# ");
206            out.push_str(&c.text);
207            out.push('\n');
208        }
209    }
210}
211
212/// Return the inline trailer for `position` in `path`. If multiple inline
213/// comments share the slot, returns the first and emits the rest as own-line.
214fn find_inline_trailer<'a>(
215    out: &mut String,
216    path: &[CommentPathSegment],
217    position: usize,
218    indent: usize,
219    nested: &'a [NestedComment],
220) -> Option<&'a str> {
221    let mut chosen: Option<&str> = None;
222    for c in nested {
223        if c.position == position && c.inline && c.container_path.as_slice() == path {
224            if chosen.is_none() {
225                chosen = Some(c.text.as_str());
226            } else {
227                push_indent(out, indent);
228                out.push_str("# ");
229                out.push_str(&c.text);
230                out.push('\n');
231            }
232        }
233    }
234    chosen
235}
236
237/// Emit orphan inline comments (`position >= container_len`) as own-line.
238fn emit_orphan_inlines(
239    out: &mut String,
240    path: &[CommentPathSegment],
241    container_len: usize,
242    indent: usize,
243    nested: &[NestedComment],
244) {
245    for c in nested {
246        if c.inline && c.position >= container_len && c.container_path.as_slice() == path {
247            push_indent(out, indent);
248            out.push_str("# ");
249            out.push_str(&c.text);
250            out.push('\n');
251        }
252    }
253}
254
255fn push_trailer(out: &mut String, trailer: Option<&str>) {
256    if let Some(t) = trailer {
257        out.push_str(" # ");
258        out.push_str(t);
259    }
260}
261
262/// Emit a `key: <value>\n` pair at `indent` spaces.
263///
264/// `path` is the container path for nested-comment interleaving. Empty objects
265/// are omitted; their inline trailer degrades to an own-line comment to
266/// preserve the text. Empty arrays emit `key: []\n`. When `fill` is `true`:
267/// scalars → `key: !fill <value>`, empty seqs → `key: !fill []`, null →
268/// `key: !fill`, non-empty seqs → `key: !fill\n  - …`. Mappings with `fill`
269/// are rejected at parse and never reach this path.
270#[allow(clippy::too_many_arguments)]
271fn emit_field(
272    out: &mut String,
273    key: &str,
274    value: &JsonValue,
275    indent: usize,
276    fill: bool,
277    path: &[CommentPathSegment],
278    nested: &[NestedComment],
279    inline_trailer: Option<&str>,
280) {
281    if fill {
282        push_indent(out, indent);
283        out.push_str(key);
284        match value {
285            JsonValue::Null => {
286                out.push_str(": !fill");
287                push_trailer(out, inline_trailer);
288                out.push('\n');
289            }
290            JsonValue::Bool(_) | JsonValue::Number(_) | JsonValue::String(_) => {
291                out.push_str(": !fill ");
292                emit_scalar(out, value);
293                push_trailer(out, inline_trailer);
294                out.push('\n');
295            }
296            JsonValue::Array(items) if items.is_empty() => {
297                out.push_str(": !fill []");
298                push_trailer(out, inline_trailer);
299                out.push('\n');
300            }
301            JsonValue::Array(items) => {
302                out.push_str(": !fill");
303                push_trailer(out, inline_trailer);
304                out.push('\n');
305                emit_sequence_children(out, items, indent + 2, path, nested);
306            }
307            JsonValue::Object(_) => {
308                out.push_str(": ");
309                emit_scalar(out, value);
310                push_trailer(out, inline_trailer);
311                out.push('\n');
312            }
313        }
314        return;
315    }
316    match value {
317        JsonValue::Object(map) if map.is_empty() => {
318            if let Some(t) = inline_trailer {
319                push_indent(out, indent);
320                out.push_str("# ");
321                out.push_str(t);
322                out.push('\n');
323            }
324        }
325        JsonValue::Object(map) => {
326            push_indent(out, indent);
327            out.push_str(key);
328            out.push(':');
329            push_trailer(out, inline_trailer);
330            out.push('\n');
331            emit_mapping_children(out, map, indent + 2, path, nested);
332        }
333        JsonValue::Array(items) if items.is_empty() => {
334            push_indent(out, indent);
335            out.push_str(key);
336            out.push_str(": []");
337            push_trailer(out, inline_trailer);
338            out.push('\n');
339        }
340        JsonValue::Array(items) => {
341            push_indent(out, indent);
342            out.push_str(key);
343            out.push(':');
344            push_trailer(out, inline_trailer);
345            out.push('\n');
346            emit_sequence_children(out, items, indent + 2, path, nested);
347        }
348        _ => {
349            push_indent(out, indent);
350            out.push_str(key);
351            out.push_str(": ");
352            emit_scalar(out, value);
353            push_trailer(out, inline_trailer);
354            out.push('\n');
355        }
356    }
357}
358
359fn emit_mapping_children(
360    out: &mut String,
361    map: &serde_json::Map<String, JsonValue>,
362    child_indent: usize,
363    path: &[CommentPathSegment],
364    nested: &[NestedComment],
365) {
366    for (i, (k, v)) in map.iter().enumerate() {
367        emit_own_line_pending(out, path, i, child_indent, nested);
368        let trailer = find_inline_trailer(out, path, i, child_indent, nested);
369        let mut child_path = path.to_vec();
370        child_path.push(CommentPathSegment::Key(k.clone()));
371        emit_field(out, k, v, child_indent, false, &child_path, nested, trailer);
372    }
373    emit_own_line_pending(out, path, map.len(), child_indent, nested);
374    emit_orphan_inlines(out, path, map.len(), child_indent, nested);
375}
376
377fn emit_sequence_children(
378    out: &mut String,
379    items: &[JsonValue],
380    base_indent: usize,
381    path: &[CommentPathSegment],
382    nested: &[NestedComment],
383) {
384    for (i, item) in items.iter().enumerate() {
385        emit_own_line_pending(out, path, i, base_indent, nested);
386        let trailer = find_inline_trailer(out, path, i, base_indent, nested);
387        let mut child_path = path.to_vec();
388        child_path.push(CommentPathSegment::Index(i));
389        emit_sequence_item(out, item, base_indent, &child_path, nested, trailer);
390    }
391    emit_own_line_pending(out, path, items.len(), base_indent, nested);
392    emit_orphan_inlines(out, path, items.len(), base_indent, nested);
393}
394
395/// Emit a single `- <value>\n` sequence item. When the item is a mapping,
396/// if both the seq-item trailer and the first key's trailer are present,
397/// the inner one degrades to an own-line comment.
398fn emit_sequence_item(
399    out: &mut String,
400    value: &JsonValue,
401    base_indent: usize,
402    path: &[CommentPathSegment],
403    nested: &[NestedComment],
404    inline_trailer: Option<&str>,
405) {
406    match value {
407        JsonValue::Object(map) if map.is_empty() => {
408            push_indent(out, base_indent);
409            out.push_str("- {}");
410            push_trailer(out, inline_trailer);
411            out.push('\n');
412        }
413        JsonValue::Object(map) => {
414            emit_own_line_pending(out, path, 0, base_indent, nested);
415
416            let mut first = true;
417            for (i, (k, v)) in map.iter().enumerate() {
418                if !first {
419                    emit_own_line_pending(out, path, i, base_indent + 2, nested);
420                }
421                let inner_trailer = find_inline_trailer(out, path, i, base_indent + 2, nested);
422                let mut child_path = path.to_vec();
423                child_path.push(CommentPathSegment::Key(k.clone()));
424                if first {
425                    let line_trailer = inline_trailer.or(inner_trailer);
426                    push_indent(out, base_indent);
427                    out.push_str("- ");
428                    emit_field_inline(
429                        out,
430                        k,
431                        v,
432                        base_indent + 2,
433                        &child_path,
434                        nested,
435                        line_trailer,
436                    );
437                    if let (Some(_), Some(loser)) = (inline_trailer, inner_trailer) {
438                        push_indent(out, base_indent + 2);
439                        out.push_str("# ");
440                        out.push_str(loser);
441                        out.push('\n');
442                    }
443                    first = false;
444                } else {
445                    emit_field(
446                        out,
447                        k,
448                        v,
449                        base_indent + 2,
450                        false,
451                        &child_path,
452                        nested,
453                        inner_trailer,
454                    );
455                }
456            }
457            emit_own_line_pending(out, path, map.len(), base_indent + 2, nested);
458            emit_orphan_inlines(out, path, map.len(), base_indent + 2, nested);
459        }
460        JsonValue::Array(inner) if inner.is_empty() => {
461            push_indent(out, base_indent);
462            out.push_str("- []");
463            push_trailer(out, inline_trailer);
464            out.push('\n');
465        }
466        JsonValue::Array(inner) => {
467            push_indent(out, base_indent);
468            out.push('-');
469            push_trailer(out, inline_trailer);
470            out.push('\n');
471            emit_sequence_children(out, inner, base_indent + 2, path, nested);
472        }
473        _ => {
474            push_indent(out, base_indent);
475            out.push_str("- ");
476            emit_scalar(out, value);
477            push_trailer(out, inline_trailer);
478            out.push('\n');
479        }
480    }
481}
482
483/// Emit `key: <value>\n` where the caller already wrote `- ` on the current line.
484fn emit_field_inline(
485    out: &mut String,
486    key: &str,
487    value: &JsonValue,
488    child_indent: usize,
489    path: &[CommentPathSegment],
490    nested: &[NestedComment],
491    inline_trailer: Option<&str>,
492) {
493    match value {
494        JsonValue::Object(map) if map.is_empty() => {
495            out.push_str(key);
496            out.push_str(": {}");
497            push_trailer(out, inline_trailer);
498            out.push('\n');
499        }
500        JsonValue::Object(map) => {
501            out.push_str(key);
502            out.push(':');
503            push_trailer(out, inline_trailer);
504            out.push('\n');
505            emit_mapping_children(out, map, child_indent, path, nested);
506        }
507        JsonValue::Array(items) if items.is_empty() => {
508            out.push_str(key);
509            out.push_str(": []");
510            push_trailer(out, inline_trailer);
511            out.push('\n');
512        }
513        JsonValue::Array(items) => {
514            out.push_str(key);
515            out.push(':');
516            push_trailer(out, inline_trailer);
517            out.push('\n');
518            emit_sequence_children(out, items, child_indent + 2, path, nested);
519        }
520        _ => {
521            out.push_str(key);
522            out.push_str(": ");
523            emit_scalar(out, value);
524            push_trailer(out, inline_trailer);
525            out.push('\n');
526        }
527    }
528}
529
530fn emit_scalar(out: &mut String, value: &JsonValue) {
531    let s = saphyr_emit_scalar(value);
532    out.push_str(&s);
533}
534
535/// `prefer_block_scalars: false` forces multi-line strings to double-quoted
536/// inline scalars (no `|` / `>` block forms in v1).
537fn saphyr_opts() -> SerializerOptions {
538    SerializerOptions {
539        prefer_block_scalars: false,
540        ..SerializerOptions::default()
541    }
542}
543
544pub(crate) fn saphyr_emit_scalar(value: &JsonValue) -> String {
545    let mut buf = String::new();
546    serde_saphyr::to_fmt_writer_with_options(&mut buf, value, saphyr_opts())
547        .expect("saphyr scalar emission is infallible for JsonValue scalars");
548    while buf.ends_with('\n') {
549        buf.pop();
550    }
551
552    // Saphyr 0.0.23's plain-safety check inspects only the leading byte
553    // as ASCII whitespace, missing strings whose first or last char is
554    // Unicode whitespace (U+2000…) or whose last char is an ASCII space.
555    // YAML strips such whitespace from plain scalars on parse, losing
556    // the data. Detect and emit double-quoted ourselves so the round-
557    // trip is preserved.
558    if let JsonValue::String(s) = value {
559        let unquoted = !buf.starts_with('"')
560            && !buf.starts_with('\'')
561            && !buf.starts_with('|')
562            && !buf.starts_with('>');
563        let has_edge_whitespace = !s.is_empty()
564            && (s.starts_with(char::is_whitespace) || s.ends_with(char::is_whitespace));
565        if unquoted && has_edge_whitespace {
566            return double_quote_string(s);
567        }
568    }
569    buf
570}
571
572/// JSON-style double-quoted fallback for strings saphyr would emit in a form
573/// that loses bytes on parse (e.g. trailing-whitespace plain scalars).
574fn double_quote_string(s: &str) -> String {
575    let mut out = String::with_capacity(s.len() + 2);
576    out.push('"');
577    for ch in s.chars() {
578        match ch {
579            '\\' => out.push_str("\\\\"),
580            '"' => out.push_str("\\\""),
581            '\n' => out.push_str("\\n"),
582            '\r' => out.push_str("\\r"),
583            '\t' => out.push_str("\\t"),
584            c if (c as u32) < 0x20 || (0x7F..=0x9F).contains(&(c as u32)) => {
585                out.push_str(&format!("\\u{:04X}", c as u32));
586            }
587            c => out.push(c),
588        }
589    }
590    out.push('"');
591    out
592}
593
594/// Render a `JsonValue` as a one-line YAML flow form (`[a, b]` / `{k: v}` /
595/// flow-quoted scalar). Used for `# e.g.` hint lines in blueprint output.
596pub(crate) fn saphyr_emit_flow(value: &JsonValue) -> String {
597    let mut buf = String::new();
598    let opts = saphyr_opts();
599    match value {
600        JsonValue::Array(items) => {
601            let wrapped = FlowSeq(items.clone());
602            serde_saphyr::to_fmt_writer_with_options(&mut buf, &wrapped, opts)
603                .expect("saphyr flow seq emission");
604        }
605        JsonValue::Object(map) => {
606            let wrapped = FlowMap(map.clone());
607            serde_saphyr::to_fmt_writer_with_options(&mut buf, &wrapped, opts)
608                .expect("saphyr flow map emission");
609        }
610        scalar => {
611            // Wrap in FlowSeq so saphyr applies flow-context quoting, then strip `[`/`]`.
612            let wrapped = FlowSeq(vec![scalar.clone()]);
613            serde_saphyr::to_fmt_writer_with_options(&mut buf, &wrapped, opts)
614                .expect("saphyr flow scalar emission");
615            while buf.ends_with('\n') {
616                buf.pop();
617            }
618            return buf
619                .strip_prefix('[')
620                .and_then(|s| s.strip_suffix(']'))
621                .unwrap_or(&buf)
622                .to_string();
623        }
624    }
625    while buf.ends_with('\n') {
626        buf.pop();
627    }
628    buf
629}
630
631// ── Utilities ─────────────────────────────────────────────────────────────────
632
633fn push_indent(out: &mut String, spaces: usize) {
634    for _ in 0..spaces {
635        out.push(' ');
636    }
637}
638
639// ── Unit tests ────────────────────────────────────────────────────────────────
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644    use crate::value::QuillValue;
645
646    fn assert_scalar_round_trips(value: serde_json::Value) {
647        let mut yaml = String::from("~~~card-yaml\n$quill: q\n$kind: main\nv: ");
648        yaml.push_str(&saphyr_emit_scalar(&value));
649        yaml.push_str("\n~~~\n");
650        let doc = crate::document::Document::from_markdown(&yaml).unwrap_or_else(|e| {
651            panic!(
652                "failed to parse emitted scalar {:?}: {}\n{}",
653                value, e, yaml
654            )
655        });
656        let parsed = doc.main().payload().get("v").expect("field 'v'").as_json();
657        assert_eq!(
658            parsed, &value,
659            "scalar round-trip mismatch for {:?}: emitted as {:?}",
660            value, yaml
661        );
662    }
663
664    #[test]
665    fn saphyr_scalar_round_trips_plain_string() {
666        assert_scalar_round_trips(serde_json::json!("hello"));
667    }
668
669    #[test]
670    fn saphyr_scalar_round_trips_ambiguous_strings() {
671        for ambiguous in &[
672            "on", "off", "yes", "no", "true", "false", "null", "~", "01234", "1e10",
673        ] {
674            assert_scalar_round_trips(serde_json::json!(*ambiguous));
675        }
676    }
677
678    #[test]
679    fn saphyr_scalar_round_trips_escapes() {
680        assert_scalar_round_trips(serde_json::json!("a\\b\"c\nd\te"));
681    }
682
683    #[test]
684    fn saphyr_scalar_round_trips_control_chars() {
685        assert_scalar_round_trips(serde_json::json!("\x01\x1F"));
686    }
687
688    fn p(key: &str) -> Vec<CommentPathSegment> {
689        vec![CommentPathSegment::Key(key.to_string())]
690    }
691
692    #[test]
693    fn empty_object_omitted() {
694        let value = QuillValue::from_json(serde_json::json!({}));
695        let mut out = String::new();
696        emit_field(
697            &mut out,
698            "empty_map",
699            value.as_json(),
700            0,
701            false,
702            &p("empty_map"),
703            &[],
704            None,
705        );
706        assert_eq!(out, "");
707    }
708
709    #[test]
710    fn empty_object_with_inline_trailer_degrades() {
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            Some("orphan"),
722        );
723        assert_eq!(out, "# orphan\n");
724    }
725
726    #[test]
727    fn empty_array_emitted() {
728        let value = QuillValue::from_json(serde_json::json!([]));
729        let mut out = String::new();
730        emit_field(
731            &mut out,
732            "empty_seq",
733            value.as_json(),
734            0,
735            false,
736            &p("empty_seq"),
737            &[],
738            None,
739        );
740        assert_eq!(out, "empty_seq: []\n");
741    }
742
743    #[test]
744    fn scalar_field_with_inline_trailer() {
745        let value = QuillValue::from_json(serde_json::json!("Hello"));
746        let mut out = String::new();
747        emit_field(
748            &mut out,
749            "title",
750            value.as_json(),
751            0,
752            false,
753            &p("title"),
754            &[],
755            Some("greeting"),
756        );
757        assert_eq!(out, "title: Hello # greeting\n");
758    }
759
760    #[test]
761    fn container_field_with_inline_trailer_lands_on_key_line() {
762        let value = QuillValue::from_json(serde_json::json!({"inner": 1}));
763        let mut out = String::new();
764        emit_field(
765            &mut out,
766            "outer",
767            value.as_json(),
768            0,
769            false,
770            &p("outer"),
771            &[],
772            Some("note"),
773        );
774        assert_eq!(out, "outer: # note\n  inner: 1\n");
775    }
776
777    #[test]
778    fn fill_null_emits_bare_tag() {
779        let value = QuillValue::from_json(serde_json::Value::Null);
780        let mut out = String::new();
781        emit_field(
782            &mut out,
783            "recipient",
784            value.as_json(),
785            0,
786            true,
787            &p("recipient"),
788            &[],
789            None,
790        );
791        assert_eq!(out, "recipient: !fill\n");
792    }
793
794    #[test]
795    fn fill_string_emits_tag_with_value() {
796        let value = QuillValue::from_json(serde_json::json!("placeholder"));
797        let mut out = String::new();
798        emit_field(
799            &mut out,
800            "dept",
801            value.as_json(),
802            0,
803            true,
804            &p("dept"),
805            &[],
806            None,
807        );
808        assert_eq!(out, "dept: !fill placeholder\n");
809    }
810
811    #[test]
812    fn fill_with_inline_trailer() {
813        let value = QuillValue::from_json(serde_json::json!("placeholder"));
814        let mut out = String::new();
815        emit_field(
816            &mut out,
817            "dept",
818            value.as_json(),
819            0,
820            true,
821            &p("dept"),
822            &[],
823            Some("note"),
824        );
825        assert_eq!(out, "dept: !fill placeholder # note\n");
826    }
827
828    #[test]
829    fn fill_integer_emits_tag_with_value() {
830        let value = QuillValue::from_json(serde_json::json!(42));
831        let mut out = String::new();
832        emit_field(
833            &mut out,
834            "count",
835            value.as_json(),
836            0,
837            true,
838            &p("count"),
839            &[],
840            None,
841        );
842        assert_eq!(out, "count: !fill 42\n");
843    }
844}