Skip to main content

omni_dev/atlassian/
convert.rs

1//! Bidirectional conversion between markdown and Atlassian Document Format.
2//!
3//! Supports Tier 1 (standard GFM) constructs: headings, paragraphs, inline
4//! marks (bold, italic, code, strikethrough, links), images, lists, code
5//! blocks, blockquotes, horizontal rules, and tables.
6
7use anyhow::{bail, Result};
8use chrono::NaiveDate;
9use tracing::{debug, warn};
10
11use crate::atlassian::adf::{AdfDocument, AdfMark, AdfNode};
12use crate::atlassian::attrs::{format_kv, parse_attrs, Attrs};
13use crate::atlassian::directive::{
14    is_container_close, try_parse_container_open, try_parse_inline_directive,
15    try_parse_leaf_directive,
16};
17
18// ── Markdown → ADF ──────────────────────────────────────────────────
19
20/// Maximum nesting depth for markdown → ADF conversion.
21///
22/// The parser recurses once per nesting level (blockquotes, lists, container
23/// directives, directive-table cells, inline marks), so unbounded input would
24/// overflow the stack — an uncatchable abort that would kill the long-lived
25/// MCP server (issue #1130).  The ADF → markdown direction is already bounded
26/// at 128 by serde_json's recursion limit; this cap is lower because the
27/// nested-list frame chain is heavy enough in debug builds that at-cap
28/// parsing must still fit within a 2 MiB thread stack.  Realistic documents
29/// nest well under 20 levels.
30const MAX_NESTING_DEPTH: usize = 64;
31
32/// The JFM block-attr key that keeps two adjacent lists from being read as
33/// one CommonMark loose list (`{list=separate}`); see
34/// [`needs_list_separator`] (issue #1729).
35///
36/// It carries no ADF meaning.  The parser recognises it only so the line is
37/// consumed rather than becoming a paragraph — the *mechanism* is that any
38/// trailing attrs line ends the list loop.
39const LIST_SEPARATE_ATTR: &str = "list";
40
41/// Converts a markdown string to an ADF document.
42pub fn markdown_to_adf(markdown: &str) -> Result<AdfDocument> {
43    debug!(
44        "markdown_to_adf: input {} bytes, {} lines",
45        markdown.len(),
46        markdown.lines().count()
47    );
48    let mut doc = AdfDocument::new();
49    let mut parser = MarkdownParser::new(markdown);
50    doc.content = parser.parse_blocks()?;
51    let split = split_emphasis_code_marks(&mut doc.content);
52    if split > 0 {
53        debug!(
54            "markdown_to_adf: dropped {split} emphasis mark(s) from inline-code runs \
55             (ADF forbids combining them with `code`; issue #1391)"
56        );
57    }
58    debug!(
59        "markdown_to_adf: produced {} top-level ADF nodes",
60        doc.content.len()
61    );
62    Ok(doc)
63}
64
65/// Returns the 1-based start number of an `orderedList`, defaulting to 1 when
66/// `attrs.order` is absent.
67fn ordered_list_start(node: &AdfNode) -> u32 {
68    node.attrs
69        .as_ref()
70        .and_then(|a| a.get("order"))
71        .and_then(serde_json::Value::as_u64)
72        .and_then(|v| u32::try_from(v).ok())
73        .unwrap_or(1)
74}
75
76/// Returns the number of items a list node renders.
77fn list_item_count(node: &AdfNode) -> usize {
78    node.content.as_ref().map_or(0, Vec::len)
79}
80
81/// Returns `true` for the three ADF list node types.
82fn is_list_node_type(node_type: &str) -> bool {
83    matches!(node_type, "bulletList" | "orderedList" | "taskList")
84}
85
86/// Returns `true` when `line` is a list marker belonging to an enclosing
87/// list rather than to the item whose content starts at `content_indent`
88/// (issue #1727).
89fn is_sibling_marker(line: &str, content_indent: usize) -> bool {
90    is_list_start(line) && leading_spaces(line) < content_indent
91}
92
93/// Line-oriented state machine for parsing markdown into ADF block nodes.
94struct MarkdownParser<'a> {
95    lines: Vec<&'a str>,
96    pos: usize,
97    /// Nesting depth of this parser: 0 at the document root, +1 for each
98    /// nested parser spawned for container content (blockquote, list item,
99    /// panel, layout column, table cell, …).  Checked against
100    /// [`MAX_NESTING_DEPTH`] in [`Self::parse_blocks`].
101    depth: usize,
102}
103
104impl<'a> MarkdownParser<'a> {
105    fn new(input: &'a str) -> Self {
106        Self::with_depth(input, 0)
107    }
108
109    /// Creates a nested parser for container content at the given depth.
110    fn with_depth(input: &'a str, depth: usize) -> Self {
111        Self {
112            lines: input.lines().collect(),
113            pos: 0,
114            depth,
115        }
116    }
117
118    fn at_end(&self) -> bool {
119        self.pos >= self.lines.len()
120    }
121
122    fn current_line(&self) -> &'a str {
123        debug_assert!(!self.at_end(), "current_line called at end of input");
124        self.lines[self.pos]
125    }
126
127    fn advance(&mut self) {
128        self.pos += 1;
129    }
130
131    /// Collects indented continuation lines produced by hardBreaks (issue #402).
132    ///
133    /// When `full_text` ends with a hardBreak marker (trailing backslash or
134    /// two trailing spaces), the next 2-space-indented line is appended as a
135    /// continuation of the same paragraph.  The joined text is later fed to
136    /// `parse_inline`, which converts the `\\\n` or `  \n` sequences back
137    /// into `hardBreak` nodes.
138    fn collect_hardbreak_continuations(&mut self, full_text: &mut String) {
139        // Outside a list item nothing is a sibling marker: no line is
140        // indented less than zero.
141        self.collect_hardbreak_continuations_with_content_indent(full_text, 0);
142    }
143
144    /// [`Self::collect_hardbreak_continuations`] for a list item whose
145    /// marker starts at `marker_indent` spaces.
146    ///
147    /// A list marker indented less than the item's content column is the
148    /// next sibling item, not continuation text, and ends the paragraph
149    /// (issue #1727).  One *at* the content column is still continuation
150    /// text (issue #402: `1. First\` + `  2. Second` is one paragraph).
151    fn collect_item_hardbreak_continuations(
152        &mut self,
153        full_text: &mut String,
154        marker_indent: usize,
155    ) {
156        self.collect_hardbreak_continuations_with_content_indent(full_text, marker_indent + 2);
157    }
158
159    fn collect_hardbreak_continuations_with_content_indent(
160        &mut self,
161        full_text: &mut String,
162        content_indent: usize,
163    ) {
164        while has_trailing_hard_break(full_text) && !self.at_end() {
165            if !self.try_append_hardbreak_continuation(full_text, content_indent) {
166                break;
167            }
168        }
169    }
170
171    /// If the current line is a valid hardBreak continuation (2-space indented
172    /// and not a block-level sibling marker), append it to `full_text` and
173    /// advance the parser.  Returns `true` on success, `false` otherwise.
174    ///
175    /// Split out from `collect_hardbreak_continuations` so the body appears
176    /// as its own function in coverage reports (issue #552 PR coverage gap).
177    fn try_append_hardbreak_continuation(
178        &mut self,
179        full_text: &mut String,
180        content_indent: usize,
181    ) -> bool {
182        // Skip indented block-level siblings — mediaSingle (`![` — issue
183        // #490), fenced code blocks (```` ``` ```` — issue #552), and
184        // container directives (`:::`).  They must stay available for their
185        // dedicated block handlers instead of being merged into paragraph
186        // text.  A list marker indented below the item's content column is
187        // a sibling item (issue #1727).
188        let line = self.current_line();
189        if is_sibling_marker(line, content_indent) {
190            return false;
191        }
192        match line
193            .strip_prefix("  ")
194            .filter(|s| !is_block_level_continuation_marker(s.trim_start()))
195        {
196            Some(stripped) => {
197                full_text.push('\n');
198                full_text.push_str(stripped);
199                self.advance();
200                true
201            }
202            None => false,
203        }
204    }
205
206    /// Collects the 2-space-indented sub-content lines of the current list
207    /// item, stripping the two spaces.
208    ///
209    /// A list marker indented less than two spaces deeper than the item's
210    /// own marker is a sibling item, not sub-content, and is left for the
211    /// enclosing item loop (issue #1727).  Non-marker lines keep the
212    /// pre-existing "any 2-space-indented line" rule so that continuation
213    /// paragraphs and fenced blocks written at the marker's own column still
214    /// belong to the item.
215    ///
216    /// A bare blank line (no 2-space prefix at all) does not by itself end
217    /// the item: CommonMark only ends a list item at a blank line when the
218    /// line that follows it is *not* indented to the item's content column.
219    /// So a run of one or more bare blank lines is looked ahead past — the
220    /// run is only consumed (as empty sub-lines, which the nested parser
221    /// treats as paragraph separators) when the first non-blank line after
222    /// it still belongs to this item; otherwise the blanks are left
223    /// untouched for the enclosing block loop, ending the item exactly as
224    /// before (issue #1732). A whitespace-only line that already carries the
225    /// 2-space prefix is unaffected and keeps falling through to the
226    /// existing paragraph-separator path below.
227    ///
228    /// Content indented *deeper* than the mandatory 2-space column (e.g. 3
229    /// spaces under a `1. ` marker, or hand-formatted 4-space nesting) keeps
230    /// its surplus after the strip above. Remove it by dedenting the item's
231    /// own *leading run* — the maximal prefix of `sub_lines` before the
232    /// first entry that is itself a nested list marker — by that run's own
233    /// minimum indent over non-blank-content entries (issue #1735).
234    ///
235    /// This is deliberately scoped to the leading run rather than the whole
236    /// returned block: a marker line's *residual* indentation is load-bearing
237    /// for the recursive parse one level down, which re-derives its own
238    /// `marker_indent` from `leading_spaces` on that very line. Dedenting a
239    /// marker line here would corrupt that derivation. Concretely, a fence
240    /// written at a nested item's own marker column (issue #1727's
241    /// "leniency": `- Child` and its fence at the *same* indent) relies on
242    /// each recursion level stripping only the mandatory 2 spaces from the
243    /// marker line for the residual to reach exactly 0 by the time the fence
244    /// needs recognizing at column 0 — a whole-block minimum computed across
245    /// the marker line too would strip too much from it and drop the fence's
246    /// content out of the item's scope entirely. Everything from the first
247    /// marker line onward is therefore left untouched; the recursive parse of
248    /// that nested content applies this same rule to *its own* leading run
249    /// when it recurses, so multi-level surplus is removed by induction one
250    /// level at a time.
251    fn collect_item_sub_lines(&mut self, marker_indent: usize) -> Vec<String> {
252        let mut sub_lines: Vec<String> = Vec::new();
253        while !self.at_end() {
254            let next = self.current_line();
255            if next.trim().is_empty() && next.strip_prefix("  ").is_none() {
256                let mut lookahead = self.pos;
257                while lookahead < self.lines.len() && self.lines[lookahead].trim().is_empty() {
258                    lookahead += 1;
259                }
260                let continues = lookahead < self.lines.len() && {
261                    let after = self.lines[lookahead];
262                    after.strip_prefix("  ").is_some()
263                        && !is_sibling_marker(after, marker_indent + 2)
264                };
265                if !continues {
266                    break;
267                }
268                while self.pos < lookahead {
269                    sub_lines.push(String::new());
270                    self.advance();
271                }
272                continue;
273            }
274            let Some(stripped) = next.strip_prefix("  ") else {
275                break;
276            };
277            if is_sibling_marker(next, marker_indent + 2) {
278                break;
279            }
280            sub_lines.push(stripped.to_string());
281            self.advance();
282        }
283
284        // Dedent the surplus indentation ahead of the first nested marker
285        // (issue #1735); see the doc comment above for why the dedent stops
286        // there instead of covering the whole block.
287        let boundary = sub_lines
288            .iter()
289            .position(|l| is_list_start(l))
290            .unwrap_or(sub_lines.len());
291        let extra = sub_lines[..boundary]
292            .iter()
293            .filter(|l| !l.trim().is_empty())
294            .map(|l| leading_spaces(l))
295            .min()
296            .unwrap_or(0);
297        if extra > 0 {
298            for line in &mut sub_lines[..boundary] {
299                if !line.trim().is_empty() {
300                    line.replace_range(..extra, "");
301                }
302            }
303        }
304
305        sub_lines
306    }
307
308    fn parse_blocks(&mut self) -> Result<Vec<AdfNode>> {
309        if self.depth > MAX_NESTING_DEPTH {
310            bail!(
311                "Markdown nesting exceeds the maximum depth of {MAX_NESTING_DEPTH} \
312                 (nested blockquotes, lists, panels, expands, layouts, or table \
313                 cells); flatten the document structure"
314            );
315        }
316
317        let mut blocks = Vec::new();
318
319        while !self.at_end() {
320            let line = self.current_line();
321
322            if line.trim().is_empty() {
323                self.advance();
324                continue;
325            }
326
327            let mut node = if let Some(node) = self.try_heading() {
328                node
329            } else if let Some(node) = self.try_horizontal_rule() {
330                node
331            } else if let Some(node) = self.try_container_directive()? {
332                node
333            } else if let Some(node) = self.try_code_block()? {
334                node
335            } else if let Some(node) = self.try_table()? {
336                node
337            } else if let Some(node) = self.try_blockquote()? {
338                node
339            } else if let Some(node) = self.try_list()? {
340                node
341            } else if let Some(node) = self.try_leaf_directive() {
342                node
343            } else if let Some(node) = self.try_image() {
344                node
345            } else {
346                self.parse_paragraph()?
347            };
348
349            // Check for trailing block-level {attrs} (align, indent, breakout)
350            self.try_apply_block_attrs(&mut node);
351            blocks.push(node);
352        }
353
354        Ok(blocks)
355    }
356
357    fn try_heading(&mut self) -> Option<AdfNode> {
358        let line = self.current_line();
359        let trimmed = line.trim_start();
360
361        if !trimmed.starts_with('#') {
362            return None;
363        }
364
365        let level = trimmed.chars().take_while(|&c| c == '#').count();
366        if !(1..=6).contains(&level) || !trimmed[level..].starts_with(' ') {
367            return None;
368        }
369
370        let mut full_text = trimmed[level + 1..].to_string();
371        self.advance();
372        // Collect indented continuation lines produced by hardBreaks (issue #433).
373        self.collect_hardbreak_continuations(&mut full_text);
374        let mut inline_nodes = parse_inline(&full_text);
375        // ADF's `heading` content model forbids the `code` mark, but
376        // JFM/CommonMark parses inline-code spans inside `#`-headings. Strip
377        // them here (keeping the text as plain) so we don't build a document
378        // the validator only rejects at write time (issue #1005).
379        if strip_heading_code_marks(&mut inline_nodes) > 0 {
380            warn!(
381                "Stripped inline `code` from heading (ADF forbids code marks on \
382                 headings; kept the text as plain): {}",
383                full_text.trim()
384            );
385        }
386
387        #[allow(clippy::cast_possible_truncation)]
388        Some(AdfNode::heading(level as u8, inline_nodes))
389    }
390
391    fn try_horizontal_rule(&mut self) -> Option<AdfNode> {
392        let line = self.current_line().trim();
393        let is_rule = (line.starts_with("---") && line.chars().all(|c| c == '-'))
394            || (line.starts_with("***") && line.chars().all(|c| c == '*'))
395            || (line.starts_with("___") && line.chars().all(|c| c == '_'));
396
397        if is_rule && line.len() >= 3 {
398            self.advance();
399            Some(AdfNode::rule())
400        } else {
401            None
402        }
403    }
404
405    fn try_code_block(&mut self) -> Result<Option<AdfNode>> {
406        let line = self.current_line();
407        if !is_code_fence_opener(line) {
408            return Ok(None);
409        }
410
411        let language = line[3..].trim();
412        let language = if language == "\"\"" {
413            // Explicit empty language attr encoded as ```""
414            Some(String::new())
415        } else if language.is_empty() {
416            None
417        } else {
418            Some(language.to_string())
419        };
420
421        self.advance();
422        let mut code_lines = Vec::new();
423
424        while !self.at_end() {
425            let line = self.current_line();
426            if line.starts_with("```") {
427                self.advance();
428                break;
429            }
430            code_lines.push(line);
431            self.advance();
432        }
433
434        let code_text = code_lines.join("\n");
435
436        // If the language is "adf-unsupported", deserialize the JSON back to an AdfNode
437        if language.as_deref() == Some("adf-unsupported") {
438            if let Ok(node) = serde_json::from_str::<AdfNode>(&code_text) {
439                return Ok(Some(node));
440            }
441        }
442
443        Ok(Some(AdfNode::code_block(language.as_deref(), &code_text)))
444    }
445
446    fn try_blockquote(&mut self) -> Result<Option<AdfNode>> {
447        let line = self.current_line();
448        if !line.starts_with('>') {
449            return Ok(None);
450        }
451
452        let mut quote_lines = Vec::new();
453        while !self.at_end() {
454            let line = self.current_line();
455            if let Some(rest) = line.strip_prefix("> ") {
456                quote_lines.push(rest);
457                self.advance();
458            } else if let Some(rest) = line.strip_prefix('>') {
459                quote_lines.push(rest);
460                self.advance();
461            } else {
462                break;
463            }
464        }
465
466        let quote_text = quote_lines.join("\n");
467        let mut inner_parser = MarkdownParser::with_depth(&quote_text, self.depth + 1);
468        let inner_blocks = inner_parser.parse_blocks()?;
469
470        Ok(Some(AdfNode::blockquote(inner_blocks)))
471    }
472
473    fn try_list(&mut self) -> Result<Option<AdfNode>> {
474        let line = self.current_line();
475        let trimmed = line.trim_start();
476
477        let is_bullet =
478            trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ");
479        let ordered_match = parse_ordered_list_marker(trimmed);
480
481        if !is_bullet && ordered_match.is_none() {
482            return Ok(None);
483        }
484
485        if is_bullet {
486            self.parse_bullet_list()
487        } else {
488            let start = ordered_match.map_or(1, |(n, _)| n);
489            self.parse_ordered_list(start)
490        }
491    }
492
493    /// Returns the index of the first non-blank line at or after the cursor,
494    /// or `None` when only blank lines remain.  Does not advance.
495    fn peek_past_blank_run(&self) -> Option<usize> {
496        let mut i = self.pos;
497        while i < self.lines.len() && self.lines[i].trim().is_empty() {
498            i += 1;
499        }
500        (i < self.lines.len()).then_some(i)
501    }
502
503    /// Consumes the run of blank lines at the cursor.
504    fn skip_blank_run(&mut self) {
505        while !self.at_end() && self.current_line().trim().is_empty() {
506            self.advance();
507        }
508    }
509
510    /// Returns `true` when the first non-blank line after the blank run at
511    /// the cursor is another item of the bullet/task list being parsed — a
512    /// marker at the list's own indent whose task-ness matches (issue #1729).
513    fn loose_bullet_item_follows(&self, first_marker_indent: usize, is_task_list: bool) -> bool {
514        let Some(idx) = self.peek_past_blank_run() else {
515            return false;
516        };
517        let after = self.lines[idx];
518        if leading_spaces(after) != first_marker_indent {
519            return false;
520        }
521        let trimmed = after.trim_start();
522        if !(trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ")) {
523            return false;
524        }
525        // A checkbox marker opens a `taskList` and a bare bullet a
526        // `bulletList`; the two are never items of the same list.
527        try_parse_task_marker(trimmed[2..].trim_start()).is_some() == is_task_list
528    }
529
530    /// The [`Self::loose_bullet_item_follows`] counterpart for ordered lists:
531    /// the next marker must also continue this list's numbering (or restart
532    /// at `1.`); see [`Self::parse_ordered_list`].
533    fn loose_ordered_item_follows(
534        &self,
535        first_marker_indent: usize,
536        start: u32,
537        seen: usize,
538    ) -> bool {
539        let Some(idx) = self.peek_past_blank_run() else {
540            return false;
541        };
542        let after = self.lines[idx];
543        if leading_spaces(after) != first_marker_indent {
544            return false;
545        }
546        let Some((n, _)) = parse_ordered_list_marker(after.trim_start()) else {
547            return false;
548        };
549        let seen = u32::try_from(seen).unwrap_or(u32::MAX);
550        n == start.saturating_add(seen) || n == 1
551    }
552
553    fn parse_bullet_list(&mut self) -> Result<Option<AdfNode>> {
554        let mut items = Vec::new();
555        // The column of this list's first marker.  A loose-list continuation
556        // must sit at exactly this indent to be an item of *this* list.
557        let first_marker_indent = leading_spaces(self.current_line());
558        // The list's kind is decided by its first item; a later marker whose
559        // task-ness differs ends this list rather than joining it (issue #1736).
560        let is_task_list = {
561            let trimmed = self.current_line().trim_start();
562            try_parse_task_marker(trimmed[2..].trim_start()).is_some()
563        };
564
565        while !self.at_end() {
566            let line = self.current_line();
567
568            // CommonMark *loose* list: a blank line between two items does
569            // not end the list.  Look ahead past the run of blanks; when the
570            // next non-blank line is an item this very loop would parse,
571            // consume the blanks and carry on.  Otherwise leave them
572            // untouched for the enclosing block loop, ending the list exactly
573            // as before (issue #1729).
574            if line.trim().is_empty() {
575                if !self.loose_bullet_item_follows(first_marker_indent, is_task_list) {
576                    break;
577                }
578                self.skip_blank_run();
579                continue;
580            }
581
582            let trimmed = line.trim_start();
583
584            if !(trimmed.starts_with("- ")
585                || trimmed.starts_with("* ")
586                || trimmed.starts_with("+ "))
587            {
588                break;
589            }
590
591            let after_marker = trimmed[2..].trim_start();
592
593            // A checkbox marker opens a `taskList` and a bare bullet a
594            // `bulletList`; the two are never items of the same list — a
595            // mismatch here ends this list, leaving the line for the
596            // enclosing block loop to start a second, adjacent one
597            // (issue #1736).
598            if try_parse_task_marker(after_marker).is_some() != is_task_list {
599                break;
600            }
601
602            // Detect task list items: - [ ] or - [x]
603            if let Some((state, text)) = try_parse_task_marker(after_marker) {
604                self.advance();
605                // Collect hardBreak continuation lines so that a trailing
606                // {localId=…} on the last continuation line is found by
607                // extract_trailing_local_id (issue #507).
608                let mut full_text = text.to_string();
609                self.collect_item_hardbreak_continuations(&mut full_text, leading_spaces(line));
610                let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
611                let inline_nodes = parse_inline(item_text);
612                // If a paraLocalId marker is present the original ADF had a
613                // paragraph wrapper around the inline content — restore it
614                // so the round-trip is lossless (issue #478).
615                let content = if let Some(ref plid) = para_local_id {
616                    let mut para = AdfNode::paragraph(inline_nodes);
617                    if plid != "_" {
618                        para.attrs = Some(serde_json::json!({"localId": plid}));
619                    }
620                    vec![para]
621                } else {
622                    inline_nodes
623                };
624                let mut task = AdfNode::task_item(state, content);
625                // Override the placeholder localId if one was parsed
626                if let Some(id) = local_id {
627                    if let Some(ref mut attrs) = task.attrs {
628                        attrs["localId"] = serde_json::Value::String(id);
629                    }
630                }
631                // Collect indented sub-content (e.g. nested task lists
632                // from malformed ADF where taskItem contains taskItem
633                // children directly — issue #489).
634                let sub_lines = self.collect_item_sub_lines(leading_spaces(line));
635                if !sub_lines.is_empty() {
636                    let sub_text = sub_lines.join("\n");
637                    let mut nested =
638                        MarkdownParser::with_depth(&sub_text, self.depth + 1).parse_blocks()?;
639                    // When the task item has no inline text and its
640                    // sub-content is a single taskList, this is a
641                    // container taskItem from malformed ADF (issue #489).
642                    // Unwrap the taskList so the taskItem children sit
643                    // directly in the container, and drop the spurious
644                    // `state` attr that was injected by the checkbox
645                    // marker.
646                    let is_empty = task.content.as_ref().is_none_or(Vec::is_empty);
647                    if is_empty && nested.len() == 1 && nested[0].node_type == "taskList" {
648                        if let Some(task_items) = nested.remove(0).content {
649                            task.content = Some(task_items);
650                        }
651                        if let Some(ref mut attrs) = task.attrs {
652                            if let Some(obj) = attrs.as_object_mut() {
653                                obj.remove("state");
654                            }
655                        }
656                        items.push(task);
657                    } else {
658                        // Separate nested taskList nodes from other block
659                        // content.  Nested taskLists become sibling children
660                        // of the outer taskList rather than children of this
661                        // taskItem, matching ADF's representation of indented
662                        // sub-lists (issue #506).
663                        let mut sibling_task_lists = Vec::new();
664                        let mut child_nodes = Vec::new();
665                        for n in nested {
666                            if n.node_type == "taskList" {
667                                sibling_task_lists.push(n);
668                            } else {
669                                child_nodes.push(n);
670                            }
671                        }
672                        if !child_nodes.is_empty() {
673                            match task.content {
674                                Some(ref mut content) => content.append(&mut child_nodes),
675                                None => task.content = Some(child_nodes),
676                            }
677                        }
678                        items.push(task);
679                        items.append(&mut sibling_task_lists);
680                    }
681                } else {
682                    items.push(task);
683                }
684            } else {
685                let first_line = &trimmed[2..];
686                self.advance();
687                let mut full_text = first_line.to_string();
688                self.collect_item_hardbreak_continuations(&mut full_text, leading_spaces(line));
689                let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
690                // Collect indented sub-content lines (2-space prefix).
691                // This captures both nested lists and continuation
692                // paragraphs that belong to the same list item.
693                let sub_lines = self.collect_item_sub_lines(leading_spaces(line));
694                let item_content = parse_list_item_first_line(
695                    item_text,
696                    sub_lines,
697                    local_id,
698                    para_local_id,
699                    self.depth + 1,
700                )?;
701                items.push(item_content);
702            }
703        }
704
705        if items.is_empty() {
706            Ok(None)
707        } else if is_task_list {
708            Ok(Some(AdfNode::task_list(items)))
709        } else {
710            Ok(Some(AdfNode::bullet_list(items)))
711        }
712    }
713
714    /// Parses an ordered list starting at `start`.
715    ///
716    /// A blank line between two items does not end the list when the next
717    /// marker continues the numbering — or restarts at `1.`, which CommonMark
718    /// treats as a "don't-care" number (`1. 1. 1.` is the idiomatic way to
719    /// write an ordered list whose items are numbered by position).  A
720    /// restart at any *other* number — `1. a`, `2. b`, blank, `5. c` — is
721    /// read as authored and stays a second list, so the `5` survives the
722    /// round trip.  That is a deliberate JFM deviation: CommonMark would fuse
723    /// the two and renumber the item to `3`, discarding the authored number
724    /// (issue #1729).
725    fn parse_ordered_list(&mut self, start: u32) -> Result<Option<AdfNode>> {
726        let mut items = Vec::new();
727        let first_marker_indent = leading_spaces(self.current_line());
728
729        while !self.at_end() {
730            let line = self.current_line();
731
732            if line.trim().is_empty() {
733                if !self.loose_ordered_item_follows(first_marker_indent, start, items.len()) {
734                    break;
735                }
736                self.skip_blank_run();
737                continue;
738            }
739
740            let trimmed = line.trim_start();
741
742            if let Some((_, rest)) = parse_ordered_list_marker(trimmed) {
743                let first_line = rest.trim_start_matches(|c: char| c.is_ascii_whitespace());
744                self.advance();
745                let mut full_text = first_line.to_string();
746                self.collect_item_hardbreak_continuations(&mut full_text, leading_spaces(line));
747                let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
748                // Collect indented sub-content lines (2-space prefix).
749                let sub_lines = self.collect_item_sub_lines(leading_spaces(line));
750                let item_content = parse_list_item_first_line(
751                    item_text,
752                    sub_lines,
753                    local_id,
754                    para_local_id,
755                    self.depth + 1,
756                )?;
757                items.push(item_content);
758            } else {
759                break;
760            }
761        }
762
763        if items.is_empty() {
764            Ok(None)
765        } else {
766            let order = if start == 1 { None } else { Some(start) };
767            Ok(Some(AdfNode::ordered_list(items, order)))
768        }
769    }
770
771    fn try_apply_block_attrs(&mut self, node: &mut AdfNode) {
772        if self.at_end() {
773            return;
774        }
775        let line = self.current_line().trim();
776        if !line.starts_with('{') {
777            return;
778        }
779        let Some((_, attrs)) = parse_attrs(line, 0) else {
780            return;
781        };
782
783        let mut marks = Vec::new();
784        if let Some(align) = attrs.get("align") {
785            marks.push(AdfMark::alignment(align));
786        }
787        if let Some(indent) = attrs.get("indent") {
788            if let Ok(level) = indent.parse::<u32>() {
789                marks.push(AdfMark::indentation(level));
790            }
791        }
792        if let Some(mode) = attrs.get("breakout") {
793            let width = attrs
794                .get("breakoutWidth")
795                .and_then(|w| w.parse::<u32>().ok());
796            marks.push(AdfMark::breakout(mode, width));
797        }
798
799        // Parse localId from block attrs
800        let local_id = attrs.get("localId").map(str::to_string);
801
802        // Parse explicit order for orderedList nodes (issue #547).
803        let order = if node.node_type == "orderedList" {
804            attrs.get("order").and_then(|v| v.parse::<u32>().ok())
805        } else {
806            None
807        };
808
809        // `{list=separate}` carries no ADF meaning: reaching this function at
810        // all already means the attrs line ended the list, which is the whole
811        // mechanism.  It is recognised only so the line is consumed instead of
812        // becoming a literal paragraph (issue #1729).
813        let list_separate =
814            attrs.get(LIST_SEPARATE_ATTR) == Some("separate") && is_list_node_type(&node.node_type);
815
816        let has_attrs = !marks.is_empty() || local_id.is_some() || order.is_some() || list_separate;
817        if has_attrs {
818            if !marks.is_empty() {
819                let existing = node.marks.get_or_insert_with(Vec::new);
820                existing.extend(marks);
821            }
822            if let Some(id) = local_id {
823                let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
824                node_attrs["localId"] = serde_json::Value::String(id);
825            }
826            if let Some(n) = order {
827                let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
828                node_attrs["order"] = serde_json::json!(n);
829            }
830            self.advance(); // consume the attrs line
831        }
832    }
833
834    fn try_container_directive(&mut self) -> Result<Option<AdfNode>> {
835        let line = self.current_line();
836        let Some((d, colon_count)) = try_parse_container_open(line) else {
837            return Ok(None);
838        };
839        let start_line = self.pos + 1; // 1-based line number of the opening fence
840        self.advance(); // past opening fence
841
842        // Collect inner lines until the matching close fence, tracking nesting
843        let mut inner_lines = Vec::new();
844        let mut depth: usize = 0;
845        let mut closed = false;
846        while !self.at_end() {
847            let current = self.current_line();
848            if try_parse_container_open(current).is_some() {
849                depth += 1;
850            } else if depth == 0 && is_container_close(current, colon_count) {
851                self.advance(); // past closing fence
852                closed = true;
853                break;
854            } else if depth > 0 && is_container_close(current, 3) {
855                depth -= 1;
856            }
857            inner_lines.push(current.to_string());
858            self.advance();
859        }
860
861        let inner_text = inner_lines.join("\n");
862
863        let node = match d.name.as_str() {
864            "panel" => {
865                let panel_type = d
866                    .attrs
867                    .as_ref()
868                    .and_then(|a| a.get("type"))
869                    .unwrap_or("info");
870                let inner_blocks =
871                    MarkdownParser::with_depth(&inner_text, self.depth + 1).parse_blocks()?;
872                let mut node = AdfNode::panel(panel_type, inner_blocks);
873                // Pass through custom panel attrs (icon, color)
874                if let Some(ref attrs) = d.attrs {
875                    if let Some(ref mut node_attrs) = node.attrs {
876                        if let Some(icon) = attrs.get("icon") {
877                            node_attrs["panelIcon"] = serde_json::Value::String(icon.to_string());
878                        }
879                        if let Some(color) = attrs.get("color") {
880                            node_attrs["panelColor"] = serde_json::Value::String(color.to_string());
881                        }
882                    }
883                }
884                node
885            }
886            "expand" => {
887                let title = d.attrs.as_ref().and_then(|a| a.get("title"));
888                let inner_blocks =
889                    MarkdownParser::with_depth(&inner_text, self.depth + 1).parse_blocks()?;
890                let mut node = AdfNode::expand(title, inner_blocks);
891                pass_through_expand_params(&d.attrs, &mut node);
892                node
893            }
894            "nested-expand" => {
895                let title = d.attrs.as_ref().and_then(|a| a.get("title"));
896                let inner_blocks =
897                    MarkdownParser::with_depth(&inner_text, self.depth + 1).parse_blocks()?;
898                let mut node = AdfNode::nested_expand(title, inner_blocks);
899                pass_through_expand_params(&d.attrs, &mut node);
900                node
901            }
902            "layout" => {
903                // Parse inner content looking for :::column sub-containers
904                let columns = self.parse_layout_columns(&inner_text)?;
905                AdfNode::layout_section(columns)
906            }
907            "decisions" => {
908                let items = parse_decision_items(&inner_text);
909                AdfNode::decision_list(items)
910            }
911            "table" => {
912                let rows = self.parse_directive_table_rows(&inner_text)?;
913                let mut table_attrs = serde_json::json!({});
914                if let Some(ref attrs) = d.attrs {
915                    if let Some(layout) = attrs.get("layout") {
916                        table_attrs["layout"] = serde_json::Value::String(layout.to_string());
917                    }
918                    if attrs.has_flag("numbered") {
919                        table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
920                    } else if attrs.get("numbered") == Some("false") {
921                        table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
922                    }
923                    if let Some(tw) = attrs.get("width") {
924                        if let Some(w) = parse_numeric_attr(tw) {
925                            table_attrs["width"] = w;
926                        }
927                    }
928                    if let Some(local_id) = attrs.get("localId") {
929                        table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
930                    }
931                }
932                if table_attrs == serde_json::json!({}) {
933                    AdfNode::table(rows)
934                } else {
935                    AdfNode::table_with_attrs(rows, table_attrs)
936                }
937            }
938            "extension" => {
939                let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
940                let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
941                let inner_blocks =
942                    MarkdownParser::with_depth(&inner_text, self.depth + 1).parse_blocks()?;
943                let mut node = AdfNode::bodied_extension(ext_type, ext_key, inner_blocks);
944                if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
945                {
946                    if let Some(layout) = dir_attrs.get("layout") {
947                        node_attrs["layout"] = serde_json::Value::String(layout.to_string());
948                    }
949                    if let Some(local_id) = dir_attrs.get("localId") {
950                        node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
951                    }
952                    if let Some(params_str) = dir_attrs.get("params") {
953                        if let Ok(params_val) =
954                            serde_json::from_str::<serde_json::Value>(params_str)
955                        {
956                            node_attrs["parameters"] = params_val;
957                        }
958                    }
959                }
960                node
961            }
962            _ => {
963                if !closed {
964                    // `td`/`tr`/`th` (valid only inside `:::table`) and `column`
965                    // (valid only inside `:::layout`), or any other name this
966                    // parser doesn't otherwise handle, reaching end-of-input
967                    // without a close fence. Recognized names above always
968                    // return `Some`, so this is the only path that can hit
969                    // `Ok(None)` after the scan has already consumed input —
970                    // erroring here (rather than silently returning `None`)
971                    // keeps that contract intact for `parse_blocks`'s callers
972                    // (issue #1734).
973                    bail!(
974                        "Unclosed `:::{}` directive at line {start_line} — add a matching `:::` close fence",
975                        d.name
976                    );
977                }
978                return Ok(None);
979            }
980        };
981
982        Ok(Some(node))
983    }
984
985    fn parse_layout_columns(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
986        let mut columns = Vec::new();
987        let mut current_column_lines: Vec<String> = Vec::new();
988        let mut current_width: serde_json::Value = serde_json::json!(50);
989        let mut current_dir_attrs: Option<crate::atlassian::attrs::Attrs> = None;
990        let mut in_column = false;
991        let mut depth: usize = 0;
992
993        let lines: Vec<&str> = inner_text.lines().collect();
994        let mut i = 0;
995
996        while i < lines.len() {
997            let line = lines[i];
998            if let Some((col_d, _)) = try_parse_container_open(line) {
999                if col_d.name == "column" && depth == 0 {
1000                    // Flush previous column
1001                    if in_column && !current_column_lines.is_empty() {
1002                        let col_text = current_column_lines.join("\n");
1003                        let blocks =
1004                            MarkdownParser::with_depth(&col_text, self.depth + 1).parse_blocks()?;
1005                        let mut col = AdfNode::layout_column(current_width.clone(), blocks);
1006                        pass_through_local_id(&current_dir_attrs, &mut col);
1007                        columns.push(col);
1008                        current_column_lines.clear();
1009                    }
1010                    current_width = col_d
1011                        .attrs
1012                        .as_ref()
1013                        .and_then(|a| a.get("width"))
1014                        .and_then(parse_numeric_attr)
1015                        .unwrap_or_else(|| serde_json::json!(50));
1016                    current_dir_attrs = col_d.attrs;
1017                    in_column = true;
1018                    i += 1;
1019                    continue;
1020                }
1021                if in_column {
1022                    depth += 1;
1023                }
1024            }
1025            if in_column && is_container_close(line, 3) {
1026                if depth > 0 {
1027                    depth -= 1;
1028                    current_column_lines.push(line.to_string());
1029                    i += 1;
1030                    continue;
1031                }
1032                // End of column
1033                let col_text = current_column_lines.join("\n");
1034                let blocks =
1035                    MarkdownParser::with_depth(&col_text, self.depth + 1).parse_blocks()?;
1036                let mut col = AdfNode::layout_column(current_width.clone(), blocks);
1037                pass_through_local_id(&current_dir_attrs, &mut col);
1038                columns.push(col);
1039                current_column_lines.clear();
1040                current_dir_attrs = None;
1041                in_column = false;
1042                i += 1;
1043                continue;
1044            }
1045            if in_column {
1046                current_column_lines.push(line.to_string());
1047            }
1048            i += 1;
1049        }
1050
1051        // Flush last column if no closing fence
1052        if in_column && !current_column_lines.is_empty() {
1053            let col_text = current_column_lines.join("\n");
1054            let blocks = MarkdownParser::with_depth(&col_text, self.depth + 1).parse_blocks()?;
1055            let mut col = AdfNode::layout_column(current_width, blocks);
1056            pass_through_local_id(&current_dir_attrs, &mut col);
1057            columns.push(col);
1058        }
1059
1060        Ok(columns)
1061    }
1062
1063    /// Parses `:::tr` / `:::th` / `:::td` sub-containers inside a `:::table` directive.
1064    fn parse_directive_table_rows(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
1065        debug!(
1066            "parse_directive_table_rows: {} lines of inner text",
1067            inner_text.lines().count()
1068        );
1069        let mut rows = Vec::new();
1070        let lines: Vec<&str> = inner_text.lines().collect();
1071        let mut i = 0;
1072
1073        while i < lines.len() {
1074            let line = lines[i];
1075            if let Some((d, _)) = try_parse_container_open(line) {
1076                if d.name == "tr" {
1077                    let tr_attrs = d.attrs.clone();
1078                    i += 1;
1079                    let (mut row, next_i) = self.parse_directive_table_row(&lines, i)?;
1080                    // Pass through localId from :::tr{localId=...}
1081                    if let Some(ref attrs) = tr_attrs {
1082                        if let Some(local_id) = attrs.get("localId") {
1083                            let row_attrs = row.attrs.get_or_insert_with(|| serde_json::json!({}));
1084                            row_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1085                        }
1086                    }
1087                    rows.push(row);
1088                    i = next_i;
1089                    continue;
1090                }
1091                if d.name == "caption" {
1092                    let dir_attrs = d.attrs.clone();
1093                    i += 1;
1094                    let mut caption_lines = Vec::new();
1095                    while i < lines.len() {
1096                        if is_container_close(lines[i], 3) {
1097                            i += 1;
1098                            break;
1099                        }
1100                        caption_lines.push(lines[i]);
1101                        i += 1;
1102                    }
1103                    let caption_text = caption_lines.join("\n");
1104                    let inline_nodes = parse_inline(&caption_text);
1105                    let mut caption = AdfNode::caption(inline_nodes);
1106                    pass_through_local_id(&dir_attrs, &mut caption);
1107                    rows.push(caption);
1108                    continue;
1109                }
1110            }
1111            i += 1;
1112        }
1113
1114        Ok(rows)
1115    }
1116
1117    /// Parses cells within a `:::tr` container until its closing fence.
1118    fn parse_directive_table_row(&self, lines: &[&str], start: usize) -> Result<(AdfNode, usize)> {
1119        let mut cells = Vec::new();
1120        let mut i = start;
1121        let mut depth: usize = 0;
1122
1123        while i < lines.len() {
1124            let line = lines[i];
1125            if is_container_close(line, 3) {
1126                if depth == 0 {
1127                    // End of :::tr
1128                    i += 1;
1129                    break;
1130                }
1131                depth -= 1;
1132                i += 1;
1133                continue;
1134            }
1135            if let Some((d, _)) = try_parse_container_open(line) {
1136                if depth == 0 && (d.name == "th" || d.name == "td") {
1137                    let is_header = d.name == "th";
1138                    let cell_attrs = d.attrs.clone();
1139                    i += 1;
1140                    let (cell, next_i) =
1141                        self.parse_directive_table_cell(lines, i, is_header, cell_attrs)?;
1142                    cells.push(cell);
1143                    i = next_i;
1144                    continue;
1145                }
1146                depth += 1;
1147            }
1148            i += 1;
1149        }
1150
1151        if cells.is_empty() {
1152            let context = lines[start.saturating_sub(1)..lines.len().min(start + 3)].to_vec();
1153            warn!(
1154                "Directive table row at line {start} has no cells — \
1155                 Confluence requires at least one. Nearby lines: {context:?}"
1156            );
1157        }
1158        debug!("Parsed directive table row: {} cells", cells.len());
1159
1160        Ok((AdfNode::table_row(cells), i))
1161    }
1162
1163    /// Parses the content of a `:::th` or `:::td` cell until its closing fence.
1164    fn parse_directive_table_cell(
1165        &self,
1166        lines: &[&str],
1167        start: usize,
1168        is_header: bool,
1169        cell_attrs: Option<crate::atlassian::attrs::Attrs>,
1170    ) -> Result<(AdfNode, usize)> {
1171        let mut cell_lines = Vec::new();
1172        let mut i = start;
1173        let mut depth: usize = 0;
1174
1175        while i < lines.len() {
1176            let line = lines[i];
1177            if try_parse_container_open(line).is_some() {
1178                depth += 1;
1179            } else if is_container_close(line, 3) {
1180                if depth == 0 {
1181                    i += 1;
1182                    break;
1183                }
1184                depth -= 1;
1185            }
1186            cell_lines.push(line.to_string());
1187            i += 1;
1188        }
1189
1190        let cell_text = cell_lines.join("\n");
1191        let blocks = MarkdownParser::with_depth(&cell_text, self.depth + 1).parse_blocks()?;
1192
1193        let adf_attrs = cell_attrs.as_ref().map(build_cell_attrs);
1194        let cell_marks = cell_attrs
1195            .as_ref()
1196            .map(build_border_marks)
1197            .unwrap_or_default();
1198
1199        let cell = if cell_marks.is_empty() {
1200            if is_header {
1201                if let Some(attrs) = adf_attrs {
1202                    AdfNode::table_header_with_attrs(blocks, attrs)
1203                } else {
1204                    AdfNode::table_header(blocks)
1205                }
1206            } else if let Some(attrs) = adf_attrs {
1207                AdfNode::table_cell_with_attrs(blocks, attrs)
1208            } else {
1209                AdfNode::table_cell(blocks)
1210            }
1211        } else if is_header {
1212            AdfNode::table_header_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
1213        } else {
1214            AdfNode::table_cell_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
1215        };
1216
1217        Ok((cell, i))
1218    }
1219
1220    fn try_leaf_directive(&mut self) -> Option<AdfNode> {
1221        let line = self.current_line();
1222        let d = try_parse_leaf_directive(line)?;
1223
1224        let node = match d.name.as_str() {
1225            "card" => {
1226                let content = d.content.as_deref().unwrap_or("");
1227                // Prefer the `url` attribute when present; fall back to the
1228                // bracketed content.  The attribute form is used when the URL
1229                // contains characters that would otherwise break
1230                // `::card[URL]` parsing.
1231                let url = match d.attrs.as_ref().and_then(|a| a.get("url")) {
1232                    Some(u) => u,
1233                    None => content,
1234                };
1235                let mut node = AdfNode::block_card(url);
1236                // Pass through layout/width attrs
1237                if let Some(ref attrs) = d.attrs {
1238                    if let Some(ref mut node_attrs) = node.attrs {
1239                        if let Some(layout) = attrs.get("layout") {
1240                            node_attrs["layout"] = serde_json::Value::String(layout.to_string());
1241                        }
1242                        if let Some(width) = attrs.get("width") {
1243                            if let Ok(w) = width.parse::<u64>() {
1244                                node_attrs["width"] = serde_json::json!(w);
1245                            }
1246                        }
1247                    }
1248                }
1249                node
1250            }
1251            "embed" => {
1252                let url = d.content.as_deref().unwrap_or("");
1253                let layout = d.attrs.as_ref().and_then(|a| a.get("layout"));
1254                let original_height = d
1255                    .attrs
1256                    .as_ref()
1257                    .and_then(|a| a.get("originalHeight"))
1258                    .and_then(|v| v.parse::<f64>().ok());
1259                let width = d
1260                    .attrs
1261                    .as_ref()
1262                    .and_then(|a| a.get("width"))
1263                    .and_then(|w| w.parse::<f64>().ok());
1264                AdfNode::embed_card(url, layout, original_height, width)
1265            }
1266            "extension" => {
1267                let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
1268                let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
1269                let params = d
1270                    .attrs
1271                    .as_ref()
1272                    .and_then(|a| a.get("params"))
1273                    .and_then(|p| serde_json::from_str(p).ok());
1274                let mut node = AdfNode::extension(ext_type, ext_key, params);
1275                if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
1276                {
1277                    if let Some(layout) = dir_attrs.get("layout") {
1278                        node_attrs["layout"] = serde_json::Value::String(layout.to_string());
1279                    }
1280                    if let Some(local_id) = dir_attrs.get("localId") {
1281                        node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1282                    }
1283                }
1284                node
1285            }
1286            "paragraph" => {
1287                let mut node = if let Some(ref text) = d.content {
1288                    AdfNode::paragraph(parse_inline(text))
1289                } else {
1290                    AdfNode::paragraph(vec![])
1291                };
1292                pass_through_local_id(&d.attrs, &mut node);
1293                node
1294            }
1295            _ => return None,
1296        };
1297
1298        self.advance();
1299        Some(node)
1300    }
1301
1302    fn try_image(&mut self) -> Option<AdfNode> {
1303        let line = self.current_line().trim();
1304        let mut node = try_parse_media_single_from_line(line)?;
1305        self.advance();
1306
1307        // Check for a trailing :::caption directive
1308        if !self.at_end() {
1309            if let Some((d, _)) = try_parse_container_open(self.current_line()) {
1310                if d.name == "caption" {
1311                    let dir_attrs = d.attrs;
1312                    self.advance(); // past :::caption
1313                    let mut caption_lines = Vec::new();
1314                    while !self.at_end() {
1315                        if is_container_close(self.current_line(), 3) {
1316                            self.advance(); // past :::
1317                            break;
1318                        }
1319                        caption_lines.push(self.current_line());
1320                        self.advance();
1321                    }
1322                    let caption_text = caption_lines.join("\n");
1323                    let inline_nodes = parse_inline(&caption_text);
1324                    let mut caption = AdfNode::caption(inline_nodes);
1325                    pass_through_local_id(&dir_attrs, &mut caption);
1326                    if let Some(ref mut content) = node.content {
1327                        content.push(caption);
1328                    }
1329                }
1330            }
1331        }
1332
1333        Some(node)
1334    }
1335
1336    fn try_table(&mut self) -> Result<Option<AdfNode>> {
1337        let line = self.current_line();
1338        if !line.contains('|') || !line.trim_start().starts_with('|') {
1339            return Ok(None);
1340        }
1341
1342        // Peek ahead to check for a separator row (indicates a table)
1343        if self.pos + 1 >= self.lines.len() {
1344            return Ok(None);
1345        }
1346        let next_line = self.lines[self.pos + 1];
1347        if !is_table_separator(next_line) {
1348            return Ok(None);
1349        }
1350
1351        // Parse header row
1352        let header_cells = parse_table_row(line);
1353        self.advance(); // skip header
1354
1355        // Parse separator row for column alignment
1356        let sep_line = self.current_line();
1357        let alignments = parse_table_alignments(sep_line);
1358        self.advance(); // skip separator
1359
1360        let mut rows = Vec::new();
1361
1362        // Header row — parse cell attrs and apply column alignment
1363        let header_adf_cells: Vec<AdfNode> = header_cells
1364            .iter()
1365            .enumerate()
1366            .map(|(col_idx, cell)| {
1367                let (cell_text, cell_attrs) = extract_cell_attrs(cell);
1368                let mut para = AdfNode::paragraph(parse_inline(&cell_text));
1369                apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
1370                if let Some(attrs) = cell_attrs {
1371                    AdfNode::table_header_with_attrs(vec![para], attrs)
1372                } else {
1373                    AdfNode::table_header(vec![para])
1374                }
1375            })
1376            .collect();
1377        if header_adf_cells.is_empty() {
1378            warn!(
1379                "Pipe table header row at line {} has no cells",
1380                self.pos - 1
1381            );
1382        }
1383        rows.push(AdfNode::table_row(header_adf_cells));
1384
1385        // Body rows
1386        while !self.at_end() {
1387            let line = self.current_line();
1388            if !line.contains('|') || line.trim().is_empty() {
1389                break;
1390            }
1391
1392            let cells = parse_table_row(line);
1393            let adf_cells: Vec<AdfNode> = cells
1394                .iter()
1395                .enumerate()
1396                .map(|(col_idx, cell)| {
1397                    let (cell_text, cell_attrs) = extract_cell_attrs(cell);
1398                    let mut para = AdfNode::paragraph(parse_inline(&cell_text));
1399                    apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
1400                    if let Some(attrs) = cell_attrs {
1401                        AdfNode::table_cell_with_attrs(vec![para], attrs)
1402                    } else {
1403                        AdfNode::table_cell(vec![para])
1404                    }
1405                })
1406                .collect();
1407            if adf_cells.is_empty() {
1408                warn!("Pipe table body row at line {} has no cells", self.pos);
1409            }
1410            rows.push(AdfNode::table_row(adf_cells));
1411            self.advance();
1412        }
1413
1414        debug!("Parsed pipe table with {} rows", rows.len());
1415        let mut table = AdfNode::table(rows);
1416
1417        // Check for trailing {attrs} on the next line
1418        if !self.at_end() {
1419            let next = self.current_line().trim();
1420            if next.starts_with('{') {
1421                if let Some((_, attrs)) = parse_attrs(next, 0) {
1422                    let mut table_attrs = serde_json::json!({});
1423                    if let Some(layout) = attrs.get("layout") {
1424                        table_attrs["layout"] = serde_json::Value::String(layout.to_string());
1425                    }
1426                    if attrs.has_flag("numbered") {
1427                        table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
1428                    } else if attrs.get("numbered") == Some("false") {
1429                        table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
1430                    }
1431                    if let Some(tw) = attrs.get("width") {
1432                        if let Some(w) = parse_numeric_attr(tw) {
1433                            table_attrs["width"] = w;
1434                        }
1435                    }
1436                    if let Some(local_id) = attrs.get("localId") {
1437                        table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1438                    }
1439                    if table_attrs != serde_json::json!({}) {
1440                        table.attrs = Some(table_attrs);
1441                        self.advance(); // consume the attrs line
1442                    }
1443                }
1444            }
1445        }
1446
1447        Ok(Some(table))
1448    }
1449
1450    fn parse_paragraph(&mut self) -> Result<AdfNode> {
1451        let mut lines: Vec<&str> = Vec::new();
1452
1453        while !self.at_end() {
1454            let line = self.current_line();
1455            // Only break on block-level patterns if we already have paragraph
1456            // content. This prevents infinite loops when a line looks like a
1457            // block starter but doesn't actually match any block parser (e.g.,
1458            // "#NoSpace" which is not a valid heading).
1459            // Issue #494: A whitespace-only line that follows a hardBreak
1460            // marker (trailing backslash or two trailing spaces) is a
1461            // continuation, not a paragraph break.  Let it fall through to
1462            // the `is_hardbreak_cont` check below.
1463            if (line.trim().is_empty()
1464                && !lines
1465                    .last()
1466                    .is_some_and(|prev| has_trailing_hard_break(prev)))
1467                || is_code_fence_opener(line)
1468                || (is_horizontal_rule(line) && !lines.is_empty())
1469            {
1470                break;
1471            }
1472            // Strip 2-space indent from hardBreak continuation lines so
1473            // the content round-trips correctly (issue #455).
1474            let is_hardbreak_cont = !lines.is_empty()
1475                && line.starts_with("  ")
1476                && lines
1477                    .last()
1478                    .is_some_and(|prev| has_trailing_hard_break(prev));
1479            if is_hardbreak_cont {
1480                lines.push(&line[2..]);
1481                self.advance();
1482                continue;
1483            }
1484            if !lines.is_empty()
1485                && (line.starts_with('#') || line.starts_with('>') || is_list_start(line))
1486            {
1487                break;
1488            }
1489            // Break on trailing block attrs like {align=center}
1490            if !lines.is_empty() && is_block_attrs_line(line) {
1491                break;
1492            }
1493            lines.push(line);
1494            self.advance();
1495        }
1496
1497        let text = lines.join("\n");
1498        let inline_nodes = parse_inline(&text);
1499        Ok(AdfNode::paragraph(inline_nodes))
1500    }
1501}
1502
1503/// Builds ADF cell attributes from JFM directive attrs.
1504/// Maps: `bg` → `background`, `colspan` → number, `rowspan` → number, `colwidth` → array.
1505fn build_cell_attrs(attrs: &crate::atlassian::attrs::Attrs) -> serde_json::Value {
1506    let mut adf = serde_json::json!({});
1507    if let Some(bg) = attrs.get("bg") {
1508        adf["background"] = serde_json::Value::String(bg.to_string());
1509    }
1510    if let Some(colspan) = attrs.get("colspan") {
1511        if let Ok(n) = colspan.parse::<u32>() {
1512            adf["colspan"] = serde_json::json!(n);
1513        }
1514    }
1515    if let Some(rowspan) = attrs.get("rowspan") {
1516        if let Ok(n) = rowspan.parse::<u32>() {
1517            adf["rowspan"] = serde_json::json!(n);
1518        }
1519    }
1520    if let Some(colwidth) = attrs.get("colwidth") {
1521        let widths: Vec<serde_json::Value> = colwidth
1522            .split(',')
1523            .filter_map(|s| parse_numeric_attr(s.trim()))
1524            .collect();
1525        if !widths.is_empty() {
1526            adf["colwidth"] = serde_json::Value::Array(widths);
1527        }
1528    }
1529    if let Some(local_id) = attrs.get("localId") {
1530        adf["localId"] = serde_json::Value::String(local_id.to_string());
1531    }
1532    adf
1533}
1534
1535/// Extracts border marks from directive attributes (used by table cells and media nodes).
1536fn build_border_marks(attrs: &crate::atlassian::attrs::Attrs) -> Vec<AdfMark> {
1537    let mut marks = Vec::new();
1538    let border_color = attrs.get("border-color");
1539    let border_size = attrs.get("border-size");
1540    if border_color.is_some() || border_size.is_some() {
1541        let color = border_color.unwrap_or("#000000");
1542        let size = border_size.and_then(|s| s.parse::<u32>().ok()).unwrap_or(1);
1543        marks.push(AdfMark::border(color, size));
1544    }
1545    marks
1546}
1547
1548/// Converts an ISO 8601 date string (e.g., "2026-04-15") to epoch milliseconds string.
1549/// If the input is already numeric (epoch ms), returns it unchanged.
1550fn iso_date_to_epoch_ms(date_str: &str) -> String {
1551    // If it's already a numeric timestamp, pass through
1552    if date_str.chars().all(|c| c.is_ascii_digit()) {
1553        return date_str.to_string();
1554    }
1555    if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
1556        let epoch_ms = date
1557            .and_hms_opt(0, 0, 0)
1558            .map_or(0, |dt| dt.and_utc().timestamp_millis());
1559        epoch_ms.to_string()
1560    } else {
1561        // Fallback: pass through as-is
1562        date_str.to_string()
1563    }
1564}
1565
1566/// Converts an epoch milliseconds string to an ISO 8601 date string.
1567/// If the input looks like an ISO date already, returns it unchanged.
1568fn epoch_ms_to_iso_date(timestamp: &str) -> String {
1569    // If it looks like an ISO date already, pass through
1570    if timestamp.contains('-') {
1571        return timestamp.to_string();
1572    }
1573    if let Ok(ms) = timestamp.parse::<i64>() {
1574        let secs = ms / 1000;
1575        if let Some(dt) = chrono::DateTime::from_timestamp(secs, 0) {
1576            return dt.format("%Y-%m-%d").to_string();
1577        }
1578    }
1579    // Fallback: pass through
1580    timestamp.to_string()
1581}
1582
1583/// Checks if a line is a standalone block-level attrs line like `{align=center}`.
1584fn is_block_attrs_line(line: &str) -> bool {
1585    let trimmed = line.trim();
1586    if !trimmed.starts_with('{') || !trimmed.ends_with('}') {
1587        return false;
1588    }
1589    if let Some((_, attrs)) = parse_attrs(trimmed, 0) {
1590        // Only consider it a block attrs line if it has recognized block attrs
1591        attrs.get("align").is_some()
1592            || attrs.get("indent").is_some()
1593            || attrs.get("breakout").is_some()
1594            || attrs.get("breakoutWidth").is_some()
1595            || attrs.get("localId").is_some()
1596    } else {
1597        false
1598    }
1599}
1600
1601/// Parses decision items from the inner content of a `:::decisions` container.
1602/// Each item starts with `- <> ` prefix.
1603fn parse_decision_items(text: &str) -> Vec<AdfNode> {
1604    let mut items = Vec::new();
1605    for line in text.lines() {
1606        let trimmed = line.trim();
1607        if let Some(rest) = trimmed.strip_prefix("- <> ") {
1608            let inline_nodes = parse_inline(rest);
1609            items.push(AdfNode::decision_item("DECIDED", inline_nodes));
1610        }
1611    }
1612    items
1613}
1614
1615/// Tries to parse a task list marker `[ ]`, `[x]`, or `[X]` at the start of text.
1616/// Returns `("TODO"|"DONE", remaining_text)` on success.
1617///
1618/// The marker must be followed by a space or the end of the text, so that
1619/// empty task items (`- [ ]` with no body) are still recognised as tasks
1620/// rather than being treated as bullet items containing literal `[ ]` text
1621/// (issue #548).
1622fn try_parse_task_marker(text: &str) -> Option<(&str, &str)> {
1623    if let Some(rest) = strip_task_checkbox(text, "[ ]") {
1624        Some(("TODO", rest))
1625    } else if let Some(rest) =
1626        strip_task_checkbox(text, "[x]").or_else(|| strip_task_checkbox(text, "[X]"))
1627    {
1628        Some(("DONE", rest))
1629    } else {
1630        None
1631    }
1632}
1633
1634/// Strips a checkbox prefix from `text` if the character after the checkbox
1635/// is a space or the text ends there.  Returns the remaining text (with the
1636/// separating space consumed, if any).
1637fn strip_task_checkbox<'a>(text: &'a str, checkbox: &str) -> Option<&'a str> {
1638    let rest = text.strip_prefix(checkbox)?;
1639    if rest.is_empty() {
1640        Some(rest)
1641    } else {
1642        rest.strip_prefix(' ')
1643    }
1644}
1645
1646/// Returns true if `s` begins with a sequence the bullet-list parser would
1647/// interpret as a task checkbox marker (`[ ]`, `[x]`, or `[X]` followed by
1648/// a space, newline, or end-of-input).
1649///
1650/// Used by the `bulletList` renderer to decide whether to escape the leading
1651/// `[` of an item whose literal text starts with a checkbox-shaped prefix
1652/// (issue #548).
1653fn starts_with_task_marker(s: &str) -> bool {
1654    let after = if let Some(rest) = s.strip_prefix("[ ]") {
1655        rest
1656    } else if let Some(rest) = s.strip_prefix("[x]").or_else(|| s.strip_prefix("[X]")) {
1657        rest
1658    } else {
1659        return false;
1660    };
1661    after.is_empty() || after.starts_with(' ') || after.starts_with('\n')
1662}
1663
1664/// Parses an ordered list marker like "1. " and returns (number, rest_of_line).
1665fn parse_ordered_list_marker(line: &str) -> Option<(u32, &str)> {
1666    let digit_end = line.find(|c: char| !c.is_ascii_digit())?;
1667    if digit_end == 0 {
1668        return None;
1669    }
1670    let rest = &line[digit_end..];
1671    let after_marker = rest.strip_prefix(". ")?;
1672    let num: u32 = line[..digit_end].parse().ok()?;
1673    Some((num, after_marker))
1674}
1675
1676/// Returns true if a line ends with a hardBreak marker
1677/// (trailing backslash or two trailing spaces).
1678fn has_trailing_hard_break(line: &str) -> bool {
1679    line.ends_with('\\') || line.ends_with("  ")
1680}
1681
1682/// Returns true if the already-trimmed continuation line starts with a
1683/// block-level marker that must not be swallowed as a paragraph continuation
1684/// in `collect_hardbreak_continuations`.
1685///
1686/// Covers mediaSingle (`![` — issue #490), fenced code blocks (```` ``` ````
1687/// — issue #552), and container directives (`:::`).  The caller is expected
1688/// to have already stripped leading whitespace.
1689fn is_block_level_continuation_marker(trimmed: &str) -> bool {
1690    trimmed.starts_with("![") || trimmed.starts_with("```") || trimmed.starts_with(":::")
1691}
1692
1693/// Checks if a line starts a list item.
1694fn is_list_start(line: &str) -> bool {
1695    let trimmed = line.trim_start();
1696    trimmed.starts_with("- ")
1697        || trimmed.starts_with("* ")
1698        || trimmed.starts_with("+ ")
1699        || parse_ordered_list_marker(trimmed).is_some()
1700}
1701
1702/// Number of leading ASCII spaces — the parser's unit of list indentation.
1703/// Deliberately not `trim_start()`-based: that would count the bytes of any
1704/// Unicode whitespace (an NBSP is two), which is not what the 2-space
1705/// nesting convention measures.
1706fn leading_spaces(line: &str) -> usize {
1707    line.bytes().take_while(|&b| b == b' ').count()
1708}
1709
1710/// Escapes asterisk and underscore sequences in text that would otherwise be
1711/// parsed as CommonMark emphasis (`*…*`, `_…_`) or strong emphasis (`**…**`,
1712/// `__…__`).
1713///
1714/// Asterisks are always escaped (they're rare in prose and the JFM parser
1715/// will gladly match them across node boundaries). Underscores are escaped
1716/// per the intraword rule: a `_` is left as-is only when it's clearly
1717/// intraword *within this text node* (alphanumeric on both sides). At the
1718/// node boundary or next to non-alphanumeric characters we escape, since
1719/// adjacent text nodes can supply the other side of an emphasis pair (issue
1720/// #554: `"_ "` followed by colored `"_Action…"` produced `_ :span[_…` which
1721/// parsed as italic and destroyed the span directive).
1722fn escape_emphasis_markers(text: &str) -> String {
1723    escape_emphasis_with(text, false)
1724}
1725
1726/// Variant of [`escape_emphasis_markers`] that escapes ALL underscores (even
1727/// intraword), not just boundary ones.
1728///
1729/// Must be used whenever the rendered markdown wraps this text in an `_..._`
1730/// em delimiter, because an unescaped `_` anywhere in the content would
1731/// otherwise close the delimiter prematurely (e.g. `_foo_bar_baz_` parses as
1732/// em("foo") + "bar" + em("baz"), not em("foo_bar_baz")).
1733fn escape_emphasis_markers_with_underscore(text: &str) -> String {
1734    escape_emphasis_with(text, true)
1735}
1736
1737/// Internal: escapes `*` always, and escapes `_` per the CommonMark intraword
1738/// rule by default — boundary or punctuation-adjacent runs are escaped, fully
1739/// intraword runs are left as-is.  When `escape_underscore_always` is true,
1740/// every `_` is escaped regardless (used when the surrounding context is an
1741/// `_..._` em delimiter that any inner `_` would close prematurely).
1742fn escape_emphasis_with(text: &str, escape_underscore_always: bool) -> String {
1743    let chars: Vec<char> = text.chars().collect();
1744    let mut out = String::with_capacity(text.len());
1745    let mut idx = 0;
1746    while idx < chars.len() {
1747        let ch = chars[idx];
1748        if ch == '*' {
1749            out.push('\\');
1750            out.push(ch);
1751            idx += 1;
1752        } else if ch == '_' {
1753            // Find the extent of this run of underscores. CommonMark treats
1754            // consecutive `_` as a single delimiter run, so the intraword
1755            // check applies to the whole run, not individual characters.
1756            let run_start = idx;
1757            let mut run_end = idx;
1758            while run_end < chars.len() && chars[run_end] == '_' {
1759                run_end += 1;
1760            }
1761            let escape_run = if escape_underscore_always {
1762                true
1763            } else {
1764                let before_alnum = run_start > 0 && chars[run_start - 1].is_alphanumeric();
1765                let after_alnum = chars.get(run_end).is_some_and(|c| c.is_alphanumeric());
1766                !(before_alnum && after_alnum)
1767            };
1768            for _ in run_start..run_end {
1769                if escape_run {
1770                    out.push('\\');
1771                }
1772                out.push('_');
1773            }
1774            idx = run_end;
1775        } else {
1776            out.push(ch);
1777            idx += 1;
1778        }
1779    }
1780    out
1781}
1782
1783/// Escapes backtick characters in text that would otherwise be parsed as
1784/// inline code spans (`` `…` ``).
1785///
1786/// Each backtick is prefixed with a backslash so that the JFM parser treats
1787/// it as a literal character rather than an inline-code delimiter.
1788fn escape_backticks(text: &str) -> String {
1789    let mut out = String::with_capacity(text.len());
1790    for ch in text.chars() {
1791        if ch == '`' {
1792            out.push('\\');
1793        }
1794        out.push(ch);
1795    }
1796    out
1797}
1798
1799/// Chooses a backtick delimiter length and padding flag for rendering `text`
1800/// as a CommonMark inline code span.
1801///
1802/// Per CommonMark: the delimiter must be a run of backticks not equal in
1803/// length to any run inside the content, and if both ends of the content
1804/// would start/end with a space (or with a backtick), a single space of
1805/// padding is added so the span survives the spec's space-stripping rule.
1806fn inline_code_delimiter(text: &str) -> (usize, bool) {
1807    let mut max_run = 0usize;
1808    let mut current = 0usize;
1809    for ch in text.chars() {
1810        if ch == '`' {
1811            current += 1;
1812            if current > max_run {
1813                max_run = current;
1814            }
1815        } else {
1816            current = 0;
1817        }
1818    }
1819    let n = max_run + 1;
1820    let starts_bt = text.starts_with('`');
1821    let ends_bt = text.ends_with('`');
1822    let starts_sp = text.starts_with(' ');
1823    let ends_sp = text.ends_with(' ');
1824    let all_sp = !text.is_empty() && text.chars().all(|c| c == ' ');
1825    let needs_pad = starts_bt || ends_bt || (starts_sp && ends_sp && !all_sp);
1826    (n, needs_pad)
1827}
1828
1829/// Appends `text` to `output` wrapped in a CommonMark inline code span whose
1830/// delimiter length allows any embedded backticks to round-trip unambiguously.
1831fn render_inline_code(text: &str, output: &mut String) {
1832    let (n, pad) = inline_code_delimiter(text);
1833    for _ in 0..n {
1834        output.push('`');
1835    }
1836    if pad {
1837        output.push(' ');
1838    }
1839    output.push_str(text);
1840    if pad {
1841        output.push(' ');
1842    }
1843    for _ in 0..n {
1844        output.push('`');
1845    }
1846}
1847
1848/// Escapes pipe characters in text that appears inside a GFM pipe table cell.
1849///
1850/// Without this, a literal `|` in cell content (including inside inline code
1851/// spans) is interpreted as a column separator on round-trip, splitting the
1852/// cell and corrupting its content (see issue #579).  Each `|` is prefixed
1853/// with a backslash so the table-row parser treats it as literal.
1854fn escape_pipes_in_cell(text: &str) -> String {
1855    let mut out = String::with_capacity(text.len());
1856    for ch in text.chars() {
1857        if ch == '|' {
1858            out.push('\\');
1859        }
1860        out.push(ch);
1861    }
1862    out
1863}
1864
1865/// Escapes square brackets (`[` and `]`) in text that will appear inside a
1866/// markdown link's `[…]` delimiters.  Without this, a text node containing a
1867/// literal `[` or `]` can create ambiguous markdown link syntax on round-trip
1868/// (see issue #493).
1869fn escape_link_brackets(text: &str) -> String {
1870    let mut out = String::with_capacity(text.len());
1871    for ch in text.chars() {
1872        if ch == '[' || ch == ']' {
1873            out.push('\\');
1874        }
1875        out.push(ch);
1876    }
1877    out
1878}
1879
1880/// Escapes bare URLs (`http://` and `https://`) in plain text so they are not
1881/// parsed as `inlineCard` nodes during round-trip.  The leading `h` is
1882/// backslash-escaped, which is enough to prevent the auto-link detector from
1883/// matching the URL while the existing backslash-escape handler restores it on
1884/// re-parse.
1885fn escape_bare_urls(text: &str) -> String {
1886    let mut result = String::with_capacity(text.len());
1887    for (i, ch) in text.char_indices() {
1888        if ch == 'h' {
1889            let rest = &text[i..];
1890            if rest.starts_with("http://") || rest.starts_with("https://") {
1891                result.push('\\');
1892            }
1893        }
1894        result.push(ch);
1895    }
1896    result
1897}
1898
1899/// Returns `true` if the string can be embedded in a `:card[...]` (or similar
1900/// bracketed inline directive) without breaking the depth-based bracket matcher
1901/// used by [`try_parse_inline_directive`].
1902///
1903/// The parser scans the content enclosed in `[...]` treating `[` as +1 and `]`
1904/// as −1 on depth, closing when depth returns to zero.  A value is safe if
1905/// every prefix has `count('[') >= count(']') − 1` (i.e., the running depth
1906/// never dips below zero before the end) and it contains no newline.
1907fn url_safe_in_bracket_content(s: &str) -> bool {
1908    if s.contains('\n') {
1909        return false;
1910    }
1911    let mut depth: i32 = 1;
1912    for ch in s.chars() {
1913        match ch {
1914            '[' => depth += 1,
1915            ']' => {
1916                depth -= 1;
1917                if depth == 0 {
1918                    return false;
1919                }
1920            }
1921            _ => {}
1922        }
1923    }
1924    true
1925}
1926
1927/// Escapes emoji shortcode patterns (`:name:`) in plain text so they are not
1928/// parsed as emoji nodes during round-trip.  Only the leading colon is
1929/// backslash-escaped, which is enough to prevent the parser from matching the
1930/// pattern while the existing backslash-escape handler restores it on re-parse.
1931///
1932/// The character class for the name segment must match `try_parse_emoji_shortcode`
1933/// exactly (Unicode `is_alphanumeric` plus `_`, `+`, `-`).  An ASCII-only escape
1934/// would leave Unicode patterns like `:Café:` or `:ZBC::Acme::配置:` un-escaped
1935/// while still being detected as emoji on re-parse, splitting the text node
1936/// (issue #552).
1937fn escape_emoji_shortcodes(text: &str) -> String {
1938    let mut result = String::with_capacity(text.len());
1939
1940    for (i, ch) in text.char_indices() {
1941        if ch == ':' {
1942            // Check if this is a `:name:` pattern where name matches the
1943            // same character class accepted by `try_parse_emoji_shortcode`.
1944            let after = i + 1;
1945            if after < text.len() {
1946                let name_end = text[after..]
1947                    .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')
1948                    .unwrap_or(text[after..].len());
1949                if name_end > 0
1950                    && after + name_end < text.len()
1951                    && text.as_bytes()[after + name_end] == b':'
1952                {
1953                    // Found `:name:` pattern — escape the leading colon
1954                    result.push('\\');
1955                }
1956            }
1957        }
1958        result.push(ch);
1959    }
1960
1961    result
1962}
1963
1964/// Escapes a leading list-marker pattern on a line so it is not
1965/// re-parsed as a new list item.  `"2. text"` → `"2\. text"`,
1966/// `"- text"` → `"\- text"`.
1967fn escape_list_marker(line: &str) -> String {
1968    if let Some(dot_pos) = line.find(". ") {
1969        if parse_ordered_list_marker(line).is_some() {
1970            let mut s = String::with_capacity(line.len() + 1);
1971            s.push_str(&line[..dot_pos]);
1972            s.push('\\');
1973            s.push_str(&line[dot_pos..]);
1974            return s;
1975        }
1976    }
1977    for prefix in &["- ", "* ", "+ "] {
1978        if line.starts_with(prefix) {
1979            let mut s = String::with_capacity(line.len() + 1);
1980            s.push('\\');
1981            s.push_str(line);
1982            return s;
1983        }
1984    }
1985    line.to_string()
1986}
1987
1988/// Checks if a line is a valid fenced code block opener.
1989///
1990/// Per CommonMark: the opener is a sequence of three or more backticks
1991/// followed by an info string that must not contain any backtick
1992/// character, otherwise some inline code spans would be misinterpreted
1993/// as the beginning of a fenced code block.
1994fn is_code_fence_opener(line: &str) -> bool {
1995    if !line.starts_with("```") {
1996        return false;
1997    }
1998    !line[3..].contains('`')
1999}
2000
2001/// Checks if a line is a horizontal rule.
2002fn is_horizontal_rule(line: &str) -> bool {
2003    let trimmed = line.trim();
2004    trimmed.len() >= 3
2005        && ((trimmed.starts_with("---") && trimmed.chars().all(|c| c == '-'))
2006            || (trimmed.starts_with("***") && trimmed.chars().all(|c| c == '*'))
2007            || (trimmed.starts_with("___") && trimmed.chars().all(|c| c == '_')))
2008}
2009
2010/// Checks if a line is a GFM table separator (e.g., "|---|---|").
2011fn is_table_separator(line: &str) -> bool {
2012    let trimmed = line.trim();
2013    trimmed.contains('|')
2014        && trimmed
2015            .chars()
2016            .all(|c| c == '|' || c == '-' || c == ':' || c == ' ')
2017}
2018
2019/// Parses a GFM table row into cell contents.
2020///
2021/// Splits on unescaped `|` characters; a preceding backslash (`\|`) is
2022/// interpreted as a literal pipe and unescaped in the emitted cell content
2023/// (see issue #579).  This allows code spans and other inline content that
2024/// contain literal `|` to survive round-trip through a pipe table.
2025fn parse_table_row(line: &str) -> Vec<String> {
2026    let trimmed = line.trim();
2027    let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
2028    let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
2029
2030    let mut cells: Vec<String> = Vec::new();
2031    let mut current = String::new();
2032    let mut chars = trimmed.chars().peekable();
2033    while let Some(ch) = chars.next() {
2034        if ch == '\\' && chars.peek() == Some(&'|') {
2035            current.push('|');
2036            chars.next();
2037        } else if ch == '|' {
2038            cells.push(std::mem::take(&mut current));
2039        } else {
2040            current.push(ch);
2041        }
2042    }
2043    cells.push(current);
2044
2045    cells
2046        .iter()
2047        .map(|s| {
2048            // Strip exactly one leading and one trailing space (pipe table padding).
2049            // Preserve any additional whitespace as significant content.
2050            let stripped = s.strip_prefix(' ').unwrap_or(s.as_str());
2051            let stripped = stripped.strip_suffix(' ').unwrap_or(stripped);
2052            stripped.to_string()
2053        })
2054        .collect()
2055}
2056
2057/// Parses column alignments from a GFM table separator row.
2058/// Returns a vec of `Option<&str>` where `Some("center")` or `Some("end")` indicate alignment.
2059fn parse_table_alignments(separator_line: &str) -> Vec<Option<&'static str>> {
2060    let trimmed = separator_line.trim();
2061    let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
2062    let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
2063
2064    trimmed
2065        .split('|')
2066        .map(|cell| {
2067            let cell = cell.trim();
2068            let starts_colon = cell.starts_with(':');
2069            let ends_colon = cell.ends_with(':');
2070            match (starts_colon, ends_colon) {
2071                (true, true) => Some("center"),
2072                (false, true) => Some("end"),
2073                _ => None, // left/default
2074            }
2075        })
2076        .collect()
2077}
2078
2079/// Applies an alignment mark to a paragraph node if alignment is specified.
2080fn apply_column_alignment(para: &mut AdfNode, alignment: Option<&str>) {
2081    if let Some(align) = alignment {
2082        para.marks = Some(vec![AdfMark::alignment(align)]);
2083    }
2084}
2085
2086/// Extracts `{attrs}` prefix from a pipe table cell text.
2087/// Returns `(remaining_text, Option<adf_attrs_json>)`.
2088fn extract_cell_attrs(cell_text: &str) -> (String, Option<serde_json::Value>) {
2089    let trimmed = cell_text.trim_start();
2090    if !trimmed.starts_with('{') {
2091        return (cell_text.to_string(), None);
2092    }
2093    if let Some((end_pos, attrs)) = parse_attrs(trimmed, 0) {
2094        let remaining = trimmed[end_pos..].trim_start().to_string();
2095        let adf_attrs = build_cell_attrs(&attrs);
2096        (remaining, Some(adf_attrs))
2097    } else {
2098        (cell_text.to_string(), None)
2099    }
2100}
2101
2102/// Tries to parse a line as a block-level image and return a mediaSingle ADF node.
2103/// Used by both `try_image` (top-level blocks) and list item parsing.
2104fn try_parse_media_single_from_line(line: &str) -> Option<AdfNode> {
2105    let line = line.trim();
2106    if !line.starts_with("![") {
2107        return None;
2108    }
2109
2110    let (alt, url) = parse_image_syntax(line)?;
2111    let alt_opt = if alt.is_empty() { None } else { Some(alt) };
2112
2113    let paren_open = line.find("](")? + 1; // index of '('
2114    let img_end = find_closing_paren(line, paren_open)? + 1;
2115    let after_img = line[img_end..].trim_start();
2116
2117    if after_img.starts_with('{') {
2118        if let Some((_, attrs)) = parse_attrs(after_img, 0) {
2119            // Confluence file attachment — reconstruct type:file media node
2120            if attrs.get("type") == Some("file") || attrs.get("id").is_some() {
2121                let mut media_attrs = serde_json::json!({"type": "file"});
2122                if let Some(id) = attrs.get("id") {
2123                    media_attrs["id"] = serde_json::Value::String(id.to_string());
2124                }
2125                if let Some(collection) = attrs.get("collection") {
2126                    media_attrs["collection"] = serde_json::Value::String(collection.to_string());
2127                }
2128                if let Some(occurrence_key) = attrs.get("occurrenceKey") {
2129                    media_attrs["occurrenceKey"] =
2130                        serde_json::Value::String(occurrence_key.to_string());
2131                }
2132                if let Some(height) = attrs.get("height") {
2133                    if let Some(h) = parse_numeric_attr(height) {
2134                        media_attrs["height"] = h;
2135                    }
2136                }
2137                if let Some(width) = attrs.get("width") {
2138                    if let Some(w) = parse_numeric_attr(width) {
2139                        media_attrs["width"] = w;
2140                    }
2141                }
2142                if let Some(alt_text) = alt_opt {
2143                    media_attrs["alt"] = serde_json::Value::String(alt_text.to_string());
2144                }
2145                if let Some(local_id) = attrs.get("localId") {
2146                    media_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2147                }
2148                let mut ms_attrs = serde_json::json!({"layout": "center"});
2149                if let Some(layout) = attrs.get("layout") {
2150                    ms_attrs["layout"] = serde_json::Value::String(layout.to_string());
2151                }
2152                if let Some(ms_width) = attrs.get("mediaWidth") {
2153                    if let Some(w) = parse_numeric_attr(ms_width) {
2154                        ms_attrs["width"] = w;
2155                    }
2156                }
2157                if let Some(wt) = attrs.get("widthType") {
2158                    ms_attrs["widthType"] = serde_json::Value::String(wt.to_string());
2159                }
2160                if let Some(mode) = attrs.get("mode") {
2161                    ms_attrs["mode"] = serde_json::Value::String(mode.to_string());
2162                }
2163                let border_marks = build_border_marks(&attrs);
2164                let media_marks = if border_marks.is_empty() {
2165                    None
2166                } else {
2167                    Some(border_marks)
2168                };
2169                return Some(AdfNode {
2170                    node_type: "mediaSingle".to_string(),
2171                    attrs: Some(ms_attrs),
2172                    content: Some(vec![AdfNode {
2173                        node_type: "media".to_string(),
2174                        attrs: Some(media_attrs),
2175                        content: None,
2176                        text: None,
2177                        marks: media_marks,
2178                        local_id: None,
2179                        parameters: None,
2180                    }]),
2181                    text: None,
2182                    marks: None,
2183                    local_id: None,
2184                    parameters: None,
2185                });
2186            }
2187
2188            // External image — apply layout/width/widthType to mediaSingle attrs
2189            let mut node = AdfNode::media_single(url, alt_opt);
2190            if let Some(ref mut node_attrs) = node.attrs {
2191                if let Some(layout) = attrs.get("layout") {
2192                    node_attrs["layout"] = serde_json::Value::String(layout.to_string());
2193                }
2194                if let Some(width) = attrs.get("width") {
2195                    if let Some(w) = parse_numeric_attr(width) {
2196                        node_attrs["width"] = w;
2197                    }
2198                }
2199                if let Some(wt) = attrs.get("widthType") {
2200                    node_attrs["widthType"] = serde_json::Value::String(wt.to_string());
2201                }
2202                if let Some(mode) = attrs.get("mode") {
2203                    node_attrs["mode"] = serde_json::Value::String(mode.to_string());
2204                }
2205            }
2206            if let Some(ref mut content) = node.content {
2207                if let Some(media) = content.first_mut() {
2208                    if let Some(local_id) = attrs.get("localId") {
2209                        if let Some(ref mut media_attrs) = media.attrs {
2210                            media_attrs["localId"] =
2211                                serde_json::Value::String(local_id.to_string());
2212                        }
2213                    }
2214                    let border_marks = build_border_marks(&attrs);
2215                    if !border_marks.is_empty() {
2216                        media.marks = Some(border_marks);
2217                    }
2218                }
2219            }
2220            return Some(node);
2221        }
2222    }
2223
2224    Some(AdfNode::media_single(url, alt_opt))
2225}
2226
2227/// Parses `![alt](url)` image syntax.
2228fn parse_image_syntax(line: &str) -> Option<(&str, &str)> {
2229    let line = line.trim();
2230    if !line.starts_with("![") {
2231        return None;
2232    }
2233
2234    let alt_end = line.find("](")?;
2235    let alt = &line[2..alt_end];
2236    let paren_start = alt_end + 1; // index of the '('
2237    let url_end = find_closing_paren(line, paren_start)?;
2238    let url = &line[paren_start + 1..url_end];
2239
2240    Some((alt, url))
2241}
2242
2243// ── Inline Parsing ──────────────────────────────────────────────────
2244
2245/// Parses inline markdown content into ADF inline nodes.
2246///
2247/// Detects bare URLs (e.g., `https://example.com`) and promotes them to
2248/// `inlineCard` nodes. Call this at the top level (paragraph, heading, cell,
2249/// list item) where a bare URL represents a smart link.
2250fn parse_inline(text: &str) -> Vec<AdfNode> {
2251    parse_inline_impl(text, true, 0)
2252}
2253
2254/// Implementation backing [`parse_inline`] and the inline recursion sites.
2255///
2256/// When `auto_inline_card` is `false`, bare `http://`/`https://` URLs are
2257/// treated as plain text instead of being promoted to `inlineCard` nodes.
2258/// Recursion into mark-wrapping constructs (emphasis, strike, bracketed
2259/// spans, links, inline directives) passes `false`: the enclosing syntax
2260/// already declares the semantic role of the content — a URL inside
2261/// `[url]{underline}` or `**url**` is the user's text, not a smart link
2262/// (issue #553).
2263///
2264/// `depth` counts those recursion levels.  Past [`MAX_NESTING_DEPTH`] the
2265/// remaining text is emitted as a plain text node instead of recursing
2266/// further — inline parsing is infallible, so unlike the block parser it
2267/// degrades gracefully rather than erroring (issue #1130).
2268fn parse_inline_impl(text: &str, auto_inline_card: bool, depth: usize) -> Vec<AdfNode> {
2269    if depth > MAX_NESTING_DEPTH {
2270        return if text.is_empty() {
2271            Vec::new()
2272        } else {
2273            vec![AdfNode::text(text)]
2274        };
2275    }
2276
2277    let mut nodes = Vec::new();
2278    let mut chars = text.char_indices().peekable();
2279    let mut plain_start = 0;
2280
2281    while let Some(&(i, ch)) = chars.peek() {
2282        match ch {
2283            '*' | '_' => {
2284                if let Some((end, content, is_bold)) = try_parse_emphasis(text, i) {
2285                    flush_plain(text, plain_start, i, &mut nodes);
2286                    let mark = if is_bold {
2287                        AdfMark::strong()
2288                    } else {
2289                        AdfMark::em()
2290                    };
2291                    let inner = parse_inline_impl(content, false, depth + 1);
2292                    for mut node in inner {
2293                        prepend_mark(&mut node, mark.clone());
2294                        nodes.push(node);
2295                    }
2296                    // Advance past the consumed characters
2297                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2298                        chars.next();
2299                    }
2300                    plain_start = end;
2301                    continue;
2302                }
2303                // For underscores, skip the entire delimiter run so that
2304                // individual `_` chars within a `__` or `___` run are not
2305                // re-tried as separate emphasis openers (CommonMark treats
2306                // consecutive underscores as a single delimiter run).
2307                if ch == '_' {
2308                    while chars.peek().is_some_and(|&(_, c)| c == '_') {
2309                        chars.next();
2310                    }
2311                } else {
2312                    chars.next();
2313                }
2314            }
2315            '~' => {
2316                if let Some((end, content)) = try_parse_strikethrough(text, i) {
2317                    flush_plain(text, plain_start, i, &mut nodes);
2318                    let inner = parse_inline_impl(content, false, depth + 1);
2319                    for mut node in inner {
2320                        prepend_mark(&mut node, AdfMark::strike());
2321                        nodes.push(node);
2322                    }
2323                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2324                        chars.next();
2325                    }
2326                    plain_start = end;
2327                    continue;
2328                }
2329                chars.next();
2330            }
2331            '`' => {
2332                if let Some((end, content)) = try_parse_inline_code(text, i) {
2333                    flush_plain(text, plain_start, i, &mut nodes);
2334                    nodes.push(AdfNode::text_with_marks(content, vec![AdfMark::code()]));
2335                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2336                        chars.next();
2337                    }
2338                    plain_start = end;
2339                    continue;
2340                }
2341                // No code span starting here; skip past the entire backtick
2342                // run so a longer opening run isn't retried as a shorter one.
2343                while chars.peek().is_some_and(|&(_, c)| c == '`') {
2344                    chars.next();
2345                }
2346            }
2347            '[' => {
2348                if let Some((end, link_text, href)) = try_parse_link(text, i) {
2349                    flush_plain(text, plain_start, i, &mut nodes);
2350                    if link_text.starts_with("http://") || link_text.starts_with("https://") {
2351                        // URL-as-link-text: emit as text with link mark,
2352                        // not via parse_inline which would produce an inlineCard.
2353                        // Covers both exact matches and trailing-slash mismatches
2354                        // (issue #523).
2355                        nodes.push(AdfNode::text_with_marks(
2356                            link_text,
2357                            vec![AdfMark::link(href)],
2358                        ));
2359                    } else {
2360                        let inner = parse_inline_impl(link_text, false, depth + 1);
2361                        for mut node in inner {
2362                            prepend_mark(&mut node, AdfMark::link(href));
2363                            nodes.push(node);
2364                        }
2365                    }
2366                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2367                        chars.next();
2368                    }
2369                    plain_start = end;
2370                    continue;
2371                }
2372                // Try bracketed span with attributes: [text]{underline}
2373                if let Some((end, span_nodes)) = try_parse_bracketed_span(text, i, depth) {
2374                    flush_plain(text, plain_start, i, &mut nodes);
2375                    nodes.extend(span_nodes);
2376                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2377                        chars.next();
2378                    }
2379                    plain_start = end;
2380                    continue;
2381                }
2382                chars.next();
2383            }
2384            ':' => {
2385                // Try generic inline directive (:card[url], :status[text]{attrs}, etc.)
2386                if let Some(node) = try_dispatch_inline_directive(text, i, depth) {
2387                    flush_plain(text, plain_start, i, &mut nodes);
2388                    let end = node.1;
2389                    nodes.push(node.0);
2390                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2391                        chars.next();
2392                    }
2393                    plain_start = end;
2394                    continue;
2395                }
2396                // Try emoji shortcode :name: with optional {attrs}
2397                if let Some((end, short_name)) = try_parse_emoji_shortcode(text, i) {
2398                    flush_plain(text, plain_start, i, &mut nodes);
2399                    let (final_end, emoji_node) = parse_emoji_with_attrs(text, end, short_name);
2400                    nodes.push(emoji_node);
2401                    while chars.peek().is_some_and(|&(idx, _)| idx < final_end) {
2402                        chars.next();
2403                    }
2404                    plain_start = final_end;
2405                    continue;
2406                }
2407                chars.next();
2408            }
2409            ' ' if text[i..].starts_with("  \n") => {
2410                // Trailing-space line break → hardBreak node.
2411                // Flush preceding text (without the trailing spaces).
2412                flush_plain(text, plain_start, i, &mut nodes);
2413                nodes.push(AdfNode::hard_break());
2414                // Skip past all spaces and the newline
2415                while chars.peek().is_some_and(|&(_, c)| c == ' ') {
2416                    chars.next();
2417                }
2418                // Skip the newline
2419                if chars.peek().is_some_and(|&(_, c)| c == '\n') {
2420                    chars.next();
2421                }
2422                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2423            }
2424            '!' if text[i..].starts_with("![") => {
2425                // Inline image — skip the ! and let [ handle it next iteration
2426                // (Images at block level are handled by try_image; inline images
2427                // degrade to link text in ADF since inline media is complex)
2428                chars.next();
2429            }
2430            'h' if auto_inline_card
2431                && (text[i..].starts_with("http://") || text[i..].starts_with("https://")) =>
2432            {
2433                if let Some((end, url)) = try_parse_bare_url(text, i) {
2434                    flush_plain(text, plain_start, i, &mut nodes);
2435                    nodes.push(AdfNode::inline_card(url));
2436                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2437                        chars.next();
2438                    }
2439                    plain_start = end;
2440                    continue;
2441                }
2442                chars.next();
2443            }
2444            '\\' if text.as_bytes().get(i + 1) == Some(&b'n')
2445                && text.as_bytes().get(i + 2) != Some(&b'\n') =>
2446            {
2447                // Issue #454: `\n` (backslash + letter n) encodes a literal
2448                // newline inside a text node. Emit the newline as a separate
2449                // text node so merge_adjacent_text can reassemble it.
2450                flush_plain(text, plain_start, i, &mut nodes);
2451                nodes.push(AdfNode::text("\n"));
2452                chars.next(); // consume the '\'
2453                chars.next(); // consume the 'n'
2454                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2455            }
2456            '\\' if i + 1 < text.len() && !text[i..].starts_with("\\\n") => {
2457                // Backslash escape: skip the backslash and treat the next
2458                // character as literal text (e.g. `\\` → `\`,
2459                // `2\. text` → `2. text`, `\*word\*` → `*word*` without
2460                // emphasis, `\:fire:` → `:fire:` without emoji parsing).
2461                flush_plain(text, plain_start, i, &mut nodes);
2462                chars.next(); // consume the backslash
2463                              // Set plain_start to the escaped character so it is included
2464                              // in the next plain-text run, then advance past it so it is
2465                              // not re-interpreted as a special character (e.g. `*`, `_`, `:`).
2466                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2467                chars.next(); // consume the escaped character
2468            }
2469            '\\' if text[i..].starts_with("\\\n") => {
2470                // Backslash line break → hardBreak node.
2471                flush_plain(text, plain_start, i, &mut nodes);
2472                nodes.push(AdfNode::hard_break());
2473                chars.next(); // consume the '\'
2474                              // Skip the newline
2475                if chars.peek().is_some_and(|&(_, c)| c == '\n') {
2476                    chars.next();
2477                }
2478                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2479            }
2480            '\\' if i + 1 == text.len() => {
2481                // Trailing backslash at end of paragraph text → hardBreak node.
2482                flush_plain(text, plain_start, i, &mut nodes);
2483                nodes.push(AdfNode::hard_break());
2484                chars.next(); // consume the '\'
2485                plain_start = text.len();
2486            }
2487            _ => {
2488                chars.next();
2489            }
2490        }
2491    }
2492
2493    // Flush remaining plain text
2494    if plain_start < text.len() {
2495        let remaining = &text[plain_start..];
2496        if !remaining.is_empty() {
2497            nodes.push(AdfNode::text(remaining));
2498        }
2499    }
2500
2501    // Merge adjacent unmarked text nodes that can arise from backslash
2502    // escape handling (e.g. `"2"` + `". text"` → `"2. text"`).
2503    merge_adjacent_text(&mut nodes);
2504
2505    nodes
2506}
2507
2508/// Merges consecutive unmarked text nodes in-place.
2509fn merge_adjacent_text(nodes: &mut Vec<AdfNode>) {
2510    let mut i = 0;
2511    while i + 1 < nodes.len() {
2512        if nodes[i].node_type == "text"
2513            && nodes[i + 1].node_type == "text"
2514            && nodes[i].marks.is_none()
2515            && nodes[i + 1].marks.is_none()
2516        {
2517            let next_text = nodes[i + 1].text.clone().unwrap_or_default();
2518            if let Some(ref mut t) = nodes[i].text {
2519                t.push_str(&next_text);
2520            }
2521            nodes.remove(i + 1);
2522        } else {
2523            i += 1;
2524        }
2525    }
2526}
2527
2528/// Flushes accumulated plain text as a text node.
2529fn flush_plain(text: &str, start: usize, end: usize, nodes: &mut Vec<AdfNode>) {
2530    if start < end {
2531        let plain = &text[start..end];
2532        if !plain.is_empty() {
2533            nodes.push(AdfNode::text(plain));
2534        }
2535    }
2536}
2537
2538/// Adds a mark to a node (creates marks vec if needed).
2539#[cfg(test)]
2540fn add_mark(node: &mut AdfNode, mark: AdfMark) {
2541    if let Some(ref mut marks) = node.marks {
2542        marks.push(mark);
2543    } else {
2544        node.marks = Some(vec![mark]);
2545    }
2546}
2547
2548/// Removes `code` marks from heading inline content, returning the count
2549/// removed.
2550///
2551/// ADF's `heading` content model forbids the `code` mark (a heading styles
2552/// its own text), but JFM/CommonMark parses inline-code spans inside
2553/// `#`-headings. Stripping here keeps the text as plain — a lossy but safe
2554/// direction, since Atlassian renders no inline-code styling on headings —
2555/// rather than building a document the validator only rejects at write time
2556/// (issue #1005). Recurses into `content` to be robust to future inline
2557/// containers; today heading inline content is flat text nodes.
2558fn strip_heading_code_marks(nodes: &mut [AdfNode]) -> usize {
2559    let mut removed = 0;
2560    for node in nodes.iter_mut() {
2561        if let Some(marks) = node.marks.as_mut() {
2562            let before = marks.len();
2563            marks.retain(|m| m.mark_type != "code");
2564            removed += before - marks.len();
2565            if marks.is_empty() {
2566                node.marks = None;
2567            }
2568        }
2569        if let Some(children) = node.content.as_mut() {
2570            removed += strip_heading_code_marks(children);
2571        }
2572    }
2573    removed
2574}
2575
2576/// Marks that markdown emphasis syntax can layer onto an inline-code run but
2577/// that ADF forbids combining with `code` (issue #1391). Deliberately not the
2578/// full complement of marks illegal with `code`: span-syntax marks
2579/// (`underline`, `textColor`, …) are explicit author requests and are left
2580/// for the validator to report.
2581const EMPHASIS_MARKS_SPLIT_FROM_CODE: [&str; 3] = ["strong", "em", "strike"];
2582
2583/// Drops `strong`/`em`/`strike` marks from text runs that also carry `code`,
2584/// returning the count dropped.
2585///
2586/// ADF allows `code` to combine only with `link` and `annotation`, so
2587/// ``**`x`**``-shaped JFM used to fail validation at write time. Inline code
2588/// always parses into its own run, so the sibling runs produced by the same
2589/// emphasis span keep their marks — dropping the leaked marks here is the
2590/// "split into adjacent legal runs" of issue #1391, and rendering is
2591/// visually unchanged (the code font dominates inside a bold/italic
2592/// sentence). Must run at the document level, after block building: heading
2593/// runs are already code-free via [`strip_heading_code_marks`] by then, so
2594/// a bold heading around inline code keeps its bold.
2595fn split_emphasis_code_marks(nodes: &mut [AdfNode]) -> usize {
2596    let mut dropped = 0;
2597    for node in nodes.iter_mut() {
2598        if let Some(marks) = node.marks.as_mut() {
2599            if node.text.is_some() && marks.iter().any(|m| m.mark_type == "code") {
2600                let before = marks.len();
2601                marks.retain(|m| !EMPHASIS_MARKS_SPLIT_FROM_CODE.contains(&m.mark_type.as_str()));
2602                dropped += before - marks.len();
2603            }
2604        }
2605        if let Some(children) = node.content.as_mut() {
2606            dropped += split_emphasis_code_marks(children);
2607        }
2608    }
2609    dropped
2610}
2611
2612/// Prepends a mark before existing marks to preserve outside-in ordering.
2613fn prepend_mark(node: &mut AdfNode, mark: AdfMark) {
2614    if let Some(ref mut marks) = node.marks {
2615        marks.insert(0, mark);
2616    } else {
2617        node.marks = Some(vec![mark]);
2618    }
2619}
2620
2621/// Returns `true` when an underscore delimiter run of `len` bytes starting at
2622/// byte position `delim_pos` in `text` is flanked by alphanumeric characters on
2623/// **both** sides — meaning it sits inside a word and must NOT open or close an
2624/// emphasis span per CommonMark.
2625fn is_intraword_underscore(text: &str, delim_pos: usize, len: usize) -> bool {
2626    let before = text[..delim_pos]
2627        .chars()
2628        .next_back()
2629        .is_some_and(char::is_alphanumeric);
2630    let after = text[delim_pos + len..]
2631        .chars()
2632        .next()
2633        .is_some_and(char::is_alphanumeric);
2634    before && after
2635}
2636
2637/// Finds the first occurrence of `needle` in `haystack`, skipping over
2638/// backslash-escaped characters (e.g. `\*` is not matched when searching
2639/// for `*`).
2640fn find_unescaped(haystack: &str, needle: &str) -> Option<usize> {
2641    let needle_bytes = needle.as_bytes();
2642    let hay_bytes = haystack.as_bytes();
2643    let mut i = 0;
2644    while i < hay_bytes.len() {
2645        if hay_bytes[i] == b'\\' {
2646            i += 2; // skip escaped character
2647            continue;
2648        }
2649        if hay_bytes[i..].starts_with(needle_bytes) {
2650            return Some(i);
2651        }
2652        i += 1;
2653    }
2654    None
2655}
2656
2657/// Finds the first occurrence of a single byte `ch` in `haystack`, skipping
2658/// over backslash-escaped characters.
2659fn find_unescaped_char(haystack: &str, ch: u8) -> Option<usize> {
2660    let hay_bytes = haystack.as_bytes();
2661    let mut i = 0;
2662    while i < hay_bytes.len() {
2663        if hay_bytes[i] == b'\\' {
2664            i += 2;
2665            continue;
2666        }
2667        if hay_bytes[i] == ch {
2668            return Some(i);
2669        }
2670        i += 1;
2671    }
2672    None
2673}
2674
2675/// Tries to parse ***bold+italic***, **bold**, *italic* (or underscore variants) starting at position `i`.
2676/// Returns (end_position, inner_content, is_bold).
2677///
2678/// The triple-delimiter case (`***` / `___`) is checked first so that `***text***` is parsed as
2679/// bold wrapping italic content, rather than having the `**` branch consume the wrong closing
2680/// delimiter and leave stray `*` characters in the text (see issue #401).
2681///
2682/// For underscore delimiters, intraword positions are rejected per CommonMark: a `_` flanked
2683/// by alphanumeric characters on both sides must not open or close emphasis (see issue #438).
2684fn try_parse_emphasis(text: &str, i: usize) -> Option<(usize, &str, bool)> {
2685    let rest = &text[i..];
2686
2687    // Bold+italic: *** or ___
2688    // Parse as bold wrapping italic: the inner content will be recursively parsed and pick up
2689    // the inner * / _ as an em mark.
2690    if rest.starts_with("***") || rest.starts_with("___") {
2691        let is_underscore = rest.starts_with("___");
2692        if is_underscore && is_intraword_underscore(text, i, 3) {
2693            return None;
2694        }
2695        let triple = &rest[..3];
2696        let after = &rest[3..];
2697        if let Some(close) = find_unescaped(after, triple) {
2698            if close > 0 {
2699                let close_pos = i + 3 + close;
2700                if is_underscore && is_intraword_underscore(text, close_pos, 3) {
2701                    return None;
2702                }
2703                // Return a slice that includes the inner italic delimiters from the
2704                // original text: for `***text***`, return `*text*`.  The recursive
2705                // parse_inline call will then pick up the inner `*…*` as an em mark.
2706                let content = &rest[2..=3 + close];
2707                let end = i + 3 + close + 3;
2708                return Some((end, content, true));
2709            }
2710        }
2711    }
2712
2713    // Bold: ** or __
2714    if rest.starts_with("**") || rest.starts_with("__") {
2715        let is_underscore = rest.starts_with("__");
2716        if is_underscore && is_intraword_underscore(text, i, 2) {
2717            return None;
2718        }
2719        let delimiter = &rest[..2];
2720        let after = &rest[2..];
2721        let close = find_unescaped(after, delimiter)?;
2722        if close == 0 {
2723            return None;
2724        }
2725        let close_pos = i + 2 + close;
2726        if is_underscore && is_intraword_underscore(text, close_pos, 2) {
2727            return None;
2728        }
2729        let content = &after[..close];
2730        let end = i + 2 + close + 2;
2731        return Some((end, content, true));
2732    }
2733
2734    // Italic: * or _
2735    if rest.starts_with('*') || rest.starts_with('_') {
2736        let delim_char = rest.as_bytes()[0];
2737        let is_underscore = delim_char == b'_';
2738        if is_underscore && is_intraword_underscore(text, i, 1) {
2739            return None;
2740        }
2741        let after = &rest[1..];
2742        let close = find_unescaped_char(after, delim_char)?;
2743        if close == 0 {
2744            return None;
2745        }
2746        let close_pos = i + 1 + close;
2747        if is_underscore && is_intraword_underscore(text, close_pos, 1) {
2748            return None;
2749        }
2750        let content = &after[..close];
2751        let end = i + 1 + close + 1;
2752        return Some((end, content, false));
2753    }
2754
2755    None
2756}
2757
2758/// Tries to parse ~~strikethrough~~ starting at position `i`.
2759fn try_parse_strikethrough(text: &str, i: usize) -> Option<(usize, &str)> {
2760    let rest = &text[i..];
2761    if !rest.starts_with("~~") {
2762        return None;
2763    }
2764    let after = &rest[2..];
2765    let close = after.find("~~")?;
2766    if close == 0 {
2767        return None;
2768    }
2769    let content = &after[..close];
2770    Some((i + 2 + close + 2, content))
2771}
2772
2773/// Tries to parse a CommonMark inline code span starting at position `i`.
2774///
2775/// Supports multi-backtick delimiters: the opening run of N backticks must
2776/// be matched by a closing run of exactly N backticks.  If both ends of the
2777/// enclosed content begin and end with a space and the content is not all
2778/// spaces, one space is stripped from each side per the CommonMark spec.
2779fn try_parse_inline_code(text: &str, i: usize) -> Option<(usize, &str)> {
2780    let rest = &text[i..];
2781    let bytes = rest.as_bytes();
2782    if bytes.is_empty() || bytes[0] != b'`' {
2783        return None;
2784    }
2785    let mut opening = 0usize;
2786    while opening < bytes.len() && bytes[opening] == b'`' {
2787        opening += 1;
2788    }
2789
2790    let mut j = opening;
2791    while j < bytes.len() {
2792        if bytes[j] == b'`' {
2793            let run_start = j;
2794            while j < bytes.len() && bytes[j] == b'`' {
2795                j += 1;
2796            }
2797            if j - run_start == opening {
2798                let content = &rest[opening..run_start];
2799                let content = strip_code_span_padding(content);
2800                return Some((i + j, content));
2801            }
2802        } else {
2803            j += 1;
2804        }
2805    }
2806    None
2807}
2808
2809/// Implements the CommonMark code-span space-stripping rule: if the content
2810/// both begins and ends with a space character and is not composed entirely
2811/// of spaces, one space character is removed from each side.
2812fn strip_code_span_padding(content: &str) -> &str {
2813    let bytes = content.as_bytes();
2814    if bytes.len() >= 2
2815        && bytes[0] == b' '
2816        && bytes[bytes.len() - 1] == b' '
2817        && content.bytes().any(|b| b != b' ')
2818    {
2819        &content[1..content.len() - 1]
2820    } else {
2821        content
2822    }
2823}
2824
2825/// Tries to parse a bracketed span `[text]{attrs}` starting at position `i`.
2826/// Used for `[text]{underline}` and similar constructs.  `depth` is the
2827/// caller's inline recursion depth (see [`parse_inline_impl`]).
2828fn try_parse_bracketed_span(text: &str, i: usize, depth: usize) -> Option<(usize, Vec<AdfNode>)> {
2829    let rest = &text[i..];
2830    if !rest.starts_with('[') {
2831        return None;
2832    }
2833
2834    // Find the matching ] by counting bracket depth (supports nested brackets
2835    // such as [[text](url)]{underline} for underline-before-link ordering).
2836    // Backslash-escaped brackets are skipped (issue #493).
2837    let mut bracket_depth: usize = 0;
2838    let mut bracket_close = None;
2839    let bs_bytes = rest.as_bytes();
2840    for (j, ch) in rest.char_indices() {
2841        match ch {
2842            '\\' if j + 1 < bs_bytes.len()
2843                && (bs_bytes[j + 1] == b'[' || bs_bytes[j + 1] == b']') => {}
2844            '[' if j == 0 || bs_bytes[j - 1] != b'\\' => bracket_depth += 1,
2845            ']' if j == 0 || bs_bytes[j - 1] != b'\\' => {
2846                bracket_depth -= 1;
2847                if bracket_depth == 0 {
2848                    bracket_close = Some(j);
2849                    break;
2850                }
2851            }
2852            _ => {}
2853        }
2854    }
2855    let bracket_close = bracket_close?;
2856    // Make sure this isn't a link: next char after ] must be { not (
2857    let after_bracket = &rest[bracket_close + 1..];
2858    if !after_bracket.starts_with('{') {
2859        return None;
2860    }
2861
2862    let span_text = &rest[1..bracket_close];
2863    let attrs_start = i + bracket_close + 1;
2864    let (attrs_end, attrs) = parse_attrs(text, attrs_start)?;
2865
2866    let mut marks = Vec::new();
2867    if attrs.has_flag("underline") {
2868        marks.push(AdfMark::underline());
2869    }
2870    let ann_ids = attrs.get_all("annotation-id");
2871    let ann_types = attrs.get_all("annotation-type");
2872    for (idx, ann_id) in ann_ids.iter().enumerate() {
2873        let ann_type = ann_types.get(idx).copied().unwrap_or("inlineComment");
2874        marks.push(AdfMark::annotation(ann_id, ann_type));
2875    }
2876
2877    if marks.is_empty() {
2878        return None; // no recognized marks
2879    }
2880
2881    let inner = parse_inline_impl(span_text, false, depth + 1);
2882    let result: Vec<AdfNode> = inner
2883        .into_iter()
2884        .map(|mut node| {
2885            // Prepend bracket marks before inner marks to preserve original
2886            // ADF mark ordering (e.g., [underline, strong] not [strong, underline]).
2887            let mut combined = marks.clone();
2888            if let Some(ref existing) = node.marks {
2889                combined.extend(existing.iter().cloned());
2890            }
2891            node.marks = Some(combined);
2892            node
2893        })
2894        .collect();
2895
2896    Some((attrs_end, result))
2897}
2898
2899/// Dispatches an inline directive to the appropriate ADF node constructor.
2900/// Returns `(AdfNode, end_pos)` on success.  `depth` is the caller's inline
2901/// recursion depth (see [`parse_inline_impl`]).
2902fn try_dispatch_inline_directive(text: &str, pos: usize, depth: usize) -> Option<(AdfNode, usize)> {
2903    let d = try_parse_inline_directive(text, pos)?;
2904    let content = d.content.as_deref().unwrap_or("");
2905
2906    let node = match d.name.as_str() {
2907        "card" => {
2908            // Prefer the `url` attribute when present; fall back to the
2909            // bracketed content.  The attribute form is used when the URL
2910            // contains characters (such as `]` or `\n`) that would otherwise
2911            // break `:card[URL]` parsing.
2912            let url = d
2913                .attrs
2914                .as_ref()
2915                .and_then(|a| a.get("url"))
2916                .unwrap_or(content);
2917            let mut node = AdfNode::inline_card(url);
2918            pass_through_local_id(&d.attrs, &mut node);
2919            node
2920        }
2921        "status" => {
2922            let color = d
2923                .attrs
2924                .as_ref()
2925                .and_then(|a| a.get("color"))
2926                .unwrap_or("neutral");
2927            let mut node = AdfNode::status(content, color);
2928            // Pass through style and localId if present
2929            if let Some(ref attrs) = d.attrs {
2930                if let Some(ref mut node_attrs) = node.attrs {
2931                    if let Some(style) = attrs.get("style") {
2932                        node_attrs["style"] = serde_json::Value::String(style.to_string());
2933                    }
2934                    if let Some(local_id) = attrs.get("localId") {
2935                        node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2936                    }
2937                }
2938            }
2939            node
2940        }
2941        "date" => {
2942            let timestamp = d
2943                .attrs
2944                .as_ref()
2945                .and_then(|a| a.get("timestamp"))
2946                .map_or_else(|| iso_date_to_epoch_ms(content), ToString::to_string);
2947            let mut node = AdfNode::date(&timestamp);
2948            pass_through_local_id(&d.attrs, &mut node);
2949            node
2950        }
2951        "mention" => {
2952            let id = d.attrs.as_ref().and_then(|a| a.get("id")).unwrap_or("");
2953            let mut node = AdfNode::mention(id, content);
2954            // Pass through optional userType and accessLevel
2955            if let Some(ref attrs) = d.attrs {
2956                if let (Some(ref mut node_attrs), true) = (
2957                    &mut node.attrs,
2958                    attrs.get("userType").is_some() || attrs.get("accessLevel").is_some(),
2959                ) {
2960                    if let Some(ut) = attrs.get("userType") {
2961                        node_attrs["userType"] = serde_json::Value::String(ut.to_string());
2962                    }
2963                    if let Some(al) = attrs.get("accessLevel") {
2964                        node_attrs["accessLevel"] = serde_json::Value::String(al.to_string());
2965                    }
2966                }
2967            }
2968            pass_through_local_id(&d.attrs, &mut node);
2969            node
2970        }
2971        "span" => {
2972            let mut marks = Vec::new();
2973            if let Some(ref attrs) = d.attrs {
2974                if let Some(color) = attrs.get("color") {
2975                    marks.push(AdfMark::text_color(color));
2976                }
2977                if let Some(bg) = attrs.get("bg") {
2978                    marks.push(AdfMark::background_color(bg));
2979                }
2980                if attrs.has_flag("sub") {
2981                    marks.push(AdfMark::subsup("sub"));
2982                }
2983                if attrs.has_flag("sup") {
2984                    marks.push(AdfMark::subsup("sup"));
2985                }
2986            }
2987            if marks.is_empty() {
2988                AdfNode::text(content)
2989            } else {
2990                // Parse inner content to handle nested syntax (e.g., links).
2991                // Prepend span marks before inner marks to preserve ordering.
2992                let inner = parse_inline_impl(content, false, depth + 1);
2993                let mut nodes: Vec<AdfNode> = inner
2994                    .into_iter()
2995                    .map(|mut node| {
2996                        let mut combined = marks.clone();
2997                        if let Some(ref existing) = node.marks {
2998                            combined.extend(existing.iter().cloned());
2999                        }
3000                        node.marks = Some(combined);
3001                        node
3002                    })
3003                    .collect();
3004                // Return the first marked node (typical case is a single node).
3005                nodes.remove(0)
3006            }
3007        }
3008        "placeholder" => AdfNode::placeholder(content),
3009        "media-inline" => {
3010            let mut json_attrs = serde_json::Map::new();
3011            if let Some(ref attrs) = d.attrs {
3012                for key in &["type", "id", "collection", "url", "alt", "width", "height"] {
3013                    if let Some(val) = attrs.get(key) {
3014                        if *key == "width" || *key == "height" {
3015                            if let Ok(n) = val.parse::<u64>() {
3016                                json_attrs.insert(
3017                                    (*key).to_string(),
3018                                    serde_json::Value::Number(n.into()),
3019                                );
3020                                continue;
3021                            }
3022                        }
3023                        json_attrs.insert(
3024                            (*key).to_string(),
3025                            serde_json::Value::String(val.to_string()),
3026                        );
3027                    }
3028                }
3029                if let Some(local_id) = attrs.get("localId") {
3030                    json_attrs.insert(
3031                        "localId".to_string(),
3032                        serde_json::Value::String(local_id.to_string()),
3033                    );
3034                }
3035            }
3036            AdfNode::media_inline(serde_json::Value::Object(json_attrs))
3037        }
3038        "extension" => {
3039            let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
3040            let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
3041            AdfNode::inline_extension(ext_type, ext_key, Some(content))
3042        }
3043        "adf-unsupported" => {
3044            // Round-trips an unsupported inline node emitted by
3045            // `render_non_text_inline_body`: the full node JSON rides in the
3046            // `json` attribute (the inline mirror of the block-level
3047            // ```adf-unsupported``` fence reader, ADR-0029 / issue #1117).
3048            // Deserialize it straight back into the original node; a malformed
3049            // or missing payload falls through to plain text, exactly like the
3050            // block reader degrading to a normal code block.
3051            let json = d.attrs.as_ref().and_then(|a| a.get("json"))?;
3052            serde_json::from_str::<AdfNode>(json).ok()?
3053        }
3054        _ => return None, // unknown directive — fall through to plain text
3055    };
3056
3057    Some((node, d.end_pos))
3058}
3059
3060/// Tries to parse a bare URL (`http://` or `https://`) starting at position `i`.
3061/// Scans forward until whitespace, `)`, `]`, or end of string.
3062fn try_parse_bare_url(text: &str, i: usize) -> Option<(usize, &str)> {
3063    let rest = &text[i..];
3064    if !rest.starts_with("http://") && !rest.starts_with("https://") {
3065        return None;
3066    }
3067    // URL extends to the next whitespace or delimiter
3068    let end = rest
3069        .find(|c: char| c.is_whitespace() || c == ')' || c == ']' || c == '>')
3070        .unwrap_or(rest.len());
3071    // Strip trailing punctuation that's likely not part of the URL
3072    let url = rest[..end].trim_end_matches(['.', ',', ';', '!', '?']);
3073    if url.len() <= "https://".len() {
3074        return None; // too short to be a real URL
3075    }
3076    Some((i + url.len(), url))
3077}
3078
3079/// Tries to parse an emoji shortcode `:name:` starting at position `i`.
3080/// The name must match `[a-zA-Z0-9_+-]+`.
3081fn try_parse_emoji_shortcode(text: &str, i: usize) -> Option<(usize, &str)> {
3082    let rest = &text[i..];
3083    if !rest.starts_with(':') {
3084        return None;
3085    }
3086    let after = &rest[1..];
3087    let name_end =
3088        after.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')?;
3089    if name_end == 0 {
3090        return None;
3091    }
3092    if after.as_bytes().get(name_end) != Some(&b':') {
3093        return None;
3094    }
3095    let name = &after[..name_end];
3096    Some((i + 1 + name_end + 1, name))
3097}
3098
3099/// Parses an emoji shortcode that has already been matched, then checks for
3100/// trailing `{id="..." text="..."}` attributes to preserve round-trip fidelity.
3101fn parse_emoji_with_attrs(text: &str, shortcode_end: usize, short_name: &str) -> (usize, AdfNode) {
3102    // Issue #576: An emoji with a combined shortName like `:slightly_smiling_face::bow:`
3103    // is emitted as `:slightly_smiling_face::bow:{shortName="..." ...}`. Extend the
3104    // match through any adjacent `:name:` shortcodes so that a trailing directive
3105    // attaches to the whole chain as a single emoji, using the directive's shortName.
3106    let mut chain_end = shortcode_end;
3107    while let Some((next_end, _)) = try_parse_emoji_shortcode(text, chain_end) {
3108        chain_end = next_end;
3109    }
3110    if chain_end > shortcode_end {
3111        if let Some((attr_end, attrs)) = parse_attrs(text, chain_end) {
3112            return (attr_end, build_emoji_node(&attrs, short_name));
3113        }
3114    }
3115
3116    if let Some((attr_end, attrs)) = parse_attrs(text, shortcode_end) {
3117        (attr_end, build_emoji_node(&attrs, short_name))
3118    } else {
3119        (shortcode_end, AdfNode::emoji(&format!(":{short_name}:")))
3120    }
3121}
3122
3123/// Builds an emoji `AdfNode` from parsed directive attrs, falling back to
3124/// the matched shortcode name when `shortName` is absent from the directive.
3125fn build_emoji_node(attrs: &Attrs, short_name: &str) -> AdfNode {
3126    let resolved_name = attrs
3127        .get("shortName")
3128        .map_or_else(|| format!(":{short_name}:"), str::to_string);
3129    let mut emoji_attrs = serde_json::json!({ "shortName": resolved_name });
3130    if let Some(id) = attrs.get("id") {
3131        emoji_attrs["id"] = serde_json::Value::String(id.to_string());
3132    }
3133    if let Some(t) = attrs.get("text") {
3134        emoji_attrs["text"] = serde_json::Value::String(t.to_string());
3135    }
3136    if let Some(lid) = attrs.get("localId") {
3137        emoji_attrs["localId"] = serde_json::Value::String(lid.to_string());
3138    }
3139    AdfNode {
3140        node_type: "emoji".to_string(),
3141        attrs: Some(emoji_attrs),
3142        content: None,
3143        text: None,
3144        marks: None,
3145        local_id: None,
3146        parameters: None,
3147    }
3148}
3149
3150/// Finds the closing `)` that matches the opening `(` at position `open`,
3151/// counting nested parentheses so that URLs containing `(` and `)` are
3152/// handled correctly.  Returns the index of the matching `)` relative to
3153/// the start of `s`, or `None` if no match is found.
3154fn find_closing_paren(s: &str, open: usize) -> Option<usize> {
3155    let mut depth: usize = 0;
3156    for (j, ch) in s[open..].char_indices() {
3157        match ch {
3158            '(' => depth += 1,
3159            ')' => {
3160                depth -= 1;
3161                if depth == 0 {
3162                    return Some(open + j);
3163                }
3164            }
3165            _ => {}
3166        }
3167    }
3168    None
3169}
3170
3171/// Tries to parse [text](url) starting at position `i`.
3172///
3173/// Uses bracket depth counting to find the matching `]`, so that `[` characters
3174/// inside the text (e.g. `[Task] some text ([Link](url))`) don't cause a false
3175/// match on an earlier `](`.
3176fn try_parse_link(text: &str, i: usize) -> Option<(usize, &str, &str)> {
3177    let rest = &text[i..];
3178    if !rest.starts_with('[') {
3179        return None;
3180    }
3181
3182    // Find the matching ] by counting bracket depth, skipping escaped brackets
3183    let mut depth: usize = 0;
3184    let mut text_end = None;
3185    let bytes = rest.as_bytes();
3186    for (j, ch) in rest.char_indices() {
3187        match ch {
3188            '\\' if j + 1 < bytes.len() && (bytes[j + 1] == b'[' || bytes[j + 1] == b']') => {
3189                // Skip backslash-escaped bracket (issue #493)
3190            }
3191            '[' if j == 0 || bytes[j - 1] != b'\\' => depth += 1,
3192            ']' if j == 0 || bytes[j - 1] != b'\\' => {
3193                depth -= 1;
3194                if depth == 0 {
3195                    text_end = Some(j);
3196                    break;
3197                }
3198            }
3199            _ => {}
3200        }
3201    }
3202
3203    let text_end = text_end?;
3204    let link_text = &rest[1..text_end];
3205    // Must be immediately followed by (
3206    let after_bracket = &rest[text_end + 1..];
3207    if !after_bracket.starts_with('(') {
3208        return None;
3209    }
3210    let url_start = text_end + 1; // index of the '('
3211    let url_end = find_closing_paren(rest, url_start)?;
3212    let href = &rest[url_start + 1..url_end];
3213
3214    Some((i + url_end + 1, link_text, href))
3215}
3216
3217// ── ADF → Markdown ──────────────────────────────────────────────────
3218
3219/// Options for ADF-to-markdown rendering.
3220#[derive(Debug, Clone, Default)]
3221pub struct RenderOptions {
3222    /// When true, omit `localId` attributes from directive output.
3223    pub strip_local_ids: bool,
3224}
3225
3226/// Converts an ADF document to a markdown string.
3227pub fn adf_to_markdown(doc: &AdfDocument) -> Result<String> {
3228    adf_to_markdown_with_options(doc, &RenderOptions::default())
3229}
3230
3231/// Converts an ADF document to a markdown string with options.
3232pub fn adf_to_markdown_with_options(doc: &AdfDocument, opts: &RenderOptions) -> Result<String> {
3233    let mut output = String::new();
3234    render_block_children(&doc.content, &mut output, opts);
3235    Ok(output)
3236}
3237
3238/// Flattens an ADF document to plain text by concatenating all `text` nodes.
3239///
3240/// Block boundaries (paragraph, heading, list item, table cell, etc.) are
3241/// separated by a single space so a multi-paragraph anchor selection matches
3242/// when the user copies the text without trailing/leading whitespace.
3243///
3244/// Used by [`crate::atlassian::confluence_api::ConfluenceApi::resolve_anchor`]
3245/// to count occurrences of an inline-comment anchor on the live page body.
3246#[must_use]
3247pub fn adf_to_plain_text(doc: &AdfDocument) -> String {
3248    let mut out = String::new();
3249    for node in &doc.content {
3250        collect_plain_text(node, &mut out);
3251        if !out.is_empty() && !out.ends_with(' ') {
3252            out.push(' ');
3253        }
3254    }
3255    out.truncate(out.trim_end().len());
3256    out
3257}
3258
3259fn collect_plain_text(node: &AdfNode, out: &mut String) {
3260    if let Some(text) = &node.text {
3261        out.push_str(text);
3262    }
3263    if let Some(children) = &node.content {
3264        for child in children {
3265            collect_plain_text(child, out);
3266        }
3267    }
3268}
3269
3270/// Pushes a `localId=<value>` entry to an attribute parts vec,
3271/// unless `opts.strip_local_ids` is set or the value is a placeholder.
3272/// Copies `localId` from parsed directive attrs to an ADF node's attrs if present.
3273fn pass_through_local_id(dir_attrs: &Option<crate::atlassian::attrs::Attrs>, node: &mut AdfNode) {
3274    if let Some(ref attrs) = dir_attrs {
3275        if let Some(local_id) = attrs.get("localId") {
3276            if let Some(ref mut node_attrs) = node.attrs {
3277                node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
3278            } else {
3279                node.attrs = Some(serde_json::json!({"localId": local_id}));
3280            }
3281        }
3282    }
3283}
3284
3285/// Copies `localId` from directive attrs to the node's top-level `local_id` field,
3286/// and parses `params` JSON from directive attrs into the node's `parameters` field.
3287fn pass_through_expand_params(
3288    dir_attrs: &Option<crate::atlassian::attrs::Attrs>,
3289    node: &mut AdfNode,
3290) {
3291    if let Some(ref attrs) = dir_attrs {
3292        if let Some(local_id) = attrs.get("localId") {
3293            node.local_id = Some(local_id.to_string());
3294        }
3295        if let Some(params_str) = attrs.get("params") {
3296            if let Ok(params) = serde_json::from_str(params_str) {
3297                node.parameters = Some(params);
3298            }
3299        }
3300    }
3301}
3302
3303// listItem localId is emitted as trailing inline attrs on the item line
3304// (e.g., `- item text {localId=...}`) and parsed back by extracting
3305// trailing attrs from the list item text. This avoids the block-attrs
3306// promotion issue where {localId=...} on a separate line would be
3307// applied to the parent list node.
3308
3309/// Extracts trailing `{localId=... paraLocalId=...}` from list item text.
3310/// Returns the text without the trailing attrs, the listItem localId, and
3311/// the paragraph localId if found.
3312fn extract_trailing_local_id(text: &str) -> (&str, Option<String>, Option<String>) {
3313    let trimmed = text.trim_end();
3314    if !trimmed.ends_with('}') {
3315        return (text, None, None);
3316    }
3317    // Find the opening brace.  Only match a standalone `{…}` block that is
3318    // preceded by whitespace (or is at the start of the string).  A `{` that
3319    // immediately follows `]` is part of an inline directive (e.g.
3320    // `:mention[text]{id=… localId=…}`) and must NOT be consumed here.
3321    if let Some(brace_pos) = trimmed.rfind('{') {
3322        if brace_pos > 0 && !trimmed.as_bytes()[brace_pos - 1].is_ascii_whitespace() {
3323            return (text, None, None);
3324        }
3325        let attr_str = &trimmed[brace_pos..];
3326        if let Some((_, attrs)) = parse_attrs(attr_str, 0) {
3327            let local_id = attrs.get("localId").map(str::to_string);
3328            let para_local_id = attrs.get("paraLocalId").map(str::to_string);
3329            if local_id.is_some() || para_local_id.is_some() {
3330                let before = trimmed[..brace_pos]
3331                    .strip_suffix(' ')
3332                    .unwrap_or(&trimmed[..brace_pos]);
3333                return (before, local_id, para_local_id);
3334            }
3335        }
3336    }
3337    (text, None, None)
3338}
3339
3340/// Creates a `listItem` node, optionally with a `localId` attribute
3341/// and a `paraLocalId` on its first paragraph child.
3342/// Parses the first line of a list item and any indented sub-content into
3343/// an `AdfNode::list_item`.  When the first line is a code fence opener
3344/// (`` ``` ``), the line is folded into the sub-content so the block-level
3345/// code fence parser handles it correctly (issue #511).
3346///
3347/// `depth` is the nesting depth for parsers spawned on the sub-content —
3348/// callers pass their own depth plus one.
3349fn parse_list_item_first_line(
3350    item_text: &str,
3351    sub_lines: Vec<String>,
3352    local_id: Option<String>,
3353    para_local_id: Option<String>,
3354    depth: usize,
3355) -> Result<AdfNode> {
3356    if item_text.starts_with("```") {
3357        // Treat the code fence opener + indented body as block content.
3358        let mut all_lines = vec![item_text.to_string()];
3359        all_lines.extend(sub_lines);
3360        let combined = all_lines.join("\n");
3361        let nested = MarkdownParser::with_depth(&combined, depth).parse_blocks()?;
3362        Ok(list_item_with_local_id(nested, local_id, para_local_id))
3363    } else if let Some(media) = try_parse_media_single_from_line(item_text) {
3364        // Block-level image (issue #430).
3365        if sub_lines.is_empty() {
3366            Ok(list_item_with_local_id(
3367                vec![media],
3368                local_id,
3369                para_local_id,
3370            ))
3371        } else {
3372            let sub_text = sub_lines.join("\n");
3373            let mut nested = MarkdownParser::with_depth(&sub_text, depth).parse_blocks()?;
3374            let mut content = vec![media];
3375            content.append(&mut nested);
3376            Ok(list_item_with_local_id(content, local_id, para_local_id))
3377        }
3378    } else {
3379        let first_node = AdfNode::paragraph(parse_inline(item_text));
3380        if sub_lines.is_empty() {
3381            Ok(list_item_with_local_id(
3382                vec![first_node],
3383                local_id,
3384                para_local_id,
3385            ))
3386        } else {
3387            let sub_text = sub_lines.join("\n");
3388            let mut nested = MarkdownParser::with_depth(&sub_text, depth).parse_blocks()?;
3389            let mut content = vec![first_node];
3390            content.append(&mut nested);
3391            Ok(list_item_with_local_id(content, local_id, para_local_id))
3392        }
3393    }
3394}
3395
3396fn list_item_with_local_id(
3397    mut content: Vec<AdfNode>,
3398    local_id: Option<String>,
3399    para_local_id: Option<String>,
3400) -> AdfNode {
3401    if let Some(id) = &para_local_id {
3402        if let Some(first) = content.first_mut() {
3403            if first.node_type == "paragraph" {
3404                let node_attrs = first.attrs.get_or_insert_with(|| serde_json::json!({}));
3405                node_attrs["localId"] = serde_json::Value::String(id.clone());
3406            }
3407        }
3408    }
3409    let mut item = AdfNode::list_item(content);
3410    if let Some(id) = local_id {
3411        item.attrs = Some(serde_json::json!({"localId": id}));
3412    }
3413    item
3414}
3415
3416fn maybe_push_local_id(attrs: &serde_json::Value, parts: &mut Vec<String>, opts: &RenderOptions) {
3417    if opts.strip_local_ids {
3418        return;
3419    }
3420    if let Some(local_id) = attrs.get("localId").and_then(serde_json::Value::as_str) {
3421        if !local_id.is_empty() && local_id != "00000000-0000-0000-0000-000000000000" {
3422            parts.push(format_kv("localId", local_id));
3423        }
3424    }
3425}
3426
3427/// Returns `true` when `node` must be rendered with a trailing
3428/// `{list=separate}` marker because the very next sibling is a list the
3429/// parser's list loop would otherwise swallow as more items of `node`
3430/// (issue #1729).
3431///
3432/// The marker goes on the list that *ends*, not the one that starts, because
3433/// the mechanism is that a trailing attrs line terminates the list loop.
3434///
3435/// It mirrors the parser's fusing rule exactly, so nothing is emitted that
3436/// would make no difference:
3437///
3438/// * a bullet or task list followed by a bullet or task list — one
3439///   `parse_bullet_list` call runs them together (and would flip a
3440///   `bulletList` to a `taskList`, producing invalid ADF);
3441/// * an ordered list followed by an ordered list whose start continues the
3442///   numbering or restarts at `1.`  A restart at any other number is already
3443///   read as a second list, so it needs no marker.
3444///
3445/// A list that renders no items at all needs no marker either — there is
3446/// nothing to fuse, and the bare attrs line would only be re-read as a
3447/// paragraph.
3448fn needs_list_separator(node: &AdfNode, next: Option<&AdfNode>) -> bool {
3449    if !is_list_node_type(&node.node_type) || list_item_count(node) == 0 {
3450        return false;
3451    }
3452    let Some(next) = next else {
3453        return false;
3454    };
3455    if list_item_count(next) == 0 {
3456        return false;
3457    }
3458    match (node.node_type.as_str(), next.node_type.as_str()) {
3459        ("bulletList" | "taskList", "bulletList" | "taskList") => true,
3460        ("orderedList", "orderedList") => {
3461            let start = ordered_list_start(node);
3462            let len = u32::try_from(list_item_count(node)).unwrap_or(u32::MAX);
3463            let next_start = ordered_list_start(next);
3464            next_start == start.saturating_add(len) || next_start == 1
3465        }
3466        _ => false,
3467    }
3468}
3469
3470/// Renders a sequence of block nodes with blank-line separators between them.
3471fn render_block_children(children: &[AdfNode], output: &mut String, opts: &RenderOptions) {
3472    for (i, child) in children.iter().enumerate() {
3473        if i > 0 {
3474            output.push('\n');
3475        }
3476        let separate = needs_list_separator(child, children.get(i + 1));
3477        render_block_node_separated(child, output, opts, separate);
3478    }
3479}
3480
3481/// Formats a float as an integer string when it has no fractional part,
3482/// otherwise as a regular float string.
3483fn fmt_f64_attr(v: f64) -> String {
3484    if v.fract() == 0.0 {
3485        format!("{}", v as i64)
3486    } else {
3487        v.to_string()
3488    }
3489}
3490
3491/// Parses a numeric attribute value string into a JSON number value that
3492/// preserves the original integer/float distinction. Returns `None` if the
3493/// string cannot be parsed as a number.
3494///
3495/// Strings without a `.` or exponent are parsed as integers (so `"800"` stays
3496/// `800`, not `800.0`); strings with a decimal point are parsed as floats.
3497fn parse_numeric_attr(s: &str) -> Option<serde_json::Value> {
3498    if s.contains('.') || s.contains('e') || s.contains('E') {
3499        s.parse::<f64>().ok().map(serde_json::Value::from)
3500    } else {
3501        s.parse::<i64>().ok().map(serde_json::Value::from)
3502    }
3503}
3504
3505/// Formats a JSON numeric value as a markdown attribute string, preserving
3506/// whether the source was stored as an integer or a float.
3507///
3508/// Returns `None` if `v` is not a number. Integer values emit as `800`;
3509/// floating-point values emit as `800.0` (or `66.66` for non-integer floats),
3510/// so that a subsequent [`parse_numeric_attr`] round-trip recovers the same
3511/// JSON type.
3512fn fmt_numeric_attr(v: &serde_json::Value) -> Option<String> {
3513    if let Some(n) = v.as_i64() {
3514        return Some(n.to_string());
3515    }
3516    if let Some(n) = v.as_u64() {
3517        return Some(n.to_string());
3518    }
3519    if let Some(n) = v.as_f64() {
3520        if n.fract() == 0.0 && n.is_finite() {
3521            return Some(format!("{n:.1}"));
3522        }
3523        return Some(n.to_string());
3524    }
3525    None
3526}
3527
3528/// Returns `true` when a paragraph's rendered inline content must be emitted
3529/// as `::paragraph[…]{attrs}` rather than as a plain line.
3530///
3531/// Two cases need it:
3532///
3533/// * whitespace-only content (e.g. a lone NBSP), which a plain line loses;
3534/// * content whose first character is a space or tab.  A plain line would
3535///   drop the indent — and after a list it is worse than lossy, because the
3536///   parser reads an indented line as the preceding item's own sub-content
3537///   and pulls the paragraph into the list (`"  # nope"` even becomes a
3538///   heading inside the item).
3539///
3540/// The directive is a single-line form, so a paragraph containing hardBreaks
3541/// cannot use it and keeps the plain rendering.
3542fn paragraph_needs_directive_form(buf: &str) -> bool {
3543    if buf.trim().is_empty() {
3544        return !buf.is_empty();
3545    }
3546    buf.starts_with([' ', '\t']) && !buf.contains('\n')
3547}
3548
3549/// Renders a block-level ADF node to markdown.
3550fn render_block_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3551    render_block_node_separated(node, output, opts, false);
3552}
3553
3554/// Renders a block-level ADF node to markdown, optionally tagging it with the
3555/// `{list=separate}` marker.
3556///
3557/// `separate_from_next` is computed by the sibling-rendering loops from
3558/// [`needs_list_separator`].  The marker is emitted through the same
3559/// trailing-attrs line as `{order=1}` / `{localId=…}`, so they compose onto
3560/// one line (`{order=1 list=separate}`) and so that a nested list's marker is
3561/// indented with the list it belongs to.
3562fn render_block_node_separated(
3563    node: &AdfNode,
3564    output: &mut String,
3565    opts: &RenderOptions,
3566    separate_from_next: bool,
3567) {
3568    match node.node_type.as_str() {
3569        "paragraph" => {
3570            let is_empty = node.content.as_ref().is_none_or(Vec::is_empty);
3571            // Build directive attr string for localId when using ::paragraph form
3572            let dir_attrs = {
3573                let mut parts = Vec::new();
3574                if let Some(ref attrs) = node.attrs {
3575                    maybe_push_local_id(attrs, &mut parts, opts);
3576                }
3577                if parts.is_empty() {
3578                    String::new()
3579                } else {
3580                    format!("{{{}}}", parts.join(" "))
3581                }
3582            };
3583            if is_empty {
3584                output.push_str(&format!("::paragraph{dir_attrs}\n"));
3585            } else {
3586                // Render to a buffer first to check if content is whitespace-only
3587                let mut buf = String::new();
3588                render_inline_content(node, &mut buf, opts);
3589                if paragraph_needs_directive_form(&buf) {
3590                    output.push_str(&format!("::paragraph[{buf}]{dir_attrs}\n"));
3591                } else {
3592                    // Escape a leading list-marker pattern so paragraph
3593                    // text is not re-parsed as a list item (issue #402).
3594                    // Indent continuation lines produced by hardBreaks so
3595                    // they are not re-parsed as list items (issue #455).
3596                    let mut is_first_line = true;
3597                    for line in buf.split('\n') {
3598                        if is_first_line {
3599                            if is_list_start(line) {
3600                                output.push_str(&escape_list_marker(line));
3601                            } else {
3602                                output.push_str(line);
3603                            }
3604                            is_first_line = false;
3605                        } else {
3606                            output.push('\n');
3607                            if !line.is_empty() {
3608                                output.push_str("  ");
3609                            }
3610                            output.push_str(line);
3611                        }
3612                    }
3613                    output.push('\n');
3614                }
3615            }
3616        }
3617        "heading" => {
3618            let level = node
3619                .attrs
3620                .as_ref()
3621                .and_then(|a| a.get("level"))
3622                .and_then(serde_json::Value::as_u64)
3623                .unwrap_or(1);
3624            for _ in 0..level {
3625                output.push('#');
3626            }
3627            output.push(' ');
3628            let mut buf = String::new();
3629            render_inline_content(node, &mut buf, opts);
3630            // Indent continuation lines produced by hardBreaks so they stay
3631            // within the heading when re-parsed (issue #433).
3632            let mut is_first_line = true;
3633            for line in buf.split('\n') {
3634                if is_first_line {
3635                    output.push_str(line);
3636                    is_first_line = false;
3637                } else {
3638                    output.push('\n');
3639                    if !line.is_empty() {
3640                        output.push_str("  ");
3641                    }
3642                    output.push_str(line);
3643                }
3644            }
3645            output.push('\n');
3646        }
3647        "codeBlock" => {
3648            let language_value = node.attrs.as_ref().and_then(|a| a.get("language"));
3649            let language = language_value
3650                .and_then(serde_json::Value::as_str)
3651                .unwrap_or("");
3652            output.push_str("```");
3653            if language.is_empty() && language_value.is_some() {
3654                // Explicit empty language attr: encode as ```"" to distinguish
3655                // from a codeBlock with no attrs at all (plain ```).
3656                output.push_str("\"\"");
3657            } else {
3658                output.push_str(language);
3659            }
3660            output.push('\n');
3661            if let Some(ref content) = node.content {
3662                for child in content {
3663                    if let Some(ref text) = child.text {
3664                        output.push_str(text);
3665                    }
3666                }
3667            }
3668            output.push_str("\n```\n");
3669        }
3670        "blockquote" => {
3671            if let Some(ref content) = node.content {
3672                for (i, child) in content.iter().enumerate() {
3673                    // Separate consecutive paragraph siblings with a blank
3674                    // blockquote-prefixed line so they re-parse as distinct
3675                    // paragraphs rather than being merged into one (issue #531).
3676                    if i > 0
3677                        && child.node_type == "paragraph"
3678                        && content[i - 1].node_type == "paragraph"
3679                    {
3680                        output.push_str(">\n");
3681                    }
3682                    let mut inner = String::new();
3683                    let separate = needs_list_separator(child, content.get(i + 1));
3684                    render_block_node_separated(child, &mut inner, opts, separate);
3685                    for line in inner.lines() {
3686                        output.push_str("> ");
3687                        output.push_str(line);
3688                        output.push('\n');
3689                    }
3690                }
3691            }
3692        }
3693        "bulletList" => {
3694            if let Some(ref items) = node.content {
3695                for item in items {
3696                    output.push_str("- ");
3697                    let content_start = output.len();
3698                    render_list_item_content(item, output, opts);
3699                    // If the rendered content begins with a sequence the
3700                    // bullet-list parser would interpret as a task checkbox
3701                    // marker, escape the leading `[` so the round-trip
3702                    // preserves this as a `bulletList` rather than promoting
3703                    // it to a `taskList` (issue #548).
3704                    if starts_with_task_marker(&output[content_start..]) {
3705                        output.insert(content_start, '\\');
3706                    }
3707                }
3708            }
3709        }
3710        "orderedList" => {
3711            let start = u64::from(ordered_list_start(node));
3712            if let Some(ref items) = node.content {
3713                for (i, item) in items.iter().enumerate() {
3714                    let num = start + i as u64;
3715                    output.push_str(&format!("{num}. "));
3716                    render_list_item_content(item, output, opts);
3717                }
3718            }
3719        }
3720        "taskList" => {
3721            if let Some(ref items) = node.content {
3722                for item in items {
3723                    if item.node_type == "taskList" {
3724                        // A nested taskList is a sibling child of the outer
3725                        // taskList — render it indented so it round-trips back
3726                        // as a taskList, not a taskItem (issue #506).
3727                        let mut nested = String::new();
3728                        render_block_node(item, &mut nested, opts);
3729                        for line in nested.lines() {
3730                            output.push_str("  ");
3731                            output.push_str(line);
3732                            output.push('\n');
3733                        }
3734                    } else {
3735                        let state = item
3736                            .attrs
3737                            .as_ref()
3738                            .and_then(|a| a.get("state"))
3739                            .and_then(serde_json::Value::as_str)
3740                            .unwrap_or("TODO");
3741                        if state == "DONE" {
3742                            output.push_str("- [x] ");
3743                        } else {
3744                            output.push_str("- [ ] ");
3745                        }
3746                        render_list_item_content(item, output, opts);
3747                    }
3748                }
3749            }
3750        }
3751        "rule" => {
3752            output.push_str("---\n");
3753        }
3754        "table" => {
3755            render_table(node, output, opts);
3756        }
3757        "mediaSingle" => {
3758            if let Some(ref content) = node.content {
3759                for child in content {
3760                    if child.node_type == "media" {
3761                        render_media(child, node.attrs.as_ref(), output, opts);
3762                    }
3763                }
3764                for child in content {
3765                    if child.node_type == "caption" {
3766                        let mut cap_parts = Vec::new();
3767                        if let Some(ref attrs) = child.attrs {
3768                            maybe_push_local_id(attrs, &mut cap_parts, opts);
3769                        }
3770                        if cap_parts.is_empty() {
3771                            output.push_str(":::caption\n");
3772                        } else {
3773                            output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3774                        }
3775                        if let Some(ref caption_content) = child.content {
3776                            for inline in caption_content {
3777                                render_inline_node(inline, output, opts);
3778                            }
3779                            output.push('\n');
3780                        }
3781                        output.push_str(":::\n");
3782                    }
3783                }
3784            }
3785        }
3786        "blockCard" => {
3787            if let Some(ref attrs) = node.attrs {
3788                let url = attrs
3789                    .get("url")
3790                    .and_then(serde_json::Value::as_str)
3791                    .unwrap_or("");
3792                let mut attr_parts = Vec::new();
3793                if url_safe_in_bracket_content(url) {
3794                    output.push_str(&format!("::card[{url}]"));
3795                } else {
3796                    // URL would break `::card[URL]` parsing; use quoted attr form.
3797                    output.push_str("::card[]");
3798                    let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
3799                    attr_parts.push(format!("url=\"{escaped}\""));
3800                }
3801                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3802                    attr_parts.push(format!("layout={layout}"));
3803                }
3804                if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
3805                    attr_parts.push(format!("width={width}"));
3806                }
3807                if !attr_parts.is_empty() {
3808                    output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3809                }
3810                output.push('\n');
3811            }
3812        }
3813        "embedCard" => {
3814            if let Some(ref attrs) = node.attrs {
3815                let url = attrs
3816                    .get("url")
3817                    .and_then(serde_json::Value::as_str)
3818                    .unwrap_or("");
3819                output.push_str(&format!("::embed[{url}]"));
3820                let mut attr_parts = Vec::new();
3821                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3822                    attr_parts.push(format!("layout={layout}"));
3823                }
3824                if let Some(h) = attrs
3825                    .get("originalHeight")
3826                    .and_then(serde_json::Value::as_f64)
3827                {
3828                    attr_parts.push(format!("originalHeight={}", fmt_f64_attr(h)));
3829                }
3830                if let Some(w) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3831                    attr_parts.push(format!("width={}", fmt_f64_attr(w)));
3832                }
3833                if !attr_parts.is_empty() {
3834                    output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3835                }
3836                output.push('\n');
3837            }
3838        }
3839        "extension" => {
3840            if let Some(ref attrs) = node.attrs {
3841                let ext_type = attrs
3842                    .get("extensionType")
3843                    .and_then(serde_json::Value::as_str)
3844                    .unwrap_or("");
3845                let ext_key = attrs
3846                    .get("extensionKey")
3847                    .and_then(serde_json::Value::as_str)
3848                    .unwrap_or("");
3849                let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3850                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3851                    attr_parts.push(format!("layout={layout}"));
3852                }
3853                if let Some(params) = attrs.get("parameters") {
3854                    if let Ok(json_str) = serde_json::to_string(params) {
3855                        attr_parts.push(format!("params='{json_str}'"));
3856                    }
3857                }
3858                maybe_push_local_id(attrs, &mut attr_parts, opts);
3859                output.push_str(&format!("::extension{{{}}}\n", attr_parts.join(" ")));
3860            }
3861        }
3862        "panel" => {
3863            let panel_type = node
3864                .attrs
3865                .as_ref()
3866                .and_then(|a| a.get("panelType"))
3867                .and_then(serde_json::Value::as_str)
3868                .unwrap_or("info");
3869            let mut attr_parts = vec![format!("type={panel_type}")];
3870            if let Some(ref attrs) = node.attrs {
3871                if let Some(icon) = attrs.get("panelIcon").and_then(serde_json::Value::as_str) {
3872                    attr_parts.push(format!("icon=\"{icon}\""));
3873                }
3874                if let Some(color) = attrs.get("panelColor").and_then(serde_json::Value::as_str) {
3875                    attr_parts.push(format!("color=\"{color}\""));
3876                }
3877            }
3878            output.push_str(&format!(":::panel{{{}}}\n", attr_parts.join(" ")));
3879            if let Some(ref content) = node.content {
3880                render_block_children(content, output, opts);
3881            }
3882            output.push_str(":::\n");
3883        }
3884        "expand" | "nestedExpand" => {
3885            let directive_name = if node.node_type == "nestedExpand" {
3886                "nested-expand"
3887            } else {
3888                "expand"
3889            };
3890            let mut attr_parts = Vec::new();
3891            if let Some(t) = node
3892                .attrs
3893                .as_ref()
3894                .and_then(|a| a.get("title"))
3895                .and_then(serde_json::Value::as_str)
3896            {
3897                attr_parts.push(format!("title=\"{t}\""));
3898            }
3899            // Check top-level localId first, then fall back to attrs.localId
3900            if let Some(ref lid) = node.local_id {
3901                if !opts.strip_local_ids && lid != "00000000-0000-0000-0000-000000000000" {
3902                    attr_parts.push(format!("localId={lid}"));
3903                }
3904            } else if let Some(ref attrs) = node.attrs {
3905                maybe_push_local_id(attrs, &mut attr_parts, opts);
3906            }
3907            // Emit top-level parameters as params='...'
3908            if let Some(ref params) = node.parameters {
3909                if let Ok(json_str) = serde_json::to_string(params) {
3910                    attr_parts.push(format!("params='{json_str}'"));
3911                }
3912            }
3913            if attr_parts.is_empty() {
3914                output.push_str(&format!(":::{directive_name}\n"));
3915            } else {
3916                output.push_str(&format!(
3917                    ":::{directive_name}{{{}}}\n",
3918                    attr_parts.join(" ")
3919                ));
3920            }
3921            if let Some(ref content) = node.content {
3922                render_block_children(content, output, opts);
3923            }
3924            output.push_str(":::\n");
3925        }
3926        "layoutSection" => {
3927            output.push_str("::::layout\n");
3928            if let Some(ref content) = node.content {
3929                for child in content {
3930                    if child.node_type == "layoutColumn" {
3931                        let width_str = child
3932                            .attrs
3933                            .as_ref()
3934                            .and_then(|a| a.get("width"))
3935                            .and_then(fmt_numeric_attr)
3936                            .unwrap_or_else(|| "50".to_string());
3937                        let mut parts = vec![format!("width={width_str}")];
3938                        if let Some(ref attrs) = child.attrs {
3939                            maybe_push_local_id(attrs, &mut parts, opts);
3940                        }
3941                        output.push_str(&format!(":::column{{{}}}\n", parts.join(" ")));
3942                        if let Some(ref col_content) = child.content {
3943                            render_block_children(col_content, output, opts);
3944                        }
3945                        output.push_str(":::\n");
3946                    }
3947                }
3948            }
3949            output.push_str("::::\n");
3950        }
3951        "decisionList" => {
3952            output.push_str(":::decisions\n");
3953            if let Some(ref content) = node.content {
3954                for item in content {
3955                    output.push_str("- <> ");
3956                    render_list_item_content(item, output, opts);
3957                }
3958            }
3959            output.push_str(":::\n");
3960        }
3961        "bodiedExtension" => {
3962            if let Some(ref attrs) = node.attrs {
3963                let ext_type = attrs
3964                    .get("extensionType")
3965                    .and_then(serde_json::Value::as_str)
3966                    .unwrap_or("");
3967                let ext_key = attrs
3968                    .get("extensionKey")
3969                    .and_then(serde_json::Value::as_str)
3970                    .unwrap_or("");
3971                let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3972                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3973                    attr_parts.push(format!("layout={layout}"));
3974                }
3975                if let Some(params) = attrs.get("parameters") {
3976                    if let Ok(json_str) = serde_json::to_string(params) {
3977                        attr_parts.push(format!("params='{json_str}'"));
3978                    }
3979                }
3980                maybe_push_local_id(attrs, &mut attr_parts, opts);
3981                output.push_str(&format!(":::extension{{{}}}\n", attr_parts.join(" ")));
3982                if let Some(ref content) = node.content {
3983                    render_block_children(content, output, opts);
3984                }
3985                output.push_str(":::\n");
3986            }
3987        }
3988        _ => {
3989            // Preserve unsupported nodes as JSON in adf-unsupported code blocks
3990            if let Ok(json) = serde_json::to_string_pretty(node) {
3991                output.push_str("```adf-unsupported\n");
3992                output.push_str(&json);
3993                output.push_str("\n```\n");
3994            }
3995        }
3996    }
3997
3998    // Emit block-level attribute marks (align, indent, breakout) and localId
3999    let mut parts = Vec::new();
4000    if let Some(ref marks) = node.marks {
4001        for mark in marks {
4002            match mark.mark_type.as_str() {
4003                "alignment" => {
4004                    if let Some(align) = mark
4005                        .attrs
4006                        .as_ref()
4007                        .and_then(|a| a.get("align"))
4008                        .and_then(serde_json::Value::as_str)
4009                    {
4010                        parts.push(format!("align={align}"));
4011                    }
4012                }
4013                "indentation" => {
4014                    if let Some(level) = mark
4015                        .attrs
4016                        .as_ref()
4017                        .and_then(|a| a.get("level"))
4018                        .and_then(serde_json::Value::as_u64)
4019                    {
4020                        parts.push(format!("indent={level}"));
4021                    }
4022                }
4023                "breakout" => {
4024                    if let Some(mode) = mark
4025                        .attrs
4026                        .as_ref()
4027                        .and_then(|a| a.get("mode"))
4028                        .and_then(serde_json::Value::as_str)
4029                    {
4030                        parts.push(format!("breakout={mode}"));
4031                    }
4032                    if let Some(width) = mark
4033                        .attrs
4034                        .as_ref()
4035                        .and_then(|a| a.get("width"))
4036                        .and_then(serde_json::Value::as_u64)
4037                    {
4038                        parts.push(format!("breakoutWidth={width}"));
4039                    }
4040                }
4041                _ => {}
4042            }
4043        }
4044    }
4045    // Skip localId for node types that already include it in their directive attrs.
4046    // For paragraphs, localId is included in the ::paragraph directive when the
4047    // paragraph uses directive form (empty or whitespace-only content).
4048    let para_used_directive = node.node_type == "paragraph" && {
4049        let is_empty = node.content.as_ref().is_none_or(Vec::is_empty);
4050        if is_empty {
4051            true
4052        } else {
4053            let mut buf = String::new();
4054            render_inline_content(node, &mut buf, opts);
4055            paragraph_needs_directive_form(&buf)
4056        }
4057    };
4058    if !matches!(node.node_type.as_str(), "expand" | "nestedExpand") && !para_used_directive {
4059        if let Some(ref attrs) = node.attrs {
4060            maybe_push_local_id(attrs, &mut parts, opts);
4061        }
4062    }
4063    // orderedList with explicit `attrs.order=1` needs a trailing `{order=1}`
4064    // signal so the round-trip can distinguish explicit default from omitted
4065    // attrs (issue #547). Values other than 1 are already encoded by the
4066    // list marker, so no signal is needed.
4067    if node.node_type == "orderedList" {
4068        if let Some(ref attrs) = node.attrs {
4069            if attrs.get("order").and_then(serde_json::Value::as_u64) == Some(1) {
4070                parts.push("order=1".to_string());
4071            }
4072        }
4073    }
4074    // A blank line between two lists is also what a CommonMark loose list
4075    // looks like, so the parser would read the next one as more items of this
4076    // one.  This marker ends the list: any trailing attrs line does, and the
4077    // key is recognised only so the line is not re-read as a paragraph
4078    // (issue #1729).
4079    if separate_from_next && is_list_node_type(&node.node_type) {
4080        parts.push(format_kv(LIST_SEPARATE_ATTR, "separate"));
4081    }
4082    if !parts.is_empty() {
4083        output.push_str(&format!("{{{}}}\n", parts.join(" ")));
4084    }
4085}
4086
4087/// Renders the content of a list item (unwraps the paragraph layer).
4088/// Nested block children (e.g. sub-lists) are indented with two spaces.
4089///
4090/// Some ADF producers (e.g. Confluence) emit `taskItem` content without a
4091/// paragraph wrapper — the inline nodes sit directly inside the item.  We
4092/// detect this by checking whether the first child is an inline node type
4093/// and, if so, render *all* leading inline children on the first line.
4094fn render_list_item_content(item: &AdfNode, output: &mut String, opts: &RenderOptions) {
4095    let Some(ref content) = item.content else {
4096        // Still emit localId and newline for items with no content (e.g. empty taskItem).
4097        let bare = AdfNode::text("");
4098        emit_list_item_local_ids(item, &bare, output, opts);
4099        output.push('\n');
4100        return;
4101    };
4102    if content.is_empty() {
4103        let bare = AdfNode::text("");
4104        emit_list_item_local_ids(item, &bare, output, opts);
4105        output.push('\n');
4106        return;
4107    }
4108    let first = &content[0];
4109    let rest_start;
4110    if first.node_type == "paragraph" {
4111        let mut buf = String::new();
4112        render_inline_content(first, &mut buf, opts);
4113        // A trailing hardBreak produces a trailing `\\\n` in the buffer.
4114        // Strip the final newline so it doesn't create a blank line after
4115        // the list item marker, which would split the list on re-parse
4116        // (issue #472).  The `\` is kept so round-trip preserves the
4117        // hardBreak, and `output.push('\n')` below supplies the terminator.
4118        let buf = buf.trim_end_matches('\n');
4119        // Indent continuation lines produced by hardBreaks so they stay
4120        // within the list item when re-parsed (issue #402).
4121        let mut is_first_line = true;
4122        for line in buf.split('\n') {
4123            if is_first_line {
4124                output.push_str(line);
4125                is_first_line = false;
4126            } else {
4127                output.push('\n');
4128                if !line.is_empty() {
4129                    output.push_str("  ");
4130                }
4131                output.push_str(line);
4132            }
4133        }
4134        // Emit paragraph + listItem localIds as trailing inline attrs on the first line
4135        emit_list_item_local_ids(item, first, output, opts);
4136        output.push('\n');
4137        rest_start = 1;
4138    } else if is_inline_node_type(&first.node_type) {
4139        // Inline nodes without a paragraph wrapper — render them directly.
4140        rest_start = content
4141            .iter()
4142            .position(|c| !is_inline_node_type(&c.node_type))
4143            .unwrap_or(content.len());
4144        let mut buf = String::new();
4145        for child in &content[..rest_start] {
4146            render_inline_node(child, &mut buf, opts);
4147        }
4148        // Indent continuation lines produced by hardBreaks so they stay
4149        // within the list item when re-parsed (issue #521).
4150        let buf = buf.trim_end_matches('\n');
4151        let mut is_first_line = true;
4152        for line in buf.split('\n') {
4153            if is_first_line {
4154                output.push_str(line);
4155                is_first_line = false;
4156            } else {
4157                output.push('\n');
4158                if !line.is_empty() {
4159                    output.push_str("  ");
4160                }
4161                output.push_str(line);
4162            }
4163        }
4164        // No paragraph wrapper — pass a bare node so paraLocalId is omitted.
4165        let bare = AdfNode::text("");
4166        emit_list_item_local_ids(item, &bare, output, opts);
4167        output.push('\n');
4168        // Any remaining children are block nodes — fall through to the
4169        // indented-block loop below.
4170    } else if first.node_type == "taskItem" {
4171        // Malformed ADF: taskItem.content contains nested taskItem nodes
4172        // directly (seen in some Confluence pages).  Render them as an
4173        // indented nested task list to preserve the data without
4174        // corrupting the surrounding structure (issue #489).
4175        let bare = AdfNode::text("");
4176        emit_list_item_local_ids(item, &bare, output, opts);
4177        output.push('\n');
4178        for (i, child) in content.iter().enumerate() {
4179            if child.node_type == "taskItem" {
4180                let state = child
4181                    .attrs
4182                    .as_ref()
4183                    .and_then(|a| a.get("state"))
4184                    .and_then(serde_json::Value::as_str)
4185                    .unwrap_or("TODO");
4186                let marker = if state == "DONE" { "- [x] " } else { "- [ ] " };
4187                output.push_str("  ");
4188                output.push_str(marker);
4189                render_list_item_content(child, output, opts);
4190            } else {
4191                let mut nested = String::new();
4192                let separate = needs_list_separator(child, content.get(i + 1));
4193                render_block_node_separated(child, &mut nested, opts, separate);
4194                for line in nested.lines() {
4195                    output.push_str("  ");
4196                    output.push_str(line);
4197                    output.push('\n');
4198                }
4199            }
4200        }
4201        return;
4202    } else {
4203        // Block-level first child (e.g. codeBlock, mediaSingle).
4204        // Render to a buffer so we can:
4205        //  1. Append listItem localId attrs to the first line (issue #525)
4206        //  2. Indent continuation lines so multi-line blocks stay inside
4207        //     the list item (issue #511)
4208        let mut buf = String::new();
4209        // A block-level first child can itself be a list that the next
4210        // sibling would fuse with (issue #1729).
4211        let separate = needs_list_separator(first, content.get(1));
4212        render_block_node_separated(first, &mut buf, opts, separate);
4213        let bare = AdfNode::text("");
4214        let mut is_first = true;
4215        for line in buf.lines() {
4216            if is_first {
4217                output.push_str(line);
4218                emit_list_item_local_ids(item, &bare, output, opts);
4219                output.push('\n');
4220                is_first = false;
4221            } else {
4222                output.push_str("  ");
4223                output.push_str(line);
4224                output.push('\n');
4225            }
4226        }
4227        rest_start = 1;
4228    }
4229    let rest = &content[rest_start..];
4230    for (i, child) in rest.iter().enumerate() {
4231        // Separate consecutive paragraph siblings with a blank indented
4232        // line so they re-parse as distinct paragraphs rather than being
4233        // merged into one (issue #522).  The previous sibling of `rest[0]`
4234        // is the node rendered on the item's first line (above).
4235        let prev = if i == 0 { first } else { &rest[i - 1] };
4236        if child.node_type == "paragraph" && prev.node_type == "paragraph" {
4237            output.push_str("  \n");
4238        }
4239        let separate = needs_list_separator(child, rest.get(i + 1));
4240        let mut nested = String::new();
4241        render_block_node_separated(child, &mut nested, opts, separate);
4242        for line in nested.lines() {
4243            output.push_str("  ");
4244            output.push_str(line);
4245            output.push('\n');
4246        }
4247    }
4248}
4249
4250/// Returns `true` if the given ADF node type is an inline node.
4251fn is_inline_node_type(node_type: &str) -> bool {
4252    matches!(
4253        node_type,
4254        "text"
4255            | "hardBreak"
4256            | "inlineCard"
4257            | "emoji"
4258            | "mention"
4259            | "status"
4260            | "date"
4261            | "placeholder"
4262            | "mediaInline"
4263    )
4264}
4265
4266/// Emits trailing `{localId=... paraLocalId=...}` on a list item line
4267/// for both the listItem and its first (unwrapped) paragraph.
4268fn emit_list_item_local_ids(
4269    item: &AdfNode,
4270    paragraph: &AdfNode,
4271    output: &mut String,
4272    opts: &RenderOptions,
4273) {
4274    if opts.strip_local_ids {
4275        return;
4276    }
4277    let mut parts = Vec::new();
4278    if let Some(ref attrs) = item.attrs {
4279        maybe_push_local_id(attrs, &mut parts, opts);
4280    }
4281    if paragraph.node_type == "paragraph" {
4282        let has_real_id = paragraph
4283            .attrs
4284            .as_ref()
4285            .and_then(|a| a.get("localId"))
4286            .and_then(serde_json::Value::as_str)
4287            .filter(|id| !id.is_empty() && *id != "00000000-0000-0000-0000-000000000000");
4288        if let Some(local_id) = has_real_id {
4289            parts.push(format!("paraLocalId={local_id}"));
4290        } else if item.node_type == "taskItem" {
4291            // taskItem content may or may not have a paragraph wrapper;
4292            // emit a sentinel so the round-trip can distinguish the two
4293            // forms and restore the wrapper (issue #478).
4294            parts.push("paraLocalId=_".to_string());
4295        }
4296    }
4297    if !parts.is_empty() {
4298        output.push_str(&format!(" {{{}}}", parts.join(" ")));
4299    }
4300}
4301
4302/// Renders a table node, choosing between pipe table and directive table form.
4303fn render_table(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4304    let Some(ref rows) = node.content else {
4305        return;
4306    };
4307
4308    if table_qualifies_for_pipe_syntax(rows) {
4309        render_pipe_table(node, rows, output, opts);
4310    } else {
4311        render_directive_table(node, rows, output, opts);
4312    }
4313}
4314
4315/// Checks whether all cells qualify for GFM pipe table syntax:
4316/// - Every cell has exactly one paragraph child with only inline nodes
4317/// - All `tableHeader` nodes appear exclusively in the first row
4318/// - The first row must contain at least one `tableHeader` (pipe tables
4319///   always treat the first row as headers, so `tableCell`-only first rows
4320///   must use directive form to preserve the cell type)
4321fn table_qualifies_for_pipe_syntax(rows: &[AdfNode]) -> bool {
4322    // Tables with caption nodes must use directive form
4323    if rows.iter().any(|n| n.node_type == "caption") {
4324        return false;
4325    }
4326    let mut first_row_has_header = false;
4327    for (row_idx, row) in rows.iter().enumerate() {
4328        let Some(ref cells) = row.content else {
4329            continue;
4330        };
4331        for cell in cells {
4332            // Header cells outside first row → must use directive form
4333            if row_idx > 0 && cell.node_type == "tableHeader" {
4334                return false;
4335            }
4336            if row_idx == 0 && cell.node_type == "tableHeader" {
4337                first_row_has_header = true;
4338            }
4339            // Check cell has exactly one paragraph with only inline content
4340            let Some(ref content) = cell.content else {
4341                continue;
4342            };
4343            if content.len() != 1 || content[0].node_type != "paragraph" {
4344                return false;
4345            }
4346            // hardBreak inside a cell produces a newline that breaks pipe
4347            // table syntax — fall back to directive form
4348            if cell_contains_hard_break(&content[0]) {
4349                return false;
4350            }
4351            // Cell-level marks (e.g., border) cannot be represented in pipe
4352            // form — fall back to directive form
4353            if cell.marks.as_ref().is_some_and(|m| !m.is_empty()) {
4354                return false;
4355            }
4356            // Paragraph-level localId would be lost in pipe form (the paragraph
4357            // is unwrapped into the cell text) — fall back to directive form
4358            if content[0]
4359                .attrs
4360                .as_ref()
4361                .and_then(|a| a.get("localId"))
4362                .is_some()
4363            {
4364                return false;
4365            }
4366        }
4367    }
4368    // First row must have at least one tableHeader for pipe syntax;
4369    // otherwise the round-trip would convert tableCell → tableHeader.
4370    first_row_has_header
4371}
4372
4373/// Returns true if a paragraph node contains any `hardBreak` inline nodes.
4374fn cell_contains_hard_break(paragraph: &AdfNode) -> bool {
4375    paragraph
4376        .content
4377        .as_ref()
4378        .is_some_and(|nodes| nodes.iter().any(|n| n.node_type == "hardBreak"))
4379}
4380
4381/// Renders a table as GFM pipe syntax.
4382fn render_pipe_table(node: &AdfNode, rows: &[AdfNode], output: &mut String, opts: &RenderOptions) {
4383    for (row_idx, row) in rows.iter().enumerate() {
4384        let Some(ref cells) = row.content else {
4385            continue;
4386        };
4387
4388        output.push('|');
4389        for cell in cells {
4390            output.push(' ');
4391            let mut cell_buf = String::new();
4392            render_cell_attrs_prefix(cell, &mut cell_buf);
4393            render_inline_content_from_first_paragraph(cell, &mut cell_buf, opts);
4394            output.push_str(&escape_pipes_in_cell(&cell_buf));
4395            output.push_str(" |");
4396        }
4397        output.push('\n');
4398
4399        // Add separator after header row
4400        if row_idx == 0 {
4401            output.push('|');
4402            for cell in cells {
4403                let align = get_cell_paragraph_alignment(cell);
4404                match align {
4405                    Some("center") => output.push_str(" :---: |"),
4406                    Some("end") => output.push_str(" ---: |"),
4407                    _ => output.push_str(" --- |"),
4408                }
4409            }
4410            output.push('\n');
4411        }
4412    }
4413
4414    // Emit table-level attrs if present
4415    render_table_level_attrs(node, output, opts);
4416}
4417
4418/// Renders a table as `::::table` directive syntax (block-content cells).
4419fn render_directive_table(
4420    node: &AdfNode,
4421    rows: &[AdfNode],
4422    output: &mut String,
4423    opts: &RenderOptions,
4424) {
4425    // Opening fence with attrs
4426    let mut attr_parts = Vec::new();
4427    if let Some(ref attrs) = node.attrs {
4428        if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
4429            attr_parts.push(format!("layout={layout}"));
4430        }
4431        if let Some(numbered) = attrs
4432            .get("isNumberColumnEnabled")
4433            .and_then(serde_json::Value::as_bool)
4434        {
4435            if numbered {
4436                attr_parts.push("numbered".to_string());
4437            } else {
4438                attr_parts.push("numbered=false".to_string());
4439            }
4440        }
4441        if let Some(tw) = attrs.get("width").and_then(serde_json::Value::as_f64) {
4442            let tw_str = if tw.fract() == 0.0 {
4443                (tw as u64).to_string()
4444            } else {
4445                tw.to_string()
4446            };
4447            attr_parts.push(format!("width={tw_str}"));
4448        }
4449        maybe_push_local_id(attrs, &mut attr_parts, opts);
4450    }
4451    if attr_parts.is_empty() {
4452        output.push_str("::::table\n");
4453    } else {
4454        output.push_str(&format!("::::table{{{}}}\n", attr_parts.join(" ")));
4455    }
4456
4457    for row in rows {
4458        if row.node_type == "caption" {
4459            let mut cap_parts = Vec::new();
4460            if let Some(ref attrs) = row.attrs {
4461                maybe_push_local_id(attrs, &mut cap_parts, opts);
4462            }
4463            if cap_parts.is_empty() {
4464                output.push_str(":::caption\n");
4465            } else {
4466                output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
4467            }
4468            if let Some(ref content) = row.content {
4469                for child in content {
4470                    render_inline_node(child, output, opts);
4471                }
4472                output.push('\n');
4473            }
4474            output.push_str(":::\n");
4475            continue;
4476        }
4477        let Some(ref cells) = row.content else {
4478            continue;
4479        };
4480        // Emit :::tr with optional localId
4481        let mut tr_attrs = Vec::new();
4482        if let Some(ref attrs) = row.attrs {
4483            maybe_push_local_id(attrs, &mut tr_attrs, opts);
4484        }
4485        if tr_attrs.is_empty() {
4486            output.push_str(":::tr\n");
4487        } else {
4488            output.push_str(&format!(":::tr{{{}}}\n", tr_attrs.join(" ")));
4489        }
4490        for cell in cells {
4491            let directive_name = if cell.node_type == "tableHeader" {
4492                "th"
4493            } else {
4494                "td"
4495            };
4496            let mut cell_attr_str = build_cell_attrs_string(cell);
4497            // Append localId to cell attrs if present
4498            if let Some(ref attrs) = cell.attrs {
4499                let mut lid_parts = Vec::new();
4500                maybe_push_local_id(attrs, &mut lid_parts, opts);
4501                if !lid_parts.is_empty() {
4502                    if !cell_attr_str.is_empty() {
4503                        cell_attr_str.push(' ');
4504                    }
4505                    cell_attr_str.push_str(&lid_parts.join(" "));
4506                }
4507            }
4508            // Append border mark attrs if present
4509            if let Some(ref marks) = cell.marks {
4510                for mark in marks {
4511                    if mark.mark_type == "border" {
4512                        if let Some(ref attrs) = mark.attrs {
4513                            if let Some(color) =
4514                                attrs.get("color").and_then(serde_json::Value::as_str)
4515                            {
4516                                if !cell_attr_str.is_empty() {
4517                                    cell_attr_str.push(' ');
4518                                }
4519                                cell_attr_str.push_str(&format!("border-color={color}"));
4520                            }
4521                            if let Some(size) =
4522                                attrs.get("size").and_then(serde_json::Value::as_u64)
4523                            {
4524                                if !cell_attr_str.is_empty() {
4525                                    cell_attr_str.push(' ');
4526                                }
4527                                cell_attr_str.push_str(&format!("border-size={size}"));
4528                            }
4529                        }
4530                    }
4531                }
4532            }
4533            let has_marks = cell.marks.as_ref().is_some_and(|m| !m.is_empty());
4534            if cell_attr_str.is_empty() && cell.attrs.is_none() && !has_marks {
4535                output.push_str(&format!(":::{directive_name}\n"));
4536            } else {
4537                output.push_str(&format!(":::{directive_name}{{{cell_attr_str}}}\n"));
4538            }
4539            if let Some(ref content) = cell.content {
4540                render_block_children(content, output, opts);
4541            }
4542            output.push_str(":::\n");
4543        }
4544        output.push_str(":::\n");
4545    }
4546
4547    output.push_str("::::\n");
4548}
4549
4550/// Returns `true` when an attribute value must be quoted to survive round-trip
4551/// through the `{key=value}` attribute parser (which stops unquoted values at
4552/// whitespace or `}`).
4553fn needs_attr_quoting(value: &str) -> bool {
4554    value.contains(|c: char| c.is_whitespace() || c == '}' || c == '(' || c == ')' || c == ',')
4555}
4556
4557/// Builds a JFM attribute string from ADF cell attributes.
4558fn build_cell_attrs_string(cell: &AdfNode) -> String {
4559    let Some(ref attrs) = cell.attrs else {
4560        return String::new();
4561    };
4562    let mut parts = Vec::new();
4563    if let Some(colspan) = attrs.get("colspan").and_then(serde_json::Value::as_u64) {
4564        parts.push(format!("colspan={colspan}"));
4565    }
4566    if let Some(rowspan) = attrs.get("rowspan").and_then(serde_json::Value::as_u64) {
4567        parts.push(format!("rowspan={rowspan}"));
4568    }
4569    if let Some(bg) = attrs.get("background").and_then(serde_json::Value::as_str) {
4570        if needs_attr_quoting(bg) {
4571            let escaped = bg.replace('\\', "\\\\").replace('"', "\\\"");
4572            parts.push(format!("bg=\"{escaped}\""));
4573        } else {
4574            parts.push(format!("bg={bg}"));
4575        }
4576    }
4577    if let Some(colwidth) = attrs.get("colwidth").and_then(serde_json::Value::as_array) {
4578        let widths: Vec<String> = colwidth
4579            .iter()
4580            .filter_map(|v| {
4581                // Preserve the original number type: integers stay as integers,
4582                // floats stay as floats (e.g. Confluence's 254.0 representation).
4583                if let Some(n) = v.as_u64() {
4584                    Some(n.to_string())
4585                } else if let Some(n) = v.as_f64() {
4586                    if n.fract() == 0.0 {
4587                        format!("{n:.1}")
4588                    } else {
4589                        n.to_string()
4590                    }
4591                    .into()
4592                } else {
4593                    None
4594                }
4595            })
4596            .collect();
4597        if !widths.is_empty() {
4598            parts.push(format!("colwidth={}", widths.join(",")));
4599        }
4600    }
4601    parts.join(" ")
4602}
4603
4604/// Renders `{attrs}` prefix for a pipe table cell (background, colspan, etc.).
4605fn render_cell_attrs_prefix(cell: &AdfNode, output: &mut String) {
4606    let Some(ref _attrs) = cell.attrs else {
4607        return;
4608    };
4609    let attr_str = build_cell_attrs_string(cell);
4610    if attr_str.is_empty() {
4611        output.push_str("{} ");
4612    } else {
4613        output.push_str(&format!("{{{attr_str}}} "));
4614    }
4615}
4616
4617/// Gets the alignment mark value from the paragraph inside a table cell.
4618fn get_cell_paragraph_alignment(cell: &AdfNode) -> Option<&str> {
4619    let content = cell.content.as_ref()?;
4620    let para = content.first()?;
4621    let marks = para.marks.as_ref()?;
4622    marks.iter().find_map(|m| {
4623        if m.mark_type == "alignment" {
4624            m.attrs
4625                .as_ref()
4626                .and_then(|a| a.get("align"))
4627                .and_then(serde_json::Value::as_str)
4628        } else {
4629            None
4630        }
4631    })
4632}
4633
4634/// Emits table-level attributes if present.
4635fn render_table_level_attrs(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4636    if let Some(ref attrs) = node.attrs {
4637        let mut parts = Vec::new();
4638        if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
4639            parts.push(format!("layout={layout}"));
4640        }
4641        if let Some(numbered) = attrs
4642            .get("isNumberColumnEnabled")
4643            .and_then(serde_json::Value::as_bool)
4644        {
4645            if numbered {
4646                parts.push("numbered".to_string());
4647            } else {
4648                parts.push("numbered=false".to_string());
4649            }
4650        }
4651        if let Some(tw_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4652            parts.push(format!("width={tw_str}"));
4653        }
4654        maybe_push_local_id(attrs, &mut parts, opts);
4655        if !parts.is_empty() {
4656            output.push_str(&format!("{{{}}}\n", parts.join(" ")));
4657        }
4658    }
4659}
4660
4661/// Renders inline content from the first paragraph child of a table cell.
4662fn render_inline_content_from_first_paragraph(
4663    cell: &AdfNode,
4664    output: &mut String,
4665    opts: &RenderOptions,
4666) {
4667    if let Some(ref content) = cell.content {
4668        if let Some(first) = content.first() {
4669            if first.node_type == "paragraph" {
4670                render_inline_content(first, output, opts);
4671            }
4672        }
4673    }
4674}
4675
4676/// Appends border mark attributes (border-color, border-size) to a parts vec.
4677fn push_border_mark_attrs(marks: &Option<Vec<AdfMark>>, parts: &mut Vec<String>) {
4678    if let Some(ref marks) = marks {
4679        for mark in marks {
4680            if mark.mark_type == "border" {
4681                if let Some(ref attrs) = mark.attrs {
4682                    if let Some(color) = attrs.get("color").and_then(serde_json::Value::as_str) {
4683                        parts.push(format!("border-color={color}"));
4684                    }
4685                    if let Some(size) = attrs.get("size").and_then(serde_json::Value::as_u64) {
4686                        parts.push(format!("border-size={size}"));
4687                    }
4688                }
4689            }
4690        }
4691    }
4692}
4693
4694/// Renders a media node as a markdown image, with optional parent (mediaSingle) attrs.
4695fn render_media(
4696    node: &AdfNode,
4697    parent_attrs: Option<&serde_json::Value>,
4698    output: &mut String,
4699    opts: &RenderOptions,
4700) {
4701    if let Some(ref attrs) = node.attrs {
4702        let media_type = attrs
4703            .get("type")
4704            .and_then(serde_json::Value::as_str)
4705            .unwrap_or("external");
4706        let alt = attrs
4707            .get("alt")
4708            .and_then(serde_json::Value::as_str)
4709            .unwrap_or("");
4710
4711        if media_type == "file" {
4712            // Confluence file attachment — encode metadata in {attrs} block so it survives round-trip
4713            output.push_str(&format!("![{alt}]()"));
4714            let mut parts = vec!["type=file".to_string()];
4715            if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4716                parts.push(format_kv("id", id));
4717            }
4718            if let Some(collection) = attrs.get("collection").and_then(serde_json::Value::as_str) {
4719                parts.push(format_kv("collection", collection));
4720            }
4721            if let Some(occurrence_key) = attrs
4722                .get("occurrenceKey")
4723                .and_then(serde_json::Value::as_str)
4724            {
4725                parts.push(format_kv("occurrenceKey", occurrence_key));
4726            }
4727            if let Some(height_str) = attrs.get("height").and_then(fmt_numeric_attr) {
4728                parts.push(format!("height={height_str}"));
4729            }
4730            if let Some(width_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4731                parts.push(format!("width={width_str}"));
4732            }
4733            maybe_push_local_id(attrs, &mut parts, opts);
4734            // Encode mediaSingle layout/width/widthType if non-default
4735            if let Some(p_attrs) = parent_attrs {
4736                if let Some(layout) = p_attrs.get("layout").and_then(serde_json::Value::as_str) {
4737                    if layout != "center" {
4738                        parts.push(format!("layout={layout}"));
4739                    }
4740                }
4741                if let Some(ms_width_str) = p_attrs.get("width").and_then(fmt_numeric_attr) {
4742                    parts.push(format!("mediaWidth={ms_width_str}"));
4743                }
4744                if let Some(wt) = p_attrs.get("widthType").and_then(serde_json::Value::as_str) {
4745                    parts.push(format!("widthType={wt}"));
4746                }
4747                if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4748                    parts.push(format!("mode={mode}"));
4749                }
4750            }
4751            push_border_mark_attrs(&node.marks, &mut parts);
4752            output.push_str(&format!("{{{}}}", parts.join(" ")));
4753        } else {
4754            // External image
4755            let url = attrs
4756                .get("url")
4757                .and_then(serde_json::Value::as_str)
4758                .unwrap_or("");
4759            output.push_str(&format!("![{alt}]({url})"));
4760
4761            // Emit {layout=... width=... widthType=... mode=... localId=...} if non-default attrs present
4762            {
4763                let mut parts = Vec::new();
4764                if let Some(p_attrs) = parent_attrs {
4765                    let layout = p_attrs.get("layout").and_then(serde_json::Value::as_str);
4766                    let width_str = p_attrs.get("width").and_then(fmt_numeric_attr);
4767                    let width_type = p_attrs.get("widthType").and_then(serde_json::Value::as_str);
4768                    if let Some(l) = layout {
4769                        if l != "center" {
4770                            parts.push(format!("layout={l}"));
4771                        }
4772                    }
4773                    if let Some(w) = width_str {
4774                        parts.push(format!("width={w}"));
4775                    }
4776                    if let Some(wt) = width_type {
4777                        parts.push(format!("widthType={wt}"));
4778                    }
4779                    if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4780                        parts.push(format!("mode={mode}"));
4781                    }
4782                }
4783                maybe_push_local_id(attrs, &mut parts, opts);
4784                push_border_mark_attrs(&node.marks, &mut parts);
4785                if !parts.is_empty() {
4786                    output.push_str(&format!("{{{}}}", parts.join(" ")));
4787                }
4788            }
4789        }
4790
4791        output.push('\n');
4792    }
4793}
4794
4795/// Renders inline content (text nodes with marks) from a block node's children.
4796fn render_inline_content(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4797    if let Some(ref content) = node.content {
4798        for child in content {
4799            render_inline_node(child, output, opts);
4800        }
4801    }
4802}
4803
4804/// Renders a single inline ADF node to markdown.
4805fn render_inline_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4806    match node.node_type.as_str() {
4807        "text" => {
4808            let text = node.text.as_deref().unwrap_or("");
4809            let marks = node.marks.as_deref().unwrap_or(&[]);
4810            let has_code = marks.iter().any(|m| m.mark_type == "code");
4811            // Issue #477: Escape literal backslashes before the newline
4812            // encoding below so they are not consumed as JFM escape
4813            // sequences on round-trip.  Code marks emit content verbatim,
4814            // so backslash escaping is skipped for them.
4815            let owned;
4816            let text = if !has_code {
4817                owned = text.replace('\\', "\\\\");
4818                owned.as_str()
4819            } else {
4820                text
4821            };
4822            // Issue #454: A literal newline inside a text node is escaped
4823            // as the two-character sequence `\n` so it survives round-trip
4824            // as a single text node instead of splitting into paragraphs or
4825            // being converted to hardBreak nodes.
4826            let owned_nl;
4827            let text = if text.contains('\n') {
4828                owned_nl = text.replace('\n', "\\n");
4829                owned_nl.as_str()
4830            } else {
4831                text
4832            };
4833            // Issue #510: Two or more trailing spaces at the end of a text
4834            // node would be misinterpreted as a hardBreak marker on
4835            // round-trip (and collapse the following paragraph).  Escape the
4836            // last space with a backslash so the parser treats it as a
4837            // literal space instead of a line-break signal.
4838            let owned_ts;
4839            let text = if !has_code && text.ends_with("  ") {
4840                let mut s = text.to_string();
4841                // Insert backslash before the final space: "foo  " → "foo \ "
4842                s.insert(s.len() - 1, '\\');
4843                owned_ts = s;
4844                owned_ts.as_str()
4845            } else {
4846                text
4847            };
4848            render_marked_text(text, marks, output);
4849        }
4850        "hardBreak" => {
4851            output.push_str("\\\n");
4852        }
4853        other => {
4854            // Issue #471: Non-text inline nodes (emoji, status, date, mention, etc.)
4855            // may carry annotation marks. Render the node body first, then wrap it
4856            // in bracketed-span syntax if annotation marks are present.
4857            let mut body = String::new();
4858            render_non_text_inline_body(other, node, &mut body, opts);
4859
4860            let annotations: Vec<&AdfMark> = node
4861                .marks
4862                .as_deref()
4863                .unwrap_or(&[])
4864                .iter()
4865                .filter(|m| m.mark_type == "annotation")
4866                .collect();
4867
4868            if annotations.is_empty() {
4869                output.push_str(&body);
4870            } else {
4871                let mut attr_parts = Vec::new();
4872                for ann in &annotations {
4873                    if let Some(ref attrs) = ann.attrs {
4874                        if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4875                            let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4876                            attr_parts.push(format!("annotation-id=\"{escaped}\""));
4877                        }
4878                        if let Some(at) = attrs
4879                            .get("annotationType")
4880                            .and_then(serde_json::Value::as_str)
4881                        {
4882                            attr_parts.push(format!("annotation-type={at}"));
4883                        }
4884                    }
4885                }
4886                output.push('[');
4887                output.push_str(&body);
4888                output.push_str("]{");
4889                output.push_str(&attr_parts.join(" "));
4890                output.push('}');
4891            }
4892        }
4893    }
4894}
4895
4896/// Renders the body of a non-text inline node (without mark wrapping).
4897fn render_non_text_inline_body(
4898    node_type: &str,
4899    node: &AdfNode,
4900    output: &mut String,
4901    opts: &RenderOptions,
4902) {
4903    match node_type {
4904        "inlineCard" => {
4905            if let Some(ref attrs) = node.attrs {
4906                if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4907                    let mut attr_parts = Vec::new();
4908                    if url_safe_in_bracket_content(url) {
4909                        output.push_str(":card[");
4910                        output.push_str(url);
4911                        output.push(']');
4912                    } else {
4913                        // URL would break `:card[URL]` parsing (e.g. contains an
4914                        // unbalanced `]` or a newline).  Fall back to quoted
4915                        // attribute form so the URL round-trips losslessly.
4916                        output.push_str(":card[]");
4917                        let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
4918                        attr_parts.push(format!("url=\"{escaped}\""));
4919                    }
4920                    maybe_push_local_id(attrs, &mut attr_parts, opts);
4921                    if !attr_parts.is_empty() {
4922                        output.push('{');
4923                        output.push_str(&attr_parts.join(" "));
4924                        output.push('}');
4925                    }
4926                }
4927            }
4928        }
4929        "emoji" => {
4930            if let Some(ref attrs) = node.attrs {
4931                if let Some(short_name) = attrs.get("shortName").and_then(serde_json::Value::as_str)
4932                {
4933                    output.push(':');
4934                    let name = short_name.strip_prefix(':').unwrap_or(short_name);
4935                    let name = name.strip_suffix(':').unwrap_or(name);
4936                    output.push_str(name);
4937                    output.push(':');
4938
4939                    let mut parts = Vec::new();
4940                    let escaped_sn = short_name.replace('\\', "\\\\").replace('"', "\\\"");
4941                    parts.push(format!("shortName=\"{escaped_sn}\""));
4942                    if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4943                        let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4944                        parts.push(format!("id=\"{escaped}\""));
4945                    }
4946                    if let Some(text) = attrs.get("text").and_then(serde_json::Value::as_str) {
4947                        let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
4948                        parts.push(format!("text=\"{escaped}\""));
4949                    }
4950                    maybe_push_local_id(attrs, &mut parts, opts);
4951                    output.push('{');
4952                    output.push_str(&parts.join(" "));
4953                    output.push('}');
4954                }
4955            }
4956        }
4957        "status" => {
4958            if let Some(ref attrs) = node.attrs {
4959                let text = attrs
4960                    .get("text")
4961                    .and_then(serde_json::Value::as_str)
4962                    .unwrap_or("");
4963                let color = attrs
4964                    .get("color")
4965                    .and_then(serde_json::Value::as_str)
4966                    .unwrap_or("neutral");
4967                let mut attr_parts = vec![format!("color={color}")];
4968                if let Some(style) = attrs.get("style").and_then(serde_json::Value::as_str) {
4969                    attr_parts.push(format!("style={style}"));
4970                }
4971                maybe_push_local_id(attrs, &mut attr_parts, opts);
4972                output.push_str(&format!(":status[{text}]{{{}}}", attr_parts.join(" ")));
4973            }
4974        }
4975        "date" => {
4976            if let Some(ref attrs) = node.attrs {
4977                if let Some(timestamp) = attrs.get("timestamp").and_then(serde_json::Value::as_str)
4978                {
4979                    let display = epoch_ms_to_iso_date(timestamp);
4980                    let mut attr_parts = vec![format!("timestamp={timestamp}")];
4981                    maybe_push_local_id(attrs, &mut attr_parts, opts);
4982                    output.push_str(&format!(":date[{display}]{{{}}}", attr_parts.join(" ")));
4983                }
4984            }
4985        }
4986        "mention" => {
4987            if let Some(ref attrs) = node.attrs {
4988                let id = attrs
4989                    .get("id")
4990                    .and_then(serde_json::Value::as_str)
4991                    .unwrap_or("");
4992                let text = attrs
4993                    .get("text")
4994                    .and_then(serde_json::Value::as_str)
4995                    .unwrap_or("");
4996                let mut attr_parts = vec![format!("id={id}")];
4997                if let Some(ut) = attrs.get("userType").and_then(serde_json::Value::as_str) {
4998                    attr_parts.push(format!("userType={ut}"));
4999                }
5000                if let Some(al) = attrs.get("accessLevel").and_then(serde_json::Value::as_str) {
5001                    attr_parts.push(format!("accessLevel={al}"));
5002                }
5003                maybe_push_local_id(attrs, &mut attr_parts, opts);
5004                output.push_str(&format!(":mention[{text}]{{{}}}", attr_parts.join(" ")));
5005            }
5006        }
5007        "placeholder" => {
5008            if let Some(ref attrs) = node.attrs {
5009                let text = attrs
5010                    .get("text")
5011                    .and_then(serde_json::Value::as_str)
5012                    .unwrap_or("");
5013                output.push_str(&format!(":placeholder[{text}]"));
5014            }
5015        }
5016        "inlineExtension" => {
5017            if let Some(ref attrs) = node.attrs {
5018                let ext_type = attrs
5019                    .get("extensionType")
5020                    .and_then(serde_json::Value::as_str)
5021                    .unwrap_or("");
5022                let ext_key = attrs
5023                    .get("extensionKey")
5024                    .and_then(serde_json::Value::as_str)
5025                    .unwrap_or("");
5026                let fallback = node.text.as_deref().unwrap_or("");
5027                output.push_str(&format!(
5028                    ":extension[{fallback}]{{type={ext_type} key={ext_key}}}"
5029                ));
5030            }
5031        }
5032        "mediaInline" => {
5033            if let Some(ref attrs) = node.attrs {
5034                let mut attr_parts = Vec::new();
5035                if let Some(media_type) = attrs.get("type").and_then(serde_json::Value::as_str) {
5036                    attr_parts.push(format_kv("type", media_type));
5037                }
5038                if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
5039                    attr_parts.push(format_kv("id", id));
5040                }
5041                if let Some(collection) =
5042                    attrs.get("collection").and_then(serde_json::Value::as_str)
5043                {
5044                    attr_parts.push(format_kv("collection", collection));
5045                }
5046                if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
5047                    attr_parts.push(format_kv("url", url));
5048                }
5049                if let Some(alt) = attrs.get("alt").and_then(serde_json::Value::as_str) {
5050                    attr_parts.push(format_kv("alt", alt));
5051                }
5052                if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
5053                    attr_parts.push(format!("width={width}"));
5054                }
5055                if let Some(height) = attrs.get("height").and_then(serde_json::Value::as_u64) {
5056                    attr_parts.push(format!("height={height}"));
5057                }
5058                maybe_push_local_id(attrs, &mut attr_parts, opts);
5059                output.push_str(&format!(":media-inline[]{{{}}}", attr_parts.join(" ")));
5060            }
5061        }
5062        _ => {
5063            // Preserve an unsupported inline node losslessly as an
5064            // `:adf-unsupported[]{json="…"}` directive carrying the full node
5065            // JSON — the inline mirror of the block-level ```adf-unsupported```
5066            // fence (ADR-0029). Read back by `try_dispatch_inline_directive`,
5067            // so a read → edit → write round-trips instead of collapsing to a
5068            // lossy `<!-- unsupported inline -->` comment (issue #1117).
5069            if let Ok(json) = serde_json::to_string(node) {
5070                output.push_str(":adf-unsupported[]{");
5071                output.push_str(&format_kv("json", &json));
5072                output.push('}');
5073            }
5074        }
5075    }
5076}
5077
5078/// Renders text with ADF marks applied as markdown syntax.
5079///
5080/// Mark ordering is preserved by walking the marks array in order and emitting
5081/// one wrapper per mark (outermost first, innermost last).  The resulting
5082/// markdown round-trips back to the original mark sequence because the parser
5083/// reconstructs marks outside-in from the nested delimiter structure.
5084///
5085/// When both `strong` and `em` are present, em is rendered with `_` instead of
5086/// `*` to avoid ambiguity (e.g., `_**text**_` rather than `***text***`).  The
5087/// single exception is `[strong, em]` (exactly those two marks in that order),
5088/// which is rendered as `***text***` to preserve the familiar compact form;
5089/// the parser's triple-delimiter rule round-trips it back to `[strong, em]`.
5090fn render_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
5091    if marks.iter().any(|m| m.mark_type == "code") {
5092        render_code_marked_text(text, marks, output);
5093        return;
5094    }
5095
5096    let has_link = marks.iter().any(|m| m.mark_type == "link");
5097    let has_strong = marks.iter().any(|m| m.mark_type == "strong");
5098    let has_em = marks.iter().any(|m| m.mark_type == "em");
5099
5100    // Compact form for the common [strong, em] case: ***text***.  em is
5101    // rendered with `*` here (as part of the `***` triple delimiter), so
5102    // underscores in the content don't need escaping.
5103    if marks.len() == 2 && marks[0].mark_type == "strong" && marks[1].mark_type == "em" {
5104        let escaped = escape_emphasis_markers(text);
5105        let escaped = escape_emoji_shortcodes(&escaped);
5106        let escaped = escape_backticks(&escaped);
5107        let escaped = escape_bare_urls(&escaped);
5108        output.push_str("***");
5109        output.push_str(&escaped);
5110        output.push_str("***");
5111        return;
5112    }
5113
5114    // When both strong and em are present (in any order), em uses `_` instead
5115    // of `*` to avoid the `***` triple-delimiter ambiguity.  Otherwise em uses
5116    // `*`, which sidesteps intraword-underscore pitfalls for plain em text.
5117    let em_delim = if has_strong && has_em { "_" } else { "*" };
5118
5119    // Text must also escape `_` when em renders as `_..._` — otherwise any
5120    // underscore in the content would close the emphasis span early.
5121    let escaped = if em_delim == "_" {
5122        escape_emphasis_markers_with_underscore(text)
5123    } else {
5124        escape_emphasis_markers(text)
5125    };
5126    let escaped = escape_emoji_shortcodes(&escaped);
5127    let escaped = escape_backticks(&escaped);
5128    // Always escape bare URLs so they are not re-parsed as `inlineCard`
5129    // nodes on round-trip.  When the text carries a link mark, also escape
5130    // `[` and `]` so they do not terminate the enclosing `[…]` link syntax
5131    // (issue #493).  Escaping bare URLs inside link text additionally
5132    // prevents `\[`/`\]` escapes from leaking through the URL-as-link-text
5133    // fast path and from corrupting an auto-detected bare URL inside the
5134    // link display text (issue #551).
5135    let escaped = escape_bare_urls(&escaped);
5136    let escaped = if has_link {
5137        escape_link_brackets(&escaped)
5138    } else {
5139        escaped
5140    };
5141
5142    // Collect (open, close) wrappers in mark order, outermost first.  Consecutive
5143    // span-attr or bracketed-span marks that happen to be in the parser's
5144    // canonical order (so the merged wrapper parses back to the same mark
5145    // sequence) are merged into a single wrapper; otherwise each mark gets its
5146    // own nested wrapper so that the mark ordering survives the round-trip.
5147    let mut wrappers: Vec<(String, String)> = Vec::new();
5148    let mut i = 0;
5149    while i < marks.len() {
5150        match marks[i].mark_type.as_str() {
5151            "em" => {
5152                wrappers.push((em_delim.to_string(), em_delim.to_string()));
5153                i += 1;
5154            }
5155            "strong" => {
5156                wrappers.push(("**".to_string(), "**".to_string()));
5157                i += 1;
5158            }
5159            "strike" => {
5160                wrappers.push(("~~".to_string(), "~~".to_string()));
5161                i += 1;
5162            }
5163            "link" => {
5164                let href = link_href(&marks[i]);
5165                wrappers.push(("[".to_string(), format!("]({href})")));
5166                i += 1;
5167            }
5168            "textColor" | "backgroundColor" | "subsup" => {
5169                let start = i;
5170                while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
5171                    i += 1;
5172                }
5173                emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
5174            }
5175            "underline" | "annotation" => {
5176                let start = i;
5177                while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
5178                    i += 1;
5179                }
5180                emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
5181            }
5182            _ => {
5183                i += 1;
5184            }
5185        }
5186    }
5187
5188    // Apply wrappers from innermost (last) to outermost (first).
5189    let mut result = escaped;
5190    for (open, close) in wrappers.iter().rev() {
5191        result.insert_str(0, open);
5192        result.push_str(close);
5193    }
5194    output.push_str(&result);
5195}
5196
5197/// Renders a text node with a `code` mark.  Code content is emitted verbatim
5198/// inside backticks, optionally wrapped by a link and/or by `:span`/bracketed-
5199/// span carrying span-attr (`textColor`, `backgroundColor`, `subsup`) and
5200/// bracketed-span (`underline`, `annotation`) marks.  No `em`/`strong`/`strike`
5201/// formatting is applied because markdown code spans do not support nested
5202/// emphasis (issue #554: previously textColor/bg/subsup/underline were
5203/// silently dropped when combined with a code mark).
5204fn render_code_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
5205    let link_mark = marks.iter().find(|m| m.mark_type == "link");
5206
5207    let mut code_str = String::new();
5208    if let Some(link_mark) = link_mark {
5209        let href = link_href(link_mark);
5210        code_str.push('[');
5211        render_inline_code(text, &mut code_str);
5212        code_str.push_str("](");
5213        code_str.push_str(href);
5214        code_str.push(')');
5215    } else {
5216        render_inline_code(text, &mut code_str);
5217    }
5218
5219    // Build wrappers (outermost first) for span-attr and bracketed-span runs,
5220    // walking marks in order so the round-trip preserves mark ordering.
5221    let mut wrappers: Vec<(String, String)> = Vec::new();
5222    let mut i = 0;
5223    while i < marks.len() {
5224        match marks[i].mark_type.as_str() {
5225            "textColor" | "backgroundColor" | "subsup" => {
5226                let start = i;
5227                while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
5228                    i += 1;
5229                }
5230                emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
5231            }
5232            "underline" | "annotation" => {
5233                let start = i;
5234                while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
5235                    i += 1;
5236                }
5237                emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
5238            }
5239            _ => {
5240                i += 1;
5241            }
5242        }
5243    }
5244
5245    // Apply wrappers from innermost (last) to outermost (first).
5246    let mut result = code_str;
5247    for (open, close) in wrappers.iter().rev() {
5248        result.insert_str(0, open);
5249        result.push_str(close);
5250    }
5251    output.push_str(&result);
5252}
5253
5254/// Collects `:span` attribute fragments (color, bg, sub/sup) for a single mark.
5255fn collect_span_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
5256    match mark.mark_type.as_str() {
5257        "textColor" => {
5258            if let Some(c) = mark
5259                .attrs
5260                .as_ref()
5261                .and_then(|a| a.get("color"))
5262                .and_then(serde_json::Value::as_str)
5263            {
5264                attrs.push(format!("color={c}"));
5265            }
5266        }
5267        "backgroundColor" => {
5268            if let Some(c) = mark
5269                .attrs
5270                .as_ref()
5271                .and_then(|a| a.get("color"))
5272                .and_then(serde_json::Value::as_str)
5273            {
5274                attrs.push(format!("bg={c}"));
5275            }
5276        }
5277        "subsup" => {
5278            if let Some(kind) = mark
5279                .attrs
5280                .as_ref()
5281                .and_then(|a| a.get("type"))
5282                .and_then(serde_json::Value::as_str)
5283            {
5284                attrs.push(kind.to_string());
5285            }
5286        }
5287        _ => {}
5288    }
5289}
5290
5291/// Collects bracketed-span attribute fragments for an `underline` or `annotation` mark.
5292fn collect_bracketed_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
5293    match mark.mark_type.as_str() {
5294        "underline" => attrs.push("underline".to_string()),
5295        "annotation" => {
5296            if let Some(ref a) = mark.attrs {
5297                if let Some(id) = a.get("id").and_then(serde_json::Value::as_str) {
5298                    let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
5299                    attrs.push(format!("annotation-id=\"{escaped}\""));
5300                }
5301                if let Some(at) = a.get("annotationType").and_then(serde_json::Value::as_str) {
5302                    attrs.push(format!("annotation-type={at}"));
5303                }
5304            }
5305        }
5306        _ => {}
5307    }
5308}
5309
5310fn is_span_attr_mark(mark_type: &str) -> bool {
5311    matches!(mark_type, "textColor" | "backgroundColor" | "subsup")
5312}
5313
5314fn is_bracketed_span_mark(mark_type: &str) -> bool {
5315    matches!(mark_type, "underline" | "annotation")
5316}
5317
5318/// Canonical ordering for span-attr marks, matching the order in which the
5319/// `:span` directive parser reads attributes (`color`, then `bg`, then
5320/// `sub`/`sup`).
5321fn span_attr_order(mark_type: &str) -> u8 {
5322    match mark_type {
5323        "textColor" => 0,
5324        "backgroundColor" => 1,
5325        "subsup" => 2,
5326        _ => u8::MAX,
5327    }
5328}
5329
5330/// Returns `true` if the run of span-attr marks is in the canonical order the
5331/// `:span` parser would produce.  A canonical run can be merged into one
5332/// `:span[...]{...}` wrapper; a non-canonical run must be split into one
5333/// nested wrapper per mark so the ordering survives the round-trip.
5334fn span_run_is_canonical(run: &[AdfMark]) -> bool {
5335    let mut prev = 0;
5336    for m in run {
5337        let order = span_attr_order(&m.mark_type);
5338        if order == u8::MAX || order < prev {
5339            return false;
5340        }
5341        prev = order;
5342    }
5343    true
5344}
5345
5346/// Returns `true` if the run of `underline`/`annotation` marks is in the
5347/// canonical order the bracketed-span parser produces (`underline` first,
5348/// followed by annotations).  A canonical run can be merged into one
5349/// `[...]{underline annotation-id=...}` wrapper.
5350fn bracketed_run_is_canonical(run: &[AdfMark]) -> bool {
5351    let mut seen_annotation = false;
5352    for m in run {
5353        match m.mark_type.as_str() {
5354            "underline" => {
5355                if seen_annotation {
5356                    return false;
5357                }
5358            }
5359            "annotation" => seen_annotation = true,
5360            _ => return false,
5361        }
5362    }
5363    true
5364}
5365
5366/// Emits one or more `:span[...]{...}` wrappers for a run of span-attr marks.
5367/// Canonical-order runs collapse into a single wrapper; non-canonical runs
5368/// emit one wrapper per mark so the order round-trips.
5369fn emit_span_attr_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
5370    if span_run_is_canonical(run) {
5371        let mut attrs = Vec::new();
5372        for m in run {
5373            collect_span_attr(m, &mut attrs);
5374        }
5375        wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
5376        return;
5377    }
5378    for m in run {
5379        let mut attrs = Vec::new();
5380        collect_span_attr(m, &mut attrs);
5381        wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
5382    }
5383}
5384
5385/// Emits one or more `[...]{...}` wrappers for a run of `underline`/`annotation`
5386/// marks.  Canonical-order runs collapse into a single wrapper; non-canonical
5387/// runs emit one wrapper per mark so the order round-trips.
5388fn emit_bracketed_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
5389    if bracketed_run_is_canonical(run) {
5390        let mut attrs = Vec::new();
5391        for m in run {
5392            collect_bracketed_attr(m, &mut attrs);
5393        }
5394        wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
5395        return;
5396    }
5397    for m in run {
5398        let mut attrs = Vec::new();
5399        collect_bracketed_attr(m, &mut attrs);
5400        wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
5401    }
5402}
5403
5404/// Extracts the href from a link mark.
5405fn link_href(mark: &AdfMark) -> &str {
5406    mark.attrs
5407        .as_ref()
5408        .and_then(|a| a.get("href"))
5409        .and_then(serde_json::Value::as_str)
5410        .unwrap_or("")
5411}
5412
5413#[cfg(test)]
5414#[allow(
5415    clippy::unwrap_used,
5416    clippy::expect_used,
5417    clippy::needless_update,
5418    clippy::needless_collect,
5419    duplicate_macro_attributes
5420)]
5421mod tests {
5422    use super::*;
5423
5424    // ── adf_to_plain_text tests ─────────────────────────────────────
5425
5426    #[test]
5427    fn adf_to_plain_text_single_paragraph() {
5428        let doc = markdown_to_adf("Hello world").unwrap();
5429        assert_eq!(adf_to_plain_text(&doc), "Hello world");
5430    }
5431
5432    #[test]
5433    fn adf_to_plain_text_multiple_paragraphs_space_separated() {
5434        let doc = markdown_to_adf("Alpha\n\nBeta").unwrap();
5435        let plain = adf_to_plain_text(&doc);
5436        // Blocks are space-separated so multi-paragraph anchor selections match.
5437        assert!(plain.contains("Alpha"));
5438        assert!(plain.contains("Beta"));
5439        assert_eq!(plain, "Alpha Beta");
5440    }
5441
5442    #[test]
5443    fn adf_to_plain_text_drops_marks_but_keeps_text() {
5444        let doc = markdown_to_adf("Hello **bold** world").unwrap();
5445        assert_eq!(adf_to_plain_text(&doc), "Hello bold world");
5446    }
5447
5448    #[test]
5449    fn adf_to_plain_text_empty_doc() {
5450        let doc = AdfDocument::new();
5451        assert_eq!(adf_to_plain_text(&doc), "");
5452    }
5453
5454    #[test]
5455    fn adf_to_plain_text_leading_empty_block_emits_no_extra_space() {
5456        // An empty paragraph followed by a text-bearing one must not produce
5457        // a leading space — the separator logic skips when `out` is still empty.
5458        let doc = AdfDocument {
5459            version: 1,
5460            doc_type: "doc".to_string(),
5461            content: vec![
5462                AdfNode {
5463                    node_type: "paragraph".to_string(),
5464                    attrs: None,
5465                    content: Some(vec![]),
5466                    text: None,
5467                    marks: None,
5468                    local_id: None,
5469                    parameters: None,
5470                },
5471                AdfNode {
5472                    node_type: "paragraph".to_string(),
5473                    attrs: None,
5474                    content: Some(vec![AdfNode::text("Hello")]),
5475                    text: None,
5476                    marks: None,
5477                    local_id: None,
5478                    parameters: None,
5479                },
5480            ],
5481        };
5482        assert_eq!(adf_to_plain_text(&doc), "Hello");
5483    }
5484
5485    // ── markdown_to_adf tests ───────────────────────────────────────
5486
5487    #[test]
5488    fn paragraph() {
5489        let doc = markdown_to_adf("Hello world").unwrap();
5490        assert_eq!(doc.content.len(), 1);
5491        assert_eq!(doc.content[0].node_type, "paragraph");
5492    }
5493
5494    #[test]
5495    fn heading_levels() {
5496        for level in 1..=6 {
5497            let hashes = "#".repeat(level);
5498            let md = format!("{hashes} Title");
5499            let doc = markdown_to_adf(&md).unwrap();
5500            assert_eq!(doc.content[0].node_type, "heading");
5501            let attrs = doc.content[0].attrs.as_ref().unwrap();
5502            assert_eq!(attrs["level"], level as u64);
5503        }
5504    }
5505
5506    // ── issue #1005: inline `code` is not permitted on headings ─────
5507
5508    #[test]
5509    fn heading_inline_code_mark_stripped() {
5510        // `### `GET /api`` parses an inline-code span; ADF forbids the `code`
5511        // mark on headings, so it is stripped and the text kept as plain.
5512        let doc = markdown_to_adf("### `GET /api`").unwrap();
5513        let heading = &doc.content[0];
5514        assert_eq!(heading.node_type, "heading");
5515        let content = heading.content.as_ref().unwrap();
5516        assert_eq!(content.len(), 1);
5517        assert_eq!(content[0].text.as_deref(), Some("GET /api"));
5518        assert!(
5519            content[0].marks.is_none(),
5520            "expected no marks, got: {:?}",
5521            content[0].marks
5522        );
5523    }
5524
5525    #[test]
5526    fn heading_code_strip_preserves_sibling_strong() {
5527        // `### **`x`**` → text `x` carrying `strong`; only `code` is removed.
5528        let doc = markdown_to_adf("### **`x`**").unwrap();
5529        let content = doc.content[0].content.as_ref().unwrap();
5530        let marks = content[0].marks.as_ref().unwrap();
5531        assert!(marks.iter().any(|m| m.mark_type == "strong"));
5532        assert!(!marks.iter().any(|m| m.mark_type == "code"));
5533    }
5534
5535    #[test]
5536    fn heading_code_strip_preserves_link() {
5537        // `### [`x`](url)` → text `x` carrying `link`; only `code` is removed.
5538        let doc = markdown_to_adf("### [`x`](https://e.com)").unwrap();
5539        let content = doc.content[0].content.as_ref().unwrap();
5540        let marks = content[0].marks.as_ref().unwrap();
5541        assert!(marks.iter().any(|m| m.mark_type == "link"));
5542        assert!(!marks.iter().any(|m| m.mark_type == "code"));
5543    }
5544
5545    #[test]
5546    fn heading_with_code_now_passes_validation() {
5547        // End-to-end guard: the converted document no longer trips the ADF
5548        // mark validator that previously rejected it at write time.
5549        let doc = markdown_to_adf("### `GET /api`").unwrap();
5550        let violations = crate::atlassian::adf_schema::validate_document(&doc);
5551        assert!(violations.is_empty(), "got: {violations:?}");
5552    }
5553
5554    #[test]
5555    fn paragraph_inline_code_mark_preserved() {
5556        // Regression guard: the strip is heading-only — paragraph inline code
5557        // keeps its `code` mark.
5558        let doc = markdown_to_adf("`x`").unwrap();
5559        let content = doc.content[0].content.as_ref().unwrap();
5560        let marks = content[0].marks.as_ref().unwrap();
5561        assert!(marks.iter().any(|m| m.mark_type == "code"));
5562    }
5563
5564    // ── strong/em/strike around inline code split silently (issue #1391) ──
5565
5566    /// Returns the mark types on `node`, empty when unmarked.
5567    fn mark_types(node: &AdfNode) -> Vec<&str> {
5568        node.marks
5569            .as_ref()
5570            .map(|marks| marks.iter().map(|m| m.mark_type.as_str()).collect())
5571            .unwrap_or_default()
5572    }
5573
5574    #[test]
5575    fn split_strong_code_single_run() {
5576        // `**`x`**` → one run carrying only `code`; the leaked `strong` is
5577        // dropped so the ADF is legal and renders identically.
5578        let doc = markdown_to_adf("**`x`**").unwrap();
5579        let content = doc.content[0].content.as_ref().unwrap();
5580        assert_eq!(content.len(), 1);
5581        assert_eq!(content[0].text.as_deref(), Some("x"));
5582        assert_eq!(mark_types(&content[0]), vec!["code"]);
5583    }
5584
5585    #[test]
5586    fn split_em_code_single_run() {
5587        let doc = markdown_to_adf("*`x`*").unwrap();
5588        let content = doc.content[0].content.as_ref().unwrap();
5589        assert_eq!(mark_types(&content[0]), vec!["code"]);
5590    }
5591
5592    #[test]
5593    fn split_strike_code_single_run() {
5594        let doc = markdown_to_adf("~~`x`~~").unwrap();
5595        let content = doc.content[0].content.as_ref().unwrap();
5596        assert_eq!(mark_types(&content[0]), vec!["code"]);
5597    }
5598
5599    #[test]
5600    fn split_strong_em_code_drops_both() {
5601        let doc = markdown_to_adf("***`x`***").unwrap();
5602        let content = doc.content[0].content.as_ref().unwrap();
5603        assert_eq!(mark_types(&content[0]), vec!["code"]);
5604    }
5605
5606    #[test]
5607    fn split_nested_code_keeps_sibling_strong() {
5608        // `**foo `bar` baz**` → three runs; only the code run drops `strong`,
5609        // so the bold prose around the identifier is preserved.
5610        let doc = markdown_to_adf("**foo `bar` baz**").unwrap();
5611        let content = doc.content[0].content.as_ref().unwrap();
5612        assert_eq!(content.len(), 3);
5613        assert_eq!(content[0].text.as_deref(), Some("foo "));
5614        assert_eq!(mark_types(&content[0]), vec!["strong"]);
5615        assert_eq!(content[1].text.as_deref(), Some("bar"));
5616        assert_eq!(mark_types(&content[1]), vec!["code"]);
5617        assert_eq!(content[2].text.as_deref(), Some(" baz"));
5618        assert_eq!(mark_types(&content[2]), vec!["strong"]);
5619    }
5620
5621    #[test]
5622    fn split_multiple_hits_in_one_line() {
5623        let doc = markdown_to_adf("**`x`** and **`y`**").unwrap();
5624        let content = doc.content[0].content.as_ref().unwrap();
5625        assert_eq!(content.len(), 3);
5626        assert_eq!(mark_types(&content[0]), vec!["code"]);
5627        assert_eq!(content[1].text.as_deref(), Some(" and "));
5628        assert!(content[1].marks.is_none());
5629        assert_eq!(mark_types(&content[2]), vec!["code"]);
5630    }
5631
5632    #[test]
5633    fn split_leaves_separate_runs_untouched() {
5634        // Marks already on separate runs never triggered the constraint.
5635        let doc = markdown_to_adf("**foo** `bar`").unwrap();
5636        let content = doc.content[0].content.as_ref().unwrap();
5637        assert_eq!(mark_types(&content[0]), vec!["strong"]);
5638        assert_eq!(content[1].text.as_deref(), Some(" "));
5639        assert_eq!(mark_types(&content[2]), vec!["code"]);
5640    }
5641
5642    #[test]
5643    fn split_preserves_link_on_code_run() {
5644        // `code+link` is legal ADF; only `strong` drops off the code run.
5645        let doc = markdown_to_adf("**[`x`](https://e.com)**").unwrap();
5646        let content = doc.content[0].content.as_ref().unwrap();
5647        assert_eq!(mark_types(&content[0]), vec!["link", "code"]);
5648    }
5649
5650    #[test]
5651    fn split_keeps_span_syntax_marks_for_validator() {
5652        // `[`x`]{underline}` is an explicit span-syntax request: the converter
5653        // preserves it byte-faithfully (issue #554 fidelity) and the mark
5654        // validator still reports the illegal combination at write time.
5655        let doc = markdown_to_adf("[`x`]{underline}").unwrap();
5656        let content = doc.content[0].content.as_ref().unwrap();
5657        assert_eq!(mark_types(&content[0]), vec!["underline", "code"]);
5658    }
5659
5660    #[test]
5661    fn split_runs_after_heading_strip_bold_kept() {
5662        // Ordering guard: the heading builder strips `code` first (#1005), so
5663        // the document-level split pass must leave the heading's bold intact
5664        // rather than dropping `strong` before the strip runs.
5665        let doc = markdown_to_adf("# **`x`**").unwrap();
5666        let content = doc.content[0].content.as_ref().unwrap();
5667        assert_eq!(mark_types(&content[0]), vec!["strong"]);
5668    }
5669
5670    #[test]
5671    fn split_applies_inside_containers() {
5672        // The pass recurses through block containers, not just paragraphs.
5673        let doc = markdown_to_adf("- **`x`**").unwrap();
5674        let list_item = &doc.content[0].content.as_ref().unwrap()[0];
5675        let paragraph = &list_item.content.as_ref().unwrap()[0];
5676        let content = paragraph.content.as_ref().unwrap();
5677        assert_eq!(mark_types(&content[0]), vec!["code"]);
5678    }
5679
5680    #[test]
5681    fn split_strong_code_passes_validation() {
5682        // End-to-end guard: the issue #1391 reproducer no longer trips the
5683        // mark validator that previously rejected the whole write.
5684        let doc = markdown_to_adf(
5685            "**Testing `widget` itself is out of scope for this ticket** — coordinate \
5686             with the team that owns the widget credentials.",
5687        )
5688        .unwrap();
5689        let violations = crate::atlassian::adf_schema::validate_document(&doc);
5690        assert!(violations.is_empty(), "got: {violations:?}");
5691    }
5692
5693    #[test]
5694    fn code_block() {
5695        let md = "```rust\nfn main() {}\n```";
5696        let doc = markdown_to_adf(md).unwrap();
5697        assert_eq!(doc.content[0].node_type, "codeBlock");
5698        let attrs = doc.content[0].attrs.as_ref().unwrap();
5699        assert_eq!(attrs["language"], "rust");
5700    }
5701
5702    #[test]
5703    fn code_block_no_language() {
5704        let md = "```\nsome code\n```";
5705        let doc = markdown_to_adf(md).unwrap();
5706        assert_eq!(doc.content[0].node_type, "codeBlock");
5707        assert!(doc.content[0].attrs.is_none());
5708    }
5709
5710    #[test]
5711    fn code_block_empty_language() {
5712        let md = "```\"\"\nsome code\n```";
5713        let doc = markdown_to_adf(md).unwrap();
5714        assert_eq!(doc.content[0].node_type, "codeBlock");
5715        let attrs = doc.content[0].attrs.as_ref().unwrap();
5716        assert_eq!(attrs["language"], "");
5717    }
5718
5719    #[test]
5720    fn horizontal_rule() {
5721        let doc = markdown_to_adf("---").unwrap();
5722        assert_eq!(doc.content[0].node_type, "rule");
5723    }
5724
5725    #[test]
5726    fn horizontal_rule_stars() {
5727        let doc = markdown_to_adf("***").unwrap();
5728        assert_eq!(doc.content[0].node_type, "rule");
5729    }
5730
5731    #[test]
5732    fn blockquote() {
5733        let md = "> This is a quote\n> Second line";
5734        let doc = markdown_to_adf(md).unwrap();
5735        assert_eq!(doc.content[0].node_type, "blockquote");
5736    }
5737
5738    #[test]
5739    fn bullet_list() {
5740        let md = "- Item 1\n- Item 2\n- Item 3";
5741        let doc = markdown_to_adf(md).unwrap();
5742        assert_eq!(doc.content[0].node_type, "bulletList");
5743        let items = doc.content[0].content.as_ref().unwrap();
5744        assert_eq!(items.len(), 3);
5745    }
5746
5747    #[test]
5748    fn ordered_list() {
5749        let md = "1. First\n2. Second\n3. Third";
5750        let doc = markdown_to_adf(md).unwrap();
5751        assert_eq!(doc.content[0].node_type, "orderedList");
5752        let items = doc.content[0].content.as_ref().unwrap();
5753        assert_eq!(items.len(), 3);
5754    }
5755
5756    // ── nested list sibling detection (issue #1727) ─────────────────
5757
5758    /// Returns the text of a list item's first paragraph.
5759    fn item_text(item: &AdfNode) -> &str {
5760        item.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
5761            .text
5762            .as_deref()
5763            .unwrap()
5764    }
5765
5766    /// Regression: a sibling item of a nested list (same indent as the
5767    /// preceding nested item) must stay a sibling instead of becoming a
5768    /// child of the preceding item, which rendered as a "staircase" in
5769    /// Jira (first sub-item letters, the rest roman numerals).
5770    #[test]
5771    fn nested_ordered_list_sibling_stays_sibling() {
5772        let md = "1. Родитель:\n    1. Пункт А\n    2. Пункт Б\n2. Следующий пункт";
5773        let doc = markdown_to_adf(md).unwrap();
5774        let outer = doc.content[0].content.as_ref().unwrap();
5775        assert_eq!(outer.len(), 2);
5776        // First item: paragraph plus one nested orderedList with two
5777        // sibling items.
5778        let first = outer[0].content.as_ref().unwrap();
5779        assert_eq!(first.len(), 2);
5780        assert_eq!(first[0].node_type, "paragraph");
5781        assert_eq!(first[1].node_type, "orderedList");
5782        // A start-at-1 nested list carries no `order` attr.
5783        assert!(first[1].attrs.is_none());
5784        let nested_items = first[1].content.as_ref().unwrap();
5785        assert_eq!(nested_items.len(), 2);
5786        assert_eq!(item_text(&nested_items[0]), "Пункт А");
5787        assert_eq!(item_text(&nested_items[1]), "Пункт Б");
5788        // The sibling line was not swallowed as sub-content of «Пункт А»:
5789        // the second top-level item is a plain single-paragraph listItem.
5790        let second = outer[1].content.as_ref().unwrap();
5791        assert_eq!(second.len(), 1);
5792        assert_eq!(item_text(&outer[1]), "Следующий пункт");
5793    }
5794
5795    #[test]
5796    fn nested_unordered_list_sibling_stays_sibling() {
5797        let md = "- Родитель:\n    - Пункт А\n    - Пункт Б\n- Следующий пункт";
5798        let doc = markdown_to_adf(md).unwrap();
5799        let outer = doc.content[0].content.as_ref().unwrap();
5800        assert_eq!(outer.len(), 2);
5801        let first = outer[0].content.as_ref().unwrap();
5802        assert_eq!(first.len(), 2);
5803        assert_eq!(first[1].node_type, "bulletList");
5804        assert_eq!(first[1].content.as_ref().unwrap().len(), 2);
5805        assert_eq!(item_text(&outer[1]), "Следующий пункт");
5806    }
5807
5808    #[test]
5809    fn mixed_nested_list_ordered_outer_unordered_inner() {
5810        let md = "1. Родитель:\n    - Пункт А\n    - Пункт Б\n2. Следующий пункт";
5811        let doc = markdown_to_adf(md).unwrap();
5812        let outer = doc.content[0].content.as_ref().unwrap();
5813        assert_eq!(outer.len(), 2);
5814        let first = outer[0].content.as_ref().unwrap();
5815        assert_eq!(first[1].node_type, "bulletList");
5816        assert_eq!(first[1].content.as_ref().unwrap().len(), 2);
5817        assert_eq!(item_text(&outer[1]), "Следующий пункт");
5818    }
5819
5820    #[test]
5821    fn mixed_nested_list_unordered_outer_ordered_inner() {
5822        let md = "- Родитель:\n    1. Пункт А\n    2. Пункт Б\n- Следующий пункт";
5823        let doc = markdown_to_adf(md).unwrap();
5824        let outer = doc.content[0].content.as_ref().unwrap();
5825        assert_eq!(outer.len(), 2);
5826        let first = outer[0].content.as_ref().unwrap();
5827        assert_eq!(first[1].node_type, "orderedList");
5828        assert!(first[1].attrs.is_none());
5829        assert_eq!(first[1].content.as_ref().unwrap().len(), 2);
5830        assert_eq!(item_text(&outer[1]), "Следующий пункт");
5831    }
5832
5833    /// Top-level numbering continues after the nested block: following
5834    /// items stay at the outer level with their own numbers.
5835    #[test]
5836    fn top_level_numbering_continues_after_nested_block() {
5837        let md = "1. Первый\n    1. Вложенный\n    2. Тоже вложенный\n2. Второй\n3. Третий";
5838        let doc = markdown_to_adf(md).unwrap();
5839        let list = &doc.content[0];
5840        assert!(list.attrs.is_none());
5841        let outer = list.content.as_ref().unwrap();
5842        assert_eq!(outer.len(), 3);
5843        assert_eq!(item_text(&outer[1]), "Второй");
5844        assert_eq!(item_text(&outer[2]), "Третий");
5845        // Each trailing item is a plain single-paragraph listItem.
5846        for item in &outer[1..] {
5847            assert_eq!(item.content.as_ref().unwrap().len(), 1);
5848        }
5849    }
5850
5851    /// Sibling detection works at any nesting depth: the deepest level
5852    /// keeps its two items as siblings inside one list.
5853    #[test]
5854    fn nested_list_three_levels_sibling_stays_sibling() {
5855        let md = "1. L1\n    1. L2a\n        1. L3a\n        2. L3b\n    2. L2b\n2. L1b";
5856        let doc = markdown_to_adf(md).unwrap();
5857        let outer = doc.content[0].content.as_ref().unwrap();
5858        assert_eq!(outer.len(), 2);
5859        // Level 2.
5860        let l1 = outer[0].content.as_ref().unwrap();
5861        assert_eq!(l1.len(), 2);
5862        let level2 = l1[1].content.as_ref().unwrap();
5863        assert_eq!(level2.len(), 2);
5864        assert_eq!(item_text(&level2[0]), "L2a");
5865        assert_eq!(item_text(&level2[1]), "L2b");
5866        // Level 3: two siblings under L2a.
5867        let l2a = level2[0].content.as_ref().unwrap();
5868        assert_eq!(l2a.len(), 2);
5869        let level3 = l2a[1].content.as_ref().unwrap();
5870        assert_eq!(level3.len(), 2);
5871        assert_eq!(item_text(&level3[0]), "L3a");
5872        assert_eq!(item_text(&level3[1]), "L3b");
5873        // L2b and L1b stay plain single-paragraph listItems.
5874        assert_eq!(level2[1].content.as_ref().unwrap().len(), 1);
5875        assert_eq!(outer[1].content.as_ref().unwrap().len(), 1);
5876    }
5877
5878    #[test]
5879    fn nested_task_list_sibling_stays_sibling() {
5880        let md = "- [ ] Родитель\n    - [ ] Подзадача А\n    - [ ] Подзадача Б";
5881        let doc = markdown_to_adf(md).unwrap();
5882        let items = doc.content[0].content.as_ref().unwrap();
5883        // The indented taskList becomes a sibling of the parent taskItem
5884        // (issue #506) and keeps both sub-items as siblings.
5885        assert_eq!(items.len(), 2);
5886        assert_eq!(items[1].node_type, "taskList");
5887        let sub_items = items[1].content.as_ref().unwrap();
5888        assert_eq!(sub_items.len(), 2);
5889        assert_eq!(sub_items[0].node_type, "taskItem");
5890        assert_eq!(sub_items[1].node_type, "taskItem");
5891    }
5892
5893    /// A nested item whose text ends in a hardBreak marker (two trailing
5894    /// spaces) must not swallow the next sibling as continuation text.
5895    #[test]
5896    fn nested_item_ending_in_hardbreak_keeps_next_sibling() {
5897        let md = "1. Родитель:\n    1. Пункт А  \n    2. Пункт Б\n2. Следующий";
5898        let doc = markdown_to_adf(md).unwrap();
5899        let outer = doc.content[0].content.as_ref().unwrap();
5900        assert_eq!(outer.len(), 2);
5901        let nested = outer[0].content.as_ref().unwrap()[1]
5902            .content
5903            .as_ref()
5904            .unwrap();
5905        assert_eq!(nested.len(), 2, "nested items: {nested:?}");
5906        assert_eq!(item_text(&nested[1]), "Пункт Б");
5907    }
5908
5909    /// A continuation paragraph written at the nested marker's own column
5910    /// still belongs to that nested item (unchanged from before #1727).
5911    #[test]
5912    fn nested_item_continuation_paragraph_stays_in_item() {
5913        let md = "1. Parent:\n    1. Child one\n    Continuation of child one";
5914        let doc = markdown_to_adf(md).unwrap();
5915        let outer = doc.content[0].content.as_ref().unwrap();
5916        assert_eq!(outer.len(), 1);
5917        let parent = outer[0].content.as_ref().unwrap();
5918        assert_eq!(parent.len(), 2, "parent content: {parent:?}");
5919        let child = parent[1].content.as_ref().unwrap()[0]
5920            .content
5921            .as_ref()
5922            .unwrap();
5923        assert_eq!(child.len(), 2);
5924        assert_eq!(child[1].node_type, "paragraph");
5925        assert_eq!(
5926            child[1].content.as_ref().unwrap()[0].text.as_deref(),
5927            Some("Continuation of child one")
5928        );
5929    }
5930
5931    /// A fenced code block written at the nested marker's own column still
5932    /// belongs to that nested item (unchanged from before #1727).
5933    #[test]
5934    fn nested_item_fenced_code_block_stays_in_item() {
5935        let md = "- Parent\n    - Child\n    ```bash\n    echo hi\n    ```";
5936        let doc = markdown_to_adf(md).unwrap();
5937        let parent = doc.content[0].content.as_ref().unwrap()[0]
5938            .content
5939            .as_ref()
5940            .unwrap();
5941        let child = parent[1].content.as_ref().unwrap()[0]
5942            .content
5943            .as_ref()
5944            .unwrap();
5945        assert_eq!(child.len(), 2, "child content: {child:?}");
5946        assert_eq!(child[1].node_type, "codeBlock");
5947        assert_eq!(
5948            child[1].content.as_ref().unwrap()[0].text.as_deref(),
5949            Some("echo hi")
5950        );
5951    }
5952
5953    /// Indentation is counted in ASCII spaces: an NBSP before the parent
5954    /// marker (common in text pasted from Confluence) must not push the
5955    /// sibling threshold out and un-nest genuine 2-space children.
5956    #[test]
5957    fn nbsp_before_marker_keeps_children_nested() {
5958        let md = "\u{a0}- Parent\n  - Child\n- Next";
5959        let doc = markdown_to_adf(md).unwrap();
5960        let outer = doc.content[0].content.as_ref().unwrap();
5961        assert_eq!(outer.len(), 2, "outer items: {outer:?}");
5962        let parent = outer[0].content.as_ref().unwrap();
5963        assert_eq!(parent.len(), 2);
5964        assert_eq!(parent[1].node_type, "bulletList");
5965        assert_eq!(item_text(&outer[1]), "Next");
5966    }
5967
5968    // ── surplus continuation indent (issue #1735) ────────────
5969
5970    /// Continuation text written at CommonMark's natural column for a `1. `
5971    /// marker (3 spaces) must not keep the surplus space as literal text.
5972    #[test]
5973    fn ordered_item_continuation_at_natural_column_no_surplus() {
5974        let md = "1. a\n   more\n";
5975        let doc = markdown_to_adf(md).unwrap();
5976        let item = doc.content[0].content.as_ref().unwrap()[0]
5977            .content
5978            .as_ref()
5979            .unwrap();
5980        assert_eq!(item.len(), 2, "item content: {item:?}");
5981        assert_eq!(
5982            item[1].content.as_ref().unwrap()[0].text.as_deref(),
5983            Some("more")
5984        );
5985    }
5986
5987    /// Same as above, with the #1732 blank-line lookahead routing the
5988    /// continuation through the blank-line branch instead of the main loop.
5989    #[test]
5990    fn ordered_item_continuation_after_blank_at_natural_column_no_surplus() {
5991        let md = "1. a\n\n   more\n";
5992        let doc = markdown_to_adf(md).unwrap();
5993        let item = doc.content[0].content.as_ref().unwrap()[0]
5994            .content
5995            .as_ref()
5996            .unwrap();
5997        assert_eq!(item.len(), 2, "item content: {item:?}");
5998        assert_eq!(
5999            item[1].content.as_ref().unwrap()[0].text.as_deref(),
6000            Some("more")
6001        );
6002    }
6003
6004    /// A bullet item's continuation indented deeper than the mandatory
6005    /// 2-space column (4 spaces here) must not keep any surplus.
6006    #[test]
6007    fn bullet_item_continuation_deep_indent_no_surplus() {
6008        let md = "- a\n    deep\n";
6009        let doc = markdown_to_adf(md).unwrap();
6010        let item = doc.content[0].content.as_ref().unwrap()[0]
6011            .content
6012            .as_ref()
6013            .unwrap();
6014        assert_eq!(item.len(), 2, "item content: {item:?}");
6015        assert_eq!(
6016            item[1].content.as_ref().unwrap()[0].text.as_deref(),
6017            Some("deep")
6018        );
6019    }
6020
6021    /// A fence written one column deeper than the item's natural content
6022    /// column: before this fix it isn't even recognized as a fence (the
6023    /// opener keeps a leading space and fails the column-0 check); the
6024    /// dedent both removes the surplus and lets the fence be recognized.
6025    #[test]
6026    fn item_fence_one_column_deeper_than_natural_is_recognized_and_dedented() {
6027        let md = "- a\n   ```bash\n   echo hi\n   ```";
6028        let doc = markdown_to_adf(md).unwrap();
6029        let item = doc.content[0].content.as_ref().unwrap()[0]
6030            .content
6031            .as_ref()
6032            .unwrap();
6033        assert_eq!(item.len(), 2, "item content: {item:?}");
6034        assert_eq!(item[1].node_type, "codeBlock");
6035        assert_eq!(
6036            item[1].content.as_ref().unwrap()[0].text.as_deref(),
6037            Some("echo hi")
6038        );
6039    }
6040
6041    /// Surplus indentation is removed independently at every nesting depth
6042    /// for a hand-formatted 4-space-per-level list with a genuine
6043    /// continuation line before each nested marker.
6044    #[test]
6045    fn surplus_indent_removed_at_every_nesting_depth() {
6046        let md = "- L1\n    text1\n    - L2\n        text2\n        - L3\n            text3\n";
6047        let doc = markdown_to_adf(md).unwrap();
6048        let l1 = doc.content[0].content.as_ref().unwrap()[0]
6049            .content
6050            .as_ref()
6051            .unwrap();
6052        assert_eq!(l1.len(), 3, "L1 content: {l1:?}");
6053        assert_eq!(
6054            l1[1].content.as_ref().unwrap()[0].text.as_deref(),
6055            Some("text1")
6056        );
6057        let l2 = l1[2].content.as_ref().unwrap()[0].content.as_ref().unwrap();
6058        assert_eq!(l2.len(), 3, "L2 content: {l2:?}");
6059        assert_eq!(
6060            l2[1].content.as_ref().unwrap()[0].text.as_deref(),
6061            Some("text2")
6062        );
6063        let l3 = l2[2].content.as_ref().unwrap()[0].content.as_ref().unwrap();
6064        assert_eq!(l3.len(), 2, "L3 content: {l3:?}");
6065        assert_eq!(
6066            l3[1].content.as_ref().unwrap()[0].text.as_deref(),
6067            Some("text3")
6068        );
6069    }
6070
6071    /// Same fix at the task-list call site.
6072    #[test]
6073    fn task_item_continuation_at_natural_column_no_surplus() {
6074        let md = "- [ ] a\n   deep\n";
6075        let doc = markdown_to_adf(md).unwrap();
6076        let item = &doc.content[0].content.as_ref().unwrap()[0];
6077        let content = item.content.as_ref().unwrap();
6078        assert_eq!(content.len(), 2, "task item content: {content:?}");
6079        assert_eq!(content[1].node_type, "paragraph");
6080        assert_eq!(
6081            content[1].content.as_ref().unwrap()[0].text.as_deref(),
6082            Some("deep")
6083        );
6084    }
6085
6086    // ── blank line before indented content (issue #1732) ────────────
6087
6088    /// A bare blank line followed by content indented to the item's column
6089    /// does not end the item: the nested list stays nested and the
6090    /// following top-level sibling is not swallowed as its own content.
6091    #[test]
6092    fn blank_line_before_nested_ordered_list_keeps_it_nested() {
6093        let md = "1. Родитель:\n\n    1. А\n    2. Б\n2. Следующий";
6094        let doc = markdown_to_adf(md).unwrap();
6095        let outer = doc.content[0].content.as_ref().unwrap();
6096        assert_eq!(outer.len(), 2, "outer items: {outer:?}");
6097        let first = outer[0].content.as_ref().unwrap();
6098        assert_eq!(first.len(), 2, "first item content: {first:?}");
6099        assert_eq!(first[0].node_type, "paragraph");
6100        assert_eq!(item_text(&outer[0]), "Родитель:");
6101        assert_eq!(first[1].node_type, "orderedList");
6102        let nested_items = first[1].content.as_ref().unwrap();
6103        assert_eq!(nested_items.len(), 2, "nested items: {nested_items:?}");
6104        assert_eq!(item_text(&nested_items[0]), "А");
6105        assert_eq!(item_text(&nested_items[1]), "Б");
6106        // The second top-level item was not swallowed as sub-content of
6107        // the nested list and carries no leaked indentation.
6108        let second = outer[1].content.as_ref().unwrap();
6109        assert_eq!(second.len(), 1, "second item content: {second:?}");
6110        assert_eq!(item_text(&outer[1]), "Следующий");
6111    }
6112
6113    /// A bare blank line followed by an indented plain paragraph is a
6114    /// continuation paragraph of the same list item, not a top-level
6115    /// paragraph with leaked indentation.
6116    #[test]
6117    fn blank_line_before_continuation_paragraph_stays_in_item() {
6118        let md = "- a\n\n  second paragraph\n- b";
6119        let doc = markdown_to_adf(md).unwrap();
6120        assert_eq!(doc.content.len(), 1, "top-level blocks: {:?}", doc.content);
6121        assert_eq!(doc.content[0].node_type, "bulletList");
6122        let items = doc.content[0].content.as_ref().unwrap();
6123        assert_eq!(items.len(), 2, "items: {items:?}");
6124        let first = items[0].content.as_ref().unwrap();
6125        assert_eq!(first.len(), 2, "first item content: {first:?}");
6126        assert_eq!(first[0].node_type, "paragraph");
6127        assert_eq!(item_text(&items[0]), "a");
6128        assert_eq!(first[1].node_type, "paragraph");
6129        assert_eq!(
6130            first[1].content.as_ref().unwrap()[0].text.as_deref(),
6131            Some("second paragraph")
6132        );
6133        assert_eq!(item_text(&items[1]), "b");
6134    }
6135
6136    /// A blank line followed by a non-indented line still ends the list —
6137    /// unchanged from before #1732.
6138    #[test]
6139    fn blank_line_before_unindented_line_still_ends_list() {
6140        let md = "- a\n\nPara";
6141        let doc = markdown_to_adf(md).unwrap();
6142        assert_eq!(doc.content.len(), 2, "top-level blocks: {:?}", doc.content);
6143        assert_eq!(doc.content[0].node_type, "bulletList");
6144        let items = doc.content[0].content.as_ref().unwrap();
6145        assert_eq!(items.len(), 1);
6146        assert_eq!(item_text(&items[0]), "a");
6147        assert_eq!(doc.content[1].node_type, "paragraph");
6148        assert_eq!(
6149            doc.content[1].content.as_ref().unwrap()[0].text.as_deref(),
6150            Some("Para")
6151        );
6152    }
6153
6154    /// Two or more consecutive bare blank lines before indented content are
6155    /// collected the same way as a single blank line.
6156    #[test]
6157    fn multiple_blank_lines_before_continuation_paragraph_stay_in_item() {
6158        let md = "- a\n\n\n  second paragraph";
6159        let doc = markdown_to_adf(md).unwrap();
6160        assert_eq!(doc.content.len(), 1, "top-level blocks: {:?}", doc.content);
6161        let items = doc.content[0].content.as_ref().unwrap();
6162        assert_eq!(items.len(), 1);
6163        let first = items[0].content.as_ref().unwrap();
6164        assert_eq!(first.len(), 2, "first item content: {first:?}");
6165        assert_eq!(
6166            first[1].content.as_ref().unwrap()[0].text.as_deref(),
6167            Some("second paragraph")
6168        );
6169    }
6170
6171    /// Sibling detection at a deeper nesting level still applies with a
6172    /// blank line before the deepest level's content (mirrors
6173    /// `nested_list_three_levels_sibling_stays_sibling`).
6174    #[test]
6175    fn blank_line_before_deepest_level_keeps_siblings() {
6176        let md = "1. L1\n    1. L2a\n\n        1. L3a\n        2. L3b\n    2. L2b\n2. L1b";
6177        let doc = markdown_to_adf(md).unwrap();
6178        let outer = doc.content[0].content.as_ref().unwrap();
6179        assert_eq!(outer.len(), 2);
6180        let l1 = outer[0].content.as_ref().unwrap();
6181        assert_eq!(l1.len(), 2);
6182        let level2 = l1[1].content.as_ref().unwrap();
6183        assert_eq!(level2.len(), 2);
6184        assert_eq!(item_text(&level2[0]), "L2a");
6185        assert_eq!(item_text(&level2[1]), "L2b");
6186        let l2a = level2[0].content.as_ref().unwrap();
6187        assert_eq!(l2a.len(), 2, "L2a content: {l2a:?}");
6188        let level3 = l2a[1].content.as_ref().unwrap();
6189        assert_eq!(level3.len(), 2, "level3 items: {level3:?}");
6190        assert_eq!(item_text(&level3[0]), "L3a");
6191        assert_eq!(item_text(&level3[1]), "L3b");
6192        assert_eq!(level2[1].content.as_ref().unwrap().len(), 1);
6193        assert_eq!(outer[1].content.as_ref().unwrap().len(), 1);
6194        assert_eq!(item_text(&outer[1]), "L1b");
6195    }
6196
6197    /// A blank line before an indented nested task list produces the same
6198    /// sibling-taskList shape (#506) as the two-space-blank form.
6199    #[test]
6200    fn blank_line_before_nested_task_list_matches_two_space_form() {
6201        let with_bare_blank = markdown_to_adf("- [ ] t\n\n  - [ ] sub").unwrap();
6202        let with_two_space_blank = markdown_to_adf("- [ ] t\n  \n  - [ ] sub").unwrap();
6203        assert_eq!(
6204            serde_json::to_value(&with_bare_blank).unwrap(),
6205            serde_json::to_value(&with_two_space_blank).unwrap()
6206        );
6207        let items = with_bare_blank.content[0].content.as_ref().unwrap();
6208        assert_eq!(items.len(), 2, "items: {items:?}");
6209        assert_eq!(items[0].node_type, "taskItem");
6210        assert_eq!(items[1].node_type, "taskList");
6211        let sub_items = items[1].content.as_ref().unwrap();
6212        assert_eq!(sub_items.len(), 1);
6213        assert_eq!(sub_items[0].node_type, "taskItem");
6214    }
6215
6216    /// A blank line followed by a same-indent sibling marker is a loose
6217    /// list (CommonMark): the parser splits it into two lists and
6218    /// [`merge_adjacent_lists`] fuses them back into one (issue #1729).
6219    #[test]
6220    fn blank_line_then_sibling_marker_becomes_one_list() {
6221        let md = "- a\n\n- b";
6222        let doc = markdown_to_adf(md).unwrap();
6223        assert_eq!(doc.content.len(), 1, "top-level blocks: {:?}", doc.content);
6224        assert_eq!(doc.content[0].node_type, "bulletList");
6225        let items = doc.content[0].content.as_ref().unwrap();
6226        assert_eq!(items.len(), 2, "items: {items:?}");
6227        assert_eq!(item_text(&items[0]), "a");
6228        assert_eq!(item_text(&items[1]), "b");
6229    }
6230
6231    /// Round-trip pin: a listItem with two paragraphs renders with a
6232    /// blank (two-space) separator line, and re-parsing it must reproduce
6233    /// the same document — the renderer is untouched by #1732.
6234    #[test]
6235    fn round_trip_blank_line_separated_paragraphs_in_list_item() {
6236        assert_round_trip(
6237            r#"{"type":"doc","version":1,"content":[{"type":"bulletList","content":[
6238                {"type":"listItem","content":[
6239                    {"type":"paragraph","content":[{"type":"text","text":"a"}]},
6240                    {"type":"paragraph","content":[{"type":"text","text":"b"}]}
6241                ]}
6242            ]}]}"#,
6243            "- a\n  \n  b\n",
6244        );
6245    }
6246
6247    /// Round-trip pin: a listItem with a nested bulletList renders with no
6248    /// blank separator line, and re-parsing it must reproduce the same
6249    /// document.
6250    #[test]
6251    fn round_trip_nested_list_no_blank_separator() {
6252        assert_round_trip(
6253            r#"{"type":"doc","version":1,"content":[{"type":"bulletList","content":[
6254                {"type":"listItem","content":[
6255                    {"type":"paragraph","content":[{"type":"text","text":"p"}]},
6256                    {"type":"bulletList","content":[
6257                        {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}
6258                    ]}
6259                ]}
6260            ]}]}"#,
6261            "- p\n  - x\n",
6262        );
6263    }
6264
6265    // ── loose lists fuse; `{list=separate}` keeps them apart (issue #1729) ──
6266    // Parser cases adapted from #1731 by Nord1cWarr1or.
6267
6268    /// A CommonMark loose ordered list — items separated by blank lines —
6269    /// is one list, not one list per item.
6270    #[test]
6271    fn loose_ordered_list_merges_into_one() {
6272        let doc = markdown_to_adf("1. а\n\n2. б\n\n3. в").unwrap();
6273        assert_eq!(doc.content.len(), 1, "blocks: {:?}", doc.content);
6274        assert_eq!(doc.content[0].node_type, "orderedList");
6275        assert!(doc.content[0].attrs.is_none(), "{:?}", doc.content[0].attrs);
6276        let items = doc.content[0].content.as_ref().unwrap();
6277        assert_eq!(items.len(), 3, "items: {items:?}");
6278        assert_eq!(item_text(&items[0]), "а");
6279        assert_eq!(item_text(&items[1]), "б");
6280        assert_eq!(item_text(&items[2]), "в");
6281    }
6282
6283    /// The bullet form of [`loose_ordered_list_merges_into_one`].
6284    #[test]
6285    fn loose_bullet_list_merges_into_one() {
6286        let doc = markdown_to_adf("- а\n\n- б\n\n- в").unwrap();
6287        assert_eq!(doc.content.len(), 1, "blocks: {:?}", doc.content);
6288        assert_eq!(doc.content[0].node_type, "bulletList");
6289        assert!(doc.content[0].attrs.is_none());
6290        let items = doc.content[0].content.as_ref().unwrap();
6291        assert_eq!(items.len(), 3, "items: {items:?}");
6292    }
6293
6294    /// Task lists fuse like bullet lists — the blank line is the author's
6295    /// spacing, not a list boundary.
6296    #[test]
6297    fn loose_task_list_merges_into_one() {
6298        let doc = markdown_to_adf("- [ ] а\n\n- [x] б").unwrap();
6299        assert_eq!(doc.content.len(), 1, "blocks: {:?}", doc.content);
6300        assert_eq!(doc.content[0].node_type, "taskList");
6301        let items = doc.content[0].content.as_ref().unwrap();
6302        assert_eq!(items.len(), 2, "items: {items:?}");
6303        assert_eq!(items[0].node_type, "taskItem");
6304        assert_eq!(items[1].node_type, "taskItem");
6305    }
6306
6307    /// A checkbox marker opens a different kind of list, so it ends the
6308    /// bullet list rather than continuing it.
6309    #[test]
6310    fn loose_bullet_then_task_list_not_merged() {
6311        let doc = markdown_to_adf("- а\n\n- [ ] б").unwrap();
6312        assert_eq!(doc.content.len(), 2, "blocks: {:?}", doc.content);
6313        assert_eq!(doc.content[0].node_type, "bulletList");
6314        assert_eq!(doc.content[1].node_type, "taskList");
6315    }
6316
6317    /// The tight (no blank line) form of [`loose_bullet_then_task_list_not_merged`]:
6318    /// without the marker-mismatch break, `parse_bullet_list` used to mix a
6319    /// `listItem` into a `taskList` and produce invalid ADF (issue #1736).
6320    #[test]
6321    fn tight_bullet_then_task_list_not_merged() {
6322        let doc = markdown_to_adf("- b\n- [ ] t\n").unwrap();
6323        assert_eq!(doc.content.len(), 2, "blocks: {:?}", doc.content);
6324        assert_eq!(doc.content[0].node_type, "bulletList");
6325        assert_eq!(doc.content[1].node_type, "taskList");
6326        assert_eq!(item_text(&doc.content[0].content.as_ref().unwrap()[0]), "b");
6327        let task_items = doc.content[1].content.as_ref().unwrap();
6328        assert_eq!(task_items[0].node_type, "taskItem");
6329        assert_eq!(
6330            task_items[0].content.as_ref().unwrap()[0].text.as_deref(),
6331            Some("t")
6332        );
6333    }
6334
6335    /// The reverse of [`tight_bullet_then_task_list_not_merged`].
6336    #[test]
6337    fn tight_task_then_bullet_list_not_merged() {
6338        let doc = markdown_to_adf("- [ ] t\n- b\n").unwrap();
6339        assert_eq!(doc.content.len(), 2, "blocks: {:?}", doc.content);
6340        assert_eq!(doc.content[0].node_type, "taskList");
6341        assert_eq!(doc.content[1].node_type, "bulletList");
6342        let task_items = doc.content[0].content.as_ref().unwrap();
6343        assert_eq!(task_items[0].node_type, "taskItem");
6344        assert_eq!(
6345            task_items[0].content.as_ref().unwrap()[0].text.as_deref(),
6346            Some("t")
6347        );
6348        assert_eq!(item_text(&doc.content[1].content.as_ref().unwrap()[0]), "b");
6349    }
6350
6351    /// A tight run of only bullets is unaffected by the #1736 fix.
6352    #[test]
6353    fn tight_bullet_run_unchanged() {
6354        let doc = markdown_to_adf("- a\n- b\n").unwrap();
6355        assert_eq!(doc.content.len(), 1, "blocks: {:?}", doc.content);
6356        assert_eq!(doc.content[0].node_type, "bulletList");
6357        assert_eq!(doc.content[0].content.as_ref().unwrap().len(), 2);
6358    }
6359
6360    /// A tight run of only task items is unaffected by the #1736 fix.
6361    #[test]
6362    fn tight_task_run_unchanged() {
6363        let doc = markdown_to_adf("- [ ] a\n- [ ] b\n").unwrap();
6364        assert_eq!(doc.content.len(), 1, "blocks: {:?}", doc.content);
6365        assert_eq!(doc.content[0].node_type, "taskList");
6366        assert_eq!(doc.content[0].content.as_ref().unwrap().len(), 2);
6367    }
6368
6369    /// The #1736 fix applies inside a list item's sub-content too, since
6370    /// nested content is parsed by the same recursive `parse_blocks`.
6371    #[test]
6372    fn tight_bullet_then_task_list_not_merged_in_list_item() {
6373        let doc = markdown_to_adf("- x\n  - b\n  - [ ] t\n").unwrap();
6374        assert_eq!(doc.content.len(), 1, "blocks: {:?}", doc.content);
6375        let outer_items = doc.content[0].content.as_ref().unwrap();
6376        assert_eq!(outer_items.len(), 1, "outer items: {outer_items:?}");
6377        let children = outer_items[0].content.as_ref().unwrap();
6378        assert_eq!(children.len(), 3, "children: {children:?}");
6379        assert_eq!(children[1].node_type, "bulletList");
6380        assert_eq!(children[2].node_type, "taskList");
6381    }
6382
6383    /// A genuine restart keeps the author's number: CommonMark would fuse
6384    /// and renumber `5.` to `3.`, which is a deliberate JFM deviation.
6385    #[test]
6386    fn ordered_lists_with_numbering_restart_not_merged() {
6387        let doc = markdown_to_adf("1. а\n2. б\n\n5. в").unwrap();
6388        assert_eq!(doc.content.len(), 2, "blocks: {:?}", doc.content);
6389        assert_eq!(doc.content[0].content.as_ref().unwrap().len(), 2);
6390        assert_eq!(
6391            doc.content[1].attrs.as_ref().and_then(|a| a.get("order")),
6392            Some(&serde_json::json!(5))
6393        );
6394    }
6395
6396    /// Repeated `1.` markers are one list — CommonMark treats `1.` as a
6397    /// "don't-care" number and numbers items by position.
6398    #[test]
6399    fn ordered_lists_both_starting_at_one_merge() {
6400        let doc = markdown_to_adf("1. а\n2. б\n\n1. в\n2. г").unwrap();
6401        assert_eq!(doc.content.len(), 1, "blocks: {:?}", doc.content);
6402        let items = doc.content[0].content.as_ref().unwrap();
6403        assert_eq!(items.len(), 4, "items: {items:?}");
6404        assert_eq!(item_text(&items[3]), "г");
6405    }
6406
6407    /// Lists of different types are never fused.
6408    #[test]
6409    fn ordered_and_bullet_lists_not_merged() {
6410        let doc = markdown_to_adf("1. а\n\n- б").unwrap();
6411        assert_eq!(doc.content.len(), 2, "blocks: {:?}", doc.content);
6412        assert_eq!(doc.content[0].node_type, "orderedList");
6413        assert_eq!(doc.content[1].node_type, "bulletList");
6414    }
6415
6416    /// Only an item of the *same* list continues it — a paragraph between
6417    /// two lists is a wall.
6418    #[test]
6419    fn lists_separated_by_paragraph_not_merged() {
6420        let doc = markdown_to_adf("- а\n\nтекст\n\n- б").unwrap();
6421        assert_eq!(doc.content.len(), 3, "blocks: {:?}", doc.content);
6422        assert_eq!(doc.content[0].node_type, "bulletList");
6423        assert_eq!(doc.content[1].node_type, "paragraph");
6424        assert_eq!(doc.content[2].node_type, "bulletList");
6425    }
6426
6427    /// The loose-list rule lives in the list loop, so a nested loose list
6428    /// fuses inside its own item without reaching across the item boundary
6429    /// to the following top-level item.
6430    #[test]
6431    fn nested_loose_list_merges_inside_list_item() {
6432        let md = "1. Родитель:\n    1. А\n\n    2. Б\n2. Следующий";
6433        let doc = markdown_to_adf(md).unwrap();
6434        assert_eq!(doc.content.len(), 1, "blocks: {:?}", doc.content);
6435        let items = doc.content[0].content.as_ref().unwrap();
6436        assert_eq!(items.len(), 2, "items: {items:?}");
6437        assert_eq!(item_text(&items[0]), "Родитель:");
6438        assert_eq!(item_text(&items[1]), "Следующий");
6439        let first_children = items[0].content.as_ref().unwrap();
6440        assert_eq!(first_children.len(), 2, "children: {first_children:?}");
6441        assert_eq!(first_children[1].node_type, "orderedList");
6442        let nested = first_children[1].content.as_ref().unwrap();
6443        assert_eq!(nested.len(), 2, "nested items: {nested:?}");
6444        assert_eq!(item_text(&nested[0]), "А");
6445        assert_eq!(item_text(&nested[1]), "Б");
6446    }
6447
6448    /// The `{list=separate}` marker sits on the list that ends.
6449    #[test]
6450    fn list_separate_marker_keeps_lists_separate() {
6451        let doc = markdown_to_adf("- а\n{list=separate}\n\n- б").unwrap();
6452        assert_eq!(doc.content.len(), 2, "blocks: {:?}", doc.content);
6453        assert_eq!(doc.content[0].node_type, "bulletList");
6454        assert_eq!(doc.content[1].node_type, "bulletList");
6455        assert!(doc.content[0].attrs.is_none(), "{:?}", doc.content[0].attrs);
6456        assert!(doc.content[1].attrs.is_none(), "{:?}", doc.content[1].attrs);
6457        assert_eq!(item_text(&doc.content[0].content.as_ref().unwrap()[0]), "а");
6458        assert_eq!(item_text(&doc.content[1].content.as_ref().unwrap()[0]), "б");
6459    }
6460
6461    /// The marker carries no ADF meaning, but it must still be consumed so
6462    /// it never shows up as a literal paragraph.
6463    #[test]
6464    fn list_separate_line_is_consumed_not_a_paragraph() {
6465        for md in [
6466            "- а\n{list=separate}\n\n- б",
6467            "1. а\n{list=separate}\n\n1. б",
6468            "- [ ] а\n{list=separate}\n\n- [ ] б",
6469        ] {
6470            let doc = markdown_to_adf(md).unwrap();
6471            assert!(
6472                doc.content.iter().all(|n| n.node_type != "paragraph"),
6473                "stray paragraph for {md:?}: {:?}",
6474                doc.content
6475            );
6476            let json = serde_json::to_string(&doc).unwrap();
6477            assert!(!json.contains("list=separate"), "leaked for {md:?}: {json}");
6478        }
6479    }
6480
6481    /// Any trailing attrs line ends a list — that, not a transient marker,
6482    /// is what keeps two lists apart.  The attrs belong to the first list
6483    /// alone and must not be inherited by the second.
6484    #[test]
6485    fn block_attrs_line_ends_a_list() {
6486        let doc = markdown_to_adf("- a\n{align=center}\n- b").unwrap();
6487        assert_eq!(doc.content.len(), 2, "blocks: {:?}", doc.content);
6488        assert_eq!(doc.content[0].node_type, "bulletList");
6489        assert_eq!(doc.content[1].node_type, "bulletList");
6490        assert!(doc.content[0].marks.is_some(), "{:?}", doc.content[0]);
6491        assert!(doc.content[1].marks.is_none(), "{:?}", doc.content[1]);
6492    }
6493
6494    /// The same, for `localId`: neither list's id may be lost.
6495    #[test]
6496    fn local_id_attrs_line_ends_a_list_keeping_both_ids() {
6497        let doc = markdown_to_adf("- a\n{localId=L1}\n- b\n{localId=L2}").unwrap();
6498        assert_eq!(doc.content.len(), 2, "blocks: {:?}", doc.content);
6499        assert_eq!(
6500            doc.content[0].attrs.as_ref().and_then(|a| a.get("localId")),
6501            Some(&serde_json::json!("L1"))
6502        );
6503        assert_eq!(
6504            doc.content[1].attrs.as_ref().and_then(|a| a.get("localId")),
6505            Some(&serde_json::json!("L2"))
6506        );
6507    }
6508
6509    /// `{order=1}` (issue #547) ends the list like any other attrs line, so
6510    /// the numbering that follows starts a second list even though it would
6511    /// otherwise have continued.
6512    #[test]
6513    fn explicit_order_one_attrs_line_ends_a_list() {
6514        let doc = markdown_to_adf("1. a\n{order=1}\n\n2. b").unwrap();
6515        assert_eq!(doc.content.len(), 2, "blocks: {:?}", doc.content);
6516        assert_eq!(
6517            doc.content[0].attrs.as_ref().and_then(|a| a.get("order")),
6518            Some(&serde_json::json!(1))
6519        );
6520        assert_eq!(
6521            doc.content[1].attrs.as_ref().and_then(|a| a.get("order")),
6522            Some(&serde_json::json!(2))
6523        );
6524    }
6525
6526    /// Two genuinely separate ADF `bulletList`s survive the round trip.
6527    #[test]
6528    fn round_trip_adjacent_bullet_lists() {
6529        assert_round_trip(
6530            r#"{"type":"doc","version":1,"content":[
6531                {"type":"bulletList","content":[
6532                    {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]}
6533                ]},
6534                {"type":"bulletList","content":[
6535                    {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}
6536                ]}
6537            ]}"#,
6538            "- a\n{list=separate}\n\n- b\n",
6539        );
6540    }
6541
6542    /// Two separate `orderedList`s whose numbering would have continued.
6543    #[test]
6544    fn round_trip_adjacent_ordered_lists() {
6545        assert_round_trip(
6546            r#"{"type":"doc","version":1,"content":[
6547                {"type":"orderedList","content":[
6548                    {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]},
6549                    {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}
6550                ]},
6551                {"type":"orderedList","content":[
6552                    {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"c"}]}]}
6553                ]}
6554            ]}"#,
6555            "1. a\n2. b\n{list=separate}\n\n1. c\n",
6556        );
6557    }
6558
6559    /// The second list's `order` rides the list marker, but `3.` is exactly
6560    /// where the first list's numbering would have carried on — so the
6561    /// marker is still needed.
6562    #[test]
6563    fn round_trip_adjacent_ordered_lists_with_explicit_order() {
6564        assert_round_trip(
6565            r#"{"type":"doc","version":1,"content":[
6566                {"type":"orderedList","content":[
6567                    {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]},
6568                    {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}
6569                ]},
6570                {"type":"orderedList","attrs":{"order":3},"content":[
6571                    {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"c"}]}]}
6572                ]}
6573            ]}"#,
6574            "1. a\n2. b\n{list=separate}\n\n3. c\n",
6575        );
6576    }
6577
6578    /// A restart the parser would never fuse needs no marker — emitting one
6579    /// would be pure churn.
6580    #[test]
6581    fn round_trip_adjacent_ordered_lists_restart_needs_no_marker() {
6582        assert_round_trip(
6583            r#"{"type":"doc","version":1,"content":[
6584                {"type":"orderedList","content":[
6585                    {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]},
6586                    {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}
6587                ]},
6588                {"type":"orderedList","attrs":{"order":5},"content":[
6589                    {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"c"}]}]}
6590                ]}
6591            ]}"#,
6592            "1. a\n2. b\n\n5. c\n",
6593        );
6594    }
6595
6596    /// The issue #547 shape: `{order=1}` and `{list=separate}` compose onto
6597    /// one attrs line because both belong to the first list.
6598    #[test]
6599    fn round_trip_adjacent_ordered_lists_explicit_order_one() {
6600        assert_round_trip(
6601            r#"{"type":"doc","version":1,"content":[
6602                {"type":"orderedList","attrs":{"order":1},"content":[
6603                    {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]}
6604                ]},
6605                {"type":"orderedList","content":[
6606                    {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}
6607                ]}
6608            ]}"#,
6609            "1. a\n{order=1 list=separate}\n\n1. b\n",
6610        );
6611    }
6612
6613    /// Two adjacent lists *inside* a list item: the marker line is indented
6614    /// with the list it ends, and no blank line is needed because the attrs
6615    /// line is itself the terminator.
6616    #[test]
6617    fn round_trip_adjacent_bullet_lists_in_list_item() {
6618        assert_round_trip(
6619            r#"{"type":"doc","version":1,"content":[{"type":"bulletList","content":[
6620                {"type":"listItem","content":[
6621                    {"type":"paragraph","content":[{"type":"text","text":"p"}]},
6622                    {"type":"bulletList","content":[
6623                        {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}
6624                    ]},
6625                    {"type":"bulletList","content":[
6626                        {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"y"}]}]}
6627                    ]}
6628                ]}
6629            ]}]}"#,
6630            "- p\n  - x\n  {list=separate}\n  - y\n",
6631        );
6632    }
6633
6634    /// A `bulletList` followed by a `taskList` inside one item: without the
6635    /// marker `parse_bullet_list` flips the whole run to a task list and
6636    /// produces a `listItem` under a `taskList` — invalid ADF.
6637    #[test]
6638    fn round_trip_bullet_then_task_list_in_list_item() {
6639        assert_round_trip(
6640            r#"{"type":"doc","version":1,"content":[{"type":"bulletList","content":[
6641                {"type":"listItem","content":[
6642                    {"type":"paragraph","content":[{"type":"text","text":"x"}]},
6643                    {"type":"bulletList","content":[
6644                        {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}
6645                    ]},
6646                    {"type":"taskList","attrs":{"localId":"l1"},"content":[
6647                        {"type":"taskItem","attrs":{"localId":"t1","state":"TODO"},"content":[{"type":"text","text":"t"}]}
6648                    ]}
6649                ]}
6650            ]}]}"#,
6651            "- x\n  - b\n  {list=separate}\n  - [ ] t {localId=t1}\n  {localId=l1}\n",
6652        );
6653    }
6654
6655    /// The blockquote arm renders siblings itself, so it needs the marker
6656    /// too.
6657    #[test]
6658    fn round_trip_blockquote_adjacent_bullet_lists() {
6659        assert_round_trip(
6660            r#"{"type":"doc","version":1,"content":[{"type":"blockquote","content":[
6661                {"type":"bulletList","content":[
6662                    {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]}
6663                ]},
6664                {"type":"bulletList","content":[
6665                    {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}
6666                ]}
6667            ]}]}"#,
6668            "> - a\n> {list=separate}\n> - b\n",
6669        );
6670    }
6671
6672    /// The same, inside a table cell — whose empty `attrs` object survives.
6673    #[test]
6674    fn round_trip_adjacent_bullet_lists_in_table_cell() {
6675        assert_round_trip(
6676            r#"{"type":"doc","version":1,"content":[{"type":"table","content":[
6677                {"type":"tableRow","content":[
6678                    {"type":"tableCell","attrs":{},"content":[
6679                        {"type":"bulletList","content":[
6680                            {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]}
6681                        ]},
6682                        {"type":"bulletList","content":[
6683                            {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}
6684                        ]}
6685                    ]}
6686                ]}
6687            ]}]}"#,
6688            "::::table\n:::tr\n:::td{}\n- a\n{list=separate}\n\n- b\n:::\n:::\n::::\n",
6689        );
6690    }
6691
6692    /// Adjacent task lists now fuse too, so they need the marker — which
6693    /// composes onto the `taskList`'s own `{localId=…}` line.
6694    #[test]
6695    fn round_trip_adjacent_task_lists() {
6696        assert_round_trip(
6697        r#"{"type":"doc","version":1,"content":[
6698                {"type":"taskList","attrs":{"localId":"l1"},"content":[
6699                    {"type":"taskItem","attrs":{"localId":"t1","state":"TODO"},"content":[{"type":"text","text":"a"}]}
6700                ]},
6701                {"type":"taskList","attrs":{"localId":"l2"},"content":[
6702                    {"type":"taskItem","attrs":{"localId":"t2","state":"TODO"},"content":[{"type":"text","text":"b"}]}
6703                ]}
6704            ]}"#,
6705        "- [ ] a {localId=t1}\n{localId=l1 list=separate}\n\n- [ ] b {localId=t2}\n{localId=l2}\n",
6706    );
6707    }
6708
6709    /// Three in a row: every list but the last carries the marker.
6710    #[test]
6711    fn round_trip_three_adjacent_bullet_lists() {
6712        assert_round_trip(
6713            r#"{"type":"doc","version":1,"content":[
6714                {"type":"bulletList","content":[
6715                    {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]}
6716                ]},
6717                {"type":"bulletList","content":[
6718                    {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}
6719                ]},
6720                {"type":"bulletList","content":[
6721                    {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"c"}]}]}
6722                ]}
6723            ]}"#,
6724            "- a\n{list=separate}\n\n- b\n{list=separate}\n\n- c\n",
6725        );
6726    }
6727
6728    /// A list with no items renders nothing, so there is nothing to fuse
6729    /// with and no marker is emitted — a bare `{list=separate}` line would
6730    /// otherwise come back as a literal paragraph.
6731    #[test]
6732    fn empty_second_bullet_list_renders_without_a_marker() {
6733        let doc: AdfDocument = serde_json::from_str(
6734            r#"{"type":"doc","version":1,"content":[
6735                {"type":"bulletList","content":[
6736                    {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]}
6737                ]},
6738                {"type":"bulletList","content":[]}
6739            ]}"#,
6740        )
6741        .unwrap();
6742        let md = adf_to_markdown(&doc).unwrap();
6743        assert_eq!(md, "- a\n\n");
6744        let reparsed = markdown_to_adf(&md).unwrap();
6745        assert_eq!(reparsed.content.len(), 1, "{:?}", reparsed.content);
6746        assert_eq!(reparsed.content[0].node_type, "bulletList");
6747    }
6748
6749    /// A subtree preserved verbatim in an ```` ```adf-unsupported ```` block
6750    /// is never rewritten: its inner lists keep their shape and an attrs key
6751    /// that happens to look like an internal marker survives.
6752    #[test]
6753    fn adf_unsupported_subtree_round_trips_verbatim() {
6754        let doc: AdfDocument = serde_json::from_str(
6755            r#"{"type":"doc","version":1,"content":[
6756                {"type":"mysteryBlock","attrs":{"__list_separate":true},"content":[
6757                    {"type":"bulletList","content":[
6758                        {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]}
6759                    ]},
6760                    {"type":"bulletList","content":[
6761                        {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}
6762                    ]}
6763                ]}
6764            ]}"#,
6765        )
6766        .unwrap();
6767        let md = adf_to_markdown(&doc).unwrap();
6768        assert!(!md.contains("list=separate"), "md: {md}");
6769        let reparsed = markdown_to_adf(&md).unwrap();
6770        assert_eq!(
6771            serde_json::to_value(&reparsed).unwrap(),
6772            serde_json::to_value(&doc).unwrap(),
6773            "markdown was:\n{md}"
6774        );
6775    }
6776
6777    /// A paragraph whose text starts with spaces must not be pulled into the
6778    /// list above it as indented sub-content (issue #1732).
6779    #[test]
6780    fn round_trip_paragraph_with_leading_spaces_after_list() {
6781        assert_round_trip(
6782            r#"{"type":"doc","version":1,"content":[
6783                {"type":"bulletList","content":[
6784                    {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]}
6785                ]},
6786                {"type":"paragraph","content":[{"type":"text","text":"  hello"}]}
6787            ]}"#,
6788            "- a\n\n::paragraph[  hello]\n",
6789        );
6790    }
6791
6792    /// The same text starting with `#` would otherwise become a heading
6793    /// *inside* the list item.
6794    #[test]
6795    fn round_trip_paragraph_with_leading_hash_after_list() {
6796        assert_round_trip(
6797            r#"{"type":"doc","version":1,"content":[
6798                {"type":"bulletList","content":[
6799                    {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]}
6800                ]},
6801                {"type":"paragraph","content":[{"type":"text","text":"  # nope"}]}
6802            ]}"#,
6803            "- a\n\n::paragraph[  # nope]\n",
6804        );
6805    }
6806
6807    #[test]
6808    fn leading_spaces_counts_ascii_spaces_only() {
6809        assert_eq!(leading_spaces(""), 0);
6810        assert_eq!(leading_spaces("- a"), 0);
6811        assert_eq!(leading_spaces("    - a"), 4);
6812        assert_eq!(leading_spaces("\t- a"), 0);
6813        assert_eq!(leading_spaces("\u{a0}- a"), 0);
6814        assert_eq!(leading_spaces("  \u{a0} - a"), 2);
6815    }
6816
6817    /// Round-trip helper: ADF JSON → markdown → ADF must reproduce the
6818    /// original document exactly.
6819    fn assert_round_trip(json: &str, expected_md: &str) {
6820        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6821        let md = adf_to_markdown(&doc).unwrap();
6822        assert_eq!(md, expected_md);
6823        let doc2 = markdown_to_adf(&md).unwrap();
6824        assert_eq!(
6825            serde_json::to_value(&doc2).unwrap(),
6826            serde_json::to_value(&doc).unwrap(),
6827            "round trip changed the document; markdown was:\n{md}"
6828        );
6829    }
6830
6831    #[test]
6832    fn round_trip_nested_list_siblings() {
6833        assert_round_trip(
6834            r#"{"type":"doc","version":1,"content":[{"type":"orderedList","content":[
6835                {"type":"listItem","content":[
6836                    {"type":"paragraph","content":[{"type":"text","text":"Родитель:"}]},
6837                    {"type":"orderedList","content":[
6838                        {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Пункт А"}]}]},
6839                        {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Пункт Б"}]}]}
6840                    ]}
6841                ]},
6842                {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Следующий"}]}]}
6843            ]}]}"#,
6844            "1. Родитель:\n  1. Пункт А\n  2. Пункт Б\n2. Следующий\n",
6845        );
6846    }
6847
6848    /// Issue #402's rule is unchanged by #1727: a marker-shaped line at
6849    /// exactly the item's content indent after a hardBreak is continuation
6850    /// text, not a nested list — only a marker at *sibling* level ends the
6851    /// item.
6852    #[test]
6853    fn hardbreak_continuation_at_content_indent_is_still_text() {
6854        let md = "- first line  \n  2. continued\n- second";
6855        let doc = markdown_to_adf(md).unwrap();
6856        let items = doc.content[0].content.as_ref().unwrap();
6857        assert_eq!(items.len(), 2, "items: {items:?}");
6858        let inlines = items[0].content.as_ref().unwrap()[0]
6859            .content
6860            .as_ref()
6861            .unwrap();
6862        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
6863        assert_eq!(types, vec!["text", "hardBreak", "text"]);
6864        assert_eq!(inlines[2].text.as_deref(), Some("2. continued"));
6865    }
6866
6867    #[test]
6868    fn task_list() {
6869        let md = "- [ ] Todo item\n- [x] Done item";
6870        let doc = markdown_to_adf(md).unwrap();
6871        assert_eq!(doc.content[0].node_type, "taskList");
6872        let items = doc.content[0].content.as_ref().unwrap();
6873        assert_eq!(items.len(), 2);
6874        assert_eq!(items[0].node_type, "taskItem");
6875        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
6876        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
6877    }
6878
6879    #[test]
6880    fn task_list_uppercase_x() {
6881        let md = "- [X] Done item";
6882        let doc = markdown_to_adf(md).unwrap();
6883        assert_eq!(doc.content[0].node_type, "taskList");
6884        let item = &doc.content[0].content.as_ref().unwrap()[0];
6885        assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
6886    }
6887
6888    /// Issue #548: an empty task marker (no trailing space) must still be
6889    /// parsed as a `taskList` rather than a `bulletList` with `[ ]` text.
6890    #[test]
6891    fn task_list_empty_todo_no_trailing_space() {
6892        let md = "- [ ]";
6893        let doc = markdown_to_adf(md).unwrap();
6894        assert_eq!(doc.content[0].node_type, "taskList");
6895        let items = doc.content[0].content.as_ref().unwrap();
6896        assert_eq!(items.len(), 1);
6897        assert_eq!(items[0].node_type, "taskItem");
6898        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
6899        assert!(items[0].content.is_none());
6900    }
6901
6902    /// Issue #548: likewise for a done checkbox with no body.
6903    #[test]
6904    fn task_list_empty_done_no_trailing_space() {
6905        let md = "- [x]\n- [X]";
6906        let doc = markdown_to_adf(md).unwrap();
6907        assert_eq!(doc.content[0].node_type, "taskList");
6908        let items = doc.content[0].content.as_ref().unwrap();
6909        assert_eq!(items.len(), 2);
6910        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
6911        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
6912    }
6913
6914    /// Issue #548: the body of `- [ ] text` must not have a spurious leading
6915    /// space introduced by relaxing the trailing-space requirement.
6916    #[test]
6917    fn task_list_body_has_no_leading_space() {
6918        let md = "- [ ] Buy groceries";
6919        let doc = markdown_to_adf(md).unwrap();
6920        let item = &doc.content[0].content.as_ref().unwrap()[0];
6921        let text = item.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6922        assert_eq!(text, "Buy groceries");
6923    }
6924
6925    /// Issue #548: round-trip from ADF with empty taskItems should preserve
6926    /// the `taskList` structure even if trailing spaces are stripped from the
6927    /// intermediate markdown (as many editors do).
6928    #[test]
6929    fn round_trip_empty_task_items_stripped_trailing_spaces() {
6930        let json = r#"{
6931            "version": 1,
6932            "type": "doc",
6933            "content": [{
6934                "type": "taskList",
6935                "attrs": {"localId": "abc"},
6936                "content": [
6937                    {"type": "taskItem", "attrs": {"localId": "def", "state": "TODO"}},
6938                    {"type": "taskItem", "attrs": {"localId": "ghi", "state": "DONE"}}
6939                ]
6940            }]
6941        }"#;
6942        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6943        let md = adf_to_markdown(&doc).unwrap();
6944        let stripped: String = md.lines().map(str::trim_end).collect::<Vec<_>>().join("\n");
6945        let parsed = markdown_to_adf(&stripped).unwrap();
6946        assert_eq!(parsed.content[0].node_type, "taskList");
6947        let items = parsed.content[0].content.as_ref().unwrap();
6948        assert_eq!(items.len(), 2);
6949        assert_eq!(items[0].node_type, "taskItem");
6950        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
6951        assert_eq!(items[1].node_type, "taskItem");
6952        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
6953    }
6954
6955    #[test]
6956    fn try_parse_task_marker_accepts_bare_checkbox() {
6957        assert_eq!(try_parse_task_marker("[ ]"), Some(("TODO", "")));
6958        assert_eq!(try_parse_task_marker("[x]"), Some(("DONE", "")));
6959        assert_eq!(try_parse_task_marker("[X]"), Some(("DONE", "")));
6960        assert_eq!(try_parse_task_marker("[ ] foo"), Some(("TODO", "foo")));
6961        assert_eq!(try_parse_task_marker("[x] foo"), Some(("DONE", "foo")));
6962        assert_eq!(try_parse_task_marker("[ ]foo"), None);
6963        assert_eq!(try_parse_task_marker("[x]foo"), None);
6964        assert_eq!(try_parse_task_marker("[y] foo"), None);
6965    }
6966
6967    #[test]
6968    fn starts_with_task_marker_matches_parser() {
6969        // Anything `try_parse_task_marker` recognises must also be flagged
6970        // here so the renderer escapes it.
6971        assert!(starts_with_task_marker("[ ]"));
6972        assert!(starts_with_task_marker("[x]"));
6973        assert!(starts_with_task_marker("[X]"));
6974        assert!(starts_with_task_marker("[ ] foo"));
6975        assert!(starts_with_task_marker("[x] foo\n"));
6976        assert!(starts_with_task_marker("[ ]\n"));
6977        // No collision when the bracket is followed by non-whitespace.
6978        assert!(!starts_with_task_marker("[ ]foo"));
6979        assert!(!starts_with_task_marker("[y] foo"));
6980        assert!(!starts_with_task_marker("foo [ ] bar"));
6981        assert!(!starts_with_task_marker(""));
6982    }
6983
6984    /// Issue #548: a `bulletList` whose item starts with literal `[ ]` text
6985    /// must round-trip through markdown without being promoted to a
6986    /// `taskList`.
6987    #[test]
6988    fn round_trip_bullet_list_with_literal_checkbox_text() {
6989        let json = r#"{
6990            "version": 1,
6991            "type": "doc",
6992            "content": [{
6993                "type": "bulletList",
6994                "content": [{
6995                    "type": "listItem",
6996                    "content": [{
6997                        "type": "paragraph",
6998                        "content": [
6999                            {"type": "text", "text": "[ ] Review the "},
7000                            {"type": "text", "text": "config.yaml", "marks": [{"type": "code"}]},
7001                            {"type": "text", "text": " file"}
7002                        ]
7003                    }]
7004                }]
7005            }]
7006        }"#;
7007        let original: AdfDocument = serde_json::from_str(json).unwrap();
7008        let md = adf_to_markdown(&original).unwrap();
7009        // Renderer must escape the leading bracket.
7010        assert!(
7011            md.contains(r"- \[ ] Review the "),
7012            "rendered markdown: {md:?}"
7013        );
7014        let parsed = markdown_to_adf(&md).unwrap();
7015        assert_eq!(parsed.content[0].node_type, "bulletList");
7016        let item = &parsed.content[0].content.as_ref().unwrap()[0];
7017        assert_eq!(item.node_type, "listItem");
7018        let para = &item.content.as_ref().unwrap()[0];
7019        assert_eq!(para.node_type, "paragraph");
7020        let text_nodes = para.content.as_ref().unwrap();
7021        assert_eq!(text_nodes[0].text.as_deref().unwrap(), "[ ] Review the ");
7022        assert_eq!(text_nodes[1].text.as_deref().unwrap(), "config.yaml");
7023        assert_eq!(text_nodes[2].text.as_deref().unwrap(), " file");
7024    }
7025
7026    /// Issue #548: the same problem with a `[x]` marker.
7027    #[test]
7028    fn round_trip_bullet_list_with_literal_done_checkbox_text() {
7029        let json = r#"{
7030            "version": 1,
7031            "type": "doc",
7032            "content": [{
7033                "type": "bulletList",
7034                "content": [{
7035                    "type": "listItem",
7036                    "content": [{
7037                        "type": "paragraph",
7038                        "content": [{"type": "text", "text": "[x] not actually done"}]
7039                    }]
7040                }]
7041            }]
7042        }"#;
7043        let original: AdfDocument = serde_json::from_str(json).unwrap();
7044        let md = adf_to_markdown(&original).unwrap();
7045        assert!(md.contains(r"- \[x] "), "rendered markdown: {md:?}");
7046        let parsed = markdown_to_adf(&md).unwrap();
7047        assert_eq!(parsed.content[0].node_type, "bulletList");
7048        let item = &parsed.content[0].content.as_ref().unwrap()[0];
7049        let para = &item.content.as_ref().unwrap()[0];
7050        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
7051        assert_eq!(text, "[x] not actually done");
7052    }
7053
7054    /// Issue #548: `bulletList` item whose entire content is literal `[ ]`.
7055    #[test]
7056    fn round_trip_bullet_list_with_bare_literal_checkbox() {
7057        let json = r#"{
7058            "version": 1,
7059            "type": "doc",
7060            "content": [{
7061                "type": "bulletList",
7062                "content": [{
7063                    "type": "listItem",
7064                    "content": [{
7065                        "type": "paragraph",
7066                        "content": [{"type": "text", "text": "[ ]"}]
7067                    }]
7068                }]
7069            }]
7070        }"#;
7071        let original: AdfDocument = serde_json::from_str(json).unwrap();
7072        let md = adf_to_markdown(&original).unwrap();
7073        let parsed = markdown_to_adf(&md).unwrap();
7074        assert_eq!(parsed.content[0].node_type, "bulletList");
7075        let item = &parsed.content[0].content.as_ref().unwrap()[0];
7076        let para = &item.content.as_ref().unwrap()[0];
7077        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
7078        assert_eq!(text, "[ ]");
7079    }
7080
7081    /// Issue #548: a `bulletList` with a non-task `[?]` prefix should not be
7082    /// escaped — that would just produce noise.
7083    #[test]
7084    fn bullet_list_non_task_bracket_text_not_escaped() {
7085        let json = r#"{
7086            "version": 1,
7087            "type": "doc",
7088            "content": [{
7089                "type": "bulletList",
7090                "content": [{
7091                    "type": "listItem",
7092                    "content": [{
7093                        "type": "paragraph",
7094                        "content": [{"type": "text", "text": "[?] unsure"}]
7095                    }]
7096                }]
7097            }]
7098        }"#;
7099        let original: AdfDocument = serde_json::from_str(json).unwrap();
7100        let md = adf_to_markdown(&original).unwrap();
7101        assert!(!md.contains(r"\["), "should not escape: {md:?}");
7102        assert!(md.contains("- [?] unsure"), "rendered: {md:?}");
7103    }
7104
7105    /// Issue #548: nested `bulletList` items inside another `bulletList`
7106    /// must also have their literal `[ ]` text escaped.
7107    #[test]
7108    fn round_trip_nested_bullet_list_with_literal_checkbox_text() {
7109        let json = r#"{
7110            "version": 1,
7111            "type": "doc",
7112            "content": [{
7113                "type": "bulletList",
7114                "content": [{
7115                    "type": "listItem",
7116                    "content": [
7117                        {"type": "paragraph", "content": [{"type": "text", "text": "outer"}]},
7118                        {"type": "bulletList", "content": [{
7119                            "type": "listItem",
7120                            "content": [{
7121                                "type": "paragraph",
7122                                "content": [{"type": "text", "text": "[ ] inner literal"}]
7123                            }]
7124                        }]}
7125                    ]
7126                }]
7127            }]
7128        }"#;
7129        let original: AdfDocument = serde_json::from_str(json).unwrap();
7130        let md = adf_to_markdown(&original).unwrap();
7131        let parsed = markdown_to_adf(&md).unwrap();
7132        let outer = &parsed.content[0];
7133        assert_eq!(outer.node_type, "bulletList");
7134        let outer_item = &outer.content.as_ref().unwrap()[0];
7135        let inner_list = &outer_item.content.as_ref().unwrap()[1];
7136        assert_eq!(inner_list.node_type, "bulletList");
7137        let inner_item = &inner_list.content.as_ref().unwrap()[0];
7138        assert_eq!(inner_item.node_type, "listItem");
7139        let para = &inner_item.content.as_ref().unwrap()[0];
7140        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
7141        assert_eq!(text, "[ ] inner literal");
7142    }
7143
7144    #[test]
7145    fn adf_task_list_to_markdown() {
7146        let doc = AdfDocument {
7147            version: 1,
7148            doc_type: "doc".to_string(),
7149            content: vec![AdfNode::task_list(vec![
7150                AdfNode::task_item(
7151                    "TODO",
7152                    vec![AdfNode::paragraph(vec![AdfNode::text("Todo")])],
7153                ),
7154                AdfNode::task_item(
7155                    "DONE",
7156                    vec![AdfNode::paragraph(vec![AdfNode::text("Done")])],
7157                ),
7158            ])],
7159        };
7160        let md = adf_to_markdown(&doc).unwrap();
7161        assert!(md.contains("- [ ] Todo"));
7162        assert!(md.contains("- [x] Done"));
7163    }
7164
7165    #[test]
7166    fn round_trip_task_list() {
7167        let md = "- [ ] Todo item\n- [x] Done item\n";
7168        let doc = markdown_to_adf(md).unwrap();
7169        let result = adf_to_markdown(&doc).unwrap();
7170        assert!(result.contains("- [ ] Todo item"));
7171        assert!(result.contains("- [x] Done item"));
7172    }
7173
7174    /// Issue #408: taskItem content with inline nodes directly (no paragraph wrapper).
7175    #[test]
7176    fn adf_task_item_unwrapped_inline_content() {
7177        // Real Confluence ADF: taskItem contains text nodes directly, no paragraph.
7178        let json = r#"{
7179            "version": 1,
7180            "type": "doc",
7181            "content": [{
7182                "type": "taskList",
7183                "attrs": {"localId": "list-001"},
7184                "content": [{
7185                    "type": "taskItem",
7186                    "attrs": {"localId": "task-001", "state": "TODO"},
7187                    "content": [{"type": "text", "text": "Do something"}]
7188                }]
7189            }]
7190        }"#;
7191        let doc: AdfDocument = serde_json::from_str(json).unwrap();
7192        let md = adf_to_markdown(&doc).unwrap();
7193        assert!(md.contains("- [ ] Do something"), "got: {md}");
7194        assert!(!md.contains("adf-unsupported"), "got: {md}");
7195    }
7196
7197    /// Issue #408: multiple taskItems with unwrapped inline content.
7198    #[test]
7199    fn adf_task_list_multiple_unwrapped_items() {
7200        let json = r#"{
7201            "version": 1,
7202            "type": "doc",
7203            "content": [{
7204                "type": "taskList",
7205                "attrs": {"localId": "list-001"},
7206                "content": [
7207                    {
7208                        "type": "taskItem",
7209                        "attrs": {"localId": "task-001", "state": "TODO"},
7210                        "content": [{"type": "text", "text": "First task"}]
7211                    },
7212                    {
7213                        "type": "taskItem",
7214                        "attrs": {"localId": "task-002", "state": "DONE"},
7215                        "content": [{"type": "text", "text": "Second task"}]
7216                    }
7217                ]
7218            }]
7219        }"#;
7220        let doc: AdfDocument = serde_json::from_str(json).unwrap();
7221        let md = adf_to_markdown(&doc).unwrap();
7222        assert!(md.contains("- [ ] First task"), "got: {md}");
7223        assert!(md.contains("- [x] Second task"), "got: {md}");
7224        assert!(!md.contains("adf-unsupported"), "got: {md}");
7225    }
7226
7227    /// Issue #408: unwrapped inline content with marks (bold text).
7228    #[test]
7229    fn adf_task_item_unwrapped_inline_with_marks() {
7230        let json = r#"{
7231            "version": 1,
7232            "type": "doc",
7233            "content": [{
7234                "type": "taskList",
7235                "attrs": {"localId": "list-001"},
7236                "content": [{
7237                    "type": "taskItem",
7238                    "attrs": {"localId": "task-001", "state": "TODO"},
7239                    "content": [
7240                        {"type": "text", "text": "Buy "},
7241                        {"type": "text", "text": "groceries", "marks": [{"type": "strong"}]},
7242                        {"type": "text", "text": " today"}
7243                    ]
7244                }]
7245            }]
7246        }"#;
7247        let doc: AdfDocument = serde_json::from_str(json).unwrap();
7248        let md = adf_to_markdown(&doc).unwrap();
7249        assert!(md.contains("- [ ] Buy **groceries** today"), "got: {md}");
7250    }
7251
7252    /// Issue #408: taskItem localId is preserved for unwrapped inline content.
7253    #[test]
7254    fn adf_task_item_unwrapped_preserves_local_id() {
7255        let json = r#"{
7256            "version": 1,
7257            "type": "doc",
7258            "content": [{
7259                "type": "taskList",
7260                "attrs": {"localId": "list-001"},
7261                "content": [{
7262                    "type": "taskItem",
7263                    "attrs": {"localId": "task-001", "state": "TODO"},
7264                    "content": [{"type": "text", "text": "Do something"}]
7265                }]
7266            }]
7267        }"#;
7268        let doc: AdfDocument = serde_json::from_str(json).unwrap();
7269        let md = adf_to_markdown(&doc).unwrap();
7270        assert!(md.contains("{localId=task-001}"), "got: {md}");
7271        assert!(md.contains("{localId=list-001}"), "got: {md}");
7272    }
7273
7274    /// Issue #408: round-trip from Confluence ADF with unwrapped taskItem content.
7275    #[test]
7276    fn round_trip_task_list_unwrapped_inline() {
7277        let json = r#"{
7278            "version": 1,
7279            "type": "doc",
7280            "content": [{
7281                "type": "taskList",
7282                "attrs": {"localId": "list-001"},
7283                "content": [
7284                    {
7285                        "type": "taskItem",
7286                        "attrs": {"localId": "task-001", "state": "TODO"},
7287                        "content": [{"type": "text", "text": "Do something"}]
7288                    },
7289                    {
7290                        "type": "taskItem",
7291                        "attrs": {"localId": "task-002", "state": "DONE"},
7292                        "content": [{"type": "text", "text": "Already done"}]
7293                    }
7294                ]
7295            }]
7296        }"#;
7297        let doc: AdfDocument = serde_json::from_str(json).unwrap();
7298        let md = adf_to_markdown(&doc).unwrap();
7299
7300        // Round-trip: markdown back to ADF
7301        let doc2 = markdown_to_adf(&md).unwrap();
7302        assert_eq!(doc2.content[0].node_type, "taskList");
7303
7304        let items = doc2.content[0].content.as_ref().unwrap();
7305        assert_eq!(items.len(), 2);
7306        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
7307        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
7308
7309        // localIds preserved
7310        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
7311        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "task-002");
7312        assert_eq!(
7313            doc2.content[0].attrs.as_ref().unwrap()["localId"],
7314            "list-001"
7315        );
7316    }
7317
7318    /// Issue #408: taskItem with inline content followed by a nested block (sub-list).
7319    #[test]
7320    fn adf_task_item_unwrapped_inline_then_block() {
7321        let json = r#"{
7322            "version": 1,
7323            "type": "doc",
7324            "content": [{
7325                "type": "taskList",
7326                "attrs": {"localId": "list-001"},
7327                "content": [{
7328                    "type": "taskItem",
7329                    "attrs": {"localId": "task-001", "state": "TODO"},
7330                    "content": [
7331                        {"type": "text", "text": "Parent task"},
7332                        {
7333                            "type": "bulletList",
7334                            "content": [{
7335                                "type": "listItem",
7336                                "content": [{
7337                                    "type": "paragraph",
7338                                    "content": [{"type": "text", "text": "sub-item"}]
7339                                }]
7340                            }]
7341                        }
7342                    ]
7343                }]
7344            }]
7345        }"#;
7346        let doc: AdfDocument = serde_json::from_str(json).unwrap();
7347        let md = adf_to_markdown(&doc).unwrap();
7348        assert!(md.contains("- [ ] Parent task"), "got: {md}");
7349        assert!(md.contains("  - sub-item"), "got: {md}");
7350        assert!(!md.contains("adf-unsupported"), "got: {md}");
7351    }
7352
7353    /// Issue #408: taskItem with empty content array renders without panic.
7354    #[test]
7355    fn adf_task_item_empty_content() {
7356        let json = r#"{
7357            "version": 1,
7358            "type": "doc",
7359            "content": [{
7360                "type": "taskList",
7361                "attrs": {"localId": "list-001"},
7362                "content": [{
7363                    "type": "taskItem",
7364                    "attrs": {"localId": "task-001", "state": "TODO"},
7365                    "content": []
7366                }]
7367            }]
7368        }"#;
7369        let doc: AdfDocument = serde_json::from_str(json).unwrap();
7370        let md = adf_to_markdown(&doc).unwrap();
7371        assert!(md.contains("- [ ] "), "got: {md}");
7372        assert!(!md.contains("adf-unsupported"), "got: {md}");
7373    }
7374
7375    /// Issue #489: nested taskItem inside taskItem.content renders as indented
7376    /// task items instead of corrupting the surrounding taskList.
7377    #[test]
7378    fn adf_nested_task_item_renders_without_corruption() {
7379        let json = r#"{
7380            "type": "doc",
7381            "version": 1,
7382            "content": [{
7383                "type": "taskList",
7384                "attrs": {"localId": ""},
7385                "content": [
7386                    {
7387                        "type": "taskItem",
7388                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-aabbccdd1234", "state": "TODO"},
7389                        "content": [{"type": "text", "text": "Normal task"}]
7390                    },
7391                    {
7392                        "type": "taskItem",
7393                        "attrs": {"localId": ""},
7394                        "content": [
7395                            {
7396                                "type": "taskItem",
7397                                "attrs": {"localId": "bbccddee-2345-6789-bcde-bbccddee2345", "state": "TODO"},
7398                                "content": [{"type": "text", "text": "Nested task one"}]
7399                            },
7400                            {
7401                                "type": "taskItem",
7402                                "attrs": {"localId": "ccddee11-3456-7890-cdef-ccddee113456", "state": "DONE"},
7403                                "content": [{"type": "text", "text": "Nested task two"}]
7404                            }
7405                        ]
7406                    }
7407                ]
7408            }]
7409        }"#;
7410        let doc: AdfDocument = serde_json::from_str(json).unwrap();
7411        let md = adf_to_markdown(&doc).unwrap();
7412        // Normal task preserved
7413        assert!(md.contains("- [ ] Normal task"), "got: {md}");
7414        // Nested tasks rendered as indented task items, not adf-unsupported
7415        assert!(!md.contains("adf-unsupported"), "got: {md}");
7416        assert!(md.contains("  - [ ] Nested task one"), "got: {md}");
7417        assert!(md.contains("  - [x] Nested task two"), "got: {md}");
7418    }
7419
7420    /// Issue #489: round-trip of nested taskItem preserves data.
7421    #[test]
7422    fn round_trip_nested_task_item() {
7423        let json = r#"{
7424            "type": "doc",
7425            "version": 1,
7426            "content": [{
7427                "type": "taskList",
7428                "attrs": {"localId": ""},
7429                "content": [
7430                    {
7431                        "type": "taskItem",
7432                        "attrs": {"localId": "task-001", "state": "TODO"},
7433                        "content": [{"type": "text", "text": "Normal task"}]
7434                    },
7435                    {
7436                        "type": "taskItem",
7437                        "attrs": {"localId": ""},
7438                        "content": [
7439                            {
7440                                "type": "taskItem",
7441                                "attrs": {"localId": "task-002", "state": "TODO"},
7442                                "content": [{"type": "text", "text": "Nested one"}]
7443                            },
7444                            {
7445                                "type": "taskItem",
7446                                "attrs": {"localId": "task-003", "state": "DONE"},
7447                                "content": [{"type": "text", "text": "Nested two"}]
7448                            }
7449                        ]
7450                    }
7451                ]
7452            }]
7453        }"#;
7454        let doc: AdfDocument = serde_json::from_str(json).unwrap();
7455        let md = adf_to_markdown(&doc).unwrap();
7456        let doc2 = markdown_to_adf(&md).unwrap();
7457
7458        // Top-level structure: taskList with 2 items
7459        assert_eq!(doc2.content[0].node_type, "taskList");
7460        let items = doc2.content[0].content.as_ref().unwrap();
7461        assert_eq!(items.len(), 2, "expected 2 top-level items, got: {items:?}");
7462
7463        // First item: normal task preserved
7464        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
7465        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
7466        let first_content = items[0].content.as_ref().unwrap();
7467        assert_eq!(first_content[0].text.as_deref(), Some("Normal task"));
7468
7469        // Second item: container taskItem — no spurious `state` attr
7470        let container = &items[1];
7471        assert_eq!(container.node_type, "taskItem");
7472        let c_attrs = container.attrs.as_ref().unwrap();
7473        assert!(
7474            c_attrs.get("state").is_none(),
7475            "container should have no state attr, got: {c_attrs:?}"
7476        );
7477
7478        // Children are bare taskItems, NOT wrapped in a taskList
7479        let container_content = container.content.as_ref().unwrap();
7480        assert_eq!(
7481            container_content.len(),
7482            2,
7483            "expected 2 bare taskItem children"
7484        );
7485        assert_eq!(container_content[0].node_type, "taskItem");
7486        assert_eq!(
7487            container_content[0].attrs.as_ref().unwrap()["state"],
7488            "TODO"
7489        );
7490        assert_eq!(
7491            container_content[0].attrs.as_ref().unwrap()["localId"],
7492            "task-002"
7493        );
7494        assert_eq!(container_content[1].node_type, "taskItem");
7495        assert_eq!(
7496            container_content[1].attrs.as_ref().unwrap()["state"],
7497            "DONE"
7498        );
7499        assert_eq!(
7500            container_content[1].attrs.as_ref().unwrap()["localId"],
7501            "task-003"
7502        );
7503    }
7504
7505    /// Issue #489: nested taskItem with localIds on both container and children.
7506    #[test]
7507    fn adf_nested_task_item_preserves_local_ids() {
7508        let json = r#"{
7509            "type": "doc",
7510            "version": 1,
7511            "content": [{
7512                "type": "taskList",
7513                "attrs": {"localId": "list-001"},
7514                "content": [{
7515                    "type": "taskItem",
7516                    "attrs": {"localId": "container-001", "state": "TODO"},
7517                    "content": [{
7518                        "type": "taskItem",
7519                        "attrs": {"localId": "child-001", "state": "DONE"},
7520                        "content": [{"type": "text", "text": "Nested child"}]
7521                    }]
7522                }]
7523            }]
7524        }"#;
7525        let doc: AdfDocument = serde_json::from_str(json).unwrap();
7526        let md = adf_to_markdown(&doc).unwrap();
7527        // Container localId is emitted
7528        assert!(
7529            md.contains("localId=container-001"),
7530            "container localId missing: {md}"
7531        );
7532        // Child localId is emitted
7533        assert!(
7534            md.contains("localId=child-001"),
7535            "child localId missing: {md}"
7536        );
7537        assert!(!md.contains("adf-unsupported"), "got: {md}");
7538    }
7539
7540    /// Issue #489: nested taskItem content mixed with a non-taskItem block node.
7541    /// Covers the else branch in the renderer where a child is not a taskItem.
7542    #[test]
7543    fn adf_nested_task_item_mixed_with_block_node() {
7544        let json = r#"{
7545            "type": "doc",
7546            "version": 1,
7547            "content": [{
7548                "type": "taskList",
7549                "attrs": {"localId": ""},
7550                "content": [{
7551                    "type": "taskItem",
7552                    "attrs": {"localId": "", "state": "TODO"},
7553                    "content": [
7554                        {
7555                            "type": "taskItem",
7556                            "attrs": {"localId": "", "state": "TODO"},
7557                            "content": [{"type": "text", "text": "A nested task"}]
7558                        },
7559                        {
7560                            "type": "paragraph",
7561                            "content": [{"type": "text", "text": "Stray paragraph"}]
7562                        }
7563                    ]
7564                }]
7565            }]
7566        }"#;
7567        let doc: AdfDocument = serde_json::from_str(json).unwrap();
7568        let md = adf_to_markdown(&doc).unwrap();
7569        assert!(md.contains("  - [ ] A nested task"), "got: {md}");
7570        assert!(md.contains("  Stray paragraph"), "got: {md}");
7571        assert!(!md.contains("adf-unsupported"), "got: {md}");
7572    }
7573
7574    /// Issue #489: task item with inline text AND indented sub-content.
7575    /// Covers the parser's `Some` branch when appending nested blocks to
7576    /// an existing content vec.
7577    #[test]
7578    fn task_item_with_text_and_nested_sub_content() {
7579        let md = "- [ ] Parent task\n  - [ ] Sub task\n";
7580        let doc = markdown_to_adf(md).unwrap();
7581        assert_eq!(doc.content[0].node_type, "taskList");
7582        let items = doc.content[0].content.as_ref().unwrap();
7583        // Issue #506: the nested taskList is a sibling of the taskItem,
7584        // not a child — matching ADF's canonical structure.
7585        assert_eq!(items.len(), 2, "got: {items:?}");
7586        let parent = &items[0];
7587        assert_eq!(parent.attrs.as_ref().unwrap()["state"], "TODO");
7588        let parent_content = parent.content.as_ref().unwrap();
7589        assert_eq!(parent_content[0].text.as_deref(), Some("Parent task"));
7590        // Second item: nested taskList (sibling)
7591        assert_eq!(items[1].node_type, "taskList");
7592        let nested = items[1].content.as_ref().unwrap();
7593        assert_eq!(nested.len(), 1);
7594        assert_eq!(nested[0].attrs.as_ref().unwrap()["state"], "TODO");
7595    }
7596
7597    /// Issue #489: empty task item with non-taskList sub-content (e.g. a
7598    /// paragraph).  Exercises the `None` branch when the sub-content does
7599    /// not qualify for container-unwrap.
7600    #[test]
7601    fn task_item_empty_with_non_tasklist_sub_content() {
7602        let md = "- [ ] \n  Some paragraph text\n";
7603        let doc = markdown_to_adf(md).unwrap();
7604        assert_eq!(doc.content[0].node_type, "taskList");
7605        let items = doc.content[0].content.as_ref().unwrap();
7606        assert_eq!(items.len(), 1);
7607        let item = &items[0];
7608        assert_eq!(item.attrs.as_ref().unwrap()["state"], "TODO");
7609        let content = item.content.as_ref().unwrap();
7610        // Sub-content is a paragraph (not unwrapped since it's not a taskList)
7611        assert_eq!(content[0].node_type, "paragraph");
7612    }
7613
7614    /// Issue #489: single nested taskItem (edge case — only one child).
7615    #[test]
7616    fn adf_nested_task_item_single_child() {
7617        let json = r#"{
7618            "type": "doc",
7619            "version": 1,
7620            "content": [{
7621                "type": "taskList",
7622                "attrs": {"localId": ""},
7623                "content": [{
7624                    "type": "taskItem",
7625                    "attrs": {"localId": "", "state": "TODO"},
7626                    "content": [{
7627                        "type": "taskItem",
7628                        "attrs": {"localId": "", "state": "DONE"},
7629                        "content": [{"type": "text", "text": "Only child"}]
7630                    }]
7631                }]
7632            }]
7633        }"#;
7634        let doc: AdfDocument = serde_json::from_str(json).unwrap();
7635        let md = adf_to_markdown(&doc).unwrap();
7636        assert!(md.contains("  - [x] Only child"), "got: {md}");
7637        assert!(!md.contains("adf-unsupported"), "got: {md}");
7638    }
7639
7640    /// Issue #506: nested taskList as direct child of outer taskList is
7641    /// rendered indented so it round-trips back as taskList, not taskItem.
7642    #[test]
7643    fn adf_nested_tasklist_sibling_renders_indented() {
7644        let json = r#"{
7645            "version": 1,
7646            "type": "doc",
7647            "content": [{
7648                "type": "taskList",
7649                "attrs": {"localId": ""},
7650                "content": [
7651                    {
7652                        "type": "taskItem",
7653                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000001", "state": "TODO"},
7654                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
7655                    },
7656                    {
7657                        "type": "taskList",
7658                        "attrs": {"localId": ""},
7659                        "content": [{
7660                            "type": "taskItem",
7661                            "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000002", "state": "TODO"},
7662                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
7663                        }]
7664                    },
7665                    {
7666                        "type": "taskItem",
7667                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000003", "state": "TODO"},
7668                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
7669                    }
7670                ]
7671            }]
7672        }"#;
7673        let doc: AdfDocument = serde_json::from_str(json).unwrap();
7674        let md = adf_to_markdown(&doc).unwrap();
7675        // The nested taskList should be indented under the preceding item.
7676        assert!(md.contains("- [ ] parent task one"), "got: {md}");
7677        assert!(md.contains("  - [ ] nested sub-task"), "got: {md}");
7678        assert!(md.contains("- [ ] parent task two"), "got: {md}");
7679    }
7680
7681    /// Issue #506: round-trip preserves nested taskList type.
7682    #[test]
7683    fn round_trip_nested_tasklist_preserves_type() {
7684        let json = r#"{
7685            "version": 1,
7686            "type": "doc",
7687            "content": [{
7688                "type": "taskList",
7689                "attrs": {"localId": ""},
7690                "content": [
7691                    {
7692                        "type": "taskItem",
7693                        "attrs": {"localId": "", "state": "TODO"},
7694                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
7695                    },
7696                    {
7697                        "type": "taskList",
7698                        "attrs": {"localId": ""},
7699                        "content": [{
7700                            "type": "taskItem",
7701                            "attrs": {"localId": "", "state": "TODO"},
7702                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
7703                        }]
7704                    },
7705                    {
7706                        "type": "taskItem",
7707                        "attrs": {"localId": "", "state": "TODO"},
7708                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
7709                    }
7710                ]
7711            }]
7712        }"#;
7713        let doc: AdfDocument = serde_json::from_str(json).unwrap();
7714        let md = adf_to_markdown(&doc).unwrap();
7715        let rt_doc = markdown_to_adf(&md).unwrap();
7716        // The outer taskList should still be present.
7717        assert_eq!(rt_doc.content[0].node_type, "taskList");
7718        let items = rt_doc.content[0].content.as_ref().unwrap();
7719        // The nested taskList is a sibling of the taskItem nodes,
7720        // matching the original ADF structure (issue #506).
7721        assert_eq!(items.len(), 3, "got: {items:?}");
7722        assert_eq!(items[0].node_type, "taskItem");
7723        assert_eq!(
7724            items[1].node_type, "taskList",
7725            "nested taskList should survive round-trip"
7726        );
7727        assert_eq!(items[2].node_type, "taskItem");
7728        let nested_items = items[1].content.as_ref().unwrap();
7729        assert_eq!(nested_items[0].attrs.as_ref().unwrap()["state"], "TODO");
7730    }
7731
7732    /// Issue #506: nested taskList with DONE state preserves checkbox.
7733    #[test]
7734    fn adf_nested_tasklist_done_state() {
7735        let json = r#"{
7736            "version": 1,
7737            "type": "doc",
7738            "content": [{
7739                "type": "taskList",
7740                "attrs": {"localId": ""},
7741                "content": [
7742                    {
7743                        "type": "taskItem",
7744                        "attrs": {"localId": "", "state": "TODO"},
7745                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
7746                    },
7747                    {
7748                        "type": "taskList",
7749                        "attrs": {"localId": ""},
7750                        "content": [{
7751                            "type": "taskItem",
7752                            "attrs": {"localId": "", "state": "DONE"},
7753                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "done child"}]}]
7754                        }]
7755                    }
7756                ]
7757            }]
7758        }"#;
7759        let doc: AdfDocument = serde_json::from_str(json).unwrap();
7760        let md = adf_to_markdown(&doc).unwrap();
7761        assert!(md.contains("  - [x] done child"), "got: {md}");
7762        // Round-trip preserves DONE state — nested taskList is a sibling.
7763        let rt_doc = markdown_to_adf(&md).unwrap();
7764        let items = rt_doc.content[0].content.as_ref().unwrap();
7765        assert_eq!(
7766            items[1].node_type, "taskList",
7767            "nested taskList should survive round-trip"
7768        );
7769        let nested_item = &items[1].content.as_ref().unwrap()[0];
7770        assert_eq!(nested_item.attrs.as_ref().unwrap()["state"], "DONE");
7771    }
7772
7773    /// Issue #506: multiple nested taskLists at the same level.
7774    #[test]
7775    fn adf_multiple_nested_tasklists() {
7776        let json = r#"{
7777            "version": 1,
7778            "type": "doc",
7779            "content": [{
7780                "type": "taskList",
7781                "attrs": {"localId": ""},
7782                "content": [
7783                    {
7784                        "type": "taskItem",
7785                        "attrs": {"localId": "", "state": "TODO"},
7786                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "first parent"}]}]
7787                    },
7788                    {
7789                        "type": "taskList",
7790                        "attrs": {"localId": ""},
7791                        "content": [{
7792                            "type": "taskItem",
7793                            "attrs": {"localId": "", "state": "TODO"},
7794                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child A"}]}]
7795                        }]
7796                    },
7797                    {
7798                        "type": "taskItem",
7799                        "attrs": {"localId": "", "state": "TODO"},
7800                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "second parent"}]}]
7801                    },
7802                    {
7803                        "type": "taskList",
7804                        "attrs": {"localId": ""},
7805                        "content": [{
7806                            "type": "taskItem",
7807                            "attrs": {"localId": "", "state": "DONE"},
7808                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child B"}]}]
7809                        }]
7810                    }
7811                ]
7812            }]
7813        }"#;
7814        let doc: AdfDocument = serde_json::from_str(json).unwrap();
7815        let md = adf_to_markdown(&doc).unwrap();
7816        assert!(md.contains("- [ ] first parent"), "got: {md}");
7817        assert!(md.contains("  - [ ] child A"), "got: {md}");
7818        assert!(md.contains("- [ ] second parent"), "got: {md}");
7819        assert!(md.contains("  - [x] child B"), "got: {md}");
7820    }
7821
7822    /// Issue #506: second round-trip is stable (idempotent after first
7823    /// structural normalisation).
7824    #[test]
7825    fn round_trip_nested_tasklist_stable() {
7826        let json = r#"{
7827            "version": 1,
7828            "type": "doc",
7829            "content": [{
7830                "type": "taskList",
7831                "attrs": {"localId": ""},
7832                "content": [
7833                    {
7834                        "type": "taskItem",
7835                        "attrs": {"localId": "", "state": "TODO"},
7836                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
7837                    },
7838                    {
7839                        "type": "taskList",
7840                        "attrs": {"localId": ""},
7841                        "content": [{
7842                            "type": "taskItem",
7843                            "attrs": {"localId": "", "state": "TODO"},
7844                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child"}]}]
7845                        }]
7846                    }
7847                ]
7848            }]
7849        }"#;
7850        let doc: AdfDocument = serde_json::from_str(json).unwrap();
7851        // First round-trip.
7852        let md1 = adf_to_markdown(&doc).unwrap();
7853        let rt1 = markdown_to_adf(&md1).unwrap();
7854        // Second round-trip.
7855        let md2 = adf_to_markdown(&rt1).unwrap();
7856        let rt2 = markdown_to_adf(&md2).unwrap();
7857        // Markdown output should be identical after first normalisation.
7858        assert_eq!(md1, md2, "markdown should be stable across round-trips");
7859        // ADF structure should also be stable.
7860        let rt1_json = serde_json::to_string(&rt1).unwrap();
7861        let rt2_json = serde_json::to_string(&rt2).unwrap();
7862        assert_eq!(
7863            rt1_json, rt2_json,
7864            "ADF should be stable across round-trips"
7865        );
7866    }
7867
7868    /// Issue #506: task item with text and mixed indented sub-content
7869    /// (taskList + non-taskList block).  Exercises the `child_nodes` branch
7870    /// where non-taskList blocks stay as children of the taskItem while
7871    /// taskLists are promoted to siblings.
7872    #[test]
7873    fn task_item_mixed_sub_content_splits_siblings() {
7874        let md = "- [ ] Parent task\n  - [ ] Sub task\n  Some paragraph\n";
7875        let doc = markdown_to_adf(md).unwrap();
7876        let items = doc.content[0].content.as_ref().unwrap();
7877        // taskItem + sibling taskList
7878        assert_eq!(items.len(), 2, "got: {items:?}");
7879        assert_eq!(items[0].node_type, "taskItem");
7880        let parent_content = items[0].content.as_ref().unwrap();
7881        // Inline text + paragraph block (the non-taskList sub-content)
7882        assert!(
7883            parent_content.iter().any(|n| n.node_type == "paragraph"),
7884            "non-taskList sub-content should stay as child: {parent_content:?}"
7885        );
7886        // Sibling taskList
7887        assert_eq!(items[1].node_type, "taskList");
7888    }
7889
7890    /// Issue #506: empty task item with mixed indented sub-content hits the
7891    /// `None` arm of the `task.content` match when promoting taskLists to
7892    /// siblings.
7893    #[test]
7894    fn empty_task_item_mixed_sub_content_none_arm() {
7895        let md = "- [ ] \n  Some paragraph\n  - [ ] Sub task\n";
7896        let doc = markdown_to_adf(md).unwrap();
7897        let items = doc.content[0].content.as_ref().unwrap();
7898        // taskItem (with paragraph child) + sibling taskList
7899        assert_eq!(items.len(), 2, "got: {items:?}");
7900        assert_eq!(items[0].node_type, "taskItem");
7901        let parent_content = items[0].content.as_ref().unwrap();
7902        assert!(
7903            parent_content.iter().any(|n| n.node_type == "paragraph"),
7904            "paragraph should be assigned to taskItem: {parent_content:?}"
7905        );
7906        assert_eq!(items[1].node_type, "taskList");
7907    }
7908
7909    /// Issue #506: task item with text and only non-taskList sub-content
7910    /// (no sibling taskLists).  Exercises the fall-through path where
7911    /// `sibling_task_lists` is empty and child_nodes are appended to
7912    /// the existing task content (Some arm).
7913    #[test]
7914    fn task_item_text_with_non_tasklist_sub_content_only() {
7915        let md = "- [ ] My task\n  Extra paragraph content\n";
7916        let doc = markdown_to_adf(md).unwrap();
7917        let items = doc.content[0].content.as_ref().unwrap();
7918        // Single taskItem — no sibling taskLists to extract.
7919        assert_eq!(items.len(), 1, "got: {items:?}");
7920        assert_eq!(items[0].node_type, "taskItem");
7921        let content = items[0].content.as_ref().unwrap();
7922        // Inline text + sub-paragraph
7923        assert!(
7924            content.iter().any(|n| n.node_type == "paragraph"),
7925            "paragraph sub-content should be a child of taskItem: {content:?}"
7926        );
7927    }
7928
7929    /// Covers the else branch in render_list_item_content where the first
7930    /// child of a list item is a block node (not paragraph, not inline).
7931    #[test]
7932    fn adf_list_item_leading_block_node() {
7933        let json = r#"{
7934            "version": 1,
7935            "type": "doc",
7936            "content": [{
7937                "type": "bulletList",
7938                "content": [{
7939                    "type": "listItem",
7940                    "content": [{
7941                        "type": "codeBlock",
7942                        "attrs": {"language": "rust"},
7943                        "content": [{"type": "text", "text": "let x = 1;"}]
7944                    }]
7945                }]
7946            }]
7947        }"#;
7948        let doc: AdfDocument = serde_json::from_str(json).unwrap();
7949        let md = adf_to_markdown(&doc).unwrap();
7950        assert!(md.contains("```rust"), "got: {md}");
7951        assert!(md.contains("let x = 1;"), "got: {md}");
7952        // Continuation lines must be indented so the block stays inside
7953        // the list item on round-trip (issue #511).
7954        for line in md.lines() {
7955            if line.starts_with("- ") {
7956                continue; // first line with list marker
7957            }
7958            if line.trim().is_empty() {
7959                continue;
7960            }
7961            assert!(
7962                line.starts_with("  "),
7963                "continuation line not indented: {line:?}"
7964            );
7965        }
7966    }
7967
7968    /// Round-trip a codeBlock inside a listItem whose content contains a
7969    /// backtick character — the exact reproducer from issue #511.
7970    #[test]
7971    fn code_block_in_list_item_backtick_roundtrip() {
7972        let json = r#"{
7973            "version": 1,
7974            "type": "doc",
7975            "content": [{
7976                "type": "bulletList",
7977                "content": [{
7978                    "type": "listItem",
7979                    "content": [{
7980                        "type": "codeBlock",
7981                        "attrs": {"language": ""},
7982                        "content": [{"type": "text", "text": "error: some value with a backtick ` at end"}]
7983                    }]
7984                }]
7985            }]
7986        }"#;
7987        let original: AdfDocument = serde_json::from_str(json).unwrap();
7988        let md = adf_to_markdown(&original).unwrap();
7989        let roundtripped = markdown_to_adf(&md).unwrap();
7990        let list = &roundtripped.content[0];
7991        assert_eq!(list.node_type, "bulletList", "top node: {}", list.node_type);
7992        let item = &list.content.as_ref().unwrap()[0];
7993        let first_child = &item.content.as_ref().unwrap()[0];
7994        assert_eq!(
7995            first_child.node_type, "codeBlock",
7996            "expected codeBlock, got: {}",
7997            first_child.node_type
7998        );
7999        let text = first_child.content.as_ref().unwrap()[0]
8000            .text
8001            .as_deref()
8002            .unwrap();
8003        assert_eq!(text, "error: some value with a backtick ` at end");
8004    }
8005
8006    /// Code block with language tag inside a list item round-trips.
8007    #[test]
8008    fn code_block_with_language_in_list_item_roundtrip() {
8009        let json = r#"{
8010            "version": 1,
8011            "type": "doc",
8012            "content": [{
8013                "type": "bulletList",
8014                "content": [{
8015                    "type": "listItem",
8016                    "content": [{
8017                        "type": "codeBlock",
8018                        "attrs": {"language": "rust"},
8019                        "content": [{"type": "text", "text": "fn main() {\n    println!(\"hello\");\n}"}]
8020                    }]
8021                }]
8022            }]
8023        }"#;
8024        let original: AdfDocument = serde_json::from_str(json).unwrap();
8025        let md = adf_to_markdown(&original).unwrap();
8026        let roundtripped = markdown_to_adf(&md).unwrap();
8027        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
8028        let code = &item.content.as_ref().unwrap()[0];
8029        assert_eq!(code.node_type, "codeBlock");
8030        let lang = code
8031            .attrs
8032            .as_ref()
8033            .and_then(|a| a.get("language"))
8034            .and_then(serde_json::Value::as_str)
8035            .unwrap_or("");
8036        assert_eq!(lang, "rust");
8037        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
8038        assert!(text.contains("println!"), "code content: {text}");
8039    }
8040
8041    /// Code block in an ordered list item round-trips correctly.
8042    #[test]
8043    fn code_block_in_ordered_list_item_roundtrip() {
8044        let json = r#"{
8045            "version": 1,
8046            "type": "doc",
8047            "content": [{
8048                "type": "orderedList",
8049                "attrs": {"order": 1},
8050                "content": [{
8051                    "type": "listItem",
8052                    "content": [{
8053                        "type": "codeBlock",
8054                        "attrs": {"language": ""},
8055                        "content": [{"type": "text", "text": "backtick ` here"}]
8056                    }]
8057                }]
8058            }]
8059        }"#;
8060        let original: AdfDocument = serde_json::from_str(json).unwrap();
8061        let md = adf_to_markdown(&original).unwrap();
8062        let roundtripped = markdown_to_adf(&md).unwrap();
8063        let list = &roundtripped.content[0];
8064        assert_eq!(list.node_type, "orderedList");
8065        let item = &list.content.as_ref().unwrap()[0];
8066        let code = &item.content.as_ref().unwrap()[0];
8067        assert_eq!(code.node_type, "codeBlock");
8068        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
8069        assert_eq!(text, "backtick ` here");
8070    }
8071
8072    /// A list item with a code block followed by a paragraph round-trips.
8073    #[test]
8074    fn code_block_then_paragraph_in_list_item() {
8075        let json = r#"{
8076            "version": 1,
8077            "type": "doc",
8078            "content": [{
8079                "type": "bulletList",
8080                "content": [{
8081                    "type": "listItem",
8082                    "content": [
8083                        {
8084                            "type": "codeBlock",
8085                            "attrs": {"language": ""},
8086                            "content": [{"type": "text", "text": "code with ` backtick"}]
8087                        },
8088                        {
8089                            "type": "paragraph",
8090                            "content": [{"type": "text", "text": "description"}]
8091                        }
8092                    ]
8093                }]
8094            }]
8095        }"#;
8096        let original: AdfDocument = serde_json::from_str(json).unwrap();
8097        let md = adf_to_markdown(&original).unwrap();
8098        let roundtripped = markdown_to_adf(&md).unwrap();
8099        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
8100        let children = item.content.as_ref().unwrap();
8101        assert_eq!(children[0].node_type, "codeBlock");
8102        assert_eq!(children[1].node_type, "paragraph");
8103    }
8104
8105    /// Multiple backticks in code block content round-trip.
8106    #[test]
8107    fn code_block_multiple_backticks_in_list_item() {
8108        let json = r#"{
8109            "version": 1,
8110            "type": "doc",
8111            "content": [{
8112                "type": "bulletList",
8113                "content": [{
8114                    "type": "listItem",
8115                    "content": [{
8116                        "type": "codeBlock",
8117                        "attrs": {"language": ""},
8118                        "content": [{"type": "text", "text": "a ` b `` c ``` d"}]
8119                    }]
8120                }]
8121            }]
8122        }"#;
8123        let original: AdfDocument = serde_json::from_str(json).unwrap();
8124        let md = adf_to_markdown(&original).unwrap();
8125        let roundtripped = markdown_to_adf(&md).unwrap();
8126        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
8127        let code = &item.content.as_ref().unwrap()[0];
8128        assert_eq!(code.node_type, "codeBlock");
8129        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
8130        assert_eq!(text, "a ` b `` c ``` d");
8131    }
8132
8133    /// Media as the first child of a list item with a subsequent paragraph
8134    /// exercises the media + sub_lines branch in `parse_list_item_first_line`.
8135    #[test]
8136    fn media_first_child_with_sub_content_in_list_item() {
8137        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
8138          {"type":"listItem","content":[
8139            {"type":"mediaSingle","attrs":{"layout":"center"},
8140             "content":[{"type":"media","attrs":{"type":"file","id":"img-99","collection":"col-x","height":50,"width":100}}]},
8141            {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
8142          ]}
8143        ]}]}"#;
8144        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8145        let md = adf_to_markdown(&doc).unwrap();
8146        let rt = markdown_to_adf(&md).unwrap();
8147        let item = &rt.content[0].content.as_ref().unwrap()[0];
8148        let children = item.content.as_ref().unwrap();
8149        assert_eq!(
8150            children.len(),
8151            2,
8152            "expected 2 children, got {}",
8153            children.len()
8154        );
8155        assert_eq!(children[0].node_type, "mediaSingle");
8156        let media = &children[0].content.as_ref().unwrap()[0];
8157        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-99");
8158        assert_eq!(children[1].node_type, "paragraph");
8159    }
8160
8161    #[test]
8162    fn inline_bold() {
8163        let doc = markdown_to_adf("Some **bold** text").unwrap();
8164        let content = doc.content[0].content.as_ref().unwrap();
8165        assert!(content.len() >= 3);
8166        let bold_node = &content[1];
8167        assert_eq!(bold_node.text.as_deref(), Some("bold"));
8168        let marks = bold_node.marks.as_ref().unwrap();
8169        assert_eq!(marks[0].mark_type, "strong");
8170    }
8171
8172    #[test]
8173    fn inline_italic() {
8174        let doc = markdown_to_adf("Some *italic* text").unwrap();
8175        let content = doc.content[0].content.as_ref().unwrap();
8176        let italic_node = &content[1];
8177        assert_eq!(italic_node.text.as_deref(), Some("italic"));
8178        let marks = italic_node.marks.as_ref().unwrap();
8179        assert_eq!(marks[0].mark_type, "em");
8180    }
8181
8182    #[test]
8183    fn inline_code() {
8184        let doc = markdown_to_adf("Use `code` here").unwrap();
8185        let content = doc.content[0].content.as_ref().unwrap();
8186        let code_node = &content[1];
8187        assert_eq!(code_node.text.as_deref(), Some("code"));
8188        let marks = code_node.marks.as_ref().unwrap();
8189        assert_eq!(marks[0].mark_type, "code");
8190    }
8191
8192    /// Issue #578: a code-marked text with an internal backtick must be
8193    /// emitted using double-backtick delimiters so it round-trips as a
8194    /// single node rather than being split on the inner backtick.
8195    #[test]
8196    fn inline_code_with_backtick_emitted_with_double_delimiters() {
8197        let doc = AdfDocument {
8198            version: 1,
8199            doc_type: "doc".to_string(),
8200            content: vec![AdfNode::paragraph(vec![
8201                AdfNode::text("Run "),
8202                AdfNode::text_with_marks(
8203                    "ADD `custom_threshold` TEXT NOT NULL",
8204                    vec![AdfMark::code()],
8205                ),
8206                AdfNode::text(" to update the schema."),
8207            ])],
8208        };
8209        let md = adf_to_markdown(&doc).unwrap();
8210        assert!(
8211            md.contains("``ADD `custom_threshold` TEXT NOT NULL``"),
8212            "expected double-backtick delimiters, got: {md}"
8213        );
8214    }
8215
8216    /// Issue #578: double-backtick delimited code spans parse as a single
8217    /// code-marked text node that preserves the embedded single backticks.
8218    #[test]
8219    fn inline_code_double_backtick_delimiters_parse() {
8220        let doc = markdown_to_adf("Run ``ADD `custom_threshold` TEXT NOT NULL`` now").unwrap();
8221        let content = doc.content[0].content.as_ref().unwrap();
8222        assert_eq!(content.len(), 3, "content: {content:?}");
8223        let code_node = &content[1];
8224        assert_eq!(
8225            code_node.text.as_deref(),
8226            Some("ADD `custom_threshold` TEXT NOT NULL")
8227        );
8228        let marks = code_node.marks.as_ref().unwrap();
8229        assert_eq!(marks[0].mark_type, "code");
8230    }
8231
8232    /// Issue #578: the full reproducer — a code-marked text with inner
8233    /// backticks survives ADF → JFM → ADF round-trip intact.
8234    #[test]
8235    fn inline_code_with_backtick_roundtrip() {
8236        let json = r#"{
8237            "version": 1,
8238            "type": "doc",
8239            "content": [{
8240                "type": "paragraph",
8241                "content": [
8242                    {"type": "text", "text": "Run "},
8243                    {
8244                        "type": "text",
8245                        "text": "ADD `custom_threshold` TEXT NOT NULL",
8246                        "marks": [{"type": "code"}]
8247                    },
8248                    {"type": "text", "text": " to update the schema."}
8249                ]
8250            }]
8251        }"#;
8252        let original: AdfDocument = serde_json::from_str(json).unwrap();
8253        let md = adf_to_markdown(&original).unwrap();
8254        let roundtripped = markdown_to_adf(&md).unwrap();
8255        let para = &roundtripped.content[0];
8256        let children = para.content.as_ref().unwrap();
8257        assert_eq!(children.len(), 3, "expected 3 children, got: {children:?}");
8258        assert_eq!(children[0].text.as_deref(), Some("Run "));
8259        assert_eq!(
8260            children[1].text.as_deref(),
8261            Some("ADD `custom_threshold` TEXT NOT NULL")
8262        );
8263        let marks = children[1].marks.as_ref().unwrap();
8264        assert_eq!(marks.len(), 1);
8265        assert_eq!(marks[0].mark_type, "code");
8266        assert_eq!(children[2].text.as_deref(), Some(" to update the schema."));
8267    }
8268
8269    /// A code-marked text containing a run of two backticks should be
8270    /// emitted with triple-backtick delimiters and round-trip intact —
8271    /// the first line of the paragraph also starts with the fence so this
8272    /// exercises the info-string-with-backtick fence-opener rejection.
8273    #[test]
8274    fn inline_code_with_double_backtick_roundtrip() {
8275        let doc = AdfDocument {
8276            version: 1,
8277            doc_type: "doc".to_string(),
8278            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8279                "x `` y",
8280                vec![AdfMark::code()],
8281            )])],
8282        };
8283        let md = adf_to_markdown(&doc).unwrap();
8284        let roundtripped = markdown_to_adf(&md).unwrap();
8285        let content = roundtripped.content[0].content.as_ref().unwrap();
8286        assert_eq!(content.len(), 1);
8287        assert_eq!(content[0].text.as_deref(), Some("x `` y"));
8288        let marks = content[0].marks.as_ref().unwrap();
8289        assert_eq!(marks[0].mark_type, "code");
8290    }
8291
8292    /// A code-marked text that begins with a backtick must be padded on
8293    /// both sides so the CommonMark space-stripping rule reconstructs it.
8294    #[test]
8295    fn inline_code_leading_backtick_roundtrip() {
8296        let doc = AdfDocument {
8297            version: 1,
8298            doc_type: "doc".to_string(),
8299            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8300                "`start",
8301                vec![AdfMark::code()],
8302            )])],
8303        };
8304        let md = adf_to_markdown(&doc).unwrap();
8305        let roundtripped = markdown_to_adf(&md).unwrap();
8306        let content = roundtripped.content[0].content.as_ref().unwrap();
8307        assert_eq!(content[0].text.as_deref(), Some("`start"));
8308        assert_eq!(content[0].marks.as_ref().unwrap()[0].mark_type, "code");
8309    }
8310
8311    /// A code-marked text that ends with a backtick must also survive.
8312    #[test]
8313    fn inline_code_trailing_backtick_roundtrip() {
8314        let doc = AdfDocument {
8315            version: 1,
8316            doc_type: "doc".to_string(),
8317            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8318                "end`",
8319                vec![AdfMark::code()],
8320            )])],
8321        };
8322        let md = adf_to_markdown(&doc).unwrap();
8323        let roundtripped = markdown_to_adf(&md).unwrap();
8324        let content = roundtripped.content[0].content.as_ref().unwrap();
8325        assert_eq!(content[0].text.as_deref(), Some("end`"));
8326    }
8327
8328    /// Content that both begins and ends with a space (but is not all
8329    /// spaces) needs padding so the stripping rule leaves it intact.
8330    #[test]
8331    fn inline_code_space_padded_content_roundtrip() {
8332        let doc = AdfDocument {
8333            version: 1,
8334            doc_type: "doc".to_string(),
8335            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8336                " foo ",
8337                vec![AdfMark::code()],
8338            )])],
8339        };
8340        let md = adf_to_markdown(&doc).unwrap();
8341        let roundtripped = markdown_to_adf(&md).unwrap();
8342        let content = roundtripped.content[0].content.as_ref().unwrap();
8343        assert_eq!(content[0].text.as_deref(), Some(" foo "));
8344    }
8345
8346    /// All-space content must round-trip without the stripping rule
8347    /// kicking in (per CommonMark: all-space content is not stripped).
8348    #[test]
8349    fn inline_code_all_spaces_roundtrip() {
8350        let doc = AdfDocument {
8351            version: 1,
8352            doc_type: "doc".to_string(),
8353            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8354                "   ",
8355                vec![AdfMark::code()],
8356            )])],
8357        };
8358        let md = adf_to_markdown(&doc).unwrap();
8359        let roundtripped = markdown_to_adf(&md).unwrap();
8360        let content = roundtripped.content[0].content.as_ref().unwrap();
8361        assert_eq!(content[0].text.as_deref(), Some("   "));
8362    }
8363
8364    /// A code+link mark where the code text contains a backtick must also
8365    /// round-trip — verifies the link branch of code-span rendering.
8366    #[test]
8367    fn inline_code_with_link_and_backtick_roundtrip() {
8368        let doc = AdfDocument {
8369            version: 1,
8370            doc_type: "doc".to_string(),
8371            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8372                "fn `inner`",
8373                vec![AdfMark::code(), AdfMark::link("https://example.com")],
8374            )])],
8375        };
8376        let md = adf_to_markdown(&doc).unwrap();
8377        assert!(
8378            md.contains("`` fn `inner` ``"),
8379            "expected padded double-backtick delimiters inside link, got: {md}"
8380        );
8381        let roundtripped = markdown_to_adf(&md).unwrap();
8382        let content = roundtripped.content[0].content.as_ref().unwrap();
8383        assert_eq!(content[0].text.as_deref(), Some("fn `inner`"));
8384        let mark_types: Vec<&str> = content[0]
8385            .marks
8386            .as_ref()
8387            .unwrap()
8388            .iter()
8389            .map(|m| m.mark_type.as_str())
8390            .collect();
8391        assert!(mark_types.contains(&"code"));
8392        assert!(mark_types.contains(&"link"));
8393    }
8394
8395    /// Unmatched opening backticks must not be parsed as a code span.
8396    #[test]
8397    fn inline_code_unmatched_run_is_plain_text() {
8398        let doc = markdown_to_adf("foo ``bar baz").unwrap();
8399        let content = doc.content[0].content.as_ref().unwrap();
8400        assert_eq!(content.len(), 1);
8401        assert_eq!(content[0].text.as_deref(), Some("foo ``bar baz"));
8402        assert!(content[0].marks.is_none());
8403    }
8404
8405    /// Mismatched delimiter lengths must not form a code span.  Per
8406    /// CommonMark the opening 2-backtick run and the trailing 1-backtick
8407    /// run never form a valid code span and the characters stay literal.
8408    #[test]
8409    fn inline_code_mismatched_delimiters_is_plain_text() {
8410        let doc = markdown_to_adf("``foo` bar").unwrap();
8411        let content = doc.content[0].content.as_ref().unwrap();
8412        assert_eq!(content.len(), 1);
8413        assert_eq!(content[0].text.as_deref(), Some("``foo` bar"));
8414        assert!(content[0].marks.is_none());
8415    }
8416
8417    #[test]
8418    fn inline_code_delimiter_chooses_correct_length() {
8419        assert_eq!(inline_code_delimiter("no ticks"), (1, false));
8420        assert_eq!(inline_code_delimiter("one ` here"), (2, false));
8421        assert_eq!(inline_code_delimiter("two `` here"), (3, false));
8422        assert_eq!(inline_code_delimiter("three ``` here"), (4, false));
8423        assert_eq!(inline_code_delimiter("`leading"), (2, true));
8424        assert_eq!(inline_code_delimiter("trailing`"), (2, true));
8425        assert_eq!(inline_code_delimiter(" foo "), (1, true));
8426        assert_eq!(inline_code_delimiter(" "), (1, false));
8427        assert_eq!(inline_code_delimiter("   "), (1, false));
8428        assert_eq!(inline_code_delimiter(" foo"), (1, false));
8429    }
8430
8431    #[test]
8432    fn try_parse_inline_code_strips_paired_spaces() {
8433        let (end, content) = try_parse_inline_code("`` `foo` ``", 0).unwrap();
8434        assert_eq!(end, 11);
8435        assert_eq!(content, "`foo`");
8436    }
8437
8438    #[test]
8439    fn try_parse_inline_code_all_space_content_is_preserved() {
8440        let (_end, content) = try_parse_inline_code("`   `", 0).unwrap();
8441        assert_eq!(content, "   ");
8442    }
8443
8444    #[test]
8445    fn try_parse_inline_code_single_run_matches_first_close() {
8446        let (end, content) = try_parse_inline_code("`foo` tail", 0).unwrap();
8447        assert_eq!(end, 5);
8448        assert_eq!(content, "foo");
8449    }
8450
8451    #[test]
8452    fn try_parse_inline_code_no_match_returns_none() {
8453        assert!(try_parse_inline_code("``unmatched", 0).is_none());
8454        assert!(try_parse_inline_code("plain text", 0).is_none());
8455    }
8456
8457    #[test]
8458    fn is_code_fence_opener_rejects_info_with_backtick() {
8459        assert!(is_code_fence_opener("```"));
8460        assert!(is_code_fence_opener("```rust"));
8461        assert!(is_code_fence_opener("```\"\""));
8462        assert!(!is_code_fence_opener("```x `` y```"));
8463        assert!(!is_code_fence_opener("``not-enough"));
8464        assert!(!is_code_fence_opener("no fence"));
8465    }
8466
8467    #[test]
8468    fn inline_strikethrough() {
8469        let doc = markdown_to_adf("Some ~~deleted~~ text").unwrap();
8470        let content = doc.content[0].content.as_ref().unwrap();
8471        let strike_node = &content[1];
8472        assert_eq!(strike_node.text.as_deref(), Some("deleted"));
8473        let marks = strike_node.marks.as_ref().unwrap();
8474        assert_eq!(marks[0].mark_type, "strike");
8475    }
8476
8477    #[test]
8478    fn inline_link() {
8479        let doc = markdown_to_adf("Click [here](https://example.com) now").unwrap();
8480        let content = doc.content[0].content.as_ref().unwrap();
8481        let link_node = &content[1];
8482        assert_eq!(link_node.text.as_deref(), Some("here"));
8483        let marks = link_node.marks.as_ref().unwrap();
8484        assert_eq!(marks[0].mark_type, "link");
8485    }
8486
8487    #[test]
8488    fn block_image() {
8489        let md = "![Alt text](https://example.com/image.png)";
8490        let doc = markdown_to_adf(md).unwrap();
8491        assert_eq!(doc.content[0].node_type, "mediaSingle");
8492    }
8493
8494    #[test]
8495    fn table() {
8496        let md = "| A | B |\n| --- | --- |\n| 1 | 2 |";
8497        let doc = markdown_to_adf(md).unwrap();
8498        assert_eq!(doc.content[0].node_type, "table");
8499        let rows = doc.content[0].content.as_ref().unwrap();
8500        assert_eq!(rows.len(), 2); // header + 1 body row
8501    }
8502
8503    // ── adf_to_markdown tests ───────────────────────────────────────
8504
8505    #[test]
8506    fn adf_paragraph_to_markdown() {
8507        let doc = AdfDocument {
8508            version: 1,
8509            doc_type: "doc".to_string(),
8510            content: vec![AdfNode::paragraph(vec![AdfNode::text("Hello world")])],
8511        };
8512        let md = adf_to_markdown(&doc).unwrap();
8513        assert_eq!(md.trim(), "Hello world");
8514    }
8515
8516    #[test]
8517    fn adf_heading_to_markdown() {
8518        let doc = AdfDocument {
8519            version: 1,
8520            doc_type: "doc".to_string(),
8521            content: vec![AdfNode::heading(2, vec![AdfNode::text("Title")])],
8522        };
8523        let md = adf_to_markdown(&doc).unwrap();
8524        assert_eq!(md.trim(), "## Title");
8525    }
8526
8527    #[test]
8528    fn adf_bold_to_markdown() {
8529        let doc = AdfDocument {
8530            version: 1,
8531            doc_type: "doc".to_string(),
8532            content: vec![AdfNode::paragraph(vec![
8533                AdfNode::text("Normal "),
8534                AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
8535                AdfNode::text(" text"),
8536            ])],
8537        };
8538        let md = adf_to_markdown(&doc).unwrap();
8539        assert_eq!(md.trim(), "Normal **bold** text");
8540    }
8541
8542    #[test]
8543    fn adf_code_block_to_markdown() {
8544        let doc = AdfDocument {
8545            version: 1,
8546            doc_type: "doc".to_string(),
8547            content: vec![AdfNode::code_block(Some("rust"), "let x = 1;")],
8548        };
8549        let md = adf_to_markdown(&doc).unwrap();
8550        assert!(md.contains("```rust"));
8551        assert!(md.contains("let x = 1;"));
8552        assert!(md.contains("```"));
8553    }
8554
8555    #[test]
8556    fn adf_rule_to_markdown() {
8557        let doc = AdfDocument {
8558            version: 1,
8559            doc_type: "doc".to_string(),
8560            content: vec![AdfNode::rule()],
8561        };
8562        let md = adf_to_markdown(&doc).unwrap();
8563        assert!(md.contains("---"));
8564    }
8565
8566    #[test]
8567    fn adf_bullet_list_to_markdown() {
8568        let doc = AdfDocument {
8569            version: 1,
8570            doc_type: "doc".to_string(),
8571            content: vec![AdfNode::bullet_list(vec![
8572                AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("A")])]),
8573                AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("B")])]),
8574            ])],
8575        };
8576        let md = adf_to_markdown(&doc).unwrap();
8577        assert!(md.contains("- A"));
8578        assert!(md.contains("- B"));
8579    }
8580
8581    #[test]
8582    fn adf_link_to_markdown() {
8583        let doc = AdfDocument {
8584            version: 1,
8585            doc_type: "doc".to_string(),
8586            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8587                "click",
8588                vec![AdfMark::link("https://example.com")],
8589            )])],
8590        };
8591        let md = adf_to_markdown(&doc).unwrap();
8592        assert_eq!(md.trim(), "[click](https://example.com)");
8593    }
8594
8595    #[test]
8596    fn unsupported_block_preserved_as_json() {
8597        let doc = AdfDocument {
8598            version: 1,
8599            doc_type: "doc".to_string(),
8600            content: vec![AdfNode {
8601                node_type: "unknownBlock".to_string(),
8602                attrs: Some(serde_json::json!({"key": "value"})),
8603                content: None,
8604                text: None,
8605                marks: None,
8606                local_id: None,
8607                parameters: None,
8608            }],
8609        };
8610        let md = adf_to_markdown(&doc).unwrap();
8611        assert!(md.contains("```adf-unsupported"));
8612        assert!(md.contains("\"unknownBlock\""));
8613    }
8614
8615    #[test]
8616    fn unsupported_block_round_trips() {
8617        let original = AdfDocument {
8618            version: 1,
8619            doc_type: "doc".to_string(),
8620            content: vec![AdfNode {
8621                node_type: "unknownBlock".to_string(),
8622                attrs: Some(serde_json::json!({"key": "value"})),
8623                content: None,
8624                text: None,
8625                marks: None,
8626                local_id: None,
8627                parameters: None,
8628            }],
8629        };
8630        let md = adf_to_markdown(&original).unwrap();
8631        let restored = markdown_to_adf(&md).unwrap();
8632        assert_eq!(restored.content[0].node_type, "unknownBlock");
8633        assert_eq!(restored.content[0].attrs.as_ref().unwrap()["key"], "value");
8634    }
8635
8636    // ── Round-trip tests ────────────────────────────────────────────
8637
8638    #[test]
8639    fn round_trip_simple_document() {
8640        let md = "# Hello\n\nSome text with **bold** and *italic*.\n\n- Item 1\n- Item 2\n";
8641        let adf = markdown_to_adf(md).unwrap();
8642        let restored = adf_to_markdown(&adf).unwrap();
8643
8644        assert!(restored.contains("# Hello"));
8645        assert!(restored.contains("**bold**"));
8646        assert!(restored.contains("*italic*"));
8647        assert!(restored.contains("- Item 1"));
8648        assert!(restored.contains("- Item 2"));
8649    }
8650
8651    #[test]
8652    fn round_trip_code_block() {
8653        let md = "```python\nprint('hello')\n```\n";
8654        let adf = markdown_to_adf(md).unwrap();
8655        let restored = adf_to_markdown(&adf).unwrap();
8656
8657        assert!(restored.contains("```python"));
8658        assert!(restored.contains("print('hello')"));
8659    }
8660
8661    #[test]
8662    fn round_trip_code_block_no_attrs() {
8663        let adf_json = r#"{"version":1,"type":"doc","content":[
8664            {"type":"codeBlock","content":[{"type":"text","text":"plain code"}]}
8665        ]}"#;
8666        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8667        assert!(doc.content[0].attrs.is_none());
8668        let md = adf_to_markdown(&doc).unwrap();
8669        let round_tripped = markdown_to_adf(&md).unwrap();
8670        assert!(round_tripped.content[0].attrs.is_none());
8671    }
8672
8673    #[test]
8674    fn round_trip_code_block_empty_language() {
8675        let adf_json = r#"{"version":1,"type":"doc","content":[
8676            {"type":"codeBlock","attrs":{"language":""},"content":[{"type":"text","text":"simple code block no backtick"}]}
8677        ]}"#;
8678        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8679        let attrs = doc.content[0].attrs.as_ref().unwrap();
8680        assert_eq!(attrs["language"], "");
8681        let md = adf_to_markdown(&doc).unwrap();
8682        let round_tripped = markdown_to_adf(&md).unwrap();
8683        let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
8684        assert_eq!(rt_attrs["language"], "");
8685    }
8686
8687    #[test]
8688    fn round_trip_code_block_with_language() {
8689        let adf_json = r#"{"version":1,"type":"doc","content":[
8690            {"type":"codeBlock","attrs":{"language":"python"},"content":[{"type":"text","text":"print('hi')"}]}
8691        ]}"#;
8692        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8693        let md = adf_to_markdown(&doc).unwrap();
8694        let round_tripped = markdown_to_adf(&md).unwrap();
8695        let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
8696        assert_eq!(rt_attrs["language"], "python");
8697    }
8698
8699    #[test]
8700    fn multiple_paragraphs() {
8701        let md = "First paragraph.\n\nSecond paragraph.\n";
8702        let adf = markdown_to_adf(md).unwrap();
8703        assert_eq!(adf.content.len(), 2);
8704        assert_eq!(adf.content[0].node_type, "paragraph");
8705        assert_eq!(adf.content[1].node_type, "paragraph");
8706    }
8707
8708    // ── Additional markdown_to_adf tests ───────────────────────────────
8709
8710    #[test]
8711    fn horizontal_rule_underscores() {
8712        let doc = markdown_to_adf("___").unwrap();
8713        assert_eq!(doc.content[0].node_type, "rule");
8714    }
8715
8716    #[test]
8717    fn not_a_horizontal_rule_too_short() {
8718        let doc = markdown_to_adf("--").unwrap();
8719        assert_eq!(doc.content[0].node_type, "paragraph");
8720    }
8721
8722    #[test]
8723    fn bullet_list_star_marker() {
8724        let md = "* Apple\n* Banana";
8725        let doc = markdown_to_adf(md).unwrap();
8726        assert_eq!(doc.content[0].node_type, "bulletList");
8727        let items = doc.content[0].content.as_ref().unwrap();
8728        assert_eq!(items.len(), 2);
8729    }
8730
8731    #[test]
8732    fn bullet_list_plus_marker() {
8733        let md = "+ One\n+ Two";
8734        let doc = markdown_to_adf(md).unwrap();
8735        assert_eq!(doc.content[0].node_type, "bulletList");
8736    }
8737
8738    #[test]
8739    fn ordered_list_non_one_start() {
8740        let md = "5. Fifth\n6. Sixth";
8741        let doc = markdown_to_adf(md).unwrap();
8742        let node = &doc.content[0];
8743        assert_eq!(node.node_type, "orderedList");
8744        let attrs = node.attrs.as_ref().unwrap();
8745        assert_eq!(attrs["order"], 5);
8746    }
8747
8748    #[test]
8749    fn ordered_list_start_at_one_omits_order_attr() {
8750        // Issue #547: order=1 is the default and must be omitted from attrs
8751        // so that ADF→JFM→ADF round-trip is byte-identical for the common
8752        // case where the source ADF has no attrs object on orderedList.
8753        let md = "1. First\n2. Second";
8754        let doc = markdown_to_adf(md).unwrap();
8755        let node = &doc.content[0];
8756        assert_eq!(node.node_type, "orderedList");
8757        assert!(
8758            node.attrs.is_none(),
8759            "attrs should be omitted when order=1, got: {:?}",
8760            node.attrs
8761        );
8762    }
8763
8764    #[test]
8765    fn blockquote_bare_marker() {
8766        // ">" with no space after
8767        let md = ">quoted text";
8768        let doc = markdown_to_adf(md).unwrap();
8769        assert_eq!(doc.content[0].node_type, "blockquote");
8770    }
8771
8772    #[test]
8773    fn image_no_alt() {
8774        let md = "![](https://example.com/img.png)";
8775        let doc = markdown_to_adf(md).unwrap();
8776        let node = &doc.content[0];
8777        assert_eq!(node.node_type, "mediaSingle");
8778        // media child should have no alt attr
8779        let media = &node.content.as_ref().unwrap()[0];
8780        let attrs = media.attrs.as_ref().unwrap();
8781        assert!(attrs.get("alt").is_none());
8782    }
8783
8784    #[test]
8785    fn image_with_alt() {
8786        let md = "![A photo](https://example.com/photo.jpg)";
8787        let doc = markdown_to_adf(md).unwrap();
8788        let media = &doc.content[0].content.as_ref().unwrap()[0];
8789        let attrs = media.attrs.as_ref().unwrap();
8790        assert_eq!(attrs["alt"], "A photo");
8791    }
8792
8793    #[test]
8794    fn table_multi_body_rows() {
8795        let md = "| H1 | H2 |\n| --- | --- |\n| a | b |\n| c | d |";
8796        let doc = markdown_to_adf(md).unwrap();
8797        let rows = doc.content[0].content.as_ref().unwrap();
8798        assert_eq!(rows.len(), 3); // header + 2 body rows
8799                                   // First row cells are tableHeader
8800        let header_cells = rows[0].content.as_ref().unwrap();
8801        assert_eq!(header_cells[0].node_type, "tableHeader");
8802        // Body row cells are tableCell
8803        let body_cells = rows[1].content.as_ref().unwrap();
8804        assert_eq!(body_cells[0].node_type, "tableCell");
8805    }
8806
8807    #[test]
8808    fn table_no_separator_is_not_table() {
8809        // Pipe characters without a separator row should not parse as table
8810        let md = "| not | a table |";
8811        let doc = markdown_to_adf(md).unwrap();
8812        assert_eq!(doc.content[0].node_type, "paragraph");
8813    }
8814
8815    #[test]
8816    fn inline_underscore_bold() {
8817        let doc = markdown_to_adf("Some __bold__ text").unwrap();
8818        let content = doc.content[0].content.as_ref().unwrap();
8819        let bold_node = &content[1];
8820        assert_eq!(bold_node.text.as_deref(), Some("bold"));
8821        let marks = bold_node.marks.as_ref().unwrap();
8822        assert_eq!(marks[0].mark_type, "strong");
8823    }
8824
8825    #[test]
8826    fn inline_underscore_italic() {
8827        let doc = markdown_to_adf("Some _italic_ text").unwrap();
8828        let content = doc.content[0].content.as_ref().unwrap();
8829        let italic_node = &content[1];
8830        assert_eq!(italic_node.text.as_deref(), Some("italic"));
8831        let marks = italic_node.marks.as_ref().unwrap();
8832        assert_eq!(marks[0].mark_type, "em");
8833    }
8834
8835    #[test]
8836    fn intraword_underscore_not_emphasis() {
8837        // Single intraword underscore pair: do_something_useful
8838        let doc = markdown_to_adf("call do_something_useful now").unwrap();
8839        let content = doc.content[0].content.as_ref().unwrap();
8840        assert_eq!(content.len(), 1, "should be a single text node");
8841        assert_eq!(
8842            content[0].text.as_deref(),
8843            Some("call do_something_useful now")
8844        );
8845        assert!(content[0].marks.is_none());
8846    }
8847
8848    #[test]
8849    fn intraword_underscore_multiple() {
8850        // Multiple intraword underscores: a_b_c_d
8851        let doc = markdown_to_adf("use a_b_c_d here").unwrap();
8852        let content = doc.content[0].content.as_ref().unwrap();
8853        assert_eq!(content.len(), 1);
8854        assert_eq!(content[0].text.as_deref(), Some("use a_b_c_d here"));
8855        assert!(content[0].marks.is_none());
8856    }
8857
8858    #[test]
8859    fn intraword_double_underscore_not_bold() {
8860        // Intraword double underscores: foo__bar__baz
8861        let doc = markdown_to_adf("foo__bar__baz").unwrap();
8862        let content = doc.content[0].content.as_ref().unwrap();
8863        assert_eq!(content.len(), 1);
8864        assert_eq!(content[0].text.as_deref(), Some("foo__bar__baz"));
8865        assert!(content[0].marks.is_none());
8866    }
8867
8868    #[test]
8869    fn intraword_triple_underscore_not_bold_italic() {
8870        // Intraword triple underscores: x___y___z
8871        let doc = markdown_to_adf("x___y___z").unwrap();
8872        let content = doc.content[0].content.as_ref().unwrap();
8873        assert_eq!(content.len(), 1);
8874        assert_eq!(content[0].text.as_deref(), Some("x___y___z"));
8875        assert!(content[0].marks.is_none());
8876    }
8877
8878    #[test]
8879    fn underscore_emphasis_still_works_with_spaces() {
8880        // Normal emphasis with spaces around delimiters should still work
8881        let doc = markdown_to_adf("some _italic_ here").unwrap();
8882        let content = doc.content[0].content.as_ref().unwrap();
8883        assert_eq!(content.len(), 3);
8884        assert_eq!(content[1].text.as_deref(), Some("italic"));
8885        let marks = content[1].marks.as_ref().unwrap();
8886        assert_eq!(marks[0].mark_type, "em");
8887    }
8888
8889    #[test]
8890    fn underscore_bold_still_works_with_spaces() {
8891        // Normal bold with spaces around delimiters should still work
8892        let doc = markdown_to_adf("some __bold__ here").unwrap();
8893        let content = doc.content[0].content.as_ref().unwrap();
8894        assert_eq!(content.len(), 3);
8895        assert_eq!(content[1].text.as_deref(), Some("bold"));
8896        let marks = content[1].marks.as_ref().unwrap();
8897        assert_eq!(marks[0].mark_type, "strong");
8898    }
8899
8900    #[test]
8901    fn intraword_underscore_closing_only() {
8902        // Opening _ is valid (preceded by space) but closing _ is intraword: _foo_bar
8903        let doc = markdown_to_adf("_foo_bar").unwrap();
8904        let content = doc.content[0].content.as_ref().unwrap();
8905        assert_eq!(content.len(), 1);
8906        assert_eq!(content[0].text.as_deref(), Some("_foo_bar"));
8907    }
8908
8909    #[test]
8910    fn intraword_double_underscore_closing_only() {
8911        // Opening __ is valid (at start) but closing __ is intraword: __foo__bar
8912        let doc = markdown_to_adf("__foo__bar").unwrap();
8913        let content = doc.content[0].content.as_ref().unwrap();
8914        assert_eq!(content.len(), 1);
8915        assert_eq!(content[0].text.as_deref(), Some("__foo__bar"));
8916    }
8917
8918    #[test]
8919    fn intraword_triple_underscore_closing_only() {
8920        // Opening ___ is valid (at start) but closing ___ is intraword: ___foo___bar
8921        let doc = markdown_to_adf("___foo___bar").unwrap();
8922        let content = doc.content[0].content.as_ref().unwrap();
8923        assert_eq!(content.len(), 1);
8924        assert_eq!(content[0].text.as_deref(), Some("___foo___bar"));
8925    }
8926
8927    #[test]
8928    fn asterisk_emphasis_unaffected_by_intraword_fix() {
8929        // Asterisks should still work for intraword emphasis (CommonMark allows this)
8930        let doc = markdown_to_adf("foo*bar*baz").unwrap();
8931        let content = doc.content[0].content.as_ref().unwrap();
8932        // Asterisks CAN be intraword emphasis per CommonMark
8933        assert!(content.len() > 1 || content[0].marks.is_some());
8934    }
8935
8936    #[test]
8937    fn intraword_underscore_at_start_of_text() {
8938        // Underscore at start of text is not intraword (no preceding alphanumeric)
8939        let doc = markdown_to_adf("_italic_ word").unwrap();
8940        let content = doc.content[0].content.as_ref().unwrap();
8941        assert_eq!(content[0].text.as_deref(), Some("italic"));
8942        let marks = content[0].marks.as_ref().unwrap();
8943        assert_eq!(marks[0].mark_type, "em");
8944    }
8945
8946    #[test]
8947    fn intraword_underscore_at_end_of_text() {
8948        // Underscore at end of text is not intraword (no following alphanumeric)
8949        let doc = markdown_to_adf("word _italic_").unwrap();
8950        let content = doc.content[0].content.as_ref().unwrap();
8951        let last = content.last().unwrap();
8952        assert_eq!(last.text.as_deref(), Some("italic"));
8953        let marks = last.marks.as_ref().unwrap();
8954        assert_eq!(marks[0].mark_type, "em");
8955    }
8956
8957    #[test]
8958    fn intraword_underscore_opening_only() {
8959        // Opening underscore is intraword but closing is not: a_b c_d
8960        // The first _ is intraword (a_b), so it shouldn't open emphasis
8961        let doc = markdown_to_adf("a_b c_d").unwrap();
8962        let content = doc.content[0].content.as_ref().unwrap();
8963        assert_eq!(content.len(), 1);
8964        assert_eq!(content[0].text.as_deref(), Some("a_b c_d"));
8965    }
8966
8967    #[test]
8968    fn intraword_underscore_roundtrip() {
8969        // The original reproducer from issue #438
8970        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call the do_something_useful function"}]}]}"#;
8971        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
8972        let jfm = adf_to_markdown(&adf).unwrap();
8973        let roundtripped = markdown_to_adf(&jfm).unwrap();
8974        let content = roundtripped.content[0].content.as_ref().unwrap();
8975        assert_eq!(content.len(), 1, "should round-trip as a single text node");
8976        assert_eq!(
8977            content[0].text.as_deref(),
8978            Some("call the do_something_useful function")
8979        );
8980        assert!(content[0].marks.is_none());
8981    }
8982
8983    #[test]
8984    fn asterisk_emphasis_roundtrip() {
8985        // The original reproducer from issue #452
8986        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status: *confirmed* and active"}]}]}"#;
8987        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
8988        let jfm = adf_to_markdown(&adf).unwrap();
8989        let roundtripped = markdown_to_adf(&jfm).unwrap();
8990        let content = roundtripped.content[0].content.as_ref().unwrap();
8991        assert_eq!(content.len(), 1, "should round-trip as a single text node");
8992        assert_eq!(
8993            content[0].text.as_deref(),
8994            Some("Status: *confirmed* and active")
8995        );
8996        assert!(content[0].marks.is_none());
8997    }
8998
8999    #[test]
9000    fn double_asterisk_roundtrip() {
9001        // **bold** delimiters in plain text should not become strong marks
9002        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use **kwargs in Python"}]}]}"#;
9003        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9004        let jfm = adf_to_markdown(&adf).unwrap();
9005        let roundtripped = markdown_to_adf(&jfm).unwrap();
9006        let content = roundtripped.content[0].content.as_ref().unwrap();
9007        assert_eq!(content.len(), 1, "should round-trip as a single text node");
9008        assert_eq!(content[0].text.as_deref(), Some("Use **kwargs in Python"));
9009        assert!(content[0].marks.is_none());
9010    }
9011
9012    #[test]
9013    fn asterisk_with_em_mark_roundtrip() {
9014        // Text that already has an em mark should preserve both the mark and escaped content
9015        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a*b","marks":[{"type":"em"}]}]}]}"#;
9016        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9017        let jfm = adf_to_markdown(&adf).unwrap();
9018        let roundtripped = markdown_to_adf(&jfm).unwrap();
9019        let content = roundtripped.content[0].content.as_ref().unwrap();
9020        // Find the node with em mark
9021        let em_node = content.iter().find(|n| {
9022            n.marks
9023                .as_ref()
9024                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
9025        });
9026        assert!(em_node.is_some(), "should have an em-marked node");
9027        assert_eq!(em_node.unwrap().text.as_deref(), Some("a*b"));
9028    }
9029
9030    #[test]
9031    fn lone_asterisk_roundtrip() {
9032        // A single asterisk that cannot form emphasis should round-trip
9033        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"rating: 5 * stars"}]}]}"#;
9034        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9035        let jfm = adf_to_markdown(&adf).unwrap();
9036        let roundtripped = markdown_to_adf(&jfm).unwrap();
9037        let content = roundtripped.content[0].content.as_ref().unwrap();
9038        assert_eq!(content.len(), 1, "should round-trip as a single text node");
9039        assert_eq!(content[0].text.as_deref(), Some("rating: 5 * stars"));
9040    }
9041
9042    #[test]
9043    fn escape_emphasis_markers_unit() {
9044        assert_eq!(escape_emphasis_markers("hello"), "hello");
9045        assert_eq!(escape_emphasis_markers("*bold*"), r"\*bold\*");
9046        assert_eq!(escape_emphasis_markers("**strong**"), r"\*\*strong\*\*");
9047        assert_eq!(escape_emphasis_markers("no stars"), "no stars");
9048        assert_eq!(escape_emphasis_markers("a * b"), r"a \* b");
9049        assert_eq!(escape_emphasis_markers(""), "");
9050    }
9051
9052    #[test]
9053    fn escape_emphasis_markers_underscore_intraword() {
9054        // Intraword underscores (alnum on both sides within the node) are
9055        // left as-is — the JFM parser will reject them as emphasis.
9056        assert_eq!(escape_emphasis_markers("foo_bar"), "foo_bar");
9057        assert_eq!(escape_emphasis_markers("a_b_c"), "a_b_c");
9058        assert_eq!(escape_emphasis_markers("foo__bar"), "foo__bar");
9059        assert_eq!(
9060            escape_emphasis_markers("call do_something_useful"),
9061            "call do_something_useful"
9062        );
9063    }
9064
9065    #[test]
9066    fn escape_emphasis_markers_underscore_at_boundary() {
9067        // Leading and trailing underscores get escaped — adjacent text nodes
9068        // could supply the alphanumeric needed to close emphasis (issue #554).
9069        assert_eq!(escape_emphasis_markers("_Action"), r"\_Action");
9070        assert_eq!(escape_emphasis_markers("Action_"), r"Action\_");
9071        assert_eq!(escape_emphasis_markers("_ "), r"\_ ");
9072        assert_eq!(escape_emphasis_markers(" _"), r" \_");
9073        assert_eq!(escape_emphasis_markers("_"), r"\_");
9074    }
9075
9076    #[test]
9077    fn escape_emphasis_markers_underscore_with_punctuation() {
9078        // Underscores adjacent to punctuation (not alphanumeric) get escaped.
9079        assert_eq!(escape_emphasis_markers("foo _bar"), r"foo \_bar");
9080        assert_eq!(escape_emphasis_markers("foo_ bar"), r"foo\_ bar");
9081        assert_eq!(escape_emphasis_markers("(_x_)"), r"(\_x\_)");
9082    }
9083
9084    #[test]
9085    fn find_unescaped_skips_backslash_escaped() {
9086        // Escaped `**` should not be found
9087        assert_eq!(find_unescaped(r"a\*\*b**", "**"), Some(6));
9088        // No unescaped match at all
9089        assert_eq!(find_unescaped(r"a\*\*b", "**"), None);
9090        // Plain match without any escaping
9091        assert_eq!(find_unescaped("a**b", "**"), Some(1));
9092        // Empty haystack
9093        assert_eq!(find_unescaped("", "**"), None);
9094    }
9095
9096    #[test]
9097    fn find_unescaped_char_skips_backslash_escaped() {
9098        // Escaped `*` should not be found
9099        assert_eq!(find_unescaped_char(r"a\*b*", b'*'), Some(4));
9100        // No unescaped match at all
9101        assert_eq!(find_unescaped_char(r"\*", b'*'), None);
9102        // Plain match
9103        assert_eq!(find_unescaped_char("a*b", b'*'), Some(1));
9104        // Empty haystack
9105        assert_eq!(find_unescaped_char("", b'*'), None);
9106    }
9107
9108    #[test]
9109    fn double_asterisk_in_strong_mark_roundtrip() {
9110        // Text with ** inside a strong mark should preserve the literal **
9111        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call **kwargs","marks":[{"type":"strong"}]}]}]}"#;
9112        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9113        let jfm = adf_to_markdown(&adf).unwrap();
9114        let roundtripped = markdown_to_adf(&jfm).unwrap();
9115        let content = roundtripped.content[0].content.as_ref().unwrap();
9116        let strong_node = content.iter().find(|n| {
9117            n.marks
9118                .as_ref()
9119                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
9120        });
9121        assert!(strong_node.is_some(), "should have a strong-marked node");
9122        assert_eq!(strong_node.unwrap().text.as_deref(), Some("call **kwargs"));
9123    }
9124
9125    #[test]
9126    fn backtick_code_roundtrip() {
9127        // The original reproducer from issue #453
9128        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Set `max_retries` to 3 in the config"}]}]}"#;
9129        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9130        let jfm = adf_to_markdown(&adf).unwrap();
9131        let roundtripped = markdown_to_adf(&jfm).unwrap();
9132        let content = roundtripped.content[0].content.as_ref().unwrap();
9133        assert_eq!(content.len(), 1, "should round-trip as a single text node");
9134        assert_eq!(
9135            content[0].text.as_deref(),
9136            Some("Set `max_retries` to 3 in the config")
9137        );
9138        assert!(content[0].marks.is_none());
9139    }
9140
9141    #[test]
9142    fn multiple_backtick_spans_roundtrip() {
9143        // Multiple backtick-delimited spans in a single text node
9144        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use `foo` and `bar` together"}]}]}"#;
9145        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9146        let jfm = adf_to_markdown(&adf).unwrap();
9147        let roundtripped = markdown_to_adf(&jfm).unwrap();
9148        let content = roundtripped.content[0].content.as_ref().unwrap();
9149        assert_eq!(content.len(), 1, "should round-trip as a single text node");
9150        assert_eq!(
9151            content[0].text.as_deref(),
9152            Some("Use `foo` and `bar` together")
9153        );
9154        assert!(content[0].marks.is_none());
9155    }
9156
9157    #[test]
9158    fn lone_backtick_roundtrip() {
9159        // A single backtick that cannot form a code span should round-trip
9160        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use a ` character"}]}]}"#;
9161        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9162        let jfm = adf_to_markdown(&adf).unwrap();
9163        let roundtripped = markdown_to_adf(&jfm).unwrap();
9164        let content = roundtripped.content[0].content.as_ref().unwrap();
9165        assert_eq!(content.len(), 1, "should round-trip as a single text node");
9166        assert_eq!(content[0].text.as_deref(), Some("Use a ` character"));
9167        assert!(content[0].marks.is_none());
9168    }
9169
9170    #[test]
9171    fn backtick_with_code_mark_roundtrip() {
9172        // Text that already has a code mark should preserve both the mark and content
9173        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"max_retries","marks":[{"type":"code"}]}]}]}"#;
9174        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9175        let jfm = adf_to_markdown(&adf).unwrap();
9176        assert_eq!(jfm.trim(), "`max_retries`");
9177        let roundtripped = markdown_to_adf(&jfm).unwrap();
9178        let content = roundtripped.content[0].content.as_ref().unwrap();
9179        let code_node = content.iter().find(|n| {
9180            n.marks
9181                .as_ref()
9182                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
9183        });
9184        assert!(code_node.is_some(), "should have a code-marked node");
9185        assert_eq!(code_node.unwrap().text.as_deref(), Some("max_retries"));
9186    }
9187
9188    #[test]
9189    fn backtick_with_em_mark_roundtrip() {
9190        // Backtick inside em-marked text should preserve both
9191        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"use `cfg`","marks":[{"type":"em"}]}]}]}"#;
9192        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9193        let jfm = adf_to_markdown(&adf).unwrap();
9194        let roundtripped = markdown_to_adf(&jfm).unwrap();
9195        let content = roundtripped.content[0].content.as_ref().unwrap();
9196        let em_node = content.iter().find(|n| {
9197            n.marks
9198                .as_ref()
9199                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
9200        });
9201        assert!(em_node.is_some(), "should have an em-marked node");
9202        assert_eq!(em_node.unwrap().text.as_deref(), Some("use `cfg`"));
9203    }
9204
9205    #[test]
9206    fn escape_pipes_in_cell_unit() {
9207        assert_eq!(escape_pipes_in_cell("hello"), "hello");
9208        assert_eq!(escape_pipes_in_cell("a|b"), r"a\|b");
9209        assert_eq!(escape_pipes_in_cell("|"), r"\|");
9210        assert_eq!(escape_pipes_in_cell("|a|b|"), r"\|a\|b\|");
9211        assert_eq!(escape_pipes_in_cell(""), "");
9212        assert_eq!(
9213            escape_pipes_in_cell("`parser.decode[T|json]`"),
9214            r"`parser.decode[T\|json]`"
9215        );
9216    }
9217
9218    #[test]
9219    fn escape_backticks_unit() {
9220        assert_eq!(escape_backticks("hello"), "hello");
9221        assert_eq!(escape_backticks("`code`"), r"\`code\`");
9222        assert_eq!(escape_backticks("no ticks"), "no ticks");
9223        assert_eq!(escape_backticks("a ` b"), r"a \` b");
9224        assert_eq!(escape_backticks(""), "");
9225        assert_eq!(escape_backticks("`a` and `b`"), r"\`a\` and \`b\`");
9226    }
9227
9228    // ── Issue #477: backslash escaping ──────────────────────────────
9229
9230    #[test]
9231    fn backslash_in_text_roundtrip() {
9232        // The original reproducer from issue #477
9233        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"The path is C:\\Users\\admin\\file.txt"}]}]}"#;
9234        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9235        let jfm = adf_to_markdown(&adf).unwrap();
9236        let roundtripped = markdown_to_adf(&jfm).unwrap();
9237        let content = roundtripped.content[0].content.as_ref().unwrap();
9238        assert_eq!(content.len(), 1, "should round-trip as a single text node");
9239        assert_eq!(
9240            content[0].text.as_deref(),
9241            Some(r"The path is C:\Users\admin\file.txt")
9242        );
9243    }
9244
9245    #[test]
9246    fn backslash_emitted_as_double_backslash() {
9247        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\b"}]}]}"#;
9248        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9249        let jfm = adf_to_markdown(&adf).unwrap();
9250        assert!(
9251            jfm.contains(r"a\\b"),
9252            "JFM should contain escaped backslash: {jfm}"
9253        );
9254    }
9255
9256    #[test]
9257    fn consecutive_backslashes_roundtrip() {
9258        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\\\b"}]}]}"#;
9259        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9260        let jfm = adf_to_markdown(&adf).unwrap();
9261        let roundtripped = markdown_to_adf(&jfm).unwrap();
9262        let content = roundtripped.content[0].content.as_ref().unwrap();
9263        assert_eq!(
9264            content[0].text.as_deref(),
9265            Some(r"a\\b"),
9266            "consecutive backslashes should survive round-trip"
9267        );
9268    }
9269
9270    #[test]
9271    fn backslash_with_strong_mark_roundtrip() {
9272        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"strong"}]}]}]}"#;
9273        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9274        let jfm = adf_to_markdown(&adf).unwrap();
9275        let roundtripped = markdown_to_adf(&jfm).unwrap();
9276        let content = roundtripped.content[0].content.as_ref().unwrap();
9277        let strong_node = content.iter().find(|n| {
9278            n.marks
9279                .as_ref()
9280                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
9281        });
9282        assert!(strong_node.is_some(), "should have a strong-marked node");
9283        assert_eq!(strong_node.unwrap().text.as_deref(), Some(r"C:\Users"));
9284    }
9285
9286    #[test]
9287    fn backslash_with_code_mark_not_escaped() {
9288        // Code marks emit content verbatim — backslashes should NOT be escaped
9289        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"code"}]}]}]}"#;
9290        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9291        let jfm = adf_to_markdown(&adf).unwrap();
9292        assert_eq!(jfm.trim(), r"`C:\Users`");
9293        let roundtripped = markdown_to_adf(&jfm).unwrap();
9294        let content = roundtripped.content[0].content.as_ref().unwrap();
9295        let code_node = content.iter().find(|n| {
9296            n.marks
9297                .as_ref()
9298                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
9299        });
9300        assert!(code_node.is_some(), "should have a code-marked node");
9301        assert_eq!(code_node.unwrap().text.as_deref(), Some(r"C:\Users"));
9302    }
9303
9304    #[test]
9305    fn backslash_before_special_chars_roundtrip() {
9306        // Backslash before characters that are themselves escaped (* ` :)
9307        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"\\*not bold\\*"}]}]}"#;
9308        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9309        let jfm = adf_to_markdown(&adf).unwrap();
9310        let roundtripped = markdown_to_adf(&jfm).unwrap();
9311        let content = roundtripped.content[0].content.as_ref().unwrap();
9312        assert_eq!(
9313            content[0].text.as_deref(),
9314            Some(r"\*not bold\*"),
9315            "backslash before special char should survive round-trip"
9316        );
9317    }
9318
9319    #[test]
9320    fn backslash_and_newline_in_text_roundtrip() {
9321        // Text with both backslashes and embedded newlines
9322        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\path\nline2"}]}]}"#;
9323        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9324        let jfm = adf_to_markdown(&adf).unwrap();
9325        let roundtripped = markdown_to_adf(&jfm).unwrap();
9326        let content = roundtripped.content[0].content.as_ref().unwrap();
9327        assert_eq!(
9328            content[0].text.as_deref(),
9329            Some("C:\\path\nline2"),
9330            "backslash and newline should both survive round-trip"
9331        );
9332    }
9333
9334    #[test]
9335    fn lone_backslash_roundtrip() {
9336        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a \\ b"}]}]}"#;
9337        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9338        let jfm = adf_to_markdown(&adf).unwrap();
9339        let roundtripped = markdown_to_adf(&jfm).unwrap();
9340        let content = roundtripped.content[0].content.as_ref().unwrap();
9341        assert_eq!(content[0].text.as_deref(), Some(r"a \ b"));
9342    }
9343
9344    #[test]
9345    fn trailing_backslash_in_text_roundtrip() {
9346        // A trailing backslash in text content (not a hardBreak) should round-trip
9347        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\"}]}]}"#;
9348        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9349        let jfm = adf_to_markdown(&adf).unwrap();
9350        let roundtripped = markdown_to_adf(&jfm).unwrap();
9351        let content = roundtripped.content[0].content.as_ref().unwrap();
9352        assert_eq!(
9353            content[0].text.as_deref(),
9354            Some(r"end\"),
9355            "trailing backslash should survive round-trip"
9356        );
9357    }
9358
9359    #[test]
9360    fn escape_bare_urls_unit() {
9361        assert_eq!(escape_bare_urls("hello"), "hello");
9362        assert_eq!(escape_bare_urls(""), "");
9363        assert_eq!(
9364            escape_bare_urls("https://example.com"),
9365            r"\https://example.com"
9366        );
9367        assert_eq!(
9368            escape_bare_urls("http://example.com"),
9369            r"\http://example.com"
9370        );
9371        assert_eq!(
9372            escape_bare_urls("see https://a.com and https://b.com"),
9373            r"see \https://a.com and \https://b.com"
9374        );
9375        // "http" without "://" is not a URL scheme — leave untouched
9376        assert_eq!(escape_bare_urls("http header"), "http header");
9377        assert_eq!(escape_bare_urls("https is secure"), "https is secure");
9378    }
9379
9380    #[test]
9381    fn heading_not_valid_without_space() {
9382        // "#Title" without space should be a paragraph, not heading
9383        let doc = markdown_to_adf("#Title").unwrap();
9384        assert_eq!(doc.content[0].node_type, "paragraph");
9385    }
9386
9387    #[test]
9388    fn heading_level_too_high() {
9389        // ####### (7 hashes) is not a valid heading
9390        let doc = markdown_to_adf("####### Not a heading").unwrap();
9391        assert_eq!(doc.content[0].node_type, "paragraph");
9392    }
9393
9394    #[test]
9395    fn empty_document() {
9396        let doc = markdown_to_adf("").unwrap();
9397        assert!(doc.content.is_empty());
9398    }
9399
9400    #[test]
9401    fn only_blank_lines() {
9402        let doc = markdown_to_adf("\n\n\n").unwrap();
9403        assert!(doc.content.is_empty());
9404    }
9405
9406    #[test]
9407    fn code_block_unterminated() {
9408        // Code block without closing fence
9409        let md = "```rust\nfn main() {}";
9410        let doc = markdown_to_adf(md).unwrap();
9411        assert_eq!(doc.content[0].node_type, "codeBlock");
9412    }
9413
9414    #[test]
9415    fn mixed_document() {
9416        let md = "# Title\n\nA paragraph.\n\n- Item\n\n```\ncode\n```\n\n> quote\n\n---\n\n1. numbered\n";
9417        let doc = markdown_to_adf(md).unwrap();
9418        let types: Vec<&str> = doc.content.iter().map(|n| n.node_type.as_str()).collect();
9419        assert_eq!(
9420            types,
9421            vec![
9422                "heading",
9423                "paragraph",
9424                "bulletList",
9425                "codeBlock",
9426                "blockquote",
9427                "rule",
9428                "orderedList",
9429            ]
9430        );
9431    }
9432
9433    // ── Additional adf_to_markdown tests ───────────────────────────────
9434
9435    #[test]
9436    fn adf_ordered_list_to_markdown() {
9437        let doc = AdfDocument {
9438            version: 1,
9439            doc_type: "doc".to_string(),
9440            content: vec![AdfNode::ordered_list(
9441                vec![
9442                    AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("First")])]),
9443                    AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("Second")])]),
9444                ],
9445                None,
9446            )],
9447        };
9448        let md = adf_to_markdown(&doc).unwrap();
9449        assert!(md.contains("1. First"));
9450        assert!(md.contains("2. Second"));
9451    }
9452
9453    #[test]
9454    fn adf_ordered_list_custom_start() {
9455        let doc = AdfDocument {
9456            version: 1,
9457            doc_type: "doc".to_string(),
9458            content: vec![AdfNode::ordered_list(
9459                vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
9460                    AdfNode::text("Third"),
9461                ])])],
9462                Some(3),
9463            )],
9464        };
9465        let md = adf_to_markdown(&doc).unwrap();
9466        assert!(md.contains("3. Third"));
9467    }
9468
9469    #[test]
9470    fn adf_blockquote_to_markdown() {
9471        let doc = AdfDocument {
9472            version: 1,
9473            doc_type: "doc".to_string(),
9474            content: vec![AdfNode::blockquote(vec![AdfNode::paragraph(vec![
9475                AdfNode::text("A quote"),
9476            ])])],
9477        };
9478        let md = adf_to_markdown(&doc).unwrap();
9479        assert!(md.contains("> A quote"));
9480    }
9481
9482    #[test]
9483    fn adf_table_to_markdown() {
9484        let doc = AdfDocument {
9485            version: 1,
9486            doc_type: "doc".to_string(),
9487            content: vec![AdfNode::table(vec![
9488                AdfNode::table_row(vec![
9489                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Name")])]),
9490                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Value")])]),
9491                ]),
9492                AdfNode::table_row(vec![
9493                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a")])]),
9494                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("1")])]),
9495                ]),
9496            ])],
9497        };
9498        let md = adf_to_markdown(&doc).unwrap();
9499        assert!(md.contains("| Name | Value |"));
9500        assert!(md.contains("| --- | --- |"));
9501        assert!(md.contains("| a | 1 |"));
9502    }
9503
9504    #[test]
9505    fn adf_media_to_markdown() {
9506        let doc = AdfDocument {
9507            version: 1,
9508            doc_type: "doc".to_string(),
9509            content: vec![AdfNode::media_single(
9510                "https://example.com/img.png",
9511                Some("Alt"),
9512            )],
9513        };
9514        let md = adf_to_markdown(&doc).unwrap();
9515        assert!(md.contains("![Alt](https://example.com/img.png)"));
9516    }
9517
9518    #[test]
9519    fn adf_media_no_alt_to_markdown() {
9520        let doc = AdfDocument {
9521            version: 1,
9522            doc_type: "doc".to_string(),
9523            content: vec![AdfNode::media_single("https://example.com/img.png", None)],
9524        };
9525        let md = adf_to_markdown(&doc).unwrap();
9526        assert!(md.contains("![](https://example.com/img.png)"));
9527    }
9528
9529    #[test]
9530    fn adf_italic_to_markdown() {
9531        let doc = AdfDocument {
9532            version: 1,
9533            doc_type: "doc".to_string(),
9534            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9535                "emphasis",
9536                vec![AdfMark::em()],
9537            )])],
9538        };
9539        let md = adf_to_markdown(&doc).unwrap();
9540        assert_eq!(md.trim(), "*emphasis*");
9541    }
9542
9543    #[test]
9544    fn adf_strikethrough_to_markdown() {
9545        let doc = AdfDocument {
9546            version: 1,
9547            doc_type: "doc".to_string(),
9548            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9549                "deleted",
9550                vec![AdfMark::strike()],
9551            )])],
9552        };
9553        let md = adf_to_markdown(&doc).unwrap();
9554        assert_eq!(md.trim(), "~~deleted~~");
9555    }
9556
9557    #[test]
9558    fn adf_inline_code_to_markdown() {
9559        let doc = AdfDocument {
9560            version: 1,
9561            doc_type: "doc".to_string(),
9562            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9563                "code",
9564                vec![AdfMark::code()],
9565            )])],
9566        };
9567        let md = adf_to_markdown(&doc).unwrap();
9568        assert_eq!(md.trim(), "`code`");
9569    }
9570
9571    #[test]
9572    fn adf_code_with_link_to_markdown() {
9573        let doc = AdfDocument {
9574            version: 1,
9575            doc_type: "doc".to_string(),
9576            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9577                "func",
9578                vec![AdfMark::code(), AdfMark::link("https://example.com")],
9579            )])],
9580        };
9581        let md = adf_to_markdown(&doc).unwrap();
9582        assert_eq!(md.trim(), "[`func`](https://example.com)");
9583    }
9584
9585    #[test]
9586    fn adf_bold_italic_to_markdown() {
9587        let doc = AdfDocument {
9588            version: 1,
9589            doc_type: "doc".to_string(),
9590            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9591                "both",
9592                vec![AdfMark::strong(), AdfMark::em()],
9593            )])],
9594        };
9595        let md = adf_to_markdown(&doc).unwrap();
9596        assert_eq!(md.trim(), "***both***");
9597    }
9598
9599    #[test]
9600    fn adf_bold_link_to_markdown() {
9601        let doc = AdfDocument {
9602            version: 1,
9603            doc_type: "doc".to_string(),
9604            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9605                "bold link",
9606                vec![AdfMark::strong(), AdfMark::link("https://example.com")],
9607            )])],
9608        };
9609        let md = adf_to_markdown(&doc).unwrap();
9610        assert_eq!(md.trim(), "**[bold link](https://example.com)**");
9611    }
9612
9613    #[test]
9614    fn adf_strikethrough_bold_to_markdown() {
9615        let doc = AdfDocument {
9616            version: 1,
9617            doc_type: "doc".to_string(),
9618            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9619                "struck",
9620                vec![AdfMark::strike(), AdfMark::strong()],
9621            )])],
9622        };
9623        let md = adf_to_markdown(&doc).unwrap();
9624        assert_eq!(md.trim(), "~~**struck**~~");
9625    }
9626
9627    #[test]
9628    fn adf_hard_break_to_markdown() {
9629        let doc = AdfDocument {
9630            version: 1,
9631            doc_type: "doc".to_string(),
9632            content: vec![AdfNode::paragraph(vec![
9633                AdfNode::text("Line 1"),
9634                AdfNode::hard_break(),
9635                AdfNode::text("Line 2"),
9636            ])],
9637        };
9638        let md = adf_to_markdown(&doc).unwrap();
9639        assert!(md.contains("Line 1\\\n  Line 2"));
9640    }
9641
9642    #[test]
9643    fn adf_unsupported_inline_to_markdown() {
9644        let doc = AdfDocument {
9645            version: 1,
9646            doc_type: "doc".to_string(),
9647            content: vec![AdfNode::paragraph(vec![AdfNode {
9648                node_type: "unknownInline".to_string(),
9649                attrs: Some(serde_json::json!({"key": "value"})),
9650                content: None,
9651                text: None,
9652                marks: None,
9653                local_id: None,
9654                parameters: None,
9655            }])],
9656        };
9657        let md = adf_to_markdown(&doc).unwrap();
9658        // Unsupported inline nodes now round-trip via an `:adf-unsupported`
9659        // directive carrying the full node JSON (issue #1117), replacing the
9660        // old lossy `<!-- unsupported inline -->` comment.
9661        assert!(md.contains(":adf-unsupported[]{json="));
9662        assert!(md.contains("unknownInline"));
9663        assert!(!md.contains("<!-- unsupported inline"));
9664    }
9665
9666    #[test]
9667    fn unsupported_inline_round_trips() {
9668        let original = AdfDocument {
9669            version: 1,
9670            doc_type: "doc".to_string(),
9671            content: vec![AdfNode::paragraph(vec![
9672                AdfNode::text("before "),
9673                AdfNode {
9674                    node_type: "unknownInline".to_string(),
9675                    attrs: Some(serde_json::json!({"key": "value", "n": 3})),
9676                    content: None,
9677                    text: Some("fallback".to_string()),
9678                    marks: None,
9679                    local_id: None,
9680                    parameters: None,
9681                },
9682                AdfNode::text(" after"),
9683            ])],
9684        };
9685        let md = adf_to_markdown(&original).unwrap();
9686        let restored = markdown_to_adf(&md).unwrap();
9687        // The paragraph's unknown inline node survives with its attrs and text.
9688        let para = &restored.content[0];
9689        let unknown = para
9690            .content
9691            .as_ref()
9692            .unwrap()
9693            .iter()
9694            .find(|n| n.node_type == "unknownInline")
9695            .expect("unknownInline node preserved");
9696        assert_eq!(unknown.attrs.as_ref().unwrap()["key"], "value");
9697        assert_eq!(unknown.attrs.as_ref().unwrap()["n"], 3);
9698        assert_eq!(unknown.text.as_deref(), Some("fallback"));
9699    }
9700
9701    // ── mediaInline tests (issue #476) ─────────────────────────────────
9702
9703    #[test]
9704    fn adf_media_inline_to_markdown() {
9705        let doc = AdfDocument {
9706            version: 1,
9707            doc_type: "doc".to_string(),
9708            content: vec![AdfNode::paragraph(vec![
9709                AdfNode::text("see "),
9710                AdfNode::media_inline(serde_json::json!({
9711                    "type": "image",
9712                    "id": "abcdef01-2345-6789-abcd-abcdef012345",
9713                    "collection": "contentId-111111",
9714                    "width": 200,
9715                    "height": 100
9716                })),
9717                AdfNode::text(" for details"),
9718            ])],
9719        };
9720        let md = adf_to_markdown(&doc).unwrap();
9721        assert!(md.contains(":media-inline[]{"), "got: {md}");
9722        assert!(md.contains("type=image"));
9723        assert!(md.contains("id=abcdef01-2345-6789-abcd-abcdef012345"));
9724        assert!(md.contains("collection=contentId-111111"));
9725        assert!(md.contains("width=200"));
9726        assert!(md.contains("height=100"));
9727        assert!(!md.contains("<!-- unsupported inline"));
9728    }
9729
9730    #[test]
9731    fn media_inline_round_trip() {
9732        let doc = AdfDocument {
9733            version: 1,
9734            doc_type: "doc".to_string(),
9735            content: vec![AdfNode::paragraph(vec![
9736                AdfNode::text("see "),
9737                AdfNode::media_inline(serde_json::json!({
9738                    "type": "image",
9739                    "id": "abcdef01-2345-6789-abcd-abcdef012345",
9740                    "collection": "contentId-111111",
9741                    "width": 200,
9742                    "height": 100
9743                })),
9744                AdfNode::text(" for details"),
9745            ])],
9746        };
9747        let md = adf_to_markdown(&doc).unwrap();
9748        let rt = markdown_to_adf(&md).unwrap();
9749
9750        let content = rt.content[0].content.as_ref().unwrap();
9751        assert_eq!(content[0].text.as_deref(), Some("see "));
9752        assert_eq!(content[1].node_type, "mediaInline");
9753        let attrs = content[1].attrs.as_ref().unwrap();
9754        assert_eq!(attrs["type"], "image");
9755        assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
9756        assert_eq!(attrs["collection"], "contentId-111111");
9757        assert_eq!(attrs["width"], 200);
9758        assert_eq!(attrs["height"], 100);
9759        assert_eq!(content[2].text.as_deref(), Some(" for details"));
9760    }
9761
9762    #[test]
9763    fn media_inline_external_url_round_trip() {
9764        let doc = AdfDocument {
9765            version: 1,
9766            doc_type: "doc".to_string(),
9767            content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
9768                serde_json::json!({
9769                    "type": "external",
9770                    "url": "https://example.com/image.png",
9771                    "alt": "example",
9772                    "width": 400,
9773                    "height": 300
9774                }),
9775            )])],
9776        };
9777        let md = adf_to_markdown(&doc).unwrap();
9778        let rt = markdown_to_adf(&md).unwrap();
9779
9780        let content = rt.content[0].content.as_ref().unwrap();
9781        assert_eq!(content[0].node_type, "mediaInline");
9782        let attrs = content[0].attrs.as_ref().unwrap();
9783        assert_eq!(attrs["type"], "external");
9784        assert_eq!(attrs["url"], "https://example.com/image.png");
9785        assert_eq!(attrs["alt"], "example");
9786        assert_eq!(attrs["width"], 400);
9787        assert_eq!(attrs["height"], 300);
9788    }
9789
9790    #[test]
9791    fn media_inline_minimal_attrs() {
9792        let doc = AdfDocument {
9793            version: 1,
9794            doc_type: "doc".to_string(),
9795            content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
9796                serde_json::json!({"type": "file", "id": "abc-123"}),
9797            )])],
9798        };
9799        let md = adf_to_markdown(&doc).unwrap();
9800        let rt = markdown_to_adf(&md).unwrap();
9801
9802        let content = rt.content[0].content.as_ref().unwrap();
9803        assert_eq!(content[0].node_type, "mediaInline");
9804        let attrs = content[0].attrs.as_ref().unwrap();
9805        assert_eq!(attrs["type"], "file");
9806        assert_eq!(attrs["id"], "abc-123");
9807    }
9808
9809    #[test]
9810    fn media_inline_from_issue_476_reproducer() {
9811        // Exact reproducer from issue #476
9812        let adf_json: serde_json::Value = serde_json::json!({
9813            "type": "doc",
9814            "version": 1,
9815            "content": [
9816                {
9817                    "type": "paragraph",
9818                    "content": [
9819                        {"type": "text", "text": "see "},
9820                        {
9821                            "type": "mediaInline",
9822                            "attrs": {
9823                                "collection": "contentId-111111",
9824                                "height": 100,
9825                                "id": "abcdef01-2345-6789-abcd-abcdef012345",
9826                                "localId": "aabbccdd-1234-5678-abcd-aabbccdd1234",
9827                                "type": "image",
9828                                "width": 200
9829                            }
9830                        },
9831                        {"type": "text", "text": " for details"}
9832                    ]
9833                }
9834            ]
9835        });
9836        let doc: AdfDocument = serde_json::from_value(adf_json).unwrap();
9837        let md = adf_to_markdown(&doc).unwrap();
9838        assert!(
9839            !md.contains("<!-- unsupported inline"),
9840            "mediaInline should not be unsupported; got: {md}"
9841        );
9842
9843        let rt = markdown_to_adf(&md).unwrap();
9844        let content = rt.content[0].content.as_ref().unwrap();
9845        assert_eq!(content[1].node_type, "mediaInline");
9846        let attrs = content[1].attrs.as_ref().unwrap();
9847        assert_eq!(attrs["type"], "image");
9848        assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
9849        assert_eq!(attrs["collection"], "contentId-111111");
9850        assert_eq!(attrs["width"], 200);
9851        assert_eq!(attrs["height"], 100);
9852        assert_eq!(attrs["localId"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
9853    }
9854
9855    #[test]
9856    fn emoji_shortcode() {
9857        let doc = markdown_to_adf("Hello :wave: world").unwrap();
9858        let content = doc.content[0].content.as_ref().unwrap();
9859        assert_eq!(content[0].text.as_deref(), Some("Hello "));
9860        assert_eq!(content[1].node_type, "emoji");
9861        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":wave:");
9862        assert_eq!(content[2].text.as_deref(), Some(" world"));
9863    }
9864
9865    #[test]
9866    fn adf_emoji_to_markdown() {
9867        let doc = AdfDocument {
9868            version: 1,
9869            doc_type: "doc".to_string(),
9870            content: vec![AdfNode::paragraph(vec![AdfNode::emoji("thumbsup")])],
9871        };
9872        let md = adf_to_markdown(&doc).unwrap();
9873        assert!(md.contains(":thumbsup:"));
9874    }
9875
9876    #[test]
9877    fn adf_emoji_with_colon_prefix_to_markdown() {
9878        // JIRA stores shortName as ":thumbsup:" with colons
9879        let doc = AdfDocument {
9880            version: 1,
9881            doc_type: "doc".to_string(),
9882            content: vec![AdfNode::paragraph(vec![AdfNode {
9883                node_type: "emoji".to_string(),
9884                attrs: Some(serde_json::json!({"shortName": ":thumbsup:"})),
9885                content: None,
9886                text: None,
9887                marks: None,
9888                local_id: None,
9889                parameters: None,
9890            }])],
9891        };
9892        let md = adf_to_markdown(&doc).unwrap();
9893        assert!(md.contains(":thumbsup:"));
9894        // Should not produce ::thumbsup:: (double colons)
9895        assert!(!md.contains("::thumbsup::"));
9896    }
9897
9898    #[test]
9899    fn round_trip_emoji() {
9900        let md = "Hello :wave: world\n";
9901        let doc = markdown_to_adf(md).unwrap();
9902        let result = adf_to_markdown(&doc).unwrap();
9903        assert!(result.contains(":wave:"));
9904    }
9905
9906    #[test]
9907    fn emoji_with_id_and_text_round_trips() {
9908        let doc = AdfDocument {
9909            version: 1,
9910            doc_type: "doc".to_string(),
9911            content: vec![AdfNode::paragraph(vec![AdfNode {
9912                node_type: "emoji".to_string(),
9913                attrs: Some(
9914                    serde_json::json!({"shortName": ":check_mark:", "id": "2705", "text": "✅"}),
9915                ),
9916                content: None,
9917                text: None,
9918                marks: None,
9919                local_id: None,
9920                parameters: None,
9921            }])],
9922        };
9923        let md = adf_to_markdown(&doc).unwrap();
9924        assert!(md.contains(":check_mark:"), "shortcode present: {md}");
9925        assert!(md.contains("id="), "id attr present: {md}");
9926        assert!(md.contains("text="), "text attr present: {md}");
9927
9928        // Round-trip back to ADF
9929        let round_tripped = markdown_to_adf(&md).unwrap();
9930        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
9931        let attrs = emoji.attrs.as_ref().unwrap();
9932        assert_eq!(attrs["shortName"], ":check_mark:");
9933        assert_eq!(attrs["id"], "2705");
9934        assert_eq!(attrs["text"], "✅");
9935    }
9936
9937    #[test]
9938    fn emoji_without_extra_attrs_still_works() {
9939        let md = "Hello :wave: world\n";
9940        let doc = markdown_to_adf(md).unwrap();
9941        let emoji = &doc.content[0].content.as_ref().unwrap()[1];
9942        assert_eq!(emoji.attrs.as_ref().unwrap()["shortName"], ":wave:");
9943        // No id or text attrs when not provided
9944        assert!(emoji.attrs.as_ref().unwrap().get("id").is_none());
9945    }
9946
9947    #[test]
9948    fn emoji_shortname_preserves_colons_round_trip() {
9949        // Issue #362: emoji shortName colons stripped during round-trip
9950        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
9951          {"type":"emoji","attrs":{"shortName":":cross_mark:","id":"atlassian-cross_mark","text":"❌"}}
9952        ]}]}"#;
9953        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9954
9955        // ADF → markdown → ADF round-trip
9956        let md = adf_to_markdown(&doc).unwrap();
9957        let round_tripped = markdown_to_adf(&md).unwrap();
9958        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
9959        let attrs = emoji.attrs.as_ref().unwrap();
9960        assert_eq!(
9961            attrs["shortName"], ":cross_mark:",
9962            "shortName should preserve colons, got: {}",
9963            attrs["shortName"]
9964        );
9965        assert_eq!(attrs["id"], "atlassian-cross_mark");
9966        assert_eq!(attrs["text"], "❌");
9967    }
9968
9969    #[test]
9970    fn emoji_shortname_without_colons_preserved() {
9971        // Issue #379: shortName without colons should not gain colons
9972        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
9973          {"type":"emoji","attrs":{"shortName":"white_check_mark","id":"2705","text":"✅"}}
9974        ]}]}"#;
9975        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9976        let md = adf_to_markdown(&doc).unwrap();
9977        let round_tripped = markdown_to_adf(&md).unwrap();
9978        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
9979        let attrs = emoji.attrs.as_ref().unwrap();
9980        assert_eq!(
9981            attrs["shortName"], "white_check_mark",
9982            "shortName without colons should stay without colons, got: {}",
9983            attrs["shortName"]
9984        );
9985    }
9986
9987    #[test]
9988    fn colon_in_text_not_emoji() {
9989        // A lone colon should not trigger emoji parsing
9990        let doc = markdown_to_adf("Time is 10:30 today").unwrap();
9991        let content = doc.content[0].content.as_ref().unwrap();
9992        assert_eq!(content.len(), 1);
9993        assert_eq!(content[0].node_type, "text");
9994    }
9995
9996    #[test]
9997    fn text_with_shortcode_pattern_round_trips_as_text() {
9998        // Issue #450: `:fire:` in a text node must not become an emoji node
9999        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Alert :fire: triggered on pod:pod42"}]}]}"#;
10000        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10001
10002        let md = adf_to_markdown(&doc).unwrap();
10003        let round_tripped = markdown_to_adf(&md).unwrap();
10004        let content = round_tripped.content[0].content.as_ref().unwrap();
10005
10006        assert_eq!(
10007            content.len(),
10008            1,
10009            "should be a single text node, got: {content:?}"
10010        );
10011        assert_eq!(content[0].node_type, "text");
10012        assert_eq!(
10013            content[0].text.as_deref().unwrap(),
10014            "Alert :fire: triggered on pod:pod42"
10015        );
10016    }
10017
10018    #[test]
10019    fn double_colon_pattern_round_trips_as_text() {
10020        // Issue #450: `::Active::` should not be parsed as emoji `:Active:`
10021        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status::Active::Running"}]}]}"#;
10022        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10023
10024        let md = adf_to_markdown(&doc).unwrap();
10025        let round_tripped = markdown_to_adf(&md).unwrap();
10026        let content = round_tripped.content[0].content.as_ref().unwrap();
10027
10028        assert_eq!(
10029            content.len(),
10030            1,
10031            "should be a single text node, got: {content:?}"
10032        );
10033        assert_eq!(content[0].node_type, "text");
10034        assert_eq!(
10035            content[0].text.as_deref().unwrap(),
10036            "Status::Active::Running"
10037        );
10038    }
10039
10040    #[test]
10041    fn real_emoji_node_still_round_trips() {
10042        // Ensure actual emoji ADF nodes still work after the escaping fix
10043        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10044          {"type":"text","text":"Hello "},
10045          {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
10046          {"type":"text","text":" world"}
10047        ]}]}"#;
10048        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10049
10050        let md = adf_to_markdown(&doc).unwrap();
10051        let round_tripped = markdown_to_adf(&md).unwrap();
10052        let content = round_tripped.content[0].content.as_ref().unwrap();
10053
10054        // Should have: text("Hello ") + emoji(:fire:) + text(" world")
10055        assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
10056        assert_eq!(content[0].text.as_deref(), Some("Hello "));
10057        assert_eq!(content[1].node_type, "emoji");
10058        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":fire:");
10059        assert_eq!(content[2].text.as_deref(), Some(" world"));
10060    }
10061
10062    #[test]
10063    fn combined_emoji_shortname_round_trips_as_single_node() {
10064        // Issue #576: an emoji node whose shortName is a combination of two
10065        // shortcodes (e.g. ":slightly_smiling_face::bow:") must survive the
10066        // ADF → markdown → ADF round-trip as a single emoji node rather than
10067        // being split into two.
10068        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10069          {"type":"text","text":"Thanks for the help "},
10070          {"type":"emoji","attrs":{"shortName":":slightly_smiling_face::bow:","id":"","text":""}}
10071        ]}]}"#;
10072        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10073
10074        let md = adf_to_markdown(&doc).unwrap();
10075        let round_tripped = markdown_to_adf(&md).unwrap();
10076        let content = round_tripped.content[0].content.as_ref().unwrap();
10077
10078        assert_eq!(
10079            content.len(),
10080            2,
10081            "should have text + single combined emoji: {content:?}"
10082        );
10083        assert_eq!(content[0].text.as_deref(), Some("Thanks for the help "));
10084        assert_eq!(content[1].node_type, "emoji");
10085        let attrs = content[1].attrs.as_ref().unwrap();
10086        assert_eq!(attrs["shortName"], ":slightly_smiling_face::bow:");
10087        assert_eq!(attrs["id"], "");
10088        assert_eq!(attrs["text"], "");
10089    }
10090
10091    #[test]
10092    fn triple_combined_emoji_shortname_round_trips_as_single_node() {
10093        // Three-part combined shortName must also survive round-trip.
10094        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10095          {"type":"emoji","attrs":{"shortName":":a::b::c:","id":"x","text":""}}
10096        ]}]}"#;
10097        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10098
10099        let md = adf_to_markdown(&doc).unwrap();
10100        let round_tripped = markdown_to_adf(&md).unwrap();
10101        let content = round_tripped.content[0].content.as_ref().unwrap();
10102
10103        assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
10104        assert_eq!(content[0].node_type, "emoji");
10105        let attrs = content[0].attrs.as_ref().unwrap();
10106        assert_eq!(attrs["shortName"], ":a::b::c:");
10107        assert_eq!(attrs["id"], "x");
10108    }
10109
10110    #[test]
10111    fn consecutive_emojis_remain_separate_nodes() {
10112        // Two independent emoji nodes (each with their own directive) must
10113        // remain two separate nodes — the combined-chain logic must not
10114        // swallow the second emoji.
10115        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10116          {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
10117          {"type":"emoji","attrs":{"shortName":":water:","id":"1f4a7","text":"💧"}}
10118        ]}]}"#;
10119        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10120
10121        let md = adf_to_markdown(&doc).unwrap();
10122        let round_tripped = markdown_to_adf(&md).unwrap();
10123        let content = round_tripped.content[0].content.as_ref().unwrap();
10124
10125        assert_eq!(content.len(), 2, "should be two emoji nodes: {content:?}");
10126        assert_eq!(content[0].node_type, "emoji");
10127        assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
10128        assert_eq!(content[1].node_type, "emoji");
10129        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
10130    }
10131
10132    #[test]
10133    fn adjacent_shortcodes_without_directive_parse_as_two_emojis() {
10134        // Raw markdown with two adjacent shortcodes and no directive should
10135        // still parse as two separate emoji nodes.
10136        let md = ":fire::water:";
10137        let doc = markdown_to_adf(md).unwrap();
10138        let content = doc.content[0].content.as_ref().unwrap();
10139
10140        assert_eq!(content.len(), 2, "should be two emojis: {content:?}");
10141        assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
10142        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
10143    }
10144
10145    #[test]
10146    fn combined_emoji_shortname_preserves_local_id() {
10147        // The directive's localId should be preserved when the chain is
10148        // collapsed into a single emoji node.
10149        let md = r#":a::b:{shortName=":a::b:" id="x" text="y" localId="abc"}"#;
10150        let doc = markdown_to_adf(md).unwrap();
10151        let content = doc.content[0].content.as_ref().unwrap();
10152
10153        assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
10154        let attrs = content[0].attrs.as_ref().unwrap();
10155        assert_eq!(attrs["shortName"], ":a::b:");
10156        assert_eq!(attrs["id"], "x");
10157        assert_eq!(attrs["text"], "y");
10158        assert_eq!(attrs["localId"], "abc");
10159    }
10160
10161    #[test]
10162    fn text_shortcode_with_marks_round_trips() {
10163        // Bold text containing an emoji-like shortcode should round-trip as bold text
10164        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10165          {"type":"text","text":"Alert :fire: triggered","marks":[{"type":"strong"}]}
10166        ]}]}"#;
10167        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10168
10169        let md = adf_to_markdown(&doc).unwrap();
10170        let round_tripped = markdown_to_adf(&md).unwrap();
10171        let content = round_tripped.content[0].content.as_ref().unwrap();
10172
10173        assert_eq!(
10174            content.len(),
10175            1,
10176            "should be single bold text node: {content:?}"
10177        );
10178        assert_eq!(content[0].node_type, "text");
10179        assert_eq!(
10180            content[0].text.as_deref().unwrap(),
10181            "Alert :fire: triggered"
10182        );
10183        assert!(content[0]
10184            .marks
10185            .as_ref()
10186            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong")));
10187    }
10188
10189    #[test]
10190    fn mixed_emoji_node_and_text_shortcode_round_trips() {
10191        // Real emoji node adjacent to text containing a shortcode-like pattern
10192        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10193          {"type":"emoji","attrs":{"shortName":":wave:","id":"1f44b","text":"👋"}},
10194          {"type":"text","text":" says :hello: to you"}
10195        ]}]}"#;
10196        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10197
10198        let md = adf_to_markdown(&doc).unwrap();
10199        let round_tripped = markdown_to_adf(&md).unwrap();
10200        let content = round_tripped.content[0].content.as_ref().unwrap();
10201
10202        // Should be: emoji(:wave:) + text(" says :hello: to you")
10203        assert_eq!(content.len(), 2, "should have 2 nodes: {content:?}");
10204        assert_eq!(content[0].node_type, "emoji");
10205        assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":wave:");
10206        assert_eq!(content[1].node_type, "text");
10207        assert_eq!(content[1].text.as_deref().unwrap(), " says :hello: to you");
10208    }
10209
10210    #[test]
10211    fn code_block_with_shortcode_pattern_round_trips() {
10212        // Issue #552: Code block content containing `::Name::` patterns must not
10213        // be re-parsed as emoji shortcodes.
10214        let adf_json = r#"{"version":1,"type":"doc","content":[
10215          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
10216            {"type":"text","text":"module Foo::Bar::Baz\n  def hello\n    puts 'world'\n  end\nend"}
10217          ]}
10218        ]}"#;
10219        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10220
10221        let md = adf_to_markdown(&doc).unwrap();
10222        let round_tripped = markdown_to_adf(&md).unwrap();
10223
10224        assert_eq!(
10225            round_tripped.content.len(),
10226            1,
10227            "should be a single codeBlock"
10228        );
10229        let cb = &round_tripped.content[0];
10230        assert_eq!(cb.node_type, "codeBlock");
10231        let content = cb.content.as_ref().expect("codeBlock content");
10232        assert_eq!(
10233            content.len(),
10234            1,
10235            "should be a single text node: {content:?}"
10236        );
10237        assert_eq!(content[0].node_type, "text");
10238        assert_eq!(
10239            content[0].text.as_deref().unwrap(),
10240            "module Foo::Bar::Baz\n  def hello\n    puts 'world'\n  end\nend"
10241        );
10242        assert!(
10243            content.iter().all(|n| n.node_type != "emoji"),
10244            "no emoji nodes should be present, got: {content:?}"
10245        );
10246    }
10247
10248    #[test]
10249    fn code_block_with_exact_namespaced_shortcode_pattern_round_trips() {
10250        // Issue #552: Use a namespaced pattern modeled on the bug report.
10251        let adf_json = r#"{"version":1,"type":"doc","content":[
10252          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
10253            {"type":"text","text":"class ZBC::Acme::PlanType::Professional < Base"}
10254          ]}
10255        ]}"#;
10256        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10257
10258        let md = adf_to_markdown(&doc).unwrap();
10259        let round_tripped = markdown_to_adf(&md).unwrap();
10260
10261        let cb = &round_tripped.content[0];
10262        assert_eq!(cb.node_type, "codeBlock");
10263        let content = cb.content.as_ref().expect("codeBlock content");
10264        assert_eq!(content.len(), 1, "should be a single text node");
10265        assert_eq!(
10266            content[0].text.as_deref().unwrap(),
10267            "class ZBC::Acme::PlanType::Professional < Base"
10268        );
10269    }
10270
10271    #[test]
10272    fn code_block_with_literal_shortcode_round_trips() {
10273        // Issue #552: Content that is exactly a shortcode (`:fire:`) inside a
10274        // code block should survive the round-trip as literal text, not emoji.
10275        let adf_json = r#"{"version":1,"type":"doc","content":[
10276          {"type":"codeBlock","attrs":{"language":"text"},"content":[
10277            {"type":"text","text":":fire: :wave: :thumbsup:"}
10278          ]}
10279        ]}"#;
10280        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10281
10282        let md = adf_to_markdown(&doc).unwrap();
10283        let round_tripped = markdown_to_adf(&md).unwrap();
10284
10285        let cb = &round_tripped.content[0];
10286        assert_eq!(cb.node_type, "codeBlock");
10287        let content = cb.content.as_ref().expect("codeBlock content");
10288        assert_eq!(
10289            content.len(),
10290            1,
10291            "should be a single text node: {content:?}"
10292        );
10293        assert_eq!(content[0].node_type, "text");
10294        assert_eq!(
10295            content[0].text.as_deref().unwrap(),
10296            ":fire: :wave: :thumbsup:"
10297        );
10298    }
10299
10300    #[test]
10301    fn inline_code_with_shortcode_pattern_round_trips() {
10302        // Issue #552: Inline code containing `::Name::` patterns must not be
10303        // re-parsed as emoji shortcodes.
10304        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10305          {"type":"text","text":"See "},
10306          {"type":"text","text":"Foo::Bar::Baz","marks":[{"type":"code"}]},
10307          {"type":"text","text":" for details"}
10308        ]}]}"#;
10309        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10310
10311        let md = adf_to_markdown(&doc).unwrap();
10312        let round_tripped = markdown_to_adf(&md).unwrap();
10313        let content = round_tripped.content[0].content.as_ref().unwrap();
10314
10315        assert_eq!(content.len(), 3, "should have 3 text nodes: {content:?}");
10316        assert_eq!(content[0].text.as_deref(), Some("See "));
10317        assert_eq!(content[1].text.as_deref(), Some("Foo::Bar::Baz"));
10318        assert!(content[1]
10319            .marks
10320            .as_ref()
10321            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
10322        assert_eq!(content[2].text.as_deref(), Some(" for details"));
10323        assert!(
10324            content.iter().all(|n| n.node_type != "emoji"),
10325            "no emoji nodes should be present"
10326        );
10327    }
10328
10329    #[test]
10330    fn inline_code_with_literal_shortcode_round_trips() {
10331        // Issue #552: Inline code whose content is exactly a shortcode must be
10332        // preserved as code, not converted to an emoji.
10333        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10334          {"type":"text","text":":fire:","marks":[{"type":"code"}]}
10335        ]}]}"#;
10336        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10337
10338        let md = adf_to_markdown(&doc).unwrap();
10339        let round_tripped = markdown_to_adf(&md).unwrap();
10340        let content = round_tripped.content[0].content.as_ref().unwrap();
10341
10342        assert_eq!(
10343            content.len(),
10344            1,
10345            "should be a single code node: {content:?}"
10346        );
10347        assert_eq!(content[0].node_type, "text");
10348        assert_eq!(content[0].text.as_deref(), Some(":fire:"));
10349        assert!(content[0]
10350            .marks
10351            .as_ref()
10352            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
10353    }
10354
10355    #[test]
10356    fn code_block_in_list_with_shortcode_pattern_round_trips() {
10357        // Issue #552: A code block containing shortcode-like patterns nested in
10358        // a list should still survive round-trip without emoji corruption.
10359        let adf_json = r#"{"version":1,"type":"doc","content":[
10360          {"type":"bulletList","content":[
10361            {"type":"listItem","content":[
10362              {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
10363                {"type":"text","text":"Foo::Bar::Baz"}
10364              ]}
10365            ]}
10366          ]}
10367        ]}"#;
10368        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10369
10370        let md = adf_to_markdown(&doc).unwrap();
10371        let round_tripped = markdown_to_adf(&md).unwrap();
10372
10373        let list = &round_tripped.content[0];
10374        assert_eq!(list.node_type, "bulletList");
10375        let item = &list.content.as_ref().unwrap()[0];
10376        assert_eq!(item.node_type, "listItem");
10377        let cb = &item.content.as_ref().unwrap()[0];
10378        assert_eq!(cb.node_type, "codeBlock");
10379        let cb_content = cb.content.as_ref().unwrap();
10380        assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
10381        assert_eq!(cb_content[0].node_type, "text");
10382    }
10383
10384    #[test]
10385    fn code_block_with_unicode_shortcode_pattern_round_trips() {
10386        // Issue #552: Code block content with non-ASCII colon-delimited words
10387        // (e.g. CJK or accented characters) must round-trip without splitting
10388        // or emoji corruption.
10389        let adf_json = r#"{"version":1,"type":"doc","content":[
10390          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
10391            {"type":"text","text":"class ZBC::配置::Production < Base"}
10392          ]}
10393        ]}"#;
10394        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10395
10396        let md = adf_to_markdown(&doc).unwrap();
10397        let round_tripped = markdown_to_adf(&md).unwrap();
10398
10399        let cb = &round_tripped.content[0];
10400        assert_eq!(cb.node_type, "codeBlock");
10401        let content = cb.content.as_ref().expect("codeBlock content");
10402        assert_eq!(content.len(), 1);
10403        assert_eq!(
10404            content[0].text.as_deref().unwrap(),
10405            "class ZBC::配置::Production < Base"
10406        );
10407    }
10408
10409    #[test]
10410    fn list_item_hardbreak_then_code_block_round_trips() {
10411        // Issue #552: A list item whose first paragraph ends with a hardBreak
10412        // followed by a codeBlock must round-trip correctly.  Previously, the
10413        // hardBreak's `\` continuation swallowed the 2-space-indented code
10414        // fence line, turning the whole block into a paragraph where `::Bar::`
10415        // was re-parsed as an emoji.
10416        let adf_json = r#"{"version":1,"type":"doc","content":[
10417          {"type":"bulletList","content":[
10418            {"type":"listItem","content":[
10419              {"type":"paragraph","content":[
10420                {"type":"text","text":"Consider removing this check."},
10421                {"type":"hardBreak"}
10422              ]},
10423              {"type":"codeBlock","content":[
10424                {"type":"text","text":"x = Foo::Bar::Baz.new"}
10425              ]}
10426            ]}
10427          ]}
10428        ]}"#;
10429        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10430
10431        let md = adf_to_markdown(&doc).unwrap();
10432        let round_tripped = markdown_to_adf(&md).unwrap();
10433
10434        let list = &round_tripped.content[0];
10435        assert_eq!(list.node_type, "bulletList");
10436        let item = &list.content.as_ref().unwrap()[0];
10437        assert_eq!(item.node_type, "listItem");
10438        let item_content = item.content.as_ref().unwrap();
10439        assert_eq!(
10440            item_content.len(),
10441            2,
10442            "listItem should have paragraph + codeBlock, got: {item_content:?}"
10443        );
10444        assert_eq!(item_content[0].node_type, "paragraph");
10445        assert_eq!(item_content[1].node_type, "codeBlock");
10446
10447        // The code block text must survive verbatim — no emoji splitting.
10448        let cb_content = item_content[1].content.as_ref().unwrap();
10449        assert_eq!(cb_content[0].node_type, "text");
10450        assert_eq!(
10451            cb_content[0].text.as_deref().unwrap(),
10452            "x = Foo::Bar::Baz.new"
10453        );
10454
10455        // And there should be no emoji node anywhere in the tree.
10456        assert!(
10457            item_content
10458                .iter()
10459                .flat_map(|n| n.content.iter().flat_map(|c| c.iter()))
10460                .all(|n| n.node_type != "emoji"),
10461            "no emoji nodes should be present, got: {item_content:?}"
10462        );
10463    }
10464
10465    #[test]
10466    fn list_item_hardbreak_then_nested_list_still_works() {
10467        // Ensure the hardBreak continuation fix doesn't break nested list
10468        // handling — an indented `- item` line after a hardBreak is still a
10469        // nested list, not a code fence.
10470        let md = "- first\\\n  continuation text\\\n  - nested item\n";
10471        let doc = markdown_to_adf(md).unwrap();
10472        let list = &doc.content[0];
10473        assert_eq!(list.node_type, "bulletList");
10474        let item = &list.content.as_ref().unwrap()[0];
10475        // First paragraph should contain the continuation text joined via hardBreaks
10476        let item_content = item.content.as_ref().unwrap();
10477        let para = &item_content[0];
10478        assert_eq!(para.node_type, "paragraph");
10479        let para_nodes = para.content.as_ref().unwrap();
10480        assert!(
10481            para_nodes
10482                .iter()
10483                .any(|n| n.text.as_deref() == Some("continuation text")),
10484            "continuation text should survive: {para_nodes:?}"
10485        );
10486    }
10487
10488    #[test]
10489    fn list_item_hardbreak_then_image_still_works() {
10490        // Regression check for issue #490: the image-skip behaviour in
10491        // collect_hardbreak_continuations must still hold after the code-fence
10492        // fix.  The `![](url)` line must remain a block-level mediaSingle, not
10493        // be swallowed into the paragraph.
10494        let md = "- first\\\n  ![](https://example.com/x.png){type=file id=x}\n";
10495        let doc = markdown_to_adf(md).unwrap();
10496        let list = &doc.content[0];
10497        let item = &list.content.as_ref().unwrap()[0];
10498        let item_content = item.content.as_ref().unwrap();
10499        // The image should be a sibling block, not part of the first paragraph.
10500        assert!(
10501            item_content.iter().any(|n| n.node_type == "mediaSingle"),
10502            "mediaSingle should still be a block-level sibling, got: {item_content:?}"
10503        );
10504    }
10505
10506    #[test]
10507    fn list_item_hardbreak_then_container_directive_round_trips() {
10508        // Issue #552: Same hardBreak-swallows-block-siblings bug class — a
10509        // container directive (`:::panel`) after a hardBreak must also not be
10510        // consumed as a continuation line.
10511        let adf_json = r#"{"version":1,"type":"doc","content":[
10512          {"type":"bulletList","content":[
10513            {"type":"listItem","content":[
10514              {"type":"paragraph","content":[
10515                {"type":"text","text":"intro"},
10516                {"type":"hardBreak"}
10517              ]},
10518              {"type":"panel","attrs":{"panelType":"info"},"content":[
10519                {"type":"paragraph","content":[
10520                  {"type":"text","text":"panel body"}
10521                ]}
10522              ]}
10523            ]}
10524          ]}
10525        ]}"#;
10526        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10527        let md = adf_to_markdown(&doc).unwrap();
10528        let round_tripped = markdown_to_adf(&md).unwrap();
10529
10530        let item = &round_tripped.content[0].content.as_ref().unwrap()[0];
10531        let item_content = item.content.as_ref().unwrap();
10532        assert!(
10533            item_content.iter().any(|n| n.node_type == "panel"),
10534            "panel should survive as block-level sibling, got: {item_content:?}"
10535        );
10536    }
10537
10538    #[test]
10539    fn inline_code_with_unicode_shortcode_pattern_round_trips() {
10540        // Issue #552: Inline code containing non-ASCII colon-delimited words
10541        // must round-trip without emoji corruption.
10542        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10543          {"type":"text","text":"See "},
10544          {"type":"text","text":"ZBC::配置::Production","marks":[{"type":"code"}]},
10545          {"type":"text","text":" for prod"}
10546        ]}]}"#;
10547        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10548
10549        let md = adf_to_markdown(&doc).unwrap();
10550        let round_tripped = markdown_to_adf(&md).unwrap();
10551        let content = round_tripped.content[0].content.as_ref().unwrap();
10552
10553        assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
10554        assert_eq!(content[1].text.as_deref(), Some("ZBC::配置::Production"));
10555        assert!(content[1]
10556            .marks
10557            .as_ref()
10558            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
10559    }
10560
10561    #[test]
10562    fn code_block_followed_by_shortcode_text_round_trips() {
10563        // Issue #552: A code block with colon-delimited content followed by a
10564        // paragraph containing emoji-like text should not confuse parsing.
10565        let adf_json = r#"{"version":1,"type":"doc","content":[
10566          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
10567            {"type":"text","text":"Foo::Bar::Baz"}
10568          ]},
10569          {"type":"paragraph","content":[
10570            {"type":"text","text":"Status::Active::Running"}
10571          ]}
10572        ]}"#;
10573        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10574
10575        let md = adf_to_markdown(&doc).unwrap();
10576        let round_tripped = markdown_to_adf(&md).unwrap();
10577
10578        assert_eq!(round_tripped.content.len(), 2);
10579        let cb = &round_tripped.content[0];
10580        assert_eq!(cb.node_type, "codeBlock");
10581        let cb_content = cb.content.as_ref().unwrap();
10582        assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
10583
10584        let para = &round_tripped.content[1];
10585        assert_eq!(para.node_type, "paragraph");
10586        let para_content = para.content.as_ref().unwrap();
10587        assert_eq!(para_content.len(), 1);
10588        assert_eq!(para_content[0].node_type, "text");
10589        assert_eq!(
10590            para_content[0].text.as_deref(),
10591            Some("Status::Active::Running")
10592        );
10593    }
10594
10595    #[test]
10596    fn adf_inline_card_to_markdown() {
10597        let doc = AdfDocument {
10598            version: 1,
10599            doc_type: "doc".to_string(),
10600            content: vec![AdfNode::paragraph(vec![AdfNode {
10601                node_type: "inlineCard".to_string(),
10602                attrs: Some(
10603                    serde_json::json!({"url": "https://org.atlassian.net/browse/ACME-4382"}),
10604                ),
10605                content: None,
10606                text: None,
10607                marks: None,
10608                local_id: None,
10609                parameters: None,
10610            }])],
10611        };
10612        let md = adf_to_markdown(&doc).unwrap();
10613        assert!(md.contains(":card[https://org.atlassian.net/browse/ACME-4382]"));
10614        assert!(!md.contains("<!-- unsupported inline"));
10615    }
10616
10617    #[test]
10618    fn inline_card_directive_round_trips() {
10619        // inlineCard → :card[url] → inlineCard
10620        let original = AdfDocument {
10621            version: 1,
10622            doc_type: "doc".to_string(),
10623            content: vec![AdfNode::paragraph(vec![AdfNode::inline_card(
10624                "https://org.atlassian.net/browse/ACME-4382",
10625            )])],
10626        };
10627        let md = adf_to_markdown(&original).unwrap();
10628        assert!(md.contains(":card[https://org.atlassian.net/browse/ACME-4382]"));
10629        let restored = markdown_to_adf(&md).unwrap();
10630        let node = &restored.content[0].content.as_ref().unwrap()[0];
10631        assert_eq!(node.node_type, "inlineCard");
10632        assert_eq!(
10633            node.attrs.as_ref().unwrap()["url"],
10634            "https://org.atlassian.net/browse/ACME-4382"
10635        );
10636    }
10637
10638    #[test]
10639    fn inline_card_directive_parsed_from_jfm() {
10640        // :card[url] in JFM → inlineCard in ADF
10641        let doc = markdown_to_adf("See :card[https://example.com/issue/123] for details.").unwrap();
10642        let nodes = doc.content[0].content.as_ref().unwrap();
10643        assert_eq!(nodes[0].node_type, "text");
10644        assert_eq!(nodes[0].text.as_deref(), Some("See "));
10645        assert_eq!(nodes[1].node_type, "inlineCard");
10646        assert_eq!(
10647            nodes[1].attrs.as_ref().unwrap()["url"],
10648            "https://example.com/issue/123"
10649        );
10650        assert_eq!(nodes[2].node_type, "text");
10651        assert_eq!(nodes[2].text.as_deref(), Some(" for details."));
10652    }
10653
10654    #[test]
10655    fn self_link_becomes_link_mark_not_inline_card() {
10656        // Issue #378: [url](url) should produce a link mark, not inlineCard.
10657        // inlineCard is only for :card[url] directives and bare URLs.
10658        let doc = markdown_to_adf("[https://example.com](https://example.com)").unwrap();
10659        let node = &doc.content[0].content.as_ref().unwrap()[0];
10660        assert_eq!(node.node_type, "text");
10661        assert_eq!(node.text.as_deref(), Some("https://example.com"));
10662        let mark = &node.marks.as_ref().unwrap()[0];
10663        assert_eq!(mark.mark_type, "link");
10664        assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
10665    }
10666
10667    #[test]
10668    fn url_link_text_with_trailing_slash_mismatch_becomes_link_mark() {
10669        // Issue #523: [url](url/) where text and href differ only by trailing
10670        // slash should produce a text node with link mark, not an inlineCard.
10671        let doc =
10672            markdown_to_adf("[https://octopz.example.com](https://octopz.example.com/)").unwrap();
10673        let node = &doc.content[0].content.as_ref().unwrap()[0];
10674        assert_eq!(node.node_type, "text");
10675        assert_eq!(node.text.as_deref(), Some("https://octopz.example.com"));
10676        let mark = &node.marks.as_ref().unwrap()[0];
10677        assert_eq!(mark.mark_type, "link");
10678        assert_eq!(
10679            mark.attrs.as_ref().unwrap()["href"],
10680            "https://octopz.example.com/"
10681        );
10682    }
10683
10684    #[test]
10685    fn named_link_does_not_become_inline_card() {
10686        // [#4668](url) — text differs from url, stays as a link mark
10687        let doc = markdown_to_adf("[#4668](https://github.com/org/repo/pull/4668)").unwrap();
10688        let node = &doc.content[0].content.as_ref().unwrap()[0];
10689        assert_eq!(node.node_type, "text");
10690        assert_eq!(node.text.as_deref(), Some("#4668"));
10691        let mark = &node.marks.as_ref().unwrap()[0];
10692        assert_eq!(mark.mark_type, "link");
10693    }
10694
10695    #[test]
10696    fn adf_inline_card_no_url_to_markdown() {
10697        let doc = AdfDocument {
10698            version: 1,
10699            doc_type: "doc".to_string(),
10700            content: vec![AdfNode::paragraph(vec![AdfNode {
10701                node_type: "inlineCard".to_string(),
10702                attrs: Some(serde_json::json!({})),
10703                content: None,
10704                text: None,
10705                marks: None,
10706                local_id: None,
10707                parameters: None,
10708            }])],
10709        };
10710        let md = adf_to_markdown(&doc).unwrap();
10711        // No url attr — renders nothing (not a comment)
10712        assert!(!md.contains("<!-- unsupported inline"));
10713    }
10714
10715    #[test]
10716    fn adf_code_block_no_language_to_markdown() {
10717        let doc = AdfDocument {
10718            version: 1,
10719            doc_type: "doc".to_string(),
10720            content: vec![AdfNode::code_block(None, "plain code")],
10721        };
10722        let md = adf_to_markdown(&doc).unwrap();
10723        assert!(md.contains("```\n"));
10724        assert!(md.contains("plain code"));
10725    }
10726
10727    #[test]
10728    fn adf_code_block_empty_language_to_markdown() {
10729        let doc = AdfDocument {
10730            version: 1,
10731            doc_type: "doc".to_string(),
10732            content: vec![AdfNode::code_block(Some(""), "plain code")],
10733        };
10734        let md = adf_to_markdown(&doc).unwrap();
10735        assert!(md.contains("```\"\"\n"));
10736        assert!(md.contains("plain code"));
10737    }
10738
10739    // ── Additional round-trip tests ────────────────────────────────────
10740
10741    #[test]
10742    fn round_trip_table() {
10743        let md = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
10744        let adf = markdown_to_adf(md).unwrap();
10745        let restored = adf_to_markdown(&adf).unwrap();
10746        assert!(restored.contains("| A | B |"));
10747        assert!(restored.contains("| 1 | 2 |"));
10748    }
10749
10750    #[test]
10751    fn round_trip_blockquote() {
10752        let md = "> This is quoted\n";
10753        let adf = markdown_to_adf(md).unwrap();
10754        let restored = adf_to_markdown(&adf).unwrap();
10755        assert!(restored.contains("> This is quoted"));
10756    }
10757
10758    #[test]
10759    fn round_trip_image() {
10760        let md = "![Logo](https://example.com/logo.png)\n";
10761        let adf = markdown_to_adf(md).unwrap();
10762        let restored = adf_to_markdown(&adf).unwrap();
10763        assert!(restored.contains("![Logo](https://example.com/logo.png)"));
10764    }
10765
10766    #[test]
10767    fn round_trip_ordered_list() {
10768        let md = "1. A\n2. B\n3. C\n";
10769        let adf = markdown_to_adf(md).unwrap();
10770        let restored = adf_to_markdown(&adf).unwrap();
10771        assert!(restored.contains("1. A"));
10772        assert!(restored.contains("2. B"));
10773        assert!(restored.contains("3. C"));
10774    }
10775
10776    #[test]
10777    fn round_trip_inline_marks() {
10778        let md = "Text with `code` and ~~strike~~ and [link](https://x.com).\n";
10779        let adf = markdown_to_adf(md).unwrap();
10780        let restored = adf_to_markdown(&adf).unwrap();
10781        assert!(restored.contains("`code`"));
10782        assert!(restored.contains("~~strike~~"));
10783        assert!(restored.contains("[link](https://x.com)"));
10784    }
10785
10786    // ── Container directive tests (Tier 2) ───────────────────────────
10787
10788    #[test]
10789    fn panel_info() {
10790        let md = ":::panel{type=info}\nThis is informational.\n:::";
10791        let doc = markdown_to_adf(md).unwrap();
10792        assert_eq!(doc.content[0].node_type, "panel");
10793        assert_eq!(doc.content[0].attrs.as_ref().unwrap()["panelType"], "info");
10794        let inner = doc.content[0].content.as_ref().unwrap();
10795        assert_eq!(inner[0].node_type, "paragraph");
10796    }
10797
10798    #[test]
10799    fn adf_panel_to_markdown() {
10800        let doc = AdfDocument {
10801            version: 1,
10802            doc_type: "doc".to_string(),
10803            content: vec![AdfNode::panel(
10804                "warning",
10805                vec![AdfNode::paragraph(vec![AdfNode::text("Be careful.")])],
10806            )],
10807        };
10808        let md = adf_to_markdown(&doc).unwrap();
10809        assert!(md.contains(":::panel{type=warning}"));
10810        assert!(md.contains("Be careful."));
10811        assert!(md.contains(":::"));
10812    }
10813
10814    #[test]
10815    fn round_trip_panel() {
10816        let md = ":::panel{type=info}\nThis is informational.\n:::\n";
10817        let doc = markdown_to_adf(md).unwrap();
10818        let result = adf_to_markdown(&doc).unwrap();
10819        assert!(result.contains(":::panel{type=info}"));
10820        assert!(result.contains("This is informational."));
10821    }
10822
10823    #[test]
10824    fn expand_with_title() {
10825        let md = ":::expand{title=\"Click me\"}\nHidden content.\n:::";
10826        let doc = markdown_to_adf(md).unwrap();
10827        assert_eq!(doc.content[0].node_type, "expand");
10828        assert_eq!(doc.content[0].attrs.as_ref().unwrap()["title"], "Click me");
10829    }
10830
10831    #[test]
10832    fn adf_expand_to_markdown() {
10833        let doc = AdfDocument {
10834            version: 1,
10835            doc_type: "doc".to_string(),
10836            content: vec![AdfNode::expand(
10837                Some("Details"),
10838                vec![AdfNode::paragraph(vec![AdfNode::text("Inner.")])],
10839            )],
10840        };
10841        let md = adf_to_markdown(&doc).unwrap();
10842        assert!(md.contains(":::expand{title=\"Details\"}"));
10843        assert!(md.contains("Inner."));
10844    }
10845
10846    #[test]
10847    fn round_trip_expand() {
10848        let md = ":::expand{title=\"Details\"}\nInner content.\n:::\n";
10849        let doc = markdown_to_adf(md).unwrap();
10850        let result = adf_to_markdown(&doc).unwrap();
10851        assert!(result.contains(":::expand{title=\"Details\"}"));
10852        assert!(result.contains("Inner content."));
10853    }
10854
10855    #[test]
10856    fn layout_two_columns() {
10857        let md =
10858            "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
10859        let doc = markdown_to_adf(md).unwrap();
10860        assert_eq!(doc.content[0].node_type, "layoutSection");
10861        let columns = doc.content[0].content.as_ref().unwrap();
10862        assert_eq!(columns.len(), 2);
10863        assert_eq!(columns[0].node_type, "layoutColumn");
10864        assert_eq!(columns[1].node_type, "layoutColumn");
10865    }
10866
10867    #[test]
10868    fn adf_layout_to_markdown() {
10869        let doc = AdfDocument {
10870            version: 1,
10871            doc_type: "doc".to_string(),
10872            content: vec![AdfNode::layout_section(vec![
10873                AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Left.")])]),
10874                AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Right.")])]),
10875            ])],
10876        };
10877        let md = adf_to_markdown(&doc).unwrap();
10878        assert!(md.contains("::::layout"));
10879        assert!(md.contains(":::column{width=50}"));
10880        assert!(md.contains("Left."));
10881        assert!(md.contains("Right."));
10882    }
10883
10884    #[test]
10885    fn layout_column_localid_roundtrip() {
10886        let adf_json = r#"{
10887            "version": 1,
10888            "type": "doc",
10889            "content": [{
10890                "type": "layoutSection",
10891                "content": [
10892                    {
10893                        "type": "layoutColumn",
10894                        "attrs": {"width": 50.0, "localId": "aabb112233cc"},
10895                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Left"}]}]
10896                    },
10897                    {
10898                        "type": "layoutColumn",
10899                        "attrs": {"width": 50.0, "localId": "ddeeff445566"},
10900                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Right"}]}]
10901                    }
10902                ]
10903            }]
10904        }"#;
10905        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10906        let md = adf_to_markdown(&doc).unwrap();
10907        assert!(
10908            md.contains("localId=aabb112233cc"),
10909            "first column localId should appear in markdown: {md}"
10910        );
10911        assert!(
10912            md.contains("localId=ddeeff445566"),
10913            "second column localId should appear in markdown: {md}"
10914        );
10915        let rt = markdown_to_adf(&md).unwrap();
10916        let cols = rt.content[0].content.as_ref().unwrap();
10917        assert_eq!(
10918            cols[0].attrs.as_ref().unwrap()["localId"],
10919            "aabb112233cc",
10920            "first column localId should round-trip"
10921        );
10922        assert_eq!(
10923            cols[1].attrs.as_ref().unwrap()["localId"],
10924            "ddeeff445566",
10925            "second column localId should round-trip"
10926        );
10927    }
10928
10929    #[test]
10930    fn layout_column_without_localid() {
10931        let md =
10932            "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
10933        let doc = markdown_to_adf(md).unwrap();
10934        let cols = doc.content[0].content.as_ref().unwrap();
10935        assert!(
10936            cols[0].attrs.as_ref().unwrap().get("localId").is_none(),
10937            "column without localId should not gain one"
10938        );
10939        let md2 = adf_to_markdown(&doc).unwrap();
10940        assert!(
10941            !md2.contains("localId"),
10942            "no localId should appear in output: {md2}"
10943        );
10944    }
10945
10946    #[test]
10947    fn layout_column_localid_stripped_when_option_set() {
10948        let adf_json = r#"{
10949            "version": 1,
10950            "type": "doc",
10951            "content": [{
10952                "type": "layoutSection",
10953                "content": [{
10954                    "type": "layoutColumn",
10955                    "attrs": {"width": 50.0, "localId": "aabb112233cc"},
10956                    "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Col"}]}]
10957                }]
10958            }]
10959        }"#;
10960        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10961        let opts = RenderOptions {
10962            strip_local_ids: true,
10963            ..Default::default()
10964        };
10965        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
10966        assert!(!md.contains("localId"), "localId should be stripped: {md}");
10967    }
10968
10969    #[test]
10970    fn layout_column_localid_flush_previous() {
10971        // Columns open without explicit `:::` close → flush-previous path
10972        let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nLeft.\n:::column{width=50 localId=ddeeff445566}\nRight.\n:::\n::::";
10973        let doc = markdown_to_adf(md).unwrap();
10974        let cols = doc.content[0].content.as_ref().unwrap();
10975        assert_eq!(
10976            cols[0].attrs.as_ref().unwrap()["localId"],
10977            "aabb112233cc",
10978            "flush-previous column should preserve localId"
10979        );
10980        assert_eq!(
10981            cols[1].attrs.as_ref().unwrap()["localId"],
10982            "ddeeff445566",
10983            "second column localId should be preserved"
10984        );
10985    }
10986
10987    #[test]
10988    fn layout_column_localid_flush_last() {
10989        // Layout with no closing fence → column never explicitly closed → flush-last path
10990        let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nOnly column.";
10991        let doc = markdown_to_adf(md).unwrap();
10992        let cols = doc.content[0].content.as_ref().unwrap();
10993        assert_eq!(
10994            cols[0].attrs.as_ref().unwrap()["localId"],
10995            "aabb112233cc",
10996            "flush-last column should preserve localId"
10997        );
10998    }
10999
11000    /// Issue #555: `layoutColumn` fractional `width` must round-trip byte-for-byte.
11001    #[test]
11002    fn issue_555_layout_column_fractional_width_roundtrip() {
11003        let adf_json = r#"{
11004            "version": 1,
11005            "type": "doc",
11006            "content": [{
11007                "type": "layoutSection",
11008                "content": [
11009                    {
11010                        "type": "layoutColumn",
11011                        "attrs": {"width": 66.66},
11012                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
11013                    },
11014                    {
11015                        "type": "layoutColumn",
11016                        "attrs": {"width": 33.34},
11017                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
11018                    }
11019                ]
11020            }]
11021        }"#;
11022        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11023        let md = adf_to_markdown(&doc).unwrap();
11024        assert!(md.contains("width=66.66"), "fractional width in md: {md}");
11025        assert!(md.contains("width=33.34"), "fractional width in md: {md}");
11026        let rt = markdown_to_adf(&md).unwrap();
11027        let cols = rt.content[0].content.as_ref().unwrap();
11028        assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 66.66);
11029        assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 33.34);
11030    }
11031
11032    /// Issue #555: `layoutColumn` 5/6 widths (`83.33`) round-trip without precision loss.
11033    #[test]
11034    fn issue_555_layout_column_five_sixths_width_roundtrip() {
11035        let adf_json = r#"{
11036            "version": 1,
11037            "type": "doc",
11038            "content": [{
11039                "type": "layoutSection",
11040                "content": [
11041                    {
11042                        "type": "layoutColumn",
11043                        "attrs": {"width": 83.33},
11044                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
11045                    },
11046                    {
11047                        "type": "layoutColumn",
11048                        "attrs": {"width": 16.67},
11049                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
11050                    }
11051                ]
11052            }]
11053        }"#;
11054        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11055        let md = adf_to_markdown(&doc).unwrap();
11056        let rt = markdown_to_adf(&md).unwrap();
11057        let cols = rt.content[0].content.as_ref().unwrap();
11058        assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 83.33);
11059        assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 16.67);
11060    }
11061
11062    /// Issue #555: `layoutColumn` integer widths must NOT be coerced to floats on round-trip.
11063    #[test]
11064    fn issue_555_layout_column_integer_width_preserved() {
11065        let adf_json = r#"{
11066            "version": 1,
11067            "type": "doc",
11068            "content": [{
11069                "type": "layoutSection",
11070                "content": [
11071                    {
11072                        "type": "layoutColumn",
11073                        "attrs": {"width": 50},
11074                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "A"}]}]
11075                    },
11076                    {
11077                        "type": "layoutColumn",
11078                        "attrs": {"width": 50},
11079                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "B"}]}]
11080                    }
11081                ]
11082            }]
11083        }"#;
11084        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11085        let md = adf_to_markdown(&doc).unwrap();
11086        assert!(
11087            md.contains("width=50") && !md.contains("width=50."),
11088            "integer width should render without decimal: {md}"
11089        );
11090        let rt = markdown_to_adf(&md).unwrap();
11091        let cols = rt.content[0].content.as_ref().unwrap();
11092        let w0 = &cols[0].attrs.as_ref().unwrap()["width"];
11093        assert!(
11094            w0.is_i64() || w0.is_u64(),
11095            "width should remain a JSON integer, got: {w0}"
11096        );
11097        assert_eq!(w0.as_i64(), Some(50));
11098    }
11099
11100    /// Issue #555: `mediaSingle` integer `width` must NOT be coerced to a float on round-trip.
11101    #[test]
11102    fn issue_555_media_single_integer_width_preserved() {
11103        let adf_json = r#"{
11104            "version": 1,
11105            "type": "doc",
11106            "content": [{
11107                "type": "mediaSingle",
11108                "attrs": {"layout": "center", "width": 800},
11109                "content": [
11110                    {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
11111                ]
11112            }]
11113        }"#;
11114        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11115        let md = adf_to_markdown(&doc).unwrap();
11116        assert!(
11117            md.contains("width=800") && !md.contains("width=800."),
11118            "integer width should render without decimal: {md}"
11119        );
11120        let rt = markdown_to_adf(&md).unwrap();
11121        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
11122        let w = &ms_attrs["width"];
11123        assert!(
11124            w.is_i64() || w.is_u64(),
11125            "mediaSingle width should remain JSON integer, got: {w}"
11126        );
11127        assert_eq!(w.as_i64(), Some(800));
11128    }
11129
11130    /// Issue #555 (follow-up): fractional `mediaSingle` width (e.g. `66.5`, a
11131    /// percentage-based size common in Jira layouts) must survive `from-adf`
11132    /// instead of being silently dropped.
11133    #[test]
11134    fn issue_555_media_single_fractional_width_preserved() {
11135        let adf_json = r#"{
11136            "version": 1,
11137            "type": "doc",
11138            "content": [{
11139                "type": "mediaSingle",
11140                "attrs": {"layout": "center", "width": 66.5},
11141                "content": [
11142                    {"type": "media", "attrs": {"type": "external", "url": "https://example.com/diagram.png"}}
11143                ]
11144            }]
11145        }"#;
11146        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11147        let md = adf_to_markdown(&doc).unwrap();
11148        assert!(
11149            md.contains("width=66.5"),
11150            "fractional width must appear in JFM: {md}"
11151        );
11152        let rt = markdown_to_adf(&md).unwrap();
11153        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
11154        assert_eq!(ms_attrs["width"], 66.5);
11155    }
11156
11157    /// Issue #555: `mediaSingle` float `width` must not be dropped during ADF→JFM→ADF.
11158    #[test]
11159    fn issue_555_media_single_float_width_preserved() {
11160        let adf_json = r#"{
11161            "version": 1,
11162            "type": "doc",
11163            "content": [{
11164                "type": "mediaSingle",
11165                "attrs": {"layout": "center", "width": 800.0},
11166                "content": [
11167                    {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
11168                ]
11169            }]
11170        }"#;
11171        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11172        let md = adf_to_markdown(&doc).unwrap();
11173        assert!(
11174            md.contains("width=800.0"),
11175            "float width should render with decimal: {md}"
11176        );
11177        let rt = markdown_to_adf(&md).unwrap();
11178        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
11179        let w = &ms_attrs["width"];
11180        assert!(
11181            w.is_f64(),
11182            "mediaSingle float width should stay a JSON float, got: {w}"
11183        );
11184        assert_eq!(w.as_f64(), Some(800.0));
11185    }
11186
11187    /// Issue #555: `mediaSingle` with `layout=wide` and integer width must round-trip.
11188    #[test]
11189    fn issue_555_media_single_wide_layout_integer_width_roundtrip() {
11190        let adf_json = r#"{
11191            "version": 1,
11192            "type": "doc",
11193            "content": [{
11194                "type": "mediaSingle",
11195                "attrs": {"layout": "wide", "width": 1420},
11196                "content": [
11197                    {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
11198                ]
11199            }]
11200        }"#;
11201        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11202        let md = adf_to_markdown(&doc).unwrap();
11203        let rt = markdown_to_adf(&md).unwrap();
11204        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
11205        assert_eq!(ms_attrs["layout"], "wide");
11206        let w = &ms_attrs["width"];
11207        assert!(
11208            w.is_i64() || w.is_u64(),
11209            "mediaSingle width should remain JSON integer, got: {w}"
11210        );
11211        assert_eq!(w.as_i64(), Some(1420));
11212    }
11213
11214    /// Issue #555: Confluence file-attachment `mediaSingle` with integer `mediaWidth`
11215    /// must round-trip without float coercion.
11216    #[test]
11217    fn issue_555_file_media_single_integer_width_preserved() {
11218        let adf_json = r#"{
11219            "version": 1,
11220            "type": "doc",
11221            "content": [{
11222                "type": "mediaSingle",
11223                "attrs": {"layout": "wide", "width": 1420},
11224                "content": [
11225                    {"type": "media", "attrs": {"id": "abc-123", "type": "file", "collection": "col-1", "width": 1200, "height": 800}}
11226                ]
11227            }]
11228        }"#;
11229        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11230        let md = adf_to_markdown(&doc).unwrap();
11231        let rt = markdown_to_adf(&md).unwrap();
11232        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
11233        let ms_w = &ms_attrs["width"];
11234        assert!(ms_w.is_i64() || ms_w.is_u64(), "ms width: {ms_w}");
11235        assert_eq!(ms_w.as_i64(), Some(1420));
11236        let media = &rt.content[0].content.as_ref().unwrap()[0];
11237        let media_attrs = media.attrs.as_ref().unwrap();
11238        let mw = &media_attrs["width"];
11239        assert!(mw.is_i64() || mw.is_u64(), "media width: {mw}");
11240        assert_eq!(mw.as_i64(), Some(1200));
11241        let mh = &media_attrs["height"];
11242        assert!(mh.is_i64() || mh.is_u64(), "media height: {mh}");
11243        assert_eq!(mh.as_i64(), Some(800));
11244    }
11245
11246    /// Issue #555: `fmt_numeric_attr` preserves the original integer/float JSON type.
11247    #[test]
11248    fn issue_555_fmt_numeric_attr_preserves_type() {
11249        assert_eq!(
11250            fmt_numeric_attr(&serde_json::json!(50)).as_deref(),
11251            Some("50")
11252        );
11253        assert_eq!(
11254            fmt_numeric_attr(&serde_json::json!(50.0)).as_deref(),
11255            Some("50.0")
11256        );
11257        assert_eq!(
11258            fmt_numeric_attr(&serde_json::json!(66.66)).as_deref(),
11259            Some("66.66")
11260        );
11261        assert_eq!(
11262            fmt_numeric_attr(&serde_json::json!(-5)).as_deref(),
11263            Some("-5")
11264        );
11265        assert_eq!(fmt_numeric_attr(&serde_json::json!("not a number")), None);
11266        // u64 values above i64::MAX exercise the u64-only branch.
11267        let big = serde_json::Value::Number(serde_json::Number::from(u64::MAX));
11268        assert_eq!(
11269            fmt_numeric_attr(&big).as_deref(),
11270            Some("18446744073709551615")
11271        );
11272        // Null is not a number.
11273        assert_eq!(fmt_numeric_attr(&serde_json::Value::Null), None);
11274    }
11275
11276    /// Issue #555: `parse_numeric_attr` distinguishes integer vs float strings.
11277    #[test]
11278    fn issue_555_parse_numeric_attr_detects_type() {
11279        let v = parse_numeric_attr("800").unwrap();
11280        assert!(v.is_i64() || v.is_u64(), "'800' should parse as integer");
11281        assert_eq!(v.as_i64(), Some(800));
11282
11283        let v = parse_numeric_attr("800.0").unwrap();
11284        assert!(v.is_f64(), "'800.0' should parse as float");
11285        assert_eq!(v.as_f64(), Some(800.0));
11286
11287        let v = parse_numeric_attr("66.66").unwrap();
11288        assert!(v.is_f64());
11289        assert_eq!(v.as_f64(), Some(66.66));
11290
11291        // Scientific notation is treated as float (matches JSON semantics).
11292        let v = parse_numeric_attr("1e2").unwrap();
11293        assert!(v.is_f64());
11294        assert_eq!(v.as_f64(), Some(100.0));
11295        let v = parse_numeric_attr("1E2").unwrap();
11296        assert!(v.is_f64());
11297        assert_eq!(v.as_f64(), Some(100.0));
11298
11299        // Negative integer.
11300        let v = parse_numeric_attr("-42").unwrap();
11301        assert!(v.is_i64());
11302        assert_eq!(v.as_i64(), Some(-42));
11303
11304        assert!(parse_numeric_attr("not-a-number").is_none());
11305        assert!(parse_numeric_attr("").is_none());
11306        assert!(parse_numeric_attr("1.2.3").is_none());
11307    }
11308
11309    /// Issue #555: fractional `mediaSingle` width with non-default `layout=wide`
11310    /// must preserve both the layout and the fractional width through round-trip.
11311    #[test]
11312    fn issue_555_media_single_wide_layout_fractional_width_roundtrip() {
11313        let adf_json = r#"{
11314            "version": 1,
11315            "type": "doc",
11316            "content": [{
11317                "type": "mediaSingle",
11318                "attrs": {"layout": "wide", "width": 83.33},
11319                "content": [
11320                    {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
11321                ]
11322            }]
11323        }"#;
11324        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11325        let md = adf_to_markdown(&doc).unwrap();
11326        assert!(md.contains("layout=wide"), "layout must appear in md: {md}");
11327        assert!(md.contains("width=83.33"), "width must appear in md: {md}");
11328        let rt = markdown_to_adf(&md).unwrap();
11329        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
11330        assert_eq!(ms_attrs["layout"], "wide");
11331        assert_eq!(ms_attrs["width"], 83.33);
11332    }
11333
11334    /// Issue #555: fractional `mediaWidth` on a Confluence file-attachment
11335    /// `mediaSingle` must round-trip (exercises the file-branch `mediaWidth`
11336    /// render path, which previously used `as_u64` and silently dropped floats).
11337    #[test]
11338    fn issue_555_file_media_single_fractional_media_width_preserved() {
11339        let adf_json = r#"{
11340            "version": 1,
11341            "type": "doc",
11342            "content": [{
11343                "type": "mediaSingle",
11344                "attrs": {"layout": "wide", "width": 66.5},
11345                "content": [
11346                    {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c"}}
11347                ]
11348            }]
11349        }"#;
11350        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11351        let md = adf_to_markdown(&doc).unwrap();
11352        assert!(md.contains("mediaWidth=66.5"), "mediaWidth in md: {md}");
11353        let rt = markdown_to_adf(&md).unwrap();
11354        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
11355        assert_eq!(ms_attrs["width"], 66.5);
11356    }
11357
11358    /// Issue #555: fractional inner `media` width/height on a file attachment
11359    /// must round-trip (exercises the file-branch inner `width`/`height` render
11360    /// path, which previously used `as_u64` and silently dropped floats).
11361    #[test]
11362    fn issue_555_file_media_fractional_inner_dimensions_preserved() {
11363        let adf_json = r#"{
11364            "version": 1,
11365            "type": "doc",
11366            "content": [{
11367                "type": "mediaSingle",
11368                "attrs": {"layout": "center"},
11369                "content": [
11370                    {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c", "width": 1200.5, "height": 800.25}}
11371                ]
11372            }]
11373        }"#;
11374        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11375        let md = adf_to_markdown(&doc).unwrap();
11376        assert!(md.contains("width=1200.5"), "width in md: {md}");
11377        assert!(md.contains("height=800.25"), "height in md: {md}");
11378        let rt = markdown_to_adf(&md).unwrap();
11379        let media = &rt.content[0].content.as_ref().unwrap()[0];
11380        let attrs = media.attrs.as_ref().unwrap();
11381        assert_eq!(attrs["width"], 1200.5);
11382        assert_eq!(attrs["height"], 800.25);
11383    }
11384
11385    #[test]
11386    fn decisions_list() {
11387        let md = ":::decisions\n- <> Use PostgreSQL\n- <> REST API\n:::";
11388        let doc = markdown_to_adf(md).unwrap();
11389        assert_eq!(doc.content[0].node_type, "decisionList");
11390        let items = doc.content[0].content.as_ref().unwrap();
11391        assert_eq!(items.len(), 2);
11392        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DECIDED");
11393    }
11394
11395    // decisionItem is inline-only per ADF schema — its content must be
11396    // text/inline nodes, not a paragraph wrapper (issue #753).
11397    #[test]
11398    fn decision_item_content_is_inline_not_paragraph() {
11399        let md = ":::decisions\n- <> Use Rust\n:::";
11400        let doc = markdown_to_adf(md).unwrap();
11401        let items = doc.content[0].content.as_ref().unwrap();
11402        let first_child = &items[0].content.as_ref().unwrap()[0];
11403        assert_eq!(
11404            first_child.node_type, "text",
11405            "decisionItem must contain inline nodes directly, not a paragraph wrapper"
11406        );
11407        assert_eq!(first_child.text.as_deref(), Some("Use Rust"));
11408    }
11409
11410    #[test]
11411    fn adf_decisions_to_markdown() {
11412        let doc = AdfDocument {
11413            version: 1,
11414            doc_type: "doc".to_string(),
11415            content: vec![AdfNode::decision_list(vec![AdfNode::decision_item(
11416                "DECIDED",
11417                vec![AdfNode::paragraph(vec![AdfNode::text("Use PostgreSQL")])],
11418            )])],
11419        };
11420        let md = adf_to_markdown(&doc).unwrap();
11421        assert!(md.contains(":::decisions"));
11422        assert!(md.contains("- <> Use PostgreSQL"));
11423    }
11424
11425    #[test]
11426    fn bodied_extension_container() {
11427        let md = ":::extension{type=com.forge key=my-macro}\nContent.\n:::";
11428        let doc = markdown_to_adf(md).unwrap();
11429        assert_eq!(doc.content[0].node_type, "bodiedExtension");
11430        assert_eq!(
11431            doc.content[0].attrs.as_ref().unwrap()["extensionType"],
11432            "com.forge"
11433        );
11434    }
11435
11436    #[test]
11437    fn adf_bodied_extension_to_markdown() {
11438        let doc = AdfDocument {
11439            version: 1,
11440            doc_type: "doc".to_string(),
11441            content: vec![AdfNode::bodied_extension(
11442                "com.forge",
11443                "my-macro",
11444                vec![AdfNode::paragraph(vec![AdfNode::text("Content.")])],
11445            )],
11446        };
11447        let md = adf_to_markdown(&doc).unwrap();
11448        assert!(md.contains(":::extension{type=com.forge key=my-macro}"));
11449        assert!(md.contains("Content."));
11450    }
11451
11452    // ── Leaf block directive tests (Tier 3) ──────────────────────────
11453
11454    #[test]
11455    fn leaf_block_card() {
11456        let doc = markdown_to_adf("::card[https://example.com/browse/PROJ-123]").unwrap();
11457        assert_eq!(doc.content[0].node_type, "blockCard");
11458        assert_eq!(
11459            doc.content[0].attrs.as_ref().unwrap()["url"],
11460            "https://example.com/browse/PROJ-123"
11461        );
11462    }
11463
11464    #[test]
11465    fn adf_block_card_to_markdown() {
11466        let doc = AdfDocument {
11467            version: 1,
11468            doc_type: "doc".to_string(),
11469            content: vec![AdfNode::block_card("https://example.com/browse/PROJ-123")],
11470        };
11471        let md = adf_to_markdown(&doc).unwrap();
11472        assert!(md.contains("::card[https://example.com/browse/PROJ-123]"));
11473    }
11474
11475    #[test]
11476    fn round_trip_block_card() {
11477        let md = "::card[https://example.com/browse/PROJ-123]\n";
11478        let doc = markdown_to_adf(md).unwrap();
11479        let result = adf_to_markdown(&doc).unwrap();
11480        assert!(result.contains("::card[https://example.com/browse/PROJ-123]"));
11481    }
11482
11483    #[test]
11484    fn leaf_embed_card() {
11485        let doc =
11486            markdown_to_adf("::embed[https://figma.com/file/abc]{layout=wide width=80}").unwrap();
11487        assert_eq!(doc.content[0].node_type, "embedCard");
11488        let attrs = doc.content[0].attrs.as_ref().unwrap();
11489        assert_eq!(attrs["url"], "https://figma.com/file/abc");
11490        assert_eq!(attrs["layout"], "wide");
11491        assert_eq!(attrs["width"], 80.0);
11492    }
11493
11494    #[test]
11495    fn leaf_embed_card_with_original_height() {
11496        let doc = markdown_to_adf(
11497            "::embed[https://example.com]{layout=center originalHeight=732 width=100}",
11498        )
11499        .unwrap();
11500        assert_eq!(doc.content[0].node_type, "embedCard");
11501        let attrs = doc.content[0].attrs.as_ref().unwrap();
11502        assert_eq!(attrs["url"], "https://example.com");
11503        assert_eq!(attrs["layout"], "center");
11504        assert_eq!(attrs["originalHeight"], 732.0);
11505        assert_eq!(attrs["width"], 100.0);
11506    }
11507
11508    #[test]
11509    fn adf_embed_card_to_markdown() {
11510        let doc = AdfDocument {
11511            version: 1,
11512            doc_type: "doc".to_string(),
11513            content: vec![AdfNode::embed_card(
11514                "https://figma.com/file/abc",
11515                Some("wide"),
11516                None,
11517                Some(80.0),
11518            )],
11519        };
11520        let md = adf_to_markdown(&doc).unwrap();
11521        assert!(md.contains("::embed[https://figma.com/file/abc]{layout=wide width=80}"));
11522    }
11523
11524    #[test]
11525    fn adf_embed_card_original_height_to_markdown() {
11526        let doc = AdfDocument {
11527            version: 1,
11528            doc_type: "doc".to_string(),
11529            content: vec![AdfNode::embed_card(
11530                "https://example.com",
11531                Some("center"),
11532                Some(732.0),
11533                Some(100.0),
11534            )],
11535        };
11536        let md = adf_to_markdown(&doc).unwrap();
11537        assert!(
11538            md.contains("::embed[https://example.com]{layout=center originalHeight=732 width=100}"),
11539            "expected originalHeight and width in md: {md}"
11540        );
11541    }
11542
11543    #[test]
11544    fn embed_card_roundtrip_with_all_attrs() {
11545        let adf_json = r#"{"version":1,"type":"doc","content":[{
11546            "type":"embedCard",
11547            "attrs":{"layout":"center","originalHeight":732.0,"url":"https://example.com","width":100.0}
11548        }]}"#;
11549        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11550        let md = adf_to_markdown(&doc).unwrap();
11551        assert!(
11552            md.contains("originalHeight=732"),
11553            "originalHeight missing from md: {md}"
11554        );
11555        assert!(md.contains("width=100"), "width missing from md: {md}");
11556        let rt = markdown_to_adf(&md).unwrap();
11557        let attrs = rt.content[0].attrs.as_ref().unwrap();
11558        assert_eq!(attrs["originalHeight"], 732.0);
11559        assert_eq!(attrs["width"], 100.0);
11560        assert_eq!(attrs["layout"], "center");
11561        assert_eq!(attrs["url"], "https://example.com");
11562    }
11563
11564    #[test]
11565    fn embed_card_fractional_dimensions() {
11566        let doc = AdfDocument {
11567            version: 1,
11568            doc_type: "doc".to_string(),
11569            content: vec![AdfNode::embed_card(
11570                "https://example.com",
11571                Some("center"),
11572                Some(732.5),
11573                Some(99.9),
11574            )],
11575        };
11576        let md = adf_to_markdown(&doc).unwrap();
11577        assert!(
11578            md.contains("originalHeight=732.5"),
11579            "fractional originalHeight missing: {md}"
11580        );
11581        assert!(md.contains("width=99.9"), "fractional width missing: {md}");
11582        let rt = markdown_to_adf(&md).unwrap();
11583        let attrs = rt.content[0].attrs.as_ref().unwrap();
11584        assert_eq!(attrs["originalHeight"], 732.5);
11585        assert_eq!(attrs["width"], 99.9);
11586    }
11587
11588    #[test]
11589    fn embed_card_integer_width_in_json() {
11590        // JSON integer (not float) should also be extracted via as_f64()
11591        let adf_json = r#"{"version":1,"type":"doc","content":[{
11592            "type":"embedCard",
11593            "attrs":{"url":"https://example.com","width":100}
11594        }]}"#;
11595        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11596        let md = adf_to_markdown(&doc).unwrap();
11597        assert!(
11598            md.contains("width=100"),
11599            "integer width missing from md: {md}"
11600        );
11601        let rt = markdown_to_adf(&md).unwrap();
11602        assert_eq!(rt.content[0].attrs.as_ref().unwrap()["width"], 100.0);
11603    }
11604
11605    #[test]
11606    fn embed_card_only_original_height() {
11607        // originalHeight without width
11608        let adf_json = r#"{"version":1,"type":"doc","content":[{
11609            "type":"embedCard",
11610            "attrs":{"url":"https://example.com","originalHeight":500.0}
11611        }]}"#;
11612        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11613        let md = adf_to_markdown(&doc).unwrap();
11614        assert!(
11615            md.contains("originalHeight=500"),
11616            "originalHeight missing: {md}"
11617        );
11618        assert!(!md.contains("width="), "width should not appear: {md}");
11619        let rt = markdown_to_adf(&md).unwrap();
11620        let attrs = rt.content[0].attrs.as_ref().unwrap();
11621        assert_eq!(attrs["originalHeight"], 500.0);
11622        assert!(attrs.get("width").is_none());
11623    }
11624
11625    #[test]
11626    fn leaf_void_extension() {
11627        let doc = markdown_to_adf("::extension{type=com.atlassian.macro key=jira-chart}").unwrap();
11628        assert_eq!(doc.content[0].node_type, "extension");
11629        assert_eq!(
11630            doc.content[0].attrs.as_ref().unwrap()["extensionType"],
11631            "com.atlassian.macro"
11632        );
11633        assert_eq!(
11634            doc.content[0].attrs.as_ref().unwrap()["extensionKey"],
11635            "jira-chart"
11636        );
11637    }
11638
11639    #[test]
11640    fn adf_void_extension_to_markdown() {
11641        let doc = AdfDocument {
11642            version: 1,
11643            doc_type: "doc".to_string(),
11644            content: vec![AdfNode::extension(
11645                "com.atlassian.macro",
11646                "jira-chart",
11647                None,
11648            )],
11649        };
11650        let md = adf_to_markdown(&doc).unwrap();
11651        assert!(md.contains("::extension{type=com.atlassian.macro key=jira-chart}"));
11652    }
11653
11654    // ── Bare URL autolink tests ──────────────────────────────────────
11655
11656    #[test]
11657    fn bare_url_autolink() {
11658        let doc = markdown_to_adf("Visit https://example.com today").unwrap();
11659        let content = doc.content[0].content.as_ref().unwrap();
11660        assert_eq!(content[0].text.as_deref(), Some("Visit "));
11661        assert_eq!(content[1].node_type, "inlineCard");
11662        assert_eq!(
11663            content[1].attrs.as_ref().unwrap()["url"],
11664            "https://example.com"
11665        );
11666        assert_eq!(content[2].text.as_deref(), Some(" today"));
11667    }
11668
11669    #[test]
11670    fn bare_url_strips_trailing_punctuation() {
11671        let doc = markdown_to_adf("See https://example.com.").unwrap();
11672        let content = doc.content[0].content.as_ref().unwrap();
11673        assert_eq!(
11674            content[1].attrs.as_ref().unwrap()["url"],
11675            "https://example.com"
11676        );
11677    }
11678
11679    #[test]
11680    fn bare_url_round_trip() {
11681        let doc = markdown_to_adf("Visit https://example.com/path today").unwrap();
11682        let md = adf_to_markdown(&doc).unwrap();
11683        assert!(md.contains(":card[https://example.com/path]"));
11684    }
11685
11686    // ── Issue #475: plain-text URL must not become inlineCard ─────────
11687
11688    #[test]
11689    fn plain_text_url_round_trips_as_text() {
11690        // A text node whose content is a bare URL (no link mark) must
11691        // survive ADF→JFM→ADF as a text node, not an inlineCard.
11692        let adf_json = r#"{
11693            "version": 1,
11694            "type": "doc",
11695            "content": [{
11696                "type": "paragraph",
11697                "content": [
11698                    {"type": "text", "text": "https://example.com/some/path/to/resource"}
11699                ]
11700            }]
11701        }"#;
11702        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
11703        let jfm = adf_to_markdown(&adf).unwrap();
11704        let roundtripped = markdown_to_adf(&jfm).unwrap();
11705        let content = roundtripped.content[0].content.as_ref().unwrap();
11706        assert_eq!(content.len(), 1, "should be a single node");
11707        assert_eq!(content[0].node_type, "text");
11708        assert_eq!(
11709            content[0].text.as_deref(),
11710            Some("https://example.com/some/path/to/resource")
11711        );
11712    }
11713
11714    #[test]
11715    fn url_text_with_link_mark_round_trips_as_text_node() {
11716        // Issue #523: A text node whose content is a URL with a link mark
11717        // (href differs by trailing slash) must round-trip as text+link,
11718        // not become an inlineCard.
11719        let adf_json = r#"{
11720            "version": 1,
11721            "type": "doc",
11722            "content": [{
11723                "type": "paragraph",
11724                "content": [{
11725                    "type": "text",
11726                    "text": "https://octopz.example.com",
11727                    "marks": [{"type": "link", "attrs": {"href": "https://octopz.example.com/"}}]
11728                }]
11729            }]
11730        }"#;
11731        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
11732        let jfm = adf_to_markdown(&adf).unwrap();
11733        let roundtripped = markdown_to_adf(&jfm).unwrap();
11734        let content = roundtripped.content[0].content.as_ref().unwrap();
11735        assert_eq!(content.len(), 1, "should be a single node");
11736        assert_eq!(content[0].node_type, "text", "must be text, not inlineCard");
11737        assert_eq!(
11738            content[0].text.as_deref(),
11739            Some("https://octopz.example.com")
11740        );
11741        let mark = &content[0].marks.as_ref().unwrap()[0];
11742        assert_eq!(mark.mark_type, "link");
11743        assert_eq!(
11744            mark.attrs.as_ref().unwrap()["href"],
11745            "https://octopz.example.com/"
11746        );
11747    }
11748
11749    #[test]
11750    fn url_text_with_exact_link_mark_round_trips() {
11751        // Variant: text and href are identical (no trailing slash difference).
11752        let adf_json = r#"{
11753            "version": 1,
11754            "type": "doc",
11755            "content": [{
11756                "type": "paragraph",
11757                "content": [{
11758                    "type": "text",
11759                    "text": "https://example.com/path",
11760                    "marks": [{"type": "link", "attrs": {"href": "https://example.com/path"}}]
11761                }]
11762            }]
11763        }"#;
11764        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
11765        let jfm = adf_to_markdown(&adf).unwrap();
11766        let roundtripped = markdown_to_adf(&jfm).unwrap();
11767        let content = roundtripped.content[0].content.as_ref().unwrap();
11768        assert_eq!(content.len(), 1, "should be a single node");
11769        assert_eq!(content[0].node_type, "text");
11770        assert_eq!(content[0].text.as_deref(), Some("https://example.com/path"));
11771        let mark = &content[0].marks.as_ref().unwrap()[0];
11772        assert_eq!(mark.mark_type, "link");
11773    }
11774
11775    #[test]
11776    fn plain_text_url_amid_text_round_trips() {
11777        // URL embedded in surrounding text, without link mark.
11778        let adf_json = r#"{
11779            "version": 1,
11780            "type": "doc",
11781            "content": [{
11782                "type": "paragraph",
11783                "content": [
11784                    {"type": "text", "text": "see https://example.com for info"}
11785                ]
11786            }]
11787        }"#;
11788        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
11789        let jfm = adf_to_markdown(&adf).unwrap();
11790        let roundtripped = markdown_to_adf(&jfm).unwrap();
11791        let content = roundtripped.content[0].content.as_ref().unwrap();
11792        assert_eq!(content.len(), 1);
11793        assert_eq!(content[0].node_type, "text");
11794        assert_eq!(
11795            content[0].text.as_deref(),
11796            Some("see https://example.com for info")
11797        );
11798    }
11799
11800    #[test]
11801    fn plain_text_multiple_urls_round_trips() {
11802        let adf_json = r#"{
11803            "version": 1,
11804            "type": "doc",
11805            "content": [{
11806                "type": "paragraph",
11807                "content": [
11808                    {"type": "text", "text": "http://a.com and https://b.com"}
11809                ]
11810            }]
11811        }"#;
11812        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
11813        let jfm = adf_to_markdown(&adf).unwrap();
11814        let roundtripped = markdown_to_adf(&jfm).unwrap();
11815        let content = roundtripped.content[0].content.as_ref().unwrap();
11816        assert_eq!(content.len(), 1);
11817        assert_eq!(content[0].node_type, "text");
11818        assert_eq!(
11819            content[0].text.as_deref(),
11820            Some("http://a.com and https://b.com")
11821        );
11822    }
11823
11824    #[test]
11825    fn plain_text_http_prefix_no_url_unchanged() {
11826        // "http" without "://" should not be escaped or altered.
11827        let adf_json = r#"{
11828            "version": 1,
11829            "type": "doc",
11830            "content": [{
11831                "type": "paragraph",
11832                "content": [
11833                    {"type": "text", "text": "the http header is important"}
11834                ]
11835            }]
11836        }"#;
11837        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
11838        let jfm = adf_to_markdown(&adf).unwrap();
11839        let roundtripped = markdown_to_adf(&jfm).unwrap();
11840        let content = roundtripped.content[0].content.as_ref().unwrap();
11841        assert_eq!(
11842            content[0].text.as_deref(),
11843            Some("the http header is important")
11844        );
11845    }
11846
11847    #[test]
11848    fn linked_url_text_round_trips() {
11849        // A text node that is exactly a URL with a link mark pointing to the
11850        // same URL must round-trip as a single text node with a link mark
11851        // (no inlineCard, no lost/split content).
11852        let adf_json = r#"{
11853            "version": 1,
11854            "type": "doc",
11855            "content": [{
11856                "type": "paragraph",
11857                "content": [{
11858                    "type": "text",
11859                    "text": "https://example.com",
11860                    "marks": [{"type": "link", "attrs": {"href": "https://example.com"}}]
11861                }]
11862            }]
11863        }"#;
11864        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
11865        let jfm = adf_to_markdown(&adf).unwrap();
11866        let roundtripped = markdown_to_adf(&jfm).unwrap();
11867        let content = roundtripped.content[0].content.as_ref().unwrap();
11868        assert_eq!(content.len(), 1);
11869        assert_eq!(content[0].node_type, "text");
11870        assert_eq!(content[0].text.as_deref(), Some("https://example.com"));
11871        let mark = &content[0].marks.as_ref().unwrap()[0];
11872        assert_eq!(mark.mark_type, "link");
11873        assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
11874    }
11875
11876    // ── Issue #493: bracket-link ambiguity ─────────────────────────────
11877
11878    #[test]
11879    fn escape_link_brackets_unit() {
11880        assert_eq!(escape_link_brackets("hello"), "hello");
11881        assert_eq!(escape_link_brackets("["), "\\[");
11882        assert_eq!(escape_link_brackets("]"), "\\]");
11883        assert_eq!(escape_link_brackets("[PROJ-456]"), "\\[PROJ-456\\]");
11884        assert_eq!(escape_link_brackets("a[b]c"), "a\\[b\\]c");
11885    }
11886
11887    #[test]
11888    fn bracket_text_with_link_mark_escapes_brackets() {
11889        // A text node whose content is "[" with a link mark should escape
11890        // the bracket so it does not create ambiguous markdown link syntax.
11891        let doc = AdfDocument {
11892            version: 1,
11893            doc_type: "doc".to_string(),
11894            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11895                "[",
11896                vec![AdfMark::link("https://example.com")],
11897            )])],
11898        };
11899        let md = adf_to_markdown(&doc).unwrap();
11900        assert_eq!(md.trim(), "[\\[](https://example.com)");
11901    }
11902
11903    #[test]
11904    fn bracket_text_with_link_mark_round_trips() {
11905        // Issue #493 reproducer: adjacent text nodes sharing a link mark
11906        // where the first node's content is "[".
11907        let adf_json = r#"{
11908            "type": "doc",
11909            "version": 1,
11910            "content": [{
11911                "type": "paragraph",
11912                "content": [
11913                    {
11914                        "type": "text",
11915                        "text": "[",
11916                        "marks": [{"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}]
11917                    },
11918                    {
11919                        "type": "text",
11920                        "text": "PROJ-456] Fix the auth bug",
11921                        "marks": [
11922                            {"type": "underline"},
11923                            {"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}
11924                        ]
11925                    }
11926                ]
11927            }]
11928        }"#;
11929        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
11930        let jfm = adf_to_markdown(&adf).unwrap();
11931
11932        // The markdown should contain escaped brackets inside the link
11933        assert!(jfm.contains("\\["), "opening bracket should be escaped");
11934
11935        // Round-trip: both text nodes must survive with link marks
11936        let rt = markdown_to_adf(&jfm).unwrap();
11937        let content = rt.content[0].content.as_ref().unwrap();
11938
11939        // All text nodes that were part of the link must still carry a link mark
11940        let link_nodes: Vec<_> = content
11941            .iter()
11942            .filter(|n| {
11943                n.marks
11944                    .as_ref()
11945                    .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
11946            })
11947            .collect();
11948        assert!(
11949            !link_nodes.is_empty(),
11950            "link mark must be preserved on round-trip"
11951        );
11952
11953        // The combined text across all nodes should contain the original content
11954        let all_text: String = content.iter().filter_map(|n| n.text.as_deref()).collect();
11955        assert!(
11956            all_text.contains('['),
11957            "literal '[' must survive round-trip"
11958        );
11959        assert!(
11960            all_text.contains("PROJ-456]"),
11961            "continuation text must survive round-trip"
11962        );
11963    }
11964
11965    #[test]
11966    fn closing_bracket_in_link_text_round_trips() {
11967        // A text node containing "]" inside a link should be escaped and
11968        // survive round-trip without breaking the link syntax.
11969        let doc = AdfDocument {
11970            version: 1,
11971            doc_type: "doc".to_string(),
11972            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11973                "item]",
11974                vec![AdfMark::link("https://example.com")],
11975            )])],
11976        };
11977        let md = adf_to_markdown(&doc).unwrap();
11978        assert_eq!(md.trim(), "[item\\]](https://example.com)");
11979
11980        let rt = markdown_to_adf(&md).unwrap();
11981        let content = rt.content[0].content.as_ref().unwrap();
11982        assert_eq!(content[0].text.as_deref(), Some("item]"));
11983        assert!(content[0]
11984            .marks
11985            .as_ref()
11986            .unwrap()
11987            .iter()
11988            .any(|m| m.mark_type == "link"));
11989    }
11990
11991    #[test]
11992    fn brackets_in_link_text_round_trip() {
11993        // Text containing both [ and ] inside a link should round-trip.
11994        let doc = AdfDocument {
11995            version: 1,
11996            doc_type: "doc".to_string(),
11997            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11998                "[PROJ-123]",
11999                vec![AdfMark::link("https://example.com")],
12000            )])],
12001        };
12002        let md = adf_to_markdown(&doc).unwrap();
12003        assert_eq!(md.trim(), "[\\[PROJ-123\\]](https://example.com)");
12004
12005        let rt = markdown_to_adf(&md).unwrap();
12006        let content = rt.content[0].content.as_ref().unwrap();
12007        assert_eq!(content[0].text.as_deref(), Some("[PROJ-123]"));
12008        assert!(content[0]
12009            .marks
12010            .as_ref()
12011            .unwrap()
12012            .iter()
12013            .any(|m| m.mark_type == "link"));
12014    }
12015
12016    #[test]
12017    fn plain_text_brackets_not_escaped() {
12018        // Brackets in plain text (no link mark) must NOT be escaped.
12019        let doc = AdfDocument {
12020            version: 1,
12021            doc_type: "doc".to_string(),
12022            content: vec![AdfNode::paragraph(vec![AdfNode::text(
12023                "see [PROJ-123] for details",
12024            )])],
12025        };
12026        let md = adf_to_markdown(&doc).unwrap();
12027        assert_eq!(md.trim(), "see [PROJ-123] for details");
12028    }
12029
12030    #[test]
12031    fn link_with_no_brackets_unchanged() {
12032        // A normal link with no brackets in the text should be unaffected.
12033        let doc = AdfDocument {
12034            version: 1,
12035            doc_type: "doc".to_string(),
12036            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12037                "click here",
12038                vec![AdfMark::link("https://example.com")],
12039            )])],
12040        };
12041        let md = adf_to_markdown(&doc).unwrap();
12042        assert_eq!(md.trim(), "[click here](https://example.com)");
12043    }
12044
12045    // ── Issue #551: URL brackets in link-marked text round-trip ────────
12046
12047    #[test]
12048    fn url_with_brackets_as_link_text_round_trips() {
12049        // Issue #551: a text node whose content is a URL containing square
12050        // brackets and which carries a link mark must round-trip verbatim.
12051        // Previously the URL-as-link-text fast path preserved `\[` and `\]`
12052        // escapes in the emitted text, corrupting the text content.
12053        let href = "https://example.com/dashboard?filter[0]=active&filter[1]=pending";
12054        let doc = AdfDocument {
12055            version: 1,
12056            doc_type: "doc".to_string(),
12057            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12058                href,
12059                vec![AdfMark::link(href)],
12060            )])],
12061        };
12062        let md = adf_to_markdown(&doc).unwrap();
12063        let rt = markdown_to_adf(&md).unwrap();
12064        let content = rt.content[0].content.as_ref().unwrap();
12065        assert_eq!(content.len(), 1);
12066        assert_eq!(content[0].node_type, "text");
12067        assert_eq!(content[0].text.as_deref(), Some(href));
12068        let mark = &content[0].marks.as_ref().unwrap()[0];
12069        assert_eq!(mark.mark_type, "link");
12070        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
12071    }
12072
12073    #[test]
12074    fn url_with_brackets_embedded_in_link_text_round_trips() {
12075        // Issue #551 updated reproducer: a link-marked text node containing
12076        // both prose and an embedded URL with brackets must round-trip
12077        // without the embedded URL being detected as a bare-URL inlineCard
12078        // or the brackets terminating the link syntax early.  This mirrors
12079        // the comment reproducer which uses an ellipsis character between
12080        // the brackets and a distinct href value.
12081        let href = "https://example.com/logs?query=service%20environment%20data&from=100&to=200";
12082        let text =
12083            "See the logs: https://example.com/logs?query=service[\u{2026}]data&from=100&to=200";
12084        let doc = AdfDocument {
12085            version: 1,
12086            doc_type: "doc".to_string(),
12087            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12088                text,
12089                vec![AdfMark::link(href)],
12090            )])],
12091        };
12092        let md = adf_to_markdown(&doc).unwrap();
12093        let rt = markdown_to_adf(&md).unwrap();
12094        let content = rt.content[0].content.as_ref().unwrap();
12095        assert_eq!(content.len(), 1, "content split unexpectedly: {content:?}");
12096        assert_eq!(content[0].node_type, "text");
12097        assert_eq!(content[0].text.as_deref(), Some(text));
12098        let mark = &content[0].marks.as_ref().unwrap()[0];
12099        assert_eq!(mark.mark_type, "link");
12100        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
12101    }
12102
12103    #[test]
12104    fn url_with_brackets_plain_text_round_trips() {
12105        // Issue #551 original reproducer: plain text with an embedded URL
12106        // that contains square brackets must round-trip verbatim.
12107        let text =
12108            "See the dashboard: https://example.com/dashboard?filter[0]=active&filter[1]=pending";
12109        let doc = AdfDocument {
12110            version: 1,
12111            doc_type: "doc".to_string(),
12112            content: vec![AdfNode::paragraph(vec![AdfNode::text(text)])],
12113        };
12114        let md = adf_to_markdown(&doc).unwrap();
12115        let rt = markdown_to_adf(&md).unwrap();
12116        let content = rt.content[0].content.as_ref().unwrap();
12117        assert_eq!(content.len(), 1);
12118        assert_eq!(content[0].node_type, "text");
12119        assert_eq!(content[0].text.as_deref(), Some(text));
12120        assert!(content[0].marks.is_none());
12121    }
12122
12123    #[test]
12124    fn url_with_link_mark_embedded_no_brackets_round_trips() {
12125        // Regression guard: embedding a bare URL inside link-marked text
12126        // (no brackets) must not create an inlineCard on round-trip.
12127        let href = "https://example.com/";
12128        let text = "See https://example.com/ for more";
12129        let doc = AdfDocument {
12130            version: 1,
12131            doc_type: "doc".to_string(),
12132            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12133                text,
12134                vec![AdfMark::link(href)],
12135            )])],
12136        };
12137        let md = adf_to_markdown(&doc).unwrap();
12138        let rt = markdown_to_adf(&md).unwrap();
12139        let content = rt.content[0].content.as_ref().unwrap();
12140        assert_eq!(content.len(), 1);
12141        assert_eq!(content[0].node_type, "text");
12142        assert_eq!(content[0].text.as_deref(), Some(text));
12143        let mark = &content[0].marks.as_ref().unwrap()[0];
12144        assert_eq!(mark.mark_type, "link");
12145        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
12146    }
12147
12148    #[test]
12149    fn nested_brackets_in_link_text_round_trip() {
12150        // Regression guard: nested brackets in link-marked text must
12151        // round-trip without corrupting the content.
12152        let href = "https://x.com";
12153        let text = "foo [a[b]c] bar";
12154        let doc = AdfDocument {
12155            version: 1,
12156            doc_type: "doc".to_string(),
12157            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12158                text,
12159                vec![AdfMark::link(href)],
12160            )])],
12161        };
12162        let md = adf_to_markdown(&doc).unwrap();
12163        let rt = markdown_to_adf(&md).unwrap();
12164        let content = rt.content[0].content.as_ref().unwrap();
12165        assert_eq!(content.len(), 1);
12166        assert_eq!(content[0].node_type, "text");
12167        assert_eq!(content[0].text.as_deref(), Some(text));
12168    }
12169
12170    #[test]
12171    fn bracket_url_bracket_in_link_text_round_trips() {
12172        // Regression guard: a link-marked text containing brackets on both
12173        // sides of an embedded URL (with brackets of its own) must
12174        // round-trip intact.  This exercises interaction between the
12175        // URL-as-link-text fast path, bare-URL detection, and bracket
12176        // escape handling.
12177        let href = "https://y.com";
12178        let text = "[see https://x.com/a[0]=1 here]";
12179        let doc = AdfDocument {
12180            version: 1,
12181            doc_type: "doc".to_string(),
12182            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12183                text,
12184                vec![AdfMark::link(href)],
12185            )])],
12186        };
12187        let md = adf_to_markdown(&doc).unwrap();
12188        let rt = markdown_to_adf(&md).unwrap();
12189        let content = rt.content[0].content.as_ref().unwrap();
12190        assert_eq!(content.len(), 1);
12191        assert_eq!(content[0].node_type, "text");
12192        assert_eq!(content[0].text.as_deref(), Some(text));
12193        let mark = &content[0].marks.as_ref().unwrap()[0];
12194        assert_eq!(mark.mark_type, "link");
12195        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
12196    }
12197
12198    #[test]
12199    fn escape_bare_urls_applied_inside_link_text() {
12200        // White-box: when a text node carries a link mark, bare URLs in the
12201        // text must still be escaped with `\h` so the parser does not
12202        // auto-link them into an inlineCard inside the link.  Without this,
12203        // round-trip of link-marked prose containing an embedded URL
12204        // silently corrupts on re-parse (issue #551).
12205        let doc = AdfDocument {
12206            version: 1,
12207            doc_type: "doc".to_string(),
12208            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12209                "See https://example.com/",
12210                vec![AdfMark::link("https://target.example.com/")],
12211            )])],
12212        };
12213        let md = adf_to_markdown(&doc).unwrap();
12214        assert!(
12215            md.contains(r"\https://example.com/"),
12216            "bare URL inside link text must be escaped, got: {md}"
12217        );
12218    }
12219
12220    #[test]
12221    fn inline_card_still_round_trips() {
12222        // An actual inlineCard node should still round-trip correctly
12223        // (it uses :card[url] syntax, not bare URL).
12224        let adf_json = r#"{
12225            "version": 1,
12226            "type": "doc",
12227            "content": [{
12228                "type": "paragraph",
12229                "content": [
12230                    {"type": "inlineCard", "attrs": {"url": "https://example.com/page"}}
12231                ]
12232            }]
12233        }"#;
12234        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
12235        let jfm = adf_to_markdown(&adf).unwrap();
12236        assert!(jfm.contains(":card[https://example.com/page]"));
12237        let roundtripped = markdown_to_adf(&jfm).unwrap();
12238        let content = roundtripped.content[0].content.as_ref().unwrap();
12239        assert_eq!(content[0].node_type, "inlineCard");
12240        assert_eq!(
12241            content[0].attrs.as_ref().unwrap()["url"],
12242            "https://example.com/page"
12243        );
12244    }
12245
12246    // ── Issue #553: inlineCard round-trip with problematic URLs ───────
12247
12248    #[test]
12249    fn url_safe_in_bracket_content_balanced() {
12250        // Balanced brackets — depth never returns to zero mid-string.
12251        assert!(url_safe_in_bracket_content("https://example.com"));
12252        assert!(url_safe_in_bracket_content("https://example.com/[id]"));
12253        assert!(url_safe_in_bracket_content("a[b[c]d]e"));
12254        assert!(url_safe_in_bracket_content(""));
12255    }
12256
12257    #[test]
12258    fn url_safe_in_bracket_content_unbalanced() {
12259        // A `]` with no prior `[` would close `:card[...]` early.
12260        assert!(!url_safe_in_bracket_content("a]b"));
12261        assert!(!url_safe_in_bracket_content("https://example.com/path]end"));
12262        // Embedded newline breaks inline directive parsing.
12263        assert!(!url_safe_in_bracket_content("a\nb"));
12264    }
12265
12266    #[test]
12267    fn inline_card_url_with_closing_bracket_round_trip() {
12268        // Issue #553 defensive fix: a URL that contains `]` (unbalanced) must
12269        // round-trip without truncation.  The renderer must switch to the
12270        // quoted attribute form `:card[]{url="..."}` so the parser's
12271        // depth-based bracket matcher does not terminate the directive early.
12272        let adf_json = r#"{
12273            "version": 1,
12274            "type": "doc",
12275            "content": [{
12276                "type": "paragraph",
12277                "content": [
12278                    {"type": "text", "text": "See: "},
12279                    {"type": "inlineCard", "attrs": {"url": "https://example.com/path]end/?q=1"}}
12280                ]
12281            }]
12282        }"#;
12283        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
12284        let jfm = adf_to_markdown(&adf).unwrap();
12285        assert!(
12286            jfm.contains(r#":card[]{url="https://example.com/path]end/?q=1"}"#),
12287            "expected attr-form for URL with `]`, got: {jfm}"
12288        );
12289        let rt = markdown_to_adf(&jfm).unwrap();
12290        let content = rt.content[0].content.as_ref().unwrap();
12291        assert_eq!(content.len(), 2, "expected 2 inline nodes, got {content:?}");
12292        assert_eq!(content[0].node_type, "text");
12293        assert_eq!(content[0].text.as_deref(), Some("See: "));
12294        assert_eq!(content[1].node_type, "inlineCard");
12295        assert_eq!(
12296            content[1].attrs.as_ref().unwrap()["url"],
12297            "https://example.com/path]end/?q=1"
12298        );
12299    }
12300
12301    #[test]
12302    fn inline_card_url_with_closing_bracket_preserves_local_id() {
12303        // Attr-form `:card[]{url=... localId=...}` must preserve localId too.
12304        let adf_json = r#"{
12305            "version": 1,
12306            "type": "doc",
12307            "content": [{
12308                "type": "paragraph",
12309                "content": [
12310                    {"type": "inlineCard", "attrs": {
12311                        "url": "https://example.com/a]b",
12312                        "localId": "c-77"
12313                    }}
12314                ]
12315            }]
12316        }"#;
12317        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
12318        let jfm = adf_to_markdown(&adf).unwrap();
12319        assert!(
12320            jfm.contains(r#"url="https://example.com/a]b""#),
12321            "jfm: {jfm}"
12322        );
12323        assert!(jfm.contains("localId=c-77"), "jfm: {jfm}");
12324        let rt = markdown_to_adf(&jfm).unwrap();
12325        let card = &rt.content[0].content.as_ref().unwrap()[0];
12326        assert_eq!(card.node_type, "inlineCard");
12327        assert_eq!(
12328            card.attrs.as_ref().unwrap()["url"],
12329            "https://example.com/a]b"
12330        );
12331        assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-77");
12332    }
12333
12334    #[test]
12335    fn block_card_url_with_closing_bracket_round_trip() {
12336        // Same defensive fix applied to the leaf directive `::card`.
12337        let adf_json = r#"{
12338            "version": 1,
12339            "type": "doc",
12340            "content": [
12341                {"type": "blockCard", "attrs": {"url": "https://example.com/path]end"}}
12342            ]
12343        }"#;
12344        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
12345        let jfm = adf_to_markdown(&adf).unwrap();
12346        assert!(
12347            jfm.contains(r#"::card[]{url="https://example.com/path]end"}"#),
12348            "expected attr-form for blockCard with `]`, got: {jfm}"
12349        );
12350        let rt = markdown_to_adf(&jfm).unwrap();
12351        assert_eq!(rt.content[0].node_type, "blockCard");
12352        assert_eq!(
12353            rt.content[0].attrs.as_ref().unwrap()["url"],
12354            "https://example.com/path]end"
12355        );
12356    }
12357
12358    #[test]
12359    fn block_card_attr_form_parses_without_renderer() {
12360        // Directly parsing `::card[]{url="..."}` exercises the attr-URL
12361        // fallback in the leaf-directive dispatcher (covers the `url` lookup
12362        // path independently of the ADF→JFM renderer).
12363        let doc = markdown_to_adf(r#"::card[]{url="https://example.com/a"}"#).unwrap();
12364        assert_eq!(doc.content[0].node_type, "blockCard");
12365        assert_eq!(
12366            doc.content[0].attrs.as_ref().unwrap()["url"],
12367            "https://example.com/a"
12368        );
12369    }
12370
12371    #[test]
12372    fn block_card_attr_form_url_overrides_content() {
12373        // When both bracket-content and `url=` attribute are present on
12374        // `::card`, the attribute wins.  Mirrors the inline-directive
12375        // behaviour and keeps hand-edited JFM forgiving.
12376        let doc =
12377            markdown_to_adf(r#"::card[https://old.example.com]{url="https://new.example.com"}"#)
12378                .unwrap();
12379        assert_eq!(doc.content[0].node_type, "blockCard");
12380        assert_eq!(
12381            doc.content[0].attrs.as_ref().unwrap()["url"],
12382            "https://new.example.com"
12383        );
12384    }
12385
12386    #[test]
12387    fn block_card_attr_form_with_layout_and_width() {
12388        // Attr-URL form combined with layout/width attrs — ensures all
12389        // sibling attrs still pass through after the URL lookup.
12390        let doc =
12391            markdown_to_adf(r#"::card[]{url="https://example.com/a]b" layout=wide width=80}"#)
12392                .unwrap();
12393        let attrs = doc.content[0].attrs.as_ref().unwrap();
12394        assert_eq!(attrs["url"], "https://example.com/a]b");
12395        assert_eq!(attrs["layout"], "wide");
12396        assert_eq!(attrs["width"], 80);
12397    }
12398
12399    #[test]
12400    fn inline_card_issue_553_reproducer() {
12401        // Verbatim reproducer from issue #553: an inlineCard in a paragraph
12402        // with preceding text must round-trip as an inlineCard, not degrade to
12403        // a text node with a link mark.
12404        let adf_json = r#"{
12405            "version": 1,
12406            "type": "doc",
12407            "content": [{
12408                "type": "paragraph",
12409                "content": [
12410                    {"type": "text", "text": "See the related page: "},
12411                    {"type": "inlineCard", "attrs": {
12412                        "url": "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
12413                    }}
12414                ]
12415            }]
12416        }"#;
12417        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
12418        let jfm = adf_to_markdown(&adf).unwrap();
12419        let rt = markdown_to_adf(&jfm).unwrap();
12420        let content = rt.content[0].content.as_ref().unwrap();
12421        assert_eq!(content.len(), 2);
12422        assert_eq!(content[0].node_type, "text");
12423        assert_eq!(content[1].node_type, "inlineCard");
12424        assert_eq!(
12425            content[1].attrs.as_ref().unwrap()["url"],
12426            "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
12427        );
12428    }
12429
12430    #[test]
12431    fn inline_card_attr_form_parses_even_without_renderer() {
12432        // Directly parsing `:card[]{url="..."}` should yield an inlineCard.
12433        let doc = markdown_to_adf(r#":card[]{url="https://example.com/a"}"#).unwrap();
12434        let node = &doc.content[0].content.as_ref().unwrap()[0];
12435        assert_eq!(node.node_type, "inlineCard");
12436        assert_eq!(node.attrs.as_ref().unwrap()["url"], "https://example.com/a");
12437    }
12438
12439    #[test]
12440    fn inline_card_attr_form_url_overrides_content() {
12441        // When both bracket-content and `url=` attr are present, attr wins.
12442        // This keeps the parser forgiving of hand-edited JFM where a user
12443        // copied an old bracket form but added attrs.
12444        let doc =
12445            markdown_to_adf(r#":card[https://old.example.com]{url="https://new.example.com"}"#)
12446                .unwrap();
12447        let node = &doc.content[0].content.as_ref().unwrap()[0];
12448        assert_eq!(node.node_type, "inlineCard");
12449        assert_eq!(
12450            node.attrs.as_ref().unwrap()["url"],
12451            "https://new.example.com"
12452        );
12453    }
12454
12455    // ── Issue #553 (updated): mark-wrapped URL must not become inlineCard ──
12456
12457    #[test]
12458    fn url_with_link_and_underline_marks_round_trip() {
12459        // Issue #553 (updated reproducer): a `text` node whose content is a
12460        // URL and that carries both `link` and `underline` marks must round-
12461        // trip as text+marks, not be promoted to an `inlineCard`.
12462        let adf_json = r#"{
12463            "version": 1,
12464            "type": "doc",
12465            "content": [{
12466                "type": "paragraph",
12467                "content": [
12468                    {"type": "text", "text": "See results at: "},
12469                    {"type": "text",
12470                     "text": "https://example.com/projects/abc123/analytics",
12471                     "marks": [
12472                        {"type": "link", "attrs": {"href": "https://example.com/projects/abc123/analytics"}},
12473                        {"type": "underline"}
12474                     ]},
12475                    {"type": "text", "text": " for details."}
12476                ]
12477            }]
12478        }"#;
12479        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
12480        let jfm = adf_to_markdown(&adf).unwrap();
12481        let rt = markdown_to_adf(&jfm).unwrap();
12482        let content = rt.content[0].content.as_ref().unwrap();
12483        assert_eq!(
12484            content.len(),
12485            3,
12486            "expected 3 inline nodes, got: {content:?}"
12487        );
12488        assert_eq!(content[0].node_type, "text");
12489        assert_eq!(
12490            content[1].node_type, "text",
12491            "must stay text, not inlineCard"
12492        );
12493        assert_eq!(
12494            content[1].text.as_deref(),
12495            Some("https://example.com/projects/abc123/analytics")
12496        );
12497        let mark_types: Vec<&str> = content[1]
12498            .marks
12499            .as_deref()
12500            .unwrap_or(&[])
12501            .iter()
12502            .map(|m| m.mark_type.as_str())
12503            .collect();
12504        assert_eq!(mark_types, vec!["link", "underline"], "marks lost");
12505        assert_eq!(content[2].node_type, "text");
12506    }
12507
12508    #[test]
12509    fn url_inside_bracketed_span_stays_text() {
12510        // `[URL]{underline}` in JFM means "underline this URL text", not
12511        // "create a smart link that's underlined".  The nested parse_inline
12512        // call must not auto-promote the bare URL to an inlineCard.
12513        let doc = markdown_to_adf("[https://example.com]{underline}").unwrap();
12514        let node = &doc.content[0].content.as_ref().unwrap()[0];
12515        assert_eq!(node.node_type, "text");
12516        assert_eq!(node.text.as_deref(), Some("https://example.com"));
12517        let mark_types: Vec<&str> = node
12518            .marks
12519            .as_deref()
12520            .unwrap_or(&[])
12521            .iter()
12522            .map(|m| m.mark_type.as_str())
12523            .collect();
12524        assert_eq!(mark_types, vec!["underline"]);
12525    }
12526
12527    #[test]
12528    fn url_inside_emphasis_stays_text() {
12529        // Bold, italic, and strike-wrapped URLs should remain as text nodes,
12530        // not get promoted to inlineCards by the nested inline parser.
12531        for (md, mark) in [
12532            ("**https://example.com**", "strong"),
12533            ("*https://example.com*", "em"),
12534            ("~~https://example.com~~", "strike"),
12535        ] {
12536            let doc = markdown_to_adf(md).unwrap();
12537            let node = &doc.content[0].content.as_ref().unwrap()[0];
12538            assert_eq!(node.node_type, "text", "md={md}: must be text");
12539            assert_eq!(node.text.as_deref(), Some("https://example.com"));
12540            let mark_types: Vec<&str> = node
12541                .marks
12542                .as_deref()
12543                .unwrap_or(&[])
12544                .iter()
12545                .map(|m| m.mark_type.as_str())
12546                .collect();
12547            assert_eq!(mark_types, vec![mark], "md={md}: wrong marks");
12548        }
12549    }
12550
12551    #[test]
12552    fn url_inside_span_directive_stays_text() {
12553        // `:span[URL]{color=red}` should not promote the URL to an inlineCard.
12554        let doc = markdown_to_adf(":span[https://example.com]{color=red}").unwrap();
12555        let node = &doc.content[0].content.as_ref().unwrap()[0];
12556        assert_eq!(node.node_type, "text");
12557        assert_eq!(node.text.as_deref(), Some("https://example.com"));
12558        let mark = &node.marks.as_ref().unwrap()[0];
12559        assert_eq!(mark.mark_type, "textColor");
12560    }
12561
12562    #[test]
12563    fn url_as_link_text_with_underline_after_link_mark_order() {
12564        // Reverse mark order — underline appears BEFORE link in the ADF array.
12565        // The JFM form is `[[text](url)]{underline}`; the nested parser must
12566        // still keep the URL as plain text.
12567        let adf_json = r#"{
12568            "version": 1,
12569            "type": "doc",
12570            "content": [{
12571                "type": "paragraph",
12572                "content": [
12573                    {"type": "text",
12574                     "text": "https://example.com",
12575                     "marks": [
12576                        {"type": "underline"},
12577                        {"type": "link", "attrs": {"href": "https://example.com"}}
12578                     ]}
12579                ]
12580            }]
12581        }"#;
12582        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
12583        let jfm = adf_to_markdown(&adf).unwrap();
12584        let rt = markdown_to_adf(&jfm).unwrap();
12585        let node = &rt.content[0].content.as_ref().unwrap()[0];
12586        assert_eq!(node.node_type, "text", "must stay text, got: {node:?}");
12587        assert_eq!(node.text.as_deref(), Some("https://example.com"));
12588        let mark_types: Vec<&str> = node
12589            .marks
12590            .as_deref()
12591            .unwrap_or(&[])
12592            .iter()
12593            .map(|m| m.mark_type.as_str())
12594            .collect();
12595        assert_eq!(mark_types, vec!["underline", "link"]);
12596    }
12597
12598    #[test]
12599    fn bare_url_at_top_level_still_becomes_inline_card() {
12600        // Regression guard: the suppression only applies inside mark-wrapping
12601        // constructs.  A bare URL in ordinary paragraph text must still be
12602        // detected and promoted to an inlineCard.
12603        let doc = markdown_to_adf("Visit https://example.com today").unwrap();
12604        let content = doc.content[0].content.as_ref().unwrap();
12605        assert_eq!(content.len(), 3);
12606        assert_eq!(content[0].node_type, "text");
12607        assert_eq!(content[1].node_type, "inlineCard");
12608        assert_eq!(
12609            content[1].attrs.as_ref().unwrap()["url"],
12610            "https://example.com"
12611        );
12612        assert_eq!(content[2].node_type, "text");
12613    }
12614
12615    // ── Block-level attribute marks (Tier 5/6) ───────────────────────
12616
12617    #[test]
12618    fn paragraph_align_center() {
12619        let md = "Centered text.\n{align=center}";
12620        let doc = markdown_to_adf(md).unwrap();
12621        let marks = doc.content[0].marks.as_ref().unwrap();
12622        assert_eq!(marks[0].mark_type, "alignment");
12623        assert_eq!(marks[0].attrs.as_ref().unwrap()["align"], "center");
12624    }
12625
12626    #[test]
12627    fn adf_alignment_to_markdown() {
12628        let mut node = AdfNode::paragraph(vec![AdfNode::text("Centered.")]);
12629        node.marks = Some(vec![AdfMark::alignment("center")]);
12630        let doc = AdfDocument {
12631            version: 1,
12632            doc_type: "doc".to_string(),
12633            content: vec![node],
12634        };
12635        let md = adf_to_markdown(&doc).unwrap();
12636        assert!(md.contains("Centered."));
12637        assert!(md.contains("{align=center}"));
12638    }
12639
12640    #[test]
12641    fn round_trip_alignment() {
12642        let md = "Centered.\n{align=center}\n";
12643        let doc = markdown_to_adf(md).unwrap();
12644        let result = adf_to_markdown(&doc).unwrap();
12645        assert!(result.contains("{align=center}"));
12646    }
12647
12648    #[test]
12649    fn paragraph_indent() {
12650        let md = "Indented.\n{indent=2}";
12651        let doc = markdown_to_adf(md).unwrap();
12652        let marks = doc.content[0].marks.as_ref().unwrap();
12653        assert_eq!(marks[0].mark_type, "indentation");
12654        assert_eq!(marks[0].attrs.as_ref().unwrap()["level"], 2);
12655    }
12656
12657    #[test]
12658    fn code_block_breakout() {
12659        let md = "```python\ndef f(): pass\n```\n{breakout=wide}";
12660        let doc = markdown_to_adf(md).unwrap();
12661        let marks = doc.content[0].marks.as_ref().unwrap();
12662        assert_eq!(marks[0].mark_type, "breakout");
12663        assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
12664        assert!(marks[0].attrs.as_ref().unwrap().get("width").is_none());
12665    }
12666
12667    #[test]
12668    fn code_block_breakout_with_width() {
12669        let md = "```python\ndef f(): pass\n```\n{breakout=wide breakoutWidth=1200}";
12670        let doc = markdown_to_adf(md).unwrap();
12671        let marks = doc.content[0].marks.as_ref().unwrap();
12672        assert_eq!(marks[0].mark_type, "breakout");
12673        assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
12674        assert_eq!(marks[0].attrs.as_ref().unwrap()["width"], 1200);
12675    }
12676
12677    #[test]
12678    fn adf_breakout_to_markdown() {
12679        let mut node = AdfNode::code_block(Some("python"), "pass");
12680        node.marks = Some(vec![AdfMark::breakout("wide", None)]);
12681        let doc = AdfDocument {
12682            version: 1,
12683            doc_type: "doc".to_string(),
12684            content: vec![node],
12685        };
12686        let md = adf_to_markdown(&doc).unwrap();
12687        assert!(md.contains("{breakout=wide}"));
12688        assert!(!md.contains("breakoutWidth"));
12689    }
12690
12691    #[test]
12692    fn adf_breakout_with_width_to_markdown() {
12693        let mut node = AdfNode::code_block(Some("python"), "pass");
12694        node.marks = Some(vec![AdfMark::breakout("wide", Some(1200))]);
12695        let doc = AdfDocument {
12696            version: 1,
12697            doc_type: "doc".to_string(),
12698            content: vec![node],
12699        };
12700        let md = adf_to_markdown(&doc).unwrap();
12701        assert!(md.contains("breakout=wide"));
12702        assert!(md.contains("breakoutWidth=1200"));
12703    }
12704
12705    #[test]
12706    fn breakout_width_round_trip() {
12707        let adf_json = r#"{"version":1,"type":"doc","content":[{
12708            "type":"codeBlock",
12709            "attrs":{"language":"text"},
12710            "marks":[{"type":"breakout","attrs":{"mode":"wide","width":1200}}],
12711            "content":[{"type":"text","text":"some code"}]
12712        }]}"#;
12713        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12714        let md = adf_to_markdown(&doc).unwrap();
12715        assert!(md.contains("breakout=wide"));
12716        assert!(md.contains("breakoutWidth=1200"));
12717        let round_tripped = markdown_to_adf(&md).unwrap();
12718        let marks = round_tripped.content[0].marks.as_ref().unwrap();
12719        let breakout = marks.iter().find(|m| m.mark_type == "breakout").unwrap();
12720        assert_eq!(breakout.attrs.as_ref().unwrap()["mode"], "wide");
12721        assert_eq!(breakout.attrs.as_ref().unwrap()["width"], 1200);
12722    }
12723
12724    // ── Attribute extensions — media & table (Tier 5) ────────────────
12725
12726    #[test]
12727    fn image_with_layout_attrs() {
12728        let doc = markdown_to_adf("![alt](url){layout=wide width=80}").unwrap();
12729        let node = &doc.content[0];
12730        assert_eq!(node.node_type, "mediaSingle");
12731        let attrs = node.attrs.as_ref().unwrap();
12732        assert_eq!(attrs["layout"], "wide");
12733        assert_eq!(attrs["width"], 80);
12734    }
12735
12736    #[test]
12737    fn adf_image_with_layout_to_markdown() {
12738        let mut node = AdfNode::media_single("url", Some("alt"));
12739        node.attrs.as_mut().unwrap()["layout"] = serde_json::json!("wide");
12740        node.attrs.as_mut().unwrap()["width"] = serde_json::json!(80);
12741        let doc = AdfDocument {
12742            version: 1,
12743            doc_type: "doc".to_string(),
12744            content: vec![node],
12745        };
12746        let md = adf_to_markdown(&doc).unwrap();
12747        assert!(md.contains("![alt](url){layout=wide width=80}"));
12748    }
12749
12750    #[test]
12751    fn table_with_layout_attrs() {
12752        let md = "| H |\n| --- |\n| C |\n{layout=wide numbered}";
12753        let doc = markdown_to_adf(md).unwrap();
12754        let table = &doc.content[0];
12755        assert_eq!(table.node_type, "table");
12756        let attrs = table.attrs.as_ref().unwrap();
12757        assert_eq!(attrs["layout"], "wide");
12758        assert_eq!(attrs["isNumberColumnEnabled"], true);
12759    }
12760
12761    #[test]
12762    fn adf_table_with_attrs_to_markdown() {
12763        let mut table = AdfNode::table(vec![
12764            AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
12765                AdfNode::text("H"),
12766            ])])]),
12767            AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
12768                AdfNode::text("C"),
12769            ])])]),
12770        ]);
12771        table.attrs = Some(serde_json::json!({"layout": "wide", "isNumberColumnEnabled": true}));
12772        let doc = AdfDocument {
12773            version: 1,
12774            doc_type: "doc".to_string(),
12775            content: vec![table],
12776        };
12777        let md = adf_to_markdown(&doc).unwrap();
12778        assert!(md.contains("{layout=wide numbered}"));
12779    }
12780
12781    // ── Attribute extensions — inline marks (Tier 5) ─────────────────
12782
12783    #[test]
12784    fn underline_bracketed_span() {
12785        let doc = markdown_to_adf("This is [underlined text]{underline} here.").unwrap();
12786        let content = doc.content[0].content.as_ref().unwrap();
12787        assert_eq!(content[1].text.as_deref(), Some("underlined text"));
12788        let marks = content[1].marks.as_ref().unwrap();
12789        assert_eq!(marks[0].mark_type, "underline");
12790    }
12791
12792    #[test]
12793    fn adf_underline_to_markdown() {
12794        let doc = AdfDocument {
12795            version: 1,
12796            doc_type: "doc".to_string(),
12797            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12798                "underlined",
12799                vec![AdfMark::underline()],
12800            )])],
12801        };
12802        let md = adf_to_markdown(&doc).unwrap();
12803        assert!(md.contains("[underlined]{underline}"));
12804    }
12805
12806    #[test]
12807    fn round_trip_underline() {
12808        let md = "This is [underlined text]{underline} here.\n";
12809        let doc = markdown_to_adf(md).unwrap();
12810        let result = adf_to_markdown(&doc).unwrap();
12811        assert!(result.contains("[underlined text]{underline}"));
12812    }
12813
12814    #[test]
12815    fn mark_ordering_underline_strong_preserved() {
12816        // Issue #383: mark ordering was non-deterministic
12817        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12818          {"type":"text","text":"bold and underlined","marks":[{"type":"underline"},{"type":"strong"}]}
12819        ]}]}"#;
12820        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12821        let md = adf_to_markdown(&doc).unwrap();
12822        let round_tripped = markdown_to_adf(&md).unwrap();
12823        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12824        let mark_types: Vec<&str> = node
12825            .marks
12826            .as_ref()
12827            .unwrap()
12828            .iter()
12829            .map(|m| m.mark_type.as_str())
12830            .collect();
12831        assert_eq!(
12832            mark_types,
12833            vec!["underline", "strong"],
12834            "mark order should be preserved, got: {mark_types:?}"
12835        );
12836    }
12837
12838    #[test]
12839    fn mark_ordering_link_strong_preserved() {
12840        // Issue #403: link+strong mark order was swapped on round-trip
12841        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12842          {"type":"text","text":"bold link","marks":[
12843            {"type":"link","attrs":{"href":"https://example.com"}},
12844            {"type":"strong"}
12845          ]}
12846        ]}]}"#;
12847        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12848        let md = adf_to_markdown(&doc).unwrap();
12849        let round_tripped = markdown_to_adf(&md).unwrap();
12850        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12851        let mark_types: Vec<&str> = node
12852            .marks
12853            .as_ref()
12854            .unwrap()
12855            .iter()
12856            .map(|m| m.mark_type.as_str())
12857            .collect();
12858        assert_eq!(
12859            mark_types,
12860            vec!["link", "strong"],
12861            "mark order should be preserved, got: {mark_types:?}"
12862        );
12863    }
12864
12865    #[test]
12866    fn mark_ordering_link_textcolor_preserved() {
12867        // Issue #403 comment: link+textColor mark order was swapped on round-trip
12868        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12869          {"type":"text","text":"red link","marks":[
12870            {"type":"link","attrs":{"href":"https://example.com"}},
12871            {"type":"textColor","attrs":{"color":"#ff0000"}}
12872          ]}
12873        ]}]}"##;
12874        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12875        let md = adf_to_markdown(&doc).unwrap();
12876        let round_tripped = markdown_to_adf(&md).unwrap();
12877        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12878        let mark_types: Vec<&str> = node
12879            .marks
12880            .as_ref()
12881            .unwrap()
12882            .iter()
12883            .map(|m| m.mark_type.as_str())
12884            .collect();
12885        assert_eq!(
12886            mark_types,
12887            vec!["link", "textColor"],
12888            "mark order should be preserved, got: {mark_types:?}"
12889        );
12890    }
12891
12892    #[test]
12893    fn mark_ordering_link_em_preserved() {
12894        // Issue #403: link+em mark order should be preserved
12895        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12896          {"type":"text","text":"italic link","marks":[
12897            {"type":"link","attrs":{"href":"https://example.com"}},
12898            {"type":"em"}
12899          ]}
12900        ]}]}"#;
12901        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12902        let md = adf_to_markdown(&doc).unwrap();
12903        let round_tripped = markdown_to_adf(&md).unwrap();
12904        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12905        let mark_types: Vec<&str> = node
12906            .marks
12907            .as_ref()
12908            .unwrap()
12909            .iter()
12910            .map(|m| m.mark_type.as_str())
12911            .collect();
12912        assert_eq!(
12913            mark_types,
12914            vec!["link", "em"],
12915            "mark order should be preserved, got: {mark_types:?}"
12916        );
12917    }
12918
12919    #[test]
12920    fn mark_ordering_link_strike_preserved() {
12921        // Issue #403: link+strike mark order should be preserved
12922        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12923          {"type":"text","text":"struck link","marks":[
12924            {"type":"link","attrs":{"href":"https://example.com"}},
12925            {"type":"strike"}
12926          ]}
12927        ]}]}"#;
12928        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12929        let md = adf_to_markdown(&doc).unwrap();
12930        let round_tripped = markdown_to_adf(&md).unwrap();
12931        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12932        let mark_types: Vec<&str> = node
12933            .marks
12934            .as_ref()
12935            .unwrap()
12936            .iter()
12937            .map(|m| m.mark_type.as_str())
12938            .collect();
12939        assert_eq!(
12940            mark_types,
12941            vec!["link", "strike"],
12942            "mark order should be preserved, got: {mark_types:?}"
12943        );
12944    }
12945
12946    #[test]
12947    fn mark_ordering_strong_link_preserved() {
12948        // Issue #403: [strong, link] order must also be preserved (reverse direction)
12949        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12950          {"type":"text","text":"bold link","marks":[
12951            {"type":"strong"},
12952            {"type":"link","attrs":{"href":"https://example.com"}}
12953          ]}
12954        ]}]}"#;
12955        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12956        let md = adf_to_markdown(&doc).unwrap();
12957        let round_tripped = markdown_to_adf(&md).unwrap();
12958        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12959        let mark_types: Vec<&str> = node
12960            .marks
12961            .as_ref()
12962            .unwrap()
12963            .iter()
12964            .map(|m| m.mark_type.as_str())
12965            .collect();
12966        assert_eq!(
12967            mark_types,
12968            vec!["strong", "link"],
12969            "mark order should be preserved, got: {mark_types:?}"
12970        );
12971    }
12972
12973    #[test]
12974    fn mark_ordering_em_link_preserved() {
12975        // Issue #403: [em, link] order must also be preserved
12976        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12977          {"type":"text","text":"italic link","marks":[
12978            {"type":"em"},
12979            {"type":"link","attrs":{"href":"https://example.com"}}
12980          ]}
12981        ]}]}"#;
12982        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12983        let md = adf_to_markdown(&doc).unwrap();
12984        let round_tripped = markdown_to_adf(&md).unwrap();
12985        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12986        let mark_types: Vec<&str> = node
12987            .marks
12988            .as_ref()
12989            .unwrap()
12990            .iter()
12991            .map(|m| m.mark_type.as_str())
12992            .collect();
12993        assert_eq!(
12994            mark_types,
12995            vec!["em", "link"],
12996            "mark order should be preserved, got: {mark_types:?}"
12997        );
12998    }
12999
13000    #[test]
13001    fn mark_ordering_strike_link_preserved() {
13002        // Issue #403: [strike, link] order must also be preserved
13003        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13004          {"type":"text","text":"struck link","marks":[
13005            {"type":"strike"},
13006            {"type":"link","attrs":{"href":"https://example.com"}}
13007          ]}
13008        ]}]}"#;
13009        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13010        let md = adf_to_markdown(&doc).unwrap();
13011        let round_tripped = markdown_to_adf(&md).unwrap();
13012        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13013        let mark_types: Vec<&str> = node
13014            .marks
13015            .as_ref()
13016            .unwrap()
13017            .iter()
13018            .map(|m| m.mark_type.as_str())
13019            .collect();
13020        assert_eq!(
13021            mark_types,
13022            vec!["strike", "link"],
13023            "mark order should be preserved, got: {mark_types:?}"
13024        );
13025    }
13026
13027    #[test]
13028    fn mark_ordering_underline_link_preserved() {
13029        // Issue #403: [underline, link] order must be preserved
13030        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13031          {"type":"text","text":"click here","marks":[
13032            {"type":"underline"},
13033            {"type":"link","attrs":{"href":"https://example.com"}}
13034          ]}
13035        ]}]}"#;
13036        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13037        let md = adf_to_markdown(&doc).unwrap();
13038        let round_tripped = markdown_to_adf(&md).unwrap();
13039        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13040        let mark_types: Vec<&str> = node
13041            .marks
13042            .as_ref()
13043            .unwrap()
13044            .iter()
13045            .map(|m| m.mark_type.as_str())
13046            .collect();
13047        assert_eq!(
13048            mark_types,
13049            vec!["underline", "link"],
13050            "mark order should be preserved, got: {mark_types:?}"
13051        );
13052    }
13053
13054    #[test]
13055    fn mark_ordering_textcolor_link_preserved() {
13056        // Issue #403: [textColor, link] order must be preserved
13057        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13058          {"type":"text","text":"red link","marks":[
13059            {"type":"textColor","attrs":{"color":"#ff0000"}},
13060            {"type":"link","attrs":{"href":"https://example.com"}}
13061          ]}
13062        ]}]}"##;
13063        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13064        let md = adf_to_markdown(&doc).unwrap();
13065        let round_tripped = markdown_to_adf(&md).unwrap();
13066        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13067        let mark_types: Vec<&str> = node
13068            .marks
13069            .as_ref()
13070            .unwrap()
13071            .iter()
13072            .map(|m| m.mark_type.as_str())
13073            .collect();
13074        assert_eq!(
13075            mark_types,
13076            vec!["textColor", "link"],
13077            "mark order should be preserved, got: {mark_types:?}"
13078        );
13079    }
13080
13081    #[test]
13082    fn mark_ordering_link_underline_preserved() {
13083        // Issue #403: [link, underline] order must be preserved (link wraps bracketed span)
13084        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13085          {"type":"text","text":"click here","marks":[
13086            {"type":"link","attrs":{"href":"https://example.com"}},
13087            {"type":"underline"}
13088          ]}
13089        ]}]}"#;
13090        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13091        let md = adf_to_markdown(&doc).unwrap();
13092        // Link should wrap the underline bracketed span: [[click here]{underline}](url)
13093        assert!(
13094            md.contains("](https://example.com)"),
13095            "should have link: {md}"
13096        );
13097        assert!(md.contains("underline"), "should have underline: {md}");
13098        let round_tripped = markdown_to_adf(&md).unwrap();
13099        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13100        let mark_types: Vec<&str> = node
13101            .marks
13102            .as_ref()
13103            .unwrap()
13104            .iter()
13105            .map(|m| m.mark_type.as_str())
13106            .collect();
13107        assert_eq!(
13108            mark_types,
13109            vec!["link", "underline"],
13110            "mark order should be preserved, got: {mark_types:?}"
13111        );
13112    }
13113
13114    #[test]
13115    fn mark_ordering_underline_strong_link_preserved() {
13116        // Issue #491: [underline, strong, link] reordered to [strong, underline, link]
13117        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13118          {"type":"text","text":"bold underlined link","marks":[
13119            {"type":"underline"},
13120            {"type":"strong"},
13121            {"type":"link","attrs":{"href":"https://example.com/page"}}
13122          ]}
13123        ]}]}"#;
13124        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13125        let md = adf_to_markdown(&doc).unwrap();
13126        let round_tripped = markdown_to_adf(&md).unwrap();
13127        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13128        let mark_types: Vec<&str> = node
13129            .marks
13130            .as_ref()
13131            .unwrap()
13132            .iter()
13133            .map(|m| m.mark_type.as_str())
13134            .collect();
13135        assert_eq!(
13136            mark_types,
13137            vec!["underline", "strong", "link"],
13138            "mark order should be preserved, got: {mark_types:?}"
13139        );
13140    }
13141
13142    #[test]
13143    fn mark_ordering_strong_underline_link_preserved() {
13144        // Issue #491: verify [strong, underline, link] is preserved
13145        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13146          {"type":"text","text":"bold underlined link","marks":[
13147            {"type":"strong"},
13148            {"type":"underline"},
13149            {"type":"link","attrs":{"href":"https://example.com/page"}}
13150          ]}
13151        ]}]}"#;
13152        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13153        let md = adf_to_markdown(&doc).unwrap();
13154        let round_tripped = markdown_to_adf(&md).unwrap();
13155        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13156        let mark_types: Vec<&str> = node
13157            .marks
13158            .as_ref()
13159            .unwrap()
13160            .iter()
13161            .map(|m| m.mark_type.as_str())
13162            .collect();
13163        assert_eq!(
13164            mark_types,
13165            vec!["strong", "underline", "link"],
13166            "mark order should be preserved, got: {mark_types:?}"
13167        );
13168    }
13169
13170    #[test]
13171    fn mark_ordering_underline_em_link_preserved() {
13172        // Issue #491: verify [underline, em, link] is preserved
13173        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13174          {"type":"text","text":"italic underlined link","marks":[
13175            {"type":"underline"},
13176            {"type":"em"},
13177            {"type":"link","attrs":{"href":"https://example.com/page"}}
13178          ]}
13179        ]}]}"#;
13180        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13181        let md = adf_to_markdown(&doc).unwrap();
13182        let round_tripped = markdown_to_adf(&md).unwrap();
13183        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13184        let mark_types: Vec<&str> = node
13185            .marks
13186            .as_ref()
13187            .unwrap()
13188            .iter()
13189            .map(|m| m.mark_type.as_str())
13190            .collect();
13191        assert_eq!(
13192            mark_types,
13193            vec!["underline", "em", "link"],
13194            "mark order should be preserved, got: {mark_types:?}"
13195        );
13196    }
13197
13198    #[test]
13199    fn mark_ordering_underline_strike_link_preserved() {
13200        // Issue #491: verify [underline, strike, link] is preserved
13201        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13202          {"type":"text","text":"struck underlined link","marks":[
13203            {"type":"underline"},
13204            {"type":"strike"},
13205            {"type":"link","attrs":{"href":"https://example.com/page"}}
13206          ]}
13207        ]}]}"#;
13208        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13209        let md = adf_to_markdown(&doc).unwrap();
13210        let round_tripped = markdown_to_adf(&md).unwrap();
13211        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13212        let mark_types: Vec<&str> = node
13213            .marks
13214            .as_ref()
13215            .unwrap()
13216            .iter()
13217            .map(|m| m.mark_type.as_str())
13218            .collect();
13219        assert_eq!(
13220            mark_types,
13221            vec!["underline", "strike", "link"],
13222            "mark order should be preserved, got: {mark_types:?}"
13223        );
13224    }
13225
13226    #[test]
13227    fn mark_ordering_underline_strong_em_link_preserved() {
13228        // Issue #491: verify four-mark combo [underline, strong, em, link] is preserved
13229        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13230          {"type":"text","text":"all the marks","marks":[
13231            {"type":"underline"},
13232            {"type":"strong"},
13233            {"type":"em"},
13234            {"type":"link","attrs":{"href":"https://example.com/page"}}
13235          ]}
13236        ]}]}"#;
13237        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13238        let md = adf_to_markdown(&doc).unwrap();
13239        let round_tripped = markdown_to_adf(&md).unwrap();
13240        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13241        let mark_types: Vec<&str> = node
13242            .marks
13243            .as_ref()
13244            .unwrap()
13245            .iter()
13246            .map(|m| m.mark_type.as_str())
13247            .collect();
13248        assert_eq!(
13249            mark_types,
13250            vec!["underline", "strong", "em", "link"],
13251            "mark order should be preserved, got: {mark_types:?}"
13252        );
13253    }
13254
13255    #[test]
13256    fn em_strong_round_trip() {
13257        // Issue #401: em mark dropped when combined with strong
13258        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13259          {"type":"text","text":"bold and italic","marks":[{"type":"strong"},{"type":"em"}]}
13260        ]}]}"#;
13261        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13262        let md = adf_to_markdown(&doc).unwrap();
13263        assert_eq!(md.trim(), "***bold and italic***");
13264        let round_tripped = markdown_to_adf(&md).unwrap();
13265        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13266        assert_eq!(node.text.as_deref(), Some("bold and italic"));
13267        let mark_types: Vec<&str> = node
13268            .marks
13269            .as_ref()
13270            .unwrap()
13271            .iter()
13272            .map(|m| m.mark_type.as_str())
13273            .collect();
13274        assert_eq!(
13275            mark_types,
13276            vec!["strong", "em"],
13277            "both strong and em marks should be preserved, got: {mark_types:?}"
13278        );
13279    }
13280
13281    #[test]
13282    fn em_strong_round_trip_em_first() {
13283        // Issue #549: [em, strong] mark order must be preserved on round-trip.
13284        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13285          {"type":"text","text":"italic and bold","marks":[{"type":"em"},{"type":"strong"}]}
13286        ]}]}"#;
13287        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13288        let md = adf_to_markdown(&doc).unwrap();
13289        let round_tripped = markdown_to_adf(&md).unwrap();
13290        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13291        assert_eq!(node.text.as_deref(), Some("italic and bold"));
13292        let mark_types: Vec<&str> = node
13293            .marks
13294            .as_ref()
13295            .unwrap()
13296            .iter()
13297            .map(|m| m.mark_type.as_str())
13298            .collect();
13299        assert_eq!(
13300            mark_types,
13301            vec!["em", "strong"],
13302            "mark order [em, strong] should be preserved, got: {mark_types:?}"
13303        );
13304    }
13305
13306    /// Round-trips an inline text node with the given marks through ADF → JFM → ADF
13307    /// and asserts the resulting mark types match `expected`.
13308    fn assert_mark_order_round_trip(marks: Vec<AdfMark>, expected: &[&str]) {
13309        let doc = AdfDocument {
13310            version: 1,
13311            doc_type: "doc".to_string(),
13312            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
13313                "text", marks,
13314            )])],
13315        };
13316        let md = adf_to_markdown(&doc).unwrap();
13317        let round_tripped = markdown_to_adf(&md).unwrap();
13318        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13319        let mark_types: Vec<&str> = node
13320            .marks
13321            .as_ref()
13322            .expect("round-tripped node should have marks")
13323            .iter()
13324            .map(|m| m.mark_type.as_str())
13325            .collect();
13326        assert_eq!(
13327            mark_types, expected,
13328            "marks should round-trip in order via {md:?}"
13329        );
13330    }
13331
13332    #[test]
13333    fn round_trip_em_strong_mark_order() {
13334        // Issue #549: em + strong in either order must round-trip.
13335        assert_mark_order_round_trip(vec![AdfMark::em(), AdfMark::strong()], &["em", "strong"]);
13336        assert_mark_order_round_trip(vec![AdfMark::strong(), AdfMark::em()], &["strong", "em"]);
13337    }
13338
13339    #[test]
13340    fn round_trip_strong_underline_mark_order() {
13341        // Issue #549: strong + underline in either order must round-trip.
13342        assert_mark_order_round_trip(
13343            vec![AdfMark::strong(), AdfMark::underline()],
13344            &["strong", "underline"],
13345        );
13346        assert_mark_order_round_trip(
13347            vec![AdfMark::underline(), AdfMark::strong()],
13348            &["underline", "strong"],
13349        );
13350    }
13351
13352    #[test]
13353    fn round_trip_em_underline_mark_order() {
13354        assert_mark_order_round_trip(
13355            vec![AdfMark::em(), AdfMark::underline()],
13356            &["em", "underline"],
13357        );
13358        assert_mark_order_round_trip(
13359            vec![AdfMark::underline(), AdfMark::em()],
13360            &["underline", "em"],
13361        );
13362    }
13363
13364    #[test]
13365    fn round_trip_strike_strong_em_permutations() {
13366        // Each permutation of {strike, strong, em} must round-trip the mark order
13367        // exactly, because the Atlassian ADF spec does not define a canonical mark
13368        // ordering and we preserve whatever ordering Jira delivered.
13369        assert_mark_order_round_trip(
13370            vec![AdfMark::strike(), AdfMark::strong(), AdfMark::em()],
13371            &["strike", "strong", "em"],
13372        );
13373        assert_mark_order_round_trip(
13374            vec![AdfMark::strike(), AdfMark::em(), AdfMark::strong()],
13375            &["strike", "em", "strong"],
13376        );
13377        assert_mark_order_round_trip(
13378            vec![AdfMark::strong(), AdfMark::strike(), AdfMark::em()],
13379            &["strong", "strike", "em"],
13380        );
13381        assert_mark_order_round_trip(
13382            vec![AdfMark::strong(), AdfMark::em(), AdfMark::strike()],
13383            &["strong", "em", "strike"],
13384        );
13385        assert_mark_order_round_trip(
13386            vec![AdfMark::em(), AdfMark::strike(), AdfMark::strong()],
13387            &["em", "strike", "strong"],
13388        );
13389        assert_mark_order_round_trip(
13390            vec![AdfMark::em(), AdfMark::strong(), AdfMark::strike()],
13391            &["em", "strong", "strike"],
13392        );
13393    }
13394
13395    #[test]
13396    fn round_trip_underline_nested_with_strong_em() {
13397        // Underline may sit outside, between, or inside strong/em — each position
13398        // must round-trip.
13399        assert_mark_order_round_trip(
13400            vec![AdfMark::underline(), AdfMark::strong(), AdfMark::em()],
13401            &["underline", "strong", "em"],
13402        );
13403        assert_mark_order_round_trip(
13404            vec![AdfMark::strong(), AdfMark::underline(), AdfMark::em()],
13405            &["strong", "underline", "em"],
13406        );
13407        assert_mark_order_round_trip(
13408            vec![AdfMark::strong(), AdfMark::em(), AdfMark::underline()],
13409            &["strong", "em", "underline"],
13410        );
13411    }
13412
13413    #[test]
13414    fn round_trip_span_attr_order_preserved() {
13415        // Issue #549: the `:span` directive always parses color/bg/subsup
13416        // attrs in a fixed order, so non-canonical orderings must be emitted
13417        // as nested :span wrappers rather than a single merged wrapper.
13418        assert_mark_order_round_trip(
13419            vec![
13420                AdfMark::background_color("#ffff00"),
13421                AdfMark::text_color("#ff0000"),
13422            ],
13423            &["backgroundColor", "textColor"],
13424        );
13425        assert_mark_order_round_trip(
13426            vec![AdfMark::subsup("sub"), AdfMark::text_color("#ff0000")],
13427            &["subsup", "textColor"],
13428        );
13429        assert_mark_order_round_trip(
13430            vec![
13431                AdfMark::text_color("#ff0000"),
13432                AdfMark::background_color("#ffff00"),
13433            ],
13434            &["textColor", "backgroundColor"],
13435        );
13436    }
13437
13438    #[test]
13439    fn round_trip_annotation_before_underline() {
13440        // Issue #549: the bracketed-span parser reads `underline` before any
13441        // annotation-ids, so `[annotation, underline]` must be emitted as
13442        // nested wrappers rather than one merged `[text]{underline annotation-id=X}`.
13443        assert_mark_order_round_trip(
13444            vec![
13445                AdfMark::annotation("ann-1", "inlineComment"),
13446                AdfMark::underline(),
13447            ],
13448            &["annotation", "underline"],
13449        );
13450        assert_mark_order_round_trip(
13451            vec![
13452                AdfMark::annotation("ann-1", "inlineComment"),
13453                AdfMark::underline(),
13454                AdfMark::annotation("ann-2", "inlineComment"),
13455            ],
13456            &["annotation", "underline", "annotation"],
13457        );
13458    }
13459
13460    #[test]
13461    fn round_trip_em_content_with_underscores() {
13462        // When em renders as `_..._` (to disambiguate from strong), any literal
13463        // underscores in the text must be escaped so they don't close the
13464        // emphasis span early.  Text like "foo_bar_baz" with [em, strong] must
13465        // survive round-trip with the underscores intact.
13466        let doc = AdfDocument {
13467            version: 1,
13468            doc_type: "doc".to_string(),
13469            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
13470                "foo _bar_ baz",
13471                vec![AdfMark::em(), AdfMark::strong()],
13472            )])],
13473        };
13474        let md = adf_to_markdown(&doc).unwrap();
13475        let round_tripped = markdown_to_adf(&md).unwrap();
13476        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13477        assert_eq!(node.text.as_deref(), Some("foo _bar_ baz"));
13478        let mark_types: Vec<&str> = node
13479            .marks
13480            .as_ref()
13481            .unwrap()
13482            .iter()
13483            .map(|m| m.mark_type.as_str())
13484            .collect();
13485        assert_eq!(mark_types, vec!["em", "strong"]);
13486    }
13487
13488    #[test]
13489    fn round_trip_link_nested_with_formatting_marks() {
13490        // Link may sit at any position in the marks array relative to em,
13491        // strong, strike, and underline — each position must round-trip.
13492        assert_mark_order_round_trip(
13493            vec![
13494                AdfMark::link("https://example.com"),
13495                AdfMark::strong(),
13496                AdfMark::em(),
13497            ],
13498            &["link", "strong", "em"],
13499        );
13500        assert_mark_order_round_trip(
13501            vec![
13502                AdfMark::em(),
13503                AdfMark::strong(),
13504                AdfMark::link("https://example.com"),
13505            ],
13506            &["em", "strong", "link"],
13507        );
13508        assert_mark_order_round_trip(
13509            vec![
13510                AdfMark::underline(),
13511                AdfMark::link("https://example.com"),
13512                AdfMark::strong(),
13513            ],
13514            &["underline", "link", "strong"],
13515        );
13516    }
13517
13518    /// Builds an `AdfMark` with the given type and no attrs, bypassing the
13519    /// usual constructors so we can exercise the defensive branches in the
13520    /// render helpers (the constructors always populate `attrs`).
13521    fn bare_mark(mark_type: &str) -> AdfMark {
13522        AdfMark {
13523            mark_type: mark_type.to_string(),
13524            attrs: None,
13525        }
13526    }
13527
13528    #[test]
13529    fn collect_span_attr_handles_missing_attrs() {
13530        // `textColor`/`backgroundColor`/`subsup` marks without the expected
13531        // `color`/`type` attr must not emit a fragment (the `if let` falls
13532        // through without pushing).  This exercises the inner-None branches
13533        // that the typed-constructor tests otherwise skip.
13534        let mut attrs = Vec::new();
13535        collect_span_attr(&bare_mark("textColor"), &mut attrs);
13536        collect_span_attr(&bare_mark("backgroundColor"), &mut attrs);
13537        collect_span_attr(&bare_mark("subsup"), &mut attrs);
13538        collect_span_attr(&bare_mark("link"), &mut attrs);
13539        assert!(attrs.is_empty(), "got: {attrs:?}");
13540    }
13541
13542    #[test]
13543    fn collect_bracketed_attr_handles_missing_attrs() {
13544        // An annotation mark with no attrs map at all must silently produce
13545        // no fragments — this covers the outer `if let Some(ref a)` None arm.
13546        let mut attrs = Vec::new();
13547        collect_bracketed_attr(&bare_mark("annotation"), &mut attrs);
13548        collect_bracketed_attr(&bare_mark("strong"), &mut attrs);
13549        assert!(attrs.is_empty(), "got: {attrs:?}");
13550    }
13551
13552    #[test]
13553    fn collect_bracketed_attr_handles_annotation_without_id() {
13554        // An annotation mark with attrs present but missing `id` and
13555        // `annotationType` keys still emits nothing — exercises the inner
13556        // None branches of each `if let` in the annotation arm.
13557        let mark = AdfMark {
13558            mark_type: "annotation".to_string(),
13559            attrs: Some(serde_json::json!({})),
13560        };
13561        let mut attrs = Vec::new();
13562        collect_bracketed_attr(&mark, &mut attrs);
13563        assert!(attrs.is_empty(), "got: {attrs:?}");
13564    }
13565
13566    #[test]
13567    fn span_attr_order_rejects_unknown_types() {
13568        // `span_attr_order` must classify unknown mark types as the sentinel
13569        // value, and `span_run_is_canonical` must reject a run that contains
13570        // any such unknown type.
13571        assert_eq!(span_attr_order("textColor"), 0);
13572        assert_eq!(span_attr_order("backgroundColor"), 1);
13573        assert_eq!(span_attr_order("subsup"), 2);
13574        assert_eq!(span_attr_order("strong"), u8::MAX);
13575        assert!(!span_run_is_canonical(&[bare_mark("strong")]));
13576    }
13577
13578    #[test]
13579    fn bracketed_run_rejects_unknown_types() {
13580        // `bracketed_run_is_canonical` only accepts `underline` and
13581        // `annotation`; any other mark type in the run short-circuits to
13582        // `false` so the caller emits nested wrappers.
13583        assert!(bracketed_run_is_canonical(&[
13584            AdfMark::underline(),
13585            AdfMark::annotation("x", "inlineComment")
13586        ]));
13587        assert!(!bracketed_run_is_canonical(&[
13588            AdfMark::annotation("x", "inlineComment"),
13589            AdfMark::underline()
13590        ]));
13591        assert!(!bracketed_run_is_canonical(&[bare_mark("strong")]));
13592    }
13593
13594    #[test]
13595    fn render_marked_text_ignores_unknown_mark_types() {
13596        // Unknown mark types fall through `render_marked_text`'s `_ =>`
13597        // arm and are dropped; the rendered JFM must still produce the
13598        // underlying text (and round-trip back to an unmarked text node).
13599        let doc = AdfDocument {
13600            version: 1,
13601            doc_type: "doc".to_string(),
13602            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
13603                "hello",
13604                vec![bare_mark("futureMark"), AdfMark::strong()],
13605            )])],
13606        };
13607        let md = adf_to_markdown(&doc).unwrap();
13608        assert_eq!(md.trim(), "**hello**");
13609        let rt = markdown_to_adf(&md).unwrap();
13610        let node = &rt.content[0].content.as_ref().unwrap()[0];
13611        assert_eq!(node.text.as_deref(), Some("hello"));
13612        let mark_types: Vec<&str> = node
13613            .marks
13614            .as_ref()
13615            .unwrap()
13616            .iter()
13617            .map(|m| m.mark_type.as_str())
13618            .collect();
13619        assert_eq!(mark_types, vec!["strong"]);
13620    }
13621
13622    #[test]
13623    fn triple_asterisk_parse_to_adf() {
13624        // Issue #401: ***text*** should parse as text with strong+em marks
13625        let md = "***bold and italic***\n";
13626        let doc = markdown_to_adf(md).unwrap();
13627        let node = &doc.content[0].content.as_ref().unwrap()[0];
13628        assert_eq!(node.text.as_deref(), Some("bold and italic"));
13629        let mark_types: Vec<&str> = node
13630            .marks
13631            .as_ref()
13632            .unwrap()
13633            .iter()
13634            .map(|m| m.mark_type.as_str())
13635            .collect();
13636        assert!(
13637            mark_types.contains(&"strong") && mark_types.contains(&"em"),
13638            "***text*** should produce both strong and em marks, got: {mark_types:?}"
13639        );
13640    }
13641
13642    #[test]
13643    fn triple_asterisk_with_surrounding_text() {
13644        // Issue #401: surrounding text should not be affected
13645        let md = "before ***bold italic*** after\n";
13646        let doc = markdown_to_adf(md).unwrap();
13647        let nodes = doc.content[0].content.as_ref().unwrap();
13648        // Should have: "before " (plain), "bold italic" (strong+em), " after" (plain)
13649        assert!(
13650            nodes.len() >= 3,
13651            "expected at least 3 nodes, got {}",
13652            nodes.len()
13653        );
13654        assert_eq!(nodes[0].text.as_deref(), Some("before "));
13655        assert_eq!(nodes[1].text.as_deref(), Some("bold italic"));
13656        let mark_types: Vec<&str> = nodes[1]
13657            .marks
13658            .as_ref()
13659            .unwrap()
13660            .iter()
13661            .map(|m| m.mark_type.as_str())
13662            .collect();
13663        assert!(
13664            mark_types.contains(&"strong") && mark_types.contains(&"em"),
13665            "middle node should have strong+em, got: {mark_types:?}"
13666        );
13667        assert_eq!(nodes[2].text.as_deref(), Some(" after"));
13668    }
13669
13670    #[test]
13671    fn annotation_mark_round_trip() {
13672        // Issue #364: annotation marks were silently dropped
13673        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13674          {"type":"text","text":"highlighted text","marks":[
13675            {"type":"annotation","attrs":{"id":"abc123","annotationType":"inlineComment"}}
13676          ]}
13677        ]}]}"#;
13678        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13679
13680        let md = adf_to_markdown(&doc).unwrap();
13681        assert!(
13682            md.contains("annotation-id="),
13683            "JFM should contain annotation-id, got: {md}"
13684        );
13685
13686        let round_tripped = markdown_to_adf(&md).unwrap();
13687        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13688        assert_eq!(text_node.text.as_deref(), Some("highlighted text"));
13689        let marks = text_node.marks.as_ref().expect("should have marks");
13690        let ann = marks
13691            .iter()
13692            .find(|m| m.mark_type == "annotation")
13693            .expect("should have annotation mark");
13694        let attrs = ann.attrs.as_ref().unwrap();
13695        assert_eq!(attrs["id"], "abc123");
13696        assert_eq!(attrs["annotationType"], "inlineComment");
13697    }
13698
13699    #[test]
13700    fn annotation_mark_with_bold() {
13701        // Annotation + bold should both survive round-trip
13702        let doc = AdfDocument {
13703            version: 1,
13704            doc_type: "doc".to_string(),
13705            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
13706                "bold comment",
13707                vec![
13708                    AdfMark::strong(),
13709                    AdfMark::annotation("def456", "inlineComment"),
13710                ],
13711            )])],
13712        };
13713        let md = adf_to_markdown(&doc).unwrap();
13714        let round_tripped = markdown_to_adf(&md).unwrap();
13715        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13716        let marks = text_node.marks.as_ref().expect("should have marks");
13717        assert!(
13718            marks.iter().any(|m| m.mark_type == "strong"),
13719            "should have strong mark"
13720        );
13721        assert!(
13722            marks.iter().any(|m| m.mark_type == "annotation"),
13723            "should have annotation mark"
13724        );
13725    }
13726
13727    #[test]
13728    fn annotation_and_link_marks_both_preserved() {
13729        // Issue #390: text with both annotation and link marks loses link on round-trip
13730        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13731          {"type":"text","text":"HANGUL-8","marks":[
13732            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"5ca7425e-34cd-48d3-b4eb-9873ac8b20e0"}},
13733            {"type":"link","attrs":{"href":"https://acme.atlassian.net/browse/HANG-8"}}
13734          ]}
13735        ]}]}"#;
13736        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13737        let md = adf_to_markdown(&doc).unwrap();
13738        // Should contain both annotation attrs and link syntax
13739        assert!(
13740            md.contains("annotation-id="),
13741            "JFM should contain annotation-id, got: {md}"
13742        );
13743        assert!(
13744            md.contains("](https://"),
13745            "JFM should contain link href, got: {md}"
13746        );
13747        let round_tripped = markdown_to_adf(&md).unwrap();
13748        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13749        let marks = text_node.marks.as_ref().expect("should have marks");
13750        assert!(
13751            marks.iter().any(|m| m.mark_type == "annotation"),
13752            "should have annotation mark, got: {:?}",
13753            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13754        );
13755        assert!(
13756            marks.iter().any(|m| m.mark_type == "link"),
13757            "should have link mark, got: {:?}",
13758            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13759        );
13760    }
13761
13762    #[test]
13763    fn annotation_and_code_marks_both_preserved() {
13764        // Issue #508: annotation mark dropped when combined with code mark
13765        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13766          {"type":"text","text":"some text with "},
13767          {"type":"text","text":"annotated code","marks":[
13768            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"aabbccdd-1234-5678-abcd-000000000001"}},
13769            {"type":"code"}
13770          ]},
13771          {"type":"text","text":" remaining text"}
13772        ]}]}"#;
13773        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13774        let md = adf_to_markdown(&doc).unwrap();
13775        assert!(
13776            md.contains("annotation-id="),
13777            "JFM should contain annotation-id, got: {md}"
13778        );
13779        assert!(
13780            md.contains('`'),
13781            "JFM should contain backticks for code, got: {md}"
13782        );
13783
13784        let round_tripped = markdown_to_adf(&md).unwrap();
13785        let nodes = round_tripped.content[0].content.as_ref().unwrap();
13786        // Find the text node with "annotated code"
13787        let code_node = nodes
13788            .iter()
13789            .find(|n| n.text.as_deref() == Some("annotated code"))
13790            .expect("should have 'annotated code' text node");
13791        let marks = code_node.marks.as_ref().expect("should have marks");
13792        assert!(
13793            marks.iter().any(|m| m.mark_type == "annotation"),
13794            "should have annotation mark, got: {:?}",
13795            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13796        );
13797        assert!(
13798            marks.iter().any(|m| m.mark_type == "code"),
13799            "should have code mark, got: {:?}",
13800            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13801        );
13802        let ann = marks.iter().find(|m| m.mark_type == "annotation").unwrap();
13803        let attrs = ann.attrs.as_ref().unwrap();
13804        assert_eq!(attrs["id"], "aabbccdd-1234-5678-abcd-000000000001");
13805        assert_eq!(attrs["annotationType"], "inlineComment");
13806    }
13807
13808    #[test]
13809    fn annotation_and_code_and_link_marks_all_preserved() {
13810        // annotation + code + link should all survive round-trip
13811        let doc = AdfDocument {
13812            version: 1,
13813            doc_type: "doc".to_string(),
13814            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
13815                "linked code",
13816                vec![
13817                    AdfMark::annotation("ann-001", "inlineComment"),
13818                    AdfMark::code(),
13819                    AdfMark::link("https://example.com"),
13820                ],
13821            )])],
13822        };
13823        let md = adf_to_markdown(&doc).unwrap();
13824        assert!(
13825            md.contains("annotation-id="),
13826            "JFM should contain annotation-id, got: {md}"
13827        );
13828        assert!(md.contains('`'), "JFM should contain backticks, got: {md}");
13829        assert!(
13830            md.contains("](https://example.com)"),
13831            "JFM should contain link, got: {md}"
13832        );
13833
13834        let round_tripped = markdown_to_adf(&md).unwrap();
13835        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13836        let marks = text_node.marks.as_ref().expect("should have marks");
13837        assert!(
13838            marks.iter().any(|m| m.mark_type == "annotation"),
13839            "should have annotation mark, got: {:?}",
13840            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13841        );
13842        assert!(
13843            marks.iter().any(|m| m.mark_type == "code"),
13844            "should have code mark, got: {:?}",
13845            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13846        );
13847        assert!(
13848            marks.iter().any(|m| m.mark_type == "link"),
13849            "should have link mark, got: {:?}",
13850            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13851        );
13852    }
13853
13854    #[test]
13855    fn multiple_annotations_and_code_mark_preserved() {
13856        // Multiple annotation marks on a code node should all survive
13857        let doc = AdfDocument {
13858            version: 1,
13859            doc_type: "doc".to_string(),
13860            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
13861                "doubly annotated",
13862                vec![
13863                    AdfMark::annotation("ann-aaa", "inlineComment"),
13864                    AdfMark::annotation("ann-bbb", "inlineComment"),
13865                    AdfMark::code(),
13866                ],
13867            )])],
13868        };
13869        let md = adf_to_markdown(&doc).unwrap();
13870        assert!(
13871            md.contains("ann-aaa"),
13872            "JFM should contain first annotation id, got: {md}"
13873        );
13874        assert!(
13875            md.contains("ann-bbb"),
13876            "JFM should contain second annotation id, got: {md}"
13877        );
13878
13879        let round_tripped = markdown_to_adf(&md).unwrap();
13880        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13881        let marks = text_node.marks.as_ref().expect("should have marks");
13882        let ann_marks: Vec<_> = marks
13883            .iter()
13884            .filter(|m| m.mark_type == "annotation")
13885            .collect();
13886        assert_eq!(
13887            ann_marks.len(),
13888            2,
13889            "should have 2 annotation marks, got: {}",
13890            ann_marks.len()
13891        );
13892        assert!(
13893            marks.iter().any(|m| m.mark_type == "code"),
13894            "should have code mark"
13895        );
13896    }
13897
13898    #[test]
13899    fn underline_and_link_marks_both_preserved() {
13900        // Underline + link should also coexist
13901        let doc = AdfDocument {
13902            version: 1,
13903            doc_type: "doc".to_string(),
13904            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
13905                "click here",
13906                vec![AdfMark::underline(), AdfMark::link("https://example.com")],
13907            )])],
13908        };
13909        let md = adf_to_markdown(&doc).unwrap();
13910        assert!(md.contains("underline"), "should have underline attr: {md}");
13911        assert!(
13912            md.contains("](https://example.com)"),
13913            "should have link: {md}"
13914        );
13915        let round_tripped = markdown_to_adf(&md).unwrap();
13916        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13917        let marks = text_node.marks.as_ref().expect("should have marks");
13918        assert!(marks.iter().any(|m| m.mark_type == "underline"));
13919        assert!(marks.iter().any(|m| m.mark_type == "link"));
13920    }
13921
13922    #[test]
13923    fn annotation_link_and_bold_all_preserved() {
13924        // All three marks should coexist
13925        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13926          {"type":"text","text":"important","marks":[
13927            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"abc"}},
13928            {"type":"link","attrs":{"href":"https://example.com"}},
13929            {"type":"strong"}
13930          ]}
13931        ]}]}"#;
13932        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13933        let md = adf_to_markdown(&doc).unwrap();
13934        let round_tripped = markdown_to_adf(&md).unwrap();
13935        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13936        let marks = text_node.marks.as_ref().expect("should have marks");
13937        assert!(
13938            marks.iter().any(|m| m.mark_type == "annotation"),
13939            "should have annotation"
13940        );
13941        assert!(
13942            marks.iter().any(|m| m.mark_type == "link"),
13943            "should have link"
13944        );
13945        assert!(
13946            marks.iter().any(|m| m.mark_type == "strong"),
13947            "should have strong"
13948        );
13949    }
13950
13951    #[test]
13952    fn multiple_annotation_marks_round_trip() {
13953        // Issue #439: multiple annotation marks on same text node — all but last dropped
13954        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13955          {"type":"text","text":"some annotated text","marks":[
13956            {"type":"annotation","attrs":{"id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","annotationType":"inlineComment"}},
13957            {"type":"annotation","attrs":{"id":"ffffffff-1111-2222-3333-444444444444","annotationType":"inlineComment"}}
13958          ]}
13959        ]}]}"#;
13960        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13961
13962        let md = adf_to_markdown(&doc).unwrap();
13963        assert!(
13964            md.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
13965            "JFM should contain first annotation id, got: {md}"
13966        );
13967        assert!(
13968            md.contains("ffffffff-1111-2222-3333-444444444444"),
13969            "JFM should contain second annotation id, got: {md}"
13970        );
13971
13972        let round_tripped = markdown_to_adf(&md).unwrap();
13973        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
13974        assert_eq!(text_node.text.as_deref(), Some("some annotated text"));
13975        let marks = text_node.marks.as_ref().expect("should have marks");
13976        let annotations: Vec<_> = marks
13977            .iter()
13978            .filter(|m| m.mark_type == "annotation")
13979            .collect();
13980        assert_eq!(
13981            annotations.len(),
13982            2,
13983            "should have 2 annotation marks, got: {annotations:?}"
13984        );
13985        let ids: Vec<_> = annotations
13986            .iter()
13987            .map(|a| a.attrs.as_ref().unwrap()["id"].as_str().unwrap())
13988            .collect();
13989        assert!(ids.contains(&"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
13990        assert!(ids.contains(&"ffffffff-1111-2222-3333-444444444444"));
13991    }
13992
13993    #[test]
13994    fn three_annotation_marks_round_trip() {
13995        // Verify three overlapping annotations all survive
13996        let doc = AdfDocument {
13997            version: 1,
13998            doc_type: "doc".to_string(),
13999            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
14000                "triple annotated",
14001                vec![
14002                    AdfMark::annotation("id-1", "inlineComment"),
14003                    AdfMark::annotation("id-2", "inlineComment"),
14004                    AdfMark::annotation("id-3", "inlineComment"),
14005                ],
14006            )])],
14007        };
14008        let md = adf_to_markdown(&doc).unwrap();
14009        let round_tripped = markdown_to_adf(&md).unwrap();
14010        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
14011        let marks = text_node.marks.as_ref().expect("should have marks");
14012        let annotations: Vec<_> = marks
14013            .iter()
14014            .filter(|m| m.mark_type == "annotation")
14015            .collect();
14016        assert_eq!(
14017            annotations.len(),
14018            3,
14019            "should have 3 annotation marks, got: {annotations:?}"
14020        );
14021    }
14022
14023    #[test]
14024    fn multiple_annotations_with_bold_round_trip() {
14025        // Multiple annotations + bold should all survive
14026        let doc = AdfDocument {
14027            version: 1,
14028            doc_type: "doc".to_string(),
14029            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
14030                "bold double annotated",
14031                vec![
14032                    AdfMark::strong(),
14033                    AdfMark::annotation("ann-a", "inlineComment"),
14034                    AdfMark::annotation("ann-b", "inlineComment"),
14035                ],
14036            )])],
14037        };
14038        let md = adf_to_markdown(&doc).unwrap();
14039        let round_tripped = markdown_to_adf(&md).unwrap();
14040        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
14041        let marks = text_node.marks.as_ref().expect("should have marks");
14042        assert!(
14043            marks.iter().any(|m| m.mark_type == "strong"),
14044            "should have strong mark"
14045        );
14046        let annotations: Vec<_> = marks
14047            .iter()
14048            .filter(|m| m.mark_type == "annotation")
14049            .collect();
14050        assert_eq!(
14051            annotations.len(),
14052            2,
14053            "should have 2 annotation marks, got: {annotations:?}"
14054        );
14055    }
14056
14057    #[test]
14058    fn multiple_annotations_with_link_round_trip() {
14059        // Multiple annotations + link should all survive
14060        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14061          {"type":"text","text":"linked text","marks":[
14062            {"type":"annotation","attrs":{"id":"ann-x","annotationType":"inlineComment"}},
14063            {"type":"annotation","attrs":{"id":"ann-y","annotationType":"inlineComment"}},
14064            {"type":"link","attrs":{"href":"https://example.com"}}
14065          ]}
14066        ]}]}"#;
14067        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14068        let md = adf_to_markdown(&doc).unwrap();
14069        let round_tripped = markdown_to_adf(&md).unwrap();
14070        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
14071        let marks = text_node.marks.as_ref().expect("should have marks");
14072        assert!(
14073            marks.iter().any(|m| m.mark_type == "link"),
14074            "should have link mark"
14075        );
14076        let annotations: Vec<_> = marks
14077            .iter()
14078            .filter(|m| m.mark_type == "annotation")
14079            .collect();
14080        assert_eq!(
14081            annotations.len(),
14082            2,
14083            "should have 2 annotation marks, got: {annotations:?}"
14084        );
14085    }
14086
14087    // ── Issue #471: annotation marks on non-text inline nodes ─────────
14088
14089    #[test]
14090    fn annotation_on_emoji_round_trip() {
14091        // Issue #471: annotation mark on emoji node should survive round-trip
14092        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14093          {"type":"emoji","attrs":{"id":"1f4dd","shortName":":memo:","text":"📝"},"marks":[
14094            {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
14095          ]},
14096          {"type":"text","text":" annotated text","marks":[
14097            {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
14098          ]}
14099        ]}]}"#;
14100        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14101        let md = adf_to_markdown(&doc).unwrap();
14102        assert!(
14103            md.contains("annotation-id="),
14104            "JFM should contain annotation-id for emoji, got: {md}"
14105        );
14106
14107        let round_tripped = markdown_to_adf(&md).unwrap();
14108        let nodes = round_tripped.content[0].content.as_ref().unwrap();
14109
14110        // Emoji node should retain annotation mark
14111        let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
14112        let emoji_marks = emoji_node.marks.as_ref().expect("emoji should have marks");
14113        assert!(
14114            emoji_marks.iter().any(|m| m.mark_type == "annotation"),
14115            "emoji should have annotation mark, got: {emoji_marks:?}"
14116        );
14117        let ann = emoji_marks
14118            .iter()
14119            .find(|m| m.mark_type == "annotation")
14120            .unwrap();
14121        assert_eq!(
14122            ann.attrs.as_ref().unwrap()["id"],
14123            "ccddee11-2233-4455-aabb-ccddee112233"
14124        );
14125
14126        // Text node should also retain annotation mark
14127        let text_node = nodes.iter().find(|n| n.node_type == "text").unwrap();
14128        let text_marks = text_node.marks.as_ref().expect("text should have marks");
14129        assert!(
14130            text_marks.iter().any(|m| m.mark_type == "annotation"),
14131            "text should have annotation mark"
14132        );
14133    }
14134
14135    #[test]
14136    fn annotation_on_status_round_trip() {
14137        let mut status = AdfNode::status("In Progress", "blue");
14138        status.marks = Some(vec![AdfMark::annotation("ann-status-1", "inlineComment")]);
14139
14140        let doc = AdfDocument {
14141            version: 1,
14142            doc_type: "doc".to_string(),
14143            content: vec![AdfNode::paragraph(vec![status])],
14144        };
14145        let md = adf_to_markdown(&doc).unwrap();
14146        assert!(
14147            md.contains("annotation-id="),
14148            "JFM should contain annotation-id for status, got: {md}"
14149        );
14150
14151        let round_tripped = markdown_to_adf(&md).unwrap();
14152        let nodes = round_tripped.content[0].content.as_ref().unwrap();
14153        let status_node = nodes.iter().find(|n| n.node_type == "status").unwrap();
14154        let marks = status_node
14155            .marks
14156            .as_ref()
14157            .expect("status should have marks");
14158        assert!(
14159            marks.iter().any(|m| m.mark_type == "annotation"),
14160            "status should have annotation mark, got: {marks:?}"
14161        );
14162    }
14163
14164    #[test]
14165    fn annotation_on_date_round_trip() {
14166        let mut date = AdfNode::date("1704067200000");
14167        date.marks = Some(vec![AdfMark::annotation("ann-date-1", "inlineComment")]);
14168
14169        let doc = AdfDocument {
14170            version: 1,
14171            doc_type: "doc".to_string(),
14172            content: vec![AdfNode::paragraph(vec![date])],
14173        };
14174        let md = adf_to_markdown(&doc).unwrap();
14175        assert!(
14176            md.contains("annotation-id="),
14177            "JFM should contain annotation-id for date, got: {md}"
14178        );
14179
14180        let round_tripped = markdown_to_adf(&md).unwrap();
14181        let nodes = round_tripped.content[0].content.as_ref().unwrap();
14182        let date_node = nodes.iter().find(|n| n.node_type == "date").unwrap();
14183        let marks = date_node.marks.as_ref().expect("date should have marks");
14184        assert!(
14185            marks.iter().any(|m| m.mark_type == "annotation"),
14186            "date should have annotation mark, got: {marks:?}"
14187        );
14188    }
14189
14190    #[test]
14191    fn annotation_on_mention_round_trip() {
14192        let mut mention = AdfNode::mention("user-123", "@Alice");
14193        mention.marks = Some(vec![AdfMark::annotation("ann-mention-1", "inlineComment")]);
14194
14195        let doc = AdfDocument {
14196            version: 1,
14197            doc_type: "doc".to_string(),
14198            content: vec![AdfNode::paragraph(vec![mention])],
14199        };
14200        let md = adf_to_markdown(&doc).unwrap();
14201        assert!(
14202            md.contains("annotation-id="),
14203            "JFM should contain annotation-id for mention, got: {md}"
14204        );
14205
14206        let round_tripped = markdown_to_adf(&md).unwrap();
14207        let nodes = round_tripped.content[0].content.as_ref().unwrap();
14208        let mention_node = nodes.iter().find(|n| n.node_type == "mention").unwrap();
14209        let marks = mention_node
14210            .marks
14211            .as_ref()
14212            .expect("mention should have marks");
14213        assert!(
14214            marks.iter().any(|m| m.mark_type == "annotation"),
14215            "mention should have annotation mark, got: {marks:?}"
14216        );
14217    }
14218
14219    #[test]
14220    fn annotation_on_inline_card_round_trip() {
14221        let mut card = AdfNode::inline_card("https://example.com");
14222        card.marks = Some(vec![AdfMark::annotation("ann-card-1", "inlineComment")]);
14223
14224        let doc = AdfDocument {
14225            version: 1,
14226            doc_type: "doc".to_string(),
14227            content: vec![AdfNode::paragraph(vec![card])],
14228        };
14229        let md = adf_to_markdown(&doc).unwrap();
14230        assert!(
14231            md.contains("annotation-id="),
14232            "JFM should contain annotation-id for inlineCard, got: {md}"
14233        );
14234
14235        let round_tripped = markdown_to_adf(&md).unwrap();
14236        let nodes = round_tripped.content[0].content.as_ref().unwrap();
14237        let card_node = nodes.iter().find(|n| n.node_type == "inlineCard").unwrap();
14238        let marks = card_node
14239            .marks
14240            .as_ref()
14241            .expect("inlineCard should have marks");
14242        assert!(
14243            marks.iter().any(|m| m.mark_type == "annotation"),
14244            "inlineCard should have annotation mark, got: {marks:?}"
14245        );
14246    }
14247
14248    #[test]
14249    fn annotation_on_placeholder_round_trip() {
14250        let mut placeholder = AdfNode::placeholder("Enter text here");
14251        placeholder.marks = Some(vec![AdfMark::annotation("ann-ph-1", "inlineComment")]);
14252
14253        let doc = AdfDocument {
14254            version: 1,
14255            doc_type: "doc".to_string(),
14256            content: vec![AdfNode::paragraph(vec![placeholder])],
14257        };
14258        let md = adf_to_markdown(&doc).unwrap();
14259        assert!(
14260            md.contains("annotation-id="),
14261            "JFM should contain annotation-id for placeholder, got: {md}"
14262        );
14263
14264        let round_tripped = markdown_to_adf(&md).unwrap();
14265        let nodes = round_tripped.content[0].content.as_ref().unwrap();
14266        let ph_node = nodes.iter().find(|n| n.node_type == "placeholder").unwrap();
14267        let marks = ph_node
14268            .marks
14269            .as_ref()
14270            .expect("placeholder should have marks");
14271        assert!(
14272            marks.iter().any(|m| m.mark_type == "annotation"),
14273            "placeholder should have annotation mark, got: {marks:?}"
14274        );
14275    }
14276
14277    #[test]
14278    fn multiple_annotations_on_emoji_round_trip() {
14279        // Multiple annotation marks on a single emoji node
14280        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14281          {"type":"emoji","attrs":{"shortName":":fire:","text":"🔥"},"marks":[
14282            {"type":"annotation","attrs":{"id":"ann-1","annotationType":"inlineComment"}},
14283            {"type":"annotation","attrs":{"id":"ann-2","annotationType":"inlineComment"}}
14284          ]}
14285        ]}]}"#;
14286        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14287        let md = adf_to_markdown(&doc).unwrap();
14288
14289        let round_tripped = markdown_to_adf(&md).unwrap();
14290        let nodes = round_tripped.content[0].content.as_ref().unwrap();
14291        let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
14292        let marks = emoji_node.marks.as_ref().expect("emoji should have marks");
14293        let annotations: Vec<_> = marks
14294            .iter()
14295            .filter(|m| m.mark_type == "annotation")
14296            .collect();
14297        assert_eq!(
14298            annotations.len(),
14299            2,
14300            "emoji should have 2 annotation marks, got: {annotations:?}"
14301        );
14302    }
14303
14304    #[test]
14305    fn emoji_without_annotation_unchanged() {
14306        // Ensure emoji nodes without annotation marks are not affected
14307        let doc = AdfDocument {
14308            version: 1,
14309            doc_type: "doc".to_string(),
14310            content: vec![AdfNode::paragraph(vec![AdfNode::emoji(":fire:")])],
14311        };
14312        let md = adf_to_markdown(&doc).unwrap();
14313        // Should NOT have bracketed span wrapping
14314        assert!(
14315            !md.contains('['),
14316            "emoji without annotation should not be wrapped in brackets, got: {md}"
14317        );
14318        assert!(md.contains(":fire:"));
14319    }
14320
14321    // ── Inline directive tests (Tier 4) ───────────────────────────────
14322
14323    #[test]
14324    fn status_directive() {
14325        let doc = markdown_to_adf("The ticket is :status[In Progress]{color=blue}.").unwrap();
14326        let content = doc.content[0].content.as_ref().unwrap();
14327        assert_eq!(content[1].node_type, "status");
14328        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "In Progress");
14329        assert_eq!(content[1].attrs.as_ref().unwrap()["color"], "blue");
14330    }
14331
14332    #[test]
14333    fn adf_status_to_markdown() {
14334        let doc = AdfDocument {
14335            version: 1,
14336            doc_type: "doc".to_string(),
14337            content: vec![AdfNode::paragraph(vec![AdfNode::status("Done", "green")])],
14338        };
14339        let md = adf_to_markdown(&doc).unwrap();
14340        assert!(md.contains(":status[Done]{color=green}"));
14341    }
14342
14343    #[test]
14344    fn round_trip_status() {
14345        let md = "The ticket is :status[In Progress]{color=blue}.\n";
14346        let doc = markdown_to_adf(md).unwrap();
14347        let result = adf_to_markdown(&doc).unwrap();
14348        assert!(result.contains(":status[In Progress]{color=blue}"));
14349    }
14350
14351    #[test]
14352    fn status_with_style_and_localid_roundtrips() {
14353        let adf = AdfDocument {
14354            version: 1,
14355            doc_type: "doc".to_string(),
14356            content: vec![AdfNode::paragraph(vec![{
14357                let mut node = AdfNode::status("open", "green");
14358                node.attrs.as_mut().unwrap()["style"] =
14359                    serde_json::Value::String("bold".to_string());
14360                node.attrs.as_mut().unwrap()["localId"] =
14361                    serde_json::Value::String("d2205ca5-84b9-4950-a730-bfe550fc146b".to_string());
14362                node
14363            }])],
14364        };
14365
14366        let md = adf_to_markdown(&adf).unwrap();
14367        assert!(
14368            md.contains("style=bold"),
14369            "Markdown should contain style attr: {md}"
14370        );
14371        assert!(
14372            md.contains("localId=d2205ca5"),
14373            "Markdown should contain localId attr: {md}"
14374        );
14375
14376        let rt = markdown_to_adf(&md).unwrap();
14377        let status = &rt.content[0].content.as_ref().unwrap()[0];
14378        let attrs = status.attrs.as_ref().unwrap();
14379        assert_eq!(attrs["text"], "open");
14380        assert_eq!(attrs["color"], "green");
14381        assert_eq!(attrs["style"], "bold");
14382        assert_eq!(
14383            attrs["localId"], "d2205ca5-84b9-4950-a730-bfe550fc146b",
14384            "localId should be preserved, got: {}",
14385            attrs["localId"]
14386        );
14387    }
14388
14389    #[test]
14390    fn status_without_style_still_works() {
14391        let md = ":status[Done]{color=green}\n";
14392        let doc = markdown_to_adf(md).unwrap();
14393        let status = &doc.content[0].content.as_ref().unwrap()[0];
14394        let attrs = status.attrs.as_ref().unwrap();
14395        assert_eq!(attrs["text"], "Done");
14396        assert_eq!(attrs["color"], "green");
14397        // No style attr — should not be present
14398        assert!(
14399            attrs.get("style").is_none() || attrs["style"].is_null(),
14400            "style should not be set when not provided"
14401        );
14402    }
14403
14404    #[test]
14405    fn strip_local_ids_removes_localid_from_status() {
14406        let adf = AdfDocument {
14407            version: 1,
14408            doc_type: "doc".to_string(),
14409            content: vec![AdfNode::paragraph(vec![{
14410                let mut node = AdfNode::status("open", "green");
14411                node.attrs.as_mut().unwrap()["localId"] =
14412                    serde_json::Value::String("real-uuid-here".to_string());
14413                node
14414            }])],
14415        };
14416        let opts = RenderOptions {
14417            strip_local_ids: true,
14418        };
14419        let md = adf_to_markdown_with_options(&adf, &opts).unwrap();
14420        assert!(
14421            !md.contains("localId"),
14422            "localId should be stripped, got: {md}"
14423        );
14424        assert!(md.contains("color=green"), "color should be preserved");
14425    }
14426
14427    #[test]
14428    fn strip_local_ids_removes_localid_from_table() {
14429        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"layout":"default","localId":"table-uuid"},"content":[{"type":"tableRow","content":[{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
14430        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14431        let opts = RenderOptions {
14432            strip_local_ids: true,
14433        };
14434        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
14435        assert!(
14436            !md.contains("localId"),
14437            "localId should be stripped from table, got: {md}"
14438        );
14439        assert!(md.contains("layout=default"), "layout should be preserved");
14440    }
14441
14442    #[test]
14443    fn default_options_preserve_localid() {
14444        let adf = AdfDocument {
14445            version: 1,
14446            doc_type: "doc".to_string(),
14447            content: vec![AdfNode::paragraph(vec![{
14448                let mut node = AdfNode::status("open", "green");
14449                node.attrs.as_mut().unwrap()["localId"] =
14450                    serde_json::Value::String("real-uuid-here".to_string());
14451                node
14452            }])],
14453        };
14454        let md = adf_to_markdown(&adf).unwrap();
14455        assert!(
14456            md.contains("localId=real-uuid-here"),
14457            "Default should preserve localId, got: {md}"
14458        );
14459    }
14460
14461    #[test]
14462    fn mention_localid_roundtrip() {
14463        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
14464        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14465        let md = adf_to_markdown(&doc).unwrap();
14466        assert!(
14467            md.contains("localId=m-001"),
14468            "mention should have localId in md: {md}"
14469        );
14470        let rt = markdown_to_adf(&md).unwrap();
14471        let mention = &rt.content[0].content.as_ref().unwrap()[0];
14472        assert_eq!(mention.attrs.as_ref().unwrap()["localId"], "m-001");
14473    }
14474
14475    #[test]
14476    fn date_localid_roundtrip() {
14477        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
14478        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14479        let md = adf_to_markdown(&doc).unwrap();
14480        assert!(
14481            md.contains("localId=d-001"),
14482            "date should have localId in md: {md}"
14483        );
14484        let rt = markdown_to_adf(&md).unwrap();
14485        let date = &rt.content[0].content.as_ref().unwrap()[0];
14486        assert_eq!(date.attrs.as_ref().unwrap()["localId"], "d-001");
14487    }
14488
14489    #[test]
14490    fn emoji_localid_roundtrip() {
14491        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"emoji","attrs":{"shortName":":smile:","localId":"e-001"}}]}]}"#;
14492        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14493        let md = adf_to_markdown(&doc).unwrap();
14494        assert!(
14495            md.contains("localId=e-001"),
14496            "emoji should have localId in md: {md}"
14497        );
14498        let rt = markdown_to_adf(&md).unwrap();
14499        let emoji = &rt.content[0].content.as_ref().unwrap()[0];
14500        assert_eq!(emoji.attrs.as_ref().unwrap()["localId"], "e-001");
14501    }
14502
14503    #[test]
14504    fn inline_card_localid_roundtrip() {
14505        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"inlineCard","attrs":{"url":"https://example.com","localId":"c-001"}}]}]}"#;
14506        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14507        let md = adf_to_markdown(&doc).unwrap();
14508        assert!(
14509            md.contains("localId=c-001"),
14510            "inlineCard should have localId in md: {md}"
14511        );
14512        let rt = markdown_to_adf(&md).unwrap();
14513        let card = &rt.content[0].content.as_ref().unwrap()[0];
14514        assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-001");
14515    }
14516
14517    #[test]
14518    fn strip_local_ids_removes_from_mention() {
14519        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
14520        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14521        let opts = RenderOptions {
14522            strip_local_ids: true,
14523        };
14524        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
14525        assert!(
14526            !md.contains("localId"),
14527            "localId should be stripped from mention: {md}"
14528        );
14529        assert!(md.contains("id=user123"), "other attrs should be preserved");
14530    }
14531
14532    #[test]
14533    fn strip_local_ids_removes_from_date() {
14534        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
14535        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14536        let opts = RenderOptions {
14537            strip_local_ids: true,
14538        };
14539        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
14540        assert!(
14541            !md.contains("localId"),
14542            "localId should be stripped from date: {md}"
14543        );
14544    }
14545
14546    #[test]
14547    fn strip_local_ids_removes_from_block_attrs() {
14548        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"hello"}]}]}"#;
14549        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14550        let opts = RenderOptions {
14551            strip_local_ids: true,
14552        };
14553        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
14554        assert!(
14555            !md.contains("localId"),
14556            "localId should be stripped from block attrs: {md}"
14557        );
14558    }
14559
14560    #[test]
14561    fn table_cell_localid_roundtrip() {
14562        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"localId":"tc-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
14563        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14564        let md = adf_to_markdown(&doc).unwrap();
14565        assert!(
14566            md.contains("localId=tc-001"),
14567            "tableCell should have localId in md: {md}"
14568        );
14569        let rt = markdown_to_adf(&md).unwrap();
14570        let cell = &rt.content[0].content.as_ref().unwrap()[0]
14571            .content
14572            .as_ref()
14573            .unwrap()[0];
14574        assert_eq!(
14575            cell.attrs.as_ref().unwrap()["localId"],
14576            "tc-001",
14577            "tableCell localId should round-trip"
14578        );
14579    }
14580
14581    #[test]
14582    fn table_cell_border_mark_roundtrip() {
14583        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"marks":[{"type":"border","attrs":{"color":"#ff000033","size":2}}],"content":[{"type":"paragraph","content":[{"type":"text","text":"cell with border"}]}]}]}]}]}"##;
14584        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14585        let md = adf_to_markdown(&doc).unwrap();
14586        assert!(
14587            md.contains("border-color=#ff000033"),
14588            "tableCell should have border-color in md: {md}"
14589        );
14590        assert!(
14591            md.contains("border-size=2"),
14592            "tableCell should have border-size in md: {md}"
14593        );
14594        let rt = markdown_to_adf(&md).unwrap();
14595        let cell = &rt.content[0].content.as_ref().unwrap()[0]
14596            .content
14597            .as_ref()
14598            .unwrap()[0];
14599        let marks = cell.marks.as_ref().expect("tableCell should have marks");
14600        assert_eq!(marks.len(), 1);
14601        assert_eq!(marks[0].mark_type, "border");
14602        let attrs = marks[0].attrs.as_ref().unwrap();
14603        assert_eq!(attrs["color"], "#ff000033");
14604        assert_eq!(attrs["size"], 2);
14605    }
14606
14607    #[test]
14608    fn table_header_border_mark_roundtrip() {
14609        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableHeader","attrs":{},"marks":[{"type":"border","attrs":{"color":"#0000ff","size":3}}],"content":[{"type":"paragraph","content":[{"type":"text","text":"header"}]}]}]}]}]}"##;
14610        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14611        let md = adf_to_markdown(&doc).unwrap();
14612        assert!(md.contains("border-color=#0000ff"), "md: {md}");
14613        assert!(md.contains("border-size=3"), "md: {md}");
14614        let rt = markdown_to_adf(&md).unwrap();
14615        let cell = &rt.content[0].content.as_ref().unwrap()[0]
14616            .content
14617            .as_ref()
14618            .unwrap()[0];
14619        assert_eq!(cell.node_type, "tableHeader");
14620        let marks = cell.marks.as_ref().expect("tableHeader should have marks");
14621        assert_eq!(marks[0].mark_type, "border");
14622        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#0000ff");
14623        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
14624    }
14625
14626    #[test]
14627    fn table_cell_border_mark_with_attrs_roundtrip() {
14628        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"background":"#e6fcff","colspan":2},"marks":[{"type":"border","attrs":{"color":"#ff000033","size":1}}],"content":[{"type":"paragraph","content":[{"type":"text","text":"styled"}]}]}]}]}]}"##;
14629        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14630        let md = adf_to_markdown(&doc).unwrap();
14631        assert!(md.contains("bg=#e6fcff"), "md: {md}");
14632        assert!(md.contains("colspan=2"), "md: {md}");
14633        assert!(md.contains("border-color=#ff000033"), "md: {md}");
14634        let rt = markdown_to_adf(&md).unwrap();
14635        let cell = &rt.content[0].content.as_ref().unwrap()[0]
14636            .content
14637            .as_ref()
14638            .unwrap()[0];
14639        assert_eq!(cell.attrs.as_ref().unwrap()["background"], "#e6fcff");
14640        assert_eq!(cell.attrs.as_ref().unwrap()["colspan"], 2);
14641        let marks = cell.marks.as_ref().expect("should have marks");
14642        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff000033");
14643    }
14644
14645    #[test]
14646    fn table_cell_no_border_mark_unchanged() {
14647        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"plain"}]}]}]}]}]}"#;
14648        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14649        let md = adf_to_markdown(&doc).unwrap();
14650        assert!(
14651            !md.contains("border-color"),
14652            "no border attrs expected: {md}"
14653        );
14654        let rt = markdown_to_adf(&md).unwrap();
14655        let cell = &rt.content[0].content.as_ref().unwrap()[0]
14656            .content
14657            .as_ref()
14658            .unwrap()[0];
14659        assert!(cell.marks.is_none(), "no marks expected on plain cell");
14660    }
14661
14662    #[test]
14663    fn table_cell_border_size_only_defaults_color() {
14664        // border-size without border-color should still produce a border mark
14665        // with the default color
14666        let md = "::::table\n:::tr\n:::td{border-size=3}\ncell\n:::\n:::\n::::\n";
14667        let doc = markdown_to_adf(md).unwrap();
14668        let cell = &doc.content[0].content.as_ref().unwrap()[0]
14669            .content
14670            .as_ref()
14671            .unwrap()[0];
14672        let marks = cell.marks.as_ref().expect("should have border mark");
14673        assert_eq!(marks[0].mark_type, "border");
14674        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
14675        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
14676    }
14677
14678    #[test]
14679    fn table_cell_border_color_only_defaults_size() {
14680        // border-color without border-size should default size to 1
14681        let md = "::::table\n:::tr\n:::td{border-color=#ff0000}\ncell\n:::\n:::\n::::\n";
14682        let doc = markdown_to_adf(md).unwrap();
14683        let cell = &doc.content[0].content.as_ref().unwrap()[0]
14684            .content
14685            .as_ref()
14686            .unwrap()[0];
14687        let marks = cell.marks.as_ref().expect("should have border mark");
14688        assert_eq!(marks[0].mark_type, "border");
14689        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
14690        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
14691    }
14692
14693    #[test]
14694    fn media_file_border_mark_roundtrip() {
14695        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"mediaSingle","attrs":{"layout":"center","width":400,"widthType":"pixel"},"content":[{"type":"media","attrs":{"id":"aabbccdd-1234-5678-abcd-aabbccdd1234","type":"file","collection":"contentId-123456","width":800,"height":600},"marks":[{"type":"border","attrs":{"color":"#091e4224","size":2}}]}]}]}"##;
14696        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14697        let md = adf_to_markdown(&doc).unwrap();
14698        assert!(
14699            md.contains("border-color=#091e4224"),
14700            "media should have border-color in md: {md}"
14701        );
14702        assert!(
14703            md.contains("border-size=2"),
14704            "media should have border-size in md: {md}"
14705        );
14706        let rt = markdown_to_adf(&md).unwrap();
14707        let media_single = &rt.content[0];
14708        let media = &media_single.content.as_ref().unwrap()[0];
14709        assert_eq!(media.node_type, "media");
14710        let marks = media.marks.as_ref().expect("media should have marks");
14711        assert_eq!(marks.len(), 1);
14712        assert_eq!(marks[0].mark_type, "border");
14713        let attrs = marks[0].attrs.as_ref().unwrap();
14714        assert_eq!(attrs["color"], "#091e4224");
14715        assert_eq!(attrs["size"], 2);
14716    }
14717
14718    #[test]
14719    fn media_external_border_mark_roundtrip() {
14720        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"mediaSingle","attrs":{"layout":"center"},"content":[{"type":"media","attrs":{"type":"external","url":"https://example.com/img.png"},"marks":[{"type":"border","attrs":{"color":"#ff0000","size":3}}]}]}]}"##;
14721        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14722        let md = adf_to_markdown(&doc).unwrap();
14723        assert!(
14724            md.contains("border-color=#ff0000"),
14725            "external media should have border-color in md: {md}"
14726        );
14727        assert!(
14728            md.contains("border-size=3"),
14729            "external media should have border-size in md: {md}"
14730        );
14731        let rt = markdown_to_adf(&md).unwrap();
14732        let media = &rt.content[0].content.as_ref().unwrap()[0];
14733        let marks = media.marks.as_ref().expect("media should have marks");
14734        assert_eq!(marks[0].mark_type, "border");
14735        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
14736        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
14737    }
14738
14739    #[test]
14740    fn media_file_no_border_mark_unchanged() {
14741        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"mediaSingle","attrs":{"layout":"center"},"content":[{"type":"media","attrs":{"id":"abc-123","type":"file","collection":"col-1","width":100,"height":100}}]}]}"#;
14742        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14743        let md = adf_to_markdown(&doc).unwrap();
14744        assert!(
14745            !md.contains("border-color"),
14746            "no border attrs expected: {md}"
14747        );
14748        let rt = markdown_to_adf(&md).unwrap();
14749        let media = &rt.content[0].content.as_ref().unwrap()[0];
14750        assert!(media.marks.is_none(), "no marks expected on plain media");
14751    }
14752
14753    #[test]
14754    fn media_border_size_only_defaults_color() {
14755        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"mediaSingle","attrs":{"layout":"center"},"content":[{"type":"media","attrs":{"id":"abc","type":"file","collection":"col"},"marks":[{"type":"border","attrs":{"size":4}}]}]}]}"#;
14756        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14757        let md = adf_to_markdown(&doc).unwrap();
14758        assert!(md.contains("border-size=4"), "md: {md}");
14759        let rt = markdown_to_adf(&md).unwrap();
14760        let media = &rt.content[0].content.as_ref().unwrap()[0];
14761        let marks = media.marks.as_ref().expect("should have border mark");
14762        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
14763        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 4);
14764    }
14765
14766    #[test]
14767    fn media_border_color_only_defaults_size() {
14768        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"mediaSingle","attrs":{"layout":"center"},"content":[{"type":"media","attrs":{"id":"abc","type":"file","collection":"col"},"marks":[{"type":"border","attrs":{"color":"#00ff00"}}]}]}]}"##;
14769        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14770        let md = adf_to_markdown(&doc).unwrap();
14771        assert!(md.contains("border-color=#00ff00"), "md: {md}");
14772        let rt = markdown_to_adf(&md).unwrap();
14773        let media = &rt.content[0].content.as_ref().unwrap()[0];
14774        let marks = media.marks.as_ref().expect("should have border mark");
14775        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#00ff00");
14776        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
14777    }
14778
14779    #[test]
14780    fn media_border_with_other_attrs_roundtrip() {
14781        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"mediaSingle","attrs":{"layout":"wide","width":600,"widthType":"pixel"},"content":[{"type":"media","attrs":{"id":"xyz","type":"file","collection":"col","width":1200,"height":800},"marks":[{"type":"border","attrs":{"color":"#091e4224","size":2}}]}]}]}"##;
14782        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14783        let md = adf_to_markdown(&doc).unwrap();
14784        assert!(md.contains("layout=wide"), "md: {md}");
14785        assert!(md.contains("mediaWidth=600"), "md: {md}");
14786        assert!(md.contains("border-color=#091e4224"), "md: {md}");
14787        assert!(md.contains("border-size=2"), "md: {md}");
14788        let rt = markdown_to_adf(&md).unwrap();
14789        let ms = &rt.content[0];
14790        assert_eq!(ms.attrs.as_ref().unwrap()["layout"], "wide");
14791        let media = &ms.content.as_ref().unwrap()[0];
14792        let marks = media.marks.as_ref().expect("should have marks");
14793        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#091e4224");
14794        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 2);
14795    }
14796
14797    #[test]
14798    fn table_row_localid_roundtrip() {
14799        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{},"content":[{"type":"tableRow","attrs":{"localId":"tr-001"},"content":[{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
14800        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14801        let md = adf_to_markdown(&doc).unwrap();
14802        assert!(
14803            md.contains("localId=tr-001"),
14804            "tableRow should have localId in md: {md}"
14805        );
14806        let rt = markdown_to_adf(&md).unwrap();
14807        let row = &rt.content[0].content.as_ref().unwrap()[0];
14808        assert_eq!(
14809            row.attrs.as_ref().unwrap()["localId"],
14810            "tr-001",
14811            "tableRow localId should round-trip"
14812        );
14813    }
14814
14815    #[test]
14816    fn list_item_localid_roundtrip() {
14817        // listItem localId is emitted as trailing inline attrs and parsed back
14818        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"item"}]}]}]}]}"#;
14819        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14820        let md = adf_to_markdown(&doc).unwrap();
14821        assert!(
14822            md.contains("localId=li-001"),
14823            "listItem should have localId in md: {md}"
14824        );
14825        // Verify localId is on the listItem, NOT promoted to bulletList
14826        let rt = markdown_to_adf(&md).unwrap();
14827        let list = &rt.content[0];
14828        assert!(
14829            list.attrs.is_none() || list.attrs.as_ref().unwrap().get("localId").is_none(),
14830            "bulletList should NOT have localId: {:?}",
14831            list.attrs
14832        );
14833        let item = &list.content.as_ref().unwrap()[0];
14834        assert_eq!(
14835            item.attrs.as_ref().unwrap()["localId"],
14836            "li-001",
14837            "listItem should have localId=li-001"
14838        );
14839    }
14840
14841    #[test]
14842    fn list_item_localid_not_promoted_to_parent() {
14843        // Verify localId stays on listItem and doesn't leak to parent list
14844        let md = "- item {localId=li-002}\n";
14845        let doc = markdown_to_adf(md).unwrap();
14846        let list = &doc.content[0];
14847        assert!(
14848            list.attrs.is_none(),
14849            "bulletList should have no attrs: {:?}",
14850            list.attrs
14851        );
14852        let item = &list.content.as_ref().unwrap()[0];
14853        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "li-002");
14854    }
14855
14856    #[test]
14857    fn ordered_list_item_localid_roundtrip() {
14858        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","attrs":{"localId":"oli-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]}]}]}"#;
14859        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14860        let md = adf_to_markdown(&doc).unwrap();
14861        assert!(md.contains("localId=oli-001"), "md: {md}");
14862        let rt = markdown_to_adf(&md).unwrap();
14863        let item = &rt.content[0].content.as_ref().unwrap()[0];
14864        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
14865    }
14866
14867    #[test]
14868    fn task_item_localid_roundtrip() {
14869        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"task"}]}]}]}]}"#;
14870        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14871        let md = adf_to_markdown(&doc).unwrap();
14872        assert!(md.contains("localId=ti-001"), "md: {md}");
14873        let rt = markdown_to_adf(&md).unwrap();
14874        let item = &rt.content[0].content.as_ref().unwrap()[0];
14875        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "ti-001");
14876    }
14877
14878    /// Issue #447: taskList with empty-string localId and taskItems with
14879    /// short numeric localIds must survive a full round-trip.
14880    #[test]
14881    fn task_list_short_localid_roundtrip() {
14882        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":""},"content":[{"type":"taskItem","attrs":{"localId":"42","state":"TODO"}},{"type":"taskItem","attrs":{"localId":"99","state":"DONE"},"content":[{"type":"text","text":"done task"}]}]}]}"#;
14883        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14884        let md = adf_to_markdown(&doc).unwrap();
14885        // Both taskItem localIds should appear in the markdown
14886        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
14887        assert!(md.contains("localId=99"), "localId=99 missing: {md}");
14888        // Empty-string localId should NOT appear as {localId=}
14889        assert!(
14890            !md.contains("localId=}"),
14891            "empty localId should not be emitted: {md}"
14892        );
14893        let rt = markdown_to_adf(&md).unwrap();
14894        let task_list = &rt.content[0];
14895        assert_eq!(task_list.node_type, "taskList");
14896        // No spurious extra nodes from {localId=}
14897        assert_eq!(rt.content.len(), 1, "should be exactly one top-level node");
14898        let items = task_list.content.as_ref().unwrap();
14899        assert_eq!(items.len(), 2);
14900        // First taskItem: localId=42, state=TODO, no content
14901        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
14902        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
14903        assert!(
14904            items[0].content.is_none(),
14905            "empty taskItem should have no content: {:?}",
14906            items[0].content
14907        );
14908        // Second taskItem: localId=99, state=DONE, content with text
14909        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "99");
14910        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
14911        let content = items[1].content.as_ref().unwrap();
14912        assert_eq!(content.len(), 1);
14913        assert_eq!(content[0].text.as_deref(), Some("done task"));
14914    }
14915
14916    /// Issue #507: numeric localId on taskItem with hardBreak must survive
14917    /// round-trip — the {localId=…} suffix lands on the continuation line
14918    /// and must still be extracted by the parser.
14919    #[test]
14920    fn task_item_numeric_localid_with_hardbreak_roundtrip() {
14921        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":""},"content":[{"type":"taskItem","attrs":{"localId":"42","state":"DONE"},"content":[{"type":"paragraph","content":[{"type":"text","text":"Engineering Onboarding Link","marks":[{"type":"link","attrs":{"href":"https://example.com/onboarding"}}]},{"type":"hardBreak"},{"type":"text","text":"(This has links to all the various useful tools!!)"}]}]}]}]}"#;
14922        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14923        let md = adf_to_markdown(&doc).unwrap();
14924        // localId must appear in the markdown output
14925        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
14926        // Round-trip back to ADF
14927        let rt = markdown_to_adf(&md).unwrap();
14928        assert_eq!(rt.content.len(), 1, "exactly one top-level node");
14929        let task_list = &rt.content[0];
14930        assert_eq!(task_list.node_type, "taskList");
14931        let items = task_list.content.as_ref().unwrap();
14932        assert_eq!(items.len(), 1);
14933        // localId preserved
14934        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
14935        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
14936        // Content structure preserved: paragraph with link + hardBreak + text
14937        let para = &items[0].content.as_ref().unwrap()[0];
14938        assert_eq!(para.node_type, "paragraph");
14939        let inlines = para.content.as_ref().unwrap();
14940        assert_eq!(inlines[0].node_type, "text");
14941        assert_eq!(
14942            inlines[0].text.as_deref(),
14943            Some("Engineering Onboarding Link")
14944        );
14945        assert_eq!(inlines[1].node_type, "hardBreak");
14946        assert_eq!(inlines[2].node_type, "text");
14947        assert_eq!(
14948            inlines[2].text.as_deref(),
14949            Some("(This has links to all the various useful tools!!)")
14950        );
14951        // The {localId=…} must not appear as literal text in the ADF output
14952        let rt_json = serde_json::to_string(&rt).unwrap();
14953        assert!(
14954            !rt_json.contains("{localId="),
14955            "localId attr syntax should not leak into ADF text: {rt_json}"
14956        );
14957    }
14958
14959    /// Issue #507: multiple taskItems with hardBreaks and numeric localIds.
14960    #[test]
14961    fn task_item_multiple_hardbreak_localids_roundtrip() {
14962        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":""},"content":[{"type":"taskItem","attrs":{"localId":"42","state":"DONE"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first line"},{"type":"hardBreak"},{"type":"text","text":"second line"}]}]},{"type":"taskItem","attrs":{"localId":"67","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"alpha"},{"type":"hardBreak"},{"type":"text","text":"beta"}]}]}]}]}"#;
14963        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14964        let md = adf_to_markdown(&doc).unwrap();
14965        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
14966        assert!(md.contains("localId=67"), "localId=67 missing: {md}");
14967        let rt = markdown_to_adf(&md).unwrap();
14968        let items = rt.content[0].content.as_ref().unwrap();
14969        assert_eq!(items.len(), 2);
14970        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
14971        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "67");
14972        // Verify hardBreak content structure for both items
14973        for item in items {
14974            let para = &item.content.as_ref().unwrap()[0];
14975            assert_eq!(para.node_type, "paragraph");
14976            let inlines = para.content.as_ref().unwrap();
14977            assert_eq!(inlines[1].node_type, "hardBreak");
14978        }
14979    }
14980
14981    /// Issue #521: sibling taskItems with numeric localIds and hardBreak —
14982    /// unwrapped inline content.  The hardBreak continuation line must be
14983    /// indented so it stays within the list item, and both localIds must
14984    /// survive the round-trip.
14985    #[test]
14986    fn task_item_sibling_localid_hardbreak_unwrapped_roundtrip() {
14987        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":""},"content":[{"type":"taskItem","attrs":{"localId":"42","state":"DONE"},"content":[{"type":"text","text":"link text","marks":[{"type":"link","attrs":{"href":"https://example.com/page"}}]},{"type":"hardBreak"},{"type":"text","text":"(parenthetical note after hard break)"}]},{"type":"taskItem","attrs":{"localId":"69","state":"DONE"},"content":[{"type":"text","text":"second task item"}]}]}]}"#;
14988        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14989        let md = adf_to_markdown(&doc).unwrap();
14990        // Continuation line must be indented
14991        assert!(
14992            md.contains("  (parenthetical"),
14993            "continuation line should be 2-space indented: {md}"
14994        );
14995        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
14996        assert!(md.contains("localId=69"), "localId=69 missing: {md}");
14997        let rt = markdown_to_adf(&md).unwrap();
14998        // Must remain a single taskList with 2 items
14999        assert_eq!(
15000            rt.content.len(),
15001            1,
15002            "should be one taskList: {:#?}",
15003            rt.content
15004        );
15005        assert_eq!(rt.content[0].node_type, "taskList");
15006        let items = rt.content[0].content.as_ref().unwrap();
15007        assert_eq!(items.len(), 2, "should have 2 taskItems");
15008        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
15009        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
15010        // Verify first item has hardBreak
15011        let first_content = items[0].content.as_ref().unwrap();
15012        assert!(
15013            first_content.iter().any(|n| n.node_type == "hardBreak"),
15014            "first item should contain hardBreak"
15015        );
15016        // Verify second item content
15017        let second_content = items[1].content.as_ref().unwrap();
15018        assert_eq!(second_content[0].node_type, "text");
15019        assert_eq!(
15020            second_content[0].text.as_deref().unwrap(),
15021            "second task item"
15022        );
15023    }
15024
15025    /// Issue #521: sibling taskItems with paragraph-wrapped content and
15026    /// hardBreak — localIds must not be swapped or lost.
15027    #[test]
15028    fn task_item_sibling_localid_hardbreak_paragraph_roundtrip() {
15029        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":""},"content":[{"type":"taskItem","attrs":{"localId":"42","state":"DONE"},"content":[{"type":"paragraph","content":[{"type":"text","text":"link text","marks":[{"type":"link","attrs":{"href":"https://example.com/page"}}]},{"type":"hardBreak"},{"type":"text","text":"(parenthetical note after hard break)"}]}]},{"type":"taskItem","attrs":{"localId":"69","state":"DONE"},"content":[{"type":"paragraph","content":[{"type":"text","text":"second task item"}]}]}]}]}"#;
15030        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15031        let md = adf_to_markdown(&doc).unwrap();
15032        let rt = markdown_to_adf(&md).unwrap();
15033        assert_eq!(
15034            rt.content.len(),
15035            1,
15036            "should be one taskList: {:#?}",
15037            rt.content
15038        );
15039        let items = rt.content[0].content.as_ref().unwrap();
15040        assert_eq!(items.len(), 2);
15041        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
15042        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
15043    }
15044
15045    /// Issue #521: three sibling taskItems — the middle one has a hardBreak.
15046    /// Ensures localIds don't leak between adjacent items.
15047    #[test]
15048    fn task_item_three_siblings_middle_hardbreak_roundtrip() {
15049        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":""},"content":[{"type":"taskItem","attrs":{"localId":"10","state":"TODO"},"content":[{"type":"text","text":"first"}]},{"type":"taskItem","attrs":{"localId":"20","state":"DONE"},"content":[{"type":"text","text":"alpha"},{"type":"hardBreak"},{"type":"text","text":"beta"}]},{"type":"taskItem","attrs":{"localId":"30","state":"TODO"},"content":[{"type":"text","text":"third"}]}]}]}"#;
15050        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15051        let md = adf_to_markdown(&doc).unwrap();
15052        let rt = markdown_to_adf(&md).unwrap();
15053        assert_eq!(rt.content.len(), 1);
15054        let items = rt.content[0].content.as_ref().unwrap();
15055        assert_eq!(items.len(), 3);
15056        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "10");
15057        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "20");
15058        assert_eq!(items[2].attrs.as_ref().unwrap()["localId"], "30");
15059        // Middle item should have hardBreak
15060        let mid_content = items[1].content.as_ref().unwrap();
15061        assert!(mid_content.iter().any(|n| n.node_type == "hardBreak"));
15062    }
15063
15064    /// Issue #447: regression — taskList with empty localId must not inject
15065    /// a spurious paragraph.
15066    #[test]
15067    fn task_list_empty_localid_no_spurious_paragraph() {
15068        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":""},"content":[{"type":"taskItem","attrs":{"localId":"tsk-1","state":"DONE"},"content":[{"type":"text","text":"completed item"}]}]}]}"#;
15069        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15070        let md = adf_to_markdown(&doc).unwrap();
15071        assert!(
15072            !md.contains("{localId=}"),
15073            "empty localId should not be emitted: {md}"
15074        );
15075        let rt = markdown_to_adf(&md).unwrap();
15076        assert_eq!(
15077            rt.content.len(),
15078            1,
15079            "no spurious paragraph: {:#?}",
15080            rt.content
15081        );
15082        assert_eq!(rt.content[0].node_type, "taskList");
15083    }
15084
15085    /// Issue #447: taskList localId should be stripped when strip_local_ids is set.
15086    #[test]
15087    fn task_list_localid_stripped() {
15088        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"task"}]}]}]}]}"#;
15089        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15090        let opts = RenderOptions {
15091            strip_local_ids: true,
15092        };
15093        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
15094        assert!(!md.contains("localId"), "localId should be stripped: {md}");
15095    }
15096
15097    /// Issue #447: taskItem with no content still emits localId.
15098    #[test]
15099    fn task_item_no_content_emits_localid() {
15100        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"00000000-0000-0000-0000-000000000000"},"content":[{"type":"taskItem","attrs":{"localId":"abc","state":"TODO"}}]}]}"#;
15101        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15102        let md = adf_to_markdown(&doc).unwrap();
15103        assert!(
15104            md.contains("localId=abc"),
15105            "localId should be emitted even without content: {md}"
15106        );
15107        let rt = markdown_to_adf(&md).unwrap();
15108        let item = &rt.content[0].content.as_ref().unwrap()[0];
15109        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "abc");
15110        assert!(item.content.is_none(), "should have no content");
15111    }
15112
15113    /// Issue #447: taskList localId roundtrips through block attrs.
15114    #[test]
15115    fn task_list_localid_roundtrip() {
15116        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-xyz"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"task"}]}]}]}]}"#;
15117        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15118        let md = adf_to_markdown(&doc).unwrap();
15119        assert!(
15120            md.contains("localId=tl-xyz"),
15121            "taskList localId missing: {md}"
15122        );
15123        let rt = markdown_to_adf(&md).unwrap();
15124        assert_eq!(
15125            rt.content[0].attrs.as_ref().unwrap()["localId"],
15126            "tl-xyz",
15127            "taskList localId should survive round-trip"
15128        );
15129    }
15130
15131    /// Issue #478: taskItem with paragraph wrapper (no localId) preserves wrapper on round-trip.
15132    #[test]
15133    fn task_item_paragraph_wrapper_roundtrip_no_localid() {
15134        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"A task with paragraph wrapper"}]}]}]}]}"#;
15135        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15136        let md = adf_to_markdown(&doc).unwrap();
15137        assert!(
15138            md.contains("paraLocalId=_"),
15139            "should emit paraLocalId=_ sentinel: {md}"
15140        );
15141        let rt = markdown_to_adf(&md).unwrap();
15142        let item = &rt.content[0].content.as_ref().unwrap()[0];
15143        let content = item.content.as_ref().unwrap();
15144        assert_eq!(content.len(), 1, "should have one child: {content:#?}");
15145        assert_eq!(
15146            content[0].node_type, "paragraph",
15147            "child should be a paragraph: {content:#?}"
15148        );
15149        let para_content = content[0].content.as_ref().unwrap();
15150        assert_eq!(
15151            para_content[0].text.as_deref(),
15152            Some("A task with paragraph wrapper")
15153        );
15154        // Paragraph should have no attrs (localId was absent in the original)
15155        assert!(
15156            content[0].attrs.is_none(),
15157            "paragraph should have no attrs: {:?}",
15158            content[0].attrs
15159        );
15160    }
15161
15162    /// Issue #478: taskItem with paragraph wrapper AND paraLocalId preserves both.
15163    #[test]
15164    fn task_item_paragraph_wrapper_roundtrip_with_localid() {
15165        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"task with para id"}]}]}]}]}"#;
15166        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15167        let md = adf_to_markdown(&doc).unwrap();
15168        assert!(
15169            md.contains("paraLocalId=p-001"),
15170            "should emit paraLocalId=p-001: {md}"
15171        );
15172        let rt = markdown_to_adf(&md).unwrap();
15173        let item = &rt.content[0].content.as_ref().unwrap()[0];
15174        let content = item.content.as_ref().unwrap();
15175        assert_eq!(content[0].node_type, "paragraph");
15176        assert_eq!(
15177            content[0].attrs.as_ref().unwrap()["localId"],
15178            "p-001",
15179            "paragraph localId should be preserved"
15180        );
15181    }
15182
15183    /// Issue #478: taskItem WITHOUT paragraph wrapper (unwrapped inline) still round-trips correctly.
15184    #[test]
15185    fn task_item_unwrapped_inline_no_paragraph_on_roundtrip() {
15186        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"text","text":"unwrapped task"}]}]}]}"#;
15187        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15188        let md = adf_to_markdown(&doc).unwrap();
15189        assert!(
15190            !md.contains("paraLocalId"),
15191            "should NOT emit paraLocalId for unwrapped inline: {md}"
15192        );
15193        let rt = markdown_to_adf(&md).unwrap();
15194        let item = &rt.content[0].content.as_ref().unwrap()[0];
15195        let content = item.content.as_ref().unwrap();
15196        assert_eq!(
15197            content[0].node_type, "text",
15198            "should remain unwrapped: {content:#?}"
15199        );
15200    }
15201
15202    /// Issue #478: DONE taskItem with paragraph wrapper round-trips.
15203    #[test]
15204    fn task_item_done_paragraph_wrapper_roundtrip() {
15205        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"DONE"},"content":[{"type":"paragraph","content":[{"type":"text","text":"completed task"}]}]}]}]}"#;
15206        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15207        let md = adf_to_markdown(&doc).unwrap();
15208        assert!(md.contains("- [x]"), "should render as done: {md}");
15209        let rt = markdown_to_adf(&md).unwrap();
15210        let item = &rt.content[0].content.as_ref().unwrap()[0];
15211        assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
15212        let content = item.content.as_ref().unwrap();
15213        assert_eq!(content[0].node_type, "paragraph");
15214    }
15215
15216    /// Issue #478: mixed taskItems — some with paragraph wrapper, some without.
15217    #[test]
15218    fn task_item_mixed_paragraph_and_unwrapped_roundtrip() {
15219        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"wrapped"}]}]},{"type":"taskItem","attrs":{"localId":"ti-002","state":"DONE"},"content":[{"type":"text","text":"unwrapped"}]}]}]}"#;
15220        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15221        let md = adf_to_markdown(&doc).unwrap();
15222        let rt = markdown_to_adf(&md).unwrap();
15223        let items = rt.content[0].content.as_ref().unwrap();
15224        assert_eq!(items.len(), 2);
15225        // First item: paragraph wrapper preserved
15226        let c1 = items[0].content.as_ref().unwrap();
15227        assert_eq!(
15228            c1[0].node_type, "paragraph",
15229            "first item should have paragraph wrapper"
15230        );
15231        // Second item: no paragraph wrapper
15232        let c2 = items[1].content.as_ref().unwrap();
15233        assert_eq!(
15234            c2[0].node_type, "text",
15235            "second item should remain unwrapped"
15236        );
15237    }
15238
15239    /// Issue #478: taskItem with paragraph wrapper containing marks round-trips.
15240    #[test]
15241    fn task_item_paragraph_wrapper_with_marks_roundtrip() {
15242        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"bold "},{"type":"text","text":"text","marks":[{"type":"strong"}]}]}]}]}]}"#;
15243        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15244        let md = adf_to_markdown(&doc).unwrap();
15245        let rt = markdown_to_adf(&md).unwrap();
15246        let item = &rt.content[0].content.as_ref().unwrap()[0];
15247        let content = item.content.as_ref().unwrap();
15248        assert_eq!(content[0].node_type, "paragraph");
15249        let para_children = content[0].content.as_ref().unwrap();
15250        assert!(
15251            para_children.len() >= 2,
15252            "paragraph should contain multiple inline nodes"
15253        );
15254    }
15255
15256    /// Issue #478: strip_local_ids suppresses the paraLocalId=_ sentinel too.
15257    #[test]
15258    fn task_item_paragraph_wrapper_stripped_with_option() {
15259        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"task"}]}]}]}]}"#;
15260        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15261        let opts = RenderOptions {
15262            strip_local_ids: true,
15263        };
15264        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
15265        assert!(
15266            !md.contains("paraLocalId"),
15267            "paraLocalId should be stripped: {md}"
15268        );
15269        assert!(
15270            !md.contains("localId"),
15271            "all localIds should be stripped: {md}"
15272        );
15273    }
15274
15275    #[test]
15276    fn trailing_space_preserved_with_hex_localid() {
15277        // Issue #449: trailing whitespace stripped from text node
15278        // when listItem has a hex-format localId (no hyphens)
15279        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","attrs":{"localId":"aabb112233cc"},"content":[{"type":"paragraph","content":[{"type":"text","text":"trailing space "}]}]}]}]}"#;
15280        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15281        let md = adf_to_markdown(&doc).unwrap();
15282        let rt = markdown_to_adf(&md).unwrap();
15283        let item = &rt.content[0].content.as_ref().unwrap()[0];
15284        assert_eq!(
15285            item.attrs.as_ref().unwrap()["localId"],
15286            "aabb112233cc",
15287            "localId should round-trip"
15288        );
15289        let para = &item.content.as_ref().unwrap()[0];
15290        let inlines = para.content.as_ref().unwrap();
15291        let last = inlines.last().unwrap();
15292        assert!(
15293            last.text.as_deref().unwrap_or("").ends_with(' '),
15294            "trailing space should be preserved, got nodes: {:?}",
15295            inlines
15296                .iter()
15297                .map(|n| (&n.node_type, &n.text))
15298                .collect::<Vec<_>>()
15299        );
15300    }
15301
15302    #[test]
15303    fn extract_trailing_local_id_preserves_trailing_space() {
15304        // Issue #449: only strip the single separator space before {localId=...}
15305        let (before, lid, _) = extract_trailing_local_id("trailing space  {localId=aabb112233cc}");
15306        assert_eq!(before, "trailing space ");
15307        assert_eq!(lid.as_deref(), Some("aabb112233cc"));
15308    }
15309
15310    #[test]
15311    fn extract_trailing_local_id_no_trailing_space() {
15312        let (before, lid, _) = extract_trailing_local_id("text {localId=abc123}");
15313        assert_eq!(before, "text");
15314        assert_eq!(lid.as_deref(), Some("abc123"));
15315    }
15316
15317    #[test]
15318    fn extract_trailing_local_id_no_attrs() {
15319        let (before, lid, pid) = extract_trailing_local_id("plain text");
15320        assert_eq!(before, "plain text");
15321        assert!(lid.is_none());
15322        assert!(pid.is_none());
15323    }
15324
15325    #[test]
15326    fn list_item_localid_stripped() {
15327        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"item"}]}]}]}]}"#;
15328        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15329        let opts = RenderOptions {
15330            strip_local_ids: true,
15331        };
15332        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
15333        assert!(!md.contains("localId"), "localId should be stripped: {md}");
15334    }
15335
15336    #[test]
15337    fn paragraph_localid_in_list_item_roundtrip() {
15338        // Issue #417: paragraph.attrs.localId dropped in listItem context
15339        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","attrs":{"localId":"list-001"},"content":[{"type":"listItem","attrs":{"localId":"item-001"},"content":[{"type":"paragraph","attrs":{"localId":"para-001"},"content":[{"type":"text","text":"item text"}]}]}]}]}"#;
15340        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15341        let md = adf_to_markdown(&doc).unwrap();
15342        assert!(
15343            md.contains("paraLocalId=para-001"),
15344            "paragraph localId should be in md: {md}"
15345        );
15346        let rt = markdown_to_adf(&md).unwrap();
15347        let item = &rt.content[0].content.as_ref().unwrap()[0];
15348        assert_eq!(
15349            item.attrs.as_ref().unwrap()["localId"],
15350            "item-001",
15351            "listItem localId should survive"
15352        );
15353        let para = &item.content.as_ref().unwrap()[0];
15354        assert_eq!(
15355            para.attrs.as_ref().unwrap()["localId"],
15356            "para-001",
15357            "paragraph localId should survive round-trip"
15358        );
15359    }
15360
15361    #[test]
15362    fn paragraph_localid_in_ordered_list_item_roundtrip() {
15363        // Issue #417: paragraph localId in ordered list
15364        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","attrs":{"localId":"oli-001"},"content":[{"type":"paragraph","attrs":{"localId":"op-001"},"content":[{"type":"text","text":"first"}]}]}]}]}"#;
15365        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15366        let md = adf_to_markdown(&doc).unwrap();
15367        assert!(md.contains("paraLocalId=op-001"), "md: {md}");
15368        let rt = markdown_to_adf(&md).unwrap();
15369        let item = &rt.content[0].content.as_ref().unwrap()[0];
15370        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
15371        let para = &item.content.as_ref().unwrap()[0];
15372        assert_eq!(para.attrs.as_ref().unwrap()["localId"], "op-001");
15373    }
15374
15375    #[test]
15376    fn paragraph_localid_only_in_list_item() {
15377        // paragraph has localId but listItem does not
15378        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","attrs":{"localId":"para-only"},"content":[{"type":"text","text":"text"}]}]}]}]}"#;
15379        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15380        let md = adf_to_markdown(&doc).unwrap();
15381        assert!(
15382            md.contains("paraLocalId=para-only"),
15383            "paragraph localId should be emitted: {md}"
15384        );
15385        let rt = markdown_to_adf(&md).unwrap();
15386        let item = &rt.content[0].content.as_ref().unwrap()[0];
15387        assert!(item.attrs.is_none(), "listItem should have no attrs");
15388        let para = &item.content.as_ref().unwrap()[0];
15389        assert_eq!(para.attrs.as_ref().unwrap()["localId"], "para-only");
15390    }
15391
15392    #[test]
15393    fn paragraph_localid_in_table_header_roundtrip() {
15394        // Issue #417: paragraph.attrs.localId dropped in tableHeader context
15395        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableHeader","attrs":{},"content":[{"type":"paragraph","attrs":{"localId":"aaaa-aaaa"},"content":[{"type":"text","text":"hello"}]}]}]}]}]}"#;
15396        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15397        let md = adf_to_markdown(&doc).unwrap();
15398        // Should use directive form (not pipe table) to preserve paragraph localId
15399        assert!(
15400            md.contains("localId=aaaa-aaaa"),
15401            "paragraph localId should be in md: {md}"
15402        );
15403        let rt = markdown_to_adf(&md).unwrap();
15404        let cell = &rt.content[0].content.as_ref().unwrap()[0]
15405            .content
15406            .as_ref()
15407            .unwrap()[0];
15408        let para = &cell.content.as_ref().unwrap()[0];
15409        assert_eq!(
15410            para.attrs.as_ref().unwrap()["localId"],
15411            "aaaa-aaaa",
15412            "paragraph localId should survive round-trip in tableHeader"
15413        );
15414    }
15415
15416    #[test]
15417    fn paragraph_localid_in_table_cell_roundtrip() {
15418        // Issue #417: paragraph localId in tableCell forces directive table
15419        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableHeader","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"header"}]}]}]},{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","attrs":{"localId":"cell-para"},"content":[{"type":"text","text":"data"}]}]}]}]}]}"#;
15420        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15421        let md = adf_to_markdown(&doc).unwrap();
15422        assert!(
15423            md.contains("localId=cell-para"),
15424            "paragraph localId should be in md: {md}"
15425        );
15426        let rt = markdown_to_adf(&md).unwrap();
15427        // Data row -> cell -> paragraph
15428        let cell = &rt.content[0].content.as_ref().unwrap()[1]
15429            .content
15430            .as_ref()
15431            .unwrap()[0];
15432        let para = &cell.content.as_ref().unwrap()[0];
15433        assert_eq!(
15434            para.attrs.as_ref().unwrap()["localId"],
15435            "cell-para",
15436            "paragraph localId should survive round-trip in tableCell"
15437        );
15438    }
15439
15440    #[test]
15441    fn nbsp_paragraph_with_localid_roundtrip() {
15442        // Issue #417: nbsp paragraph localId emitted as text instead of attrs
15443        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"nbsp-para"},"content":[{"type":"text","text":"\u00a0"}]}]}"#;
15444        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15445        let md = adf_to_markdown(&doc).unwrap();
15446        assert!(
15447            md.contains("::paragraph["),
15448            "nbsp should use directive form: {md}"
15449        );
15450        assert!(
15451            md.contains("localId=nbsp-para"),
15452            "localId should be in directive: {md}"
15453        );
15454        let rt = markdown_to_adf(&md).unwrap();
15455        let para = &rt.content[0];
15456        assert_eq!(
15457            para.attrs.as_ref().unwrap()["localId"],
15458            "nbsp-para",
15459            "localId should survive round-trip"
15460        );
15461        let text = para.content.as_ref().unwrap()[0].text.as_ref().unwrap();
15462        assert_eq!(text, "\u{00a0}", "nbsp should survive");
15463    }
15464
15465    #[test]
15466    fn empty_paragraph_with_localid_roundtrip() {
15467        // Empty paragraph directive with localId
15468        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"empty-para"}}]}"#;
15469        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15470        let md = adf_to_markdown(&doc).unwrap();
15471        assert!(
15472            md.contains("::paragraph{localId=empty-para}"),
15473            "empty paragraph should include localId in directive: {md}"
15474        );
15475        let rt = markdown_to_adf(&md).unwrap();
15476        assert_eq!(
15477            rt.content[0].attrs.as_ref().unwrap()["localId"],
15478            "empty-para"
15479        );
15480    }
15481
15482    #[test]
15483    fn paragraph_localid_stripped_from_list_item() {
15484        // strip_local_ids should also strip paraLocalId
15485        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"item"}]}]}]}]}"#;
15486        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15487        let opts = RenderOptions {
15488            strip_local_ids: true,
15489        };
15490        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
15491        assert!(!md.contains("localId"), "localId should be stripped: {md}");
15492        assert!(
15493            !md.contains("paraLocalId"),
15494            "paraLocalId should be stripped: {md}"
15495        );
15496    }
15497
15498    #[test]
15499    fn date_directive() {
15500        let doc = markdown_to_adf("Due by :date[2026-04-15].").unwrap();
15501        let content = doc.content[0].content.as_ref().unwrap();
15502        assert_eq!(content[1].node_type, "date");
15503        // ISO date is converted to epoch milliseconds
15504        assert_eq!(
15505            content[1].attrs.as_ref().unwrap()["timestamp"],
15506            "1776211200000"
15507        );
15508    }
15509
15510    #[test]
15511    fn adf_date_to_markdown() {
15512        // ADF dates use epoch ms; renderer converts back to ISO with timestamp attr
15513        let doc = AdfDocument {
15514            version: 1,
15515            doc_type: "doc".to_string(),
15516            content: vec![AdfNode::paragraph(vec![AdfNode::date("1776211200000")])],
15517        };
15518        let md = adf_to_markdown(&doc).unwrap();
15519        assert!(md.contains(":date[2026-04-15]{timestamp=1776211200000}"));
15520    }
15521
15522    #[test]
15523    fn adf_date_iso_passthrough() {
15524        // If ADF already has ISO date (legacy), pass through
15525        let doc = AdfDocument {
15526            version: 1,
15527            doc_type: "doc".to_string(),
15528            content: vec![AdfNode::paragraph(vec![AdfNode::date("2026-04-15")])],
15529        };
15530        let md = adf_to_markdown(&doc).unwrap();
15531        assert!(md.contains(":date[2026-04-15]{timestamp=2026-04-15}"));
15532    }
15533
15534    #[test]
15535    fn round_trip_date() {
15536        let md = "Due by :date[2026-04-15].\n";
15537        let doc = markdown_to_adf(md).unwrap();
15538        let result = adf_to_markdown(&doc).unwrap();
15539        assert!(result.contains(":date[2026-04-15]"));
15540    }
15541
15542    #[test]
15543    fn round_trip_date_non_midnight_timestamp() {
15544        // Issue #409: non-midnight timestamps must survive round-trip
15545        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000"}}]}]}"#;
15546        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15547        let md = adf_to_markdown(&doc).unwrap();
15548        // JFM should include the original timestamp
15549        assert!(
15550            md.contains("timestamp=1700000000000"),
15551            "JFM should preserve original timestamp: {md}"
15552        );
15553        // Round-trip back to ADF
15554        let doc2 = markdown_to_adf(&md).unwrap();
15555        let content = doc2.content[0].content.as_ref().unwrap();
15556        assert_eq!(
15557            content[0].attrs.as_ref().unwrap()["timestamp"],
15558            "1700000000000",
15559            "Round-trip must preserve original non-midnight timestamp"
15560        );
15561    }
15562
15563    #[test]
15564    fn date_epoch_ms_passthrough() {
15565        // If JFM date is already epoch ms, pass through
15566        let doc = markdown_to_adf("Due by :date[1776211200000].").unwrap();
15567        let content = doc.content[0].content.as_ref().unwrap();
15568        assert_eq!(
15569            content[1].attrs.as_ref().unwrap()["timestamp"],
15570            "1776211200000"
15571        );
15572    }
15573
15574    #[test]
15575    fn date_timestamp_attr_preferred_over_content() {
15576        // When timestamp attr is present, it takes priority over the display date
15577        let md = ":date[2023-11-14]{timestamp=1700000000000}\n";
15578        let doc = markdown_to_adf(md).unwrap();
15579        let content = doc.content[0].content.as_ref().unwrap();
15580        assert_eq!(
15581            content[0].attrs.as_ref().unwrap()["timestamp"],
15582            "1700000000000",
15583            "timestamp attr should be used directly"
15584        );
15585    }
15586
15587    #[test]
15588    fn date_without_timestamp_attr_backward_compat() {
15589        // Legacy JFM without timestamp attr still works via iso_date_to_epoch_ms
15590        let md = ":date[2026-04-15]\n";
15591        let doc = markdown_to_adf(md).unwrap();
15592        let content = doc.content[0].content.as_ref().unwrap();
15593        assert_eq!(
15594            content[0].attrs.as_ref().unwrap()["timestamp"],
15595            "1776211200000",
15596            "Should fall back to computing timestamp from date string"
15597        );
15598    }
15599
15600    #[test]
15601    fn date_with_local_id_and_timestamp() {
15602        // Both localId and timestamp should round-trip
15603        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
15604        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15605        let md = adf_to_markdown(&doc).unwrap();
15606        assert!(
15607            md.contains("timestamp=1700000000000"),
15608            "Should contain timestamp: {md}"
15609        );
15610        assert!(md.contains("localId=d-001"), "Should contain localId: {md}");
15611        // Round-trip
15612        let doc2 = markdown_to_adf(&md).unwrap();
15613        let content = doc2.content[0].content.as_ref().unwrap();
15614        let attrs = content[0].attrs.as_ref().unwrap();
15615        assert_eq!(attrs["timestamp"], "1700000000000");
15616        assert_eq!(attrs["localId"], "d-001");
15617    }
15618
15619    #[test]
15620    fn mention_directive() {
15621        let doc = markdown_to_adf("Assigned to :mention[Alice]{id=abc123}.").unwrap();
15622        let content = doc.content[0].content.as_ref().unwrap();
15623        assert_eq!(content[1].node_type, "mention");
15624        assert_eq!(content[1].attrs.as_ref().unwrap()["id"], "abc123");
15625        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "Alice");
15626    }
15627
15628    #[test]
15629    fn adf_mention_to_markdown() {
15630        let doc = AdfDocument {
15631            version: 1,
15632            doc_type: "doc".to_string(),
15633            content: vec![AdfNode::paragraph(vec![AdfNode::mention(
15634                "abc123", "Alice",
15635            )])],
15636        };
15637        let md = adf_to_markdown(&doc).unwrap();
15638        assert!(md.contains(":mention[Alice]{id=abc123}"));
15639    }
15640
15641    #[test]
15642    fn round_trip_mention() {
15643        let md = "Assigned to :mention[Alice]{id=abc123}.\n";
15644        let doc = markdown_to_adf(md).unwrap();
15645        let result = adf_to_markdown(&doc).unwrap();
15646        assert!(result.contains(":mention[Alice]{id=abc123}"));
15647    }
15648
15649    #[test]
15650    fn mention_with_empty_access_level_round_trips() {
15651        // Issue #363: accessLevel="" produces accessLevel= which failed to parse
15652        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15653          {"type":"mention","attrs":{"id":"61921b41c15977006af2b1d1","text":"@Javier Inchausti","accessLevel":""}}
15654        ]}]}"#;
15655        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15656
15657        let md = adf_to_markdown(&doc).unwrap();
15658        let round_tripped = markdown_to_adf(&md).unwrap();
15659        let mention = &round_tripped.content[0].content.as_ref().unwrap()[0];
15660        assert_eq!(
15661            mention.node_type, "mention",
15662            "mention with empty accessLevel was not parsed as mention, got: {}",
15663            mention.node_type
15664        );
15665    }
15666
15667    #[test]
15668    fn span_with_color() {
15669        let doc = markdown_to_adf("This is :span[red text]{color=#ff5630}.").unwrap();
15670        let content = doc.content[0].content.as_ref().unwrap();
15671        assert_eq!(content[1].node_type, "text");
15672        assert_eq!(content[1].text.as_deref(), Some("red text"));
15673        let marks = content[1].marks.as_ref().unwrap();
15674        assert_eq!(marks[0].mark_type, "textColor");
15675    }
15676
15677    #[test]
15678    fn adf_text_color_to_markdown() {
15679        let doc = AdfDocument {
15680            version: 1,
15681            doc_type: "doc".to_string(),
15682            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
15683                "red text",
15684                vec![AdfMark::text_color("#ff5630")],
15685            )])],
15686        };
15687        let md = adf_to_markdown(&doc).unwrap();
15688        assert!(md.contains(":span[red text]{color=#ff5630}"));
15689    }
15690
15691    #[test]
15692    fn round_trip_span_color() {
15693        let md = "This is :span[red text]{color=#ff5630}.\n";
15694        let doc = markdown_to_adf(md).unwrap();
15695        let result = adf_to_markdown(&doc).unwrap();
15696        assert!(result.contains(":span[red text]{color=#ff5630}"));
15697    }
15698
15699    #[test]
15700    fn text_color_and_link_marks_both_preserved() {
15701        // Issue #405: text with both textColor and link marks loses link on round-trip
15702        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15703          {"type":"text","text":"red link","marks":[
15704            {"type":"link","attrs":{"href":"https://example.com"}},
15705            {"type":"textColor","attrs":{"color":"#ff0000"}}
15706          ]}
15707        ]}]}"##;
15708        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15709        let md = adf_to_markdown(&doc).unwrap();
15710        assert!(
15711            md.contains(":span[red link]{color=#ff0000}"),
15712            "JFM should contain span with color, got: {md}"
15713        );
15714        assert!(
15715            md.contains("](https://example.com)"),
15716            "JFM should contain link href, got: {md}"
15717        );
15718        // Full round-trip: both marks survive
15719        let rt = markdown_to_adf(&md).unwrap();
15720        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
15721        let marks = text_node.marks.as_ref().expect("should have marks");
15722        assert!(
15723            marks.iter().any(|m| m.mark_type == "textColor"),
15724            "should have textColor mark, got: {:?}",
15725            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
15726        );
15727        assert!(
15728            marks.iter().any(|m| m.mark_type == "link"),
15729            "should have link mark, got: {:?}",
15730            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
15731        );
15732        // Verify attribute values survive
15733        let link_mark = marks.iter().find(|m| m.mark_type == "link").unwrap();
15734        assert_eq!(
15735            link_mark.attrs.as_ref().unwrap()["href"],
15736            "https://example.com"
15737        );
15738        let color_mark = marks.iter().find(|m| m.mark_type == "textColor").unwrap();
15739        assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#ff0000");
15740    }
15741
15742    #[test]
15743    fn bg_color_and_link_marks_both_preserved() {
15744        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15745          {"type":"text","text":"highlighted link","marks":[
15746            {"type":"link","attrs":{"href":"https://example.com"}},
15747            {"type":"backgroundColor","attrs":{"color":"#ffff00"}}
15748          ]}
15749        ]}]}"##;
15750        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15751        let md = adf_to_markdown(&doc).unwrap();
15752        assert!(md.contains("bg=#ffff00"), "should have bg color: {md}");
15753        assert!(
15754            md.contains("](https://example.com)"),
15755            "should have link: {md}"
15756        );
15757        let rt = markdown_to_adf(&md).unwrap();
15758        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
15759        let marks = text_node.marks.as_ref().expect("should have marks");
15760        assert!(marks.iter().any(|m| m.mark_type == "backgroundColor"));
15761        assert!(marks.iter().any(|m| m.mark_type == "link"));
15762    }
15763
15764    #[test]
15765    fn text_color_link_and_strong_rendering() {
15766        // Verify textColor + link + strong renders all three formatting elements
15767        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15768          {"type":"text","text":"bold red link","marks":[
15769            {"type":"strong"},
15770            {"type":"link","attrs":{"href":"https://example.com"}},
15771            {"type":"textColor","attrs":{"color":"#ff0000"}}
15772          ]}
15773        ]}]}"##;
15774        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15775        let md = adf_to_markdown(&doc).unwrap();
15776        assert!(
15777            md.starts_with("**") && md.trim().ends_with("**"),
15778            "should have bold wrapping: {md}"
15779        );
15780        assert!(md.contains("color=#ff0000"), "should have color: {md}");
15781        assert!(
15782            md.contains("](https://example.com)"),
15783            "should have link: {md}"
15784        );
15785    }
15786
15787    #[test]
15788    fn subsup_and_link_marks_both_preserved() {
15789        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15790          {"type":"text","text":"note","marks":[
15791            {"type":"link","attrs":{"href":"https://example.com"}},
15792            {"type":"subsup","attrs":{"type":"sup"}}
15793          ]}
15794        ]}]}"#;
15795        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15796        let md = adf_to_markdown(&doc).unwrap();
15797        assert!(md.contains("sup"), "should have sup: {md}");
15798        assert!(
15799            md.contains("](https://example.com)"),
15800            "should have link: {md}"
15801        );
15802        let rt = markdown_to_adf(&md).unwrap();
15803        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
15804        let marks = text_node.marks.as_ref().expect("should have marks");
15805        assert!(marks.iter().any(|m| m.mark_type == "subsup"));
15806        assert!(marks.iter().any(|m| m.mark_type == "link"));
15807    }
15808
15809    #[test]
15810    fn text_color_without_link_unchanged() {
15811        // Regression guard: textColor without link should still work
15812        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15813          {"type":"text","text":"just red","marks":[
15814            {"type":"textColor","attrs":{"color":"#ff0000"}}
15815          ]}
15816        ]}]}"##;
15817        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15818        let md = adf_to_markdown(&doc).unwrap();
15819        assert!(md.contains(":span[just red]{color=#ff0000}"), "md: {md}");
15820        assert!(!md.contains("](http"), "should NOT have link syntax: {md}");
15821    }
15822
15823    #[test]
15824    fn inline_extension_directive() {
15825        let doc =
15826            markdown_to_adf("See :extension[fallback]{type=com.app key=widget} here.").unwrap();
15827        let content = doc.content[0].content.as_ref().unwrap();
15828        assert_eq!(content[1].node_type, "inlineExtension");
15829        assert_eq!(
15830            content[1].attrs.as_ref().unwrap()["extensionType"],
15831            "com.app"
15832        );
15833        assert_eq!(content[1].attrs.as_ref().unwrap()["extensionKey"], "widget");
15834    }
15835
15836    #[test]
15837    fn adf_inline_extension_to_markdown() {
15838        let doc = AdfDocument {
15839            version: 1,
15840            doc_type: "doc".to_string(),
15841            content: vec![AdfNode::paragraph(vec![AdfNode::inline_extension(
15842                "com.app",
15843                "widget",
15844                Some("fallback"),
15845            )])],
15846        };
15847        let md = adf_to_markdown(&doc).unwrap();
15848        assert!(md.contains(":extension[fallback]{type=com.app key=widget}"));
15849    }
15850
15851    // ── Helper function tests ──────────────────────────────────────────
15852
15853    #[test]
15854    fn parse_ordered_list_marker_valid() {
15855        let result = parse_ordered_list_marker("1. Hello");
15856        assert_eq!(result, Some((1, "Hello")));
15857    }
15858
15859    #[test]
15860    fn parse_ordered_list_marker_high_number() {
15861        let result = parse_ordered_list_marker("42. Item");
15862        assert_eq!(result, Some((42, "Item")));
15863    }
15864
15865    #[test]
15866    fn parse_ordered_list_marker_not_a_list() {
15867        assert!(parse_ordered_list_marker("not a list").is_none());
15868        assert!(parse_ordered_list_marker("1.no space").is_none());
15869    }
15870
15871    #[test]
15872    fn is_list_start_various() {
15873        assert!(is_list_start("- item"));
15874        assert!(is_list_start("* item"));
15875        assert!(is_list_start("+ item"));
15876        assert!(is_list_start("1. item"));
15877        assert!(!is_list_start("not a list"));
15878    }
15879
15880    #[test]
15881    fn is_horizontal_rule_various() {
15882        assert!(is_horizontal_rule("---"));
15883        assert!(is_horizontal_rule("***"));
15884        assert!(is_horizontal_rule("___"));
15885        assert!(is_horizontal_rule("------"));
15886        assert!(!is_horizontal_rule("--"));
15887        assert!(!is_horizontal_rule("abc"));
15888    }
15889
15890    #[test]
15891    fn is_table_separator_valid() {
15892        assert!(is_table_separator("| --- | --- |"));
15893        assert!(is_table_separator("|:---:|:---|"));
15894        assert!(!is_table_separator("no pipes here"));
15895    }
15896
15897    #[test]
15898    fn parse_table_row_cells() {
15899        let cells = parse_table_row("| A | B | C |");
15900        assert_eq!(cells, vec!["A", "B", "C"]);
15901    }
15902
15903    #[test]
15904    fn parse_table_row_escaped_pipe_in_cell() {
15905        // Issue #579: `\|` inside a cell is a literal pipe, not a column separator.
15906        let cells = parse_table_row(r"| a\|b | c |");
15907        assert_eq!(cells, vec!["a|b", "c"]);
15908    }
15909
15910    #[test]
15911    fn parse_table_row_escaped_pipe_in_code_span() {
15912        // Issue #579: `\|` inside an inline code span is unescaped at the row level.
15913        let cells = parse_table_row(r"| `parser.decode[T\|json]` | other |");
15914        assert_eq!(cells, vec!["`parser.decode[T|json]`", "other"]);
15915    }
15916
15917    #[test]
15918    fn parse_table_row_preserves_other_backslashes() {
15919        // Only `\|` is special at the row-splitting level; other backslashes pass through.
15920        let cells = parse_table_row(r"| a\\b | c\*d |");
15921        assert_eq!(cells, vec![r"a\\b", r"c\*d"]);
15922    }
15923
15924    #[test]
15925    fn parse_image_syntax_valid() {
15926        let result = parse_image_syntax("![alt](url)");
15927        assert_eq!(result, Some(("alt", "url")));
15928    }
15929
15930    #[test]
15931    fn parse_image_syntax_not_image() {
15932        assert!(parse_image_syntax("not an image").is_none());
15933    }
15934
15935    // ── find_closing_paren tests ────────────────────────────────────
15936
15937    #[test]
15938    fn find_closing_paren_simple() {
15939        assert_eq!(find_closing_paren("(hello)", 0), Some(6));
15940    }
15941
15942    #[test]
15943    fn find_closing_paren_nested() {
15944        assert_eq!(find_closing_paren("(a(b)c)", 0), Some(6));
15945    }
15946
15947    #[test]
15948    fn find_closing_paren_unmatched() {
15949        assert_eq!(find_closing_paren("(no close", 0), None);
15950    }
15951
15952    #[test]
15953    fn find_closing_paren_offset() {
15954        // Start scanning from the second '('
15955        assert_eq!(find_closing_paren("xx(inner)", 2), Some(8));
15956    }
15957
15958    // ── Parentheses-in-URL tests (issue #509) ──────────────────────
15959
15960    #[test]
15961    fn try_parse_link_url_with_parens() {
15962        let input = "[here](https://example.com/faq#access-(permissions)-rest)";
15963        let result = try_parse_link(input, 0);
15964        assert_eq!(
15965            result,
15966            Some((
15967                input.len(),
15968                "here",
15969                "https://example.com/faq#access-(permissions)-rest"
15970            ))
15971        );
15972    }
15973
15974    #[test]
15975    fn try_parse_link_url_no_parens() {
15976        let input = "[text](https://example.com)";
15977        let result = try_parse_link(input, 0);
15978        assert_eq!(result, Some((input.len(), "text", "https://example.com")));
15979    }
15980
15981    #[test]
15982    fn try_parse_link_url_with_multiple_nested_parens() {
15983        let input = "[x](http://en.wikipedia.org/wiki/Foo_(bar_(baz)))";
15984        let result = try_parse_link(input, 0);
15985        assert_eq!(
15986            result,
15987            Some((
15988                input.len(),
15989                "x",
15990                "http://en.wikipedia.org/wiki/Foo_(bar_(baz))"
15991            ))
15992        );
15993    }
15994
15995    #[test]
15996    fn parse_image_syntax_url_with_parens() {
15997        let result = parse_image_syntax("![alt](https://example.com/page_(1))");
15998        assert_eq!(result, Some(("alt", "https://example.com/page_(1)")));
15999    }
16000
16001    #[test]
16002    fn parse_image_syntax_url_no_parens() {
16003        let result = parse_image_syntax("![alt](https://example.com)");
16004        assert_eq!(result, Some(("alt", "https://example.com")));
16005    }
16006
16007    #[test]
16008    fn link_with_parens_round_trip() {
16009        let href = "https://example.com/faq#I-need-access-(permissions)-added-in-Monitor";
16010        let mut text_node = AdfNode::text("here");
16011        text_node.marks = Some(vec![AdfMark::link(href)]);
16012        let adf_input = AdfDocument {
16013            version: 1,
16014            doc_type: "doc".to_string(),
16015            content: vec![AdfNode::paragraph(vec![text_node])],
16016        };
16017
16018        let jfm = adf_to_markdown(&adf_input).unwrap();
16019        let adf_output = markdown_to_adf(&jfm).unwrap();
16020
16021        // Extract the href from the round-tripped ADF
16022        let para = &adf_output.content[0];
16023        let text_node = &para.content.as_ref().unwrap()[0];
16024        let mark = &text_node.marks.as_ref().unwrap()[0];
16025        let result_href = mark.attrs.as_ref().unwrap()["href"].as_str().unwrap();
16026
16027        assert_eq!(result_href, href);
16028    }
16029
16030    #[test]
16031    fn flush_plain_empty_range() {
16032        let mut nodes = Vec::new();
16033        flush_plain("hello", 3, 3, &mut nodes);
16034        assert!(nodes.is_empty());
16035    }
16036
16037    #[test]
16038    fn add_mark_to_unmarked_node() {
16039        let mut node = AdfNode::text("test");
16040        add_mark(&mut node, AdfMark::strong());
16041        assert_eq!(node.marks.as_ref().unwrap().len(), 1);
16042    }
16043
16044    #[test]
16045    fn add_mark_to_marked_node() {
16046        let mut node = AdfNode::text_with_marks("test", vec![AdfMark::strong()]);
16047        add_mark(&mut node, AdfMark::em());
16048        assert_eq!(node.marks.as_ref().unwrap().len(), 2);
16049    }
16050
16051    // ── Directive table tests ──────────────────────────────────────
16052
16053    #[test]
16054    fn directive_table_basic() {
16055        let md = "::::table\n:::tr\n:::th\nHeader 1\n:::\n:::th\nHeader 2\n:::\n:::\n:::tr\n:::td\nCell 1\n:::\n:::td\nCell 2\n:::\n:::\n::::\n";
16056        let doc = markdown_to_adf(md).unwrap();
16057        assert_eq!(doc.content[0].node_type, "table");
16058        let rows = doc.content[0].content.as_ref().unwrap();
16059        assert_eq!(rows.len(), 2);
16060        assert_eq!(
16061            rows[0].content.as_ref().unwrap()[0].node_type,
16062            "tableHeader"
16063        );
16064        assert_eq!(rows[1].content.as_ref().unwrap()[0].node_type, "tableCell");
16065    }
16066
16067    #[test]
16068    fn directive_table_with_block_content() {
16069        let md = "::::table\n:::tr\n:::td\nCell with list:\n\n- Item 1\n- Item 2\n:::\n:::td\nSimple cell\n:::\n:::\n::::\n";
16070        let doc = markdown_to_adf(md).unwrap();
16071        let rows = doc.content[0].content.as_ref().unwrap();
16072        let cell = &rows[0].content.as_ref().unwrap()[0];
16073        // Cell should have block content (paragraph + bullet list)
16074        let content = cell.content.as_ref().unwrap();
16075        assert!(content.len() >= 2);
16076        assert_eq!(content[1].node_type, "bulletList");
16077    }
16078
16079    #[test]
16080    fn directive_table_with_cell_attrs() {
16081        let md = "::::table\n:::tr\n:::td{colspan=2 bg=#DEEBFF}\nSpanning cell\n:::\n:::\n::::\n";
16082        let doc = markdown_to_adf(md).unwrap();
16083        let cell = &doc.content[0].content.as_ref().unwrap()[0]
16084            .content
16085            .as_ref()
16086            .unwrap()[0];
16087        let attrs = cell.attrs.as_ref().unwrap();
16088        assert_eq!(attrs["colspan"], 2);
16089        assert_eq!(attrs["background"], "#DEEBFF");
16090    }
16091
16092    #[test]
16093    fn directive_table_with_css_var_background() {
16094        let bg = "var(--ds-background-accent-gray-subtlest, var(--ds-background-accent-gray-subtlest, #F1F2F4))";
16095        let md = format!("::::table\n:::tr\n:::th{{bg=\"{bg}\"}}\nHeader\n:::\n:::\n::::\n");
16096        let doc = markdown_to_adf(&md).unwrap();
16097        let row = &doc.content[0].content.as_ref().unwrap()[0];
16098        let cells = row.content.as_ref().unwrap();
16099        assert_eq!(cells.len(), 1, "row must have at least one cell");
16100        let attrs = cells[0].attrs.as_ref().unwrap();
16101        assert_eq!(attrs["background"], bg);
16102    }
16103
16104    #[test]
16105    fn css_var_background_round_trips() {
16106        let bg = "var(--ds-background-accent-gray-subtlest, #F1F2F4)";
16107        let adf = AdfDocument {
16108            version: 1,
16109            doc_type: "doc".to_string(),
16110            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16111                AdfNode::table_header_with_attrs(
16112                    vec![AdfNode::paragraph(vec![AdfNode::text("Header")])],
16113                    serde_json::json!({"background": bg}),
16114                ),
16115            ])])],
16116        };
16117        let md = adf_to_markdown(&adf).unwrap();
16118        assert!(
16119            md.contains(&format!("bg=\"{bg}\"")),
16120            "bg value must be quoted in markdown: {md}"
16121        );
16122
16123        let round_tripped = markdown_to_adf(&md).unwrap();
16124        let row = &round_tripped.content[0].content.as_ref().unwrap()[0];
16125        let cells = row.content.as_ref().unwrap();
16126        assert_eq!(cells.len(), 1, "round-tripped row must have one cell");
16127        let rt_attrs = cells[0].attrs.as_ref().unwrap();
16128        assert_eq!(rt_attrs["background"], bg);
16129    }
16130
16131    #[test]
16132    fn directive_table_with_table_attrs() {
16133        let md = "::::table{layout=wide numbered}\n:::tr\n:::td\nCell\n:::\n:::\n::::\n";
16134        let doc = markdown_to_adf(md).unwrap();
16135        let attrs = doc.content[0].attrs.as_ref().unwrap();
16136        assert_eq!(attrs["layout"], "wide");
16137        assert_eq!(attrs["isNumberColumnEnabled"], true);
16138    }
16139
16140    #[test]
16141    fn adf_table_with_block_content_renders_directive_form() {
16142        // Table with a bullet list in a cell → should render as ::::table directive
16143        let doc = AdfDocument {
16144            version: 1,
16145            doc_type: "doc".to_string(),
16146            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16147                AdfNode::table_cell(vec![
16148                    AdfNode::paragraph(vec![AdfNode::text("Cell with list:")]),
16149                    AdfNode::bullet_list(vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
16150                        AdfNode::text("Item 1"),
16151                    ])])]),
16152                ]),
16153            ])])],
16154        };
16155        let md = adf_to_markdown(&doc).unwrap();
16156        assert!(md.contains("::::table"));
16157        assert!(md.contains(":::td"));
16158        assert!(md.contains("- Item 1"));
16159    }
16160
16161    #[test]
16162    fn adf_table_inline_only_renders_pipe_form() {
16163        // Table with only inline content → pipe table
16164        let doc = AdfDocument {
16165            version: 1,
16166            doc_type: "doc".to_string(),
16167            content: vec![AdfNode::table(vec![
16168                AdfNode::table_row(vec![
16169                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
16170                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
16171                ]),
16172                AdfNode::table_row(vec![
16173                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
16174                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
16175                ]),
16176            ])],
16177        };
16178        let md = adf_to_markdown(&doc).unwrap();
16179        assert!(md.contains("| H1 | H2 |"));
16180        assert!(!md.contains("::::table"));
16181    }
16182
16183    #[test]
16184    fn adf_table_header_outside_first_row_renders_directive() {
16185        let doc = AdfDocument {
16186            version: 1,
16187            doc_type: "doc".to_string(),
16188            content: vec![AdfNode::table(vec![
16189                AdfNode::table_row(vec![
16190                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H")])]),
16191                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C")])]),
16192                ]),
16193                AdfNode::table_row(vec![
16194                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
16195                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
16196                ]),
16197            ])],
16198        };
16199        let md = adf_to_markdown(&doc).unwrap();
16200        assert!(md.contains("::::table"));
16201        assert!(md.contains(":::th"));
16202    }
16203
16204    #[test]
16205    fn adf_table_cell_attrs_rendered() {
16206        let doc = AdfDocument {
16207            version: 1,
16208            doc_type: "doc".to_string(),
16209            content: vec![AdfNode::table(vec![
16210                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
16211                    AdfNode::text("H"),
16212                ])])]),
16213                AdfNode::table_row(vec![AdfNode::table_cell_with_attrs(
16214                    vec![AdfNode::paragraph(vec![AdfNode::text("C")])],
16215                    serde_json::json!({"background": "#DEEBFF", "colspan": 2}),
16216                )]),
16217            ])],
16218        };
16219        let md = adf_to_markdown(&doc).unwrap();
16220        assert!(md.contains("{colspan=2 bg=#DEEBFF}"));
16221    }
16222
16223    // ── Pipe table cell attrs tests ────────────────────────────────
16224
16225    #[test]
16226    fn pipe_table_cell_attrs() {
16227        let md = "| H1 | H2 |\n|---|---|\n| {bg=#DEEBFF} highlighted | normal |\n";
16228        let doc = markdown_to_adf(md).unwrap();
16229        let rows = doc.content[0].content.as_ref().unwrap();
16230        let cell = &rows[1].content.as_ref().unwrap()[0];
16231        let attrs = cell.attrs.as_ref().unwrap();
16232        assert_eq!(attrs["background"], "#DEEBFF");
16233    }
16234
16235    #[test]
16236    fn pipe_table_cell_colspan() {
16237        let md = "| H1 | H2 |\n|---|---|\n| {colspan=2} spanning |\n";
16238        let doc = markdown_to_adf(md).unwrap();
16239        let rows = doc.content[0].content.as_ref().unwrap();
16240        let cell = &rows[1].content.as_ref().unwrap()[0];
16241        let attrs = cell.attrs.as_ref().unwrap();
16242        assert_eq!(attrs["colspan"], 2);
16243    }
16244
16245    #[test]
16246    fn trailing_space_after_mention_in_table_cell_preserved() {
16247        // Issue #372: trailing space after mention in table cell was dropped
16248        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[
16249          {"type":"mention","attrs":{"id":"aaa","text":"@Rob"}},
16250          {"type":"text","text":" "}
16251        ]}]}]}]}]}"#;
16252        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16253        let md = adf_to_markdown(&doc).unwrap();
16254        let round_tripped = markdown_to_adf(&md).unwrap();
16255        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
16256            .content
16257            .as_ref()
16258            .unwrap()[0];
16259        let para = &cell.content.as_ref().unwrap()[0];
16260        let inlines = para.content.as_ref().unwrap();
16261        assert!(
16262            inlines.len() >= 2,
16263            "expected mention + text(' ') nodes, got {} nodes: {:?}",
16264            inlines.len(),
16265            inlines.iter().map(|n| &n.node_type).collect::<Vec<_>>()
16266        );
16267        assert_eq!(inlines[0].node_type, "mention");
16268        assert_eq!(inlines[1].node_type, "text");
16269        assert_eq!(inlines[1].text.as_deref(), Some(" "));
16270    }
16271
16272    // ── Column alignment tests ─────────────────────────────────────
16273
16274    #[test]
16275    fn pipe_table_column_alignment() {
16276        let md = "| Left | Center | Right |\n|:---|:---:|---:|\n| L | C | R |\n";
16277        let doc = markdown_to_adf(md).unwrap();
16278        let rows = doc.content[0].content.as_ref().unwrap();
16279        // Header row
16280        let h_cells = rows[0].content.as_ref().unwrap();
16281        // Left → no mark
16282        assert!(h_cells[0].content.as_ref().unwrap()[0].marks.is_none());
16283        // Center → alignment center
16284        let center_marks = h_cells[1].content.as_ref().unwrap()[0]
16285            .marks
16286            .as_ref()
16287            .unwrap();
16288        assert_eq!(center_marks[0].attrs.as_ref().unwrap()["align"], "center");
16289        // Right → alignment end
16290        let right_marks = h_cells[2].content.as_ref().unwrap()[0]
16291            .marks
16292            .as_ref()
16293            .unwrap();
16294        assert_eq!(right_marks[0].attrs.as_ref().unwrap()["align"], "end");
16295    }
16296
16297    #[test]
16298    fn adf_table_alignment_roundtrip() {
16299        let doc = AdfDocument {
16300            version: 1,
16301            doc_type: "doc".to_string(),
16302            content: vec![AdfNode::table(vec![
16303                AdfNode::table_row(vec![
16304                    AdfNode::table_header(vec![{
16305                        let mut p = AdfNode::paragraph(vec![AdfNode::text("Center")]);
16306                        p.marks = Some(vec![AdfMark::alignment("center")]);
16307                        p
16308                    }]),
16309                    AdfNode::table_header(vec![{
16310                        let mut p = AdfNode::paragraph(vec![AdfNode::text("Right")]);
16311                        p.marks = Some(vec![AdfMark::alignment("end")]);
16312                        p
16313                    }]),
16314                ]),
16315                AdfNode::table_row(vec![
16316                    AdfNode::table_cell(vec![{
16317                        let mut p = AdfNode::paragraph(vec![AdfNode::text("C")]);
16318                        p.marks = Some(vec![AdfMark::alignment("center")]);
16319                        p
16320                    }]),
16321                    AdfNode::table_cell(vec![{
16322                        let mut p = AdfNode::paragraph(vec![AdfNode::text("R")]);
16323                        p.marks = Some(vec![AdfMark::alignment("end")]);
16324                        p
16325                    }]),
16326                ]),
16327            ])],
16328        };
16329        let md = adf_to_markdown(&doc).unwrap();
16330        assert!(md.contains(":---:"));
16331        assert!(md.contains("---:"));
16332    }
16333
16334    // ── Panel custom attrs tests ───────────────────────────────────
16335
16336    #[test]
16337    fn panel_custom_attrs_round_trip() {
16338        let md = ":::panel{type=custom icon=\":star:\" color=\"#DEEBFF\"}\nContent\n:::\n";
16339        let doc = markdown_to_adf(md).unwrap();
16340        let panel = &doc.content[0];
16341        let attrs = panel.attrs.as_ref().unwrap();
16342        assert_eq!(attrs["panelType"], "custom");
16343        assert_eq!(attrs["panelIcon"], ":star:");
16344        assert_eq!(attrs["panelColor"], "#DEEBFF");
16345
16346        let result = adf_to_markdown(&doc).unwrap();
16347        assert!(result.contains("type=custom"));
16348        assert!(result.contains("icon="));
16349        assert!(result.contains("color="));
16350    }
16351
16352    // ── Block card with attrs tests ────────────────────────────────
16353
16354    #[test]
16355    fn block_card_with_layout() {
16356        let md = "::card[https://example.com]{layout=wide}\n";
16357        let doc = markdown_to_adf(md).unwrap();
16358        let attrs = doc.content[0].attrs.as_ref().unwrap();
16359        assert_eq!(attrs["layout"], "wide");
16360
16361        let result = adf_to_markdown(&doc).unwrap();
16362        assert!(result.contains("::card[https://example.com]{layout=wide}"));
16363    }
16364
16365    // ── Extension params test ──────────────────────────────────────
16366
16367    #[test]
16368    fn extension_with_params() {
16369        let md = r#"::extension{type=com.atlassian.macro key=jira-chart params='{"jql":"project=PROJ"}'}"#;
16370        let doc = markdown_to_adf(&format!("{md}\n")).unwrap();
16371        let attrs = doc.content[0].attrs.as_ref().unwrap();
16372        assert_eq!(attrs["parameters"]["jql"], "project=PROJ");
16373    }
16374
16375    #[test]
16376    fn leaf_extension_layout_preserved_in_roundtrip() {
16377        // Issue #381: layout attr on extension nodes was dropped
16378        let adf_json = r#"{"version":1,"type":"doc","content":[
16379          {"type":"extension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"toc","layout":"default","parameters":{}}}
16380        ]}"#;
16381        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16382        let md = adf_to_markdown(&doc).unwrap();
16383        assert!(
16384            md.contains("layout=default"),
16385            "JFM should contain layout=default, got: {md}"
16386        );
16387        let round_tripped = markdown_to_adf(&md).unwrap();
16388        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16389        assert_eq!(attrs["layout"], "default", "layout should be preserved");
16390        assert_eq!(attrs["extensionKey"], "toc");
16391    }
16392
16393    #[test]
16394    fn bodied_extension_layout_preserved_in_roundtrip() {
16395        // Bodied extension with layout
16396        let adf_json = r#"{"version":1,"type":"doc","content":[
16397          {"type":"bodiedExtension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"expand","layout":"wide"},
16398           "content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}
16399        ]}"#;
16400        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16401        let md = adf_to_markdown(&doc).unwrap();
16402        assert!(
16403            md.contains("layout=wide"),
16404            "JFM should contain layout=wide, got: {md}"
16405        );
16406        let round_tripped = markdown_to_adf(&md).unwrap();
16407        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16408        assert_eq!(attrs["layout"], "wide", "layout should be preserved");
16409    }
16410
16411    #[test]
16412    fn bodied_extension_parameters_preserved_in_roundtrip() {
16413        // Issue #473: parameters block inside bodiedExtension.attrs was dropped
16414        let adf_json = r#"{"version":1,"type":"doc","content":[
16415          {"type":"bodiedExtension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"details","layout":"default","localId":"aabbccdd-1234","parameters":{"macroMetadata":{"macroId":{"value":"bbccddee-2345"},"schemaVersion":{"value":"1"},"title":"Page Properties"},"macroParams":{}}},
16416           "content":[{"type":"paragraph","content":[{"type":"text","text":"Content inside bodied extension"}]}]}
16417        ]}"#;
16418        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16419        let md = adf_to_markdown(&doc).unwrap();
16420        assert!(
16421            md.contains("params="),
16422            "JFM should contain params attribute, got: {md}"
16423        );
16424        let round_tripped = markdown_to_adf(&md).unwrap();
16425        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16426        assert_eq!(
16427            attrs["parameters"]["macroMetadata"]["title"], "Page Properties",
16428            "parameters should be preserved in round-trip"
16429        );
16430        assert_eq!(attrs["extensionKey"], "details");
16431        assert_eq!(attrs["layout"], "default");
16432        assert_eq!(attrs["localId"], "aabbccdd-1234");
16433    }
16434
16435    #[test]
16436    fn bodied_extension_malformed_params_ignored() {
16437        // Malformed params JSON should be silently ignored, not crash
16438        let md = ":::extension{type=com.atlassian.macro key=details params='not-valid-json'}\nContent\n:::\n";
16439        let doc = markdown_to_adf(md).unwrap();
16440        let attrs = doc.content[0].attrs.as_ref().unwrap();
16441        assert_eq!(attrs["extensionKey"], "details");
16442        // parameters should be absent since the JSON was invalid
16443        assert!(attrs.get("parameters").is_none());
16444    }
16445
16446    #[test]
16447    fn leaf_extension_localid_preserved_in_roundtrip() {
16448        // Extension with both layout and localId
16449        let adf_json = r#"{"version":1,"type":"doc","content":[
16450          {"type":"extension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"toc","layout":"default","localId":"abc-123"}}
16451        ]}"#;
16452        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16453        let md = adf_to_markdown(&doc).unwrap();
16454        let round_tripped = markdown_to_adf(&md).unwrap();
16455        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16456        assert_eq!(attrs["layout"], "default");
16457        assert_eq!(attrs["localId"], "abc-123");
16458    }
16459
16460    // ── Mention with userType test ─────────────────────────────────
16461
16462    #[test]
16463    fn mention_with_user_type() {
16464        let md = "Hi :mention[Alice]{id=abc123 userType=DEFAULT}.\n";
16465        let doc = markdown_to_adf(md).unwrap();
16466        let mention = &doc.content[0].content.as_ref().unwrap()[1];
16467        assert_eq!(mention.attrs.as_ref().unwrap()["userType"], "DEFAULT");
16468
16469        let result = adf_to_markdown(&doc).unwrap();
16470        assert!(result.contains("userType=DEFAULT"));
16471    }
16472
16473    // ── Colwidth tests ─────────────────────────────────────────────
16474
16475    #[test]
16476    fn directive_table_colwidth() {
16477        let md = "::::table\n:::tr\n:::td{colwidth=100,200}\nCell\n:::\n:::\n::::\n";
16478        let doc = markdown_to_adf(md).unwrap();
16479        let cell = &doc.content[0].content.as_ref().unwrap()[0]
16480            .content
16481            .as_ref()
16482            .unwrap()[0];
16483        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
16484        assert_eq!(colwidth, &[serde_json::json!(100), serde_json::json!(200)]);
16485    }
16486
16487    #[test]
16488    fn directive_table_colwidth_float_roundtrip() {
16489        // Confluence returns colwidth as floats (e.g. 157.0, 863.0).
16490        // adf_to_markdown must preserve them so markdown_to_adf can restore them.
16491        let adf_doc = serde_json::json!({
16492            "type": "doc",
16493            "version": 1,
16494            "content": [{
16495                "type": "table",
16496                "content": [{
16497                    "type": "tableRow",
16498                    "content": [
16499                        {
16500                            "type": "tableHeader",
16501                            "attrs": { "colwidth": [157.0] },
16502                            "content": [{ "type": "paragraph" }]
16503                        },
16504                        {
16505                            "type": "tableHeader",
16506                            "attrs": { "colwidth": [863.0] },
16507                            "content": [{ "type": "paragraph" }]
16508                        }
16509                    ]
16510                }]
16511            }]
16512        });
16513        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16514        let md = adf_to_markdown(&doc).unwrap();
16515        assert!(
16516            md.contains("colwidth=157.0"),
16517            "expected colwidth=157.0 in markdown, got: {md}"
16518        );
16519        assert!(
16520            md.contains("colwidth=863.0"),
16521            "expected colwidth=863.0 in markdown, got: {md}"
16522        );
16523        // Round-trip back to ADF
16524        let doc2 = markdown_to_adf(&md).unwrap();
16525        let row = &doc2.content[0].content.as_ref().unwrap()[0];
16526        let header1 = &row.content.as_ref().unwrap()[0];
16527        let header2 = &row.content.as_ref().unwrap()[1];
16528        assert_eq!(
16529            header1.attrs.as_ref().unwrap()["colwidth"]
16530                .as_array()
16531                .unwrap(),
16532            &[serde_json::json!(157.0)]
16533        );
16534        assert_eq!(
16535            header2.attrs.as_ref().unwrap()["colwidth"]
16536                .as_array()
16537                .unwrap(),
16538            &[serde_json::json!(863.0)]
16539        );
16540    }
16541
16542    #[test]
16543    fn colwidth_float_preserved_in_roundtrip() {
16544        // Issue #369: colwidth 254.0 was coerced to integer 254
16545        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableHeader","attrs":{"colwidth":[254.0,416.0]},"content":[{"type":"paragraph","content":[]}]}]}]}]}"#;
16546        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16547        let md = adf_to_markdown(&doc).unwrap();
16548        let round_tripped = markdown_to_adf(&md).unwrap();
16549        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
16550            .content
16551            .as_ref()
16552            .unwrap()[0];
16553        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
16554        assert_eq!(
16555            colwidth,
16556            &[serde_json::json!(254.0), serde_json::json!(416.0)],
16557            "colwidth should preserve float values"
16558        );
16559    }
16560
16561    #[test]
16562    fn colwidth_integer_preserved_in_roundtrip() {
16563        // Issue #459: colwidth integer values emitted as floats after round-trip
16564        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colspan":1,"colwidth":[150],"rowspan":1},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
16565        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16566        let md = adf_to_markdown(&doc).unwrap();
16567        assert!(
16568            md.contains("colwidth=150"),
16569            "expected colwidth=150 (no decimal) in markdown, got: {md}"
16570        );
16571        assert!(
16572            !md.contains("colwidth=150.0"),
16573            "colwidth should not have .0 suffix for integers, got: {md}"
16574        );
16575        // Round-trip back to ADF
16576        let round_tripped = markdown_to_adf(&md).unwrap();
16577        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
16578            .content
16579            .as_ref()
16580            .unwrap()[0];
16581        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
16582        assert_eq!(
16583            colwidth,
16584            &[serde_json::json!(150)],
16585            "colwidth should preserve integer values"
16586        );
16587        // Verify JSON serialization uses integer, not float
16588        let json_output = serde_json::to_string(&round_tripped).unwrap();
16589        assert!(
16590            json_output.contains(r#""colwidth":[150]"#),
16591            "JSON should contain integer colwidth, got: {json_output}"
16592        );
16593    }
16594
16595    #[test]
16596    fn colwidth_mixed_int_and_float_roundtrip() {
16597        // Integer colwidth from standard ADF and float colwidth from Confluence
16598        // should each preserve their original type through round-trip.
16599        let int_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100,200]}}]}]}]}"#;
16600        let float_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100.0,200.0]}}]}]}]}"#;
16601
16602        // Integer input → integer output
16603        let int_doc: AdfDocument = serde_json::from_str(int_json).unwrap();
16604        let int_md = adf_to_markdown(&int_doc).unwrap();
16605        assert!(
16606            int_md.contains("colwidth=100,200"),
16607            "integer colwidth in md: {int_md}"
16608        );
16609        let int_rt = markdown_to_adf(&int_md).unwrap();
16610        let int_serial = serde_json::to_string(&int_rt).unwrap();
16611        assert!(
16612            int_serial.contains(r#""colwidth":[100,200]"#),
16613            "integer colwidth in JSON: {int_serial}"
16614        );
16615
16616        // Float input → float output
16617        let float_doc: AdfDocument = serde_json::from_str(float_json).unwrap();
16618        let float_md = adf_to_markdown(&float_doc).unwrap();
16619        assert!(
16620            float_md.contains("colwidth=100.0,200.0"),
16621            "float colwidth in md: {float_md}"
16622        );
16623        let float_rt = markdown_to_adf(&float_md).unwrap();
16624        let float_serial = serde_json::to_string(&float_rt).unwrap();
16625        assert!(
16626            float_serial.contains(r#""colwidth":[100.0,200.0]"#),
16627            "float colwidth in JSON: {float_serial}"
16628        );
16629    }
16630
16631    #[test]
16632    fn colwidth_fractional_float_preserved() {
16633        // Covers the fractional-float branch (n.fract() != 0.0) in build_cell_attrs_string
16634        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100.5]},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
16635        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16636        let md = adf_to_markdown(&doc).unwrap();
16637        assert!(
16638            md.contains("colwidth=100.5"),
16639            "expected colwidth=100.5 in markdown, got: {md}"
16640        );
16641    }
16642
16643    #[test]
16644    fn colwidth_non_numeric_values_skipped() {
16645        // Covers the None branch for non-numeric colwidth entries in build_cell_attrs_string
16646        let adf_doc = serde_json::json!({
16647            "type": "doc",
16648            "version": 1,
16649            "content": [{
16650                "type": "table",
16651                "content": [{
16652                    "type": "tableRow",
16653                    "content": [{
16654                        "type": "tableCell",
16655                        "attrs": { "colwidth": ["invalid"] },
16656                        "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "cell" }] }]
16657                    }]
16658                }]
16659            }]
16660        });
16661        let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
16662        let md = adf_to_markdown(&doc).unwrap();
16663        // Non-numeric values are filtered out, so colwidth should not appear
16664        assert!(
16665            !md.contains("colwidth"),
16666            "non-numeric colwidth should be filtered out, got: {md}"
16667        );
16668    }
16669
16670    #[test]
16671    fn default_rowspan_colspan_preserved_in_roundtrip() {
16672        // Issue #369: rowspan=1 and colspan=1 were elided
16673        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"rowspan":1,"colspan":1},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
16674        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16675        let md = adf_to_markdown(&doc).unwrap();
16676        let round_tripped = markdown_to_adf(&md).unwrap();
16677        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
16678            .content
16679            .as_ref()
16680            .unwrap()[0];
16681        let attrs = cell.attrs.as_ref().unwrap();
16682        assert_eq!(attrs["rowspan"], 1, "rowspan=1 should be preserved");
16683        assert_eq!(attrs["colspan"], 1, "colspan=1 should be preserved");
16684    }
16685
16686    // ── Nested list tests ──────────────────────────────────────────────
16687
16688    #[test]
16689    fn table_localid_preserved_in_roundtrip() {
16690        // Issue #374: localId on table nodes was dropped
16691        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default","localId":"7afd4550-e66c-4b12-875f-a91c6c7b62c7"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
16692        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16693        let md = adf_to_markdown(&doc).unwrap();
16694        assert!(
16695            md.contains("localId="),
16696            "JFM should contain localId, got: {md}"
16697        );
16698        let round_tripped = markdown_to_adf(&md).unwrap();
16699        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16700        assert_eq!(
16701            attrs["localId"], "7afd4550-e66c-4b12-875f-a91c6c7b62c7",
16702            "localId should be preserved"
16703        );
16704    }
16705
16706    #[test]
16707    fn paragraph_localid_preserved_in_roundtrip() {
16708        // Issue #399: localId on paragraph nodes was dropped
16709        let adf_json = r#"{"version":1,"type":"doc","content":[
16710          {"type":"paragraph","attrs":{"localId":"abc-123"},"content":[{"type":"text","text":"hello"}]}
16711        ]}"#;
16712        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16713        let md = adf_to_markdown(&doc).unwrap();
16714        assert!(
16715            md.contains("localId=abc-123"),
16716            "JFM should contain localId, got: {md}"
16717        );
16718        let round_tripped = markdown_to_adf(&md).unwrap();
16719        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16720        assert_eq!(attrs["localId"], "abc-123", "localId should be preserved");
16721    }
16722
16723    #[test]
16724    fn heading_localid_preserved_in_roundtrip() {
16725        let adf_json = r#"{"version":1,"type":"doc","content":[
16726          {"type":"heading","attrs":{"level":2,"localId":"h-456"},"content":[{"type":"text","text":"Title"}]}
16727        ]}"#;
16728        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16729        let md = adf_to_markdown(&doc).unwrap();
16730        let round_tripped = markdown_to_adf(&md).unwrap();
16731        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16732        assert_eq!(attrs["localId"], "h-456");
16733    }
16734
16735    #[test]
16736    fn localid_with_alignment_preserved() {
16737        // localId and alignment marks should coexist in the same {attrs} block
16738        let adf_json = r#"{"version":1,"type":"doc","content":[
16739          {"type":"paragraph","attrs":{"localId":"p-789"},"marks":[{"type":"alignment","attrs":{"align":"center"}}],
16740           "content":[{"type":"text","text":"centered"}]}
16741        ]}"#;
16742        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16743        let md = adf_to_markdown(&doc).unwrap();
16744        assert!(md.contains("localId=p-789"), "should have localId: {md}");
16745        assert!(md.contains("align=center"), "should have align: {md}");
16746        let round_tripped = markdown_to_adf(&md).unwrap();
16747        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16748        assert_eq!(attrs["localId"], "p-789");
16749        let marks = round_tripped.content[0].marks.as_ref().unwrap();
16750        assert!(marks.iter().any(|m| m.mark_type == "alignment"));
16751    }
16752
16753    #[test]
16754    fn table_layout_default_preserved_in_roundtrip() {
16755        // Issue #380: layout='default' was elided
16756        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
16757        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16758        let md = adf_to_markdown(&doc).unwrap();
16759        let round_tripped = markdown_to_adf(&md).unwrap();
16760        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16761        assert_eq!(
16762            attrs["layout"], "default",
16763            "layout='default' should be preserved"
16764        );
16765    }
16766
16767    #[test]
16768    fn table_is_number_column_enabled_false_preserved() {
16769        // Issue #380: isNumberColumnEnabled=false was elided
16770        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
16771        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16772        let md = adf_to_markdown(&doc).unwrap();
16773        let round_tripped = markdown_to_adf(&md).unwrap();
16774        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16775        assert_eq!(
16776            attrs["isNumberColumnEnabled"], false,
16777            "isNumberColumnEnabled=false should be preserved"
16778        );
16779    }
16780
16781    #[test]
16782    fn table_is_number_column_enabled_true_preserved() {
16783        // Regression check: isNumberColumnEnabled=true should still work
16784        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":true,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
16785        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16786        let md = adf_to_markdown(&doc).unwrap();
16787        let round_tripped = markdown_to_adf(&md).unwrap();
16788        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16789        assert_eq!(
16790            attrs["isNumberColumnEnabled"], true,
16791            "isNumberColumnEnabled=true should be preserved"
16792        );
16793    }
16794
16795    #[test]
16796    fn directive_table_is_number_column_enabled_false_preserved() {
16797        // Covers render_directive_table + directive table parsing for numbered=false.
16798        // Multi-paragraph cell forces directive table form.
16799        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
16800          {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
16801          {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
16802        ]}]}]}]}"#;
16803        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16804        let md = adf_to_markdown(&doc).unwrap();
16805        assert!(md.contains("::::table"), "should use directive table form");
16806        assert!(
16807            md.contains("numbered=false"),
16808            "should contain numbered=false, got: {md}"
16809        );
16810        let round_tripped = markdown_to_adf(&md).unwrap();
16811        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16812        assert_eq!(attrs["isNumberColumnEnabled"], false);
16813        assert_eq!(attrs["layout"], "default");
16814    }
16815
16816    #[test]
16817    fn directive_table_is_number_column_enabled_true_preserved() {
16818        // Covers render_directive_table + directive table parsing for numbered (true).
16819        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":true,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
16820          {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
16821          {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
16822        ]}]}]}]}"#;
16823        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16824        let md = adf_to_markdown(&doc).unwrap();
16825        assert!(md.contains("::::table"), "should use directive table form");
16826        assert!(
16827            md.contains("numbered}") || md.contains("numbered "),
16828            "should contain numbered flag, got: {md}"
16829        );
16830        let round_tripped = markdown_to_adf(&md).unwrap();
16831        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
16832        assert_eq!(attrs["isNumberColumnEnabled"], true);
16833    }
16834
16835    #[test]
16836    fn trailing_space_in_bullet_list_item_preserved() {
16837        // Issue #394: trailing space text node in list item dropped
16838        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
16839          {"type":"listItem","content":[{"type":"paragraph","content":[
16840            {"type":"text","text":"Before link "},
16841            {"type":"text","text":"link text","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
16842            {"type":"text","text":" "}
16843          ]}]}
16844        ]}]}"#;
16845        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16846        let md = adf_to_markdown(&doc).unwrap();
16847        let round_tripped = markdown_to_adf(&md).unwrap();
16848        let list = &round_tripped.content[0];
16849        let item = &list.content.as_ref().unwrap()[0];
16850        let para = &item.content.as_ref().unwrap()[0];
16851        let inlines = para.content.as_ref().unwrap();
16852        let last = inlines.last().unwrap();
16853        assert_eq!(
16854            last.text.as_deref(),
16855            Some(" "),
16856            "trailing space text node should be preserved, got nodes: {:?}",
16857            inlines
16858                .iter()
16859                .map(|n| (&n.node_type, &n.text))
16860                .collect::<Vec<_>>()
16861        );
16862    }
16863
16864    #[test]
16865    fn trailing_space_after_mention_in_bullet_list_preserved() {
16866        // Mention + trailing space in list item
16867        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
16868          {"type":"listItem","content":[{"type":"paragraph","content":[
16869            {"type":"mention","attrs":{"id":"abc","text":"@Alice"}},
16870            {"type":"text","text":" "}
16871          ]}]}
16872        ]}]}"#;
16873        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16874        let md = adf_to_markdown(&doc).unwrap();
16875        let round_tripped = markdown_to_adf(&md).unwrap();
16876        let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
16877            .content
16878            .as_ref()
16879            .unwrap()[0];
16880        let inlines = para.content.as_ref().unwrap();
16881        assert!(
16882            inlines.len() >= 2,
16883            "should have mention + trailing space, got {} nodes",
16884            inlines.len()
16885        );
16886        assert_eq!(inlines.last().unwrap().text.as_deref(), Some(" "));
16887    }
16888
16889    #[test]
16890    fn trailing_space_in_ordered_list_item_preserved() {
16891        // Same issue in ordered list context
16892        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
16893          {"type":"listItem","content":[{"type":"paragraph","content":[
16894            {"type":"text","text":"item "},
16895            {"type":"text","text":"link","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
16896            {"type":"text","text":" "}
16897          ]}]}
16898        ]}]}"#;
16899        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16900        let md = adf_to_markdown(&doc).unwrap();
16901        let round_tripped = markdown_to_adf(&md).unwrap();
16902        let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
16903            .content
16904            .as_ref()
16905            .unwrap()[0];
16906        let inlines = para.content.as_ref().unwrap();
16907        let last = inlines.last().unwrap();
16908        assert_eq!(
16909            last.text.as_deref(),
16910            Some(" "),
16911            "trailing space should be preserved in ordered list item"
16912        );
16913    }
16914
16915    #[test]
16916    fn trailing_space_in_heading_text_preserved() {
16917        // Issue #400: trailing space in heading text node trimmed on round-trip
16918        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[
16919          {"type":"text","text":"Firefighting Engineers "}
16920        ]}]}"#;
16921        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16922        let md = adf_to_markdown(&doc).unwrap();
16923        let round_tripped = markdown_to_adf(&md).unwrap();
16924        let inlines = round_tripped.content[0].content.as_ref().unwrap();
16925        assert_eq!(
16926            inlines[0].text.as_deref(),
16927            Some("Firefighting Engineers "),
16928            "trailing space in heading should be preserved"
16929        );
16930    }
16931
16932    #[test]
16933    fn trailing_space_in_heading_before_bold_preserved() {
16934        // Issue #400: trailing space before bold sibling in heading
16935        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
16936          {"type":"text","text":"Classic "},
16937          {"type":"text","text":"bold","marks":[{"type":"strong"}]}
16938        ]}]}"#;
16939        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16940        let md = adf_to_markdown(&doc).unwrap();
16941        let round_tripped = markdown_to_adf(&md).unwrap();
16942        let inlines = round_tripped.content[0].content.as_ref().unwrap();
16943        assert_eq!(
16944            inlines[0].text.as_deref(),
16945            Some("Classic "),
16946            "trailing space in heading text before bold should be preserved"
16947        );
16948    }
16949
16950    #[test]
16951    fn leading_space_in_heading_text_preserved() {
16952        // Issue #492: leading spaces in heading text node stripped on round-trip
16953        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":3},"content":[
16954          {"type":"text","text":"  #general-channel"}
16955        ]}]}"#;
16956        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16957        let md = adf_to_markdown(&doc).unwrap();
16958        let round_tripped = markdown_to_adf(&md).unwrap();
16959        let inlines = round_tripped.content[0].content.as_ref().unwrap();
16960        assert_eq!(
16961            inlines[0].text.as_deref(),
16962            Some("  #general-channel"),
16963            "leading spaces in heading text should be preserved"
16964        );
16965    }
16966
16967    #[test]
16968    fn leading_space_in_heading_before_bold_preserved() {
16969        // Issue #492: leading space before bold sibling in heading
16970        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
16971          {"type":"text","text":"   indented"},
16972          {"type":"text","text":" bold","marks":[{"type":"strong"}]}
16973        ]}]}"#;
16974        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16975        let md = adf_to_markdown(&doc).unwrap();
16976        let round_tripped = markdown_to_adf(&md).unwrap();
16977        let inlines = round_tripped.content[0].content.as_ref().unwrap();
16978        assert_eq!(
16979            inlines[0].text.as_deref(),
16980            Some("   indented"),
16981            "leading spaces in heading text before bold should be preserved"
16982        );
16983    }
16984
16985    #[test]
16986    fn heading_multiple_leading_spaces_markdown_parse() {
16987        // Issue #492: verify JFM parsing preserves leading spaces
16988        let md = "### \t  #general-channel";
16989        let doc = markdown_to_adf(md).unwrap();
16990        let inlines = doc.content[0].content.as_ref().unwrap();
16991        assert_eq!(
16992            inlines[0].text.as_deref(),
16993            Some("\t  #general-channel"),
16994            "leading whitespace in heading text should be preserved during JFM parsing"
16995        );
16996    }
16997
16998    #[test]
16999    fn trailing_space_in_paragraph_text_preserved() {
17000        // Issue #400: trailing space in paragraph text node preserved on round-trip
17001        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17002          {"type":"text","text":"word followed by space "},
17003          {"type":"text","text":"next node","marks":[{"type":"strong"}]}
17004        ]}]}"#;
17005        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17006        let md = adf_to_markdown(&doc).unwrap();
17007        let round_tripped = markdown_to_adf(&md).unwrap();
17008        let inlines = round_tripped.content[0].content.as_ref().unwrap();
17009        assert_eq!(
17010            inlines[0].text.as_deref(),
17011            Some("word followed by space "),
17012            "trailing space in paragraph text should be preserved"
17013        );
17014    }
17015
17016    #[test]
17017    fn nested_bullet_list_roundtrip() {
17018        // ADF with a listItem containing a paragraph + nested bulletList
17019        let adf_doc = serde_json::json!({
17020            "type": "doc",
17021            "version": 1,
17022            "content": [{
17023                "type": "bulletList",
17024                "content": [{
17025                    "type": "listItem",
17026                    "content": [
17027                        {
17028                            "type": "paragraph",
17029                            "content": [{"type": "text", "text": "parent item"}]
17030                        },
17031                        {
17032                            "type": "bulletList",
17033                            "content": [
17034                                {
17035                                    "type": "listItem",
17036                                    "content": [{
17037                                        "type": "paragraph",
17038                                        "content": [{"type": "text", "text": "sub item 1"}]
17039                                    }]
17040                                },
17041                                {
17042                                    "type": "listItem",
17043                                    "content": [{
17044                                        "type": "paragraph",
17045                                        "content": [{"type": "text", "text": "sub item 2"}]
17046                                    }]
17047                                }
17048                            ]
17049                        }
17050                    ]
17051                }]
17052            }]
17053        });
17054        let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
17055        let md = adf_to_markdown(&doc).unwrap();
17056        assert!(
17057            md.contains("- parent item\n"),
17058            "expected top-level item in markdown, got: {md}"
17059        );
17060        assert!(
17061            md.contains("  - sub item 1\n"),
17062            "expected indented sub item 1 in markdown, got: {md}"
17063        );
17064        assert!(
17065            md.contains("  - sub item 2\n"),
17066            "expected indented sub item 2 in markdown, got: {md}"
17067        );
17068
17069        // Round-trip back
17070        let doc2 = markdown_to_adf(&md).unwrap();
17071        let list = &doc2.content[0];
17072        assert_eq!(list.node_type, "bulletList");
17073        let item = &list.content.as_ref().unwrap()[0];
17074        assert_eq!(item.node_type, "listItem");
17075        let item_content = item.content.as_ref().unwrap();
17076        assert_eq!(
17077            item_content.len(),
17078            2,
17079            "listItem should have paragraph + nested list"
17080        );
17081        assert_eq!(item_content[0].node_type, "paragraph");
17082        assert_eq!(item_content[1].node_type, "bulletList");
17083        let sub_items = item_content[1].content.as_ref().unwrap();
17084        assert_eq!(sub_items.len(), 2);
17085    }
17086
17087    #[test]
17088    fn nested_bullet_in_table_cell_roundtrip() {
17089        let md = "::::table\n:::tr\n:::td\n- parent\n  - child\n:::\n:::\n::::\n";
17090        let doc = markdown_to_adf(md).unwrap();
17091        let table = &doc.content[0];
17092        let row = &table.content.as_ref().unwrap()[0];
17093        let cell = &row.content.as_ref().unwrap()[0];
17094        let list = &cell.content.as_ref().unwrap()[0];
17095        assert_eq!(list.node_type, "bulletList");
17096        let item = &list.content.as_ref().unwrap()[0];
17097        let item_content = item.content.as_ref().unwrap();
17098        assert_eq!(
17099            item_content.len(),
17100            2,
17101            "listItem should have paragraph + nested list"
17102        );
17103        assert_eq!(item_content[1].node_type, "bulletList");
17104
17105        // Round-trip: adf→md→adf should preserve the nested list
17106        let md2 = adf_to_markdown(&doc).unwrap();
17107        assert!(
17108            md2.contains("  - child"),
17109            "expected indented child in round-tripped markdown, got: {md2}"
17110        );
17111    }
17112
17113    #[test]
17114    fn nested_ordered_list_roundtrip() {
17115        // Issue #389: nested orderedList inside listItem flattened
17116        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
17117          {"type":"listItem","content":[
17118            {"type":"paragraph","content":[{"type":"text","text":"Top level"}]},
17119            {"type":"orderedList","attrs":{"order":1},"content":[
17120              {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 1"}]}]},
17121              {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 2"}]}]}
17122            ]}
17123          ]},
17124          {"type":"listItem","content":[
17125            {"type":"paragraph","content":[{"type":"text","text":"Second top"}]}
17126          ]}
17127        ]}]}"#;
17128        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17129        let md = adf_to_markdown(&doc).unwrap();
17130        let round_tripped = markdown_to_adf(&md).unwrap();
17131
17132        // Outer list should have 2 items
17133        let outer = &round_tripped.content[0];
17134        assert_eq!(outer.node_type, "orderedList");
17135        assert_eq!(
17136            outer.attrs.as_ref().unwrap()["order"],
17137            1,
17138            "explicit order=1 must be preserved via trailing {{order=1}} (issue #547)"
17139        );
17140        let outer_items = outer.content.as_ref().unwrap();
17141        assert_eq!(
17142            outer_items.len(),
17143            2,
17144            "outer list should have 2 items, got {}",
17145            outer_items.len()
17146        );
17147
17148        // First item should have paragraph + nested orderedList
17149        let first_item = &outer_items[0];
17150        let first_content = first_item.content.as_ref().unwrap();
17151        assert_eq!(
17152            first_content.len(),
17153            2,
17154            "first listItem should have paragraph + nested list, got {}",
17155            first_content.len()
17156        );
17157        assert_eq!(first_content[0].node_type, "paragraph");
17158        assert_eq!(first_content[1].node_type, "orderedList");
17159        let nested_items = first_content[1].content.as_ref().unwrap();
17160        assert_eq!(nested_items.len(), 2, "nested list should have 2 items");
17161    }
17162
17163    #[test]
17164    fn nested_ordered_list_markdown_parsing() {
17165        // Direct markdown parsing of nested ordered list
17166        let md = "1. Top level\n  1. Nested 1\n  2. Nested 2\n2. Second top\n";
17167        let doc = markdown_to_adf(md).unwrap();
17168        let outer = &doc.content[0];
17169        assert_eq!(outer.node_type, "orderedList");
17170        let outer_items = outer.content.as_ref().unwrap();
17171        assert_eq!(outer_items.len(), 2, "should have 2 top-level items");
17172
17173        let first_content = outer_items[0].content.as_ref().unwrap();
17174        assert_eq!(
17175            first_content.len(),
17176            2,
17177            "first item should have paragraph + nested list"
17178        );
17179        assert_eq!(first_content[1].node_type, "orderedList");
17180    }
17181
17182    #[test]
17183    fn bullet_list_nested_inside_ordered_list() {
17184        // Mixed nesting: bullet list nested inside ordered list
17185        let md = "1. Ordered item\n  - Bullet child 1\n  - Bullet child 2\n2. Second ordered\n";
17186        let doc = markdown_to_adf(md).unwrap();
17187        let outer = &doc.content[0];
17188        assert_eq!(outer.node_type, "orderedList");
17189        let outer_items = outer.content.as_ref().unwrap();
17190        assert_eq!(outer_items.len(), 2);
17191
17192        let first_content = outer_items[0].content.as_ref().unwrap();
17193        assert_eq!(
17194            first_content.len(),
17195            2,
17196            "first item should have paragraph + nested list"
17197        );
17198        assert_eq!(first_content[1].node_type, "bulletList");
17199        let sub_items = first_content[1].content.as_ref().unwrap();
17200        assert_eq!(sub_items.len(), 2, "nested bullet list should have 2 items");
17201    }
17202
17203    #[test]
17204    fn ordered_list_order_attr_one_is_elided() {
17205        // Issue #547: order=1 is the default and must be elided from attrs
17206        // for round-trip fidelity with ADF documents that omit the attrs
17207        // object on orderedList.
17208        let md = "1. A\n2. B\n";
17209        let doc = markdown_to_adf(md).unwrap();
17210        assert!(
17211            doc.content[0].attrs.is_none(),
17212            "attrs should be elided when order=1"
17213        );
17214
17215        // Round-trip should preserve the elision
17216        let md2 = adf_to_markdown(&doc).unwrap();
17217        let doc2 = markdown_to_adf(&md2).unwrap();
17218        assert!(
17219            doc2.content[0].attrs.is_none(),
17220            "attrs should remain elided after round-trip"
17221        );
17222    }
17223
17224    #[test]
17225    fn issue_547_ordered_list_no_attrs_roundtrip_byte_identical() {
17226        // Issue #547: ADF orderedList without an attrs field must round-trip
17227        // (ADF → JFM → ADF) without gaining a spurious {"order": 1} attrs.
17228        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"First item"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Second item"}]}]}]}]}"#;
17229        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17230        let md = adf_to_markdown(&doc).unwrap();
17231        let rt = markdown_to_adf(&md).unwrap();
17232        assert!(
17233            rt.content[0].attrs.is_none(),
17234            "round-tripped orderedList should not have attrs, got: {:?}",
17235            rt.content[0].attrs
17236        );
17237
17238        // Serialized JSON must also omit attrs entirely for byte fidelity.
17239        let rt_json = serde_json::to_string(&rt).unwrap();
17240        assert!(
17241            !rt_json.contains("\"order\""),
17242            "round-tripped JSON should not contain \"order\", got: {rt_json}"
17243        );
17244    }
17245
17246    // ── Issue #547: orderedList byte-identical roundtrip coverage ───────
17247
17248    /// Assert that ADF → JFM → ADF produces a document whose serialized JSON
17249    /// (as a sorted-key canonical form) matches the source JSON. Mirrors the
17250    /// `jq --sort-keys` comparison used in the issue's reproducer.
17251    fn assert_roundtrip_byte_identical(adf_json: &str) {
17252        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17253        let md = adf_to_markdown(&doc).unwrap();
17254        let rt = markdown_to_adf(&md).unwrap();
17255
17256        let canonical_src: serde_json::Value = serde_json::from_str(adf_json).unwrap();
17257        let canonical_rt: serde_json::Value =
17258            serde_json::from_str(&serde_json::to_string(&rt).unwrap()).unwrap();
17259        assert_eq!(
17260            canonical_src, canonical_rt,
17261            "round-trip diverged\n  src: {canonical_src}\n   rt: {canonical_rt}\n   md: {md:?}"
17262        );
17263    }
17264
17265    #[test]
17266    fn issue_547_single_item_no_attrs_roundtrip() {
17267        assert_roundtrip_byte_identical(
17268            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"only"}]}]}]}]}"#,
17269        );
17270    }
17271
17272    #[test]
17273    fn issue_547_many_items_no_attrs_roundtrip() {
17274        assert_roundtrip_byte_identical(
17275            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"A"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"B"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"C"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"D"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"E"}]}]}]}]}"#,
17276        );
17277    }
17278
17279    #[test]
17280    fn issue_547_non_default_order_preserved() {
17281        // When order != 1, attrs must still be serialized (fix must not
17282        // over-eagerly drop attrs).
17283        assert_roundtrip_byte_identical(
17284            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":5},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"fifth"}]}]}]}]}"#,
17285        );
17286    }
17287
17288    #[test]
17289    fn issue_547_nested_ordered_in_ordered_no_attrs_roundtrip() {
17290        // Outer and inner both omit attrs; fix must apply at every level.
17291        assert_roundtrip_byte_identical(
17292            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"outer"}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}]}]}]}]}"#,
17293        );
17294    }
17295
17296    #[test]
17297    fn issue_547_ordered_nested_in_bullet_no_attrs_roundtrip() {
17298        assert_roundtrip_byte_identical(
17299            r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"bullet"}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"nested"}]}]}]}]}]}]}"#,
17300        );
17301    }
17302
17303    #[test]
17304    fn issue_547_bullet_nested_in_ordered_no_attrs_roundtrip() {
17305        assert_roundtrip_byte_identical(
17306            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"outer"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"nested"}]}]}]}]}]}]}"#,
17307        );
17308    }
17309
17310    #[test]
17311    fn issue_547_ordered_list_between_paragraphs_roundtrip() {
17312        assert_roundtrip_byte_identical(
17313            r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"intro"}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item"}]}]}]},{"type":"paragraph","content":[{"type":"text","text":"outro"}]}]}"#,
17314        );
17315    }
17316
17317    #[test]
17318    fn issue_547_ordered_list_with_marked_text_roundtrip() {
17319        assert_roundtrip_byte_identical(
17320            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"bold","marks":[{"type":"strong"}]}]}]}]}]}"#,
17321        );
17322    }
17323
17324    #[test]
17325    fn issue_547_ordered_list_with_link_roundtrip() {
17326        assert_roundtrip_byte_identical(
17327            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"site","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]}]}]}]}"#,
17328        );
17329    }
17330
17331    #[test]
17332    fn issue_547_ordered_list_with_hardbreak_roundtrip() {
17333        assert_roundtrip_byte_identical(
17334            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"},{"type":"hardBreak"},{"type":"text","text":"b"}]}]}]}]}"#,
17335        );
17336    }
17337
17338    #[test]
17339    fn issue_547_triple_nested_ordered_roundtrip() {
17340        assert_roundtrip_byte_identical(
17341            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"L1"}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"L2"}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"L3"}]}]}]}]}]}]}]}]}"#,
17342        );
17343    }
17344
17345    #[test]
17346    fn issue_547_ordered_list_heading_rule_mix_roundtrip() {
17347        assert_roundtrip_byte_identical(
17348            r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Title"}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]},{"type":"rule"}]}"#,
17349        );
17350    }
17351
17352    #[test]
17353    fn issue_547_ordered_list_listitem_localid_roundtrip() {
17354        // listItem attrs must coexist with the no-attrs outer orderedList.
17355        assert_roundtrip_byte_identical(
17356            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]}]}]}"#,
17357        );
17358    }
17359
17360    #[test]
17361    fn issue_547_explicit_order_one_preserved_roundtrip() {
17362        // Inverse regression (see PR #562 comment 4266630848): when the source
17363        // ADF has an explicit `"attrs": {"order": 1}` the round-trip must
17364        // preserve it, not strip it. A trailing `{order=1}` signal on the
17365        // rendered markdown distinguishes explicit-default from omitted attrs.
17366        assert_roundtrip_byte_identical(
17367            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"First item"}]}]}]}]}"#,
17368        );
17369    }
17370
17371    #[test]
17372    fn issue_547_explicit_order_one_nested_preserved_roundtrip() {
17373        // Both outer and inner orderedList have explicit `order: 1`; both must
17374        // be preserved across the round-trip independently.
17375        assert_roundtrip_byte_identical(
17376            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"outer"}]},{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}]}]}]}]}"#,
17377        );
17378    }
17379
17380    #[test]
17381    fn issue_547_mixed_explicit_and_implicit_order_roundtrip() {
17382        // Sibling orderedLists with different attrs presence must round-trip
17383        // independently: first has explicit `order: 1`, second omits attrs.
17384        assert_roundtrip_byte_identical(
17385            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}]}]}"#,
17386        );
17387    }
17388
17389    #[test]
17390    fn issue_547_explicit_order_one_with_listitem_localid_roundtrip() {
17391        // Explicit `order: 1` outer, plus a listItem `localId` inside — the
17392        // trailing `{order=1}` line must not swallow or collide with listItem
17393        // attrs.
17394        assert_roundtrip_byte_identical(
17395            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","attrs":{"localId":"li-1"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]}]}]}"#,
17396        );
17397    }
17398
17399    #[test]
17400    fn issue_547_order_attr_signal_appears_only_for_explicit_one() {
17401        // Render-layer guard: `{order=1}` appears in markdown only when the
17402        // source ADF has explicit `attrs.order=1`. No signal for attrs=None,
17403        // no signal for attrs.order>1 (marker already encodes the value).
17404        let no_attrs = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}]}"#;
17405        let explicit_one = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}]}"#;
17406        let order_five = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":5},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}]}"#;
17407
17408        let md_no =
17409            adf_to_markdown(&serde_json::from_str::<AdfDocument>(no_attrs).unwrap()).unwrap();
17410        let md_one =
17411            adf_to_markdown(&serde_json::from_str::<AdfDocument>(explicit_one).unwrap()).unwrap();
17412        let md_five =
17413            adf_to_markdown(&serde_json::from_str::<AdfDocument>(order_five).unwrap()).unwrap();
17414
17415        assert!(
17416            !md_no.contains("{order="),
17417            "no-attrs source must not emit order signal, got: {md_no:?}"
17418        );
17419        assert!(
17420            md_one.contains("{order=1}"),
17421            "explicit order=1 must emit trailing signal, got: {md_one:?}"
17422        );
17423        assert!(
17424            !md_five.contains("{order="),
17425            "order=5 is already encoded by marker; must not emit signal, got: {md_five:?}"
17426        );
17427    }
17428
17429    // ── File media round-trip tests ─────────────────────────────────────
17430
17431    #[test]
17432    fn file_media_roundtrip() {
17433        // ADF with a Confluence file attachment (type:file media)
17434        let adf_doc = serde_json::json!({
17435            "type": "doc",
17436            "version": 1,
17437            "content": [{
17438                "type": "mediaSingle",
17439                "attrs": {"layout": "center"},
17440                "content": [{
17441                    "type": "media",
17442                    "attrs": {
17443                        "type": "file",
17444                        "id": "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d",
17445                        "collection": "contentId-8220672100",
17446                        "height": 56,
17447                        "width": 312,
17448                        "alt": "Screenshot.png"
17449                    }
17450                }]
17451            }]
17452        });
17453        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
17454        let md = adf_to_markdown(&doc).unwrap();
17455        assert!(
17456            md.contains("type=file"),
17457            "expected type=file in markdown, got: {md}"
17458        );
17459        assert!(
17460            md.contains("id=6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d"),
17461            "expected id in markdown, got: {md}"
17462        );
17463        assert!(
17464            md.contains("collection=contentId-8220672100"),
17465            "expected collection in markdown, got: {md}"
17466        );
17467        // Round-trip back to ADF
17468        let doc2 = markdown_to_adf(&md).unwrap();
17469        let ms = &doc2.content[0];
17470        assert_eq!(ms.node_type, "mediaSingle");
17471        let media = &ms.content.as_ref().unwrap()[0];
17472        assert_eq!(media.node_type, "media");
17473        let attrs = media.attrs.as_ref().unwrap();
17474        assert_eq!(attrs["type"], "file");
17475        assert_eq!(attrs["id"], "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d");
17476        assert_eq!(attrs["collection"], "contentId-8220672100");
17477        assert_eq!(attrs["height"], 56);
17478        assert_eq!(attrs["width"], 312);
17479        assert_eq!(attrs["alt"], "Screenshot.png");
17480    }
17481
17482    /// Issue #550: roundtrip of mediaSingle with file-type media preserves all
17483    /// file attributes (type, id, collection, width, height). Regression guard
17484    /// for the exact reproducer in the issue body.
17485    #[test]
17486    fn file_media_roundtrip_issue_550_reproducer() {
17487        let adf_json = r#"{
17488          "version": 1,
17489          "type": "doc",
17490          "content": [
17491            {
17492              "type": "mediaSingle",
17493              "attrs": {"layout": "center"},
17494              "content": [
17495                {
17496                  "type": "media",
17497                  "attrs": {
17498                    "type": "file",
17499                    "id": "abc-123-def-456",
17500                    "collection": "my-collection",
17501                    "width": 941,
17502                    "height": 655
17503                  }
17504                }
17505              ]
17506            }
17507          ]
17508        }"#;
17509        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17510        let md = adf_to_markdown(&doc).unwrap();
17511        let rt = markdown_to_adf(&md).unwrap();
17512        let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
17513        let actual = serde_json::to_value(&rt).unwrap();
17514        assert_eq!(
17515            actual, expected,
17516            "roundtrip should preserve file media attrs; md was:\n{md}"
17517        );
17518    }
17519
17520    /// Issue #550 (updated reproducer): roundtrip of a file-media `id`
17521    /// containing spaces must not truncate the value. Before the fix, the
17522    /// JFM renderer emitted `id=abc 123 def 456` unquoted and the parser
17523    /// treated the first space as a value terminator, so the `id` became
17524    /// `"abc"` after round-trip.
17525    #[test]
17526    fn file_media_roundtrip_id_with_spaces() {
17527        let adf_json = r#"{
17528          "version": 1,
17529          "type": "doc",
17530          "content": [
17531            {
17532              "type": "mediaSingle",
17533              "attrs": {"layout": "center"},
17534              "content": [
17535                {
17536                  "type": "media",
17537                  "attrs": {
17538                    "type": "file",
17539                    "id": "abc 123 def 456",
17540                    "collection": "my-collection",
17541                    "width": 800,
17542                    "height": 600
17543                  }
17544                }
17545              ]
17546            }
17547          ]
17548        }"#;
17549        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17550        let md = adf_to_markdown(&doc).unwrap();
17551        assert!(
17552            md.contains(r#"id="abc 123 def 456""#),
17553            "id with spaces should be quoted in JFM, got:\n{md}"
17554        );
17555        let rt = markdown_to_adf(&md).unwrap();
17556        let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
17557        let actual = serde_json::to_value(&rt).unwrap();
17558        assert_eq!(
17559            actual, expected,
17560            "space-containing id must round-trip; md was:\n{md}"
17561        );
17562    }
17563
17564    /// Space-containing `collection` values must round-trip.
17565    #[test]
17566    fn file_media_roundtrip_collection_with_spaces() {
17567        let adf_json = r#"{
17568          "version": 1,
17569          "type": "doc",
17570          "content": [
17571            {
17572              "type": "mediaSingle",
17573              "attrs": {"layout": "center"},
17574              "content": [
17575                {
17576                  "type": "media",
17577                  "attrs": {
17578                    "type": "file",
17579                    "id": "abc-123",
17580                    "collection": "my collection with spaces"
17581                  }
17582                }
17583              ]
17584            }
17585          ]
17586        }"#;
17587        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17588        let md = adf_to_markdown(&doc).unwrap();
17589        let rt = markdown_to_adf(&md).unwrap();
17590        let media = &rt.content[0].content.as_ref().unwrap()[0];
17591        assert_eq!(
17592            media.attrs.as_ref().unwrap()["collection"],
17593            "my collection with spaces"
17594        );
17595    }
17596
17597    /// Space-containing `occurrenceKey` values must round-trip.
17598    #[test]
17599    fn file_media_roundtrip_occurrence_key_with_spaces() {
17600        let adf_json = r#"{
17601          "version": 1,
17602          "type": "doc",
17603          "content": [
17604            {
17605              "type": "mediaSingle",
17606              "attrs": {"layout": "center"},
17607              "content": [
17608                {
17609                  "type": "media",
17610                  "attrs": {
17611                    "type": "file",
17612                    "id": "x",
17613                    "collection": "y",
17614                    "occurrenceKey": "key with spaces"
17615                  }
17616                }
17617              ]
17618            }
17619          ]
17620        }"#;
17621        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17622        let md = adf_to_markdown(&doc).unwrap();
17623        let rt = markdown_to_adf(&md).unwrap();
17624        let media = &rt.content[0].content.as_ref().unwrap()[0];
17625        assert_eq!(
17626            media.attrs.as_ref().unwrap()["occurrenceKey"],
17627            "key with spaces"
17628        );
17629    }
17630
17631    /// Values with embedded `"` must be escape-quoted and round-trip.
17632    #[test]
17633    fn file_media_roundtrip_id_with_quote_char() {
17634        let adf_json = r#"{
17635          "version": 1,
17636          "type": "doc",
17637          "content": [
17638            {
17639              "type": "mediaSingle",
17640              "attrs": {"layout": "center"},
17641              "content": [
17642                {
17643                  "type": "media",
17644                  "attrs": {
17645                    "type": "file",
17646                    "id": "a\"b\"c",
17647                    "collection": "col"
17648                  }
17649                }
17650              ]
17651            }
17652          ]
17653        }"#;
17654        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17655        let md = adf_to_markdown(&doc).unwrap();
17656        let rt = markdown_to_adf(&md).unwrap();
17657        let media = &rt.content[0].content.as_ref().unwrap()[0];
17658        assert_eq!(media.attrs.as_ref().unwrap()["id"], "a\"b\"c");
17659    }
17660
17661    /// `mediaInline` string attrs with spaces must round-trip (parallel fix
17662    /// for the inline-directive rendering path).
17663    #[test]
17664    fn media_inline_roundtrip_id_with_spaces() {
17665        let adf_json = r#"{
17666          "version": 1,
17667          "type": "doc",
17668          "content": [
17669            {
17670              "type": "paragraph",
17671              "content": [
17672                {"type": "text", "text": "before "},
17673                {
17674                  "type": "mediaInline",
17675                  "attrs": {
17676                    "type": "file",
17677                    "id": "a b c",
17678                    "collection": "my col"
17679                  }
17680                },
17681                {"type": "text", "text": " after"}
17682              ]
17683            }
17684          ]
17685        }"#;
17686        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17687        let md = adf_to_markdown(&doc).unwrap();
17688        let rt = markdown_to_adf(&md).unwrap();
17689        let inline = &rt.content[0].content.as_ref().unwrap()[1];
17690        assert_eq!(inline.node_type, "mediaInline");
17691        let attrs = inline.attrs.as_ref().unwrap();
17692        assert_eq!(attrs["id"], "a b c");
17693        assert_eq!(attrs["collection"], "my col");
17694    }
17695
17696    /// Issue #550: `occurrenceKey` attribute is a standard ADF media attr and
17697    /// must be preserved through ADF→JFM→ADF roundtrip.
17698    #[test]
17699    fn file_media_roundtrip_preserves_occurrence_key() {
17700        let adf_json = r#"{
17701          "version": 1,
17702          "type": "doc",
17703          "content": [
17704            {
17705              "type": "mediaSingle",
17706              "attrs": {"layout": "center"},
17707              "content": [
17708                {
17709                  "type": "media",
17710                  "attrs": {
17711                    "type": "file",
17712                    "id": "abc-123",
17713                    "collection": "my-collection",
17714                    "occurrenceKey": "unique-key-xyz",
17715                    "width": 200,
17716                    "height": 100
17717                  }
17718                }
17719              ]
17720            }
17721          ]
17722        }"#;
17723        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17724        let md = adf_to_markdown(&doc).unwrap();
17725        assert!(
17726            md.contains("occurrenceKey=unique-key-xyz"),
17727            "expected occurrenceKey in markdown, got: {md}"
17728        );
17729        let rt = markdown_to_adf(&md).unwrap();
17730        let media = &rt.content[0].content.as_ref().unwrap()[0];
17731        let attrs = media.attrs.as_ref().unwrap();
17732        assert_eq!(attrs["occurrenceKey"], "unique-key-xyz");
17733        assert_eq!(attrs["type"], "file");
17734        assert_eq!(attrs["id"], "abc-123");
17735        assert_eq!(attrs["collection"], "my-collection");
17736    }
17737
17738    // ── mediaSingle caption tests (issue #470) ──────────────────────────
17739
17740    #[test]
17741    fn media_single_caption_adf_to_markdown() {
17742        let adf_doc = serde_json::json!({
17743            "type": "doc",
17744            "version": 1,
17745            "content": [{
17746                "type": "mediaSingle",
17747                "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
17748                "content": [
17749                    {
17750                        "type": "media",
17751                        "attrs": {
17752                            "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
17753                            "type": "file",
17754                            "collection": "contentId-123456",
17755                            "width": 800,
17756                            "height": 600
17757                        }
17758                    },
17759                    {
17760                        "type": "caption",
17761                        "content": [{"type": "text", "text": "An image caption here"}]
17762                    }
17763                ]
17764            }]
17765        });
17766        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
17767        let md = adf_to_markdown(&doc).unwrap();
17768        assert!(
17769            md.contains(":::caption"),
17770            "expected :::caption in markdown, got: {md}"
17771        );
17772        assert!(
17773            md.contains("An image caption here"),
17774            "expected caption text in markdown, got: {md}"
17775        );
17776    }
17777
17778    #[test]
17779    fn media_single_caption_markdown_to_adf() {
17780        let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nAn image caption here\n:::\n";
17781        let doc = markdown_to_adf(md).unwrap();
17782        let ms = &doc.content[0];
17783        assert_eq!(ms.node_type, "mediaSingle");
17784        let content = ms.content.as_ref().unwrap();
17785        assert_eq!(content.len(), 2, "expected media + caption children");
17786        assert_eq!(content[0].node_type, "media");
17787        assert_eq!(content[1].node_type, "caption");
17788        let caption_content = content[1].content.as_ref().unwrap();
17789        assert_eq!(
17790            caption_content[0].text.as_deref(),
17791            Some("An image caption here")
17792        );
17793    }
17794
17795    #[test]
17796    fn media_single_caption_round_trip() {
17797        // Full round-trip: ADF → JFM → ADF preserves caption
17798        let adf_doc = serde_json::json!({
17799            "type": "doc",
17800            "version": 1,
17801            "content": [{
17802                "type": "mediaSingle",
17803                "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
17804                "content": [
17805                    {
17806                        "type": "media",
17807                        "attrs": {
17808                            "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
17809                            "type": "file",
17810                            "collection": "contentId-123456",
17811                            "width": 800,
17812                            "height": 600
17813                        }
17814                    },
17815                    {
17816                        "type": "caption",
17817                        "content": [{"type": "text", "text": "An image caption here"}]
17818                    }
17819                ]
17820            }]
17821        });
17822        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
17823        let md = adf_to_markdown(&doc).unwrap();
17824        let doc2 = markdown_to_adf(&md).unwrap();
17825        let ms = &doc2.content[0];
17826        assert_eq!(ms.node_type, "mediaSingle");
17827        let content = ms.content.as_ref().unwrap();
17828        assert_eq!(
17829            content.len(),
17830            2,
17831            "expected media + caption after round-trip"
17832        );
17833        assert_eq!(content[1].node_type, "caption");
17834        let caption_content = content[1].content.as_ref().unwrap();
17835        assert_eq!(
17836            caption_content[0].text.as_deref(),
17837            Some("An image caption here")
17838        );
17839    }
17840
17841    #[test]
17842    fn media_single_caption_with_inline_marks() {
17843        let adf_doc = serde_json::json!({
17844            "type": "doc",
17845            "version": 1,
17846            "content": [{
17847                "type": "mediaSingle",
17848                "attrs": {"layout": "center"},
17849                "content": [
17850                    {
17851                        "type": "media",
17852                        "attrs": {"type": "external", "url": "https://example.com/img.png"}
17853                    },
17854                    {
17855                        "type": "caption",
17856                        "content": [
17857                            {"type": "text", "text": "A "},
17858                            {"type": "text", "text": "bold", "marks": [{"type": "strong"}]},
17859                            {"type": "text", "text": " caption"}
17860                        ]
17861                    }
17862                ]
17863            }]
17864        });
17865        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
17866        let md = adf_to_markdown(&doc).unwrap();
17867        assert!(
17868            md.contains("**bold**"),
17869            "expected bold in caption, got: {md}"
17870        );
17871
17872        let doc2 = markdown_to_adf(&md).unwrap();
17873        let content = doc2.content[0].content.as_ref().unwrap();
17874        assert_eq!(content.len(), 2, "expected media + caption");
17875        assert_eq!(content[1].node_type, "caption");
17876        let caption_inlines = content[1].content.as_ref().unwrap();
17877        let bold_node = caption_inlines
17878            .iter()
17879            .find(|n| n.text.as_deref() == Some("bold"))
17880            .unwrap();
17881        let marks = bold_node.marks.as_ref().unwrap();
17882        assert_eq!(marks[0].mark_type, "strong");
17883    }
17884
17885    #[test]
17886    fn media_single_no_caption_unaffected() {
17887        // Existing mediaSingle without caption should be unaffected
17888        let adf_doc = serde_json::json!({
17889            "type": "doc",
17890            "version": 1,
17891            "content": [{
17892                "type": "mediaSingle",
17893                "attrs": {"layout": "center"},
17894                "content": [{
17895                    "type": "media",
17896                    "attrs": {"type": "external", "url": "https://example.com/img.png"}
17897                }]
17898            }]
17899        });
17900        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
17901        let md = adf_to_markdown(&doc).unwrap();
17902        assert!(
17903            !md.contains(":::caption"),
17904            "should not emit caption when none present"
17905        );
17906        let doc2 = markdown_to_adf(&md).unwrap();
17907        let content = doc2.content[0].content.as_ref().unwrap();
17908        assert_eq!(content.len(), 1, "should only have media child");
17909        assert_eq!(content[0].node_type, "media");
17910    }
17911
17912    #[test]
17913    fn media_single_empty_caption_round_trip() {
17914        // Caption node with no content should still round-trip
17915        let adf_doc = serde_json::json!({
17916            "type": "doc",
17917            "version": 1,
17918            "content": [{
17919                "type": "mediaSingle",
17920                "attrs": {"layout": "center"},
17921                "content": [
17922                    {
17923                        "type": "media",
17924                        "attrs": {"type": "external", "url": "https://example.com/img.png"}
17925                    },
17926                    {
17927                        "type": "caption"
17928                    }
17929                ]
17930            }]
17931        });
17932        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
17933        let md = adf_to_markdown(&doc).unwrap();
17934        assert!(
17935            md.contains(":::caption"),
17936            "expected :::caption even for empty caption, got: {md}"
17937        );
17938        assert!(
17939            md.contains(":::\n"),
17940            "expected closing ::: fence, got: {md}"
17941        );
17942    }
17943
17944    #[test]
17945    fn media_single_external_caption_round_trip() {
17946        // External image with caption round-trips
17947        let md = "![alt](https://example.com/img.png)\n:::caption\nImage description\n:::\n";
17948        let doc = markdown_to_adf(md).unwrap();
17949        let ms = &doc.content[0];
17950        assert_eq!(ms.node_type, "mediaSingle");
17951        let content = ms.content.as_ref().unwrap();
17952        assert_eq!(content.len(), 2);
17953        assert_eq!(content[0].node_type, "media");
17954        assert_eq!(content[1].node_type, "caption");
17955
17956        let md2 = adf_to_markdown(&doc).unwrap();
17957        let doc2 = markdown_to_adf(&md2).unwrap();
17958        let content2 = doc2.content[0].content.as_ref().unwrap();
17959        assert_eq!(content2.len(), 2);
17960        assert_eq!(content2[1].node_type, "caption");
17961        let caption_text = content2[1].content.as_ref().unwrap();
17962        assert_eq!(caption_text[0].text.as_deref(), Some("Image description"));
17963    }
17964
17965    // ── mediaSingle caption localId tests (issue #524) ─────────────────
17966
17967    #[test]
17968    fn media_single_caption_localid_roundtrip() {
17969        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"mediaSingle","attrs":{"layout":"center"},"content":[{"type":"media","attrs":{"id":"aabbccdd-1234-5678-abcd-000000000001","type":"file","collection":"test-collection"}},{"type":"caption","attrs":{"localId":"9da8c2104471"},"content":[{"type":"text","text":"a caption with hex localId"}]}]}]}"#;
17970        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17971        let md = adf_to_markdown(&doc).unwrap();
17972        assert!(
17973            md.contains("localId=9da8c2104471"),
17974            "caption localId should appear in markdown: {md}"
17975        );
17976        let rt = markdown_to_adf(&md).unwrap();
17977        let content = rt.content[0].content.as_ref().unwrap();
17978        let caption = &content[1];
17979        assert_eq!(caption.node_type, "caption");
17980        assert_eq!(
17981            caption.attrs.as_ref().unwrap()["localId"],
17982            "9da8c2104471",
17983            "caption localId should round-trip"
17984        );
17985    }
17986
17987    #[test]
17988    fn media_single_caption_without_localid() {
17989        let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nPlain caption\n:::\n";
17990        let doc = markdown_to_adf(md).unwrap();
17991        let caption = &doc.content[0].content.as_ref().unwrap()[1];
17992        assert_eq!(caption.node_type, "caption");
17993        assert!(
17994            caption.attrs.is_none(),
17995            "caption without localId should not gain attrs"
17996        );
17997        let md2 = adf_to_markdown(&doc).unwrap();
17998        assert!(
17999            !md2.contains("localId"),
18000            "no localId should appear in output: {md2}"
18001        );
18002    }
18003
18004    #[test]
18005    fn media_single_caption_localid_stripped_when_option_set() {
18006        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"mediaSingle","attrs":{"layout":"center"},"content":[{"type":"media","attrs":{"id":"aabbccdd-1234-5678-abcd-000000000001","type":"file","collection":"test-collection"}},{"type":"caption","attrs":{"localId":"9da8c2104471"},"content":[{"type":"text","text":"stripped caption"}]}]}]}"#;
18007        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18008        let opts = RenderOptions {
18009            strip_local_ids: true,
18010            ..Default::default()
18011        };
18012        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18013        assert!(!md.contains("localId"), "localId should be stripped: {md}");
18014    }
18015
18016    #[test]
18017    fn table_width_roundtrip() {
18018        // ADF table with width attribute
18019        let adf_doc = serde_json::json!({
18020            "type": "doc",
18021            "version": 1,
18022            "content": [{
18023                "type": "table",
18024                "attrs": {"layout": "default", "width": 760.0},
18025                "content": [{
18026                    "type": "tableRow",
18027                    "content": [{
18028                        "type": "tableHeader",
18029                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
18030                    }]
18031                }]
18032            }]
18033        });
18034        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
18035        let md = adf_to_markdown(&doc).unwrap();
18036        assert!(
18037            md.contains("width=760.0"),
18038            "expected width=760.0 in markdown (float preserved), got: {md}"
18039        );
18040        // Round-trip back to ADF
18041        let doc2 = markdown_to_adf(&md).unwrap();
18042        let table = &doc2.content[0];
18043        assert_eq!(table.node_type, "table");
18044        let table_attrs = table.attrs.as_ref().unwrap();
18045        assert_eq!(table_attrs["width"], 760.0);
18046        assert!(
18047            table_attrs["width"].is_f64(),
18048            "expected float width to be preserved as f64, got: {:?}",
18049            table_attrs["width"]
18050        );
18051    }
18052
18053    #[test]
18054    fn table_integer_width_roundtrip_preserves_integer() {
18055        // Issue #577: Integer width in ADF must survive roundtrip without being
18056        // coerced to a float.
18057        let adf_doc = serde_json::json!({
18058            "type": "doc",
18059            "version": 1,
18060            "content": [{
18061                "type": "table",
18062                "attrs": {
18063                    "isNumberColumnEnabled": false,
18064                    "layout": "center",
18065                    "localId": "abc-123",
18066                    "width": 1420
18067                },
18068                "content": [{
18069                    "type": "tableRow",
18070                    "content": [{
18071                        "type": "tableCell",
18072                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Cell"}]}]
18073                    }]
18074                }]
18075            }]
18076        });
18077        let doc: crate::atlassian::adf::AdfDocument =
18078            serde_json::from_value(adf_doc.clone()).unwrap();
18079        let md = adf_to_markdown(&doc).unwrap();
18080        assert!(
18081            md.contains("width=1420"),
18082            "expected width=1420 in markdown, got: {md}"
18083        );
18084        assert!(
18085            !md.contains("width=1420.0"),
18086            "integer width should not be rendered with decimal: {md}"
18087        );
18088
18089        let doc2 = markdown_to_adf(&md).unwrap();
18090        let table = &doc2.content[0];
18091        assert_eq!(table.node_type, "table");
18092        let table_attrs = table.attrs.as_ref().unwrap();
18093        assert_eq!(table_attrs["width"], 1420);
18094        assert!(
18095            table_attrs["width"].is_u64() || table_attrs["width"].is_i64(),
18096            "width should remain an integer, got: {:?}",
18097            table_attrs["width"]
18098        );
18099        assert!(
18100            !table_attrs["width"].is_f64(),
18101            "width should not be a float, got: {:?}",
18102            table_attrs["width"]
18103        );
18104
18105        // Full byte-fidelity: re-serialized ADF should match original JSON.
18106        let roundtripped = serde_json::to_value(&doc2).unwrap();
18107        let orig_width = &adf_doc["content"][0]["attrs"]["width"];
18108        let rt_width = &roundtripped["content"][0]["attrs"]["width"];
18109        assert_eq!(
18110            orig_width, rt_width,
18111            "width value must roundtrip byte-for-byte"
18112        );
18113    }
18114
18115    #[test]
18116    fn table_fractional_width_roundtrip() {
18117        // Fractional float widths should also roundtrip faithfully.
18118        let adf_doc = serde_json::json!({
18119            "type": "doc",
18120            "version": 1,
18121            "content": [{
18122                "type": "table",
18123                "attrs": {"layout": "default", "width": 760.5},
18124                "content": [{
18125                    "type": "tableRow",
18126                    "content": [{
18127                        "type": "tableHeader",
18128                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
18129                    }]
18130                }]
18131            }]
18132        });
18133        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
18134        let md = adf_to_markdown(&doc).unwrap();
18135        assert!(
18136            md.contains("width=760.5"),
18137            "expected width=760.5 in markdown, got: {md}"
18138        );
18139        let doc2 = markdown_to_adf(&md).unwrap();
18140        let table_attrs = doc2.content[0].attrs.as_ref().unwrap();
18141        assert_eq!(table_attrs["width"], 760.5);
18142        assert!(table_attrs["width"].is_f64());
18143    }
18144
18145    #[test]
18146    fn pipe_table_integer_width_roundtrip() {
18147        // Exercises the try_table() attrs-on-next-line parsing path.
18148        let md = "| A | B |\n|---|---|\n| 1 | 2 |\n{layout=default width=1420}\n";
18149        let doc = markdown_to_adf(md).unwrap();
18150        let table = &doc.content[0];
18151        assert_eq!(table.node_type, "table");
18152        let attrs = table.attrs.as_ref().unwrap();
18153        assert_eq!(attrs["width"], 1420);
18154        assert!(
18155            attrs["width"].is_u64() || attrs["width"].is_i64(),
18156            "pipe-table width must stay integer, got: {:?}",
18157            attrs["width"]
18158        );
18159    }
18160
18161    #[test]
18162    fn file_media_width_type_roundtrip() {
18163        // mediaSingle with widthType:pixel should survive round-trip
18164        let adf_doc = serde_json::json!({
18165            "type": "doc",
18166            "version": 1,
18167            "content": [{
18168                "type": "mediaSingle",
18169                "attrs": {"layout": "center", "width": 312, "widthType": "pixel"},
18170                "content": [{
18171                    "type": "media",
18172                    "attrs": {
18173                        "type": "file",
18174                        "id": "abc123",
18175                        "collection": "contentId-999",
18176                        "height": 56,
18177                        "width": 312
18178                    }
18179                }]
18180            }]
18181        });
18182        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
18183        let md = adf_to_markdown(&doc).unwrap();
18184        assert!(
18185            md.contains("widthType=pixel"),
18186            "expected widthType=pixel in markdown, got: {md}"
18187        );
18188        let doc2 = markdown_to_adf(&md).unwrap();
18189        let ms = &doc2.content[0];
18190        let ms_attrs = ms.attrs.as_ref().unwrap();
18191        assert_eq!(ms_attrs["widthType"], "pixel");
18192        assert_eq!(ms_attrs["width"], 312);
18193    }
18194
18195    #[test]
18196    fn file_media_pixel_width_above_100_passes_validation() {
18197        // Issue #1037: confluence_read emits pixel widths well above 100 for
18198        // editor-sized images (here width=900). Lowering carries width=900
18199        // widthType=pixel onto the mediaSingle, which is valid ADF — the
18200        // upstream schema's pixel branch is unbounded. A read→write round-trip
18201        // must clear the write-path validator, not be rejected as out of range
18202        // (the original failure: "value 900 is outside the allowed range
18203        // [0, 100]"). Unlike `file_media_width_type_roundtrip`, this asserts on
18204        // the validator, which the conversion-only round-trip never exercises.
18205        let md = "![alt](){type=file id=11111111-2222-3333-4444-555555555555 collection=contentId-98765 height=904 width=900 mediaWidth=900 widthType=pixel}\n";
18206        let adf = markdown_to_adf(md).unwrap();
18207
18208        let ms_attrs = adf.content[0].attrs.as_ref().unwrap();
18209        assert_eq!(ms_attrs["width"], 900);
18210        assert_eq!(ms_attrs["widthType"], "pixel");
18211
18212        crate::atlassian::adf_validated::validate(&adf).unwrap();
18213    }
18214
18215    #[test]
18216    fn file_media_mode_roundtrip() {
18217        // mediaSingle with mode attr should survive round-trip (issue #431)
18218        let adf_doc = serde_json::json!({
18219            "type": "doc",
18220            "version": 1,
18221            "content": [{
18222                "type": "mediaSingle",
18223                "attrs": {"layout": "wide", "mode": "wide", "width": 1200},
18224                "content": [{
18225                    "type": "media",
18226                    "attrs": {
18227                        "type": "file",
18228                        "id": "abc123",
18229                        "collection": "test",
18230                        "width": 1200,
18231                        "height": 600
18232                    }
18233                }]
18234            }]
18235        });
18236        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
18237        let md = adf_to_markdown(&doc).unwrap();
18238        assert!(
18239            md.contains("mode=wide"),
18240            "expected mode=wide in markdown, got: {md}"
18241        );
18242        let doc2 = markdown_to_adf(&md).unwrap();
18243        let ms = &doc2.content[0];
18244        let ms_attrs = ms.attrs.as_ref().unwrap();
18245        assert_eq!(ms_attrs["mode"], "wide");
18246        assert_eq!(ms_attrs["layout"], "wide");
18247        assert_eq!(ms_attrs["width"], 1200);
18248    }
18249
18250    #[test]
18251    fn external_media_mode_roundtrip() {
18252        // External mediaSingle with mode attr should survive round-trip (issue #431)
18253        let adf_doc = serde_json::json!({
18254            "type": "doc",
18255            "version": 1,
18256            "content": [{
18257                "type": "mediaSingle",
18258                "attrs": {"layout": "wide", "mode": "wide"},
18259                "content": [{
18260                    "type": "media",
18261                    "attrs": {
18262                        "type": "external",
18263                        "url": "https://example.com/image.png"
18264                    }
18265                }]
18266            }]
18267        });
18268        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
18269        let md = adf_to_markdown(&doc).unwrap();
18270        assert!(
18271            md.contains("mode=wide"),
18272            "expected mode=wide in markdown, got: {md}"
18273        );
18274        let doc2 = markdown_to_adf(&md).unwrap();
18275        let ms = &doc2.content[0];
18276        let ms_attrs = ms.attrs.as_ref().unwrap();
18277        assert_eq!(ms_attrs["mode"], "wide");
18278        assert_eq!(ms_attrs["layout"], "wide");
18279    }
18280
18281    #[test]
18282    fn media_mode_only_roundtrip() {
18283        // mediaSingle with mode but default layout should still preserve mode (issue #431)
18284        let adf_doc = serde_json::json!({
18285            "type": "doc",
18286            "version": 1,
18287            "content": [{
18288                "type": "mediaSingle",
18289                "attrs": {"layout": "center", "mode": "default"},
18290                "content": [{
18291                    "type": "media",
18292                    "attrs": {
18293                        "type": "external",
18294                        "url": "https://example.com/image.png"
18295                    }
18296                }]
18297            }]
18298        });
18299        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
18300        let md = adf_to_markdown(&doc).unwrap();
18301        assert!(
18302            md.contains("mode=default"),
18303            "expected mode=default in markdown, got: {md}"
18304        );
18305        let doc2 = markdown_to_adf(&md).unwrap();
18306        let ms = &doc2.content[0];
18307        let ms_attrs = ms.attrs.as_ref().unwrap();
18308        assert_eq!(ms_attrs["mode"], "default");
18309    }
18310
18311    #[test]
18312    fn file_media_hex_localid_roundtrip() {
18313        // Issue #432: short hex localId (non-UUID) must survive round-trip
18314        let adf_doc = serde_json::json!({
18315            "type": "doc",
18316            "version": 1,
18317            "content": [{
18318                "type": "mediaSingle",
18319                "attrs": {"layout": "wide", "width": 1200, "widthType": "pixel"},
18320                "content": [{
18321                    "type": "media",
18322                    "attrs": {
18323                        "type": "file",
18324                        "id": "eb7a9c3b-314e-4458-8200-4b22b67b122e",
18325                        "collection": "contentId-123",
18326                        "height": 484,
18327                        "width": 915,
18328                        "alt": "image.png",
18329                        "localId": "0e79f58ac382"
18330                    }
18331                }]
18332            }]
18333        });
18334        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
18335        let md = adf_to_markdown(&doc).unwrap();
18336        assert!(
18337            md.contains("localId=0e79f58ac382"),
18338            "expected localId=0e79f58ac382 in markdown, got: {md}"
18339        );
18340        let doc2 = markdown_to_adf(&md).unwrap();
18341        let ms = &doc2.content[0];
18342        let media = &ms.content.as_ref().unwrap()[0];
18343        let attrs = media.attrs.as_ref().unwrap();
18344        assert_eq!(attrs["localId"], "0e79f58ac382");
18345    }
18346
18347    #[test]
18348    fn file_media_uuid_localid_roundtrip() {
18349        // UUID-format localId must also survive round-trip
18350        let adf_doc = serde_json::json!({
18351            "type": "doc",
18352            "version": 1,
18353            "content": [{
18354                "type": "mediaSingle",
18355                "attrs": {"layout": "center"},
18356                "content": [{
18357                    "type": "media",
18358                    "attrs": {
18359                        "type": "file",
18360                        "id": "abc-123",
18361                        "collection": "contentId-456",
18362                        "height": 100,
18363                        "width": 200,
18364                        "localId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
18365                    }
18366                }]
18367            }]
18368        });
18369        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
18370        let md = adf_to_markdown(&doc).unwrap();
18371        assert!(
18372            md.contains("localId=a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
18373            "expected UUID localId in markdown, got: {md}"
18374        );
18375        let doc2 = markdown_to_adf(&md).unwrap();
18376        let media = &doc2.content[0].content.as_ref().unwrap()[0];
18377        let attrs = media.attrs.as_ref().unwrap();
18378        assert_eq!(attrs["localId"], "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
18379    }
18380
18381    #[test]
18382    fn file_media_null_uuid_localid_stripped() {
18383        // Null UUID localId should be stripped (consistent with other node types)
18384        let adf_doc = serde_json::json!({
18385            "type": "doc",
18386            "version": 1,
18387            "content": [{
18388                "type": "mediaSingle",
18389                "attrs": {"layout": "center"},
18390                "content": [{
18391                    "type": "media",
18392                    "attrs": {
18393                        "type": "file",
18394                        "id": "abc-123",
18395                        "collection": "contentId-456",
18396                        "height": 100,
18397                        "width": 200,
18398                        "localId": "00000000-0000-0000-0000-000000000000"
18399                    }
18400                }]
18401            }]
18402        });
18403        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
18404        let md = adf_to_markdown(&doc).unwrap();
18405        assert!(
18406            !md.contains("localId="),
18407            "null UUID localId should be stripped, got: {md}"
18408        );
18409    }
18410
18411    #[test]
18412    fn file_media_localid_stripped_when_option_set() {
18413        // localId should be stripped when strip_local_ids option is enabled
18414        let adf_doc = serde_json::json!({
18415            "type": "doc",
18416            "version": 1,
18417            "content": [{
18418                "type": "mediaSingle",
18419                "attrs": {"layout": "center"},
18420                "content": [{
18421                    "type": "media",
18422                    "attrs": {
18423                        "type": "file",
18424                        "id": "abc-123",
18425                        "collection": "contentId-456",
18426                        "height": 100,
18427                        "width": 200,
18428                        "localId": "0e79f58ac382"
18429                    }
18430                }]
18431            }]
18432        });
18433        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
18434        let opts = RenderOptions {
18435            strip_local_ids: true,
18436            ..Default::default()
18437        };
18438        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18439        assert!(
18440            !md.contains("localId="),
18441            "localId should be stripped with strip_local_ids, got: {md}"
18442        );
18443    }
18444
18445    #[test]
18446    fn external_media_localid_roundtrip() {
18447        // localId on external media nodes must also survive round-trip
18448        let adf_doc = serde_json::json!({
18449            "type": "doc",
18450            "version": 1,
18451            "content": [{
18452                "type": "mediaSingle",
18453                "attrs": {"layout": "center"},
18454                "content": [{
18455                    "type": "media",
18456                    "attrs": {
18457                        "type": "external",
18458                        "url": "https://example.com/image.png",
18459                        "alt": "test",
18460                        "localId": "deadbeef1234"
18461                    }
18462                }]
18463            }]
18464        });
18465        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
18466        let md = adf_to_markdown(&doc).unwrap();
18467        assert!(
18468            md.contains("localId=deadbeef1234"),
18469            "expected localId in markdown for external media, got: {md}"
18470        );
18471        let doc2 = markdown_to_adf(&md).unwrap();
18472        let media = &doc2.content[0].content.as_ref().unwrap()[0];
18473        let attrs = media.attrs.as_ref().unwrap();
18474        assert_eq!(attrs["localId"], "deadbeef1234");
18475    }
18476
18477    #[test]
18478    fn bracket_in_text_not_parsed_as_link() {
18479        // "[Task] some text (Link)" — the [Task] must NOT be treated as a link anchor
18480        let md = ":check_mark: [Task] Unable to start trial ([Link](https://example.com/link))";
18481        let doc = markdown_to_adf(md).unwrap();
18482        let para = &doc.content[0];
18483        assert_eq!(para.node_type, "paragraph");
18484        let content = para.content.as_ref().unwrap();
18485        // Find the text node containing "[Task]"
18486        let text_nodes: Vec<_> = content.iter().filter(|n| n.node_type == "text").collect();
18487        let has_task_bracket = text_nodes
18488            .iter()
18489            .any(|n| n.text.as_deref().unwrap_or("").contains("[Task]"));
18490        assert!(
18491            has_task_bracket,
18492            "expected [Task] in plain text, nodes: {content:?}"
18493        );
18494        // Also verify the (Link) is a proper link
18495        let link_nodes: Vec<_> = content
18496            .iter()
18497            .filter(|n| {
18498                n.marks
18499                    .as_ref()
18500                    .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
18501            })
18502            .collect();
18503        assert!(!link_nodes.is_empty(), "expected a link node");
18504        assert_eq!(
18505            link_nodes[0].text.as_deref(),
18506            Some("Link"),
18507            "link text should be 'Link'"
18508        );
18509    }
18510
18511    #[test]
18512    fn empty_paragraph_roundtrip() {
18513        // An empty ADF paragraph node should survive a round-trip through markdown
18514        let mut adf_in = AdfDocument::new();
18515        adf_in.content = vec![
18516            AdfNode::paragraph(vec![AdfNode::text("before")]),
18517            AdfNode::paragraph(vec![]),
18518            AdfNode::paragraph(vec![AdfNode::text("after")]),
18519        ];
18520        let md = adf_to_markdown(&adf_in).unwrap();
18521        let adf_out = markdown_to_adf(&md).unwrap();
18522        assert_eq!(
18523            adf_out.content.len(),
18524            3,
18525            "should have 3 blocks, markdown:\n{md}"
18526        );
18527        assert_eq!(adf_out.content[0].node_type, "paragraph");
18528        assert_eq!(adf_out.content[1].node_type, "paragraph");
18529        assert!(
18530            adf_out.content[1].content.is_none(),
18531            "middle paragraph should be empty"
18532        );
18533        assert_eq!(adf_out.content[2].node_type, "paragraph");
18534    }
18535
18536    #[test]
18537    fn nbsp_paragraph_roundtrip() {
18538        // Issue #411: paragraph with only NBSP should survive round-trip
18539        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}";
18540        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18541        let md = adf_to_markdown(&doc).unwrap();
18542        assert!(
18543            md.contains("::paragraph["),
18544            "NBSP paragraph should use directive form: {md}"
18545        );
18546        let rt = markdown_to_adf(&md).unwrap();
18547        assert_eq!(rt.content.len(), 1, "should have 1 block");
18548        assert_eq!(rt.content[0].node_type, "paragraph");
18549        let text = rt.content[0].content.as_ref().unwrap()[0]
18550            .text
18551            .as_deref()
18552            .unwrap_or("");
18553        assert_eq!(text, "\u{00a0}", "NBSP should survive round-trip");
18554    }
18555
18556    #[test]
18557    fn nbsp_in_nested_expand_roundtrip() {
18558        // Issue #411 real-world case: NBSP paragraph inside nestedExpand
18559        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"nestedExpand\",\"attrs\":{\"title\":\"Section\"},\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}]}";
18560        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18561        let md = adf_to_markdown(&doc).unwrap();
18562        let rt = markdown_to_adf(&md).unwrap();
18563        let ne = &rt.content[0];
18564        assert_eq!(ne.node_type, "nestedExpand");
18565        let inner = ne.content.as_ref().unwrap();
18566        assert_eq!(inner.len(), 1, "should have 1 inner block");
18567        assert_eq!(inner[0].node_type, "paragraph");
18568        let content = inner[0].content.as_ref().unwrap();
18569        assert!(!content.is_empty(), "paragraph should not be empty");
18570        let text = content[0].text.as_deref().unwrap_or("");
18571        assert_eq!(text, "\u{00a0}", "NBSP should survive in nestedExpand");
18572    }
18573
18574    #[test]
18575    fn nbsp_followed_by_content() {
18576        // NBSP paragraph followed by regular content should not interfere
18577        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"nestedExpand\",\"attrs\":{\"title\":\"S\"},\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]},{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]}]}";
18578        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18579        let md = adf_to_markdown(&doc).unwrap();
18580        let rt = markdown_to_adf(&md).unwrap();
18581        assert!(rt.content.len() >= 2, "should have at least 2 blocks");
18582        // The second block should be a paragraph with "after"
18583        let after_para = rt.content.iter().find(|n| {
18584            n.node_type == "paragraph"
18585                && n.content
18586                    .as_ref()
18587                    .and_then(|c| c.first())
18588                    .and_then(|n| n.text.as_deref())
18589                    .is_some_and(|t| t.contains("after"))
18590        });
18591        assert!(after_para.is_some(), "should have paragraph with 'after'");
18592    }
18593
18594    #[test]
18595    fn nbsp_paragraph_with_marks_survives() {
18596        // NBSP with bold marks renders as `** **` which contains non-whitespace
18597        // chars and thus doesn't need the directive form — it round-trips naturally
18598        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\",\"marks\":[{\"type\":\"strong\"}]}]}]}";
18599        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18600        let md = adf_to_markdown(&doc).unwrap();
18601        assert!(md.contains("**"), "should have bold markers: {md}");
18602        let rt = markdown_to_adf(&md).unwrap();
18603        let content = rt.content[0].content.as_ref().unwrap();
18604        assert!(!content.is_empty(), "should preserve content");
18605    }
18606
18607    #[test]
18608    fn regular_paragraph_unchanged() {
18609        // Regression guard: normal paragraphs should NOT use directive form
18610        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}"#;
18611        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18612        let md = adf_to_markdown(&doc).unwrap();
18613        assert!(
18614            !md.contains("::paragraph"),
18615            "regular paragraphs should not use directive form: {md}"
18616        );
18617        assert!(md.contains("hello"));
18618    }
18619
18620    #[test]
18621    fn paragraph_directive_with_content_parsed() {
18622        // ::paragraph[content] should parse to a paragraph with inline nodes
18623        let md = "::paragraph[\u{00a0}]\n";
18624        let doc = markdown_to_adf(md).unwrap();
18625        assert_eq!(doc.content.len(), 1);
18626        assert_eq!(doc.content[0].node_type, "paragraph");
18627        let content = doc.content[0].content.as_ref().unwrap();
18628        assert!(!content.is_empty(), "should have inline content");
18629        assert_eq!(content[0].text.as_deref().unwrap(), "\u{00a0}");
18630    }
18631
18632    #[test]
18633    fn nbsp_paragraph_in_list_item_with_nested_list() {
18634        // Issue #448: NBSP paragraph content lost inside listItem with nested bulletList
18635        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"\u00a0"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"sub item one"}]}]}]}]}]}]}"#;
18636        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18637        let md = adf_to_markdown(&doc).unwrap();
18638        let rt = markdown_to_adf(&md).unwrap();
18639        let list = &rt.content[0];
18640        assert_eq!(list.node_type, "bulletList");
18641        let item = &list.content.as_ref().unwrap()[0];
18642        let item_content = item.content.as_ref().unwrap();
18643        assert_eq!(
18644            item_content.len(),
18645            2,
18646            "listItem should have paragraph + nested list, got: {item_content:?}"
18647        );
18648        let para = &item_content[0];
18649        assert_eq!(para.node_type, "paragraph");
18650        let para_content = para
18651            .content
18652            .as_ref()
18653            .expect("paragraph should have content");
18654        assert!(
18655            !para_content.is_empty(),
18656            "NBSP paragraph content should not be empty"
18657        );
18658        assert_eq!(
18659            para_content[0].text.as_deref().unwrap(),
18660            "\u{00a0}",
18661            "NBSP should survive round-trip inside listItem"
18662        );
18663    }
18664
18665    #[test]
18666    fn nbsp_paragraph_in_list_item_with_local_ids() {
18667        // Issue #448: NBSP paragraph with localIds inside listItem with nested list
18668        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"\u00a0"}]},{"type":"bulletList","content":[{"type":"listItem","attrs":{"localId":"li-002"},"content":[{"type":"paragraph","attrs":{"localId":"p-002"},"content":[{"type":"text","text":"sub item"}]}]}]}]}]}]}"#;
18669        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18670        let md = adf_to_markdown(&doc).unwrap();
18671        let rt = markdown_to_adf(&md).unwrap();
18672        let list = &rt.content[0];
18673        let item = &list.content.as_ref().unwrap()[0];
18674        // Check listItem localId
18675        assert_eq!(
18676            item.attrs.as_ref().unwrap()["localId"],
18677            "li-001",
18678            "listItem localId should survive"
18679        );
18680        let item_content = item.content.as_ref().unwrap();
18681        assert_eq!(item_content.len(), 2);
18682        // Check paragraph localId and NBSP content
18683        let para = &item_content[0];
18684        assert_eq!(
18685            para.attrs.as_ref().unwrap()["localId"],
18686            "p-001",
18687            "paragraph localId should survive"
18688        );
18689        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
18690        assert_eq!(text, "\u{00a0}", "NBSP should survive with localIds");
18691    }
18692
18693    #[test]
18694    fn nbsp_paragraph_in_list_item_without_nested_list() {
18695        // NBSP paragraph in a simple listItem (no nested list)
18696        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"\u00a0"}]}]}]}]}"#;
18697        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18698        let md = adf_to_markdown(&doc).unwrap();
18699        let rt = markdown_to_adf(&md).unwrap();
18700        let list = &rt.content[0];
18701        let item = &list.content.as_ref().unwrap()[0];
18702        let para = &item.content.as_ref().unwrap()[0];
18703        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
18704        assert_eq!(text, "\u{00a0}", "NBSP should survive in simple list item");
18705    }
18706
18707    #[test]
18708    fn nbsp_paragraph_in_ordered_list_item_with_nested_list() {
18709        // NBSP paragraph in ordered listItem with nested bulletList
18710        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"\u00a0"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"sub item"}]}]}]}]}]}]}"#;
18711        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18712        let md = adf_to_markdown(&doc).unwrap();
18713        let rt = markdown_to_adf(&md).unwrap();
18714        let list = &rt.content[0];
18715        let item = &list.content.as_ref().unwrap()[0];
18716        let item_content = item.content.as_ref().unwrap();
18717        assert_eq!(item_content.len(), 2);
18718        let para = &item_content[0];
18719        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
18720        assert_eq!(text, "\u{00a0}", "NBSP should survive in ordered list item");
18721    }
18722
18723    #[test]
18724    fn list_item_leading_space_preserved() {
18725        // Leading space in list item text must not be stripped
18726        let md = "- hello world\n- - text";
18727        let doc = markdown_to_adf(md).unwrap();
18728        let list = &doc.content[0];
18729        assert_eq!(list.node_type, "bulletList");
18730        let items = list.content.as_ref().unwrap();
18731        // First item: "hello world" (no leading space, unchanged)
18732        let first_para = &items[0].content.as_ref().unwrap()[0];
18733        let first_text = &first_para.content.as_ref().unwrap()[0];
18734        assert_eq!(first_text.text.as_deref(), Some("hello world"));
18735    }
18736
18737    #[test]
18738    fn list_item_leading_space_not_stripped() {
18739        // When the markdown list item content has a leading space (e.g. " :emoji:"),
18740        // that space must reach parse_inline as-is.
18741        let md = "-  leading space text";
18742        let doc = markdown_to_adf(md).unwrap();
18743        let list = &doc.content[0];
18744        let items = list.content.as_ref().unwrap();
18745        let para = &items[0].content.as_ref().unwrap()[0];
18746        let text_node = &para.content.as_ref().unwrap()[0];
18747        // After "- " (2 chars), trim_end keeps the leading space: " leading space text"
18748        assert_eq!(
18749            text_node.text.as_deref(),
18750            Some(" leading space text"),
18751            "leading space should be preserved"
18752        );
18753    }
18754
18755    // ── Nested container directive tests ───────────────────────────
18756
18757    // ── hardBreak in table cell tests ────────────────────────────
18758
18759    #[test]
18760    fn hardbreak_in_cell_uses_directive_table() {
18761        // A table cell with a hardBreak should NOT use pipe syntax
18762        // because the newline would break the row
18763        let adf = AdfDocument {
18764            version: 1,
18765            doc_type: "doc".to_string(),
18766            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
18767                AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18768                    AdfNode::text("before"),
18769                    AdfNode::hard_break(),
18770                    AdfNode::text("after"),
18771                ])]),
18772            ])])],
18773        };
18774        let md = adf_to_markdown(&adf).unwrap();
18775        // Should render as directive table, not pipe table
18776        assert!(
18777            md.contains(":::td") || md.contains("::::table"),
18778            "Table with hardBreak should use directive form, got:\n{md}"
18779        );
18780        assert!(
18781            !md.contains("| before"),
18782            "Should NOT use pipe syntax with hardBreak"
18783        );
18784    }
18785
18786    #[test]
18787    fn hardbreak_in_cell_roundtrips() {
18788        // Verify the directive table form preserves the hardBreak on round-trip
18789        let adf = AdfDocument {
18790            version: 1,
18791            doc_type: "doc".to_string(),
18792            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
18793                AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18794                    AdfNode::text("line one"),
18795                    AdfNode::hard_break(),
18796                    AdfNode::text("line two"),
18797                ])]),
18798            ])])],
18799        };
18800        let md = adf_to_markdown(&adf).unwrap();
18801        let roundtripped = markdown_to_adf(&md).unwrap();
18802
18803        // Should still have one table with one row with one cell
18804        assert_eq!(roundtripped.content.len(), 1);
18805        assert_eq!(roundtripped.content[0].node_type, "table");
18806        let rows = roundtripped.content[0].content.as_ref().unwrap();
18807        assert_eq!(
18808            rows.len(),
18809            1,
18810            "Should have exactly 1 row, got {}",
18811            rows.len()
18812        );
18813    }
18814
18815    #[test]
18816    fn hardbreak_in_paragraph_roundtrips() {
18817        // Issue #373: hardBreak absorbed into preceding text node
18818        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18819          {"type":"text","text":"line one"},
18820          {"type":"hardBreak"},
18821          {"type":"text","text":"line two"}
18822        ]}]}"#;
18823        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18824        let md = adf_to_markdown(&doc).unwrap();
18825        let round_tripped = markdown_to_adf(&md).unwrap();
18826        let inlines = round_tripped.content[0].content.as_ref().unwrap();
18827        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18828        assert_eq!(
18829            types,
18830            vec!["text", "hardBreak", "text"],
18831            "hardBreak should be preserved, got: {types:?}"
18832        );
18833        assert_eq!(inlines[0].text.as_deref(), Some("line one"));
18834        assert_eq!(inlines[2].text.as_deref(), Some("line two"));
18835    }
18836
18837    #[test]
18838    fn consecutive_hardbreaks_in_paragraph_roundtrip() {
18839        // Issue #410: consecutive hardBreak nodes collapsed on round-trip
18840        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18841          {"type":"text","text":"before"},
18842          {"type":"hardBreak"},
18843          {"type":"hardBreak"},
18844          {"type":"text","text":"after"}
18845        ]}]}"#;
18846        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18847        let md = adf_to_markdown(&doc).unwrap();
18848        let round_tripped = markdown_to_adf(&md).unwrap();
18849        assert_eq!(
18850            round_tripped.content.len(),
18851            1,
18852            "Should remain a single paragraph, got {} blocks",
18853            round_tripped.content.len()
18854        );
18855        let inlines = round_tripped.content[0].content.as_ref().unwrap();
18856        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18857        assert_eq!(
18858            types,
18859            vec!["text", "hardBreak", "hardBreak", "text"],
18860            "Both hardBreaks should be preserved, got: {types:?}"
18861        );
18862        assert_eq!(inlines[0].text.as_deref(), Some("before"));
18863        assert_eq!(inlines[3].text.as_deref(), Some("after"));
18864    }
18865
18866    #[test]
18867    fn hardbreak_only_paragraph_roundtrips() {
18868        // Issue #410: paragraph whose only content is a hardBreak is dropped
18869        let adf_json = r#"{"version":1,"type":"doc","content":[
18870          {"type":"paragraph","content":[{"type":"hardBreak"}]}
18871        ]}"#;
18872        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18873        let md = adf_to_markdown(&doc).unwrap();
18874        let round_tripped = markdown_to_adf(&md).unwrap();
18875        assert_eq!(
18876            round_tripped.content.len(),
18877            1,
18878            "Paragraph should not be dropped, got {} blocks",
18879            round_tripped.content.len()
18880        );
18881        let inlines = round_tripped.content[0].content.as_ref().unwrap();
18882        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18883        assert_eq!(
18884            types,
18885            vec!["hardBreak"],
18886            "hardBreak-only paragraph should preserve its content, got: {types:?}"
18887        );
18888    }
18889
18890    #[test]
18891    fn issue_410_full_reproducer_roundtrips() {
18892        // Full reproducer from issue #410: consecutive hardBreaks + hardBreak-only paragraph
18893        let adf_json = r#"{"version":1,"type":"doc","content":[
18894          {"type":"paragraph","content":[
18895            {"type":"text","text":"before"},
18896            {"type":"hardBreak"},
18897            {"type":"hardBreak"},
18898            {"type":"text","text":"after"}
18899          ]},
18900          {"type":"paragraph","content":[
18901            {"type":"hardBreak"}
18902          ]}
18903        ]}"#;
18904        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18905        let md = adf_to_markdown(&doc).unwrap();
18906        let round_tripped = markdown_to_adf(&md).unwrap();
18907        assert_eq!(
18908            round_tripped.content.len(),
18909            2,
18910            "Should have exactly 2 paragraphs, got {}",
18911            round_tripped.content.len()
18912        );
18913        // First paragraph: text, hardBreak, hardBreak, text
18914        let p1 = round_tripped.content[0].content.as_ref().unwrap();
18915        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
18916        assert_eq!(types1, vec!["text", "hardBreak", "hardBreak", "text"]);
18917        // Second paragraph: hardBreak only
18918        let p2 = round_tripped.content[1].content.as_ref().unwrap();
18919        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
18920        assert_eq!(types2, vec!["hardBreak"]);
18921    }
18922
18923    #[test]
18924    fn trailing_space_hardbreak_still_parsed() {
18925        // Backward compatibility: trailing-space hardBreak (old JFM format) still parses
18926        let md = "line one  \nline two\n";
18927        let doc = markdown_to_adf(md).unwrap();
18928        let inlines = doc.content[0].content.as_ref().unwrap();
18929        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18930        assert_eq!(
18931            types,
18932            vec!["text", "hardBreak", "text"],
18933            "Trailing-space hardBreak should still parse, got: {types:?}"
18934        );
18935    }
18936
18937    #[test]
18938    fn trailing_hardbreak_at_end_of_paragraph_roundtrips() {
18939        // A paragraph ending with a hardBreak (no text after it)
18940        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18941          {"type":"text","text":"text"},
18942          {"type":"hardBreak"}
18943        ]}]}"#;
18944        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18945        let md = adf_to_markdown(&doc).unwrap();
18946        let round_tripped = markdown_to_adf(&md).unwrap();
18947        let inlines = round_tripped.content[0].content.as_ref().unwrap();
18948        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18949        assert_eq!(
18950            types,
18951            vec!["text", "hardBreak"],
18952            "Trailing hardBreak should be preserved, got: {types:?}"
18953        );
18954    }
18955
18956    #[test]
18957    #[test]
18958    fn table_with_header_row_uses_pipe_syntax() {
18959        // A table with tableHeader in the first row should use pipe syntax
18960        let adf = AdfDocument {
18961            version: 1,
18962            doc_type: "doc".to_string(),
18963            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
18964                AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("header cell")])]),
18965            ])])],
18966        };
18967        let md = adf_to_markdown(&adf).unwrap();
18968        assert!(
18969            md.contains("| header cell |"),
18970            "Table with header row should use pipe syntax, got:\n{md}"
18971        );
18972    }
18973
18974    #[test]
18975    fn table_without_header_row_uses_directive_syntax() {
18976        // Issue #392: tableCell-only first row must use directive syntax
18977        // to avoid converting tableCell → tableHeader on round-trip
18978        let adf = AdfDocument {
18979            version: 1,
18980            doc_type: "doc".to_string(),
18981            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
18982                AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("simple cell")])]),
18983            ])])],
18984        };
18985        let md = adf_to_markdown(&adf).unwrap();
18986        assert!(
18987            md.contains("::::table"),
18988            "Table without header row should use directive syntax, got:\n{md}"
18989        );
18990    }
18991
18992    #[test]
18993    fn tablecell_first_row_preserved_on_roundtrip() {
18994        // Issue #392: tableCell in first row round-trips as tableHeader
18995        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{},"content":[
18996          {"type":"tableRow","content":[
18997            {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row1 cell"}]}]}
18998          ]},
18999          {"type":"tableRow","content":[
19000            {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row2 cell"}]}]}
19001          ]}
19002        ]}]}"#;
19003        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19004        let md = adf_to_markdown(&doc).unwrap();
19005        let round_tripped = markdown_to_adf(&md).unwrap();
19006        let rows = round_tripped.content[0].content.as_ref().unwrap();
19007        let row0_cell = &rows[0].content.as_ref().unwrap()[0];
19008        assert_eq!(
19009            row0_cell.node_type, "tableCell",
19010            "first row cell should remain tableCell, got: {}",
19011            row0_cell.node_type
19012        );
19013        let row1_cell = &rows[1].content.as_ref().unwrap()[0];
19014        assert_eq!(row1_cell.node_type, "tableCell");
19015    }
19016
19017    #[test]
19018    fn mixed_header_and_cell_first_row_uses_pipe() {
19019        // A first row with at least one tableHeader qualifies for pipe syntax
19020        let adf = AdfDocument {
19021            version: 1,
19022            doc_type: "doc".to_string(),
19023            content: vec![AdfNode::table(vec![
19024                AdfNode::table_row(vec![
19025                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
19026                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
19027                ]),
19028                AdfNode::table_row(vec![
19029                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
19030                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
19031                ]),
19032            ])],
19033        };
19034        let md = adf_to_markdown(&adf).unwrap();
19035        assert!(
19036            md.contains("| H1 |"),
19037            "Table with header first row should use pipe syntax, got:\n{md}"
19038        );
19039        assert!(!md.contains("::::table"), "should not use directive syntax");
19040    }
19041
19042    // ── Issue #579: pipes in pipe-table cells ─────────────────────
19043
19044    #[test]
19045    fn render_pipe_table_escapes_pipe_in_code_span_cell() {
19046        // A code-marked text node with a literal `|` in a pipe-table cell
19047        // must emit `\|` so the column separator is unambiguous.
19048        let adf = AdfDocument {
19049            version: 1,
19050            doc_type: "doc".to_string(),
19051            content: vec![AdfNode::table(vec![
19052                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
19053                    AdfNode::text("Header"),
19054                ])])]),
19055                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
19056                    AdfNode::text_with_marks("a|b", vec![AdfMark::code()]),
19057                ])])]),
19058            ])],
19059        };
19060        let md = adf_to_markdown(&adf).unwrap();
19061        assert!(
19062            md.contains(r"`a\|b`"),
19063            "Pipe inside code span must be escaped, got:\n{md}"
19064        );
19065    }
19066
19067    #[test]
19068    fn render_pipe_table_escapes_pipe_in_plain_text_cell() {
19069        let adf = AdfDocument {
19070            version: 1,
19071            doc_type: "doc".to_string(),
19072            content: vec![AdfNode::table(vec![
19073                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
19074                    AdfNode::text("Header"),
19075                ])])]),
19076                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
19077                    AdfNode::text("x|y"),
19078                ])])]),
19079            ])],
19080        };
19081        let md = adf_to_markdown(&adf).unwrap();
19082        assert!(
19083            md.contains(r"x\|y"),
19084            "Pipe inside plain-text cell must be escaped, got:\n{md}"
19085        );
19086    }
19087
19088    #[test]
19089    fn code_span_with_pipe_in_table_cell_roundtrips() {
19090        // Issue #579 reproducer: code span containing `|` in a pipe-table cell.
19091        let adf_json = r#"{
19092            "version": 1,
19093            "type": "doc",
19094            "content": [{
19095                "type": "table",
19096                "attrs": {"isNumberColumnEnabled": false, "layout": "default", "localId": "abc-789"},
19097                "content": [
19098                    {"type": "tableRow", "content": [
19099                        {"type": "tableHeader", "attrs": {}, "content": [
19100                            {"type": "paragraph", "content": [{"type": "text", "text": "Before"}]}
19101                        ]},
19102                        {"type": "tableHeader", "attrs": {}, "content": [
19103                            {"type": "paragraph", "content": [{"type": "text", "text": "After"}]}
19104                        ]}
19105                    ]},
19106                    {"type": "tableRow", "content": [
19107                        {"type": "tableCell", "attrs": {}, "content": [
19108                            {"type": "paragraph", "content": [
19109                                {"type": "text", "text": "parse(json).extract[T]", "marks": [{"type": "code"}]}
19110                            ]}
19111                        ]},
19112                        {"type": "tableCell", "attrs": {}, "content": [
19113                            {"type": "paragraph", "content": [
19114                                {"type": "text", "text": "parser.decode[T|json]", "marks": [{"type": "code"}]}
19115                            ]}
19116                        ]}
19117                    ]}
19118                ]
19119            }]
19120        }"#;
19121        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19122        let md = adf_to_markdown(&doc).unwrap();
19123        let round_tripped = markdown_to_adf(&md).unwrap();
19124
19125        let rows = round_tripped.content[0].content.as_ref().unwrap();
19126        assert_eq!(
19127            rows.len(),
19128            2,
19129            "Table should have 2 rows, got: {}",
19130            rows.len()
19131        );
19132
19133        let body_row = rows[1].content.as_ref().unwrap();
19134        assert_eq!(
19135            body_row.len(),
19136            2,
19137            "Body row should have 2 cells (not split by the pipe), got: {}",
19138            body_row.len()
19139        );
19140
19141        let second_cell = &body_row[1];
19142        let para = second_cell.content.as_ref().unwrap().first().unwrap();
19143        let inlines = para.content.as_ref().unwrap();
19144        assert_eq!(inlines.len(), 1, "Cell should have a single text node");
19145        assert_eq!(
19146            inlines[0].text.as_deref(),
19147            Some("parser.decode[T|json]"),
19148            "Code-span text must be preserved with literal pipe"
19149        );
19150        let marks = inlines[0]
19151            .marks
19152            .as_ref()
19153            .expect("code mark must be preserved");
19154        assert!(
19155            marks.iter().any(|m| m.mark_type == "code"),
19156            "text node should carry the code mark"
19157        );
19158    }
19159
19160    #[test]
19161    fn plain_text_pipe_in_table_cell_roundtrips() {
19162        // Plain text with `|` in a pipe-table cell should also survive.
19163        let adf = AdfDocument {
19164            version: 1,
19165            doc_type: "doc".to_string(),
19166            content: vec![AdfNode::table(vec![
19167                AdfNode::table_row(vec![
19168                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
19169                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
19170                ]),
19171                AdfNode::table_row(vec![
19172                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a|b")])]),
19173                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("c")])]),
19174                ]),
19175            ])],
19176        };
19177        let md = adf_to_markdown(&adf).unwrap();
19178        let round_tripped = markdown_to_adf(&md).unwrap();
19179        let rows = round_tripped.content[0].content.as_ref().unwrap();
19180        let body_row = rows[1].content.as_ref().unwrap();
19181        assert_eq!(
19182            body_row.len(),
19183            2,
19184            "Body row should keep 2 cells, got: {}",
19185            body_row.len()
19186        );
19187        let first_cell_text = body_row[0].content.as_ref().unwrap()[0]
19188            .content
19189            .as_ref()
19190            .unwrap()[0]
19191            .text
19192            .as_deref();
19193        assert_eq!(first_cell_text, Some("a|b"));
19194    }
19195
19196    #[test]
19197    fn cell_contains_hard_break_true() {
19198        let para = AdfNode::paragraph(vec![
19199            AdfNode::text("a"),
19200            AdfNode::hard_break(),
19201            AdfNode::text("b"),
19202        ]);
19203        assert!(cell_contains_hard_break(&para));
19204    }
19205
19206    #[test]
19207    fn cell_contains_hard_break_false() {
19208        let para = AdfNode::paragraph(vec![AdfNode::text("no break here")]);
19209        assert!(!cell_contains_hard_break(&para));
19210    }
19211
19212    #[test]
19213    fn cell_contains_hard_break_empty() {
19214        let para = AdfNode::paragraph(vec![]);
19215        assert!(!cell_contains_hard_break(&para));
19216    }
19217
19218    // ── Multi-paragraph container tests ──────────────────────────
19219
19220    #[test]
19221    fn multi_paragraph_panel_roundtrips() {
19222        let adf = AdfDocument {
19223            version: 1,
19224            doc_type: "doc".to_string(),
19225            content: vec![AdfNode {
19226                node_type: "panel".to_string(),
19227                attrs: Some(serde_json::json!({"panelType": "info"})),
19228                content: Some(vec![
19229                    AdfNode::paragraph(vec![AdfNode::text("First paragraph.")]),
19230                    AdfNode::paragraph(vec![AdfNode::text("Second paragraph.")]),
19231                ]),
19232                text: None,
19233                marks: None,
19234                local_id: None,
19235                parameters: None,
19236            }],
19237        };
19238
19239        let md = adf_to_markdown(&adf).unwrap();
19240        // Should have blank line between paragraphs inside the panel
19241        assert!(
19242            md.contains("First paragraph.\n\nSecond paragraph."),
19243            "Panel should have blank line between paragraphs, got:\n{md}"
19244        );
19245
19246        // Round-trip should preserve two separate paragraphs
19247        let roundtripped = markdown_to_adf(&md).unwrap();
19248        assert_eq!(roundtripped.content.len(), 1);
19249        assert_eq!(roundtripped.content[0].node_type, "panel");
19250        let panel_content = roundtripped.content[0].content.as_ref().unwrap();
19251        assert_eq!(
19252            panel_content.len(),
19253            2,
19254            "Panel should have 2 paragraphs after round-trip, got {}",
19255            panel_content.len()
19256        );
19257    }
19258
19259    #[test]
19260    fn multi_paragraph_expand_roundtrips() {
19261        let adf = AdfDocument {
19262            version: 1,
19263            doc_type: "doc".to_string(),
19264            content: vec![AdfNode {
19265                node_type: "expand".to_string(),
19266                attrs: Some(serde_json::json!({"title": "Details"})),
19267                content: Some(vec![
19268                    AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
19269                    AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
19270                ]),
19271                text: None,
19272                marks: None,
19273                local_id: None,
19274                parameters: None,
19275            }],
19276        };
19277
19278        let md = adf_to_markdown(&adf).unwrap();
19279        let roundtripped = markdown_to_adf(&md).unwrap();
19280        let expand_content = roundtripped.content[0].content.as_ref().unwrap();
19281        assert_eq!(
19282            expand_content.len(),
19283            2,
19284            "Expand should have 2 paragraphs after round-trip, got {}",
19285            expand_content.len()
19286        );
19287    }
19288
19289    #[test]
19290    fn consecutive_nested_expands_in_table_cell_roundtrip() {
19291        let cell_content = vec![
19292            AdfNode {
19293                node_type: "nestedExpand".to_string(),
19294                attrs: Some(serde_json::json!({"title": "First"})),
19295                content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 1")])]),
19296                text: None,
19297                marks: None,
19298                local_id: None,
19299                parameters: None,
19300            },
19301            AdfNode {
19302                node_type: "nestedExpand".to_string(),
19303                attrs: Some(serde_json::json!({"title": "Second"})),
19304                content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 2")])]),
19305                text: None,
19306                marks: None,
19307                local_id: None,
19308                parameters: None,
19309            },
19310        ];
19311        let adf = AdfDocument {
19312            version: 1,
19313            doc_type: "doc".to_string(),
19314            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
19315                AdfNode::table_cell(cell_content),
19316            ])])],
19317        };
19318
19319        let md = adf_to_markdown(&adf).unwrap();
19320        assert!(
19321            md.contains(":::\n\n:::nested-expand"),
19322            "Should have blank line between consecutive nested-expands in cell, got:\n{md}"
19323        );
19324
19325        let rt = markdown_to_adf(&md).unwrap();
19326        let cell = &rt.content[0].content.as_ref().unwrap()[0]
19327            .content
19328            .as_ref()
19329            .unwrap()[0];
19330        let cell_nodes = cell.content.as_ref().unwrap();
19331        let expand_count = cell_nodes
19332            .iter()
19333            .filter(|n| n.node_type == "nestedExpand")
19334            .count();
19335        assert_eq!(
19336            expand_count, 2,
19337            "Both nested-expands should survive round-trip, got {expand_count}"
19338        );
19339    }
19340
19341    #[test]
19342    fn multi_paragraph_in_table_cell_roundtrip() {
19343        // Two paragraphs inside a directive table cell should survive round-trip
19344        let adf = AdfDocument {
19345            version: 1,
19346            doc_type: "doc".to_string(),
19347            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
19348                AdfNode::table_cell(vec![
19349                    AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
19350                    AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
19351                ]),
19352            ])])],
19353        };
19354
19355        let md = adf_to_markdown(&adf).unwrap();
19356        assert!(
19357            md.contains("Para one.\n\nPara two."),
19358            "Should have blank line between paragraphs in cell, got:\n{md}"
19359        );
19360
19361        let rt = markdown_to_adf(&md).unwrap();
19362        let cell = &rt.content[0].content.as_ref().unwrap()[0]
19363            .content
19364            .as_ref()
19365            .unwrap()[0];
19366        let para_count = cell
19367            .content
19368            .as_ref()
19369            .unwrap()
19370            .iter()
19371            .filter(|n| n.node_type == "paragraph")
19372            .count();
19373        assert_eq!(para_count, 2, "Both paragraphs should survive round-trip");
19374    }
19375
19376    #[test]
19377    fn panel_inside_table_cell_roundtrip() {
19378        // A panel inside a directive table cell
19379        let adf = AdfDocument {
19380            version: 1,
19381            doc_type: "doc".to_string(),
19382            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
19383                AdfNode::table_cell(vec![
19384                    AdfNode::paragraph(vec![AdfNode::text("Before panel.")]),
19385                    AdfNode {
19386                        node_type: "panel".to_string(),
19387                        attrs: Some(serde_json::json!({"panelType": "info"})),
19388                        content: Some(vec![AdfNode::paragraph(vec![AdfNode::text(
19389                            "Panel content",
19390                        )])]),
19391                        text: None,
19392                        marks: None,
19393                        local_id: None,
19394                        parameters: None,
19395                    },
19396                ]),
19397            ])])],
19398        };
19399
19400        let md = adf_to_markdown(&adf).unwrap();
19401        assert!(
19402            md.contains(":::panel"),
19403            "Should contain panel directive, got:\n{md}"
19404        );
19405
19406        let rt = markdown_to_adf(&md).unwrap();
19407        let cell = &rt.content[0].content.as_ref().unwrap()[0]
19408            .content
19409            .as_ref()
19410            .unwrap()[0];
19411        let has_panel = cell
19412            .content
19413            .as_ref()
19414            .unwrap()
19415            .iter()
19416            .any(|n| n.node_type == "panel");
19417        assert!(has_panel, "Panel should survive round-trip in table cell");
19418    }
19419
19420    #[test]
19421    fn three_consecutive_expands_in_table_cell() {
19422        let make_expand = |title: &str| AdfNode {
19423            node_type: "nestedExpand".to_string(),
19424            attrs: Some(serde_json::json!({"title": title})),
19425            content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("content")])]),
19426            text: None,
19427            marks: None,
19428            local_id: None,
19429            parameters: None,
19430        };
19431        let adf = AdfDocument {
19432            version: 1,
19433            doc_type: "doc".to_string(),
19434            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
19435                AdfNode::table_cell(vec![
19436                    make_expand("First"),
19437                    make_expand("Second"),
19438                    make_expand("Third"),
19439                ]),
19440            ])])],
19441        };
19442
19443        let md = adf_to_markdown(&adf).unwrap();
19444        let rt = markdown_to_adf(&md).unwrap();
19445        let cell = &rt.content[0].content.as_ref().unwrap()[0]
19446            .content
19447            .as_ref()
19448            .unwrap()[0];
19449        let expand_count = cell
19450            .content
19451            .as_ref()
19452            .unwrap()
19453            .iter()
19454            .filter(|n| n.node_type == "nestedExpand")
19455            .count();
19456        assert_eq!(expand_count, 3, "All 3 expands should survive round-trip");
19457    }
19458
19459    // ── Nested container directive tests ───────────────────────────
19460
19461    #[test]
19462    fn nested_expand_inside_panel() {
19463        // Issue #714: the converter still produces panel→expand at the AST
19464        // level (this is what users may type), but the document fails ADF
19465        // schema validation, surfacing an actionable error before the API
19466        // call rather than an opaque Confluence HTTP 500.
19467        let md = ":::panel{type=info}\n:::expand{title=\"Details\"}\nHidden content\n:::\nMore panel content\n:::";
19468        let adf = markdown_to_adf(md).unwrap();
19469
19470        let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
19471        assert!(err.violations.iter().any(|v| matches!(
19472            v,
19473            crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
19474                parent_type, child_type, ..
19475            } if parent_type == "panel" && child_type == "expand"
19476        )));
19477    }
19478
19479    #[test]
19480    fn nested_expand_inside_table_cell() {
19481        // Issue #714: tableCell → expand is a Confluence content-model
19482        // violation. Table cells require `nestedExpand` instead. Validation
19483        // catches this before the API call.
19484        let md = "::::table\n:::tr\n:::td\n:::expand{title=\"Details\"}\nExpand content\n:::\n:::\n:::\n::::";
19485        let adf = markdown_to_adf(md).unwrap();
19486
19487        let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
19488        assert!(err.violations.iter().any(|v| matches!(
19489            v,
19490            crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
19491                parent_type, child_type, ..
19492            } if parent_type == "tableCell" && child_type == "expand"
19493        )));
19494    }
19495
19496    #[test]
19497    fn nested_expand_inside_layout_column() {
19498        // Issue #714 sanity check: `expand` inside a `layoutColumn` is
19499        // legitimate per the ADF schema and must NOT trigger validation.
19500        // Note: layoutSection requires 2..=3 columns per #733's quantifier
19501        // checks, so the markdown declares two columns.
19502        let md = ":::layout\n:::column{width=50}\n:::expand{title=\"Col Expand\"}\nExpanded\n:::\n:::\n:::column{width=50}\nFiller paragraph.\n:::\n:::";
19503        let adf = markdown_to_adf(md).unwrap();
19504
19505        assert_eq!(adf.content.len(), 1);
19506        assert_eq!(adf.content[0].node_type, "layoutSection");
19507
19508        let columns = adf.content[0].content.as_ref().unwrap();
19509        assert_eq!(columns.len(), 2);
19510        let col_content = columns[0].content.as_ref().unwrap();
19511        assert!(
19512            col_content.iter().any(|n| n.node_type == "expand"),
19513            "Column should contain an expand node, got: {:?}",
19514            col_content.iter().map(|n| &n.node_type).collect::<Vec<_>>()
19515        );
19516
19517        // Validation must not flag this legitimate nesting.
19518        crate::atlassian::adf_validated::validate(&adf).unwrap();
19519    }
19520
19521    #[test]
19522    fn expand_localid_in_directive_attrs() {
19523        // Issue #412: localId should be in directive attrs, not trailing text
19524        let adf_json = r#"{"version":1,"type":"doc","content":[
19525          {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
19526            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
19527          ]}
19528        ]}"#;
19529        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19530        let md = adf_to_markdown(&doc).unwrap();
19531        assert!(
19532            md.contains("localId=exp-001"),
19533            "should contain localId: {md}"
19534        );
19535        assert!(
19536            md.contains(":::expand{"),
19537            "should have expand directive with attrs: {md}"
19538        );
19539        assert!(
19540            !md.contains(":::\n{localId="),
19541            "localId should NOT be trailing: {md}"
19542        );
19543    }
19544
19545    #[test]
19546    fn expand_localid_roundtrip() {
19547        let adf_json = r#"{"version":1,"type":"doc","content":[
19548          {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
19549            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
19550          ]}
19551        ]}"#;
19552        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19553        let md = adf_to_markdown(&doc).unwrap();
19554        let rt = markdown_to_adf(&md).unwrap();
19555        let expand = &rt.content[0];
19556        assert_eq!(expand.node_type, "expand");
19557        assert_eq!(
19558            expand.local_id.as_deref(),
19559            Some("exp-001"),
19560            "expand localId should survive round-trip"
19561        );
19562        assert_eq!(
19563            expand.attrs.as_ref().unwrap()["title"],
19564            "Details",
19565            "expand title should survive round-trip"
19566        );
19567    }
19568
19569    #[test]
19570    fn nested_expand_localid_roundtrip() {
19571        let adf_json = r#"{"version":1,"type":"doc","content":[
19572          {"type":"nestedExpand","attrs":{"localId":"ne-001","title":"S"},"content":[
19573            {"type":"paragraph","content":[{"type":"text","text":"content"}]}
19574          ]}
19575        ]}"#;
19576        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19577        let md = adf_to_markdown(&doc).unwrap();
19578        assert!(
19579            md.contains(":::nested-expand{"),
19580            "should have directive: {md}"
19581        );
19582        assert!(md.contains("localId=ne-001"), "should have localId: {md}");
19583        let rt = markdown_to_adf(&md).unwrap();
19584        let ne = &rt.content[0];
19585        assert_eq!(ne.node_type, "nestedExpand");
19586        assert_eq!(ne.local_id.as_deref(), Some("ne-001"));
19587    }
19588
19589    #[test]
19590    fn nested_expand_localid_followed_by_content() {
19591        // Issue #412 reproducer: localId must not leak into following paragraph
19592        let adf_json = "{\
19593            \"version\":1,\"type\":\"doc\",\"content\":[\
19594              {\"type\":\"nestedExpand\",\"attrs\":{\"localId\":\"exp-001\",\"title\":\"S\"},\"content\":[\
19595                {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}\
19596              ]},\
19597              {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]}\
19598            ]}";
19599        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19600        let md = adf_to_markdown(&doc).unwrap();
19601        let rt = markdown_to_adf(&md).unwrap();
19602        // nestedExpand should have localId
19603        let ne = &rt.content[0];
19604        assert_eq!(ne.node_type, "nestedExpand");
19605        assert_eq!(
19606            ne.local_id.as_deref(),
19607            Some("exp-001"),
19608            "nestedExpand should preserve localId"
19609        );
19610        // Following paragraph should contain "after", not "{localId=...}"
19611        let para = &rt.content[1];
19612        assert_eq!(para.node_type, "paragraph");
19613        let text = para.content.as_ref().unwrap()[0]
19614            .text
19615            .as_deref()
19616            .unwrap_or("");
19617        assert!(
19618            !text.contains("localId"),
19619            "following paragraph should not contain localId: {text}"
19620        );
19621        assert!(
19622            text.contains("after"),
19623            "following paragraph should contain 'after': {text}"
19624        );
19625    }
19626
19627    #[test]
19628    fn expand_localid_without_title() {
19629        let adf_json = r#"{"version":1,"type":"doc","content":[
19630          {"type":"expand","attrs":{"localId":"exp-002"},"content":[
19631            {"type":"paragraph","content":[{"type":"text","text":"no title"}]}
19632          ]}
19633        ]}"#;
19634        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19635        let md = adf_to_markdown(&doc).unwrap();
19636        assert!(
19637            md.contains(":::expand{localId=exp-002}"),
19638            "should have localId without title: {md}"
19639        );
19640        let rt = markdown_to_adf(&md).unwrap();
19641        assert_eq!(rt.content[0].local_id.as_deref(), Some("exp-002"));
19642    }
19643
19644    #[test]
19645    fn expand_localid_stripped() {
19646        let adf_json = r#"{"version":1,"type":"doc","content":[
19647          {"type":"expand","attrs":{"localId":"exp-001","title":"X"},"content":[
19648            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
19649          ]}
19650        ]}"#;
19651        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19652        let opts = RenderOptions {
19653            strip_local_ids: true,
19654        };
19655        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
19656        assert!(!md.contains("localId"), "localId should be stripped: {md}");
19657        assert!(
19658            md.contains(":::expand{title=\"X\"}"),
19659            "title should remain: {md}"
19660        );
19661    }
19662
19663    // ── Issue #444: top-level localId and parameters on expand ──
19664
19665    #[test]
19666    fn expand_top_level_localid_roundtrip() {
19667        // localId as a top-level field (not inside attrs) should survive round-trip
19668        let adf_json = r#"{"version":1,"type":"doc","content":[
19669          {"type":"expand","attrs":{"title":"My Section"},"localId":"abc-123","content":[
19670            {"type":"paragraph","content":[{"type":"text","text":"hello"}]}
19671          ]}
19672        ]}"#;
19673        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19674        assert_eq!(doc.content[0].local_id.as_deref(), Some("abc-123"));
19675        let md = adf_to_markdown(&doc).unwrap();
19676        assert!(
19677            md.contains("localId=abc-123"),
19678            "JFM should contain localId: {md}"
19679        );
19680        let rt = markdown_to_adf(&md).unwrap();
19681        let expand = &rt.content[0];
19682        assert_eq!(expand.node_type, "expand");
19683        assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
19684        assert_eq!(
19685            expand.attrs.as_ref().unwrap()["title"],
19686            "My Section",
19687            "title should survive round-trip"
19688        );
19689    }
19690
19691    #[test]
19692    fn expand_parameters_roundtrip() {
19693        // parameters (macroMetadata) should survive round-trip
19694        let adf_json = r#"{"version":1,"type":"doc","content":[
19695          {"type":"expand","attrs":{"title":"Props"},"parameters":{"macroMetadata":{"macroId":{"value":"m-001"},"schemaVersion":{"value":"1"}}},"content":[
19696            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
19697          ]}
19698        ]}"#;
19699        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19700        assert!(doc.content[0].parameters.is_some());
19701        let md = adf_to_markdown(&doc).unwrap();
19702        assert!(md.contains("params="), "JFM should contain params: {md}");
19703        let rt = markdown_to_adf(&md).unwrap();
19704        let expand = &rt.content[0];
19705        let params = expand
19706            .parameters
19707            .as_ref()
19708            .expect("parameters should survive round-trip");
19709        assert_eq!(params["macroMetadata"]["macroId"]["value"], "m-001");
19710        assert_eq!(params["macroMetadata"]["schemaVersion"]["value"], "1");
19711    }
19712
19713    #[test]
19714    fn expand_localid_and_parameters_roundtrip() {
19715        // Issue #444: both localId and parameters on expand should survive round-trip
19716        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"expand","attrs":{"title":"My Section"},"localId":"abc-123","parameters":{"macroMetadata":{"macroId":{"value":"macro-001"},"schemaVersion":{"value":"1"},"title":"Page Properties"}},"content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}]}"#;
19717        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19718        let md = adf_to_markdown(&doc).unwrap();
19719        let rt = markdown_to_adf(&md).unwrap();
19720        let expand = &rt.content[0];
19721        assert_eq!(expand.node_type, "expand");
19722        assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
19723        assert_eq!(expand.attrs.as_ref().unwrap()["title"], "My Section");
19724        let params = expand
19725            .parameters
19726            .as_ref()
19727            .expect("parameters should survive");
19728        assert_eq!(params["macroMetadata"]["macroId"]["value"], "macro-001");
19729        assert_eq!(params["macroMetadata"]["title"], "Page Properties");
19730    }
19731
19732    #[test]
19733    fn nested_expand_top_level_localid_and_parameters_roundtrip() {
19734        let adf_json = r#"{"version":1,"type":"doc","content":[
19735          {"type":"nestedExpand","attrs":{"title":"Nested"},"localId":"ne-100","parameters":{"macroMetadata":{"macroId":{"value":"nm-001"}}},"content":[
19736            {"type":"paragraph","content":[{"type":"text","text":"inner"}]}
19737          ]}
19738        ]}"#;
19739        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19740        let md = adf_to_markdown(&doc).unwrap();
19741        assert!(
19742            md.contains(":::nested-expand{"),
19743            "should use nested-expand: {md}"
19744        );
19745        assert!(md.contains("localId=ne-100"), "should have localId: {md}");
19746        assert!(md.contains("params="), "should have params: {md}");
19747        let rt = markdown_to_adf(&md).unwrap();
19748        let ne = &rt.content[0];
19749        assert_eq!(ne.node_type, "nestedExpand");
19750        assert_eq!(ne.local_id.as_deref(), Some("ne-100"));
19751        assert_eq!(
19752            ne.parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
19753            "nm-001"
19754        );
19755    }
19756
19757    #[test]
19758    fn expand_top_level_localid_stripped() {
19759        // strip_local_ids should strip top-level localId too
19760        let adf_json = r#"{"version":1,"type":"doc","content":[
19761          {"type":"expand","attrs":{"title":"X"},"localId":"exp-strip","content":[
19762            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
19763          ]}
19764        ]}"#;
19765        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19766        let opts = RenderOptions {
19767            strip_local_ids: true,
19768        };
19769        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
19770        assert!(!md.contains("localId"), "localId should be stripped: {md}");
19771        assert!(
19772            md.contains(":::expand{title=\"X\"}"),
19773            "title should remain: {md}"
19774        );
19775    }
19776
19777    #[test]
19778    fn expand_parameters_without_localid() {
19779        // parameters without localId should work
19780        let adf_json = r#"{"version":1,"type":"doc","content":[
19781          {"type":"expand","attrs":{"title":"P"},"parameters":{"macroMetadata":{"macroId":{"value":"solo"}}},"content":[
19782            {"type":"paragraph","content":[{"type":"text","text":"data"}]}
19783          ]}
19784        ]}"#;
19785        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19786        let md = adf_to_markdown(&doc).unwrap();
19787        assert!(!md.contains("localId"), "no localId: {md}");
19788        assert!(md.contains("params="), "has params: {md}");
19789        let rt = markdown_to_adf(&md).unwrap();
19790        assert!(rt.content[0].local_id.is_none());
19791        assert_eq!(
19792            rt.content[0].parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
19793            "solo"
19794        );
19795    }
19796
19797    #[test]
19798    fn expand_localid_without_parameters() {
19799        // top-level localId without parameters should work
19800        let adf_json = r#"{"version":1,"type":"doc","content":[
19801          {"type":"expand","attrs":{"title":"L"},"localId":"lid-only","content":[
19802            {"type":"paragraph","content":[{"type":"text","text":"txt"}]}
19803          ]}
19804        ]}"#;
19805        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19806        let md = adf_to_markdown(&doc).unwrap();
19807        assert!(md.contains("localId=lid-only"), "has localId: {md}");
19808        assert!(!md.contains("params="), "no params: {md}");
19809        let rt = markdown_to_adf(&md).unwrap();
19810        assert_eq!(rt.content[0].local_id.as_deref(), Some("lid-only"));
19811        assert!(rt.content[0].parameters.is_none());
19812    }
19813
19814    #[test]
19815    fn nested_panel_inside_panel() {
19816        let md = ":::panel{type=info}\n:::panel{type=warning}\nInner warning\n:::\n:::";
19817        let adf = markdown_to_adf(md).unwrap();
19818
19819        // Outer panel should exist
19820        assert_eq!(adf.content.len(), 1);
19821        assert_eq!(adf.content[0].node_type, "panel");
19822
19823        // Outer panel should contain an inner panel (not have it truncated)
19824        let panel_content = adf.content[0].content.as_ref().unwrap();
19825        assert!(
19826            panel_content.iter().any(|n| n.node_type == "panel"),
19827            "Outer panel should contain an inner panel, got: {:?}",
19828            panel_content
19829                .iter()
19830                .map(|n| &n.node_type)
19831                .collect::<Vec<_>>()
19832        );
19833    }
19834
19835    #[test]
19836    fn content_after_directive_table_is_preserved() {
19837        // Issue #361: content after a ::::table block was silently dropped
19838        let md = "\
19839## Before table
19840
19841::::table{layout=default}
19842:::tr
19843:::th{}
19844Cell
19845:::
19846:::
19847::::
19848
19849## After table
19850
19851Paragraph after.";
19852        let adf = markdown_to_adf(md).unwrap();
19853        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
19854        assert_eq!(
19855            types,
19856            vec!["heading", "table", "heading", "paragraph"],
19857            "Content after table was dropped: got {types:?}"
19858        );
19859    }
19860
19861    #[test]
19862    fn paragraph_after_directive_table_is_preserved() {
19863        // Issue #361: minimal reproducer — paragraph after table
19864        let md = "\
19865::::table{layout=default}
19866:::tr
19867:::th{}
19868Header
19869:::
19870:::
19871::::
19872
19873Just a paragraph.";
19874        let adf = markdown_to_adf(md).unwrap();
19875        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
19876        assert_eq!(
19877            types,
19878            vec!["table", "paragraph"],
19879            "Paragraph after table was dropped: got {types:?}"
19880        );
19881    }
19882
19883    #[test]
19884    fn extension_after_directive_table_is_preserved() {
19885        // Issue #361: extension after table
19886        let md = "\
19887::::table{layout=default}
19888:::tr
19889:::th{}
19890Header
19891:::
19892:::
19893::::
19894
19895::extension{type=com.atlassian.confluence.macro.core key=toc}";
19896        let adf = markdown_to_adf(md).unwrap();
19897        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
19898        assert_eq!(
19899            types,
19900            vec!["table", "extension"],
19901            "Extension after table was dropped: got {types:?}"
19902        );
19903    }
19904
19905    #[test]
19906    fn multiple_blocks_after_directive_table() {
19907        // Issue #361: multiple blocks after table, including another table
19908        let md = "\
19909## Heading 1
19910
19911::::table{layout=default}
19912:::tr
19913:::td{}
19914A
19915:::
19916:::td{}
19917B
19918:::
19919:::
19920::::
19921
19922## Heading 2
19923
19924Some text.
19925
19926---
19927
19928::::table{layout=default}
19929:::tr
19930:::th{}
19931C
19932:::
19933:::
19934::::
19935
19936## Heading 3";
19937        let adf = markdown_to_adf(md).unwrap();
19938        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
19939        assert_eq!(
19940            types,
19941            vec![
19942                "heading",
19943                "table",
19944                "heading",
19945                "paragraph",
19946                "rule",
19947                "table",
19948                "heading"
19949            ],
19950            "Content after tables was dropped: got {types:?}"
19951        );
19952    }
19953
19954    // ── Table caption tests (issue #382) ────────────────────────────
19955
19956    #[test]
19957    fn adf_table_caption_to_markdown() {
19958        let doc = AdfDocument {
19959            version: 1,
19960            doc_type: "doc".to_string(),
19961            content: vec![AdfNode::table(vec![
19962                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
19963                    AdfNode::text("cell"),
19964                ])])]),
19965                AdfNode::caption(vec![AdfNode::text("Table caption")]),
19966            ])],
19967        };
19968        let md = adf_to_markdown(&doc).unwrap();
19969        assert!(
19970            md.contains("::::table"),
19971            "table with caption must use directive form"
19972        );
19973        assert!(
19974            md.contains(":::caption"),
19975            "caption directive missing, got: {md}"
19976        );
19977        assert!(
19978            md.contains("Table caption"),
19979            "caption text missing, got: {md}"
19980        );
19981    }
19982
19983    #[test]
19984    fn directive_table_caption_parses() {
19985        let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nTable caption\n:::\n::::\n";
19986        let doc = markdown_to_adf(md).unwrap();
19987        let table = &doc.content[0];
19988        assert_eq!(table.node_type, "table");
19989        let children = table.content.as_ref().unwrap();
19990        assert_eq!(children.len(), 2, "expected row + caption");
19991        assert_eq!(children[0].node_type, "tableRow");
19992        assert_eq!(children[1].node_type, "caption");
19993        let caption_content = children[1].content.as_ref().unwrap();
19994        assert_eq!(caption_content[0].text.as_deref(), Some("Table caption"));
19995    }
19996
19997    #[test]
19998    fn table_caption_round_trip_from_adf_json() {
19999        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
20000          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
20001          {"type":"caption","content":[{"type":"text","text":"Table caption"}]}
20002        ]}]}"#;
20003        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20004        let md = adf_to_markdown(&doc).unwrap();
20005        assert!(md.contains("Table caption"), "caption text lost in ADF→JFM");
20006        let round_tripped = markdown_to_adf(&md).unwrap();
20007        let children = round_tripped.content[0].content.as_ref().unwrap();
20008        let caption = children.iter().find(|n| n.node_type == "caption");
20009        assert!(caption.is_some(), "caption lost on round-trip");
20010        let caption_text = caption.unwrap().content.as_ref().unwrap();
20011        assert_eq!(caption_text[0].text.as_deref(), Some("Table caption"));
20012    }
20013
20014    #[test]
20015    fn table_caption_with_inline_marks_round_trips() {
20016        let doc = AdfDocument {
20017            version: 1,
20018            doc_type: "doc".to_string(),
20019            content: vec![AdfNode::table(vec![
20020                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
20021                    AdfNode::text("data"),
20022                ])])]),
20023                AdfNode::caption(vec![
20024                    AdfNode::text("Caption with "),
20025                    AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
20026                ]),
20027            ])],
20028        };
20029        let md = adf_to_markdown(&doc).unwrap();
20030        assert!(md.contains("**bold**"), "bold mark missing in caption");
20031        let round_tripped = markdown_to_adf(&md).unwrap();
20032        let caption = round_tripped.content[0]
20033            .content
20034            .as_ref()
20035            .unwrap()
20036            .iter()
20037            .find(|n| n.node_type == "caption")
20038            .expect("caption node missing after round-trip");
20039        let inlines = caption.content.as_ref().unwrap();
20040        let bold_node = inlines.iter().find(|n| {
20041            n.marks
20042                .as_ref()
20043                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
20044        });
20045        assert!(bold_node.is_some(), "bold mark lost in caption round-trip");
20046    }
20047
20048    // ── table caption localId tests (issue #524) ──────────────────────
20049
20050    #[test]
20051    fn table_caption_localid_roundtrip() {
20052        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
20053          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
20054          {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Table with localId"}]}
20055        ]}]}"#;
20056        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20057        let md = adf_to_markdown(&doc).unwrap();
20058        assert!(
20059            md.contains("localId=abcdef123456"),
20060            "table caption localId should appear in markdown: {md}"
20061        );
20062        let rt = markdown_to_adf(&md).unwrap();
20063        let caption = rt.content[0]
20064            .content
20065            .as_ref()
20066            .unwrap()
20067            .iter()
20068            .find(|n| n.node_type == "caption")
20069            .expect("caption should survive round-trip");
20070        assert_eq!(
20071            caption.attrs.as_ref().unwrap()["localId"],
20072            "abcdef123456",
20073            "table caption localId should round-trip"
20074        );
20075    }
20076
20077    #[test]
20078    fn table_caption_without_localid_unchanged() {
20079        let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nPlain caption\n:::\n::::\n";
20080        let doc = markdown_to_adf(md).unwrap();
20081        let caption = doc.content[0]
20082            .content
20083            .as_ref()
20084            .unwrap()
20085            .iter()
20086            .find(|n| n.node_type == "caption")
20087            .unwrap();
20088        assert!(
20089            caption.attrs.is_none(),
20090            "table caption without localId should not gain attrs"
20091        );
20092        let md2 = adf_to_markdown(&doc).unwrap();
20093        assert!(!md2.contains("localId"), "no localId should appear: {md2}");
20094    }
20095
20096    #[test]
20097    fn table_caption_localid_stripped_when_option_set() {
20098        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
20099          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
20100          {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Stripped"}]}
20101        ]}]}"#;
20102        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20103        let opts = RenderOptions {
20104            strip_local_ids: true,
20105            ..Default::default()
20106        };
20107        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
20108        assert!(
20109            !md.contains("localId"),
20110            "table caption localId should be stripped: {md}"
20111        );
20112    }
20113
20114    #[test]
20115    #[test]
20116    fn tablecell_empty_attrs_preserved_on_roundtrip() {
20117        // Issue #385: tableCell with empty attrs:{} dropped on round-trip
20118        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}]}]}]}"#;
20119        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20120        let md = adf_to_markdown(&doc).unwrap();
20121        let round_tripped = markdown_to_adf(&md).unwrap();
20122        let rows = round_tripped.content[0].content.as_ref().unwrap();
20123        let cell = &rows[0].content.as_ref().unwrap()[0];
20124        assert!(
20125            cell.attrs.is_some(),
20126            "tableCell attrs should be preserved, got None"
20127        );
20128        assert_eq!(
20129            cell.attrs.as_ref().unwrap(),
20130            &serde_json::json!({}),
20131            "tableCell attrs should be an empty object"
20132        );
20133    }
20134
20135    #[test]
20136    fn tablecell_empty_attrs_serialized_in_json() {
20137        // Issue #385: ensure the serialized JSON includes "attrs":{}
20138        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}]}]}]}"#;
20139        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20140        let md = adf_to_markdown(&doc).unwrap();
20141        let round_tripped = markdown_to_adf(&md).unwrap();
20142        let json = serde_json::to_string(&round_tripped).unwrap();
20143        assert!(
20144            json.contains(r#""attrs":{}"#),
20145            "serialized JSON should contain \"attrs\":{{}}, got: {json}"
20146        );
20147    }
20148
20149    #[test]
20150    fn tablecell_empty_attrs_renders_braces_in_markdown() {
20151        // Issue #385: tableCell with empty attrs should render {} prefix in pipe tables
20152        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableHeader","content":[{"type":"paragraph","content":[{"type":"text","text":"H"}]}]},{"type":"tableHeader","content":[{"type":"paragraph","content":[{"type":"text","text":"H2"}]}]}]},{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]},{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"world"}]}]}]}]}]}"#;
20153        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20154        let md = adf_to_markdown(&doc).unwrap();
20155        // Cell with attrs:{} should have {} prefix, cell without attrs should not
20156        assert!(
20157            md.contains("{} hello"),
20158            "cell with empty attrs should render '{{}} hello', got: {md}"
20159        );
20160        assert!(
20161            !md.contains("{} world"),
20162            "cell without attrs should not render '{{}}', got: {md}"
20163        );
20164    }
20165
20166    #[test]
20167    fn tablecell_no_attrs_unchanged_on_roundtrip() {
20168        // Ensure tableCell without attrs stays without attrs
20169        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}]}]}]}"#;
20170        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20171        let md = adf_to_markdown(&doc).unwrap();
20172        let round_tripped = markdown_to_adf(&md).unwrap();
20173        let rows = round_tripped.content[0].content.as_ref().unwrap();
20174        let cell = &rows[0].content.as_ref().unwrap()[0];
20175        assert!(
20176            cell.attrs.is_none(),
20177            "tableCell without attrs should stay None, got: {:?}",
20178            cell.attrs
20179        );
20180    }
20181
20182    #[test]
20183    fn tablecell_nonempty_attrs_preserved_on_roundtrip() {
20184        // Ensure tableCell with non-empty attrs still works
20185        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableHeader","content":[{"type":"paragraph","content":[{"type":"text","text":"H"}]}]}]},{"type":"tableRow","content":[{"type":"tableCell","attrs":{"background":"#DEEBFF","colspan":2},"content":[{"type":"paragraph","content":[{"type":"text","text":"highlighted"}]}]}]}]}]}"##;
20186        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20187        let md = adf_to_markdown(&doc).unwrap();
20188        let round_tripped = markdown_to_adf(&md).unwrap();
20189        let rows = round_tripped.content[0].content.as_ref().unwrap();
20190        let cell = &rows[1].content.as_ref().unwrap()[0];
20191        let attrs = cell.attrs.as_ref().unwrap();
20192        assert_eq!(attrs["background"], "#DEEBFF");
20193        assert_eq!(attrs["colspan"], 2);
20194    }
20195
20196    #[test]
20197    fn pipe_table_not_used_when_caption_present() {
20198        let doc = AdfDocument {
20199            version: 1,
20200            doc_type: "doc".to_string(),
20201            content: vec![AdfNode::table(vec![
20202                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
20203                    AdfNode::text("H"),
20204                ])])]),
20205                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
20206                    AdfNode::text("D"),
20207                ])])]),
20208                AdfNode::caption(vec![AdfNode::text("cap")]),
20209            ])],
20210        };
20211        let md = adf_to_markdown(&doc).unwrap();
20212        assert!(
20213            md.contains("::::table"),
20214            "pipe syntax should not be used when caption is present"
20215        );
20216    }
20217
20218    // ── Issue #402: ordered-list-like text in list item hardBreak ──
20219
20220    #[test]
20221    fn hardbreak_with_ordered_marker_in_bullet_item_roundtrips() {
20222        // Issue #402: text starting with "2. " after a hardBreak inside a
20223        // bullet list item must not be re-parsed as a new ordered list.
20224        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20225          {"type":"listItem","content":[{"type":"paragraph","content":[
20226            {"type":"text","text":"1. First item"},
20227            {"type":"hardBreak"},
20228            {"type":"text","text":"2. Honouring existing commitments"}
20229          ]}]}
20230        ]}]}"#;
20231        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20232        let md = adf_to_markdown(&doc).unwrap();
20233
20234        // The continuation line must be indented so it stays within the list item.
20235        assert!(
20236            md.contains("  2. Honouring"),
20237            "Continuation line should be indented, got:\n{md}"
20238        );
20239
20240        // Round-trip back to ADF
20241        let rt = markdown_to_adf(&md).unwrap();
20242        let list = &rt.content[0];
20243        assert_eq!(list.node_type, "bulletList");
20244        let items = list.content.as_ref().unwrap();
20245        assert_eq!(
20246            items.len(),
20247            1,
20248            "Should be one list item, got {}",
20249            items.len()
20250        );
20251
20252        let para = &items[0].content.as_ref().unwrap()[0];
20253        let inlines = para.content.as_ref().unwrap();
20254        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20255        assert_eq!(
20256            types,
20257            vec!["text", "hardBreak", "text"],
20258            "Expected text+hardBreak+text, got {types:?}"
20259        );
20260        assert_eq!(
20261            inlines[2].text.as_deref().unwrap(),
20262            "2. Honouring existing commitments"
20263        );
20264    }
20265
20266    #[test]
20267    fn hardbreak_with_ordered_marker_in_ordered_item_roundtrips() {
20268        // Same as above but inside an ordered list.
20269        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20270          {"type":"listItem","content":[{"type":"paragraph","content":[
20271            {"type":"text","text":"Introduction  "},
20272            {"type":"hardBreak"},
20273            {"type":"text","text":"3. Third point"}
20274          ]}]}
20275        ]}]}"#;
20276        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20277        let md = adf_to_markdown(&doc).unwrap();
20278        let rt = markdown_to_adf(&md).unwrap();
20279
20280        let list = &rt.content[0];
20281        assert_eq!(list.node_type, "orderedList");
20282        let items = list.content.as_ref().unwrap();
20283        assert_eq!(items.len(), 1);
20284
20285        let para = &items[0].content.as_ref().unwrap()[0];
20286        let inlines = para.content.as_ref().unwrap();
20287        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20288        assert_eq!(types, vec!["text", "hardBreak", "text"]);
20289        assert_eq!(inlines[2].text.as_deref().unwrap(), "3. Third point");
20290    }
20291
20292    #[test]
20293    fn hardbreak_with_bullet_marker_in_bullet_item_roundtrips() {
20294        // Text starting with "- " after a hardBreak must not become a nested bullet list.
20295        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20296          {"type":"listItem","content":[{"type":"paragraph","content":[
20297            {"type":"text","text":"Header  "},
20298            {"type":"hardBreak"},
20299            {"type":"text","text":"- not a sub-item"}
20300          ]}]}
20301        ]}]}"#;
20302        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20303        let md = adf_to_markdown(&doc).unwrap();
20304        let rt = markdown_to_adf(&md).unwrap();
20305
20306        let list = &rt.content[0];
20307        assert_eq!(list.node_type, "bulletList");
20308        let items = list.content.as_ref().unwrap();
20309        assert_eq!(
20310            items.len(),
20311            1,
20312            "Should be one list item, not {}",
20313            items.len()
20314        );
20315
20316        let para = &items[0].content.as_ref().unwrap()[0];
20317        let inlines = para.content.as_ref().unwrap();
20318        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20319        assert_eq!(types, vec!["text", "hardBreak", "text"]);
20320        assert_eq!(inlines[2].text.as_deref().unwrap(), "- not a sub-item");
20321    }
20322
20323    #[test]
20324    fn hardbreak_continuation_followed_by_sub_list() {
20325        // A hardBreak continuation line followed by a real sub-list.
20326        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20327          {"type":"listItem","content":[
20328            {"type":"paragraph","content":[
20329              {"type":"text","text":"Main item  "},
20330              {"type":"hardBreak"},
20331              {"type":"text","text":"continued here"}
20332            ]},
20333            {"type":"bulletList","content":[
20334              {"type":"listItem","content":[{"type":"paragraph","content":[
20335                {"type":"text","text":"sub-item"}
20336              ]}]}
20337            ]}
20338          ]}
20339        ]}]}"#;
20340        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20341        let md = adf_to_markdown(&doc).unwrap();
20342        let rt = markdown_to_adf(&md).unwrap();
20343
20344        let list = &rt.content[0];
20345        let items = list.content.as_ref().unwrap();
20346        assert_eq!(items.len(), 1);
20347
20348        let item_content = items[0].content.as_ref().unwrap();
20349        assert_eq!(item_content.len(), 2, "Expected paragraph + nested list");
20350        assert_eq!(item_content[0].node_type, "paragraph");
20351        assert_eq!(item_content[1].node_type, "bulletList");
20352
20353        // Check the paragraph has hardBreak
20354        let inlines = item_content[0].content.as_ref().unwrap();
20355        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20356        assert_eq!(types, vec!["text", "hardBreak", "text"]);
20357    }
20358
20359    #[test]
20360    fn multiple_hardbreaks_with_numbered_text_roundtrip() {
20361        // Multiple hardBreaks where each continuation resembles an ordered list.
20362        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20363          {"type":"listItem","content":[{"type":"paragraph","content":[
20364            {"type":"text","text":"Preamble  "},
20365            {"type":"hardBreak"},
20366            {"type":"text","text":"1. Alpha  "},
20367            {"type":"hardBreak"},
20368            {"type":"text","text":"2. Bravo"}
20369          ]}]}
20370        ]}]}"#;
20371        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20372        let md = adf_to_markdown(&doc).unwrap();
20373        let rt = markdown_to_adf(&md).unwrap();
20374
20375        let items = rt.content[0].content.as_ref().unwrap();
20376        assert_eq!(items.len(), 1);
20377
20378        let inlines = items[0].content.as_ref().unwrap()[0]
20379            .content
20380            .as_ref()
20381            .unwrap();
20382        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20383        assert_eq!(
20384            types,
20385            vec!["text", "hardBreak", "text", "hardBreak", "text"]
20386        );
20387    }
20388
20389    #[test]
20390    fn trailing_hardbreak_in_bullet_item_roundtrips() {
20391        // A hardBreak as the last inline node with no text after it.
20392        // Exercises the `break` path in the continuation loop and the
20393        // empty-line rendering branch.
20394        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20395          {"type":"listItem","content":[{"type":"paragraph","content":[
20396            {"type":"text","text":"ends with break"},
20397            {"type":"hardBreak"}
20398          ]}]}
20399        ]}]}"#;
20400        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20401        let md = adf_to_markdown(&doc).unwrap();
20402        let rt = markdown_to_adf(&md).unwrap();
20403
20404        let list = &rt.content[0];
20405        assert_eq!(list.node_type, "bulletList");
20406        let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
20407            .content
20408            .as_ref()
20409            .unwrap();
20410        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20411        assert_eq!(types, vec!["text", "hardBreak"]);
20412    }
20413
20414    #[test]
20415    fn trailing_hardbreak_in_ordered_item_roundtrips() {
20416        // Same as above but in an ordered list, covering the ordered-list
20417        // continuation `break` path.
20418        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20419          {"type":"listItem","content":[{"type":"paragraph","content":[
20420            {"type":"text","text":"ends with break"},
20421            {"type":"hardBreak"}
20422          ]}]}
20423        ]}]}"#;
20424        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20425        let md = adf_to_markdown(&doc).unwrap();
20426        let rt = markdown_to_adf(&md).unwrap();
20427
20428        let list = &rt.content[0];
20429        assert_eq!(list.node_type, "orderedList");
20430        let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
20431            .content
20432            .as_ref()
20433            .unwrap();
20434        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20435        assert_eq!(types, vec!["text", "hardBreak"]);
20436    }
20437
20438    #[test]
20439    fn trailing_space_hardbreak_continuation_in_bullet_item() {
20440        // Exercises the `ends_with("  ")` path in `has_trailing_hard_break`
20441        // by parsing hand-written markdown that uses trailing-space style
20442        // hardBreaks instead of backslash style.
20443        let md = "- first line  \n  2. continued\n";
20444        let doc = markdown_to_adf(md).unwrap();
20445
20446        let list = &doc.content[0];
20447        assert_eq!(list.node_type, "bulletList");
20448        let items = list.content.as_ref().unwrap();
20449        assert_eq!(
20450            items.len(),
20451            1,
20452            "Should be one list item, got {}",
20453            items.len()
20454        );
20455
20456        let para = &items[0].content.as_ref().unwrap()[0];
20457        let inlines = para.content.as_ref().unwrap();
20458        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20459        assert_eq!(types, vec!["text", "hardBreak", "text"]);
20460        assert_eq!(inlines[2].text.as_deref().unwrap(), "2. continued");
20461    }
20462
20463    #[test]
20464    fn trailing_space_hardbreak_continuation_in_ordered_item() {
20465        // Same as above but for ordered list, exercising the trailing-space
20466        // path in the ordered-list continuation loop.
20467        let md = "1. first line  \n  - continued\n";
20468        let doc = markdown_to_adf(md).unwrap();
20469
20470        let list = &doc.content[0];
20471        assert_eq!(list.node_type, "orderedList");
20472        let items = list.content.as_ref().unwrap();
20473        assert_eq!(
20474            items.len(),
20475            1,
20476            "Should be one list item, got {}",
20477            items.len()
20478        );
20479
20480        let para = &items[0].content.as_ref().unwrap()[0];
20481        let inlines = para.content.as_ref().unwrap();
20482        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20483        assert_eq!(types, vec!["text", "hardBreak", "text"]);
20484        assert_eq!(inlines[2].text.as_deref().unwrap(), "- continued");
20485    }
20486
20487    #[test]
20488    fn multi_paragraph_list_item_with_ordered_marker_roundtrips() {
20489        // Issue #402 comment: a listItem with a second paragraph starting
20490        // with "2. " must not become a separate orderedList.
20491        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20492          {"type":"listItem","content":[
20493            {"type":"paragraph","content":[{"type":"text","text":"some preamble"}]},
20494            {"type":"paragraph","content":[{"type":"text","text":"2. Honouring existing commitments"}]}
20495          ]}
20496        ]}]}"#;
20497        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20498        let md = adf_to_markdown(&doc).unwrap();
20499        let rt = markdown_to_adf(&md).unwrap();
20500
20501        assert_eq!(rt.content.len(), 1, "Should be one top-level block");
20502        let list = &rt.content[0];
20503        assert_eq!(list.node_type, "bulletList");
20504        let items = list.content.as_ref().unwrap();
20505        assert_eq!(items.len(), 1);
20506        let item_content = items[0].content.as_ref().unwrap();
20507        assert_eq!(
20508            item_content.len(),
20509            2,
20510            "Expected 2 paragraphs inside the list item, got {}",
20511            item_content.len()
20512        );
20513        assert_eq!(item_content[0].node_type, "paragraph");
20514        assert_eq!(item_content[1].node_type, "paragraph");
20515        let text = item_content[1].content.as_ref().unwrap()[0]
20516            .text
20517            .as_deref()
20518            .unwrap();
20519        assert_eq!(text, "2. Honouring existing commitments");
20520    }
20521
20522    #[test]
20523    fn multi_paragraph_list_item_with_bullet_marker_roundtrips() {
20524        // Paragraph starting with "- " inside a list item.
20525        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20526          {"type":"listItem","content":[
20527            {"type":"paragraph","content":[{"type":"text","text":"preamble"}]},
20528            {"type":"paragraph","content":[{"type":"text","text":"- not a sub-item"}]}
20529          ]}
20530        ]}]}"#;
20531        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20532        let md = adf_to_markdown(&doc).unwrap();
20533        let rt = markdown_to_adf(&md).unwrap();
20534
20535        let items = rt.content[0].content.as_ref().unwrap();
20536        assert_eq!(items.len(), 1);
20537        let item_content = items[0].content.as_ref().unwrap();
20538        assert_eq!(item_content.len(), 2);
20539        assert_eq!(item_content[1].node_type, "paragraph");
20540        let text = item_content[1].content.as_ref().unwrap()[0]
20541            .text
20542            .as_deref()
20543            .unwrap();
20544        assert_eq!(text, "- not a sub-item");
20545    }
20546
20547    #[test]
20548    fn backslash_escape_in_inline_text() {
20549        // Verify that `\. ` is unescaped to `. ` in inline parsing.
20550        let nodes = parse_inline(r"2\. text");
20551        assert_eq!(nodes.len(), 1, "Should be one text node");
20552        assert_eq!(nodes[0].text.as_deref().unwrap(), "2. text");
20553    }
20554
20555    #[test]
20556    fn escape_list_marker_ordered() {
20557        assert_eq!(escape_list_marker("2. text"), r"2\. text");
20558        assert_eq!(escape_list_marker("10. tenth"), r"10\. tenth");
20559    }
20560
20561    #[test]
20562    fn escape_list_marker_bullet() {
20563        assert_eq!(escape_list_marker("- text"), r"\- text");
20564        assert_eq!(escape_list_marker("* text"), r"\* text");
20565        assert_eq!(escape_list_marker("+ text"), r"\+ text");
20566    }
20567
20568    #[test]
20569    fn escape_list_marker_plain() {
20570        assert_eq!(escape_list_marker("plain text"), "plain text");
20571        assert_eq!(escape_list_marker("no. marker"), "no. marker");
20572    }
20573
20574    #[test]
20575    fn escape_emoji_shortcodes_basic() {
20576        assert_eq!(escape_emoji_shortcodes(":fire:"), r"\:fire:");
20577        assert_eq!(
20578            escape_emoji_shortcodes("hello :wave: world"),
20579            r"hello \:wave: world"
20580        );
20581    }
20582
20583    #[test]
20584    fn escape_emoji_shortcodes_double_colon() {
20585        // Only the colon that starts `:Active:` needs escaping
20586        assert_eq!(
20587            escape_emoji_shortcodes("Status::Active::Running"),
20588            r"Status:\:Active::Running"
20589        );
20590    }
20591
20592    #[test]
20593    fn escape_emoji_shortcodes_no_match() {
20594        // Lone colons, numeric-only between colons like 10:30
20595        assert_eq!(escape_emoji_shortcodes("Time is 10:30"), "Time is 10:30");
20596        assert_eq!(escape_emoji_shortcodes("no colons here"), "no colons here");
20597        assert_eq!(escape_emoji_shortcodes("trailing:"), "trailing:");
20598        assert_eq!(escape_emoji_shortcodes(":"), ":");
20599    }
20600
20601    #[test]
20602    fn escape_emoji_shortcodes_mixed() {
20603        assert_eq!(
20604            escape_emoji_shortcodes("Alert :fire: on pod:pod42"),
20605            r"Alert \:fire: on pod:pod42"
20606        );
20607    }
20608
20609    #[test]
20610    fn escape_emoji_shortcodes_unicode() {
20611        // Issue #552: Unicode alphanumeric chars must be escaped to match
20612        // `try_parse_emoji_shortcode`, which uses `is_alphanumeric` (not the
20613        // ASCII-only variant).  Without this, `:Café:` rendered un-escaped
20614        // would be re-parsed as an emoji on round-trip.
20615        assert_eq!(escape_emoji_shortcodes(":Café:"), r"\:Café:");
20616        assert_eq!(escape_emoji_shortcodes(":über:"), r"\:über:");
20617        assert_eq!(escape_emoji_shortcodes(":配置:"), r"\:配置:");
20618        assert_eq!(
20619            escape_emoji_shortcodes("ZBC::配置::Production"),
20620            r"ZBC:\:配置::Production"
20621        );
20622    }
20623
20624    #[test]
20625    fn escape_emoji_shortcodes_mixed_script_name() {
20626        // Issue #552: A name that mixes ASCII and Unicode alphanumerics is
20627        // still a single valid shortcode under `is_alphanumeric`.
20628        assert_eq!(escape_emoji_shortcodes(":abc配置:"), r"\:abc配置:");
20629        assert_eq!(escape_emoji_shortcodes(":配置abc:"), r"\:配置abc:");
20630    }
20631
20632    #[test]
20633    fn escape_emoji_shortcodes_unicode_followed_by_non_colon() {
20634        // `:Café world:` — `Café` is alphanumeric but the terminator is a
20635        // space, not `:`, so the `after + name_end < text.len()` path is
20636        // exercised but the final `== b':'` check bails out and nothing
20637        // gets escaped.  Guards the negative branch of the predicate.
20638        assert_eq!(escape_emoji_shortcodes(":Café world:"), ":Café world:");
20639    }
20640
20641    #[test]
20642    fn escape_emoji_shortcodes_name_runs_to_end() {
20643        // Colon followed by alphanumerics to end of string: `.find(...)` returns
20644        // `None`, so `name_end` falls back to the full remaining length via
20645        // `map_or`.  The `after + name_end < text.len()` check then fails and
20646        // nothing is escaped.  Exercises the `map_or` default branch for both
20647        // ASCII and Unicode names.
20648        assert_eq!(escape_emoji_shortcodes(":abc"), ":abc");
20649        assert_eq!(escape_emoji_shortcodes(":配置"), ":配置");
20650    }
20651
20652    #[test]
20653    fn unicode_shortcode_pattern_text_round_trips_as_text() {
20654        // Issue #552: A text node containing `:Café:` must round-trip as text,
20655        // not be split into text + emoji nodes.
20656        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20657          {"type":"text","text":"Visit :Café: today"}
20658        ]}]}"#;
20659        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20660
20661        let md = adf_to_markdown(&doc).unwrap();
20662        let round_tripped = markdown_to_adf(&md).unwrap();
20663        let content = round_tripped.content[0].content.as_ref().unwrap();
20664
20665        assert_eq!(
20666            content.len(),
20667            1,
20668            "should be a single text node, got: {content:?}"
20669        );
20670        assert_eq!(content[0].node_type, "text");
20671        assert_eq!(content[0].text.as_deref().unwrap(), "Visit :Café: today");
20672    }
20673
20674    #[test]
20675    fn unicode_double_colon_pattern_text_round_trips() {
20676        // Issue #552: `ZBC::配置::Production` should round-trip without splitting.
20677        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20678          {"type":"text","text":"Use ZBC::配置::Production for prod"}
20679        ]}]}"#;
20680        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20681
20682        let md = adf_to_markdown(&doc).unwrap();
20683        let round_tripped = markdown_to_adf(&md).unwrap();
20684        let content = round_tripped.content[0].content.as_ref().unwrap();
20685
20686        assert_eq!(
20687            content.len(),
20688            1,
20689            "should be a single text node, got: {content:?}"
20690        );
20691        assert_eq!(
20692            content[0].text.as_deref().unwrap(),
20693            "Use ZBC::配置::Production for prod"
20694        );
20695    }
20696
20697    #[test]
20698    fn merge_adjacent_text_nodes() {
20699        let mut nodes = vec![AdfNode::text("a"), AdfNode::text("b"), AdfNode::text("c")];
20700        merge_adjacent_text(&mut nodes);
20701        assert_eq!(nodes.len(), 1);
20702        assert_eq!(nodes[0].text.as_deref().unwrap(), "abc");
20703    }
20704
20705    // ── Issue #455: text after hardBreak in paragraph re-parsed as list ──
20706
20707    #[test]
20708    fn issue_455_paragraph_hardbreak_ordered_marker_roundtrips() {
20709        // Issue #455: "1. text" after a hardBreak in a paragraph must not
20710        // become an ordered list.
20711        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20712          {"type":"text","text":"Introduction: "},
20713          {"type":"hardBreak"},
20714          {"type":"text","text":"1. This text follows a hardBreak"}
20715        ]}]}"#;
20716        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20717        let md = adf_to_markdown(&doc).unwrap();
20718        let rt = markdown_to_adf(&md).unwrap();
20719
20720        assert_eq!(rt.content.len(), 1, "Should remain one block");
20721        assert_eq!(rt.content[0].node_type, "paragraph");
20722        let inlines = rt.content[0].content.as_ref().unwrap();
20723        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20724        assert_eq!(types, vec!["text", "hardBreak", "text"]);
20725        assert_eq!(
20726            inlines[2].text.as_deref(),
20727            Some("1. This text follows a hardBreak")
20728        );
20729    }
20730
20731    #[test]
20732    fn issue_455_paragraph_hardbreak_bullet_marker_roundtrips() {
20733        // Issue #455 variant: "- text" after a hardBreak in a paragraph.
20734        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20735          {"type":"text","text":"Intro"},
20736          {"type":"hardBreak"},
20737          {"type":"text","text":"- not a list item"}
20738        ]}]}"#;
20739        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20740        let md = adf_to_markdown(&doc).unwrap();
20741        let rt = markdown_to_adf(&md).unwrap();
20742
20743        assert_eq!(rt.content.len(), 1);
20744        assert_eq!(rt.content[0].node_type, "paragraph");
20745        let inlines = rt.content[0].content.as_ref().unwrap();
20746        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20747        assert_eq!(types, vec!["text", "hardBreak", "text"]);
20748        assert_eq!(inlines[2].text.as_deref(), Some("- not a list item"));
20749    }
20750
20751    #[test]
20752    fn issue_455_paragraph_hardbreak_heading_marker_roundtrips() {
20753        // Issue #455 variant: "# text" after a hardBreak in a paragraph.
20754        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20755          {"type":"text","text":"Intro"},
20756          {"type":"hardBreak"},
20757          {"type":"text","text":"# not a heading"}
20758        ]}]}"##;
20759        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20760        let md = adf_to_markdown(&doc).unwrap();
20761        let rt = markdown_to_adf(&md).unwrap();
20762
20763        assert_eq!(rt.content.len(), 1);
20764        assert_eq!(rt.content[0].node_type, "paragraph");
20765        let inlines = rt.content[0].content.as_ref().unwrap();
20766        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20767        assert_eq!(types, vec!["text", "hardBreak", "text"]);
20768        assert_eq!(inlines[2].text.as_deref(), Some("# not a heading"));
20769    }
20770
20771    #[test]
20772    fn issue_455_paragraph_hardbreak_blockquote_marker_roundtrips() {
20773        // Issue #455 variant: "> text" after a hardBreak in a paragraph.
20774        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20775          {"type":"text","text":"Intro"},
20776          {"type":"hardBreak"},
20777          {"type":"text","text":"> not a blockquote"}
20778        ]}]}"#;
20779        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20780        let md = adf_to_markdown(&doc).unwrap();
20781        let rt = markdown_to_adf(&md).unwrap();
20782
20783        assert_eq!(rt.content.len(), 1);
20784        assert_eq!(rt.content[0].node_type, "paragraph");
20785        let inlines = rt.content[0].content.as_ref().unwrap();
20786        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20787        assert_eq!(types, vec!["text", "hardBreak", "text"]);
20788        assert_eq!(inlines[2].text.as_deref(), Some("> not a blockquote"));
20789    }
20790
20791    #[test]
20792    fn issue_455_paragraph_multiple_hardbreaks_with_ordered_markers() {
20793        // Multiple hardBreaks in a paragraph, each followed by "N. text".
20794        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20795          {"type":"text","text":"Preamble"},
20796          {"type":"hardBreak"},
20797          {"type":"text","text":"1. First"},
20798          {"type":"hardBreak"},
20799          {"type":"text","text":"2. Second"},
20800          {"type":"hardBreak"},
20801          {"type":"text","text":"3. Third"}
20802        ]}]}"#;
20803        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20804        let md = adf_to_markdown(&doc).unwrap();
20805        let rt = markdown_to_adf(&md).unwrap();
20806
20807        assert_eq!(rt.content.len(), 1);
20808        assert_eq!(rt.content[0].node_type, "paragraph");
20809        let inlines = rt.content[0].content.as_ref().unwrap();
20810        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20811        assert_eq!(
20812            types,
20813            vec![
20814                "text",
20815                "hardBreak",
20816                "text",
20817                "hardBreak",
20818                "text",
20819                "hardBreak",
20820                "text"
20821            ]
20822        );
20823        assert_eq!(inlines[2].text.as_deref(), Some("1. First"));
20824        assert_eq!(inlines[4].text.as_deref(), Some("2. Second"));
20825        assert_eq!(inlines[6].text.as_deref(), Some("3. Third"));
20826    }
20827
20828    #[test]
20829    fn issue_455_paragraph_hardbreak_jfm_indentation() {
20830        // Verify that ADF→JFM output indents continuation lines.
20831        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20832          {"type":"text","text":"Intro"},
20833          {"type":"hardBreak"},
20834          {"type":"text","text":"1. continued"}
20835        ]}]}"#;
20836        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20837        let md = adf_to_markdown(&doc).unwrap();
20838        assert!(
20839            md.contains("Intro\\\n  1. continued"),
20840            "Continuation should be 2-space-indented, got: {md:?}"
20841        );
20842    }
20843
20844    #[test]
20845    fn issue_455_paragraph_hardbreak_from_jfm() {
20846        // Verify that JFM with 2-space-indented continuation is parsed
20847        // back as a single paragraph with hardBreak.
20848        let md = "Intro\\\n  1. This is continuation text\n";
20849        let doc = markdown_to_adf(md).unwrap();
20850
20851        assert_eq!(doc.content.len(), 1);
20852        assert_eq!(doc.content[0].node_type, "paragraph");
20853        let inlines = doc.content[0].content.as_ref().unwrap();
20854        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20855        assert_eq!(types, vec!["text", "hardBreak", "text"]);
20856        assert_eq!(
20857            inlines[2].text.as_deref(),
20858            Some("1. This is continuation text")
20859        );
20860    }
20861
20862    #[test]
20863    fn issue_455_paragraph_starts_with_ordered_marker_and_hardbreak() {
20864        // Coverage: first line IS a list marker AND paragraph has hardBreaks.
20865        // Exercises the escape_list_marker path on the first line of a
20866        // multi-line paragraph buf in the rendering code.
20867        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20868          {"type":"text","text":"1. Starting with a number"},
20869          {"type":"hardBreak"},
20870          {"type":"text","text":"continuation after break"}
20871        ]}]}"#;
20872        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20873        let md = adf_to_markdown(&doc).unwrap();
20874        // First line should be escaped so it's not parsed as ordered list
20875        assert!(
20876            md.contains(r"1\. Starting with a number"),
20877            "First line should have escaped list marker, got: {md:?}"
20878        );
20879        let rt = markdown_to_adf(&md).unwrap();
20880
20881        assert_eq!(rt.content.len(), 1);
20882        assert_eq!(rt.content[0].node_type, "paragraph");
20883        let inlines = rt.content[0].content.as_ref().unwrap();
20884        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20885        assert_eq!(types, vec!["text", "hardBreak", "text"]);
20886        assert_eq!(
20887            inlines[0].text.as_deref(),
20888            Some("1. Starting with a number")
20889        );
20890        assert_eq!(inlines[2].text.as_deref(), Some("continuation after break"));
20891    }
20892
20893    #[test]
20894    fn ordered_marker_paragraph_in_table_cell_roundtrips() {
20895        // Issue #402: paragraph with "2. " text inside a tableCell must
20896        // not be re-parsed as an ordered list.
20897        let adf_json = r#"{"version":1,"type":"doc","content":[{
20898          "type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},
20899          "content":[{"type":"tableRow","content":[{
20900            "type":"tableCell","attrs":{"colspan":1,"rowspan":1},
20901            "content":[{"type":"paragraph","content":[
20902              {"type":"text","text":"2. Honouring existing commitments"}
20903            ]}]
20904          }]}]
20905        }]}"#;
20906        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20907        let md = adf_to_markdown(&doc).unwrap();
20908        let rt = markdown_to_adf(&md).unwrap();
20909
20910        let table = &rt.content[0];
20911        let cell = &table.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0];
20912        let para = &cell.content.as_ref().unwrap()[0];
20913        assert_eq!(para.node_type, "paragraph");
20914        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
20915        assert_eq!(text, "2. Honouring existing commitments");
20916    }
20917
20918    #[test]
20919    fn bullet_marker_paragraph_standalone_roundtrips() {
20920        // A top-level paragraph starting with "- " must round-trip as
20921        // a paragraph, not a bullet list.
20922        let adf_json = r#"{"version":1,"type":"doc","content":[
20923          {"type":"paragraph","content":[
20924            {"type":"text","text":"- not a list item"}
20925          ]}
20926        ]}"#;
20927        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20928        let md = adf_to_markdown(&doc).unwrap();
20929        assert!(
20930            md.contains(r"\- not a list item"),
20931            "Should escape the leading dash, got:\n{md}"
20932        );
20933        let rt = markdown_to_adf(&md).unwrap();
20934        assert_eq!(rt.content[0].node_type, "paragraph");
20935        let text = rt.content[0].content.as_ref().unwrap()[0]
20936            .text
20937            .as_deref()
20938            .unwrap();
20939        assert_eq!(text, "- not a list item");
20940    }
20941
20942    #[test]
20943    fn merge_adjacent_text_skips_non_text_nodes() {
20944        // Exercises the `else { i += 1 }` branch when adjacent nodes
20945        // are not both plain text.
20946        let mut nodes = vec![
20947            AdfNode::text("a"),
20948            AdfNode::hard_break(),
20949            AdfNode::text("b"),
20950        ];
20951        merge_adjacent_text(&mut nodes);
20952        assert_eq!(nodes.len(), 3);
20953    }
20954
20955    #[test]
20956    fn star_bullet_paragraph_roundtrips() {
20957        // Paragraph starting with "* " must round-trip without becoming
20958        // a bullet list.
20959        let adf_json = r#"{"version":1,"type":"doc","content":[
20960          {"type":"paragraph","content":[
20961            {"type":"text","text":"* starred"}
20962          ]}
20963        ]}"#;
20964        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20965        let md = adf_to_markdown(&doc).unwrap();
20966        let rt = markdown_to_adf(&md).unwrap();
20967        assert_eq!(rt.content[0].node_type, "paragraph");
20968        assert_eq!(
20969            rt.content[0].content.as_ref().unwrap()[0]
20970                .text
20971                .as_deref()
20972                .unwrap(),
20973            "* starred"
20974        );
20975    }
20976
20977    // ---- Issue #388 tests ----
20978
20979    #[test]
20980    fn issue_388_ordered_list_with_strong_hardbreak_roundtrips() {
20981        // Issue #388: orderedList with 2 listItems, each containing
20982        // strong-marked text + hardBreak + plain text.
20983        let adf_json = r#"{"version":1,"type":"doc","content":[
20984          {"type":"orderedList","attrs":{"order":1},"content":[
20985            {"type":"listItem","content":[
20986              {"type":"paragraph","content":[
20987                {"type":"text","text":"Bold heading","marks":[{"type":"strong"}]},
20988                {"type":"hardBreak"},
20989                {"type":"text","text":"Content after break"}
20990              ]}
20991            ]},
20992            {"type":"listItem","content":[
20993              {"type":"paragraph","content":[
20994                {"type":"text","text":"Second item","marks":[{"type":"strong"}]},
20995                {"type":"hardBreak"},
20996                {"type":"text","text":"More content"}
20997              ]}
20998            ]}
20999          ]}
21000        ]}"#;
21001        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21002        let md = adf_to_markdown(&doc).unwrap();
21003        let rt = markdown_to_adf(&md).unwrap();
21004
21005        // Must remain a single orderedList
21006        assert_eq!(
21007            rt.content.len(),
21008            1,
21009            "Should be 1 block (orderedList), got {}",
21010            rt.content.len()
21011        );
21012        assert_eq!(rt.content[0].node_type, "orderedList");
21013        let items = rt.content[0].content.as_ref().unwrap();
21014        assert_eq!(
21015            items.len(),
21016            2,
21017            "Should have 2 listItems, got {}",
21018            items.len()
21019        );
21020
21021        // First item: text(strong) + hardBreak + text
21022        let p1 = items[0].content.as_ref().unwrap()[0]
21023            .content
21024            .as_ref()
21025            .unwrap();
21026        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21027        assert_eq!(types1, vec!["text", "hardBreak", "text"]);
21028        assert_eq!(p1[0].text.as_deref(), Some("Bold heading"));
21029        assert_eq!(p1[2].text.as_deref(), Some("Content after break"));
21030
21031        // Second item: text(strong) + hardBreak + text
21032        let p2 = items[1].content.as_ref().unwrap()[0]
21033            .content
21034            .as_ref()
21035            .unwrap();
21036        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
21037        assert_eq!(types2, vec!["text", "hardBreak", "text"]);
21038        assert_eq!(p2[0].text.as_deref(), Some("Second item"));
21039        assert_eq!(p2[2].text.as_deref(), Some("More content"));
21040    }
21041
21042    #[test]
21043    fn issue_388_bullet_list_with_strong_hardbreak_roundtrips() {
21044        // Bullet list variant of issue #388.
21045        let adf_json = r#"{"version":1,"type":"doc","content":[
21046          {"type":"bulletList","content":[
21047            {"type":"listItem","content":[
21048              {"type":"paragraph","content":[
21049                {"type":"text","text":"First","marks":[{"type":"strong"}]},
21050                {"type":"hardBreak"},
21051                {"type":"text","text":"details"}
21052              ]}
21053            ]},
21054            {"type":"listItem","content":[
21055              {"type":"paragraph","content":[
21056                {"type":"text","text":"Second","marks":[{"type":"em"}]},
21057                {"type":"hardBreak"},
21058                {"type":"text","text":"more details"}
21059              ]}
21060            ]}
21061          ]}
21062        ]}"#;
21063        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21064        let md = adf_to_markdown(&doc).unwrap();
21065        let rt = markdown_to_adf(&md).unwrap();
21066
21067        assert_eq!(rt.content.len(), 1);
21068        assert_eq!(rt.content[0].node_type, "bulletList");
21069        let items = rt.content[0].content.as_ref().unwrap();
21070        assert_eq!(items.len(), 2);
21071
21072        let p1 = items[0].content.as_ref().unwrap()[0]
21073            .content
21074            .as_ref()
21075            .unwrap();
21076        assert_eq!(p1[0].text.as_deref(), Some("First"));
21077        assert_eq!(p1[2].text.as_deref(), Some("details"));
21078
21079        let p2 = items[1].content.as_ref().unwrap()[0]
21080            .content
21081            .as_ref()
21082            .unwrap();
21083        assert_eq!(p2[0].text.as_deref(), Some("Second"));
21084        assert_eq!(p2[2].text.as_deref(), Some("more details"));
21085    }
21086
21087    #[test]
21088    fn issue_388_ordered_list_hardbreak_jfm_indentation() {
21089        // Verify the JFM output has properly indented continuation lines.
21090        let adf_json = r#"{"version":1,"type":"doc","content":[
21091          {"type":"orderedList","attrs":{"order":1},"content":[
21092            {"type":"listItem","content":[
21093              {"type":"paragraph","content":[
21094                {"type":"text","text":"heading","marks":[{"type":"strong"}]},
21095                {"type":"hardBreak"},
21096                {"type":"text","text":"body"}
21097              ]}
21098            ]}
21099          ]}
21100        ]}"#;
21101        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21102        let md = adf_to_markdown(&doc).unwrap();
21103        assert!(
21104            md.contains("1. **heading**\\\n  body"),
21105            "Continuation should be indented, got:\n{md}"
21106        );
21107    }
21108
21109    #[test]
21110    fn issue_388_ordered_list_hardbreak_from_jfm() {
21111        // Direct JFM → ADF: ordered list with hardBreak continuation.
21112        let md = "1. **bold**\\\n  continued\n2. **also bold**\\\n  also continued\n";
21113        let doc = markdown_to_adf(md).unwrap();
21114
21115        assert_eq!(doc.content.len(), 1);
21116        assert_eq!(doc.content[0].node_type, "orderedList");
21117        let items = doc.content[0].content.as_ref().unwrap();
21118        assert_eq!(items.len(), 2);
21119
21120        let p1 = items[0].content.as_ref().unwrap()[0]
21121            .content
21122            .as_ref()
21123            .unwrap();
21124        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21125        assert_eq!(types1, vec!["text", "hardBreak", "text"]);
21126        assert_eq!(p1[0].text.as_deref(), Some("bold"));
21127        assert_eq!(p1[2].text.as_deref(), Some("continued"));
21128
21129        let p2 = items[1].content.as_ref().unwrap()[0]
21130            .content
21131            .as_ref()
21132            .unwrap();
21133        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
21134        assert_eq!(types2, vec!["text", "hardBreak", "text"]);
21135    }
21136
21137    #[test]
21138    fn issue_388_bullet_list_hardbreak_from_jfm() {
21139        // Direct JFM → ADF: bullet list with hardBreak continuation.
21140        let md = "- first\\\n  second\n- third\\\n  fourth\n";
21141        let doc = markdown_to_adf(md).unwrap();
21142
21143        assert_eq!(doc.content.len(), 1);
21144        assert_eq!(doc.content[0].node_type, "bulletList");
21145        let items = doc.content[0].content.as_ref().unwrap();
21146        assert_eq!(items.len(), 2);
21147
21148        for (i, expected) in [("first", "second"), ("third", "fourth")]
21149            .iter()
21150            .enumerate()
21151        {
21152            let p = items[i].content.as_ref().unwrap()[0]
21153                .content
21154                .as_ref()
21155                .unwrap();
21156            let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
21157            assert_eq!(types, vec!["text", "hardBreak", "text"]);
21158            assert_eq!(p[0].text.as_deref(), Some(expected.0));
21159            assert_eq!(p[2].text.as_deref(), Some(expected.1));
21160        }
21161    }
21162
21163    #[test]
21164    fn issue_433_heading_hardbreak_roundtrips() {
21165        // Issue #433: hardBreak inside heading splits into heading + paragraph.
21166        let adf_json = r#"{"version":1,"type":"doc","content":[{
21167          "type":"heading",
21168          "attrs":{"level":1},
21169          "content":[
21170            {"type":"text","text":"Line one"},
21171            {"type":"hardBreak"},
21172            {"type":"text","text":"Line two"}
21173          ]
21174        }]}"#;
21175        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21176        let md = adf_to_markdown(&doc).unwrap();
21177        let rt = markdown_to_adf(&md).unwrap();
21178
21179        assert_eq!(
21180            rt.content.len(),
21181            1,
21182            "Should remain a single heading, got {} blocks",
21183            rt.content.len()
21184        );
21185        assert_eq!(rt.content[0].node_type, "heading");
21186        let inlines = rt.content[0].content.as_ref().unwrap();
21187        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21188        assert_eq!(
21189            types,
21190            vec!["text", "hardBreak", "text"],
21191            "hardBreak should be preserved, got: {types:?}"
21192        );
21193        assert_eq!(inlines[0].text.as_deref(), Some("Line one"));
21194        assert_eq!(inlines[2].text.as_deref(), Some("Line two"));
21195    }
21196
21197    #[test]
21198    fn issue_433_heading_hardbreak_jfm_indentation() {
21199        // Verify the JFM output has properly indented continuation lines.
21200        let adf_json = r#"{"version":1,"type":"doc","content":[{
21201          "type":"heading",
21202          "attrs":{"level":2},
21203          "content":[
21204            {"type":"text","text":"Title"},
21205            {"type":"hardBreak"},
21206            {"type":"text","text":"Subtitle"}
21207          ]
21208        }]}"#;
21209        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21210        let md = adf_to_markdown(&doc).unwrap();
21211        assert!(
21212            md.contains("## Title\\\n  Subtitle"),
21213            "Continuation should be indented, got:\n{md}"
21214        );
21215    }
21216
21217    #[test]
21218    fn issue_433_heading_hardbreak_from_jfm() {
21219        // Direct JFM → ADF: heading with hardBreak continuation.
21220        let md = "# First\\\n  Second\n";
21221        let doc = markdown_to_adf(md).unwrap();
21222
21223        assert_eq!(doc.content.len(), 1);
21224        assert_eq!(doc.content[0].node_type, "heading");
21225        let inlines = doc.content[0].content.as_ref().unwrap();
21226        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21227        assert_eq!(types, vec!["text", "hardBreak", "text"]);
21228        assert_eq!(inlines[0].text.as_deref(), Some("First"));
21229        assert_eq!(inlines[2].text.as_deref(), Some("Second"));
21230    }
21231
21232    #[test]
21233    fn issue_433_heading_consecutive_hardbreaks_roundtrip() {
21234        // Consecutive hardBreaks in a heading.
21235        let adf_json = r#"{"version":1,"type":"doc","content":[{
21236          "type":"heading",
21237          "attrs":{"level":3},
21238          "content":[
21239            {"type":"text","text":"A"},
21240            {"type":"hardBreak"},
21241            {"type":"hardBreak"},
21242            {"type":"text","text":"B"}
21243          ]
21244        }]}"#;
21245        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21246        let md = adf_to_markdown(&doc).unwrap();
21247        let rt = markdown_to_adf(&md).unwrap();
21248
21249        assert_eq!(rt.content.len(), 1, "Should remain a single heading");
21250        assert_eq!(rt.content[0].node_type, "heading");
21251        let inlines = rt.content[0].content.as_ref().unwrap();
21252        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21253        assert_eq!(types, vec!["text", "hardBreak", "hardBreak", "text"]);
21254    }
21255
21256    #[test]
21257    fn issue_433_heading_with_strong_and_hardbreak_roundtrips() {
21258        // Heading with strong-marked text + hardBreak + plain text.
21259        let adf_json = r#"{"version":1,"type":"doc","content":[{
21260          "type":"heading",
21261          "attrs":{"level":1},
21262          "content":[
21263            {"type":"text","text":"Bold title","marks":[{"type":"strong"}]},
21264            {"type":"hardBreak"},
21265            {"type":"text","text":"plain continuation"}
21266          ]
21267        }]}"#;
21268        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21269        let md = adf_to_markdown(&doc).unwrap();
21270        let rt = markdown_to_adf(&md).unwrap();
21271
21272        assert_eq!(rt.content.len(), 1);
21273        assert_eq!(rt.content[0].node_type, "heading");
21274        let inlines = rt.content[0].content.as_ref().unwrap();
21275        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21276        assert_eq!(types, vec!["text", "hardBreak", "text"]);
21277        assert_eq!(inlines[0].text.as_deref(), Some("Bold title"));
21278        assert_eq!(inlines[2].text.as_deref(), Some("plain continuation"));
21279    }
21280
21281    #[test]
21282    fn issue_433_heading_with_link_and_hardbreak_roundtrips() {
21283        // Real-world pattern: heading with link + hardBreak + text.
21284        let adf_json = r#"{"version":1,"type":"doc","content":[{
21285          "type":"heading",
21286          "attrs":{"level":1},
21287          "content":[
21288            {"type":"text","text":"Click here","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
21289            {"type":"hardBreak"},
21290            {"type":"text","text":"Subtitle text"}
21291          ]
21292        }]}"#;
21293        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21294        let md = adf_to_markdown(&doc).unwrap();
21295        let rt = markdown_to_adf(&md).unwrap();
21296
21297        assert_eq!(rt.content.len(), 1);
21298        assert_eq!(rt.content[0].node_type, "heading");
21299        let inlines = rt.content[0].content.as_ref().unwrap();
21300        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21301        assert_eq!(types, vec!["text", "hardBreak", "text"]);
21302        assert_eq!(inlines[2].text.as_deref(), Some("Subtitle text"));
21303    }
21304
21305    #[test]
21306    fn has_trailing_hard_break_backslash() {
21307        assert!(has_trailing_hard_break("text\\"));
21308        assert!(has_trailing_hard_break("**bold**\\"));
21309    }
21310
21311    #[test]
21312    fn has_trailing_hard_break_trailing_spaces() {
21313        assert!(has_trailing_hard_break("text  "));
21314        assert!(has_trailing_hard_break("word   "));
21315    }
21316
21317    #[test]
21318    fn has_trailing_hard_break_false() {
21319        assert!(!has_trailing_hard_break("plain text"));
21320        assert!(!has_trailing_hard_break("text "));
21321        assert!(!has_trailing_hard_break(""));
21322    }
21323
21324    #[test]
21325    fn collect_hardbreak_continuations_collects_indented() {
21326        // A line ending with `\` followed by 2-space-indented continuation.
21327        // Only one line is collected because the result no longer ends with `\`.
21328        let input = "first\\\n  second\n  third\n";
21329        let mut parser = MarkdownParser::new(input);
21330        parser.advance(); // skip first line
21331        let mut text = "first\\".to_string();
21332        parser.collect_hardbreak_continuations(&mut text);
21333        assert_eq!(text, "first\\\nsecond");
21334    }
21335
21336    #[test]
21337    fn collect_hardbreak_continuations_stops_at_non_indented() {
21338        let input = "first\\\nnot indented\n";
21339        let mut parser = MarkdownParser::new(input);
21340        parser.advance();
21341        let mut text = "first\\".to_string();
21342        parser.collect_hardbreak_continuations(&mut text);
21343        // Should NOT collect the non-indented line
21344        assert_eq!(text, "first\\");
21345    }
21346
21347    #[test]
21348    fn collect_hardbreak_continuations_no_trailing_break() {
21349        // If the text doesn't end with a hardBreak marker, nothing is collected.
21350        let input = "plain\n  indented\n";
21351        let mut parser = MarkdownParser::new(input);
21352        parser.advance();
21353        let mut text = "plain".to_string();
21354        parser.collect_hardbreak_continuations(&mut text);
21355        assert_eq!(text, "plain");
21356    }
21357
21358    #[test]
21359    fn collect_hardbreak_continuations_chained() {
21360        // Multiple continuation lines chained via repeated hardBreaks.
21361        let input = "a\\\n  b\\\n  c\\\n  d\n";
21362        let mut parser = MarkdownParser::new(input);
21363        parser.advance();
21364        let mut text = "a\\".to_string();
21365        parser.collect_hardbreak_continuations(&mut text);
21366        assert_eq!(text, "a\\\nb\\\nc\\\nd");
21367    }
21368
21369    #[test]
21370    fn collect_hardbreak_continuations_stops_before_image_line() {
21371        // An indented continuation that starts with `![` (mediaSingle syntax)
21372        // must NOT be swallowed as a paragraph continuation (issue #490).
21373        let input = "text\\\n  ![](url){type=file id=x}\n";
21374        let mut parser = MarkdownParser::new(input);
21375        parser.advance(); // skip first line
21376        let mut text = "text\\".to_string();
21377        parser.collect_hardbreak_continuations(&mut text);
21378        // The image line should NOT have been consumed.
21379        assert_eq!(text, "text\\");
21380        // Parser should still be on the image line (not past it).
21381        assert!(!parser.at_end());
21382        assert!(parser.current_line().contains("![](url)"));
21383    }
21384
21385    #[test]
21386    fn is_block_level_continuation_marker_positive_cases() {
21387        // Each marker that forces `collect_hardbreak_continuations` to stop.
21388        assert!(is_block_level_continuation_marker("![](url)"));
21389        assert!(is_block_level_continuation_marker("```ruby"));
21390        assert!(is_block_level_continuation_marker(":::panel{type=info}"));
21391    }
21392
21393    #[test]
21394    fn is_block_level_continuation_marker_negative_cases() {
21395        // Plain continuation text must NOT look like a block-level marker.
21396        assert!(!is_block_level_continuation_marker("plain text"));
21397        assert!(!is_block_level_continuation_marker("- nested item"));
21398        assert!(!is_block_level_continuation_marker("continuation\\"));
21399        assert!(!is_block_level_continuation_marker(""));
21400        // Double-colon `::` is not a container directive.
21401        assert!(!is_block_level_continuation_marker("::partial"));
21402        // Single backticks are inline code, not a fence.
21403        assert!(!is_block_level_continuation_marker("`inline`"));
21404    }
21405
21406    #[test]
21407    fn collect_hardbreak_continuations_stops_before_code_fence() {
21408        // Issue #552: An indented continuation that opens a fenced code block
21409        // must NOT be swallowed as a paragraph continuation — it has to stay
21410        // available for `try_code_block` on the next parse iteration.
21411        let input = "text\\\n  ```ruby\n  Foo::Bar::Baz\n  ```\n";
21412        let mut parser = MarkdownParser::new(input);
21413        parser.advance();
21414        let mut text = "text\\".to_string();
21415        parser.collect_hardbreak_continuations(&mut text);
21416        assert_eq!(text, "text\\");
21417        assert!(!parser.at_end());
21418        assert!(parser.current_line().starts_with("  ```"));
21419    }
21420
21421    #[test]
21422    fn collect_hardbreak_continuations_stops_before_container_directive() {
21423        // Issue #552: An indented continuation that opens a `:::` container
21424        // directive (panel, expand, etc.) must also stay available for the
21425        // directive parser.
21426        let input = "text\\\n  :::panel{type=info}\n  body\n  :::\n";
21427        let mut parser = MarkdownParser::new(input);
21428        parser.advance();
21429        let mut text = "text\\".to_string();
21430        parser.collect_hardbreak_continuations(&mut text);
21431        assert_eq!(text, "text\\");
21432        assert!(!parser.at_end());
21433        assert!(parser.current_line().contains(":::panel"));
21434    }
21435
21436    #[test]
21437    fn collect_hardbreak_continuations_stops_before_indented_code_fence() {
21438        // Variant: extra leading whitespace on the code-fence line (so the
21439        // stripped tail is `  ```` rather than a bare ` ``` `) must still be
21440        // recognised by the `trim_start().starts_with("```")` check.
21441        let input = "text\\\n     ```text\n     :fire:\n     ```\n";
21442        let mut parser = MarkdownParser::new(input);
21443        parser.advance();
21444        let mut text = "text\\".to_string();
21445        parser.collect_hardbreak_continuations(&mut text);
21446        assert_eq!(text, "text\\");
21447        assert!(!parser.at_end());
21448        assert!(parser.current_line().contains("```text"));
21449    }
21450
21451    #[test]
21452    fn ordered_list_with_sub_content_after_hardbreak() {
21453        // Exercises the sub-content collection loop in parse_ordered_list
21454        // (lines 339-347) with a hardBreak item that also has a nested list.
21455        let adf_json = r#"{"version":1,"type":"doc","content":[
21456          {"type":"orderedList","attrs":{"order":1},"content":[
21457            {"type":"listItem","content":[
21458              {"type":"paragraph","content":[
21459                {"type":"text","text":"parent"},
21460                {"type":"hardBreak"},
21461                {"type":"text","text":"continued"}
21462              ]},
21463              {"type":"bulletList","content":[
21464                {"type":"listItem","content":[
21465                  {"type":"paragraph","content":[
21466                    {"type":"text","text":"child"}
21467                  ]}
21468                ]}
21469              ]}
21470            ]}
21471          ]}
21472        ]}"#;
21473        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21474        let md = adf_to_markdown(&doc).unwrap();
21475        let rt = markdown_to_adf(&md).unwrap();
21476
21477        assert_eq!(rt.content.len(), 1);
21478        assert_eq!(rt.content[0].node_type, "orderedList");
21479        let item_content = rt.content[0].content.as_ref().unwrap()[0]
21480            .content
21481            .as_ref()
21482            .unwrap();
21483        // Paragraph with hardBreak
21484        let p = item_content[0].content.as_ref().unwrap();
21485        let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
21486        assert_eq!(types, vec!["text", "hardBreak", "text"]);
21487        assert_eq!(p[0].text.as_deref(), Some("parent"));
21488        assert_eq!(p[2].text.as_deref(), Some("continued"));
21489        // Nested bullet list
21490        assert_eq!(item_content[1].node_type, "bulletList");
21491    }
21492
21493    #[test]
21494    fn render_list_item_content_no_content() {
21495        // A listItem with content: None should produce just a newline.
21496        let item = AdfNode {
21497            node_type: "listItem".to_string(),
21498            attrs: None,
21499            content: None,
21500            text: None,
21501            marks: None,
21502            local_id: None,
21503            parameters: None,
21504        };
21505        let mut output = String::new();
21506        let opts = RenderOptions::default();
21507        render_list_item_content(&item, &mut output, &opts);
21508        assert_eq!(output, "\n");
21509    }
21510
21511    #[test]
21512    fn render_list_item_content_empty_content() {
21513        // A listItem with content: Some(vec![]) should produce just a newline.
21514        let item = AdfNode::list_item(vec![]);
21515        let mut output = String::new();
21516        let opts = RenderOptions::default();
21517        render_list_item_content(&item, &mut output, &opts);
21518        assert_eq!(output, "\n");
21519    }
21520
21521    #[test]
21522    fn plus_bullet_paragraph_roundtrips() {
21523        // Paragraph starting with "+ " must round-trip without becoming
21524        // a bullet list.
21525        let adf_json = r#"{"version":1,"type":"doc","content":[
21526          {"type":"paragraph","content":[
21527            {"type":"text","text":"+ plus"}
21528          ]}
21529        ]}"#;
21530        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21531        let md = adf_to_markdown(&doc).unwrap();
21532        let rt = markdown_to_adf(&md).unwrap();
21533        assert_eq!(rt.content[0].node_type, "paragraph");
21534        assert_eq!(
21535            rt.content[0].content.as_ref().unwrap()[0]
21536                .text
21537                .as_deref()
21538                .unwrap(),
21539            "+ plus"
21540        );
21541    }
21542
21543    // ---- Issue #430 tests: mediaSingle inside listItem ----
21544
21545    #[test]
21546    fn issue_430_file_media_in_bullet_list_roundtrip() {
21547        // Issue #430: mediaSingle (type:file) as direct child of listItem
21548        // in a bulletList must survive round-trip.
21549        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21550          {"type":"listItem","content":[{
21551            "type":"mediaSingle",
21552            "attrs":{"layout":"center","width":1009,"widthType":"pixel"},
21553            "content":[{
21554              "type":"media",
21555              "attrs":{"collection":"contentId-123","height":576,"id":"00066e8e-554e-4d7e-af59-a0ef2888bdb6","type":"file","width":1009}
21556            }]
21557          }]}
21558        ]}]}"#;
21559        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21560        let md = adf_to_markdown(&doc).unwrap();
21561        let rt = markdown_to_adf(&md).unwrap();
21562
21563        let list = &rt.content[0];
21564        assert_eq!(list.node_type, "bulletList");
21565        let item = &list.content.as_ref().unwrap()[0];
21566        assert_eq!(item.node_type, "listItem");
21567        let ms = &item.content.as_ref().unwrap()[0];
21568        assert_eq!(ms.node_type, "mediaSingle");
21569        let ms_attrs = ms.attrs.as_ref().unwrap();
21570        assert_eq!(ms_attrs["layout"], "center");
21571        assert_eq!(ms_attrs["width"], 1009);
21572        assert_eq!(ms_attrs["widthType"], "pixel");
21573        let media = &ms.content.as_ref().unwrap()[0];
21574        assert_eq!(media.node_type, "media");
21575        let m_attrs = media.attrs.as_ref().unwrap();
21576        assert_eq!(m_attrs["type"], "file");
21577        assert_eq!(m_attrs["id"], "00066e8e-554e-4d7e-af59-a0ef2888bdb6");
21578        assert_eq!(m_attrs["collection"], "contentId-123");
21579        assert_eq!(m_attrs["height"], 576);
21580        assert_eq!(m_attrs["width"], 1009);
21581    }
21582
21583    #[test]
21584    fn issue_430_file_media_in_ordered_list_roundtrip() {
21585        // Same as above but inside an orderedList.
21586        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
21587          {"type":"listItem","content":[{
21588            "type":"mediaSingle",
21589            "attrs":{"layout":"center"},
21590            "content":[{
21591              "type":"media",
21592              "attrs":{"type":"file","id":"abc-123","collection":"contentId-456","height":100,"width":200}
21593            }]
21594          }]}
21595        ]}]}"#;
21596        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21597        let md = adf_to_markdown(&doc).unwrap();
21598        let rt = markdown_to_adf(&md).unwrap();
21599
21600        let list = &rt.content[0];
21601        assert_eq!(list.node_type, "orderedList");
21602        let item = &list.content.as_ref().unwrap()[0];
21603        assert_eq!(item.node_type, "listItem");
21604        let ms = &item.content.as_ref().unwrap()[0];
21605        assert_eq!(ms.node_type, "mediaSingle");
21606        let media = &ms.content.as_ref().unwrap()[0];
21607        assert_eq!(media.node_type, "media");
21608        let m_attrs = media.attrs.as_ref().unwrap();
21609        assert_eq!(m_attrs["type"], "file");
21610        assert_eq!(m_attrs["id"], "abc-123");
21611        assert_eq!(m_attrs["collection"], "contentId-456");
21612    }
21613
21614    #[test]
21615    fn issue_430_external_media_in_bullet_list_roundtrip() {
21616        // External image (type:external) inside a bullet list item.
21617        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21618          {"type":"listItem","content":[{
21619            "type":"mediaSingle",
21620            "attrs":{"layout":"center"},
21621            "content":[{
21622              "type":"media",
21623              "attrs":{"type":"external","url":"https://example.com/img.png","alt":"Photo"}
21624            }]
21625          }]}
21626        ]}]}"#;
21627        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21628        let md = adf_to_markdown(&doc).unwrap();
21629        let rt = markdown_to_adf(&md).unwrap();
21630
21631        let list = &rt.content[0];
21632        assert_eq!(list.node_type, "bulletList");
21633        let item = &list.content.as_ref().unwrap()[0];
21634        let ms = &item.content.as_ref().unwrap()[0];
21635        assert_eq!(ms.node_type, "mediaSingle");
21636        let media = &ms.content.as_ref().unwrap()[0];
21637        assert_eq!(media.node_type, "media");
21638        let m_attrs = media.attrs.as_ref().unwrap();
21639        assert_eq!(m_attrs["type"], "external");
21640        assert_eq!(m_attrs["url"], "https://example.com/img.png");
21641    }
21642
21643    #[test]
21644    fn issue_430_media_with_paragraph_siblings_in_list_item() {
21645        // listItem containing a paragraph followed by a mediaSingle.
21646        // Both children must survive round-trip.
21647        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21648          {"type":"listItem","content":[
21649            {"type":"paragraph","content":[{"type":"text","text":"Caption:"}]},
21650            {"type":"mediaSingle","attrs":{"layout":"center"},
21651             "content":[{"type":"media","attrs":{"type":"file","id":"img-001","collection":"col-1","height":50,"width":100}}]}
21652          ]}
21653        ]}]}"#;
21654        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21655        let md = adf_to_markdown(&doc).unwrap();
21656        let rt = markdown_to_adf(&md).unwrap();
21657
21658        let item = &rt.content[0].content.as_ref().unwrap()[0];
21659        let children = item.content.as_ref().unwrap();
21660        assert_eq!(children.len(), 2, "expected 2 children in listItem");
21661        assert_eq!(children[0].node_type, "paragraph");
21662        assert_eq!(children[1].node_type, "mediaSingle");
21663        let media = &children[1].content.as_ref().unwrap()[0];
21664        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-001");
21665    }
21666
21667    #[test]
21668    fn issue_430_multiple_media_in_list_items() {
21669        // Multiple list items each containing mediaSingle.
21670        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21671          {"type":"listItem","content":[{
21672            "type":"mediaSingle","attrs":{"layout":"center"},
21673            "content":[{"type":"media","attrs":{"type":"file","id":"img-a","collection":"c1","height":10,"width":20}}]
21674          }]},
21675          {"type":"listItem","content":[{
21676            "type":"mediaSingle","attrs":{"layout":"center"},
21677            "content":[{"type":"media","attrs":{"type":"file","id":"img-b","collection":"c2","height":30,"width":40}}]
21678          }]}
21679        ]}]}"#;
21680        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21681        let md = adf_to_markdown(&doc).unwrap();
21682        let rt = markdown_to_adf(&md).unwrap();
21683
21684        let items = rt.content[0].content.as_ref().unwrap();
21685        assert_eq!(items.len(), 2);
21686        for (i, expected_id) in [("img-a", "c1"), ("img-b", "c2")].iter().enumerate() {
21687            let ms = &items[i].content.as_ref().unwrap()[0];
21688            assert_eq!(ms.node_type, "mediaSingle");
21689            let m_attrs = ms.content.as_ref().unwrap()[0].attrs.as_ref().unwrap();
21690            assert_eq!(m_attrs["id"], expected_id.0);
21691            assert_eq!(m_attrs["collection"], expected_id.1);
21692        }
21693    }
21694
21695    #[test]
21696    fn issue_430_jfm_to_adf_media_in_bullet_item() {
21697        // Parse JFM directly: image syntax on the first line of a bullet item
21698        // must produce mediaSingle, not a paragraph with corrupted text.
21699        let md = "- ![](){type=file id=test-id collection=col-1 height=100 width=200}\n";
21700        let doc = markdown_to_adf(md).unwrap();
21701
21702        let list = &doc.content[0];
21703        assert_eq!(list.node_type, "bulletList");
21704        let item = &list.content.as_ref().unwrap()[0];
21705        let ms = &item.content.as_ref().unwrap()[0];
21706        assert_eq!(
21707            ms.node_type, "mediaSingle",
21708            "expected mediaSingle, got {}",
21709            ms.node_type
21710        );
21711        let media = &ms.content.as_ref().unwrap()[0];
21712        assert_eq!(media.node_type, "media");
21713        let m_attrs = media.attrs.as_ref().unwrap();
21714        assert_eq!(m_attrs["type"], "file");
21715        assert_eq!(m_attrs["id"], "test-id");
21716    }
21717
21718    #[test]
21719    fn issue_430_jfm_to_adf_media_in_ordered_item() {
21720        // Parse JFM directly: image syntax on the first line of an ordered list item.
21721        let md = "1. ![alt text](https://example.com/photo.jpg)\n";
21722        let doc = markdown_to_adf(md).unwrap();
21723
21724        let list = &doc.content[0];
21725        assert_eq!(list.node_type, "orderedList");
21726        let item = &list.content.as_ref().unwrap()[0];
21727        let ms = &item.content.as_ref().unwrap()[0];
21728        assert_eq!(
21729            ms.node_type, "mediaSingle",
21730            "expected mediaSingle, got {}",
21731            ms.node_type
21732        );
21733    }
21734
21735    #[test]
21736    fn issue_430_media_then_paragraph_in_bullet_list_roundtrip() {
21737        // listItem with mediaSingle as first child followed by a paragraph.
21738        // Exercises the sub_lines non-empty path when first_node is mediaSingle.
21739        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21740          {"type":"listItem","content":[
21741            {"type":"mediaSingle","attrs":{"layout":"center"},
21742             "content":[{"type":"media","attrs":{"type":"file","id":"img-first","collection":"col-1","height":50,"width":100}}]},
21743            {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
21744          ]}
21745        ]}]}"#;
21746        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21747        let md = adf_to_markdown(&doc).unwrap();
21748        let rt = markdown_to_adf(&md).unwrap();
21749
21750        let item = &rt.content[0].content.as_ref().unwrap()[0];
21751        let children = item.content.as_ref().unwrap();
21752        assert_eq!(children.len(), 2, "expected 2 children in listItem");
21753        assert_eq!(children[0].node_type, "mediaSingle");
21754        let media = &children[0].content.as_ref().unwrap()[0];
21755        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-first");
21756        assert_eq!(children[1].node_type, "paragraph");
21757    }
21758
21759    #[test]
21760    fn issue_430_media_then_paragraph_in_ordered_list_roundtrip() {
21761        // Same as above but for ordered lists.
21762        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
21763          {"type":"listItem","content":[
21764            {"type":"mediaSingle","attrs":{"layout":"center"},
21765             "content":[{"type":"media","attrs":{"type":"file","id":"img-ord","collection":"col-2","height":60,"width":120}}]},
21766            {"type":"paragraph","content":[{"type":"text","text":"Description"}]}
21767          ]}
21768        ]}]}"#;
21769        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21770        let md = adf_to_markdown(&doc).unwrap();
21771        let rt = markdown_to_adf(&md).unwrap();
21772
21773        let item = &rt.content[0].content.as_ref().unwrap()[0];
21774        let children = item.content.as_ref().unwrap();
21775        assert_eq!(children.len(), 2, "expected 2 children in listItem");
21776        assert_eq!(children[0].node_type, "mediaSingle");
21777        assert_eq!(children[1].node_type, "paragraph");
21778    }
21779
21780    #[test]
21781    fn issue_430_external_media_with_width_type_roundtrip() {
21782        // External image with widthType attr must survive round-trip.
21783        let adf_json = r#"{"version":1,"type":"doc","content":[{
21784          "type":"mediaSingle",
21785          "attrs":{"layout":"wide","width":800,"widthType":"pixel"},
21786          "content":[{
21787            "type":"media",
21788            "attrs":{"type":"external","url":"https://example.com/photo.png","alt":"wide photo"}
21789          }]
21790        }]}"#;
21791        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21792        let md = adf_to_markdown(&doc).unwrap();
21793        assert!(
21794            md.contains("widthType=pixel"),
21795            "expected widthType=pixel in markdown, got: {md}"
21796        );
21797        let rt = markdown_to_adf(&md).unwrap();
21798        let ms = &rt.content[0];
21799        assert_eq!(ms.node_type, "mediaSingle");
21800        let ms_attrs = ms.attrs.as_ref().unwrap();
21801        assert_eq!(ms_attrs["widthType"], "pixel");
21802        assert_eq!(ms_attrs["width"], 800);
21803        assert_eq!(ms_attrs["layout"], "wide");
21804    }
21805
21806    // ── Issue #490: mediaSingle after hardBreak in listItem ─────
21807
21808    #[test]
21809    fn issue_490_paragraph_with_hardbreak_then_media_single_roundtrip() {
21810        // Reproducer from issue #490: paragraph with trailing hardBreak
21811        // followed by mediaSingle inside a listItem.
21812        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21813          {"type":"listItem","content":[
21814            {"type":"paragraph","content":[
21815              {"type":"text","text":"Item with image:"},
21816              {"type":"hardBreak"}
21817            ]},
21818            {"type":"mediaSingle","attrs":{"layout":"center","width":400,"widthType":"pixel"},
21819             "content":[{"type":"media","attrs":{
21820               "id":"aabbccdd-1234-5678-abcd-aabbccdd1234",
21821               "type":"file",
21822               "collection":"contentId-123456",
21823               "width":800,
21824               "height":600
21825             }}]}
21826          ]}
21827        ]}]}"#;
21828        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21829        let md = adf_to_markdown(&doc).unwrap();
21830        let rt = markdown_to_adf(&md).unwrap();
21831
21832        let item = &rt.content[0].content.as_ref().unwrap()[0];
21833        let children = item.content.as_ref().unwrap();
21834        assert_eq!(children.len(), 2, "expected 2 children in listItem");
21835        assert_eq!(children[0].node_type, "paragraph");
21836        assert_eq!(
21837            children[1].node_type, "mediaSingle",
21838            "expected mediaSingle, got {:?}",
21839            children[1].node_type
21840        );
21841        let media = &children[1].content.as_ref().unwrap()[0];
21842        let m_attrs = media.attrs.as_ref().unwrap();
21843        assert_eq!(m_attrs["id"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
21844        assert_eq!(m_attrs["collection"], "contentId-123456");
21845        assert_eq!(m_attrs["height"], 600);
21846        assert_eq!(m_attrs["width"], 800);
21847    }
21848
21849    #[test]
21850    fn issue_490_paragraph_with_hardbreak_then_media_single_ordered_list() {
21851        // Same scenario but in an ordered list.
21852        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
21853          {"type":"listItem","content":[
21854            {"type":"paragraph","content":[
21855              {"type":"text","text":"Step with screenshot:"},
21856              {"type":"hardBreak"}
21857            ]},
21858            {"type":"mediaSingle","attrs":{"layout":"center"},
21859             "content":[{"type":"media","attrs":{
21860               "id":"ord-media-id","type":"file","collection":"col-ord","width":640,"height":480
21861             }}]}
21862          ]}
21863        ]}]}"#;
21864        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21865        let md = adf_to_markdown(&doc).unwrap();
21866        let rt = markdown_to_adf(&md).unwrap();
21867
21868        let item = &rt.content[0].content.as_ref().unwrap()[0];
21869        let children = item.content.as_ref().unwrap();
21870        assert_eq!(children.len(), 2, "expected 2 children in listItem");
21871        assert_eq!(children[0].node_type, "paragraph");
21872        assert_eq!(children[1].node_type, "mediaSingle");
21873        let media = &children[1].content.as_ref().unwrap()[0];
21874        assert_eq!(media.attrs.as_ref().unwrap()["id"], "ord-media-id");
21875    }
21876
21877    #[test]
21878    fn issue_490_hardbreak_continuation_does_not_swallow_media_line() {
21879        // Directly tests that collect_hardbreak_continuations stops before
21880        // an indented mediaSingle line.
21881        let md = "- Item with image:\\\n  ![](){type=file id=test-490 collection=col height=100 width=200}\n";
21882        let doc = markdown_to_adf(md).unwrap();
21883
21884        let item = &doc.content[0].content.as_ref().unwrap()[0];
21885        let children = item.content.as_ref().unwrap();
21886        assert_eq!(children.len(), 2, "expected 2 children in listItem");
21887        assert_eq!(children[0].node_type, "paragraph");
21888        assert_eq!(
21889            children[1].node_type, "mediaSingle",
21890            "expected mediaSingle as second child, got {:?}",
21891            children[1].node_type
21892        );
21893        let media = &children[1].content.as_ref().unwrap()[0];
21894        assert_eq!(media.attrs.as_ref().unwrap()["id"], "test-490");
21895    }
21896
21897    #[test]
21898    fn issue_490_hardbreak_continuation_still_works_for_text() {
21899        // Ensure regular hardBreak continuations still work after the fix.
21900        let md = "- first line\\\n  second line\n";
21901        let doc = markdown_to_adf(md).unwrap();
21902
21903        let item = &doc.content[0].content.as_ref().unwrap()[0];
21904        let children = item.content.as_ref().unwrap();
21905        assert_eq!(
21906            children.len(),
21907            1,
21908            "expected 1 child (paragraph) in listItem"
21909        );
21910        assert_eq!(children[0].node_type, "paragraph");
21911        let inlines = children[0].content.as_ref().unwrap();
21912        // Should contain: text("first line"), hardBreak, text("second line")
21913        assert_eq!(inlines.len(), 3);
21914        assert_eq!(inlines[0].node_type, "text");
21915        assert_eq!(inlines[1].node_type, "hardBreak");
21916        assert_eq!(inlines[2].node_type, "text");
21917    }
21918
21919    #[test]
21920    fn issue_490_external_media_after_hardbreak_roundtrip() {
21921        // External image (URL-based) after a paragraph with hardBreak.
21922        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21923          {"type":"listItem","content":[
21924            {"type":"paragraph","content":[
21925              {"type":"text","text":"See image:"},
21926              {"type":"hardBreak"}
21927            ]},
21928            {"type":"mediaSingle","attrs":{"layout":"center"},
21929             "content":[{"type":"media","attrs":{
21930               "type":"external","url":"https://example.com/photo.png","alt":"photo"
21931             }}]}
21932          ]}
21933        ]}]}"#;
21934        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21935        let md = adf_to_markdown(&doc).unwrap();
21936        let rt = markdown_to_adf(&md).unwrap();
21937
21938        let item = &rt.content[0].content.as_ref().unwrap()[0];
21939        let children = item.content.as_ref().unwrap();
21940        assert_eq!(children.len(), 2);
21941        assert_eq!(children[0].node_type, "paragraph");
21942        assert_eq!(children[1].node_type, "mediaSingle");
21943        let media = &children[1].content.as_ref().unwrap()[0];
21944        let m_attrs = media.attrs.as_ref().unwrap();
21945        assert_eq!(m_attrs["url"], "https://example.com/photo.png");
21946    }
21947
21948    #[test]
21949    fn issue_490_multiple_hardbreaks_then_media_single() {
21950        // Paragraph with multiple hardBreaks, then mediaSingle.
21951        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21952          {"type":"listItem","content":[
21953            {"type":"paragraph","content":[
21954              {"type":"text","text":"line one"},
21955              {"type":"hardBreak"},
21956              {"type":"text","text":"line two"},
21957              {"type":"hardBreak"}
21958            ]},
21959            {"type":"mediaSingle","attrs":{"layout":"center"},
21960             "content":[{"type":"media","attrs":{
21961               "type":"file","id":"multi-hb","collection":"col-m","width":320,"height":240
21962             }}]}
21963          ]}
21964        ]}]}"#;
21965        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21966        let md = adf_to_markdown(&doc).unwrap();
21967        let rt = markdown_to_adf(&md).unwrap();
21968
21969        let item = &rt.content[0].content.as_ref().unwrap()[0];
21970        let children = item.content.as_ref().unwrap();
21971        assert_eq!(children.len(), 2, "expected paragraph + mediaSingle");
21972        assert_eq!(children[0].node_type, "paragraph");
21973        assert_eq!(children[1].node_type, "mediaSingle");
21974        let media = &children[1].content.as_ref().unwrap()[0];
21975        assert_eq!(media.attrs.as_ref().unwrap()["id"], "multi-hb");
21976    }
21977
21978    // ── Issue #525: listItem localId dropped when content includes mediaSingle ──
21979
21980    #[test]
21981    fn issue_525_listitem_localid_with_mediasingle_roundtrip() {
21982        // Exact reproducer from issue #525: listItem with UUID localId whose
21983        // content includes mediaSingle + paragraph + nested bulletList.
21984        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","attrs":{"localId":"aabbccdd-1234-5678-abcd-000000000001"},"content":[{"type":"mediaSingle","attrs":{"layout":"center","width":100,"widthType":"pixel"},"content":[{"type":"media","attrs":{"id":"aabbccdd-1234-5678-abcd-000000000002","type":"file","collection":"test-collection","height":100,"width":100}}]},{"type":"paragraph","content":[{"type":"text","text":"some text"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"nested item"}]}]}]}]}]}]}"#;
21985        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21986        let md = adf_to_markdown(&doc).unwrap();
21987        let rt = markdown_to_adf(&md).unwrap();
21988
21989        let list = &rt.content[0];
21990        assert_eq!(list.node_type, "bulletList");
21991        let item = &list.content.as_ref().unwrap()[0];
21992        // The localId must be preserved on the listItem.
21993        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
21994        assert_eq!(
21995            item_attrs["localId"], "aabbccdd-1234-5678-abcd-000000000001",
21996            "listItem localId must survive round-trip"
21997        );
21998        let children = item.content.as_ref().unwrap();
21999        assert_eq!(
22000            children.len(),
22001            3,
22002            "expected mediaSingle + paragraph + bulletList"
22003        );
22004        assert_eq!(children[0].node_type, "mediaSingle");
22005        assert_eq!(children[1].node_type, "paragraph");
22006        assert_eq!(children[2].node_type, "bulletList");
22007    }
22008
22009    #[test]
22010    fn issue_525_listitem_localid_with_mediasingle_only() {
22011        // Minimal case: listItem with localId whose sole child is mediaSingle.
22012        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
22013          {"type":"listItem","attrs":{"localId":"li-media-only"},"content":[
22014            {"type":"mediaSingle","attrs":{"layout":"center"},
22015             "content":[{"type":"media","attrs":{"type":"file","id":"m-001","collection":"c1","height":50,"width":100}}]}
22016          ]}
22017        ]}]}"#;
22018        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22019        let md = adf_to_markdown(&doc).unwrap();
22020        let rt = markdown_to_adf(&md).unwrap();
22021
22022        let item = &rt.content[0].content.as_ref().unwrap()[0];
22023        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
22024        assert_eq!(
22025            item_attrs["localId"], "li-media-only",
22026            "listItem localId must survive when sole child is mediaSingle"
22027        );
22028        assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
22029    }
22030
22031    #[test]
22032    fn issue_525_listitem_localid_with_external_media() {
22033        // External image (URL-based) as first child with listItem localId.
22034        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
22035          {"type":"listItem","attrs":{"localId":"li-ext-media"},"content":[
22036            {"type":"mediaSingle","attrs":{"layout":"center"},
22037             "content":[{"type":"media","attrs":{"type":"external","url":"https://example.com/img.png","alt":"photo"}}]}
22038          ]}
22039        ]}]}"#;
22040        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22041        let md = adf_to_markdown(&doc).unwrap();
22042        let rt = markdown_to_adf(&md).unwrap();
22043
22044        let item = &rt.content[0].content.as_ref().unwrap()[0];
22045        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
22046        assert_eq!(
22047            item_attrs["localId"], "li-ext-media",
22048            "listItem localId must survive with external mediaSingle"
22049        );
22050    }
22051
22052    #[test]
22053    fn issue_525_listitem_localid_with_mediasingle_in_ordered_list() {
22054        // Same bug in an ordered list.
22055        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
22056          {"type":"listItem","attrs":{"localId":"li-ord-media"},"content":[
22057            {"type":"mediaSingle","attrs":{"layout":"center","width":200,"widthType":"pixel"},
22058             "content":[{"type":"media","attrs":{"type":"file","id":"ord-m-001","collection":"col-ord","height":80,"width":160}}]},
22059            {"type":"paragraph","content":[{"type":"text","text":"ordered item text"}]}
22060          ]}
22061        ]}]}"#;
22062        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22063        let md = adf_to_markdown(&doc).unwrap();
22064        let rt = markdown_to_adf(&md).unwrap();
22065
22066        let item = &rt.content[0].content.as_ref().unwrap()[0];
22067        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
22068        assert_eq!(
22069            item_attrs["localId"], "li-ord-media",
22070            "listItem localId must survive in ordered list with mediaSingle"
22071        );
22072        let children = item.content.as_ref().unwrap();
22073        assert_eq!(children[0].node_type, "mediaSingle");
22074        assert_eq!(children[1].node_type, "paragraph");
22075    }
22076
22077    #[test]
22078    fn issue_525_jfm_localid_on_mediasingle_line_parses_correctly() {
22079        // Verify JFM→ADF: trailing {localId=...} on a mediaSingle line
22080        // is assigned to the listItem, not the media node.
22081        let md = "- ![](){type=file id=test-525 collection=col height=100 width=200 mediaWidth=100 widthType=pixel} {localId=li-jfm-525}\n";
22082        let doc = markdown_to_adf(md).unwrap();
22083
22084        let item = &doc.content[0].content.as_ref().unwrap()[0];
22085        let item_attrs = item
22086            .attrs
22087            .as_ref()
22088            .expect("listItem attrs must be present from JFM");
22089        assert_eq!(item_attrs["localId"], "li-jfm-525");
22090        assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
22091    }
22092
22093    #[test]
22094    fn issue_525_encoding_emits_localid_on_mediasingle_line() {
22095        // Verify the ADF→JFM encoding: localId appears on the same line
22096        // as the mediaSingle image syntax.
22097        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
22098          {"type":"listItem","attrs":{"localId":"li-emit-check"},"content":[
22099            {"type":"mediaSingle","attrs":{"layout":"center"},
22100             "content":[{"type":"media","attrs":{"type":"file","id":"m-emit","collection":"c-emit","height":10,"width":20}}]}
22101          ]}
22102        ]}]}"#;
22103        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22104        let md = adf_to_markdown(&doc).unwrap();
22105        assert!(
22106            md.contains("{localId=li-emit-check}"),
22107            "expected localId in JFM output, got: {md}"
22108        );
22109        // The localId must be on the same line as the image
22110        for line in md.lines() {
22111            if line.contains("![") {
22112                assert!(
22113                    line.contains("localId=li-emit-check"),
22114                    "localId must be on the same line as the image: {line}"
22115                );
22116            }
22117        }
22118    }
22119
22120    // ── Placeholder node tests ────────────────────────────────────
22121
22122    #[test]
22123    fn adf_placeholder_to_markdown() {
22124        let doc = AdfDocument {
22125            version: 1,
22126            doc_type: "doc".to_string(),
22127            content: vec![AdfNode::paragraph(vec![AdfNode::placeholder(
22128                "Type something here",
22129            )])],
22130        };
22131        let md = adf_to_markdown(&doc).unwrap();
22132        assert!(
22133            md.contains(":placeholder[Type something here]"),
22134            "expected :placeholder directive, got: {md}"
22135        );
22136    }
22137
22138    #[test]
22139    fn markdown_placeholder_to_adf() {
22140        let doc = markdown_to_adf("Before :placeholder[Enter name] after").unwrap();
22141        let content = doc.content[0].content.as_ref().unwrap();
22142        assert_eq!(content[1].node_type, "placeholder");
22143        let attrs = content[1].attrs.as_ref().unwrap();
22144        assert_eq!(attrs["text"], "Enter name");
22145    }
22146
22147    #[test]
22148    fn placeholder_round_trip() {
22149        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"placeholder","attrs":{"text":"Type something here"}}]}]}"#;
22150        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22151        let md = adf_to_markdown(&doc).unwrap();
22152        let rt = markdown_to_adf(&md).unwrap();
22153        let content = rt.content[0].content.as_ref().unwrap();
22154        assert_eq!(content.len(), 1);
22155        assert_eq!(content[0].node_type, "placeholder");
22156        let attrs = content[0].attrs.as_ref().unwrap();
22157        assert_eq!(attrs["text"], "Type something here");
22158    }
22159
22160    #[test]
22161    fn placeholder_empty_text() {
22162        let doc = AdfDocument {
22163            version: 1,
22164            doc_type: "doc".to_string(),
22165            content: vec![AdfNode::paragraph(vec![AdfNode::placeholder("")])],
22166        };
22167        let md = adf_to_markdown(&doc).unwrap();
22168        assert!(
22169            md.contains(":placeholder[]"),
22170            "expected empty placeholder directive, got: {md}"
22171        );
22172        let rt = markdown_to_adf(&md).unwrap();
22173        let content = rt.content[0].content.as_ref().unwrap();
22174        assert_eq!(content[0].node_type, "placeholder");
22175        assert_eq!(content[0].attrs.as_ref().unwrap()["text"], "");
22176    }
22177
22178    #[test]
22179    fn placeholder_with_surrounding_text() {
22180        let md = "Click :placeholder[here] to continue\n";
22181        let doc = markdown_to_adf(md).unwrap();
22182        let content = doc.content[0].content.as_ref().unwrap();
22183        assert_eq!(content[0].text.as_deref(), Some("Click "));
22184        assert_eq!(content[1].node_type, "placeholder");
22185        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "here");
22186        assert_eq!(content[2].text.as_deref(), Some(" to continue"));
22187    }
22188
22189    #[test]
22190    fn placeholder_missing_attrs() {
22191        // Placeholder node with no attrs should not panic
22192        let doc = AdfDocument {
22193            version: 1,
22194            doc_type: "doc".to_string(),
22195            content: vec![AdfNode::paragraph(vec![AdfNode {
22196                node_type: "placeholder".to_string(),
22197                attrs: None,
22198                content: None,
22199                text: None,
22200                marks: None,
22201                local_id: None,
22202                parameters: None,
22203            }])],
22204        };
22205        let md = adf_to_markdown(&doc).unwrap();
22206        // With no attrs, nothing is emitted for the placeholder
22207        assert!(!md.contains("placeholder"));
22208    }
22209
22210    // Issue #446: mention in table+list loses id and misplaces localId
22211    #[test]
22212    fn mention_in_table_bullet_list_preserves_id_and_local_id() {
22213        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colspan":1,"colwidth":[200],"rowspan":1},"content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"prefix text "},{"type":"mention","attrs":{"id":"aabbccdd11223344aabbccdd","localId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","text":"@Alice Example"}},{"type":"text","text":" "}]}]}]}]}]}]}]}"#;
22214        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22215        let md = adf_to_markdown(&doc).unwrap();
22216        let rt = markdown_to_adf(&md).unwrap();
22217
22218        // Navigate: doc → table → tableRow → tableCell → bulletList → listItem → paragraph
22219        let cell = &rt.content[0].content.as_ref().unwrap()[0]
22220            .content
22221            .as_ref()
22222            .unwrap()[0];
22223        let list = &cell.content.as_ref().unwrap()[0];
22224        let list_item = &list.content.as_ref().unwrap()[0];
22225
22226        // listItem must NOT have a localId attribute
22227        assert!(
22228            list_item
22229                .attrs
22230                .as_ref()
22231                .and_then(|a| a.get("localId"))
22232                .is_none(),
22233            "localId should stay on the mention, not the listItem"
22234        );
22235
22236        let para = &list_item.content.as_ref().unwrap()[0];
22237        let inlines = para.content.as_ref().unwrap();
22238
22239        // Should have: text("prefix text "), mention, text(" ")
22240        assert_eq!(inlines.len(), 3, "expected 3 inline nodes, got {inlines:?}");
22241
22242        assert_eq!(inlines[0].node_type, "text");
22243        assert_eq!(inlines[0].text.as_deref(), Some("prefix text "));
22244
22245        assert_eq!(inlines[1].node_type, "mention");
22246        let mention_attrs = inlines[1].attrs.as_ref().unwrap();
22247        assert_eq!(
22248            mention_attrs["id"], "aabbccdd11223344aabbccdd",
22249            "mention id must be preserved"
22250        );
22251        assert_eq!(
22252            mention_attrs["localId"], "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
22253            "mention localId must be preserved"
22254        );
22255        assert_eq!(mention_attrs["text"], "@Alice Example");
22256
22257        assert_eq!(inlines[2].node_type, "text");
22258        assert_eq!(inlines[2].text.as_deref(), Some(" "));
22259    }
22260
22261    #[test]
22262    fn mention_in_bullet_list_preserves_id_and_local_id() {
22263        // Same bug outside of a table context
22264        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","localId":"11111111-2222-3333-4444-555555555555","text":"@Bob"}},{"type":"text","text":" "}]}]}]}]}"#;
22265        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22266        let md = adf_to_markdown(&doc).unwrap();
22267        let rt = markdown_to_adf(&md).unwrap();
22268
22269        let list_item = &rt.content[0].content.as_ref().unwrap()[0];
22270        assert!(
22271            list_item
22272                .attrs
22273                .as_ref()
22274                .and_then(|a| a.get("localId"))
22275                .is_none(),
22276            "localId should stay on the mention, not the listItem"
22277        );
22278
22279        let para = &list_item.content.as_ref().unwrap()[0];
22280        let inlines = para.content.as_ref().unwrap();
22281        assert_eq!(inlines[0].node_type, "mention");
22282        let mention_attrs = inlines[0].attrs.as_ref().unwrap();
22283        assert_eq!(mention_attrs["id"], "user123");
22284        assert_eq!(
22285            mention_attrs["localId"],
22286            "11111111-2222-3333-4444-555555555555"
22287        );
22288    }
22289
22290    #[test]
22291    fn mention_in_ordered_list_preserves_id_and_local_id() {
22292        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"see "},{"type":"mention","attrs":{"id":"xyz","localId":"aaaa-bbbb","text":"@Carol"}}]}]}]}]}"#;
22293        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22294        let md = adf_to_markdown(&doc).unwrap();
22295        let rt = markdown_to_adf(&md).unwrap();
22296
22297        let list_item = &rt.content[0].content.as_ref().unwrap()[0];
22298        assert!(
22299            list_item
22300                .attrs
22301                .as_ref()
22302                .and_then(|a| a.get("localId"))
22303                .is_none(),
22304            "localId should stay on the mention, not the listItem"
22305        );
22306
22307        let para = &list_item.content.as_ref().unwrap()[0];
22308        let inlines = para.content.as_ref().unwrap();
22309        assert_eq!(inlines[1].node_type, "mention");
22310        let mention_attrs = inlines[1].attrs.as_ref().unwrap();
22311        assert_eq!(mention_attrs["id"], "xyz");
22312        assert_eq!(mention_attrs["localId"], "aaaa-bbbb");
22313    }
22314
22315    #[test]
22316    fn list_item_own_local_id_with_mention_both_preserved() {
22317        // When a listItem has its own localId AND contains a mention with localId,
22318        // both should be preserved independently.
22319        let md = "- hello :mention[@Eve]{id=e1 localId=mention-lid} {localId=item-lid}\n";
22320        let doc = markdown_to_adf(md).unwrap();
22321        let list_item = &doc.content[0].content.as_ref().unwrap()[0];
22322
22323        // listItem should have its own localId
22324        let item_attrs = list_item.attrs.as_ref().unwrap();
22325        assert_eq!(item_attrs["localId"], "item-lid");
22326
22327        // mention should have its own localId
22328        let para = &list_item.content.as_ref().unwrap()[0];
22329        let inlines = para.content.as_ref().unwrap();
22330        let mention = inlines.iter().find(|n| n.node_type == "mention").unwrap();
22331        let mention_attrs = mention.attrs.as_ref().unwrap();
22332        assert_eq!(mention_attrs["id"], "e1");
22333        assert_eq!(mention_attrs["localId"], "mention-lid");
22334    }
22335
22336    #[test]
22337    fn extract_trailing_local_id_ignores_directive_attrs() {
22338        // Directly test the helper: a line ending with a directive's {…}
22339        // should NOT be treated as a trailing localId.
22340        let line = "text :mention[@X]{id=abc localId=uuid}";
22341        let (text, lid, plid) = extract_trailing_local_id(line);
22342        assert_eq!(text, line, "text should be unchanged");
22343        assert!(
22344            lid.is_none(),
22345            "should not extract localId from directive attrs"
22346        );
22347        assert!(plid.is_none());
22348    }
22349
22350    #[test]
22351    fn extract_trailing_local_id_matches_standalone_block() {
22352        // A standalone trailing {localId=…} separated by whitespace should still work.
22353        let line = "some text {localId=abc-123}";
22354        let (text, lid, plid) = extract_trailing_local_id(line);
22355        assert_eq!(text, "some text");
22356        assert_eq!(lid.as_deref(), Some("abc-123"));
22357        assert!(plid.is_none());
22358    }
22359
22360    // --- Issue #454: literal newline in text node inside listItem paragraph ---
22361
22362    #[test]
22363    fn newline_in_text_node_roundtrips_in_bullet_list() {
22364        // A text node containing a literal \n inside a bullet list item
22365        // must round-trip as a single text node with the embedded newline
22366        // preserved, not split into multiple paragraphs or hardBreak nodes.
22367        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Run these commands:"},{"type":"hardBreak"},{"type":"text","text":"first command\nsecond command"}]}]}]}]}"#;
22368        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22369        let md = adf_to_markdown(&doc).unwrap();
22370        let rt = markdown_to_adf(&md).unwrap();
22371
22372        // Should still be a single bulletList with one listItem
22373        assert_eq!(rt.content.len(), 1);
22374        let list = &rt.content[0];
22375        assert_eq!(list.node_type, "bulletList");
22376        let items = list.content.as_ref().unwrap();
22377        assert_eq!(items.len(), 1);
22378
22379        // The listItem should have exactly one paragraph child
22380        let item_content = items[0].content.as_ref().unwrap();
22381        assert_eq!(
22382            item_content.len(),
22383            1,
22384            "listItem should have exactly one paragraph"
22385        );
22386        assert_eq!(item_content[0].node_type, "paragraph");
22387
22388        // The embedded newline must survive as a single text node
22389        let inlines = item_content[0].content.as_ref().unwrap();
22390        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
22391        assert_eq!(
22392            types,
22393            vec!["text", "hardBreak", "text"],
22394            "embedded newline should stay in a single text node, not produce extra hardBreaks"
22395        );
22396        assert_eq!(
22397            inlines[2].text.as_deref(),
22398            Some("first command\nsecond command")
22399        );
22400    }
22401
22402    #[test]
22403    fn newline_in_text_node_roundtrips_in_ordered_list() {
22404        // Same as above but in an ordered list.
22405        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"first\nsecond"}]}]}]}]}"#;
22406        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22407        let md = adf_to_markdown(&doc).unwrap();
22408        let rt = markdown_to_adf(&md).unwrap();
22409
22410        let list = &rt.content[0];
22411        assert_eq!(list.node_type, "orderedList");
22412        let items = list.content.as_ref().unwrap();
22413        assert_eq!(items.len(), 1);
22414
22415        let item_content = items[0].content.as_ref().unwrap();
22416        assert_eq!(item_content.len(), 1);
22417        assert_eq!(item_content[0].node_type, "paragraph");
22418
22419        let inlines = item_content[0].content.as_ref().unwrap();
22420        assert_eq!(inlines.len(), 1);
22421        assert_eq!(inlines[0].node_type, "text");
22422        assert_eq!(inlines[0].text.as_deref(), Some("first\nsecond"));
22423    }
22424
22425    #[test]
22426    fn newline_in_text_node_roundtrips_in_paragraph() {
22427        // A text node with \n in a top-level paragraph should render as
22428        // escaped \n and round-trip back to a single text node.
22429        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello\nworld"}]}]}"#;
22430        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22431        let md = adf_to_markdown(&doc).unwrap();
22432        assert!(
22433            md.contains("hello\\nworld"),
22434            "newline in text node should render as escaped \\n: {md:?}"
22435        );
22436
22437        let rt = markdown_to_adf(&md).unwrap();
22438        let inlines = rt.content[0].content.as_ref().unwrap();
22439        assert_eq!(inlines.len(), 1);
22440        assert_eq!(inlines[0].text.as_deref(), Some("hello\nworld"));
22441    }
22442
22443    #[test]
22444    fn multiple_newlines_in_text_node_roundtrip() {
22445        // Multiple \n characters should each round-trip within the same text node.
22446        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a\nb\nc"}]}]}]}]}"#;
22447        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22448        let md = adf_to_markdown(&doc).unwrap();
22449        let rt = markdown_to_adf(&md).unwrap();
22450
22451        let item_content = rt.content[0].content.as_ref().unwrap()[0]
22452            .content
22453            .as_ref()
22454            .unwrap();
22455        assert_eq!(item_content.len(), 1);
22456
22457        let inlines = item_content[0].content.as_ref().unwrap();
22458        assert_eq!(inlines.len(), 1);
22459        assert_eq!(inlines[0].text.as_deref(), Some("a\nb\nc"));
22460    }
22461
22462    #[test]
22463    fn newline_in_marked_text_node_roundtrips() {
22464        // A bold text node with \n should round-trip preserving both
22465        // the marks and the embedded newline.
22466        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold\ntext","marks":[{"type":"strong"}]}]}]}"#;
22467        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22468        let md = adf_to_markdown(&doc).unwrap();
22469        assert!(
22470            md.contains("**bold\\ntext**"),
22471            "bold text with embedded newline should stay in one marked run: {md:?}"
22472        );
22473
22474        let rt = markdown_to_adf(&md).unwrap();
22475        let inlines = rt.content[0].content.as_ref().unwrap();
22476        assert_eq!(inlines.len(), 1);
22477        assert_eq!(inlines[0].text.as_deref(), Some("bold\ntext"));
22478        assert!(inlines[0]
22479            .marks
22480            .as_ref()
22481            .unwrap()
22482            .iter()
22483            .any(|m| m.mark_type == "strong"));
22484    }
22485
22486    #[test]
22487    fn trailing_newline_in_text_node_roundtrips() {
22488        // A text node ending with \n should round-trip preserving the
22489        // trailing newline.
22490        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"trailing\n"}]}]}"#;
22491        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22492        let md = adf_to_markdown(&doc).unwrap();
22493        assert!(
22494            md.contains("trailing\\n"),
22495            "trailing newline should be escaped: {md:?}"
22496        );
22497
22498        let rt = markdown_to_adf(&md).unwrap();
22499        let inlines = rt.content[0].content.as_ref().unwrap();
22500        assert_eq!(inlines.len(), 1);
22501        assert_eq!(inlines[0].text.as_deref(), Some("trailing\n"));
22502    }
22503
22504    #[test]
22505    fn hardbreak_and_embedded_newline_are_distinct() {
22506        // A hardBreak node and an embedded \n in a text node must not be
22507        // conflated — each must round-trip to its original form.
22508        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"before"},{"type":"hardBreak"},{"type":"text","text":"mid\ndle"},{"type":"hardBreak"},{"type":"text","text":"after"}]}]}"#;
22509        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22510        let md = adf_to_markdown(&doc).unwrap();
22511        let rt = markdown_to_adf(&md).unwrap();
22512
22513        let inlines = rt.content[0].content.as_ref().unwrap();
22514        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
22515        assert_eq!(
22516            types,
22517            vec!["text", "hardBreak", "text", "hardBreak", "text"]
22518        );
22519        assert_eq!(inlines[0].text.as_deref(), Some("before"));
22520        assert_eq!(inlines[2].text.as_deref(), Some("mid\ndle"));
22521        assert_eq!(inlines[4].text.as_deref(), Some("after"));
22522    }
22523
22524    // ---- Issue #472 tests ----
22525
22526    #[test]
22527    fn issue_472_bullet_list_trailing_hardbreak_roundtrips() {
22528        // Issue #472: trailing hardBreak at end of listItem paragraph must
22529        // not split the parent bulletList on round-trip.
22530        let adf_json = r#"{"version":1,"type":"doc","content":[
22531          {"type":"bulletList","content":[
22532            {"type":"listItem","content":[
22533              {"type":"paragraph","content":[
22534                {"type":"text","text":"First item"},
22535                {"type":"hardBreak"}
22536              ]}
22537            ]},
22538            {"type":"listItem","content":[
22539              {"type":"paragraph","content":[
22540                {"type":"text","text":"Second item"}
22541              ]}
22542            ]}
22543          ]}
22544        ]}"#;
22545        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22546        let md = adf_to_markdown(&doc).unwrap();
22547        let rt = markdown_to_adf(&md).unwrap();
22548
22549        // Must remain a single bulletList
22550        assert_eq!(
22551            rt.content.len(),
22552            1,
22553            "Should be 1 block (bulletList), got {}",
22554            rt.content.len()
22555        );
22556        assert_eq!(rt.content[0].node_type, "bulletList");
22557        let items = rt.content[0].content.as_ref().unwrap();
22558        assert_eq!(
22559            items.len(),
22560            2,
22561            "Should have 2 listItems, got {}",
22562            items.len()
22563        );
22564
22565        // First item: text + hardBreak (trailing)
22566        let p1 = items[0].content.as_ref().unwrap()[0]
22567            .content
22568            .as_ref()
22569            .unwrap();
22570        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
22571        assert_eq!(types1, vec!["text", "hardBreak"]);
22572        assert_eq!(p1[0].text.as_deref(), Some("First item"));
22573
22574        // Second item: text only
22575        let p2 = items[1].content.as_ref().unwrap()[0]
22576            .content
22577            .as_ref()
22578            .unwrap();
22579        assert_eq!(p2[0].text.as_deref(), Some("Second item"));
22580    }
22581
22582    #[test]
22583    fn issue_472_ordered_list_trailing_hardbreak_roundtrips() {
22584        // Ordered list variant of issue #472.
22585        let adf_json = r#"{"version":1,"type":"doc","content":[
22586          {"type":"orderedList","attrs":{"order":1},"content":[
22587            {"type":"listItem","content":[
22588              {"type":"paragraph","content":[
22589                {"type":"text","text":"Alpha"},
22590                {"type":"hardBreak"}
22591              ]}
22592            ]},
22593            {"type":"listItem","content":[
22594              {"type":"paragraph","content":[
22595                {"type":"text","text":"Beta"}
22596              ]}
22597            ]}
22598          ]}
22599        ]}"#;
22600        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22601        let md = adf_to_markdown(&doc).unwrap();
22602        let rt = markdown_to_adf(&md).unwrap();
22603
22604        assert_eq!(rt.content.len(), 1);
22605        assert_eq!(rt.content[0].node_type, "orderedList");
22606        let items = rt.content[0].content.as_ref().unwrap();
22607        assert_eq!(items.len(), 2);
22608
22609        let p1 = items[0].content.as_ref().unwrap()[0]
22610            .content
22611            .as_ref()
22612            .unwrap();
22613        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
22614        assert_eq!(types1, vec!["text", "hardBreak"]);
22615        assert_eq!(p1[0].text.as_deref(), Some("Alpha"));
22616    }
22617
22618    #[test]
22619    fn issue_472_trailing_hardbreak_jfm_no_blank_line() {
22620        // The rendered JFM must not contain a blank line after the
22621        // trailing hardBreak — that would split the list.
22622        let adf_json = r#"{"version":1,"type":"doc","content":[
22623          {"type":"bulletList","content":[
22624            {"type":"listItem","content":[
22625              {"type":"paragraph","content":[
22626                {"type":"text","text":"Hello"},
22627                {"type":"hardBreak"}
22628              ]}
22629            ]},
22630            {"type":"listItem","content":[
22631              {"type":"paragraph","content":[
22632                {"type":"text","text":"World"}
22633              ]}
22634            ]}
22635          ]}
22636        ]}"#;
22637        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22638        let md = adf_to_markdown(&doc).unwrap();
22639
22640        // Should produce "- Hello\\n- World\n" (no blank line between items).
22641        assert_eq!(md, "- Hello\\\n- World\n");
22642    }
22643
22644    #[test]
22645    fn issue_472_multiple_trailing_hardbreaks_roundtrip() {
22646        // Multiple trailing hardBreaks at the end of a listItem paragraph.
22647        let adf_json = r#"{"version":1,"type":"doc","content":[
22648          {"type":"bulletList","content":[
22649            {"type":"listItem","content":[
22650              {"type":"paragraph","content":[
22651                {"type":"text","text":"Item"},
22652                {"type":"hardBreak"},
22653                {"type":"hardBreak"}
22654              ]}
22655            ]},
22656            {"type":"listItem","content":[
22657              {"type":"paragraph","content":[
22658                {"type":"text","text":"Next"}
22659              ]}
22660            ]}
22661          ]}
22662        ]}"#;
22663        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22664        let md = adf_to_markdown(&doc).unwrap();
22665        let rt = markdown_to_adf(&md).unwrap();
22666
22667        // Must remain a single bulletList
22668        assert_eq!(rt.content.len(), 1);
22669        assert_eq!(rt.content[0].node_type, "bulletList");
22670        let items = rt.content[0].content.as_ref().unwrap();
22671        assert_eq!(items.len(), 2);
22672
22673        // First item should preserve both hardBreaks
22674        let p1 = items[0].content.as_ref().unwrap()[0]
22675            .content
22676            .as_ref()
22677            .unwrap();
22678        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
22679        assert_eq!(types1, vec!["text", "hardBreak", "hardBreak"]);
22680    }
22681
22682    #[test]
22683    fn issue_472_hardbreak_mid_and_trailing_roundtrip() {
22684        // A hardBreak in the middle AND at the end of a listItem paragraph.
22685        let adf_json = r#"{"version":1,"type":"doc","content":[
22686          {"type":"bulletList","content":[
22687            {"type":"listItem","content":[
22688              {"type":"paragraph","content":[
22689                {"type":"text","text":"Line one"},
22690                {"type":"hardBreak"},
22691                {"type":"text","text":"Line two"},
22692                {"type":"hardBreak"}
22693              ]}
22694            ]},
22695            {"type":"listItem","content":[
22696              {"type":"paragraph","content":[
22697                {"type":"text","text":"Other item"}
22698              ]}
22699            ]}
22700          ]}
22701        ]}"#;
22702        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22703        let md = adf_to_markdown(&doc).unwrap();
22704        let rt = markdown_to_adf(&md).unwrap();
22705
22706        assert_eq!(rt.content.len(), 1);
22707        assert_eq!(rt.content[0].node_type, "bulletList");
22708        let items = rt.content[0].content.as_ref().unwrap();
22709        assert_eq!(items.len(), 2);
22710
22711        let p1 = items[0].content.as_ref().unwrap()[0]
22712            .content
22713            .as_ref()
22714            .unwrap();
22715        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
22716        assert_eq!(types1, vec!["text", "hardBreak", "text", "hardBreak"]);
22717        assert_eq!(p1[0].text.as_deref(), Some("Line one"));
22718        assert_eq!(p1[2].text.as_deref(), Some("Line two"));
22719    }
22720
22721    #[test]
22722    fn issue_472_only_hardbreak_in_listitem_paragraph() {
22723        // Edge case: paragraph contains only a hardBreak, no text.
22724        let adf_json = r#"{"version":1,"type":"doc","content":[
22725          {"type":"bulletList","content":[
22726            {"type":"listItem","content":[
22727              {"type":"paragraph","content":[
22728                {"type":"hardBreak"}
22729              ]}
22730            ]},
22731            {"type":"listItem","content":[
22732              {"type":"paragraph","content":[
22733                {"type":"text","text":"After"}
22734              ]}
22735            ]}
22736          ]}
22737        ]}"#;
22738        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22739        let md = adf_to_markdown(&doc).unwrap();
22740        let rt = markdown_to_adf(&md).unwrap();
22741
22742        // Must remain a single bulletList with 2 items
22743        assert_eq!(rt.content.len(), 1);
22744        assert_eq!(rt.content[0].node_type, "bulletList");
22745        let items = rt.content[0].content.as_ref().unwrap();
22746        assert_eq!(items.len(), 2);
22747    }
22748
22749    #[test]
22750    fn issue_472_three_items_middle_has_trailing_hardbreak() {
22751        // Three-item list where only the middle item has a trailing hardBreak.
22752        let adf_json = r#"{"version":1,"type":"doc","content":[
22753          {"type":"bulletList","content":[
22754            {"type":"listItem","content":[
22755              {"type":"paragraph","content":[
22756                {"type":"text","text":"First"}
22757              ]}
22758            ]},
22759            {"type":"listItem","content":[
22760              {"type":"paragraph","content":[
22761                {"type":"text","text":"Second"},
22762                {"type":"hardBreak"}
22763              ]}
22764            ]},
22765            {"type":"listItem","content":[
22766              {"type":"paragraph","content":[
22767                {"type":"text","text":"Third"}
22768              ]}
22769            ]}
22770          ]}
22771        ]}"#;
22772        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22773        let md = adf_to_markdown(&doc).unwrap();
22774        let rt = markdown_to_adf(&md).unwrap();
22775
22776        assert_eq!(rt.content.len(), 1);
22777        assert_eq!(rt.content[0].node_type, "bulletList");
22778        let items = rt.content[0].content.as_ref().unwrap();
22779        assert_eq!(items.len(), 3);
22780        assert_eq!(
22781            items[0].content.as_ref().unwrap()[0]
22782                .content
22783                .as_ref()
22784                .unwrap()[0]
22785                .text
22786                .as_deref(),
22787            Some("First")
22788        );
22789        assert_eq!(
22790            items[2].content.as_ref().unwrap()[0]
22791                .content
22792                .as_ref()
22793                .unwrap()[0]
22794                .text
22795                .as_deref(),
22796            Some("Third")
22797        );
22798    }
22799
22800    // ── Issue #494: trailing space-only text node after hardBreak ────
22801
22802    #[test]
22803    fn issue_494_space_after_hardbreak_roundtrip() {
22804        // The original reproducer from issue #494: a single space text
22805        // node following a hardBreak is silently dropped on round-trip.
22806        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22807          {"type":"text","text":"Some text"},
22808          {"type":"hardBreak"},
22809          {"type":"text","text":" "}
22810        ]}]}"#;
22811        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22812        let md = adf_to_markdown(&doc).unwrap();
22813        let rt = markdown_to_adf(&md).unwrap();
22814        let inlines = rt.content[0].content.as_ref().unwrap();
22815        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
22816        assert_eq!(
22817            types,
22818            vec!["text", "hardBreak", "text"],
22819            "space-only text node after hardBreak should survive round-trip"
22820        );
22821        assert_eq!(inlines[2].text.as_deref(), Some(" "));
22822    }
22823
22824    #[test]
22825    fn issue_494_multiple_spaces_after_hardbreak_roundtrip() {
22826        // Multiple spaces after hardBreak should also survive.
22827        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22828          {"type":"text","text":"Hello"},
22829          {"type":"hardBreak"},
22830          {"type":"text","text":"   "}
22831        ]}]}"#;
22832        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22833        let md = adf_to_markdown(&doc).unwrap();
22834        let rt = markdown_to_adf(&md).unwrap();
22835        let inlines = rt.content[0].content.as_ref().unwrap();
22836        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
22837        assert_eq!(
22838            types,
22839            vec!["text", "hardBreak", "text"],
22840            "multi-space text node after hardBreak should survive round-trip"
22841        );
22842        assert_eq!(inlines[2].text.as_deref(), Some("   "));
22843    }
22844
22845    #[test]
22846    fn issue_494_space_then_text_after_hardbreak_roundtrip() {
22847        // Space followed by real text after hardBreak — the space should
22848        // be preserved as part of the text node.
22849        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22850          {"type":"text","text":"Before"},
22851          {"type":"hardBreak"},
22852          {"type":"text","text":" After"}
22853        ]}]}"#;
22854        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22855        let md = adf_to_markdown(&doc).unwrap();
22856        let rt = markdown_to_adf(&md).unwrap();
22857        let inlines = rt.content[0].content.as_ref().unwrap();
22858        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
22859        assert_eq!(types, vec!["text", "hardBreak", "text"]);
22860        assert_eq!(inlines[2].text.as_deref(), Some(" After"));
22861    }
22862
22863    #[test]
22864    fn issue_494_hardbreak_then_space_then_hardbreak_roundtrip() {
22865        // Space sandwiched between two hardBreaks.
22866        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22867          {"type":"text","text":"A"},
22868          {"type":"hardBreak"},
22869          {"type":"text","text":" "},
22870          {"type":"hardBreak"},
22871          {"type":"text","text":"B"}
22872        ]}]}"#;
22873        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22874        let md = adf_to_markdown(&doc).unwrap();
22875        let rt = markdown_to_adf(&md).unwrap();
22876        let inlines = rt.content[0].content.as_ref().unwrap();
22877        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
22878        assert_eq!(
22879            types,
22880            vec!["text", "hardBreak", "text", "hardBreak", "text"],
22881            "space between two hardBreaks should survive round-trip"
22882        );
22883        assert_eq!(inlines[2].text.as_deref(), Some(" "));
22884        assert_eq!(inlines[4].text.as_deref(), Some("B"));
22885    }
22886
22887    #[test]
22888    fn issue_494_trailing_space_hardbreak_style_not_confused() {
22889        // A plain paragraph break (blank line) should still work after
22890        // a line that does NOT end with a hardBreak marker.
22891        let md = "first paragraph\n\nsecond paragraph\n";
22892        let doc = markdown_to_adf(md).unwrap();
22893        assert_eq!(
22894            doc.content.len(),
22895            2,
22896            "blank line should still separate paragraphs"
22897        );
22898    }
22899
22900    #[test]
22901    fn issue_494_space_after_trailing_space_hardbreak_roundtrip() {
22902        // Same bug but with trailing-space style hardBreak (two spaces
22903        // before newline) instead of backslash style.
22904        let md = "line one  \n   \n";
22905        // The above is: "line one" + trailing-space hardBreak + continuation
22906        // line "   " (2-space indent + 1 space content).  The space-only
22907        // continuation should not be treated as a blank paragraph break.
22908        let doc = markdown_to_adf(md).unwrap();
22909        let inlines = doc.content[0].content.as_ref().unwrap();
22910        let has_text_after_break = inlines.iter().any(|n| {
22911            n.node_type == "text"
22912                && n.text
22913                    .as_deref()
22914                    .is_some_and(|t| t.trim().is_empty() && !t.is_empty())
22915        });
22916        assert!(
22917            has_text_after_break || inlines.len() >= 2,
22918            "space-only line after trailing-space hardBreak should be preserved"
22919        );
22920    }
22921
22922    #[test]
22923    fn issue_494_space_after_hardbreak_in_list_item_roundtrip() {
22924        // Exercises the same bug inside a list item context.
22925        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
22926          {"type":"listItem","content":[{"type":"paragraph","content":[
22927            {"type":"text","text":"item"},
22928            {"type":"hardBreak"},
22929            {"type":"text","text":" "}
22930          ]}]}
22931        ]}]}"#;
22932        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22933        let md = adf_to_markdown(&doc).unwrap();
22934        let rt = markdown_to_adf(&md).unwrap();
22935        let list = &rt.content[0];
22936        let item = &list.content.as_ref().unwrap()[0];
22937        let para = &item.content.as_ref().unwrap()[0];
22938        let inlines = para.content.as_ref().unwrap();
22939        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
22940        assert_eq!(
22941            types,
22942            vec!["text", "hardBreak", "text"],
22943            "space after hardBreak in list item should survive round-trip"
22944        );
22945        assert_eq!(inlines[2].text.as_deref(), Some(" "));
22946    }
22947
22948    // ── Issue #510: trailing spaces in text node should not become hardBreak ──
22949
22950    #[test]
22951    fn issue_510_trailing_double_space_paragraph_roundtrip() {
22952        // Two trailing spaces in a text node must survive round-trip without
22953        // being converted to a hardBreak or merging the next paragraph.
22954        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"first paragraph with trailing spaces  "}]},{"type":"paragraph","content":[{"type":"text","text":"second paragraph"}]}]}"#;
22955        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22956        let md = adf_to_markdown(&doc).unwrap();
22957        let rt = markdown_to_adf(&md).unwrap();
22958
22959        // Must produce two separate paragraphs
22960        assert_eq!(
22961            rt.content.len(),
22962            2,
22963            "should produce two paragraphs, got: {}",
22964            rt.content.len()
22965        );
22966        assert_eq!(rt.content[0].node_type, "paragraph");
22967        assert_eq!(rt.content[1].node_type, "paragraph");
22968
22969        // First paragraph text preserves trailing spaces
22970        let p1 = rt.content[0].content.as_ref().unwrap();
22971        assert_eq!(
22972            p1[0].text.as_deref(),
22973            Some("first paragraph with trailing spaces  "),
22974            "trailing spaces should be preserved in first paragraph"
22975        );
22976
22977        // Second paragraph is intact
22978        let p2 = rt.content[1].content.as_ref().unwrap();
22979        assert_eq!(p2[0].text.as_deref(), Some("second paragraph"));
22980
22981        // No hardBreak nodes should exist
22982        let all_types: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
22983        assert!(
22984            !all_types.contains(&"hardBreak"),
22985            "trailing spaces should not produce hardBreak, got: {all_types:?}"
22986        );
22987    }
22988
22989    #[test]
22990    fn issue_510_trailing_triple_space_roundtrip() {
22991        // Three trailing spaces also must not become a hardBreak.
22992        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"text   "}]},{"type":"paragraph","content":[{"type":"text","text":"next"}]}]}"#;
22993        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22994        let md = adf_to_markdown(&doc).unwrap();
22995        let rt = markdown_to_adf(&md).unwrap();
22996
22997        assert_eq!(rt.content.len(), 2, "should still be two paragraphs");
22998        let p1 = rt.content[0].content.as_ref().unwrap();
22999        assert_eq!(
23000            p1[0].text.as_deref(),
23001            Some("text   "),
23002            "three trailing spaces should be preserved"
23003        );
23004    }
23005
23006    #[test]
23007    fn issue_510_trailing_spaces_with_backslash_roundtrip() {
23008        // Text ending with backslash + trailing spaces: both must survive.
23009        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\  "}]}]}"#;
23010        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23011        let md = adf_to_markdown(&doc).unwrap();
23012        let rt = markdown_to_adf(&md).unwrap();
23013        let p = rt.content[0].content.as_ref().unwrap();
23014        assert_eq!(
23015            p[0].text.as_deref(),
23016            Some("end\\  "),
23017            "backslash + trailing spaces should both survive"
23018        );
23019    }
23020
23021    #[test]
23022    fn issue_510_jfm_contains_escaped_trailing_space() {
23023        // Verify the serializer actually emits the backslash-space escape.
23024        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello  "}]}]}"#;
23025        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23026        let md = adf_to_markdown(&doc).unwrap();
23027        assert!(
23028            md.contains(r"\ "),
23029            "JFM should contain backslash-space escape for trailing spaces, got: {md:?}"
23030        );
23031        // Must NOT end with two plain spaces before newline
23032        for line in md.lines() {
23033            assert!(
23034                !line.ends_with("  "),
23035                "no JFM line should end with two plain spaces, got: {line:?}"
23036            );
23037        }
23038    }
23039
23040    #[test]
23041    fn issue_510_single_trailing_space_not_escaped() {
23042        // A single trailing space should NOT be escaped (not a hardBreak trigger).
23043        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"word "}]}]}"#;
23044        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23045        let md = adf_to_markdown(&doc).unwrap();
23046        assert!(
23047            !md.contains('\\'),
23048            "single trailing space should not be escaped, got: {md:?}"
23049        );
23050        let rt = markdown_to_adf(&md).unwrap();
23051        let p = rt.content[0].content.as_ref().unwrap();
23052        assert_eq!(p[0].text.as_deref(), Some("word "));
23053    }
23054
23055    #[test]
23056    fn issue_510_trailing_spaces_in_heading_roundtrip() {
23057        // Trailing double-spaces in a heading text node should also survive.
23058        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"heading  "}]}]}"#;
23059        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23060        let md = adf_to_markdown(&doc).unwrap();
23061        let rt = markdown_to_adf(&md).unwrap();
23062        let h = rt.content[0].content.as_ref().unwrap();
23063        assert_eq!(
23064            h[0].text.as_deref(),
23065            Some("heading  "),
23066            "trailing spaces in heading should be preserved"
23067        );
23068    }
23069
23070    #[test]
23071    fn issue_510_trailing_spaces_in_list_item_roundtrip() {
23072        // Trailing double-spaces in a bullet list item text node.
23073        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item  "}]}]}]}]}"#;
23074        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23075        let md = adf_to_markdown(&doc).unwrap();
23076        let rt = markdown_to_adf(&md).unwrap();
23077        let list = &rt.content[0];
23078        let item = &list.content.as_ref().unwrap()[0];
23079        let para = &item.content.as_ref().unwrap()[0];
23080        let inlines = para.content.as_ref().unwrap();
23081        assert_eq!(
23082            inlines[0].text.as_deref(),
23083            Some("item  "),
23084            "trailing spaces in list item should be preserved"
23085        );
23086    }
23087
23088    #[test]
23089    fn issue_510_trailing_spaces_with_bold_mark_roundtrip() {
23090        // Trailing spaces in a bold-marked text node: the closing **
23091        // comes after the spaces, so the line doesn't end with spaces.
23092        // But the escape should still be applied (and be harmless).
23093        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold  ","marks":[{"type":"strong"}]}]}]}"#;
23094        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23095        let md = adf_to_markdown(&doc).unwrap();
23096        let rt = markdown_to_adf(&md).unwrap();
23097        let p = rt.content[0].content.as_ref().unwrap();
23098        assert_eq!(
23099            p[0].text.as_deref(),
23100            Some("bold  "),
23101            "trailing spaces in bold text should be preserved"
23102        );
23103    }
23104
23105    #[test]
23106    fn issue_510_hardbreak_between_paragraphs_still_works() {
23107        // Actual hardBreak nodes must still round-trip correctly.
23108        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"line one"},{"type":"hardBreak"},{"type":"text","text":"line two"}]}]}"#;
23109        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23110        let md = adf_to_markdown(&doc).unwrap();
23111        let rt = markdown_to_adf(&md).unwrap();
23112        let inlines = rt.content[0].content.as_ref().unwrap();
23113        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
23114        assert_eq!(
23115            types,
23116            vec!["text", "hardBreak", "text"],
23117            "explicit hardBreak should still round-trip"
23118        );
23119    }
23120
23121    #[test]
23122    fn issue_510_all_spaces_text_node_roundtrip() {
23123        // A text node that is entirely spaces (2+) should survive.
23124        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"  "}]}]}"#;
23125        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23126        let md = adf_to_markdown(&doc).unwrap();
23127        let rt = markdown_to_adf(&md).unwrap();
23128        let p = rt.content[0].content.as_ref().unwrap();
23129        assert_eq!(
23130            p[0].text.as_deref(),
23131            Some("  "),
23132            "space-only text node should survive round-trip"
23133        );
23134    }
23135
23136    // ── Issue #522: listItem multi-paragraph merge ──────────────────────
23137
23138    #[test]
23139    fn issue_522_listitem_hardbreak_then_two_paragraphs_roundtrips() {
23140        // The exact reproducer from issue #522: first paragraph has
23141        // hardBreak nodes, followed by two sibling paragraphs.
23142        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"preamble"},{"type":"hardBreak"},{"type":"text","text":"\u00a0"},{"type":"hardBreak"},{"type":"text","text":"line with "},{"marks":[{"type":"code"}],"text":"code","type":"text"},{"type":"text","text":". "}]},{"type":"paragraph","content":[{"type":"text","text":"second paragraph"}]},{"type":"paragraph","content":[{"type":"text","text":"third paragraph"}]}]}]}]}"#;
23143        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23144        let md = adf_to_markdown(&doc).unwrap();
23145        let rt = markdown_to_adf(&md).unwrap();
23146
23147        let items = rt.content[0].content.as_ref().unwrap();
23148        assert_eq!(items.len(), 1);
23149        let children = items[0].content.as_ref().unwrap();
23150        assert_eq!(
23151            children.len(),
23152            3,
23153            "Expected 3 paragraphs in listItem, got {}",
23154            children.len()
23155        );
23156        assert_eq!(children[0].node_type, "paragraph");
23157        assert_eq!(children[1].node_type, "paragraph");
23158        assert_eq!(children[2].node_type, "paragraph");
23159
23160        // Verify the text content of each paragraph
23161        let text1 = children[1].content.as_ref().unwrap()[0]
23162            .text
23163            .as_deref()
23164            .unwrap();
23165        assert_eq!(text1, "second paragraph");
23166        let text2 = children[2].content.as_ref().unwrap()[0]
23167            .text
23168            .as_deref()
23169            .unwrap();
23170        assert_eq!(text2, "third paragraph");
23171    }
23172
23173    #[test]
23174    fn issue_522_ordered_list_hardbreak_then_paragraphs_roundtrips() {
23175        // Same scenario in an ordered list.
23176        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"first"},{"type":"hardBreak"},{"type":"text","text":"continued"}]},{"type":"paragraph","content":[{"type":"text","text":"second para"}]},{"type":"paragraph","content":[{"type":"text","text":"third para"}]}]}]}]}"#;
23177        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23178        let md = adf_to_markdown(&doc).unwrap();
23179        let rt = markdown_to_adf(&md).unwrap();
23180
23181        let items = rt.content[0].content.as_ref().unwrap();
23182        let children = items[0].content.as_ref().unwrap();
23183        assert_eq!(
23184            children.len(),
23185            3,
23186            "Expected 3 paragraphs in ordered listItem, got {}",
23187            children.len()
23188        );
23189        assert_eq!(children[1].node_type, "paragraph");
23190        assert_eq!(children[2].node_type, "paragraph");
23191        assert_eq!(
23192            children[1].content.as_ref().unwrap()[0]
23193                .text
23194                .as_deref()
23195                .unwrap(),
23196            "second para"
23197        );
23198        assert_eq!(
23199            children[2].content.as_ref().unwrap()[0]
23200                .text
23201                .as_deref()
23202                .unwrap(),
23203            "third para"
23204        );
23205    }
23206
23207    #[test]
23208    fn issue_522_two_paragraphs_without_hardbreak_roundtrips() {
23209        // Two paragraphs without hardBreak — should also remain separate.
23210        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"first paragraph"}]},{"type":"paragraph","content":[{"type":"text","text":"second paragraph"}]}]}]}]}"#;
23211        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23212        let md = adf_to_markdown(&doc).unwrap();
23213        let rt = markdown_to_adf(&md).unwrap();
23214
23215        let items = rt.content[0].content.as_ref().unwrap();
23216        let children = items[0].content.as_ref().unwrap();
23217        assert_eq!(
23218            children.len(),
23219            2,
23220            "Expected 2 paragraphs in listItem, got {}",
23221            children.len()
23222        );
23223        assert_eq!(children[0].node_type, "paragraph");
23224        assert_eq!(children[1].node_type, "paragraph");
23225    }
23226
23227    #[test]
23228    fn issue_522_paragraph_then_nested_list_no_spurious_blank() {
23229        // A paragraph followed by a nested list should NOT get a blank
23230        // separator (only paragraph-paragraph transitions need one).
23231        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"parent"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"child"}]}]}]}]}]}]}"#;
23232        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23233        let md = adf_to_markdown(&doc).unwrap();
23234        // Should not contain a blank indented line between parent text and sub-list
23235        assert!(
23236            !md.contains("  \n  -"),
23237            "No blank separator between paragraph and nested list"
23238        );
23239        let rt = markdown_to_adf(&md).unwrap();
23240
23241        let items = rt.content[0].content.as_ref().unwrap();
23242        let children = items[0].content.as_ref().unwrap();
23243        assert_eq!(children.len(), 2);
23244        assert_eq!(children[0].node_type, "paragraph");
23245        assert_eq!(children[1].node_type, "bulletList");
23246    }
23247
23248    #[test]
23249    fn issue_522_three_paragraphs_no_hardbreak_roundtrips() {
23250        // Three plain paragraphs (no hardBreak) inside a single listItem.
23251        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"alpha"}]},{"type":"paragraph","content":[{"type":"text","text":"bravo"}]},{"type":"paragraph","content":[{"type":"text","text":"charlie"}]}]}]}]}"#;
23252        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23253        let md = adf_to_markdown(&doc).unwrap();
23254        let rt = markdown_to_adf(&md).unwrap();
23255
23256        let items = rt.content[0].content.as_ref().unwrap();
23257        let children = items[0].content.as_ref().unwrap();
23258        assert_eq!(
23259            children.len(),
23260            3,
23261            "Expected 3 paragraphs, got {}",
23262            children.len()
23263        );
23264        for (i, child) in children.iter().enumerate() {
23265            assert_eq!(
23266                child.node_type, "paragraph",
23267                "Child {i} should be a paragraph"
23268            );
23269        }
23270    }
23271
23272    #[test]
23273    fn issue_522_multiple_list_items_each_with_paragraphs() {
23274        // Multiple list items, each with multiple paragraphs.
23275        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item1 p1"}]},{"type":"paragraph","content":[{"type":"text","text":"item1 p2"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item2 p1"},{"type":"hardBreak"},{"type":"text","text":"item2 cont"}]},{"type":"paragraph","content":[{"type":"text","text":"item2 p2"}]}]}]}]}"#;
23276        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23277        let md = adf_to_markdown(&doc).unwrap();
23278        let rt = markdown_to_adf(&md).unwrap();
23279
23280        let items = rt.content[0].content.as_ref().unwrap();
23281        assert_eq!(items.len(), 2, "Expected 2 list items");
23282
23283        let item1 = items[0].content.as_ref().unwrap();
23284        assert_eq!(item1.len(), 2, "Item 1 should have 2 paragraphs");
23285
23286        let item2 = items[1].content.as_ref().unwrap();
23287        assert_eq!(item2.len(), 2, "Item 2 should have 2 paragraphs");
23288        // Verify hardBreak is preserved in item2's first paragraph
23289        let item2_p1_inlines = item2[0].content.as_ref().unwrap();
23290        let types: Vec<&str> = item2_p1_inlines
23291            .iter()
23292            .map(|n| n.node_type.as_str())
23293            .collect();
23294        assert_eq!(types, vec!["text", "hardBreak", "text"]);
23295    }
23296
23297    #[test]
23298    fn issue_531_blockquote_hardbreak_then_two_paragraphs_roundtrips() {
23299        // The exact reproducer from issue #531: blockquote with first
23300        // paragraph containing hardBreak nodes, followed by two sibling
23301        // paragraphs.
23302        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"preamble"},{"type":"hardBreak"},{"type":"text","text":"\u00a0"},{"type":"hardBreak"},{"type":"text","text":"line with "},{"marks":[{"type":"code"}],"text":"code","type":"text"},{"type":"text","text":". "}]},{"type":"paragraph","content":[{"type":"text","text":"second paragraph"}]},{"type":"paragraph","content":[{"type":"text","text":"third paragraph"}]}]}]}"#;
23303        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23304        let md = adf_to_markdown(&doc).unwrap();
23305        let rt = markdown_to_adf(&md).unwrap();
23306
23307        let children = rt.content[0].content.as_ref().unwrap();
23308        assert_eq!(
23309            children.len(),
23310            3,
23311            "Expected 3 paragraphs in blockquote, got {}",
23312            children.len()
23313        );
23314        assert_eq!(children[0].node_type, "paragraph");
23315        assert_eq!(children[1].node_type, "paragraph");
23316        assert_eq!(children[2].node_type, "paragraph");
23317
23318        let text1 = children[1].content.as_ref().unwrap()[0]
23319            .text
23320            .as_deref()
23321            .unwrap();
23322        assert_eq!(text1, "second paragraph");
23323        let text2 = children[2].content.as_ref().unwrap()[0]
23324            .text
23325            .as_deref()
23326            .unwrap();
23327        assert_eq!(text2, "third paragraph");
23328    }
23329
23330    #[test]
23331    fn issue_531_blockquote_two_paragraphs_without_hardbreak_roundtrips() {
23332        // Two simple paragraphs inside a blockquote, no hardBreak.
23333        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]},{"type":"paragraph","content":[{"type":"text","text":"second"}]}]}]}"#;
23334        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23335        let md = adf_to_markdown(&doc).unwrap();
23336        let rt = markdown_to_adf(&md).unwrap();
23337
23338        let children = rt.content[0].content.as_ref().unwrap();
23339        assert_eq!(
23340            children.len(),
23341            2,
23342            "Expected 2 paragraphs in blockquote, got {}",
23343            children.len()
23344        );
23345        assert_eq!(children[0].node_type, "paragraph");
23346        assert_eq!(children[1].node_type, "paragraph");
23347    }
23348
23349    #[test]
23350    fn issue_531_blockquote_three_paragraphs_no_hardbreak_roundtrips() {
23351        // Three paragraphs, none with hardBreak.
23352        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"alpha"}]},{"type":"paragraph","content":[{"type":"text","text":"beta"}]},{"type":"paragraph","content":[{"type":"text","text":"gamma"}]}]}]}"#;
23353        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23354        let md = adf_to_markdown(&doc).unwrap();
23355        let rt = markdown_to_adf(&md).unwrap();
23356
23357        let children = rt.content[0].content.as_ref().unwrap();
23358        assert_eq!(
23359            children.len(),
23360            3,
23361            "Expected 3 paragraphs in blockquote, got {}",
23362            children.len()
23363        );
23364        for child in children {
23365            assert_eq!(child.node_type, "paragraph");
23366        }
23367    }
23368
23369    #[test]
23370    fn issue_531_blockquote_paragraph_then_list_no_spurious_blank() {
23371        // A paragraph followed by a nested list inside a blockquote —
23372        // should NOT insert a blank separator line.
23373        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"intro"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item one"}]}]}]}]}]}"#;
23374        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23375        let md = adf_to_markdown(&doc).unwrap();
23376        let rt = markdown_to_adf(&md).unwrap();
23377
23378        let children = rt.content[0].content.as_ref().unwrap();
23379        assert_eq!(children[0].node_type, "paragraph");
23380        assert_eq!(children[1].node_type, "bulletList");
23381    }
23382
23383    #[test]
23384    fn issue_531_blockquote_single_paragraph_unchanged() {
23385        // A single paragraph in a blockquote should remain unchanged.
23386        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"solo"}]}]}]}"#;
23387        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23388        let md = adf_to_markdown(&doc).unwrap();
23389        let rt = markdown_to_adf(&md).unwrap();
23390
23391        let children = rt.content[0].content.as_ref().unwrap();
23392        assert_eq!(children.len(), 1);
23393        assert_eq!(children[0].node_type, "paragraph");
23394        let text = children[0].content.as_ref().unwrap()[0]
23395            .text
23396            .as_deref()
23397            .unwrap();
23398        assert_eq!(text, "solo");
23399    }
23400
23401    // ── Issue #554: marks combined with `code` or with each other ──────
23402
23403    /// Helper: roundtrip an ADF document and assert the marks on the first
23404    /// text node match `expected_marks` (in order).
23405    fn assert_roundtrip_marks(adf_json: &str, expected_marks: &[&str]) {
23406        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23407        let md = adf_to_markdown(&doc).unwrap();
23408        let rt = markdown_to_adf(&md).unwrap();
23409        let node = &rt.content[0].content.as_ref().unwrap()[0];
23410        let mark_types: Vec<&str> = node
23411            .marks
23412            .as_ref()
23413            .expect("should have marks")
23414            .iter()
23415            .map(|m| m.mark_type.as_str())
23416            .collect();
23417        assert_eq!(
23418            mark_types, expected_marks,
23419            "mark order mismatch for md={md}"
23420        );
23421    }
23422
23423    #[test]
23424    fn issue_554_code_and_text_color_preserved() {
23425        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23426          {"type":"text","text":"x","marks":[
23427            {"type":"textColor","attrs":{"color":"#008000"}},
23428            {"type":"code"}
23429          ]}
23430        ]}]}"##;
23431        assert_roundtrip_marks(adf_json, &["textColor", "code"]);
23432    }
23433
23434    #[test]
23435    fn issue_554_code_and_bg_color_preserved() {
23436        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23437          {"type":"text","text":"x","marks":[
23438            {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
23439            {"type":"code"}
23440          ]}
23441        ]}]}"##;
23442        assert_roundtrip_marks(adf_json, &["backgroundColor", "code"]);
23443    }
23444
23445    #[test]
23446    fn issue_554_code_and_subsup_preserved() {
23447        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23448          {"type":"text","text":"x","marks":[
23449            {"type":"subsup","attrs":{"type":"sub"}},
23450            {"type":"code"}
23451          ]}
23452        ]}]}"#;
23453        assert_roundtrip_marks(adf_json, &["subsup", "code"]);
23454    }
23455
23456    #[test]
23457    fn issue_554_code_and_underline_preserved() {
23458        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23459          {"type":"text","text":"x","marks":[
23460            {"type":"underline"},
23461            {"type":"code"}
23462          ]}
23463        ]}]}"#;
23464        assert_roundtrip_marks(adf_json, &["underline", "code"]);
23465    }
23466
23467    #[test]
23468    fn issue_554_code_textcolor_and_underline_preserved() {
23469        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23470          {"type":"text","text":"x","marks":[
23471            {"type":"textColor","attrs":{"color":"#008000"}},
23472            {"type":"underline"},
23473            {"type":"code"}
23474          ]}
23475        ]}]}"##;
23476        assert_roundtrip_marks(adf_json, &["textColor", "underline", "code"]);
23477    }
23478
23479    #[test]
23480    fn issue_554_textcolor_and_underline_preserved() {
23481        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23482          {"type":"text","text":"x","marks":[
23483            {"type":"textColor","attrs":{"color":"#008000"}},
23484            {"type":"underline"}
23485          ]}
23486        ]}]}"##;
23487        assert_roundtrip_marks(adf_json, &["textColor", "underline"]);
23488    }
23489
23490    #[test]
23491    fn issue_554_underline_and_textcolor_preserved_order_swapped() {
23492        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23493          {"type":"text","text":"x","marks":[
23494            {"type":"underline"},
23495            {"type":"textColor","attrs":{"color":"#008000"}}
23496          ]}
23497        ]}]}"##;
23498        // underline appears first, so it should be the OUTER wrapper.
23499        assert_roundtrip_marks(adf_json, &["underline", "textColor"]);
23500    }
23501
23502    #[test]
23503    fn issue_554_textcolor_and_annotation_preserved() {
23504        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23505          {"type":"text","text":"x","marks":[
23506            {"type":"textColor","attrs":{"color":"#008000"}},
23507            {"type":"annotation","attrs":{"id":"abc-123","annotationType":"inlineComment"}}
23508          ]}
23509        ]}]}"##;
23510        assert_roundtrip_marks(adf_json, &["textColor", "annotation"]);
23511    }
23512
23513    #[test]
23514    fn issue_554_bgcolor_and_underline_preserved() {
23515        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23516          {"type":"text","text":"x","marks":[
23517            {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
23518            {"type":"underline"}
23519          ]}
23520        ]}]}"##;
23521        assert_roundtrip_marks(adf_json, &["backgroundColor", "underline"]);
23522    }
23523
23524    #[test]
23525    fn issue_554_subsup_and_underline_preserved() {
23526        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23527          {"type":"text","text":"x","marks":[
23528            {"type":"subsup","attrs":{"type":"sub"}},
23529            {"type":"underline"}
23530          ]}
23531        ]}]}"#;
23532        assert_roundtrip_marks(adf_json, &["subsup", "underline"]);
23533    }
23534
23535    #[test]
23536    fn issue_554_exact_reproducer_full_match() {
23537        // The exact reproducer from issue #554. The byte-for-byte ADF JSON
23538        // must round-trip through `from-adf | to-adf` unchanged.
23539        let adf_json = r##"{
23540          "version": 1,
23541          "type": "doc",
23542          "content": [
23543            {
23544              "type": "paragraph",
23545              "content": [
23546                {"type":"text","text":"Status: ","marks":[{"type":"strong"}]},
23547                {"type":"text","text":"Approved","marks":[
23548                  {"type":"textColor","attrs":{"color":"#008000"}}
23549                ]},
23550                {"type":"text","text":" — ready to proceed"}
23551              ]
23552            }
23553          ]
23554        }"##;
23555        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23556        let md = adf_to_markdown(&doc).unwrap();
23557        assert!(
23558            md.contains(":span[Approved]{color=#008000}"),
23559            "JFM should contain green span: {md}"
23560        );
23561        let rt = markdown_to_adf(&md).unwrap();
23562        // Find the "Approved" text node and verify color is preserved.
23563        let approved = rt.content[0]
23564            .content
23565            .as_ref()
23566            .unwrap()
23567            .iter()
23568            .find(|n| n.text.as_deref() == Some("Approved"))
23569            .expect("Approved text node");
23570        let marks = approved.marks.as_ref().expect("should have marks");
23571        let color_mark = marks
23572            .iter()
23573            .find(|m| m.mark_type == "textColor")
23574            .expect("textColor mark must be preserved");
23575        assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
23576    }
23577
23578    #[test]
23579    fn issue_554_textcolor_with_code_renders_span_around_code() {
23580        // Verify the rendered JFM uses `:span[`text`]{color=...}` — the
23581        // syntax suggested in the issue.
23582        let doc = AdfDocument {
23583            version: 1,
23584            doc_type: "doc".to_string(),
23585            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
23586                "fn main",
23587                vec![
23588                    AdfMark::text_color("#008000"),
23589                    AdfMark {
23590                        mark_type: "code".to_string(),
23591                        attrs: None,
23592                    },
23593                ],
23594            )])],
23595        };
23596        let md = adf_to_markdown(&doc).unwrap();
23597        assert!(
23598            md.contains(":span[`fn main`]{color=#008000}"),
23599            "expected span-wrapped code, got: {md}"
23600        );
23601    }
23602
23603    #[test]
23604    fn issue_554_underline_with_code_renders_bracketed_around_code() {
23605        let doc = AdfDocument {
23606            version: 1,
23607            doc_type: "doc".to_string(),
23608            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
23609                "fn main",
23610                vec![
23611                    AdfMark::underline(),
23612                    AdfMark {
23613                        mark_type: "code".to_string(),
23614                        attrs: None,
23615                    },
23616                ],
23617            )])],
23618        };
23619        let md = adf_to_markdown(&doc).unwrap();
23620        assert!(
23621            md.contains("[`fn main`]{underline}"),
23622            "expected bracketed-span around code, got: {md}"
23623        );
23624    }
23625
23626    // ── Issue #554 (re-opened): boundary-underscore destroys span directives ──
23627
23628    #[test]
23629    fn issue_554_underscore_adjacent_to_textcolor_span_roundtrip() {
23630        // Reproducer from the re-opened issue: a `_ ` plain-text node followed
23631        // by a textColor span whose text starts with `_` produced JFM that the
23632        // parser saw as an italic delimiter pair, destroying the span and
23633        // losing the textColor mark entirely.
23634        let adf_json = r##"{
23635          "version": 1,
23636          "type": "doc",
23637          "content": [
23638            {
23639              "type": "paragraph",
23640              "content": [
23641                {"type":"text","text":"_ "},
23642                {"type":"text","text":"_Action:*","marks":[
23643                  {"type":"textColor","attrs":{"color":"#008000"}}
23644                ]},
23645                {"type":"text","text":" Complete the setup process."}
23646              ]
23647            }
23648          ]
23649        }"##;
23650        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23651        let md = adf_to_markdown(&doc).unwrap();
23652        // The leading `_` chars must be backslash-escaped so the parser
23653        // doesn't form a false italic pair across the span boundary.
23654        assert!(
23655            md.contains(r"\_ ") && md.contains(r":span[\_Action"),
23656            "underscores at node boundaries should be escaped: {md}"
23657        );
23658        let rt = markdown_to_adf(&md).unwrap();
23659        let para_content = rt.content[0].content.as_ref().unwrap();
23660        // Find the textColor-marked node.
23661        let colored = para_content
23662            .iter()
23663            .find(|n| {
23664                n.marks
23665                    .as_deref()
23666                    .is_some_and(|ms| ms.iter().any(|m| m.mark_type == "textColor"))
23667            })
23668            .expect("textColor node must be preserved");
23669        assert_eq!(colored.text.as_deref(), Some("_Action:*"));
23670        let color_mark = colored
23671            .marks
23672            .as_ref()
23673            .unwrap()
23674            .iter()
23675            .find(|m| m.mark_type == "textColor")
23676            .unwrap();
23677        assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
23678        // Verify no spurious em mark crept in.
23679        for n in para_content {
23680            if let Some(ms) = n.marks.as_deref() {
23681                assert!(
23682                    !ms.iter().any(|m| m.mark_type == "em"),
23683                    "no em mark should appear, got marks {:?}",
23684                    ms.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
23685                );
23686            }
23687        }
23688    }
23689
23690    #[test]
23691    fn issue_554_underscore_intraword_left_unescaped() {
23692        // Sanity check: ordinary intraword underscores like `do_something_useful`
23693        // should NOT be escaped — escaping would still round-trip correctly,
23694        // but produces noisy backslashes in the JFM output.
23695        let doc = AdfDocument {
23696            version: 1,
23697            doc_type: "doc".to_string(),
23698            content: vec![AdfNode::paragraph(vec![AdfNode::text(
23699                "call do_something_useful now",
23700            )])],
23701        };
23702        let md = adf_to_markdown(&doc).unwrap();
23703        assert!(
23704            md.contains("do_something_useful") && !md.contains(r"do\_something\_useful"),
23705            "intraword underscores should not be escaped: {md}"
23706        );
23707    }
23708
23709    #[test]
23710    fn issue_554_code_underline_then_textcolor_bracketed_outer() {
23711        // Mark order [underline, textColor, code] — bracketed-span outer,
23712        // span inner. Exercises wrap_with_attrs (true, true) !span_before.
23713        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23714          {"type":"text","text":"x","marks":[
23715            {"type":"underline"},
23716            {"type":"textColor","attrs":{"color":"#008000"}},
23717            {"type":"code"}
23718          ]}
23719        ]}]}"##;
23720        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23721        let md = adf_to_markdown(&doc).unwrap();
23722        // Bracketed-span should be the outermost wrapper.
23723        assert!(
23724            md.starts_with('[') && md.contains("underline}"),
23725            "bracketed-span should wrap the span, got: {md}"
23726        );
23727        let rt = markdown_to_adf(&md).unwrap();
23728        let node = &rt.content[0].content.as_ref().unwrap()[0];
23729        let mark_types: Vec<&str> = node
23730            .marks
23731            .as_ref()
23732            .unwrap()
23733            .iter()
23734            .map(|m| m.mark_type.as_str())
23735            .collect();
23736        assert_eq!(mark_types, vec!["underline", "textColor", "code"]);
23737    }
23738
23739    #[test]
23740    fn issue_554_textcolor_underline_link_all_preserved() {
23741        // Mark order [textColor, underline, link] — span outer, bracketed
23742        // wraps the link inside. Exercises the span-wraps-link-with-bracketed
23743        // branch.
23744        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23745          {"type":"text","text":"linked","marks":[
23746            {"type":"textColor","attrs":{"color":"#008000"}},
23747            {"type":"underline"},
23748            {"type":"link","attrs":{"href":"https://example.com"}}
23749          ]}
23750        ]}]}"##;
23751        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23752        let md = adf_to_markdown(&doc).unwrap();
23753        let rt = markdown_to_adf(&md).unwrap();
23754        let node = &rt.content[0].content.as_ref().unwrap()[0];
23755        let mark_types: Vec<&str> = node
23756            .marks
23757            .as_ref()
23758            .unwrap()
23759            .iter()
23760            .map(|m| m.mark_type.as_str())
23761            .collect();
23762        assert_eq!(mark_types, vec!["textColor", "underline", "link"]);
23763    }
23764
23765    #[test]
23766    fn issue_554_underline_textcolor_link_bracketed_outer_link_last() {
23767        // Mark order [underline, textColor, link] — bracketed-span outer of
23768        // both span and link. Exercises the bracketed-wraps-everything branch.
23769        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23770          {"type":"text","text":"linked","marks":[
23771            {"type":"underline"},
23772            {"type":"textColor","attrs":{"color":"#008000"}},
23773            {"type":"link","attrs":{"href":"https://example.com"}}
23774          ]}
23775        ]}]}"##;
23776        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23777        let md = adf_to_markdown(&doc).unwrap();
23778        let rt = markdown_to_adf(&md).unwrap();
23779        let node = &rt.content[0].content.as_ref().unwrap()[0];
23780        let mark_types: Vec<&str> = node
23781            .marks
23782            .as_ref()
23783            .unwrap()
23784            .iter()
23785            .map(|m| m.mark_type.as_str())
23786            .collect();
23787        assert_eq!(mark_types, vec!["underline", "textColor", "link"]);
23788    }
23789
23790    #[test]
23791    fn issue_554_link_underline_textcolor_link_outer() {
23792        // Mark order [link, underline, textColor] — link outermost, wraps a
23793        // bracketed-span that wraps the span. Exercises the link-wraps-
23794        // bracketed-wraps-span branch.
23795        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23796          {"type":"text","text":"linked","marks":[
23797            {"type":"link","attrs":{"href":"https://example.com"}},
23798            {"type":"underline"},
23799            {"type":"textColor","attrs":{"color":"#008000"}}
23800          ]}
23801        ]}]}"##;
23802        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23803        let md = adf_to_markdown(&doc).unwrap();
23804        assert!(
23805            md.starts_with('[') && md.contains("](https://example.com)"),
23806            "link should be outermost, got: {md}"
23807        );
23808        let rt = markdown_to_adf(&md).unwrap();
23809        let node = &rt.content[0].content.as_ref().unwrap()[0];
23810        let mark_types: Vec<&str> = node
23811            .marks
23812            .as_ref()
23813            .unwrap()
23814            .iter()
23815            .map(|m| m.mark_type.as_str())
23816            .collect();
23817        assert_eq!(mark_types, vec!["link", "underline", "textColor"]);
23818    }
23819
23820    #[test]
23821    fn issue_554_trailing_underscore_then_leading_underscore_round_trip() {
23822        // Two adjacent text nodes where the first ends with `_` and the
23823        // second starts with `_` — without escaping, the JFM parser sees
23824        // an `_..._` pair spanning the boundary.
23825        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
23826          {"type":"text","text":"end_"},
23827          {"type":"text","text":"_start"}
23828        ]}]}"#;
23829        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
23830        let md = adf_to_markdown(&doc).unwrap();
23831        let rt = markdown_to_adf(&md).unwrap();
23832        // Reassemble all text in the paragraph.
23833        let combined: String = rt.content[0]
23834            .content
23835            .as_ref()
23836            .unwrap()
23837            .iter()
23838            .filter_map(|n| n.text.as_deref())
23839            .collect();
23840        assert_eq!(combined, "end__start");
23841        // No node should have an em mark.
23842        for n in rt.content[0].content.as_ref().unwrap() {
23843            if let Some(ms) = n.marks.as_deref() {
23844                assert!(!ms.iter().any(|m| m.mark_type == "em"));
23845            }
23846        }
23847    }
23848
23849    // ── Nesting depth limit (issue #1130) ──────────────────────────────
23850    //
23851    // Without MAX_NESTING_DEPTH each of these inputs recurses one stack
23852    // frame per nesting level, overflowing the stack — an uncatchable
23853    // abort.  The block-path tests assert a clean error; the inline-path
23854    // tests assert graceful degradation (no abort, valid document).
23855
23856    #[test]
23857    fn blockquote_nesting_beyond_cap_errors() {
23858        let md = format!("{}x", "> ".repeat(200));
23859        let err = markdown_to_adf(&md).unwrap_err();
23860        assert!(err.to_string().contains("maximum depth"));
23861    }
23862
23863    #[test]
23864    fn list_nesting_beyond_cap_errors() {
23865        let mut md = String::new();
23866        for i in 0..200 {
23867            md.push_str(&"  ".repeat(i));
23868            md.push_str("- x\n");
23869        }
23870        let err = markdown_to_adf(&md).unwrap_err();
23871        assert!(err.to_string().contains("maximum depth"));
23872    }
23873
23874    #[test]
23875    fn container_directive_nesting_beyond_cap_errors() {
23876        let mut md = String::new();
23877        for _ in 0..200 {
23878            md.push_str(":::panel{type=info}\n");
23879        }
23880        md.push_str("body\n");
23881        for _ in 0..200 {
23882            md.push_str(":::\n");
23883        }
23884        let err = markdown_to_adf(&md).unwrap_err();
23885        assert!(err.to_string().contains("maximum depth"));
23886    }
23887
23888    // ── Unclosed container directives (issue #1734) ──
23889    //
23890    // A `:::name` container directive with no matching close fence must
23891    // return a clean error naming the directive and its start line, not
23892    // panic. `td`/`tr`/`th` and `column` are only meaningful nested inside
23893    // `:::table`/`:::layout`, so they always fall through `try_container_directive`'s
23894    // unhandled-name path when unclosed — previously that left the parser's
23895    // position corrupted, causing an index-out-of-bounds panic in the next
23896    // block-type parser tried.
23897
23898    #[test]
23899    fn bare_td_directive_errors_without_panicking() {
23900        let err = markdown_to_adf(":::td\n").unwrap_err();
23901        let msg = err.to_string();
23902        assert!(msg.contains("Unclosed"));
23903        assert!(msg.contains("td"));
23904    }
23905
23906    #[test]
23907    fn bare_tr_directive_errors_without_panicking() {
23908        let err = markdown_to_adf(":::tr\n").unwrap_err();
23909        let msg = err.to_string();
23910        assert!(msg.contains("Unclosed"));
23911        assert!(msg.contains("tr"));
23912    }
23913
23914    #[test]
23915    fn bare_td_directive_after_blank_lines_errors() {
23916        let err = markdown_to_adf("\n\t\n:::td\n").unwrap_err();
23917        let msg = err.to_string();
23918        assert!(msg.contains("Unclosed"));
23919        assert!(msg.contains("td"));
23920        assert!(msg.contains("line 3"));
23921    }
23922
23923    #[test]
23924    fn bare_column_directive_errors_without_panicking() {
23925        let err = markdown_to_adf(":::column\n").unwrap_err();
23926        let msg = err.to_string();
23927        assert!(msg.contains("Unclosed"));
23928        assert!(msg.contains("column"));
23929    }
23930
23931    #[test]
23932    fn directive_table_cell_nesting_beyond_cap_errors() {
23933        let mut md = String::new();
23934        for _ in 0..200 {
23935            md.push_str("::::table\n:::tr\n:::td\n");
23936        }
23937        md.push_str("cell\n");
23938        for _ in 0..200 {
23939            md.push_str(":::\n:::\n::::\n");
23940        }
23941        let err = markdown_to_adf(&md).unwrap_err();
23942        assert!(err.to_string().contains("maximum depth"));
23943    }
23944
23945    #[test]
23946    fn layout_column_nesting_beyond_cap_errors() {
23947        let mut md = String::new();
23948        for _ in 0..200 {
23949            md.push_str("::::layout\n:::column{width=100}\n");
23950        }
23951        md.push_str("x\n");
23952        for _ in 0..200 {
23953            md.push_str(":::\n::::\n");
23954        }
23955        let err = markdown_to_adf(&md).unwrap_err();
23956        assert!(err.to_string().contains("maximum depth"));
23957    }
23958
23959    #[test]
23960    fn blockquote_nesting_at_cap_boundary() {
23961        // A parser at depth d handles the (d+1)-th `>` level, so exactly
23962        // MAX_NESTING_DEPTH levels succeed and one more errors.
23963        let at_cap = format!("{}x", "> ".repeat(MAX_NESTING_DEPTH));
23964        assert!(markdown_to_adf(&at_cap).is_ok());
23965        let past_cap = format!("{}x", "> ".repeat(MAX_NESTING_DEPTH + 1));
23966        let err = markdown_to_adf(&past_cap).unwrap_err();
23967        assert!(err.to_string().contains("maximum depth"));
23968    }
23969
23970    #[test]
23971    fn list_nesting_at_cap_succeeds() {
23972        // The nested-list path has the largest debug-build stack frames, so
23973        // an at-cap success here proves the cap leaves stack headroom on a
23974        // default 2 MiB test thread (the canary for MAX_NESTING_DEPTH).
23975        let mut md = String::new();
23976        for i in 0..MAX_NESTING_DEPTH {
23977            md.push_str(&"  ".repeat(i));
23978            md.push_str("- x\n");
23979        }
23980        assert!(markdown_to_adf(&md).is_ok());
23981    }
23982
23983    #[test]
23984    fn deep_but_reasonable_nesting_succeeds() {
23985        let md = format!("{}deep", "> ".repeat(20));
23986        let doc = markdown_to_adf(&md).unwrap();
23987        // Walk down the 20 blockquote levels to the paragraph.
23988        let mut node = &doc.content[0];
23989        for _ in 0..20 {
23990            assert_eq!(node.node_type, "blockquote");
23991            node = &node.content.as_ref().unwrap()[0];
23992        }
23993        assert_eq!(node.node_type, "paragraph");
23994    }
23995
23996    #[test]
23997    fn deeply_nested_emphasis_degrades_without_overflow() {
23998        let md = format!("{}x{}", "*_".repeat(400), "_*".repeat(400));
23999        let doc = markdown_to_adf(&md).unwrap();
24000        assert_eq!(doc.content[0].node_type, "paragraph");
24001    }
24002
24003    #[test]
24004    fn deeply_nested_links_degrade_without_overflow() {
24005        let mut md = String::from("x");
24006        for _ in 0..400 {
24007            md = format!("[{md}](https://example.com)");
24008        }
24009        let doc = markdown_to_adf(&md).unwrap();
24010        assert_eq!(doc.content[0].node_type, "paragraph");
24011    }
24012
24013    #[test]
24014    fn deeply_nested_bracketed_spans_degrade_without_overflow() {
24015        let mut md = String::from("x");
24016        for _ in 0..400 {
24017            md = format!("[{md}]{{underline}}");
24018        }
24019        let doc = markdown_to_adf(&md).unwrap();
24020        assert_eq!(doc.content[0].node_type, "paragraph");
24021    }
24022}