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