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