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