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/// Converts a markdown string to an ADF document.
33pub fn markdown_to_adf(markdown: &str) -> Result<AdfDocument> {
34    debug!(
35        "markdown_to_adf: input {} bytes, {} lines",
36        markdown.len(),
37        markdown.lines().count()
38    );
39    let mut doc = AdfDocument::new();
40    let mut parser = MarkdownParser::new(markdown);
41    doc.content = parser.parse_blocks()?;
42    let split = split_emphasis_code_marks(&mut doc.content);
43    if split > 0 {
44        debug!(
45            "markdown_to_adf: dropped {split} emphasis mark(s) from inline-code runs \
46             (ADF forbids combining them with `code`; issue #1391)"
47        );
48    }
49    debug!(
50        "markdown_to_adf: produced {} top-level ADF nodes",
51        doc.content.len()
52    );
53    Ok(doc)
54}
55
56/// Line-oriented state machine for parsing markdown into ADF block nodes.
57struct MarkdownParser<'a> {
58    lines: Vec<&'a str>,
59    pos: usize,
60    /// Nesting depth of this parser: 0 at the document root, +1 for each
61    /// nested parser spawned for container content (blockquote, list item,
62    /// panel, layout column, table cell, …).  Checked against
63    /// [`MAX_NESTING_DEPTH`] in [`Self::parse_blocks`].
64    depth: usize,
65}
66
67impl<'a> MarkdownParser<'a> {
68    fn new(input: &'a str) -> Self {
69        Self::with_depth(input, 0)
70    }
71
72    /// Creates a nested parser for container content at the given depth.
73    fn with_depth(input: &'a str, depth: usize) -> Self {
74        Self {
75            lines: input.lines().collect(),
76            pos: 0,
77            depth,
78        }
79    }
80
81    fn at_end(&self) -> bool {
82        self.pos >= self.lines.len()
83    }
84
85    fn current_line(&self) -> &'a str {
86        self.lines[self.pos]
87    }
88
89    fn advance(&mut self) {
90        self.pos += 1;
91    }
92
93    /// Collects indented continuation lines produced by hardBreaks (issue #402).
94    ///
95    /// When `full_text` ends with a hardBreak marker (trailing backslash or
96    /// two trailing spaces), the next 2-space-indented line is appended as a
97    /// continuation of the same paragraph.  The joined text is later fed to
98    /// `parse_inline`, which converts the `\\\n` or `  \n` sequences back
99    /// into `hardBreak` nodes.
100    fn collect_hardbreak_continuations(&mut self, full_text: &mut String) {
101        while has_trailing_hard_break(full_text) && !self.at_end() {
102            if !self.try_append_hardbreak_continuation(full_text) {
103                break;
104            }
105        }
106    }
107
108    /// If the current line is a valid hardBreak continuation (2-space indented
109    /// and not a block-level sibling marker), append it to `full_text` and
110    /// advance the parser.  Returns `true` on success, `false` otherwise.
111    ///
112    /// Split out from `collect_hardbreak_continuations` so the body appears
113    /// as its own function in coverage reports (issue #552 PR coverage gap).
114    fn try_append_hardbreak_continuation(&mut self, full_text: &mut String) -> bool {
115        // Skip indented block-level siblings — mediaSingle (`![` — issue
116        // #490), fenced code blocks (```` ``` ```` — issue #552), and
117        // container directives (`:::`).  They must stay available for their
118        // dedicated block handlers instead of being merged into paragraph
119        // text.
120        match self
121            .current_line()
122            .strip_prefix("  ")
123            .filter(|s| !is_block_level_continuation_marker(s.trim_start()))
124        {
125            Some(stripped) => {
126                full_text.push('\n');
127                full_text.push_str(stripped);
128                self.advance();
129                true
130            }
131            None => false,
132        }
133    }
134
135    fn parse_blocks(&mut self) -> Result<Vec<AdfNode>> {
136        if self.depth > MAX_NESTING_DEPTH {
137            bail!(
138                "Markdown nesting exceeds the maximum depth of {MAX_NESTING_DEPTH} \
139                 (nested blockquotes, lists, panels, expands, layouts, or table \
140                 cells); flatten the document structure"
141            );
142        }
143
144        let mut blocks = Vec::new();
145
146        while !self.at_end() {
147            let line = self.current_line();
148
149            if line.trim().is_empty() {
150                self.advance();
151                continue;
152            }
153
154            let mut node = if let Some(node) = self.try_heading() {
155                node
156            } else if let Some(node) = self.try_horizontal_rule() {
157                node
158            } else if let Some(node) = self.try_container_directive()? {
159                node
160            } else if let Some(node) = self.try_code_block()? {
161                node
162            } else if let Some(node) = self.try_table()? {
163                node
164            } else if let Some(node) = self.try_blockquote()? {
165                node
166            } else if let Some(node) = self.try_list()? {
167                node
168            } else if let Some(node) = self.try_leaf_directive() {
169                node
170            } else if let Some(node) = self.try_image() {
171                node
172            } else {
173                self.parse_paragraph()?
174            };
175
176            // Check for trailing block-level {attrs} (align, indent, breakout)
177            self.try_apply_block_attrs(&mut node);
178            blocks.push(node);
179        }
180
181        Ok(blocks)
182    }
183
184    fn try_heading(&mut self) -> Option<AdfNode> {
185        let line = self.current_line();
186        let trimmed = line.trim_start();
187
188        if !trimmed.starts_with('#') {
189            return None;
190        }
191
192        let level = trimmed.chars().take_while(|&c| c == '#').count();
193        if !(1..=6).contains(&level) || !trimmed[level..].starts_with(' ') {
194            return None;
195        }
196
197        let mut full_text = trimmed[level + 1..].to_string();
198        self.advance();
199        // Collect indented continuation lines produced by hardBreaks (issue #433).
200        self.collect_hardbreak_continuations(&mut full_text);
201        let mut inline_nodes = parse_inline(&full_text);
202        // ADF's `heading` content model forbids the `code` mark, but
203        // JFM/CommonMark parses inline-code spans inside `#`-headings. Strip
204        // them here (keeping the text as plain) so we don't build a document
205        // the validator only rejects at write time (issue #1005).
206        if strip_heading_code_marks(&mut inline_nodes) > 0 {
207            warn!(
208                "Stripped inline `code` from heading (ADF forbids code marks on \
209                 headings; kept the text as plain): {}",
210                full_text.trim()
211            );
212        }
213
214        #[allow(clippy::cast_possible_truncation)]
215        Some(AdfNode::heading(level as u8, inline_nodes))
216    }
217
218    fn try_horizontal_rule(&mut self) -> Option<AdfNode> {
219        let line = self.current_line().trim();
220        let is_rule = (line.starts_with("---") && line.chars().all(|c| c == '-'))
221            || (line.starts_with("***") && line.chars().all(|c| c == '*'))
222            || (line.starts_with("___") && line.chars().all(|c| c == '_'));
223
224        if is_rule && line.len() >= 3 {
225            self.advance();
226            Some(AdfNode::rule())
227        } else {
228            None
229        }
230    }
231
232    fn try_code_block(&mut self) -> Result<Option<AdfNode>> {
233        let line = self.current_line();
234        if !is_code_fence_opener(line) {
235            return Ok(None);
236        }
237
238        let language = line[3..].trim();
239        let language = if language == "\"\"" {
240            // Explicit empty language attr encoded as ```""
241            Some(String::new())
242        } else if language.is_empty() {
243            None
244        } else {
245            Some(language.to_string())
246        };
247
248        self.advance();
249        let mut code_lines = Vec::new();
250
251        while !self.at_end() {
252            let line = self.current_line();
253            if line.starts_with("```") {
254                self.advance();
255                break;
256            }
257            code_lines.push(line);
258            self.advance();
259        }
260
261        let code_text = code_lines.join("\n");
262
263        // If the language is "adf-unsupported", deserialize the JSON back to an AdfNode
264        if language.as_deref() == Some("adf-unsupported") {
265            if let Ok(node) = serde_json::from_str::<AdfNode>(&code_text) {
266                return Ok(Some(node));
267            }
268        }
269
270        Ok(Some(AdfNode::code_block(language.as_deref(), &code_text)))
271    }
272
273    fn try_blockquote(&mut self) -> Result<Option<AdfNode>> {
274        let line = self.current_line();
275        if !line.starts_with('>') {
276            return Ok(None);
277        }
278
279        let mut quote_lines = Vec::new();
280        while !self.at_end() {
281            let line = self.current_line();
282            if let Some(rest) = line.strip_prefix("> ") {
283                quote_lines.push(rest);
284                self.advance();
285            } else if let Some(rest) = line.strip_prefix('>') {
286                quote_lines.push(rest);
287                self.advance();
288            } else {
289                break;
290            }
291        }
292
293        let quote_text = quote_lines.join("\n");
294        let mut inner_parser = MarkdownParser::with_depth(&quote_text, self.depth + 1);
295        let inner_blocks = inner_parser.parse_blocks()?;
296
297        Ok(Some(AdfNode::blockquote(inner_blocks)))
298    }
299
300    fn try_list(&mut self) -> Result<Option<AdfNode>> {
301        let line = self.current_line();
302        let trimmed = line.trim_start();
303
304        let is_bullet =
305            trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ");
306        let ordered_match = parse_ordered_list_marker(trimmed);
307
308        if !is_bullet && ordered_match.is_none() {
309            return Ok(None);
310        }
311
312        if is_bullet {
313            self.parse_bullet_list()
314        } else {
315            let start = ordered_match.map_or(1, |(n, _)| n);
316            self.parse_ordered_list(start)
317        }
318    }
319
320    fn parse_bullet_list(&mut self) -> Result<Option<AdfNode>> {
321        let mut items = Vec::new();
322        let mut is_task_list = false;
323
324        while !self.at_end() {
325            let line = self.current_line();
326            let trimmed = line.trim_start();
327
328            if !(trimmed.starts_with("- ")
329                || trimmed.starts_with("* ")
330                || trimmed.starts_with("+ "))
331            {
332                break;
333            }
334
335            let after_marker = trimmed[2..].trim_start();
336
337            // Detect task list items: - [ ] or - [x]
338            if let Some((state, text)) = try_parse_task_marker(after_marker) {
339                is_task_list = true;
340                self.advance();
341                // Collect hardBreak continuation lines so that a trailing
342                // {localId=…} on the last continuation line is found by
343                // extract_trailing_local_id (issue #507).
344                let mut full_text = text.to_string();
345                self.collect_hardbreak_continuations(&mut full_text);
346                let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
347                let inline_nodes = parse_inline(item_text);
348                // If a paraLocalId marker is present the original ADF had a
349                // paragraph wrapper around the inline content — restore it
350                // so the round-trip is lossless (issue #478).
351                let content = if let Some(ref plid) = para_local_id {
352                    let mut para = AdfNode::paragraph(inline_nodes);
353                    if plid != "_" {
354                        para.attrs = Some(serde_json::json!({"localId": plid}));
355                    }
356                    vec![para]
357                } else {
358                    inline_nodes
359                };
360                let mut task = AdfNode::task_item(state, content);
361                // Override the placeholder localId if one was parsed
362                if let Some(id) = local_id {
363                    if let Some(ref mut attrs) = task.attrs {
364                        attrs["localId"] = serde_json::Value::String(id);
365                    }
366                }
367                // Collect indented sub-content (e.g. nested task lists
368                // from malformed ADF where taskItem contains taskItem
369                // children directly — issue #489).
370                let mut sub_lines: Vec<String> = Vec::new();
371                while !self.at_end() && self.current_line().starts_with("  ") {
372                    let stripped = &self.current_line()[2..];
373                    sub_lines.push(stripped.to_string());
374                    self.advance();
375                }
376                if !sub_lines.is_empty() {
377                    let sub_text = sub_lines.join("\n");
378                    let mut nested =
379                        MarkdownParser::with_depth(&sub_text, self.depth + 1).parse_blocks()?;
380                    // When the task item has no inline text and its
381                    // sub-content is a single taskList, this is a
382                    // container taskItem from malformed ADF (issue #489).
383                    // Unwrap the taskList so the taskItem children sit
384                    // directly in the container, and drop the spurious
385                    // `state` attr that was injected by the checkbox
386                    // marker.
387                    let is_empty = task.content.as_ref().map_or(true, Vec::is_empty);
388                    if is_empty && nested.len() == 1 && nested[0].node_type == "taskList" {
389                        if let Some(task_items) = nested.remove(0).content {
390                            task.content = Some(task_items);
391                        }
392                        if let Some(ref mut attrs) = task.attrs {
393                            if let Some(obj) = attrs.as_object_mut() {
394                                obj.remove("state");
395                            }
396                        }
397                        items.push(task);
398                    } else {
399                        // Separate nested taskList nodes from other block
400                        // content.  Nested taskLists become sibling children
401                        // of the outer taskList rather than children of this
402                        // taskItem, matching ADF's representation of indented
403                        // sub-lists (issue #506).
404                        let mut sibling_task_lists = Vec::new();
405                        let mut child_nodes = Vec::new();
406                        for n in nested {
407                            if n.node_type == "taskList" {
408                                sibling_task_lists.push(n);
409                            } else {
410                                child_nodes.push(n);
411                            }
412                        }
413                        if !child_nodes.is_empty() {
414                            match task.content {
415                                Some(ref mut content) => content.append(&mut child_nodes),
416                                None => task.content = Some(child_nodes),
417                            }
418                        }
419                        items.push(task);
420                        items.append(&mut sibling_task_lists);
421                    }
422                } else {
423                    items.push(task);
424                }
425            } else {
426                let first_line = &trimmed[2..];
427                self.advance();
428                let mut full_text = first_line.to_string();
429                self.collect_hardbreak_continuations(&mut full_text);
430                let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
431                // Collect indented sub-content lines (2-space prefix).
432                // This captures both nested lists and continuation
433                // paragraphs that belong to the same list item.
434                let mut sub_lines: Vec<String> = Vec::new();
435                while !self.at_end() {
436                    let next = self.current_line();
437                    if let Some(stripped) = next.strip_prefix("  ") {
438                        sub_lines.push(stripped.to_string());
439                        self.advance();
440                        continue;
441                    }
442                    break;
443                }
444                let item_content = parse_list_item_first_line(
445                    item_text,
446                    sub_lines,
447                    local_id,
448                    para_local_id,
449                    self.depth + 1,
450                )?;
451                items.push(item_content);
452            }
453        }
454
455        if items.is_empty() {
456            Ok(None)
457        } else if is_task_list {
458            Ok(Some(AdfNode::task_list(items)))
459        } else {
460            Ok(Some(AdfNode::bullet_list(items)))
461        }
462    }
463
464    fn parse_ordered_list(&mut self, start: u32) -> Result<Option<AdfNode>> {
465        let mut items = Vec::new();
466
467        while !self.at_end() {
468            let line = self.current_line();
469            let trimmed = line.trim_start();
470
471            if let Some((_, rest)) = parse_ordered_list_marker(trimmed) {
472                let first_line = rest.trim_start_matches(|c: char| c.is_ascii_whitespace());
473                self.advance();
474                let mut full_text = first_line.to_string();
475                self.collect_hardbreak_continuations(&mut full_text);
476                let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
477                // Collect indented sub-content lines (2-space prefix).
478                let mut sub_lines: Vec<String> = Vec::new();
479                while !self.at_end() {
480                    let next = self.current_line();
481                    if let Some(stripped) = next.strip_prefix("  ") {
482                        sub_lines.push(stripped.to_string());
483                        self.advance();
484                        continue;
485                    }
486                    break;
487                }
488                let item_content = parse_list_item_first_line(
489                    item_text,
490                    sub_lines,
491                    local_id,
492                    para_local_id,
493                    self.depth + 1,
494                )?;
495                items.push(item_content);
496            } else {
497                break;
498            }
499        }
500
501        if items.is_empty() {
502            Ok(None)
503        } else {
504            let order = if start == 1 { None } else { Some(start) };
505            Ok(Some(AdfNode::ordered_list(items, order)))
506        }
507    }
508
509    fn try_apply_block_attrs(&mut self, node: &mut AdfNode) {
510        if self.at_end() {
511            return;
512        }
513        let line = self.current_line().trim();
514        if !line.starts_with('{') {
515            return;
516        }
517        let Some((_, attrs)) = parse_attrs(line, 0) else {
518            return;
519        };
520
521        let mut marks = Vec::new();
522        if let Some(align) = attrs.get("align") {
523            marks.push(AdfMark::alignment(align));
524        }
525        if let Some(indent) = attrs.get("indent") {
526            if let Ok(level) = indent.parse::<u32>() {
527                marks.push(AdfMark::indentation(level));
528            }
529        }
530        if let Some(mode) = attrs.get("breakout") {
531            let width = attrs
532                .get("breakoutWidth")
533                .and_then(|w| w.parse::<u32>().ok());
534            marks.push(AdfMark::breakout(mode, width));
535        }
536
537        // Parse localId from block attrs
538        let local_id = attrs.get("localId").map(str::to_string);
539
540        // Parse explicit order for orderedList nodes (issue #547).
541        let order = if node.node_type == "orderedList" {
542            attrs.get("order").and_then(|v| v.parse::<u32>().ok())
543        } else {
544            None
545        };
546
547        let has_attrs = !marks.is_empty() || local_id.is_some() || order.is_some();
548        if has_attrs {
549            if !marks.is_empty() {
550                let existing = node.marks.get_or_insert_with(Vec::new);
551                existing.extend(marks);
552            }
553            if let Some(id) = local_id {
554                let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
555                node_attrs["localId"] = serde_json::Value::String(id);
556            }
557            if let Some(n) = order {
558                let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
559                node_attrs["order"] = serde_json::json!(n);
560            }
561            self.advance(); // consume the attrs line
562        }
563    }
564
565    fn try_container_directive(&mut self) -> Result<Option<AdfNode>> {
566        let line = self.current_line();
567        let Some((d, colon_count)) = try_parse_container_open(line) else {
568            return Ok(None);
569        };
570        self.advance(); // past opening fence
571
572        // Collect inner lines until the matching close fence, tracking nesting
573        let mut inner_lines = Vec::new();
574        let mut depth: usize = 0;
575        while !self.at_end() {
576            let current = self.current_line();
577            if try_parse_container_open(current).is_some() {
578                depth += 1;
579            } else if depth == 0 && is_container_close(current, colon_count) {
580                self.advance(); // past closing fence
581                break;
582            } else if depth > 0 && is_container_close(current, 3) {
583                depth -= 1;
584            }
585            inner_lines.push(current.to_string());
586            self.advance();
587        }
588
589        let inner_text = inner_lines.join("\n");
590
591        let node = match d.name.as_str() {
592            "panel" => {
593                let panel_type = d
594                    .attrs
595                    .as_ref()
596                    .and_then(|a| a.get("type"))
597                    .unwrap_or("info");
598                let inner_blocks =
599                    MarkdownParser::with_depth(&inner_text, self.depth + 1).parse_blocks()?;
600                let mut node = AdfNode::panel(panel_type, inner_blocks);
601                // Pass through custom panel attrs (icon, color)
602                if let Some(ref attrs) = d.attrs {
603                    if let Some(ref mut node_attrs) = node.attrs {
604                        if let Some(icon) = attrs.get("icon") {
605                            node_attrs["panelIcon"] = serde_json::Value::String(icon.to_string());
606                        }
607                        if let Some(color) = attrs.get("color") {
608                            node_attrs["panelColor"] = serde_json::Value::String(color.to_string());
609                        }
610                    }
611                }
612                node
613            }
614            "expand" => {
615                let title = d.attrs.as_ref().and_then(|a| a.get("title"));
616                let inner_blocks =
617                    MarkdownParser::with_depth(&inner_text, self.depth + 1).parse_blocks()?;
618                let mut node = AdfNode::expand(title, inner_blocks);
619                pass_through_expand_params(&d.attrs, &mut node);
620                node
621            }
622            "nested-expand" => {
623                let title = d.attrs.as_ref().and_then(|a| a.get("title"));
624                let inner_blocks =
625                    MarkdownParser::with_depth(&inner_text, self.depth + 1).parse_blocks()?;
626                let mut node = AdfNode::nested_expand(title, inner_blocks);
627                pass_through_expand_params(&d.attrs, &mut node);
628                node
629            }
630            "layout" => {
631                // Parse inner content looking for :::column sub-containers
632                let columns = self.parse_layout_columns(&inner_text)?;
633                AdfNode::layout_section(columns)
634            }
635            "decisions" => {
636                let items = parse_decision_items(&inner_text);
637                AdfNode::decision_list(items)
638            }
639            "table" => {
640                let rows = self.parse_directive_table_rows(&inner_text)?;
641                let mut table_attrs = serde_json::json!({});
642                if let Some(ref attrs) = d.attrs {
643                    if let Some(layout) = attrs.get("layout") {
644                        table_attrs["layout"] = serde_json::Value::String(layout.to_string());
645                    }
646                    if attrs.has_flag("numbered") {
647                        table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
648                    } else if attrs.get("numbered") == Some("false") {
649                        table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
650                    }
651                    if let Some(tw) = attrs.get("width") {
652                        if let Some(w) = parse_numeric_attr(tw) {
653                            table_attrs["width"] = w;
654                        }
655                    }
656                    if let Some(local_id) = attrs.get("localId") {
657                        table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
658                    }
659                }
660                if table_attrs == serde_json::json!({}) {
661                    AdfNode::table(rows)
662                } else {
663                    AdfNode::table_with_attrs(rows, table_attrs)
664                }
665            }
666            "extension" => {
667                let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
668                let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
669                let inner_blocks =
670                    MarkdownParser::with_depth(&inner_text, self.depth + 1).parse_blocks()?;
671                let mut node = AdfNode::bodied_extension(ext_type, ext_key, inner_blocks);
672                if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
673                {
674                    if let Some(layout) = dir_attrs.get("layout") {
675                        node_attrs["layout"] = serde_json::Value::String(layout.to_string());
676                    }
677                    if let Some(local_id) = dir_attrs.get("localId") {
678                        node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
679                    }
680                    if let Some(params_str) = dir_attrs.get("params") {
681                        if let Ok(params_val) =
682                            serde_json::from_str::<serde_json::Value>(params_str)
683                        {
684                            node_attrs["parameters"] = params_val;
685                        }
686                    }
687                }
688                node
689            }
690            _ => return Ok(None),
691        };
692
693        Ok(Some(node))
694    }
695
696    fn parse_layout_columns(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
697        let mut columns = Vec::new();
698        let mut current_column_lines: Vec<String> = Vec::new();
699        let mut current_width: serde_json::Value = serde_json::json!(50);
700        let mut current_dir_attrs: Option<crate::atlassian::attrs::Attrs> = None;
701        let mut in_column = false;
702        let mut depth: usize = 0;
703
704        let lines: Vec<&str> = inner_text.lines().collect();
705        let mut i = 0;
706
707        while i < lines.len() {
708            let line = lines[i];
709            if let Some((col_d, _)) = try_parse_container_open(line) {
710                if col_d.name == "column" && depth == 0 {
711                    // Flush previous column
712                    if in_column && !current_column_lines.is_empty() {
713                        let col_text = current_column_lines.join("\n");
714                        let blocks =
715                            MarkdownParser::with_depth(&col_text, self.depth + 1).parse_blocks()?;
716                        let mut col = AdfNode::layout_column(current_width.clone(), blocks);
717                        pass_through_local_id(&current_dir_attrs, &mut col);
718                        columns.push(col);
719                        current_column_lines.clear();
720                    }
721                    current_width = col_d
722                        .attrs
723                        .as_ref()
724                        .and_then(|a| a.get("width"))
725                        .and_then(parse_numeric_attr)
726                        .unwrap_or_else(|| serde_json::json!(50));
727                    current_dir_attrs = col_d.attrs;
728                    in_column = true;
729                    i += 1;
730                    continue;
731                }
732                if in_column {
733                    depth += 1;
734                }
735            }
736            if in_column && is_container_close(line, 3) {
737                if depth > 0 {
738                    depth -= 1;
739                    current_column_lines.push(line.to_string());
740                    i += 1;
741                    continue;
742                }
743                // End of column
744                let col_text = current_column_lines.join("\n");
745                let blocks =
746                    MarkdownParser::with_depth(&col_text, self.depth + 1).parse_blocks()?;
747                let mut col = AdfNode::layout_column(current_width.clone(), blocks);
748                pass_through_local_id(&current_dir_attrs, &mut col);
749                columns.push(col);
750                current_column_lines.clear();
751                current_dir_attrs = None;
752                in_column = false;
753                i += 1;
754                continue;
755            }
756            if in_column {
757                current_column_lines.push(line.to_string());
758            }
759            i += 1;
760        }
761
762        // Flush last column if no closing fence
763        if in_column && !current_column_lines.is_empty() {
764            let col_text = current_column_lines.join("\n");
765            let blocks = MarkdownParser::with_depth(&col_text, self.depth + 1).parse_blocks()?;
766            let mut col = AdfNode::layout_column(current_width, blocks);
767            pass_through_local_id(&current_dir_attrs, &mut col);
768            columns.push(col);
769        }
770
771        Ok(columns)
772    }
773
774    /// Parses `:::tr` / `:::th` / `:::td` sub-containers inside a `:::table` directive.
775    fn parse_directive_table_rows(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
776        debug!(
777            "parse_directive_table_rows: {} lines of inner text",
778            inner_text.lines().count()
779        );
780        let mut rows = Vec::new();
781        let lines: Vec<&str> = inner_text.lines().collect();
782        let mut i = 0;
783
784        while i < lines.len() {
785            let line = lines[i];
786            if let Some((d, _)) = try_parse_container_open(line) {
787                if d.name == "tr" {
788                    let tr_attrs = d.attrs.clone();
789                    i += 1;
790                    let (mut row, next_i) = self.parse_directive_table_row(&lines, i)?;
791                    // Pass through localId from :::tr{localId=...}
792                    if let Some(ref attrs) = tr_attrs {
793                        if let Some(local_id) = attrs.get("localId") {
794                            let row_attrs = row.attrs.get_or_insert_with(|| serde_json::json!({}));
795                            row_attrs["localId"] = serde_json::Value::String(local_id.to_string());
796                        }
797                    }
798                    rows.push(row);
799                    i = next_i;
800                    continue;
801                }
802                if d.name == "caption" {
803                    let dir_attrs = d.attrs.clone();
804                    i += 1;
805                    let mut caption_lines = Vec::new();
806                    while i < lines.len() {
807                        if is_container_close(lines[i], 3) {
808                            i += 1;
809                            break;
810                        }
811                        caption_lines.push(lines[i]);
812                        i += 1;
813                    }
814                    let caption_text = caption_lines.join("\n");
815                    let inline_nodes = parse_inline(&caption_text);
816                    let mut caption = AdfNode::caption(inline_nodes);
817                    pass_through_local_id(&dir_attrs, &mut caption);
818                    rows.push(caption);
819                    continue;
820                }
821            }
822            i += 1;
823        }
824
825        Ok(rows)
826    }
827
828    /// Parses cells within a `:::tr` container until its closing fence.
829    fn parse_directive_table_row(&self, lines: &[&str], start: usize) -> Result<(AdfNode, usize)> {
830        let mut cells = Vec::new();
831        let mut i = start;
832        let mut depth: usize = 0;
833
834        while i < lines.len() {
835            let line = lines[i];
836            if is_container_close(line, 3) {
837                if depth == 0 {
838                    // End of :::tr
839                    i += 1;
840                    break;
841                }
842                depth -= 1;
843                i += 1;
844                continue;
845            }
846            if let Some((d, _)) = try_parse_container_open(line) {
847                if depth == 0 && (d.name == "th" || d.name == "td") {
848                    let is_header = d.name == "th";
849                    let cell_attrs = d.attrs.clone();
850                    i += 1;
851                    let (cell, next_i) =
852                        self.parse_directive_table_cell(lines, i, is_header, cell_attrs)?;
853                    cells.push(cell);
854                    i = next_i;
855                    continue;
856                }
857                depth += 1;
858            }
859            i += 1;
860        }
861
862        if cells.is_empty() {
863            let context = lines[start.saturating_sub(1)..lines.len().min(start + 3)].to_vec();
864            warn!(
865                "Directive table row at line {start} has no cells — \
866                 Confluence requires at least one. Nearby lines: {context:?}"
867            );
868        }
869        debug!("Parsed directive table row: {} cells", cells.len());
870
871        Ok((AdfNode::table_row(cells), i))
872    }
873
874    /// Parses the content of a `:::th` or `:::td` cell until its closing fence.
875    fn parse_directive_table_cell(
876        &self,
877        lines: &[&str],
878        start: usize,
879        is_header: bool,
880        cell_attrs: Option<crate::atlassian::attrs::Attrs>,
881    ) -> Result<(AdfNode, usize)> {
882        let mut cell_lines = Vec::new();
883        let mut i = start;
884        let mut depth: usize = 0;
885
886        while i < lines.len() {
887            let line = lines[i];
888            if try_parse_container_open(line).is_some() {
889                depth += 1;
890            } else if is_container_close(line, 3) {
891                if depth == 0 {
892                    i += 1;
893                    break;
894                }
895                depth -= 1;
896            }
897            cell_lines.push(line.to_string());
898            i += 1;
899        }
900
901        let cell_text = cell_lines.join("\n");
902        let blocks = MarkdownParser::with_depth(&cell_text, self.depth + 1).parse_blocks()?;
903
904        let adf_attrs = cell_attrs.as_ref().map(build_cell_attrs);
905        let cell_marks = cell_attrs
906            .as_ref()
907            .map(build_border_marks)
908            .unwrap_or_default();
909
910        let cell = if cell_marks.is_empty() {
911            if is_header {
912                if let Some(attrs) = adf_attrs {
913                    AdfNode::table_header_with_attrs(blocks, attrs)
914                } else {
915                    AdfNode::table_header(blocks)
916                }
917            } else if let Some(attrs) = adf_attrs {
918                AdfNode::table_cell_with_attrs(blocks, attrs)
919            } else {
920                AdfNode::table_cell(blocks)
921            }
922        } else if is_header {
923            AdfNode::table_header_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
924        } else {
925            AdfNode::table_cell_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
926        };
927
928        Ok((cell, i))
929    }
930
931    fn try_leaf_directive(&mut self) -> Option<AdfNode> {
932        let line = self.current_line();
933        let d = try_parse_leaf_directive(line)?;
934
935        let node = match d.name.as_str() {
936            "card" => {
937                let content = d.content.as_deref().unwrap_or("");
938                // Prefer the `url` attribute when present; fall back to the
939                // bracketed content.  The attribute form is used when the URL
940                // contains characters that would otherwise break
941                // `::card[URL]` parsing.
942                let url = match d.attrs.as_ref().and_then(|a| a.get("url")) {
943                    Some(u) => u,
944                    None => content,
945                };
946                let mut node = AdfNode::block_card(url);
947                // Pass through layout/width attrs
948                if let Some(ref attrs) = d.attrs {
949                    if let Some(ref mut node_attrs) = node.attrs {
950                        if let Some(layout) = attrs.get("layout") {
951                            node_attrs["layout"] = serde_json::Value::String(layout.to_string());
952                        }
953                        if let Some(width) = attrs.get("width") {
954                            if let Ok(w) = width.parse::<u64>() {
955                                node_attrs["width"] = serde_json::json!(w);
956                            }
957                        }
958                    }
959                }
960                node
961            }
962            "embed" => {
963                let url = d.content.as_deref().unwrap_or("");
964                let layout = d.attrs.as_ref().and_then(|a| a.get("layout"));
965                let original_height = d
966                    .attrs
967                    .as_ref()
968                    .and_then(|a| a.get("originalHeight"))
969                    .and_then(|v| v.parse::<f64>().ok());
970                let width = d
971                    .attrs
972                    .as_ref()
973                    .and_then(|a| a.get("width"))
974                    .and_then(|w| w.parse::<f64>().ok());
975                AdfNode::embed_card(url, layout, original_height, width)
976            }
977            "extension" => {
978                let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
979                let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
980                let params = d
981                    .attrs
982                    .as_ref()
983                    .and_then(|a| a.get("params"))
984                    .and_then(|p| serde_json::from_str(p).ok());
985                let mut node = AdfNode::extension(ext_type, ext_key, params);
986                if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
987                {
988                    if let Some(layout) = dir_attrs.get("layout") {
989                        node_attrs["layout"] = serde_json::Value::String(layout.to_string());
990                    }
991                    if let Some(local_id) = dir_attrs.get("localId") {
992                        node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
993                    }
994                }
995                node
996            }
997            "paragraph" => {
998                let mut node = if let Some(ref text) = d.content {
999                    AdfNode::paragraph(parse_inline(text))
1000                } else {
1001                    AdfNode::paragraph(vec![])
1002                };
1003                pass_through_local_id(&d.attrs, &mut node);
1004                node
1005            }
1006            _ => return None,
1007        };
1008
1009        self.advance();
1010        Some(node)
1011    }
1012
1013    fn try_image(&mut self) -> Option<AdfNode> {
1014        let line = self.current_line().trim();
1015        let mut node = try_parse_media_single_from_line(line)?;
1016        self.advance();
1017
1018        // Check for a trailing :::caption directive
1019        if !self.at_end() {
1020            if let Some((d, _)) = try_parse_container_open(self.current_line()) {
1021                if d.name == "caption" {
1022                    let dir_attrs = d.attrs;
1023                    self.advance(); // past :::caption
1024                    let mut caption_lines = Vec::new();
1025                    while !self.at_end() {
1026                        if is_container_close(self.current_line(), 3) {
1027                            self.advance(); // past :::
1028                            break;
1029                        }
1030                        caption_lines.push(self.current_line());
1031                        self.advance();
1032                    }
1033                    let caption_text = caption_lines.join("\n");
1034                    let inline_nodes = parse_inline(&caption_text);
1035                    let mut caption = AdfNode::caption(inline_nodes);
1036                    pass_through_local_id(&dir_attrs, &mut caption);
1037                    if let Some(ref mut content) = node.content {
1038                        content.push(caption);
1039                    }
1040                }
1041            }
1042        }
1043
1044        Some(node)
1045    }
1046
1047    fn try_table(&mut self) -> Result<Option<AdfNode>> {
1048        let line = self.current_line();
1049        if !line.contains('|') || !line.trim_start().starts_with('|') {
1050            return Ok(None);
1051        }
1052
1053        // Peek ahead to check for a separator row (indicates a table)
1054        if self.pos + 1 >= self.lines.len() {
1055            return Ok(None);
1056        }
1057        let next_line = self.lines[self.pos + 1];
1058        if !is_table_separator(next_line) {
1059            return Ok(None);
1060        }
1061
1062        // Parse header row
1063        let header_cells = parse_table_row(line);
1064        self.advance(); // skip header
1065
1066        // Parse separator row for column alignment
1067        let sep_line = self.current_line();
1068        let alignments = parse_table_alignments(sep_line);
1069        self.advance(); // skip separator
1070
1071        let mut rows = Vec::new();
1072
1073        // Header row — parse cell attrs and apply column alignment
1074        let header_adf_cells: Vec<AdfNode> = header_cells
1075            .iter()
1076            .enumerate()
1077            .map(|(col_idx, cell)| {
1078                let (cell_text, cell_attrs) = extract_cell_attrs(cell);
1079                let mut para = AdfNode::paragraph(parse_inline(&cell_text));
1080                apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
1081                if let Some(attrs) = cell_attrs {
1082                    AdfNode::table_header_with_attrs(vec![para], attrs)
1083                } else {
1084                    AdfNode::table_header(vec![para])
1085                }
1086            })
1087            .collect();
1088        if header_adf_cells.is_empty() {
1089            warn!(
1090                "Pipe table header row at line {} has no cells",
1091                self.pos - 1
1092            );
1093        }
1094        rows.push(AdfNode::table_row(header_adf_cells));
1095
1096        // Body rows
1097        while !self.at_end() {
1098            let line = self.current_line();
1099            if !line.contains('|') || line.trim().is_empty() {
1100                break;
1101            }
1102
1103            let cells = parse_table_row(line);
1104            let adf_cells: Vec<AdfNode> = cells
1105                .iter()
1106                .enumerate()
1107                .map(|(col_idx, cell)| {
1108                    let (cell_text, cell_attrs) = extract_cell_attrs(cell);
1109                    let mut para = AdfNode::paragraph(parse_inline(&cell_text));
1110                    apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
1111                    if let Some(attrs) = cell_attrs {
1112                        AdfNode::table_cell_with_attrs(vec![para], attrs)
1113                    } else {
1114                        AdfNode::table_cell(vec![para])
1115                    }
1116                })
1117                .collect();
1118            if adf_cells.is_empty() {
1119                warn!("Pipe table body row at line {} has no cells", self.pos);
1120            }
1121            rows.push(AdfNode::table_row(adf_cells));
1122            self.advance();
1123        }
1124
1125        debug!("Parsed pipe table with {} rows", rows.len());
1126        let mut table = AdfNode::table(rows);
1127
1128        // Check for trailing {attrs} on the next line
1129        if !self.at_end() {
1130            let next = self.current_line().trim();
1131            if next.starts_with('{') {
1132                if let Some((_, attrs)) = parse_attrs(next, 0) {
1133                    let mut table_attrs = serde_json::json!({});
1134                    if let Some(layout) = attrs.get("layout") {
1135                        table_attrs["layout"] = serde_json::Value::String(layout.to_string());
1136                    }
1137                    if attrs.has_flag("numbered") {
1138                        table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
1139                    } else if attrs.get("numbered") == Some("false") {
1140                        table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
1141                    }
1142                    if let Some(tw) = attrs.get("width") {
1143                        if let Some(w) = parse_numeric_attr(tw) {
1144                            table_attrs["width"] = w;
1145                        }
1146                    }
1147                    if let Some(local_id) = attrs.get("localId") {
1148                        table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1149                    }
1150                    if table_attrs != serde_json::json!({}) {
1151                        table.attrs = Some(table_attrs);
1152                        self.advance(); // consume the attrs line
1153                    }
1154                }
1155            }
1156        }
1157
1158        Ok(Some(table))
1159    }
1160
1161    fn parse_paragraph(&mut self) -> Result<AdfNode> {
1162        let mut lines: Vec<&str> = Vec::new();
1163
1164        while !self.at_end() {
1165            let line = self.current_line();
1166            // Only break on block-level patterns if we already have paragraph
1167            // content. This prevents infinite loops when a line looks like a
1168            // block starter but doesn't actually match any block parser (e.g.,
1169            // "#NoSpace" which is not a valid heading).
1170            // Issue #494: A whitespace-only line that follows a hardBreak
1171            // marker (trailing backslash or two trailing spaces) is a
1172            // continuation, not a paragraph break.  Let it fall through to
1173            // the `is_hardbreak_cont` check below.
1174            if (line.trim().is_empty()
1175                && !lines
1176                    .last()
1177                    .is_some_and(|prev| has_trailing_hard_break(prev)))
1178                || is_code_fence_opener(line)
1179                || (is_horizontal_rule(line) && !lines.is_empty())
1180            {
1181                break;
1182            }
1183            // Strip 2-space indent from hardBreak continuation lines so
1184            // the content round-trips correctly (issue #455).
1185            let is_hardbreak_cont = !lines.is_empty()
1186                && line.starts_with("  ")
1187                && lines
1188                    .last()
1189                    .is_some_and(|prev| has_trailing_hard_break(prev));
1190            if is_hardbreak_cont {
1191                lines.push(&line[2..]);
1192                self.advance();
1193                continue;
1194            }
1195            if !lines.is_empty()
1196                && (line.starts_with('#') || line.starts_with('>') || is_list_start(line))
1197            {
1198                break;
1199            }
1200            // Break on trailing block attrs like {align=center}
1201            if !lines.is_empty() && is_block_attrs_line(line) {
1202                break;
1203            }
1204            lines.push(line);
1205            self.advance();
1206        }
1207
1208        let text = lines.join("\n");
1209        let inline_nodes = parse_inline(&text);
1210        Ok(AdfNode::paragraph(inline_nodes))
1211    }
1212}
1213
1214/// Builds ADF cell attributes from JFM directive attrs.
1215/// Maps: `bg` → `background`, `colspan` → number, `rowspan` → number, `colwidth` → array.
1216fn build_cell_attrs(attrs: &crate::atlassian::attrs::Attrs) -> serde_json::Value {
1217    let mut adf = serde_json::json!({});
1218    if let Some(bg) = attrs.get("bg") {
1219        adf["background"] = serde_json::Value::String(bg.to_string());
1220    }
1221    if let Some(colspan) = attrs.get("colspan") {
1222        if let Ok(n) = colspan.parse::<u32>() {
1223            adf["colspan"] = serde_json::json!(n);
1224        }
1225    }
1226    if let Some(rowspan) = attrs.get("rowspan") {
1227        if let Ok(n) = rowspan.parse::<u32>() {
1228            adf["rowspan"] = serde_json::json!(n);
1229        }
1230    }
1231    if let Some(colwidth) = attrs.get("colwidth") {
1232        let widths: Vec<serde_json::Value> = colwidth
1233            .split(',')
1234            .filter_map(|s| parse_numeric_attr(s.trim()))
1235            .collect();
1236        if !widths.is_empty() {
1237            adf["colwidth"] = serde_json::Value::Array(widths);
1238        }
1239    }
1240    if let Some(local_id) = attrs.get("localId") {
1241        adf["localId"] = serde_json::Value::String(local_id.to_string());
1242    }
1243    adf
1244}
1245
1246/// Extracts border marks from directive attributes (used by table cells and media nodes).
1247fn build_border_marks(attrs: &crate::atlassian::attrs::Attrs) -> Vec<AdfMark> {
1248    let mut marks = Vec::new();
1249    let border_color = attrs.get("border-color");
1250    let border_size = attrs.get("border-size");
1251    if border_color.is_some() || border_size.is_some() {
1252        let color = border_color.unwrap_or("#000000");
1253        let size = border_size.and_then(|s| s.parse::<u32>().ok()).unwrap_or(1);
1254        marks.push(AdfMark::border(color, size));
1255    }
1256    marks
1257}
1258
1259/// Converts an ISO 8601 date string (e.g., "2026-04-15") to epoch milliseconds string.
1260/// If the input is already numeric (epoch ms), returns it unchanged.
1261fn iso_date_to_epoch_ms(date_str: &str) -> String {
1262    // If it's already a numeric timestamp, pass through
1263    if date_str.chars().all(|c| c.is_ascii_digit()) {
1264        return date_str.to_string();
1265    }
1266    if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
1267        let epoch_ms = date
1268            .and_hms_opt(0, 0, 0)
1269            .map_or(0, |dt| dt.and_utc().timestamp_millis());
1270        epoch_ms.to_string()
1271    } else {
1272        // Fallback: pass through as-is
1273        date_str.to_string()
1274    }
1275}
1276
1277/// Converts an epoch milliseconds string to an ISO 8601 date string.
1278/// If the input looks like an ISO date already, returns it unchanged.
1279fn epoch_ms_to_iso_date(timestamp: &str) -> String {
1280    // If it looks like an ISO date already, pass through
1281    if timestamp.contains('-') {
1282        return timestamp.to_string();
1283    }
1284    if let Ok(ms) = timestamp.parse::<i64>() {
1285        let secs = ms / 1000;
1286        if let Some(dt) = chrono::DateTime::from_timestamp(secs, 0) {
1287            return dt.format("%Y-%m-%d").to_string();
1288        }
1289    }
1290    // Fallback: pass through
1291    timestamp.to_string()
1292}
1293
1294/// Checks if a line is a standalone block-level attrs line like `{align=center}`.
1295fn is_block_attrs_line(line: &str) -> bool {
1296    let trimmed = line.trim();
1297    if !trimmed.starts_with('{') || !trimmed.ends_with('}') {
1298        return false;
1299    }
1300    if let Some((_, attrs)) = parse_attrs(trimmed, 0) {
1301        // Only consider it a block attrs line if it has recognized block attrs
1302        attrs.get("align").is_some()
1303            || attrs.get("indent").is_some()
1304            || attrs.get("breakout").is_some()
1305            || attrs.get("breakoutWidth").is_some()
1306            || attrs.get("localId").is_some()
1307    } else {
1308        false
1309    }
1310}
1311
1312/// Parses decision items from the inner content of a `:::decisions` container.
1313/// Each item starts with `- <> ` prefix.
1314fn parse_decision_items(text: &str) -> Vec<AdfNode> {
1315    let mut items = Vec::new();
1316    for line in text.lines() {
1317        let trimmed = line.trim();
1318        if let Some(rest) = trimmed.strip_prefix("- <> ") {
1319            let inline_nodes = parse_inline(rest);
1320            items.push(AdfNode::decision_item("DECIDED", inline_nodes));
1321        }
1322    }
1323    items
1324}
1325
1326/// Tries to parse a task list marker `[ ]`, `[x]`, or `[X]` at the start of text.
1327/// Returns `("TODO"|"DONE", remaining_text)` on success.
1328///
1329/// The marker must be followed by a space or the end of the text, so that
1330/// empty task items (`- [ ]` with no body) are still recognised as tasks
1331/// rather than being treated as bullet items containing literal `[ ]` text
1332/// (issue #548).
1333fn try_parse_task_marker(text: &str) -> Option<(&str, &str)> {
1334    if let Some(rest) = strip_task_checkbox(text, "[ ]") {
1335        Some(("TODO", rest))
1336    } else if let Some(rest) =
1337        strip_task_checkbox(text, "[x]").or_else(|| strip_task_checkbox(text, "[X]"))
1338    {
1339        Some(("DONE", rest))
1340    } else {
1341        None
1342    }
1343}
1344
1345/// Strips a checkbox prefix from `text` if the character after the checkbox
1346/// is a space or the text ends there.  Returns the remaining text (with the
1347/// separating space consumed, if any).
1348fn strip_task_checkbox<'a>(text: &'a str, checkbox: &str) -> Option<&'a str> {
1349    let rest = text.strip_prefix(checkbox)?;
1350    if rest.is_empty() {
1351        Some(rest)
1352    } else {
1353        rest.strip_prefix(' ')
1354    }
1355}
1356
1357/// Returns true if `s` begins with a sequence the bullet-list parser would
1358/// interpret as a task checkbox marker (`[ ]`, `[x]`, or `[X]` followed by
1359/// a space, newline, or end-of-input).
1360///
1361/// Used by the `bulletList` renderer to decide whether to escape the leading
1362/// `[` of an item whose literal text starts with a checkbox-shaped prefix
1363/// (issue #548).
1364fn starts_with_task_marker(s: &str) -> bool {
1365    let after = if let Some(rest) = s.strip_prefix("[ ]") {
1366        rest
1367    } else if let Some(rest) = s.strip_prefix("[x]").or_else(|| s.strip_prefix("[X]")) {
1368        rest
1369    } else {
1370        return false;
1371    };
1372    after.is_empty() || after.starts_with(' ') || after.starts_with('\n')
1373}
1374
1375/// Parses an ordered list marker like "1. " and returns (number, rest_of_line).
1376fn parse_ordered_list_marker(line: &str) -> Option<(u32, &str)> {
1377    let digit_end = line.find(|c: char| !c.is_ascii_digit())?;
1378    if digit_end == 0 {
1379        return None;
1380    }
1381    let rest = &line[digit_end..];
1382    let after_marker = rest.strip_prefix(". ")?;
1383    let num: u32 = line[..digit_end].parse().ok()?;
1384    Some((num, after_marker))
1385}
1386
1387/// Returns true if a line ends with a hardBreak marker
1388/// (trailing backslash or two trailing spaces).
1389fn has_trailing_hard_break(line: &str) -> bool {
1390    line.ends_with('\\') || line.ends_with("  ")
1391}
1392
1393/// Returns true if the already-trimmed continuation line starts with a
1394/// block-level marker that must not be swallowed as a paragraph continuation
1395/// in `collect_hardbreak_continuations`.
1396///
1397/// Covers mediaSingle (`![` — issue #490), fenced code blocks (```` ``` ````
1398/// — issue #552), and container directives (`:::`).  The caller is expected
1399/// to have already stripped leading whitespace.
1400fn is_block_level_continuation_marker(trimmed: &str) -> bool {
1401    trimmed.starts_with("![") || trimmed.starts_with("```") || trimmed.starts_with(":::")
1402}
1403
1404/// Checks if a line starts a list item.
1405fn is_list_start(line: &str) -> bool {
1406    let trimmed = line.trim_start();
1407    trimmed.starts_with("- ")
1408        || trimmed.starts_with("* ")
1409        || trimmed.starts_with("+ ")
1410        || parse_ordered_list_marker(trimmed).is_some()
1411}
1412
1413/// Escapes asterisk and underscore sequences in text that would otherwise be
1414/// parsed as CommonMark emphasis (`*…*`, `_…_`) or strong emphasis (`**…**`,
1415/// `__…__`).
1416///
1417/// Asterisks are always escaped (they're rare in prose and the JFM parser
1418/// will gladly match them across node boundaries). Underscores are escaped
1419/// per the intraword rule: a `_` is left as-is only when it's clearly
1420/// intraword *within this text node* (alphanumeric on both sides). At the
1421/// node boundary or next to non-alphanumeric characters we escape, since
1422/// adjacent text nodes can supply the other side of an emphasis pair (issue
1423/// #554: `"_ "` followed by colored `"_Action…"` produced `_ :span[_…` which
1424/// parsed as italic and destroyed the span directive).
1425fn escape_emphasis_markers(text: &str) -> String {
1426    escape_emphasis_with(text, false)
1427}
1428
1429/// Variant of [`escape_emphasis_markers`] that escapes ALL underscores (even
1430/// intraword), not just boundary ones.
1431///
1432/// Must be used whenever the rendered markdown wraps this text in an `_..._`
1433/// em delimiter, because an unescaped `_` anywhere in the content would
1434/// otherwise close the delimiter prematurely (e.g. `_foo_bar_baz_` parses as
1435/// em("foo") + "bar" + em("baz"), not em("foo_bar_baz")).
1436fn escape_emphasis_markers_with_underscore(text: &str) -> String {
1437    escape_emphasis_with(text, true)
1438}
1439
1440/// Internal: escapes `*` always, and escapes `_` per the CommonMark intraword
1441/// rule by default — boundary or punctuation-adjacent runs are escaped, fully
1442/// intraword runs are left as-is.  When `escape_underscore_always` is true,
1443/// every `_` is escaped regardless (used when the surrounding context is an
1444/// `_..._` em delimiter that any inner `_` would close prematurely).
1445fn escape_emphasis_with(text: &str, escape_underscore_always: bool) -> String {
1446    let chars: Vec<char> = text.chars().collect();
1447    let mut out = String::with_capacity(text.len());
1448    let mut idx = 0;
1449    while idx < chars.len() {
1450        let ch = chars[idx];
1451        if ch == '*' {
1452            out.push('\\');
1453            out.push(ch);
1454            idx += 1;
1455        } else if ch == '_' {
1456            // Find the extent of this run of underscores. CommonMark treats
1457            // consecutive `_` as a single delimiter run, so the intraword
1458            // check applies to the whole run, not individual characters.
1459            let run_start = idx;
1460            let mut run_end = idx;
1461            while run_end < chars.len() && chars[run_end] == '_' {
1462                run_end += 1;
1463            }
1464            let escape_run = if escape_underscore_always {
1465                true
1466            } else {
1467                let before_alnum = run_start > 0 && chars[run_start - 1].is_alphanumeric();
1468                let after_alnum = chars.get(run_end).is_some_and(|c| c.is_alphanumeric());
1469                !(before_alnum && after_alnum)
1470            };
1471            for _ in run_start..run_end {
1472                if escape_run {
1473                    out.push('\\');
1474                }
1475                out.push('_');
1476            }
1477            idx = run_end;
1478        } else {
1479            out.push(ch);
1480            idx += 1;
1481        }
1482    }
1483    out
1484}
1485
1486/// Escapes backtick characters in text that would otherwise be parsed as
1487/// inline code spans (`` `…` ``).
1488///
1489/// Each backtick is prefixed with a backslash so that the JFM parser treats
1490/// it as a literal character rather than an inline-code delimiter.
1491fn escape_backticks(text: &str) -> String {
1492    let mut out = String::with_capacity(text.len());
1493    for ch in text.chars() {
1494        if ch == '`' {
1495            out.push('\\');
1496        }
1497        out.push(ch);
1498    }
1499    out
1500}
1501
1502/// Chooses a backtick delimiter length and padding flag for rendering `text`
1503/// as a CommonMark inline code span.
1504///
1505/// Per CommonMark: the delimiter must be a run of backticks not equal in
1506/// length to any run inside the content, and if both ends of the content
1507/// would start/end with a space (or with a backtick), a single space of
1508/// padding is added so the span survives the spec's space-stripping rule.
1509fn inline_code_delimiter(text: &str) -> (usize, bool) {
1510    let mut max_run = 0usize;
1511    let mut current = 0usize;
1512    for ch in text.chars() {
1513        if ch == '`' {
1514            current += 1;
1515            if current > max_run {
1516                max_run = current;
1517            }
1518        } else {
1519            current = 0;
1520        }
1521    }
1522    let n = max_run + 1;
1523    let starts_bt = text.starts_with('`');
1524    let ends_bt = text.ends_with('`');
1525    let starts_sp = text.starts_with(' ');
1526    let ends_sp = text.ends_with(' ');
1527    let all_sp = !text.is_empty() && text.chars().all(|c| c == ' ');
1528    let needs_pad = starts_bt || ends_bt || (starts_sp && ends_sp && !all_sp);
1529    (n, needs_pad)
1530}
1531
1532/// Appends `text` to `output` wrapped in a CommonMark inline code span whose
1533/// delimiter length allows any embedded backticks to round-trip unambiguously.
1534fn render_inline_code(text: &str, output: &mut String) {
1535    let (n, pad) = inline_code_delimiter(text);
1536    for _ in 0..n {
1537        output.push('`');
1538    }
1539    if pad {
1540        output.push(' ');
1541    }
1542    output.push_str(text);
1543    if pad {
1544        output.push(' ');
1545    }
1546    for _ in 0..n {
1547        output.push('`');
1548    }
1549}
1550
1551/// Escapes pipe characters in text that appears inside a GFM pipe table cell.
1552///
1553/// Without this, a literal `|` in cell content (including inside inline code
1554/// spans) is interpreted as a column separator on round-trip, splitting the
1555/// cell and corrupting its content (see issue #579).  Each `|` is prefixed
1556/// with a backslash so the table-row parser treats it as literal.
1557fn escape_pipes_in_cell(text: &str) -> String {
1558    let mut out = String::with_capacity(text.len());
1559    for ch in text.chars() {
1560        if ch == '|' {
1561            out.push('\\');
1562        }
1563        out.push(ch);
1564    }
1565    out
1566}
1567
1568/// Escapes square brackets (`[` and `]`) in text that will appear inside a
1569/// markdown link's `[…]` delimiters.  Without this, a text node containing a
1570/// literal `[` or `]` can create ambiguous markdown link syntax on round-trip
1571/// (see issue #493).
1572fn escape_link_brackets(text: &str) -> String {
1573    let mut out = String::with_capacity(text.len());
1574    for ch in text.chars() {
1575        if ch == '[' || ch == ']' {
1576            out.push('\\');
1577        }
1578        out.push(ch);
1579    }
1580    out
1581}
1582
1583/// Escapes bare URLs (`http://` and `https://`) in plain text so they are not
1584/// parsed as `inlineCard` nodes during round-trip.  The leading `h` is
1585/// backslash-escaped, which is enough to prevent the auto-link detector from
1586/// matching the URL while the existing backslash-escape handler restores it on
1587/// re-parse.
1588fn escape_bare_urls(text: &str) -> String {
1589    let mut result = String::with_capacity(text.len());
1590    for (i, ch) in text.char_indices() {
1591        if ch == 'h' {
1592            let rest = &text[i..];
1593            if rest.starts_with("http://") || rest.starts_with("https://") {
1594                result.push('\\');
1595            }
1596        }
1597        result.push(ch);
1598    }
1599    result
1600}
1601
1602/// Returns `true` if the string can be embedded in a `:card[...]` (or similar
1603/// bracketed inline directive) without breaking the depth-based bracket matcher
1604/// used by [`try_parse_inline_directive`].
1605///
1606/// The parser scans the content enclosed in `[...]` treating `[` as +1 and `]`
1607/// as −1 on depth, closing when depth returns to zero.  A value is safe if
1608/// every prefix has `count('[') >= count(']') − 1` (i.e., the running depth
1609/// never dips below zero before the end) and it contains no newline.
1610fn url_safe_in_bracket_content(s: &str) -> bool {
1611    if s.contains('\n') {
1612        return false;
1613    }
1614    let mut depth: i32 = 1;
1615    for ch in s.chars() {
1616        match ch {
1617            '[' => depth += 1,
1618            ']' => {
1619                depth -= 1;
1620                if depth == 0 {
1621                    return false;
1622                }
1623            }
1624            _ => {}
1625        }
1626    }
1627    true
1628}
1629
1630/// Escapes emoji shortcode patterns (`:name:`) in plain text so they are not
1631/// parsed as emoji nodes during round-trip.  Only the leading colon is
1632/// backslash-escaped, which is enough to prevent the parser from matching the
1633/// pattern while the existing backslash-escape handler restores it on re-parse.
1634///
1635/// The character class for the name segment must match `try_parse_emoji_shortcode`
1636/// exactly (Unicode `is_alphanumeric` plus `_`, `+`, `-`).  An ASCII-only escape
1637/// would leave Unicode patterns like `:Café:` or `:ZBC::Acme::配置:` un-escaped
1638/// while still being detected as emoji on re-parse, splitting the text node
1639/// (issue #552).
1640fn escape_emoji_shortcodes(text: &str) -> String {
1641    let mut result = String::with_capacity(text.len());
1642
1643    for (i, ch) in text.char_indices() {
1644        if ch == ':' {
1645            // Check if this is a `:name:` pattern where name matches the
1646            // same character class accepted by `try_parse_emoji_shortcode`.
1647            let after = i + 1;
1648            if after < text.len() {
1649                let name_end = text[after..]
1650                    .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')
1651                    .map_or(text[after..].len(), |pos| pos);
1652                if name_end > 0
1653                    && after + name_end < text.len()
1654                    && text.as_bytes()[after + name_end] == b':'
1655                {
1656                    // Found `:name:` pattern — escape the leading colon
1657                    result.push('\\');
1658                }
1659            }
1660        }
1661        result.push(ch);
1662    }
1663
1664    result
1665}
1666
1667/// Escapes a leading list-marker pattern on a line so it is not
1668/// re-parsed as a new list item.  `"2. text"` → `"2\. text"`,
1669/// `"- text"` → `"\- text"`.
1670fn escape_list_marker(line: &str) -> String {
1671    if let Some(dot_pos) = line.find(". ") {
1672        if parse_ordered_list_marker(line).is_some() {
1673            let mut s = String::with_capacity(line.len() + 1);
1674            s.push_str(&line[..dot_pos]);
1675            s.push('\\');
1676            s.push_str(&line[dot_pos..]);
1677            return s;
1678        }
1679    }
1680    for prefix in &["- ", "* ", "+ "] {
1681        if line.starts_with(prefix) {
1682            let mut s = String::with_capacity(line.len() + 1);
1683            s.push('\\');
1684            s.push_str(line);
1685            return s;
1686        }
1687    }
1688    line.to_string()
1689}
1690
1691/// Checks if a line is a valid fenced code block opener.
1692///
1693/// Per CommonMark: the opener is a sequence of three or more backticks
1694/// followed by an info string that must not contain any backtick
1695/// character, otherwise some inline code spans would be misinterpreted
1696/// as the beginning of a fenced code block.
1697fn is_code_fence_opener(line: &str) -> bool {
1698    if !line.starts_with("```") {
1699        return false;
1700    }
1701    !line[3..].contains('`')
1702}
1703
1704/// Checks if a line is a horizontal rule.
1705fn is_horizontal_rule(line: &str) -> bool {
1706    let trimmed = line.trim();
1707    trimmed.len() >= 3
1708        && ((trimmed.starts_with("---") && trimmed.chars().all(|c| c == '-'))
1709            || (trimmed.starts_with("***") && trimmed.chars().all(|c| c == '*'))
1710            || (trimmed.starts_with("___") && trimmed.chars().all(|c| c == '_')))
1711}
1712
1713/// Checks if a line is a GFM table separator (e.g., "|---|---|").
1714fn is_table_separator(line: &str) -> bool {
1715    let trimmed = line.trim();
1716    trimmed.contains('|')
1717        && trimmed
1718            .chars()
1719            .all(|c| c == '|' || c == '-' || c == ':' || c == ' ')
1720}
1721
1722/// Parses a GFM table row into cell contents.
1723///
1724/// Splits on unescaped `|` characters; a preceding backslash (`\|`) is
1725/// interpreted as a literal pipe and unescaped in the emitted cell content
1726/// (see issue #579).  This allows code spans and other inline content that
1727/// contain literal `|` to survive round-trip through a pipe table.
1728fn parse_table_row(line: &str) -> Vec<String> {
1729    let trimmed = line.trim();
1730    let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1731    let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1732
1733    let mut cells: Vec<String> = Vec::new();
1734    let mut current = String::new();
1735    let mut chars = trimmed.chars().peekable();
1736    while let Some(ch) = chars.next() {
1737        if ch == '\\' && chars.peek() == Some(&'|') {
1738            current.push('|');
1739            chars.next();
1740        } else if ch == '|' {
1741            cells.push(std::mem::take(&mut current));
1742        } else {
1743            current.push(ch);
1744        }
1745    }
1746    cells.push(current);
1747
1748    cells
1749        .iter()
1750        .map(|s| {
1751            // Strip exactly one leading and one trailing space (pipe table padding).
1752            // Preserve any additional whitespace as significant content.
1753            let stripped = s.strip_prefix(' ').unwrap_or(s.as_str());
1754            let stripped = stripped.strip_suffix(' ').unwrap_or(stripped);
1755            stripped.to_string()
1756        })
1757        .collect()
1758}
1759
1760/// Parses column alignments from a GFM table separator row.
1761/// Returns a vec of `Option<&str>` where `Some("center")` or `Some("end")` indicate alignment.
1762fn parse_table_alignments(separator_line: &str) -> Vec<Option<&'static str>> {
1763    let trimmed = separator_line.trim();
1764    let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1765    let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1766
1767    trimmed
1768        .split('|')
1769        .map(|cell| {
1770            let cell = cell.trim();
1771            let starts_colon = cell.starts_with(':');
1772            let ends_colon = cell.ends_with(':');
1773            match (starts_colon, ends_colon) {
1774                (true, true) => Some("center"),
1775                (false, true) => Some("end"),
1776                _ => None, // left/default
1777            }
1778        })
1779        .collect()
1780}
1781
1782/// Applies an alignment mark to a paragraph node if alignment is specified.
1783fn apply_column_alignment(para: &mut AdfNode, alignment: Option<&str>) {
1784    if let Some(align) = alignment {
1785        para.marks = Some(vec![AdfMark::alignment(align)]);
1786    }
1787}
1788
1789/// Extracts `{attrs}` prefix from a pipe table cell text.
1790/// Returns `(remaining_text, Option<adf_attrs_json>)`.
1791fn extract_cell_attrs(cell_text: &str) -> (String, Option<serde_json::Value>) {
1792    let trimmed = cell_text.trim_start();
1793    if !trimmed.starts_with('{') {
1794        return (cell_text.to_string(), None);
1795    }
1796    if let Some((end_pos, attrs)) = parse_attrs(trimmed, 0) {
1797        let remaining = trimmed[end_pos..].trim_start().to_string();
1798        let adf_attrs = build_cell_attrs(&attrs);
1799        (remaining, Some(adf_attrs))
1800    } else {
1801        (cell_text.to_string(), None)
1802    }
1803}
1804
1805/// Tries to parse a line as a block-level image and return a mediaSingle ADF node.
1806/// Used by both `try_image` (top-level blocks) and list item parsing.
1807fn try_parse_media_single_from_line(line: &str) -> Option<AdfNode> {
1808    let line = line.trim();
1809    if !line.starts_with("![") {
1810        return None;
1811    }
1812
1813    let (alt, url) = parse_image_syntax(line)?;
1814    let alt_opt = if alt.is_empty() { None } else { Some(alt) };
1815
1816    let paren_open = line.find("](")? + 1; // index of '('
1817    let img_end = find_closing_paren(line, paren_open)? + 1;
1818    let after_img = line[img_end..].trim_start();
1819
1820    if after_img.starts_with('{') {
1821        if let Some((_, attrs)) = parse_attrs(after_img, 0) {
1822            // Confluence file attachment — reconstruct type:file media node
1823            if attrs.get("type") == Some("file") || attrs.get("id").is_some() {
1824                let mut media_attrs = serde_json::json!({"type": "file"});
1825                if let Some(id) = attrs.get("id") {
1826                    media_attrs["id"] = serde_json::Value::String(id.to_string());
1827                }
1828                if let Some(collection) = attrs.get("collection") {
1829                    media_attrs["collection"] = serde_json::Value::String(collection.to_string());
1830                }
1831                if let Some(occurrence_key) = attrs.get("occurrenceKey") {
1832                    media_attrs["occurrenceKey"] =
1833                        serde_json::Value::String(occurrence_key.to_string());
1834                }
1835                if let Some(height) = attrs.get("height") {
1836                    if let Some(h) = parse_numeric_attr(height) {
1837                        media_attrs["height"] = h;
1838                    }
1839                }
1840                if let Some(width) = attrs.get("width") {
1841                    if let Some(w) = parse_numeric_attr(width) {
1842                        media_attrs["width"] = w;
1843                    }
1844                }
1845                if let Some(alt_text) = alt_opt {
1846                    media_attrs["alt"] = serde_json::Value::String(alt_text.to_string());
1847                }
1848                if let Some(local_id) = attrs.get("localId") {
1849                    media_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1850                }
1851                let mut ms_attrs = serde_json::json!({"layout": "center"});
1852                if let Some(layout) = attrs.get("layout") {
1853                    ms_attrs["layout"] = serde_json::Value::String(layout.to_string());
1854                }
1855                if let Some(ms_width) = attrs.get("mediaWidth") {
1856                    if let Some(w) = parse_numeric_attr(ms_width) {
1857                        ms_attrs["width"] = w;
1858                    }
1859                }
1860                if let Some(wt) = attrs.get("widthType") {
1861                    ms_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1862                }
1863                if let Some(mode) = attrs.get("mode") {
1864                    ms_attrs["mode"] = serde_json::Value::String(mode.to_string());
1865                }
1866                let border_marks = build_border_marks(&attrs);
1867                let media_marks = if border_marks.is_empty() {
1868                    None
1869                } else {
1870                    Some(border_marks)
1871                };
1872                return Some(AdfNode {
1873                    node_type: "mediaSingle".to_string(),
1874                    attrs: Some(ms_attrs),
1875                    content: Some(vec![AdfNode {
1876                        node_type: "media".to_string(),
1877                        attrs: Some(media_attrs),
1878                        content: None,
1879                        text: None,
1880                        marks: media_marks,
1881                        local_id: None,
1882                        parameters: None,
1883                    }]),
1884                    text: None,
1885                    marks: None,
1886                    local_id: None,
1887                    parameters: None,
1888                });
1889            }
1890
1891            // External image — apply layout/width/widthType to mediaSingle attrs
1892            let mut node = AdfNode::media_single(url, alt_opt);
1893            if let Some(ref mut node_attrs) = node.attrs {
1894                if let Some(layout) = attrs.get("layout") {
1895                    node_attrs["layout"] = serde_json::Value::String(layout.to_string());
1896                }
1897                if let Some(width) = attrs.get("width") {
1898                    if let Some(w) = parse_numeric_attr(width) {
1899                        node_attrs["width"] = w;
1900                    }
1901                }
1902                if let Some(wt) = attrs.get("widthType") {
1903                    node_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1904                }
1905                if let Some(mode) = attrs.get("mode") {
1906                    node_attrs["mode"] = serde_json::Value::String(mode.to_string());
1907                }
1908            }
1909            if let Some(ref mut content) = node.content {
1910                if let Some(media) = content.first_mut() {
1911                    if let Some(local_id) = attrs.get("localId") {
1912                        if let Some(ref mut media_attrs) = media.attrs {
1913                            media_attrs["localId"] =
1914                                serde_json::Value::String(local_id.to_string());
1915                        }
1916                    }
1917                    let border_marks = build_border_marks(&attrs);
1918                    if !border_marks.is_empty() {
1919                        media.marks = Some(border_marks);
1920                    }
1921                }
1922            }
1923            return Some(node);
1924        }
1925    }
1926
1927    Some(AdfNode::media_single(url, alt_opt))
1928}
1929
1930/// Parses `![alt](url)` image syntax.
1931fn parse_image_syntax(line: &str) -> Option<(&str, &str)> {
1932    let line = line.trim();
1933    if !line.starts_with("![") {
1934        return None;
1935    }
1936
1937    let alt_end = line.find("](")?;
1938    let alt = &line[2..alt_end];
1939    let paren_start = alt_end + 1; // index of the '('
1940    let url_end = find_closing_paren(line, paren_start)?;
1941    let url = &line[paren_start + 1..url_end];
1942
1943    Some((alt, url))
1944}
1945
1946// ── Inline Parsing ──────────────────────────────────────────────────
1947
1948/// Parses inline markdown content into ADF inline nodes.
1949///
1950/// Detects bare URLs (e.g., `https://example.com`) and promotes them to
1951/// `inlineCard` nodes. Call this at the top level (paragraph, heading, cell,
1952/// list item) where a bare URL represents a smart link.
1953fn parse_inline(text: &str) -> Vec<AdfNode> {
1954    parse_inline_impl(text, true, 0)
1955}
1956
1957/// Implementation backing [`parse_inline`] and the inline recursion sites.
1958///
1959/// When `auto_inline_card` is `false`, bare `http://`/`https://` URLs are
1960/// treated as plain text instead of being promoted to `inlineCard` nodes.
1961/// Recursion into mark-wrapping constructs (emphasis, strike, bracketed
1962/// spans, links, inline directives) passes `false`: the enclosing syntax
1963/// already declares the semantic role of the content — a URL inside
1964/// `[url]{underline}` or `**url**` is the user's text, not a smart link
1965/// (issue #553).
1966///
1967/// `depth` counts those recursion levels.  Past [`MAX_NESTING_DEPTH`] the
1968/// remaining text is emitted as a plain text node instead of recursing
1969/// further — inline parsing is infallible, so unlike the block parser it
1970/// degrades gracefully rather than erroring (issue #1130).
1971fn parse_inline_impl(text: &str, auto_inline_card: bool, depth: usize) -> Vec<AdfNode> {
1972    if depth > MAX_NESTING_DEPTH {
1973        return if text.is_empty() {
1974            Vec::new()
1975        } else {
1976            vec![AdfNode::text(text)]
1977        };
1978    }
1979
1980    let mut nodes = Vec::new();
1981    let mut chars = text.char_indices().peekable();
1982    let mut plain_start = 0;
1983
1984    while let Some(&(i, ch)) = chars.peek() {
1985        match ch {
1986            '*' | '_' => {
1987                if let Some((end, content, is_bold)) = try_parse_emphasis(text, i) {
1988                    flush_plain(text, plain_start, i, &mut nodes);
1989                    let mark = if is_bold {
1990                        AdfMark::strong()
1991                    } else {
1992                        AdfMark::em()
1993                    };
1994                    let inner = parse_inline_impl(content, false, depth + 1);
1995                    for mut node in inner {
1996                        prepend_mark(&mut node, mark.clone());
1997                        nodes.push(node);
1998                    }
1999                    // Advance past the consumed characters
2000                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2001                        chars.next();
2002                    }
2003                    plain_start = end;
2004                    continue;
2005                }
2006                // For underscores, skip the entire delimiter run so that
2007                // individual `_` chars within a `__` or `___` run are not
2008                // re-tried as separate emphasis openers (CommonMark treats
2009                // consecutive underscores as a single delimiter run).
2010                if ch == '_' {
2011                    while chars.peek().is_some_and(|&(_, c)| c == '_') {
2012                        chars.next();
2013                    }
2014                } else {
2015                    chars.next();
2016                }
2017            }
2018            '~' => {
2019                if let Some((end, content)) = try_parse_strikethrough(text, i) {
2020                    flush_plain(text, plain_start, i, &mut nodes);
2021                    let inner = parse_inline_impl(content, false, depth + 1);
2022                    for mut node in inner {
2023                        prepend_mark(&mut node, AdfMark::strike());
2024                        nodes.push(node);
2025                    }
2026                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2027                        chars.next();
2028                    }
2029                    plain_start = end;
2030                    continue;
2031                }
2032                chars.next();
2033            }
2034            '`' => {
2035                if let Some((end, content)) = try_parse_inline_code(text, i) {
2036                    flush_plain(text, plain_start, i, &mut nodes);
2037                    nodes.push(AdfNode::text_with_marks(content, vec![AdfMark::code()]));
2038                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2039                        chars.next();
2040                    }
2041                    plain_start = end;
2042                    continue;
2043                }
2044                // No code span starting here; skip past the entire backtick
2045                // run so a longer opening run isn't retried as a shorter one.
2046                while chars.peek().is_some_and(|&(_, c)| c == '`') {
2047                    chars.next();
2048                }
2049            }
2050            '[' => {
2051                if let Some((end, link_text, href)) = try_parse_link(text, i) {
2052                    flush_plain(text, plain_start, i, &mut nodes);
2053                    if link_text.starts_with("http://") || link_text.starts_with("https://") {
2054                        // URL-as-link-text: emit as text with link mark,
2055                        // not via parse_inline which would produce an inlineCard.
2056                        // Covers both exact matches and trailing-slash mismatches
2057                        // (issue #523).
2058                        nodes.push(AdfNode::text_with_marks(
2059                            link_text,
2060                            vec![AdfMark::link(href)],
2061                        ));
2062                    } else {
2063                        let inner = parse_inline_impl(link_text, false, depth + 1);
2064                        for mut node in inner {
2065                            prepend_mark(&mut node, AdfMark::link(href));
2066                            nodes.push(node);
2067                        }
2068                    }
2069                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2070                        chars.next();
2071                    }
2072                    plain_start = end;
2073                    continue;
2074                }
2075                // Try bracketed span with attributes: [text]{underline}
2076                if let Some((end, span_nodes)) = try_parse_bracketed_span(text, i, depth) {
2077                    flush_plain(text, plain_start, i, &mut nodes);
2078                    nodes.extend(span_nodes);
2079                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2080                        chars.next();
2081                    }
2082                    plain_start = end;
2083                    continue;
2084                }
2085                chars.next();
2086            }
2087            ':' => {
2088                // Try generic inline directive (:card[url], :status[text]{attrs}, etc.)
2089                if let Some(node) = try_dispatch_inline_directive(text, i, depth) {
2090                    flush_plain(text, plain_start, i, &mut nodes);
2091                    let end = node.1;
2092                    nodes.push(node.0);
2093                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2094                        chars.next();
2095                    }
2096                    plain_start = end;
2097                    continue;
2098                }
2099                // Try emoji shortcode :name: with optional {attrs}
2100                if let Some((end, short_name)) = try_parse_emoji_shortcode(text, i) {
2101                    flush_plain(text, plain_start, i, &mut nodes);
2102                    let (final_end, emoji_node) = parse_emoji_with_attrs(text, end, short_name);
2103                    nodes.push(emoji_node);
2104                    while chars.peek().is_some_and(|&(idx, _)| idx < final_end) {
2105                        chars.next();
2106                    }
2107                    plain_start = final_end;
2108                    continue;
2109                }
2110                chars.next();
2111            }
2112            ' ' if text[i..].starts_with("  \n") => {
2113                // Trailing-space line break → hardBreak node.
2114                // Flush preceding text (without the trailing spaces).
2115                flush_plain(text, plain_start, i, &mut nodes);
2116                nodes.push(AdfNode::hard_break());
2117                // Skip past all spaces and the newline
2118                while chars.peek().is_some_and(|&(_, c)| c == ' ') {
2119                    chars.next();
2120                }
2121                // Skip the newline
2122                if chars.peek().is_some_and(|&(_, c)| c == '\n') {
2123                    chars.next();
2124                }
2125                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2126            }
2127            '!' if text[i..].starts_with("![") => {
2128                // Inline image — skip the ! and let [ handle it next iteration
2129                // (Images at block level are handled by try_image; inline images
2130                // degrade to link text in ADF since inline media is complex)
2131                chars.next();
2132            }
2133            'h' if auto_inline_card
2134                && (text[i..].starts_with("http://") || text[i..].starts_with("https://")) =>
2135            {
2136                if let Some((end, url)) = try_parse_bare_url(text, i) {
2137                    flush_plain(text, plain_start, i, &mut nodes);
2138                    nodes.push(AdfNode::inline_card(url));
2139                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2140                        chars.next();
2141                    }
2142                    plain_start = end;
2143                    continue;
2144                }
2145                chars.next();
2146            }
2147            '\\' if text.as_bytes().get(i + 1) == Some(&b'n')
2148                && text.as_bytes().get(i + 2) != Some(&b'\n') =>
2149            {
2150                // Issue #454: `\n` (backslash + letter n) encodes a literal
2151                // newline inside a text node. Emit the newline as a separate
2152                // text node so merge_adjacent_text can reassemble it.
2153                flush_plain(text, plain_start, i, &mut nodes);
2154                nodes.push(AdfNode::text("\n"));
2155                chars.next(); // consume the '\'
2156                chars.next(); // consume the 'n'
2157                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2158            }
2159            '\\' if i + 1 < text.len() && !text[i..].starts_with("\\\n") => {
2160                // Backslash escape: skip the backslash and treat the next
2161                // character as literal text (e.g. `\\` → `\`,
2162                // `2\. text` → `2. text`, `\*word\*` → `*word*` without
2163                // emphasis, `\:fire:` → `:fire:` without emoji parsing).
2164                flush_plain(text, plain_start, i, &mut nodes);
2165                chars.next(); // consume the backslash
2166                              // Set plain_start to the escaped character so it is included
2167                              // in the next plain-text run, then advance past it so it is
2168                              // not re-interpreted as a special character (e.g. `*`, `_`, `:`).
2169                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2170                chars.next(); // consume the escaped character
2171            }
2172            '\\' if text[i..].starts_with("\\\n") => {
2173                // Backslash line break → hardBreak node.
2174                flush_plain(text, plain_start, i, &mut nodes);
2175                nodes.push(AdfNode::hard_break());
2176                chars.next(); // consume the '\'
2177                              // Skip the newline
2178                if chars.peek().is_some_and(|&(_, c)| c == '\n') {
2179                    chars.next();
2180                }
2181                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2182            }
2183            '\\' if i + 1 == text.len() => {
2184                // Trailing backslash at end of paragraph text → hardBreak node.
2185                flush_plain(text, plain_start, i, &mut nodes);
2186                nodes.push(AdfNode::hard_break());
2187                chars.next(); // consume the '\'
2188                plain_start = text.len();
2189            }
2190            _ => {
2191                chars.next();
2192            }
2193        }
2194    }
2195
2196    // Flush remaining plain text
2197    if plain_start < text.len() {
2198        let remaining = &text[plain_start..];
2199        if !remaining.is_empty() {
2200            nodes.push(AdfNode::text(remaining));
2201        }
2202    }
2203
2204    // Merge adjacent unmarked text nodes that can arise from backslash
2205    // escape handling (e.g. `"2"` + `". text"` → `"2. text"`).
2206    merge_adjacent_text(&mut nodes);
2207
2208    nodes
2209}
2210
2211/// Merges consecutive unmarked text nodes in-place.
2212fn merge_adjacent_text(nodes: &mut Vec<AdfNode>) {
2213    let mut i = 0;
2214    while i + 1 < nodes.len() {
2215        if nodes[i].node_type == "text"
2216            && nodes[i + 1].node_type == "text"
2217            && nodes[i].marks.is_none()
2218            && nodes[i + 1].marks.is_none()
2219        {
2220            let next_text = nodes[i + 1].text.clone().unwrap_or_default();
2221            if let Some(ref mut t) = nodes[i].text {
2222                t.push_str(&next_text);
2223            }
2224            nodes.remove(i + 1);
2225        } else {
2226            i += 1;
2227        }
2228    }
2229}
2230
2231/// Flushes accumulated plain text as a text node.
2232fn flush_plain(text: &str, start: usize, end: usize, nodes: &mut Vec<AdfNode>) {
2233    if start < end {
2234        let plain = &text[start..end];
2235        if !plain.is_empty() {
2236            nodes.push(AdfNode::text(plain));
2237        }
2238    }
2239}
2240
2241/// Adds a mark to a node (creates marks vec if needed).
2242#[cfg(test)]
2243fn add_mark(node: &mut AdfNode, mark: AdfMark) {
2244    if let Some(ref mut marks) = node.marks {
2245        marks.push(mark);
2246    } else {
2247        node.marks = Some(vec![mark]);
2248    }
2249}
2250
2251/// Removes `code` marks from heading inline content, returning the count
2252/// removed.
2253///
2254/// ADF's `heading` content model forbids the `code` mark (a heading styles
2255/// its own text), but JFM/CommonMark parses inline-code spans inside
2256/// `#`-headings. Stripping here keeps the text as plain — a lossy but safe
2257/// direction, since Atlassian renders no inline-code styling on headings —
2258/// rather than building a document the validator only rejects at write time
2259/// (issue #1005). Recurses into `content` to be robust to future inline
2260/// containers; today heading inline content is flat text nodes.
2261fn strip_heading_code_marks(nodes: &mut [AdfNode]) -> usize {
2262    let mut removed = 0;
2263    for node in nodes.iter_mut() {
2264        if let Some(marks) = node.marks.as_mut() {
2265            let before = marks.len();
2266            marks.retain(|m| m.mark_type != "code");
2267            removed += before - marks.len();
2268            if marks.is_empty() {
2269                node.marks = None;
2270            }
2271        }
2272        if let Some(children) = node.content.as_mut() {
2273            removed += strip_heading_code_marks(children);
2274        }
2275    }
2276    removed
2277}
2278
2279/// Marks that markdown emphasis syntax can layer onto an inline-code run but
2280/// that ADF forbids combining with `code` (issue #1391). Deliberately not the
2281/// full complement of marks illegal with `code`: span-syntax marks
2282/// (`underline`, `textColor`, …) are explicit author requests and are left
2283/// for the validator to report.
2284const EMPHASIS_MARKS_SPLIT_FROM_CODE: [&str; 3] = ["strong", "em", "strike"];
2285
2286/// Drops `strong`/`em`/`strike` marks from text runs that also carry `code`,
2287/// returning the count dropped.
2288///
2289/// ADF allows `code` to combine only with `link` and `annotation`, so
2290/// ``**`x`**``-shaped JFM used to fail validation at write time. Inline code
2291/// always parses into its own run, so the sibling runs produced by the same
2292/// emphasis span keep their marks — dropping the leaked marks here is the
2293/// "split into adjacent legal runs" of issue #1391, and rendering is
2294/// visually unchanged (the code font dominates inside a bold/italic
2295/// sentence). Must run at the document level, after block building: heading
2296/// runs are already code-free via [`strip_heading_code_marks`] by then, so
2297/// a bold heading around inline code keeps its bold.
2298fn split_emphasis_code_marks(nodes: &mut [AdfNode]) -> usize {
2299    let mut dropped = 0;
2300    for node in nodes.iter_mut() {
2301        if let Some(marks) = node.marks.as_mut() {
2302            if node.text.is_some() && marks.iter().any(|m| m.mark_type == "code") {
2303                let before = marks.len();
2304                marks.retain(|m| !EMPHASIS_MARKS_SPLIT_FROM_CODE.contains(&m.mark_type.as_str()));
2305                dropped += before - marks.len();
2306            }
2307        }
2308        if let Some(children) = node.content.as_mut() {
2309            dropped += split_emphasis_code_marks(children);
2310        }
2311    }
2312    dropped
2313}
2314
2315/// Prepends a mark before existing marks to preserve outside-in ordering.
2316fn prepend_mark(node: &mut AdfNode, mark: AdfMark) {
2317    if let Some(ref mut marks) = node.marks {
2318        marks.insert(0, mark);
2319    } else {
2320        node.marks = Some(vec![mark]);
2321    }
2322}
2323
2324/// Returns `true` when an underscore delimiter run of `len` bytes starting at
2325/// byte position `delim_pos` in `text` is flanked by alphanumeric characters on
2326/// **both** sides — meaning it sits inside a word and must NOT open or close an
2327/// emphasis span per CommonMark.
2328fn is_intraword_underscore(text: &str, delim_pos: usize, len: usize) -> bool {
2329    let before = text[..delim_pos]
2330        .chars()
2331        .next_back()
2332        .is_some_and(char::is_alphanumeric);
2333    let after = text[delim_pos + len..]
2334        .chars()
2335        .next()
2336        .is_some_and(char::is_alphanumeric);
2337    before && after
2338}
2339
2340/// Finds the first occurrence of `needle` in `haystack`, skipping over
2341/// backslash-escaped characters (e.g. `\*` is not matched when searching
2342/// for `*`).
2343fn find_unescaped(haystack: &str, needle: &str) -> Option<usize> {
2344    let needle_bytes = needle.as_bytes();
2345    let hay_bytes = haystack.as_bytes();
2346    let mut i = 0;
2347    while i < hay_bytes.len() {
2348        if hay_bytes[i] == b'\\' {
2349            i += 2; // skip escaped character
2350            continue;
2351        }
2352        if hay_bytes[i..].starts_with(needle_bytes) {
2353            return Some(i);
2354        }
2355        i += 1;
2356    }
2357    None
2358}
2359
2360/// Finds the first occurrence of a single byte `ch` in `haystack`, skipping
2361/// over backslash-escaped characters.
2362fn find_unescaped_char(haystack: &str, ch: u8) -> Option<usize> {
2363    let hay_bytes = haystack.as_bytes();
2364    let mut i = 0;
2365    while i < hay_bytes.len() {
2366        if hay_bytes[i] == b'\\' {
2367            i += 2;
2368            continue;
2369        }
2370        if hay_bytes[i] == ch {
2371            return Some(i);
2372        }
2373        i += 1;
2374    }
2375    None
2376}
2377
2378/// Tries to parse ***bold+italic***, **bold**, *italic* (or underscore variants) starting at position `i`.
2379/// Returns (end_position, inner_content, is_bold).
2380///
2381/// The triple-delimiter case (`***` / `___`) is checked first so that `***text***` is parsed as
2382/// bold wrapping italic content, rather than having the `**` branch consume the wrong closing
2383/// delimiter and leave stray `*` characters in the text (see issue #401).
2384///
2385/// For underscore delimiters, intraword positions are rejected per CommonMark: a `_` flanked
2386/// by alphanumeric characters on both sides must not open or close emphasis (see issue #438).
2387fn try_parse_emphasis(text: &str, i: usize) -> Option<(usize, &str, bool)> {
2388    let rest = &text[i..];
2389
2390    // Bold+italic: *** or ___
2391    // Parse as bold wrapping italic: the inner content will be recursively parsed and pick up
2392    // the inner * / _ as an em mark.
2393    if rest.starts_with("***") || rest.starts_with("___") {
2394        let is_underscore = rest.starts_with("___");
2395        if is_underscore && is_intraword_underscore(text, i, 3) {
2396            return None;
2397        }
2398        let triple = &rest[..3];
2399        let after = &rest[3..];
2400        if let Some(close) = find_unescaped(after, triple) {
2401            if close > 0 {
2402                let close_pos = i + 3 + close;
2403                if is_underscore && is_intraword_underscore(text, close_pos, 3) {
2404                    return None;
2405                }
2406                // Return a slice that includes the inner italic delimiters from the
2407                // original text: for `***text***`, return `*text*`.  The recursive
2408                // parse_inline call will then pick up the inner `*…*` as an em mark.
2409                let content = &rest[2..=3 + close];
2410                let end = i + 3 + close + 3;
2411                return Some((end, content, true));
2412            }
2413        }
2414    }
2415
2416    // Bold: ** or __
2417    if rest.starts_with("**") || rest.starts_with("__") {
2418        let is_underscore = rest.starts_with("__");
2419        if is_underscore && is_intraword_underscore(text, i, 2) {
2420            return None;
2421        }
2422        let delimiter = &rest[..2];
2423        let after = &rest[2..];
2424        let close = find_unescaped(after, delimiter)?;
2425        if close == 0 {
2426            return None;
2427        }
2428        let close_pos = i + 2 + close;
2429        if is_underscore && is_intraword_underscore(text, close_pos, 2) {
2430            return None;
2431        }
2432        let content = &after[..close];
2433        let end = i + 2 + close + 2;
2434        return Some((end, content, true));
2435    }
2436
2437    // Italic: * or _
2438    if rest.starts_with('*') || rest.starts_with('_') {
2439        let delim_char = rest.as_bytes()[0];
2440        let is_underscore = delim_char == b'_';
2441        if is_underscore && is_intraword_underscore(text, i, 1) {
2442            return None;
2443        }
2444        let after = &rest[1..];
2445        let close = find_unescaped_char(after, delim_char)?;
2446        if close == 0 {
2447            return None;
2448        }
2449        let close_pos = i + 1 + close;
2450        if is_underscore && is_intraword_underscore(text, close_pos, 1) {
2451            return None;
2452        }
2453        let content = &after[..close];
2454        let end = i + 1 + close + 1;
2455        return Some((end, content, false));
2456    }
2457
2458    None
2459}
2460
2461/// Tries to parse ~~strikethrough~~ starting at position `i`.
2462fn try_parse_strikethrough(text: &str, i: usize) -> Option<(usize, &str)> {
2463    let rest = &text[i..];
2464    if !rest.starts_with("~~") {
2465        return None;
2466    }
2467    let after = &rest[2..];
2468    let close = after.find("~~")?;
2469    if close == 0 {
2470        return None;
2471    }
2472    let content = &after[..close];
2473    Some((i + 2 + close + 2, content))
2474}
2475
2476/// Tries to parse a CommonMark inline code span starting at position `i`.
2477///
2478/// Supports multi-backtick delimiters: the opening run of N backticks must
2479/// be matched by a closing run of exactly N backticks.  If both ends of the
2480/// enclosed content begin and end with a space and the content is not all
2481/// spaces, one space is stripped from each side per the CommonMark spec.
2482fn try_parse_inline_code(text: &str, i: usize) -> Option<(usize, &str)> {
2483    let rest = &text[i..];
2484    let bytes = rest.as_bytes();
2485    if bytes.is_empty() || bytes[0] != b'`' {
2486        return None;
2487    }
2488    let mut opening = 0usize;
2489    while opening < bytes.len() && bytes[opening] == b'`' {
2490        opening += 1;
2491    }
2492
2493    let mut j = opening;
2494    while j < bytes.len() {
2495        if bytes[j] == b'`' {
2496            let run_start = j;
2497            while j < bytes.len() && bytes[j] == b'`' {
2498                j += 1;
2499            }
2500            if j - run_start == opening {
2501                let content = &rest[opening..run_start];
2502                let content = strip_code_span_padding(content);
2503                return Some((i + j, content));
2504            }
2505        } else {
2506            j += 1;
2507        }
2508    }
2509    None
2510}
2511
2512/// Implements the CommonMark code-span space-stripping rule: if the content
2513/// both begins and ends with a space character and is not composed entirely
2514/// of spaces, one space character is removed from each side.
2515fn strip_code_span_padding(content: &str) -> &str {
2516    let bytes = content.as_bytes();
2517    if bytes.len() >= 2
2518        && bytes[0] == b' '
2519        && bytes[bytes.len() - 1] == b' '
2520        && content.bytes().any(|b| b != b' ')
2521    {
2522        &content[1..content.len() - 1]
2523    } else {
2524        content
2525    }
2526}
2527
2528/// Tries to parse a bracketed span `[text]{attrs}` starting at position `i`.
2529/// Used for `[text]{underline}` and similar constructs.  `depth` is the
2530/// caller's inline recursion depth (see [`parse_inline_impl`]).
2531fn try_parse_bracketed_span(text: &str, i: usize, depth: usize) -> Option<(usize, Vec<AdfNode>)> {
2532    let rest = &text[i..];
2533    if !rest.starts_with('[') {
2534        return None;
2535    }
2536
2537    // Find the matching ] by counting bracket depth (supports nested brackets
2538    // such as [[text](url)]{underline} for underline-before-link ordering).
2539    // Backslash-escaped brackets are skipped (issue #493).
2540    let mut bracket_depth: usize = 0;
2541    let mut bracket_close = None;
2542    let bs_bytes = rest.as_bytes();
2543    for (j, ch) in rest.char_indices() {
2544        match ch {
2545            '\\' if j + 1 < bs_bytes.len()
2546                && (bs_bytes[j + 1] == b'[' || bs_bytes[j + 1] == b']') => {}
2547            '[' if j == 0 || bs_bytes[j - 1] != b'\\' => bracket_depth += 1,
2548            ']' if j == 0 || bs_bytes[j - 1] != b'\\' => {
2549                bracket_depth -= 1;
2550                if bracket_depth == 0 {
2551                    bracket_close = Some(j);
2552                    break;
2553                }
2554            }
2555            _ => {}
2556        }
2557    }
2558    let bracket_close = bracket_close?;
2559    // Make sure this isn't a link: next char after ] must be { not (
2560    let after_bracket = &rest[bracket_close + 1..];
2561    if !after_bracket.starts_with('{') {
2562        return None;
2563    }
2564
2565    let span_text = &rest[1..bracket_close];
2566    let attrs_start = i + bracket_close + 1;
2567    let (attrs_end, attrs) = parse_attrs(text, attrs_start)?;
2568
2569    let mut marks = Vec::new();
2570    if attrs.has_flag("underline") {
2571        marks.push(AdfMark::underline());
2572    }
2573    let ann_ids = attrs.get_all("annotation-id");
2574    let ann_types = attrs.get_all("annotation-type");
2575    for (idx, ann_id) in ann_ids.iter().enumerate() {
2576        let ann_type = ann_types.get(idx).copied().unwrap_or("inlineComment");
2577        marks.push(AdfMark::annotation(ann_id, ann_type));
2578    }
2579
2580    if marks.is_empty() {
2581        return None; // no recognized marks
2582    }
2583
2584    let inner = parse_inline_impl(span_text, false, depth + 1);
2585    let result: Vec<AdfNode> = inner
2586        .into_iter()
2587        .map(|mut node| {
2588            // Prepend bracket marks before inner marks to preserve original
2589            // ADF mark ordering (e.g., [underline, strong] not [strong, underline]).
2590            let mut combined = marks.clone();
2591            if let Some(ref existing) = node.marks {
2592                combined.extend(existing.iter().cloned());
2593            }
2594            node.marks = Some(combined);
2595            node
2596        })
2597        .collect();
2598
2599    Some((attrs_end, result))
2600}
2601
2602/// Dispatches an inline directive to the appropriate ADF node constructor.
2603/// Returns `(AdfNode, end_pos)` on success.  `depth` is the caller's inline
2604/// recursion depth (see [`parse_inline_impl`]).
2605fn try_dispatch_inline_directive(text: &str, pos: usize, depth: usize) -> Option<(AdfNode, usize)> {
2606    let d = try_parse_inline_directive(text, pos)?;
2607    let content = d.content.as_deref().unwrap_or("");
2608
2609    let node = match d.name.as_str() {
2610        "card" => {
2611            // Prefer the `url` attribute when present; fall back to the
2612            // bracketed content.  The attribute form is used when the URL
2613            // contains characters (such as `]` or `\n`) that would otherwise
2614            // break `:card[URL]` parsing.
2615            let url = d
2616                .attrs
2617                .as_ref()
2618                .and_then(|a| a.get("url"))
2619                .unwrap_or(content);
2620            let mut node = AdfNode::inline_card(url);
2621            pass_through_local_id(&d.attrs, &mut node);
2622            node
2623        }
2624        "status" => {
2625            let color = d
2626                .attrs
2627                .as_ref()
2628                .and_then(|a| a.get("color"))
2629                .unwrap_or("neutral");
2630            let mut node = AdfNode::status(content, color);
2631            // Pass through style and localId if present
2632            if let Some(ref attrs) = d.attrs {
2633                if let Some(ref mut node_attrs) = node.attrs {
2634                    if let Some(style) = attrs.get("style") {
2635                        node_attrs["style"] = serde_json::Value::String(style.to_string());
2636                    }
2637                    if let Some(local_id) = attrs.get("localId") {
2638                        node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2639                    }
2640                }
2641            }
2642            node
2643        }
2644        "date" => {
2645            let timestamp = d
2646                .attrs
2647                .as_ref()
2648                .and_then(|a| a.get("timestamp"))
2649                .map_or_else(|| iso_date_to_epoch_ms(content), ToString::to_string);
2650            let mut node = AdfNode::date(&timestamp);
2651            pass_through_local_id(&d.attrs, &mut node);
2652            node
2653        }
2654        "mention" => {
2655            let id = d.attrs.as_ref().and_then(|a| a.get("id")).unwrap_or("");
2656            let mut node = AdfNode::mention(id, content);
2657            // Pass through optional userType and accessLevel
2658            if let Some(ref attrs) = d.attrs {
2659                if let (Some(ref mut node_attrs), true) = (
2660                    &mut node.attrs,
2661                    attrs.get("userType").is_some() || attrs.get("accessLevel").is_some(),
2662                ) {
2663                    if let Some(ut) = attrs.get("userType") {
2664                        node_attrs["userType"] = serde_json::Value::String(ut.to_string());
2665                    }
2666                    if let Some(al) = attrs.get("accessLevel") {
2667                        node_attrs["accessLevel"] = serde_json::Value::String(al.to_string());
2668                    }
2669                }
2670            }
2671            pass_through_local_id(&d.attrs, &mut node);
2672            node
2673        }
2674        "span" => {
2675            let mut marks = Vec::new();
2676            if let Some(ref attrs) = d.attrs {
2677                if let Some(color) = attrs.get("color") {
2678                    marks.push(AdfMark::text_color(color));
2679                }
2680                if let Some(bg) = attrs.get("bg") {
2681                    marks.push(AdfMark::background_color(bg));
2682                }
2683                if attrs.has_flag("sub") {
2684                    marks.push(AdfMark::subsup("sub"));
2685                }
2686                if attrs.has_flag("sup") {
2687                    marks.push(AdfMark::subsup("sup"));
2688                }
2689            }
2690            if marks.is_empty() {
2691                AdfNode::text(content)
2692            } else {
2693                // Parse inner content to handle nested syntax (e.g., links).
2694                // Prepend span marks before inner marks to preserve ordering.
2695                let inner = parse_inline_impl(content, false, depth + 1);
2696                let mut nodes: Vec<AdfNode> = inner
2697                    .into_iter()
2698                    .map(|mut node| {
2699                        let mut combined = marks.clone();
2700                        if let Some(ref existing) = node.marks {
2701                            combined.extend(existing.iter().cloned());
2702                        }
2703                        node.marks = Some(combined);
2704                        node
2705                    })
2706                    .collect();
2707                // Return the first marked node (typical case is a single node).
2708                nodes.remove(0)
2709            }
2710        }
2711        "placeholder" => AdfNode::placeholder(content),
2712        "media-inline" => {
2713            let mut json_attrs = serde_json::Map::new();
2714            if let Some(ref attrs) = d.attrs {
2715                for key in &["type", "id", "collection", "url", "alt", "width", "height"] {
2716                    if let Some(val) = attrs.get(key) {
2717                        if *key == "width" || *key == "height" {
2718                            if let Ok(n) = val.parse::<u64>() {
2719                                json_attrs.insert(
2720                                    (*key).to_string(),
2721                                    serde_json::Value::Number(n.into()),
2722                                );
2723                                continue;
2724                            }
2725                        }
2726                        json_attrs.insert(
2727                            (*key).to_string(),
2728                            serde_json::Value::String(val.to_string()),
2729                        );
2730                    }
2731                }
2732                if let Some(local_id) = attrs.get("localId") {
2733                    json_attrs.insert(
2734                        "localId".to_string(),
2735                        serde_json::Value::String(local_id.to_string()),
2736                    );
2737                }
2738            }
2739            AdfNode::media_inline(serde_json::Value::Object(json_attrs))
2740        }
2741        "extension" => {
2742            let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
2743            let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
2744            AdfNode::inline_extension(ext_type, ext_key, Some(content))
2745        }
2746        "adf-unsupported" => {
2747            // Round-trips an unsupported inline node emitted by
2748            // `render_non_text_inline_body`: the full node JSON rides in the
2749            // `json` attribute (the inline mirror of the block-level
2750            // ```adf-unsupported``` fence reader, ADR-0029 / issue #1117).
2751            // Deserialize it straight back into the original node; a malformed
2752            // or missing payload falls through to plain text, exactly like the
2753            // block reader degrading to a normal code block.
2754            let json = d.attrs.as_ref().and_then(|a| a.get("json"))?;
2755            serde_json::from_str::<AdfNode>(json).ok()?
2756        }
2757        _ => return None, // unknown directive — fall through to plain text
2758    };
2759
2760    Some((node, d.end_pos))
2761}
2762
2763/// Tries to parse a bare URL (`http://` or `https://`) starting at position `i`.
2764/// Scans forward until whitespace, `)`, `]`, or end of string.
2765fn try_parse_bare_url(text: &str, i: usize) -> Option<(usize, &str)> {
2766    let rest = &text[i..];
2767    if !rest.starts_with("http://") && !rest.starts_with("https://") {
2768        return None;
2769    }
2770    // URL extends to the next whitespace or delimiter
2771    let end = rest
2772        .find(|c: char| c.is_whitespace() || c == ')' || c == ']' || c == '>')
2773        .unwrap_or(rest.len());
2774    // Strip trailing punctuation that's likely not part of the URL
2775    let url = rest[..end].trim_end_matches(['.', ',', ';', '!', '?']);
2776    if url.len() <= "https://".len() {
2777        return None; // too short to be a real URL
2778    }
2779    Some((i + url.len(), url))
2780}
2781
2782/// Tries to parse an emoji shortcode `:name:` starting at position `i`.
2783/// The name must match `[a-zA-Z0-9_+-]+`.
2784fn try_parse_emoji_shortcode(text: &str, i: usize) -> Option<(usize, &str)> {
2785    let rest = &text[i..];
2786    if !rest.starts_with(':') {
2787        return None;
2788    }
2789    let after = &rest[1..];
2790    let name_end =
2791        after.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')?;
2792    if name_end == 0 {
2793        return None;
2794    }
2795    if after.as_bytes().get(name_end) != Some(&b':') {
2796        return None;
2797    }
2798    let name = &after[..name_end];
2799    Some((i + 1 + name_end + 1, name))
2800}
2801
2802/// Parses an emoji shortcode that has already been matched, then checks for
2803/// trailing `{id="..." text="..."}` attributes to preserve round-trip fidelity.
2804fn parse_emoji_with_attrs(text: &str, shortcode_end: usize, short_name: &str) -> (usize, AdfNode) {
2805    // Issue #576: An emoji with a combined shortName like `:slightly_smiling_face::bow:`
2806    // is emitted as `:slightly_smiling_face::bow:{shortName="..." ...}`. Extend the
2807    // match through any adjacent `:name:` shortcodes so that a trailing directive
2808    // attaches to the whole chain as a single emoji, using the directive's shortName.
2809    let mut chain_end = shortcode_end;
2810    while let Some((next_end, _)) = try_parse_emoji_shortcode(text, chain_end) {
2811        chain_end = next_end;
2812    }
2813    if chain_end > shortcode_end {
2814        if let Some((attr_end, attrs)) = parse_attrs(text, chain_end) {
2815            return (attr_end, build_emoji_node(&attrs, short_name));
2816        }
2817    }
2818
2819    if let Some((attr_end, attrs)) = parse_attrs(text, shortcode_end) {
2820        (attr_end, build_emoji_node(&attrs, short_name))
2821    } else {
2822        (shortcode_end, AdfNode::emoji(&format!(":{short_name}:")))
2823    }
2824}
2825
2826/// Builds an emoji `AdfNode` from parsed directive attrs, falling back to
2827/// the matched shortcode name when `shortName` is absent from the directive.
2828fn build_emoji_node(attrs: &Attrs, short_name: &str) -> AdfNode {
2829    let resolved_name = attrs
2830        .get("shortName")
2831        .map_or_else(|| format!(":{short_name}:"), str::to_string);
2832    let mut emoji_attrs = serde_json::json!({ "shortName": resolved_name });
2833    if let Some(id) = attrs.get("id") {
2834        emoji_attrs["id"] = serde_json::Value::String(id.to_string());
2835    }
2836    if let Some(t) = attrs.get("text") {
2837        emoji_attrs["text"] = serde_json::Value::String(t.to_string());
2838    }
2839    if let Some(lid) = attrs.get("localId") {
2840        emoji_attrs["localId"] = serde_json::Value::String(lid.to_string());
2841    }
2842    AdfNode {
2843        node_type: "emoji".to_string(),
2844        attrs: Some(emoji_attrs),
2845        content: None,
2846        text: None,
2847        marks: None,
2848        local_id: None,
2849        parameters: None,
2850    }
2851}
2852
2853/// Finds the closing `)` that matches the opening `(` at position `open`,
2854/// counting nested parentheses so that URLs containing `(` and `)` are
2855/// handled correctly.  Returns the index of the matching `)` relative to
2856/// the start of `s`, or `None` if no match is found.
2857fn find_closing_paren(s: &str, open: usize) -> Option<usize> {
2858    let mut depth: usize = 0;
2859    for (j, ch) in s[open..].char_indices() {
2860        match ch {
2861            '(' => depth += 1,
2862            ')' => {
2863                depth -= 1;
2864                if depth == 0 {
2865                    return Some(open + j);
2866                }
2867            }
2868            _ => {}
2869        }
2870    }
2871    None
2872}
2873
2874/// Tries to parse [text](url) starting at position `i`.
2875///
2876/// Uses bracket depth counting to find the matching `]`, so that `[` characters
2877/// inside the text (e.g. `[Task] some text ([Link](url))`) don't cause a false
2878/// match on an earlier `](`.
2879fn try_parse_link(text: &str, i: usize) -> Option<(usize, &str, &str)> {
2880    let rest = &text[i..];
2881    if !rest.starts_with('[') {
2882        return None;
2883    }
2884
2885    // Find the matching ] by counting bracket depth, skipping escaped brackets
2886    let mut depth: usize = 0;
2887    let mut text_end = None;
2888    let bytes = rest.as_bytes();
2889    for (j, ch) in rest.char_indices() {
2890        match ch {
2891            '\\' if j + 1 < bytes.len() && (bytes[j + 1] == b'[' || bytes[j + 1] == b']') => {
2892                // Skip backslash-escaped bracket (issue #493)
2893            }
2894            '[' if j == 0 || bytes[j - 1] != b'\\' => depth += 1,
2895            ']' if j == 0 || bytes[j - 1] != b'\\' => {
2896                depth -= 1;
2897                if depth == 0 {
2898                    text_end = Some(j);
2899                    break;
2900                }
2901            }
2902            _ => {}
2903        }
2904    }
2905
2906    let text_end = text_end?;
2907    let link_text = &rest[1..text_end];
2908    // Must be immediately followed by (
2909    let after_bracket = &rest[text_end + 1..];
2910    if !after_bracket.starts_with('(') {
2911        return None;
2912    }
2913    let url_start = text_end + 1; // index of the '('
2914    let url_end = find_closing_paren(rest, url_start)?;
2915    let href = &rest[url_start + 1..url_end];
2916
2917    Some((i + url_end + 1, link_text, href))
2918}
2919
2920// ── ADF → Markdown ──────────────────────────────────────────────────
2921
2922/// Options for ADF-to-markdown rendering.
2923#[derive(Debug, Clone, Default)]
2924pub struct RenderOptions {
2925    /// When true, omit `localId` attributes from directive output.
2926    pub strip_local_ids: bool,
2927}
2928
2929/// Converts an ADF document to a markdown string.
2930pub fn adf_to_markdown(doc: &AdfDocument) -> Result<String> {
2931    adf_to_markdown_with_options(doc, &RenderOptions::default())
2932}
2933
2934/// Converts an ADF document to a markdown string with options.
2935pub fn adf_to_markdown_with_options(doc: &AdfDocument, opts: &RenderOptions) -> Result<String> {
2936    let mut output = String::new();
2937
2938    for (i, node) in doc.content.iter().enumerate() {
2939        if i > 0 {
2940            output.push('\n');
2941        }
2942        render_block_node(node, &mut output, opts);
2943    }
2944
2945    Ok(output)
2946}
2947
2948/// Flattens an ADF document to plain text by concatenating all `text` nodes.
2949///
2950/// Block boundaries (paragraph, heading, list item, table cell, etc.) are
2951/// separated by a single space so a multi-paragraph anchor selection matches
2952/// when the user copies the text without trailing/leading whitespace.
2953///
2954/// Used by [`crate::atlassian::confluence_api::ConfluenceApi::resolve_anchor`]
2955/// to count occurrences of an inline-comment anchor on the live page body.
2956#[must_use]
2957pub fn adf_to_plain_text(doc: &AdfDocument) -> String {
2958    let mut out = String::new();
2959    for node in &doc.content {
2960        collect_plain_text(node, &mut out);
2961        if !out.is_empty() && !out.ends_with(' ') {
2962            out.push(' ');
2963        }
2964    }
2965    out.truncate(out.trim_end().len());
2966    out
2967}
2968
2969fn collect_plain_text(node: &AdfNode, out: &mut String) {
2970    if let Some(text) = &node.text {
2971        out.push_str(text);
2972    }
2973    if let Some(children) = &node.content {
2974        for child in children {
2975            collect_plain_text(child, out);
2976        }
2977    }
2978}
2979
2980/// Pushes a `localId=<value>` entry to an attribute parts vec,
2981/// unless `opts.strip_local_ids` is set or the value is a placeholder.
2982/// Copies `localId` from parsed directive attrs to an ADF node's attrs if present.
2983fn pass_through_local_id(dir_attrs: &Option<crate::atlassian::attrs::Attrs>, node: &mut AdfNode) {
2984    if let Some(ref attrs) = dir_attrs {
2985        if let Some(local_id) = attrs.get("localId") {
2986            if let Some(ref mut node_attrs) = node.attrs {
2987                node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2988            } else {
2989                node.attrs = Some(serde_json::json!({"localId": local_id}));
2990            }
2991        }
2992    }
2993}
2994
2995/// Copies `localId` from directive attrs to the node's top-level `local_id` field,
2996/// and parses `params` JSON from directive attrs into the node's `parameters` field.
2997fn pass_through_expand_params(
2998    dir_attrs: &Option<crate::atlassian::attrs::Attrs>,
2999    node: &mut AdfNode,
3000) {
3001    if let Some(ref attrs) = dir_attrs {
3002        if let Some(local_id) = attrs.get("localId") {
3003            node.local_id = Some(local_id.to_string());
3004        }
3005        if let Some(params_str) = attrs.get("params") {
3006            if let Ok(params) = serde_json::from_str(params_str) {
3007                node.parameters = Some(params);
3008            }
3009        }
3010    }
3011}
3012
3013// listItem localId is emitted as trailing inline attrs on the item line
3014// (e.g., `- item text {localId=...}`) and parsed back by extracting
3015// trailing attrs from the list item text. This avoids the block-attrs
3016// promotion issue where {localId=...} on a separate line would be
3017// applied to the parent list node.
3018
3019/// Extracts trailing `{localId=... paraLocalId=...}` from list item text.
3020/// Returns the text without the trailing attrs, the listItem localId, and
3021/// the paragraph localId if found.
3022fn extract_trailing_local_id(text: &str) -> (&str, Option<String>, Option<String>) {
3023    let trimmed = text.trim_end();
3024    if !trimmed.ends_with('}') {
3025        return (text, None, None);
3026    }
3027    // Find the opening brace.  Only match a standalone `{…}` block that is
3028    // preceded by whitespace (or is at the start of the string).  A `{` that
3029    // immediately follows `]` is part of an inline directive (e.g.
3030    // `:mention[text]{id=… localId=…}`) and must NOT be consumed here.
3031    if let Some(brace_pos) = trimmed.rfind('{') {
3032        if brace_pos > 0 && !trimmed.as_bytes()[brace_pos - 1].is_ascii_whitespace() {
3033            return (text, None, None);
3034        }
3035        let attr_str = &trimmed[brace_pos..];
3036        if let Some((_, attrs)) = parse_attrs(attr_str, 0) {
3037            let local_id = attrs.get("localId").map(str::to_string);
3038            let para_local_id = attrs.get("paraLocalId").map(str::to_string);
3039            if local_id.is_some() || para_local_id.is_some() {
3040                let before = trimmed[..brace_pos]
3041                    .strip_suffix(' ')
3042                    .unwrap_or(&trimmed[..brace_pos]);
3043                return (before, local_id, para_local_id);
3044            }
3045        }
3046    }
3047    (text, None, None)
3048}
3049
3050/// Creates a `listItem` node, optionally with a `localId` attribute
3051/// and a `paraLocalId` on its first paragraph child.
3052/// Parses the first line of a list item and any indented sub-content into
3053/// an `AdfNode::list_item`.  When the first line is a code fence opener
3054/// (`` ``` ``), the line is folded into the sub-content so the block-level
3055/// code fence parser handles it correctly (issue #511).
3056///
3057/// `depth` is the nesting depth for parsers spawned on the sub-content —
3058/// callers pass their own depth plus one.
3059fn parse_list_item_first_line(
3060    item_text: &str,
3061    sub_lines: Vec<String>,
3062    local_id: Option<String>,
3063    para_local_id: Option<String>,
3064    depth: usize,
3065) -> Result<AdfNode> {
3066    if item_text.starts_with("```") {
3067        // Treat the code fence opener + indented body as block content.
3068        let mut all_lines = vec![item_text.to_string()];
3069        all_lines.extend(sub_lines);
3070        let combined = all_lines.join("\n");
3071        let nested = MarkdownParser::with_depth(&combined, depth).parse_blocks()?;
3072        Ok(list_item_with_local_id(nested, local_id, para_local_id))
3073    } else if let Some(media) = try_parse_media_single_from_line(item_text) {
3074        // Block-level image (issue #430).
3075        if sub_lines.is_empty() {
3076            Ok(list_item_with_local_id(
3077                vec![media],
3078                local_id,
3079                para_local_id,
3080            ))
3081        } else {
3082            let sub_text = sub_lines.join("\n");
3083            let mut nested = MarkdownParser::with_depth(&sub_text, depth).parse_blocks()?;
3084            let mut content = vec![media];
3085            content.append(&mut nested);
3086            Ok(list_item_with_local_id(content, local_id, para_local_id))
3087        }
3088    } else {
3089        let first_node = AdfNode::paragraph(parse_inline(item_text));
3090        if sub_lines.is_empty() {
3091            Ok(list_item_with_local_id(
3092                vec![first_node],
3093                local_id,
3094                para_local_id,
3095            ))
3096        } else {
3097            let sub_text = sub_lines.join("\n");
3098            let mut nested = MarkdownParser::with_depth(&sub_text, depth).parse_blocks()?;
3099            let mut content = vec![first_node];
3100            content.append(&mut nested);
3101            Ok(list_item_with_local_id(content, local_id, para_local_id))
3102        }
3103    }
3104}
3105
3106fn list_item_with_local_id(
3107    mut content: Vec<AdfNode>,
3108    local_id: Option<String>,
3109    para_local_id: Option<String>,
3110) -> AdfNode {
3111    if let Some(id) = &para_local_id {
3112        if let Some(first) = content.first_mut() {
3113            if first.node_type == "paragraph" {
3114                let node_attrs = first.attrs.get_or_insert_with(|| serde_json::json!({}));
3115                node_attrs["localId"] = serde_json::Value::String(id.clone());
3116            }
3117        }
3118    }
3119    let mut item = AdfNode::list_item(content);
3120    if let Some(id) = local_id {
3121        item.attrs = Some(serde_json::json!({"localId": id}));
3122    }
3123    item
3124}
3125
3126fn maybe_push_local_id(attrs: &serde_json::Value, parts: &mut Vec<String>, opts: &RenderOptions) {
3127    if opts.strip_local_ids {
3128        return;
3129    }
3130    if let Some(local_id) = attrs.get("localId").and_then(serde_json::Value::as_str) {
3131        if !local_id.is_empty() && local_id != "00000000-0000-0000-0000-000000000000" {
3132            parts.push(format_kv("localId", local_id));
3133        }
3134    }
3135}
3136
3137/// Renders a sequence of block nodes with blank-line separators between them.
3138fn render_block_children(children: &[AdfNode], output: &mut String, opts: &RenderOptions) {
3139    for (i, child) in children.iter().enumerate() {
3140        if i > 0 {
3141            output.push('\n');
3142        }
3143        render_block_node(child, output, opts);
3144    }
3145}
3146
3147/// Formats a float as an integer string when it has no fractional part,
3148/// otherwise as a regular float string.
3149fn fmt_f64_attr(v: f64) -> String {
3150    if v.fract() == 0.0 {
3151        format!("{}", v as i64)
3152    } else {
3153        v.to_string()
3154    }
3155}
3156
3157/// Parses a numeric attribute value string into a JSON number value that
3158/// preserves the original integer/float distinction. Returns `None` if the
3159/// string cannot be parsed as a number.
3160///
3161/// Strings without a `.` or exponent are parsed as integers (so `"800"` stays
3162/// `800`, not `800.0`); strings with a decimal point are parsed as floats.
3163fn parse_numeric_attr(s: &str) -> Option<serde_json::Value> {
3164    if s.contains('.') || s.contains('e') || s.contains('E') {
3165        s.parse::<f64>().ok().map(serde_json::Value::from)
3166    } else {
3167        s.parse::<i64>().ok().map(serde_json::Value::from)
3168    }
3169}
3170
3171/// Formats a JSON numeric value as a markdown attribute string, preserving
3172/// whether the source was stored as an integer or a float.
3173///
3174/// Returns `None` if `v` is not a number. Integer values emit as `800`;
3175/// floating-point values emit as `800.0` (or `66.66` for non-integer floats),
3176/// so that a subsequent [`parse_numeric_attr`] round-trip recovers the same
3177/// JSON type.
3178fn fmt_numeric_attr(v: &serde_json::Value) -> Option<String> {
3179    if let Some(n) = v.as_i64() {
3180        return Some(n.to_string());
3181    }
3182    if let Some(n) = v.as_u64() {
3183        return Some(n.to_string());
3184    }
3185    if let Some(n) = v.as_f64() {
3186        if n.fract() == 0.0 && n.is_finite() {
3187            return Some(format!("{n:.1}"));
3188        }
3189        return Some(n.to_string());
3190    }
3191    None
3192}
3193
3194/// Renders a block-level ADF node to markdown.
3195fn render_block_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3196    match node.node_type.as_str() {
3197        "paragraph" => {
3198            let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3199            // Build directive attr string for localId when using ::paragraph form
3200            let dir_attrs = {
3201                let mut parts = Vec::new();
3202                if let Some(ref attrs) = node.attrs {
3203                    maybe_push_local_id(attrs, &mut parts, opts);
3204                }
3205                if parts.is_empty() {
3206                    String::new()
3207                } else {
3208                    format!("{{{}}}", parts.join(" "))
3209                }
3210            };
3211            if is_empty {
3212                output.push_str(&format!("::paragraph{dir_attrs}\n"));
3213            } else {
3214                // Render to a buffer first to check if content is whitespace-only
3215                let mut buf = String::new();
3216                render_inline_content(node, &mut buf, opts);
3217                if buf.trim().is_empty() && !buf.is_empty() {
3218                    // Whitespace-only content (e.g. NBSP) would be lost as a plain
3219                    // line — use the ::paragraph[content]{attrs} directive form
3220                    output.push_str(&format!("::paragraph[{buf}]{dir_attrs}\n"));
3221                } else {
3222                    // Escape a leading list-marker pattern so paragraph
3223                    // text is not re-parsed as a list item (issue #402).
3224                    // Indent continuation lines produced by hardBreaks so
3225                    // they are not re-parsed as list items (issue #455).
3226                    let mut is_first_line = true;
3227                    for line in buf.split('\n') {
3228                        if is_first_line {
3229                            if is_list_start(line) {
3230                                output.push_str(&escape_list_marker(line));
3231                            } else {
3232                                output.push_str(line);
3233                            }
3234                            is_first_line = false;
3235                        } else {
3236                            output.push('\n');
3237                            if !line.is_empty() {
3238                                output.push_str("  ");
3239                            }
3240                            output.push_str(line);
3241                        }
3242                    }
3243                    output.push('\n');
3244                }
3245            }
3246        }
3247        "heading" => {
3248            let level = node
3249                .attrs
3250                .as_ref()
3251                .and_then(|a| a.get("level"))
3252                .and_then(serde_json::Value::as_u64)
3253                .unwrap_or(1);
3254            for _ in 0..level {
3255                output.push('#');
3256            }
3257            output.push(' ');
3258            let mut buf = String::new();
3259            render_inline_content(node, &mut buf, opts);
3260            // Indent continuation lines produced by hardBreaks so they stay
3261            // within the heading when re-parsed (issue #433).
3262            let mut is_first_line = true;
3263            for line in buf.split('\n') {
3264                if is_first_line {
3265                    output.push_str(line);
3266                    is_first_line = false;
3267                } else {
3268                    output.push('\n');
3269                    if !line.is_empty() {
3270                        output.push_str("  ");
3271                    }
3272                    output.push_str(line);
3273                }
3274            }
3275            output.push('\n');
3276        }
3277        "codeBlock" => {
3278            let language_value = node.attrs.as_ref().and_then(|a| a.get("language"));
3279            let language = language_value
3280                .and_then(serde_json::Value::as_str)
3281                .unwrap_or("");
3282            output.push_str("```");
3283            if language.is_empty() && language_value.is_some() {
3284                // Explicit empty language attr: encode as ```"" to distinguish
3285                // from a codeBlock with no attrs at all (plain ```).
3286                output.push_str("\"\"");
3287            } else {
3288                output.push_str(language);
3289            }
3290            output.push('\n');
3291            if let Some(ref content) = node.content {
3292                for child in content {
3293                    if let Some(ref text) = child.text {
3294                        output.push_str(text);
3295                    }
3296                }
3297            }
3298            output.push_str("\n```\n");
3299        }
3300        "blockquote" => {
3301            if let Some(ref content) = node.content {
3302                for (i, child) in content.iter().enumerate() {
3303                    // Separate consecutive paragraph siblings with a blank
3304                    // blockquote-prefixed line so they re-parse as distinct
3305                    // paragraphs rather than being merged into one (issue #531).
3306                    if i > 0
3307                        && child.node_type == "paragraph"
3308                        && content[i - 1].node_type == "paragraph"
3309                    {
3310                        output.push_str(">\n");
3311                    }
3312                    let mut inner = String::new();
3313                    render_block_node(child, &mut inner, opts);
3314                    for line in inner.lines() {
3315                        output.push_str("> ");
3316                        output.push_str(line);
3317                        output.push('\n');
3318                    }
3319                }
3320            }
3321        }
3322        "bulletList" => {
3323            if let Some(ref items) = node.content {
3324                for item in items {
3325                    output.push_str("- ");
3326                    let content_start = output.len();
3327                    render_list_item_content(item, output, opts);
3328                    // If the rendered content begins with a sequence the
3329                    // bullet-list parser would interpret as a task checkbox
3330                    // marker, escape the leading `[` so the round-trip
3331                    // preserves this as a `bulletList` rather than promoting
3332                    // it to a `taskList` (issue #548).
3333                    if starts_with_task_marker(&output[content_start..]) {
3334                        output.insert(content_start, '\\');
3335                    }
3336                }
3337            }
3338        }
3339        "orderedList" => {
3340            let start = node
3341                .attrs
3342                .as_ref()
3343                .and_then(|a| a.get("order"))
3344                .and_then(serde_json::Value::as_u64)
3345                .unwrap_or(1);
3346            if let Some(ref items) = node.content {
3347                for (i, item) in items.iter().enumerate() {
3348                    let num = start + i as u64;
3349                    output.push_str(&format!("{num}. "));
3350                    render_list_item_content(item, output, opts);
3351                }
3352            }
3353        }
3354        "taskList" => {
3355            if let Some(ref items) = node.content {
3356                for item in items {
3357                    if item.node_type == "taskList" {
3358                        // A nested taskList is a sibling child of the outer
3359                        // taskList — render it indented so it round-trips back
3360                        // as a taskList, not a taskItem (issue #506).
3361                        let mut nested = String::new();
3362                        render_block_node(item, &mut nested, opts);
3363                        for line in nested.lines() {
3364                            output.push_str("  ");
3365                            output.push_str(line);
3366                            output.push('\n');
3367                        }
3368                    } else {
3369                        let state = item
3370                            .attrs
3371                            .as_ref()
3372                            .and_then(|a| a.get("state"))
3373                            .and_then(serde_json::Value::as_str)
3374                            .unwrap_or("TODO");
3375                        if state == "DONE" {
3376                            output.push_str("- [x] ");
3377                        } else {
3378                            output.push_str("- [ ] ");
3379                        }
3380                        render_list_item_content(item, output, opts);
3381                    }
3382                }
3383            }
3384        }
3385        "rule" => {
3386            output.push_str("---\n");
3387        }
3388        "table" => {
3389            render_table(node, output, opts);
3390        }
3391        "mediaSingle" => {
3392            if let Some(ref content) = node.content {
3393                for child in content {
3394                    if child.node_type == "media" {
3395                        render_media(child, node.attrs.as_ref(), output, opts);
3396                    }
3397                }
3398                for child in content {
3399                    if child.node_type == "caption" {
3400                        let mut cap_parts = Vec::new();
3401                        if let Some(ref attrs) = child.attrs {
3402                            maybe_push_local_id(attrs, &mut cap_parts, opts);
3403                        }
3404                        if cap_parts.is_empty() {
3405                            output.push_str(":::caption\n");
3406                        } else {
3407                            output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3408                        }
3409                        if let Some(ref caption_content) = child.content {
3410                            for inline in caption_content {
3411                                render_inline_node(inline, output, opts);
3412                            }
3413                            output.push('\n');
3414                        }
3415                        output.push_str(":::\n");
3416                    }
3417                }
3418            }
3419        }
3420        "blockCard" => {
3421            if let Some(ref attrs) = node.attrs {
3422                let url = attrs
3423                    .get("url")
3424                    .and_then(serde_json::Value::as_str)
3425                    .unwrap_or("");
3426                let mut attr_parts = Vec::new();
3427                if url_safe_in_bracket_content(url) {
3428                    output.push_str(&format!("::card[{url}]"));
3429                } else {
3430                    // URL would break `::card[URL]` parsing; use quoted attr form.
3431                    output.push_str("::card[]");
3432                    let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
3433                    attr_parts.push(format!("url=\"{escaped}\""));
3434                }
3435                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3436                    attr_parts.push(format!("layout={layout}"));
3437                }
3438                if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
3439                    attr_parts.push(format!("width={width}"));
3440                }
3441                if !attr_parts.is_empty() {
3442                    output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3443                }
3444                output.push('\n');
3445            }
3446        }
3447        "embedCard" => {
3448            if let Some(ref attrs) = node.attrs {
3449                let url = attrs
3450                    .get("url")
3451                    .and_then(serde_json::Value::as_str)
3452                    .unwrap_or("");
3453                output.push_str(&format!("::embed[{url}]"));
3454                let mut attr_parts = Vec::new();
3455                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3456                    attr_parts.push(format!("layout={layout}"));
3457                }
3458                if let Some(h) = attrs
3459                    .get("originalHeight")
3460                    .and_then(serde_json::Value::as_f64)
3461                {
3462                    attr_parts.push(format!("originalHeight={}", fmt_f64_attr(h)));
3463                }
3464                if let Some(w) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3465                    attr_parts.push(format!("width={}", fmt_f64_attr(w)));
3466                }
3467                if !attr_parts.is_empty() {
3468                    output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3469                }
3470                output.push('\n');
3471            }
3472        }
3473        "extension" => {
3474            if let Some(ref attrs) = node.attrs {
3475                let ext_type = attrs
3476                    .get("extensionType")
3477                    .and_then(serde_json::Value::as_str)
3478                    .unwrap_or("");
3479                let ext_key = attrs
3480                    .get("extensionKey")
3481                    .and_then(serde_json::Value::as_str)
3482                    .unwrap_or("");
3483                let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3484                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3485                    attr_parts.push(format!("layout={layout}"));
3486                }
3487                if let Some(params) = attrs.get("parameters") {
3488                    if let Ok(json_str) = serde_json::to_string(params) {
3489                        attr_parts.push(format!("params='{json_str}'"));
3490                    }
3491                }
3492                maybe_push_local_id(attrs, &mut attr_parts, opts);
3493                output.push_str(&format!("::extension{{{}}}\n", attr_parts.join(" ")));
3494            }
3495        }
3496        "panel" => {
3497            let panel_type = node
3498                .attrs
3499                .as_ref()
3500                .and_then(|a| a.get("panelType"))
3501                .and_then(serde_json::Value::as_str)
3502                .unwrap_or("info");
3503            let mut attr_parts = vec![format!("type={panel_type}")];
3504            if let Some(ref attrs) = node.attrs {
3505                if let Some(icon) = attrs.get("panelIcon").and_then(serde_json::Value::as_str) {
3506                    attr_parts.push(format!("icon=\"{icon}\""));
3507                }
3508                if let Some(color) = attrs.get("panelColor").and_then(serde_json::Value::as_str) {
3509                    attr_parts.push(format!("color=\"{color}\""));
3510                }
3511            }
3512            output.push_str(&format!(":::panel{{{}}}\n", attr_parts.join(" ")));
3513            if let Some(ref content) = node.content {
3514                render_block_children(content, output, opts);
3515            }
3516            output.push_str(":::\n");
3517        }
3518        "expand" | "nestedExpand" => {
3519            let directive_name = if node.node_type == "nestedExpand" {
3520                "nested-expand"
3521            } else {
3522                "expand"
3523            };
3524            let mut attr_parts = Vec::new();
3525            if let Some(t) = node
3526                .attrs
3527                .as_ref()
3528                .and_then(|a| a.get("title"))
3529                .and_then(serde_json::Value::as_str)
3530            {
3531                attr_parts.push(format!("title=\"{t}\""));
3532            }
3533            // Check top-level localId first, then fall back to attrs.localId
3534            if let Some(ref lid) = node.local_id {
3535                if !opts.strip_local_ids && lid != "00000000-0000-0000-0000-000000000000" {
3536                    attr_parts.push(format!("localId={lid}"));
3537                }
3538            } else if let Some(ref attrs) = node.attrs {
3539                maybe_push_local_id(attrs, &mut attr_parts, opts);
3540            }
3541            // Emit top-level parameters as params='...'
3542            if let Some(ref params) = node.parameters {
3543                if let Ok(json_str) = serde_json::to_string(params) {
3544                    attr_parts.push(format!("params='{json_str}'"));
3545                }
3546            }
3547            if attr_parts.is_empty() {
3548                output.push_str(&format!(":::{directive_name}\n"));
3549            } else {
3550                output.push_str(&format!(
3551                    ":::{directive_name}{{{}}}\n",
3552                    attr_parts.join(" ")
3553                ));
3554            }
3555            if let Some(ref content) = node.content {
3556                render_block_children(content, output, opts);
3557            }
3558            output.push_str(":::\n");
3559        }
3560        "layoutSection" => {
3561            output.push_str("::::layout\n");
3562            if let Some(ref content) = node.content {
3563                for child in content {
3564                    if child.node_type == "layoutColumn" {
3565                        let width_str = child
3566                            .attrs
3567                            .as_ref()
3568                            .and_then(|a| a.get("width"))
3569                            .and_then(fmt_numeric_attr)
3570                            .unwrap_or_else(|| "50".to_string());
3571                        let mut parts = vec![format!("width={width_str}")];
3572                        if let Some(ref attrs) = child.attrs {
3573                            maybe_push_local_id(attrs, &mut parts, opts);
3574                        }
3575                        output.push_str(&format!(":::column{{{}}}\n", parts.join(" ")));
3576                        if let Some(ref col_content) = child.content {
3577                            render_block_children(col_content, output, opts);
3578                        }
3579                        output.push_str(":::\n");
3580                    }
3581                }
3582            }
3583            output.push_str("::::\n");
3584        }
3585        "decisionList" => {
3586            output.push_str(":::decisions\n");
3587            if let Some(ref content) = node.content {
3588                for item in content {
3589                    output.push_str("- <> ");
3590                    render_list_item_content(item, output, opts);
3591                }
3592            }
3593            output.push_str(":::\n");
3594        }
3595        "bodiedExtension" => {
3596            if let Some(ref attrs) = node.attrs {
3597                let ext_type = attrs
3598                    .get("extensionType")
3599                    .and_then(serde_json::Value::as_str)
3600                    .unwrap_or("");
3601                let ext_key = attrs
3602                    .get("extensionKey")
3603                    .and_then(serde_json::Value::as_str)
3604                    .unwrap_or("");
3605                let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3606                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3607                    attr_parts.push(format!("layout={layout}"));
3608                }
3609                if let Some(params) = attrs.get("parameters") {
3610                    if let Ok(json_str) = serde_json::to_string(params) {
3611                        attr_parts.push(format!("params='{json_str}'"));
3612                    }
3613                }
3614                maybe_push_local_id(attrs, &mut attr_parts, opts);
3615                output.push_str(&format!(":::extension{{{}}}\n", attr_parts.join(" ")));
3616                if let Some(ref content) = node.content {
3617                    render_block_children(content, output, opts);
3618                }
3619                output.push_str(":::\n");
3620            }
3621        }
3622        _ => {
3623            // Preserve unsupported nodes as JSON in adf-unsupported code blocks
3624            if let Ok(json) = serde_json::to_string_pretty(node) {
3625                output.push_str("```adf-unsupported\n");
3626                output.push_str(&json);
3627                output.push_str("\n```\n");
3628            }
3629        }
3630    }
3631
3632    // Emit block-level attribute marks (align, indent, breakout) and localId
3633    let mut parts = Vec::new();
3634    if let Some(ref marks) = node.marks {
3635        for mark in marks {
3636            match mark.mark_type.as_str() {
3637                "alignment" => {
3638                    if let Some(align) = mark
3639                        .attrs
3640                        .as_ref()
3641                        .and_then(|a| a.get("align"))
3642                        .and_then(serde_json::Value::as_str)
3643                    {
3644                        parts.push(format!("align={align}"));
3645                    }
3646                }
3647                "indentation" => {
3648                    if let Some(level) = mark
3649                        .attrs
3650                        .as_ref()
3651                        .and_then(|a| a.get("level"))
3652                        .and_then(serde_json::Value::as_u64)
3653                    {
3654                        parts.push(format!("indent={level}"));
3655                    }
3656                }
3657                "breakout" => {
3658                    if let Some(mode) = mark
3659                        .attrs
3660                        .as_ref()
3661                        .and_then(|a| a.get("mode"))
3662                        .and_then(serde_json::Value::as_str)
3663                    {
3664                        parts.push(format!("breakout={mode}"));
3665                    }
3666                    if let Some(width) = mark
3667                        .attrs
3668                        .as_ref()
3669                        .and_then(|a| a.get("width"))
3670                        .and_then(serde_json::Value::as_u64)
3671                    {
3672                        parts.push(format!("breakoutWidth={width}"));
3673                    }
3674                }
3675                _ => {}
3676            }
3677        }
3678    }
3679    // Skip localId for node types that already include it in their directive attrs.
3680    // For paragraphs, localId is included in the ::paragraph directive when the
3681    // paragraph uses directive form (empty or whitespace-only content).
3682    let para_used_directive = node.node_type == "paragraph" && {
3683        let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3684        if is_empty {
3685            true
3686        } else {
3687            let mut buf = String::new();
3688            render_inline_content(node, &mut buf, opts);
3689            buf.trim().is_empty() && !buf.is_empty()
3690        }
3691    };
3692    if !matches!(node.node_type.as_str(), "expand" | "nestedExpand") && !para_used_directive {
3693        if let Some(ref attrs) = node.attrs {
3694            maybe_push_local_id(attrs, &mut parts, opts);
3695        }
3696    }
3697    // orderedList with explicit `attrs.order=1` needs a trailing `{order=1}`
3698    // signal so the round-trip can distinguish explicit default from omitted
3699    // attrs (issue #547). Values other than 1 are already encoded by the
3700    // list marker, so no signal is needed.
3701    if node.node_type == "orderedList" {
3702        if let Some(ref attrs) = node.attrs {
3703            if attrs.get("order").and_then(serde_json::Value::as_u64) == Some(1) {
3704                parts.push("order=1".to_string());
3705            }
3706        }
3707    }
3708    if !parts.is_empty() {
3709        output.push_str(&format!("{{{}}}\n", parts.join(" ")));
3710    }
3711}
3712
3713/// Renders the content of a list item (unwraps the paragraph layer).
3714/// Nested block children (e.g. sub-lists) are indented with two spaces.
3715///
3716/// Some ADF producers (e.g. Confluence) emit `taskItem` content without a
3717/// paragraph wrapper — the inline nodes sit directly inside the item.  We
3718/// detect this by checking whether the first child is an inline node type
3719/// and, if so, render *all* leading inline children on the first line.
3720fn render_list_item_content(item: &AdfNode, output: &mut String, opts: &RenderOptions) {
3721    let Some(ref content) = item.content else {
3722        // Still emit localId and newline for items with no content (e.g. empty taskItem).
3723        let bare = AdfNode::text("");
3724        emit_list_item_local_ids(item, &bare, output, opts);
3725        output.push('\n');
3726        return;
3727    };
3728    if content.is_empty() {
3729        let bare = AdfNode::text("");
3730        emit_list_item_local_ids(item, &bare, output, opts);
3731        output.push('\n');
3732        return;
3733    }
3734    let first = &content[0];
3735    let rest_start;
3736    if first.node_type == "paragraph" {
3737        let mut buf = String::new();
3738        render_inline_content(first, &mut buf, opts);
3739        // A trailing hardBreak produces a trailing `\\\n` in the buffer.
3740        // Strip the final newline so it doesn't create a blank line after
3741        // the list item marker, which would split the list on re-parse
3742        // (issue #472).  The `\` is kept so round-trip preserves the
3743        // hardBreak, and `output.push('\n')` below supplies the terminator.
3744        let buf = buf.trim_end_matches('\n');
3745        // Indent continuation lines produced by hardBreaks so they stay
3746        // within the list item when re-parsed (issue #402).
3747        let mut is_first_line = true;
3748        for line in buf.split('\n') {
3749            if is_first_line {
3750                output.push_str(line);
3751                is_first_line = false;
3752            } else {
3753                output.push('\n');
3754                if !line.is_empty() {
3755                    output.push_str("  ");
3756                }
3757                output.push_str(line);
3758            }
3759        }
3760        // Emit paragraph + listItem localIds as trailing inline attrs on the first line
3761        emit_list_item_local_ids(item, first, output, opts);
3762        output.push('\n');
3763        rest_start = 1;
3764    } else if is_inline_node_type(&first.node_type) {
3765        // Inline nodes without a paragraph wrapper — render them directly.
3766        rest_start = content
3767            .iter()
3768            .position(|c| !is_inline_node_type(&c.node_type))
3769            .unwrap_or(content.len());
3770        let mut buf = String::new();
3771        for child in &content[..rest_start] {
3772            render_inline_node(child, &mut buf, opts);
3773        }
3774        // Indent continuation lines produced by hardBreaks so they stay
3775        // within the list item when re-parsed (issue #521).
3776        let buf = buf.trim_end_matches('\n');
3777        let mut is_first_line = true;
3778        for line in buf.split('\n') {
3779            if is_first_line {
3780                output.push_str(line);
3781                is_first_line = false;
3782            } else {
3783                output.push('\n');
3784                if !line.is_empty() {
3785                    output.push_str("  ");
3786                }
3787                output.push_str(line);
3788            }
3789        }
3790        // No paragraph wrapper — pass a bare node so paraLocalId is omitted.
3791        let bare = AdfNode::text("");
3792        emit_list_item_local_ids(item, &bare, output, opts);
3793        output.push('\n');
3794        // Any remaining children are block nodes — fall through to the
3795        // indented-block loop below.
3796    } else if first.node_type == "taskItem" {
3797        // Malformed ADF: taskItem.content contains nested taskItem nodes
3798        // directly (seen in some Confluence pages).  Render them as an
3799        // indented nested task list to preserve the data without
3800        // corrupting the surrounding structure (issue #489).
3801        let bare = AdfNode::text("");
3802        emit_list_item_local_ids(item, &bare, output, opts);
3803        output.push('\n');
3804        for child in content {
3805            if child.node_type == "taskItem" {
3806                let state = child
3807                    .attrs
3808                    .as_ref()
3809                    .and_then(|a| a.get("state"))
3810                    .and_then(serde_json::Value::as_str)
3811                    .unwrap_or("TODO");
3812                let marker = if state == "DONE" { "- [x] " } else { "- [ ] " };
3813                output.push_str("  ");
3814                output.push_str(marker);
3815                render_list_item_content(child, output, opts);
3816            } else {
3817                let mut nested = String::new();
3818                render_block_node(child, &mut nested, opts);
3819                for line in nested.lines() {
3820                    output.push_str("  ");
3821                    output.push_str(line);
3822                    output.push('\n');
3823                }
3824            }
3825        }
3826        return;
3827    } else {
3828        // Block-level first child (e.g. codeBlock, mediaSingle).
3829        // Render to a buffer so we can:
3830        //  1. Append listItem localId attrs to the first line (issue #525)
3831        //  2. Indent continuation lines so multi-line blocks stay inside
3832        //     the list item (issue #511)
3833        let mut buf = String::new();
3834        render_block_node(first, &mut buf, opts);
3835        let bare = AdfNode::text("");
3836        let mut is_first = true;
3837        for line in buf.lines() {
3838            if is_first {
3839                output.push_str(line);
3840                emit_list_item_local_ids(item, &bare, output, opts);
3841                output.push('\n');
3842                is_first = false;
3843            } else {
3844                output.push_str("  ");
3845                output.push_str(line);
3846                output.push('\n');
3847            }
3848        }
3849        rest_start = 1;
3850    }
3851    let rest = &content[rest_start..];
3852    for (i, child) in rest.iter().enumerate() {
3853        // Separate consecutive paragraph siblings with a blank indented
3854        // line so they re-parse as distinct paragraphs rather than being
3855        // merged into one (issue #522).
3856        if child.node_type == "paragraph" {
3857            let prev_is_para = if i == 0 {
3858                // First rest child — check whether the first-line node
3859                // (rendered above) was a paragraph.
3860                first.node_type == "paragraph"
3861            } else {
3862                rest[i - 1].node_type == "paragraph"
3863            };
3864            if prev_is_para {
3865                output.push_str("  \n");
3866            }
3867        }
3868        let mut nested = String::new();
3869        render_block_node(child, &mut nested, opts);
3870        for line in nested.lines() {
3871            output.push_str("  ");
3872            output.push_str(line);
3873            output.push('\n');
3874        }
3875    }
3876}
3877
3878/// Returns `true` if the given ADF node type is an inline node.
3879fn is_inline_node_type(node_type: &str) -> bool {
3880    matches!(
3881        node_type,
3882        "text"
3883            | "hardBreak"
3884            | "inlineCard"
3885            | "emoji"
3886            | "mention"
3887            | "status"
3888            | "date"
3889            | "placeholder"
3890            | "mediaInline"
3891    )
3892}
3893
3894/// Emits trailing `{localId=... paraLocalId=...}` on a list item line
3895/// for both the listItem and its first (unwrapped) paragraph.
3896fn emit_list_item_local_ids(
3897    item: &AdfNode,
3898    paragraph: &AdfNode,
3899    output: &mut String,
3900    opts: &RenderOptions,
3901) {
3902    if opts.strip_local_ids {
3903        return;
3904    }
3905    let mut parts = Vec::new();
3906    if let Some(ref attrs) = item.attrs {
3907        maybe_push_local_id(attrs, &mut parts, opts);
3908    }
3909    if paragraph.node_type == "paragraph" {
3910        let has_real_id = paragraph
3911            .attrs
3912            .as_ref()
3913            .and_then(|a| a.get("localId"))
3914            .and_then(serde_json::Value::as_str)
3915            .filter(|id| !id.is_empty() && *id != "00000000-0000-0000-0000-000000000000");
3916        if let Some(local_id) = has_real_id {
3917            parts.push(format!("paraLocalId={local_id}"));
3918        } else if item.node_type == "taskItem" {
3919            // taskItem content may or may not have a paragraph wrapper;
3920            // emit a sentinel so the round-trip can distinguish the two
3921            // forms and restore the wrapper (issue #478).
3922            parts.push("paraLocalId=_".to_string());
3923        }
3924    }
3925    if !parts.is_empty() {
3926        output.push_str(&format!(" {{{}}}", parts.join(" ")));
3927    }
3928}
3929
3930/// Renders a table node, choosing between pipe table and directive table form.
3931fn render_table(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3932    let Some(ref rows) = node.content else {
3933        return;
3934    };
3935
3936    if table_qualifies_for_pipe_syntax(rows) {
3937        render_pipe_table(node, rows, output, opts);
3938    } else {
3939        render_directive_table(node, rows, output, opts);
3940    }
3941}
3942
3943/// Checks whether all cells qualify for GFM pipe table syntax:
3944/// - Every cell has exactly one paragraph child with only inline nodes
3945/// - All `tableHeader` nodes appear exclusively in the first row
3946/// - The first row must contain at least one `tableHeader` (pipe tables
3947///   always treat the first row as headers, so `tableCell`-only first rows
3948///   must use directive form to preserve the cell type)
3949fn table_qualifies_for_pipe_syntax(rows: &[AdfNode]) -> bool {
3950    // Tables with caption nodes must use directive form
3951    if rows.iter().any(|n| n.node_type == "caption") {
3952        return false;
3953    }
3954    let mut first_row_has_header = false;
3955    for (row_idx, row) in rows.iter().enumerate() {
3956        let Some(ref cells) = row.content else {
3957            continue;
3958        };
3959        for cell in cells {
3960            // Header cells outside first row → must use directive form
3961            if row_idx > 0 && cell.node_type == "tableHeader" {
3962                return false;
3963            }
3964            if row_idx == 0 && cell.node_type == "tableHeader" {
3965                first_row_has_header = true;
3966            }
3967            // Check cell has exactly one paragraph with only inline content
3968            let Some(ref content) = cell.content else {
3969                continue;
3970            };
3971            if content.len() != 1 || content[0].node_type != "paragraph" {
3972                return false;
3973            }
3974            // hardBreak inside a cell produces a newline that breaks pipe
3975            // table syntax — fall back to directive form
3976            if cell_contains_hard_break(&content[0]) {
3977                return false;
3978            }
3979            // Cell-level marks (e.g., border) cannot be represented in pipe
3980            // form — fall back to directive form
3981            if cell.marks.as_ref().is_some_and(|m| !m.is_empty()) {
3982                return false;
3983            }
3984            // Paragraph-level localId would be lost in pipe form (the paragraph
3985            // is unwrapped into the cell text) — fall back to directive form
3986            if content[0]
3987                .attrs
3988                .as_ref()
3989                .and_then(|a| a.get("localId"))
3990                .is_some()
3991            {
3992                return false;
3993            }
3994        }
3995    }
3996    // First row must have at least one tableHeader for pipe syntax;
3997    // otherwise the round-trip would convert tableCell → tableHeader.
3998    first_row_has_header
3999}
4000
4001/// Returns true if a paragraph node contains any `hardBreak` inline nodes.
4002fn cell_contains_hard_break(paragraph: &AdfNode) -> bool {
4003    paragraph
4004        .content
4005        .as_ref()
4006        .is_some_and(|nodes| nodes.iter().any(|n| n.node_type == "hardBreak"))
4007}
4008
4009/// Renders a table as GFM pipe syntax.
4010fn render_pipe_table(node: &AdfNode, rows: &[AdfNode], output: &mut String, opts: &RenderOptions) {
4011    for (row_idx, row) in rows.iter().enumerate() {
4012        let Some(ref cells) = row.content else {
4013            continue;
4014        };
4015
4016        output.push('|');
4017        for cell in cells {
4018            output.push(' ');
4019            let mut cell_buf = String::new();
4020            render_cell_attrs_prefix(cell, &mut cell_buf);
4021            render_inline_content_from_first_paragraph(cell, &mut cell_buf, opts);
4022            output.push_str(&escape_pipes_in_cell(&cell_buf));
4023            output.push_str(" |");
4024        }
4025        output.push('\n');
4026
4027        // Add separator after header row
4028        if row_idx == 0 {
4029            output.push('|');
4030            for cell in cells {
4031                let align = get_cell_paragraph_alignment(cell);
4032                match align {
4033                    Some("center") => output.push_str(" :---: |"),
4034                    Some("end") => output.push_str(" ---: |"),
4035                    _ => output.push_str(" --- |"),
4036                }
4037            }
4038            output.push('\n');
4039        }
4040    }
4041
4042    // Emit table-level attrs if present
4043    render_table_level_attrs(node, output, opts);
4044}
4045
4046/// Renders a table as `::::table` directive syntax (block-content cells).
4047fn render_directive_table(
4048    node: &AdfNode,
4049    rows: &[AdfNode],
4050    output: &mut String,
4051    opts: &RenderOptions,
4052) {
4053    // Opening fence with attrs
4054    let mut attr_parts = Vec::new();
4055    if let Some(ref attrs) = node.attrs {
4056        if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
4057            attr_parts.push(format!("layout={layout}"));
4058        }
4059        if let Some(numbered) = attrs
4060            .get("isNumberColumnEnabled")
4061            .and_then(serde_json::Value::as_bool)
4062        {
4063            if numbered {
4064                attr_parts.push("numbered".to_string());
4065            } else {
4066                attr_parts.push("numbered=false".to_string());
4067            }
4068        }
4069        if let Some(tw) = attrs.get("width").and_then(serde_json::Value::as_f64) {
4070            let tw_str = if tw.fract() == 0.0 {
4071                (tw as u64).to_string()
4072            } else {
4073                tw.to_string()
4074            };
4075            attr_parts.push(format!("width={tw_str}"));
4076        }
4077        maybe_push_local_id(attrs, &mut attr_parts, opts);
4078    }
4079    if attr_parts.is_empty() {
4080        output.push_str("::::table\n");
4081    } else {
4082        output.push_str(&format!("::::table{{{}}}\n", attr_parts.join(" ")));
4083    }
4084
4085    for row in rows {
4086        if row.node_type == "caption" {
4087            let mut cap_parts = Vec::new();
4088            if let Some(ref attrs) = row.attrs {
4089                maybe_push_local_id(attrs, &mut cap_parts, opts);
4090            }
4091            if cap_parts.is_empty() {
4092                output.push_str(":::caption\n");
4093            } else {
4094                output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
4095            }
4096            if let Some(ref content) = row.content {
4097                for child in content {
4098                    render_inline_node(child, output, opts);
4099                }
4100                output.push('\n');
4101            }
4102            output.push_str(":::\n");
4103            continue;
4104        }
4105        let Some(ref cells) = row.content else {
4106            continue;
4107        };
4108        // Emit :::tr with optional localId
4109        let mut tr_attrs = Vec::new();
4110        if let Some(ref attrs) = row.attrs {
4111            maybe_push_local_id(attrs, &mut tr_attrs, opts);
4112        }
4113        if tr_attrs.is_empty() {
4114            output.push_str(":::tr\n");
4115        } else {
4116            output.push_str(&format!(":::tr{{{}}}\n", tr_attrs.join(" ")));
4117        }
4118        for cell in cells {
4119            let directive_name = if cell.node_type == "tableHeader" {
4120                "th"
4121            } else {
4122                "td"
4123            };
4124            let mut cell_attr_str = build_cell_attrs_string(cell);
4125            // Append localId to cell attrs if present
4126            if let Some(ref attrs) = cell.attrs {
4127                let mut lid_parts = Vec::new();
4128                maybe_push_local_id(attrs, &mut lid_parts, opts);
4129                if !lid_parts.is_empty() {
4130                    if !cell_attr_str.is_empty() {
4131                        cell_attr_str.push(' ');
4132                    }
4133                    cell_attr_str.push_str(&lid_parts.join(" "));
4134                }
4135            }
4136            // Append border mark attrs if present
4137            if let Some(ref marks) = cell.marks {
4138                for mark in marks {
4139                    if mark.mark_type == "border" {
4140                        if let Some(ref attrs) = mark.attrs {
4141                            if let Some(color) =
4142                                attrs.get("color").and_then(serde_json::Value::as_str)
4143                            {
4144                                if !cell_attr_str.is_empty() {
4145                                    cell_attr_str.push(' ');
4146                                }
4147                                cell_attr_str.push_str(&format!("border-color={color}"));
4148                            }
4149                            if let Some(size) =
4150                                attrs.get("size").and_then(serde_json::Value::as_u64)
4151                            {
4152                                if !cell_attr_str.is_empty() {
4153                                    cell_attr_str.push(' ');
4154                                }
4155                                cell_attr_str.push_str(&format!("border-size={size}"));
4156                            }
4157                        }
4158                    }
4159                }
4160            }
4161            let has_marks = cell.marks.as_ref().is_some_and(|m| !m.is_empty());
4162            if cell_attr_str.is_empty() && cell.attrs.is_none() && !has_marks {
4163                output.push_str(&format!(":::{directive_name}\n"));
4164            } else {
4165                output.push_str(&format!(":::{directive_name}{{{cell_attr_str}}}\n"));
4166            }
4167            if let Some(ref content) = cell.content {
4168                render_block_children(content, output, opts);
4169            }
4170            output.push_str(":::\n");
4171        }
4172        output.push_str(":::\n");
4173    }
4174
4175    output.push_str("::::\n");
4176}
4177
4178/// Returns `true` when an attribute value must be quoted to survive round-trip
4179/// through the `{key=value}` attribute parser (which stops unquoted values at
4180/// whitespace or `}`).
4181fn needs_attr_quoting(value: &str) -> bool {
4182    value.contains(|c: char| c.is_whitespace() || c == '}' || c == '(' || c == ')' || c == ',')
4183}
4184
4185/// Builds a JFM attribute string from ADF cell attributes.
4186fn build_cell_attrs_string(cell: &AdfNode) -> String {
4187    let Some(ref attrs) = cell.attrs else {
4188        return String::new();
4189    };
4190    let mut parts = Vec::new();
4191    if let Some(colspan) = attrs.get("colspan").and_then(serde_json::Value::as_u64) {
4192        parts.push(format!("colspan={colspan}"));
4193    }
4194    if let Some(rowspan) = attrs.get("rowspan").and_then(serde_json::Value::as_u64) {
4195        parts.push(format!("rowspan={rowspan}"));
4196    }
4197    if let Some(bg) = attrs.get("background").and_then(serde_json::Value::as_str) {
4198        if needs_attr_quoting(bg) {
4199            let escaped = bg.replace('\\', "\\\\").replace('"', "\\\"");
4200            parts.push(format!("bg=\"{escaped}\""));
4201        } else {
4202            parts.push(format!("bg={bg}"));
4203        }
4204    }
4205    if let Some(colwidth) = attrs.get("colwidth").and_then(serde_json::Value::as_array) {
4206        let widths: Vec<String> = colwidth
4207            .iter()
4208            .filter_map(|v| {
4209                // Preserve the original number type: integers stay as integers,
4210                // floats stay as floats (e.g. Confluence's 254.0 representation).
4211                if let Some(n) = v.as_u64() {
4212                    Some(n.to_string())
4213                } else if let Some(n) = v.as_f64() {
4214                    if n.fract() == 0.0 {
4215                        format!("{n:.1}")
4216                    } else {
4217                        n.to_string()
4218                    }
4219                    .into()
4220                } else {
4221                    None
4222                }
4223            })
4224            .collect();
4225        if !widths.is_empty() {
4226            parts.push(format!("colwidth={}", widths.join(",")));
4227        }
4228    }
4229    parts.join(" ")
4230}
4231
4232/// Renders `{attrs}` prefix for a pipe table cell (background, colspan, etc.).
4233fn render_cell_attrs_prefix(cell: &AdfNode, output: &mut String) {
4234    let Some(ref _attrs) = cell.attrs else {
4235        return;
4236    };
4237    let attr_str = build_cell_attrs_string(cell);
4238    if attr_str.is_empty() {
4239        output.push_str("{} ");
4240    } else {
4241        output.push_str(&format!("{{{attr_str}}} "));
4242    }
4243}
4244
4245/// Gets the alignment mark value from the paragraph inside a table cell.
4246fn get_cell_paragraph_alignment(cell: &AdfNode) -> Option<&str> {
4247    let content = cell.content.as_ref()?;
4248    let para = content.first()?;
4249    let marks = para.marks.as_ref()?;
4250    marks.iter().find_map(|m| {
4251        if m.mark_type == "alignment" {
4252            m.attrs
4253                .as_ref()
4254                .and_then(|a| a.get("align"))
4255                .and_then(serde_json::Value::as_str)
4256        } else {
4257            None
4258        }
4259    })
4260}
4261
4262/// Emits table-level attributes if present.
4263fn render_table_level_attrs(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4264    if let Some(ref attrs) = node.attrs {
4265        let mut parts = Vec::new();
4266        if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
4267            parts.push(format!("layout={layout}"));
4268        }
4269        if let Some(numbered) = attrs
4270            .get("isNumberColumnEnabled")
4271            .and_then(serde_json::Value::as_bool)
4272        {
4273            if numbered {
4274                parts.push("numbered".to_string());
4275            } else {
4276                parts.push("numbered=false".to_string());
4277            }
4278        }
4279        if let Some(tw_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4280            parts.push(format!("width={tw_str}"));
4281        }
4282        maybe_push_local_id(attrs, &mut parts, opts);
4283        if !parts.is_empty() {
4284            output.push_str(&format!("{{{}}}\n", parts.join(" ")));
4285        }
4286    }
4287}
4288
4289/// Renders inline content from the first paragraph child of a table cell.
4290fn render_inline_content_from_first_paragraph(
4291    cell: &AdfNode,
4292    output: &mut String,
4293    opts: &RenderOptions,
4294) {
4295    if let Some(ref content) = cell.content {
4296        if let Some(first) = content.first() {
4297            if first.node_type == "paragraph" {
4298                render_inline_content(first, output, opts);
4299            }
4300        }
4301    }
4302}
4303
4304/// Appends border mark attributes (border-color, border-size) to a parts vec.
4305fn push_border_mark_attrs(marks: &Option<Vec<AdfMark>>, parts: &mut Vec<String>) {
4306    if let Some(ref marks) = marks {
4307        for mark in marks {
4308            if mark.mark_type == "border" {
4309                if let Some(ref attrs) = mark.attrs {
4310                    if let Some(color) = attrs.get("color").and_then(serde_json::Value::as_str) {
4311                        parts.push(format!("border-color={color}"));
4312                    }
4313                    if let Some(size) = attrs.get("size").and_then(serde_json::Value::as_u64) {
4314                        parts.push(format!("border-size={size}"));
4315                    }
4316                }
4317            }
4318        }
4319    }
4320}
4321
4322/// Renders a media node as a markdown image, with optional parent (mediaSingle) attrs.
4323fn render_media(
4324    node: &AdfNode,
4325    parent_attrs: Option<&serde_json::Value>,
4326    output: &mut String,
4327    opts: &RenderOptions,
4328) {
4329    if let Some(ref attrs) = node.attrs {
4330        let media_type = attrs
4331            .get("type")
4332            .and_then(serde_json::Value::as_str)
4333            .unwrap_or("external");
4334        let alt = attrs
4335            .get("alt")
4336            .and_then(serde_json::Value::as_str)
4337            .unwrap_or("");
4338
4339        if media_type == "file" {
4340            // Confluence file attachment — encode metadata in {attrs} block so it survives round-trip
4341            output.push_str(&format!("![{alt}]()"));
4342            let mut parts = vec!["type=file".to_string()];
4343            if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4344                parts.push(format_kv("id", id));
4345            }
4346            if let Some(collection) = attrs.get("collection").and_then(serde_json::Value::as_str) {
4347                parts.push(format_kv("collection", collection));
4348            }
4349            if let Some(occurrence_key) = attrs
4350                .get("occurrenceKey")
4351                .and_then(serde_json::Value::as_str)
4352            {
4353                parts.push(format_kv("occurrenceKey", occurrence_key));
4354            }
4355            if let Some(height_str) = attrs.get("height").and_then(fmt_numeric_attr) {
4356                parts.push(format!("height={height_str}"));
4357            }
4358            if let Some(width_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4359                parts.push(format!("width={width_str}"));
4360            }
4361            maybe_push_local_id(attrs, &mut parts, opts);
4362            // Encode mediaSingle layout/width/widthType if non-default
4363            if let Some(p_attrs) = parent_attrs {
4364                if let Some(layout) = p_attrs.get("layout").and_then(serde_json::Value::as_str) {
4365                    if layout != "center" {
4366                        parts.push(format!("layout={layout}"));
4367                    }
4368                }
4369                if let Some(ms_width_str) = p_attrs.get("width").and_then(fmt_numeric_attr) {
4370                    parts.push(format!("mediaWidth={ms_width_str}"));
4371                }
4372                if let Some(wt) = p_attrs.get("widthType").and_then(serde_json::Value::as_str) {
4373                    parts.push(format!("widthType={wt}"));
4374                }
4375                if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4376                    parts.push(format!("mode={mode}"));
4377                }
4378            }
4379            push_border_mark_attrs(&node.marks, &mut parts);
4380            output.push_str(&format!("{{{}}}", parts.join(" ")));
4381        } else {
4382            // External image
4383            let url = attrs
4384                .get("url")
4385                .and_then(serde_json::Value::as_str)
4386                .unwrap_or("");
4387            output.push_str(&format!("![{alt}]({url})"));
4388
4389            // Emit {layout=... width=... widthType=... mode=... localId=...} if non-default attrs present
4390            {
4391                let mut parts = Vec::new();
4392                if let Some(p_attrs) = parent_attrs {
4393                    let layout = p_attrs.get("layout").and_then(serde_json::Value::as_str);
4394                    let width_str = p_attrs.get("width").and_then(fmt_numeric_attr);
4395                    let width_type = p_attrs.get("widthType").and_then(serde_json::Value::as_str);
4396                    if let Some(l) = layout {
4397                        if l != "center" {
4398                            parts.push(format!("layout={l}"));
4399                        }
4400                    }
4401                    if let Some(w) = width_str {
4402                        parts.push(format!("width={w}"));
4403                    }
4404                    if let Some(wt) = width_type {
4405                        parts.push(format!("widthType={wt}"));
4406                    }
4407                    if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4408                        parts.push(format!("mode={mode}"));
4409                    }
4410                }
4411                maybe_push_local_id(attrs, &mut parts, opts);
4412                push_border_mark_attrs(&node.marks, &mut parts);
4413                if !parts.is_empty() {
4414                    output.push_str(&format!("{{{}}}", parts.join(" ")));
4415                }
4416            }
4417        }
4418
4419        output.push('\n');
4420    }
4421}
4422
4423/// Renders inline content (text nodes with marks) from a block node's children.
4424fn render_inline_content(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4425    if let Some(ref content) = node.content {
4426        for child in content {
4427            render_inline_node(child, output, opts);
4428        }
4429    }
4430}
4431
4432/// Renders a single inline ADF node to markdown.
4433fn render_inline_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4434    match node.node_type.as_str() {
4435        "text" => {
4436            let text = node.text.as_deref().unwrap_or("");
4437            let marks = node.marks.as_deref().unwrap_or(&[]);
4438            let has_code = marks.iter().any(|m| m.mark_type == "code");
4439            // Issue #477: Escape literal backslashes before the newline
4440            // encoding below so they are not consumed as JFM escape
4441            // sequences on round-trip.  Code marks emit content verbatim,
4442            // so backslash escaping is skipped for them.
4443            let owned;
4444            let text = if !has_code {
4445                owned = text.replace('\\', "\\\\");
4446                owned.as_str()
4447            } else {
4448                text
4449            };
4450            // Issue #454: A literal newline inside a text node is escaped
4451            // as the two-character sequence `\n` so it survives round-trip
4452            // as a single text node instead of splitting into paragraphs or
4453            // being converted to hardBreak nodes.
4454            let owned_nl;
4455            let text = if text.contains('\n') {
4456                owned_nl = text.replace('\n', "\\n");
4457                owned_nl.as_str()
4458            } else {
4459                text
4460            };
4461            // Issue #510: Two or more trailing spaces at the end of a text
4462            // node would be misinterpreted as a hardBreak marker on
4463            // round-trip (and collapse the following paragraph).  Escape the
4464            // last space with a backslash so the parser treats it as a
4465            // literal space instead of a line-break signal.
4466            let owned_ts;
4467            let text = if !has_code && text.ends_with("  ") {
4468                let mut s = text.to_string();
4469                // Insert backslash before the final space: "foo  " → "foo \ "
4470                s.insert(s.len() - 1, '\\');
4471                owned_ts = s;
4472                owned_ts.as_str()
4473            } else {
4474                text
4475            };
4476            render_marked_text(text, marks, output);
4477        }
4478        "hardBreak" => {
4479            output.push_str("\\\n");
4480        }
4481        other => {
4482            // Issue #471: Non-text inline nodes (emoji, status, date, mention, etc.)
4483            // may carry annotation marks. Render the node body first, then wrap it
4484            // in bracketed-span syntax if annotation marks are present.
4485            let mut body = String::new();
4486            render_non_text_inline_body(other, node, &mut body, opts);
4487
4488            let annotations: Vec<&AdfMark> = node
4489                .marks
4490                .as_deref()
4491                .unwrap_or(&[])
4492                .iter()
4493                .filter(|m| m.mark_type == "annotation")
4494                .collect();
4495
4496            if annotations.is_empty() {
4497                output.push_str(&body);
4498            } else {
4499                let mut attr_parts = Vec::new();
4500                for ann in &annotations {
4501                    if let Some(ref attrs) = ann.attrs {
4502                        if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4503                            let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4504                            attr_parts.push(format!("annotation-id=\"{escaped}\""));
4505                        }
4506                        if let Some(at) = attrs
4507                            .get("annotationType")
4508                            .and_then(serde_json::Value::as_str)
4509                        {
4510                            attr_parts.push(format!("annotation-type={at}"));
4511                        }
4512                    }
4513                }
4514                output.push('[');
4515                output.push_str(&body);
4516                output.push_str("]{");
4517                output.push_str(&attr_parts.join(" "));
4518                output.push('}');
4519            }
4520        }
4521    }
4522}
4523
4524/// Renders the body of a non-text inline node (without mark wrapping).
4525fn render_non_text_inline_body(
4526    node_type: &str,
4527    node: &AdfNode,
4528    output: &mut String,
4529    opts: &RenderOptions,
4530) {
4531    match node_type {
4532        "inlineCard" => {
4533            if let Some(ref attrs) = node.attrs {
4534                if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4535                    let mut attr_parts = Vec::new();
4536                    if url_safe_in_bracket_content(url) {
4537                        output.push_str(":card[");
4538                        output.push_str(url);
4539                        output.push(']');
4540                    } else {
4541                        // URL would break `:card[URL]` parsing (e.g. contains an
4542                        // unbalanced `]` or a newline).  Fall back to quoted
4543                        // attribute form so the URL round-trips losslessly.
4544                        output.push_str(":card[]");
4545                        let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
4546                        attr_parts.push(format!("url=\"{escaped}\""));
4547                    }
4548                    maybe_push_local_id(attrs, &mut attr_parts, opts);
4549                    if !attr_parts.is_empty() {
4550                        output.push('{');
4551                        output.push_str(&attr_parts.join(" "));
4552                        output.push('}');
4553                    }
4554                }
4555            }
4556        }
4557        "emoji" => {
4558            if let Some(ref attrs) = node.attrs {
4559                if let Some(short_name) = attrs.get("shortName").and_then(serde_json::Value::as_str)
4560                {
4561                    output.push(':');
4562                    let name = short_name.strip_prefix(':').unwrap_or(short_name);
4563                    let name = name.strip_suffix(':').unwrap_or(name);
4564                    output.push_str(name);
4565                    output.push(':');
4566
4567                    let mut parts = Vec::new();
4568                    let escaped_sn = short_name.replace('\\', "\\\\").replace('"', "\\\"");
4569                    parts.push(format!("shortName=\"{escaped_sn}\""));
4570                    if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4571                        let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4572                        parts.push(format!("id=\"{escaped}\""));
4573                    }
4574                    if let Some(text) = attrs.get("text").and_then(serde_json::Value::as_str) {
4575                        let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
4576                        parts.push(format!("text=\"{escaped}\""));
4577                    }
4578                    maybe_push_local_id(attrs, &mut parts, opts);
4579                    output.push('{');
4580                    output.push_str(&parts.join(" "));
4581                    output.push('}');
4582                }
4583            }
4584        }
4585        "status" => {
4586            if let Some(ref attrs) = node.attrs {
4587                let text = attrs
4588                    .get("text")
4589                    .and_then(serde_json::Value::as_str)
4590                    .unwrap_or("");
4591                let color = attrs
4592                    .get("color")
4593                    .and_then(serde_json::Value::as_str)
4594                    .unwrap_or("neutral");
4595                let mut attr_parts = vec![format!("color={color}")];
4596                if let Some(style) = attrs.get("style").and_then(serde_json::Value::as_str) {
4597                    attr_parts.push(format!("style={style}"));
4598                }
4599                maybe_push_local_id(attrs, &mut attr_parts, opts);
4600                output.push_str(&format!(":status[{text}]{{{}}}", attr_parts.join(" ")));
4601            }
4602        }
4603        "date" => {
4604            if let Some(ref attrs) = node.attrs {
4605                if let Some(timestamp) = attrs.get("timestamp").and_then(serde_json::Value::as_str)
4606                {
4607                    let display = epoch_ms_to_iso_date(timestamp);
4608                    let mut attr_parts = vec![format!("timestamp={timestamp}")];
4609                    maybe_push_local_id(attrs, &mut attr_parts, opts);
4610                    output.push_str(&format!(":date[{display}]{{{}}}", attr_parts.join(" ")));
4611                }
4612            }
4613        }
4614        "mention" => {
4615            if let Some(ref attrs) = node.attrs {
4616                let id = attrs
4617                    .get("id")
4618                    .and_then(serde_json::Value::as_str)
4619                    .unwrap_or("");
4620                let text = attrs
4621                    .get("text")
4622                    .and_then(serde_json::Value::as_str)
4623                    .unwrap_or("");
4624                let mut attr_parts = vec![format!("id={id}")];
4625                if let Some(ut) = attrs.get("userType").and_then(serde_json::Value::as_str) {
4626                    attr_parts.push(format!("userType={ut}"));
4627                }
4628                if let Some(al) = attrs.get("accessLevel").and_then(serde_json::Value::as_str) {
4629                    attr_parts.push(format!("accessLevel={al}"));
4630                }
4631                maybe_push_local_id(attrs, &mut attr_parts, opts);
4632                output.push_str(&format!(":mention[{text}]{{{}}}", attr_parts.join(" ")));
4633            }
4634        }
4635        "placeholder" => {
4636            if let Some(ref attrs) = node.attrs {
4637                let text = attrs
4638                    .get("text")
4639                    .and_then(serde_json::Value::as_str)
4640                    .unwrap_or("");
4641                output.push_str(&format!(":placeholder[{text}]"));
4642            }
4643        }
4644        "inlineExtension" => {
4645            if let Some(ref attrs) = node.attrs {
4646                let ext_type = attrs
4647                    .get("extensionType")
4648                    .and_then(serde_json::Value::as_str)
4649                    .unwrap_or("");
4650                let ext_key = attrs
4651                    .get("extensionKey")
4652                    .and_then(serde_json::Value::as_str)
4653                    .unwrap_or("");
4654                let fallback = node.text.as_deref().unwrap_or("");
4655                output.push_str(&format!(
4656                    ":extension[{fallback}]{{type={ext_type} key={ext_key}}}"
4657                ));
4658            }
4659        }
4660        "mediaInline" => {
4661            if let Some(ref attrs) = node.attrs {
4662                let mut attr_parts = Vec::new();
4663                if let Some(media_type) = attrs.get("type").and_then(serde_json::Value::as_str) {
4664                    attr_parts.push(format_kv("type", media_type));
4665                }
4666                if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4667                    attr_parts.push(format_kv("id", id));
4668                }
4669                if let Some(collection) =
4670                    attrs.get("collection").and_then(serde_json::Value::as_str)
4671                {
4672                    attr_parts.push(format_kv("collection", collection));
4673                }
4674                if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4675                    attr_parts.push(format_kv("url", url));
4676                }
4677                if let Some(alt) = attrs.get("alt").and_then(serde_json::Value::as_str) {
4678                    attr_parts.push(format_kv("alt", alt));
4679                }
4680                if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
4681                    attr_parts.push(format!("width={width}"));
4682                }
4683                if let Some(height) = attrs.get("height").and_then(serde_json::Value::as_u64) {
4684                    attr_parts.push(format!("height={height}"));
4685                }
4686                maybe_push_local_id(attrs, &mut attr_parts, opts);
4687                output.push_str(&format!(":media-inline[]{{{}}}", attr_parts.join(" ")));
4688            }
4689        }
4690        _ => {
4691            // Preserve an unsupported inline node losslessly as an
4692            // `:adf-unsupported[]{json="…"}` directive carrying the full node
4693            // JSON — the inline mirror of the block-level ```adf-unsupported```
4694            // fence (ADR-0029). Read back by `try_dispatch_inline_directive`,
4695            // so a read → edit → write round-trips instead of collapsing to a
4696            // lossy `<!-- unsupported inline -->` comment (issue #1117).
4697            if let Ok(json) = serde_json::to_string(node) {
4698                output.push_str(":adf-unsupported[]{");
4699                output.push_str(&format_kv("json", &json));
4700                output.push('}');
4701            }
4702        }
4703    }
4704}
4705
4706/// Renders text with ADF marks applied as markdown syntax.
4707///
4708/// Mark ordering is preserved by walking the marks array in order and emitting
4709/// one wrapper per mark (outermost first, innermost last).  The resulting
4710/// markdown round-trips back to the original mark sequence because the parser
4711/// reconstructs marks outside-in from the nested delimiter structure.
4712///
4713/// When both `strong` and `em` are present, em is rendered with `_` instead of
4714/// `*` to avoid ambiguity (e.g., `_**text**_` rather than `***text***`).  The
4715/// single exception is `[strong, em]` (exactly those two marks in that order),
4716/// which is rendered as `***text***` to preserve the familiar compact form;
4717/// the parser's triple-delimiter rule round-trips it back to `[strong, em]`.
4718fn render_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4719    if marks.iter().any(|m| m.mark_type == "code") {
4720        render_code_marked_text(text, marks, output);
4721        return;
4722    }
4723
4724    let has_link = marks.iter().any(|m| m.mark_type == "link");
4725    let has_strong = marks.iter().any(|m| m.mark_type == "strong");
4726    let has_em = marks.iter().any(|m| m.mark_type == "em");
4727
4728    // Compact form for the common [strong, em] case: ***text***.  em is
4729    // rendered with `*` here (as part of the `***` triple delimiter), so
4730    // underscores in the content don't need escaping.
4731    if marks.len() == 2 && marks[0].mark_type == "strong" && marks[1].mark_type == "em" {
4732        let escaped = escape_emphasis_markers(text);
4733        let escaped = escape_emoji_shortcodes(&escaped);
4734        let escaped = escape_backticks(&escaped);
4735        let escaped = escape_bare_urls(&escaped);
4736        output.push_str("***");
4737        output.push_str(&escaped);
4738        output.push_str("***");
4739        return;
4740    }
4741
4742    // When both strong and em are present (in any order), em uses `_` instead
4743    // of `*` to avoid the `***` triple-delimiter ambiguity.  Otherwise em uses
4744    // `*`, which sidesteps intraword-underscore pitfalls for plain em text.
4745    let em_delim = if has_strong && has_em { "_" } else { "*" };
4746
4747    // Text must also escape `_` when em renders as `_..._` — otherwise any
4748    // underscore in the content would close the emphasis span early.
4749    let escaped = if em_delim == "_" {
4750        escape_emphasis_markers_with_underscore(text)
4751    } else {
4752        escape_emphasis_markers(text)
4753    };
4754    let escaped = escape_emoji_shortcodes(&escaped);
4755    let escaped = escape_backticks(&escaped);
4756    // Always escape bare URLs so they are not re-parsed as `inlineCard`
4757    // nodes on round-trip.  When the text carries a link mark, also escape
4758    // `[` and `]` so they do not terminate the enclosing `[…]` link syntax
4759    // (issue #493).  Escaping bare URLs inside link text additionally
4760    // prevents `\[`/`\]` escapes from leaking through the URL-as-link-text
4761    // fast path and from corrupting an auto-detected bare URL inside the
4762    // link display text (issue #551).
4763    let escaped = escape_bare_urls(&escaped);
4764    let escaped = if has_link {
4765        escape_link_brackets(&escaped)
4766    } else {
4767        escaped
4768    };
4769
4770    // Collect (open, close) wrappers in mark order, outermost first.  Consecutive
4771    // span-attr or bracketed-span marks that happen to be in the parser's
4772    // canonical order (so the merged wrapper parses back to the same mark
4773    // sequence) are merged into a single wrapper; otherwise each mark gets its
4774    // own nested wrapper so that the mark ordering survives the round-trip.
4775    let mut wrappers: Vec<(String, String)> = Vec::new();
4776    let mut i = 0;
4777    while i < marks.len() {
4778        match marks[i].mark_type.as_str() {
4779            "em" => {
4780                wrappers.push((em_delim.to_string(), em_delim.to_string()));
4781                i += 1;
4782            }
4783            "strong" => {
4784                wrappers.push(("**".to_string(), "**".to_string()));
4785                i += 1;
4786            }
4787            "strike" => {
4788                wrappers.push(("~~".to_string(), "~~".to_string()));
4789                i += 1;
4790            }
4791            "link" => {
4792                let href = link_href(&marks[i]);
4793                wrappers.push(("[".to_string(), format!("]({href})")));
4794                i += 1;
4795            }
4796            "textColor" | "backgroundColor" | "subsup" => {
4797                let start = i;
4798                while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4799                    i += 1;
4800                }
4801                emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4802            }
4803            "underline" | "annotation" => {
4804                let start = i;
4805                while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4806                    i += 1;
4807                }
4808                emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4809            }
4810            _ => {
4811                i += 1;
4812            }
4813        }
4814    }
4815
4816    // Apply wrappers from innermost (last) to outermost (first).
4817    let mut result = escaped;
4818    for (open, close) in wrappers.iter().rev() {
4819        result.insert_str(0, open);
4820        result.push_str(close);
4821    }
4822    output.push_str(&result);
4823}
4824
4825/// Renders a text node with a `code` mark.  Code content is emitted verbatim
4826/// inside backticks, optionally wrapped by a link and/or by `:span`/bracketed-
4827/// span carrying span-attr (`textColor`, `backgroundColor`, `subsup`) and
4828/// bracketed-span (`underline`, `annotation`) marks.  No `em`/`strong`/`strike`
4829/// formatting is applied because markdown code spans do not support nested
4830/// emphasis (issue #554: previously textColor/bg/subsup/underline were
4831/// silently dropped when combined with a code mark).
4832fn render_code_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4833    let link_mark = marks.iter().find(|m| m.mark_type == "link");
4834
4835    let mut code_str = String::new();
4836    if let Some(link_mark) = link_mark {
4837        let href = link_href(link_mark);
4838        code_str.push('[');
4839        render_inline_code(text, &mut code_str);
4840        code_str.push_str("](");
4841        code_str.push_str(href);
4842        code_str.push(')');
4843    } else {
4844        render_inline_code(text, &mut code_str);
4845    }
4846
4847    // Build wrappers (outermost first) for span-attr and bracketed-span runs,
4848    // walking marks in order so the round-trip preserves mark ordering.
4849    let mut wrappers: Vec<(String, String)> = Vec::new();
4850    let mut i = 0;
4851    while i < marks.len() {
4852        match marks[i].mark_type.as_str() {
4853            "textColor" | "backgroundColor" | "subsup" => {
4854                let start = i;
4855                while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4856                    i += 1;
4857                }
4858                emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4859            }
4860            "underline" | "annotation" => {
4861                let start = i;
4862                while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4863                    i += 1;
4864                }
4865                emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4866            }
4867            _ => {
4868                i += 1;
4869            }
4870        }
4871    }
4872
4873    // Apply wrappers from innermost (last) to outermost (first).
4874    let mut result = code_str;
4875    for (open, close) in wrappers.iter().rev() {
4876        result.insert_str(0, open);
4877        result.push_str(close);
4878    }
4879    output.push_str(&result);
4880}
4881
4882/// Collects `:span` attribute fragments (color, bg, sub/sup) for a single mark.
4883fn collect_span_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4884    match mark.mark_type.as_str() {
4885        "textColor" => {
4886            if let Some(c) = mark
4887                .attrs
4888                .as_ref()
4889                .and_then(|a| a.get("color"))
4890                .and_then(serde_json::Value::as_str)
4891            {
4892                attrs.push(format!("color={c}"));
4893            }
4894        }
4895        "backgroundColor" => {
4896            if let Some(c) = mark
4897                .attrs
4898                .as_ref()
4899                .and_then(|a| a.get("color"))
4900                .and_then(serde_json::Value::as_str)
4901            {
4902                attrs.push(format!("bg={c}"));
4903            }
4904        }
4905        "subsup" => {
4906            if let Some(kind) = mark
4907                .attrs
4908                .as_ref()
4909                .and_then(|a| a.get("type"))
4910                .and_then(serde_json::Value::as_str)
4911            {
4912                attrs.push(kind.to_string());
4913            }
4914        }
4915        _ => {}
4916    }
4917}
4918
4919/// Collects bracketed-span attribute fragments for an `underline` or `annotation` mark.
4920fn collect_bracketed_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4921    match mark.mark_type.as_str() {
4922        "underline" => attrs.push("underline".to_string()),
4923        "annotation" => {
4924            if let Some(ref a) = mark.attrs {
4925                if let Some(id) = a.get("id").and_then(serde_json::Value::as_str) {
4926                    let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4927                    attrs.push(format!("annotation-id=\"{escaped}\""));
4928                }
4929                if let Some(at) = a.get("annotationType").and_then(serde_json::Value::as_str) {
4930                    attrs.push(format!("annotation-type={at}"));
4931                }
4932            }
4933        }
4934        _ => {}
4935    }
4936}
4937
4938fn is_span_attr_mark(mark_type: &str) -> bool {
4939    matches!(mark_type, "textColor" | "backgroundColor" | "subsup")
4940}
4941
4942fn is_bracketed_span_mark(mark_type: &str) -> bool {
4943    matches!(mark_type, "underline" | "annotation")
4944}
4945
4946/// Canonical ordering for span-attr marks, matching the order in which the
4947/// `:span` directive parser reads attributes (`color`, then `bg`, then
4948/// `sub`/`sup`).
4949fn span_attr_order(mark_type: &str) -> u8 {
4950    match mark_type {
4951        "textColor" => 0,
4952        "backgroundColor" => 1,
4953        "subsup" => 2,
4954        _ => u8::MAX,
4955    }
4956}
4957
4958/// Returns `true` if the run of span-attr marks is in the canonical order the
4959/// `:span` parser would produce.  A canonical run can be merged into one
4960/// `:span[...]{...}` wrapper; a non-canonical run must be split into one
4961/// nested wrapper per mark so the ordering survives the round-trip.
4962fn span_run_is_canonical(run: &[AdfMark]) -> bool {
4963    let mut prev = 0;
4964    for m in run {
4965        let order = span_attr_order(&m.mark_type);
4966        if order == u8::MAX || order < prev {
4967            return false;
4968        }
4969        prev = order;
4970    }
4971    true
4972}
4973
4974/// Returns `true` if the run of `underline`/`annotation` marks is in the
4975/// canonical order the bracketed-span parser produces (`underline` first,
4976/// followed by annotations).  A canonical run can be merged into one
4977/// `[...]{underline annotation-id=...}` wrapper.
4978fn bracketed_run_is_canonical(run: &[AdfMark]) -> bool {
4979    let mut seen_annotation = false;
4980    for m in run {
4981        match m.mark_type.as_str() {
4982            "underline" => {
4983                if seen_annotation {
4984                    return false;
4985                }
4986            }
4987            "annotation" => seen_annotation = true,
4988            _ => return false,
4989        }
4990    }
4991    true
4992}
4993
4994/// Emits one or more `:span[...]{...}` wrappers for a run of span-attr marks.
4995/// Canonical-order runs collapse into a single wrapper; non-canonical runs
4996/// emit one wrapper per mark so the order round-trips.
4997fn emit_span_attr_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4998    if span_run_is_canonical(run) {
4999        let mut attrs = Vec::new();
5000        for m in run {
5001            collect_span_attr(m, &mut attrs);
5002        }
5003        wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
5004        return;
5005    }
5006    for m in run {
5007        let mut attrs = Vec::new();
5008        collect_span_attr(m, &mut attrs);
5009        wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
5010    }
5011}
5012
5013/// Emits one or more `[...]{...}` wrappers for a run of `underline`/`annotation`
5014/// marks.  Canonical-order runs collapse into a single wrapper; non-canonical
5015/// runs emit one wrapper per mark so the order round-trips.
5016fn emit_bracketed_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
5017    if bracketed_run_is_canonical(run) {
5018        let mut attrs = Vec::new();
5019        for m in run {
5020            collect_bracketed_attr(m, &mut attrs);
5021        }
5022        wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
5023        return;
5024    }
5025    for m in run {
5026        let mut attrs = Vec::new();
5027        collect_bracketed_attr(m, &mut attrs);
5028        wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
5029    }
5030}
5031
5032/// Extracts the href from a link mark.
5033fn link_href(mark: &AdfMark) -> &str {
5034    mark.attrs
5035        .as_ref()
5036        .and_then(|a| a.get("href"))
5037        .and_then(serde_json::Value::as_str)
5038        .unwrap_or("")
5039}
5040
5041#[cfg(test)]
5042#[allow(
5043    clippy::unwrap_used,
5044    clippy::expect_used,
5045    clippy::needless_update,
5046    clippy::needless_collect,
5047    duplicate_macro_attributes
5048)]
5049mod tests {
5050    use super::*;
5051
5052    // ── adf_to_plain_text tests ─────────────────────────────────────
5053
5054    #[test]
5055    fn adf_to_plain_text_single_paragraph() {
5056        let doc = markdown_to_adf("Hello world").unwrap();
5057        assert_eq!(adf_to_plain_text(&doc), "Hello world");
5058    }
5059
5060    #[test]
5061    fn adf_to_plain_text_multiple_paragraphs_space_separated() {
5062        let doc = markdown_to_adf("Alpha\n\nBeta").unwrap();
5063        let plain = adf_to_plain_text(&doc);
5064        // Blocks are space-separated so multi-paragraph anchor selections match.
5065        assert!(plain.contains("Alpha"));
5066        assert!(plain.contains("Beta"));
5067        assert_eq!(plain, "Alpha Beta");
5068    }
5069
5070    #[test]
5071    fn adf_to_plain_text_drops_marks_but_keeps_text() {
5072        let doc = markdown_to_adf("Hello **bold** world").unwrap();
5073        assert_eq!(adf_to_plain_text(&doc), "Hello bold world");
5074    }
5075
5076    #[test]
5077    fn adf_to_plain_text_empty_doc() {
5078        let doc = AdfDocument::new();
5079        assert_eq!(adf_to_plain_text(&doc), "");
5080    }
5081
5082    #[test]
5083    fn adf_to_plain_text_leading_empty_block_emits_no_extra_space() {
5084        // An empty paragraph followed by a text-bearing one must not produce
5085        // a leading space — the separator logic skips when `out` is still empty.
5086        let doc = AdfDocument {
5087            version: 1,
5088            doc_type: "doc".to_string(),
5089            content: vec![
5090                AdfNode {
5091                    node_type: "paragraph".to_string(),
5092                    attrs: None,
5093                    content: Some(vec![]),
5094                    text: None,
5095                    marks: None,
5096                    local_id: None,
5097                    parameters: None,
5098                },
5099                AdfNode {
5100                    node_type: "paragraph".to_string(),
5101                    attrs: None,
5102                    content: Some(vec![AdfNode::text("Hello")]),
5103                    text: None,
5104                    marks: None,
5105                    local_id: None,
5106                    parameters: None,
5107                },
5108            ],
5109        };
5110        assert_eq!(adf_to_plain_text(&doc), "Hello");
5111    }
5112
5113    // ── markdown_to_adf tests ───────────────────────────────────────
5114
5115    #[test]
5116    fn paragraph() {
5117        let doc = markdown_to_adf("Hello world").unwrap();
5118        assert_eq!(doc.content.len(), 1);
5119        assert_eq!(doc.content[0].node_type, "paragraph");
5120    }
5121
5122    #[test]
5123    fn heading_levels() {
5124        for level in 1..=6 {
5125            let hashes = "#".repeat(level);
5126            let md = format!("{hashes} Title");
5127            let doc = markdown_to_adf(&md).unwrap();
5128            assert_eq!(doc.content[0].node_type, "heading");
5129            let attrs = doc.content[0].attrs.as_ref().unwrap();
5130            assert_eq!(attrs["level"], level as u64);
5131        }
5132    }
5133
5134    // ── issue #1005: inline `code` is not permitted on headings ─────
5135
5136    #[test]
5137    fn heading_inline_code_mark_stripped() {
5138        // `### `GET /api`` parses an inline-code span; ADF forbids the `code`
5139        // mark on headings, so it is stripped and the text kept as plain.
5140        let doc = markdown_to_adf("### `GET /api`").unwrap();
5141        let heading = &doc.content[0];
5142        assert_eq!(heading.node_type, "heading");
5143        let content = heading.content.as_ref().unwrap();
5144        assert_eq!(content.len(), 1);
5145        assert_eq!(content[0].text.as_deref(), Some("GET /api"));
5146        assert!(
5147            content[0].marks.is_none(),
5148            "expected no marks, got: {:?}",
5149            content[0].marks
5150        );
5151    }
5152
5153    #[test]
5154    fn heading_code_strip_preserves_sibling_strong() {
5155        // `### **`x`**` → text `x` carrying `strong`; only `code` is removed.
5156        let doc = markdown_to_adf("### **`x`**").unwrap();
5157        let content = doc.content[0].content.as_ref().unwrap();
5158        let marks = content[0].marks.as_ref().unwrap();
5159        assert!(marks.iter().any(|m| m.mark_type == "strong"));
5160        assert!(!marks.iter().any(|m| m.mark_type == "code"));
5161    }
5162
5163    #[test]
5164    fn heading_code_strip_preserves_link() {
5165        // `### [`x`](url)` → text `x` carrying `link`; only `code` is removed.
5166        let doc = markdown_to_adf("### [`x`](https://e.com)").unwrap();
5167        let content = doc.content[0].content.as_ref().unwrap();
5168        let marks = content[0].marks.as_ref().unwrap();
5169        assert!(marks.iter().any(|m| m.mark_type == "link"));
5170        assert!(!marks.iter().any(|m| m.mark_type == "code"));
5171    }
5172
5173    #[test]
5174    fn heading_with_code_now_passes_validation() {
5175        // End-to-end guard: the converted document no longer trips the ADF
5176        // mark validator that previously rejected it at write time.
5177        let doc = markdown_to_adf("### `GET /api`").unwrap();
5178        let violations = crate::atlassian::adf_schema::validate_document(&doc);
5179        assert!(violations.is_empty(), "got: {violations:?}");
5180    }
5181
5182    #[test]
5183    fn paragraph_inline_code_mark_preserved() {
5184        // Regression guard: the strip is heading-only — paragraph inline code
5185        // keeps its `code` mark.
5186        let doc = markdown_to_adf("`x`").unwrap();
5187        let content = doc.content[0].content.as_ref().unwrap();
5188        let marks = content[0].marks.as_ref().unwrap();
5189        assert!(marks.iter().any(|m| m.mark_type == "code"));
5190    }
5191
5192    // ── strong/em/strike around inline code split silently (issue #1391) ──
5193
5194    /// Returns the mark types on `node`, empty when unmarked.
5195    fn mark_types(node: &AdfNode) -> Vec<&str> {
5196        node.marks
5197            .as_ref()
5198            .map(|marks| marks.iter().map(|m| m.mark_type.as_str()).collect())
5199            .unwrap_or_default()
5200    }
5201
5202    #[test]
5203    fn split_strong_code_single_run() {
5204        // `**`x`**` → one run carrying only `code`; the leaked `strong` is
5205        // dropped so the ADF is legal and renders identically.
5206        let doc = markdown_to_adf("**`x`**").unwrap();
5207        let content = doc.content[0].content.as_ref().unwrap();
5208        assert_eq!(content.len(), 1);
5209        assert_eq!(content[0].text.as_deref(), Some("x"));
5210        assert_eq!(mark_types(&content[0]), vec!["code"]);
5211    }
5212
5213    #[test]
5214    fn split_em_code_single_run() {
5215        let doc = markdown_to_adf("*`x`*").unwrap();
5216        let content = doc.content[0].content.as_ref().unwrap();
5217        assert_eq!(mark_types(&content[0]), vec!["code"]);
5218    }
5219
5220    #[test]
5221    fn split_strike_code_single_run() {
5222        let doc = markdown_to_adf("~~`x`~~").unwrap();
5223        let content = doc.content[0].content.as_ref().unwrap();
5224        assert_eq!(mark_types(&content[0]), vec!["code"]);
5225    }
5226
5227    #[test]
5228    fn split_strong_em_code_drops_both() {
5229        let doc = markdown_to_adf("***`x`***").unwrap();
5230        let content = doc.content[0].content.as_ref().unwrap();
5231        assert_eq!(mark_types(&content[0]), vec!["code"]);
5232    }
5233
5234    #[test]
5235    fn split_nested_code_keeps_sibling_strong() {
5236        // `**foo `bar` baz**` → three runs; only the code run drops `strong`,
5237        // so the bold prose around the identifier is preserved.
5238        let doc = markdown_to_adf("**foo `bar` baz**").unwrap();
5239        let content = doc.content[0].content.as_ref().unwrap();
5240        assert_eq!(content.len(), 3);
5241        assert_eq!(content[0].text.as_deref(), Some("foo "));
5242        assert_eq!(mark_types(&content[0]), vec!["strong"]);
5243        assert_eq!(content[1].text.as_deref(), Some("bar"));
5244        assert_eq!(mark_types(&content[1]), vec!["code"]);
5245        assert_eq!(content[2].text.as_deref(), Some(" baz"));
5246        assert_eq!(mark_types(&content[2]), vec!["strong"]);
5247    }
5248
5249    #[test]
5250    fn split_multiple_hits_in_one_line() {
5251        let doc = markdown_to_adf("**`x`** and **`y`**").unwrap();
5252        let content = doc.content[0].content.as_ref().unwrap();
5253        assert_eq!(content.len(), 3);
5254        assert_eq!(mark_types(&content[0]), vec!["code"]);
5255        assert_eq!(content[1].text.as_deref(), Some(" and "));
5256        assert!(content[1].marks.is_none());
5257        assert_eq!(mark_types(&content[2]), vec!["code"]);
5258    }
5259
5260    #[test]
5261    fn split_leaves_separate_runs_untouched() {
5262        // Marks already on separate runs never triggered the constraint.
5263        let doc = markdown_to_adf("**foo** `bar`").unwrap();
5264        let content = doc.content[0].content.as_ref().unwrap();
5265        assert_eq!(mark_types(&content[0]), vec!["strong"]);
5266        assert_eq!(content[1].text.as_deref(), Some(" "));
5267        assert_eq!(mark_types(&content[2]), vec!["code"]);
5268    }
5269
5270    #[test]
5271    fn split_preserves_link_on_code_run() {
5272        // `code+link` is legal ADF; only `strong` drops off the code run.
5273        let doc = markdown_to_adf("**[`x`](https://e.com)**").unwrap();
5274        let content = doc.content[0].content.as_ref().unwrap();
5275        assert_eq!(mark_types(&content[0]), vec!["link", "code"]);
5276    }
5277
5278    #[test]
5279    fn split_keeps_span_syntax_marks_for_validator() {
5280        // `[`x`]{underline}` is an explicit span-syntax request: the converter
5281        // preserves it byte-faithfully (issue #554 fidelity) and the mark
5282        // validator still reports the illegal combination at write time.
5283        let doc = markdown_to_adf("[`x`]{underline}").unwrap();
5284        let content = doc.content[0].content.as_ref().unwrap();
5285        assert_eq!(mark_types(&content[0]), vec!["underline", "code"]);
5286    }
5287
5288    #[test]
5289    fn split_runs_after_heading_strip_bold_kept() {
5290        // Ordering guard: the heading builder strips `code` first (#1005), so
5291        // the document-level split pass must leave the heading's bold intact
5292        // rather than dropping `strong` before the strip runs.
5293        let doc = markdown_to_adf("# **`x`**").unwrap();
5294        let content = doc.content[0].content.as_ref().unwrap();
5295        assert_eq!(mark_types(&content[0]), vec!["strong"]);
5296    }
5297
5298    #[test]
5299    fn split_applies_inside_containers() {
5300        // The pass recurses through block containers, not just paragraphs.
5301        let doc = markdown_to_adf("- **`x`**").unwrap();
5302        let list_item = &doc.content[0].content.as_ref().unwrap()[0];
5303        let paragraph = &list_item.content.as_ref().unwrap()[0];
5304        let content = paragraph.content.as_ref().unwrap();
5305        assert_eq!(mark_types(&content[0]), vec!["code"]);
5306    }
5307
5308    #[test]
5309    fn split_strong_code_passes_validation() {
5310        // End-to-end guard: the issue #1391 reproducer no longer trips the
5311        // mark validator that previously rejected the whole write.
5312        let doc = markdown_to_adf(
5313            "**Testing `exodus` itself is out of scope for this ticket** — coordinate \
5314             with the team that owns the exodus credentials.",
5315        )
5316        .unwrap();
5317        let violations = crate::atlassian::adf_schema::validate_document(&doc);
5318        assert!(violations.is_empty(), "got: {violations:?}");
5319    }
5320
5321    #[test]
5322    fn code_block() {
5323        let md = "```rust\nfn main() {}\n```";
5324        let doc = markdown_to_adf(md).unwrap();
5325        assert_eq!(doc.content[0].node_type, "codeBlock");
5326        let attrs = doc.content[0].attrs.as_ref().unwrap();
5327        assert_eq!(attrs["language"], "rust");
5328    }
5329
5330    #[test]
5331    fn code_block_no_language() {
5332        let md = "```\nsome code\n```";
5333        let doc = markdown_to_adf(md).unwrap();
5334        assert_eq!(doc.content[0].node_type, "codeBlock");
5335        assert!(doc.content[0].attrs.is_none());
5336    }
5337
5338    #[test]
5339    fn code_block_empty_language() {
5340        let md = "```\"\"\nsome code\n```";
5341        let doc = markdown_to_adf(md).unwrap();
5342        assert_eq!(doc.content[0].node_type, "codeBlock");
5343        let attrs = doc.content[0].attrs.as_ref().unwrap();
5344        assert_eq!(attrs["language"], "");
5345    }
5346
5347    #[test]
5348    fn horizontal_rule() {
5349        let doc = markdown_to_adf("---").unwrap();
5350        assert_eq!(doc.content[0].node_type, "rule");
5351    }
5352
5353    #[test]
5354    fn horizontal_rule_stars() {
5355        let doc = markdown_to_adf("***").unwrap();
5356        assert_eq!(doc.content[0].node_type, "rule");
5357    }
5358
5359    #[test]
5360    fn blockquote() {
5361        let md = "> This is a quote\n> Second line";
5362        let doc = markdown_to_adf(md).unwrap();
5363        assert_eq!(doc.content[0].node_type, "blockquote");
5364    }
5365
5366    #[test]
5367    fn bullet_list() {
5368        let md = "- Item 1\n- Item 2\n- Item 3";
5369        let doc = markdown_to_adf(md).unwrap();
5370        assert_eq!(doc.content[0].node_type, "bulletList");
5371        let items = doc.content[0].content.as_ref().unwrap();
5372        assert_eq!(items.len(), 3);
5373    }
5374
5375    #[test]
5376    fn ordered_list() {
5377        let md = "1. First\n2. Second\n3. Third";
5378        let doc = markdown_to_adf(md).unwrap();
5379        assert_eq!(doc.content[0].node_type, "orderedList");
5380        let items = doc.content[0].content.as_ref().unwrap();
5381        assert_eq!(items.len(), 3);
5382    }
5383
5384    #[test]
5385    fn task_list() {
5386        let md = "- [ ] Todo item\n- [x] Done item";
5387        let doc = markdown_to_adf(md).unwrap();
5388        assert_eq!(doc.content[0].node_type, "taskList");
5389        let items = doc.content[0].content.as_ref().unwrap();
5390        assert_eq!(items.len(), 2);
5391        assert_eq!(items[0].node_type, "taskItem");
5392        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5393        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5394    }
5395
5396    #[test]
5397    fn task_list_uppercase_x() {
5398        let md = "- [X] Done item";
5399        let doc = markdown_to_adf(md).unwrap();
5400        assert_eq!(doc.content[0].node_type, "taskList");
5401        let item = &doc.content[0].content.as_ref().unwrap()[0];
5402        assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
5403    }
5404
5405    /// Issue #548: an empty task marker (no trailing space) must still be
5406    /// parsed as a `taskList` rather than a `bulletList` with `[ ]` text.
5407    #[test]
5408    fn task_list_empty_todo_no_trailing_space() {
5409        let md = "- [ ]";
5410        let doc = markdown_to_adf(md).unwrap();
5411        assert_eq!(doc.content[0].node_type, "taskList");
5412        let items = doc.content[0].content.as_ref().unwrap();
5413        assert_eq!(items.len(), 1);
5414        assert_eq!(items[0].node_type, "taskItem");
5415        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5416        assert!(items[0].content.is_none());
5417    }
5418
5419    /// Issue #548: likewise for a done checkbox with no body.
5420    #[test]
5421    fn task_list_empty_done_no_trailing_space() {
5422        let md = "- [x]\n- [X]";
5423        let doc = markdown_to_adf(md).unwrap();
5424        assert_eq!(doc.content[0].node_type, "taskList");
5425        let items = doc.content[0].content.as_ref().unwrap();
5426        assert_eq!(items.len(), 2);
5427        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
5428        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5429    }
5430
5431    /// Issue #548: the body of `- [ ] text` must not have a spurious leading
5432    /// space introduced by relaxing the trailing-space requirement.
5433    #[test]
5434    fn task_list_body_has_no_leading_space() {
5435        let md = "- [ ] Buy groceries";
5436        let doc = markdown_to_adf(md).unwrap();
5437        let item = &doc.content[0].content.as_ref().unwrap()[0];
5438        let text = item.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5439        assert_eq!(text, "Buy groceries");
5440    }
5441
5442    /// Issue #548: round-trip from ADF with empty taskItems should preserve
5443    /// the `taskList` structure even if trailing spaces are stripped from the
5444    /// intermediate markdown (as many editors do).
5445    #[test]
5446    fn round_trip_empty_task_items_stripped_trailing_spaces() {
5447        let json = r#"{
5448            "version": 1,
5449            "type": "doc",
5450            "content": [{
5451                "type": "taskList",
5452                "attrs": {"localId": "abc"},
5453                "content": [
5454                    {"type": "taskItem", "attrs": {"localId": "def", "state": "TODO"}},
5455                    {"type": "taskItem", "attrs": {"localId": "ghi", "state": "DONE"}}
5456                ]
5457            }]
5458        }"#;
5459        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5460        let md = adf_to_markdown(&doc).unwrap();
5461        let stripped: String = md.lines().map(str::trim_end).collect::<Vec<_>>().join("\n");
5462        let parsed = markdown_to_adf(&stripped).unwrap();
5463        assert_eq!(parsed.content[0].node_type, "taskList");
5464        let items = parsed.content[0].content.as_ref().unwrap();
5465        assert_eq!(items.len(), 2);
5466        assert_eq!(items[0].node_type, "taskItem");
5467        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5468        assert_eq!(items[1].node_type, "taskItem");
5469        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5470    }
5471
5472    #[test]
5473    fn try_parse_task_marker_accepts_bare_checkbox() {
5474        assert_eq!(try_parse_task_marker("[ ]"), Some(("TODO", "")));
5475        assert_eq!(try_parse_task_marker("[x]"), Some(("DONE", "")));
5476        assert_eq!(try_parse_task_marker("[X]"), Some(("DONE", "")));
5477        assert_eq!(try_parse_task_marker("[ ] foo"), Some(("TODO", "foo")));
5478        assert_eq!(try_parse_task_marker("[x] foo"), Some(("DONE", "foo")));
5479        assert_eq!(try_parse_task_marker("[ ]foo"), None);
5480        assert_eq!(try_parse_task_marker("[x]foo"), None);
5481        assert_eq!(try_parse_task_marker("[y] foo"), None);
5482    }
5483
5484    #[test]
5485    fn starts_with_task_marker_matches_parser() {
5486        // Anything `try_parse_task_marker` recognises must also be flagged
5487        // here so the renderer escapes it.
5488        assert!(starts_with_task_marker("[ ]"));
5489        assert!(starts_with_task_marker("[x]"));
5490        assert!(starts_with_task_marker("[X]"));
5491        assert!(starts_with_task_marker("[ ] foo"));
5492        assert!(starts_with_task_marker("[x] foo\n"));
5493        assert!(starts_with_task_marker("[ ]\n"));
5494        // No collision when the bracket is followed by non-whitespace.
5495        assert!(!starts_with_task_marker("[ ]foo"));
5496        assert!(!starts_with_task_marker("[y] foo"));
5497        assert!(!starts_with_task_marker("foo [ ] bar"));
5498        assert!(!starts_with_task_marker(""));
5499    }
5500
5501    /// Issue #548: a `bulletList` whose item starts with literal `[ ]` text
5502    /// must round-trip through markdown without being promoted to a
5503    /// `taskList`.
5504    #[test]
5505    fn round_trip_bullet_list_with_literal_checkbox_text() {
5506        let json = r#"{
5507            "version": 1,
5508            "type": "doc",
5509            "content": [{
5510                "type": "bulletList",
5511                "content": [{
5512                    "type": "listItem",
5513                    "content": [{
5514                        "type": "paragraph",
5515                        "content": [
5516                            {"type": "text", "text": "[ ] Review the "},
5517                            {"type": "text", "text": "config.yaml", "marks": [{"type": "code"}]},
5518                            {"type": "text", "text": " file"}
5519                        ]
5520                    }]
5521                }]
5522            }]
5523        }"#;
5524        let original: AdfDocument = serde_json::from_str(json).unwrap();
5525        let md = adf_to_markdown(&original).unwrap();
5526        // Renderer must escape the leading bracket.
5527        assert!(
5528            md.contains(r"- \[ ] Review the "),
5529            "rendered markdown: {md:?}"
5530        );
5531        let parsed = markdown_to_adf(&md).unwrap();
5532        assert_eq!(parsed.content[0].node_type, "bulletList");
5533        let item = &parsed.content[0].content.as_ref().unwrap()[0];
5534        assert_eq!(item.node_type, "listItem");
5535        let para = &item.content.as_ref().unwrap()[0];
5536        assert_eq!(para.node_type, "paragraph");
5537        let text_nodes = para.content.as_ref().unwrap();
5538        assert_eq!(text_nodes[0].text.as_deref().unwrap(), "[ ] Review the ");
5539        assert_eq!(text_nodes[1].text.as_deref().unwrap(), "config.yaml");
5540        assert_eq!(text_nodes[2].text.as_deref().unwrap(), " file");
5541    }
5542
5543    /// Issue #548: the same problem with a `[x]` marker.
5544    #[test]
5545    fn round_trip_bullet_list_with_literal_done_checkbox_text() {
5546        let json = r#"{
5547            "version": 1,
5548            "type": "doc",
5549            "content": [{
5550                "type": "bulletList",
5551                "content": [{
5552                    "type": "listItem",
5553                    "content": [{
5554                        "type": "paragraph",
5555                        "content": [{"type": "text", "text": "[x] not actually done"}]
5556                    }]
5557                }]
5558            }]
5559        }"#;
5560        let original: AdfDocument = serde_json::from_str(json).unwrap();
5561        let md = adf_to_markdown(&original).unwrap();
5562        assert!(md.contains(r"- \[x] "), "rendered markdown: {md:?}");
5563        let parsed = markdown_to_adf(&md).unwrap();
5564        assert_eq!(parsed.content[0].node_type, "bulletList");
5565        let item = &parsed.content[0].content.as_ref().unwrap()[0];
5566        let para = &item.content.as_ref().unwrap()[0];
5567        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5568        assert_eq!(text, "[x] not actually done");
5569    }
5570
5571    /// Issue #548: `bulletList` item whose entire content is literal `[ ]`.
5572    #[test]
5573    fn round_trip_bullet_list_with_bare_literal_checkbox() {
5574        let json = r#"{
5575            "version": 1,
5576            "type": "doc",
5577            "content": [{
5578                "type": "bulletList",
5579                "content": [{
5580                    "type": "listItem",
5581                    "content": [{
5582                        "type": "paragraph",
5583                        "content": [{"type": "text", "text": "[ ]"}]
5584                    }]
5585                }]
5586            }]
5587        }"#;
5588        let original: AdfDocument = serde_json::from_str(json).unwrap();
5589        let md = adf_to_markdown(&original).unwrap();
5590        let parsed = markdown_to_adf(&md).unwrap();
5591        assert_eq!(parsed.content[0].node_type, "bulletList");
5592        let item = &parsed.content[0].content.as_ref().unwrap()[0];
5593        let para = &item.content.as_ref().unwrap()[0];
5594        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5595        assert_eq!(text, "[ ]");
5596    }
5597
5598    /// Issue #548: a `bulletList` with a non-task `[?]` prefix should not be
5599    /// escaped — that would just produce noise.
5600    #[test]
5601    fn bullet_list_non_task_bracket_text_not_escaped() {
5602        let json = r#"{
5603            "version": 1,
5604            "type": "doc",
5605            "content": [{
5606                "type": "bulletList",
5607                "content": [{
5608                    "type": "listItem",
5609                    "content": [{
5610                        "type": "paragraph",
5611                        "content": [{"type": "text", "text": "[?] unsure"}]
5612                    }]
5613                }]
5614            }]
5615        }"#;
5616        let original: AdfDocument = serde_json::from_str(json).unwrap();
5617        let md = adf_to_markdown(&original).unwrap();
5618        assert!(!md.contains(r"\["), "should not escape: {md:?}");
5619        assert!(md.contains("- [?] unsure"), "rendered: {md:?}");
5620    }
5621
5622    /// Issue #548: nested `bulletList` items inside another `bulletList`
5623    /// must also have their literal `[ ]` text escaped.
5624    #[test]
5625    fn round_trip_nested_bullet_list_with_literal_checkbox_text() {
5626        let json = r#"{
5627            "version": 1,
5628            "type": "doc",
5629            "content": [{
5630                "type": "bulletList",
5631                "content": [{
5632                    "type": "listItem",
5633                    "content": [
5634                        {"type": "paragraph", "content": [{"type": "text", "text": "outer"}]},
5635                        {"type": "bulletList", "content": [{
5636                            "type": "listItem",
5637                            "content": [{
5638                                "type": "paragraph",
5639                                "content": [{"type": "text", "text": "[ ] inner literal"}]
5640                            }]
5641                        }]}
5642                    ]
5643                }]
5644            }]
5645        }"#;
5646        let original: AdfDocument = serde_json::from_str(json).unwrap();
5647        let md = adf_to_markdown(&original).unwrap();
5648        let parsed = markdown_to_adf(&md).unwrap();
5649        let outer = &parsed.content[0];
5650        assert_eq!(outer.node_type, "bulletList");
5651        let outer_item = &outer.content.as_ref().unwrap()[0];
5652        let inner_list = &outer_item.content.as_ref().unwrap()[1];
5653        assert_eq!(inner_list.node_type, "bulletList");
5654        let inner_item = &inner_list.content.as_ref().unwrap()[0];
5655        assert_eq!(inner_item.node_type, "listItem");
5656        let para = &inner_item.content.as_ref().unwrap()[0];
5657        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5658        assert_eq!(text, "[ ] inner literal");
5659    }
5660
5661    #[test]
5662    fn adf_task_list_to_markdown() {
5663        let doc = AdfDocument {
5664            version: 1,
5665            doc_type: "doc".to_string(),
5666            content: vec![AdfNode::task_list(vec![
5667                AdfNode::task_item(
5668                    "TODO",
5669                    vec![AdfNode::paragraph(vec![AdfNode::text("Todo")])],
5670                ),
5671                AdfNode::task_item(
5672                    "DONE",
5673                    vec![AdfNode::paragraph(vec![AdfNode::text("Done")])],
5674                ),
5675            ])],
5676        };
5677        let md = adf_to_markdown(&doc).unwrap();
5678        assert!(md.contains("- [ ] Todo"));
5679        assert!(md.contains("- [x] Done"));
5680    }
5681
5682    #[test]
5683    fn round_trip_task_list() {
5684        let md = "- [ ] Todo item\n- [x] Done item\n";
5685        let doc = markdown_to_adf(md).unwrap();
5686        let result = adf_to_markdown(&doc).unwrap();
5687        assert!(result.contains("- [ ] Todo item"));
5688        assert!(result.contains("- [x] Done item"));
5689    }
5690
5691    /// Issue #408: taskItem content with inline nodes directly (no paragraph wrapper).
5692    #[test]
5693    fn adf_task_item_unwrapped_inline_content() {
5694        // Real Confluence ADF: taskItem contains text nodes directly, no paragraph.
5695        let json = r#"{
5696            "version": 1,
5697            "type": "doc",
5698            "content": [{
5699                "type": "taskList",
5700                "attrs": {"localId": "list-001"},
5701                "content": [{
5702                    "type": "taskItem",
5703                    "attrs": {"localId": "task-001", "state": "TODO"},
5704                    "content": [{"type": "text", "text": "Do something"}]
5705                }]
5706            }]
5707        }"#;
5708        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5709        let md = adf_to_markdown(&doc).unwrap();
5710        assert!(md.contains("- [ ] Do something"), "got: {md}");
5711        assert!(!md.contains("adf-unsupported"), "got: {md}");
5712    }
5713
5714    /// Issue #408: multiple taskItems with unwrapped inline content.
5715    #[test]
5716    fn adf_task_list_multiple_unwrapped_items() {
5717        let json = r#"{
5718            "version": 1,
5719            "type": "doc",
5720            "content": [{
5721                "type": "taskList",
5722                "attrs": {"localId": "list-001"},
5723                "content": [
5724                    {
5725                        "type": "taskItem",
5726                        "attrs": {"localId": "task-001", "state": "TODO"},
5727                        "content": [{"type": "text", "text": "First task"}]
5728                    },
5729                    {
5730                        "type": "taskItem",
5731                        "attrs": {"localId": "task-002", "state": "DONE"},
5732                        "content": [{"type": "text", "text": "Second task"}]
5733                    }
5734                ]
5735            }]
5736        }"#;
5737        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5738        let md = adf_to_markdown(&doc).unwrap();
5739        assert!(md.contains("- [ ] First task"), "got: {md}");
5740        assert!(md.contains("- [x] Second task"), "got: {md}");
5741        assert!(!md.contains("adf-unsupported"), "got: {md}");
5742    }
5743
5744    /// Issue #408: unwrapped inline content with marks (bold text).
5745    #[test]
5746    fn adf_task_item_unwrapped_inline_with_marks() {
5747        let json = r#"{
5748            "version": 1,
5749            "type": "doc",
5750            "content": [{
5751                "type": "taskList",
5752                "attrs": {"localId": "list-001"},
5753                "content": [{
5754                    "type": "taskItem",
5755                    "attrs": {"localId": "task-001", "state": "TODO"},
5756                    "content": [
5757                        {"type": "text", "text": "Buy "},
5758                        {"type": "text", "text": "groceries", "marks": [{"type": "strong"}]},
5759                        {"type": "text", "text": " today"}
5760                    ]
5761                }]
5762            }]
5763        }"#;
5764        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5765        let md = adf_to_markdown(&doc).unwrap();
5766        assert!(md.contains("- [ ] Buy **groceries** today"), "got: {md}");
5767    }
5768
5769    /// Issue #408: taskItem localId is preserved for unwrapped inline content.
5770    #[test]
5771    fn adf_task_item_unwrapped_preserves_local_id() {
5772        let json = r#"{
5773            "version": 1,
5774            "type": "doc",
5775            "content": [{
5776                "type": "taskList",
5777                "attrs": {"localId": "list-001"},
5778                "content": [{
5779                    "type": "taskItem",
5780                    "attrs": {"localId": "task-001", "state": "TODO"},
5781                    "content": [{"type": "text", "text": "Do something"}]
5782                }]
5783            }]
5784        }"#;
5785        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5786        let md = adf_to_markdown(&doc).unwrap();
5787        assert!(md.contains("{localId=task-001}"), "got: {md}");
5788        assert!(md.contains("{localId=list-001}"), "got: {md}");
5789    }
5790
5791    /// Issue #408: round-trip from Confluence ADF with unwrapped taskItem content.
5792    #[test]
5793    fn round_trip_task_list_unwrapped_inline() {
5794        let json = r#"{
5795            "version": 1,
5796            "type": "doc",
5797            "content": [{
5798                "type": "taskList",
5799                "attrs": {"localId": "list-001"},
5800                "content": [
5801                    {
5802                        "type": "taskItem",
5803                        "attrs": {"localId": "task-001", "state": "TODO"},
5804                        "content": [{"type": "text", "text": "Do something"}]
5805                    },
5806                    {
5807                        "type": "taskItem",
5808                        "attrs": {"localId": "task-002", "state": "DONE"},
5809                        "content": [{"type": "text", "text": "Already done"}]
5810                    }
5811                ]
5812            }]
5813        }"#;
5814        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5815        let md = adf_to_markdown(&doc).unwrap();
5816
5817        // Round-trip: markdown back to ADF
5818        let doc2 = markdown_to_adf(&md).unwrap();
5819        assert_eq!(doc2.content[0].node_type, "taskList");
5820
5821        let items = doc2.content[0].content.as_ref().unwrap();
5822        assert_eq!(items.len(), 2);
5823        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5824        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5825
5826        // localIds preserved
5827        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5828        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "task-002");
5829        assert_eq!(
5830            doc2.content[0].attrs.as_ref().unwrap()["localId"],
5831            "list-001"
5832        );
5833    }
5834
5835    /// Issue #408: taskItem with inline content followed by a nested block (sub-list).
5836    #[test]
5837    fn adf_task_item_unwrapped_inline_then_block() {
5838        let json = r#"{
5839            "version": 1,
5840            "type": "doc",
5841            "content": [{
5842                "type": "taskList",
5843                "attrs": {"localId": "list-001"},
5844                "content": [{
5845                    "type": "taskItem",
5846                    "attrs": {"localId": "task-001", "state": "TODO"},
5847                    "content": [
5848                        {"type": "text", "text": "Parent task"},
5849                        {
5850                            "type": "bulletList",
5851                            "content": [{
5852                                "type": "listItem",
5853                                "content": [{
5854                                    "type": "paragraph",
5855                                    "content": [{"type": "text", "text": "sub-item"}]
5856                                }]
5857                            }]
5858                        }
5859                    ]
5860                }]
5861            }]
5862        }"#;
5863        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5864        let md = adf_to_markdown(&doc).unwrap();
5865        assert!(md.contains("- [ ] Parent task"), "got: {md}");
5866        assert!(md.contains("  - sub-item"), "got: {md}");
5867        assert!(!md.contains("adf-unsupported"), "got: {md}");
5868    }
5869
5870    /// Issue #408: taskItem with empty content array renders without panic.
5871    #[test]
5872    fn adf_task_item_empty_content() {
5873        let json = r#"{
5874            "version": 1,
5875            "type": "doc",
5876            "content": [{
5877                "type": "taskList",
5878                "attrs": {"localId": "list-001"},
5879                "content": [{
5880                    "type": "taskItem",
5881                    "attrs": {"localId": "task-001", "state": "TODO"},
5882                    "content": []
5883                }]
5884            }]
5885        }"#;
5886        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5887        let md = adf_to_markdown(&doc).unwrap();
5888        assert!(md.contains("- [ ] "), "got: {md}");
5889        assert!(!md.contains("adf-unsupported"), "got: {md}");
5890    }
5891
5892    /// Issue #489: nested taskItem inside taskItem.content renders as indented
5893    /// task items instead of corrupting the surrounding taskList.
5894    #[test]
5895    fn adf_nested_task_item_renders_without_corruption() {
5896        let json = r#"{
5897            "type": "doc",
5898            "version": 1,
5899            "content": [{
5900                "type": "taskList",
5901                "attrs": {"localId": ""},
5902                "content": [
5903                    {
5904                        "type": "taskItem",
5905                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-aabbccdd1234", "state": "TODO"},
5906                        "content": [{"type": "text", "text": "Normal task"}]
5907                    },
5908                    {
5909                        "type": "taskItem",
5910                        "attrs": {"localId": ""},
5911                        "content": [
5912                            {
5913                                "type": "taskItem",
5914                                "attrs": {"localId": "bbccddee-2345-6789-bcde-bbccddee2345", "state": "TODO"},
5915                                "content": [{"type": "text", "text": "Nested task one"}]
5916                            },
5917                            {
5918                                "type": "taskItem",
5919                                "attrs": {"localId": "ccddee11-3456-7890-cdef-ccddee113456", "state": "DONE"},
5920                                "content": [{"type": "text", "text": "Nested task two"}]
5921                            }
5922                        ]
5923                    }
5924                ]
5925            }]
5926        }"#;
5927        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5928        let md = adf_to_markdown(&doc).unwrap();
5929        // Normal task preserved
5930        assert!(md.contains("- [ ] Normal task"), "got: {md}");
5931        // Nested tasks rendered as indented task items, not adf-unsupported
5932        assert!(!md.contains("adf-unsupported"), "got: {md}");
5933        assert!(md.contains("  - [ ] Nested task one"), "got: {md}");
5934        assert!(md.contains("  - [x] Nested task two"), "got: {md}");
5935    }
5936
5937    /// Issue #489: round-trip of nested taskItem preserves data.
5938    #[test]
5939    fn round_trip_nested_task_item() {
5940        let json = r#"{
5941            "type": "doc",
5942            "version": 1,
5943            "content": [{
5944                "type": "taskList",
5945                "attrs": {"localId": ""},
5946                "content": [
5947                    {
5948                        "type": "taskItem",
5949                        "attrs": {"localId": "task-001", "state": "TODO"},
5950                        "content": [{"type": "text", "text": "Normal task"}]
5951                    },
5952                    {
5953                        "type": "taskItem",
5954                        "attrs": {"localId": ""},
5955                        "content": [
5956                            {
5957                                "type": "taskItem",
5958                                "attrs": {"localId": "task-002", "state": "TODO"},
5959                                "content": [{"type": "text", "text": "Nested one"}]
5960                            },
5961                            {
5962                                "type": "taskItem",
5963                                "attrs": {"localId": "task-003", "state": "DONE"},
5964                                "content": [{"type": "text", "text": "Nested two"}]
5965                            }
5966                        ]
5967                    }
5968                ]
5969            }]
5970        }"#;
5971        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5972        let md = adf_to_markdown(&doc).unwrap();
5973        let doc2 = markdown_to_adf(&md).unwrap();
5974
5975        // Top-level structure: taskList with 2 items
5976        assert_eq!(doc2.content[0].node_type, "taskList");
5977        let items = doc2.content[0].content.as_ref().unwrap();
5978        assert_eq!(items.len(), 2, "expected 2 top-level items, got: {items:?}");
5979
5980        // First item: normal task preserved
5981        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5982        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5983        let first_content = items[0].content.as_ref().unwrap();
5984        assert_eq!(first_content[0].text.as_deref(), Some("Normal task"));
5985
5986        // Second item: container taskItem — no spurious `state` attr
5987        let container = &items[1];
5988        assert_eq!(container.node_type, "taskItem");
5989        let c_attrs = container.attrs.as_ref().unwrap();
5990        assert!(
5991            c_attrs.get("state").is_none(),
5992            "container should have no state attr, got: {c_attrs:?}"
5993        );
5994
5995        // Children are bare taskItems, NOT wrapped in a taskList
5996        let container_content = container.content.as_ref().unwrap();
5997        assert_eq!(
5998            container_content.len(),
5999            2,
6000            "expected 2 bare taskItem children"
6001        );
6002        assert_eq!(container_content[0].node_type, "taskItem");
6003        assert_eq!(
6004            container_content[0].attrs.as_ref().unwrap()["state"],
6005            "TODO"
6006        );
6007        assert_eq!(
6008            container_content[0].attrs.as_ref().unwrap()["localId"],
6009            "task-002"
6010        );
6011        assert_eq!(container_content[1].node_type, "taskItem");
6012        assert_eq!(
6013            container_content[1].attrs.as_ref().unwrap()["state"],
6014            "DONE"
6015        );
6016        assert_eq!(
6017            container_content[1].attrs.as_ref().unwrap()["localId"],
6018            "task-003"
6019        );
6020    }
6021
6022    /// Issue #489: nested taskItem with localIds on both container and children.
6023    #[test]
6024    fn adf_nested_task_item_preserves_local_ids() {
6025        let json = r#"{
6026            "type": "doc",
6027            "version": 1,
6028            "content": [{
6029                "type": "taskList",
6030                "attrs": {"localId": "list-001"},
6031                "content": [{
6032                    "type": "taskItem",
6033                    "attrs": {"localId": "container-001", "state": "TODO"},
6034                    "content": [{
6035                        "type": "taskItem",
6036                        "attrs": {"localId": "child-001", "state": "DONE"},
6037                        "content": [{"type": "text", "text": "Nested child"}]
6038                    }]
6039                }]
6040            }]
6041        }"#;
6042        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6043        let md = adf_to_markdown(&doc).unwrap();
6044        // Container localId is emitted
6045        assert!(
6046            md.contains("localId=container-001"),
6047            "container localId missing: {md}"
6048        );
6049        // Child localId is emitted
6050        assert!(
6051            md.contains("localId=child-001"),
6052            "child localId missing: {md}"
6053        );
6054        assert!(!md.contains("adf-unsupported"), "got: {md}");
6055    }
6056
6057    /// Issue #489: nested taskItem content mixed with a non-taskItem block node.
6058    /// Covers the else branch in the renderer where a child is not a taskItem.
6059    #[test]
6060    fn adf_nested_task_item_mixed_with_block_node() {
6061        let json = r#"{
6062            "type": "doc",
6063            "version": 1,
6064            "content": [{
6065                "type": "taskList",
6066                "attrs": {"localId": ""},
6067                "content": [{
6068                    "type": "taskItem",
6069                    "attrs": {"localId": "", "state": "TODO"},
6070                    "content": [
6071                        {
6072                            "type": "taskItem",
6073                            "attrs": {"localId": "", "state": "TODO"},
6074                            "content": [{"type": "text", "text": "A nested task"}]
6075                        },
6076                        {
6077                            "type": "paragraph",
6078                            "content": [{"type": "text", "text": "Stray paragraph"}]
6079                        }
6080                    ]
6081                }]
6082            }]
6083        }"#;
6084        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6085        let md = adf_to_markdown(&doc).unwrap();
6086        assert!(md.contains("  - [ ] A nested task"), "got: {md}");
6087        assert!(md.contains("  Stray paragraph"), "got: {md}");
6088        assert!(!md.contains("adf-unsupported"), "got: {md}");
6089    }
6090
6091    /// Issue #489: task item with inline text AND indented sub-content.
6092    /// Covers the parser's `Some` branch when appending nested blocks to
6093    /// an existing content vec.
6094    #[test]
6095    fn task_item_with_text_and_nested_sub_content() {
6096        let md = "- [ ] Parent task\n  - [ ] Sub task\n";
6097        let doc = markdown_to_adf(md).unwrap();
6098        assert_eq!(doc.content[0].node_type, "taskList");
6099        let items = doc.content[0].content.as_ref().unwrap();
6100        // Issue #506: the nested taskList is a sibling of the taskItem,
6101        // not a child — matching ADF's canonical structure.
6102        assert_eq!(items.len(), 2, "got: {items:?}");
6103        let parent = &items[0];
6104        assert_eq!(parent.attrs.as_ref().unwrap()["state"], "TODO");
6105        let parent_content = parent.content.as_ref().unwrap();
6106        assert_eq!(parent_content[0].text.as_deref(), Some("Parent task"));
6107        // Second item: nested taskList (sibling)
6108        assert_eq!(items[1].node_type, "taskList");
6109        let nested = items[1].content.as_ref().unwrap();
6110        assert_eq!(nested.len(), 1);
6111        assert_eq!(nested[0].attrs.as_ref().unwrap()["state"], "TODO");
6112    }
6113
6114    /// Issue #489: empty task item with non-taskList sub-content (e.g. a
6115    /// paragraph).  Exercises the `None` branch when the sub-content does
6116    /// not qualify for container-unwrap.
6117    #[test]
6118    fn task_item_empty_with_non_tasklist_sub_content() {
6119        let md = "- [ ] \n  Some paragraph text\n";
6120        let doc = markdown_to_adf(md).unwrap();
6121        assert_eq!(doc.content[0].node_type, "taskList");
6122        let items = doc.content[0].content.as_ref().unwrap();
6123        assert_eq!(items.len(), 1);
6124        let item = &items[0];
6125        assert_eq!(item.attrs.as_ref().unwrap()["state"], "TODO");
6126        let content = item.content.as_ref().unwrap();
6127        // Sub-content is a paragraph (not unwrapped since it's not a taskList)
6128        assert_eq!(content[0].node_type, "paragraph");
6129    }
6130
6131    /// Issue #489: single nested taskItem (edge case — only one child).
6132    #[test]
6133    fn adf_nested_task_item_single_child() {
6134        let json = r#"{
6135            "type": "doc",
6136            "version": 1,
6137            "content": [{
6138                "type": "taskList",
6139                "attrs": {"localId": ""},
6140                "content": [{
6141                    "type": "taskItem",
6142                    "attrs": {"localId": "", "state": "TODO"},
6143                    "content": [{
6144                        "type": "taskItem",
6145                        "attrs": {"localId": "", "state": "DONE"},
6146                        "content": [{"type": "text", "text": "Only child"}]
6147                    }]
6148                }]
6149            }]
6150        }"#;
6151        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6152        let md = adf_to_markdown(&doc).unwrap();
6153        assert!(md.contains("  - [x] Only child"), "got: {md}");
6154        assert!(!md.contains("adf-unsupported"), "got: {md}");
6155    }
6156
6157    /// Issue #506: nested taskList as direct child of outer taskList is
6158    /// rendered indented so it round-trips back as taskList, not taskItem.
6159    #[test]
6160    fn adf_nested_tasklist_sibling_renders_indented() {
6161        let json = r#"{
6162            "version": 1,
6163            "type": "doc",
6164            "content": [{
6165                "type": "taskList",
6166                "attrs": {"localId": ""},
6167                "content": [
6168                    {
6169                        "type": "taskItem",
6170                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000001", "state": "TODO"},
6171                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
6172                    },
6173                    {
6174                        "type": "taskList",
6175                        "attrs": {"localId": ""},
6176                        "content": [{
6177                            "type": "taskItem",
6178                            "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000002", "state": "TODO"},
6179                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
6180                        }]
6181                    },
6182                    {
6183                        "type": "taskItem",
6184                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000003", "state": "TODO"},
6185                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
6186                    }
6187                ]
6188            }]
6189        }"#;
6190        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6191        let md = adf_to_markdown(&doc).unwrap();
6192        // The nested taskList should be indented under the preceding item.
6193        assert!(md.contains("- [ ] parent task one"), "got: {md}");
6194        assert!(md.contains("  - [ ] nested sub-task"), "got: {md}");
6195        assert!(md.contains("- [ ] parent task two"), "got: {md}");
6196    }
6197
6198    /// Issue #506: round-trip preserves nested taskList type.
6199    #[test]
6200    fn round_trip_nested_tasklist_preserves_type() {
6201        let json = r#"{
6202            "version": 1,
6203            "type": "doc",
6204            "content": [{
6205                "type": "taskList",
6206                "attrs": {"localId": ""},
6207                "content": [
6208                    {
6209                        "type": "taskItem",
6210                        "attrs": {"localId": "", "state": "TODO"},
6211                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
6212                    },
6213                    {
6214                        "type": "taskList",
6215                        "attrs": {"localId": ""},
6216                        "content": [{
6217                            "type": "taskItem",
6218                            "attrs": {"localId": "", "state": "TODO"},
6219                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
6220                        }]
6221                    },
6222                    {
6223                        "type": "taskItem",
6224                        "attrs": {"localId": "", "state": "TODO"},
6225                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
6226                    }
6227                ]
6228            }]
6229        }"#;
6230        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6231        let md = adf_to_markdown(&doc).unwrap();
6232        let rt_doc = markdown_to_adf(&md).unwrap();
6233        // The outer taskList should still be present.
6234        assert_eq!(rt_doc.content[0].node_type, "taskList");
6235        let items = rt_doc.content[0].content.as_ref().unwrap();
6236        // The nested taskList is a sibling of the taskItem nodes,
6237        // matching the original ADF structure (issue #506).
6238        assert_eq!(items.len(), 3, "got: {items:?}");
6239        assert_eq!(items[0].node_type, "taskItem");
6240        assert_eq!(
6241            items[1].node_type, "taskList",
6242            "nested taskList should survive round-trip"
6243        );
6244        assert_eq!(items[2].node_type, "taskItem");
6245        let nested_items = items[1].content.as_ref().unwrap();
6246        assert_eq!(nested_items[0].attrs.as_ref().unwrap()["state"], "TODO");
6247    }
6248
6249    /// Issue #506: nested taskList with DONE state preserves checkbox.
6250    #[test]
6251    fn adf_nested_tasklist_done_state() {
6252        let json = r#"{
6253            "version": 1,
6254            "type": "doc",
6255            "content": [{
6256                "type": "taskList",
6257                "attrs": {"localId": ""},
6258                "content": [
6259                    {
6260                        "type": "taskItem",
6261                        "attrs": {"localId": "", "state": "TODO"},
6262                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
6263                    },
6264                    {
6265                        "type": "taskList",
6266                        "attrs": {"localId": ""},
6267                        "content": [{
6268                            "type": "taskItem",
6269                            "attrs": {"localId": "", "state": "DONE"},
6270                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "done child"}]}]
6271                        }]
6272                    }
6273                ]
6274            }]
6275        }"#;
6276        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6277        let md = adf_to_markdown(&doc).unwrap();
6278        assert!(md.contains("  - [x] done child"), "got: {md}");
6279        // Round-trip preserves DONE state — nested taskList is a sibling.
6280        let rt_doc = markdown_to_adf(&md).unwrap();
6281        let items = rt_doc.content[0].content.as_ref().unwrap();
6282        assert_eq!(
6283            items[1].node_type, "taskList",
6284            "nested taskList should survive round-trip"
6285        );
6286        let nested_item = &items[1].content.as_ref().unwrap()[0];
6287        assert_eq!(nested_item.attrs.as_ref().unwrap()["state"], "DONE");
6288    }
6289
6290    /// Issue #506: multiple nested taskLists at the same level.
6291    #[test]
6292    fn adf_multiple_nested_tasklists() {
6293        let json = r#"{
6294            "version": 1,
6295            "type": "doc",
6296            "content": [{
6297                "type": "taskList",
6298                "attrs": {"localId": ""},
6299                "content": [
6300                    {
6301                        "type": "taskItem",
6302                        "attrs": {"localId": "", "state": "TODO"},
6303                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "first parent"}]}]
6304                    },
6305                    {
6306                        "type": "taskList",
6307                        "attrs": {"localId": ""},
6308                        "content": [{
6309                            "type": "taskItem",
6310                            "attrs": {"localId": "", "state": "TODO"},
6311                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child A"}]}]
6312                        }]
6313                    },
6314                    {
6315                        "type": "taskItem",
6316                        "attrs": {"localId": "", "state": "TODO"},
6317                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "second parent"}]}]
6318                    },
6319                    {
6320                        "type": "taskList",
6321                        "attrs": {"localId": ""},
6322                        "content": [{
6323                            "type": "taskItem",
6324                            "attrs": {"localId": "", "state": "DONE"},
6325                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child B"}]}]
6326                        }]
6327                    }
6328                ]
6329            }]
6330        }"#;
6331        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6332        let md = adf_to_markdown(&doc).unwrap();
6333        assert!(md.contains("- [ ] first parent"), "got: {md}");
6334        assert!(md.contains("  - [ ] child A"), "got: {md}");
6335        assert!(md.contains("- [ ] second parent"), "got: {md}");
6336        assert!(md.contains("  - [x] child B"), "got: {md}");
6337    }
6338
6339    /// Issue #506: second round-trip is stable (idempotent after first
6340    /// structural normalisation).
6341    #[test]
6342    fn round_trip_nested_tasklist_stable() {
6343        let json = r#"{
6344            "version": 1,
6345            "type": "doc",
6346            "content": [{
6347                "type": "taskList",
6348                "attrs": {"localId": ""},
6349                "content": [
6350                    {
6351                        "type": "taskItem",
6352                        "attrs": {"localId": "", "state": "TODO"},
6353                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
6354                    },
6355                    {
6356                        "type": "taskList",
6357                        "attrs": {"localId": ""},
6358                        "content": [{
6359                            "type": "taskItem",
6360                            "attrs": {"localId": "", "state": "TODO"},
6361                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child"}]}]
6362                        }]
6363                    }
6364                ]
6365            }]
6366        }"#;
6367        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6368        // First round-trip.
6369        let md1 = adf_to_markdown(&doc).unwrap();
6370        let rt1 = markdown_to_adf(&md1).unwrap();
6371        // Second round-trip.
6372        let md2 = adf_to_markdown(&rt1).unwrap();
6373        let rt2 = markdown_to_adf(&md2).unwrap();
6374        // Markdown output should be identical after first normalisation.
6375        assert_eq!(md1, md2, "markdown should be stable across round-trips");
6376        // ADF structure should also be stable.
6377        let rt1_json = serde_json::to_string(&rt1).unwrap();
6378        let rt2_json = serde_json::to_string(&rt2).unwrap();
6379        assert_eq!(
6380            rt1_json, rt2_json,
6381            "ADF should be stable across round-trips"
6382        );
6383    }
6384
6385    /// Issue #506: task item with text and mixed indented sub-content
6386    /// (taskList + non-taskList block).  Exercises the `child_nodes` branch
6387    /// where non-taskList blocks stay as children of the taskItem while
6388    /// taskLists are promoted to siblings.
6389    #[test]
6390    fn task_item_mixed_sub_content_splits_siblings() {
6391        let md = "- [ ] Parent task\n  - [ ] Sub task\n  Some paragraph\n";
6392        let doc = markdown_to_adf(md).unwrap();
6393        let items = doc.content[0].content.as_ref().unwrap();
6394        // taskItem + sibling taskList
6395        assert_eq!(items.len(), 2, "got: {items:?}");
6396        assert_eq!(items[0].node_type, "taskItem");
6397        let parent_content = items[0].content.as_ref().unwrap();
6398        // Inline text + paragraph block (the non-taskList sub-content)
6399        assert!(
6400            parent_content.iter().any(|n| n.node_type == "paragraph"),
6401            "non-taskList sub-content should stay as child: {parent_content:?}"
6402        );
6403        // Sibling taskList
6404        assert_eq!(items[1].node_type, "taskList");
6405    }
6406
6407    /// Issue #506: empty task item with mixed indented sub-content hits the
6408    /// `None` arm of the `task.content` match when promoting taskLists to
6409    /// siblings.
6410    #[test]
6411    fn empty_task_item_mixed_sub_content_none_arm() {
6412        let md = "- [ ] \n  Some paragraph\n  - [ ] Sub task\n";
6413        let doc = markdown_to_adf(md).unwrap();
6414        let items = doc.content[0].content.as_ref().unwrap();
6415        // taskItem (with paragraph child) + sibling taskList
6416        assert_eq!(items.len(), 2, "got: {items:?}");
6417        assert_eq!(items[0].node_type, "taskItem");
6418        let parent_content = items[0].content.as_ref().unwrap();
6419        assert!(
6420            parent_content.iter().any(|n| n.node_type == "paragraph"),
6421            "paragraph should be assigned to taskItem: {parent_content:?}"
6422        );
6423        assert_eq!(items[1].node_type, "taskList");
6424    }
6425
6426    /// Issue #506: task item with text and only non-taskList sub-content
6427    /// (no sibling taskLists).  Exercises the fall-through path where
6428    /// `sibling_task_lists` is empty and child_nodes are appended to
6429    /// the existing task content (Some arm).
6430    #[test]
6431    fn task_item_text_with_non_tasklist_sub_content_only() {
6432        let md = "- [ ] My task\n  Extra paragraph content\n";
6433        let doc = markdown_to_adf(md).unwrap();
6434        let items = doc.content[0].content.as_ref().unwrap();
6435        // Single taskItem — no sibling taskLists to extract.
6436        assert_eq!(items.len(), 1, "got: {items:?}");
6437        assert_eq!(items[0].node_type, "taskItem");
6438        let content = items[0].content.as_ref().unwrap();
6439        // Inline text + sub-paragraph
6440        assert!(
6441            content.iter().any(|n| n.node_type == "paragraph"),
6442            "paragraph sub-content should be a child of taskItem: {content:?}"
6443        );
6444    }
6445
6446    /// Covers the else branch in render_list_item_content where the first
6447    /// child of a list item is a block node (not paragraph, not inline).
6448    #[test]
6449    fn adf_list_item_leading_block_node() {
6450        let json = r#"{
6451            "version": 1,
6452            "type": "doc",
6453            "content": [{
6454                "type": "bulletList",
6455                "content": [{
6456                    "type": "listItem",
6457                    "content": [{
6458                        "type": "codeBlock",
6459                        "attrs": {"language": "rust"},
6460                        "content": [{"type": "text", "text": "let x = 1;"}]
6461                    }]
6462                }]
6463            }]
6464        }"#;
6465        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6466        let md = adf_to_markdown(&doc).unwrap();
6467        assert!(md.contains("```rust"), "got: {md}");
6468        assert!(md.contains("let x = 1;"), "got: {md}");
6469        // Continuation lines must be indented so the block stays inside
6470        // the list item on round-trip (issue #511).
6471        for line in md.lines() {
6472            if line.starts_with("- ") {
6473                continue; // first line with list marker
6474            }
6475            if line.trim().is_empty() {
6476                continue;
6477            }
6478            assert!(
6479                line.starts_with("  "),
6480                "continuation line not indented: {line:?}"
6481            );
6482        }
6483    }
6484
6485    /// Round-trip a codeBlock inside a listItem whose content contains a
6486    /// backtick character — the exact reproducer from issue #511.
6487    #[test]
6488    fn code_block_in_list_item_backtick_roundtrip() {
6489        let json = r#"{
6490            "version": 1,
6491            "type": "doc",
6492            "content": [{
6493                "type": "bulletList",
6494                "content": [{
6495                    "type": "listItem",
6496                    "content": [{
6497                        "type": "codeBlock",
6498                        "attrs": {"language": ""},
6499                        "content": [{"type": "text", "text": "error: some value with a backtick ` at end"}]
6500                    }]
6501                }]
6502            }]
6503        }"#;
6504        let original: AdfDocument = serde_json::from_str(json).unwrap();
6505        let md = adf_to_markdown(&original).unwrap();
6506        let roundtripped = markdown_to_adf(&md).unwrap();
6507        let list = &roundtripped.content[0];
6508        assert_eq!(list.node_type, "bulletList", "top node: {}", list.node_type);
6509        let item = &list.content.as_ref().unwrap()[0];
6510        let first_child = &item.content.as_ref().unwrap()[0];
6511        assert_eq!(
6512            first_child.node_type, "codeBlock",
6513            "expected codeBlock, got: {}",
6514            first_child.node_type
6515        );
6516        let text = first_child.content.as_ref().unwrap()[0]
6517            .text
6518            .as_deref()
6519            .unwrap();
6520        assert_eq!(text, "error: some value with a backtick ` at end");
6521    }
6522
6523    /// Code block with language tag inside a list item round-trips.
6524    #[test]
6525    fn code_block_with_language_in_list_item_roundtrip() {
6526        let json = r#"{
6527            "version": 1,
6528            "type": "doc",
6529            "content": [{
6530                "type": "bulletList",
6531                "content": [{
6532                    "type": "listItem",
6533                    "content": [{
6534                        "type": "codeBlock",
6535                        "attrs": {"language": "rust"},
6536                        "content": [{"type": "text", "text": "fn main() {\n    println!(\"hello\");\n}"}]
6537                    }]
6538                }]
6539            }]
6540        }"#;
6541        let original: AdfDocument = serde_json::from_str(json).unwrap();
6542        let md = adf_to_markdown(&original).unwrap();
6543        let roundtripped = markdown_to_adf(&md).unwrap();
6544        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6545        let code = &item.content.as_ref().unwrap()[0];
6546        assert_eq!(code.node_type, "codeBlock");
6547        let lang = code
6548            .attrs
6549            .as_ref()
6550            .and_then(|a| a.get("language"))
6551            .and_then(serde_json::Value::as_str)
6552            .unwrap_or("");
6553        assert_eq!(lang, "rust");
6554        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6555        assert!(text.contains("println!"), "code content: {text}");
6556    }
6557
6558    /// Code block in an ordered list item round-trips correctly.
6559    #[test]
6560    fn code_block_in_ordered_list_item_roundtrip() {
6561        let json = r#"{
6562            "version": 1,
6563            "type": "doc",
6564            "content": [{
6565                "type": "orderedList",
6566                "attrs": {"order": 1},
6567                "content": [{
6568                    "type": "listItem",
6569                    "content": [{
6570                        "type": "codeBlock",
6571                        "attrs": {"language": ""},
6572                        "content": [{"type": "text", "text": "backtick ` here"}]
6573                    }]
6574                }]
6575            }]
6576        }"#;
6577        let original: AdfDocument = serde_json::from_str(json).unwrap();
6578        let md = adf_to_markdown(&original).unwrap();
6579        let roundtripped = markdown_to_adf(&md).unwrap();
6580        let list = &roundtripped.content[0];
6581        assert_eq!(list.node_type, "orderedList");
6582        let item = &list.content.as_ref().unwrap()[0];
6583        let code = &item.content.as_ref().unwrap()[0];
6584        assert_eq!(code.node_type, "codeBlock");
6585        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6586        assert_eq!(text, "backtick ` here");
6587    }
6588
6589    /// A list item with a code block followed by a paragraph round-trips.
6590    #[test]
6591    fn code_block_then_paragraph_in_list_item() {
6592        let json = r#"{
6593            "version": 1,
6594            "type": "doc",
6595            "content": [{
6596                "type": "bulletList",
6597                "content": [{
6598                    "type": "listItem",
6599                    "content": [
6600                        {
6601                            "type": "codeBlock",
6602                            "attrs": {"language": ""},
6603                            "content": [{"type": "text", "text": "code with ` backtick"}]
6604                        },
6605                        {
6606                            "type": "paragraph",
6607                            "content": [{"type": "text", "text": "description"}]
6608                        }
6609                    ]
6610                }]
6611            }]
6612        }"#;
6613        let original: AdfDocument = serde_json::from_str(json).unwrap();
6614        let md = adf_to_markdown(&original).unwrap();
6615        let roundtripped = markdown_to_adf(&md).unwrap();
6616        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6617        let children = item.content.as_ref().unwrap();
6618        assert_eq!(children[0].node_type, "codeBlock");
6619        assert_eq!(children[1].node_type, "paragraph");
6620    }
6621
6622    /// Multiple backticks in code block content round-trip.
6623    #[test]
6624    fn code_block_multiple_backticks_in_list_item() {
6625        let json = r#"{
6626            "version": 1,
6627            "type": "doc",
6628            "content": [{
6629                "type": "bulletList",
6630                "content": [{
6631                    "type": "listItem",
6632                    "content": [{
6633                        "type": "codeBlock",
6634                        "attrs": {"language": ""},
6635                        "content": [{"type": "text", "text": "a ` b `` c ``` d"}]
6636                    }]
6637                }]
6638            }]
6639        }"#;
6640        let original: AdfDocument = serde_json::from_str(json).unwrap();
6641        let md = adf_to_markdown(&original).unwrap();
6642        let roundtripped = markdown_to_adf(&md).unwrap();
6643        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6644        let code = &item.content.as_ref().unwrap()[0];
6645        assert_eq!(code.node_type, "codeBlock");
6646        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6647        assert_eq!(text, "a ` b `` c ``` d");
6648    }
6649
6650    /// Media as the first child of a list item with a subsequent paragraph
6651    /// exercises the media + sub_lines branch in `parse_list_item_first_line`.
6652    #[test]
6653    fn media_first_child_with_sub_content_in_list_item() {
6654        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
6655          {"type":"listItem","content":[
6656            {"type":"mediaSingle","attrs":{"layout":"center"},
6657             "content":[{"type":"media","attrs":{"type":"file","id":"img-99","collection":"col-x","height":50,"width":100}}]},
6658            {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
6659          ]}
6660        ]}]}"#;
6661        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6662        let md = adf_to_markdown(&doc).unwrap();
6663        let rt = markdown_to_adf(&md).unwrap();
6664        let item = &rt.content[0].content.as_ref().unwrap()[0];
6665        let children = item.content.as_ref().unwrap();
6666        assert_eq!(
6667            children.len(),
6668            2,
6669            "expected 2 children, got {}",
6670            children.len()
6671        );
6672        assert_eq!(children[0].node_type, "mediaSingle");
6673        let media = &children[0].content.as_ref().unwrap()[0];
6674        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-99");
6675        assert_eq!(children[1].node_type, "paragraph");
6676    }
6677
6678    #[test]
6679    fn inline_bold() {
6680        let doc = markdown_to_adf("Some **bold** text").unwrap();
6681        let content = doc.content[0].content.as_ref().unwrap();
6682        assert!(content.len() >= 3);
6683        let bold_node = &content[1];
6684        assert_eq!(bold_node.text.as_deref(), Some("bold"));
6685        let marks = bold_node.marks.as_ref().unwrap();
6686        assert_eq!(marks[0].mark_type, "strong");
6687    }
6688
6689    #[test]
6690    fn inline_italic() {
6691        let doc = markdown_to_adf("Some *italic* text").unwrap();
6692        let content = doc.content[0].content.as_ref().unwrap();
6693        let italic_node = &content[1];
6694        assert_eq!(italic_node.text.as_deref(), Some("italic"));
6695        let marks = italic_node.marks.as_ref().unwrap();
6696        assert_eq!(marks[0].mark_type, "em");
6697    }
6698
6699    #[test]
6700    fn inline_code() {
6701        let doc = markdown_to_adf("Use `code` here").unwrap();
6702        let content = doc.content[0].content.as_ref().unwrap();
6703        let code_node = &content[1];
6704        assert_eq!(code_node.text.as_deref(), Some("code"));
6705        let marks = code_node.marks.as_ref().unwrap();
6706        assert_eq!(marks[0].mark_type, "code");
6707    }
6708
6709    /// Issue #578: a code-marked text with an internal backtick must be
6710    /// emitted using double-backtick delimiters so it round-trips as a
6711    /// single node rather than being split on the inner backtick.
6712    #[test]
6713    fn inline_code_with_backtick_emitted_with_double_delimiters() {
6714        let doc = AdfDocument {
6715            version: 1,
6716            doc_type: "doc".to_string(),
6717            content: vec![AdfNode::paragraph(vec![
6718                AdfNode::text("Run "),
6719                AdfNode::text_with_marks(
6720                    "ADD `custom_threshold` TEXT NOT NULL",
6721                    vec![AdfMark::code()],
6722                ),
6723                AdfNode::text(" to update the schema."),
6724            ])],
6725        };
6726        let md = adf_to_markdown(&doc).unwrap();
6727        assert!(
6728            md.contains("``ADD `custom_threshold` TEXT NOT NULL``"),
6729            "expected double-backtick delimiters, got: {md}"
6730        );
6731    }
6732
6733    /// Issue #578: double-backtick delimited code spans parse as a single
6734    /// code-marked text node that preserves the embedded single backticks.
6735    #[test]
6736    fn inline_code_double_backtick_delimiters_parse() {
6737        let doc = markdown_to_adf("Run ``ADD `custom_threshold` TEXT NOT NULL`` now").unwrap();
6738        let content = doc.content[0].content.as_ref().unwrap();
6739        assert_eq!(content.len(), 3, "content: {content:?}");
6740        let code_node = &content[1];
6741        assert_eq!(
6742            code_node.text.as_deref(),
6743            Some("ADD `custom_threshold` TEXT NOT NULL")
6744        );
6745        let marks = code_node.marks.as_ref().unwrap();
6746        assert_eq!(marks[0].mark_type, "code");
6747    }
6748
6749    /// Issue #578: the full reproducer — a code-marked text with inner
6750    /// backticks survives ADF → JFM → ADF round-trip intact.
6751    #[test]
6752    fn inline_code_with_backtick_roundtrip() {
6753        let json = r#"{
6754            "version": 1,
6755            "type": "doc",
6756            "content": [{
6757                "type": "paragraph",
6758                "content": [
6759                    {"type": "text", "text": "Run "},
6760                    {
6761                        "type": "text",
6762                        "text": "ADD `custom_threshold` TEXT NOT NULL",
6763                        "marks": [{"type": "code"}]
6764                    },
6765                    {"type": "text", "text": " to update the schema."}
6766                ]
6767            }]
6768        }"#;
6769        let original: AdfDocument = serde_json::from_str(json).unwrap();
6770        let md = adf_to_markdown(&original).unwrap();
6771        let roundtripped = markdown_to_adf(&md).unwrap();
6772        let para = &roundtripped.content[0];
6773        let children = para.content.as_ref().unwrap();
6774        assert_eq!(children.len(), 3, "expected 3 children, got: {children:?}");
6775        assert_eq!(children[0].text.as_deref(), Some("Run "));
6776        assert_eq!(
6777            children[1].text.as_deref(),
6778            Some("ADD `custom_threshold` TEXT NOT NULL")
6779        );
6780        let marks = children[1].marks.as_ref().unwrap();
6781        assert_eq!(marks.len(), 1);
6782        assert_eq!(marks[0].mark_type, "code");
6783        assert_eq!(children[2].text.as_deref(), Some(" to update the schema."));
6784    }
6785
6786    /// A code-marked text containing a run of two backticks should be
6787    /// emitted with triple-backtick delimiters and round-trip intact —
6788    /// the first line of the paragraph also starts with the fence so this
6789    /// exercises the info-string-with-backtick fence-opener rejection.
6790    #[test]
6791    fn inline_code_with_double_backtick_roundtrip() {
6792        let doc = AdfDocument {
6793            version: 1,
6794            doc_type: "doc".to_string(),
6795            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6796                "x `` y",
6797                vec![AdfMark::code()],
6798            )])],
6799        };
6800        let md = adf_to_markdown(&doc).unwrap();
6801        let roundtripped = markdown_to_adf(&md).unwrap();
6802        let content = roundtripped.content[0].content.as_ref().unwrap();
6803        assert_eq!(content.len(), 1);
6804        assert_eq!(content[0].text.as_deref(), Some("x `` y"));
6805        let marks = content[0].marks.as_ref().unwrap();
6806        assert_eq!(marks[0].mark_type, "code");
6807    }
6808
6809    /// A code-marked text that begins with a backtick must be padded on
6810    /// both sides so the CommonMark space-stripping rule reconstructs it.
6811    #[test]
6812    fn inline_code_leading_backtick_roundtrip() {
6813        let doc = AdfDocument {
6814            version: 1,
6815            doc_type: "doc".to_string(),
6816            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6817                "`start",
6818                vec![AdfMark::code()],
6819            )])],
6820        };
6821        let md = adf_to_markdown(&doc).unwrap();
6822        let roundtripped = markdown_to_adf(&md).unwrap();
6823        let content = roundtripped.content[0].content.as_ref().unwrap();
6824        assert_eq!(content[0].text.as_deref(), Some("`start"));
6825        assert_eq!(content[0].marks.as_ref().unwrap()[0].mark_type, "code");
6826    }
6827
6828    /// A code-marked text that ends with a backtick must also survive.
6829    #[test]
6830    fn inline_code_trailing_backtick_roundtrip() {
6831        let doc = AdfDocument {
6832            version: 1,
6833            doc_type: "doc".to_string(),
6834            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6835                "end`",
6836                vec![AdfMark::code()],
6837            )])],
6838        };
6839        let md = adf_to_markdown(&doc).unwrap();
6840        let roundtripped = markdown_to_adf(&md).unwrap();
6841        let content = roundtripped.content[0].content.as_ref().unwrap();
6842        assert_eq!(content[0].text.as_deref(), Some("end`"));
6843    }
6844
6845    /// Content that both begins and ends with a space (but is not all
6846    /// spaces) needs padding so the stripping rule leaves it intact.
6847    #[test]
6848    fn inline_code_space_padded_content_roundtrip() {
6849        let doc = AdfDocument {
6850            version: 1,
6851            doc_type: "doc".to_string(),
6852            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6853                " foo ",
6854                vec![AdfMark::code()],
6855            )])],
6856        };
6857        let md = adf_to_markdown(&doc).unwrap();
6858        let roundtripped = markdown_to_adf(&md).unwrap();
6859        let content = roundtripped.content[0].content.as_ref().unwrap();
6860        assert_eq!(content[0].text.as_deref(), Some(" foo "));
6861    }
6862
6863    /// All-space content must round-trip without the stripping rule
6864    /// kicking in (per CommonMark: all-space content is not stripped).
6865    #[test]
6866    fn inline_code_all_spaces_roundtrip() {
6867        let doc = AdfDocument {
6868            version: 1,
6869            doc_type: "doc".to_string(),
6870            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6871                "   ",
6872                vec![AdfMark::code()],
6873            )])],
6874        };
6875        let md = adf_to_markdown(&doc).unwrap();
6876        let roundtripped = markdown_to_adf(&md).unwrap();
6877        let content = roundtripped.content[0].content.as_ref().unwrap();
6878        assert_eq!(content[0].text.as_deref(), Some("   "));
6879    }
6880
6881    /// A code+link mark where the code text contains a backtick must also
6882    /// round-trip — verifies the link branch of code-span rendering.
6883    #[test]
6884    fn inline_code_with_link_and_backtick_roundtrip() {
6885        let doc = AdfDocument {
6886            version: 1,
6887            doc_type: "doc".to_string(),
6888            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6889                "fn `inner`",
6890                vec![AdfMark::code(), AdfMark::link("https://example.com")],
6891            )])],
6892        };
6893        let md = adf_to_markdown(&doc).unwrap();
6894        assert!(
6895            md.contains("`` fn `inner` ``"),
6896            "expected padded double-backtick delimiters inside link, got: {md}"
6897        );
6898        let roundtripped = markdown_to_adf(&md).unwrap();
6899        let content = roundtripped.content[0].content.as_ref().unwrap();
6900        assert_eq!(content[0].text.as_deref(), Some("fn `inner`"));
6901        let mark_types: Vec<&str> = content[0]
6902            .marks
6903            .as_ref()
6904            .unwrap()
6905            .iter()
6906            .map(|m| m.mark_type.as_str())
6907            .collect();
6908        assert!(mark_types.contains(&"code"));
6909        assert!(mark_types.contains(&"link"));
6910    }
6911
6912    /// Unmatched opening backticks must not be parsed as a code span.
6913    #[test]
6914    fn inline_code_unmatched_run_is_plain_text() {
6915        let doc = markdown_to_adf("foo ``bar baz").unwrap();
6916        let content = doc.content[0].content.as_ref().unwrap();
6917        assert_eq!(content.len(), 1);
6918        assert_eq!(content[0].text.as_deref(), Some("foo ``bar baz"));
6919        assert!(content[0].marks.is_none());
6920    }
6921
6922    /// Mismatched delimiter lengths must not form a code span.  Per
6923    /// CommonMark the opening 2-backtick run and the trailing 1-backtick
6924    /// run never form a valid code span and the characters stay literal.
6925    #[test]
6926    fn inline_code_mismatched_delimiters_is_plain_text() {
6927        let doc = markdown_to_adf("``foo` bar").unwrap();
6928        let content = doc.content[0].content.as_ref().unwrap();
6929        assert_eq!(content.len(), 1);
6930        assert_eq!(content[0].text.as_deref(), Some("``foo` bar"));
6931        assert!(content[0].marks.is_none());
6932    }
6933
6934    #[test]
6935    fn inline_code_delimiter_chooses_correct_length() {
6936        assert_eq!(inline_code_delimiter("no ticks"), (1, false));
6937        assert_eq!(inline_code_delimiter("one ` here"), (2, false));
6938        assert_eq!(inline_code_delimiter("two `` here"), (3, false));
6939        assert_eq!(inline_code_delimiter("three ``` here"), (4, false));
6940        assert_eq!(inline_code_delimiter("`leading"), (2, true));
6941        assert_eq!(inline_code_delimiter("trailing`"), (2, true));
6942        assert_eq!(inline_code_delimiter(" foo "), (1, true));
6943        assert_eq!(inline_code_delimiter(" "), (1, false));
6944        assert_eq!(inline_code_delimiter("   "), (1, false));
6945        assert_eq!(inline_code_delimiter(" foo"), (1, false));
6946    }
6947
6948    #[test]
6949    fn try_parse_inline_code_strips_paired_spaces() {
6950        let (end, content) = try_parse_inline_code("`` `foo` ``", 0).unwrap();
6951        assert_eq!(end, 11);
6952        assert_eq!(content, "`foo`");
6953    }
6954
6955    #[test]
6956    fn try_parse_inline_code_all_space_content_is_preserved() {
6957        let (_end, content) = try_parse_inline_code("`   `", 0).unwrap();
6958        assert_eq!(content, "   ");
6959    }
6960
6961    #[test]
6962    fn try_parse_inline_code_single_run_matches_first_close() {
6963        let (end, content) = try_parse_inline_code("`foo` tail", 0).unwrap();
6964        assert_eq!(end, 5);
6965        assert_eq!(content, "foo");
6966    }
6967
6968    #[test]
6969    fn try_parse_inline_code_no_match_returns_none() {
6970        assert!(try_parse_inline_code("``unmatched", 0).is_none());
6971        assert!(try_parse_inline_code("plain text", 0).is_none());
6972    }
6973
6974    #[test]
6975    fn is_code_fence_opener_rejects_info_with_backtick() {
6976        assert!(is_code_fence_opener("```"));
6977        assert!(is_code_fence_opener("```rust"));
6978        assert!(is_code_fence_opener("```\"\""));
6979        assert!(!is_code_fence_opener("```x `` y```"));
6980        assert!(!is_code_fence_opener("``not-enough"));
6981        assert!(!is_code_fence_opener("no fence"));
6982    }
6983
6984    #[test]
6985    fn inline_strikethrough() {
6986        let doc = markdown_to_adf("Some ~~deleted~~ text").unwrap();
6987        let content = doc.content[0].content.as_ref().unwrap();
6988        let strike_node = &content[1];
6989        assert_eq!(strike_node.text.as_deref(), Some("deleted"));
6990        let marks = strike_node.marks.as_ref().unwrap();
6991        assert_eq!(marks[0].mark_type, "strike");
6992    }
6993
6994    #[test]
6995    fn inline_link() {
6996        let doc = markdown_to_adf("Click [here](https://example.com) now").unwrap();
6997        let content = doc.content[0].content.as_ref().unwrap();
6998        let link_node = &content[1];
6999        assert_eq!(link_node.text.as_deref(), Some("here"));
7000        let marks = link_node.marks.as_ref().unwrap();
7001        assert_eq!(marks[0].mark_type, "link");
7002    }
7003
7004    #[test]
7005    fn block_image() {
7006        let md = "![Alt text](https://example.com/image.png)";
7007        let doc = markdown_to_adf(md).unwrap();
7008        assert_eq!(doc.content[0].node_type, "mediaSingle");
7009    }
7010
7011    #[test]
7012    fn table() {
7013        let md = "| A | B |\n| --- | --- |\n| 1 | 2 |";
7014        let doc = markdown_to_adf(md).unwrap();
7015        assert_eq!(doc.content[0].node_type, "table");
7016        let rows = doc.content[0].content.as_ref().unwrap();
7017        assert_eq!(rows.len(), 2); // header + 1 body row
7018    }
7019
7020    // ── adf_to_markdown tests ───────────────────────────────────────
7021
7022    #[test]
7023    fn adf_paragraph_to_markdown() {
7024        let doc = AdfDocument {
7025            version: 1,
7026            doc_type: "doc".to_string(),
7027            content: vec![AdfNode::paragraph(vec![AdfNode::text("Hello world")])],
7028        };
7029        let md = adf_to_markdown(&doc).unwrap();
7030        assert_eq!(md.trim(), "Hello world");
7031    }
7032
7033    #[test]
7034    fn adf_heading_to_markdown() {
7035        let doc = AdfDocument {
7036            version: 1,
7037            doc_type: "doc".to_string(),
7038            content: vec![AdfNode::heading(2, vec![AdfNode::text("Title")])],
7039        };
7040        let md = adf_to_markdown(&doc).unwrap();
7041        assert_eq!(md.trim(), "## Title");
7042    }
7043
7044    #[test]
7045    fn adf_bold_to_markdown() {
7046        let doc = AdfDocument {
7047            version: 1,
7048            doc_type: "doc".to_string(),
7049            content: vec![AdfNode::paragraph(vec![
7050                AdfNode::text("Normal "),
7051                AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
7052                AdfNode::text(" text"),
7053            ])],
7054        };
7055        let md = adf_to_markdown(&doc).unwrap();
7056        assert_eq!(md.trim(), "Normal **bold** text");
7057    }
7058
7059    #[test]
7060    fn adf_code_block_to_markdown() {
7061        let doc = AdfDocument {
7062            version: 1,
7063            doc_type: "doc".to_string(),
7064            content: vec![AdfNode::code_block(Some("rust"), "let x = 1;")],
7065        };
7066        let md = adf_to_markdown(&doc).unwrap();
7067        assert!(md.contains("```rust"));
7068        assert!(md.contains("let x = 1;"));
7069        assert!(md.contains("```"));
7070    }
7071
7072    #[test]
7073    fn adf_rule_to_markdown() {
7074        let doc = AdfDocument {
7075            version: 1,
7076            doc_type: "doc".to_string(),
7077            content: vec![AdfNode::rule()],
7078        };
7079        let md = adf_to_markdown(&doc).unwrap();
7080        assert!(md.contains("---"));
7081    }
7082
7083    #[test]
7084    fn adf_bullet_list_to_markdown() {
7085        let doc = AdfDocument {
7086            version: 1,
7087            doc_type: "doc".to_string(),
7088            content: vec![AdfNode::bullet_list(vec![
7089                AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("A")])]),
7090                AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("B")])]),
7091            ])],
7092        };
7093        let md = adf_to_markdown(&doc).unwrap();
7094        assert!(md.contains("- A"));
7095        assert!(md.contains("- B"));
7096    }
7097
7098    #[test]
7099    fn adf_link_to_markdown() {
7100        let doc = AdfDocument {
7101            version: 1,
7102            doc_type: "doc".to_string(),
7103            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7104                "click",
7105                vec![AdfMark::link("https://example.com")],
7106            )])],
7107        };
7108        let md = adf_to_markdown(&doc).unwrap();
7109        assert_eq!(md.trim(), "[click](https://example.com)");
7110    }
7111
7112    #[test]
7113    fn unsupported_block_preserved_as_json() {
7114        let doc = AdfDocument {
7115            version: 1,
7116            doc_type: "doc".to_string(),
7117            content: vec![AdfNode {
7118                node_type: "unknownBlock".to_string(),
7119                attrs: Some(serde_json::json!({"key": "value"})),
7120                content: None,
7121                text: None,
7122                marks: None,
7123                local_id: None,
7124                parameters: None,
7125            }],
7126        };
7127        let md = adf_to_markdown(&doc).unwrap();
7128        assert!(md.contains("```adf-unsupported"));
7129        assert!(md.contains("\"unknownBlock\""));
7130    }
7131
7132    #[test]
7133    fn unsupported_block_round_trips() {
7134        let original = AdfDocument {
7135            version: 1,
7136            doc_type: "doc".to_string(),
7137            content: vec![AdfNode {
7138                node_type: "unknownBlock".to_string(),
7139                attrs: Some(serde_json::json!({"key": "value"})),
7140                content: None,
7141                text: None,
7142                marks: None,
7143                local_id: None,
7144                parameters: None,
7145            }],
7146        };
7147        let md = adf_to_markdown(&original).unwrap();
7148        let restored = markdown_to_adf(&md).unwrap();
7149        assert_eq!(restored.content[0].node_type, "unknownBlock");
7150        assert_eq!(restored.content[0].attrs.as_ref().unwrap()["key"], "value");
7151    }
7152
7153    // ── Round-trip tests ────────────────────────────────────────────
7154
7155    #[test]
7156    fn round_trip_simple_document() {
7157        let md = "# Hello\n\nSome text with **bold** and *italic*.\n\n- Item 1\n- Item 2\n";
7158        let adf = markdown_to_adf(md).unwrap();
7159        let restored = adf_to_markdown(&adf).unwrap();
7160
7161        assert!(restored.contains("# Hello"));
7162        assert!(restored.contains("**bold**"));
7163        assert!(restored.contains("*italic*"));
7164        assert!(restored.contains("- Item 1"));
7165        assert!(restored.contains("- Item 2"));
7166    }
7167
7168    #[test]
7169    fn round_trip_code_block() {
7170        let md = "```python\nprint('hello')\n```\n";
7171        let adf = markdown_to_adf(md).unwrap();
7172        let restored = adf_to_markdown(&adf).unwrap();
7173
7174        assert!(restored.contains("```python"));
7175        assert!(restored.contains("print('hello')"));
7176    }
7177
7178    #[test]
7179    fn round_trip_code_block_no_attrs() {
7180        let adf_json = r#"{"version":1,"type":"doc","content":[
7181            {"type":"codeBlock","content":[{"type":"text","text":"plain code"}]}
7182        ]}"#;
7183        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7184        assert!(doc.content[0].attrs.is_none());
7185        let md = adf_to_markdown(&doc).unwrap();
7186        let round_tripped = markdown_to_adf(&md).unwrap();
7187        assert!(round_tripped.content[0].attrs.is_none());
7188    }
7189
7190    #[test]
7191    fn round_trip_code_block_empty_language() {
7192        let adf_json = r#"{"version":1,"type":"doc","content":[
7193            {"type":"codeBlock","attrs":{"language":""},"content":[{"type":"text","text":"simple code block no backtick"}]}
7194        ]}"#;
7195        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7196        let attrs = doc.content[0].attrs.as_ref().unwrap();
7197        assert_eq!(attrs["language"], "");
7198        let md = adf_to_markdown(&doc).unwrap();
7199        let round_tripped = markdown_to_adf(&md).unwrap();
7200        let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
7201        assert_eq!(rt_attrs["language"], "");
7202    }
7203
7204    #[test]
7205    fn round_trip_code_block_with_language() {
7206        let adf_json = r#"{"version":1,"type":"doc","content":[
7207            {"type":"codeBlock","attrs":{"language":"python"},"content":[{"type":"text","text":"print('hi')"}]}
7208        ]}"#;
7209        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7210        let md = adf_to_markdown(&doc).unwrap();
7211        let round_tripped = markdown_to_adf(&md).unwrap();
7212        let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
7213        assert_eq!(rt_attrs["language"], "python");
7214    }
7215
7216    #[test]
7217    fn multiple_paragraphs() {
7218        let md = "First paragraph.\n\nSecond paragraph.\n";
7219        let adf = markdown_to_adf(md).unwrap();
7220        assert_eq!(adf.content.len(), 2);
7221        assert_eq!(adf.content[0].node_type, "paragraph");
7222        assert_eq!(adf.content[1].node_type, "paragraph");
7223    }
7224
7225    // ── Additional markdown_to_adf tests ───────────────────────────────
7226
7227    #[test]
7228    fn horizontal_rule_underscores() {
7229        let doc = markdown_to_adf("___").unwrap();
7230        assert_eq!(doc.content[0].node_type, "rule");
7231    }
7232
7233    #[test]
7234    fn not_a_horizontal_rule_too_short() {
7235        let doc = markdown_to_adf("--").unwrap();
7236        assert_eq!(doc.content[0].node_type, "paragraph");
7237    }
7238
7239    #[test]
7240    fn bullet_list_star_marker() {
7241        let md = "* Apple\n* Banana";
7242        let doc = markdown_to_adf(md).unwrap();
7243        assert_eq!(doc.content[0].node_type, "bulletList");
7244        let items = doc.content[0].content.as_ref().unwrap();
7245        assert_eq!(items.len(), 2);
7246    }
7247
7248    #[test]
7249    fn bullet_list_plus_marker() {
7250        let md = "+ One\n+ Two";
7251        let doc = markdown_to_adf(md).unwrap();
7252        assert_eq!(doc.content[0].node_type, "bulletList");
7253    }
7254
7255    #[test]
7256    fn ordered_list_non_one_start() {
7257        let md = "5. Fifth\n6. Sixth";
7258        let doc = markdown_to_adf(md).unwrap();
7259        let node = &doc.content[0];
7260        assert_eq!(node.node_type, "orderedList");
7261        let attrs = node.attrs.as_ref().unwrap();
7262        assert_eq!(attrs["order"], 5);
7263    }
7264
7265    #[test]
7266    fn ordered_list_start_at_one_omits_order_attr() {
7267        // Issue #547: order=1 is the default and must be omitted from attrs
7268        // so that ADF→JFM→ADF round-trip is byte-identical for the common
7269        // case where the source ADF has no attrs object on orderedList.
7270        let md = "1. First\n2. Second";
7271        let doc = markdown_to_adf(md).unwrap();
7272        let node = &doc.content[0];
7273        assert_eq!(node.node_type, "orderedList");
7274        assert!(
7275            node.attrs.is_none(),
7276            "attrs should be omitted when order=1, got: {:?}",
7277            node.attrs
7278        );
7279    }
7280
7281    #[test]
7282    fn blockquote_bare_marker() {
7283        // ">" with no space after
7284        let md = ">quoted text";
7285        let doc = markdown_to_adf(md).unwrap();
7286        assert_eq!(doc.content[0].node_type, "blockquote");
7287    }
7288
7289    #[test]
7290    fn image_no_alt() {
7291        let md = "![](https://example.com/img.png)";
7292        let doc = markdown_to_adf(md).unwrap();
7293        let node = &doc.content[0];
7294        assert_eq!(node.node_type, "mediaSingle");
7295        // media child should have no alt attr
7296        let media = &node.content.as_ref().unwrap()[0];
7297        let attrs = media.attrs.as_ref().unwrap();
7298        assert!(attrs.get("alt").is_none());
7299    }
7300
7301    #[test]
7302    fn image_with_alt() {
7303        let md = "![A photo](https://example.com/photo.jpg)";
7304        let doc = markdown_to_adf(md).unwrap();
7305        let media = &doc.content[0].content.as_ref().unwrap()[0];
7306        let attrs = media.attrs.as_ref().unwrap();
7307        assert_eq!(attrs["alt"], "A photo");
7308    }
7309
7310    #[test]
7311    fn table_multi_body_rows() {
7312        let md = "| H1 | H2 |\n| --- | --- |\n| a | b |\n| c | d |";
7313        let doc = markdown_to_adf(md).unwrap();
7314        let rows = doc.content[0].content.as_ref().unwrap();
7315        assert_eq!(rows.len(), 3); // header + 2 body rows
7316                                   // First row cells are tableHeader
7317        let header_cells = rows[0].content.as_ref().unwrap();
7318        assert_eq!(header_cells[0].node_type, "tableHeader");
7319        // Body row cells are tableCell
7320        let body_cells = rows[1].content.as_ref().unwrap();
7321        assert_eq!(body_cells[0].node_type, "tableCell");
7322    }
7323
7324    #[test]
7325    fn table_no_separator_is_not_table() {
7326        // Pipe characters without a separator row should not parse as table
7327        let md = "| not | a table |";
7328        let doc = markdown_to_adf(md).unwrap();
7329        assert_eq!(doc.content[0].node_type, "paragraph");
7330    }
7331
7332    #[test]
7333    fn inline_underscore_bold() {
7334        let doc = markdown_to_adf("Some __bold__ text").unwrap();
7335        let content = doc.content[0].content.as_ref().unwrap();
7336        let bold_node = &content[1];
7337        assert_eq!(bold_node.text.as_deref(), Some("bold"));
7338        let marks = bold_node.marks.as_ref().unwrap();
7339        assert_eq!(marks[0].mark_type, "strong");
7340    }
7341
7342    #[test]
7343    fn inline_underscore_italic() {
7344        let doc = markdown_to_adf("Some _italic_ text").unwrap();
7345        let content = doc.content[0].content.as_ref().unwrap();
7346        let italic_node = &content[1];
7347        assert_eq!(italic_node.text.as_deref(), Some("italic"));
7348        let marks = italic_node.marks.as_ref().unwrap();
7349        assert_eq!(marks[0].mark_type, "em");
7350    }
7351
7352    #[test]
7353    fn intraword_underscore_not_emphasis() {
7354        // Single intraword underscore pair: do_something_useful
7355        let doc = markdown_to_adf("call do_something_useful now").unwrap();
7356        let content = doc.content[0].content.as_ref().unwrap();
7357        assert_eq!(content.len(), 1, "should be a single text node");
7358        assert_eq!(
7359            content[0].text.as_deref(),
7360            Some("call do_something_useful now")
7361        );
7362        assert!(content[0].marks.is_none());
7363    }
7364
7365    #[test]
7366    fn intraword_underscore_multiple() {
7367        // Multiple intraword underscores: a_b_c_d
7368        let doc = markdown_to_adf("use a_b_c_d here").unwrap();
7369        let content = doc.content[0].content.as_ref().unwrap();
7370        assert_eq!(content.len(), 1);
7371        assert_eq!(content[0].text.as_deref(), Some("use a_b_c_d here"));
7372        assert!(content[0].marks.is_none());
7373    }
7374
7375    #[test]
7376    fn intraword_double_underscore_not_bold() {
7377        // Intraword double underscores: foo__bar__baz
7378        let doc = markdown_to_adf("foo__bar__baz").unwrap();
7379        let content = doc.content[0].content.as_ref().unwrap();
7380        assert_eq!(content.len(), 1);
7381        assert_eq!(content[0].text.as_deref(), Some("foo__bar__baz"));
7382        assert!(content[0].marks.is_none());
7383    }
7384
7385    #[test]
7386    fn intraword_triple_underscore_not_bold_italic() {
7387        // Intraword triple underscores: x___y___z
7388        let doc = markdown_to_adf("x___y___z").unwrap();
7389        let content = doc.content[0].content.as_ref().unwrap();
7390        assert_eq!(content.len(), 1);
7391        assert_eq!(content[0].text.as_deref(), Some("x___y___z"));
7392        assert!(content[0].marks.is_none());
7393    }
7394
7395    #[test]
7396    fn underscore_emphasis_still_works_with_spaces() {
7397        // Normal emphasis with spaces around delimiters should still work
7398        let doc = markdown_to_adf("some _italic_ here").unwrap();
7399        let content = doc.content[0].content.as_ref().unwrap();
7400        assert_eq!(content.len(), 3);
7401        assert_eq!(content[1].text.as_deref(), Some("italic"));
7402        let marks = content[1].marks.as_ref().unwrap();
7403        assert_eq!(marks[0].mark_type, "em");
7404    }
7405
7406    #[test]
7407    fn underscore_bold_still_works_with_spaces() {
7408        // Normal bold with spaces around delimiters should still work
7409        let doc = markdown_to_adf("some __bold__ here").unwrap();
7410        let content = doc.content[0].content.as_ref().unwrap();
7411        assert_eq!(content.len(), 3);
7412        assert_eq!(content[1].text.as_deref(), Some("bold"));
7413        let marks = content[1].marks.as_ref().unwrap();
7414        assert_eq!(marks[0].mark_type, "strong");
7415    }
7416
7417    #[test]
7418    fn intraword_underscore_closing_only() {
7419        // Opening _ is valid (preceded by space) but closing _ is intraword: _foo_bar
7420        let doc = markdown_to_adf("_foo_bar").unwrap();
7421        let content = doc.content[0].content.as_ref().unwrap();
7422        assert_eq!(content.len(), 1);
7423        assert_eq!(content[0].text.as_deref(), Some("_foo_bar"));
7424    }
7425
7426    #[test]
7427    fn intraword_double_underscore_closing_only() {
7428        // Opening __ is valid (at start) but closing __ is intraword: __foo__bar
7429        let doc = markdown_to_adf("__foo__bar").unwrap();
7430        let content = doc.content[0].content.as_ref().unwrap();
7431        assert_eq!(content.len(), 1);
7432        assert_eq!(content[0].text.as_deref(), Some("__foo__bar"));
7433    }
7434
7435    #[test]
7436    fn intraword_triple_underscore_closing_only() {
7437        // Opening ___ is valid (at start) but closing ___ is intraword: ___foo___bar
7438        let doc = markdown_to_adf("___foo___bar").unwrap();
7439        let content = doc.content[0].content.as_ref().unwrap();
7440        assert_eq!(content.len(), 1);
7441        assert_eq!(content[0].text.as_deref(), Some("___foo___bar"));
7442    }
7443
7444    #[test]
7445    fn asterisk_emphasis_unaffected_by_intraword_fix() {
7446        // Asterisks should still work for intraword emphasis (CommonMark allows this)
7447        let doc = markdown_to_adf("foo*bar*baz").unwrap();
7448        let content = doc.content[0].content.as_ref().unwrap();
7449        // Asterisks CAN be intraword emphasis per CommonMark
7450        assert!(content.len() > 1 || content[0].marks.is_some());
7451    }
7452
7453    #[test]
7454    fn intraword_underscore_at_start_of_text() {
7455        // Underscore at start of text is not intraword (no preceding alphanumeric)
7456        let doc = markdown_to_adf("_italic_ word").unwrap();
7457        let content = doc.content[0].content.as_ref().unwrap();
7458        assert_eq!(content[0].text.as_deref(), Some("italic"));
7459        let marks = content[0].marks.as_ref().unwrap();
7460        assert_eq!(marks[0].mark_type, "em");
7461    }
7462
7463    #[test]
7464    fn intraword_underscore_at_end_of_text() {
7465        // Underscore at end of text is not intraword (no following alphanumeric)
7466        let doc = markdown_to_adf("word _italic_").unwrap();
7467        let content = doc.content[0].content.as_ref().unwrap();
7468        let last = content.last().unwrap();
7469        assert_eq!(last.text.as_deref(), Some("italic"));
7470        let marks = last.marks.as_ref().unwrap();
7471        assert_eq!(marks[0].mark_type, "em");
7472    }
7473
7474    #[test]
7475    fn intraword_underscore_opening_only() {
7476        // Opening underscore is intraword but closing is not: a_b c_d
7477        // The first _ is intraword (a_b), so it shouldn't open emphasis
7478        let doc = markdown_to_adf("a_b c_d").unwrap();
7479        let content = doc.content[0].content.as_ref().unwrap();
7480        assert_eq!(content.len(), 1);
7481        assert_eq!(content[0].text.as_deref(), Some("a_b c_d"));
7482    }
7483
7484    #[test]
7485    fn intraword_underscore_roundtrip() {
7486        // The original reproducer from issue #438
7487        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call the do_something_useful function"}]}]}"#;
7488        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7489        let jfm = adf_to_markdown(&adf).unwrap();
7490        let roundtripped = markdown_to_adf(&jfm).unwrap();
7491        let content = roundtripped.content[0].content.as_ref().unwrap();
7492        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7493        assert_eq!(
7494            content[0].text.as_deref(),
7495            Some("call the do_something_useful function")
7496        );
7497        assert!(content[0].marks.is_none());
7498    }
7499
7500    #[test]
7501    fn asterisk_emphasis_roundtrip() {
7502        // The original reproducer from issue #452
7503        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status: *confirmed* and active"}]}]}"#;
7504        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7505        let jfm = adf_to_markdown(&adf).unwrap();
7506        let roundtripped = markdown_to_adf(&jfm).unwrap();
7507        let content = roundtripped.content[0].content.as_ref().unwrap();
7508        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7509        assert_eq!(
7510            content[0].text.as_deref(),
7511            Some("Status: *confirmed* and active")
7512        );
7513        assert!(content[0].marks.is_none());
7514    }
7515
7516    #[test]
7517    fn double_asterisk_roundtrip() {
7518        // **bold** delimiters in plain text should not become strong marks
7519        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use **kwargs in Python"}]}]}"#;
7520        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7521        let jfm = adf_to_markdown(&adf).unwrap();
7522        let roundtripped = markdown_to_adf(&jfm).unwrap();
7523        let content = roundtripped.content[0].content.as_ref().unwrap();
7524        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7525        assert_eq!(content[0].text.as_deref(), Some("Use **kwargs in Python"));
7526        assert!(content[0].marks.is_none());
7527    }
7528
7529    #[test]
7530    fn asterisk_with_em_mark_roundtrip() {
7531        // Text that already has an em mark should preserve both the mark and escaped content
7532        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a*b","marks":[{"type":"em"}]}]}]}"#;
7533        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7534        let jfm = adf_to_markdown(&adf).unwrap();
7535        let roundtripped = markdown_to_adf(&jfm).unwrap();
7536        let content = roundtripped.content[0].content.as_ref().unwrap();
7537        // Find the node with em mark
7538        let em_node = content.iter().find(|n| {
7539            n.marks
7540                .as_ref()
7541                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7542        });
7543        assert!(em_node.is_some(), "should have an em-marked node");
7544        assert_eq!(em_node.unwrap().text.as_deref(), Some("a*b"));
7545    }
7546
7547    #[test]
7548    fn lone_asterisk_roundtrip() {
7549        // A single asterisk that cannot form emphasis should round-trip
7550        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"rating: 5 * stars"}]}]}"#;
7551        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7552        let jfm = adf_to_markdown(&adf).unwrap();
7553        let roundtripped = markdown_to_adf(&jfm).unwrap();
7554        let content = roundtripped.content[0].content.as_ref().unwrap();
7555        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7556        assert_eq!(content[0].text.as_deref(), Some("rating: 5 * stars"));
7557    }
7558
7559    #[test]
7560    fn escape_emphasis_markers_unit() {
7561        assert_eq!(escape_emphasis_markers("hello"), "hello");
7562        assert_eq!(escape_emphasis_markers("*bold*"), r"\*bold\*");
7563        assert_eq!(escape_emphasis_markers("**strong**"), r"\*\*strong\*\*");
7564        assert_eq!(escape_emphasis_markers("no stars"), "no stars");
7565        assert_eq!(escape_emphasis_markers("a * b"), r"a \* b");
7566        assert_eq!(escape_emphasis_markers(""), "");
7567    }
7568
7569    #[test]
7570    fn escape_emphasis_markers_underscore_intraword() {
7571        // Intraword underscores (alnum on both sides within the node) are
7572        // left as-is — the JFM parser will reject them as emphasis.
7573        assert_eq!(escape_emphasis_markers("foo_bar"), "foo_bar");
7574        assert_eq!(escape_emphasis_markers("a_b_c"), "a_b_c");
7575        assert_eq!(escape_emphasis_markers("foo__bar"), "foo__bar");
7576        assert_eq!(
7577            escape_emphasis_markers("call do_something_useful"),
7578            "call do_something_useful"
7579        );
7580    }
7581
7582    #[test]
7583    fn escape_emphasis_markers_underscore_at_boundary() {
7584        // Leading and trailing underscores get escaped — adjacent text nodes
7585        // could supply the alphanumeric needed to close emphasis (issue #554).
7586        assert_eq!(escape_emphasis_markers("_Action"), r"\_Action");
7587        assert_eq!(escape_emphasis_markers("Action_"), r"Action\_");
7588        assert_eq!(escape_emphasis_markers("_ "), r"\_ ");
7589        assert_eq!(escape_emphasis_markers(" _"), r" \_");
7590        assert_eq!(escape_emphasis_markers("_"), r"\_");
7591    }
7592
7593    #[test]
7594    fn escape_emphasis_markers_underscore_with_punctuation() {
7595        // Underscores adjacent to punctuation (not alphanumeric) get escaped.
7596        assert_eq!(escape_emphasis_markers("foo _bar"), r"foo \_bar");
7597        assert_eq!(escape_emphasis_markers("foo_ bar"), r"foo\_ bar");
7598        assert_eq!(escape_emphasis_markers("(_x_)"), r"(\_x\_)");
7599    }
7600
7601    #[test]
7602    fn find_unescaped_skips_backslash_escaped() {
7603        // Escaped `**` should not be found
7604        assert_eq!(find_unescaped(r"a\*\*b**", "**"), Some(6));
7605        // No unescaped match at all
7606        assert_eq!(find_unescaped(r"a\*\*b", "**"), None);
7607        // Plain match without any escaping
7608        assert_eq!(find_unescaped("a**b", "**"), Some(1));
7609        // Empty haystack
7610        assert_eq!(find_unescaped("", "**"), None);
7611    }
7612
7613    #[test]
7614    fn find_unescaped_char_skips_backslash_escaped() {
7615        // Escaped `*` should not be found
7616        assert_eq!(find_unescaped_char(r"a\*b*", b'*'), Some(4));
7617        // No unescaped match at all
7618        assert_eq!(find_unescaped_char(r"\*", b'*'), None);
7619        // Plain match
7620        assert_eq!(find_unescaped_char("a*b", b'*'), Some(1));
7621        // Empty haystack
7622        assert_eq!(find_unescaped_char("", b'*'), None);
7623    }
7624
7625    #[test]
7626    fn double_asterisk_in_strong_mark_roundtrip() {
7627        // Text with ** inside a strong mark should preserve the literal **
7628        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call **kwargs","marks":[{"type":"strong"}]}]}]}"#;
7629        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7630        let jfm = adf_to_markdown(&adf).unwrap();
7631        let roundtripped = markdown_to_adf(&jfm).unwrap();
7632        let content = roundtripped.content[0].content.as_ref().unwrap();
7633        let strong_node = content.iter().find(|n| {
7634            n.marks
7635                .as_ref()
7636                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7637        });
7638        assert!(strong_node.is_some(), "should have a strong-marked node");
7639        assert_eq!(strong_node.unwrap().text.as_deref(), Some("call **kwargs"));
7640    }
7641
7642    #[test]
7643    fn backtick_code_roundtrip() {
7644        // The original reproducer from issue #453
7645        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Set `max_retries` to 3 in the config"}]}]}"#;
7646        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7647        let jfm = adf_to_markdown(&adf).unwrap();
7648        let roundtripped = markdown_to_adf(&jfm).unwrap();
7649        let content = roundtripped.content[0].content.as_ref().unwrap();
7650        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7651        assert_eq!(
7652            content[0].text.as_deref(),
7653            Some("Set `max_retries` to 3 in the config")
7654        );
7655        assert!(content[0].marks.is_none());
7656    }
7657
7658    #[test]
7659    fn multiple_backtick_spans_roundtrip() {
7660        // Multiple backtick-delimited spans in a single text node
7661        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use `foo` and `bar` together"}]}]}"#;
7662        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7663        let jfm = adf_to_markdown(&adf).unwrap();
7664        let roundtripped = markdown_to_adf(&jfm).unwrap();
7665        let content = roundtripped.content[0].content.as_ref().unwrap();
7666        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7667        assert_eq!(
7668            content[0].text.as_deref(),
7669            Some("Use `foo` and `bar` together")
7670        );
7671        assert!(content[0].marks.is_none());
7672    }
7673
7674    #[test]
7675    fn lone_backtick_roundtrip() {
7676        // A single backtick that cannot form a code span should round-trip
7677        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use a ` character"}]}]}"#;
7678        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7679        let jfm = adf_to_markdown(&adf).unwrap();
7680        let roundtripped = markdown_to_adf(&jfm).unwrap();
7681        let content = roundtripped.content[0].content.as_ref().unwrap();
7682        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7683        assert_eq!(content[0].text.as_deref(), Some("Use a ` character"));
7684        assert!(content[0].marks.is_none());
7685    }
7686
7687    #[test]
7688    fn backtick_with_code_mark_roundtrip() {
7689        // Text that already has a code mark should preserve both the mark and content
7690        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"max_retries","marks":[{"type":"code"}]}]}]}"#;
7691        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7692        let jfm = adf_to_markdown(&adf).unwrap();
7693        assert_eq!(jfm.trim(), "`max_retries`");
7694        let roundtripped = markdown_to_adf(&jfm).unwrap();
7695        let content = roundtripped.content[0].content.as_ref().unwrap();
7696        let code_node = content.iter().find(|n| {
7697            n.marks
7698                .as_ref()
7699                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7700        });
7701        assert!(code_node.is_some(), "should have a code-marked node");
7702        assert_eq!(code_node.unwrap().text.as_deref(), Some("max_retries"));
7703    }
7704
7705    #[test]
7706    fn backtick_with_em_mark_roundtrip() {
7707        // Backtick inside em-marked text should preserve both
7708        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"use `cfg`","marks":[{"type":"em"}]}]}]}"#;
7709        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7710        let jfm = adf_to_markdown(&adf).unwrap();
7711        let roundtripped = markdown_to_adf(&jfm).unwrap();
7712        let content = roundtripped.content[0].content.as_ref().unwrap();
7713        let em_node = content.iter().find(|n| {
7714            n.marks
7715                .as_ref()
7716                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7717        });
7718        assert!(em_node.is_some(), "should have an em-marked node");
7719        assert_eq!(em_node.unwrap().text.as_deref(), Some("use `cfg`"));
7720    }
7721
7722    #[test]
7723    fn escape_pipes_in_cell_unit() {
7724        assert_eq!(escape_pipes_in_cell("hello"), "hello");
7725        assert_eq!(escape_pipes_in_cell("a|b"), r"a\|b");
7726        assert_eq!(escape_pipes_in_cell("|"), r"\|");
7727        assert_eq!(escape_pipes_in_cell("|a|b|"), r"\|a\|b\|");
7728        assert_eq!(escape_pipes_in_cell(""), "");
7729        assert_eq!(
7730            escape_pipes_in_cell("`parser.decode[T|json]`"),
7731            r"`parser.decode[T\|json]`"
7732        );
7733    }
7734
7735    #[test]
7736    fn escape_backticks_unit() {
7737        assert_eq!(escape_backticks("hello"), "hello");
7738        assert_eq!(escape_backticks("`code`"), r"\`code\`");
7739        assert_eq!(escape_backticks("no ticks"), "no ticks");
7740        assert_eq!(escape_backticks("a ` b"), r"a \` b");
7741        assert_eq!(escape_backticks(""), "");
7742        assert_eq!(escape_backticks("`a` and `b`"), r"\`a\` and \`b\`");
7743    }
7744
7745    // ── Issue #477: backslash escaping ──────────────────────────────
7746
7747    #[test]
7748    fn backslash_in_text_roundtrip() {
7749        // The original reproducer from issue #477
7750        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"The path is C:\\Users\\admin\\file.txt"}]}]}"#;
7751        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7752        let jfm = adf_to_markdown(&adf).unwrap();
7753        let roundtripped = markdown_to_adf(&jfm).unwrap();
7754        let content = roundtripped.content[0].content.as_ref().unwrap();
7755        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7756        assert_eq!(
7757            content[0].text.as_deref(),
7758            Some(r"The path is C:\Users\admin\file.txt")
7759        );
7760    }
7761
7762    #[test]
7763    fn backslash_emitted_as_double_backslash() {
7764        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\b"}]}]}"#;
7765        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7766        let jfm = adf_to_markdown(&adf).unwrap();
7767        assert!(
7768            jfm.contains(r"a\\b"),
7769            "JFM should contain escaped backslash: {jfm}"
7770        );
7771    }
7772
7773    #[test]
7774    fn consecutive_backslashes_roundtrip() {
7775        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\\\b"}]}]}"#;
7776        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7777        let jfm = adf_to_markdown(&adf).unwrap();
7778        let roundtripped = markdown_to_adf(&jfm).unwrap();
7779        let content = roundtripped.content[0].content.as_ref().unwrap();
7780        assert_eq!(
7781            content[0].text.as_deref(),
7782            Some(r"a\\b"),
7783            "consecutive backslashes should survive round-trip"
7784        );
7785    }
7786
7787    #[test]
7788    fn backslash_with_strong_mark_roundtrip() {
7789        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"strong"}]}]}]}"#;
7790        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7791        let jfm = adf_to_markdown(&adf).unwrap();
7792        let roundtripped = markdown_to_adf(&jfm).unwrap();
7793        let content = roundtripped.content[0].content.as_ref().unwrap();
7794        let strong_node = content.iter().find(|n| {
7795            n.marks
7796                .as_ref()
7797                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7798        });
7799        assert!(strong_node.is_some(), "should have a strong-marked node");
7800        assert_eq!(strong_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7801    }
7802
7803    #[test]
7804    fn backslash_with_code_mark_not_escaped() {
7805        // Code marks emit content verbatim — backslashes should NOT be escaped
7806        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"code"}]}]}]}"#;
7807        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7808        let jfm = adf_to_markdown(&adf).unwrap();
7809        assert_eq!(jfm.trim(), r"`C:\Users`");
7810        let roundtripped = markdown_to_adf(&jfm).unwrap();
7811        let content = roundtripped.content[0].content.as_ref().unwrap();
7812        let code_node = content.iter().find(|n| {
7813            n.marks
7814                .as_ref()
7815                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7816        });
7817        assert!(code_node.is_some(), "should have a code-marked node");
7818        assert_eq!(code_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7819    }
7820
7821    #[test]
7822    fn backslash_before_special_chars_roundtrip() {
7823        // Backslash before characters that are themselves escaped (* ` :)
7824        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"\\*not bold\\*"}]}]}"#;
7825        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7826        let jfm = adf_to_markdown(&adf).unwrap();
7827        let roundtripped = markdown_to_adf(&jfm).unwrap();
7828        let content = roundtripped.content[0].content.as_ref().unwrap();
7829        assert_eq!(
7830            content[0].text.as_deref(),
7831            Some(r"\*not bold\*"),
7832            "backslash before special char should survive round-trip"
7833        );
7834    }
7835
7836    #[test]
7837    fn backslash_and_newline_in_text_roundtrip() {
7838        // Text with both backslashes and embedded newlines
7839        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\path\nline2"}]}]}"#;
7840        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7841        let jfm = adf_to_markdown(&adf).unwrap();
7842        let roundtripped = markdown_to_adf(&jfm).unwrap();
7843        let content = roundtripped.content[0].content.as_ref().unwrap();
7844        assert_eq!(
7845            content[0].text.as_deref(),
7846            Some("C:\\path\nline2"),
7847            "backslash and newline should both survive round-trip"
7848        );
7849    }
7850
7851    #[test]
7852    fn lone_backslash_roundtrip() {
7853        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a \\ b"}]}]}"#;
7854        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7855        let jfm = adf_to_markdown(&adf).unwrap();
7856        let roundtripped = markdown_to_adf(&jfm).unwrap();
7857        let content = roundtripped.content[0].content.as_ref().unwrap();
7858        assert_eq!(content[0].text.as_deref(), Some(r"a \ b"));
7859    }
7860
7861    #[test]
7862    fn trailing_backslash_in_text_roundtrip() {
7863        // A trailing backslash in text content (not a hardBreak) should round-trip
7864        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\"}]}]}"#;
7865        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7866        let jfm = adf_to_markdown(&adf).unwrap();
7867        let roundtripped = markdown_to_adf(&jfm).unwrap();
7868        let content = roundtripped.content[0].content.as_ref().unwrap();
7869        assert_eq!(
7870            content[0].text.as_deref(),
7871            Some(r"end\"),
7872            "trailing backslash should survive round-trip"
7873        );
7874    }
7875
7876    #[test]
7877    fn escape_bare_urls_unit() {
7878        assert_eq!(escape_bare_urls("hello"), "hello");
7879        assert_eq!(escape_bare_urls(""), "");
7880        assert_eq!(
7881            escape_bare_urls("https://example.com"),
7882            r"\https://example.com"
7883        );
7884        assert_eq!(
7885            escape_bare_urls("http://example.com"),
7886            r"\http://example.com"
7887        );
7888        assert_eq!(
7889            escape_bare_urls("see https://a.com and https://b.com"),
7890            r"see \https://a.com and \https://b.com"
7891        );
7892        // "http" without "://" is not a URL scheme — leave untouched
7893        assert_eq!(escape_bare_urls("http header"), "http header");
7894        assert_eq!(escape_bare_urls("https is secure"), "https is secure");
7895    }
7896
7897    #[test]
7898    fn heading_not_valid_without_space() {
7899        // "#Title" without space should be a paragraph, not heading
7900        let doc = markdown_to_adf("#Title").unwrap();
7901        assert_eq!(doc.content[0].node_type, "paragraph");
7902    }
7903
7904    #[test]
7905    fn heading_level_too_high() {
7906        // ####### (7 hashes) is not a valid heading
7907        let doc = markdown_to_adf("####### Not a heading").unwrap();
7908        assert_eq!(doc.content[0].node_type, "paragraph");
7909    }
7910
7911    #[test]
7912    fn empty_document() {
7913        let doc = markdown_to_adf("").unwrap();
7914        assert!(doc.content.is_empty());
7915    }
7916
7917    #[test]
7918    fn only_blank_lines() {
7919        let doc = markdown_to_adf("\n\n\n").unwrap();
7920        assert!(doc.content.is_empty());
7921    }
7922
7923    #[test]
7924    fn code_block_unterminated() {
7925        // Code block without closing fence
7926        let md = "```rust\nfn main() {}";
7927        let doc = markdown_to_adf(md).unwrap();
7928        assert_eq!(doc.content[0].node_type, "codeBlock");
7929    }
7930
7931    #[test]
7932    fn mixed_document() {
7933        let md = "# Title\n\nA paragraph.\n\n- Item\n\n```\ncode\n```\n\n> quote\n\n---\n\n1. numbered\n";
7934        let doc = markdown_to_adf(md).unwrap();
7935        let types: Vec<&str> = doc.content.iter().map(|n| n.node_type.as_str()).collect();
7936        assert_eq!(
7937            types,
7938            vec![
7939                "heading",
7940                "paragraph",
7941                "bulletList",
7942                "codeBlock",
7943                "blockquote",
7944                "rule",
7945                "orderedList",
7946            ]
7947        );
7948    }
7949
7950    // ── Additional adf_to_markdown tests ───────────────────────────────
7951
7952    #[test]
7953    fn adf_ordered_list_to_markdown() {
7954        let doc = AdfDocument {
7955            version: 1,
7956            doc_type: "doc".to_string(),
7957            content: vec![AdfNode::ordered_list(
7958                vec![
7959                    AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("First")])]),
7960                    AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("Second")])]),
7961                ],
7962                None,
7963            )],
7964        };
7965        let md = adf_to_markdown(&doc).unwrap();
7966        assert!(md.contains("1. First"));
7967        assert!(md.contains("2. Second"));
7968    }
7969
7970    #[test]
7971    fn adf_ordered_list_custom_start() {
7972        let doc = AdfDocument {
7973            version: 1,
7974            doc_type: "doc".to_string(),
7975            content: vec![AdfNode::ordered_list(
7976                vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
7977                    AdfNode::text("Third"),
7978                ])])],
7979                Some(3),
7980            )],
7981        };
7982        let md = adf_to_markdown(&doc).unwrap();
7983        assert!(md.contains("3. Third"));
7984    }
7985
7986    #[test]
7987    fn adf_blockquote_to_markdown() {
7988        let doc = AdfDocument {
7989            version: 1,
7990            doc_type: "doc".to_string(),
7991            content: vec![AdfNode::blockquote(vec![AdfNode::paragraph(vec![
7992                AdfNode::text("A quote"),
7993            ])])],
7994        };
7995        let md = adf_to_markdown(&doc).unwrap();
7996        assert!(md.contains("> A quote"));
7997    }
7998
7999    #[test]
8000    fn adf_table_to_markdown() {
8001        let doc = AdfDocument {
8002            version: 1,
8003            doc_type: "doc".to_string(),
8004            content: vec![AdfNode::table(vec![
8005                AdfNode::table_row(vec![
8006                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Name")])]),
8007                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Value")])]),
8008                ]),
8009                AdfNode::table_row(vec![
8010                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a")])]),
8011                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("1")])]),
8012                ]),
8013            ])],
8014        };
8015        let md = adf_to_markdown(&doc).unwrap();
8016        assert!(md.contains("| Name | Value |"));
8017        assert!(md.contains("| --- | --- |"));
8018        assert!(md.contains("| a | 1 |"));
8019    }
8020
8021    #[test]
8022    fn adf_media_to_markdown() {
8023        let doc = AdfDocument {
8024            version: 1,
8025            doc_type: "doc".to_string(),
8026            content: vec![AdfNode::media_single(
8027                "https://example.com/img.png",
8028                Some("Alt"),
8029            )],
8030        };
8031        let md = adf_to_markdown(&doc).unwrap();
8032        assert!(md.contains("![Alt](https://example.com/img.png)"));
8033    }
8034
8035    #[test]
8036    fn adf_media_no_alt_to_markdown() {
8037        let doc = AdfDocument {
8038            version: 1,
8039            doc_type: "doc".to_string(),
8040            content: vec![AdfNode::media_single("https://example.com/img.png", None)],
8041        };
8042        let md = adf_to_markdown(&doc).unwrap();
8043        assert!(md.contains("![](https://example.com/img.png)"));
8044    }
8045
8046    #[test]
8047    fn adf_italic_to_markdown() {
8048        let doc = AdfDocument {
8049            version: 1,
8050            doc_type: "doc".to_string(),
8051            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8052                "emphasis",
8053                vec![AdfMark::em()],
8054            )])],
8055        };
8056        let md = adf_to_markdown(&doc).unwrap();
8057        assert_eq!(md.trim(), "*emphasis*");
8058    }
8059
8060    #[test]
8061    fn adf_strikethrough_to_markdown() {
8062        let doc = AdfDocument {
8063            version: 1,
8064            doc_type: "doc".to_string(),
8065            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8066                "deleted",
8067                vec![AdfMark::strike()],
8068            )])],
8069        };
8070        let md = adf_to_markdown(&doc).unwrap();
8071        assert_eq!(md.trim(), "~~deleted~~");
8072    }
8073
8074    #[test]
8075    fn adf_inline_code_to_markdown() {
8076        let doc = AdfDocument {
8077            version: 1,
8078            doc_type: "doc".to_string(),
8079            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8080                "code",
8081                vec![AdfMark::code()],
8082            )])],
8083        };
8084        let md = adf_to_markdown(&doc).unwrap();
8085        assert_eq!(md.trim(), "`code`");
8086    }
8087
8088    #[test]
8089    fn adf_code_with_link_to_markdown() {
8090        let doc = AdfDocument {
8091            version: 1,
8092            doc_type: "doc".to_string(),
8093            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8094                "func",
8095                vec![AdfMark::code(), AdfMark::link("https://example.com")],
8096            )])],
8097        };
8098        let md = adf_to_markdown(&doc).unwrap();
8099        assert_eq!(md.trim(), "[`func`](https://example.com)");
8100    }
8101
8102    #[test]
8103    fn adf_bold_italic_to_markdown() {
8104        let doc = AdfDocument {
8105            version: 1,
8106            doc_type: "doc".to_string(),
8107            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8108                "both",
8109                vec![AdfMark::strong(), AdfMark::em()],
8110            )])],
8111        };
8112        let md = adf_to_markdown(&doc).unwrap();
8113        assert_eq!(md.trim(), "***both***");
8114    }
8115
8116    #[test]
8117    fn adf_bold_link_to_markdown() {
8118        let doc = AdfDocument {
8119            version: 1,
8120            doc_type: "doc".to_string(),
8121            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8122                "bold link",
8123                vec![AdfMark::strong(), AdfMark::link("https://example.com")],
8124            )])],
8125        };
8126        let md = adf_to_markdown(&doc).unwrap();
8127        assert_eq!(md.trim(), "**[bold link](https://example.com)**");
8128    }
8129
8130    #[test]
8131    fn adf_strikethrough_bold_to_markdown() {
8132        let doc = AdfDocument {
8133            version: 1,
8134            doc_type: "doc".to_string(),
8135            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8136                "struck",
8137                vec![AdfMark::strike(), AdfMark::strong()],
8138            )])],
8139        };
8140        let md = adf_to_markdown(&doc).unwrap();
8141        assert_eq!(md.trim(), "~~**struck**~~");
8142    }
8143
8144    #[test]
8145    fn adf_hard_break_to_markdown() {
8146        let doc = AdfDocument {
8147            version: 1,
8148            doc_type: "doc".to_string(),
8149            content: vec![AdfNode::paragraph(vec![
8150                AdfNode::text("Line 1"),
8151                AdfNode::hard_break(),
8152                AdfNode::text("Line 2"),
8153            ])],
8154        };
8155        let md = adf_to_markdown(&doc).unwrap();
8156        assert!(md.contains("Line 1\\\n  Line 2"));
8157    }
8158
8159    #[test]
8160    fn adf_unsupported_inline_to_markdown() {
8161        let doc = AdfDocument {
8162            version: 1,
8163            doc_type: "doc".to_string(),
8164            content: vec![AdfNode::paragraph(vec![AdfNode {
8165                node_type: "unknownInline".to_string(),
8166                attrs: Some(serde_json::json!({"key": "value"})),
8167                content: None,
8168                text: None,
8169                marks: None,
8170                local_id: None,
8171                parameters: None,
8172            }])],
8173        };
8174        let md = adf_to_markdown(&doc).unwrap();
8175        // Unsupported inline nodes now round-trip via an `:adf-unsupported`
8176        // directive carrying the full node JSON (issue #1117), replacing the
8177        // old lossy `<!-- unsupported inline -->` comment.
8178        assert!(md.contains(":adf-unsupported[]{json="));
8179        assert!(md.contains("unknownInline"));
8180        assert!(!md.contains("<!-- unsupported inline"));
8181    }
8182
8183    #[test]
8184    fn unsupported_inline_round_trips() {
8185        let original = AdfDocument {
8186            version: 1,
8187            doc_type: "doc".to_string(),
8188            content: vec![AdfNode::paragraph(vec![
8189                AdfNode::text("before "),
8190                AdfNode {
8191                    node_type: "unknownInline".to_string(),
8192                    attrs: Some(serde_json::json!({"key": "value", "n": 3})),
8193                    content: None,
8194                    text: Some("fallback".to_string()),
8195                    marks: None,
8196                    local_id: None,
8197                    parameters: None,
8198                },
8199                AdfNode::text(" after"),
8200            ])],
8201        };
8202        let md = adf_to_markdown(&original).unwrap();
8203        let restored = markdown_to_adf(&md).unwrap();
8204        // The paragraph's unknown inline node survives with its attrs and text.
8205        let para = &restored.content[0];
8206        let unknown = para
8207            .content
8208            .as_ref()
8209            .unwrap()
8210            .iter()
8211            .find(|n| n.node_type == "unknownInline")
8212            .expect("unknownInline node preserved");
8213        assert_eq!(unknown.attrs.as_ref().unwrap()["key"], "value");
8214        assert_eq!(unknown.attrs.as_ref().unwrap()["n"], 3);
8215        assert_eq!(unknown.text.as_deref(), Some("fallback"));
8216    }
8217
8218    // ── mediaInline tests (issue #476) ─────────────────────────────────
8219
8220    #[test]
8221    fn adf_media_inline_to_markdown() {
8222        let doc = AdfDocument {
8223            version: 1,
8224            doc_type: "doc".to_string(),
8225            content: vec![AdfNode::paragraph(vec![
8226                AdfNode::text("see "),
8227                AdfNode::media_inline(serde_json::json!({
8228                    "type": "image",
8229                    "id": "abcdef01-2345-6789-abcd-abcdef012345",
8230                    "collection": "contentId-111111",
8231                    "width": 200,
8232                    "height": 100
8233                })),
8234                AdfNode::text(" for details"),
8235            ])],
8236        };
8237        let md = adf_to_markdown(&doc).unwrap();
8238        assert!(md.contains(":media-inline[]{"), "got: {md}");
8239        assert!(md.contains("type=image"));
8240        assert!(md.contains("id=abcdef01-2345-6789-abcd-abcdef012345"));
8241        assert!(md.contains("collection=contentId-111111"));
8242        assert!(md.contains("width=200"));
8243        assert!(md.contains("height=100"));
8244        assert!(!md.contains("<!-- unsupported inline"));
8245    }
8246
8247    #[test]
8248    fn media_inline_round_trip() {
8249        let doc = AdfDocument {
8250            version: 1,
8251            doc_type: "doc".to_string(),
8252            content: vec![AdfNode::paragraph(vec![
8253                AdfNode::text("see "),
8254                AdfNode::media_inline(serde_json::json!({
8255                    "type": "image",
8256                    "id": "abcdef01-2345-6789-abcd-abcdef012345",
8257                    "collection": "contentId-111111",
8258                    "width": 200,
8259                    "height": 100
8260                })),
8261                AdfNode::text(" for details"),
8262            ])],
8263        };
8264        let md = adf_to_markdown(&doc).unwrap();
8265        let rt = markdown_to_adf(&md).unwrap();
8266
8267        let content = rt.content[0].content.as_ref().unwrap();
8268        assert_eq!(content[0].text.as_deref(), Some("see "));
8269        assert_eq!(content[1].node_type, "mediaInline");
8270        let attrs = content[1].attrs.as_ref().unwrap();
8271        assert_eq!(attrs["type"], "image");
8272        assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
8273        assert_eq!(attrs["collection"], "contentId-111111");
8274        assert_eq!(attrs["width"], 200);
8275        assert_eq!(attrs["height"], 100);
8276        assert_eq!(content[2].text.as_deref(), Some(" for details"));
8277    }
8278
8279    #[test]
8280    fn media_inline_external_url_round_trip() {
8281        let doc = AdfDocument {
8282            version: 1,
8283            doc_type: "doc".to_string(),
8284            content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
8285                serde_json::json!({
8286                    "type": "external",
8287                    "url": "https://example.com/image.png",
8288                    "alt": "example",
8289                    "width": 400,
8290                    "height": 300
8291                }),
8292            )])],
8293        };
8294        let md = adf_to_markdown(&doc).unwrap();
8295        let rt = markdown_to_adf(&md).unwrap();
8296
8297        let content = rt.content[0].content.as_ref().unwrap();
8298        assert_eq!(content[0].node_type, "mediaInline");
8299        let attrs = content[0].attrs.as_ref().unwrap();
8300        assert_eq!(attrs["type"], "external");
8301        assert_eq!(attrs["url"], "https://example.com/image.png");
8302        assert_eq!(attrs["alt"], "example");
8303        assert_eq!(attrs["width"], 400);
8304        assert_eq!(attrs["height"], 300);
8305    }
8306
8307    #[test]
8308    fn media_inline_minimal_attrs() {
8309        let doc = AdfDocument {
8310            version: 1,
8311            doc_type: "doc".to_string(),
8312            content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
8313                serde_json::json!({"type": "file", "id": "abc-123"}),
8314            )])],
8315        };
8316        let md = adf_to_markdown(&doc).unwrap();
8317        let rt = markdown_to_adf(&md).unwrap();
8318
8319        let content = rt.content[0].content.as_ref().unwrap();
8320        assert_eq!(content[0].node_type, "mediaInline");
8321        let attrs = content[0].attrs.as_ref().unwrap();
8322        assert_eq!(attrs["type"], "file");
8323        assert_eq!(attrs["id"], "abc-123");
8324    }
8325
8326    #[test]
8327    fn media_inline_from_issue_476_reproducer() {
8328        // Exact reproducer from issue #476
8329        let adf_json: serde_json::Value = serde_json::json!({
8330            "type": "doc",
8331            "version": 1,
8332            "content": [
8333                {
8334                    "type": "paragraph",
8335                    "content": [
8336                        {"type": "text", "text": "see "},
8337                        {
8338                            "type": "mediaInline",
8339                            "attrs": {
8340                                "collection": "contentId-111111",
8341                                "height": 100,
8342                                "id": "abcdef01-2345-6789-abcd-abcdef012345",
8343                                "localId": "aabbccdd-1234-5678-abcd-aabbccdd1234",
8344                                "type": "image",
8345                                "width": 200
8346                            }
8347                        },
8348                        {"type": "text", "text": " for details"}
8349                    ]
8350                }
8351            ]
8352        });
8353        let doc: AdfDocument = serde_json::from_value(adf_json).unwrap();
8354        let md = adf_to_markdown(&doc).unwrap();
8355        assert!(
8356            !md.contains("<!-- unsupported inline"),
8357            "mediaInline should not be unsupported; got: {md}"
8358        );
8359
8360        let rt = markdown_to_adf(&md).unwrap();
8361        let content = rt.content[0].content.as_ref().unwrap();
8362        assert_eq!(content[1].node_type, "mediaInline");
8363        let attrs = content[1].attrs.as_ref().unwrap();
8364        assert_eq!(attrs["type"], "image");
8365        assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
8366        assert_eq!(attrs["collection"], "contentId-111111");
8367        assert_eq!(attrs["width"], 200);
8368        assert_eq!(attrs["height"], 100);
8369        assert_eq!(attrs["localId"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
8370    }
8371
8372    #[test]
8373    fn emoji_shortcode() {
8374        let doc = markdown_to_adf("Hello :wave: world").unwrap();
8375        let content = doc.content[0].content.as_ref().unwrap();
8376        assert_eq!(content[0].text.as_deref(), Some("Hello "));
8377        assert_eq!(content[1].node_type, "emoji");
8378        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":wave:");
8379        assert_eq!(content[2].text.as_deref(), Some(" world"));
8380    }
8381
8382    #[test]
8383    fn adf_emoji_to_markdown() {
8384        let doc = AdfDocument {
8385            version: 1,
8386            doc_type: "doc".to_string(),
8387            content: vec![AdfNode::paragraph(vec![AdfNode::emoji("thumbsup")])],
8388        };
8389        let md = adf_to_markdown(&doc).unwrap();
8390        assert!(md.contains(":thumbsup:"));
8391    }
8392
8393    #[test]
8394    fn adf_emoji_with_colon_prefix_to_markdown() {
8395        // JIRA stores shortName as ":thumbsup:" with colons
8396        let doc = AdfDocument {
8397            version: 1,
8398            doc_type: "doc".to_string(),
8399            content: vec![AdfNode::paragraph(vec![AdfNode {
8400                node_type: "emoji".to_string(),
8401                attrs: Some(serde_json::json!({"shortName": ":thumbsup:"})),
8402                content: None,
8403                text: None,
8404                marks: None,
8405                local_id: None,
8406                parameters: None,
8407            }])],
8408        };
8409        let md = adf_to_markdown(&doc).unwrap();
8410        assert!(md.contains(":thumbsup:"));
8411        // Should not produce ::thumbsup:: (double colons)
8412        assert!(!md.contains("::thumbsup::"));
8413    }
8414
8415    #[test]
8416    fn round_trip_emoji() {
8417        let md = "Hello :wave: world\n";
8418        let doc = markdown_to_adf(md).unwrap();
8419        let result = adf_to_markdown(&doc).unwrap();
8420        assert!(result.contains(":wave:"));
8421    }
8422
8423    #[test]
8424    fn emoji_with_id_and_text_round_trips() {
8425        let doc = AdfDocument {
8426            version: 1,
8427            doc_type: "doc".to_string(),
8428            content: vec![AdfNode::paragraph(vec![AdfNode {
8429                node_type: "emoji".to_string(),
8430                attrs: Some(
8431                    serde_json::json!({"shortName": ":check_mark:", "id": "2705", "text": "✅"}),
8432                ),
8433                content: None,
8434                text: None,
8435                marks: None,
8436                local_id: None,
8437                parameters: None,
8438            }])],
8439        };
8440        let md = adf_to_markdown(&doc).unwrap();
8441        assert!(md.contains(":check_mark:"), "shortcode present: {md}");
8442        assert!(md.contains("id="), "id attr present: {md}");
8443        assert!(md.contains("text="), "text attr present: {md}");
8444
8445        // Round-trip back to ADF
8446        let round_tripped = markdown_to_adf(&md).unwrap();
8447        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8448        let attrs = emoji.attrs.as_ref().unwrap();
8449        assert_eq!(attrs["shortName"], ":check_mark:");
8450        assert_eq!(attrs["id"], "2705");
8451        assert_eq!(attrs["text"], "✅");
8452    }
8453
8454    #[test]
8455    fn emoji_without_extra_attrs_still_works() {
8456        let md = "Hello :wave: world\n";
8457        let doc = markdown_to_adf(md).unwrap();
8458        let emoji = &doc.content[0].content.as_ref().unwrap()[1];
8459        assert_eq!(emoji.attrs.as_ref().unwrap()["shortName"], ":wave:");
8460        // No id or text attrs when not provided
8461        assert!(emoji.attrs.as_ref().unwrap().get("id").is_none());
8462    }
8463
8464    #[test]
8465    fn emoji_shortname_preserves_colons_round_trip() {
8466        // Issue #362: emoji shortName colons stripped during round-trip
8467        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8468          {"type":"emoji","attrs":{"shortName":":cross_mark:","id":"atlassian-cross_mark","text":"❌"}}
8469        ]}]}"#;
8470        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8471
8472        // ADF → markdown → ADF round-trip
8473        let md = adf_to_markdown(&doc).unwrap();
8474        let round_tripped = markdown_to_adf(&md).unwrap();
8475        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8476        let attrs = emoji.attrs.as_ref().unwrap();
8477        assert_eq!(
8478            attrs["shortName"], ":cross_mark:",
8479            "shortName should preserve colons, got: {}",
8480            attrs["shortName"]
8481        );
8482        assert_eq!(attrs["id"], "atlassian-cross_mark");
8483        assert_eq!(attrs["text"], "❌");
8484    }
8485
8486    #[test]
8487    fn emoji_shortname_without_colons_preserved() {
8488        // Issue #379: shortName without colons should not gain colons
8489        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8490          {"type":"emoji","attrs":{"shortName":"white_check_mark","id":"2705","text":"✅"}}
8491        ]}]}"#;
8492        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8493        let md = adf_to_markdown(&doc).unwrap();
8494        let round_tripped = markdown_to_adf(&md).unwrap();
8495        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8496        let attrs = emoji.attrs.as_ref().unwrap();
8497        assert_eq!(
8498            attrs["shortName"], "white_check_mark",
8499            "shortName without colons should stay without colons, got: {}",
8500            attrs["shortName"]
8501        );
8502    }
8503
8504    #[test]
8505    fn colon_in_text_not_emoji() {
8506        // A lone colon should not trigger emoji parsing
8507        let doc = markdown_to_adf("Time is 10:30 today").unwrap();
8508        let content = doc.content[0].content.as_ref().unwrap();
8509        assert_eq!(content.len(), 1);
8510        assert_eq!(content[0].node_type, "text");
8511    }
8512
8513    #[test]
8514    fn text_with_shortcode_pattern_round_trips_as_text() {
8515        // Issue #450: `:fire:` in a text node must not become an emoji node
8516        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Alert :fire: triggered on pod:pod42"}]}]}"#;
8517        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8518
8519        let md = adf_to_markdown(&doc).unwrap();
8520        let round_tripped = markdown_to_adf(&md).unwrap();
8521        let content = round_tripped.content[0].content.as_ref().unwrap();
8522
8523        assert_eq!(
8524            content.len(),
8525            1,
8526            "should be a single text node, got: {content:?}"
8527        );
8528        assert_eq!(content[0].node_type, "text");
8529        assert_eq!(
8530            content[0].text.as_deref().unwrap(),
8531            "Alert :fire: triggered on pod:pod42"
8532        );
8533    }
8534
8535    #[test]
8536    fn double_colon_pattern_round_trips_as_text() {
8537        // Issue #450: `::Active::` should not be parsed as emoji `:Active:`
8538        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status::Active::Running"}]}]}"#;
8539        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8540
8541        let md = adf_to_markdown(&doc).unwrap();
8542        let round_tripped = markdown_to_adf(&md).unwrap();
8543        let content = round_tripped.content[0].content.as_ref().unwrap();
8544
8545        assert_eq!(
8546            content.len(),
8547            1,
8548            "should be a single text node, got: {content:?}"
8549        );
8550        assert_eq!(content[0].node_type, "text");
8551        assert_eq!(
8552            content[0].text.as_deref().unwrap(),
8553            "Status::Active::Running"
8554        );
8555    }
8556
8557    #[test]
8558    fn real_emoji_node_still_round_trips() {
8559        // Ensure actual emoji ADF nodes still work after the escaping fix
8560        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8561          {"type":"text","text":"Hello "},
8562          {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8563          {"type":"text","text":" world"}
8564        ]}]}"#;
8565        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8566
8567        let md = adf_to_markdown(&doc).unwrap();
8568        let round_tripped = markdown_to_adf(&md).unwrap();
8569        let content = round_tripped.content[0].content.as_ref().unwrap();
8570
8571        // Should have: text("Hello ") + emoji(:fire:) + text(" world")
8572        assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8573        assert_eq!(content[0].text.as_deref(), Some("Hello "));
8574        assert_eq!(content[1].node_type, "emoji");
8575        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":fire:");
8576        assert_eq!(content[2].text.as_deref(), Some(" world"));
8577    }
8578
8579    #[test]
8580    fn combined_emoji_shortname_round_trips_as_single_node() {
8581        // Issue #576: an emoji node whose shortName is a combination of two
8582        // shortcodes (e.g. ":slightly_smiling_face::bow:") must survive the
8583        // ADF → markdown → ADF round-trip as a single emoji node rather than
8584        // being split into two.
8585        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8586          {"type":"text","text":"Thanks for the help "},
8587          {"type":"emoji","attrs":{"shortName":":slightly_smiling_face::bow:","id":"","text":""}}
8588        ]}]}"#;
8589        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8590
8591        let md = adf_to_markdown(&doc).unwrap();
8592        let round_tripped = markdown_to_adf(&md).unwrap();
8593        let content = round_tripped.content[0].content.as_ref().unwrap();
8594
8595        assert_eq!(
8596            content.len(),
8597            2,
8598            "should have text + single combined emoji: {content:?}"
8599        );
8600        assert_eq!(content[0].text.as_deref(), Some("Thanks for the help "));
8601        assert_eq!(content[1].node_type, "emoji");
8602        let attrs = content[1].attrs.as_ref().unwrap();
8603        assert_eq!(attrs["shortName"], ":slightly_smiling_face::bow:");
8604        assert_eq!(attrs["id"], "");
8605        assert_eq!(attrs["text"], "");
8606    }
8607
8608    #[test]
8609    fn triple_combined_emoji_shortname_round_trips_as_single_node() {
8610        // Three-part combined shortName must also survive round-trip.
8611        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8612          {"type":"emoji","attrs":{"shortName":":a::b::c:","id":"x","text":""}}
8613        ]}]}"#;
8614        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8615
8616        let md = adf_to_markdown(&doc).unwrap();
8617        let round_tripped = markdown_to_adf(&md).unwrap();
8618        let content = round_tripped.content[0].content.as_ref().unwrap();
8619
8620        assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8621        assert_eq!(content[0].node_type, "emoji");
8622        let attrs = content[0].attrs.as_ref().unwrap();
8623        assert_eq!(attrs["shortName"], ":a::b::c:");
8624        assert_eq!(attrs["id"], "x");
8625    }
8626
8627    #[test]
8628    fn consecutive_emojis_remain_separate_nodes() {
8629        // Two independent emoji nodes (each with their own directive) must
8630        // remain two separate nodes — the combined-chain logic must not
8631        // swallow the second emoji.
8632        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8633          {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8634          {"type":"emoji","attrs":{"shortName":":water:","id":"1f4a7","text":"💧"}}
8635        ]}]}"#;
8636        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8637
8638        let md = adf_to_markdown(&doc).unwrap();
8639        let round_tripped = markdown_to_adf(&md).unwrap();
8640        let content = round_tripped.content[0].content.as_ref().unwrap();
8641
8642        assert_eq!(content.len(), 2, "should be two emoji nodes: {content:?}");
8643        assert_eq!(content[0].node_type, "emoji");
8644        assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8645        assert_eq!(content[1].node_type, "emoji");
8646        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8647    }
8648
8649    #[test]
8650    fn adjacent_shortcodes_without_directive_parse_as_two_emojis() {
8651        // Raw markdown with two adjacent shortcodes and no directive should
8652        // still parse as two separate emoji nodes.
8653        let md = ":fire::water:";
8654        let doc = markdown_to_adf(md).unwrap();
8655        let content = doc.content[0].content.as_ref().unwrap();
8656
8657        assert_eq!(content.len(), 2, "should be two emojis: {content:?}");
8658        assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8659        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8660    }
8661
8662    #[test]
8663    fn combined_emoji_shortname_preserves_local_id() {
8664        // The directive's localId should be preserved when the chain is
8665        // collapsed into a single emoji node.
8666        let md = r#":a::b:{shortName=":a::b:" id="x" text="y" localId="abc"}"#;
8667        let doc = markdown_to_adf(md).unwrap();
8668        let content = doc.content[0].content.as_ref().unwrap();
8669
8670        assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8671        let attrs = content[0].attrs.as_ref().unwrap();
8672        assert_eq!(attrs["shortName"], ":a::b:");
8673        assert_eq!(attrs["id"], "x");
8674        assert_eq!(attrs["text"], "y");
8675        assert_eq!(attrs["localId"], "abc");
8676    }
8677
8678    #[test]
8679    fn text_shortcode_with_marks_round_trips() {
8680        // Bold text containing an emoji-like shortcode should round-trip as bold text
8681        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8682          {"type":"text","text":"Alert :fire: triggered","marks":[{"type":"strong"}]}
8683        ]}]}"#;
8684        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8685
8686        let md = adf_to_markdown(&doc).unwrap();
8687        let round_tripped = markdown_to_adf(&md).unwrap();
8688        let content = round_tripped.content[0].content.as_ref().unwrap();
8689
8690        assert_eq!(
8691            content.len(),
8692            1,
8693            "should be single bold text node: {content:?}"
8694        );
8695        assert_eq!(content[0].node_type, "text");
8696        assert_eq!(
8697            content[0].text.as_deref().unwrap(),
8698            "Alert :fire: triggered"
8699        );
8700        assert!(content[0]
8701            .marks
8702            .as_ref()
8703            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong")));
8704    }
8705
8706    #[test]
8707    fn mixed_emoji_node_and_text_shortcode_round_trips() {
8708        // Real emoji node adjacent to text containing a shortcode-like pattern
8709        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8710          {"type":"emoji","attrs":{"shortName":":wave:","id":"1f44b","text":"👋"}},
8711          {"type":"text","text":" says :hello: to you"}
8712        ]}]}"#;
8713        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8714
8715        let md = adf_to_markdown(&doc).unwrap();
8716        let round_tripped = markdown_to_adf(&md).unwrap();
8717        let content = round_tripped.content[0].content.as_ref().unwrap();
8718
8719        // Should be: emoji(:wave:) + text(" says :hello: to you")
8720        assert_eq!(content.len(), 2, "should have 2 nodes: {content:?}");
8721        assert_eq!(content[0].node_type, "emoji");
8722        assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":wave:");
8723        assert_eq!(content[1].node_type, "text");
8724        assert_eq!(content[1].text.as_deref().unwrap(), " says :hello: to you");
8725    }
8726
8727    #[test]
8728    fn code_block_with_shortcode_pattern_round_trips() {
8729        // Issue #552: Code block content containing `::Name::` patterns must not
8730        // be re-parsed as emoji shortcodes.
8731        let adf_json = r#"{"version":1,"type":"doc","content":[
8732          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8733            {"type":"text","text":"module Foo::Bar::Baz\n  def hello\n    puts 'world'\n  end\nend"}
8734          ]}
8735        ]}"#;
8736        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8737
8738        let md = adf_to_markdown(&doc).unwrap();
8739        let round_tripped = markdown_to_adf(&md).unwrap();
8740
8741        assert_eq!(
8742            round_tripped.content.len(),
8743            1,
8744            "should be a single codeBlock"
8745        );
8746        let cb = &round_tripped.content[0];
8747        assert_eq!(cb.node_type, "codeBlock");
8748        let content = cb.content.as_ref().expect("codeBlock content");
8749        assert_eq!(
8750            content.len(),
8751            1,
8752            "should be a single text node: {content:?}"
8753        );
8754        assert_eq!(content[0].node_type, "text");
8755        assert_eq!(
8756            content[0].text.as_deref().unwrap(),
8757            "module Foo::Bar::Baz\n  def hello\n    puts 'world'\n  end\nend"
8758        );
8759        assert!(
8760            content.iter().all(|n| n.node_type != "emoji"),
8761            "no emoji nodes should be present, got: {content:?}"
8762        );
8763    }
8764
8765    #[test]
8766    fn code_block_with_exact_namespaced_shortcode_pattern_round_trips() {
8767        // Issue #552: Use a namespaced pattern modeled on the bug report.
8768        let adf_json = r#"{"version":1,"type":"doc","content":[
8769          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8770            {"type":"text","text":"class ZBC::Acme::PlanType::Professional < Base"}
8771          ]}
8772        ]}"#;
8773        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8774
8775        let md = adf_to_markdown(&doc).unwrap();
8776        let round_tripped = markdown_to_adf(&md).unwrap();
8777
8778        let cb = &round_tripped.content[0];
8779        assert_eq!(cb.node_type, "codeBlock");
8780        let content = cb.content.as_ref().expect("codeBlock content");
8781        assert_eq!(content.len(), 1, "should be a single text node");
8782        assert_eq!(
8783            content[0].text.as_deref().unwrap(),
8784            "class ZBC::Acme::PlanType::Professional < Base"
8785        );
8786    }
8787
8788    #[test]
8789    fn code_block_with_literal_shortcode_round_trips() {
8790        // Issue #552: Content that is exactly a shortcode (`:fire:`) inside a
8791        // code block should survive the round-trip as literal text, not emoji.
8792        let adf_json = r#"{"version":1,"type":"doc","content":[
8793          {"type":"codeBlock","attrs":{"language":"text"},"content":[
8794            {"type":"text","text":":fire: :wave: :thumbsup:"}
8795          ]}
8796        ]}"#;
8797        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8798
8799        let md = adf_to_markdown(&doc).unwrap();
8800        let round_tripped = markdown_to_adf(&md).unwrap();
8801
8802        let cb = &round_tripped.content[0];
8803        assert_eq!(cb.node_type, "codeBlock");
8804        let content = cb.content.as_ref().expect("codeBlock content");
8805        assert_eq!(
8806            content.len(),
8807            1,
8808            "should be a single text node: {content:?}"
8809        );
8810        assert_eq!(content[0].node_type, "text");
8811        assert_eq!(
8812            content[0].text.as_deref().unwrap(),
8813            ":fire: :wave: :thumbsup:"
8814        );
8815    }
8816
8817    #[test]
8818    fn inline_code_with_shortcode_pattern_round_trips() {
8819        // Issue #552: Inline code containing `::Name::` patterns must not be
8820        // re-parsed as emoji shortcodes.
8821        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8822          {"type":"text","text":"See "},
8823          {"type":"text","text":"Foo::Bar::Baz","marks":[{"type":"code"}]},
8824          {"type":"text","text":" for details"}
8825        ]}]}"#;
8826        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8827
8828        let md = adf_to_markdown(&doc).unwrap();
8829        let round_tripped = markdown_to_adf(&md).unwrap();
8830        let content = round_tripped.content[0].content.as_ref().unwrap();
8831
8832        assert_eq!(content.len(), 3, "should have 3 text nodes: {content:?}");
8833        assert_eq!(content[0].text.as_deref(), Some("See "));
8834        assert_eq!(content[1].text.as_deref(), Some("Foo::Bar::Baz"));
8835        assert!(content[1]
8836            .marks
8837            .as_ref()
8838            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8839        assert_eq!(content[2].text.as_deref(), Some(" for details"));
8840        assert!(
8841            content.iter().all(|n| n.node_type != "emoji"),
8842            "no emoji nodes should be present"
8843        );
8844    }
8845
8846    #[test]
8847    fn inline_code_with_literal_shortcode_round_trips() {
8848        // Issue #552: Inline code whose content is exactly a shortcode must be
8849        // preserved as code, not converted to an emoji.
8850        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8851          {"type":"text","text":":fire:","marks":[{"type":"code"}]}
8852        ]}]}"#;
8853        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8854
8855        let md = adf_to_markdown(&doc).unwrap();
8856        let round_tripped = markdown_to_adf(&md).unwrap();
8857        let content = round_tripped.content[0].content.as_ref().unwrap();
8858
8859        assert_eq!(
8860            content.len(),
8861            1,
8862            "should be a single code node: {content:?}"
8863        );
8864        assert_eq!(content[0].node_type, "text");
8865        assert_eq!(content[0].text.as_deref(), Some(":fire:"));
8866        assert!(content[0]
8867            .marks
8868            .as_ref()
8869            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8870    }
8871
8872    #[test]
8873    fn code_block_in_list_with_shortcode_pattern_round_trips() {
8874        // Issue #552: A code block containing shortcode-like patterns nested in
8875        // a list should still survive round-trip without emoji corruption.
8876        let adf_json = r#"{"version":1,"type":"doc","content":[
8877          {"type":"bulletList","content":[
8878            {"type":"listItem","content":[
8879              {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8880                {"type":"text","text":"Foo::Bar::Baz"}
8881              ]}
8882            ]}
8883          ]}
8884        ]}"#;
8885        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8886
8887        let md = adf_to_markdown(&doc).unwrap();
8888        let round_tripped = markdown_to_adf(&md).unwrap();
8889
8890        let list = &round_tripped.content[0];
8891        assert_eq!(list.node_type, "bulletList");
8892        let item = &list.content.as_ref().unwrap()[0];
8893        assert_eq!(item.node_type, "listItem");
8894        let cb = &item.content.as_ref().unwrap()[0];
8895        assert_eq!(cb.node_type, "codeBlock");
8896        let cb_content = cb.content.as_ref().unwrap();
8897        assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8898        assert_eq!(cb_content[0].node_type, "text");
8899    }
8900
8901    #[test]
8902    fn code_block_with_unicode_shortcode_pattern_round_trips() {
8903        // Issue #552: Code block content with non-ASCII colon-delimited words
8904        // (e.g. CJK or accented characters) must round-trip without splitting
8905        // or emoji corruption.
8906        let adf_json = r#"{"version":1,"type":"doc","content":[
8907          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8908            {"type":"text","text":"class ZBC::配置::Production < Base"}
8909          ]}
8910        ]}"#;
8911        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8912
8913        let md = adf_to_markdown(&doc).unwrap();
8914        let round_tripped = markdown_to_adf(&md).unwrap();
8915
8916        let cb = &round_tripped.content[0];
8917        assert_eq!(cb.node_type, "codeBlock");
8918        let content = cb.content.as_ref().expect("codeBlock content");
8919        assert_eq!(content.len(), 1);
8920        assert_eq!(
8921            content[0].text.as_deref().unwrap(),
8922            "class ZBC::配置::Production < Base"
8923        );
8924    }
8925
8926    #[test]
8927    fn list_item_hardbreak_then_code_block_round_trips() {
8928        // Issue #552: A list item whose first paragraph ends with a hardBreak
8929        // followed by a codeBlock must round-trip correctly.  Previously, the
8930        // hardBreak's `\` continuation swallowed the 2-space-indented code
8931        // fence line, turning the whole block into a paragraph where `::Bar::`
8932        // was re-parsed as an emoji.
8933        let adf_json = r#"{"version":1,"type":"doc","content":[
8934          {"type":"bulletList","content":[
8935            {"type":"listItem","content":[
8936              {"type":"paragraph","content":[
8937                {"type":"text","text":"Consider removing this check."},
8938                {"type":"hardBreak"}
8939              ]},
8940              {"type":"codeBlock","content":[
8941                {"type":"text","text":"x = Foo::Bar::Baz.new"}
8942              ]}
8943            ]}
8944          ]}
8945        ]}"#;
8946        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8947
8948        let md = adf_to_markdown(&doc).unwrap();
8949        let round_tripped = markdown_to_adf(&md).unwrap();
8950
8951        let list = &round_tripped.content[0];
8952        assert_eq!(list.node_type, "bulletList");
8953        let item = &list.content.as_ref().unwrap()[0];
8954        assert_eq!(item.node_type, "listItem");
8955        let item_content = item.content.as_ref().unwrap();
8956        assert_eq!(
8957            item_content.len(),
8958            2,
8959            "listItem should have paragraph + codeBlock, got: {item_content:?}"
8960        );
8961        assert_eq!(item_content[0].node_type, "paragraph");
8962        assert_eq!(item_content[1].node_type, "codeBlock");
8963
8964        // The code block text must survive verbatim — no emoji splitting.
8965        let cb_content = item_content[1].content.as_ref().unwrap();
8966        assert_eq!(cb_content[0].node_type, "text");
8967        assert_eq!(
8968            cb_content[0].text.as_deref().unwrap(),
8969            "x = Foo::Bar::Baz.new"
8970        );
8971
8972        // And there should be no emoji node anywhere in the tree.
8973        assert!(
8974            item_content
8975                .iter()
8976                .flat_map(|n| n.content.iter().flat_map(|c| c.iter()))
8977                .all(|n| n.node_type != "emoji"),
8978            "no emoji nodes should be present, got: {item_content:?}"
8979        );
8980    }
8981
8982    #[test]
8983    fn list_item_hardbreak_then_nested_list_still_works() {
8984        // Ensure the hardBreak continuation fix doesn't break nested list
8985        // handling — an indented `- item` line after a hardBreak is still a
8986        // nested list, not a code fence.
8987        let md = "- first\\\n  continuation text\\\n  - nested item\n";
8988        let doc = markdown_to_adf(md).unwrap();
8989        let list = &doc.content[0];
8990        assert_eq!(list.node_type, "bulletList");
8991        let item = &list.content.as_ref().unwrap()[0];
8992        // First paragraph should contain the continuation text joined via hardBreaks
8993        let item_content = item.content.as_ref().unwrap();
8994        let para = &item_content[0];
8995        assert_eq!(para.node_type, "paragraph");
8996        let para_nodes = para.content.as_ref().unwrap();
8997        assert!(
8998            para_nodes
8999                .iter()
9000                .any(|n| n.text.as_deref() == Some("continuation text")),
9001            "continuation text should survive: {para_nodes:?}"
9002        );
9003    }
9004
9005    #[test]
9006    fn list_item_hardbreak_then_image_still_works() {
9007        // Regression check for issue #490: the image-skip behaviour in
9008        // collect_hardbreak_continuations must still hold after the code-fence
9009        // fix.  The `![](url)` line must remain a block-level mediaSingle, not
9010        // be swallowed into the paragraph.
9011        let md = "- first\\\n  ![](https://example.com/x.png){type=file id=x}\n";
9012        let doc = markdown_to_adf(md).unwrap();
9013        let list = &doc.content[0];
9014        let item = &list.content.as_ref().unwrap()[0];
9015        let item_content = item.content.as_ref().unwrap();
9016        // The image should be a sibling block, not part of the first paragraph.
9017        assert!(
9018            item_content.iter().any(|n| n.node_type == "mediaSingle"),
9019            "mediaSingle should still be a block-level sibling, got: {item_content:?}"
9020        );
9021    }
9022
9023    #[test]
9024    fn list_item_hardbreak_then_container_directive_round_trips() {
9025        // Issue #552: Same hardBreak-swallows-block-siblings bug class — a
9026        // container directive (`:::panel`) after a hardBreak must also not be
9027        // consumed as a continuation line.
9028        let adf_json = r#"{"version":1,"type":"doc","content":[
9029          {"type":"bulletList","content":[
9030            {"type":"listItem","content":[
9031              {"type":"paragraph","content":[
9032                {"type":"text","text":"intro"},
9033                {"type":"hardBreak"}
9034              ]},
9035              {"type":"panel","attrs":{"panelType":"info"},"content":[
9036                {"type":"paragraph","content":[
9037                  {"type":"text","text":"panel body"}
9038                ]}
9039              ]}
9040            ]}
9041          ]}
9042        ]}"#;
9043        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9044        let md = adf_to_markdown(&doc).unwrap();
9045        let round_tripped = markdown_to_adf(&md).unwrap();
9046
9047        let item = &round_tripped.content[0].content.as_ref().unwrap()[0];
9048        let item_content = item.content.as_ref().unwrap();
9049        assert!(
9050            item_content.iter().any(|n| n.node_type == "panel"),
9051            "panel should survive as block-level sibling, got: {item_content:?}"
9052        );
9053    }
9054
9055    #[test]
9056    fn inline_code_with_unicode_shortcode_pattern_round_trips() {
9057        // Issue #552: Inline code containing non-ASCII colon-delimited words
9058        // must round-trip without emoji corruption.
9059        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
9060          {"type":"text","text":"See "},
9061          {"type":"text","text":"ZBC::配置::Production","marks":[{"type":"code"}]},
9062          {"type":"text","text":" for prod"}
9063        ]}]}"#;
9064        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9065
9066        let md = adf_to_markdown(&doc).unwrap();
9067        let round_tripped = markdown_to_adf(&md).unwrap();
9068        let content = round_tripped.content[0].content.as_ref().unwrap();
9069
9070        assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
9071        assert_eq!(content[1].text.as_deref(), Some("ZBC::配置::Production"));
9072        assert!(content[1]
9073            .marks
9074            .as_ref()
9075            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
9076    }
9077
9078    #[test]
9079    fn code_block_followed_by_shortcode_text_round_trips() {
9080        // Issue #552: A code block with colon-delimited content followed by a
9081        // paragraph containing emoji-like text should not confuse parsing.
9082        let adf_json = r#"{"version":1,"type":"doc","content":[
9083          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
9084            {"type":"text","text":"Foo::Bar::Baz"}
9085          ]},
9086          {"type":"paragraph","content":[
9087            {"type":"text","text":"Status::Active::Running"}
9088          ]}
9089        ]}"#;
9090        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9091
9092        let md = adf_to_markdown(&doc).unwrap();
9093        let round_tripped = markdown_to_adf(&md).unwrap();
9094
9095        assert_eq!(round_tripped.content.len(), 2);
9096        let cb = &round_tripped.content[0];
9097        assert_eq!(cb.node_type, "codeBlock");
9098        let cb_content = cb.content.as_ref().unwrap();
9099        assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
9100
9101        let para = &round_tripped.content[1];
9102        assert_eq!(para.node_type, "paragraph");
9103        let para_content = para.content.as_ref().unwrap();
9104        assert_eq!(para_content.len(), 1);
9105        assert_eq!(para_content[0].node_type, "text");
9106        assert_eq!(
9107            para_content[0].text.as_deref(),
9108            Some("Status::Active::Running")
9109        );
9110    }
9111
9112    #[test]
9113    fn adf_inline_card_to_markdown() {
9114        let doc = AdfDocument {
9115            version: 1,
9116            doc_type: "doc".to_string(),
9117            content: vec![AdfNode::paragraph(vec![AdfNode {
9118                node_type: "inlineCard".to_string(),
9119                attrs: Some(
9120                    serde_json::json!({"url": "https://org.atlassian.net/browse/ACCS-4382"}),
9121                ),
9122                content: None,
9123                text: None,
9124                marks: None,
9125                local_id: None,
9126                parameters: None,
9127            }])],
9128        };
9129        let md = adf_to_markdown(&doc).unwrap();
9130        assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
9131        assert!(!md.contains("<!-- unsupported inline"));
9132    }
9133
9134    #[test]
9135    fn inline_card_directive_round_trips() {
9136        // inlineCard → :card[url] → inlineCard
9137        let original = AdfDocument {
9138            version: 1,
9139            doc_type: "doc".to_string(),
9140            content: vec![AdfNode::paragraph(vec![AdfNode::inline_card(
9141                "https://org.atlassian.net/browse/ACCS-4382",
9142            )])],
9143        };
9144        let md = adf_to_markdown(&original).unwrap();
9145        assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
9146        let restored = markdown_to_adf(&md).unwrap();
9147        let node = &restored.content[0].content.as_ref().unwrap()[0];
9148        assert_eq!(node.node_type, "inlineCard");
9149        assert_eq!(
9150            node.attrs.as_ref().unwrap()["url"],
9151            "https://org.atlassian.net/browse/ACCS-4382"
9152        );
9153    }
9154
9155    #[test]
9156    fn inline_card_directive_parsed_from_jfm() {
9157        // :card[url] in JFM → inlineCard in ADF
9158        let doc = markdown_to_adf("See :card[https://example.com/issue/123] for details.").unwrap();
9159        let nodes = doc.content[0].content.as_ref().unwrap();
9160        assert_eq!(nodes[0].node_type, "text");
9161        assert_eq!(nodes[0].text.as_deref(), Some("See "));
9162        assert_eq!(nodes[1].node_type, "inlineCard");
9163        assert_eq!(
9164            nodes[1].attrs.as_ref().unwrap()["url"],
9165            "https://example.com/issue/123"
9166        );
9167        assert_eq!(nodes[2].node_type, "text");
9168        assert_eq!(nodes[2].text.as_deref(), Some(" for details."));
9169    }
9170
9171    #[test]
9172    fn self_link_becomes_link_mark_not_inline_card() {
9173        // Issue #378: [url](url) should produce a link mark, not inlineCard.
9174        // inlineCard is only for :card[url] directives and bare URLs.
9175        let doc = markdown_to_adf("[https://example.com](https://example.com)").unwrap();
9176        let node = &doc.content[0].content.as_ref().unwrap()[0];
9177        assert_eq!(node.node_type, "text");
9178        assert_eq!(node.text.as_deref(), Some("https://example.com"));
9179        let mark = &node.marks.as_ref().unwrap()[0];
9180        assert_eq!(mark.mark_type, "link");
9181        assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
9182    }
9183
9184    #[test]
9185    fn url_link_text_with_trailing_slash_mismatch_becomes_link_mark() {
9186        // Issue #523: [url](url/) where text and href differ only by trailing
9187        // slash should produce a text node with link mark, not an inlineCard.
9188        let doc =
9189            markdown_to_adf("[https://octopz.example.com](https://octopz.example.com/)").unwrap();
9190        let node = &doc.content[0].content.as_ref().unwrap()[0];
9191        assert_eq!(node.node_type, "text");
9192        assert_eq!(node.text.as_deref(), Some("https://octopz.example.com"));
9193        let mark = &node.marks.as_ref().unwrap()[0];
9194        assert_eq!(mark.mark_type, "link");
9195        assert_eq!(
9196            mark.attrs.as_ref().unwrap()["href"],
9197            "https://octopz.example.com/"
9198        );
9199    }
9200
9201    #[test]
9202    fn named_link_does_not_become_inline_card() {
9203        // [#4668](url) — text differs from url, stays as a link mark
9204        let doc = markdown_to_adf("[#4668](https://github.com/org/repo/pull/4668)").unwrap();
9205        let node = &doc.content[0].content.as_ref().unwrap()[0];
9206        assert_eq!(node.node_type, "text");
9207        assert_eq!(node.text.as_deref(), Some("#4668"));
9208        let mark = &node.marks.as_ref().unwrap()[0];
9209        assert_eq!(mark.mark_type, "link");
9210    }
9211
9212    #[test]
9213    fn adf_inline_card_no_url_to_markdown() {
9214        let doc = AdfDocument {
9215            version: 1,
9216            doc_type: "doc".to_string(),
9217            content: vec![AdfNode::paragraph(vec![AdfNode {
9218                node_type: "inlineCard".to_string(),
9219                attrs: Some(serde_json::json!({})),
9220                content: None,
9221                text: None,
9222                marks: None,
9223                local_id: None,
9224                parameters: None,
9225            }])],
9226        };
9227        let md = adf_to_markdown(&doc).unwrap();
9228        // No url attr — renders nothing (not a comment)
9229        assert!(!md.contains("<!-- unsupported inline"));
9230    }
9231
9232    #[test]
9233    fn adf_code_block_no_language_to_markdown() {
9234        let doc = AdfDocument {
9235            version: 1,
9236            doc_type: "doc".to_string(),
9237            content: vec![AdfNode::code_block(None, "plain code")],
9238        };
9239        let md = adf_to_markdown(&doc).unwrap();
9240        assert!(md.contains("```\n"));
9241        assert!(md.contains("plain code"));
9242    }
9243
9244    #[test]
9245    fn adf_code_block_empty_language_to_markdown() {
9246        let doc = AdfDocument {
9247            version: 1,
9248            doc_type: "doc".to_string(),
9249            content: vec![AdfNode::code_block(Some(""), "plain code")],
9250        };
9251        let md = adf_to_markdown(&doc).unwrap();
9252        assert!(md.contains("```\"\"\n"));
9253        assert!(md.contains("plain code"));
9254    }
9255
9256    // ── Additional round-trip tests ────────────────────────────────────
9257
9258    #[test]
9259    fn round_trip_table() {
9260        let md = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
9261        let adf = markdown_to_adf(md).unwrap();
9262        let restored = adf_to_markdown(&adf).unwrap();
9263        assert!(restored.contains("| A | B |"));
9264        assert!(restored.contains("| 1 | 2 |"));
9265    }
9266
9267    #[test]
9268    fn round_trip_blockquote() {
9269        let md = "> This is quoted\n";
9270        let adf = markdown_to_adf(md).unwrap();
9271        let restored = adf_to_markdown(&adf).unwrap();
9272        assert!(restored.contains("> This is quoted"));
9273    }
9274
9275    #[test]
9276    fn round_trip_image() {
9277        let md = "![Logo](https://example.com/logo.png)\n";
9278        let adf = markdown_to_adf(md).unwrap();
9279        let restored = adf_to_markdown(&adf).unwrap();
9280        assert!(restored.contains("![Logo](https://example.com/logo.png)"));
9281    }
9282
9283    #[test]
9284    fn round_trip_ordered_list() {
9285        let md = "1. A\n2. B\n3. C\n";
9286        let adf = markdown_to_adf(md).unwrap();
9287        let restored = adf_to_markdown(&adf).unwrap();
9288        assert!(restored.contains("1. A"));
9289        assert!(restored.contains("2. B"));
9290        assert!(restored.contains("3. C"));
9291    }
9292
9293    #[test]
9294    fn round_trip_inline_marks() {
9295        let md = "Text with `code` and ~~strike~~ and [link](https://x.com).\n";
9296        let adf = markdown_to_adf(md).unwrap();
9297        let restored = adf_to_markdown(&adf).unwrap();
9298        assert!(restored.contains("`code`"));
9299        assert!(restored.contains("~~strike~~"));
9300        assert!(restored.contains("[link](https://x.com)"));
9301    }
9302
9303    // ── Container directive tests (Tier 2) ───────────────────────────
9304
9305    #[test]
9306    fn panel_info() {
9307        let md = ":::panel{type=info}\nThis is informational.\n:::";
9308        let doc = markdown_to_adf(md).unwrap();
9309        assert_eq!(doc.content[0].node_type, "panel");
9310        assert_eq!(doc.content[0].attrs.as_ref().unwrap()["panelType"], "info");
9311        let inner = doc.content[0].content.as_ref().unwrap();
9312        assert_eq!(inner[0].node_type, "paragraph");
9313    }
9314
9315    #[test]
9316    fn adf_panel_to_markdown() {
9317        let doc = AdfDocument {
9318            version: 1,
9319            doc_type: "doc".to_string(),
9320            content: vec![AdfNode::panel(
9321                "warning",
9322                vec![AdfNode::paragraph(vec![AdfNode::text("Be careful.")])],
9323            )],
9324        };
9325        let md = adf_to_markdown(&doc).unwrap();
9326        assert!(md.contains(":::panel{type=warning}"));
9327        assert!(md.contains("Be careful."));
9328        assert!(md.contains(":::"));
9329    }
9330
9331    #[test]
9332    fn round_trip_panel() {
9333        let md = ":::panel{type=info}\nThis is informational.\n:::\n";
9334        let doc = markdown_to_adf(md).unwrap();
9335        let result = adf_to_markdown(&doc).unwrap();
9336        assert!(result.contains(":::panel{type=info}"));
9337        assert!(result.contains("This is informational."));
9338    }
9339
9340    #[test]
9341    fn expand_with_title() {
9342        let md = ":::expand{title=\"Click me\"}\nHidden content.\n:::";
9343        let doc = markdown_to_adf(md).unwrap();
9344        assert_eq!(doc.content[0].node_type, "expand");
9345        assert_eq!(doc.content[0].attrs.as_ref().unwrap()["title"], "Click me");
9346    }
9347
9348    #[test]
9349    fn adf_expand_to_markdown() {
9350        let doc = AdfDocument {
9351            version: 1,
9352            doc_type: "doc".to_string(),
9353            content: vec![AdfNode::expand(
9354                Some("Details"),
9355                vec![AdfNode::paragraph(vec![AdfNode::text("Inner.")])],
9356            )],
9357        };
9358        let md = adf_to_markdown(&doc).unwrap();
9359        assert!(md.contains(":::expand{title=\"Details\"}"));
9360        assert!(md.contains("Inner."));
9361    }
9362
9363    #[test]
9364    fn round_trip_expand() {
9365        let md = ":::expand{title=\"Details\"}\nInner content.\n:::\n";
9366        let doc = markdown_to_adf(md).unwrap();
9367        let result = adf_to_markdown(&doc).unwrap();
9368        assert!(result.contains(":::expand{title=\"Details\"}"));
9369        assert!(result.contains("Inner content."));
9370    }
9371
9372    #[test]
9373    fn layout_two_columns() {
9374        let md =
9375            "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
9376        let doc = markdown_to_adf(md).unwrap();
9377        assert_eq!(doc.content[0].node_type, "layoutSection");
9378        let columns = doc.content[0].content.as_ref().unwrap();
9379        assert_eq!(columns.len(), 2);
9380        assert_eq!(columns[0].node_type, "layoutColumn");
9381        assert_eq!(columns[1].node_type, "layoutColumn");
9382    }
9383
9384    #[test]
9385    fn adf_layout_to_markdown() {
9386        let doc = AdfDocument {
9387            version: 1,
9388            doc_type: "doc".to_string(),
9389            content: vec![AdfNode::layout_section(vec![
9390                AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Left.")])]),
9391                AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Right.")])]),
9392            ])],
9393        };
9394        let md = adf_to_markdown(&doc).unwrap();
9395        assert!(md.contains("::::layout"));
9396        assert!(md.contains(":::column{width=50}"));
9397        assert!(md.contains("Left."));
9398        assert!(md.contains("Right."));
9399    }
9400
9401    #[test]
9402    fn layout_column_localid_roundtrip() {
9403        let adf_json = r#"{
9404            "version": 1,
9405            "type": "doc",
9406            "content": [{
9407                "type": "layoutSection",
9408                "content": [
9409                    {
9410                        "type": "layoutColumn",
9411                        "attrs": {"width": 50.0, "localId": "aabb112233cc"},
9412                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Left"}]}]
9413                    },
9414                    {
9415                        "type": "layoutColumn",
9416                        "attrs": {"width": 50.0, "localId": "ddeeff445566"},
9417                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Right"}]}]
9418                    }
9419                ]
9420            }]
9421        }"#;
9422        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9423        let md = adf_to_markdown(&doc).unwrap();
9424        assert!(
9425            md.contains("localId=aabb112233cc"),
9426            "first column localId should appear in markdown: {md}"
9427        );
9428        assert!(
9429            md.contains("localId=ddeeff445566"),
9430            "second column localId should appear in markdown: {md}"
9431        );
9432        let rt = markdown_to_adf(&md).unwrap();
9433        let cols = rt.content[0].content.as_ref().unwrap();
9434        assert_eq!(
9435            cols[0].attrs.as_ref().unwrap()["localId"],
9436            "aabb112233cc",
9437            "first column localId should round-trip"
9438        );
9439        assert_eq!(
9440            cols[1].attrs.as_ref().unwrap()["localId"],
9441            "ddeeff445566",
9442            "second column localId should round-trip"
9443        );
9444    }
9445
9446    #[test]
9447    fn layout_column_without_localid() {
9448        let md =
9449            "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
9450        let doc = markdown_to_adf(md).unwrap();
9451        let cols = doc.content[0].content.as_ref().unwrap();
9452        assert!(
9453            cols[0].attrs.as_ref().unwrap().get("localId").is_none(),
9454            "column without localId should not gain one"
9455        );
9456        let md2 = adf_to_markdown(&doc).unwrap();
9457        assert!(
9458            !md2.contains("localId"),
9459            "no localId should appear in output: {md2}"
9460        );
9461    }
9462
9463    #[test]
9464    fn layout_column_localid_stripped_when_option_set() {
9465        let adf_json = r#"{
9466            "version": 1,
9467            "type": "doc",
9468            "content": [{
9469                "type": "layoutSection",
9470                "content": [{
9471                    "type": "layoutColumn",
9472                    "attrs": {"width": 50.0, "localId": "aabb112233cc"},
9473                    "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Col"}]}]
9474                }]
9475            }]
9476        }"#;
9477        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9478        let opts = RenderOptions {
9479            strip_local_ids: true,
9480            ..Default::default()
9481        };
9482        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
9483        assert!(!md.contains("localId"), "localId should be stripped: {md}");
9484    }
9485
9486    #[test]
9487    fn layout_column_localid_flush_previous() {
9488        // Columns open without explicit `:::` close → flush-previous path
9489        let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nLeft.\n:::column{width=50 localId=ddeeff445566}\nRight.\n:::\n::::";
9490        let doc = markdown_to_adf(md).unwrap();
9491        let cols = doc.content[0].content.as_ref().unwrap();
9492        assert_eq!(
9493            cols[0].attrs.as_ref().unwrap()["localId"],
9494            "aabb112233cc",
9495            "flush-previous column should preserve localId"
9496        );
9497        assert_eq!(
9498            cols[1].attrs.as_ref().unwrap()["localId"],
9499            "ddeeff445566",
9500            "second column localId should be preserved"
9501        );
9502    }
9503
9504    #[test]
9505    fn layout_column_localid_flush_last() {
9506        // Layout with no closing fence → column never explicitly closed → flush-last path
9507        let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nOnly column.";
9508        let doc = markdown_to_adf(md).unwrap();
9509        let cols = doc.content[0].content.as_ref().unwrap();
9510        assert_eq!(
9511            cols[0].attrs.as_ref().unwrap()["localId"],
9512            "aabb112233cc",
9513            "flush-last column should preserve localId"
9514        );
9515    }
9516
9517    /// Issue #555: `layoutColumn` fractional `width` must round-trip byte-for-byte.
9518    #[test]
9519    fn issue_555_layout_column_fractional_width_roundtrip() {
9520        let adf_json = r#"{
9521            "version": 1,
9522            "type": "doc",
9523            "content": [{
9524                "type": "layoutSection",
9525                "content": [
9526                    {
9527                        "type": "layoutColumn",
9528                        "attrs": {"width": 66.66},
9529                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9530                    },
9531                    {
9532                        "type": "layoutColumn",
9533                        "attrs": {"width": 33.34},
9534                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9535                    }
9536                ]
9537            }]
9538        }"#;
9539        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9540        let md = adf_to_markdown(&doc).unwrap();
9541        assert!(md.contains("width=66.66"), "fractional width in md: {md}");
9542        assert!(md.contains("width=33.34"), "fractional width in md: {md}");
9543        let rt = markdown_to_adf(&md).unwrap();
9544        let cols = rt.content[0].content.as_ref().unwrap();
9545        assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 66.66);
9546        assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 33.34);
9547    }
9548
9549    /// Issue #555: `layoutColumn` 5/6 widths (`83.33`) round-trip without precision loss.
9550    #[test]
9551    fn issue_555_layout_column_five_sixths_width_roundtrip() {
9552        let adf_json = r#"{
9553            "version": 1,
9554            "type": "doc",
9555            "content": [{
9556                "type": "layoutSection",
9557                "content": [
9558                    {
9559                        "type": "layoutColumn",
9560                        "attrs": {"width": 83.33},
9561                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9562                    },
9563                    {
9564                        "type": "layoutColumn",
9565                        "attrs": {"width": 16.67},
9566                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9567                    }
9568                ]
9569            }]
9570        }"#;
9571        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9572        let md = adf_to_markdown(&doc).unwrap();
9573        let rt = markdown_to_adf(&md).unwrap();
9574        let cols = rt.content[0].content.as_ref().unwrap();
9575        assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 83.33);
9576        assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 16.67);
9577    }
9578
9579    /// Issue #555: `layoutColumn` integer widths must NOT be coerced to floats on round-trip.
9580    #[test]
9581    fn issue_555_layout_column_integer_width_preserved() {
9582        let adf_json = r#"{
9583            "version": 1,
9584            "type": "doc",
9585            "content": [{
9586                "type": "layoutSection",
9587                "content": [
9588                    {
9589                        "type": "layoutColumn",
9590                        "attrs": {"width": 50},
9591                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "A"}]}]
9592                    },
9593                    {
9594                        "type": "layoutColumn",
9595                        "attrs": {"width": 50},
9596                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "B"}]}]
9597                    }
9598                ]
9599            }]
9600        }"#;
9601        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9602        let md = adf_to_markdown(&doc).unwrap();
9603        assert!(
9604            md.contains("width=50") && !md.contains("width=50."),
9605            "integer width should render without decimal: {md}"
9606        );
9607        let rt = markdown_to_adf(&md).unwrap();
9608        let cols = rt.content[0].content.as_ref().unwrap();
9609        let w0 = &cols[0].attrs.as_ref().unwrap()["width"];
9610        assert!(
9611            w0.is_i64() || w0.is_u64(),
9612            "width should remain a JSON integer, got: {w0}"
9613        );
9614        assert_eq!(w0.as_i64(), Some(50));
9615    }
9616
9617    /// Issue #555: `mediaSingle` integer `width` must NOT be coerced to a float on round-trip.
9618    #[test]
9619    fn issue_555_media_single_integer_width_preserved() {
9620        let adf_json = r#"{
9621            "version": 1,
9622            "type": "doc",
9623            "content": [{
9624                "type": "mediaSingle",
9625                "attrs": {"layout": "center", "width": 800},
9626                "content": [
9627                    {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9628                ]
9629            }]
9630        }"#;
9631        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9632        let md = adf_to_markdown(&doc).unwrap();
9633        assert!(
9634            md.contains("width=800") && !md.contains("width=800."),
9635            "integer width should render without decimal: {md}"
9636        );
9637        let rt = markdown_to_adf(&md).unwrap();
9638        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9639        let w = &ms_attrs["width"];
9640        assert!(
9641            w.is_i64() || w.is_u64(),
9642            "mediaSingle width should remain JSON integer, got: {w}"
9643        );
9644        assert_eq!(w.as_i64(), Some(800));
9645    }
9646
9647    /// Issue #555 (follow-up): fractional `mediaSingle` width (e.g. `66.5`, a
9648    /// percentage-based size common in Jira layouts) must survive `from-adf`
9649    /// instead of being silently dropped.
9650    #[test]
9651    fn issue_555_media_single_fractional_width_preserved() {
9652        let adf_json = r#"{
9653            "version": 1,
9654            "type": "doc",
9655            "content": [{
9656                "type": "mediaSingle",
9657                "attrs": {"layout": "center", "width": 66.5},
9658                "content": [
9659                    {"type": "media", "attrs": {"type": "external", "url": "https://example.com/diagram.png"}}
9660                ]
9661            }]
9662        }"#;
9663        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9664        let md = adf_to_markdown(&doc).unwrap();
9665        assert!(
9666            md.contains("width=66.5"),
9667            "fractional width must appear in JFM: {md}"
9668        );
9669        let rt = markdown_to_adf(&md).unwrap();
9670        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9671        assert_eq!(ms_attrs["width"], 66.5);
9672    }
9673
9674    /// Issue #555: `mediaSingle` float `width` must not be dropped during ADF→JFM→ADF.
9675    #[test]
9676    fn issue_555_media_single_float_width_preserved() {
9677        let adf_json = r#"{
9678            "version": 1,
9679            "type": "doc",
9680            "content": [{
9681                "type": "mediaSingle",
9682                "attrs": {"layout": "center", "width": 800.0},
9683                "content": [
9684                    {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9685                ]
9686            }]
9687        }"#;
9688        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9689        let md = adf_to_markdown(&doc).unwrap();
9690        assert!(
9691            md.contains("width=800.0"),
9692            "float width should render with decimal: {md}"
9693        );
9694        let rt = markdown_to_adf(&md).unwrap();
9695        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9696        let w = &ms_attrs["width"];
9697        assert!(
9698            w.is_f64(),
9699            "mediaSingle float width should stay a JSON float, got: {w}"
9700        );
9701        assert_eq!(w.as_f64(), Some(800.0));
9702    }
9703
9704    /// Issue #555: `mediaSingle` with `layout=wide` and integer width must round-trip.
9705    #[test]
9706    fn issue_555_media_single_wide_layout_integer_width_roundtrip() {
9707        let adf_json = r#"{
9708            "version": 1,
9709            "type": "doc",
9710            "content": [{
9711                "type": "mediaSingle",
9712                "attrs": {"layout": "wide", "width": 1420},
9713                "content": [
9714                    {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9715                ]
9716            }]
9717        }"#;
9718        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9719        let md = adf_to_markdown(&doc).unwrap();
9720        let rt = markdown_to_adf(&md).unwrap();
9721        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9722        assert_eq!(ms_attrs["layout"], "wide");
9723        let w = &ms_attrs["width"];
9724        assert!(
9725            w.is_i64() || w.is_u64(),
9726            "mediaSingle width should remain JSON integer, got: {w}"
9727        );
9728        assert_eq!(w.as_i64(), Some(1420));
9729    }
9730
9731    /// Issue #555: Confluence file-attachment `mediaSingle` with integer `mediaWidth`
9732    /// must round-trip without float coercion.
9733    #[test]
9734    fn issue_555_file_media_single_integer_width_preserved() {
9735        let adf_json = r#"{
9736            "version": 1,
9737            "type": "doc",
9738            "content": [{
9739                "type": "mediaSingle",
9740                "attrs": {"layout": "wide", "width": 1420},
9741                "content": [
9742                    {"type": "media", "attrs": {"id": "abc-123", "type": "file", "collection": "col-1", "width": 1200, "height": 800}}
9743                ]
9744            }]
9745        }"#;
9746        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9747        let md = adf_to_markdown(&doc).unwrap();
9748        let rt = markdown_to_adf(&md).unwrap();
9749        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9750        let ms_w = &ms_attrs["width"];
9751        assert!(ms_w.is_i64() || ms_w.is_u64(), "ms width: {ms_w}");
9752        assert_eq!(ms_w.as_i64(), Some(1420));
9753        let media = &rt.content[0].content.as_ref().unwrap()[0];
9754        let media_attrs = media.attrs.as_ref().unwrap();
9755        let mw = &media_attrs["width"];
9756        assert!(mw.is_i64() || mw.is_u64(), "media width: {mw}");
9757        assert_eq!(mw.as_i64(), Some(1200));
9758        let mh = &media_attrs["height"];
9759        assert!(mh.is_i64() || mh.is_u64(), "media height: {mh}");
9760        assert_eq!(mh.as_i64(), Some(800));
9761    }
9762
9763    /// Issue #555: `fmt_numeric_attr` preserves the original integer/float JSON type.
9764    #[test]
9765    fn issue_555_fmt_numeric_attr_preserves_type() {
9766        assert_eq!(
9767            fmt_numeric_attr(&serde_json::json!(50)).as_deref(),
9768            Some("50")
9769        );
9770        assert_eq!(
9771            fmt_numeric_attr(&serde_json::json!(50.0)).as_deref(),
9772            Some("50.0")
9773        );
9774        assert_eq!(
9775            fmt_numeric_attr(&serde_json::json!(66.66)).as_deref(),
9776            Some("66.66")
9777        );
9778        assert_eq!(
9779            fmt_numeric_attr(&serde_json::json!(-5)).as_deref(),
9780            Some("-5")
9781        );
9782        assert_eq!(fmt_numeric_attr(&serde_json::json!("not a number")), None);
9783        // u64 values above i64::MAX exercise the u64-only branch.
9784        let big = serde_json::Value::Number(serde_json::Number::from(u64::MAX));
9785        assert_eq!(
9786            fmt_numeric_attr(&big).as_deref(),
9787            Some("18446744073709551615")
9788        );
9789        // Null is not a number.
9790        assert_eq!(fmt_numeric_attr(&serde_json::Value::Null), None);
9791    }
9792
9793    /// Issue #555: `parse_numeric_attr` distinguishes integer vs float strings.
9794    #[test]
9795    fn issue_555_parse_numeric_attr_detects_type() {
9796        let v = parse_numeric_attr("800").unwrap();
9797        assert!(v.is_i64() || v.is_u64(), "'800' should parse as integer");
9798        assert_eq!(v.as_i64(), Some(800));
9799
9800        let v = parse_numeric_attr("800.0").unwrap();
9801        assert!(v.is_f64(), "'800.0' should parse as float");
9802        assert_eq!(v.as_f64(), Some(800.0));
9803
9804        let v = parse_numeric_attr("66.66").unwrap();
9805        assert!(v.is_f64());
9806        assert_eq!(v.as_f64(), Some(66.66));
9807
9808        // Scientific notation is treated as float (matches JSON semantics).
9809        let v = parse_numeric_attr("1e2").unwrap();
9810        assert!(v.is_f64());
9811        assert_eq!(v.as_f64(), Some(100.0));
9812        let v = parse_numeric_attr("1E2").unwrap();
9813        assert!(v.is_f64());
9814        assert_eq!(v.as_f64(), Some(100.0));
9815
9816        // Negative integer.
9817        let v = parse_numeric_attr("-42").unwrap();
9818        assert!(v.is_i64());
9819        assert_eq!(v.as_i64(), Some(-42));
9820
9821        assert!(parse_numeric_attr("not-a-number").is_none());
9822        assert!(parse_numeric_attr("").is_none());
9823        assert!(parse_numeric_attr("1.2.3").is_none());
9824    }
9825
9826    /// Issue #555: fractional `mediaSingle` width with non-default `layout=wide`
9827    /// must preserve both the layout and the fractional width through round-trip.
9828    #[test]
9829    fn issue_555_media_single_wide_layout_fractional_width_roundtrip() {
9830        let adf_json = r#"{
9831            "version": 1,
9832            "type": "doc",
9833            "content": [{
9834                "type": "mediaSingle",
9835                "attrs": {"layout": "wide", "width": 83.33},
9836                "content": [
9837                    {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9838                ]
9839            }]
9840        }"#;
9841        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9842        let md = adf_to_markdown(&doc).unwrap();
9843        assert!(md.contains("layout=wide"), "layout must appear in md: {md}");
9844        assert!(md.contains("width=83.33"), "width must appear in md: {md}");
9845        let rt = markdown_to_adf(&md).unwrap();
9846        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9847        assert_eq!(ms_attrs["layout"], "wide");
9848        assert_eq!(ms_attrs["width"], 83.33);
9849    }
9850
9851    /// Issue #555: fractional `mediaWidth` on a Confluence file-attachment
9852    /// `mediaSingle` must round-trip (exercises the file-branch `mediaWidth`
9853    /// render path, which previously used `as_u64` and silently dropped floats).
9854    #[test]
9855    fn issue_555_file_media_single_fractional_media_width_preserved() {
9856        let adf_json = r#"{
9857            "version": 1,
9858            "type": "doc",
9859            "content": [{
9860                "type": "mediaSingle",
9861                "attrs": {"layout": "wide", "width": 66.5},
9862                "content": [
9863                    {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c"}}
9864                ]
9865            }]
9866        }"#;
9867        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9868        let md = adf_to_markdown(&doc).unwrap();
9869        assert!(md.contains("mediaWidth=66.5"), "mediaWidth in md: {md}");
9870        let rt = markdown_to_adf(&md).unwrap();
9871        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9872        assert_eq!(ms_attrs["width"], 66.5);
9873    }
9874
9875    /// Issue #555: fractional inner `media` width/height on a file attachment
9876    /// must round-trip (exercises the file-branch inner `width`/`height` render
9877    /// path, which previously used `as_u64` and silently dropped floats).
9878    #[test]
9879    fn issue_555_file_media_fractional_inner_dimensions_preserved() {
9880        let adf_json = r#"{
9881            "version": 1,
9882            "type": "doc",
9883            "content": [{
9884                "type": "mediaSingle",
9885                "attrs": {"layout": "center"},
9886                "content": [
9887                    {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c", "width": 1200.5, "height": 800.25}}
9888                ]
9889            }]
9890        }"#;
9891        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9892        let md = adf_to_markdown(&doc).unwrap();
9893        assert!(md.contains("width=1200.5"), "width in md: {md}");
9894        assert!(md.contains("height=800.25"), "height in md: {md}");
9895        let rt = markdown_to_adf(&md).unwrap();
9896        let media = &rt.content[0].content.as_ref().unwrap()[0];
9897        let attrs = media.attrs.as_ref().unwrap();
9898        assert_eq!(attrs["width"], 1200.5);
9899        assert_eq!(attrs["height"], 800.25);
9900    }
9901
9902    #[test]
9903    fn decisions_list() {
9904        let md = ":::decisions\n- <> Use PostgreSQL\n- <> REST API\n:::";
9905        let doc = markdown_to_adf(md).unwrap();
9906        assert_eq!(doc.content[0].node_type, "decisionList");
9907        let items = doc.content[0].content.as_ref().unwrap();
9908        assert_eq!(items.len(), 2);
9909        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DECIDED");
9910    }
9911
9912    // decisionItem is inline-only per ADF schema — its content must be
9913    // text/inline nodes, not a paragraph wrapper (issue #753).
9914    #[test]
9915    fn decision_item_content_is_inline_not_paragraph() {
9916        let md = ":::decisions\n- <> Use Rust\n:::";
9917        let doc = markdown_to_adf(md).unwrap();
9918        let items = doc.content[0].content.as_ref().unwrap();
9919        let first_child = &items[0].content.as_ref().unwrap()[0];
9920        assert_eq!(
9921            first_child.node_type, "text",
9922            "decisionItem must contain inline nodes directly, not a paragraph wrapper"
9923        );
9924        assert_eq!(first_child.text.as_deref(), Some("Use Rust"));
9925    }
9926
9927    #[test]
9928    fn adf_decisions_to_markdown() {
9929        let doc = AdfDocument {
9930            version: 1,
9931            doc_type: "doc".to_string(),
9932            content: vec![AdfNode::decision_list(vec![AdfNode::decision_item(
9933                "DECIDED",
9934                vec![AdfNode::paragraph(vec![AdfNode::text("Use PostgreSQL")])],
9935            )])],
9936        };
9937        let md = adf_to_markdown(&doc).unwrap();
9938        assert!(md.contains(":::decisions"));
9939        assert!(md.contains("- <> Use PostgreSQL"));
9940    }
9941
9942    #[test]
9943    fn bodied_extension_container() {
9944        let md = ":::extension{type=com.forge key=my-macro}\nContent.\n:::";
9945        let doc = markdown_to_adf(md).unwrap();
9946        assert_eq!(doc.content[0].node_type, "bodiedExtension");
9947        assert_eq!(
9948            doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9949            "com.forge"
9950        );
9951    }
9952
9953    #[test]
9954    fn adf_bodied_extension_to_markdown() {
9955        let doc = AdfDocument {
9956            version: 1,
9957            doc_type: "doc".to_string(),
9958            content: vec![AdfNode::bodied_extension(
9959                "com.forge",
9960                "my-macro",
9961                vec![AdfNode::paragraph(vec![AdfNode::text("Content.")])],
9962            )],
9963        };
9964        let md = adf_to_markdown(&doc).unwrap();
9965        assert!(md.contains(":::extension{type=com.forge key=my-macro}"));
9966        assert!(md.contains("Content."));
9967    }
9968
9969    // ── Leaf block directive tests (Tier 3) ──────────────────────────
9970
9971    #[test]
9972    fn leaf_block_card() {
9973        let doc = markdown_to_adf("::card[https://example.com/browse/PROJ-123]").unwrap();
9974        assert_eq!(doc.content[0].node_type, "blockCard");
9975        assert_eq!(
9976            doc.content[0].attrs.as_ref().unwrap()["url"],
9977            "https://example.com/browse/PROJ-123"
9978        );
9979    }
9980
9981    #[test]
9982    fn adf_block_card_to_markdown() {
9983        let doc = AdfDocument {
9984            version: 1,
9985            doc_type: "doc".to_string(),
9986            content: vec![AdfNode::block_card("https://example.com/browse/PROJ-123")],
9987        };
9988        let md = adf_to_markdown(&doc).unwrap();
9989        assert!(md.contains("::card[https://example.com/browse/PROJ-123]"));
9990    }
9991
9992    #[test]
9993    fn round_trip_block_card() {
9994        let md = "::card[https://example.com/browse/PROJ-123]\n";
9995        let doc = markdown_to_adf(md).unwrap();
9996        let result = adf_to_markdown(&doc).unwrap();
9997        assert!(result.contains("::card[https://example.com/browse/PROJ-123]"));
9998    }
9999
10000    #[test]
10001    fn leaf_embed_card() {
10002        let doc =
10003            markdown_to_adf("::embed[https://figma.com/file/abc]{layout=wide width=80}").unwrap();
10004        assert_eq!(doc.content[0].node_type, "embedCard");
10005        let attrs = doc.content[0].attrs.as_ref().unwrap();
10006        assert_eq!(attrs["url"], "https://figma.com/file/abc");
10007        assert_eq!(attrs["layout"], "wide");
10008        assert_eq!(attrs["width"], 80.0);
10009    }
10010
10011    #[test]
10012    fn leaf_embed_card_with_original_height() {
10013        let doc = markdown_to_adf(
10014            "::embed[https://example.com]{layout=center originalHeight=732 width=100}",
10015        )
10016        .unwrap();
10017        assert_eq!(doc.content[0].node_type, "embedCard");
10018        let attrs = doc.content[0].attrs.as_ref().unwrap();
10019        assert_eq!(attrs["url"], "https://example.com");
10020        assert_eq!(attrs["layout"], "center");
10021        assert_eq!(attrs["originalHeight"], 732.0);
10022        assert_eq!(attrs["width"], 100.0);
10023    }
10024
10025    #[test]
10026    fn adf_embed_card_to_markdown() {
10027        let doc = AdfDocument {
10028            version: 1,
10029            doc_type: "doc".to_string(),
10030            content: vec![AdfNode::embed_card(
10031                "https://figma.com/file/abc",
10032                Some("wide"),
10033                None,
10034                Some(80.0),
10035            )],
10036        };
10037        let md = adf_to_markdown(&doc).unwrap();
10038        assert!(md.contains("::embed[https://figma.com/file/abc]{layout=wide width=80}"));
10039    }
10040
10041    #[test]
10042    fn adf_embed_card_original_height_to_markdown() {
10043        let doc = AdfDocument {
10044            version: 1,
10045            doc_type: "doc".to_string(),
10046            content: vec![AdfNode::embed_card(
10047                "https://example.com",
10048                Some("center"),
10049                Some(732.0),
10050                Some(100.0),
10051            )],
10052        };
10053        let md = adf_to_markdown(&doc).unwrap();
10054        assert!(
10055            md.contains("::embed[https://example.com]{layout=center originalHeight=732 width=100}"),
10056            "expected originalHeight and width in md: {md}"
10057        );
10058    }
10059
10060    #[test]
10061    fn embed_card_roundtrip_with_all_attrs() {
10062        let adf_json = r#"{"version":1,"type":"doc","content":[{
10063            "type":"embedCard",
10064            "attrs":{"layout":"center","originalHeight":732.0,"url":"https://example.com","width":100.0}
10065        }]}"#;
10066        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10067        let md = adf_to_markdown(&doc).unwrap();
10068        assert!(
10069            md.contains("originalHeight=732"),
10070            "originalHeight missing from md: {md}"
10071        );
10072        assert!(md.contains("width=100"), "width missing from md: {md}");
10073        let rt = markdown_to_adf(&md).unwrap();
10074        let attrs = rt.content[0].attrs.as_ref().unwrap();
10075        assert_eq!(attrs["originalHeight"], 732.0);
10076        assert_eq!(attrs["width"], 100.0);
10077        assert_eq!(attrs["layout"], "center");
10078        assert_eq!(attrs["url"], "https://example.com");
10079    }
10080
10081    #[test]
10082    fn embed_card_fractional_dimensions() {
10083        let doc = AdfDocument {
10084            version: 1,
10085            doc_type: "doc".to_string(),
10086            content: vec![AdfNode::embed_card(
10087                "https://example.com",
10088                Some("center"),
10089                Some(732.5),
10090                Some(99.9),
10091            )],
10092        };
10093        let md = adf_to_markdown(&doc).unwrap();
10094        assert!(
10095            md.contains("originalHeight=732.5"),
10096            "fractional originalHeight missing: {md}"
10097        );
10098        assert!(md.contains("width=99.9"), "fractional width missing: {md}");
10099        let rt = markdown_to_adf(&md).unwrap();
10100        let attrs = rt.content[0].attrs.as_ref().unwrap();
10101        assert_eq!(attrs["originalHeight"], 732.5);
10102        assert_eq!(attrs["width"], 99.9);
10103    }
10104
10105    #[test]
10106    fn embed_card_integer_width_in_json() {
10107        // JSON integer (not float) should also be extracted via as_f64()
10108        let adf_json = r#"{"version":1,"type":"doc","content":[{
10109            "type":"embedCard",
10110            "attrs":{"url":"https://example.com","width":100}
10111        }]}"#;
10112        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10113        let md = adf_to_markdown(&doc).unwrap();
10114        assert!(
10115            md.contains("width=100"),
10116            "integer width missing from md: {md}"
10117        );
10118        let rt = markdown_to_adf(&md).unwrap();
10119        assert_eq!(rt.content[0].attrs.as_ref().unwrap()["width"], 100.0);
10120    }
10121
10122    #[test]
10123    fn embed_card_only_original_height() {
10124        // originalHeight without width
10125        let adf_json = r#"{"version":1,"type":"doc","content":[{
10126            "type":"embedCard",
10127            "attrs":{"url":"https://example.com","originalHeight":500.0}
10128        }]}"#;
10129        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10130        let md = adf_to_markdown(&doc).unwrap();
10131        assert!(
10132            md.contains("originalHeight=500"),
10133            "originalHeight missing: {md}"
10134        );
10135        assert!(!md.contains("width="), "width should not appear: {md}");
10136        let rt = markdown_to_adf(&md).unwrap();
10137        let attrs = rt.content[0].attrs.as_ref().unwrap();
10138        assert_eq!(attrs["originalHeight"], 500.0);
10139        assert!(attrs.get("width").is_none());
10140    }
10141
10142    #[test]
10143    fn leaf_void_extension() {
10144        let doc = markdown_to_adf("::extension{type=com.atlassian.macro key=jira-chart}").unwrap();
10145        assert_eq!(doc.content[0].node_type, "extension");
10146        assert_eq!(
10147            doc.content[0].attrs.as_ref().unwrap()["extensionType"],
10148            "com.atlassian.macro"
10149        );
10150        assert_eq!(
10151            doc.content[0].attrs.as_ref().unwrap()["extensionKey"],
10152            "jira-chart"
10153        );
10154    }
10155
10156    #[test]
10157    fn adf_void_extension_to_markdown() {
10158        let doc = AdfDocument {
10159            version: 1,
10160            doc_type: "doc".to_string(),
10161            content: vec![AdfNode::extension(
10162                "com.atlassian.macro",
10163                "jira-chart",
10164                None,
10165            )],
10166        };
10167        let md = adf_to_markdown(&doc).unwrap();
10168        assert!(md.contains("::extension{type=com.atlassian.macro key=jira-chart}"));
10169    }
10170
10171    // ── Bare URL autolink tests ──────────────────────────────────────
10172
10173    #[test]
10174    fn bare_url_autolink() {
10175        let doc = markdown_to_adf("Visit https://example.com today").unwrap();
10176        let content = doc.content[0].content.as_ref().unwrap();
10177        assert_eq!(content[0].text.as_deref(), Some("Visit "));
10178        assert_eq!(content[1].node_type, "inlineCard");
10179        assert_eq!(
10180            content[1].attrs.as_ref().unwrap()["url"],
10181            "https://example.com"
10182        );
10183        assert_eq!(content[2].text.as_deref(), Some(" today"));
10184    }
10185
10186    #[test]
10187    fn bare_url_strips_trailing_punctuation() {
10188        let doc = markdown_to_adf("See https://example.com.").unwrap();
10189        let content = doc.content[0].content.as_ref().unwrap();
10190        assert_eq!(
10191            content[1].attrs.as_ref().unwrap()["url"],
10192            "https://example.com"
10193        );
10194    }
10195
10196    #[test]
10197    fn bare_url_round_trip() {
10198        let doc = markdown_to_adf("Visit https://example.com/path today").unwrap();
10199        let md = adf_to_markdown(&doc).unwrap();
10200        assert!(md.contains(":card[https://example.com/path]"));
10201    }
10202
10203    // ── Issue #475: plain-text URL must not become inlineCard ─────────
10204
10205    #[test]
10206    fn plain_text_url_round_trips_as_text() {
10207        // A text node whose content is a bare URL (no link mark) must
10208        // survive ADF→JFM→ADF as a text node, not an inlineCard.
10209        let adf_json = r#"{
10210            "version": 1,
10211            "type": "doc",
10212            "content": [{
10213                "type": "paragraph",
10214                "content": [
10215                    {"type": "text", "text": "https://example.com/some/path/to/resource"}
10216                ]
10217            }]
10218        }"#;
10219        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10220        let jfm = adf_to_markdown(&adf).unwrap();
10221        let roundtripped = markdown_to_adf(&jfm).unwrap();
10222        let content = roundtripped.content[0].content.as_ref().unwrap();
10223        assert_eq!(content.len(), 1, "should be a single node");
10224        assert_eq!(content[0].node_type, "text");
10225        assert_eq!(
10226            content[0].text.as_deref(),
10227            Some("https://example.com/some/path/to/resource")
10228        );
10229    }
10230
10231    #[test]
10232    fn url_text_with_link_mark_round_trips_as_text_node() {
10233        // Issue #523: A text node whose content is a URL with a link mark
10234        // (href differs by trailing slash) must round-trip as text+link,
10235        // not become an inlineCard.
10236        let adf_json = r#"{
10237            "version": 1,
10238            "type": "doc",
10239            "content": [{
10240                "type": "paragraph",
10241                "content": [{
10242                    "type": "text",
10243                    "text": "https://octopz.example.com",
10244                    "marks": [{"type": "link", "attrs": {"href": "https://octopz.example.com/"}}]
10245                }]
10246            }]
10247        }"#;
10248        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10249        let jfm = adf_to_markdown(&adf).unwrap();
10250        let roundtripped = markdown_to_adf(&jfm).unwrap();
10251        let content = roundtripped.content[0].content.as_ref().unwrap();
10252        assert_eq!(content.len(), 1, "should be a single node");
10253        assert_eq!(content[0].node_type, "text", "must be text, not inlineCard");
10254        assert_eq!(
10255            content[0].text.as_deref(),
10256            Some("https://octopz.example.com")
10257        );
10258        let mark = &content[0].marks.as_ref().unwrap()[0];
10259        assert_eq!(mark.mark_type, "link");
10260        assert_eq!(
10261            mark.attrs.as_ref().unwrap()["href"],
10262            "https://octopz.example.com/"
10263        );
10264    }
10265
10266    #[test]
10267    fn url_text_with_exact_link_mark_round_trips() {
10268        // Variant: text and href are identical (no trailing slash difference).
10269        let adf_json = r#"{
10270            "version": 1,
10271            "type": "doc",
10272            "content": [{
10273                "type": "paragraph",
10274                "content": [{
10275                    "type": "text",
10276                    "text": "https://example.com/path",
10277                    "marks": [{"type": "link", "attrs": {"href": "https://example.com/path"}}]
10278                }]
10279            }]
10280        }"#;
10281        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10282        let jfm = adf_to_markdown(&adf).unwrap();
10283        let roundtripped = markdown_to_adf(&jfm).unwrap();
10284        let content = roundtripped.content[0].content.as_ref().unwrap();
10285        assert_eq!(content.len(), 1, "should be a single node");
10286        assert_eq!(content[0].node_type, "text");
10287        assert_eq!(content[0].text.as_deref(), Some("https://example.com/path"));
10288        let mark = &content[0].marks.as_ref().unwrap()[0];
10289        assert_eq!(mark.mark_type, "link");
10290    }
10291
10292    #[test]
10293    fn plain_text_url_amid_text_round_trips() {
10294        // URL embedded in surrounding text, without link mark.
10295        let adf_json = r#"{
10296            "version": 1,
10297            "type": "doc",
10298            "content": [{
10299                "type": "paragraph",
10300                "content": [
10301                    {"type": "text", "text": "see https://example.com for info"}
10302                ]
10303            }]
10304        }"#;
10305        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10306        let jfm = adf_to_markdown(&adf).unwrap();
10307        let roundtripped = markdown_to_adf(&jfm).unwrap();
10308        let content = roundtripped.content[0].content.as_ref().unwrap();
10309        assert_eq!(content.len(), 1);
10310        assert_eq!(content[0].node_type, "text");
10311        assert_eq!(
10312            content[0].text.as_deref(),
10313            Some("see https://example.com for info")
10314        );
10315    }
10316
10317    #[test]
10318    fn plain_text_multiple_urls_round_trips() {
10319        let adf_json = r#"{
10320            "version": 1,
10321            "type": "doc",
10322            "content": [{
10323                "type": "paragraph",
10324                "content": [
10325                    {"type": "text", "text": "http://a.com and https://b.com"}
10326                ]
10327            }]
10328        }"#;
10329        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10330        let jfm = adf_to_markdown(&adf).unwrap();
10331        let roundtripped = markdown_to_adf(&jfm).unwrap();
10332        let content = roundtripped.content[0].content.as_ref().unwrap();
10333        assert_eq!(content.len(), 1);
10334        assert_eq!(content[0].node_type, "text");
10335        assert_eq!(
10336            content[0].text.as_deref(),
10337            Some("http://a.com and https://b.com")
10338        );
10339    }
10340
10341    #[test]
10342    fn plain_text_http_prefix_no_url_unchanged() {
10343        // "http" without "://" should not be escaped or altered.
10344        let adf_json = r#"{
10345            "version": 1,
10346            "type": "doc",
10347            "content": [{
10348                "type": "paragraph",
10349                "content": [
10350                    {"type": "text", "text": "the http header is important"}
10351                ]
10352            }]
10353        }"#;
10354        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10355        let jfm = adf_to_markdown(&adf).unwrap();
10356        let roundtripped = markdown_to_adf(&jfm).unwrap();
10357        let content = roundtripped.content[0].content.as_ref().unwrap();
10358        assert_eq!(
10359            content[0].text.as_deref(),
10360            Some("the http header is important")
10361        );
10362    }
10363
10364    #[test]
10365    fn linked_url_text_round_trips() {
10366        // A text node that is exactly a URL with a link mark pointing to the
10367        // same URL must round-trip as a single text node with a link mark
10368        // (no inlineCard, no lost/split content).
10369        let adf_json = r#"{
10370            "version": 1,
10371            "type": "doc",
10372            "content": [{
10373                "type": "paragraph",
10374                "content": [{
10375                    "type": "text",
10376                    "text": "https://example.com",
10377                    "marks": [{"type": "link", "attrs": {"href": "https://example.com"}}]
10378                }]
10379            }]
10380        }"#;
10381        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10382        let jfm = adf_to_markdown(&adf).unwrap();
10383        let roundtripped = markdown_to_adf(&jfm).unwrap();
10384        let content = roundtripped.content[0].content.as_ref().unwrap();
10385        assert_eq!(content.len(), 1);
10386        assert_eq!(content[0].node_type, "text");
10387        assert_eq!(content[0].text.as_deref(), Some("https://example.com"));
10388        let mark = &content[0].marks.as_ref().unwrap()[0];
10389        assert_eq!(mark.mark_type, "link");
10390        assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
10391    }
10392
10393    // ── Issue #493: bracket-link ambiguity ─────────────────────────────
10394
10395    #[test]
10396    fn escape_link_brackets_unit() {
10397        assert_eq!(escape_link_brackets("hello"), "hello");
10398        assert_eq!(escape_link_brackets("["), "\\[");
10399        assert_eq!(escape_link_brackets("]"), "\\]");
10400        assert_eq!(escape_link_brackets("[PROJ-456]"), "\\[PROJ-456\\]");
10401        assert_eq!(escape_link_brackets("a[b]c"), "a\\[b\\]c");
10402    }
10403
10404    #[test]
10405    fn bracket_text_with_link_mark_escapes_brackets() {
10406        // A text node whose content is "[" with a link mark should escape
10407        // the bracket so it does not create ambiguous markdown link syntax.
10408        let doc = AdfDocument {
10409            version: 1,
10410            doc_type: "doc".to_string(),
10411            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10412                "[",
10413                vec![AdfMark::link("https://example.com")],
10414            )])],
10415        };
10416        let md = adf_to_markdown(&doc).unwrap();
10417        assert_eq!(md.trim(), "[\\[](https://example.com)");
10418    }
10419
10420    #[test]
10421    fn bracket_text_with_link_mark_round_trips() {
10422        // Issue #493 reproducer: adjacent text nodes sharing a link mark
10423        // where the first node's content is "[".
10424        let adf_json = r#"{
10425            "type": "doc",
10426            "version": 1,
10427            "content": [{
10428                "type": "paragraph",
10429                "content": [
10430                    {
10431                        "type": "text",
10432                        "text": "[",
10433                        "marks": [{"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}]
10434                    },
10435                    {
10436                        "type": "text",
10437                        "text": "PROJ-456] Fix the auth bug",
10438                        "marks": [
10439                            {"type": "underline"},
10440                            {"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}
10441                        ]
10442                    }
10443                ]
10444            }]
10445        }"#;
10446        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10447        let jfm = adf_to_markdown(&adf).unwrap();
10448
10449        // The markdown should contain escaped brackets inside the link
10450        assert!(jfm.contains("\\["), "opening bracket should be escaped");
10451
10452        // Round-trip: both text nodes must survive with link marks
10453        let rt = markdown_to_adf(&jfm).unwrap();
10454        let content = rt.content[0].content.as_ref().unwrap();
10455
10456        // All text nodes that were part of the link must still carry a link mark
10457        let link_nodes: Vec<_> = content
10458            .iter()
10459            .filter(|n| {
10460                n.marks
10461                    .as_ref()
10462                    .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
10463            })
10464            .collect();
10465        assert!(
10466            !link_nodes.is_empty(),
10467            "link mark must be preserved on round-trip"
10468        );
10469
10470        // The combined text across all nodes should contain the original content
10471        let all_text: String = content.iter().filter_map(|n| n.text.as_deref()).collect();
10472        assert!(
10473            all_text.contains('['),
10474            "literal '[' must survive round-trip"
10475        );
10476        assert!(
10477            all_text.contains("PROJ-456]"),
10478            "continuation text must survive round-trip"
10479        );
10480    }
10481
10482    #[test]
10483    fn closing_bracket_in_link_text_round_trips() {
10484        // A text node containing "]" inside a link should be escaped and
10485        // survive round-trip without breaking the link syntax.
10486        let doc = AdfDocument {
10487            version: 1,
10488            doc_type: "doc".to_string(),
10489            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10490                "item]",
10491                vec![AdfMark::link("https://example.com")],
10492            )])],
10493        };
10494        let md = adf_to_markdown(&doc).unwrap();
10495        assert_eq!(md.trim(), "[item\\]](https://example.com)");
10496
10497        let rt = markdown_to_adf(&md).unwrap();
10498        let content = rt.content[0].content.as_ref().unwrap();
10499        assert_eq!(content[0].text.as_deref(), Some("item]"));
10500        assert!(content[0]
10501            .marks
10502            .as_ref()
10503            .unwrap()
10504            .iter()
10505            .any(|m| m.mark_type == "link"));
10506    }
10507
10508    #[test]
10509    fn brackets_in_link_text_round_trip() {
10510        // Text containing both [ and ] inside a link should round-trip.
10511        let doc = AdfDocument {
10512            version: 1,
10513            doc_type: "doc".to_string(),
10514            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10515                "[PROJ-123]",
10516                vec![AdfMark::link("https://example.com")],
10517            )])],
10518        };
10519        let md = adf_to_markdown(&doc).unwrap();
10520        assert_eq!(md.trim(), "[\\[PROJ-123\\]](https://example.com)");
10521
10522        let rt = markdown_to_adf(&md).unwrap();
10523        let content = rt.content[0].content.as_ref().unwrap();
10524        assert_eq!(content[0].text.as_deref(), Some("[PROJ-123]"));
10525        assert!(content[0]
10526            .marks
10527            .as_ref()
10528            .unwrap()
10529            .iter()
10530            .any(|m| m.mark_type == "link"));
10531    }
10532
10533    #[test]
10534    fn plain_text_brackets_not_escaped() {
10535        // Brackets in plain text (no link mark) must NOT be escaped.
10536        let doc = AdfDocument {
10537            version: 1,
10538            doc_type: "doc".to_string(),
10539            content: vec![AdfNode::paragraph(vec![AdfNode::text(
10540                "see [PROJ-123] for details",
10541            )])],
10542        };
10543        let md = adf_to_markdown(&doc).unwrap();
10544        assert_eq!(md.trim(), "see [PROJ-123] for details");
10545    }
10546
10547    #[test]
10548    fn link_with_no_brackets_unchanged() {
10549        // A normal link with no brackets in the text should be unaffected.
10550        let doc = AdfDocument {
10551            version: 1,
10552            doc_type: "doc".to_string(),
10553            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10554                "click here",
10555                vec![AdfMark::link("https://example.com")],
10556            )])],
10557        };
10558        let md = adf_to_markdown(&doc).unwrap();
10559        assert_eq!(md.trim(), "[click here](https://example.com)");
10560    }
10561
10562    // ── Issue #551: URL brackets in link-marked text round-trip ────────
10563
10564    #[test]
10565    fn url_with_brackets_as_link_text_round_trips() {
10566        // Issue #551: a text node whose content is a URL containing square
10567        // brackets and which carries a link mark must round-trip verbatim.
10568        // Previously the URL-as-link-text fast path preserved `\[` and `\]`
10569        // escapes in the emitted text, corrupting the text content.
10570        let href = "https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10571        let doc = AdfDocument {
10572            version: 1,
10573            doc_type: "doc".to_string(),
10574            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10575                href,
10576                vec![AdfMark::link(href)],
10577            )])],
10578        };
10579        let md = adf_to_markdown(&doc).unwrap();
10580        let rt = markdown_to_adf(&md).unwrap();
10581        let content = rt.content[0].content.as_ref().unwrap();
10582        assert_eq!(content.len(), 1);
10583        assert_eq!(content[0].node_type, "text");
10584        assert_eq!(content[0].text.as_deref(), Some(href));
10585        let mark = &content[0].marks.as_ref().unwrap()[0];
10586        assert_eq!(mark.mark_type, "link");
10587        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10588    }
10589
10590    #[test]
10591    fn url_with_brackets_embedded_in_link_text_round_trips() {
10592        // Issue #551 updated reproducer: a link-marked text node containing
10593        // both prose and an embedded URL with brackets must round-trip
10594        // without the embedded URL being detected as a bare-URL inlineCard
10595        // or the brackets terminating the link syntax early.  This mirrors
10596        // the comment reproducer which uses an ellipsis character between
10597        // the brackets and a distinct href value.
10598        let href = "https://example.com/logs?query=service%20environment%20data&from=100&to=200";
10599        let text =
10600            "See the logs: https://example.com/logs?query=service[\u{2026}]data&from=100&to=200";
10601        let doc = AdfDocument {
10602            version: 1,
10603            doc_type: "doc".to_string(),
10604            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10605                text,
10606                vec![AdfMark::link(href)],
10607            )])],
10608        };
10609        let md = adf_to_markdown(&doc).unwrap();
10610        let rt = markdown_to_adf(&md).unwrap();
10611        let content = rt.content[0].content.as_ref().unwrap();
10612        assert_eq!(content.len(), 1, "content split unexpectedly: {content:?}");
10613        assert_eq!(content[0].node_type, "text");
10614        assert_eq!(content[0].text.as_deref(), Some(text));
10615        let mark = &content[0].marks.as_ref().unwrap()[0];
10616        assert_eq!(mark.mark_type, "link");
10617        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10618    }
10619
10620    #[test]
10621    fn url_with_brackets_plain_text_round_trips() {
10622        // Issue #551 original reproducer: plain text with an embedded URL
10623        // that contains square brackets must round-trip verbatim.
10624        let text =
10625            "See the dashboard: https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10626        let doc = AdfDocument {
10627            version: 1,
10628            doc_type: "doc".to_string(),
10629            content: vec![AdfNode::paragraph(vec![AdfNode::text(text)])],
10630        };
10631        let md = adf_to_markdown(&doc).unwrap();
10632        let rt = markdown_to_adf(&md).unwrap();
10633        let content = rt.content[0].content.as_ref().unwrap();
10634        assert_eq!(content.len(), 1);
10635        assert_eq!(content[0].node_type, "text");
10636        assert_eq!(content[0].text.as_deref(), Some(text));
10637        assert!(content[0].marks.is_none());
10638    }
10639
10640    #[test]
10641    fn url_with_link_mark_embedded_no_brackets_round_trips() {
10642        // Regression guard: embedding a bare URL inside link-marked text
10643        // (no brackets) must not create an inlineCard on round-trip.
10644        let href = "https://example.com/";
10645        let text = "See https://example.com/ for more";
10646        let doc = AdfDocument {
10647            version: 1,
10648            doc_type: "doc".to_string(),
10649            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10650                text,
10651                vec![AdfMark::link(href)],
10652            )])],
10653        };
10654        let md = adf_to_markdown(&doc).unwrap();
10655        let rt = markdown_to_adf(&md).unwrap();
10656        let content = rt.content[0].content.as_ref().unwrap();
10657        assert_eq!(content.len(), 1);
10658        assert_eq!(content[0].node_type, "text");
10659        assert_eq!(content[0].text.as_deref(), Some(text));
10660        let mark = &content[0].marks.as_ref().unwrap()[0];
10661        assert_eq!(mark.mark_type, "link");
10662        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10663    }
10664
10665    #[test]
10666    fn nested_brackets_in_link_text_round_trip() {
10667        // Regression guard: nested brackets in link-marked text must
10668        // round-trip without corrupting the content.
10669        let href = "https://x.com";
10670        let text = "foo [a[b]c] bar";
10671        let doc = AdfDocument {
10672            version: 1,
10673            doc_type: "doc".to_string(),
10674            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10675                text,
10676                vec![AdfMark::link(href)],
10677            )])],
10678        };
10679        let md = adf_to_markdown(&doc).unwrap();
10680        let rt = markdown_to_adf(&md).unwrap();
10681        let content = rt.content[0].content.as_ref().unwrap();
10682        assert_eq!(content.len(), 1);
10683        assert_eq!(content[0].node_type, "text");
10684        assert_eq!(content[0].text.as_deref(), Some(text));
10685    }
10686
10687    #[test]
10688    fn bracket_url_bracket_in_link_text_round_trips() {
10689        // Regression guard: a link-marked text containing brackets on both
10690        // sides of an embedded URL (with brackets of its own) must
10691        // round-trip intact.  This exercises interaction between the
10692        // URL-as-link-text fast path, bare-URL detection, and bracket
10693        // escape handling.
10694        let href = "https://y.com";
10695        let text = "[see https://x.com/a[0]=1 here]";
10696        let doc = AdfDocument {
10697            version: 1,
10698            doc_type: "doc".to_string(),
10699            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10700                text,
10701                vec![AdfMark::link(href)],
10702            )])],
10703        };
10704        let md = adf_to_markdown(&doc).unwrap();
10705        let rt = markdown_to_adf(&md).unwrap();
10706        let content = rt.content[0].content.as_ref().unwrap();
10707        assert_eq!(content.len(), 1);
10708        assert_eq!(content[0].node_type, "text");
10709        assert_eq!(content[0].text.as_deref(), Some(text));
10710        let mark = &content[0].marks.as_ref().unwrap()[0];
10711        assert_eq!(mark.mark_type, "link");
10712        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10713    }
10714
10715    #[test]
10716    fn escape_bare_urls_applied_inside_link_text() {
10717        // White-box: when a text node carries a link mark, bare URLs in the
10718        // text must still be escaped with `\h` so the parser does not
10719        // auto-link them into an inlineCard inside the link.  Without this,
10720        // round-trip of link-marked prose containing an embedded URL
10721        // silently corrupts on re-parse (issue #551).
10722        let doc = AdfDocument {
10723            version: 1,
10724            doc_type: "doc".to_string(),
10725            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10726                "See https://example.com/",
10727                vec![AdfMark::link("https://target.example.com/")],
10728            )])],
10729        };
10730        let md = adf_to_markdown(&doc).unwrap();
10731        assert!(
10732            md.contains(r"\https://example.com/"),
10733            "bare URL inside link text must be escaped, got: {md}"
10734        );
10735    }
10736
10737    #[test]
10738    fn inline_card_still_round_trips() {
10739        // An actual inlineCard node should still round-trip correctly
10740        // (it uses :card[url] syntax, not bare URL).
10741        let adf_json = r#"{
10742            "version": 1,
10743            "type": "doc",
10744            "content": [{
10745                "type": "paragraph",
10746                "content": [
10747                    {"type": "inlineCard", "attrs": {"url": "https://example.com/page"}}
10748                ]
10749            }]
10750        }"#;
10751        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10752        let jfm = adf_to_markdown(&adf).unwrap();
10753        assert!(jfm.contains(":card[https://example.com/page]"));
10754        let roundtripped = markdown_to_adf(&jfm).unwrap();
10755        let content = roundtripped.content[0].content.as_ref().unwrap();
10756        assert_eq!(content[0].node_type, "inlineCard");
10757        assert_eq!(
10758            content[0].attrs.as_ref().unwrap()["url"],
10759            "https://example.com/page"
10760        );
10761    }
10762
10763    // ── Issue #553: inlineCard round-trip with problematic URLs ───────
10764
10765    #[test]
10766    fn url_safe_in_bracket_content_balanced() {
10767        // Balanced brackets — depth never returns to zero mid-string.
10768        assert!(url_safe_in_bracket_content("https://example.com"));
10769        assert!(url_safe_in_bracket_content("https://example.com/[id]"));
10770        assert!(url_safe_in_bracket_content("a[b[c]d]e"));
10771        assert!(url_safe_in_bracket_content(""));
10772    }
10773
10774    #[test]
10775    fn url_safe_in_bracket_content_unbalanced() {
10776        // A `]` with no prior `[` would close `:card[...]` early.
10777        assert!(!url_safe_in_bracket_content("a]b"));
10778        assert!(!url_safe_in_bracket_content("https://example.com/path]end"));
10779        // Embedded newline breaks inline directive parsing.
10780        assert!(!url_safe_in_bracket_content("a\nb"));
10781    }
10782
10783    #[test]
10784    fn inline_card_url_with_closing_bracket_round_trip() {
10785        // Issue #553 defensive fix: a URL that contains `]` (unbalanced) must
10786        // round-trip without truncation.  The renderer must switch to the
10787        // quoted attribute form `:card[]{url="..."}` so the parser's
10788        // depth-based bracket matcher does not terminate the directive early.
10789        let adf_json = r#"{
10790            "version": 1,
10791            "type": "doc",
10792            "content": [{
10793                "type": "paragraph",
10794                "content": [
10795                    {"type": "text", "text": "See: "},
10796                    {"type": "inlineCard", "attrs": {"url": "https://example.com/path]end/?q=1"}}
10797                ]
10798            }]
10799        }"#;
10800        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10801        let jfm = adf_to_markdown(&adf).unwrap();
10802        assert!(
10803            jfm.contains(r#":card[]{url="https://example.com/path]end/?q=1"}"#),
10804            "expected attr-form for URL with `]`, got: {jfm}"
10805        );
10806        let rt = markdown_to_adf(&jfm).unwrap();
10807        let content = rt.content[0].content.as_ref().unwrap();
10808        assert_eq!(content.len(), 2, "expected 2 inline nodes, got {content:?}");
10809        assert_eq!(content[0].node_type, "text");
10810        assert_eq!(content[0].text.as_deref(), Some("See: "));
10811        assert_eq!(content[1].node_type, "inlineCard");
10812        assert_eq!(
10813            content[1].attrs.as_ref().unwrap()["url"],
10814            "https://example.com/path]end/?q=1"
10815        );
10816    }
10817
10818    #[test]
10819    fn inline_card_url_with_closing_bracket_preserves_local_id() {
10820        // Attr-form `:card[]{url=... localId=...}` must preserve localId too.
10821        let adf_json = r#"{
10822            "version": 1,
10823            "type": "doc",
10824            "content": [{
10825                "type": "paragraph",
10826                "content": [
10827                    {"type": "inlineCard", "attrs": {
10828                        "url": "https://example.com/a]b",
10829                        "localId": "c-77"
10830                    }}
10831                ]
10832            }]
10833        }"#;
10834        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10835        let jfm = adf_to_markdown(&adf).unwrap();
10836        assert!(
10837            jfm.contains(r#"url="https://example.com/a]b""#),
10838            "jfm: {jfm}"
10839        );
10840        assert!(jfm.contains("localId=c-77"), "jfm: {jfm}");
10841        let rt = markdown_to_adf(&jfm).unwrap();
10842        let card = &rt.content[0].content.as_ref().unwrap()[0];
10843        assert_eq!(card.node_type, "inlineCard");
10844        assert_eq!(
10845            card.attrs.as_ref().unwrap()["url"],
10846            "https://example.com/a]b"
10847        );
10848        assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-77");
10849    }
10850
10851    #[test]
10852    fn block_card_url_with_closing_bracket_round_trip() {
10853        // Same defensive fix applied to the leaf directive `::card`.
10854        let adf_json = r#"{
10855            "version": 1,
10856            "type": "doc",
10857            "content": [
10858                {"type": "blockCard", "attrs": {"url": "https://example.com/path]end"}}
10859            ]
10860        }"#;
10861        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10862        let jfm = adf_to_markdown(&adf).unwrap();
10863        assert!(
10864            jfm.contains(r#"::card[]{url="https://example.com/path]end"}"#),
10865            "expected attr-form for blockCard with `]`, got: {jfm}"
10866        );
10867        let rt = markdown_to_adf(&jfm).unwrap();
10868        assert_eq!(rt.content[0].node_type, "blockCard");
10869        assert_eq!(
10870            rt.content[0].attrs.as_ref().unwrap()["url"],
10871            "https://example.com/path]end"
10872        );
10873    }
10874
10875    #[test]
10876    fn block_card_attr_form_parses_without_renderer() {
10877        // Directly parsing `::card[]{url="..."}` exercises the attr-URL
10878        // fallback in the leaf-directive dispatcher (covers the `url` lookup
10879        // path independently of the ADF→JFM renderer).
10880        let doc = markdown_to_adf(r#"::card[]{url="https://example.com/a"}"#).unwrap();
10881        assert_eq!(doc.content[0].node_type, "blockCard");
10882        assert_eq!(
10883            doc.content[0].attrs.as_ref().unwrap()["url"],
10884            "https://example.com/a"
10885        );
10886    }
10887
10888    #[test]
10889    fn block_card_attr_form_url_overrides_content() {
10890        // When both bracket-content and `url=` attribute are present on
10891        // `::card`, the attribute wins.  Mirrors the inline-directive
10892        // behaviour and keeps hand-edited JFM forgiving.
10893        let doc =
10894            markdown_to_adf(r#"::card[https://old.example.com]{url="https://new.example.com"}"#)
10895                .unwrap();
10896        assert_eq!(doc.content[0].node_type, "blockCard");
10897        assert_eq!(
10898            doc.content[0].attrs.as_ref().unwrap()["url"],
10899            "https://new.example.com"
10900        );
10901    }
10902
10903    #[test]
10904    fn block_card_attr_form_with_layout_and_width() {
10905        // Attr-URL form combined with layout/width attrs — ensures all
10906        // sibling attrs still pass through after the URL lookup.
10907        let doc =
10908            markdown_to_adf(r#"::card[]{url="https://example.com/a]b" layout=wide width=80}"#)
10909                .unwrap();
10910        let attrs = doc.content[0].attrs.as_ref().unwrap();
10911        assert_eq!(attrs["url"], "https://example.com/a]b");
10912        assert_eq!(attrs["layout"], "wide");
10913        assert_eq!(attrs["width"], 80);
10914    }
10915
10916    #[test]
10917    fn inline_card_issue_553_reproducer() {
10918        // Verbatim reproducer from issue #553: an inlineCard in a paragraph
10919        // with preceding text must round-trip as an inlineCard, not degrade to
10920        // a text node with a link mark.
10921        let adf_json = r#"{
10922            "version": 1,
10923            "type": "doc",
10924            "content": [{
10925                "type": "paragraph",
10926                "content": [
10927                    {"type": "text", "text": "See the related page: "},
10928                    {"type": "inlineCard", "attrs": {
10929                        "url": "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10930                    }}
10931                ]
10932            }]
10933        }"#;
10934        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10935        let jfm = adf_to_markdown(&adf).unwrap();
10936        let rt = markdown_to_adf(&jfm).unwrap();
10937        let content = rt.content[0].content.as_ref().unwrap();
10938        assert_eq!(content.len(), 2);
10939        assert_eq!(content[0].node_type, "text");
10940        assert_eq!(content[1].node_type, "inlineCard");
10941        assert_eq!(
10942            content[1].attrs.as_ref().unwrap()["url"],
10943            "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10944        );
10945    }
10946
10947    #[test]
10948    fn inline_card_attr_form_parses_even_without_renderer() {
10949        // Directly parsing `:card[]{url="..."}` should yield an inlineCard.
10950        let doc = markdown_to_adf(r#":card[]{url="https://example.com/a"}"#).unwrap();
10951        let node = &doc.content[0].content.as_ref().unwrap()[0];
10952        assert_eq!(node.node_type, "inlineCard");
10953        assert_eq!(node.attrs.as_ref().unwrap()["url"], "https://example.com/a");
10954    }
10955
10956    #[test]
10957    fn inline_card_attr_form_url_overrides_content() {
10958        // When both bracket-content and `url=` attr are present, attr wins.
10959        // This keeps the parser forgiving of hand-edited JFM where a user
10960        // copied an old bracket form but added attrs.
10961        let doc =
10962            markdown_to_adf(r#":card[https://old.example.com]{url="https://new.example.com"}"#)
10963                .unwrap();
10964        let node = &doc.content[0].content.as_ref().unwrap()[0];
10965        assert_eq!(node.node_type, "inlineCard");
10966        assert_eq!(
10967            node.attrs.as_ref().unwrap()["url"],
10968            "https://new.example.com"
10969        );
10970    }
10971
10972    // ── Issue #553 (updated): mark-wrapped URL must not become inlineCard ──
10973
10974    #[test]
10975    fn url_with_link_and_underline_marks_round_trip() {
10976        // Issue #553 (updated reproducer): a `text` node whose content is a
10977        // URL and that carries both `link` and `underline` marks must round-
10978        // trip as text+marks, not be promoted to an `inlineCard`.
10979        let adf_json = r#"{
10980            "version": 1,
10981            "type": "doc",
10982            "content": [{
10983                "type": "paragraph",
10984                "content": [
10985                    {"type": "text", "text": "See results at: "},
10986                    {"type": "text",
10987                     "text": "https://example.com/projects/abc123/analytics",
10988                     "marks": [
10989                        {"type": "link", "attrs": {"href": "https://example.com/projects/abc123/analytics"}},
10990                        {"type": "underline"}
10991                     ]},
10992                    {"type": "text", "text": " for details."}
10993                ]
10994            }]
10995        }"#;
10996        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10997        let jfm = adf_to_markdown(&adf).unwrap();
10998        let rt = markdown_to_adf(&jfm).unwrap();
10999        let content = rt.content[0].content.as_ref().unwrap();
11000        assert_eq!(
11001            content.len(),
11002            3,
11003            "expected 3 inline nodes, got: {content:?}"
11004        );
11005        assert_eq!(content[0].node_type, "text");
11006        assert_eq!(
11007            content[1].node_type, "text",
11008            "must stay text, not inlineCard"
11009        );
11010        assert_eq!(
11011            content[1].text.as_deref(),
11012            Some("https://example.com/projects/abc123/analytics")
11013        );
11014        let mark_types: Vec<&str> = content[1]
11015            .marks
11016            .as_deref()
11017            .unwrap_or(&[])
11018            .iter()
11019            .map(|m| m.mark_type.as_str())
11020            .collect();
11021        assert_eq!(mark_types, vec!["link", "underline"], "marks lost");
11022        assert_eq!(content[2].node_type, "text");
11023    }
11024
11025    #[test]
11026    fn url_inside_bracketed_span_stays_text() {
11027        // `[URL]{underline}` in JFM means "underline this URL text", not
11028        // "create a smart link that's underlined".  The nested parse_inline
11029        // call must not auto-promote the bare URL to an inlineCard.
11030        let doc = markdown_to_adf("[https://example.com]{underline}").unwrap();
11031        let node = &doc.content[0].content.as_ref().unwrap()[0];
11032        assert_eq!(node.node_type, "text");
11033        assert_eq!(node.text.as_deref(), Some("https://example.com"));
11034        let mark_types: Vec<&str> = node
11035            .marks
11036            .as_deref()
11037            .unwrap_or(&[])
11038            .iter()
11039            .map(|m| m.mark_type.as_str())
11040            .collect();
11041        assert_eq!(mark_types, vec!["underline"]);
11042    }
11043
11044    #[test]
11045    fn url_inside_emphasis_stays_text() {
11046        // Bold, italic, and strike-wrapped URLs should remain as text nodes,
11047        // not get promoted to inlineCards by the nested inline parser.
11048        for (md, mark) in [
11049            ("**https://example.com**", "strong"),
11050            ("*https://example.com*", "em"),
11051            ("~~https://example.com~~", "strike"),
11052        ] {
11053            let doc = markdown_to_adf(md).unwrap();
11054            let node = &doc.content[0].content.as_ref().unwrap()[0];
11055            assert_eq!(node.node_type, "text", "md={md}: must be text");
11056            assert_eq!(node.text.as_deref(), Some("https://example.com"));
11057            let mark_types: Vec<&str> = node
11058                .marks
11059                .as_deref()
11060                .unwrap_or(&[])
11061                .iter()
11062                .map(|m| m.mark_type.as_str())
11063                .collect();
11064            assert_eq!(mark_types, vec![mark], "md={md}: wrong marks");
11065        }
11066    }
11067
11068    #[test]
11069    fn url_inside_span_directive_stays_text() {
11070        // `:span[URL]{color=red}` should not promote the URL to an inlineCard.
11071        let doc = markdown_to_adf(":span[https://example.com]{color=red}").unwrap();
11072        let node = &doc.content[0].content.as_ref().unwrap()[0];
11073        assert_eq!(node.node_type, "text");
11074        assert_eq!(node.text.as_deref(), Some("https://example.com"));
11075        let mark = &node.marks.as_ref().unwrap()[0];
11076        assert_eq!(mark.mark_type, "textColor");
11077    }
11078
11079    #[test]
11080    fn url_as_link_text_with_underline_after_link_mark_order() {
11081        // Reverse mark order — underline appears BEFORE link in the ADF array.
11082        // The JFM form is `[[text](url)]{underline}`; the nested parser must
11083        // still keep the URL as plain text.
11084        let adf_json = r#"{
11085            "version": 1,
11086            "type": "doc",
11087            "content": [{
11088                "type": "paragraph",
11089                "content": [
11090                    {"type": "text",
11091                     "text": "https://example.com",
11092                     "marks": [
11093                        {"type": "underline"},
11094                        {"type": "link", "attrs": {"href": "https://example.com"}}
11095                     ]}
11096                ]
11097            }]
11098        }"#;
11099        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
11100        let jfm = adf_to_markdown(&adf).unwrap();
11101        let rt = markdown_to_adf(&jfm).unwrap();
11102        let node = &rt.content[0].content.as_ref().unwrap()[0];
11103        assert_eq!(node.node_type, "text", "must stay text, got: {node:?}");
11104        assert_eq!(node.text.as_deref(), Some("https://example.com"));
11105        let mark_types: Vec<&str> = node
11106            .marks
11107            .as_deref()
11108            .unwrap_or(&[])
11109            .iter()
11110            .map(|m| m.mark_type.as_str())
11111            .collect();
11112        assert_eq!(mark_types, vec!["underline", "link"]);
11113    }
11114
11115    #[test]
11116    fn bare_url_at_top_level_still_becomes_inline_card() {
11117        // Regression guard: the suppression only applies inside mark-wrapping
11118        // constructs.  A bare URL in ordinary paragraph text must still be
11119        // detected and promoted to an inlineCard.
11120        let doc = markdown_to_adf("Visit https://example.com today").unwrap();
11121        let content = doc.content[0].content.as_ref().unwrap();
11122        assert_eq!(content.len(), 3);
11123        assert_eq!(content[0].node_type, "text");
11124        assert_eq!(content[1].node_type, "inlineCard");
11125        assert_eq!(
11126            content[1].attrs.as_ref().unwrap()["url"],
11127            "https://example.com"
11128        );
11129        assert_eq!(content[2].node_type, "text");
11130    }
11131
11132    // ── Block-level attribute marks (Tier 5/6) ───────────────────────
11133
11134    #[test]
11135    fn paragraph_align_center() {
11136        let md = "Centered text.\n{align=center}";
11137        let doc = markdown_to_adf(md).unwrap();
11138        let marks = doc.content[0].marks.as_ref().unwrap();
11139        assert_eq!(marks[0].mark_type, "alignment");
11140        assert_eq!(marks[0].attrs.as_ref().unwrap()["align"], "center");
11141    }
11142
11143    #[test]
11144    fn adf_alignment_to_markdown() {
11145        let mut node = AdfNode::paragraph(vec![AdfNode::text("Centered.")]);
11146        node.marks = Some(vec![AdfMark::alignment("center")]);
11147        let doc = AdfDocument {
11148            version: 1,
11149            doc_type: "doc".to_string(),
11150            content: vec![node],
11151        };
11152        let md = adf_to_markdown(&doc).unwrap();
11153        assert!(md.contains("Centered."));
11154        assert!(md.contains("{align=center}"));
11155    }
11156
11157    #[test]
11158    fn round_trip_alignment() {
11159        let md = "Centered.\n{align=center}\n";
11160        let doc = markdown_to_adf(md).unwrap();
11161        let result = adf_to_markdown(&doc).unwrap();
11162        assert!(result.contains("{align=center}"));
11163    }
11164
11165    #[test]
11166    fn paragraph_indent() {
11167        let md = "Indented.\n{indent=2}";
11168        let doc = markdown_to_adf(md).unwrap();
11169        let marks = doc.content[0].marks.as_ref().unwrap();
11170        assert_eq!(marks[0].mark_type, "indentation");
11171        assert_eq!(marks[0].attrs.as_ref().unwrap()["level"], 2);
11172    }
11173
11174    #[test]
11175    fn code_block_breakout() {
11176        let md = "```python\ndef f(): pass\n```\n{breakout=wide}";
11177        let doc = markdown_to_adf(md).unwrap();
11178        let marks = doc.content[0].marks.as_ref().unwrap();
11179        assert_eq!(marks[0].mark_type, "breakout");
11180        assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
11181        assert!(marks[0].attrs.as_ref().unwrap().get("width").is_none());
11182    }
11183
11184    #[test]
11185    fn code_block_breakout_with_width() {
11186        let md = "```python\ndef f(): pass\n```\n{breakout=wide breakoutWidth=1200}";
11187        let doc = markdown_to_adf(md).unwrap();
11188        let marks = doc.content[0].marks.as_ref().unwrap();
11189        assert_eq!(marks[0].mark_type, "breakout");
11190        assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
11191        assert_eq!(marks[0].attrs.as_ref().unwrap()["width"], 1200);
11192    }
11193
11194    #[test]
11195    fn adf_breakout_to_markdown() {
11196        let mut node = AdfNode::code_block(Some("python"), "pass");
11197        node.marks = Some(vec![AdfMark::breakout("wide", None)]);
11198        let doc = AdfDocument {
11199            version: 1,
11200            doc_type: "doc".to_string(),
11201            content: vec![node],
11202        };
11203        let md = adf_to_markdown(&doc).unwrap();
11204        assert!(md.contains("{breakout=wide}"));
11205        assert!(!md.contains("breakoutWidth"));
11206    }
11207
11208    #[test]
11209    fn adf_breakout_with_width_to_markdown() {
11210        let mut node = AdfNode::code_block(Some("python"), "pass");
11211        node.marks = Some(vec![AdfMark::breakout("wide", Some(1200))]);
11212        let doc = AdfDocument {
11213            version: 1,
11214            doc_type: "doc".to_string(),
11215            content: vec![node],
11216        };
11217        let md = adf_to_markdown(&doc).unwrap();
11218        assert!(md.contains("breakout=wide"));
11219        assert!(md.contains("breakoutWidth=1200"));
11220    }
11221
11222    #[test]
11223    fn breakout_width_round_trip() {
11224        let adf_json = r#"{"version":1,"type":"doc","content":[{
11225            "type":"codeBlock",
11226            "attrs":{"language":"text"},
11227            "marks":[{"type":"breakout","attrs":{"mode":"wide","width":1200}}],
11228            "content":[{"type":"text","text":"some code"}]
11229        }]}"#;
11230        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11231        let md = adf_to_markdown(&doc).unwrap();
11232        assert!(md.contains("breakout=wide"));
11233        assert!(md.contains("breakoutWidth=1200"));
11234        let round_tripped = markdown_to_adf(&md).unwrap();
11235        let marks = round_tripped.content[0].marks.as_ref().unwrap();
11236        let breakout = marks.iter().find(|m| m.mark_type == "breakout").unwrap();
11237        assert_eq!(breakout.attrs.as_ref().unwrap()["mode"], "wide");
11238        assert_eq!(breakout.attrs.as_ref().unwrap()["width"], 1200);
11239    }
11240
11241    // ── Attribute extensions — media & table (Tier 5) ────────────────
11242
11243    #[test]
11244    fn image_with_layout_attrs() {
11245        let doc = markdown_to_adf("![alt](url){layout=wide width=80}").unwrap();
11246        let node = &doc.content[0];
11247        assert_eq!(node.node_type, "mediaSingle");
11248        let attrs = node.attrs.as_ref().unwrap();
11249        assert_eq!(attrs["layout"], "wide");
11250        assert_eq!(attrs["width"], 80);
11251    }
11252
11253    #[test]
11254    fn adf_image_with_layout_to_markdown() {
11255        let mut node = AdfNode::media_single("url", Some("alt"));
11256        node.attrs.as_mut().unwrap()["layout"] = serde_json::json!("wide");
11257        node.attrs.as_mut().unwrap()["width"] = serde_json::json!(80);
11258        let doc = AdfDocument {
11259            version: 1,
11260            doc_type: "doc".to_string(),
11261            content: vec![node],
11262        };
11263        let md = adf_to_markdown(&doc).unwrap();
11264        assert!(md.contains("![alt](url){layout=wide width=80}"));
11265    }
11266
11267    #[test]
11268    fn table_with_layout_attrs() {
11269        let md = "| H |\n| --- |\n| C |\n{layout=wide numbered}";
11270        let doc = markdown_to_adf(md).unwrap();
11271        let table = &doc.content[0];
11272        assert_eq!(table.node_type, "table");
11273        let attrs = table.attrs.as_ref().unwrap();
11274        assert_eq!(attrs["layout"], "wide");
11275        assert_eq!(attrs["isNumberColumnEnabled"], true);
11276    }
11277
11278    #[test]
11279    fn adf_table_with_attrs_to_markdown() {
11280        let mut table = AdfNode::table(vec![
11281            AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
11282                AdfNode::text("H"),
11283            ])])]),
11284            AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
11285                AdfNode::text("C"),
11286            ])])]),
11287        ]);
11288        table.attrs = Some(serde_json::json!({"layout": "wide", "isNumberColumnEnabled": true}));
11289        let doc = AdfDocument {
11290            version: 1,
11291            doc_type: "doc".to_string(),
11292            content: vec![table],
11293        };
11294        let md = adf_to_markdown(&doc).unwrap();
11295        assert!(md.contains("{layout=wide numbered}"));
11296    }
11297
11298    // ── Attribute extensions — inline marks (Tier 5) ─────────────────
11299
11300    #[test]
11301    fn underline_bracketed_span() {
11302        let doc = markdown_to_adf("This is [underlined text]{underline} here.").unwrap();
11303        let content = doc.content[0].content.as_ref().unwrap();
11304        assert_eq!(content[1].text.as_deref(), Some("underlined text"));
11305        let marks = content[1].marks.as_ref().unwrap();
11306        assert_eq!(marks[0].mark_type, "underline");
11307    }
11308
11309    #[test]
11310    fn adf_underline_to_markdown() {
11311        let doc = AdfDocument {
11312            version: 1,
11313            doc_type: "doc".to_string(),
11314            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11315                "underlined",
11316                vec![AdfMark::underline()],
11317            )])],
11318        };
11319        let md = adf_to_markdown(&doc).unwrap();
11320        assert!(md.contains("[underlined]{underline}"));
11321    }
11322
11323    #[test]
11324    fn round_trip_underline() {
11325        let md = "This is [underlined text]{underline} here.\n";
11326        let doc = markdown_to_adf(md).unwrap();
11327        let result = adf_to_markdown(&doc).unwrap();
11328        assert!(result.contains("[underlined text]{underline}"));
11329    }
11330
11331    #[test]
11332    fn mark_ordering_underline_strong_preserved() {
11333        // Issue #383: mark ordering was non-deterministic
11334        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11335          {"type":"text","text":"bold and underlined","marks":[{"type":"underline"},{"type":"strong"}]}
11336        ]}]}"#;
11337        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11338        let md = adf_to_markdown(&doc).unwrap();
11339        let round_tripped = markdown_to_adf(&md).unwrap();
11340        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11341        let mark_types: Vec<&str> = node
11342            .marks
11343            .as_ref()
11344            .unwrap()
11345            .iter()
11346            .map(|m| m.mark_type.as_str())
11347            .collect();
11348        assert_eq!(
11349            mark_types,
11350            vec!["underline", "strong"],
11351            "mark order should be preserved, got: {mark_types:?}"
11352        );
11353    }
11354
11355    #[test]
11356    fn mark_ordering_link_strong_preserved() {
11357        // Issue #403: link+strong mark order was swapped on round-trip
11358        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11359          {"type":"text","text":"bold link","marks":[
11360            {"type":"link","attrs":{"href":"https://example.com"}},
11361            {"type":"strong"}
11362          ]}
11363        ]}]}"#;
11364        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11365        let md = adf_to_markdown(&doc).unwrap();
11366        let round_tripped = markdown_to_adf(&md).unwrap();
11367        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11368        let mark_types: Vec<&str> = node
11369            .marks
11370            .as_ref()
11371            .unwrap()
11372            .iter()
11373            .map(|m| m.mark_type.as_str())
11374            .collect();
11375        assert_eq!(
11376            mark_types,
11377            vec!["link", "strong"],
11378            "mark order should be preserved, got: {mark_types:?}"
11379        );
11380    }
11381
11382    #[test]
11383    fn mark_ordering_link_textcolor_preserved() {
11384        // Issue #403 comment: link+textColor mark order was swapped on round-trip
11385        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11386          {"type":"text","text":"red link","marks":[
11387            {"type":"link","attrs":{"href":"https://example.com"}},
11388            {"type":"textColor","attrs":{"color":"#ff0000"}}
11389          ]}
11390        ]}]}"##;
11391        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11392        let md = adf_to_markdown(&doc).unwrap();
11393        let round_tripped = markdown_to_adf(&md).unwrap();
11394        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11395        let mark_types: Vec<&str> = node
11396            .marks
11397            .as_ref()
11398            .unwrap()
11399            .iter()
11400            .map(|m| m.mark_type.as_str())
11401            .collect();
11402        assert_eq!(
11403            mark_types,
11404            vec!["link", "textColor"],
11405            "mark order should be preserved, got: {mark_types:?}"
11406        );
11407    }
11408
11409    #[test]
11410    fn mark_ordering_link_em_preserved() {
11411        // Issue #403: link+em mark order should be preserved
11412        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11413          {"type":"text","text":"italic link","marks":[
11414            {"type":"link","attrs":{"href":"https://example.com"}},
11415            {"type":"em"}
11416          ]}
11417        ]}]}"#;
11418        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11419        let md = adf_to_markdown(&doc).unwrap();
11420        let round_tripped = markdown_to_adf(&md).unwrap();
11421        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11422        let mark_types: Vec<&str> = node
11423            .marks
11424            .as_ref()
11425            .unwrap()
11426            .iter()
11427            .map(|m| m.mark_type.as_str())
11428            .collect();
11429        assert_eq!(
11430            mark_types,
11431            vec!["link", "em"],
11432            "mark order should be preserved, got: {mark_types:?}"
11433        );
11434    }
11435
11436    #[test]
11437    fn mark_ordering_link_strike_preserved() {
11438        // Issue #403: link+strike mark order should be preserved
11439        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11440          {"type":"text","text":"struck link","marks":[
11441            {"type":"link","attrs":{"href":"https://example.com"}},
11442            {"type":"strike"}
11443          ]}
11444        ]}]}"#;
11445        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11446        let md = adf_to_markdown(&doc).unwrap();
11447        let round_tripped = markdown_to_adf(&md).unwrap();
11448        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11449        let mark_types: Vec<&str> = node
11450            .marks
11451            .as_ref()
11452            .unwrap()
11453            .iter()
11454            .map(|m| m.mark_type.as_str())
11455            .collect();
11456        assert_eq!(
11457            mark_types,
11458            vec!["link", "strike"],
11459            "mark order should be preserved, got: {mark_types:?}"
11460        );
11461    }
11462
11463    #[test]
11464    fn mark_ordering_strong_link_preserved() {
11465        // Issue #403: [strong, link] order must also be preserved (reverse direction)
11466        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11467          {"type":"text","text":"bold link","marks":[
11468            {"type":"strong"},
11469            {"type":"link","attrs":{"href":"https://example.com"}}
11470          ]}
11471        ]}]}"#;
11472        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11473        let md = adf_to_markdown(&doc).unwrap();
11474        let round_tripped = markdown_to_adf(&md).unwrap();
11475        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11476        let mark_types: Vec<&str> = node
11477            .marks
11478            .as_ref()
11479            .unwrap()
11480            .iter()
11481            .map(|m| m.mark_type.as_str())
11482            .collect();
11483        assert_eq!(
11484            mark_types,
11485            vec!["strong", "link"],
11486            "mark order should be preserved, got: {mark_types:?}"
11487        );
11488    }
11489
11490    #[test]
11491    fn mark_ordering_em_link_preserved() {
11492        // Issue #403: [em, link] order must also be preserved
11493        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11494          {"type":"text","text":"italic link","marks":[
11495            {"type":"em"},
11496            {"type":"link","attrs":{"href":"https://example.com"}}
11497          ]}
11498        ]}]}"#;
11499        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11500        let md = adf_to_markdown(&doc).unwrap();
11501        let round_tripped = markdown_to_adf(&md).unwrap();
11502        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11503        let mark_types: Vec<&str> = node
11504            .marks
11505            .as_ref()
11506            .unwrap()
11507            .iter()
11508            .map(|m| m.mark_type.as_str())
11509            .collect();
11510        assert_eq!(
11511            mark_types,
11512            vec!["em", "link"],
11513            "mark order should be preserved, got: {mark_types:?}"
11514        );
11515    }
11516
11517    #[test]
11518    fn mark_ordering_strike_link_preserved() {
11519        // Issue #403: [strike, link] order must also be preserved
11520        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11521          {"type":"text","text":"struck link","marks":[
11522            {"type":"strike"},
11523            {"type":"link","attrs":{"href":"https://example.com"}}
11524          ]}
11525        ]}]}"#;
11526        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11527        let md = adf_to_markdown(&doc).unwrap();
11528        let round_tripped = markdown_to_adf(&md).unwrap();
11529        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11530        let mark_types: Vec<&str> = node
11531            .marks
11532            .as_ref()
11533            .unwrap()
11534            .iter()
11535            .map(|m| m.mark_type.as_str())
11536            .collect();
11537        assert_eq!(
11538            mark_types,
11539            vec!["strike", "link"],
11540            "mark order should be preserved, got: {mark_types:?}"
11541        );
11542    }
11543
11544    #[test]
11545    fn mark_ordering_underline_link_preserved() {
11546        // Issue #403: [underline, link] order must be preserved
11547        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11548          {"type":"text","text":"click here","marks":[
11549            {"type":"underline"},
11550            {"type":"link","attrs":{"href":"https://example.com"}}
11551          ]}
11552        ]}]}"#;
11553        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11554        let md = adf_to_markdown(&doc).unwrap();
11555        let round_tripped = markdown_to_adf(&md).unwrap();
11556        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11557        let mark_types: Vec<&str> = node
11558            .marks
11559            .as_ref()
11560            .unwrap()
11561            .iter()
11562            .map(|m| m.mark_type.as_str())
11563            .collect();
11564        assert_eq!(
11565            mark_types,
11566            vec!["underline", "link"],
11567            "mark order should be preserved, got: {mark_types:?}"
11568        );
11569    }
11570
11571    #[test]
11572    fn mark_ordering_textcolor_link_preserved() {
11573        // Issue #403: [textColor, link] order must be preserved
11574        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11575          {"type":"text","text":"red link","marks":[
11576            {"type":"textColor","attrs":{"color":"#ff0000"}},
11577            {"type":"link","attrs":{"href":"https://example.com"}}
11578          ]}
11579        ]}]}"##;
11580        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11581        let md = adf_to_markdown(&doc).unwrap();
11582        let round_tripped = markdown_to_adf(&md).unwrap();
11583        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11584        let mark_types: Vec<&str> = node
11585            .marks
11586            .as_ref()
11587            .unwrap()
11588            .iter()
11589            .map(|m| m.mark_type.as_str())
11590            .collect();
11591        assert_eq!(
11592            mark_types,
11593            vec!["textColor", "link"],
11594            "mark order should be preserved, got: {mark_types:?}"
11595        );
11596    }
11597
11598    #[test]
11599    fn mark_ordering_link_underline_preserved() {
11600        // Issue #403: [link, underline] order must be preserved (link wraps bracketed span)
11601        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11602          {"type":"text","text":"click here","marks":[
11603            {"type":"link","attrs":{"href":"https://example.com"}},
11604            {"type":"underline"}
11605          ]}
11606        ]}]}"#;
11607        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11608        let md = adf_to_markdown(&doc).unwrap();
11609        // Link should wrap the underline bracketed span: [[click here]{underline}](url)
11610        assert!(
11611            md.contains("](https://example.com)"),
11612            "should have link: {md}"
11613        );
11614        assert!(md.contains("underline"), "should have underline: {md}");
11615        let round_tripped = markdown_to_adf(&md).unwrap();
11616        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11617        let mark_types: Vec<&str> = node
11618            .marks
11619            .as_ref()
11620            .unwrap()
11621            .iter()
11622            .map(|m| m.mark_type.as_str())
11623            .collect();
11624        assert_eq!(
11625            mark_types,
11626            vec!["link", "underline"],
11627            "mark order should be preserved, got: {mark_types:?}"
11628        );
11629    }
11630
11631    #[test]
11632    fn mark_ordering_underline_strong_link_preserved() {
11633        // Issue #491: [underline, strong, link] reordered to [strong, underline, link]
11634        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11635          {"type":"text","text":"bold underlined link","marks":[
11636            {"type":"underline"},
11637            {"type":"strong"},
11638            {"type":"link","attrs":{"href":"https://example.com/page"}}
11639          ]}
11640        ]}]}"#;
11641        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11642        let md = adf_to_markdown(&doc).unwrap();
11643        let round_tripped = markdown_to_adf(&md).unwrap();
11644        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11645        let mark_types: Vec<&str> = node
11646            .marks
11647            .as_ref()
11648            .unwrap()
11649            .iter()
11650            .map(|m| m.mark_type.as_str())
11651            .collect();
11652        assert_eq!(
11653            mark_types,
11654            vec!["underline", "strong", "link"],
11655            "mark order should be preserved, got: {mark_types:?}"
11656        );
11657    }
11658
11659    #[test]
11660    fn mark_ordering_strong_underline_link_preserved() {
11661        // Issue #491: verify [strong, underline, link] is preserved
11662        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11663          {"type":"text","text":"bold underlined link","marks":[
11664            {"type":"strong"},
11665            {"type":"underline"},
11666            {"type":"link","attrs":{"href":"https://example.com/page"}}
11667          ]}
11668        ]}]}"#;
11669        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11670        let md = adf_to_markdown(&doc).unwrap();
11671        let round_tripped = markdown_to_adf(&md).unwrap();
11672        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11673        let mark_types: Vec<&str> = node
11674            .marks
11675            .as_ref()
11676            .unwrap()
11677            .iter()
11678            .map(|m| m.mark_type.as_str())
11679            .collect();
11680        assert_eq!(
11681            mark_types,
11682            vec!["strong", "underline", "link"],
11683            "mark order should be preserved, got: {mark_types:?}"
11684        );
11685    }
11686
11687    #[test]
11688    fn mark_ordering_underline_em_link_preserved() {
11689        // Issue #491: verify [underline, em, link] is preserved
11690        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11691          {"type":"text","text":"italic underlined link","marks":[
11692            {"type":"underline"},
11693            {"type":"em"},
11694            {"type":"link","attrs":{"href":"https://example.com/page"}}
11695          ]}
11696        ]}]}"#;
11697        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11698        let md = adf_to_markdown(&doc).unwrap();
11699        let round_tripped = markdown_to_adf(&md).unwrap();
11700        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11701        let mark_types: Vec<&str> = node
11702            .marks
11703            .as_ref()
11704            .unwrap()
11705            .iter()
11706            .map(|m| m.mark_type.as_str())
11707            .collect();
11708        assert_eq!(
11709            mark_types,
11710            vec!["underline", "em", "link"],
11711            "mark order should be preserved, got: {mark_types:?}"
11712        );
11713    }
11714
11715    #[test]
11716    fn mark_ordering_underline_strike_link_preserved() {
11717        // Issue #491: verify [underline, strike, link] is preserved
11718        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11719          {"type":"text","text":"struck underlined link","marks":[
11720            {"type":"underline"},
11721            {"type":"strike"},
11722            {"type":"link","attrs":{"href":"https://example.com/page"}}
11723          ]}
11724        ]}]}"#;
11725        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11726        let md = adf_to_markdown(&doc).unwrap();
11727        let round_tripped = markdown_to_adf(&md).unwrap();
11728        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11729        let mark_types: Vec<&str> = node
11730            .marks
11731            .as_ref()
11732            .unwrap()
11733            .iter()
11734            .map(|m| m.mark_type.as_str())
11735            .collect();
11736        assert_eq!(
11737            mark_types,
11738            vec!["underline", "strike", "link"],
11739            "mark order should be preserved, got: {mark_types:?}"
11740        );
11741    }
11742
11743    #[test]
11744    fn mark_ordering_underline_strong_em_link_preserved() {
11745        // Issue #491: verify four-mark combo [underline, strong, em, link] is preserved
11746        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11747          {"type":"text","text":"all the marks","marks":[
11748            {"type":"underline"},
11749            {"type":"strong"},
11750            {"type":"em"},
11751            {"type":"link","attrs":{"href":"https://example.com/page"}}
11752          ]}
11753        ]}]}"#;
11754        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11755        let md = adf_to_markdown(&doc).unwrap();
11756        let round_tripped = markdown_to_adf(&md).unwrap();
11757        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11758        let mark_types: Vec<&str> = node
11759            .marks
11760            .as_ref()
11761            .unwrap()
11762            .iter()
11763            .map(|m| m.mark_type.as_str())
11764            .collect();
11765        assert_eq!(
11766            mark_types,
11767            vec!["underline", "strong", "em", "link"],
11768            "mark order should be preserved, got: {mark_types:?}"
11769        );
11770    }
11771
11772    #[test]
11773    fn em_strong_round_trip() {
11774        // Issue #401: em mark dropped when combined with strong
11775        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11776          {"type":"text","text":"bold and italic","marks":[{"type":"strong"},{"type":"em"}]}
11777        ]}]}"#;
11778        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11779        let md = adf_to_markdown(&doc).unwrap();
11780        assert_eq!(md.trim(), "***bold and italic***");
11781        let round_tripped = markdown_to_adf(&md).unwrap();
11782        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11783        assert_eq!(node.text.as_deref(), Some("bold and italic"));
11784        let mark_types: Vec<&str> = node
11785            .marks
11786            .as_ref()
11787            .unwrap()
11788            .iter()
11789            .map(|m| m.mark_type.as_str())
11790            .collect();
11791        assert_eq!(
11792            mark_types,
11793            vec!["strong", "em"],
11794            "both strong and em marks should be preserved, got: {mark_types:?}"
11795        );
11796    }
11797
11798    #[test]
11799    fn em_strong_round_trip_em_first() {
11800        // Issue #549: [em, strong] mark order must be preserved on round-trip.
11801        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11802          {"type":"text","text":"italic and bold","marks":[{"type":"em"},{"type":"strong"}]}
11803        ]}]}"#;
11804        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11805        let md = adf_to_markdown(&doc).unwrap();
11806        let round_tripped = markdown_to_adf(&md).unwrap();
11807        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11808        assert_eq!(node.text.as_deref(), Some("italic and bold"));
11809        let mark_types: Vec<&str> = node
11810            .marks
11811            .as_ref()
11812            .unwrap()
11813            .iter()
11814            .map(|m| m.mark_type.as_str())
11815            .collect();
11816        assert_eq!(
11817            mark_types,
11818            vec!["em", "strong"],
11819            "mark order [em, strong] should be preserved, got: {mark_types:?}"
11820        );
11821    }
11822
11823    /// Round-trips an inline text node with the given marks through ADF → JFM → ADF
11824    /// and asserts the resulting mark types match `expected`.
11825    fn assert_mark_order_round_trip(marks: Vec<AdfMark>, expected: &[&str]) {
11826        let doc = AdfDocument {
11827            version: 1,
11828            doc_type: "doc".to_string(),
11829            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11830                "text", marks,
11831            )])],
11832        };
11833        let md = adf_to_markdown(&doc).unwrap();
11834        let round_tripped = markdown_to_adf(&md).unwrap();
11835        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11836        let mark_types: Vec<&str> = node
11837            .marks
11838            .as_ref()
11839            .expect("round-tripped node should have marks")
11840            .iter()
11841            .map(|m| m.mark_type.as_str())
11842            .collect();
11843        assert_eq!(
11844            mark_types, expected,
11845            "marks should round-trip in order via {md:?}"
11846        );
11847    }
11848
11849    #[test]
11850    fn round_trip_em_strong_mark_order() {
11851        // Issue #549: em + strong in either order must round-trip.
11852        assert_mark_order_round_trip(vec![AdfMark::em(), AdfMark::strong()], &["em", "strong"]);
11853        assert_mark_order_round_trip(vec![AdfMark::strong(), AdfMark::em()], &["strong", "em"]);
11854    }
11855
11856    #[test]
11857    fn round_trip_strong_underline_mark_order() {
11858        // Issue #549: strong + underline in either order must round-trip.
11859        assert_mark_order_round_trip(
11860            vec![AdfMark::strong(), AdfMark::underline()],
11861            &["strong", "underline"],
11862        );
11863        assert_mark_order_round_trip(
11864            vec![AdfMark::underline(), AdfMark::strong()],
11865            &["underline", "strong"],
11866        );
11867    }
11868
11869    #[test]
11870    fn round_trip_em_underline_mark_order() {
11871        assert_mark_order_round_trip(
11872            vec![AdfMark::em(), AdfMark::underline()],
11873            &["em", "underline"],
11874        );
11875        assert_mark_order_round_trip(
11876            vec![AdfMark::underline(), AdfMark::em()],
11877            &["underline", "em"],
11878        );
11879    }
11880
11881    #[test]
11882    fn round_trip_strike_strong_em_permutations() {
11883        // Each permutation of {strike, strong, em} must round-trip the mark order
11884        // exactly, because the Atlassian ADF spec does not define a canonical mark
11885        // ordering and we preserve whatever ordering Jira delivered.
11886        assert_mark_order_round_trip(
11887            vec![AdfMark::strike(), AdfMark::strong(), AdfMark::em()],
11888            &["strike", "strong", "em"],
11889        );
11890        assert_mark_order_round_trip(
11891            vec![AdfMark::strike(), AdfMark::em(), AdfMark::strong()],
11892            &["strike", "em", "strong"],
11893        );
11894        assert_mark_order_round_trip(
11895            vec![AdfMark::strong(), AdfMark::strike(), AdfMark::em()],
11896            &["strong", "strike", "em"],
11897        );
11898        assert_mark_order_round_trip(
11899            vec![AdfMark::strong(), AdfMark::em(), AdfMark::strike()],
11900            &["strong", "em", "strike"],
11901        );
11902        assert_mark_order_round_trip(
11903            vec![AdfMark::em(), AdfMark::strike(), AdfMark::strong()],
11904            &["em", "strike", "strong"],
11905        );
11906        assert_mark_order_round_trip(
11907            vec![AdfMark::em(), AdfMark::strong(), AdfMark::strike()],
11908            &["em", "strong", "strike"],
11909        );
11910    }
11911
11912    #[test]
11913    fn round_trip_underline_nested_with_strong_em() {
11914        // Underline may sit outside, between, or inside strong/em — each position
11915        // must round-trip.
11916        assert_mark_order_round_trip(
11917            vec![AdfMark::underline(), AdfMark::strong(), AdfMark::em()],
11918            &["underline", "strong", "em"],
11919        );
11920        assert_mark_order_round_trip(
11921            vec![AdfMark::strong(), AdfMark::underline(), AdfMark::em()],
11922            &["strong", "underline", "em"],
11923        );
11924        assert_mark_order_round_trip(
11925            vec![AdfMark::strong(), AdfMark::em(), AdfMark::underline()],
11926            &["strong", "em", "underline"],
11927        );
11928    }
11929
11930    #[test]
11931    fn round_trip_span_attr_order_preserved() {
11932        // Issue #549: the `:span` directive always parses color/bg/subsup
11933        // attrs in a fixed order, so non-canonical orderings must be emitted
11934        // as nested :span wrappers rather than a single merged wrapper.
11935        assert_mark_order_round_trip(
11936            vec![
11937                AdfMark::background_color("#ffff00"),
11938                AdfMark::text_color("#ff0000"),
11939            ],
11940            &["backgroundColor", "textColor"],
11941        );
11942        assert_mark_order_round_trip(
11943            vec![AdfMark::subsup("sub"), AdfMark::text_color("#ff0000")],
11944            &["subsup", "textColor"],
11945        );
11946        assert_mark_order_round_trip(
11947            vec![
11948                AdfMark::text_color("#ff0000"),
11949                AdfMark::background_color("#ffff00"),
11950            ],
11951            &["textColor", "backgroundColor"],
11952        );
11953    }
11954
11955    #[test]
11956    fn round_trip_annotation_before_underline() {
11957        // Issue #549: the bracketed-span parser reads `underline` before any
11958        // annotation-ids, so `[annotation, underline]` must be emitted as
11959        // nested wrappers rather than one merged `[text]{underline annotation-id=X}`.
11960        assert_mark_order_round_trip(
11961            vec![
11962                AdfMark::annotation("ann-1", "inlineComment"),
11963                AdfMark::underline(),
11964            ],
11965            &["annotation", "underline"],
11966        );
11967        assert_mark_order_round_trip(
11968            vec![
11969                AdfMark::annotation("ann-1", "inlineComment"),
11970                AdfMark::underline(),
11971                AdfMark::annotation("ann-2", "inlineComment"),
11972            ],
11973            &["annotation", "underline", "annotation"],
11974        );
11975    }
11976
11977    #[test]
11978    fn round_trip_em_content_with_underscores() {
11979        // When em renders as `_..._` (to disambiguate from strong), any literal
11980        // underscores in the text must be escaped so they don't close the
11981        // emphasis span early.  Text like "foo_bar_baz" with [em, strong] must
11982        // survive round-trip with the underscores intact.
11983        let doc = AdfDocument {
11984            version: 1,
11985            doc_type: "doc".to_string(),
11986            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11987                "foo _bar_ baz",
11988                vec![AdfMark::em(), AdfMark::strong()],
11989            )])],
11990        };
11991        let md = adf_to_markdown(&doc).unwrap();
11992        let round_tripped = markdown_to_adf(&md).unwrap();
11993        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11994        assert_eq!(node.text.as_deref(), Some("foo _bar_ baz"));
11995        let mark_types: Vec<&str> = node
11996            .marks
11997            .as_ref()
11998            .unwrap()
11999            .iter()
12000            .map(|m| m.mark_type.as_str())
12001            .collect();
12002        assert_eq!(mark_types, vec!["em", "strong"]);
12003    }
12004
12005    #[test]
12006    fn round_trip_link_nested_with_formatting_marks() {
12007        // Link may sit at any position in the marks array relative to em,
12008        // strong, strike, and underline — each position must round-trip.
12009        assert_mark_order_round_trip(
12010            vec![
12011                AdfMark::link("https://example.com"),
12012                AdfMark::strong(),
12013                AdfMark::em(),
12014            ],
12015            &["link", "strong", "em"],
12016        );
12017        assert_mark_order_round_trip(
12018            vec![
12019                AdfMark::em(),
12020                AdfMark::strong(),
12021                AdfMark::link("https://example.com"),
12022            ],
12023            &["em", "strong", "link"],
12024        );
12025        assert_mark_order_round_trip(
12026            vec![
12027                AdfMark::underline(),
12028                AdfMark::link("https://example.com"),
12029                AdfMark::strong(),
12030            ],
12031            &["underline", "link", "strong"],
12032        );
12033    }
12034
12035    /// Builds an `AdfMark` with the given type and no attrs, bypassing the
12036    /// usual constructors so we can exercise the defensive branches in the
12037    /// render helpers (the constructors always populate `attrs`).
12038    fn bare_mark(mark_type: &str) -> AdfMark {
12039        AdfMark {
12040            mark_type: mark_type.to_string(),
12041            attrs: None,
12042        }
12043    }
12044
12045    #[test]
12046    fn collect_span_attr_handles_missing_attrs() {
12047        // `textColor`/`backgroundColor`/`subsup` marks without the expected
12048        // `color`/`type` attr must not emit a fragment (the `if let` falls
12049        // through without pushing).  This exercises the inner-None branches
12050        // that the typed-constructor tests otherwise skip.
12051        let mut attrs = Vec::new();
12052        collect_span_attr(&bare_mark("textColor"), &mut attrs);
12053        collect_span_attr(&bare_mark("backgroundColor"), &mut attrs);
12054        collect_span_attr(&bare_mark("subsup"), &mut attrs);
12055        collect_span_attr(&bare_mark("link"), &mut attrs);
12056        assert!(attrs.is_empty(), "got: {attrs:?}");
12057    }
12058
12059    #[test]
12060    fn collect_bracketed_attr_handles_missing_attrs() {
12061        // An annotation mark with no attrs map at all must silently produce
12062        // no fragments — this covers the outer `if let Some(ref a)` None arm.
12063        let mut attrs = Vec::new();
12064        collect_bracketed_attr(&bare_mark("annotation"), &mut attrs);
12065        collect_bracketed_attr(&bare_mark("strong"), &mut attrs);
12066        assert!(attrs.is_empty(), "got: {attrs:?}");
12067    }
12068
12069    #[test]
12070    fn collect_bracketed_attr_handles_annotation_without_id() {
12071        // An annotation mark with attrs present but missing `id` and
12072        // `annotationType` keys still emits nothing — exercises the inner
12073        // None branches of each `if let` in the annotation arm.
12074        let mark = AdfMark {
12075            mark_type: "annotation".to_string(),
12076            attrs: Some(serde_json::json!({})),
12077        };
12078        let mut attrs = Vec::new();
12079        collect_bracketed_attr(&mark, &mut attrs);
12080        assert!(attrs.is_empty(), "got: {attrs:?}");
12081    }
12082
12083    #[test]
12084    fn span_attr_order_rejects_unknown_types() {
12085        // `span_attr_order` must classify unknown mark types as the sentinel
12086        // value, and `span_run_is_canonical` must reject a run that contains
12087        // any such unknown type.
12088        assert_eq!(span_attr_order("textColor"), 0);
12089        assert_eq!(span_attr_order("backgroundColor"), 1);
12090        assert_eq!(span_attr_order("subsup"), 2);
12091        assert_eq!(span_attr_order("strong"), u8::MAX);
12092        assert!(!span_run_is_canonical(&[bare_mark("strong")]));
12093    }
12094
12095    #[test]
12096    fn bracketed_run_rejects_unknown_types() {
12097        // `bracketed_run_is_canonical` only accepts `underline` and
12098        // `annotation`; any other mark type in the run short-circuits to
12099        // `false` so the caller emits nested wrappers.
12100        assert!(bracketed_run_is_canonical(&[
12101            AdfMark::underline(),
12102            AdfMark::annotation("x", "inlineComment")
12103        ]));
12104        assert!(!bracketed_run_is_canonical(&[
12105            AdfMark::annotation("x", "inlineComment"),
12106            AdfMark::underline()
12107        ]));
12108        assert!(!bracketed_run_is_canonical(&[bare_mark("strong")]));
12109    }
12110
12111    #[test]
12112    fn render_marked_text_ignores_unknown_mark_types() {
12113        // Unknown mark types fall through `render_marked_text`'s `_ =>`
12114        // arm and are dropped; the rendered JFM must still produce the
12115        // underlying text (and round-trip back to an unmarked text node).
12116        let doc = AdfDocument {
12117            version: 1,
12118            doc_type: "doc".to_string(),
12119            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12120                "hello",
12121                vec![bare_mark("futureMark"), AdfMark::strong()],
12122            )])],
12123        };
12124        let md = adf_to_markdown(&doc).unwrap();
12125        assert_eq!(md.trim(), "**hello**");
12126        let rt = markdown_to_adf(&md).unwrap();
12127        let node = &rt.content[0].content.as_ref().unwrap()[0];
12128        assert_eq!(node.text.as_deref(), Some("hello"));
12129        let mark_types: Vec<&str> = node
12130            .marks
12131            .as_ref()
12132            .unwrap()
12133            .iter()
12134            .map(|m| m.mark_type.as_str())
12135            .collect();
12136        assert_eq!(mark_types, vec!["strong"]);
12137    }
12138
12139    #[test]
12140    fn triple_asterisk_parse_to_adf() {
12141        // Issue #401: ***text*** should parse as text with strong+em marks
12142        let md = "***bold and italic***\n";
12143        let doc = markdown_to_adf(md).unwrap();
12144        let node = &doc.content[0].content.as_ref().unwrap()[0];
12145        assert_eq!(node.text.as_deref(), Some("bold and italic"));
12146        let mark_types: Vec<&str> = node
12147            .marks
12148            .as_ref()
12149            .unwrap()
12150            .iter()
12151            .map(|m| m.mark_type.as_str())
12152            .collect();
12153        assert!(
12154            mark_types.contains(&"strong") && mark_types.contains(&"em"),
12155            "***text*** should produce both strong and em marks, got: {mark_types:?}"
12156        );
12157    }
12158
12159    #[test]
12160    fn triple_asterisk_with_surrounding_text() {
12161        // Issue #401: surrounding text should not be affected
12162        let md = "before ***bold italic*** after\n";
12163        let doc = markdown_to_adf(md).unwrap();
12164        let nodes = doc.content[0].content.as_ref().unwrap();
12165        // Should have: "before " (plain), "bold italic" (strong+em), " after" (plain)
12166        assert!(
12167            nodes.len() >= 3,
12168            "expected at least 3 nodes, got {}",
12169            nodes.len()
12170        );
12171        assert_eq!(nodes[0].text.as_deref(), Some("before "));
12172        assert_eq!(nodes[1].text.as_deref(), Some("bold italic"));
12173        let mark_types: Vec<&str> = nodes[1]
12174            .marks
12175            .as_ref()
12176            .unwrap()
12177            .iter()
12178            .map(|m| m.mark_type.as_str())
12179            .collect();
12180        assert!(
12181            mark_types.contains(&"strong") && mark_types.contains(&"em"),
12182            "middle node should have strong+em, got: {mark_types:?}"
12183        );
12184        assert_eq!(nodes[2].text.as_deref(), Some(" after"));
12185    }
12186
12187    #[test]
12188    fn annotation_mark_round_trip() {
12189        // Issue #364: annotation marks were silently dropped
12190        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12191          {"type":"text","text":"highlighted text","marks":[
12192            {"type":"annotation","attrs":{"id":"abc123","annotationType":"inlineComment"}}
12193          ]}
12194        ]}]}"#;
12195        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12196
12197        let md = adf_to_markdown(&doc).unwrap();
12198        assert!(
12199            md.contains("annotation-id="),
12200            "JFM should contain annotation-id, got: {md}"
12201        );
12202
12203        let round_tripped = markdown_to_adf(&md).unwrap();
12204        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12205        assert_eq!(text_node.text.as_deref(), Some("highlighted text"));
12206        let marks = text_node.marks.as_ref().expect("should have marks");
12207        let ann = marks
12208            .iter()
12209            .find(|m| m.mark_type == "annotation")
12210            .expect("should have annotation mark");
12211        let attrs = ann.attrs.as_ref().unwrap();
12212        assert_eq!(attrs["id"], "abc123");
12213        assert_eq!(attrs["annotationType"], "inlineComment");
12214    }
12215
12216    #[test]
12217    fn annotation_mark_with_bold() {
12218        // Annotation + bold should both survive round-trip
12219        let doc = AdfDocument {
12220            version: 1,
12221            doc_type: "doc".to_string(),
12222            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12223                "bold comment",
12224                vec![
12225                    AdfMark::strong(),
12226                    AdfMark::annotation("def456", "inlineComment"),
12227                ],
12228            )])],
12229        };
12230        let md = adf_to_markdown(&doc).unwrap();
12231        let round_tripped = markdown_to_adf(&md).unwrap();
12232        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12233        let marks = text_node.marks.as_ref().expect("should have marks");
12234        assert!(
12235            marks.iter().any(|m| m.mark_type == "strong"),
12236            "should have strong mark"
12237        );
12238        assert!(
12239            marks.iter().any(|m| m.mark_type == "annotation"),
12240            "should have annotation mark"
12241        );
12242    }
12243
12244    #[test]
12245    fn annotation_and_link_marks_both_preserved() {
12246        // Issue #390: text with both annotation and link marks loses link on round-trip
12247        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12248          {"type":"text","text":"HANGUL-8","marks":[
12249            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"5ca7425e-34cd-48d3-b4eb-9873ac8b20e0"}},
12250            {"type":"link","attrs":{"href":"https://zd.atlassian.net/browse/HANG-8"}}
12251          ]}
12252        ]}]}"#;
12253        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12254        let md = adf_to_markdown(&doc).unwrap();
12255        // Should contain both annotation attrs and link syntax
12256        assert!(
12257            md.contains("annotation-id="),
12258            "JFM should contain annotation-id, got: {md}"
12259        );
12260        assert!(
12261            md.contains("](https://"),
12262            "JFM should contain link href, got: {md}"
12263        );
12264        let round_tripped = markdown_to_adf(&md).unwrap();
12265        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12266        let marks = text_node.marks.as_ref().expect("should have marks");
12267        assert!(
12268            marks.iter().any(|m| m.mark_type == "annotation"),
12269            "should have annotation mark, got: {:?}",
12270            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12271        );
12272        assert!(
12273            marks.iter().any(|m| m.mark_type == "link"),
12274            "should have link mark, got: {:?}",
12275            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12276        );
12277    }
12278
12279    #[test]
12280    fn annotation_and_code_marks_both_preserved() {
12281        // Issue #508: annotation mark dropped when combined with code mark
12282        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12283          {"type":"text","text":"some text with "},
12284          {"type":"text","text":"annotated code","marks":[
12285            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"aabbccdd-1234-5678-abcd-000000000001"}},
12286            {"type":"code"}
12287          ]},
12288          {"type":"text","text":" remaining text"}
12289        ]}]}"#;
12290        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12291        let md = adf_to_markdown(&doc).unwrap();
12292        assert!(
12293            md.contains("annotation-id="),
12294            "JFM should contain annotation-id, got: {md}"
12295        );
12296        assert!(
12297            md.contains('`'),
12298            "JFM should contain backticks for code, got: {md}"
12299        );
12300
12301        let round_tripped = markdown_to_adf(&md).unwrap();
12302        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12303        // Find the text node with "annotated code"
12304        let code_node = nodes
12305            .iter()
12306            .find(|n| n.text.as_deref() == Some("annotated code"))
12307            .expect("should have 'annotated code' text node");
12308        let marks = code_node.marks.as_ref().expect("should have marks");
12309        assert!(
12310            marks.iter().any(|m| m.mark_type == "annotation"),
12311            "should have annotation mark, got: {:?}",
12312            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12313        );
12314        assert!(
12315            marks.iter().any(|m| m.mark_type == "code"),
12316            "should have code mark, got: {:?}",
12317            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12318        );
12319        let ann = marks.iter().find(|m| m.mark_type == "annotation").unwrap();
12320        let attrs = ann.attrs.as_ref().unwrap();
12321        assert_eq!(attrs["id"], "aabbccdd-1234-5678-abcd-000000000001");
12322        assert_eq!(attrs["annotationType"], "inlineComment");
12323    }
12324
12325    #[test]
12326    fn annotation_and_code_and_link_marks_all_preserved() {
12327        // annotation + code + link should all survive round-trip
12328        let doc = AdfDocument {
12329            version: 1,
12330            doc_type: "doc".to_string(),
12331            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12332                "linked code",
12333                vec![
12334                    AdfMark::annotation("ann-001", "inlineComment"),
12335                    AdfMark::code(),
12336                    AdfMark::link("https://example.com"),
12337                ],
12338            )])],
12339        };
12340        let md = adf_to_markdown(&doc).unwrap();
12341        assert!(
12342            md.contains("annotation-id="),
12343            "JFM should contain annotation-id, got: {md}"
12344        );
12345        assert!(md.contains('`'), "JFM should contain backticks, got: {md}");
12346        assert!(
12347            md.contains("](https://example.com)"),
12348            "JFM should contain link, got: {md}"
12349        );
12350
12351        let round_tripped = markdown_to_adf(&md).unwrap();
12352        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12353        let marks = text_node.marks.as_ref().expect("should have marks");
12354        assert!(
12355            marks.iter().any(|m| m.mark_type == "annotation"),
12356            "should have annotation mark, got: {:?}",
12357            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12358        );
12359        assert!(
12360            marks.iter().any(|m| m.mark_type == "code"),
12361            "should have code mark, got: {:?}",
12362            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12363        );
12364        assert!(
12365            marks.iter().any(|m| m.mark_type == "link"),
12366            "should have link mark, got: {:?}",
12367            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12368        );
12369    }
12370
12371    #[test]
12372    fn multiple_annotations_and_code_mark_preserved() {
12373        // Multiple annotation marks on a code node should all survive
12374        let doc = AdfDocument {
12375            version: 1,
12376            doc_type: "doc".to_string(),
12377            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12378                "doubly annotated",
12379                vec![
12380                    AdfMark::annotation("ann-aaa", "inlineComment"),
12381                    AdfMark::annotation("ann-bbb", "inlineComment"),
12382                    AdfMark::code(),
12383                ],
12384            )])],
12385        };
12386        let md = adf_to_markdown(&doc).unwrap();
12387        assert!(
12388            md.contains("ann-aaa"),
12389            "JFM should contain first annotation id, got: {md}"
12390        );
12391        assert!(
12392            md.contains("ann-bbb"),
12393            "JFM should contain second annotation id, got: {md}"
12394        );
12395
12396        let round_tripped = markdown_to_adf(&md).unwrap();
12397        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12398        let marks = text_node.marks.as_ref().expect("should have marks");
12399        let ann_marks: Vec<_> = marks
12400            .iter()
12401            .filter(|m| m.mark_type == "annotation")
12402            .collect();
12403        assert_eq!(
12404            ann_marks.len(),
12405            2,
12406            "should have 2 annotation marks, got: {}",
12407            ann_marks.len()
12408        );
12409        assert!(
12410            marks.iter().any(|m| m.mark_type == "code"),
12411            "should have code mark"
12412        );
12413    }
12414
12415    #[test]
12416    fn underline_and_link_marks_both_preserved() {
12417        // Underline + link should also coexist
12418        let doc = AdfDocument {
12419            version: 1,
12420            doc_type: "doc".to_string(),
12421            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12422                "click here",
12423                vec![AdfMark::underline(), AdfMark::link("https://example.com")],
12424            )])],
12425        };
12426        let md = adf_to_markdown(&doc).unwrap();
12427        assert!(md.contains("underline"), "should have underline attr: {md}");
12428        assert!(
12429            md.contains("](https://example.com)"),
12430            "should have link: {md}"
12431        );
12432        let round_tripped = markdown_to_adf(&md).unwrap();
12433        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12434        let marks = text_node.marks.as_ref().expect("should have marks");
12435        assert!(marks.iter().any(|m| m.mark_type == "underline"));
12436        assert!(marks.iter().any(|m| m.mark_type == "link"));
12437    }
12438
12439    #[test]
12440    fn annotation_link_and_bold_all_preserved() {
12441        // All three marks should coexist
12442        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12443          {"type":"text","text":"important","marks":[
12444            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"abc"}},
12445            {"type":"link","attrs":{"href":"https://example.com"}},
12446            {"type":"strong"}
12447          ]}
12448        ]}]}"#;
12449        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12450        let md = adf_to_markdown(&doc).unwrap();
12451        let round_tripped = markdown_to_adf(&md).unwrap();
12452        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12453        let marks = text_node.marks.as_ref().expect("should have marks");
12454        assert!(
12455            marks.iter().any(|m| m.mark_type == "annotation"),
12456            "should have annotation"
12457        );
12458        assert!(
12459            marks.iter().any(|m| m.mark_type == "link"),
12460            "should have link"
12461        );
12462        assert!(
12463            marks.iter().any(|m| m.mark_type == "strong"),
12464            "should have strong"
12465        );
12466    }
12467
12468    #[test]
12469    fn multiple_annotation_marks_round_trip() {
12470        // Issue #439: multiple annotation marks on same text node — all but last dropped
12471        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12472          {"type":"text","text":"some annotated text","marks":[
12473            {"type":"annotation","attrs":{"id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","annotationType":"inlineComment"}},
12474            {"type":"annotation","attrs":{"id":"ffffffff-1111-2222-3333-444444444444","annotationType":"inlineComment"}}
12475          ]}
12476        ]}]}"#;
12477        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12478
12479        let md = adf_to_markdown(&doc).unwrap();
12480        assert!(
12481            md.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
12482            "JFM should contain first annotation id, got: {md}"
12483        );
12484        assert!(
12485            md.contains("ffffffff-1111-2222-3333-444444444444"),
12486            "JFM should contain second annotation id, got: {md}"
12487        );
12488
12489        let round_tripped = markdown_to_adf(&md).unwrap();
12490        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12491        assert_eq!(text_node.text.as_deref(), Some("some annotated text"));
12492        let marks = text_node.marks.as_ref().expect("should have marks");
12493        let annotations: Vec<_> = marks
12494            .iter()
12495            .filter(|m| m.mark_type == "annotation")
12496            .collect();
12497        assert_eq!(
12498            annotations.len(),
12499            2,
12500            "should have 2 annotation marks, got: {annotations:?}"
12501        );
12502        let ids: Vec<_> = annotations
12503            .iter()
12504            .map(|a| a.attrs.as_ref().unwrap()["id"].as_str().unwrap())
12505            .collect();
12506        assert!(ids.contains(&"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
12507        assert!(ids.contains(&"ffffffff-1111-2222-3333-444444444444"));
12508    }
12509
12510    #[test]
12511    fn three_annotation_marks_round_trip() {
12512        // Verify three overlapping annotations all survive
12513        let doc = AdfDocument {
12514            version: 1,
12515            doc_type: "doc".to_string(),
12516            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12517                "triple annotated",
12518                vec![
12519                    AdfMark::annotation("id-1", "inlineComment"),
12520                    AdfMark::annotation("id-2", "inlineComment"),
12521                    AdfMark::annotation("id-3", "inlineComment"),
12522                ],
12523            )])],
12524        };
12525        let md = adf_to_markdown(&doc).unwrap();
12526        let round_tripped = markdown_to_adf(&md).unwrap();
12527        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12528        let marks = text_node.marks.as_ref().expect("should have marks");
12529        let annotations: Vec<_> = marks
12530            .iter()
12531            .filter(|m| m.mark_type == "annotation")
12532            .collect();
12533        assert_eq!(
12534            annotations.len(),
12535            3,
12536            "should have 3 annotation marks, got: {annotations:?}"
12537        );
12538    }
12539
12540    #[test]
12541    fn multiple_annotations_with_bold_round_trip() {
12542        // Multiple annotations + bold should all survive
12543        let doc = AdfDocument {
12544            version: 1,
12545            doc_type: "doc".to_string(),
12546            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12547                "bold double annotated",
12548                vec![
12549                    AdfMark::strong(),
12550                    AdfMark::annotation("ann-a", "inlineComment"),
12551                    AdfMark::annotation("ann-b", "inlineComment"),
12552                ],
12553            )])],
12554        };
12555        let md = adf_to_markdown(&doc).unwrap();
12556        let round_tripped = markdown_to_adf(&md).unwrap();
12557        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12558        let marks = text_node.marks.as_ref().expect("should have marks");
12559        assert!(
12560            marks.iter().any(|m| m.mark_type == "strong"),
12561            "should have strong mark"
12562        );
12563        let annotations: Vec<_> = marks
12564            .iter()
12565            .filter(|m| m.mark_type == "annotation")
12566            .collect();
12567        assert_eq!(
12568            annotations.len(),
12569            2,
12570            "should have 2 annotation marks, got: {annotations:?}"
12571        );
12572    }
12573
12574    #[test]
12575    fn multiple_annotations_with_link_round_trip() {
12576        // Multiple annotations + link should all survive
12577        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12578          {"type":"text","text":"linked text","marks":[
12579            {"type":"annotation","attrs":{"id":"ann-x","annotationType":"inlineComment"}},
12580            {"type":"annotation","attrs":{"id":"ann-y","annotationType":"inlineComment"}},
12581            {"type":"link","attrs":{"href":"https://example.com"}}
12582          ]}
12583        ]}]}"#;
12584        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12585        let md = adf_to_markdown(&doc).unwrap();
12586        let round_tripped = markdown_to_adf(&md).unwrap();
12587        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12588        let marks = text_node.marks.as_ref().expect("should have marks");
12589        assert!(
12590            marks.iter().any(|m| m.mark_type == "link"),
12591            "should have link mark"
12592        );
12593        let annotations: Vec<_> = marks
12594            .iter()
12595            .filter(|m| m.mark_type == "annotation")
12596            .collect();
12597        assert_eq!(
12598            annotations.len(),
12599            2,
12600            "should have 2 annotation marks, got: {annotations:?}"
12601        );
12602    }
12603
12604    // ── Issue #471: annotation marks on non-text inline nodes ─────────
12605
12606    #[test]
12607    fn annotation_on_emoji_round_trip() {
12608        // Issue #471: annotation mark on emoji node should survive round-trip
12609        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12610          {"type":"emoji","attrs":{"id":"1f4dd","shortName":":memo:","text":"📝"},"marks":[
12611            {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12612          ]},
12613          {"type":"text","text":" annotated text","marks":[
12614            {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12615          ]}
12616        ]}]}"#;
12617        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12618        let md = adf_to_markdown(&doc).unwrap();
12619        assert!(
12620            md.contains("annotation-id="),
12621            "JFM should contain annotation-id for emoji, got: {md}"
12622        );
12623
12624        let round_tripped = markdown_to_adf(&md).unwrap();
12625        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12626
12627        // Emoji node should retain annotation mark
12628        let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12629        let emoji_marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12630        assert!(
12631            emoji_marks.iter().any(|m| m.mark_type == "annotation"),
12632            "emoji should have annotation mark, got: {emoji_marks:?}"
12633        );
12634        let ann = emoji_marks
12635            .iter()
12636            .find(|m| m.mark_type == "annotation")
12637            .unwrap();
12638        assert_eq!(
12639            ann.attrs.as_ref().unwrap()["id"],
12640            "ccddee11-2233-4455-aabb-ccddee112233"
12641        );
12642
12643        // Text node should also retain annotation mark
12644        let text_node = nodes.iter().find(|n| n.node_type == "text").unwrap();
12645        let text_marks = text_node.marks.as_ref().expect("text should have marks");
12646        assert!(
12647            text_marks.iter().any(|m| m.mark_type == "annotation"),
12648            "text should have annotation mark"
12649        );
12650    }
12651
12652    #[test]
12653    fn annotation_on_status_round_trip() {
12654        let mut status = AdfNode::status("In Progress", "blue");
12655        status.marks = Some(vec![AdfMark::annotation("ann-status-1", "inlineComment")]);
12656
12657        let doc = AdfDocument {
12658            version: 1,
12659            doc_type: "doc".to_string(),
12660            content: vec![AdfNode::paragraph(vec![status])],
12661        };
12662        let md = adf_to_markdown(&doc).unwrap();
12663        assert!(
12664            md.contains("annotation-id="),
12665            "JFM should contain annotation-id for status, got: {md}"
12666        );
12667
12668        let round_tripped = markdown_to_adf(&md).unwrap();
12669        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12670        let status_node = nodes.iter().find(|n| n.node_type == "status").unwrap();
12671        let marks = status_node
12672            .marks
12673            .as_ref()
12674            .expect("status should have marks");
12675        assert!(
12676            marks.iter().any(|m| m.mark_type == "annotation"),
12677            "status should have annotation mark, got: {marks:?}"
12678        );
12679    }
12680
12681    #[test]
12682    fn annotation_on_date_round_trip() {
12683        let mut date = AdfNode::date("1704067200000");
12684        date.marks = Some(vec![AdfMark::annotation("ann-date-1", "inlineComment")]);
12685
12686        let doc = AdfDocument {
12687            version: 1,
12688            doc_type: "doc".to_string(),
12689            content: vec![AdfNode::paragraph(vec![date])],
12690        };
12691        let md = adf_to_markdown(&doc).unwrap();
12692        assert!(
12693            md.contains("annotation-id="),
12694            "JFM should contain annotation-id for date, got: {md}"
12695        );
12696
12697        let round_tripped = markdown_to_adf(&md).unwrap();
12698        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12699        let date_node = nodes.iter().find(|n| n.node_type == "date").unwrap();
12700        let marks = date_node.marks.as_ref().expect("date should have marks");
12701        assert!(
12702            marks.iter().any(|m| m.mark_type == "annotation"),
12703            "date should have annotation mark, got: {marks:?}"
12704        );
12705    }
12706
12707    #[test]
12708    fn annotation_on_mention_round_trip() {
12709        let mut mention = AdfNode::mention("user-123", "@Alice");
12710        mention.marks = Some(vec![AdfMark::annotation("ann-mention-1", "inlineComment")]);
12711
12712        let doc = AdfDocument {
12713            version: 1,
12714            doc_type: "doc".to_string(),
12715            content: vec![AdfNode::paragraph(vec![mention])],
12716        };
12717        let md = adf_to_markdown(&doc).unwrap();
12718        assert!(
12719            md.contains("annotation-id="),
12720            "JFM should contain annotation-id for mention, got: {md}"
12721        );
12722
12723        let round_tripped = markdown_to_adf(&md).unwrap();
12724        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12725        let mention_node = nodes.iter().find(|n| n.node_type == "mention").unwrap();
12726        let marks = mention_node
12727            .marks
12728            .as_ref()
12729            .expect("mention should have marks");
12730        assert!(
12731            marks.iter().any(|m| m.mark_type == "annotation"),
12732            "mention should have annotation mark, got: {marks:?}"
12733        );
12734    }
12735
12736    #[test]
12737    fn annotation_on_inline_card_round_trip() {
12738        let mut card = AdfNode::inline_card("https://example.com");
12739        card.marks = Some(vec![AdfMark::annotation("ann-card-1", "inlineComment")]);
12740
12741        let doc = AdfDocument {
12742            version: 1,
12743            doc_type: "doc".to_string(),
12744            content: vec![AdfNode::paragraph(vec![card])],
12745        };
12746        let md = adf_to_markdown(&doc).unwrap();
12747        assert!(
12748            md.contains("annotation-id="),
12749            "JFM should contain annotation-id for inlineCard, got: {md}"
12750        );
12751
12752        let round_tripped = markdown_to_adf(&md).unwrap();
12753        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12754        let card_node = nodes.iter().find(|n| n.node_type == "inlineCard").unwrap();
12755        let marks = card_node
12756            .marks
12757            .as_ref()
12758            .expect("inlineCard should have marks");
12759        assert!(
12760            marks.iter().any(|m| m.mark_type == "annotation"),
12761            "inlineCard should have annotation mark, got: {marks:?}"
12762        );
12763    }
12764
12765    #[test]
12766    fn annotation_on_placeholder_round_trip() {
12767        let mut placeholder = AdfNode::placeholder("Enter text here");
12768        placeholder.marks = Some(vec![AdfMark::annotation("ann-ph-1", "inlineComment")]);
12769
12770        let doc = AdfDocument {
12771            version: 1,
12772            doc_type: "doc".to_string(),
12773            content: vec![AdfNode::paragraph(vec![placeholder])],
12774        };
12775        let md = adf_to_markdown(&doc).unwrap();
12776        assert!(
12777            md.contains("annotation-id="),
12778            "JFM should contain annotation-id for placeholder, got: {md}"
12779        );
12780
12781        let round_tripped = markdown_to_adf(&md).unwrap();
12782        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12783        let ph_node = nodes.iter().find(|n| n.node_type == "placeholder").unwrap();
12784        let marks = ph_node
12785            .marks
12786            .as_ref()
12787            .expect("placeholder should have marks");
12788        assert!(
12789            marks.iter().any(|m| m.mark_type == "annotation"),
12790            "placeholder should have annotation mark, got: {marks:?}"
12791        );
12792    }
12793
12794    #[test]
12795    fn multiple_annotations_on_emoji_round_trip() {
12796        // Multiple annotation marks on a single emoji node
12797        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12798          {"type":"emoji","attrs":{"shortName":":fire:","text":"🔥"},"marks":[
12799            {"type":"annotation","attrs":{"id":"ann-1","annotationType":"inlineComment"}},
12800            {"type":"annotation","attrs":{"id":"ann-2","annotationType":"inlineComment"}}
12801          ]}
12802        ]}]}"#;
12803        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12804        let md = adf_to_markdown(&doc).unwrap();
12805
12806        let round_tripped = markdown_to_adf(&md).unwrap();
12807        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12808        let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12809        let marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12810        let annotations: Vec<_> = marks
12811            .iter()
12812            .filter(|m| m.mark_type == "annotation")
12813            .collect();
12814        assert_eq!(
12815            annotations.len(),
12816            2,
12817            "emoji should have 2 annotation marks, got: {annotations:?}"
12818        );
12819    }
12820
12821    #[test]
12822    fn emoji_without_annotation_unchanged() {
12823        // Ensure emoji nodes without annotation marks are not affected
12824        let doc = AdfDocument {
12825            version: 1,
12826            doc_type: "doc".to_string(),
12827            content: vec![AdfNode::paragraph(vec![AdfNode::emoji(":fire:")])],
12828        };
12829        let md = adf_to_markdown(&doc).unwrap();
12830        // Should NOT have bracketed span wrapping
12831        assert!(
12832            !md.contains('['),
12833            "emoji without annotation should not be wrapped in brackets, got: {md}"
12834        );
12835        assert!(md.contains(":fire:"));
12836    }
12837
12838    // ── Inline directive tests (Tier 4) ───────────────────────────────
12839
12840    #[test]
12841    fn status_directive() {
12842        let doc = markdown_to_adf("The ticket is :status[In Progress]{color=blue}.").unwrap();
12843        let content = doc.content[0].content.as_ref().unwrap();
12844        assert_eq!(content[1].node_type, "status");
12845        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "In Progress");
12846        assert_eq!(content[1].attrs.as_ref().unwrap()["color"], "blue");
12847    }
12848
12849    #[test]
12850    fn adf_status_to_markdown() {
12851        let doc = AdfDocument {
12852            version: 1,
12853            doc_type: "doc".to_string(),
12854            content: vec![AdfNode::paragraph(vec![AdfNode::status("Done", "green")])],
12855        };
12856        let md = adf_to_markdown(&doc).unwrap();
12857        assert!(md.contains(":status[Done]{color=green}"));
12858    }
12859
12860    #[test]
12861    fn round_trip_status() {
12862        let md = "The ticket is :status[In Progress]{color=blue}.\n";
12863        let doc = markdown_to_adf(md).unwrap();
12864        let result = adf_to_markdown(&doc).unwrap();
12865        assert!(result.contains(":status[In Progress]{color=blue}"));
12866    }
12867
12868    #[test]
12869    fn status_with_style_and_localid_roundtrips() {
12870        let adf = AdfDocument {
12871            version: 1,
12872            doc_type: "doc".to_string(),
12873            content: vec![AdfNode::paragraph(vec![{
12874                let mut node = AdfNode::status("open", "green");
12875                node.attrs.as_mut().unwrap()["style"] =
12876                    serde_json::Value::String("bold".to_string());
12877                node.attrs.as_mut().unwrap()["localId"] =
12878                    serde_json::Value::String("d2205ca5-84b9-4950-a730-bfe550fc146b".to_string());
12879                node
12880            }])],
12881        };
12882
12883        let md = adf_to_markdown(&adf).unwrap();
12884        assert!(
12885            md.contains("style=bold"),
12886            "Markdown should contain style attr: {md}"
12887        );
12888        assert!(
12889            md.contains("localId=d2205ca5"),
12890            "Markdown should contain localId attr: {md}"
12891        );
12892
12893        let rt = markdown_to_adf(&md).unwrap();
12894        let status = &rt.content[0].content.as_ref().unwrap()[0];
12895        let attrs = status.attrs.as_ref().unwrap();
12896        assert_eq!(attrs["text"], "open");
12897        assert_eq!(attrs["color"], "green");
12898        assert_eq!(attrs["style"], "bold");
12899        assert_eq!(
12900            attrs["localId"], "d2205ca5-84b9-4950-a730-bfe550fc146b",
12901            "localId should be preserved, got: {}",
12902            attrs["localId"]
12903        );
12904    }
12905
12906    #[test]
12907    fn status_without_style_still_works() {
12908        let md = ":status[Done]{color=green}\n";
12909        let doc = markdown_to_adf(md).unwrap();
12910        let status = &doc.content[0].content.as_ref().unwrap()[0];
12911        let attrs = status.attrs.as_ref().unwrap();
12912        assert_eq!(attrs["text"], "Done");
12913        assert_eq!(attrs["color"], "green");
12914        // No style attr — should not be present
12915        assert!(
12916            attrs.get("style").is_none() || attrs["style"].is_null(),
12917            "style should not be set when not provided"
12918        );
12919    }
12920
12921    #[test]
12922    fn strip_local_ids_removes_localid_from_status() {
12923        let adf = AdfDocument {
12924            version: 1,
12925            doc_type: "doc".to_string(),
12926            content: vec![AdfNode::paragraph(vec![{
12927                let mut node = AdfNode::status("open", "green");
12928                node.attrs.as_mut().unwrap()["localId"] =
12929                    serde_json::Value::String("real-uuid-here".to_string());
12930                node
12931            }])],
12932        };
12933        let opts = RenderOptions {
12934            strip_local_ids: true,
12935        };
12936        let md = adf_to_markdown_with_options(&adf, &opts).unwrap();
12937        assert!(
12938            !md.contains("localId"),
12939            "localId should be stripped, got: {md}"
12940        );
12941        assert!(md.contains("color=green"), "color should be preserved");
12942    }
12943
12944    #[test]
12945    fn strip_local_ids_removes_localid_from_table() {
12946        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"}]}]}]}]}]}"#;
12947        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12948        let opts = RenderOptions {
12949            strip_local_ids: true,
12950        };
12951        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12952        assert!(
12953            !md.contains("localId"),
12954            "localId should be stripped from table, got: {md}"
12955        );
12956        assert!(md.contains("layout=default"), "layout should be preserved");
12957    }
12958
12959    #[test]
12960    fn default_options_preserve_localid() {
12961        let adf = AdfDocument {
12962            version: 1,
12963            doc_type: "doc".to_string(),
12964            content: vec![AdfNode::paragraph(vec![{
12965                let mut node = AdfNode::status("open", "green");
12966                node.attrs.as_mut().unwrap()["localId"] =
12967                    serde_json::Value::String("real-uuid-here".to_string());
12968                node
12969            }])],
12970        };
12971        let md = adf_to_markdown(&adf).unwrap();
12972        assert!(
12973            md.contains("localId=real-uuid-here"),
12974            "Default should preserve localId, got: {md}"
12975        );
12976    }
12977
12978    #[test]
12979    fn mention_localid_roundtrip() {
12980        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
12981        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12982        let md = adf_to_markdown(&doc).unwrap();
12983        assert!(
12984            md.contains("localId=m-001"),
12985            "mention should have localId in md: {md}"
12986        );
12987        let rt = markdown_to_adf(&md).unwrap();
12988        let mention = &rt.content[0].content.as_ref().unwrap()[0];
12989        assert_eq!(mention.attrs.as_ref().unwrap()["localId"], "m-001");
12990    }
12991
12992    #[test]
12993    fn date_localid_roundtrip() {
12994        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
12995        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12996        let md = adf_to_markdown(&doc).unwrap();
12997        assert!(
12998            md.contains("localId=d-001"),
12999            "date should have localId in md: {md}"
13000        );
13001        let rt = markdown_to_adf(&md).unwrap();
13002        let date = &rt.content[0].content.as_ref().unwrap()[0];
13003        assert_eq!(date.attrs.as_ref().unwrap()["localId"], "d-001");
13004    }
13005
13006    #[test]
13007    fn emoji_localid_roundtrip() {
13008        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"emoji","attrs":{"shortName":":smile:","localId":"e-001"}}]}]}"#;
13009        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13010        let md = adf_to_markdown(&doc).unwrap();
13011        assert!(
13012            md.contains("localId=e-001"),
13013            "emoji should have localId in md: {md}"
13014        );
13015        let rt = markdown_to_adf(&md).unwrap();
13016        let emoji = &rt.content[0].content.as_ref().unwrap()[0];
13017        assert_eq!(emoji.attrs.as_ref().unwrap()["localId"], "e-001");
13018    }
13019
13020    #[test]
13021    fn inline_card_localid_roundtrip() {
13022        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"inlineCard","attrs":{"url":"https://example.com","localId":"c-001"}}]}]}"#;
13023        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13024        let md = adf_to_markdown(&doc).unwrap();
13025        assert!(
13026            md.contains("localId=c-001"),
13027            "inlineCard should have localId in md: {md}"
13028        );
13029        let rt = markdown_to_adf(&md).unwrap();
13030        let card = &rt.content[0].content.as_ref().unwrap()[0];
13031        assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-001");
13032    }
13033
13034    #[test]
13035    fn strip_local_ids_removes_from_mention() {
13036        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
13037        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13038        let opts = RenderOptions {
13039            strip_local_ids: true,
13040        };
13041        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13042        assert!(
13043            !md.contains("localId"),
13044            "localId should be stripped from mention: {md}"
13045        );
13046        assert!(md.contains("id=user123"), "other attrs should be preserved");
13047    }
13048
13049    #[test]
13050    fn strip_local_ids_removes_from_date() {
13051        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
13052        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13053        let opts = RenderOptions {
13054            strip_local_ids: true,
13055        };
13056        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13057        assert!(
13058            !md.contains("localId"),
13059            "localId should be stripped from date: {md}"
13060        );
13061    }
13062
13063    #[test]
13064    fn strip_local_ids_removes_from_block_attrs() {
13065        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"hello"}]}]}"#;
13066        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13067        let opts = RenderOptions {
13068            strip_local_ids: true,
13069        };
13070        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13071        assert!(
13072            !md.contains("localId"),
13073            "localId should be stripped from block attrs: {md}"
13074        );
13075    }
13076
13077    #[test]
13078    fn table_cell_localid_roundtrip() {
13079        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"}]}]}]}]}]}"#;
13080        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13081        let md = adf_to_markdown(&doc).unwrap();
13082        assert!(
13083            md.contains("localId=tc-001"),
13084            "tableCell should have localId in md: {md}"
13085        );
13086        let rt = markdown_to_adf(&md).unwrap();
13087        let cell = &rt.content[0].content.as_ref().unwrap()[0]
13088            .content
13089            .as_ref()
13090            .unwrap()[0];
13091        assert_eq!(
13092            cell.attrs.as_ref().unwrap()["localId"],
13093            "tc-001",
13094            "tableCell localId should round-trip"
13095        );
13096    }
13097
13098    #[test]
13099    fn table_cell_border_mark_roundtrip() {
13100        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"}]}]}]}]}]}"##;
13101        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13102        let md = adf_to_markdown(&doc).unwrap();
13103        assert!(
13104            md.contains("border-color=#ff000033"),
13105            "tableCell should have border-color in md: {md}"
13106        );
13107        assert!(
13108            md.contains("border-size=2"),
13109            "tableCell should have border-size in md: {md}"
13110        );
13111        let rt = markdown_to_adf(&md).unwrap();
13112        let cell = &rt.content[0].content.as_ref().unwrap()[0]
13113            .content
13114            .as_ref()
13115            .unwrap()[0];
13116        let marks = cell.marks.as_ref().expect("tableCell should have marks");
13117        assert_eq!(marks.len(), 1);
13118        assert_eq!(marks[0].mark_type, "border");
13119        let attrs = marks[0].attrs.as_ref().unwrap();
13120        assert_eq!(attrs["color"], "#ff000033");
13121        assert_eq!(attrs["size"], 2);
13122    }
13123
13124    #[test]
13125    fn table_header_border_mark_roundtrip() {
13126        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"}]}]}]}]}]}"##;
13127        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13128        let md = adf_to_markdown(&doc).unwrap();
13129        assert!(md.contains("border-color=#0000ff"), "md: {md}");
13130        assert!(md.contains("border-size=3"), "md: {md}");
13131        let rt = markdown_to_adf(&md).unwrap();
13132        let cell = &rt.content[0].content.as_ref().unwrap()[0]
13133            .content
13134            .as_ref()
13135            .unwrap()[0];
13136        assert_eq!(cell.node_type, "tableHeader");
13137        let marks = cell.marks.as_ref().expect("tableHeader should have marks");
13138        assert_eq!(marks[0].mark_type, "border");
13139        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#0000ff");
13140        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
13141    }
13142
13143    #[test]
13144    fn table_cell_border_mark_with_attrs_roundtrip() {
13145        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"}]}]}]}]}]}"##;
13146        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13147        let md = adf_to_markdown(&doc).unwrap();
13148        assert!(md.contains("bg=#e6fcff"), "md: {md}");
13149        assert!(md.contains("colspan=2"), "md: {md}");
13150        assert!(md.contains("border-color=#ff000033"), "md: {md}");
13151        let rt = markdown_to_adf(&md).unwrap();
13152        let cell = &rt.content[0].content.as_ref().unwrap()[0]
13153            .content
13154            .as_ref()
13155            .unwrap()[0];
13156        assert_eq!(cell.attrs.as_ref().unwrap()["background"], "#e6fcff");
13157        assert_eq!(cell.attrs.as_ref().unwrap()["colspan"], 2);
13158        let marks = cell.marks.as_ref().expect("should have marks");
13159        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff000033");
13160    }
13161
13162    #[test]
13163    fn table_cell_no_border_mark_unchanged() {
13164        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"}]}]}]}]}]}"#;
13165        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13166        let md = adf_to_markdown(&doc).unwrap();
13167        assert!(
13168            !md.contains("border-color"),
13169            "no border attrs expected: {md}"
13170        );
13171        let rt = markdown_to_adf(&md).unwrap();
13172        let cell = &rt.content[0].content.as_ref().unwrap()[0]
13173            .content
13174            .as_ref()
13175            .unwrap()[0];
13176        assert!(cell.marks.is_none(), "no marks expected on plain cell");
13177    }
13178
13179    #[test]
13180    fn table_cell_border_size_only_defaults_color() {
13181        // border-size without border-color should still produce a border mark
13182        // with the default color
13183        let md = "::::table\n:::tr\n:::td{border-size=3}\ncell\n:::\n:::\n::::\n";
13184        let doc = markdown_to_adf(md).unwrap();
13185        let cell = &doc.content[0].content.as_ref().unwrap()[0]
13186            .content
13187            .as_ref()
13188            .unwrap()[0];
13189        let marks = cell.marks.as_ref().expect("should have border mark");
13190        assert_eq!(marks[0].mark_type, "border");
13191        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
13192        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
13193    }
13194
13195    #[test]
13196    fn table_cell_border_color_only_defaults_size() {
13197        // border-color without border-size should default size to 1
13198        let md = "::::table\n:::tr\n:::td{border-color=#ff0000}\ncell\n:::\n:::\n::::\n";
13199        let doc = markdown_to_adf(md).unwrap();
13200        let cell = &doc.content[0].content.as_ref().unwrap()[0]
13201            .content
13202            .as_ref()
13203            .unwrap()[0];
13204        let marks = cell.marks.as_ref().expect("should have border mark");
13205        assert_eq!(marks[0].mark_type, "border");
13206        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
13207        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
13208    }
13209
13210    #[test]
13211    fn media_file_border_mark_roundtrip() {
13212        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}}]}]}]}"##;
13213        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13214        let md = adf_to_markdown(&doc).unwrap();
13215        assert!(
13216            md.contains("border-color=#091e4224"),
13217            "media should have border-color in md: {md}"
13218        );
13219        assert!(
13220            md.contains("border-size=2"),
13221            "media should have border-size in md: {md}"
13222        );
13223        let rt = markdown_to_adf(&md).unwrap();
13224        let media_single = &rt.content[0];
13225        let media = &media_single.content.as_ref().unwrap()[0];
13226        assert_eq!(media.node_type, "media");
13227        let marks = media.marks.as_ref().expect("media should have marks");
13228        assert_eq!(marks.len(), 1);
13229        assert_eq!(marks[0].mark_type, "border");
13230        let attrs = marks[0].attrs.as_ref().unwrap();
13231        assert_eq!(attrs["color"], "#091e4224");
13232        assert_eq!(attrs["size"], 2);
13233    }
13234
13235    #[test]
13236    fn media_external_border_mark_roundtrip() {
13237        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}}]}]}]}"##;
13238        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13239        let md = adf_to_markdown(&doc).unwrap();
13240        assert!(
13241            md.contains("border-color=#ff0000"),
13242            "external media should have border-color in md: {md}"
13243        );
13244        assert!(
13245            md.contains("border-size=3"),
13246            "external media should have border-size in md: {md}"
13247        );
13248        let rt = markdown_to_adf(&md).unwrap();
13249        let media = &rt.content[0].content.as_ref().unwrap()[0];
13250        let marks = media.marks.as_ref().expect("media should have marks");
13251        assert_eq!(marks[0].mark_type, "border");
13252        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
13253        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
13254    }
13255
13256    #[test]
13257    fn media_file_no_border_mark_unchanged() {
13258        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}}]}]}"#;
13259        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13260        let md = adf_to_markdown(&doc).unwrap();
13261        assert!(
13262            !md.contains("border-color"),
13263            "no border attrs expected: {md}"
13264        );
13265        let rt = markdown_to_adf(&md).unwrap();
13266        let media = &rt.content[0].content.as_ref().unwrap()[0];
13267        assert!(media.marks.is_none(), "no marks expected on plain media");
13268    }
13269
13270    #[test]
13271    fn media_border_size_only_defaults_color() {
13272        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}}]}]}]}"#;
13273        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13274        let md = adf_to_markdown(&doc).unwrap();
13275        assert!(md.contains("border-size=4"), "md: {md}");
13276        let rt = markdown_to_adf(&md).unwrap();
13277        let media = &rt.content[0].content.as_ref().unwrap()[0];
13278        let marks = media.marks.as_ref().expect("should have border mark");
13279        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
13280        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 4);
13281    }
13282
13283    #[test]
13284    fn media_border_color_only_defaults_size() {
13285        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"}}]}]}]}"##;
13286        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13287        let md = adf_to_markdown(&doc).unwrap();
13288        assert!(md.contains("border-color=#00ff00"), "md: {md}");
13289        let rt = markdown_to_adf(&md).unwrap();
13290        let media = &rt.content[0].content.as_ref().unwrap()[0];
13291        let marks = media.marks.as_ref().expect("should have border mark");
13292        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#00ff00");
13293        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
13294    }
13295
13296    #[test]
13297    fn media_border_with_other_attrs_roundtrip() {
13298        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}}]}]}]}"##;
13299        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13300        let md = adf_to_markdown(&doc).unwrap();
13301        assert!(md.contains("layout=wide"), "md: {md}");
13302        assert!(md.contains("mediaWidth=600"), "md: {md}");
13303        assert!(md.contains("border-color=#091e4224"), "md: {md}");
13304        assert!(md.contains("border-size=2"), "md: {md}");
13305        let rt = markdown_to_adf(&md).unwrap();
13306        let ms = &rt.content[0];
13307        assert_eq!(ms.attrs.as_ref().unwrap()["layout"], "wide");
13308        let media = &ms.content.as_ref().unwrap()[0];
13309        let marks = media.marks.as_ref().expect("should have marks");
13310        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#091e4224");
13311        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 2);
13312    }
13313
13314    #[test]
13315    fn table_row_localid_roundtrip() {
13316        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"}]}]}]}]}]}"#;
13317        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13318        let md = adf_to_markdown(&doc).unwrap();
13319        assert!(
13320            md.contains("localId=tr-001"),
13321            "tableRow should have localId in md: {md}"
13322        );
13323        let rt = markdown_to_adf(&md).unwrap();
13324        let row = &rt.content[0].content.as_ref().unwrap()[0];
13325        assert_eq!(
13326            row.attrs.as_ref().unwrap()["localId"],
13327            "tr-001",
13328            "tableRow localId should round-trip"
13329        );
13330    }
13331
13332    #[test]
13333    fn list_item_localid_roundtrip() {
13334        // listItem localId is emitted as trailing inline attrs and parsed back
13335        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"}]}]}]}]}"#;
13336        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13337        let md = adf_to_markdown(&doc).unwrap();
13338        assert!(
13339            md.contains("localId=li-001"),
13340            "listItem should have localId in md: {md}"
13341        );
13342        // Verify localId is on the listItem, NOT promoted to bulletList
13343        let rt = markdown_to_adf(&md).unwrap();
13344        let list = &rt.content[0];
13345        assert!(
13346            list.attrs.is_none() || list.attrs.as_ref().unwrap().get("localId").is_none(),
13347            "bulletList should NOT have localId: {:?}",
13348            list.attrs
13349        );
13350        let item = &list.content.as_ref().unwrap()[0];
13351        assert_eq!(
13352            item.attrs.as_ref().unwrap()["localId"],
13353            "li-001",
13354            "listItem should have localId=li-001"
13355        );
13356    }
13357
13358    #[test]
13359    fn list_item_localid_not_promoted_to_parent() {
13360        // Verify localId stays on listItem and doesn't leak to parent list
13361        let md = "- item {localId=li-002}\n";
13362        let doc = markdown_to_adf(md).unwrap();
13363        let list = &doc.content[0];
13364        assert!(
13365            list.attrs.is_none(),
13366            "bulletList should have no attrs: {:?}",
13367            list.attrs
13368        );
13369        let item = &list.content.as_ref().unwrap()[0];
13370        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "li-002");
13371    }
13372
13373    #[test]
13374    fn ordered_list_item_localid_roundtrip() {
13375        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"}]}]}]}]}"#;
13376        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13377        let md = adf_to_markdown(&doc).unwrap();
13378        assert!(md.contains("localId=oli-001"), "md: {md}");
13379        let rt = markdown_to_adf(&md).unwrap();
13380        let item = &rt.content[0].content.as_ref().unwrap()[0];
13381        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
13382    }
13383
13384    #[test]
13385    fn task_item_localid_roundtrip() {
13386        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"}]}]}]}]}"#;
13387        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13388        let md = adf_to_markdown(&doc).unwrap();
13389        assert!(md.contains("localId=ti-001"), "md: {md}");
13390        let rt = markdown_to_adf(&md).unwrap();
13391        let item = &rt.content[0].content.as_ref().unwrap()[0];
13392        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "ti-001");
13393    }
13394
13395    /// Issue #447: taskList with empty-string localId and taskItems with
13396    /// short numeric localIds must survive a full round-trip.
13397    #[test]
13398    fn task_list_short_localid_roundtrip() {
13399        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"}]}]}]}"#;
13400        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13401        let md = adf_to_markdown(&doc).unwrap();
13402        // Both taskItem localIds should appear in the markdown
13403        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13404        assert!(md.contains("localId=99"), "localId=99 missing: {md}");
13405        // Empty-string localId should NOT appear as {localId=}
13406        assert!(
13407            !md.contains("localId=}"),
13408            "empty localId should not be emitted: {md}"
13409        );
13410        let rt = markdown_to_adf(&md).unwrap();
13411        let task_list = &rt.content[0];
13412        assert_eq!(task_list.node_type, "taskList");
13413        // No spurious extra nodes from {localId=}
13414        assert_eq!(rt.content.len(), 1, "should be exactly one top-level node");
13415        let items = task_list.content.as_ref().unwrap();
13416        assert_eq!(items.len(), 2);
13417        // First taskItem: localId=42, state=TODO, no content
13418        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13419        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
13420        assert!(
13421            items[0].content.is_none(),
13422            "empty taskItem should have no content: {:?}",
13423            items[0].content
13424        );
13425        // Second taskItem: localId=99, state=DONE, content with text
13426        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "99");
13427        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
13428        let content = items[1].content.as_ref().unwrap();
13429        assert_eq!(content.len(), 1);
13430        assert_eq!(content[0].text.as_deref(), Some("done task"));
13431    }
13432
13433    /// Issue #507: numeric localId on taskItem with hardBreak must survive
13434    /// round-trip — the {localId=…} suffix lands on the continuation line
13435    /// and must still be extracted by the parser.
13436    #[test]
13437    fn task_item_numeric_localid_with_hardbreak_roundtrip() {
13438        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!!)"}]}]}]}]}"#;
13439        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13440        let md = adf_to_markdown(&doc).unwrap();
13441        // localId must appear in the markdown output
13442        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13443        // Round-trip back to ADF
13444        let rt = markdown_to_adf(&md).unwrap();
13445        assert_eq!(rt.content.len(), 1, "exactly one top-level node");
13446        let task_list = &rt.content[0];
13447        assert_eq!(task_list.node_type, "taskList");
13448        let items = task_list.content.as_ref().unwrap();
13449        assert_eq!(items.len(), 1);
13450        // localId preserved
13451        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13452        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
13453        // Content structure preserved: paragraph with link + hardBreak + text
13454        let para = &items[0].content.as_ref().unwrap()[0];
13455        assert_eq!(para.node_type, "paragraph");
13456        let inlines = para.content.as_ref().unwrap();
13457        assert_eq!(inlines[0].node_type, "text");
13458        assert_eq!(
13459            inlines[0].text.as_deref(),
13460            Some("Engineering Onboarding Link")
13461        );
13462        assert_eq!(inlines[1].node_type, "hardBreak");
13463        assert_eq!(inlines[2].node_type, "text");
13464        assert_eq!(
13465            inlines[2].text.as_deref(),
13466            Some("(This has links to all the various useful tools!!)")
13467        );
13468        // The {localId=…} must not appear as literal text in the ADF output
13469        let rt_json = serde_json::to_string(&rt).unwrap();
13470        assert!(
13471            !rt_json.contains("{localId="),
13472            "localId attr syntax should not leak into ADF text: {rt_json}"
13473        );
13474    }
13475
13476    /// Issue #507: multiple taskItems with hardBreaks and numeric localIds.
13477    #[test]
13478    fn task_item_multiple_hardbreak_localids_roundtrip() {
13479        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"}]}]}]}]}"#;
13480        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13481        let md = adf_to_markdown(&doc).unwrap();
13482        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13483        assert!(md.contains("localId=67"), "localId=67 missing: {md}");
13484        let rt = markdown_to_adf(&md).unwrap();
13485        let items = rt.content[0].content.as_ref().unwrap();
13486        assert_eq!(items.len(), 2);
13487        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13488        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "67");
13489        // Verify hardBreak content structure for both items
13490        for item in items {
13491            let para = &item.content.as_ref().unwrap()[0];
13492            assert_eq!(para.node_type, "paragraph");
13493            let inlines = para.content.as_ref().unwrap();
13494            assert_eq!(inlines[1].node_type, "hardBreak");
13495        }
13496    }
13497
13498    /// Issue #521: sibling taskItems with numeric localIds and hardBreak —
13499    /// unwrapped inline content.  The hardBreak continuation line must be
13500    /// indented so it stays within the list item, and both localIds must
13501    /// survive the round-trip.
13502    #[test]
13503    fn task_item_sibling_localid_hardbreak_unwrapped_roundtrip() {
13504        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"}]}]}]}"#;
13505        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13506        let md = adf_to_markdown(&doc).unwrap();
13507        // Continuation line must be indented
13508        assert!(
13509            md.contains("  (parenthetical"),
13510            "continuation line should be 2-space indented: {md}"
13511        );
13512        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13513        assert!(md.contains("localId=69"), "localId=69 missing: {md}");
13514        let rt = markdown_to_adf(&md).unwrap();
13515        // Must remain a single taskList with 2 items
13516        assert_eq!(
13517            rt.content.len(),
13518            1,
13519            "should be one taskList: {:#?}",
13520            rt.content
13521        );
13522        assert_eq!(rt.content[0].node_type, "taskList");
13523        let items = rt.content[0].content.as_ref().unwrap();
13524        assert_eq!(items.len(), 2, "should have 2 taskItems");
13525        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13526        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13527        // Verify first item has hardBreak
13528        let first_content = items[0].content.as_ref().unwrap();
13529        assert!(
13530            first_content.iter().any(|n| n.node_type == "hardBreak"),
13531            "first item should contain hardBreak"
13532        );
13533        // Verify second item content
13534        let second_content = items[1].content.as_ref().unwrap();
13535        assert_eq!(second_content[0].node_type, "text");
13536        assert_eq!(
13537            second_content[0].text.as_deref().unwrap(),
13538            "second task item"
13539        );
13540    }
13541
13542    /// Issue #521: sibling taskItems with paragraph-wrapped content and
13543    /// hardBreak — localIds must not be swapped or lost.
13544    #[test]
13545    fn task_item_sibling_localid_hardbreak_paragraph_roundtrip() {
13546        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"}]}]}]}]}"#;
13547        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13548        let md = adf_to_markdown(&doc).unwrap();
13549        let rt = markdown_to_adf(&md).unwrap();
13550        assert_eq!(
13551            rt.content.len(),
13552            1,
13553            "should be one taskList: {:#?}",
13554            rt.content
13555        );
13556        let items = rt.content[0].content.as_ref().unwrap();
13557        assert_eq!(items.len(), 2);
13558        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13559        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13560    }
13561
13562    /// Issue #521: three sibling taskItems — the middle one has a hardBreak.
13563    /// Ensures localIds don't leak between adjacent items.
13564    #[test]
13565    fn task_item_three_siblings_middle_hardbreak_roundtrip() {
13566        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"}]}]}]}"#;
13567        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13568        let md = adf_to_markdown(&doc).unwrap();
13569        let rt = markdown_to_adf(&md).unwrap();
13570        assert_eq!(rt.content.len(), 1);
13571        let items = rt.content[0].content.as_ref().unwrap();
13572        assert_eq!(items.len(), 3);
13573        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "10");
13574        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "20");
13575        assert_eq!(items[2].attrs.as_ref().unwrap()["localId"], "30");
13576        // Middle item should have hardBreak
13577        let mid_content = items[1].content.as_ref().unwrap();
13578        assert!(mid_content.iter().any(|n| n.node_type == "hardBreak"));
13579    }
13580
13581    /// Issue #447: regression — taskList with empty localId must not inject
13582    /// a spurious paragraph.
13583    #[test]
13584    fn task_list_empty_localid_no_spurious_paragraph() {
13585        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"}]}]}]}"#;
13586        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13587        let md = adf_to_markdown(&doc).unwrap();
13588        assert!(
13589            !md.contains("{localId=}"),
13590            "empty localId should not be emitted: {md}"
13591        );
13592        let rt = markdown_to_adf(&md).unwrap();
13593        assert_eq!(
13594            rt.content.len(),
13595            1,
13596            "no spurious paragraph: {:#?}",
13597            rt.content
13598        );
13599        assert_eq!(rt.content[0].node_type, "taskList");
13600    }
13601
13602    /// Issue #447: taskList localId should be stripped when strip_local_ids is set.
13603    #[test]
13604    fn task_list_localid_stripped() {
13605        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"}]}]}]}]}"#;
13606        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13607        let opts = RenderOptions {
13608            strip_local_ids: true,
13609        };
13610        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13611        assert!(!md.contains("localId"), "localId should be stripped: {md}");
13612    }
13613
13614    /// Issue #447: taskItem with no content still emits localId.
13615    #[test]
13616    fn task_item_no_content_emits_localid() {
13617        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"}}]}]}"#;
13618        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13619        let md = adf_to_markdown(&doc).unwrap();
13620        assert!(
13621            md.contains("localId=abc"),
13622            "localId should be emitted even without content: {md}"
13623        );
13624        let rt = markdown_to_adf(&md).unwrap();
13625        let item = &rt.content[0].content.as_ref().unwrap()[0];
13626        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "abc");
13627        assert!(item.content.is_none(), "should have no content");
13628    }
13629
13630    /// Issue #447: taskList localId roundtrips through block attrs.
13631    #[test]
13632    fn task_list_localid_roundtrip() {
13633        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"}]}]}]}]}"#;
13634        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13635        let md = adf_to_markdown(&doc).unwrap();
13636        assert!(
13637            md.contains("localId=tl-xyz"),
13638            "taskList localId missing: {md}"
13639        );
13640        let rt = markdown_to_adf(&md).unwrap();
13641        assert_eq!(
13642            rt.content[0].attrs.as_ref().unwrap()["localId"],
13643            "tl-xyz",
13644            "taskList localId should survive round-trip"
13645        );
13646    }
13647
13648    /// Issue #478: taskItem with paragraph wrapper (no localId) preserves wrapper on round-trip.
13649    #[test]
13650    fn task_item_paragraph_wrapper_roundtrip_no_localid() {
13651        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"}]}]}]}]}"#;
13652        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13653        let md = adf_to_markdown(&doc).unwrap();
13654        assert!(
13655            md.contains("paraLocalId=_"),
13656            "should emit paraLocalId=_ sentinel: {md}"
13657        );
13658        let rt = markdown_to_adf(&md).unwrap();
13659        let item = &rt.content[0].content.as_ref().unwrap()[0];
13660        let content = item.content.as_ref().unwrap();
13661        assert_eq!(content.len(), 1, "should have one child: {content:#?}");
13662        assert_eq!(
13663            content[0].node_type, "paragraph",
13664            "child should be a paragraph: {content:#?}"
13665        );
13666        let para_content = content[0].content.as_ref().unwrap();
13667        assert_eq!(
13668            para_content[0].text.as_deref(),
13669            Some("A task with paragraph wrapper")
13670        );
13671        // Paragraph should have no attrs (localId was absent in the original)
13672        assert!(
13673            content[0].attrs.is_none(),
13674            "paragraph should have no attrs: {:?}",
13675            content[0].attrs
13676        );
13677    }
13678
13679    /// Issue #478: taskItem with paragraph wrapper AND paraLocalId preserves both.
13680    #[test]
13681    fn task_item_paragraph_wrapper_roundtrip_with_localid() {
13682        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"}]}]}]}]}"#;
13683        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13684        let md = adf_to_markdown(&doc).unwrap();
13685        assert!(
13686            md.contains("paraLocalId=p-001"),
13687            "should emit paraLocalId=p-001: {md}"
13688        );
13689        let rt = markdown_to_adf(&md).unwrap();
13690        let item = &rt.content[0].content.as_ref().unwrap()[0];
13691        let content = item.content.as_ref().unwrap();
13692        assert_eq!(content[0].node_type, "paragraph");
13693        assert_eq!(
13694            content[0].attrs.as_ref().unwrap()["localId"],
13695            "p-001",
13696            "paragraph localId should be preserved"
13697        );
13698    }
13699
13700    /// Issue #478: taskItem WITHOUT paragraph wrapper (unwrapped inline) still round-trips correctly.
13701    #[test]
13702    fn task_item_unwrapped_inline_no_paragraph_on_roundtrip() {
13703        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"}]}]}]}"#;
13704        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13705        let md = adf_to_markdown(&doc).unwrap();
13706        assert!(
13707            !md.contains("paraLocalId"),
13708            "should NOT emit paraLocalId for unwrapped inline: {md}"
13709        );
13710        let rt = markdown_to_adf(&md).unwrap();
13711        let item = &rt.content[0].content.as_ref().unwrap()[0];
13712        let content = item.content.as_ref().unwrap();
13713        assert_eq!(
13714            content[0].node_type, "text",
13715            "should remain unwrapped: {content:#?}"
13716        );
13717    }
13718
13719    /// Issue #478: DONE taskItem with paragraph wrapper round-trips.
13720    #[test]
13721    fn task_item_done_paragraph_wrapper_roundtrip() {
13722        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"}]}]}]}]}"#;
13723        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13724        let md = adf_to_markdown(&doc).unwrap();
13725        assert!(md.contains("- [x]"), "should render as done: {md}");
13726        let rt = markdown_to_adf(&md).unwrap();
13727        let item = &rt.content[0].content.as_ref().unwrap()[0];
13728        assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
13729        let content = item.content.as_ref().unwrap();
13730        assert_eq!(content[0].node_type, "paragraph");
13731    }
13732
13733    /// Issue #478: mixed taskItems — some with paragraph wrapper, some without.
13734    #[test]
13735    fn task_item_mixed_paragraph_and_unwrapped_roundtrip() {
13736        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"}]}]}]}"#;
13737        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13738        let md = adf_to_markdown(&doc).unwrap();
13739        let rt = markdown_to_adf(&md).unwrap();
13740        let items = rt.content[0].content.as_ref().unwrap();
13741        assert_eq!(items.len(), 2);
13742        // First item: paragraph wrapper preserved
13743        let c1 = items[0].content.as_ref().unwrap();
13744        assert_eq!(
13745            c1[0].node_type, "paragraph",
13746            "first item should have paragraph wrapper"
13747        );
13748        // Second item: no paragraph wrapper
13749        let c2 = items[1].content.as_ref().unwrap();
13750        assert_eq!(
13751            c2[0].node_type, "text",
13752            "second item should remain unwrapped"
13753        );
13754    }
13755
13756    /// Issue #478: taskItem with paragraph wrapper containing marks round-trips.
13757    #[test]
13758    fn task_item_paragraph_wrapper_with_marks_roundtrip() {
13759        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"}]}]}]}]}]}"#;
13760        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13761        let md = adf_to_markdown(&doc).unwrap();
13762        let rt = markdown_to_adf(&md).unwrap();
13763        let item = &rt.content[0].content.as_ref().unwrap()[0];
13764        let content = item.content.as_ref().unwrap();
13765        assert_eq!(content[0].node_type, "paragraph");
13766        let para_children = content[0].content.as_ref().unwrap();
13767        assert!(
13768            para_children.len() >= 2,
13769            "paragraph should contain multiple inline nodes"
13770        );
13771    }
13772
13773    /// Issue #478: strip_local_ids suppresses the paraLocalId=_ sentinel too.
13774    #[test]
13775    fn task_item_paragraph_wrapper_stripped_with_option() {
13776        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"}]}]}]}]}"#;
13777        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13778        let opts = RenderOptions {
13779            strip_local_ids: true,
13780        };
13781        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13782        assert!(
13783            !md.contains("paraLocalId"),
13784            "paraLocalId should be stripped: {md}"
13785        );
13786        assert!(
13787            !md.contains("localId"),
13788            "all localIds should be stripped: {md}"
13789        );
13790    }
13791
13792    #[test]
13793    fn trailing_space_preserved_with_hex_localid() {
13794        // Issue #449: trailing whitespace stripped from text node
13795        // when listItem has a hex-format localId (no hyphens)
13796        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 "}]}]}]}]}"#;
13797        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13798        let md = adf_to_markdown(&doc).unwrap();
13799        let rt = markdown_to_adf(&md).unwrap();
13800        let item = &rt.content[0].content.as_ref().unwrap()[0];
13801        assert_eq!(
13802            item.attrs.as_ref().unwrap()["localId"],
13803            "aabb112233cc",
13804            "localId should round-trip"
13805        );
13806        let para = &item.content.as_ref().unwrap()[0];
13807        let inlines = para.content.as_ref().unwrap();
13808        let last = inlines.last().unwrap();
13809        assert!(
13810            last.text.as_deref().unwrap_or("").ends_with(' '),
13811            "trailing space should be preserved, got nodes: {:?}",
13812            inlines
13813                .iter()
13814                .map(|n| (&n.node_type, &n.text))
13815                .collect::<Vec<_>>()
13816        );
13817    }
13818
13819    #[test]
13820    fn extract_trailing_local_id_preserves_trailing_space() {
13821        // Issue #449: only strip the single separator space before {localId=...}
13822        let (before, lid, _) = extract_trailing_local_id("trailing space  {localId=aabb112233cc}");
13823        assert_eq!(before, "trailing space ");
13824        assert_eq!(lid.as_deref(), Some("aabb112233cc"));
13825    }
13826
13827    #[test]
13828    fn extract_trailing_local_id_no_trailing_space() {
13829        let (before, lid, _) = extract_trailing_local_id("text {localId=abc123}");
13830        assert_eq!(before, "text");
13831        assert_eq!(lid.as_deref(), Some("abc123"));
13832    }
13833
13834    #[test]
13835    fn extract_trailing_local_id_no_attrs() {
13836        let (before, lid, pid) = extract_trailing_local_id("plain text");
13837        assert_eq!(before, "plain text");
13838        assert!(lid.is_none());
13839        assert!(pid.is_none());
13840    }
13841
13842    #[test]
13843    fn list_item_localid_stripped() {
13844        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"}]}]}]}]}"#;
13845        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13846        let opts = RenderOptions {
13847            strip_local_ids: true,
13848        };
13849        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13850        assert!(!md.contains("localId"), "localId should be stripped: {md}");
13851    }
13852
13853    #[test]
13854    fn paragraph_localid_in_list_item_roundtrip() {
13855        // Issue #417: paragraph.attrs.localId dropped in listItem context
13856        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"}]}]}]}]}"#;
13857        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13858        let md = adf_to_markdown(&doc).unwrap();
13859        assert!(
13860            md.contains("paraLocalId=para-001"),
13861            "paragraph localId should be in md: {md}"
13862        );
13863        let rt = markdown_to_adf(&md).unwrap();
13864        let item = &rt.content[0].content.as_ref().unwrap()[0];
13865        assert_eq!(
13866            item.attrs.as_ref().unwrap()["localId"],
13867            "item-001",
13868            "listItem localId should survive"
13869        );
13870        let para = &item.content.as_ref().unwrap()[0];
13871        assert_eq!(
13872            para.attrs.as_ref().unwrap()["localId"],
13873            "para-001",
13874            "paragraph localId should survive round-trip"
13875        );
13876    }
13877
13878    #[test]
13879    fn paragraph_localid_in_ordered_list_item_roundtrip() {
13880        // Issue #417: paragraph localId in ordered list
13881        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"}]}]}]}]}"#;
13882        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13883        let md = adf_to_markdown(&doc).unwrap();
13884        assert!(md.contains("paraLocalId=op-001"), "md: {md}");
13885        let rt = markdown_to_adf(&md).unwrap();
13886        let item = &rt.content[0].content.as_ref().unwrap()[0];
13887        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
13888        let para = &item.content.as_ref().unwrap()[0];
13889        assert_eq!(para.attrs.as_ref().unwrap()["localId"], "op-001");
13890    }
13891
13892    #[test]
13893    fn paragraph_localid_only_in_list_item() {
13894        // paragraph has localId but listItem does not
13895        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"}]}]}]}]}"#;
13896        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13897        let md = adf_to_markdown(&doc).unwrap();
13898        assert!(
13899            md.contains("paraLocalId=para-only"),
13900            "paragraph localId should be emitted: {md}"
13901        );
13902        let rt = markdown_to_adf(&md).unwrap();
13903        let item = &rt.content[0].content.as_ref().unwrap()[0];
13904        assert!(item.attrs.is_none(), "listItem should have no attrs");
13905        let para = &item.content.as_ref().unwrap()[0];
13906        assert_eq!(para.attrs.as_ref().unwrap()["localId"], "para-only");
13907    }
13908
13909    #[test]
13910    fn paragraph_localid_in_table_header_roundtrip() {
13911        // Issue #417: paragraph.attrs.localId dropped in tableHeader context
13912        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"}]}]}]}]}]}"#;
13913        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13914        let md = adf_to_markdown(&doc).unwrap();
13915        // Should use directive form (not pipe table) to preserve paragraph localId
13916        assert!(
13917            md.contains("localId=aaaa-aaaa"),
13918            "paragraph localId should be in md: {md}"
13919        );
13920        let rt = markdown_to_adf(&md).unwrap();
13921        let cell = &rt.content[0].content.as_ref().unwrap()[0]
13922            .content
13923            .as_ref()
13924            .unwrap()[0];
13925        let para = &cell.content.as_ref().unwrap()[0];
13926        assert_eq!(
13927            para.attrs.as_ref().unwrap()["localId"],
13928            "aaaa-aaaa",
13929            "paragraph localId should survive round-trip in tableHeader"
13930        );
13931    }
13932
13933    #[test]
13934    fn paragraph_localid_in_table_cell_roundtrip() {
13935        // Issue #417: paragraph localId in tableCell forces directive table
13936        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"}]}]}]}]}]}"#;
13937        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13938        let md = adf_to_markdown(&doc).unwrap();
13939        assert!(
13940            md.contains("localId=cell-para"),
13941            "paragraph localId should be in md: {md}"
13942        );
13943        let rt = markdown_to_adf(&md).unwrap();
13944        // Data row -> cell -> paragraph
13945        let cell = &rt.content[0].content.as_ref().unwrap()[1]
13946            .content
13947            .as_ref()
13948            .unwrap()[0];
13949        let para = &cell.content.as_ref().unwrap()[0];
13950        assert_eq!(
13951            para.attrs.as_ref().unwrap()["localId"],
13952            "cell-para",
13953            "paragraph localId should survive round-trip in tableCell"
13954        );
13955    }
13956
13957    #[test]
13958    fn nbsp_paragraph_with_localid_roundtrip() {
13959        // Issue #417: nbsp paragraph localId emitted as text instead of attrs
13960        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"nbsp-para"},"content":[{"type":"text","text":"\u00a0"}]}]}"#;
13961        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13962        let md = adf_to_markdown(&doc).unwrap();
13963        assert!(
13964            md.contains("::paragraph["),
13965            "nbsp should use directive form: {md}"
13966        );
13967        assert!(
13968            md.contains("localId=nbsp-para"),
13969            "localId should be in directive: {md}"
13970        );
13971        let rt = markdown_to_adf(&md).unwrap();
13972        let para = &rt.content[0];
13973        assert_eq!(
13974            para.attrs.as_ref().unwrap()["localId"],
13975            "nbsp-para",
13976            "localId should survive round-trip"
13977        );
13978        let text = para.content.as_ref().unwrap()[0].text.as_ref().unwrap();
13979        assert_eq!(text, "\u{00a0}", "nbsp should survive");
13980    }
13981
13982    #[test]
13983    fn empty_paragraph_with_localid_roundtrip() {
13984        // Empty paragraph directive with localId
13985        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"empty-para"}}]}"#;
13986        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13987        let md = adf_to_markdown(&doc).unwrap();
13988        assert!(
13989            md.contains("::paragraph{localId=empty-para}"),
13990            "empty paragraph should include localId in directive: {md}"
13991        );
13992        let rt = markdown_to_adf(&md).unwrap();
13993        assert_eq!(
13994            rt.content[0].attrs.as_ref().unwrap()["localId"],
13995            "empty-para"
13996        );
13997    }
13998
13999    #[test]
14000    fn paragraph_localid_stripped_from_list_item() {
14001        // strip_local_ids should also strip paraLocalId
14002        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"}]}]}]}]}"#;
14003        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14004        let opts = RenderOptions {
14005            strip_local_ids: true,
14006        };
14007        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
14008        assert!(!md.contains("localId"), "localId should be stripped: {md}");
14009        assert!(
14010            !md.contains("paraLocalId"),
14011            "paraLocalId should be stripped: {md}"
14012        );
14013    }
14014
14015    #[test]
14016    fn date_directive() {
14017        let doc = markdown_to_adf("Due by :date[2026-04-15].").unwrap();
14018        let content = doc.content[0].content.as_ref().unwrap();
14019        assert_eq!(content[1].node_type, "date");
14020        // ISO date is converted to epoch milliseconds
14021        assert_eq!(
14022            content[1].attrs.as_ref().unwrap()["timestamp"],
14023            "1776211200000"
14024        );
14025    }
14026
14027    #[test]
14028    fn adf_date_to_markdown() {
14029        // ADF dates use epoch ms; renderer converts back to ISO with timestamp attr
14030        let doc = AdfDocument {
14031            version: 1,
14032            doc_type: "doc".to_string(),
14033            content: vec![AdfNode::paragraph(vec![AdfNode::date("1776211200000")])],
14034        };
14035        let md = adf_to_markdown(&doc).unwrap();
14036        assert!(md.contains(":date[2026-04-15]{timestamp=1776211200000}"));
14037    }
14038
14039    #[test]
14040    fn adf_date_iso_passthrough() {
14041        // If ADF already has ISO date (legacy), pass through
14042        let doc = AdfDocument {
14043            version: 1,
14044            doc_type: "doc".to_string(),
14045            content: vec![AdfNode::paragraph(vec![AdfNode::date("2026-04-15")])],
14046        };
14047        let md = adf_to_markdown(&doc).unwrap();
14048        assert!(md.contains(":date[2026-04-15]{timestamp=2026-04-15}"));
14049    }
14050
14051    #[test]
14052    fn round_trip_date() {
14053        let md = "Due by :date[2026-04-15].\n";
14054        let doc = markdown_to_adf(md).unwrap();
14055        let result = adf_to_markdown(&doc).unwrap();
14056        assert!(result.contains(":date[2026-04-15]"));
14057    }
14058
14059    #[test]
14060    fn round_trip_date_non_midnight_timestamp() {
14061        // Issue #409: non-midnight timestamps must survive round-trip
14062        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000"}}]}]}"#;
14063        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14064        let md = adf_to_markdown(&doc).unwrap();
14065        // JFM should include the original timestamp
14066        assert!(
14067            md.contains("timestamp=1700000000000"),
14068            "JFM should preserve original timestamp: {md}"
14069        );
14070        // Round-trip back to ADF
14071        let doc2 = markdown_to_adf(&md).unwrap();
14072        let content = doc2.content[0].content.as_ref().unwrap();
14073        assert_eq!(
14074            content[0].attrs.as_ref().unwrap()["timestamp"],
14075            "1700000000000",
14076            "Round-trip must preserve original non-midnight timestamp"
14077        );
14078    }
14079
14080    #[test]
14081    fn date_epoch_ms_passthrough() {
14082        // If JFM date is already epoch ms, pass through
14083        let doc = markdown_to_adf("Due by :date[1776211200000].").unwrap();
14084        let content = doc.content[0].content.as_ref().unwrap();
14085        assert_eq!(
14086            content[1].attrs.as_ref().unwrap()["timestamp"],
14087            "1776211200000"
14088        );
14089    }
14090
14091    #[test]
14092    fn date_timestamp_attr_preferred_over_content() {
14093        // When timestamp attr is present, it takes priority over the display date
14094        let md = ":date[2023-11-14]{timestamp=1700000000000}\n";
14095        let doc = markdown_to_adf(md).unwrap();
14096        let content = doc.content[0].content.as_ref().unwrap();
14097        assert_eq!(
14098            content[0].attrs.as_ref().unwrap()["timestamp"],
14099            "1700000000000",
14100            "timestamp attr should be used directly"
14101        );
14102    }
14103
14104    #[test]
14105    fn date_without_timestamp_attr_backward_compat() {
14106        // Legacy JFM without timestamp attr still works via iso_date_to_epoch_ms
14107        let md = ":date[2026-04-15]\n";
14108        let doc = markdown_to_adf(md).unwrap();
14109        let content = doc.content[0].content.as_ref().unwrap();
14110        assert_eq!(
14111            content[0].attrs.as_ref().unwrap()["timestamp"],
14112            "1776211200000",
14113            "Should fall back to computing timestamp from date string"
14114        );
14115    }
14116
14117    #[test]
14118    fn date_with_local_id_and_timestamp() {
14119        // Both localId and timestamp should round-trip
14120        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
14121        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14122        let md = adf_to_markdown(&doc).unwrap();
14123        assert!(
14124            md.contains("timestamp=1700000000000"),
14125            "Should contain timestamp: {md}"
14126        );
14127        assert!(md.contains("localId=d-001"), "Should contain localId: {md}");
14128        // Round-trip
14129        let doc2 = markdown_to_adf(&md).unwrap();
14130        let content = doc2.content[0].content.as_ref().unwrap();
14131        let attrs = content[0].attrs.as_ref().unwrap();
14132        assert_eq!(attrs["timestamp"], "1700000000000");
14133        assert_eq!(attrs["localId"], "d-001");
14134    }
14135
14136    #[test]
14137    fn mention_directive() {
14138        let doc = markdown_to_adf("Assigned to :mention[Alice]{id=abc123}.").unwrap();
14139        let content = doc.content[0].content.as_ref().unwrap();
14140        assert_eq!(content[1].node_type, "mention");
14141        assert_eq!(content[1].attrs.as_ref().unwrap()["id"], "abc123");
14142        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "Alice");
14143    }
14144
14145    #[test]
14146    fn adf_mention_to_markdown() {
14147        let doc = AdfDocument {
14148            version: 1,
14149            doc_type: "doc".to_string(),
14150            content: vec![AdfNode::paragraph(vec![AdfNode::mention(
14151                "abc123", "Alice",
14152            )])],
14153        };
14154        let md = adf_to_markdown(&doc).unwrap();
14155        assert!(md.contains(":mention[Alice]{id=abc123}"));
14156    }
14157
14158    #[test]
14159    fn round_trip_mention() {
14160        let md = "Assigned to :mention[Alice]{id=abc123}.\n";
14161        let doc = markdown_to_adf(md).unwrap();
14162        let result = adf_to_markdown(&doc).unwrap();
14163        assert!(result.contains(":mention[Alice]{id=abc123}"));
14164    }
14165
14166    #[test]
14167    fn mention_with_empty_access_level_round_trips() {
14168        // Issue #363: accessLevel="" produces accessLevel= which failed to parse
14169        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14170          {"type":"mention","attrs":{"id":"61921b41c15977006af2b1d1","text":"@Javier Inchausti","accessLevel":""}}
14171        ]}]}"#;
14172        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14173
14174        let md = adf_to_markdown(&doc).unwrap();
14175        let round_tripped = markdown_to_adf(&md).unwrap();
14176        let mention = &round_tripped.content[0].content.as_ref().unwrap()[0];
14177        assert_eq!(
14178            mention.node_type, "mention",
14179            "mention with empty accessLevel was not parsed as mention, got: {}",
14180            mention.node_type
14181        );
14182    }
14183
14184    #[test]
14185    fn span_with_color() {
14186        let doc = markdown_to_adf("This is :span[red text]{color=#ff5630}.").unwrap();
14187        let content = doc.content[0].content.as_ref().unwrap();
14188        assert_eq!(content[1].node_type, "text");
14189        assert_eq!(content[1].text.as_deref(), Some("red text"));
14190        let marks = content[1].marks.as_ref().unwrap();
14191        assert_eq!(marks[0].mark_type, "textColor");
14192    }
14193
14194    #[test]
14195    fn adf_text_color_to_markdown() {
14196        let doc = AdfDocument {
14197            version: 1,
14198            doc_type: "doc".to_string(),
14199            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
14200                "red text",
14201                vec![AdfMark::text_color("#ff5630")],
14202            )])],
14203        };
14204        let md = adf_to_markdown(&doc).unwrap();
14205        assert!(md.contains(":span[red text]{color=#ff5630}"));
14206    }
14207
14208    #[test]
14209    fn round_trip_span_color() {
14210        let md = "This is :span[red text]{color=#ff5630}.\n";
14211        let doc = markdown_to_adf(md).unwrap();
14212        let result = adf_to_markdown(&doc).unwrap();
14213        assert!(result.contains(":span[red text]{color=#ff5630}"));
14214    }
14215
14216    #[test]
14217    fn text_color_and_link_marks_both_preserved() {
14218        // Issue #405: text with both textColor and link marks loses link on round-trip
14219        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14220          {"type":"text","text":"red link","marks":[
14221            {"type":"link","attrs":{"href":"https://example.com"}},
14222            {"type":"textColor","attrs":{"color":"#ff0000"}}
14223          ]}
14224        ]}]}"##;
14225        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14226        let md = adf_to_markdown(&doc).unwrap();
14227        assert!(
14228            md.contains(":span[red link]{color=#ff0000}"),
14229            "JFM should contain span with color, got: {md}"
14230        );
14231        assert!(
14232            md.contains("](https://example.com)"),
14233            "JFM should contain link href, got: {md}"
14234        );
14235        // Full round-trip: both marks survive
14236        let rt = markdown_to_adf(&md).unwrap();
14237        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
14238        let marks = text_node.marks.as_ref().expect("should have marks");
14239        assert!(
14240            marks.iter().any(|m| m.mark_type == "textColor"),
14241            "should have textColor mark, got: {:?}",
14242            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
14243        );
14244        assert!(
14245            marks.iter().any(|m| m.mark_type == "link"),
14246            "should have link mark, got: {:?}",
14247            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
14248        );
14249        // Verify attribute values survive
14250        let link_mark = marks.iter().find(|m| m.mark_type == "link").unwrap();
14251        assert_eq!(
14252            link_mark.attrs.as_ref().unwrap()["href"],
14253            "https://example.com"
14254        );
14255        let color_mark = marks.iter().find(|m| m.mark_type == "textColor").unwrap();
14256        assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#ff0000");
14257    }
14258
14259    #[test]
14260    fn bg_color_and_link_marks_both_preserved() {
14261        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14262          {"type":"text","text":"highlighted link","marks":[
14263            {"type":"link","attrs":{"href":"https://example.com"}},
14264            {"type":"backgroundColor","attrs":{"color":"#ffff00"}}
14265          ]}
14266        ]}]}"##;
14267        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14268        let md = adf_to_markdown(&doc).unwrap();
14269        assert!(md.contains("bg=#ffff00"), "should have bg color: {md}");
14270        assert!(
14271            md.contains("](https://example.com)"),
14272            "should have link: {md}"
14273        );
14274        let rt = markdown_to_adf(&md).unwrap();
14275        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
14276        let marks = text_node.marks.as_ref().expect("should have marks");
14277        assert!(marks.iter().any(|m| m.mark_type == "backgroundColor"));
14278        assert!(marks.iter().any(|m| m.mark_type == "link"));
14279    }
14280
14281    #[test]
14282    fn text_color_link_and_strong_rendering() {
14283        // Verify textColor + link + strong renders all three formatting elements
14284        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14285          {"type":"text","text":"bold red link","marks":[
14286            {"type":"strong"},
14287            {"type":"link","attrs":{"href":"https://example.com"}},
14288            {"type":"textColor","attrs":{"color":"#ff0000"}}
14289          ]}
14290        ]}]}"##;
14291        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14292        let md = adf_to_markdown(&doc).unwrap();
14293        assert!(
14294            md.starts_with("**") && md.trim().ends_with("**"),
14295            "should have bold wrapping: {md}"
14296        );
14297        assert!(md.contains("color=#ff0000"), "should have color: {md}");
14298        assert!(
14299            md.contains("](https://example.com)"),
14300            "should have link: {md}"
14301        );
14302    }
14303
14304    #[test]
14305    fn subsup_and_link_marks_both_preserved() {
14306        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14307          {"type":"text","text":"note","marks":[
14308            {"type":"link","attrs":{"href":"https://example.com"}},
14309            {"type":"subsup","attrs":{"type":"sup"}}
14310          ]}
14311        ]}]}"#;
14312        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14313        let md = adf_to_markdown(&doc).unwrap();
14314        assert!(md.contains("sup"), "should have sup: {md}");
14315        assert!(
14316            md.contains("](https://example.com)"),
14317            "should have link: {md}"
14318        );
14319        let rt = markdown_to_adf(&md).unwrap();
14320        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
14321        let marks = text_node.marks.as_ref().expect("should have marks");
14322        assert!(marks.iter().any(|m| m.mark_type == "subsup"));
14323        assert!(marks.iter().any(|m| m.mark_type == "link"));
14324    }
14325
14326    #[test]
14327    fn text_color_without_link_unchanged() {
14328        // Regression guard: textColor without link should still work
14329        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14330          {"type":"text","text":"just red","marks":[
14331            {"type":"textColor","attrs":{"color":"#ff0000"}}
14332          ]}
14333        ]}]}"##;
14334        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14335        let md = adf_to_markdown(&doc).unwrap();
14336        assert!(md.contains(":span[just red]{color=#ff0000}"), "md: {md}");
14337        assert!(!md.contains("](http"), "should NOT have link syntax: {md}");
14338    }
14339
14340    #[test]
14341    fn inline_extension_directive() {
14342        let doc =
14343            markdown_to_adf("See :extension[fallback]{type=com.app key=widget} here.").unwrap();
14344        let content = doc.content[0].content.as_ref().unwrap();
14345        assert_eq!(content[1].node_type, "inlineExtension");
14346        assert_eq!(
14347            content[1].attrs.as_ref().unwrap()["extensionType"],
14348            "com.app"
14349        );
14350        assert_eq!(content[1].attrs.as_ref().unwrap()["extensionKey"], "widget");
14351    }
14352
14353    #[test]
14354    fn adf_inline_extension_to_markdown() {
14355        let doc = AdfDocument {
14356            version: 1,
14357            doc_type: "doc".to_string(),
14358            content: vec![AdfNode::paragraph(vec![AdfNode::inline_extension(
14359                "com.app",
14360                "widget",
14361                Some("fallback"),
14362            )])],
14363        };
14364        let md = adf_to_markdown(&doc).unwrap();
14365        assert!(md.contains(":extension[fallback]{type=com.app key=widget}"));
14366    }
14367
14368    // ── Helper function tests ──────────────────────────────────────────
14369
14370    #[test]
14371    fn parse_ordered_list_marker_valid() {
14372        let result = parse_ordered_list_marker("1. Hello");
14373        assert_eq!(result, Some((1, "Hello")));
14374    }
14375
14376    #[test]
14377    fn parse_ordered_list_marker_high_number() {
14378        let result = parse_ordered_list_marker("42. Item");
14379        assert_eq!(result, Some((42, "Item")));
14380    }
14381
14382    #[test]
14383    fn parse_ordered_list_marker_not_a_list() {
14384        assert!(parse_ordered_list_marker("not a list").is_none());
14385        assert!(parse_ordered_list_marker("1.no space").is_none());
14386    }
14387
14388    #[test]
14389    fn is_list_start_various() {
14390        assert!(is_list_start("- item"));
14391        assert!(is_list_start("* item"));
14392        assert!(is_list_start("+ item"));
14393        assert!(is_list_start("1. item"));
14394        assert!(!is_list_start("not a list"));
14395    }
14396
14397    #[test]
14398    fn is_horizontal_rule_various() {
14399        assert!(is_horizontal_rule("---"));
14400        assert!(is_horizontal_rule("***"));
14401        assert!(is_horizontal_rule("___"));
14402        assert!(is_horizontal_rule("------"));
14403        assert!(!is_horizontal_rule("--"));
14404        assert!(!is_horizontal_rule("abc"));
14405    }
14406
14407    #[test]
14408    fn is_table_separator_valid() {
14409        assert!(is_table_separator("| --- | --- |"));
14410        assert!(is_table_separator("|:---:|:---|"));
14411        assert!(!is_table_separator("no pipes here"));
14412    }
14413
14414    #[test]
14415    fn parse_table_row_cells() {
14416        let cells = parse_table_row("| A | B | C |");
14417        assert_eq!(cells, vec!["A", "B", "C"]);
14418    }
14419
14420    #[test]
14421    fn parse_table_row_escaped_pipe_in_cell() {
14422        // Issue #579: `\|` inside a cell is a literal pipe, not a column separator.
14423        let cells = parse_table_row(r"| a\|b | c |");
14424        assert_eq!(cells, vec!["a|b", "c"]);
14425    }
14426
14427    #[test]
14428    fn parse_table_row_escaped_pipe_in_code_span() {
14429        // Issue #579: `\|` inside an inline code span is unescaped at the row level.
14430        let cells = parse_table_row(r"| `parser.decode[T\|json]` | other |");
14431        assert_eq!(cells, vec!["`parser.decode[T|json]`", "other"]);
14432    }
14433
14434    #[test]
14435    fn parse_table_row_preserves_other_backslashes() {
14436        // Only `\|` is special at the row-splitting level; other backslashes pass through.
14437        let cells = parse_table_row(r"| a\\b | c\*d |");
14438        assert_eq!(cells, vec![r"a\\b", r"c\*d"]);
14439    }
14440
14441    #[test]
14442    fn parse_image_syntax_valid() {
14443        let result = parse_image_syntax("![alt](url)");
14444        assert_eq!(result, Some(("alt", "url")));
14445    }
14446
14447    #[test]
14448    fn parse_image_syntax_not_image() {
14449        assert!(parse_image_syntax("not an image").is_none());
14450    }
14451
14452    // ── find_closing_paren tests ────────────────────────────────────
14453
14454    #[test]
14455    fn find_closing_paren_simple() {
14456        assert_eq!(find_closing_paren("(hello)", 0), Some(6));
14457    }
14458
14459    #[test]
14460    fn find_closing_paren_nested() {
14461        assert_eq!(find_closing_paren("(a(b)c)", 0), Some(6));
14462    }
14463
14464    #[test]
14465    fn find_closing_paren_unmatched() {
14466        assert_eq!(find_closing_paren("(no close", 0), None);
14467    }
14468
14469    #[test]
14470    fn find_closing_paren_offset() {
14471        // Start scanning from the second '('
14472        assert_eq!(find_closing_paren("xx(inner)", 2), Some(8));
14473    }
14474
14475    // ── Parentheses-in-URL tests (issue #509) ──────────────────────
14476
14477    #[test]
14478    fn try_parse_link_url_with_parens() {
14479        let input = "[here](https://example.com/faq#access-(permissions)-rest)";
14480        let result = try_parse_link(input, 0);
14481        assert_eq!(
14482            result,
14483            Some((
14484                input.len(),
14485                "here",
14486                "https://example.com/faq#access-(permissions)-rest"
14487            ))
14488        );
14489    }
14490
14491    #[test]
14492    fn try_parse_link_url_no_parens() {
14493        let input = "[text](https://example.com)";
14494        let result = try_parse_link(input, 0);
14495        assert_eq!(result, Some((input.len(), "text", "https://example.com")));
14496    }
14497
14498    #[test]
14499    fn try_parse_link_url_with_multiple_nested_parens() {
14500        let input = "[x](http://en.wikipedia.org/wiki/Foo_(bar_(baz)))";
14501        let result = try_parse_link(input, 0);
14502        assert_eq!(
14503            result,
14504            Some((
14505                input.len(),
14506                "x",
14507                "http://en.wikipedia.org/wiki/Foo_(bar_(baz))"
14508            ))
14509        );
14510    }
14511
14512    #[test]
14513    fn parse_image_syntax_url_with_parens() {
14514        let result = parse_image_syntax("![alt](https://example.com/page_(1))");
14515        assert_eq!(result, Some(("alt", "https://example.com/page_(1)")));
14516    }
14517
14518    #[test]
14519    fn parse_image_syntax_url_no_parens() {
14520        let result = parse_image_syntax("![alt](https://example.com)");
14521        assert_eq!(result, Some(("alt", "https://example.com")));
14522    }
14523
14524    #[test]
14525    fn link_with_parens_round_trip() {
14526        let href = "https://example.com/faq#I-need-access-(permissions)-added-in-Monitor";
14527        let mut text_node = AdfNode::text("here");
14528        text_node.marks = Some(vec![AdfMark::link(href)]);
14529        let adf_input = AdfDocument {
14530            version: 1,
14531            doc_type: "doc".to_string(),
14532            content: vec![AdfNode::paragraph(vec![text_node])],
14533        };
14534
14535        let jfm = adf_to_markdown(&adf_input).unwrap();
14536        let adf_output = markdown_to_adf(&jfm).unwrap();
14537
14538        // Extract the href from the round-tripped ADF
14539        let para = &adf_output.content[0];
14540        let text_node = &para.content.as_ref().unwrap()[0];
14541        let mark = &text_node.marks.as_ref().unwrap()[0];
14542        let result_href = mark.attrs.as_ref().unwrap()["href"].as_str().unwrap();
14543
14544        assert_eq!(result_href, href);
14545    }
14546
14547    #[test]
14548    fn flush_plain_empty_range() {
14549        let mut nodes = Vec::new();
14550        flush_plain("hello", 3, 3, &mut nodes);
14551        assert!(nodes.is_empty());
14552    }
14553
14554    #[test]
14555    fn add_mark_to_unmarked_node() {
14556        let mut node = AdfNode::text("test");
14557        add_mark(&mut node, AdfMark::strong());
14558        assert_eq!(node.marks.as_ref().unwrap().len(), 1);
14559    }
14560
14561    #[test]
14562    fn add_mark_to_marked_node() {
14563        let mut node = AdfNode::text_with_marks("test", vec![AdfMark::strong()]);
14564        add_mark(&mut node, AdfMark::em());
14565        assert_eq!(node.marks.as_ref().unwrap().len(), 2);
14566    }
14567
14568    // ── Directive table tests ──────────────────────────────────────
14569
14570    #[test]
14571    fn directive_table_basic() {
14572        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";
14573        let doc = markdown_to_adf(md).unwrap();
14574        assert_eq!(doc.content[0].node_type, "table");
14575        let rows = doc.content[0].content.as_ref().unwrap();
14576        assert_eq!(rows.len(), 2);
14577        assert_eq!(
14578            rows[0].content.as_ref().unwrap()[0].node_type,
14579            "tableHeader"
14580        );
14581        assert_eq!(rows[1].content.as_ref().unwrap()[0].node_type, "tableCell");
14582    }
14583
14584    #[test]
14585    fn directive_table_with_block_content() {
14586        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";
14587        let doc = markdown_to_adf(md).unwrap();
14588        let rows = doc.content[0].content.as_ref().unwrap();
14589        let cell = &rows[0].content.as_ref().unwrap()[0];
14590        // Cell should have block content (paragraph + bullet list)
14591        let content = cell.content.as_ref().unwrap();
14592        assert!(content.len() >= 2);
14593        assert_eq!(content[1].node_type, "bulletList");
14594    }
14595
14596    #[test]
14597    fn directive_table_with_cell_attrs() {
14598        let md = "::::table\n:::tr\n:::td{colspan=2 bg=#DEEBFF}\nSpanning cell\n:::\n:::\n::::\n";
14599        let doc = markdown_to_adf(md).unwrap();
14600        let cell = &doc.content[0].content.as_ref().unwrap()[0]
14601            .content
14602            .as_ref()
14603            .unwrap()[0];
14604        let attrs = cell.attrs.as_ref().unwrap();
14605        assert_eq!(attrs["colspan"], 2);
14606        assert_eq!(attrs["background"], "#DEEBFF");
14607    }
14608
14609    #[test]
14610    fn directive_table_with_css_var_background() {
14611        let bg = "var(--ds-background-accent-gray-subtlest, var(--ds-background-accent-gray-subtlest, #F1F2F4))";
14612        let md = format!("::::table\n:::tr\n:::th{{bg=\"{bg}\"}}\nHeader\n:::\n:::\n::::\n");
14613        let doc = markdown_to_adf(&md).unwrap();
14614        let row = &doc.content[0].content.as_ref().unwrap()[0];
14615        let cells = row.content.as_ref().unwrap();
14616        assert_eq!(cells.len(), 1, "row must have at least one cell");
14617        let attrs = cells[0].attrs.as_ref().unwrap();
14618        assert_eq!(attrs["background"], bg);
14619    }
14620
14621    #[test]
14622    fn css_var_background_round_trips() {
14623        let bg = "var(--ds-background-accent-gray-subtlest, #F1F2F4)";
14624        let adf = AdfDocument {
14625            version: 1,
14626            doc_type: "doc".to_string(),
14627            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14628                AdfNode::table_header_with_attrs(
14629                    vec![AdfNode::paragraph(vec![AdfNode::text("Header")])],
14630                    serde_json::json!({"background": bg}),
14631                ),
14632            ])])],
14633        };
14634        let md = adf_to_markdown(&adf).unwrap();
14635        assert!(
14636            md.contains(&format!("bg=\"{bg}\"")),
14637            "bg value must be quoted in markdown: {md}"
14638        );
14639
14640        let round_tripped = markdown_to_adf(&md).unwrap();
14641        let row = &round_tripped.content[0].content.as_ref().unwrap()[0];
14642        let cells = row.content.as_ref().unwrap();
14643        assert_eq!(cells.len(), 1, "round-tripped row must have one cell");
14644        let rt_attrs = cells[0].attrs.as_ref().unwrap();
14645        assert_eq!(rt_attrs["background"], bg);
14646    }
14647
14648    #[test]
14649    fn directive_table_with_table_attrs() {
14650        let md = "::::table{layout=wide numbered}\n:::tr\n:::td\nCell\n:::\n:::\n::::\n";
14651        let doc = markdown_to_adf(md).unwrap();
14652        let attrs = doc.content[0].attrs.as_ref().unwrap();
14653        assert_eq!(attrs["layout"], "wide");
14654        assert_eq!(attrs["isNumberColumnEnabled"], true);
14655    }
14656
14657    #[test]
14658    fn adf_table_with_block_content_renders_directive_form() {
14659        // Table with a bullet list in a cell → should render as ::::table directive
14660        let doc = AdfDocument {
14661            version: 1,
14662            doc_type: "doc".to_string(),
14663            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14664                AdfNode::table_cell(vec![
14665                    AdfNode::paragraph(vec![AdfNode::text("Cell with list:")]),
14666                    AdfNode::bullet_list(vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
14667                        AdfNode::text("Item 1"),
14668                    ])])]),
14669                ]),
14670            ])])],
14671        };
14672        let md = adf_to_markdown(&doc).unwrap();
14673        assert!(md.contains("::::table"));
14674        assert!(md.contains(":::td"));
14675        assert!(md.contains("- Item 1"));
14676    }
14677
14678    #[test]
14679    fn adf_table_inline_only_renders_pipe_form() {
14680        // Table with only inline content → pipe table
14681        let doc = AdfDocument {
14682            version: 1,
14683            doc_type: "doc".to_string(),
14684            content: vec![AdfNode::table(vec![
14685                AdfNode::table_row(vec![
14686                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
14687                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14688                ]),
14689                AdfNode::table_row(vec![
14690                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
14691                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14692                ]),
14693            ])],
14694        };
14695        let md = adf_to_markdown(&doc).unwrap();
14696        assert!(md.contains("| H1 | H2 |"));
14697        assert!(!md.contains("::::table"));
14698    }
14699
14700    #[test]
14701    fn adf_table_header_outside_first_row_renders_directive() {
14702        let doc = AdfDocument {
14703            version: 1,
14704            doc_type: "doc".to_string(),
14705            content: vec![AdfNode::table(vec![
14706                AdfNode::table_row(vec![
14707                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H")])]),
14708                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C")])]),
14709                ]),
14710                AdfNode::table_row(vec![
14711                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14712                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14713                ]),
14714            ])],
14715        };
14716        let md = adf_to_markdown(&doc).unwrap();
14717        assert!(md.contains("::::table"));
14718        assert!(md.contains(":::th"));
14719    }
14720
14721    #[test]
14722    fn adf_table_cell_attrs_rendered() {
14723        let doc = AdfDocument {
14724            version: 1,
14725            doc_type: "doc".to_string(),
14726            content: vec![AdfNode::table(vec![
14727                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
14728                    AdfNode::text("H"),
14729                ])])]),
14730                AdfNode::table_row(vec![AdfNode::table_cell_with_attrs(
14731                    vec![AdfNode::paragraph(vec![AdfNode::text("C")])],
14732                    serde_json::json!({"background": "#DEEBFF", "colspan": 2}),
14733                )]),
14734            ])],
14735        };
14736        let md = adf_to_markdown(&doc).unwrap();
14737        assert!(md.contains("{colspan=2 bg=#DEEBFF}"));
14738    }
14739
14740    // ── Pipe table cell attrs tests ────────────────────────────────
14741
14742    #[test]
14743    fn pipe_table_cell_attrs() {
14744        let md = "| H1 | H2 |\n|---|---|\n| {bg=#DEEBFF} highlighted | normal |\n";
14745        let doc = markdown_to_adf(md).unwrap();
14746        let rows = doc.content[0].content.as_ref().unwrap();
14747        let cell = &rows[1].content.as_ref().unwrap()[0];
14748        let attrs = cell.attrs.as_ref().unwrap();
14749        assert_eq!(attrs["background"], "#DEEBFF");
14750    }
14751
14752    #[test]
14753    fn pipe_table_cell_colspan() {
14754        let md = "| H1 | H2 |\n|---|---|\n| {colspan=2} spanning |\n";
14755        let doc = markdown_to_adf(md).unwrap();
14756        let rows = doc.content[0].content.as_ref().unwrap();
14757        let cell = &rows[1].content.as_ref().unwrap()[0];
14758        let attrs = cell.attrs.as_ref().unwrap();
14759        assert_eq!(attrs["colspan"], 2);
14760    }
14761
14762    #[test]
14763    fn trailing_space_after_mention_in_table_cell_preserved() {
14764        // Issue #372: trailing space after mention in table cell was dropped
14765        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":[
14766          {"type":"mention","attrs":{"id":"aaa","text":"@Rob"}},
14767          {"type":"text","text":" "}
14768        ]}]}]}]}]}"#;
14769        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14770        let md = adf_to_markdown(&doc).unwrap();
14771        let round_tripped = markdown_to_adf(&md).unwrap();
14772        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14773            .content
14774            .as_ref()
14775            .unwrap()[0];
14776        let para = &cell.content.as_ref().unwrap()[0];
14777        let inlines = para.content.as_ref().unwrap();
14778        assert!(
14779            inlines.len() >= 2,
14780            "expected mention + text(' ') nodes, got {} nodes: {:?}",
14781            inlines.len(),
14782            inlines.iter().map(|n| &n.node_type).collect::<Vec<_>>()
14783        );
14784        assert_eq!(inlines[0].node_type, "mention");
14785        assert_eq!(inlines[1].node_type, "text");
14786        assert_eq!(inlines[1].text.as_deref(), Some(" "));
14787    }
14788
14789    // ── Column alignment tests ─────────────────────────────────────
14790
14791    #[test]
14792    fn pipe_table_column_alignment() {
14793        let md = "| Left | Center | Right |\n|:---|:---:|---:|\n| L | C | R |\n";
14794        let doc = markdown_to_adf(md).unwrap();
14795        let rows = doc.content[0].content.as_ref().unwrap();
14796        // Header row
14797        let h_cells = rows[0].content.as_ref().unwrap();
14798        // Left → no mark
14799        assert!(h_cells[0].content.as_ref().unwrap()[0].marks.is_none());
14800        // Center → alignment center
14801        let center_marks = h_cells[1].content.as_ref().unwrap()[0]
14802            .marks
14803            .as_ref()
14804            .unwrap();
14805        assert_eq!(center_marks[0].attrs.as_ref().unwrap()["align"], "center");
14806        // Right → alignment end
14807        let right_marks = h_cells[2].content.as_ref().unwrap()[0]
14808            .marks
14809            .as_ref()
14810            .unwrap();
14811        assert_eq!(right_marks[0].attrs.as_ref().unwrap()["align"], "end");
14812    }
14813
14814    #[test]
14815    fn adf_table_alignment_roundtrip() {
14816        let doc = AdfDocument {
14817            version: 1,
14818            doc_type: "doc".to_string(),
14819            content: vec![AdfNode::table(vec![
14820                AdfNode::table_row(vec![
14821                    AdfNode::table_header(vec![{
14822                        let mut p = AdfNode::paragraph(vec![AdfNode::text("Center")]);
14823                        p.marks = Some(vec![AdfMark::alignment("center")]);
14824                        p
14825                    }]),
14826                    AdfNode::table_header(vec![{
14827                        let mut p = AdfNode::paragraph(vec![AdfNode::text("Right")]);
14828                        p.marks = Some(vec![AdfMark::alignment("end")]);
14829                        p
14830                    }]),
14831                ]),
14832                AdfNode::table_row(vec![
14833                    AdfNode::table_cell(vec![{
14834                        let mut p = AdfNode::paragraph(vec![AdfNode::text("C")]);
14835                        p.marks = Some(vec![AdfMark::alignment("center")]);
14836                        p
14837                    }]),
14838                    AdfNode::table_cell(vec![{
14839                        let mut p = AdfNode::paragraph(vec![AdfNode::text("R")]);
14840                        p.marks = Some(vec![AdfMark::alignment("end")]);
14841                        p
14842                    }]),
14843                ]),
14844            ])],
14845        };
14846        let md = adf_to_markdown(&doc).unwrap();
14847        assert!(md.contains(":---:"));
14848        assert!(md.contains("---:"));
14849    }
14850
14851    // ── Panel custom attrs tests ───────────────────────────────────
14852
14853    #[test]
14854    fn panel_custom_attrs_round_trip() {
14855        let md = ":::panel{type=custom icon=\":star:\" color=\"#DEEBFF\"}\nContent\n:::\n";
14856        let doc = markdown_to_adf(md).unwrap();
14857        let panel = &doc.content[0];
14858        let attrs = panel.attrs.as_ref().unwrap();
14859        assert_eq!(attrs["panelType"], "custom");
14860        assert_eq!(attrs["panelIcon"], ":star:");
14861        assert_eq!(attrs["panelColor"], "#DEEBFF");
14862
14863        let result = adf_to_markdown(&doc).unwrap();
14864        assert!(result.contains("type=custom"));
14865        assert!(result.contains("icon="));
14866        assert!(result.contains("color="));
14867    }
14868
14869    // ── Block card with attrs tests ────────────────────────────────
14870
14871    #[test]
14872    fn block_card_with_layout() {
14873        let md = "::card[https://example.com]{layout=wide}\n";
14874        let doc = markdown_to_adf(md).unwrap();
14875        let attrs = doc.content[0].attrs.as_ref().unwrap();
14876        assert_eq!(attrs["layout"], "wide");
14877
14878        let result = adf_to_markdown(&doc).unwrap();
14879        assert!(result.contains("::card[https://example.com]{layout=wide}"));
14880    }
14881
14882    // ── Extension params test ──────────────────────────────────────
14883
14884    #[test]
14885    fn extension_with_params() {
14886        let md = r#"::extension{type=com.atlassian.macro key=jira-chart params='{"jql":"project=PROJ"}'}"#;
14887        let doc = markdown_to_adf(&format!("{md}\n")).unwrap();
14888        let attrs = doc.content[0].attrs.as_ref().unwrap();
14889        assert_eq!(attrs["parameters"]["jql"], "project=PROJ");
14890    }
14891
14892    #[test]
14893    fn leaf_extension_layout_preserved_in_roundtrip() {
14894        // Issue #381: layout attr on extension nodes was dropped
14895        let adf_json = r#"{"version":1,"type":"doc","content":[
14896          {"type":"extension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"toc","layout":"default","parameters":{}}}
14897        ]}"#;
14898        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14899        let md = adf_to_markdown(&doc).unwrap();
14900        assert!(
14901            md.contains("layout=default"),
14902            "JFM should contain layout=default, got: {md}"
14903        );
14904        let round_tripped = markdown_to_adf(&md).unwrap();
14905        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14906        assert_eq!(attrs["layout"], "default", "layout should be preserved");
14907        assert_eq!(attrs["extensionKey"], "toc");
14908    }
14909
14910    #[test]
14911    fn bodied_extension_layout_preserved_in_roundtrip() {
14912        // Bodied extension with layout
14913        let adf_json = r#"{"version":1,"type":"doc","content":[
14914          {"type":"bodiedExtension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"expand","layout":"wide"},
14915           "content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}
14916        ]}"#;
14917        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14918        let md = adf_to_markdown(&doc).unwrap();
14919        assert!(
14920            md.contains("layout=wide"),
14921            "JFM should contain layout=wide, got: {md}"
14922        );
14923        let round_tripped = markdown_to_adf(&md).unwrap();
14924        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14925        assert_eq!(attrs["layout"], "wide", "layout should be preserved");
14926    }
14927
14928    #[test]
14929    fn bodied_extension_parameters_preserved_in_roundtrip() {
14930        // Issue #473: parameters block inside bodiedExtension.attrs was dropped
14931        let adf_json = r#"{"version":1,"type":"doc","content":[
14932          {"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":{}}},
14933           "content":[{"type":"paragraph","content":[{"type":"text","text":"Content inside bodied extension"}]}]}
14934        ]}"#;
14935        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14936        let md = adf_to_markdown(&doc).unwrap();
14937        assert!(
14938            md.contains("params="),
14939            "JFM should contain params attribute, got: {md}"
14940        );
14941        let round_tripped = markdown_to_adf(&md).unwrap();
14942        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14943        assert_eq!(
14944            attrs["parameters"]["macroMetadata"]["title"], "Page Properties",
14945            "parameters should be preserved in round-trip"
14946        );
14947        assert_eq!(attrs["extensionKey"], "details");
14948        assert_eq!(attrs["layout"], "default");
14949        assert_eq!(attrs["localId"], "aabbccdd-1234");
14950    }
14951
14952    #[test]
14953    fn bodied_extension_malformed_params_ignored() {
14954        // Malformed params JSON should be silently ignored, not crash
14955        let md = ":::extension{type=com.atlassian.macro key=details params='not-valid-json'}\nContent\n:::\n";
14956        let doc = markdown_to_adf(md).unwrap();
14957        let attrs = doc.content[0].attrs.as_ref().unwrap();
14958        assert_eq!(attrs["extensionKey"], "details");
14959        // parameters should be absent since the JSON was invalid
14960        assert!(attrs.get("parameters").is_none());
14961    }
14962
14963    #[test]
14964    fn leaf_extension_localid_preserved_in_roundtrip() {
14965        // Extension with both layout and localId
14966        let adf_json = r#"{"version":1,"type":"doc","content":[
14967          {"type":"extension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"toc","layout":"default","localId":"abc-123"}}
14968        ]}"#;
14969        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14970        let md = adf_to_markdown(&doc).unwrap();
14971        let round_tripped = markdown_to_adf(&md).unwrap();
14972        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14973        assert_eq!(attrs["layout"], "default");
14974        assert_eq!(attrs["localId"], "abc-123");
14975    }
14976
14977    // ── Mention with userType test ─────────────────────────────────
14978
14979    #[test]
14980    fn mention_with_user_type() {
14981        let md = "Hi :mention[Alice]{id=abc123 userType=DEFAULT}.\n";
14982        let doc = markdown_to_adf(md).unwrap();
14983        let mention = &doc.content[0].content.as_ref().unwrap()[1];
14984        assert_eq!(mention.attrs.as_ref().unwrap()["userType"], "DEFAULT");
14985
14986        let result = adf_to_markdown(&doc).unwrap();
14987        assert!(result.contains("userType=DEFAULT"));
14988    }
14989
14990    // ── Colwidth tests ─────────────────────────────────────────────
14991
14992    #[test]
14993    fn directive_table_colwidth() {
14994        let md = "::::table\n:::tr\n:::td{colwidth=100,200}\nCell\n:::\n:::\n::::\n";
14995        let doc = markdown_to_adf(md).unwrap();
14996        let cell = &doc.content[0].content.as_ref().unwrap()[0]
14997            .content
14998            .as_ref()
14999            .unwrap()[0];
15000        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
15001        assert_eq!(colwidth, &[serde_json::json!(100), serde_json::json!(200)]);
15002    }
15003
15004    #[test]
15005    fn directive_table_colwidth_float_roundtrip() {
15006        // Confluence returns colwidth as floats (e.g. 157.0, 863.0).
15007        // adf_to_markdown must preserve them so markdown_to_adf can restore them.
15008        let adf_doc = serde_json::json!({
15009            "type": "doc",
15010            "version": 1,
15011            "content": [{
15012                "type": "table",
15013                "content": [{
15014                    "type": "tableRow",
15015                    "content": [
15016                        {
15017                            "type": "tableHeader",
15018                            "attrs": { "colwidth": [157.0] },
15019                            "content": [{ "type": "paragraph" }]
15020                        },
15021                        {
15022                            "type": "tableHeader",
15023                            "attrs": { "colwidth": [863.0] },
15024                            "content": [{ "type": "paragraph" }]
15025                        }
15026                    ]
15027                }]
15028            }]
15029        });
15030        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15031        let md = adf_to_markdown(&doc).unwrap();
15032        assert!(
15033            md.contains("colwidth=157.0"),
15034            "expected colwidth=157.0 in markdown, got: {md}"
15035        );
15036        assert!(
15037            md.contains("colwidth=863.0"),
15038            "expected colwidth=863.0 in markdown, got: {md}"
15039        );
15040        // Round-trip back to ADF
15041        let doc2 = markdown_to_adf(&md).unwrap();
15042        let row = &doc2.content[0].content.as_ref().unwrap()[0];
15043        let header1 = &row.content.as_ref().unwrap()[0];
15044        let header2 = &row.content.as_ref().unwrap()[1];
15045        assert_eq!(
15046            header1.attrs.as_ref().unwrap()["colwidth"]
15047                .as_array()
15048                .unwrap(),
15049            &[serde_json::json!(157.0)]
15050        );
15051        assert_eq!(
15052            header2.attrs.as_ref().unwrap()["colwidth"]
15053                .as_array()
15054                .unwrap(),
15055            &[serde_json::json!(863.0)]
15056        );
15057    }
15058
15059    #[test]
15060    fn colwidth_float_preserved_in_roundtrip() {
15061        // Issue #369: colwidth 254.0 was coerced to integer 254
15062        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":[]}]}]}]}]}"#;
15063        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15064        let md = adf_to_markdown(&doc).unwrap();
15065        let round_tripped = markdown_to_adf(&md).unwrap();
15066        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
15067            .content
15068            .as_ref()
15069            .unwrap()[0];
15070        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
15071        assert_eq!(
15072            colwidth,
15073            &[serde_json::json!(254.0), serde_json::json!(416.0)],
15074            "colwidth should preserve float values"
15075        );
15076    }
15077
15078    #[test]
15079    fn colwidth_integer_preserved_in_roundtrip() {
15080        // Issue #459: colwidth integer values emitted as floats after round-trip
15081        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"}]}]}]}]}]}"#;
15082        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15083        let md = adf_to_markdown(&doc).unwrap();
15084        assert!(
15085            md.contains("colwidth=150"),
15086            "expected colwidth=150 (no decimal) in markdown, got: {md}"
15087        );
15088        assert!(
15089            !md.contains("colwidth=150.0"),
15090            "colwidth should not have .0 suffix for integers, got: {md}"
15091        );
15092        // Round-trip back to ADF
15093        let round_tripped = markdown_to_adf(&md).unwrap();
15094        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
15095            .content
15096            .as_ref()
15097            .unwrap()[0];
15098        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
15099        assert_eq!(
15100            colwidth,
15101            &[serde_json::json!(150)],
15102            "colwidth should preserve integer values"
15103        );
15104        // Verify JSON serialization uses integer, not float
15105        let json_output = serde_json::to_string(&round_tripped).unwrap();
15106        assert!(
15107            json_output.contains(r#""colwidth":[150]"#),
15108            "JSON should contain integer colwidth, got: {json_output}"
15109        );
15110    }
15111
15112    #[test]
15113    fn colwidth_mixed_int_and_float_roundtrip() {
15114        // Integer colwidth from standard ADF and float colwidth from Confluence
15115        // should each preserve their original type through round-trip.
15116        let int_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100,200]}}]}]}]}"#;
15117        let float_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100.0,200.0]}}]}]}]}"#;
15118
15119        // Integer input → integer output
15120        let int_doc: AdfDocument = serde_json::from_str(int_json).unwrap();
15121        let int_md = adf_to_markdown(&int_doc).unwrap();
15122        assert!(
15123            int_md.contains("colwidth=100,200"),
15124            "integer colwidth in md: {int_md}"
15125        );
15126        let int_rt = markdown_to_adf(&int_md).unwrap();
15127        let int_serial = serde_json::to_string(&int_rt).unwrap();
15128        assert!(
15129            int_serial.contains(r#""colwidth":[100,200]"#),
15130            "integer colwidth in JSON: {int_serial}"
15131        );
15132
15133        // Float input → float output
15134        let float_doc: AdfDocument = serde_json::from_str(float_json).unwrap();
15135        let float_md = adf_to_markdown(&float_doc).unwrap();
15136        assert!(
15137            float_md.contains("colwidth=100.0,200.0"),
15138            "float colwidth in md: {float_md}"
15139        );
15140        let float_rt = markdown_to_adf(&float_md).unwrap();
15141        let float_serial = serde_json::to_string(&float_rt).unwrap();
15142        assert!(
15143            float_serial.contains(r#""colwidth":[100.0,200.0]"#),
15144            "float colwidth in JSON: {float_serial}"
15145        );
15146    }
15147
15148    #[test]
15149    fn colwidth_fractional_float_preserved() {
15150        // Covers the fractional-float branch (n.fract() != 0.0) in build_cell_attrs_string
15151        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"}]}]}]}]}]}"#;
15152        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15153        let md = adf_to_markdown(&doc).unwrap();
15154        assert!(
15155            md.contains("colwidth=100.5"),
15156            "expected colwidth=100.5 in markdown, got: {md}"
15157        );
15158    }
15159
15160    #[test]
15161    fn colwidth_non_numeric_values_skipped() {
15162        // Covers the None branch for non-numeric colwidth entries in build_cell_attrs_string
15163        let adf_doc = serde_json::json!({
15164            "type": "doc",
15165            "version": 1,
15166            "content": [{
15167                "type": "table",
15168                "content": [{
15169                    "type": "tableRow",
15170                    "content": [{
15171                        "type": "tableCell",
15172                        "attrs": { "colwidth": ["invalid"] },
15173                        "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "cell" }] }]
15174                    }]
15175                }]
15176            }]
15177        });
15178        let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
15179        let md = adf_to_markdown(&doc).unwrap();
15180        // Non-numeric values are filtered out, so colwidth should not appear
15181        assert!(
15182            !md.contains("colwidth"),
15183            "non-numeric colwidth should be filtered out, got: {md}"
15184        );
15185    }
15186
15187    #[test]
15188    fn default_rowspan_colspan_preserved_in_roundtrip() {
15189        // Issue #369: rowspan=1 and colspan=1 were elided
15190        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"}]}]}]}]}]}"#;
15191        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15192        let md = adf_to_markdown(&doc).unwrap();
15193        let round_tripped = markdown_to_adf(&md).unwrap();
15194        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
15195            .content
15196            .as_ref()
15197            .unwrap()[0];
15198        let attrs = cell.attrs.as_ref().unwrap();
15199        assert_eq!(attrs["rowspan"], 1, "rowspan=1 should be preserved");
15200        assert_eq!(attrs["colspan"], 1, "colspan=1 should be preserved");
15201    }
15202
15203    // ── Nested list tests ──────────────────────────────────────────────
15204
15205    #[test]
15206    fn table_localid_preserved_in_roundtrip() {
15207        // Issue #374: localId on table nodes was dropped
15208        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"}]}]}]}]}]}"#;
15209        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15210        let md = adf_to_markdown(&doc).unwrap();
15211        assert!(
15212            md.contains("localId="),
15213            "JFM should contain localId, got: {md}"
15214        );
15215        let round_tripped = markdown_to_adf(&md).unwrap();
15216        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15217        assert_eq!(
15218            attrs["localId"], "7afd4550-e66c-4b12-875f-a91c6c7b62c7",
15219            "localId should be preserved"
15220        );
15221    }
15222
15223    #[test]
15224    fn paragraph_localid_preserved_in_roundtrip() {
15225        // Issue #399: localId on paragraph nodes was dropped
15226        let adf_json = r#"{"version":1,"type":"doc","content":[
15227          {"type":"paragraph","attrs":{"localId":"abc-123"},"content":[{"type":"text","text":"hello"}]}
15228        ]}"#;
15229        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15230        let md = adf_to_markdown(&doc).unwrap();
15231        assert!(
15232            md.contains("localId=abc-123"),
15233            "JFM should contain localId, got: {md}"
15234        );
15235        let round_tripped = markdown_to_adf(&md).unwrap();
15236        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15237        assert_eq!(attrs["localId"], "abc-123", "localId should be preserved");
15238    }
15239
15240    #[test]
15241    fn heading_localid_preserved_in_roundtrip() {
15242        let adf_json = r#"{"version":1,"type":"doc","content":[
15243          {"type":"heading","attrs":{"level":2,"localId":"h-456"},"content":[{"type":"text","text":"Title"}]}
15244        ]}"#;
15245        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15246        let md = adf_to_markdown(&doc).unwrap();
15247        let round_tripped = markdown_to_adf(&md).unwrap();
15248        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15249        assert_eq!(attrs["localId"], "h-456");
15250    }
15251
15252    #[test]
15253    fn localid_with_alignment_preserved() {
15254        // localId and alignment marks should coexist in the same {attrs} block
15255        let adf_json = r#"{"version":1,"type":"doc","content":[
15256          {"type":"paragraph","attrs":{"localId":"p-789"},"marks":[{"type":"alignment","attrs":{"align":"center"}}],
15257           "content":[{"type":"text","text":"centered"}]}
15258        ]}"#;
15259        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15260        let md = adf_to_markdown(&doc).unwrap();
15261        assert!(md.contains("localId=p-789"), "should have localId: {md}");
15262        assert!(md.contains("align=center"), "should have align: {md}");
15263        let round_tripped = markdown_to_adf(&md).unwrap();
15264        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15265        assert_eq!(attrs["localId"], "p-789");
15266        let marks = round_tripped.content[0].marks.as_ref().unwrap();
15267        assert!(marks.iter().any(|m| m.mark_type == "alignment"));
15268    }
15269
15270    #[test]
15271    fn table_layout_default_preserved_in_roundtrip() {
15272        // Issue #380: layout='default' was elided
15273        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"}]}]}]}]}]}"#;
15274        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15275        let md = adf_to_markdown(&doc).unwrap();
15276        let round_tripped = markdown_to_adf(&md).unwrap();
15277        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15278        assert_eq!(
15279            attrs["layout"], "default",
15280            "layout='default' should be preserved"
15281        );
15282    }
15283
15284    #[test]
15285    fn table_is_number_column_enabled_false_preserved() {
15286        // Issue #380: isNumberColumnEnabled=false was elided
15287        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"}]}]}]}]}]}"#;
15288        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15289        let md = adf_to_markdown(&doc).unwrap();
15290        let round_tripped = markdown_to_adf(&md).unwrap();
15291        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15292        assert_eq!(
15293            attrs["isNumberColumnEnabled"], false,
15294            "isNumberColumnEnabled=false should be preserved"
15295        );
15296    }
15297
15298    #[test]
15299    fn table_is_number_column_enabled_true_preserved() {
15300        // Regression check: isNumberColumnEnabled=true should still work
15301        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"}]}]}]}]}]}"#;
15302        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15303        let md = adf_to_markdown(&doc).unwrap();
15304        let round_tripped = markdown_to_adf(&md).unwrap();
15305        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15306        assert_eq!(
15307            attrs["isNumberColumnEnabled"], true,
15308            "isNumberColumnEnabled=true should be preserved"
15309        );
15310    }
15311
15312    #[test]
15313    fn directive_table_is_number_column_enabled_false_preserved() {
15314        // Covers render_directive_table + directive table parsing for numbered=false.
15315        // Multi-paragraph cell forces directive table form.
15316        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
15317          {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
15318          {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
15319        ]}]}]}]}"#;
15320        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15321        let md = adf_to_markdown(&doc).unwrap();
15322        assert!(md.contains("::::table"), "should use directive table form");
15323        assert!(
15324            md.contains("numbered=false"),
15325            "should contain numbered=false, got: {md}"
15326        );
15327        let round_tripped = markdown_to_adf(&md).unwrap();
15328        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15329        assert_eq!(attrs["isNumberColumnEnabled"], false);
15330        assert_eq!(attrs["layout"], "default");
15331    }
15332
15333    #[test]
15334    fn directive_table_is_number_column_enabled_true_preserved() {
15335        // Covers render_directive_table + directive table parsing for numbered (true).
15336        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":true,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
15337          {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
15338          {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
15339        ]}]}]}]}"#;
15340        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15341        let md = adf_to_markdown(&doc).unwrap();
15342        assert!(md.contains("::::table"), "should use directive table form");
15343        assert!(
15344            md.contains("numbered}") || md.contains("numbered "),
15345            "should contain numbered flag, got: {md}"
15346        );
15347        let round_tripped = markdown_to_adf(&md).unwrap();
15348        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15349        assert_eq!(attrs["isNumberColumnEnabled"], true);
15350    }
15351
15352    #[test]
15353    fn trailing_space_in_bullet_list_item_preserved() {
15354        // Issue #394: trailing space text node in list item dropped
15355        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15356          {"type":"listItem","content":[{"type":"paragraph","content":[
15357            {"type":"text","text":"Before link "},
15358            {"type":"text","text":"link text","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
15359            {"type":"text","text":" "}
15360          ]}]}
15361        ]}]}"#;
15362        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15363        let md = adf_to_markdown(&doc).unwrap();
15364        let round_tripped = markdown_to_adf(&md).unwrap();
15365        let list = &round_tripped.content[0];
15366        let item = &list.content.as_ref().unwrap()[0];
15367        let para = &item.content.as_ref().unwrap()[0];
15368        let inlines = para.content.as_ref().unwrap();
15369        let last = inlines.last().unwrap();
15370        assert_eq!(
15371            last.text.as_deref(),
15372            Some(" "),
15373            "trailing space text node should be preserved, got nodes: {:?}",
15374            inlines
15375                .iter()
15376                .map(|n| (&n.node_type, &n.text))
15377                .collect::<Vec<_>>()
15378        );
15379    }
15380
15381    #[test]
15382    fn trailing_space_after_mention_in_bullet_list_preserved() {
15383        // Mention + trailing space in list item
15384        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15385          {"type":"listItem","content":[{"type":"paragraph","content":[
15386            {"type":"mention","attrs":{"id":"abc","text":"@Alice"}},
15387            {"type":"text","text":" "}
15388          ]}]}
15389        ]}]}"#;
15390        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15391        let md = adf_to_markdown(&doc).unwrap();
15392        let round_tripped = markdown_to_adf(&md).unwrap();
15393        let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
15394            .content
15395            .as_ref()
15396            .unwrap()[0];
15397        let inlines = para.content.as_ref().unwrap();
15398        assert!(
15399            inlines.len() >= 2,
15400            "should have mention + trailing space, got {} nodes",
15401            inlines.len()
15402        );
15403        assert_eq!(inlines.last().unwrap().text.as_deref(), Some(" "));
15404    }
15405
15406    #[test]
15407    fn trailing_space_in_ordered_list_item_preserved() {
15408        // Same issue in ordered list context
15409        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15410          {"type":"listItem","content":[{"type":"paragraph","content":[
15411            {"type":"text","text":"item "},
15412            {"type":"text","text":"link","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
15413            {"type":"text","text":" "}
15414          ]}]}
15415        ]}]}"#;
15416        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15417        let md = adf_to_markdown(&doc).unwrap();
15418        let round_tripped = markdown_to_adf(&md).unwrap();
15419        let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
15420            .content
15421            .as_ref()
15422            .unwrap()[0];
15423        let inlines = para.content.as_ref().unwrap();
15424        let last = inlines.last().unwrap();
15425        assert_eq!(
15426            last.text.as_deref(),
15427            Some(" "),
15428            "trailing space should be preserved in ordered list item"
15429        );
15430    }
15431
15432    #[test]
15433    fn trailing_space_in_heading_text_preserved() {
15434        // Issue #400: trailing space in heading text node trimmed on round-trip
15435        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[
15436          {"type":"text","text":"Firefighting Engineers "}
15437        ]}]}"#;
15438        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15439        let md = adf_to_markdown(&doc).unwrap();
15440        let round_tripped = markdown_to_adf(&md).unwrap();
15441        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15442        assert_eq!(
15443            inlines[0].text.as_deref(),
15444            Some("Firefighting Engineers "),
15445            "trailing space in heading should be preserved"
15446        );
15447    }
15448
15449    #[test]
15450    fn trailing_space_in_heading_before_bold_preserved() {
15451        // Issue #400: trailing space before bold sibling in heading
15452        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
15453          {"type":"text","text":"Classic "},
15454          {"type":"text","text":"bold","marks":[{"type":"strong"}]}
15455        ]}]}"#;
15456        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15457        let md = adf_to_markdown(&doc).unwrap();
15458        let round_tripped = markdown_to_adf(&md).unwrap();
15459        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15460        assert_eq!(
15461            inlines[0].text.as_deref(),
15462            Some("Classic "),
15463            "trailing space in heading text before bold should be preserved"
15464        );
15465    }
15466
15467    #[test]
15468    fn leading_space_in_heading_text_preserved() {
15469        // Issue #492: leading spaces in heading text node stripped on round-trip
15470        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":3},"content":[
15471          {"type":"text","text":"  #general-channel"}
15472        ]}]}"#;
15473        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15474        let md = adf_to_markdown(&doc).unwrap();
15475        let round_tripped = markdown_to_adf(&md).unwrap();
15476        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15477        assert_eq!(
15478            inlines[0].text.as_deref(),
15479            Some("  #general-channel"),
15480            "leading spaces in heading text should be preserved"
15481        );
15482    }
15483
15484    #[test]
15485    fn leading_space_in_heading_before_bold_preserved() {
15486        // Issue #492: leading space before bold sibling in heading
15487        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
15488          {"type":"text","text":"   indented"},
15489          {"type":"text","text":" bold","marks":[{"type":"strong"}]}
15490        ]}]}"#;
15491        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15492        let md = adf_to_markdown(&doc).unwrap();
15493        let round_tripped = markdown_to_adf(&md).unwrap();
15494        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15495        assert_eq!(
15496            inlines[0].text.as_deref(),
15497            Some("   indented"),
15498            "leading spaces in heading text before bold should be preserved"
15499        );
15500    }
15501
15502    #[test]
15503    fn heading_multiple_leading_spaces_markdown_parse() {
15504        // Issue #492: verify JFM parsing preserves leading spaces
15505        let md = "### \t  #general-channel";
15506        let doc = markdown_to_adf(md).unwrap();
15507        let inlines = doc.content[0].content.as_ref().unwrap();
15508        assert_eq!(
15509            inlines[0].text.as_deref(),
15510            Some("\t  #general-channel"),
15511            "leading whitespace in heading text should be preserved during JFM parsing"
15512        );
15513    }
15514
15515    #[test]
15516    fn trailing_space_in_paragraph_text_preserved() {
15517        // Issue #400: trailing space in paragraph text node preserved on round-trip
15518        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15519          {"type":"text","text":"word followed by space "},
15520          {"type":"text","text":"next node","marks":[{"type":"strong"}]}
15521        ]}]}"#;
15522        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15523        let md = adf_to_markdown(&doc).unwrap();
15524        let round_tripped = markdown_to_adf(&md).unwrap();
15525        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15526        assert_eq!(
15527            inlines[0].text.as_deref(),
15528            Some("word followed by space "),
15529            "trailing space in paragraph text should be preserved"
15530        );
15531    }
15532
15533    #[test]
15534    fn nested_bullet_list_roundtrip() {
15535        // ADF with a listItem containing a paragraph + nested bulletList
15536        let adf_doc = serde_json::json!({
15537            "type": "doc",
15538            "version": 1,
15539            "content": [{
15540                "type": "bulletList",
15541                "content": [{
15542                    "type": "listItem",
15543                    "content": [
15544                        {
15545                            "type": "paragraph",
15546                            "content": [{"type": "text", "text": "parent item"}]
15547                        },
15548                        {
15549                            "type": "bulletList",
15550                            "content": [
15551                                {
15552                                    "type": "listItem",
15553                                    "content": [{
15554                                        "type": "paragraph",
15555                                        "content": [{"type": "text", "text": "sub item 1"}]
15556                                    }]
15557                                },
15558                                {
15559                                    "type": "listItem",
15560                                    "content": [{
15561                                        "type": "paragraph",
15562                                        "content": [{"type": "text", "text": "sub item 2"}]
15563                                    }]
15564                                }
15565                            ]
15566                        }
15567                    ]
15568                }]
15569            }]
15570        });
15571        let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
15572        let md = adf_to_markdown(&doc).unwrap();
15573        assert!(
15574            md.contains("- parent item\n"),
15575            "expected top-level item in markdown, got: {md}"
15576        );
15577        assert!(
15578            md.contains("  - sub item 1\n"),
15579            "expected indented sub item 1 in markdown, got: {md}"
15580        );
15581        assert!(
15582            md.contains("  - sub item 2\n"),
15583            "expected indented sub item 2 in markdown, got: {md}"
15584        );
15585
15586        // Round-trip back
15587        let doc2 = markdown_to_adf(&md).unwrap();
15588        let list = &doc2.content[0];
15589        assert_eq!(list.node_type, "bulletList");
15590        let item = &list.content.as_ref().unwrap()[0];
15591        assert_eq!(item.node_type, "listItem");
15592        let item_content = item.content.as_ref().unwrap();
15593        assert_eq!(
15594            item_content.len(),
15595            2,
15596            "listItem should have paragraph + nested list"
15597        );
15598        assert_eq!(item_content[0].node_type, "paragraph");
15599        assert_eq!(item_content[1].node_type, "bulletList");
15600        let sub_items = item_content[1].content.as_ref().unwrap();
15601        assert_eq!(sub_items.len(), 2);
15602    }
15603
15604    #[test]
15605    fn nested_bullet_in_table_cell_roundtrip() {
15606        let md = "::::table\n:::tr\n:::td\n- parent\n  - child\n:::\n:::\n::::\n";
15607        let doc = markdown_to_adf(md).unwrap();
15608        let table = &doc.content[0];
15609        let row = &table.content.as_ref().unwrap()[0];
15610        let cell = &row.content.as_ref().unwrap()[0];
15611        let list = &cell.content.as_ref().unwrap()[0];
15612        assert_eq!(list.node_type, "bulletList");
15613        let item = &list.content.as_ref().unwrap()[0];
15614        let item_content = item.content.as_ref().unwrap();
15615        assert_eq!(
15616            item_content.len(),
15617            2,
15618            "listItem should have paragraph + nested list"
15619        );
15620        assert_eq!(item_content[1].node_type, "bulletList");
15621
15622        // Round-trip: adf→md→adf should preserve the nested list
15623        let md2 = adf_to_markdown(&doc).unwrap();
15624        assert!(
15625            md2.contains("  - child"),
15626            "expected indented child in round-tripped markdown, got: {md2}"
15627        );
15628    }
15629
15630    #[test]
15631    fn nested_ordered_list_roundtrip() {
15632        // Issue #389: nested orderedList inside listItem flattened
15633        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15634          {"type":"listItem","content":[
15635            {"type":"paragraph","content":[{"type":"text","text":"Top level"}]},
15636            {"type":"orderedList","attrs":{"order":1},"content":[
15637              {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 1"}]}]},
15638              {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 2"}]}]}
15639            ]}
15640          ]},
15641          {"type":"listItem","content":[
15642            {"type":"paragraph","content":[{"type":"text","text":"Second top"}]}
15643          ]}
15644        ]}]}"#;
15645        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15646        let md = adf_to_markdown(&doc).unwrap();
15647        let round_tripped = markdown_to_adf(&md).unwrap();
15648
15649        // Outer list should have 2 items
15650        let outer = &round_tripped.content[0];
15651        assert_eq!(outer.node_type, "orderedList");
15652        assert_eq!(
15653            outer.attrs.as_ref().unwrap()["order"],
15654            1,
15655            "explicit order=1 must be preserved via trailing {{order=1}} (issue #547)"
15656        );
15657        let outer_items = outer.content.as_ref().unwrap();
15658        assert_eq!(
15659            outer_items.len(),
15660            2,
15661            "outer list should have 2 items, got {}",
15662            outer_items.len()
15663        );
15664
15665        // First item should have paragraph + nested orderedList
15666        let first_item = &outer_items[0];
15667        let first_content = first_item.content.as_ref().unwrap();
15668        assert_eq!(
15669            first_content.len(),
15670            2,
15671            "first listItem should have paragraph + nested list, got {}",
15672            first_content.len()
15673        );
15674        assert_eq!(first_content[0].node_type, "paragraph");
15675        assert_eq!(first_content[1].node_type, "orderedList");
15676        let nested_items = first_content[1].content.as_ref().unwrap();
15677        assert_eq!(nested_items.len(), 2, "nested list should have 2 items");
15678    }
15679
15680    #[test]
15681    fn nested_ordered_list_markdown_parsing() {
15682        // Direct markdown parsing of nested ordered list
15683        let md = "1. Top level\n  1. Nested 1\n  2. Nested 2\n2. Second top\n";
15684        let doc = markdown_to_adf(md).unwrap();
15685        let outer = &doc.content[0];
15686        assert_eq!(outer.node_type, "orderedList");
15687        let outer_items = outer.content.as_ref().unwrap();
15688        assert_eq!(outer_items.len(), 2, "should have 2 top-level items");
15689
15690        let first_content = outer_items[0].content.as_ref().unwrap();
15691        assert_eq!(
15692            first_content.len(),
15693            2,
15694            "first item should have paragraph + nested list"
15695        );
15696        assert_eq!(first_content[1].node_type, "orderedList");
15697    }
15698
15699    #[test]
15700    fn bullet_list_nested_inside_ordered_list() {
15701        // Mixed nesting: bullet list nested inside ordered list
15702        let md = "1. Ordered item\n  - Bullet child 1\n  - Bullet child 2\n2. Second ordered\n";
15703        let doc = markdown_to_adf(md).unwrap();
15704        let outer = &doc.content[0];
15705        assert_eq!(outer.node_type, "orderedList");
15706        let outer_items = outer.content.as_ref().unwrap();
15707        assert_eq!(outer_items.len(), 2);
15708
15709        let first_content = outer_items[0].content.as_ref().unwrap();
15710        assert_eq!(
15711            first_content.len(),
15712            2,
15713            "first item should have paragraph + nested list"
15714        );
15715        assert_eq!(first_content[1].node_type, "bulletList");
15716        let sub_items = first_content[1].content.as_ref().unwrap();
15717        assert_eq!(sub_items.len(), 2, "nested bullet list should have 2 items");
15718    }
15719
15720    #[test]
15721    fn ordered_list_order_attr_one_is_elided() {
15722        // Issue #547: order=1 is the default and must be elided from attrs
15723        // for round-trip fidelity with ADF documents that omit the attrs
15724        // object on orderedList.
15725        let md = "1. A\n2. B\n";
15726        let doc = markdown_to_adf(md).unwrap();
15727        assert!(
15728            doc.content[0].attrs.is_none(),
15729            "attrs should be elided when order=1"
15730        );
15731
15732        // Round-trip should preserve the elision
15733        let md2 = adf_to_markdown(&doc).unwrap();
15734        let doc2 = markdown_to_adf(&md2).unwrap();
15735        assert!(
15736            doc2.content[0].attrs.is_none(),
15737            "attrs should remain elided after round-trip"
15738        );
15739    }
15740
15741    #[test]
15742    fn issue_547_ordered_list_no_attrs_roundtrip_byte_identical() {
15743        // Issue #547: ADF orderedList without an attrs field must round-trip
15744        // (ADF → JFM → ADF) without gaining a spurious {"order": 1} attrs.
15745        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"}]}]}]}]}"#;
15746        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15747        let md = adf_to_markdown(&doc).unwrap();
15748        let rt = markdown_to_adf(&md).unwrap();
15749        assert!(
15750            rt.content[0].attrs.is_none(),
15751            "round-tripped orderedList should not have attrs, got: {:?}",
15752            rt.content[0].attrs
15753        );
15754
15755        // Serialized JSON must also omit attrs entirely for byte fidelity.
15756        let rt_json = serde_json::to_string(&rt).unwrap();
15757        assert!(
15758            !rt_json.contains("\"order\""),
15759            "round-tripped JSON should not contain \"order\", got: {rt_json}"
15760        );
15761    }
15762
15763    // ── Issue #547: orderedList byte-identical roundtrip coverage ───────
15764
15765    /// Assert that ADF → JFM → ADF produces a document whose serialized JSON
15766    /// (as a sorted-key canonical form) matches the source JSON. Mirrors the
15767    /// `jq --sort-keys` comparison used in the issue's reproducer.
15768    fn assert_roundtrip_byte_identical(adf_json: &str) {
15769        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15770        let md = adf_to_markdown(&doc).unwrap();
15771        let rt = markdown_to_adf(&md).unwrap();
15772
15773        let canonical_src: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15774        let canonical_rt: serde_json::Value =
15775            serde_json::from_str(&serde_json::to_string(&rt).unwrap()).unwrap();
15776        assert_eq!(
15777            canonical_src, canonical_rt,
15778            "round-trip diverged\n  src: {canonical_src}\n   rt: {canonical_rt}\n   md: {md:?}"
15779        );
15780    }
15781
15782    #[test]
15783    fn issue_547_single_item_no_attrs_roundtrip() {
15784        assert_roundtrip_byte_identical(
15785            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"only"}]}]}]}]}"#,
15786        );
15787    }
15788
15789    #[test]
15790    fn issue_547_many_items_no_attrs_roundtrip() {
15791        assert_roundtrip_byte_identical(
15792            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"}]}]}]}]}"#,
15793        );
15794    }
15795
15796    #[test]
15797    fn issue_547_non_default_order_preserved() {
15798        // When order != 1, attrs must still be serialized (fix must not
15799        // over-eagerly drop attrs).
15800        assert_roundtrip_byte_identical(
15801            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":5},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"fifth"}]}]}]}]}"#,
15802        );
15803    }
15804
15805    #[test]
15806    fn issue_547_nested_ordered_in_ordered_no_attrs_roundtrip() {
15807        // Outer and inner both omit attrs; fix must apply at every level.
15808        assert_roundtrip_byte_identical(
15809            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"}]}]}]}]}]}]}"#,
15810        );
15811    }
15812
15813    #[test]
15814    fn issue_547_ordered_nested_in_bullet_no_attrs_roundtrip() {
15815        assert_roundtrip_byte_identical(
15816            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"}]}]}]}]}]}]}"#,
15817        );
15818    }
15819
15820    #[test]
15821    fn issue_547_bullet_nested_in_ordered_no_attrs_roundtrip() {
15822        assert_roundtrip_byte_identical(
15823            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"}]}]}]}]}]}]}"#,
15824        );
15825    }
15826
15827    #[test]
15828    fn issue_547_ordered_list_between_paragraphs_roundtrip() {
15829        assert_roundtrip_byte_identical(
15830            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"}]}]}"#,
15831        );
15832    }
15833
15834    #[test]
15835    fn issue_547_ordered_list_with_marked_text_roundtrip() {
15836        assert_roundtrip_byte_identical(
15837            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"bold","marks":[{"type":"strong"}]}]}]}]}]}"#,
15838        );
15839    }
15840
15841    #[test]
15842    fn issue_547_ordered_list_with_link_roundtrip() {
15843        assert_roundtrip_byte_identical(
15844            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"}}]}]}]}]}]}"#,
15845        );
15846    }
15847
15848    #[test]
15849    fn issue_547_ordered_list_with_hardbreak_roundtrip() {
15850        assert_roundtrip_byte_identical(
15851            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"}]}]}]}]}"#,
15852        );
15853    }
15854
15855    #[test]
15856    fn issue_547_triple_nested_ordered_roundtrip() {
15857        assert_roundtrip_byte_identical(
15858            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"}]}]}]}]}]}]}]}]}"#,
15859        );
15860    }
15861
15862    #[test]
15863    fn issue_547_ordered_list_heading_rule_mix_roundtrip() {
15864        assert_roundtrip_byte_identical(
15865            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"}]}"#,
15866        );
15867    }
15868
15869    #[test]
15870    fn issue_547_ordered_list_listitem_localid_roundtrip() {
15871        // listItem attrs must coexist with the no-attrs outer orderedList.
15872        assert_roundtrip_byte_identical(
15873            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]}]}]}"#,
15874        );
15875    }
15876
15877    #[test]
15878    fn issue_547_explicit_order_one_preserved_roundtrip() {
15879        // Inverse regression (see PR #562 comment 4266630848): when the source
15880        // ADF has an explicit `"attrs": {"order": 1}` the round-trip must
15881        // preserve it, not strip it. A trailing `{order=1}` signal on the
15882        // rendered markdown distinguishes explicit-default from omitted attrs.
15883        assert_roundtrip_byte_identical(
15884            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"First item"}]}]}]}]}"#,
15885        );
15886    }
15887
15888    #[test]
15889    fn issue_547_explicit_order_one_nested_preserved_roundtrip() {
15890        // Both outer and inner orderedList have explicit `order: 1`; both must
15891        // be preserved across the round-trip independently.
15892        assert_roundtrip_byte_identical(
15893            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"}]}]}]}]}]}]}"#,
15894        );
15895    }
15896
15897    #[test]
15898    fn issue_547_mixed_explicit_and_implicit_order_roundtrip() {
15899        // Sibling orderedLists with different attrs presence must round-trip
15900        // independently: first has explicit `order: 1`, second omits attrs.
15901        assert_roundtrip_byte_identical(
15902            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"}]}]}]}]}"#,
15903        );
15904    }
15905
15906    #[test]
15907    fn issue_547_explicit_order_one_with_listitem_localid_roundtrip() {
15908        // Explicit `order: 1` outer, plus a listItem `localId` inside — the
15909        // trailing `{order=1}` line must not swallow or collide with listItem
15910        // attrs.
15911        assert_roundtrip_byte_identical(
15912            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"}]}]}]}]}"#,
15913        );
15914    }
15915
15916    #[test]
15917    fn issue_547_order_attr_signal_appears_only_for_explicit_one() {
15918        // Render-layer guard: `{order=1}` appears in markdown only when the
15919        // source ADF has explicit `attrs.order=1`. No signal for attrs=None,
15920        // no signal for attrs.order>1 (marker already encodes the value).
15921        let no_attrs = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}]}"#;
15922        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"}]}]}]}]}"#;
15923        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"}]}]}]}]}"#;
15924
15925        let md_no =
15926            adf_to_markdown(&serde_json::from_str::<AdfDocument>(no_attrs).unwrap()).unwrap();
15927        let md_one =
15928            adf_to_markdown(&serde_json::from_str::<AdfDocument>(explicit_one).unwrap()).unwrap();
15929        let md_five =
15930            adf_to_markdown(&serde_json::from_str::<AdfDocument>(order_five).unwrap()).unwrap();
15931
15932        assert!(
15933            !md_no.contains("{order="),
15934            "no-attrs source must not emit order signal, got: {md_no:?}"
15935        );
15936        assert!(
15937            md_one.contains("{order=1}"),
15938            "explicit order=1 must emit trailing signal, got: {md_one:?}"
15939        );
15940        assert!(
15941            !md_five.contains("{order="),
15942            "order=5 is already encoded by marker; must not emit signal, got: {md_five:?}"
15943        );
15944    }
15945
15946    // ── File media round-trip tests ─────────────────────────────────────
15947
15948    #[test]
15949    fn file_media_roundtrip() {
15950        // ADF with a Confluence file attachment (type:file media)
15951        let adf_doc = serde_json::json!({
15952            "type": "doc",
15953            "version": 1,
15954            "content": [{
15955                "type": "mediaSingle",
15956                "attrs": {"layout": "center"},
15957                "content": [{
15958                    "type": "media",
15959                    "attrs": {
15960                        "type": "file",
15961                        "id": "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d",
15962                        "collection": "contentId-8220672100",
15963                        "height": 56,
15964                        "width": 312,
15965                        "alt": "Screenshot.png"
15966                    }
15967                }]
15968            }]
15969        });
15970        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15971        let md = adf_to_markdown(&doc).unwrap();
15972        assert!(
15973            md.contains("type=file"),
15974            "expected type=file in markdown, got: {md}"
15975        );
15976        assert!(
15977            md.contains("id=6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d"),
15978            "expected id in markdown, got: {md}"
15979        );
15980        assert!(
15981            md.contains("collection=contentId-8220672100"),
15982            "expected collection in markdown, got: {md}"
15983        );
15984        // Round-trip back to ADF
15985        let doc2 = markdown_to_adf(&md).unwrap();
15986        let ms = &doc2.content[0];
15987        assert_eq!(ms.node_type, "mediaSingle");
15988        let media = &ms.content.as_ref().unwrap()[0];
15989        assert_eq!(media.node_type, "media");
15990        let attrs = media.attrs.as_ref().unwrap();
15991        assert_eq!(attrs["type"], "file");
15992        assert_eq!(attrs["id"], "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d");
15993        assert_eq!(attrs["collection"], "contentId-8220672100");
15994        assert_eq!(attrs["height"], 56);
15995        assert_eq!(attrs["width"], 312);
15996        assert_eq!(attrs["alt"], "Screenshot.png");
15997    }
15998
15999    /// Issue #550: roundtrip of mediaSingle with file-type media preserves all
16000    /// file attributes (type, id, collection, width, height). Regression guard
16001    /// for the exact reproducer in the issue body.
16002    #[test]
16003    fn file_media_roundtrip_issue_550_reproducer() {
16004        let adf_json = r#"{
16005          "version": 1,
16006          "type": "doc",
16007          "content": [
16008            {
16009              "type": "mediaSingle",
16010              "attrs": {"layout": "center"},
16011              "content": [
16012                {
16013                  "type": "media",
16014                  "attrs": {
16015                    "type": "file",
16016                    "id": "abc-123-def-456",
16017                    "collection": "my-collection",
16018                    "width": 941,
16019                    "height": 655
16020                  }
16021                }
16022              ]
16023            }
16024          ]
16025        }"#;
16026        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16027        let md = adf_to_markdown(&doc).unwrap();
16028        let rt = markdown_to_adf(&md).unwrap();
16029        let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
16030        let actual = serde_json::to_value(&rt).unwrap();
16031        assert_eq!(
16032            actual, expected,
16033            "roundtrip should preserve file media attrs; md was:\n{md}"
16034        );
16035    }
16036
16037    /// Issue #550 (updated reproducer): roundtrip of a file-media `id`
16038    /// containing spaces must not truncate the value. Before the fix, the
16039    /// JFM renderer emitted `id=abc 123 def 456` unquoted and the parser
16040    /// treated the first space as a value terminator, so the `id` became
16041    /// `"abc"` after round-trip.
16042    #[test]
16043    fn file_media_roundtrip_id_with_spaces() {
16044        let adf_json = r#"{
16045          "version": 1,
16046          "type": "doc",
16047          "content": [
16048            {
16049              "type": "mediaSingle",
16050              "attrs": {"layout": "center"},
16051              "content": [
16052                {
16053                  "type": "media",
16054                  "attrs": {
16055                    "type": "file",
16056                    "id": "abc 123 def 456",
16057                    "collection": "my-collection",
16058                    "width": 800,
16059                    "height": 600
16060                  }
16061                }
16062              ]
16063            }
16064          ]
16065        }"#;
16066        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16067        let md = adf_to_markdown(&doc).unwrap();
16068        assert!(
16069            md.contains(r#"id="abc 123 def 456""#),
16070            "id with spaces should be quoted in JFM, got:\n{md}"
16071        );
16072        let rt = markdown_to_adf(&md).unwrap();
16073        let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
16074        let actual = serde_json::to_value(&rt).unwrap();
16075        assert_eq!(
16076            actual, expected,
16077            "space-containing id must round-trip; md was:\n{md}"
16078        );
16079    }
16080
16081    /// Space-containing `collection` values must round-trip.
16082    #[test]
16083    fn file_media_roundtrip_collection_with_spaces() {
16084        let adf_json = r#"{
16085          "version": 1,
16086          "type": "doc",
16087          "content": [
16088            {
16089              "type": "mediaSingle",
16090              "attrs": {"layout": "center"},
16091              "content": [
16092                {
16093                  "type": "media",
16094                  "attrs": {
16095                    "type": "file",
16096                    "id": "abc-123",
16097                    "collection": "my collection with spaces"
16098                  }
16099                }
16100              ]
16101            }
16102          ]
16103        }"#;
16104        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16105        let md = adf_to_markdown(&doc).unwrap();
16106        let rt = markdown_to_adf(&md).unwrap();
16107        let media = &rt.content[0].content.as_ref().unwrap()[0];
16108        assert_eq!(
16109            media.attrs.as_ref().unwrap()["collection"],
16110            "my collection with spaces"
16111        );
16112    }
16113
16114    /// Space-containing `occurrenceKey` values must round-trip.
16115    #[test]
16116    fn file_media_roundtrip_occurrence_key_with_spaces() {
16117        let adf_json = r#"{
16118          "version": 1,
16119          "type": "doc",
16120          "content": [
16121            {
16122              "type": "mediaSingle",
16123              "attrs": {"layout": "center"},
16124              "content": [
16125                {
16126                  "type": "media",
16127                  "attrs": {
16128                    "type": "file",
16129                    "id": "x",
16130                    "collection": "y",
16131                    "occurrenceKey": "key with spaces"
16132                  }
16133                }
16134              ]
16135            }
16136          ]
16137        }"#;
16138        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16139        let md = adf_to_markdown(&doc).unwrap();
16140        let rt = markdown_to_adf(&md).unwrap();
16141        let media = &rt.content[0].content.as_ref().unwrap()[0];
16142        assert_eq!(
16143            media.attrs.as_ref().unwrap()["occurrenceKey"],
16144            "key with spaces"
16145        );
16146    }
16147
16148    /// Values with embedded `"` must be escape-quoted and round-trip.
16149    #[test]
16150    fn file_media_roundtrip_id_with_quote_char() {
16151        let adf_json = r#"{
16152          "version": 1,
16153          "type": "doc",
16154          "content": [
16155            {
16156              "type": "mediaSingle",
16157              "attrs": {"layout": "center"},
16158              "content": [
16159                {
16160                  "type": "media",
16161                  "attrs": {
16162                    "type": "file",
16163                    "id": "a\"b\"c",
16164                    "collection": "col"
16165                  }
16166                }
16167              ]
16168            }
16169          ]
16170        }"#;
16171        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16172        let md = adf_to_markdown(&doc).unwrap();
16173        let rt = markdown_to_adf(&md).unwrap();
16174        let media = &rt.content[0].content.as_ref().unwrap()[0];
16175        assert_eq!(media.attrs.as_ref().unwrap()["id"], "a\"b\"c");
16176    }
16177
16178    /// `mediaInline` string attrs with spaces must round-trip (parallel fix
16179    /// for the inline-directive rendering path).
16180    #[test]
16181    fn media_inline_roundtrip_id_with_spaces() {
16182        let adf_json = r#"{
16183          "version": 1,
16184          "type": "doc",
16185          "content": [
16186            {
16187              "type": "paragraph",
16188              "content": [
16189                {"type": "text", "text": "before "},
16190                {
16191                  "type": "mediaInline",
16192                  "attrs": {
16193                    "type": "file",
16194                    "id": "a b c",
16195                    "collection": "my col"
16196                  }
16197                },
16198                {"type": "text", "text": " after"}
16199              ]
16200            }
16201          ]
16202        }"#;
16203        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16204        let md = adf_to_markdown(&doc).unwrap();
16205        let rt = markdown_to_adf(&md).unwrap();
16206        let inline = &rt.content[0].content.as_ref().unwrap()[1];
16207        assert_eq!(inline.node_type, "mediaInline");
16208        let attrs = inline.attrs.as_ref().unwrap();
16209        assert_eq!(attrs["id"], "a b c");
16210        assert_eq!(attrs["collection"], "my col");
16211    }
16212
16213    /// Issue #550: `occurrenceKey` attribute is a standard ADF media attr and
16214    /// must be preserved through ADF→JFM→ADF roundtrip.
16215    #[test]
16216    fn file_media_roundtrip_preserves_occurrence_key() {
16217        let adf_json = r#"{
16218          "version": 1,
16219          "type": "doc",
16220          "content": [
16221            {
16222              "type": "mediaSingle",
16223              "attrs": {"layout": "center"},
16224              "content": [
16225                {
16226                  "type": "media",
16227                  "attrs": {
16228                    "type": "file",
16229                    "id": "abc-123",
16230                    "collection": "my-collection",
16231                    "occurrenceKey": "unique-key-xyz",
16232                    "width": 200,
16233                    "height": 100
16234                  }
16235                }
16236              ]
16237            }
16238          ]
16239        }"#;
16240        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16241        let md = adf_to_markdown(&doc).unwrap();
16242        assert!(
16243            md.contains("occurrenceKey=unique-key-xyz"),
16244            "expected occurrenceKey in markdown, got: {md}"
16245        );
16246        let rt = markdown_to_adf(&md).unwrap();
16247        let media = &rt.content[0].content.as_ref().unwrap()[0];
16248        let attrs = media.attrs.as_ref().unwrap();
16249        assert_eq!(attrs["occurrenceKey"], "unique-key-xyz");
16250        assert_eq!(attrs["type"], "file");
16251        assert_eq!(attrs["id"], "abc-123");
16252        assert_eq!(attrs["collection"], "my-collection");
16253    }
16254
16255    // ── mediaSingle caption tests (issue #470) ──────────────────────────
16256
16257    #[test]
16258    fn media_single_caption_adf_to_markdown() {
16259        let adf_doc = serde_json::json!({
16260            "type": "doc",
16261            "version": 1,
16262            "content": [{
16263                "type": "mediaSingle",
16264                "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
16265                "content": [
16266                    {
16267                        "type": "media",
16268                        "attrs": {
16269                            "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
16270                            "type": "file",
16271                            "collection": "contentId-123456",
16272                            "width": 800,
16273                            "height": 600
16274                        }
16275                    },
16276                    {
16277                        "type": "caption",
16278                        "content": [{"type": "text", "text": "An image caption here"}]
16279                    }
16280                ]
16281            }]
16282        });
16283        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16284        let md = adf_to_markdown(&doc).unwrap();
16285        assert!(
16286            md.contains(":::caption"),
16287            "expected :::caption in markdown, got: {md}"
16288        );
16289        assert!(
16290            md.contains("An image caption here"),
16291            "expected caption text in markdown, got: {md}"
16292        );
16293    }
16294
16295    #[test]
16296    fn media_single_caption_markdown_to_adf() {
16297        let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nAn image caption here\n:::\n";
16298        let doc = markdown_to_adf(md).unwrap();
16299        let ms = &doc.content[0];
16300        assert_eq!(ms.node_type, "mediaSingle");
16301        let content = ms.content.as_ref().unwrap();
16302        assert_eq!(content.len(), 2, "expected media + caption children");
16303        assert_eq!(content[0].node_type, "media");
16304        assert_eq!(content[1].node_type, "caption");
16305        let caption_content = content[1].content.as_ref().unwrap();
16306        assert_eq!(
16307            caption_content[0].text.as_deref(),
16308            Some("An image caption here")
16309        );
16310    }
16311
16312    #[test]
16313    fn media_single_caption_round_trip() {
16314        // Full round-trip: ADF → JFM → ADF preserves caption
16315        let adf_doc = serde_json::json!({
16316            "type": "doc",
16317            "version": 1,
16318            "content": [{
16319                "type": "mediaSingle",
16320                "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
16321                "content": [
16322                    {
16323                        "type": "media",
16324                        "attrs": {
16325                            "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
16326                            "type": "file",
16327                            "collection": "contentId-123456",
16328                            "width": 800,
16329                            "height": 600
16330                        }
16331                    },
16332                    {
16333                        "type": "caption",
16334                        "content": [{"type": "text", "text": "An image caption here"}]
16335                    }
16336                ]
16337            }]
16338        });
16339        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16340        let md = adf_to_markdown(&doc).unwrap();
16341        let doc2 = markdown_to_adf(&md).unwrap();
16342        let ms = &doc2.content[0];
16343        assert_eq!(ms.node_type, "mediaSingle");
16344        let content = ms.content.as_ref().unwrap();
16345        assert_eq!(
16346            content.len(),
16347            2,
16348            "expected media + caption after round-trip"
16349        );
16350        assert_eq!(content[1].node_type, "caption");
16351        let caption_content = content[1].content.as_ref().unwrap();
16352        assert_eq!(
16353            caption_content[0].text.as_deref(),
16354            Some("An image caption here")
16355        );
16356    }
16357
16358    #[test]
16359    fn media_single_caption_with_inline_marks() {
16360        let adf_doc = serde_json::json!({
16361            "type": "doc",
16362            "version": 1,
16363            "content": [{
16364                "type": "mediaSingle",
16365                "attrs": {"layout": "center"},
16366                "content": [
16367                    {
16368                        "type": "media",
16369                        "attrs": {"type": "external", "url": "https://example.com/img.png"}
16370                    },
16371                    {
16372                        "type": "caption",
16373                        "content": [
16374                            {"type": "text", "text": "A "},
16375                            {"type": "text", "text": "bold", "marks": [{"type": "strong"}]},
16376                            {"type": "text", "text": " caption"}
16377                        ]
16378                    }
16379                ]
16380            }]
16381        });
16382        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16383        let md = adf_to_markdown(&doc).unwrap();
16384        assert!(
16385            md.contains("**bold**"),
16386            "expected bold in caption, got: {md}"
16387        );
16388
16389        let doc2 = markdown_to_adf(&md).unwrap();
16390        let content = doc2.content[0].content.as_ref().unwrap();
16391        assert_eq!(content.len(), 2, "expected media + caption");
16392        assert_eq!(content[1].node_type, "caption");
16393        let caption_inlines = content[1].content.as_ref().unwrap();
16394        let bold_node = caption_inlines
16395            .iter()
16396            .find(|n| n.text.as_deref() == Some("bold"))
16397            .unwrap();
16398        let marks = bold_node.marks.as_ref().unwrap();
16399        assert_eq!(marks[0].mark_type, "strong");
16400    }
16401
16402    #[test]
16403    fn media_single_no_caption_unaffected() {
16404        // Existing mediaSingle without caption should be unaffected
16405        let adf_doc = serde_json::json!({
16406            "type": "doc",
16407            "version": 1,
16408            "content": [{
16409                "type": "mediaSingle",
16410                "attrs": {"layout": "center"},
16411                "content": [{
16412                    "type": "media",
16413                    "attrs": {"type": "external", "url": "https://example.com/img.png"}
16414                }]
16415            }]
16416        });
16417        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16418        let md = adf_to_markdown(&doc).unwrap();
16419        assert!(
16420            !md.contains(":::caption"),
16421            "should not emit caption when none present"
16422        );
16423        let doc2 = markdown_to_adf(&md).unwrap();
16424        let content = doc2.content[0].content.as_ref().unwrap();
16425        assert_eq!(content.len(), 1, "should only have media child");
16426        assert_eq!(content[0].node_type, "media");
16427    }
16428
16429    #[test]
16430    fn media_single_empty_caption_round_trip() {
16431        // Caption node with no content should still round-trip
16432        let adf_doc = serde_json::json!({
16433            "type": "doc",
16434            "version": 1,
16435            "content": [{
16436                "type": "mediaSingle",
16437                "attrs": {"layout": "center"},
16438                "content": [
16439                    {
16440                        "type": "media",
16441                        "attrs": {"type": "external", "url": "https://example.com/img.png"}
16442                    },
16443                    {
16444                        "type": "caption"
16445                    }
16446                ]
16447            }]
16448        });
16449        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16450        let md = adf_to_markdown(&doc).unwrap();
16451        assert!(
16452            md.contains(":::caption"),
16453            "expected :::caption even for empty caption, got: {md}"
16454        );
16455        assert!(
16456            md.contains(":::\n"),
16457            "expected closing ::: fence, got: {md}"
16458        );
16459    }
16460
16461    #[test]
16462    fn media_single_external_caption_round_trip() {
16463        // External image with caption round-trips
16464        let md = "![alt](https://example.com/img.png)\n:::caption\nImage description\n:::\n";
16465        let doc = markdown_to_adf(md).unwrap();
16466        let ms = &doc.content[0];
16467        assert_eq!(ms.node_type, "mediaSingle");
16468        let content = ms.content.as_ref().unwrap();
16469        assert_eq!(content.len(), 2);
16470        assert_eq!(content[0].node_type, "media");
16471        assert_eq!(content[1].node_type, "caption");
16472
16473        let md2 = adf_to_markdown(&doc).unwrap();
16474        let doc2 = markdown_to_adf(&md2).unwrap();
16475        let content2 = doc2.content[0].content.as_ref().unwrap();
16476        assert_eq!(content2.len(), 2);
16477        assert_eq!(content2[1].node_type, "caption");
16478        let caption_text = content2[1].content.as_ref().unwrap();
16479        assert_eq!(caption_text[0].text.as_deref(), Some("Image description"));
16480    }
16481
16482    // ── mediaSingle caption localId tests (issue #524) ─────────────────
16483
16484    #[test]
16485    fn media_single_caption_localid_roundtrip() {
16486        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"}]}]}]}"#;
16487        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16488        let md = adf_to_markdown(&doc).unwrap();
16489        assert!(
16490            md.contains("localId=9da8c2104471"),
16491            "caption localId should appear in markdown: {md}"
16492        );
16493        let rt = markdown_to_adf(&md).unwrap();
16494        let content = rt.content[0].content.as_ref().unwrap();
16495        let caption = &content[1];
16496        assert_eq!(caption.node_type, "caption");
16497        assert_eq!(
16498            caption.attrs.as_ref().unwrap()["localId"],
16499            "9da8c2104471",
16500            "caption localId should round-trip"
16501        );
16502    }
16503
16504    #[test]
16505    fn media_single_caption_without_localid() {
16506        let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nPlain caption\n:::\n";
16507        let doc = markdown_to_adf(md).unwrap();
16508        let caption = &doc.content[0].content.as_ref().unwrap()[1];
16509        assert_eq!(caption.node_type, "caption");
16510        assert!(
16511            caption.attrs.is_none(),
16512            "caption without localId should not gain attrs"
16513        );
16514        let md2 = adf_to_markdown(&doc).unwrap();
16515        assert!(
16516            !md2.contains("localId"),
16517            "no localId should appear in output: {md2}"
16518        );
16519    }
16520
16521    #[test]
16522    fn media_single_caption_localid_stripped_when_option_set() {
16523        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"}]}]}]}"#;
16524        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16525        let opts = RenderOptions {
16526            strip_local_ids: true,
16527            ..Default::default()
16528        };
16529        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16530        assert!(!md.contains("localId"), "localId should be stripped: {md}");
16531    }
16532
16533    #[test]
16534    fn table_width_roundtrip() {
16535        // ADF table with width attribute
16536        let adf_doc = serde_json::json!({
16537            "type": "doc",
16538            "version": 1,
16539            "content": [{
16540                "type": "table",
16541                "attrs": {"layout": "default", "width": 760.0},
16542                "content": [{
16543                    "type": "tableRow",
16544                    "content": [{
16545                        "type": "tableHeader",
16546                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16547                    }]
16548                }]
16549            }]
16550        });
16551        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16552        let md = adf_to_markdown(&doc).unwrap();
16553        assert!(
16554            md.contains("width=760.0"),
16555            "expected width=760.0 in markdown (float preserved), got: {md}"
16556        );
16557        // Round-trip back to ADF
16558        let doc2 = markdown_to_adf(&md).unwrap();
16559        let table = &doc2.content[0];
16560        assert_eq!(table.node_type, "table");
16561        let table_attrs = table.attrs.as_ref().unwrap();
16562        assert_eq!(table_attrs["width"], 760.0);
16563        assert!(
16564            table_attrs["width"].is_f64(),
16565            "expected float width to be preserved as f64, got: {:?}",
16566            table_attrs["width"]
16567        );
16568    }
16569
16570    #[test]
16571    fn table_integer_width_roundtrip_preserves_integer() {
16572        // Issue #577: Integer width in ADF must survive roundtrip without being
16573        // coerced to a float.
16574        let adf_doc = serde_json::json!({
16575            "type": "doc",
16576            "version": 1,
16577            "content": [{
16578                "type": "table",
16579                "attrs": {
16580                    "isNumberColumnEnabled": false,
16581                    "layout": "center",
16582                    "localId": "abc-123",
16583                    "width": 1420
16584                },
16585                "content": [{
16586                    "type": "tableRow",
16587                    "content": [{
16588                        "type": "tableCell",
16589                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Cell"}]}]
16590                    }]
16591                }]
16592            }]
16593        });
16594        let doc: crate::atlassian::adf::AdfDocument =
16595            serde_json::from_value(adf_doc.clone()).unwrap();
16596        let md = adf_to_markdown(&doc).unwrap();
16597        assert!(
16598            md.contains("width=1420"),
16599            "expected width=1420 in markdown, got: {md}"
16600        );
16601        assert!(
16602            !md.contains("width=1420.0"),
16603            "integer width should not be rendered with decimal: {md}"
16604        );
16605
16606        let doc2 = markdown_to_adf(&md).unwrap();
16607        let table = &doc2.content[0];
16608        assert_eq!(table.node_type, "table");
16609        let table_attrs = table.attrs.as_ref().unwrap();
16610        assert_eq!(table_attrs["width"], 1420);
16611        assert!(
16612            table_attrs["width"].is_u64() || table_attrs["width"].is_i64(),
16613            "width should remain an integer, got: {:?}",
16614            table_attrs["width"]
16615        );
16616        assert!(
16617            !table_attrs["width"].is_f64(),
16618            "width should not be a float, got: {:?}",
16619            table_attrs["width"]
16620        );
16621
16622        // Full byte-fidelity: re-serialized ADF should match original JSON.
16623        let roundtripped = serde_json::to_value(&doc2).unwrap();
16624        let orig_width = &adf_doc["content"][0]["attrs"]["width"];
16625        let rt_width = &roundtripped["content"][0]["attrs"]["width"];
16626        assert_eq!(
16627            orig_width, rt_width,
16628            "width value must roundtrip byte-for-byte"
16629        );
16630    }
16631
16632    #[test]
16633    fn table_fractional_width_roundtrip() {
16634        // Fractional float widths should also roundtrip faithfully.
16635        let adf_doc = serde_json::json!({
16636            "type": "doc",
16637            "version": 1,
16638            "content": [{
16639                "type": "table",
16640                "attrs": {"layout": "default", "width": 760.5},
16641                "content": [{
16642                    "type": "tableRow",
16643                    "content": [{
16644                        "type": "tableHeader",
16645                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16646                    }]
16647                }]
16648            }]
16649        });
16650        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16651        let md = adf_to_markdown(&doc).unwrap();
16652        assert!(
16653            md.contains("width=760.5"),
16654            "expected width=760.5 in markdown, got: {md}"
16655        );
16656        let doc2 = markdown_to_adf(&md).unwrap();
16657        let table_attrs = doc2.content[0].attrs.as_ref().unwrap();
16658        assert_eq!(table_attrs["width"], 760.5);
16659        assert!(table_attrs["width"].is_f64());
16660    }
16661
16662    #[test]
16663    fn pipe_table_integer_width_roundtrip() {
16664        // Exercises the try_table() attrs-on-next-line parsing path.
16665        let md = "| A | B |\n|---|---|\n| 1 | 2 |\n{layout=default width=1420}\n";
16666        let doc = markdown_to_adf(md).unwrap();
16667        let table = &doc.content[0];
16668        assert_eq!(table.node_type, "table");
16669        let attrs = table.attrs.as_ref().unwrap();
16670        assert_eq!(attrs["width"], 1420);
16671        assert!(
16672            attrs["width"].is_u64() || attrs["width"].is_i64(),
16673            "pipe-table width must stay integer, got: {:?}",
16674            attrs["width"]
16675        );
16676    }
16677
16678    #[test]
16679    fn file_media_width_type_roundtrip() {
16680        // mediaSingle with widthType:pixel should survive round-trip
16681        let adf_doc = serde_json::json!({
16682            "type": "doc",
16683            "version": 1,
16684            "content": [{
16685                "type": "mediaSingle",
16686                "attrs": {"layout": "center", "width": 312, "widthType": "pixel"},
16687                "content": [{
16688                    "type": "media",
16689                    "attrs": {
16690                        "type": "file",
16691                        "id": "abc123",
16692                        "collection": "contentId-999",
16693                        "height": 56,
16694                        "width": 312
16695                    }
16696                }]
16697            }]
16698        });
16699        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16700        let md = adf_to_markdown(&doc).unwrap();
16701        assert!(
16702            md.contains("widthType=pixel"),
16703            "expected widthType=pixel in markdown, got: {md}"
16704        );
16705        let doc2 = markdown_to_adf(&md).unwrap();
16706        let ms = &doc2.content[0];
16707        let ms_attrs = ms.attrs.as_ref().unwrap();
16708        assert_eq!(ms_attrs["widthType"], "pixel");
16709        assert_eq!(ms_attrs["width"], 312);
16710    }
16711
16712    #[test]
16713    fn file_media_pixel_width_above_100_passes_validation() {
16714        // Issue #1037: confluence_read emits pixel widths well above 100 for
16715        // editor-sized images (here width=900). Lowering carries width=900
16716        // widthType=pixel onto the mediaSingle, which is valid ADF — the
16717        // upstream schema's pixel branch is unbounded. A read→write round-trip
16718        // must clear the write-path validator, not be rejected as out of range
16719        // (the original failure: "value 900 is outside the allowed range
16720        // [0, 100]"). Unlike `file_media_width_type_roundtrip`, this asserts on
16721        // the validator, which the conversion-only round-trip never exercises.
16722        let md = "![alt](){type=file id=11111111-2222-3333-4444-555555555555 collection=contentId-98765 height=904 width=900 mediaWidth=900 widthType=pixel}\n";
16723        let adf = markdown_to_adf(md).unwrap();
16724
16725        let ms_attrs = adf.content[0].attrs.as_ref().unwrap();
16726        assert_eq!(ms_attrs["width"], 900);
16727        assert_eq!(ms_attrs["widthType"], "pixel");
16728
16729        crate::atlassian::adf_validated::validate(&adf).unwrap();
16730    }
16731
16732    #[test]
16733    fn file_media_mode_roundtrip() {
16734        // mediaSingle with mode attr should survive round-trip (issue #431)
16735        let adf_doc = serde_json::json!({
16736            "type": "doc",
16737            "version": 1,
16738            "content": [{
16739                "type": "mediaSingle",
16740                "attrs": {"layout": "wide", "mode": "wide", "width": 1200},
16741                "content": [{
16742                    "type": "media",
16743                    "attrs": {
16744                        "type": "file",
16745                        "id": "abc123",
16746                        "collection": "test",
16747                        "width": 1200,
16748                        "height": 600
16749                    }
16750                }]
16751            }]
16752        });
16753        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16754        let md = adf_to_markdown(&doc).unwrap();
16755        assert!(
16756            md.contains("mode=wide"),
16757            "expected mode=wide in markdown, got: {md}"
16758        );
16759        let doc2 = markdown_to_adf(&md).unwrap();
16760        let ms = &doc2.content[0];
16761        let ms_attrs = ms.attrs.as_ref().unwrap();
16762        assert_eq!(ms_attrs["mode"], "wide");
16763        assert_eq!(ms_attrs["layout"], "wide");
16764        assert_eq!(ms_attrs["width"], 1200);
16765    }
16766
16767    #[test]
16768    fn external_media_mode_roundtrip() {
16769        // External mediaSingle with mode attr should survive round-trip (issue #431)
16770        let adf_doc = serde_json::json!({
16771            "type": "doc",
16772            "version": 1,
16773            "content": [{
16774                "type": "mediaSingle",
16775                "attrs": {"layout": "wide", "mode": "wide"},
16776                "content": [{
16777                    "type": "media",
16778                    "attrs": {
16779                        "type": "external",
16780                        "url": "https://example.com/image.png"
16781                    }
16782                }]
16783            }]
16784        });
16785        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16786        let md = adf_to_markdown(&doc).unwrap();
16787        assert!(
16788            md.contains("mode=wide"),
16789            "expected mode=wide in markdown, got: {md}"
16790        );
16791        let doc2 = markdown_to_adf(&md).unwrap();
16792        let ms = &doc2.content[0];
16793        let ms_attrs = ms.attrs.as_ref().unwrap();
16794        assert_eq!(ms_attrs["mode"], "wide");
16795        assert_eq!(ms_attrs["layout"], "wide");
16796    }
16797
16798    #[test]
16799    fn media_mode_only_roundtrip() {
16800        // mediaSingle with mode but default layout should still preserve mode (issue #431)
16801        let adf_doc = serde_json::json!({
16802            "type": "doc",
16803            "version": 1,
16804            "content": [{
16805                "type": "mediaSingle",
16806                "attrs": {"layout": "center", "mode": "default"},
16807                "content": [{
16808                    "type": "media",
16809                    "attrs": {
16810                        "type": "external",
16811                        "url": "https://example.com/image.png"
16812                    }
16813                }]
16814            }]
16815        });
16816        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16817        let md = adf_to_markdown(&doc).unwrap();
16818        assert!(
16819            md.contains("mode=default"),
16820            "expected mode=default in markdown, got: {md}"
16821        );
16822        let doc2 = markdown_to_adf(&md).unwrap();
16823        let ms = &doc2.content[0];
16824        let ms_attrs = ms.attrs.as_ref().unwrap();
16825        assert_eq!(ms_attrs["mode"], "default");
16826    }
16827
16828    #[test]
16829    fn file_media_hex_localid_roundtrip() {
16830        // Issue #432: short hex localId (non-UUID) must survive round-trip
16831        let adf_doc = serde_json::json!({
16832            "type": "doc",
16833            "version": 1,
16834            "content": [{
16835                "type": "mediaSingle",
16836                "attrs": {"layout": "wide", "width": 1200, "widthType": "pixel"},
16837                "content": [{
16838                    "type": "media",
16839                    "attrs": {
16840                        "type": "file",
16841                        "id": "eb7a9c3b-314e-4458-8200-4b22b67b122e",
16842                        "collection": "contentId-123",
16843                        "height": 484,
16844                        "width": 915,
16845                        "alt": "image.png",
16846                        "localId": "0e79f58ac382"
16847                    }
16848                }]
16849            }]
16850        });
16851        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16852        let md = adf_to_markdown(&doc).unwrap();
16853        assert!(
16854            md.contains("localId=0e79f58ac382"),
16855            "expected localId=0e79f58ac382 in markdown, got: {md}"
16856        );
16857        let doc2 = markdown_to_adf(&md).unwrap();
16858        let ms = &doc2.content[0];
16859        let media = &ms.content.as_ref().unwrap()[0];
16860        let attrs = media.attrs.as_ref().unwrap();
16861        assert_eq!(attrs["localId"], "0e79f58ac382");
16862    }
16863
16864    #[test]
16865    fn file_media_uuid_localid_roundtrip() {
16866        // UUID-format localId must also survive round-trip
16867        let adf_doc = serde_json::json!({
16868            "type": "doc",
16869            "version": 1,
16870            "content": [{
16871                "type": "mediaSingle",
16872                "attrs": {"layout": "center"},
16873                "content": [{
16874                    "type": "media",
16875                    "attrs": {
16876                        "type": "file",
16877                        "id": "abc-123",
16878                        "collection": "contentId-456",
16879                        "height": 100,
16880                        "width": 200,
16881                        "localId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
16882                    }
16883                }]
16884            }]
16885        });
16886        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16887        let md = adf_to_markdown(&doc).unwrap();
16888        assert!(
16889            md.contains("localId=a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
16890            "expected UUID localId in markdown, got: {md}"
16891        );
16892        let doc2 = markdown_to_adf(&md).unwrap();
16893        let media = &doc2.content[0].content.as_ref().unwrap()[0];
16894        let attrs = media.attrs.as_ref().unwrap();
16895        assert_eq!(attrs["localId"], "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
16896    }
16897
16898    #[test]
16899    fn file_media_null_uuid_localid_stripped() {
16900        // Null UUID localId should be stripped (consistent with other node types)
16901        let adf_doc = serde_json::json!({
16902            "type": "doc",
16903            "version": 1,
16904            "content": [{
16905                "type": "mediaSingle",
16906                "attrs": {"layout": "center"},
16907                "content": [{
16908                    "type": "media",
16909                    "attrs": {
16910                        "type": "file",
16911                        "id": "abc-123",
16912                        "collection": "contentId-456",
16913                        "height": 100,
16914                        "width": 200,
16915                        "localId": "00000000-0000-0000-0000-000000000000"
16916                    }
16917                }]
16918            }]
16919        });
16920        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16921        let md = adf_to_markdown(&doc).unwrap();
16922        assert!(
16923            !md.contains("localId="),
16924            "null UUID localId should be stripped, got: {md}"
16925        );
16926    }
16927
16928    #[test]
16929    fn file_media_localid_stripped_when_option_set() {
16930        // localId should be stripped when strip_local_ids option is enabled
16931        let adf_doc = serde_json::json!({
16932            "type": "doc",
16933            "version": 1,
16934            "content": [{
16935                "type": "mediaSingle",
16936                "attrs": {"layout": "center"},
16937                "content": [{
16938                    "type": "media",
16939                    "attrs": {
16940                        "type": "file",
16941                        "id": "abc-123",
16942                        "collection": "contentId-456",
16943                        "height": 100,
16944                        "width": 200,
16945                        "localId": "0e79f58ac382"
16946                    }
16947                }]
16948            }]
16949        });
16950        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16951        let opts = RenderOptions {
16952            strip_local_ids: true,
16953            ..Default::default()
16954        };
16955        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16956        assert!(
16957            !md.contains("localId="),
16958            "localId should be stripped with strip_local_ids, got: {md}"
16959        );
16960    }
16961
16962    #[test]
16963    fn external_media_localid_roundtrip() {
16964        // localId on external media nodes must also survive round-trip
16965        let adf_doc = serde_json::json!({
16966            "type": "doc",
16967            "version": 1,
16968            "content": [{
16969                "type": "mediaSingle",
16970                "attrs": {"layout": "center"},
16971                "content": [{
16972                    "type": "media",
16973                    "attrs": {
16974                        "type": "external",
16975                        "url": "https://example.com/image.png",
16976                        "alt": "test",
16977                        "localId": "deadbeef1234"
16978                    }
16979                }]
16980            }]
16981        });
16982        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16983        let md = adf_to_markdown(&doc).unwrap();
16984        assert!(
16985            md.contains("localId=deadbeef1234"),
16986            "expected localId in markdown for external media, got: {md}"
16987        );
16988        let doc2 = markdown_to_adf(&md).unwrap();
16989        let media = &doc2.content[0].content.as_ref().unwrap()[0];
16990        let attrs = media.attrs.as_ref().unwrap();
16991        assert_eq!(attrs["localId"], "deadbeef1234");
16992    }
16993
16994    #[test]
16995    fn bracket_in_text_not_parsed_as_link() {
16996        // "[Task] some text (Link)" — the [Task] must NOT be treated as a link anchor
16997        let md = ":check_mark: [Task] Unable to start trial ([Link](https://example.com/link))";
16998        let doc = markdown_to_adf(md).unwrap();
16999        let para = &doc.content[0];
17000        assert_eq!(para.node_type, "paragraph");
17001        let content = para.content.as_ref().unwrap();
17002        // Find the text node containing "[Task]"
17003        let text_nodes: Vec<_> = content.iter().filter(|n| n.node_type == "text").collect();
17004        let has_task_bracket = text_nodes
17005            .iter()
17006            .any(|n| n.text.as_deref().unwrap_or("").contains("[Task]"));
17007        assert!(
17008            has_task_bracket,
17009            "expected [Task] in plain text, nodes: {content:?}"
17010        );
17011        // Also verify the (Link) is a proper link
17012        let link_nodes: Vec<_> = content
17013            .iter()
17014            .filter(|n| {
17015                n.marks
17016                    .as_ref()
17017                    .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
17018            })
17019            .collect();
17020        assert!(!link_nodes.is_empty(), "expected a link node");
17021        assert_eq!(
17022            link_nodes[0].text.as_deref(),
17023            Some("Link"),
17024            "link text should be 'Link'"
17025        );
17026    }
17027
17028    #[test]
17029    fn empty_paragraph_roundtrip() {
17030        // An empty ADF paragraph node should survive a round-trip through markdown
17031        let mut adf_in = AdfDocument::new();
17032        adf_in.content = vec![
17033            AdfNode::paragraph(vec![AdfNode::text("before")]),
17034            AdfNode::paragraph(vec![]),
17035            AdfNode::paragraph(vec![AdfNode::text("after")]),
17036        ];
17037        let md = adf_to_markdown(&adf_in).unwrap();
17038        let adf_out = markdown_to_adf(&md).unwrap();
17039        assert_eq!(
17040            adf_out.content.len(),
17041            3,
17042            "should have 3 blocks, markdown:\n{md}"
17043        );
17044        assert_eq!(adf_out.content[0].node_type, "paragraph");
17045        assert_eq!(adf_out.content[1].node_type, "paragraph");
17046        assert!(
17047            adf_out.content[1].content.is_none(),
17048            "middle paragraph should be empty"
17049        );
17050        assert_eq!(adf_out.content[2].node_type, "paragraph");
17051    }
17052
17053    #[test]
17054    fn nbsp_paragraph_roundtrip() {
17055        // Issue #411: paragraph with only NBSP should survive round-trip
17056        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}";
17057        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17058        let md = adf_to_markdown(&doc).unwrap();
17059        assert!(
17060            md.contains("::paragraph["),
17061            "NBSP paragraph should use directive form: {md}"
17062        );
17063        let rt = markdown_to_adf(&md).unwrap();
17064        assert_eq!(rt.content.len(), 1, "should have 1 block");
17065        assert_eq!(rt.content[0].node_type, "paragraph");
17066        let text = rt.content[0].content.as_ref().unwrap()[0]
17067            .text
17068            .as_deref()
17069            .unwrap_or("");
17070        assert_eq!(text, "\u{00a0}", "NBSP should survive round-trip");
17071    }
17072
17073    #[test]
17074    fn nbsp_in_nested_expand_roundtrip() {
17075        // Issue #411 real-world case: NBSP paragraph inside nestedExpand
17076        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"nestedExpand\",\"attrs\":{\"title\":\"Section\"},\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}]}";
17077        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17078        let md = adf_to_markdown(&doc).unwrap();
17079        let rt = markdown_to_adf(&md).unwrap();
17080        let ne = &rt.content[0];
17081        assert_eq!(ne.node_type, "nestedExpand");
17082        let inner = ne.content.as_ref().unwrap();
17083        assert_eq!(inner.len(), 1, "should have 1 inner block");
17084        assert_eq!(inner[0].node_type, "paragraph");
17085        let content = inner[0].content.as_ref().unwrap();
17086        assert!(!content.is_empty(), "paragraph should not be empty");
17087        let text = content[0].text.as_deref().unwrap_or("");
17088        assert_eq!(text, "\u{00a0}", "NBSP should survive in nestedExpand");
17089    }
17090
17091    #[test]
17092    fn nbsp_followed_by_content() {
17093        // NBSP paragraph followed by regular content should not interfere
17094        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\"}]}]}";
17095        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17096        let md = adf_to_markdown(&doc).unwrap();
17097        let rt = markdown_to_adf(&md).unwrap();
17098        assert!(rt.content.len() >= 2, "should have at least 2 blocks");
17099        // The second block should be a paragraph with "after"
17100        let after_para = rt.content.iter().find(|n| {
17101            n.node_type == "paragraph"
17102                && n.content
17103                    .as_ref()
17104                    .and_then(|c| c.first())
17105                    .and_then(|n| n.text.as_deref())
17106                    .is_some_and(|t| t.contains("after"))
17107        });
17108        assert!(after_para.is_some(), "should have paragraph with 'after'");
17109    }
17110
17111    #[test]
17112    fn nbsp_paragraph_with_marks_survives() {
17113        // NBSP with bold marks renders as `** **` which contains non-whitespace
17114        // chars and thus doesn't need the directive form — it round-trips naturally
17115        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\",\"marks\":[{\"type\":\"strong\"}]}]}]}";
17116        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17117        let md = adf_to_markdown(&doc).unwrap();
17118        assert!(md.contains("**"), "should have bold markers: {md}");
17119        let rt = markdown_to_adf(&md).unwrap();
17120        let content = rt.content[0].content.as_ref().unwrap();
17121        assert!(!content.is_empty(), "should preserve content");
17122    }
17123
17124    #[test]
17125    fn regular_paragraph_unchanged() {
17126        // Regression guard: normal paragraphs should NOT use directive form
17127        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}"#;
17128        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17129        let md = adf_to_markdown(&doc).unwrap();
17130        assert!(
17131            !md.contains("::paragraph"),
17132            "regular paragraphs should not use directive form: {md}"
17133        );
17134        assert!(md.contains("hello"));
17135    }
17136
17137    #[test]
17138    fn paragraph_directive_with_content_parsed() {
17139        // ::paragraph[content] should parse to a paragraph with inline nodes
17140        let md = "::paragraph[\u{00a0}]\n";
17141        let doc = markdown_to_adf(md).unwrap();
17142        assert_eq!(doc.content.len(), 1);
17143        assert_eq!(doc.content[0].node_type, "paragraph");
17144        let content = doc.content[0].content.as_ref().unwrap();
17145        assert!(!content.is_empty(), "should have inline content");
17146        assert_eq!(content[0].text.as_deref().unwrap(), "\u{00a0}");
17147    }
17148
17149    #[test]
17150    fn nbsp_paragraph_in_list_item_with_nested_list() {
17151        // Issue #448: NBSP paragraph content lost inside listItem with nested bulletList
17152        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"}]}]}]}]}]}]}"#;
17153        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17154        let md = adf_to_markdown(&doc).unwrap();
17155        let rt = markdown_to_adf(&md).unwrap();
17156        let list = &rt.content[0];
17157        assert_eq!(list.node_type, "bulletList");
17158        let item = &list.content.as_ref().unwrap()[0];
17159        let item_content = item.content.as_ref().unwrap();
17160        assert_eq!(
17161            item_content.len(),
17162            2,
17163            "listItem should have paragraph + nested list, got: {item_content:?}"
17164        );
17165        let para = &item_content[0];
17166        assert_eq!(para.node_type, "paragraph");
17167        let para_content = para
17168            .content
17169            .as_ref()
17170            .expect("paragraph should have content");
17171        assert!(
17172            !para_content.is_empty(),
17173            "NBSP paragraph content should not be empty"
17174        );
17175        assert_eq!(
17176            para_content[0].text.as_deref().unwrap(),
17177            "\u{00a0}",
17178            "NBSP should survive round-trip inside listItem"
17179        );
17180    }
17181
17182    #[test]
17183    fn nbsp_paragraph_in_list_item_with_local_ids() {
17184        // Issue #448: NBSP paragraph with localIds inside listItem with nested list
17185        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"}]}]}]}]}]}]}"#;
17186        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17187        let md = adf_to_markdown(&doc).unwrap();
17188        let rt = markdown_to_adf(&md).unwrap();
17189        let list = &rt.content[0];
17190        let item = &list.content.as_ref().unwrap()[0];
17191        // Check listItem localId
17192        assert_eq!(
17193            item.attrs.as_ref().unwrap()["localId"],
17194            "li-001",
17195            "listItem localId should survive"
17196        );
17197        let item_content = item.content.as_ref().unwrap();
17198        assert_eq!(item_content.len(), 2);
17199        // Check paragraph localId and NBSP content
17200        let para = &item_content[0];
17201        assert_eq!(
17202            para.attrs.as_ref().unwrap()["localId"],
17203            "p-001",
17204            "paragraph localId should survive"
17205        );
17206        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
17207        assert_eq!(text, "\u{00a0}", "NBSP should survive with localIds");
17208    }
17209
17210    #[test]
17211    fn nbsp_paragraph_in_list_item_without_nested_list() {
17212        // NBSP paragraph in a simple listItem (no nested list)
17213        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"}]}]}]}]}"#;
17214        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17215        let md = adf_to_markdown(&doc).unwrap();
17216        let rt = markdown_to_adf(&md).unwrap();
17217        let list = &rt.content[0];
17218        let item = &list.content.as_ref().unwrap()[0];
17219        let para = &item.content.as_ref().unwrap()[0];
17220        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
17221        assert_eq!(text, "\u{00a0}", "NBSP should survive in simple list item");
17222    }
17223
17224    #[test]
17225    fn nbsp_paragraph_in_ordered_list_item_with_nested_list() {
17226        // NBSP paragraph in ordered listItem with nested bulletList
17227        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"}]}]}]}]}]}]}"#;
17228        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17229        let md = adf_to_markdown(&doc).unwrap();
17230        let rt = markdown_to_adf(&md).unwrap();
17231        let list = &rt.content[0];
17232        let item = &list.content.as_ref().unwrap()[0];
17233        let item_content = item.content.as_ref().unwrap();
17234        assert_eq!(item_content.len(), 2);
17235        let para = &item_content[0];
17236        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
17237        assert_eq!(text, "\u{00a0}", "NBSP should survive in ordered list item");
17238    }
17239
17240    #[test]
17241    fn list_item_leading_space_preserved() {
17242        // Leading space in list item text must not be stripped
17243        let md = "- hello world\n- - text";
17244        let doc = markdown_to_adf(md).unwrap();
17245        let list = &doc.content[0];
17246        assert_eq!(list.node_type, "bulletList");
17247        let items = list.content.as_ref().unwrap();
17248        // First item: "hello world" (no leading space, unchanged)
17249        let first_para = &items[0].content.as_ref().unwrap()[0];
17250        let first_text = &first_para.content.as_ref().unwrap()[0];
17251        assert_eq!(first_text.text.as_deref(), Some("hello world"));
17252    }
17253
17254    #[test]
17255    fn list_item_leading_space_not_stripped() {
17256        // When the markdown list item content has a leading space (e.g. " :emoji:"),
17257        // that space must reach parse_inline as-is.
17258        let md = "-  leading space text";
17259        let doc = markdown_to_adf(md).unwrap();
17260        let list = &doc.content[0];
17261        let items = list.content.as_ref().unwrap();
17262        let para = &items[0].content.as_ref().unwrap()[0];
17263        let text_node = &para.content.as_ref().unwrap()[0];
17264        // After "- " (2 chars), trim_end keeps the leading space: " leading space text"
17265        assert_eq!(
17266            text_node.text.as_deref(),
17267            Some(" leading space text"),
17268            "leading space should be preserved"
17269        );
17270    }
17271
17272    // ── Nested container directive tests ───────────────────────────
17273
17274    // ── hardBreak in table cell tests ────────────────────────────
17275
17276    #[test]
17277    fn hardbreak_in_cell_uses_directive_table() {
17278        // A table cell with a hardBreak should NOT use pipe syntax
17279        // because the newline would break the row
17280        let adf = AdfDocument {
17281            version: 1,
17282            doc_type: "doc".to_string(),
17283            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17284                AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17285                    AdfNode::text("before"),
17286                    AdfNode::hard_break(),
17287                    AdfNode::text("after"),
17288                ])]),
17289            ])])],
17290        };
17291        let md = adf_to_markdown(&adf).unwrap();
17292        // Should render as directive table, not pipe table
17293        assert!(
17294            md.contains(":::td") || md.contains("::::table"),
17295            "Table with hardBreak should use directive form, got:\n{md}"
17296        );
17297        assert!(
17298            !md.contains("| before"),
17299            "Should NOT use pipe syntax with hardBreak"
17300        );
17301    }
17302
17303    #[test]
17304    fn hardbreak_in_cell_roundtrips() {
17305        // Verify the directive table form preserves the hardBreak on round-trip
17306        let adf = AdfDocument {
17307            version: 1,
17308            doc_type: "doc".to_string(),
17309            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17310                AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17311                    AdfNode::text("line one"),
17312                    AdfNode::hard_break(),
17313                    AdfNode::text("line two"),
17314                ])]),
17315            ])])],
17316        };
17317        let md = adf_to_markdown(&adf).unwrap();
17318        let roundtripped = markdown_to_adf(&md).unwrap();
17319
17320        // Should still have one table with one row with one cell
17321        assert_eq!(roundtripped.content.len(), 1);
17322        assert_eq!(roundtripped.content[0].node_type, "table");
17323        let rows = roundtripped.content[0].content.as_ref().unwrap();
17324        assert_eq!(
17325            rows.len(),
17326            1,
17327            "Should have exactly 1 row, got {}",
17328            rows.len()
17329        );
17330    }
17331
17332    #[test]
17333    fn hardbreak_in_paragraph_roundtrips() {
17334        // Issue #373: hardBreak absorbed into preceding text node
17335        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17336          {"type":"text","text":"line one"},
17337          {"type":"hardBreak"},
17338          {"type":"text","text":"line two"}
17339        ]}]}"#;
17340        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17341        let md = adf_to_markdown(&doc).unwrap();
17342        let round_tripped = markdown_to_adf(&md).unwrap();
17343        let inlines = round_tripped.content[0].content.as_ref().unwrap();
17344        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17345        assert_eq!(
17346            types,
17347            vec!["text", "hardBreak", "text"],
17348            "hardBreak should be preserved, got: {types:?}"
17349        );
17350        assert_eq!(inlines[0].text.as_deref(), Some("line one"));
17351        assert_eq!(inlines[2].text.as_deref(), Some("line two"));
17352    }
17353
17354    #[test]
17355    fn consecutive_hardbreaks_in_paragraph_roundtrip() {
17356        // Issue #410: consecutive hardBreak nodes collapsed on round-trip
17357        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17358          {"type":"text","text":"before"},
17359          {"type":"hardBreak"},
17360          {"type":"hardBreak"},
17361          {"type":"text","text":"after"}
17362        ]}]}"#;
17363        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17364        let md = adf_to_markdown(&doc).unwrap();
17365        let round_tripped = markdown_to_adf(&md).unwrap();
17366        assert_eq!(
17367            round_tripped.content.len(),
17368            1,
17369            "Should remain a single paragraph, got {} blocks",
17370            round_tripped.content.len()
17371        );
17372        let inlines = round_tripped.content[0].content.as_ref().unwrap();
17373        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17374        assert_eq!(
17375            types,
17376            vec!["text", "hardBreak", "hardBreak", "text"],
17377            "Both hardBreaks should be preserved, got: {types:?}"
17378        );
17379        assert_eq!(inlines[0].text.as_deref(), Some("before"));
17380        assert_eq!(inlines[3].text.as_deref(), Some("after"));
17381    }
17382
17383    #[test]
17384    fn hardbreak_only_paragraph_roundtrips() {
17385        // Issue #410: paragraph whose only content is a hardBreak is dropped
17386        let adf_json = r#"{"version":1,"type":"doc","content":[
17387          {"type":"paragraph","content":[{"type":"hardBreak"}]}
17388        ]}"#;
17389        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17390        let md = adf_to_markdown(&doc).unwrap();
17391        let round_tripped = markdown_to_adf(&md).unwrap();
17392        assert_eq!(
17393            round_tripped.content.len(),
17394            1,
17395            "Paragraph should not be dropped, got {} blocks",
17396            round_tripped.content.len()
17397        );
17398        let inlines = round_tripped.content[0].content.as_ref().unwrap();
17399        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17400        assert_eq!(
17401            types,
17402            vec!["hardBreak"],
17403            "hardBreak-only paragraph should preserve its content, got: {types:?}"
17404        );
17405    }
17406
17407    #[test]
17408    fn issue_410_full_reproducer_roundtrips() {
17409        // Full reproducer from issue #410: consecutive hardBreaks + hardBreak-only paragraph
17410        let adf_json = r#"{"version":1,"type":"doc","content":[
17411          {"type":"paragraph","content":[
17412            {"type":"text","text":"before"},
17413            {"type":"hardBreak"},
17414            {"type":"hardBreak"},
17415            {"type":"text","text":"after"}
17416          ]},
17417          {"type":"paragraph","content":[
17418            {"type":"hardBreak"}
17419          ]}
17420        ]}"#;
17421        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17422        let md = adf_to_markdown(&doc).unwrap();
17423        let round_tripped = markdown_to_adf(&md).unwrap();
17424        assert_eq!(
17425            round_tripped.content.len(),
17426            2,
17427            "Should have exactly 2 paragraphs, got {}",
17428            round_tripped.content.len()
17429        );
17430        // First paragraph: text, hardBreak, hardBreak, text
17431        let p1 = round_tripped.content[0].content.as_ref().unwrap();
17432        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
17433        assert_eq!(types1, vec!["text", "hardBreak", "hardBreak", "text"]);
17434        // Second paragraph: hardBreak only
17435        let p2 = round_tripped.content[1].content.as_ref().unwrap();
17436        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
17437        assert_eq!(types2, vec!["hardBreak"]);
17438    }
17439
17440    #[test]
17441    fn trailing_space_hardbreak_still_parsed() {
17442        // Backward compatibility: trailing-space hardBreak (old JFM format) still parses
17443        let md = "line one  \nline two\n";
17444        let doc = markdown_to_adf(md).unwrap();
17445        let inlines = doc.content[0].content.as_ref().unwrap();
17446        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17447        assert_eq!(
17448            types,
17449            vec!["text", "hardBreak", "text"],
17450            "Trailing-space hardBreak should still parse, got: {types:?}"
17451        );
17452    }
17453
17454    #[test]
17455    fn trailing_hardbreak_at_end_of_paragraph_roundtrips() {
17456        // A paragraph ending with a hardBreak (no text after it)
17457        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17458          {"type":"text","text":"text"},
17459          {"type":"hardBreak"}
17460        ]}]}"#;
17461        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17462        let md = adf_to_markdown(&doc).unwrap();
17463        let round_tripped = markdown_to_adf(&md).unwrap();
17464        let inlines = round_tripped.content[0].content.as_ref().unwrap();
17465        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17466        assert_eq!(
17467            types,
17468            vec!["text", "hardBreak"],
17469            "Trailing hardBreak should be preserved, got: {types:?}"
17470        );
17471    }
17472
17473    #[test]
17474    #[test]
17475    fn table_with_header_row_uses_pipe_syntax() {
17476        // A table with tableHeader in the first row should use pipe syntax
17477        let adf = AdfDocument {
17478            version: 1,
17479            doc_type: "doc".to_string(),
17480            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17481                AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("header cell")])]),
17482            ])])],
17483        };
17484        let md = adf_to_markdown(&adf).unwrap();
17485        assert!(
17486            md.contains("| header cell |"),
17487            "Table with header row should use pipe syntax, got:\n{md}"
17488        );
17489    }
17490
17491    #[test]
17492    fn table_without_header_row_uses_directive_syntax() {
17493        // Issue #392: tableCell-only first row must use directive syntax
17494        // to avoid converting tableCell → tableHeader on round-trip
17495        let adf = AdfDocument {
17496            version: 1,
17497            doc_type: "doc".to_string(),
17498            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17499                AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("simple cell")])]),
17500            ])])],
17501        };
17502        let md = adf_to_markdown(&adf).unwrap();
17503        assert!(
17504            md.contains("::::table"),
17505            "Table without header row should use directive syntax, got:\n{md}"
17506        );
17507    }
17508
17509    #[test]
17510    fn tablecell_first_row_preserved_on_roundtrip() {
17511        // Issue #392: tableCell in first row round-trips as tableHeader
17512        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{},"content":[
17513          {"type":"tableRow","content":[
17514            {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row1 cell"}]}]}
17515          ]},
17516          {"type":"tableRow","content":[
17517            {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row2 cell"}]}]}
17518          ]}
17519        ]}]}"#;
17520        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17521        let md = adf_to_markdown(&doc).unwrap();
17522        let round_tripped = markdown_to_adf(&md).unwrap();
17523        let rows = round_tripped.content[0].content.as_ref().unwrap();
17524        let row0_cell = &rows[0].content.as_ref().unwrap()[0];
17525        assert_eq!(
17526            row0_cell.node_type, "tableCell",
17527            "first row cell should remain tableCell, got: {}",
17528            row0_cell.node_type
17529        );
17530        let row1_cell = &rows[1].content.as_ref().unwrap()[0];
17531        assert_eq!(row1_cell.node_type, "tableCell");
17532    }
17533
17534    #[test]
17535    fn mixed_header_and_cell_first_row_uses_pipe() {
17536        // A first row with at least one tableHeader qualifies for pipe syntax
17537        let adf = AdfDocument {
17538            version: 1,
17539            doc_type: "doc".to_string(),
17540            content: vec![AdfNode::table(vec![
17541                AdfNode::table_row(vec![
17542                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17543                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17544                ]),
17545                AdfNode::table_row(vec![
17546                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
17547                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
17548                ]),
17549            ])],
17550        };
17551        let md = adf_to_markdown(&adf).unwrap();
17552        assert!(
17553            md.contains("| H1 |"),
17554            "Table with header first row should use pipe syntax, got:\n{md}"
17555        );
17556        assert!(!md.contains("::::table"), "should not use directive syntax");
17557    }
17558
17559    // ── Issue #579: pipes in pipe-table cells ─────────────────────
17560
17561    #[test]
17562    fn render_pipe_table_escapes_pipe_in_code_span_cell() {
17563        // A code-marked text node with a literal `|` in a pipe-table cell
17564        // must emit `\|` so the column separator is unambiguous.
17565        let adf = AdfDocument {
17566            version: 1,
17567            doc_type: "doc".to_string(),
17568            content: vec![AdfNode::table(vec![
17569                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17570                    AdfNode::text("Header"),
17571                ])])]),
17572                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17573                    AdfNode::text_with_marks("a|b", vec![AdfMark::code()]),
17574                ])])]),
17575            ])],
17576        };
17577        let md = adf_to_markdown(&adf).unwrap();
17578        assert!(
17579            md.contains(r"`a\|b`"),
17580            "Pipe inside code span must be escaped, got:\n{md}"
17581        );
17582    }
17583
17584    #[test]
17585    fn render_pipe_table_escapes_pipe_in_plain_text_cell() {
17586        let adf = AdfDocument {
17587            version: 1,
17588            doc_type: "doc".to_string(),
17589            content: vec![AdfNode::table(vec![
17590                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17591                    AdfNode::text("Header"),
17592                ])])]),
17593                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17594                    AdfNode::text("x|y"),
17595                ])])]),
17596            ])],
17597        };
17598        let md = adf_to_markdown(&adf).unwrap();
17599        assert!(
17600            md.contains(r"x\|y"),
17601            "Pipe inside plain-text cell must be escaped, got:\n{md}"
17602        );
17603    }
17604
17605    #[test]
17606    fn code_span_with_pipe_in_table_cell_roundtrips() {
17607        // Issue #579 reproducer: code span containing `|` in a pipe-table cell.
17608        let adf_json = r#"{
17609            "version": 1,
17610            "type": "doc",
17611            "content": [{
17612                "type": "table",
17613                "attrs": {"isNumberColumnEnabled": false, "layout": "default", "localId": "abc-789"},
17614                "content": [
17615                    {"type": "tableRow", "content": [
17616                        {"type": "tableHeader", "attrs": {}, "content": [
17617                            {"type": "paragraph", "content": [{"type": "text", "text": "Before"}]}
17618                        ]},
17619                        {"type": "tableHeader", "attrs": {}, "content": [
17620                            {"type": "paragraph", "content": [{"type": "text", "text": "After"}]}
17621                        ]}
17622                    ]},
17623                    {"type": "tableRow", "content": [
17624                        {"type": "tableCell", "attrs": {}, "content": [
17625                            {"type": "paragraph", "content": [
17626                                {"type": "text", "text": "parse(json).extract[T]", "marks": [{"type": "code"}]}
17627                            ]}
17628                        ]},
17629                        {"type": "tableCell", "attrs": {}, "content": [
17630                            {"type": "paragraph", "content": [
17631                                {"type": "text", "text": "parser.decode[T|json]", "marks": [{"type": "code"}]}
17632                            ]}
17633                        ]}
17634                    ]}
17635                ]
17636            }]
17637        }"#;
17638        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17639        let md = adf_to_markdown(&doc).unwrap();
17640        let round_tripped = markdown_to_adf(&md).unwrap();
17641
17642        let rows = round_tripped.content[0].content.as_ref().unwrap();
17643        assert_eq!(
17644            rows.len(),
17645            2,
17646            "Table should have 2 rows, got: {}",
17647            rows.len()
17648        );
17649
17650        let body_row = rows[1].content.as_ref().unwrap();
17651        assert_eq!(
17652            body_row.len(),
17653            2,
17654            "Body row should have 2 cells (not split by the pipe), got: {}",
17655            body_row.len()
17656        );
17657
17658        let second_cell = &body_row[1];
17659        let para = second_cell.content.as_ref().unwrap().first().unwrap();
17660        let inlines = para.content.as_ref().unwrap();
17661        assert_eq!(inlines.len(), 1, "Cell should have a single text node");
17662        assert_eq!(
17663            inlines[0].text.as_deref(),
17664            Some("parser.decode[T|json]"),
17665            "Code-span text must be preserved with literal pipe"
17666        );
17667        let marks = inlines[0]
17668            .marks
17669            .as_ref()
17670            .expect("code mark must be preserved");
17671        assert!(
17672            marks.iter().any(|m| m.mark_type == "code"),
17673            "text node should carry the code mark"
17674        );
17675    }
17676
17677    #[test]
17678    fn plain_text_pipe_in_table_cell_roundtrips() {
17679        // Plain text with `|` in a pipe-table cell should also survive.
17680        let adf = AdfDocument {
17681            version: 1,
17682            doc_type: "doc".to_string(),
17683            content: vec![AdfNode::table(vec![
17684                AdfNode::table_row(vec![
17685                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17686                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17687                ]),
17688                AdfNode::table_row(vec![
17689                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a|b")])]),
17690                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("c")])]),
17691                ]),
17692            ])],
17693        };
17694        let md = adf_to_markdown(&adf).unwrap();
17695        let round_tripped = markdown_to_adf(&md).unwrap();
17696        let rows = round_tripped.content[0].content.as_ref().unwrap();
17697        let body_row = rows[1].content.as_ref().unwrap();
17698        assert_eq!(
17699            body_row.len(),
17700            2,
17701            "Body row should keep 2 cells, got: {}",
17702            body_row.len()
17703        );
17704        let first_cell_text = body_row[0].content.as_ref().unwrap()[0]
17705            .content
17706            .as_ref()
17707            .unwrap()[0]
17708            .text
17709            .as_deref();
17710        assert_eq!(first_cell_text, Some("a|b"));
17711    }
17712
17713    #[test]
17714    fn cell_contains_hard_break_true() {
17715        let para = AdfNode::paragraph(vec![
17716            AdfNode::text("a"),
17717            AdfNode::hard_break(),
17718            AdfNode::text("b"),
17719        ]);
17720        assert!(cell_contains_hard_break(&para));
17721    }
17722
17723    #[test]
17724    fn cell_contains_hard_break_false() {
17725        let para = AdfNode::paragraph(vec![AdfNode::text("no break here")]);
17726        assert!(!cell_contains_hard_break(&para));
17727    }
17728
17729    #[test]
17730    fn cell_contains_hard_break_empty() {
17731        let para = AdfNode::paragraph(vec![]);
17732        assert!(!cell_contains_hard_break(&para));
17733    }
17734
17735    // ── Multi-paragraph container tests ──────────────────────────
17736
17737    #[test]
17738    fn multi_paragraph_panel_roundtrips() {
17739        let adf = AdfDocument {
17740            version: 1,
17741            doc_type: "doc".to_string(),
17742            content: vec![AdfNode {
17743                node_type: "panel".to_string(),
17744                attrs: Some(serde_json::json!({"panelType": "info"})),
17745                content: Some(vec![
17746                    AdfNode::paragraph(vec![AdfNode::text("First paragraph.")]),
17747                    AdfNode::paragraph(vec![AdfNode::text("Second paragraph.")]),
17748                ]),
17749                text: None,
17750                marks: None,
17751                local_id: None,
17752                parameters: None,
17753            }],
17754        };
17755
17756        let md = adf_to_markdown(&adf).unwrap();
17757        // Should have blank line between paragraphs inside the panel
17758        assert!(
17759            md.contains("First paragraph.\n\nSecond paragraph."),
17760            "Panel should have blank line between paragraphs, got:\n{md}"
17761        );
17762
17763        // Round-trip should preserve two separate paragraphs
17764        let roundtripped = markdown_to_adf(&md).unwrap();
17765        assert_eq!(roundtripped.content.len(), 1);
17766        assert_eq!(roundtripped.content[0].node_type, "panel");
17767        let panel_content = roundtripped.content[0].content.as_ref().unwrap();
17768        assert_eq!(
17769            panel_content.len(),
17770            2,
17771            "Panel should have 2 paragraphs after round-trip, got {}",
17772            panel_content.len()
17773        );
17774    }
17775
17776    #[test]
17777    fn multi_paragraph_expand_roundtrips() {
17778        let adf = AdfDocument {
17779            version: 1,
17780            doc_type: "doc".to_string(),
17781            content: vec![AdfNode {
17782                node_type: "expand".to_string(),
17783                attrs: Some(serde_json::json!({"title": "Details"})),
17784                content: Some(vec![
17785                    AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17786                    AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17787                ]),
17788                text: None,
17789                marks: None,
17790                local_id: None,
17791                parameters: None,
17792            }],
17793        };
17794
17795        let md = adf_to_markdown(&adf).unwrap();
17796        let roundtripped = markdown_to_adf(&md).unwrap();
17797        let expand_content = roundtripped.content[0].content.as_ref().unwrap();
17798        assert_eq!(
17799            expand_content.len(),
17800            2,
17801            "Expand should have 2 paragraphs after round-trip, got {}",
17802            expand_content.len()
17803        );
17804    }
17805
17806    #[test]
17807    fn consecutive_nested_expands_in_table_cell_roundtrip() {
17808        let cell_content = vec![
17809            AdfNode {
17810                node_type: "nestedExpand".to_string(),
17811                attrs: Some(serde_json::json!({"title": "First"})),
17812                content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 1")])]),
17813                text: None,
17814                marks: None,
17815                local_id: None,
17816                parameters: None,
17817            },
17818            AdfNode {
17819                node_type: "nestedExpand".to_string(),
17820                attrs: Some(serde_json::json!({"title": "Second"})),
17821                content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 2")])]),
17822                text: None,
17823                marks: None,
17824                local_id: None,
17825                parameters: None,
17826            },
17827        ];
17828        let adf = AdfDocument {
17829            version: 1,
17830            doc_type: "doc".to_string(),
17831            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17832                AdfNode::table_cell(cell_content),
17833            ])])],
17834        };
17835
17836        let md = adf_to_markdown(&adf).unwrap();
17837        assert!(
17838            md.contains(":::\n\n:::nested-expand"),
17839            "Should have blank line between consecutive nested-expands in cell, got:\n{md}"
17840        );
17841
17842        let rt = markdown_to_adf(&md).unwrap();
17843        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17844            .content
17845            .as_ref()
17846            .unwrap()[0];
17847        let cell_nodes = cell.content.as_ref().unwrap();
17848        let expand_count = cell_nodes
17849            .iter()
17850            .filter(|n| n.node_type == "nestedExpand")
17851            .count();
17852        assert_eq!(
17853            expand_count, 2,
17854            "Both nested-expands should survive round-trip, got {expand_count}"
17855        );
17856    }
17857
17858    #[test]
17859    fn multi_paragraph_in_table_cell_roundtrip() {
17860        // Two paragraphs inside a directive table cell should survive round-trip
17861        let adf = AdfDocument {
17862            version: 1,
17863            doc_type: "doc".to_string(),
17864            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17865                AdfNode::table_cell(vec![
17866                    AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17867                    AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17868                ]),
17869            ])])],
17870        };
17871
17872        let md = adf_to_markdown(&adf).unwrap();
17873        assert!(
17874            md.contains("Para one.\n\nPara two."),
17875            "Should have blank line between paragraphs in cell, got:\n{md}"
17876        );
17877
17878        let rt = markdown_to_adf(&md).unwrap();
17879        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17880            .content
17881            .as_ref()
17882            .unwrap()[0];
17883        let para_count = cell
17884            .content
17885            .as_ref()
17886            .unwrap()
17887            .iter()
17888            .filter(|n| n.node_type == "paragraph")
17889            .count();
17890        assert_eq!(para_count, 2, "Both paragraphs should survive round-trip");
17891    }
17892
17893    #[test]
17894    fn panel_inside_table_cell_roundtrip() {
17895        // A panel inside a directive table cell
17896        let adf = AdfDocument {
17897            version: 1,
17898            doc_type: "doc".to_string(),
17899            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17900                AdfNode::table_cell(vec![
17901                    AdfNode::paragraph(vec![AdfNode::text("Before panel.")]),
17902                    AdfNode {
17903                        node_type: "panel".to_string(),
17904                        attrs: Some(serde_json::json!({"panelType": "info"})),
17905                        content: Some(vec![AdfNode::paragraph(vec![AdfNode::text(
17906                            "Panel content",
17907                        )])]),
17908                        text: None,
17909                        marks: None,
17910                        local_id: None,
17911                        parameters: None,
17912                    },
17913                ]),
17914            ])])],
17915        };
17916
17917        let md = adf_to_markdown(&adf).unwrap();
17918        assert!(
17919            md.contains(":::panel"),
17920            "Should contain panel directive, got:\n{md}"
17921        );
17922
17923        let rt = markdown_to_adf(&md).unwrap();
17924        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17925            .content
17926            .as_ref()
17927            .unwrap()[0];
17928        let has_panel = cell
17929            .content
17930            .as_ref()
17931            .unwrap()
17932            .iter()
17933            .any(|n| n.node_type == "panel");
17934        assert!(has_panel, "Panel should survive round-trip in table cell");
17935    }
17936
17937    #[test]
17938    fn three_consecutive_expands_in_table_cell() {
17939        let make_expand = |title: &str| AdfNode {
17940            node_type: "nestedExpand".to_string(),
17941            attrs: Some(serde_json::json!({"title": title})),
17942            content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("content")])]),
17943            text: None,
17944            marks: None,
17945            local_id: None,
17946            parameters: None,
17947        };
17948        let adf = AdfDocument {
17949            version: 1,
17950            doc_type: "doc".to_string(),
17951            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17952                AdfNode::table_cell(vec![
17953                    make_expand("First"),
17954                    make_expand("Second"),
17955                    make_expand("Third"),
17956                ]),
17957            ])])],
17958        };
17959
17960        let md = adf_to_markdown(&adf).unwrap();
17961        let rt = markdown_to_adf(&md).unwrap();
17962        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17963            .content
17964            .as_ref()
17965            .unwrap()[0];
17966        let expand_count = cell
17967            .content
17968            .as_ref()
17969            .unwrap()
17970            .iter()
17971            .filter(|n| n.node_type == "nestedExpand")
17972            .count();
17973        assert_eq!(expand_count, 3, "All 3 expands should survive round-trip");
17974    }
17975
17976    // ── Nested container directive tests ───────────────────────────
17977
17978    #[test]
17979    fn nested_expand_inside_panel() {
17980        // Issue #714: the converter still produces panel→expand at the AST
17981        // level (this is what users may type), but the document fails ADF
17982        // schema validation, surfacing an actionable error before the API
17983        // call rather than an opaque Confluence HTTP 500.
17984        let md = ":::panel{type=info}\n:::expand{title=\"Details\"}\nHidden content\n:::\nMore panel content\n:::";
17985        let adf = markdown_to_adf(md).unwrap();
17986
17987        let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
17988        assert!(err.violations.iter().any(|v| matches!(
17989            v,
17990            crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
17991                parent_type, child_type, ..
17992            } if parent_type == "panel" && child_type == "expand"
17993        )));
17994    }
17995
17996    #[test]
17997    fn nested_expand_inside_table_cell() {
17998        // Issue #714: tableCell → expand is a Confluence content-model
17999        // violation. Table cells require `nestedExpand` instead. Validation
18000        // catches this before the API call.
18001        let md = "::::table\n:::tr\n:::td\n:::expand{title=\"Details\"}\nExpand content\n:::\n:::\n:::\n::::";
18002        let adf = markdown_to_adf(md).unwrap();
18003
18004        let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
18005        assert!(err.violations.iter().any(|v| matches!(
18006            v,
18007            crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
18008                parent_type, child_type, ..
18009            } if parent_type == "tableCell" && child_type == "expand"
18010        )));
18011    }
18012
18013    #[test]
18014    fn nested_expand_inside_layout_column() {
18015        // Issue #714 sanity check: `expand` inside a `layoutColumn` is
18016        // legitimate per the ADF schema and must NOT trigger validation.
18017        // Note: layoutSection requires 2..=3 columns per #733's quantifier
18018        // checks, so the markdown declares two columns.
18019        let md = ":::layout\n:::column{width=50}\n:::expand{title=\"Col Expand\"}\nExpanded\n:::\n:::\n:::column{width=50}\nFiller paragraph.\n:::\n:::";
18020        let adf = markdown_to_adf(md).unwrap();
18021
18022        assert_eq!(adf.content.len(), 1);
18023        assert_eq!(adf.content[0].node_type, "layoutSection");
18024
18025        let columns = adf.content[0].content.as_ref().unwrap();
18026        assert_eq!(columns.len(), 2);
18027        let col_content = columns[0].content.as_ref().unwrap();
18028        assert!(
18029            col_content.iter().any(|n| n.node_type == "expand"),
18030            "Column should contain an expand node, got: {:?}",
18031            col_content.iter().map(|n| &n.node_type).collect::<Vec<_>>()
18032        );
18033
18034        // Validation must not flag this legitimate nesting.
18035        crate::atlassian::adf_validated::validate(&adf).unwrap();
18036    }
18037
18038    #[test]
18039    fn expand_localid_in_directive_attrs() {
18040        // Issue #412: localId should be in directive attrs, not trailing text
18041        let adf_json = r#"{"version":1,"type":"doc","content":[
18042          {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
18043            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
18044          ]}
18045        ]}"#;
18046        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18047        let md = adf_to_markdown(&doc).unwrap();
18048        assert!(
18049            md.contains("localId=exp-001"),
18050            "should contain localId: {md}"
18051        );
18052        assert!(
18053            md.contains(":::expand{"),
18054            "should have expand directive with attrs: {md}"
18055        );
18056        assert!(
18057            !md.contains(":::\n{localId="),
18058            "localId should NOT be trailing: {md}"
18059        );
18060    }
18061
18062    #[test]
18063    fn expand_localid_roundtrip() {
18064        let adf_json = r#"{"version":1,"type":"doc","content":[
18065          {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
18066            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
18067          ]}
18068        ]}"#;
18069        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18070        let md = adf_to_markdown(&doc).unwrap();
18071        let rt = markdown_to_adf(&md).unwrap();
18072        let expand = &rt.content[0];
18073        assert_eq!(expand.node_type, "expand");
18074        assert_eq!(
18075            expand.local_id.as_deref(),
18076            Some("exp-001"),
18077            "expand localId should survive round-trip"
18078        );
18079        assert_eq!(
18080            expand.attrs.as_ref().unwrap()["title"],
18081            "Details",
18082            "expand title should survive round-trip"
18083        );
18084    }
18085
18086    #[test]
18087    fn nested_expand_localid_roundtrip() {
18088        let adf_json = r#"{"version":1,"type":"doc","content":[
18089          {"type":"nestedExpand","attrs":{"localId":"ne-001","title":"S"},"content":[
18090            {"type":"paragraph","content":[{"type":"text","text":"content"}]}
18091          ]}
18092        ]}"#;
18093        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18094        let md = adf_to_markdown(&doc).unwrap();
18095        assert!(
18096            md.contains(":::nested-expand{"),
18097            "should have directive: {md}"
18098        );
18099        assert!(md.contains("localId=ne-001"), "should have localId: {md}");
18100        let rt = markdown_to_adf(&md).unwrap();
18101        let ne = &rt.content[0];
18102        assert_eq!(ne.node_type, "nestedExpand");
18103        assert_eq!(ne.local_id.as_deref(), Some("ne-001"));
18104    }
18105
18106    #[test]
18107    fn nested_expand_localid_followed_by_content() {
18108        // Issue #412 reproducer: localId must not leak into following paragraph
18109        let adf_json = "{\
18110            \"version\":1,\"type\":\"doc\",\"content\":[\
18111              {\"type\":\"nestedExpand\",\"attrs\":{\"localId\":\"exp-001\",\"title\":\"S\"},\"content\":[\
18112                {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}\
18113              ]},\
18114              {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]}\
18115            ]}";
18116        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18117        let md = adf_to_markdown(&doc).unwrap();
18118        let rt = markdown_to_adf(&md).unwrap();
18119        // nestedExpand should have localId
18120        let ne = &rt.content[0];
18121        assert_eq!(ne.node_type, "nestedExpand");
18122        assert_eq!(
18123            ne.local_id.as_deref(),
18124            Some("exp-001"),
18125            "nestedExpand should preserve localId"
18126        );
18127        // Following paragraph should contain "after", not "{localId=...}"
18128        let para = &rt.content[1];
18129        assert_eq!(para.node_type, "paragraph");
18130        let text = para.content.as_ref().unwrap()[0]
18131            .text
18132            .as_deref()
18133            .unwrap_or("");
18134        assert!(
18135            !text.contains("localId"),
18136            "following paragraph should not contain localId: {text}"
18137        );
18138        assert!(
18139            text.contains("after"),
18140            "following paragraph should contain 'after': {text}"
18141        );
18142    }
18143
18144    #[test]
18145    fn expand_localid_without_title() {
18146        let adf_json = r#"{"version":1,"type":"doc","content":[
18147          {"type":"expand","attrs":{"localId":"exp-002"},"content":[
18148            {"type":"paragraph","content":[{"type":"text","text":"no title"}]}
18149          ]}
18150        ]}"#;
18151        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18152        let md = adf_to_markdown(&doc).unwrap();
18153        assert!(
18154            md.contains(":::expand{localId=exp-002}"),
18155            "should have localId without title: {md}"
18156        );
18157        let rt = markdown_to_adf(&md).unwrap();
18158        assert_eq!(rt.content[0].local_id.as_deref(), Some("exp-002"));
18159    }
18160
18161    #[test]
18162    fn expand_localid_stripped() {
18163        let adf_json = r#"{"version":1,"type":"doc","content":[
18164          {"type":"expand","attrs":{"localId":"exp-001","title":"X"},"content":[
18165            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
18166          ]}
18167        ]}"#;
18168        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18169        let opts = RenderOptions {
18170            strip_local_ids: true,
18171        };
18172        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18173        assert!(!md.contains("localId"), "localId should be stripped: {md}");
18174        assert!(
18175            md.contains(":::expand{title=\"X\"}"),
18176            "title should remain: {md}"
18177        );
18178    }
18179
18180    // ── Issue #444: top-level localId and parameters on expand ──
18181
18182    #[test]
18183    fn expand_top_level_localid_roundtrip() {
18184        // localId as a top-level field (not inside attrs) should survive round-trip
18185        let adf_json = r#"{"version":1,"type":"doc","content":[
18186          {"type":"expand","attrs":{"title":"My Section"},"localId":"abc-123","content":[
18187            {"type":"paragraph","content":[{"type":"text","text":"hello"}]}
18188          ]}
18189        ]}"#;
18190        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18191        assert_eq!(doc.content[0].local_id.as_deref(), Some("abc-123"));
18192        let md = adf_to_markdown(&doc).unwrap();
18193        assert!(
18194            md.contains("localId=abc-123"),
18195            "JFM should contain localId: {md}"
18196        );
18197        let rt = markdown_to_adf(&md).unwrap();
18198        let expand = &rt.content[0];
18199        assert_eq!(expand.node_type, "expand");
18200        assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
18201        assert_eq!(
18202            expand.attrs.as_ref().unwrap()["title"],
18203            "My Section",
18204            "title should survive round-trip"
18205        );
18206    }
18207
18208    #[test]
18209    fn expand_parameters_roundtrip() {
18210        // parameters (macroMetadata) should survive round-trip
18211        let adf_json = r#"{"version":1,"type":"doc","content":[
18212          {"type":"expand","attrs":{"title":"Props"},"parameters":{"macroMetadata":{"macroId":{"value":"m-001"},"schemaVersion":{"value":"1"}}},"content":[
18213            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
18214          ]}
18215        ]}"#;
18216        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18217        assert!(doc.content[0].parameters.is_some());
18218        let md = adf_to_markdown(&doc).unwrap();
18219        assert!(md.contains("params="), "JFM should contain params: {md}");
18220        let rt = markdown_to_adf(&md).unwrap();
18221        let expand = &rt.content[0];
18222        let params = expand
18223            .parameters
18224            .as_ref()
18225            .expect("parameters should survive round-trip");
18226        assert_eq!(params["macroMetadata"]["macroId"]["value"], "m-001");
18227        assert_eq!(params["macroMetadata"]["schemaVersion"]["value"], "1");
18228    }
18229
18230    #[test]
18231    fn expand_localid_and_parameters_roundtrip() {
18232        // Issue #444: both localId and parameters on expand should survive round-trip
18233        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"}]}]}]}"#;
18234        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18235        let md = adf_to_markdown(&doc).unwrap();
18236        let rt = markdown_to_adf(&md).unwrap();
18237        let expand = &rt.content[0];
18238        assert_eq!(expand.node_type, "expand");
18239        assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
18240        assert_eq!(expand.attrs.as_ref().unwrap()["title"], "My Section");
18241        let params = expand
18242            .parameters
18243            .as_ref()
18244            .expect("parameters should survive");
18245        assert_eq!(params["macroMetadata"]["macroId"]["value"], "macro-001");
18246        assert_eq!(params["macroMetadata"]["title"], "Page Properties");
18247    }
18248
18249    #[test]
18250    fn nested_expand_top_level_localid_and_parameters_roundtrip() {
18251        let adf_json = r#"{"version":1,"type":"doc","content":[
18252          {"type":"nestedExpand","attrs":{"title":"Nested"},"localId":"ne-100","parameters":{"macroMetadata":{"macroId":{"value":"nm-001"}}},"content":[
18253            {"type":"paragraph","content":[{"type":"text","text":"inner"}]}
18254          ]}
18255        ]}"#;
18256        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18257        let md = adf_to_markdown(&doc).unwrap();
18258        assert!(
18259            md.contains(":::nested-expand{"),
18260            "should use nested-expand: {md}"
18261        );
18262        assert!(md.contains("localId=ne-100"), "should have localId: {md}");
18263        assert!(md.contains("params="), "should have params: {md}");
18264        let rt = markdown_to_adf(&md).unwrap();
18265        let ne = &rt.content[0];
18266        assert_eq!(ne.node_type, "nestedExpand");
18267        assert_eq!(ne.local_id.as_deref(), Some("ne-100"));
18268        assert_eq!(
18269            ne.parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
18270            "nm-001"
18271        );
18272    }
18273
18274    #[test]
18275    fn expand_top_level_localid_stripped() {
18276        // strip_local_ids should strip top-level localId too
18277        let adf_json = r#"{"version":1,"type":"doc","content":[
18278          {"type":"expand","attrs":{"title":"X"},"localId":"exp-strip","content":[
18279            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
18280          ]}
18281        ]}"#;
18282        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18283        let opts = RenderOptions {
18284            strip_local_ids: true,
18285        };
18286        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18287        assert!(!md.contains("localId"), "localId should be stripped: {md}");
18288        assert!(
18289            md.contains(":::expand{title=\"X\"}"),
18290            "title should remain: {md}"
18291        );
18292    }
18293
18294    #[test]
18295    fn expand_parameters_without_localid() {
18296        // parameters without localId should work
18297        let adf_json = r#"{"version":1,"type":"doc","content":[
18298          {"type":"expand","attrs":{"title":"P"},"parameters":{"macroMetadata":{"macroId":{"value":"solo"}}},"content":[
18299            {"type":"paragraph","content":[{"type":"text","text":"data"}]}
18300          ]}
18301        ]}"#;
18302        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18303        let md = adf_to_markdown(&doc).unwrap();
18304        assert!(!md.contains("localId"), "no localId: {md}");
18305        assert!(md.contains("params="), "has params: {md}");
18306        let rt = markdown_to_adf(&md).unwrap();
18307        assert!(rt.content[0].local_id.is_none());
18308        assert_eq!(
18309            rt.content[0].parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
18310            "solo"
18311        );
18312    }
18313
18314    #[test]
18315    fn expand_localid_without_parameters() {
18316        // top-level localId without parameters should work
18317        let adf_json = r#"{"version":1,"type":"doc","content":[
18318          {"type":"expand","attrs":{"title":"L"},"localId":"lid-only","content":[
18319            {"type":"paragraph","content":[{"type":"text","text":"txt"}]}
18320          ]}
18321        ]}"#;
18322        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18323        let md = adf_to_markdown(&doc).unwrap();
18324        assert!(md.contains("localId=lid-only"), "has localId: {md}");
18325        assert!(!md.contains("params="), "no params: {md}");
18326        let rt = markdown_to_adf(&md).unwrap();
18327        assert_eq!(rt.content[0].local_id.as_deref(), Some("lid-only"));
18328        assert!(rt.content[0].parameters.is_none());
18329    }
18330
18331    #[test]
18332    fn nested_panel_inside_panel() {
18333        let md = ":::panel{type=info}\n:::panel{type=warning}\nInner warning\n:::\n:::";
18334        let adf = markdown_to_adf(md).unwrap();
18335
18336        // Outer panel should exist
18337        assert_eq!(adf.content.len(), 1);
18338        assert_eq!(adf.content[0].node_type, "panel");
18339
18340        // Outer panel should contain an inner panel (not have it truncated)
18341        let panel_content = adf.content[0].content.as_ref().unwrap();
18342        assert!(
18343            panel_content.iter().any(|n| n.node_type == "panel"),
18344            "Outer panel should contain an inner panel, got: {:?}",
18345            panel_content
18346                .iter()
18347                .map(|n| &n.node_type)
18348                .collect::<Vec<_>>()
18349        );
18350    }
18351
18352    #[test]
18353    fn content_after_directive_table_is_preserved() {
18354        // Issue #361: content after a ::::table block was silently dropped
18355        let md = "\
18356## Before table
18357
18358::::table{layout=default}
18359:::tr
18360:::th{}
18361Cell
18362:::
18363:::
18364::::
18365
18366## After table
18367
18368Paragraph after.";
18369        let adf = markdown_to_adf(md).unwrap();
18370        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18371        assert_eq!(
18372            types,
18373            vec!["heading", "table", "heading", "paragraph"],
18374            "Content after table was dropped: got {types:?}"
18375        );
18376    }
18377
18378    #[test]
18379    fn paragraph_after_directive_table_is_preserved() {
18380        // Issue #361: minimal reproducer — paragraph after table
18381        let md = "\
18382::::table{layout=default}
18383:::tr
18384:::th{}
18385Header
18386:::
18387:::
18388::::
18389
18390Just a paragraph.";
18391        let adf = markdown_to_adf(md).unwrap();
18392        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18393        assert_eq!(
18394            types,
18395            vec!["table", "paragraph"],
18396            "Paragraph after table was dropped: got {types:?}"
18397        );
18398    }
18399
18400    #[test]
18401    fn extension_after_directive_table_is_preserved() {
18402        // Issue #361: extension after table
18403        let md = "\
18404::::table{layout=default}
18405:::tr
18406:::th{}
18407Header
18408:::
18409:::
18410::::
18411
18412::extension{type=com.atlassian.confluence.macro.core key=toc}";
18413        let adf = markdown_to_adf(md).unwrap();
18414        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18415        assert_eq!(
18416            types,
18417            vec!["table", "extension"],
18418            "Extension after table was dropped: got {types:?}"
18419        );
18420    }
18421
18422    #[test]
18423    fn multiple_blocks_after_directive_table() {
18424        // Issue #361: multiple blocks after table, including another table
18425        let md = "\
18426## Heading 1
18427
18428::::table{layout=default}
18429:::tr
18430:::td{}
18431A
18432:::
18433:::td{}
18434B
18435:::
18436:::
18437::::
18438
18439## Heading 2
18440
18441Some text.
18442
18443---
18444
18445::::table{layout=default}
18446:::tr
18447:::th{}
18448C
18449:::
18450:::
18451::::
18452
18453## Heading 3";
18454        let adf = markdown_to_adf(md).unwrap();
18455        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18456        assert_eq!(
18457            types,
18458            vec![
18459                "heading",
18460                "table",
18461                "heading",
18462                "paragraph",
18463                "rule",
18464                "table",
18465                "heading"
18466            ],
18467            "Content after tables was dropped: got {types:?}"
18468        );
18469    }
18470
18471    // ── Table caption tests (issue #382) ────────────────────────────
18472
18473    #[test]
18474    fn adf_table_caption_to_markdown() {
18475        let doc = AdfDocument {
18476            version: 1,
18477            doc_type: "doc".to_string(),
18478            content: vec![AdfNode::table(vec![
18479                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18480                    AdfNode::text("cell"),
18481                ])])]),
18482                AdfNode::caption(vec![AdfNode::text("Table caption")]),
18483            ])],
18484        };
18485        let md = adf_to_markdown(&doc).unwrap();
18486        assert!(
18487            md.contains("::::table"),
18488            "table with caption must use directive form"
18489        );
18490        assert!(
18491            md.contains(":::caption"),
18492            "caption directive missing, got: {md}"
18493        );
18494        assert!(
18495            md.contains("Table caption"),
18496            "caption text missing, got: {md}"
18497        );
18498    }
18499
18500    #[test]
18501    fn directive_table_caption_parses() {
18502        let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nTable caption\n:::\n::::\n";
18503        let doc = markdown_to_adf(md).unwrap();
18504        let table = &doc.content[0];
18505        assert_eq!(table.node_type, "table");
18506        let children = table.content.as_ref().unwrap();
18507        assert_eq!(children.len(), 2, "expected row + caption");
18508        assert_eq!(children[0].node_type, "tableRow");
18509        assert_eq!(children[1].node_type, "caption");
18510        let caption_content = children[1].content.as_ref().unwrap();
18511        assert_eq!(caption_content[0].text.as_deref(), Some("Table caption"));
18512    }
18513
18514    #[test]
18515    fn table_caption_round_trip_from_adf_json() {
18516        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18517          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18518          {"type":"caption","content":[{"type":"text","text":"Table caption"}]}
18519        ]}]}"#;
18520        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18521        let md = adf_to_markdown(&doc).unwrap();
18522        assert!(md.contains("Table caption"), "caption text lost in ADF→JFM");
18523        let round_tripped = markdown_to_adf(&md).unwrap();
18524        let children = round_tripped.content[0].content.as_ref().unwrap();
18525        let caption = children.iter().find(|n| n.node_type == "caption");
18526        assert!(caption.is_some(), "caption lost on round-trip");
18527        let caption_text = caption.unwrap().content.as_ref().unwrap();
18528        assert_eq!(caption_text[0].text.as_deref(), Some("Table caption"));
18529    }
18530
18531    #[test]
18532    fn table_caption_with_inline_marks_round_trips() {
18533        let doc = AdfDocument {
18534            version: 1,
18535            doc_type: "doc".to_string(),
18536            content: vec![AdfNode::table(vec![
18537                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18538                    AdfNode::text("data"),
18539                ])])]),
18540                AdfNode::caption(vec![
18541                    AdfNode::text("Caption with "),
18542                    AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
18543                ]),
18544            ])],
18545        };
18546        let md = adf_to_markdown(&doc).unwrap();
18547        assert!(md.contains("**bold**"), "bold mark missing in caption");
18548        let round_tripped = markdown_to_adf(&md).unwrap();
18549        let caption = round_tripped.content[0]
18550            .content
18551            .as_ref()
18552            .unwrap()
18553            .iter()
18554            .find(|n| n.node_type == "caption")
18555            .expect("caption node missing after round-trip");
18556        let inlines = caption.content.as_ref().unwrap();
18557        let bold_node = inlines.iter().find(|n| {
18558            n.marks
18559                .as_ref()
18560                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
18561        });
18562        assert!(bold_node.is_some(), "bold mark lost in caption round-trip");
18563    }
18564
18565    // ── table caption localId tests (issue #524) ──────────────────────
18566
18567    #[test]
18568    fn table_caption_localid_roundtrip() {
18569        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18570          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18571          {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Table with localId"}]}
18572        ]}]}"#;
18573        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18574        let md = adf_to_markdown(&doc).unwrap();
18575        assert!(
18576            md.contains("localId=abcdef123456"),
18577            "table caption localId should appear in markdown: {md}"
18578        );
18579        let rt = markdown_to_adf(&md).unwrap();
18580        let caption = rt.content[0]
18581            .content
18582            .as_ref()
18583            .unwrap()
18584            .iter()
18585            .find(|n| n.node_type == "caption")
18586            .expect("caption should survive round-trip");
18587        assert_eq!(
18588            caption.attrs.as_ref().unwrap()["localId"],
18589            "abcdef123456",
18590            "table caption localId should round-trip"
18591        );
18592    }
18593
18594    #[test]
18595    fn table_caption_without_localid_unchanged() {
18596        let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nPlain caption\n:::\n::::\n";
18597        let doc = markdown_to_adf(md).unwrap();
18598        let caption = doc.content[0]
18599            .content
18600            .as_ref()
18601            .unwrap()
18602            .iter()
18603            .find(|n| n.node_type == "caption")
18604            .unwrap();
18605        assert!(
18606            caption.attrs.is_none(),
18607            "table caption without localId should not gain attrs"
18608        );
18609        let md2 = adf_to_markdown(&doc).unwrap();
18610        assert!(!md2.contains("localId"), "no localId should appear: {md2}");
18611    }
18612
18613    #[test]
18614    fn table_caption_localid_stripped_when_option_set() {
18615        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18616          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18617          {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Stripped"}]}
18618        ]}]}"#;
18619        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18620        let opts = RenderOptions {
18621            strip_local_ids: true,
18622            ..Default::default()
18623        };
18624        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18625        assert!(
18626            !md.contains("localId"),
18627            "table caption localId should be stripped: {md}"
18628        );
18629    }
18630
18631    #[test]
18632    #[test]
18633    fn tablecell_empty_attrs_preserved_on_roundtrip() {
18634        // Issue #385: tableCell with empty attrs:{} dropped on round-trip
18635        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"}]}]}]}]}]}"#;
18636        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18637        let md = adf_to_markdown(&doc).unwrap();
18638        let round_tripped = markdown_to_adf(&md).unwrap();
18639        let rows = round_tripped.content[0].content.as_ref().unwrap();
18640        let cell = &rows[0].content.as_ref().unwrap()[0];
18641        assert!(
18642            cell.attrs.is_some(),
18643            "tableCell attrs should be preserved, got None"
18644        );
18645        assert_eq!(
18646            cell.attrs.as_ref().unwrap(),
18647            &serde_json::json!({}),
18648            "tableCell attrs should be an empty object"
18649        );
18650    }
18651
18652    #[test]
18653    fn tablecell_empty_attrs_serialized_in_json() {
18654        // Issue #385: ensure the serialized JSON includes "attrs":{}
18655        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"}]}]}]}]}]}"#;
18656        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18657        let md = adf_to_markdown(&doc).unwrap();
18658        let round_tripped = markdown_to_adf(&md).unwrap();
18659        let json = serde_json::to_string(&round_tripped).unwrap();
18660        assert!(
18661            json.contains(r#""attrs":{}"#),
18662            "serialized JSON should contain \"attrs\":{{}}, got: {json}"
18663        );
18664    }
18665
18666    #[test]
18667    fn tablecell_empty_attrs_renders_braces_in_markdown() {
18668        // Issue #385: tableCell with empty attrs should render {} prefix in pipe tables
18669        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"}]}]}]}]}]}"#;
18670        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18671        let md = adf_to_markdown(&doc).unwrap();
18672        // Cell with attrs:{} should have {} prefix, cell without attrs should not
18673        assert!(
18674            md.contains("{} hello"),
18675            "cell with empty attrs should render '{{}} hello', got: {md}"
18676        );
18677        assert!(
18678            !md.contains("{} world"),
18679            "cell without attrs should not render '{{}}', got: {md}"
18680        );
18681    }
18682
18683    #[test]
18684    fn tablecell_no_attrs_unchanged_on_roundtrip() {
18685        // Ensure tableCell without attrs stays without attrs
18686        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"}]}]}]}]}]}"#;
18687        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18688        let md = adf_to_markdown(&doc).unwrap();
18689        let round_tripped = markdown_to_adf(&md).unwrap();
18690        let rows = round_tripped.content[0].content.as_ref().unwrap();
18691        let cell = &rows[0].content.as_ref().unwrap()[0];
18692        assert!(
18693            cell.attrs.is_none(),
18694            "tableCell without attrs should stay None, got: {:?}",
18695            cell.attrs
18696        );
18697    }
18698
18699    #[test]
18700    fn tablecell_nonempty_attrs_preserved_on_roundtrip() {
18701        // Ensure tableCell with non-empty attrs still works
18702        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"}]}]}]}]}]}"##;
18703        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18704        let md = adf_to_markdown(&doc).unwrap();
18705        let round_tripped = markdown_to_adf(&md).unwrap();
18706        let rows = round_tripped.content[0].content.as_ref().unwrap();
18707        let cell = &rows[1].content.as_ref().unwrap()[0];
18708        let attrs = cell.attrs.as_ref().unwrap();
18709        assert_eq!(attrs["background"], "#DEEBFF");
18710        assert_eq!(attrs["colspan"], 2);
18711    }
18712
18713    #[test]
18714    fn pipe_table_not_used_when_caption_present() {
18715        let doc = AdfDocument {
18716            version: 1,
18717            doc_type: "doc".to_string(),
18718            content: vec![AdfNode::table(vec![
18719                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
18720                    AdfNode::text("H"),
18721                ])])]),
18722                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18723                    AdfNode::text("D"),
18724                ])])]),
18725                AdfNode::caption(vec![AdfNode::text("cap")]),
18726            ])],
18727        };
18728        let md = adf_to_markdown(&doc).unwrap();
18729        assert!(
18730            md.contains("::::table"),
18731            "pipe syntax should not be used when caption is present"
18732        );
18733    }
18734
18735    // ── Issue #402: ordered-list-like text in list item hardBreak ──
18736
18737    #[test]
18738    fn hardbreak_with_ordered_marker_in_bullet_item_roundtrips() {
18739        // Issue #402: text starting with "2. " after a hardBreak inside a
18740        // bullet list item must not be re-parsed as a new ordered list.
18741        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18742          {"type":"listItem","content":[{"type":"paragraph","content":[
18743            {"type":"text","text":"1. First item"},
18744            {"type":"hardBreak"},
18745            {"type":"text","text":"2. Honouring existing commitments"}
18746          ]}]}
18747        ]}]}"#;
18748        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18749        let md = adf_to_markdown(&doc).unwrap();
18750
18751        // The continuation line must be indented so it stays within the list item.
18752        assert!(
18753            md.contains("  2. Honouring"),
18754            "Continuation line should be indented, got:\n{md}"
18755        );
18756
18757        // Round-trip back to ADF
18758        let rt = markdown_to_adf(&md).unwrap();
18759        let list = &rt.content[0];
18760        assert_eq!(list.node_type, "bulletList");
18761        let items = list.content.as_ref().unwrap();
18762        assert_eq!(
18763            items.len(),
18764            1,
18765            "Should be one list item, got {}",
18766            items.len()
18767        );
18768
18769        let para = &items[0].content.as_ref().unwrap()[0];
18770        let inlines = para.content.as_ref().unwrap();
18771        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18772        assert_eq!(
18773            types,
18774            vec!["text", "hardBreak", "text"],
18775            "Expected text+hardBreak+text, got {types:?}"
18776        );
18777        assert_eq!(
18778            inlines[2].text.as_deref().unwrap(),
18779            "2. Honouring existing commitments"
18780        );
18781    }
18782
18783    #[test]
18784    fn hardbreak_with_ordered_marker_in_ordered_item_roundtrips() {
18785        // Same as above but inside an ordered list.
18786        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18787          {"type":"listItem","content":[{"type":"paragraph","content":[
18788            {"type":"text","text":"Introduction  "},
18789            {"type":"hardBreak"},
18790            {"type":"text","text":"3. Third point"}
18791          ]}]}
18792        ]}]}"#;
18793        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18794        let md = adf_to_markdown(&doc).unwrap();
18795        let rt = markdown_to_adf(&md).unwrap();
18796
18797        let list = &rt.content[0];
18798        assert_eq!(list.node_type, "orderedList");
18799        let items = list.content.as_ref().unwrap();
18800        assert_eq!(items.len(), 1);
18801
18802        let para = &items[0].content.as_ref().unwrap()[0];
18803        let inlines = para.content.as_ref().unwrap();
18804        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18805        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18806        assert_eq!(inlines[2].text.as_deref().unwrap(), "3. Third point");
18807    }
18808
18809    #[test]
18810    fn hardbreak_with_bullet_marker_in_bullet_item_roundtrips() {
18811        // Text starting with "- " after a hardBreak must not become a nested bullet list.
18812        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18813          {"type":"listItem","content":[{"type":"paragraph","content":[
18814            {"type":"text","text":"Header  "},
18815            {"type":"hardBreak"},
18816            {"type":"text","text":"- not a sub-item"}
18817          ]}]}
18818        ]}]}"#;
18819        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18820        let md = adf_to_markdown(&doc).unwrap();
18821        let rt = markdown_to_adf(&md).unwrap();
18822
18823        let list = &rt.content[0];
18824        assert_eq!(list.node_type, "bulletList");
18825        let items = list.content.as_ref().unwrap();
18826        assert_eq!(
18827            items.len(),
18828            1,
18829            "Should be one list item, not {}",
18830            items.len()
18831        );
18832
18833        let para = &items[0].content.as_ref().unwrap()[0];
18834        let inlines = para.content.as_ref().unwrap();
18835        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18836        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18837        assert_eq!(inlines[2].text.as_deref().unwrap(), "- not a sub-item");
18838    }
18839
18840    #[test]
18841    fn hardbreak_continuation_followed_by_sub_list() {
18842        // A hardBreak continuation line followed by a real sub-list.
18843        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18844          {"type":"listItem","content":[
18845            {"type":"paragraph","content":[
18846              {"type":"text","text":"Main item  "},
18847              {"type":"hardBreak"},
18848              {"type":"text","text":"continued here"}
18849            ]},
18850            {"type":"bulletList","content":[
18851              {"type":"listItem","content":[{"type":"paragraph","content":[
18852                {"type":"text","text":"sub-item"}
18853              ]}]}
18854            ]}
18855          ]}
18856        ]}]}"#;
18857        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18858        let md = adf_to_markdown(&doc).unwrap();
18859        let rt = markdown_to_adf(&md).unwrap();
18860
18861        let list = &rt.content[0];
18862        let items = list.content.as_ref().unwrap();
18863        assert_eq!(items.len(), 1);
18864
18865        let item_content = items[0].content.as_ref().unwrap();
18866        assert_eq!(item_content.len(), 2, "Expected paragraph + nested list");
18867        assert_eq!(item_content[0].node_type, "paragraph");
18868        assert_eq!(item_content[1].node_type, "bulletList");
18869
18870        // Check the paragraph has hardBreak
18871        let inlines = item_content[0].content.as_ref().unwrap();
18872        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18873        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18874    }
18875
18876    #[test]
18877    fn multiple_hardbreaks_with_numbered_text_roundtrip() {
18878        // Multiple hardBreaks where each continuation resembles an ordered list.
18879        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18880          {"type":"listItem","content":[{"type":"paragraph","content":[
18881            {"type":"text","text":"Preamble  "},
18882            {"type":"hardBreak"},
18883            {"type":"text","text":"1. Alpha  "},
18884            {"type":"hardBreak"},
18885            {"type":"text","text":"2. Bravo"}
18886          ]}]}
18887        ]}]}"#;
18888        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18889        let md = adf_to_markdown(&doc).unwrap();
18890        let rt = markdown_to_adf(&md).unwrap();
18891
18892        let items = rt.content[0].content.as_ref().unwrap();
18893        assert_eq!(items.len(), 1);
18894
18895        let inlines = items[0].content.as_ref().unwrap()[0]
18896            .content
18897            .as_ref()
18898            .unwrap();
18899        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18900        assert_eq!(
18901            types,
18902            vec!["text", "hardBreak", "text", "hardBreak", "text"]
18903        );
18904    }
18905
18906    #[test]
18907    fn trailing_hardbreak_in_bullet_item_roundtrips() {
18908        // A hardBreak as the last inline node with no text after it.
18909        // Exercises the `break` path in the continuation loop and the
18910        // empty-line rendering branch.
18911        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18912          {"type":"listItem","content":[{"type":"paragraph","content":[
18913            {"type":"text","text":"ends with break"},
18914            {"type":"hardBreak"}
18915          ]}]}
18916        ]}]}"#;
18917        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18918        let md = adf_to_markdown(&doc).unwrap();
18919        let rt = markdown_to_adf(&md).unwrap();
18920
18921        let list = &rt.content[0];
18922        assert_eq!(list.node_type, "bulletList");
18923        let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18924            .content
18925            .as_ref()
18926            .unwrap();
18927        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18928        assert_eq!(types, vec!["text", "hardBreak"]);
18929    }
18930
18931    #[test]
18932    fn trailing_hardbreak_in_ordered_item_roundtrips() {
18933        // Same as above but in an ordered list, covering the ordered-list
18934        // continuation `break` path.
18935        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18936          {"type":"listItem","content":[{"type":"paragraph","content":[
18937            {"type":"text","text":"ends with break"},
18938            {"type":"hardBreak"}
18939          ]}]}
18940        ]}]}"#;
18941        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18942        let md = adf_to_markdown(&doc).unwrap();
18943        let rt = markdown_to_adf(&md).unwrap();
18944
18945        let list = &rt.content[0];
18946        assert_eq!(list.node_type, "orderedList");
18947        let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18948            .content
18949            .as_ref()
18950            .unwrap();
18951        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18952        assert_eq!(types, vec!["text", "hardBreak"]);
18953    }
18954
18955    #[test]
18956    fn trailing_space_hardbreak_continuation_in_bullet_item() {
18957        // Exercises the `ends_with("  ")` path in `has_trailing_hard_break`
18958        // by parsing hand-written markdown that uses trailing-space style
18959        // hardBreaks instead of backslash style.
18960        let md = "- first line  \n  2. continued\n";
18961        let doc = markdown_to_adf(md).unwrap();
18962
18963        let list = &doc.content[0];
18964        assert_eq!(list.node_type, "bulletList");
18965        let items = list.content.as_ref().unwrap();
18966        assert_eq!(
18967            items.len(),
18968            1,
18969            "Should be one list item, got {}",
18970            items.len()
18971        );
18972
18973        let para = &items[0].content.as_ref().unwrap()[0];
18974        let inlines = para.content.as_ref().unwrap();
18975        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18976        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18977        assert_eq!(inlines[2].text.as_deref().unwrap(), "2. continued");
18978    }
18979
18980    #[test]
18981    fn trailing_space_hardbreak_continuation_in_ordered_item() {
18982        // Same as above but for ordered list, exercising the trailing-space
18983        // path in the ordered-list continuation loop.
18984        let md = "1. first line  \n  - continued\n";
18985        let doc = markdown_to_adf(md).unwrap();
18986
18987        let list = &doc.content[0];
18988        assert_eq!(list.node_type, "orderedList");
18989        let items = list.content.as_ref().unwrap();
18990        assert_eq!(
18991            items.len(),
18992            1,
18993            "Should be one list item, got {}",
18994            items.len()
18995        );
18996
18997        let para = &items[0].content.as_ref().unwrap()[0];
18998        let inlines = para.content.as_ref().unwrap();
18999        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19000        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19001        assert_eq!(inlines[2].text.as_deref().unwrap(), "- continued");
19002    }
19003
19004    #[test]
19005    fn multi_paragraph_list_item_with_ordered_marker_roundtrips() {
19006        // Issue #402 comment: a listItem with a second paragraph starting
19007        // with "2. " must not become a separate orderedList.
19008        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19009          {"type":"listItem","content":[
19010            {"type":"paragraph","content":[{"type":"text","text":"some preamble"}]},
19011            {"type":"paragraph","content":[{"type":"text","text":"2. Honouring existing commitments"}]}
19012          ]}
19013        ]}]}"#;
19014        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19015        let md = adf_to_markdown(&doc).unwrap();
19016        let rt = markdown_to_adf(&md).unwrap();
19017
19018        assert_eq!(rt.content.len(), 1, "Should be one top-level block");
19019        let list = &rt.content[0];
19020        assert_eq!(list.node_type, "bulletList");
19021        let items = list.content.as_ref().unwrap();
19022        assert_eq!(items.len(), 1);
19023        let item_content = items[0].content.as_ref().unwrap();
19024        assert_eq!(
19025            item_content.len(),
19026            2,
19027            "Expected 2 paragraphs inside the list item, got {}",
19028            item_content.len()
19029        );
19030        assert_eq!(item_content[0].node_type, "paragraph");
19031        assert_eq!(item_content[1].node_type, "paragraph");
19032        let text = item_content[1].content.as_ref().unwrap()[0]
19033            .text
19034            .as_deref()
19035            .unwrap();
19036        assert_eq!(text, "2. Honouring existing commitments");
19037    }
19038
19039    #[test]
19040    fn multi_paragraph_list_item_with_bullet_marker_roundtrips() {
19041        // Paragraph starting with "- " inside a list item.
19042        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19043          {"type":"listItem","content":[
19044            {"type":"paragraph","content":[{"type":"text","text":"preamble"}]},
19045            {"type":"paragraph","content":[{"type":"text","text":"- not a sub-item"}]}
19046          ]}
19047        ]}]}"#;
19048        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19049        let md = adf_to_markdown(&doc).unwrap();
19050        let rt = markdown_to_adf(&md).unwrap();
19051
19052        let items = rt.content[0].content.as_ref().unwrap();
19053        assert_eq!(items.len(), 1);
19054        let item_content = items[0].content.as_ref().unwrap();
19055        assert_eq!(item_content.len(), 2);
19056        assert_eq!(item_content[1].node_type, "paragraph");
19057        let text = item_content[1].content.as_ref().unwrap()[0]
19058            .text
19059            .as_deref()
19060            .unwrap();
19061        assert_eq!(text, "- not a sub-item");
19062    }
19063
19064    #[test]
19065    fn backslash_escape_in_inline_text() {
19066        // Verify that `\. ` is unescaped to `. ` in inline parsing.
19067        let nodes = parse_inline(r"2\. text");
19068        assert_eq!(nodes.len(), 1, "Should be one text node");
19069        assert_eq!(nodes[0].text.as_deref().unwrap(), "2. text");
19070    }
19071
19072    #[test]
19073    fn escape_list_marker_ordered() {
19074        assert_eq!(escape_list_marker("2. text"), r"2\. text");
19075        assert_eq!(escape_list_marker("10. tenth"), r"10\. tenth");
19076    }
19077
19078    #[test]
19079    fn escape_list_marker_bullet() {
19080        assert_eq!(escape_list_marker("- text"), r"\- text");
19081        assert_eq!(escape_list_marker("* text"), r"\* text");
19082        assert_eq!(escape_list_marker("+ text"), r"\+ text");
19083    }
19084
19085    #[test]
19086    fn escape_list_marker_plain() {
19087        assert_eq!(escape_list_marker("plain text"), "plain text");
19088        assert_eq!(escape_list_marker("no. marker"), "no. marker");
19089    }
19090
19091    #[test]
19092    fn escape_emoji_shortcodes_basic() {
19093        assert_eq!(escape_emoji_shortcodes(":fire:"), r"\:fire:");
19094        assert_eq!(
19095            escape_emoji_shortcodes("hello :wave: world"),
19096            r"hello \:wave: world"
19097        );
19098    }
19099
19100    #[test]
19101    fn escape_emoji_shortcodes_double_colon() {
19102        // Only the colon that starts `:Active:` needs escaping
19103        assert_eq!(
19104            escape_emoji_shortcodes("Status::Active::Running"),
19105            r"Status:\:Active::Running"
19106        );
19107    }
19108
19109    #[test]
19110    fn escape_emoji_shortcodes_no_match() {
19111        // Lone colons, numeric-only between colons like 10:30
19112        assert_eq!(escape_emoji_shortcodes("Time is 10:30"), "Time is 10:30");
19113        assert_eq!(escape_emoji_shortcodes("no colons here"), "no colons here");
19114        assert_eq!(escape_emoji_shortcodes("trailing:"), "trailing:");
19115        assert_eq!(escape_emoji_shortcodes(":"), ":");
19116    }
19117
19118    #[test]
19119    fn escape_emoji_shortcodes_mixed() {
19120        assert_eq!(
19121            escape_emoji_shortcodes("Alert :fire: on pod:pod42"),
19122            r"Alert \:fire: on pod:pod42"
19123        );
19124    }
19125
19126    #[test]
19127    fn escape_emoji_shortcodes_unicode() {
19128        // Issue #552: Unicode alphanumeric chars must be escaped to match
19129        // `try_parse_emoji_shortcode`, which uses `is_alphanumeric` (not the
19130        // ASCII-only variant).  Without this, `:Café:` rendered un-escaped
19131        // would be re-parsed as an emoji on round-trip.
19132        assert_eq!(escape_emoji_shortcodes(":Café:"), r"\:Café:");
19133        assert_eq!(escape_emoji_shortcodes(":über:"), r"\:über:");
19134        assert_eq!(escape_emoji_shortcodes(":配置:"), r"\:配置:");
19135        assert_eq!(
19136            escape_emoji_shortcodes("ZBC::配置::Production"),
19137            r"ZBC:\:配置::Production"
19138        );
19139    }
19140
19141    #[test]
19142    fn escape_emoji_shortcodes_mixed_script_name() {
19143        // Issue #552: A name that mixes ASCII and Unicode alphanumerics is
19144        // still a single valid shortcode under `is_alphanumeric`.
19145        assert_eq!(escape_emoji_shortcodes(":abc配置:"), r"\:abc配置:");
19146        assert_eq!(escape_emoji_shortcodes(":配置abc:"), r"\:配置abc:");
19147    }
19148
19149    #[test]
19150    fn escape_emoji_shortcodes_unicode_followed_by_non_colon() {
19151        // `:Café world:` — `Café` is alphanumeric but the terminator is a
19152        // space, not `:`, so the `after + name_end < text.len()` path is
19153        // exercised but the final `== b':'` check bails out and nothing
19154        // gets escaped.  Guards the negative branch of the predicate.
19155        assert_eq!(escape_emoji_shortcodes(":Café world:"), ":Café world:");
19156    }
19157
19158    #[test]
19159    fn escape_emoji_shortcodes_name_runs_to_end() {
19160        // Colon followed by alphanumerics to end of string: `.find(...)` returns
19161        // `None`, so `name_end` falls back to the full remaining length via
19162        // `map_or`.  The `after + name_end < text.len()` check then fails and
19163        // nothing is escaped.  Exercises the `map_or` default branch for both
19164        // ASCII and Unicode names.
19165        assert_eq!(escape_emoji_shortcodes(":abc"), ":abc");
19166        assert_eq!(escape_emoji_shortcodes(":配置"), ":配置");
19167    }
19168
19169    #[test]
19170    fn unicode_shortcode_pattern_text_round_trips_as_text() {
19171        // Issue #552: A text node containing `:Café:` must round-trip as text,
19172        // not be split into text + emoji nodes.
19173        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19174          {"type":"text","text":"Visit :Café: today"}
19175        ]}]}"#;
19176        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19177
19178        let md = adf_to_markdown(&doc).unwrap();
19179        let round_tripped = markdown_to_adf(&md).unwrap();
19180        let content = round_tripped.content[0].content.as_ref().unwrap();
19181
19182        assert_eq!(
19183            content.len(),
19184            1,
19185            "should be a single text node, got: {content:?}"
19186        );
19187        assert_eq!(content[0].node_type, "text");
19188        assert_eq!(content[0].text.as_deref().unwrap(), "Visit :Café: today");
19189    }
19190
19191    #[test]
19192    fn unicode_double_colon_pattern_text_round_trips() {
19193        // Issue #552: `ZBC::配置::Production` should round-trip without splitting.
19194        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19195          {"type":"text","text":"Use ZBC::配置::Production for prod"}
19196        ]}]}"#;
19197        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19198
19199        let md = adf_to_markdown(&doc).unwrap();
19200        let round_tripped = markdown_to_adf(&md).unwrap();
19201        let content = round_tripped.content[0].content.as_ref().unwrap();
19202
19203        assert_eq!(
19204            content.len(),
19205            1,
19206            "should be a single text node, got: {content:?}"
19207        );
19208        assert_eq!(
19209            content[0].text.as_deref().unwrap(),
19210            "Use ZBC::配置::Production for prod"
19211        );
19212    }
19213
19214    #[test]
19215    fn merge_adjacent_text_nodes() {
19216        let mut nodes = vec![AdfNode::text("a"), AdfNode::text("b"), AdfNode::text("c")];
19217        merge_adjacent_text(&mut nodes);
19218        assert_eq!(nodes.len(), 1);
19219        assert_eq!(nodes[0].text.as_deref().unwrap(), "abc");
19220    }
19221
19222    // ── Issue #455: text after hardBreak in paragraph re-parsed as list ──
19223
19224    #[test]
19225    fn issue_455_paragraph_hardbreak_ordered_marker_roundtrips() {
19226        // Issue #455: "1. text" after a hardBreak in a paragraph must not
19227        // become an ordered list.
19228        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19229          {"type":"text","text":"Introduction: "},
19230          {"type":"hardBreak"},
19231          {"type":"text","text":"1. This text follows a hardBreak"}
19232        ]}]}"#;
19233        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19234        let md = adf_to_markdown(&doc).unwrap();
19235        let rt = markdown_to_adf(&md).unwrap();
19236
19237        assert_eq!(rt.content.len(), 1, "Should remain one block");
19238        assert_eq!(rt.content[0].node_type, "paragraph");
19239        let inlines = rt.content[0].content.as_ref().unwrap();
19240        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19241        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19242        assert_eq!(
19243            inlines[2].text.as_deref(),
19244            Some("1. This text follows a hardBreak")
19245        );
19246    }
19247
19248    #[test]
19249    fn issue_455_paragraph_hardbreak_bullet_marker_roundtrips() {
19250        // Issue #455 variant: "- text" after a hardBreak in a paragraph.
19251        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19252          {"type":"text","text":"Intro"},
19253          {"type":"hardBreak"},
19254          {"type":"text","text":"- not a list item"}
19255        ]}]}"#;
19256        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19257        let md = adf_to_markdown(&doc).unwrap();
19258        let rt = markdown_to_adf(&md).unwrap();
19259
19260        assert_eq!(rt.content.len(), 1);
19261        assert_eq!(rt.content[0].node_type, "paragraph");
19262        let inlines = rt.content[0].content.as_ref().unwrap();
19263        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19264        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19265        assert_eq!(inlines[2].text.as_deref(), Some("- not a list item"));
19266    }
19267
19268    #[test]
19269    fn issue_455_paragraph_hardbreak_heading_marker_roundtrips() {
19270        // Issue #455 variant: "# text" after a hardBreak in a paragraph.
19271        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19272          {"type":"text","text":"Intro"},
19273          {"type":"hardBreak"},
19274          {"type":"text","text":"# not a heading"}
19275        ]}]}"##;
19276        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19277        let md = adf_to_markdown(&doc).unwrap();
19278        let rt = markdown_to_adf(&md).unwrap();
19279
19280        assert_eq!(rt.content.len(), 1);
19281        assert_eq!(rt.content[0].node_type, "paragraph");
19282        let inlines = rt.content[0].content.as_ref().unwrap();
19283        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19284        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19285        assert_eq!(inlines[2].text.as_deref(), Some("# not a heading"));
19286    }
19287
19288    #[test]
19289    fn issue_455_paragraph_hardbreak_blockquote_marker_roundtrips() {
19290        // Issue #455 variant: "> text" after a hardBreak in a paragraph.
19291        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19292          {"type":"text","text":"Intro"},
19293          {"type":"hardBreak"},
19294          {"type":"text","text":"> not a blockquote"}
19295        ]}]}"#;
19296        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19297        let md = adf_to_markdown(&doc).unwrap();
19298        let rt = markdown_to_adf(&md).unwrap();
19299
19300        assert_eq!(rt.content.len(), 1);
19301        assert_eq!(rt.content[0].node_type, "paragraph");
19302        let inlines = rt.content[0].content.as_ref().unwrap();
19303        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19304        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19305        assert_eq!(inlines[2].text.as_deref(), Some("> not a blockquote"));
19306    }
19307
19308    #[test]
19309    fn issue_455_paragraph_multiple_hardbreaks_with_ordered_markers() {
19310        // Multiple hardBreaks in a paragraph, each followed by "N. text".
19311        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19312          {"type":"text","text":"Preamble"},
19313          {"type":"hardBreak"},
19314          {"type":"text","text":"1. First"},
19315          {"type":"hardBreak"},
19316          {"type":"text","text":"2. Second"},
19317          {"type":"hardBreak"},
19318          {"type":"text","text":"3. Third"}
19319        ]}]}"#;
19320        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19321        let md = adf_to_markdown(&doc).unwrap();
19322        let rt = markdown_to_adf(&md).unwrap();
19323
19324        assert_eq!(rt.content.len(), 1);
19325        assert_eq!(rt.content[0].node_type, "paragraph");
19326        let inlines = rt.content[0].content.as_ref().unwrap();
19327        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19328        assert_eq!(
19329            types,
19330            vec![
19331                "text",
19332                "hardBreak",
19333                "text",
19334                "hardBreak",
19335                "text",
19336                "hardBreak",
19337                "text"
19338            ]
19339        );
19340        assert_eq!(inlines[2].text.as_deref(), Some("1. First"));
19341        assert_eq!(inlines[4].text.as_deref(), Some("2. Second"));
19342        assert_eq!(inlines[6].text.as_deref(), Some("3. Third"));
19343    }
19344
19345    #[test]
19346    fn issue_455_paragraph_hardbreak_jfm_indentation() {
19347        // Verify that ADF→JFM output indents continuation lines.
19348        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19349          {"type":"text","text":"Intro"},
19350          {"type":"hardBreak"},
19351          {"type":"text","text":"1. continued"}
19352        ]}]}"#;
19353        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19354        let md = adf_to_markdown(&doc).unwrap();
19355        assert!(
19356            md.contains("Intro\\\n  1. continued"),
19357            "Continuation should be 2-space-indented, got: {md:?}"
19358        );
19359    }
19360
19361    #[test]
19362    fn issue_455_paragraph_hardbreak_from_jfm() {
19363        // Verify that JFM with 2-space-indented continuation is parsed
19364        // back as a single paragraph with hardBreak.
19365        let md = "Intro\\\n  1. This is continuation text\n";
19366        let doc = markdown_to_adf(md).unwrap();
19367
19368        assert_eq!(doc.content.len(), 1);
19369        assert_eq!(doc.content[0].node_type, "paragraph");
19370        let inlines = doc.content[0].content.as_ref().unwrap();
19371        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19372        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19373        assert_eq!(
19374            inlines[2].text.as_deref(),
19375            Some("1. This is continuation text")
19376        );
19377    }
19378
19379    #[test]
19380    fn issue_455_paragraph_starts_with_ordered_marker_and_hardbreak() {
19381        // Coverage: first line IS a list marker AND paragraph has hardBreaks.
19382        // Exercises the escape_list_marker path on the first line of a
19383        // multi-line paragraph buf in the rendering code.
19384        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19385          {"type":"text","text":"1. Starting with a number"},
19386          {"type":"hardBreak"},
19387          {"type":"text","text":"continuation after break"}
19388        ]}]}"#;
19389        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19390        let md = adf_to_markdown(&doc).unwrap();
19391        // First line should be escaped so it's not parsed as ordered list
19392        assert!(
19393            md.contains(r"1\. Starting with a number"),
19394            "First line should have escaped list marker, got: {md:?}"
19395        );
19396        let rt = markdown_to_adf(&md).unwrap();
19397
19398        assert_eq!(rt.content.len(), 1);
19399        assert_eq!(rt.content[0].node_type, "paragraph");
19400        let inlines = rt.content[0].content.as_ref().unwrap();
19401        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19402        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19403        assert_eq!(
19404            inlines[0].text.as_deref(),
19405            Some("1. Starting with a number")
19406        );
19407        assert_eq!(inlines[2].text.as_deref(), Some("continuation after break"));
19408    }
19409
19410    #[test]
19411    fn ordered_marker_paragraph_in_table_cell_roundtrips() {
19412        // Issue #402: paragraph with "2. " text inside a tableCell must
19413        // not be re-parsed as an ordered list.
19414        let adf_json = r#"{"version":1,"type":"doc","content":[{
19415          "type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},
19416          "content":[{"type":"tableRow","content":[{
19417            "type":"tableCell","attrs":{"colspan":1,"rowspan":1},
19418            "content":[{"type":"paragraph","content":[
19419              {"type":"text","text":"2. Honouring existing commitments"}
19420            ]}]
19421          }]}]
19422        }]}"#;
19423        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19424        let md = adf_to_markdown(&doc).unwrap();
19425        let rt = markdown_to_adf(&md).unwrap();
19426
19427        let table = &rt.content[0];
19428        let cell = &table.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0];
19429        let para = &cell.content.as_ref().unwrap()[0];
19430        assert_eq!(para.node_type, "paragraph");
19431        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
19432        assert_eq!(text, "2. Honouring existing commitments");
19433    }
19434
19435    #[test]
19436    fn bullet_marker_paragraph_standalone_roundtrips() {
19437        // A top-level paragraph starting with "- " must round-trip as
19438        // a paragraph, not a bullet list.
19439        let adf_json = r#"{"version":1,"type":"doc","content":[
19440          {"type":"paragraph","content":[
19441            {"type":"text","text":"- not a list item"}
19442          ]}
19443        ]}"#;
19444        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19445        let md = adf_to_markdown(&doc).unwrap();
19446        assert!(
19447            md.contains(r"\- not a list item"),
19448            "Should escape the leading dash, got:\n{md}"
19449        );
19450        let rt = markdown_to_adf(&md).unwrap();
19451        assert_eq!(rt.content[0].node_type, "paragraph");
19452        let text = rt.content[0].content.as_ref().unwrap()[0]
19453            .text
19454            .as_deref()
19455            .unwrap();
19456        assert_eq!(text, "- not a list item");
19457    }
19458
19459    #[test]
19460    fn merge_adjacent_text_skips_non_text_nodes() {
19461        // Exercises the `else { i += 1 }` branch when adjacent nodes
19462        // are not both plain text.
19463        let mut nodes = vec![
19464            AdfNode::text("a"),
19465            AdfNode::hard_break(),
19466            AdfNode::text("b"),
19467        ];
19468        merge_adjacent_text(&mut nodes);
19469        assert_eq!(nodes.len(), 3);
19470    }
19471
19472    #[test]
19473    fn star_bullet_paragraph_roundtrips() {
19474        // Paragraph starting with "* " must round-trip without becoming
19475        // a bullet list.
19476        let adf_json = r#"{"version":1,"type":"doc","content":[
19477          {"type":"paragraph","content":[
19478            {"type":"text","text":"* starred"}
19479          ]}
19480        ]}"#;
19481        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19482        let md = adf_to_markdown(&doc).unwrap();
19483        let rt = markdown_to_adf(&md).unwrap();
19484        assert_eq!(rt.content[0].node_type, "paragraph");
19485        assert_eq!(
19486            rt.content[0].content.as_ref().unwrap()[0]
19487                .text
19488                .as_deref()
19489                .unwrap(),
19490            "* starred"
19491        );
19492    }
19493
19494    // ---- Issue #388 tests ----
19495
19496    #[test]
19497    fn issue_388_ordered_list_with_strong_hardbreak_roundtrips() {
19498        // Issue #388: orderedList with 2 listItems, each containing
19499        // strong-marked text + hardBreak + plain text.
19500        let adf_json = r#"{"version":1,"type":"doc","content":[
19501          {"type":"orderedList","attrs":{"order":1},"content":[
19502            {"type":"listItem","content":[
19503              {"type":"paragraph","content":[
19504                {"type":"text","text":"Bold heading","marks":[{"type":"strong"}]},
19505                {"type":"hardBreak"},
19506                {"type":"text","text":"Content after break"}
19507              ]}
19508            ]},
19509            {"type":"listItem","content":[
19510              {"type":"paragraph","content":[
19511                {"type":"text","text":"Second item","marks":[{"type":"strong"}]},
19512                {"type":"hardBreak"},
19513                {"type":"text","text":"More content"}
19514              ]}
19515            ]}
19516          ]}
19517        ]}"#;
19518        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19519        let md = adf_to_markdown(&doc).unwrap();
19520        let rt = markdown_to_adf(&md).unwrap();
19521
19522        // Must remain a single orderedList
19523        assert_eq!(
19524            rt.content.len(),
19525            1,
19526            "Should be 1 block (orderedList), got {}",
19527            rt.content.len()
19528        );
19529        assert_eq!(rt.content[0].node_type, "orderedList");
19530        let items = rt.content[0].content.as_ref().unwrap();
19531        assert_eq!(
19532            items.len(),
19533            2,
19534            "Should have 2 listItems, got {}",
19535            items.len()
19536        );
19537
19538        // First item: text(strong) + hardBreak + text
19539        let p1 = items[0].content.as_ref().unwrap()[0]
19540            .content
19541            .as_ref()
19542            .unwrap();
19543        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19544        assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19545        assert_eq!(p1[0].text.as_deref(), Some("Bold heading"));
19546        assert_eq!(p1[2].text.as_deref(), Some("Content after break"));
19547
19548        // Second item: text(strong) + hardBreak + text
19549        let p2 = items[1].content.as_ref().unwrap()[0]
19550            .content
19551            .as_ref()
19552            .unwrap();
19553        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19554        assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19555        assert_eq!(p2[0].text.as_deref(), Some("Second item"));
19556        assert_eq!(p2[2].text.as_deref(), Some("More content"));
19557    }
19558
19559    #[test]
19560    fn issue_388_bullet_list_with_strong_hardbreak_roundtrips() {
19561        // Bullet list variant of issue #388.
19562        let adf_json = r#"{"version":1,"type":"doc","content":[
19563          {"type":"bulletList","content":[
19564            {"type":"listItem","content":[
19565              {"type":"paragraph","content":[
19566                {"type":"text","text":"First","marks":[{"type":"strong"}]},
19567                {"type":"hardBreak"},
19568                {"type":"text","text":"details"}
19569              ]}
19570            ]},
19571            {"type":"listItem","content":[
19572              {"type":"paragraph","content":[
19573                {"type":"text","text":"Second","marks":[{"type":"em"}]},
19574                {"type":"hardBreak"},
19575                {"type":"text","text":"more details"}
19576              ]}
19577            ]}
19578          ]}
19579        ]}"#;
19580        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19581        let md = adf_to_markdown(&doc).unwrap();
19582        let rt = markdown_to_adf(&md).unwrap();
19583
19584        assert_eq!(rt.content.len(), 1);
19585        assert_eq!(rt.content[0].node_type, "bulletList");
19586        let items = rt.content[0].content.as_ref().unwrap();
19587        assert_eq!(items.len(), 2);
19588
19589        let p1 = items[0].content.as_ref().unwrap()[0]
19590            .content
19591            .as_ref()
19592            .unwrap();
19593        assert_eq!(p1[0].text.as_deref(), Some("First"));
19594        assert_eq!(p1[2].text.as_deref(), Some("details"));
19595
19596        let p2 = items[1].content.as_ref().unwrap()[0]
19597            .content
19598            .as_ref()
19599            .unwrap();
19600        assert_eq!(p2[0].text.as_deref(), Some("Second"));
19601        assert_eq!(p2[2].text.as_deref(), Some("more details"));
19602    }
19603
19604    #[test]
19605    fn issue_388_ordered_list_hardbreak_jfm_indentation() {
19606        // Verify the JFM output has properly indented continuation lines.
19607        let adf_json = r#"{"version":1,"type":"doc","content":[
19608          {"type":"orderedList","attrs":{"order":1},"content":[
19609            {"type":"listItem","content":[
19610              {"type":"paragraph","content":[
19611                {"type":"text","text":"heading","marks":[{"type":"strong"}]},
19612                {"type":"hardBreak"},
19613                {"type":"text","text":"body"}
19614              ]}
19615            ]}
19616          ]}
19617        ]}"#;
19618        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19619        let md = adf_to_markdown(&doc).unwrap();
19620        assert!(
19621            md.contains("1. **heading**\\\n  body"),
19622            "Continuation should be indented, got:\n{md}"
19623        );
19624    }
19625
19626    #[test]
19627    fn issue_388_ordered_list_hardbreak_from_jfm() {
19628        // Direct JFM → ADF: ordered list with hardBreak continuation.
19629        let md = "1. **bold**\\\n  continued\n2. **also bold**\\\n  also continued\n";
19630        let doc = markdown_to_adf(md).unwrap();
19631
19632        assert_eq!(doc.content.len(), 1);
19633        assert_eq!(doc.content[0].node_type, "orderedList");
19634        let items = doc.content[0].content.as_ref().unwrap();
19635        assert_eq!(items.len(), 2);
19636
19637        let p1 = items[0].content.as_ref().unwrap()[0]
19638            .content
19639            .as_ref()
19640            .unwrap();
19641        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19642        assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19643        assert_eq!(p1[0].text.as_deref(), Some("bold"));
19644        assert_eq!(p1[2].text.as_deref(), Some("continued"));
19645
19646        let p2 = items[1].content.as_ref().unwrap()[0]
19647            .content
19648            .as_ref()
19649            .unwrap();
19650        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19651        assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19652    }
19653
19654    #[test]
19655    fn issue_388_bullet_list_hardbreak_from_jfm() {
19656        // Direct JFM → ADF: bullet list with hardBreak continuation.
19657        let md = "- first\\\n  second\n- third\\\n  fourth\n";
19658        let doc = markdown_to_adf(md).unwrap();
19659
19660        assert_eq!(doc.content.len(), 1);
19661        assert_eq!(doc.content[0].node_type, "bulletList");
19662        let items = doc.content[0].content.as_ref().unwrap();
19663        assert_eq!(items.len(), 2);
19664
19665        for (i, expected) in [("first", "second"), ("third", "fourth")]
19666            .iter()
19667            .enumerate()
19668        {
19669            let p = items[i].content.as_ref().unwrap()[0]
19670                .content
19671                .as_ref()
19672                .unwrap();
19673            let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19674            assert_eq!(types, vec!["text", "hardBreak", "text"]);
19675            assert_eq!(p[0].text.as_deref(), Some(expected.0));
19676            assert_eq!(p[2].text.as_deref(), Some(expected.1));
19677        }
19678    }
19679
19680    #[test]
19681    fn issue_433_heading_hardbreak_roundtrips() {
19682        // Issue #433: hardBreak inside heading splits into heading + paragraph.
19683        let adf_json = r#"{"version":1,"type":"doc","content":[{
19684          "type":"heading",
19685          "attrs":{"level":1},
19686          "content":[
19687            {"type":"text","text":"Line one"},
19688            {"type":"hardBreak"},
19689            {"type":"text","text":"Line two"}
19690          ]
19691        }]}"#;
19692        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19693        let md = adf_to_markdown(&doc).unwrap();
19694        let rt = markdown_to_adf(&md).unwrap();
19695
19696        assert_eq!(
19697            rt.content.len(),
19698            1,
19699            "Should remain a single heading, got {} blocks",
19700            rt.content.len()
19701        );
19702        assert_eq!(rt.content[0].node_type, "heading");
19703        let inlines = rt.content[0].content.as_ref().unwrap();
19704        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19705        assert_eq!(
19706            types,
19707            vec!["text", "hardBreak", "text"],
19708            "hardBreak should be preserved, got: {types:?}"
19709        );
19710        assert_eq!(inlines[0].text.as_deref(), Some("Line one"));
19711        assert_eq!(inlines[2].text.as_deref(), Some("Line two"));
19712    }
19713
19714    #[test]
19715    fn issue_433_heading_hardbreak_jfm_indentation() {
19716        // Verify the JFM output has properly indented continuation lines.
19717        let adf_json = r#"{"version":1,"type":"doc","content":[{
19718          "type":"heading",
19719          "attrs":{"level":2},
19720          "content":[
19721            {"type":"text","text":"Title"},
19722            {"type":"hardBreak"},
19723            {"type":"text","text":"Subtitle"}
19724          ]
19725        }]}"#;
19726        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19727        let md = adf_to_markdown(&doc).unwrap();
19728        assert!(
19729            md.contains("## Title\\\n  Subtitle"),
19730            "Continuation should be indented, got:\n{md}"
19731        );
19732    }
19733
19734    #[test]
19735    fn issue_433_heading_hardbreak_from_jfm() {
19736        // Direct JFM → ADF: heading with hardBreak continuation.
19737        let md = "# First\\\n  Second\n";
19738        let doc = markdown_to_adf(md).unwrap();
19739
19740        assert_eq!(doc.content.len(), 1);
19741        assert_eq!(doc.content[0].node_type, "heading");
19742        let inlines = doc.content[0].content.as_ref().unwrap();
19743        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19744        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19745        assert_eq!(inlines[0].text.as_deref(), Some("First"));
19746        assert_eq!(inlines[2].text.as_deref(), Some("Second"));
19747    }
19748
19749    #[test]
19750    fn issue_433_heading_consecutive_hardbreaks_roundtrip() {
19751        // Consecutive hardBreaks in a heading.
19752        let adf_json = r#"{"version":1,"type":"doc","content":[{
19753          "type":"heading",
19754          "attrs":{"level":3},
19755          "content":[
19756            {"type":"text","text":"A"},
19757            {"type":"hardBreak"},
19758            {"type":"hardBreak"},
19759            {"type":"text","text":"B"}
19760          ]
19761        }]}"#;
19762        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19763        let md = adf_to_markdown(&doc).unwrap();
19764        let rt = markdown_to_adf(&md).unwrap();
19765
19766        assert_eq!(rt.content.len(), 1, "Should remain a single heading");
19767        assert_eq!(rt.content[0].node_type, "heading");
19768        let inlines = rt.content[0].content.as_ref().unwrap();
19769        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19770        assert_eq!(types, vec!["text", "hardBreak", "hardBreak", "text"]);
19771    }
19772
19773    #[test]
19774    fn issue_433_heading_with_strong_and_hardbreak_roundtrips() {
19775        // Heading with strong-marked text + hardBreak + plain text.
19776        let adf_json = r#"{"version":1,"type":"doc","content":[{
19777          "type":"heading",
19778          "attrs":{"level":1},
19779          "content":[
19780            {"type":"text","text":"Bold title","marks":[{"type":"strong"}]},
19781            {"type":"hardBreak"},
19782            {"type":"text","text":"plain continuation"}
19783          ]
19784        }]}"#;
19785        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19786        let md = adf_to_markdown(&doc).unwrap();
19787        let rt = markdown_to_adf(&md).unwrap();
19788
19789        assert_eq!(rt.content.len(), 1);
19790        assert_eq!(rt.content[0].node_type, "heading");
19791        let inlines = rt.content[0].content.as_ref().unwrap();
19792        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19793        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19794        assert_eq!(inlines[0].text.as_deref(), Some("Bold title"));
19795        assert_eq!(inlines[2].text.as_deref(), Some("plain continuation"));
19796    }
19797
19798    #[test]
19799    fn issue_433_heading_with_link_and_hardbreak_roundtrips() {
19800        // Real-world pattern: heading with link + hardBreak + text.
19801        let adf_json = r#"{"version":1,"type":"doc","content":[{
19802          "type":"heading",
19803          "attrs":{"level":1},
19804          "content":[
19805            {"type":"text","text":"Click here","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
19806            {"type":"hardBreak"},
19807            {"type":"text","text":"Subtitle text"}
19808          ]
19809        }]}"#;
19810        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19811        let md = adf_to_markdown(&doc).unwrap();
19812        let rt = markdown_to_adf(&md).unwrap();
19813
19814        assert_eq!(rt.content.len(), 1);
19815        assert_eq!(rt.content[0].node_type, "heading");
19816        let inlines = rt.content[0].content.as_ref().unwrap();
19817        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19818        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19819        assert_eq!(inlines[2].text.as_deref(), Some("Subtitle text"));
19820    }
19821
19822    #[test]
19823    fn has_trailing_hard_break_backslash() {
19824        assert!(has_trailing_hard_break("text\\"));
19825        assert!(has_trailing_hard_break("**bold**\\"));
19826    }
19827
19828    #[test]
19829    fn has_trailing_hard_break_trailing_spaces() {
19830        assert!(has_trailing_hard_break("text  "));
19831        assert!(has_trailing_hard_break("word   "));
19832    }
19833
19834    #[test]
19835    fn has_trailing_hard_break_false() {
19836        assert!(!has_trailing_hard_break("plain text"));
19837        assert!(!has_trailing_hard_break("text "));
19838        assert!(!has_trailing_hard_break(""));
19839    }
19840
19841    #[test]
19842    fn collect_hardbreak_continuations_collects_indented() {
19843        // A line ending with `\` followed by 2-space-indented continuation.
19844        // Only one line is collected because the result no longer ends with `\`.
19845        let input = "first\\\n  second\n  third\n";
19846        let mut parser = MarkdownParser::new(input);
19847        parser.advance(); // skip first line
19848        let mut text = "first\\".to_string();
19849        parser.collect_hardbreak_continuations(&mut text);
19850        assert_eq!(text, "first\\\nsecond");
19851    }
19852
19853    #[test]
19854    fn collect_hardbreak_continuations_stops_at_non_indented() {
19855        let input = "first\\\nnot indented\n";
19856        let mut parser = MarkdownParser::new(input);
19857        parser.advance();
19858        let mut text = "first\\".to_string();
19859        parser.collect_hardbreak_continuations(&mut text);
19860        // Should NOT collect the non-indented line
19861        assert_eq!(text, "first\\");
19862    }
19863
19864    #[test]
19865    fn collect_hardbreak_continuations_no_trailing_break() {
19866        // If the text doesn't end with a hardBreak marker, nothing is collected.
19867        let input = "plain\n  indented\n";
19868        let mut parser = MarkdownParser::new(input);
19869        parser.advance();
19870        let mut text = "plain".to_string();
19871        parser.collect_hardbreak_continuations(&mut text);
19872        assert_eq!(text, "plain");
19873    }
19874
19875    #[test]
19876    fn collect_hardbreak_continuations_chained() {
19877        // Multiple continuation lines chained via repeated hardBreaks.
19878        let input = "a\\\n  b\\\n  c\\\n  d\n";
19879        let mut parser = MarkdownParser::new(input);
19880        parser.advance();
19881        let mut text = "a\\".to_string();
19882        parser.collect_hardbreak_continuations(&mut text);
19883        assert_eq!(text, "a\\\nb\\\nc\\\nd");
19884    }
19885
19886    #[test]
19887    fn collect_hardbreak_continuations_stops_before_image_line() {
19888        // An indented continuation that starts with `![` (mediaSingle syntax)
19889        // must NOT be swallowed as a paragraph continuation (issue #490).
19890        let input = "text\\\n  ![](url){type=file id=x}\n";
19891        let mut parser = MarkdownParser::new(input);
19892        parser.advance(); // skip first line
19893        let mut text = "text\\".to_string();
19894        parser.collect_hardbreak_continuations(&mut text);
19895        // The image line should NOT have been consumed.
19896        assert_eq!(text, "text\\");
19897        // Parser should still be on the image line (not past it).
19898        assert!(!parser.at_end());
19899        assert!(parser.current_line().contains("![](url)"));
19900    }
19901
19902    #[test]
19903    fn is_block_level_continuation_marker_positive_cases() {
19904        // Each marker that forces `collect_hardbreak_continuations` to stop.
19905        assert!(is_block_level_continuation_marker("![](url)"));
19906        assert!(is_block_level_continuation_marker("```ruby"));
19907        assert!(is_block_level_continuation_marker(":::panel{type=info}"));
19908    }
19909
19910    #[test]
19911    fn is_block_level_continuation_marker_negative_cases() {
19912        // Plain continuation text must NOT look like a block-level marker.
19913        assert!(!is_block_level_continuation_marker("plain text"));
19914        assert!(!is_block_level_continuation_marker("- nested item"));
19915        assert!(!is_block_level_continuation_marker("continuation\\"));
19916        assert!(!is_block_level_continuation_marker(""));
19917        // Double-colon `::` is not a container directive.
19918        assert!(!is_block_level_continuation_marker("::partial"));
19919        // Single backticks are inline code, not a fence.
19920        assert!(!is_block_level_continuation_marker("`inline`"));
19921    }
19922
19923    #[test]
19924    fn collect_hardbreak_continuations_stops_before_code_fence() {
19925        // Issue #552: An indented continuation that opens a fenced code block
19926        // must NOT be swallowed as a paragraph continuation — it has to stay
19927        // available for `try_code_block` on the next parse iteration.
19928        let input = "text\\\n  ```ruby\n  Foo::Bar::Baz\n  ```\n";
19929        let mut parser = MarkdownParser::new(input);
19930        parser.advance();
19931        let mut text = "text\\".to_string();
19932        parser.collect_hardbreak_continuations(&mut text);
19933        assert_eq!(text, "text\\");
19934        assert!(!parser.at_end());
19935        assert!(parser.current_line().starts_with("  ```"));
19936    }
19937
19938    #[test]
19939    fn collect_hardbreak_continuations_stops_before_container_directive() {
19940        // Issue #552: An indented continuation that opens a `:::` container
19941        // directive (panel, expand, etc.) must also stay available for the
19942        // directive parser.
19943        let input = "text\\\n  :::panel{type=info}\n  body\n  :::\n";
19944        let mut parser = MarkdownParser::new(input);
19945        parser.advance();
19946        let mut text = "text\\".to_string();
19947        parser.collect_hardbreak_continuations(&mut text);
19948        assert_eq!(text, "text\\");
19949        assert!(!parser.at_end());
19950        assert!(parser.current_line().contains(":::panel"));
19951    }
19952
19953    #[test]
19954    fn collect_hardbreak_continuations_stops_before_indented_code_fence() {
19955        // Variant: extra leading whitespace on the code-fence line (so the
19956        // stripped tail is `  ```` rather than a bare ` ``` `) must still be
19957        // recognised by the `trim_start().starts_with("```")` check.
19958        let input = "text\\\n     ```text\n     :fire:\n     ```\n";
19959        let mut parser = MarkdownParser::new(input);
19960        parser.advance();
19961        let mut text = "text\\".to_string();
19962        parser.collect_hardbreak_continuations(&mut text);
19963        assert_eq!(text, "text\\");
19964        assert!(!parser.at_end());
19965        assert!(parser.current_line().contains("```text"));
19966    }
19967
19968    #[test]
19969    fn ordered_list_with_sub_content_after_hardbreak() {
19970        // Exercises the sub-content collection loop in parse_ordered_list
19971        // (lines 339-347) with a hardBreak item that also has a nested list.
19972        let adf_json = r#"{"version":1,"type":"doc","content":[
19973          {"type":"orderedList","attrs":{"order":1},"content":[
19974            {"type":"listItem","content":[
19975              {"type":"paragraph","content":[
19976                {"type":"text","text":"parent"},
19977                {"type":"hardBreak"},
19978                {"type":"text","text":"continued"}
19979              ]},
19980              {"type":"bulletList","content":[
19981                {"type":"listItem","content":[
19982                  {"type":"paragraph","content":[
19983                    {"type":"text","text":"child"}
19984                  ]}
19985                ]}
19986              ]}
19987            ]}
19988          ]}
19989        ]}"#;
19990        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19991        let md = adf_to_markdown(&doc).unwrap();
19992        let rt = markdown_to_adf(&md).unwrap();
19993
19994        assert_eq!(rt.content.len(), 1);
19995        assert_eq!(rt.content[0].node_type, "orderedList");
19996        let item_content = rt.content[0].content.as_ref().unwrap()[0]
19997            .content
19998            .as_ref()
19999            .unwrap();
20000        // Paragraph with hardBreak
20001        let p = item_content[0].content.as_ref().unwrap();
20002        let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
20003        assert_eq!(types, vec!["text", "hardBreak", "text"]);
20004        assert_eq!(p[0].text.as_deref(), Some("parent"));
20005        assert_eq!(p[2].text.as_deref(), Some("continued"));
20006        // Nested bullet list
20007        assert_eq!(item_content[1].node_type, "bulletList");
20008    }
20009
20010    #[test]
20011    fn render_list_item_content_no_content() {
20012        // A listItem with content: None should produce just a newline.
20013        let item = AdfNode {
20014            node_type: "listItem".to_string(),
20015            attrs: None,
20016            content: None,
20017            text: None,
20018            marks: None,
20019            local_id: None,
20020            parameters: None,
20021        };
20022        let mut output = String::new();
20023        let opts = RenderOptions::default();
20024        render_list_item_content(&item, &mut output, &opts);
20025        assert_eq!(output, "\n");
20026    }
20027
20028    #[test]
20029    fn render_list_item_content_empty_content() {
20030        // A listItem with content: Some(vec![]) should produce just a newline.
20031        let item = AdfNode::list_item(vec![]);
20032        let mut output = String::new();
20033        let opts = RenderOptions::default();
20034        render_list_item_content(&item, &mut output, &opts);
20035        assert_eq!(output, "\n");
20036    }
20037
20038    #[test]
20039    fn plus_bullet_paragraph_roundtrips() {
20040        // Paragraph starting with "+ " must round-trip without becoming
20041        // a bullet list.
20042        let adf_json = r#"{"version":1,"type":"doc","content":[
20043          {"type":"paragraph","content":[
20044            {"type":"text","text":"+ plus"}
20045          ]}
20046        ]}"#;
20047        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20048        let md = adf_to_markdown(&doc).unwrap();
20049        let rt = markdown_to_adf(&md).unwrap();
20050        assert_eq!(rt.content[0].node_type, "paragraph");
20051        assert_eq!(
20052            rt.content[0].content.as_ref().unwrap()[0]
20053                .text
20054                .as_deref()
20055                .unwrap(),
20056            "+ plus"
20057        );
20058    }
20059
20060    // ---- Issue #430 tests: mediaSingle inside listItem ----
20061
20062    #[test]
20063    fn issue_430_file_media_in_bullet_list_roundtrip() {
20064        // Issue #430: mediaSingle (type:file) as direct child of listItem
20065        // in a bulletList must survive round-trip.
20066        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20067          {"type":"listItem","content":[{
20068            "type":"mediaSingle",
20069            "attrs":{"layout":"center","width":1009,"widthType":"pixel"},
20070            "content":[{
20071              "type":"media",
20072              "attrs":{"collection":"contentId-123","height":576,"id":"00066e8e-554e-4d7e-af59-a0ef2888bdb6","type":"file","width":1009}
20073            }]
20074          }]}
20075        ]}]}"#;
20076        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20077        let md = adf_to_markdown(&doc).unwrap();
20078        let rt = markdown_to_adf(&md).unwrap();
20079
20080        let list = &rt.content[0];
20081        assert_eq!(list.node_type, "bulletList");
20082        let item = &list.content.as_ref().unwrap()[0];
20083        assert_eq!(item.node_type, "listItem");
20084        let ms = &item.content.as_ref().unwrap()[0];
20085        assert_eq!(ms.node_type, "mediaSingle");
20086        let ms_attrs = ms.attrs.as_ref().unwrap();
20087        assert_eq!(ms_attrs["layout"], "center");
20088        assert_eq!(ms_attrs["width"], 1009);
20089        assert_eq!(ms_attrs["widthType"], "pixel");
20090        let media = &ms.content.as_ref().unwrap()[0];
20091        assert_eq!(media.node_type, "media");
20092        let m_attrs = media.attrs.as_ref().unwrap();
20093        assert_eq!(m_attrs["type"], "file");
20094        assert_eq!(m_attrs["id"], "00066e8e-554e-4d7e-af59-a0ef2888bdb6");
20095        assert_eq!(m_attrs["collection"], "contentId-123");
20096        assert_eq!(m_attrs["height"], 576);
20097        assert_eq!(m_attrs["width"], 1009);
20098    }
20099
20100    #[test]
20101    fn issue_430_file_media_in_ordered_list_roundtrip() {
20102        // Same as above but inside an orderedList.
20103        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20104          {"type":"listItem","content":[{
20105            "type":"mediaSingle",
20106            "attrs":{"layout":"center"},
20107            "content":[{
20108              "type":"media",
20109              "attrs":{"type":"file","id":"abc-123","collection":"contentId-456","height":100,"width":200}
20110            }]
20111          }]}
20112        ]}]}"#;
20113        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20114        let md = adf_to_markdown(&doc).unwrap();
20115        let rt = markdown_to_adf(&md).unwrap();
20116
20117        let list = &rt.content[0];
20118        assert_eq!(list.node_type, "orderedList");
20119        let item = &list.content.as_ref().unwrap()[0];
20120        assert_eq!(item.node_type, "listItem");
20121        let ms = &item.content.as_ref().unwrap()[0];
20122        assert_eq!(ms.node_type, "mediaSingle");
20123        let media = &ms.content.as_ref().unwrap()[0];
20124        assert_eq!(media.node_type, "media");
20125        let m_attrs = media.attrs.as_ref().unwrap();
20126        assert_eq!(m_attrs["type"], "file");
20127        assert_eq!(m_attrs["id"], "abc-123");
20128        assert_eq!(m_attrs["collection"], "contentId-456");
20129    }
20130
20131    #[test]
20132    fn issue_430_external_media_in_bullet_list_roundtrip() {
20133        // External image (type:external) inside a bullet list item.
20134        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20135          {"type":"listItem","content":[{
20136            "type":"mediaSingle",
20137            "attrs":{"layout":"center"},
20138            "content":[{
20139              "type":"media",
20140              "attrs":{"type":"external","url":"https://example.com/img.png","alt":"Photo"}
20141            }]
20142          }]}
20143        ]}]}"#;
20144        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20145        let md = adf_to_markdown(&doc).unwrap();
20146        let rt = markdown_to_adf(&md).unwrap();
20147
20148        let list = &rt.content[0];
20149        assert_eq!(list.node_type, "bulletList");
20150        let item = &list.content.as_ref().unwrap()[0];
20151        let ms = &item.content.as_ref().unwrap()[0];
20152        assert_eq!(ms.node_type, "mediaSingle");
20153        let media = &ms.content.as_ref().unwrap()[0];
20154        assert_eq!(media.node_type, "media");
20155        let m_attrs = media.attrs.as_ref().unwrap();
20156        assert_eq!(m_attrs["type"], "external");
20157        assert_eq!(m_attrs["url"], "https://example.com/img.png");
20158    }
20159
20160    #[test]
20161    fn issue_430_media_with_paragraph_siblings_in_list_item() {
20162        // listItem containing a paragraph followed by a mediaSingle.
20163        // Both children must survive round-trip.
20164        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20165          {"type":"listItem","content":[
20166            {"type":"paragraph","content":[{"type":"text","text":"Caption:"}]},
20167            {"type":"mediaSingle","attrs":{"layout":"center"},
20168             "content":[{"type":"media","attrs":{"type":"file","id":"img-001","collection":"col-1","height":50,"width":100}}]}
20169          ]}
20170        ]}]}"#;
20171        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20172        let md = adf_to_markdown(&doc).unwrap();
20173        let rt = markdown_to_adf(&md).unwrap();
20174
20175        let item = &rt.content[0].content.as_ref().unwrap()[0];
20176        let children = item.content.as_ref().unwrap();
20177        assert_eq!(children.len(), 2, "expected 2 children in listItem");
20178        assert_eq!(children[0].node_type, "paragraph");
20179        assert_eq!(children[1].node_type, "mediaSingle");
20180        let media = &children[1].content.as_ref().unwrap()[0];
20181        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-001");
20182    }
20183
20184    #[test]
20185    fn issue_430_multiple_media_in_list_items() {
20186        // Multiple list items each containing mediaSingle.
20187        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20188          {"type":"listItem","content":[{
20189            "type":"mediaSingle","attrs":{"layout":"center"},
20190            "content":[{"type":"media","attrs":{"type":"file","id":"img-a","collection":"c1","height":10,"width":20}}]
20191          }]},
20192          {"type":"listItem","content":[{
20193            "type":"mediaSingle","attrs":{"layout":"center"},
20194            "content":[{"type":"media","attrs":{"type":"file","id":"img-b","collection":"c2","height":30,"width":40}}]
20195          }]}
20196        ]}]}"#;
20197        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20198        let md = adf_to_markdown(&doc).unwrap();
20199        let rt = markdown_to_adf(&md).unwrap();
20200
20201        let items = rt.content[0].content.as_ref().unwrap();
20202        assert_eq!(items.len(), 2);
20203        for (i, expected_id) in [("img-a", "c1"), ("img-b", "c2")].iter().enumerate() {
20204            let ms = &items[i].content.as_ref().unwrap()[0];
20205            assert_eq!(ms.node_type, "mediaSingle");
20206            let m_attrs = ms.content.as_ref().unwrap()[0].attrs.as_ref().unwrap();
20207            assert_eq!(m_attrs["id"], expected_id.0);
20208            assert_eq!(m_attrs["collection"], expected_id.1);
20209        }
20210    }
20211
20212    #[test]
20213    fn issue_430_jfm_to_adf_media_in_bullet_item() {
20214        // Parse JFM directly: image syntax on the first line of a bullet item
20215        // must produce mediaSingle, not a paragraph with corrupted text.
20216        let md = "- ![](){type=file id=test-id collection=col-1 height=100 width=200}\n";
20217        let doc = markdown_to_adf(md).unwrap();
20218
20219        let list = &doc.content[0];
20220        assert_eq!(list.node_type, "bulletList");
20221        let item = &list.content.as_ref().unwrap()[0];
20222        let ms = &item.content.as_ref().unwrap()[0];
20223        assert_eq!(
20224            ms.node_type, "mediaSingle",
20225            "expected mediaSingle, got {}",
20226            ms.node_type
20227        );
20228        let media = &ms.content.as_ref().unwrap()[0];
20229        assert_eq!(media.node_type, "media");
20230        let m_attrs = media.attrs.as_ref().unwrap();
20231        assert_eq!(m_attrs["type"], "file");
20232        assert_eq!(m_attrs["id"], "test-id");
20233    }
20234
20235    #[test]
20236    fn issue_430_jfm_to_adf_media_in_ordered_item() {
20237        // Parse JFM directly: image syntax on the first line of an ordered list item.
20238        let md = "1. ![alt text](https://example.com/photo.jpg)\n";
20239        let doc = markdown_to_adf(md).unwrap();
20240
20241        let list = &doc.content[0];
20242        assert_eq!(list.node_type, "orderedList");
20243        let item = &list.content.as_ref().unwrap()[0];
20244        let ms = &item.content.as_ref().unwrap()[0];
20245        assert_eq!(
20246            ms.node_type, "mediaSingle",
20247            "expected mediaSingle, got {}",
20248            ms.node_type
20249        );
20250    }
20251
20252    #[test]
20253    fn issue_430_media_then_paragraph_in_bullet_list_roundtrip() {
20254        // listItem with mediaSingle as first child followed by a paragraph.
20255        // Exercises the sub_lines non-empty path when first_node is mediaSingle.
20256        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20257          {"type":"listItem","content":[
20258            {"type":"mediaSingle","attrs":{"layout":"center"},
20259             "content":[{"type":"media","attrs":{"type":"file","id":"img-first","collection":"col-1","height":50,"width":100}}]},
20260            {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
20261          ]}
20262        ]}]}"#;
20263        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20264        let md = adf_to_markdown(&doc).unwrap();
20265        let rt = markdown_to_adf(&md).unwrap();
20266
20267        let item = &rt.content[0].content.as_ref().unwrap()[0];
20268        let children = item.content.as_ref().unwrap();
20269        assert_eq!(children.len(), 2, "expected 2 children in listItem");
20270        assert_eq!(children[0].node_type, "mediaSingle");
20271        let media = &children[0].content.as_ref().unwrap()[0];
20272        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-first");
20273        assert_eq!(children[1].node_type, "paragraph");
20274    }
20275
20276    #[test]
20277    fn issue_430_media_then_paragraph_in_ordered_list_roundtrip() {
20278        // Same as above but for ordered lists.
20279        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20280          {"type":"listItem","content":[
20281            {"type":"mediaSingle","attrs":{"layout":"center"},
20282             "content":[{"type":"media","attrs":{"type":"file","id":"img-ord","collection":"col-2","height":60,"width":120}}]},
20283            {"type":"paragraph","content":[{"type":"text","text":"Description"}]}
20284          ]}
20285        ]}]}"#;
20286        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20287        let md = adf_to_markdown(&doc).unwrap();
20288        let rt = markdown_to_adf(&md).unwrap();
20289
20290        let item = &rt.content[0].content.as_ref().unwrap()[0];
20291        let children = item.content.as_ref().unwrap();
20292        assert_eq!(children.len(), 2, "expected 2 children in listItem");
20293        assert_eq!(children[0].node_type, "mediaSingle");
20294        assert_eq!(children[1].node_type, "paragraph");
20295    }
20296
20297    #[test]
20298    fn issue_430_external_media_with_width_type_roundtrip() {
20299        // External image with widthType attr must survive round-trip.
20300        let adf_json = r#"{"version":1,"type":"doc","content":[{
20301          "type":"mediaSingle",
20302          "attrs":{"layout":"wide","width":800,"widthType":"pixel"},
20303          "content":[{
20304            "type":"media",
20305            "attrs":{"type":"external","url":"https://example.com/photo.png","alt":"wide photo"}
20306          }]
20307        }]}"#;
20308        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20309        let md = adf_to_markdown(&doc).unwrap();
20310        assert!(
20311            md.contains("widthType=pixel"),
20312            "expected widthType=pixel in markdown, got: {md}"
20313        );
20314        let rt = markdown_to_adf(&md).unwrap();
20315        let ms = &rt.content[0];
20316        assert_eq!(ms.node_type, "mediaSingle");
20317        let ms_attrs = ms.attrs.as_ref().unwrap();
20318        assert_eq!(ms_attrs["widthType"], "pixel");
20319        assert_eq!(ms_attrs["width"], 800);
20320        assert_eq!(ms_attrs["layout"], "wide");
20321    }
20322
20323    // ── Issue #490: mediaSingle after hardBreak in listItem ─────
20324
20325    #[test]
20326    fn issue_490_paragraph_with_hardbreak_then_media_single_roundtrip() {
20327        // Reproducer from issue #490: paragraph with trailing hardBreak
20328        // followed by mediaSingle inside a listItem.
20329        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20330          {"type":"listItem","content":[
20331            {"type":"paragraph","content":[
20332              {"type":"text","text":"Item with image:"},
20333              {"type":"hardBreak"}
20334            ]},
20335            {"type":"mediaSingle","attrs":{"layout":"center","width":400,"widthType":"pixel"},
20336             "content":[{"type":"media","attrs":{
20337               "id":"aabbccdd-1234-5678-abcd-aabbccdd1234",
20338               "type":"file",
20339               "collection":"contentId-123456",
20340               "width":800,
20341               "height":600
20342             }}]}
20343          ]}
20344        ]}]}"#;
20345        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20346        let md = adf_to_markdown(&doc).unwrap();
20347        let rt = markdown_to_adf(&md).unwrap();
20348
20349        let item = &rt.content[0].content.as_ref().unwrap()[0];
20350        let children = item.content.as_ref().unwrap();
20351        assert_eq!(children.len(), 2, "expected 2 children in listItem");
20352        assert_eq!(children[0].node_type, "paragraph");
20353        assert_eq!(
20354            children[1].node_type, "mediaSingle",
20355            "expected mediaSingle, got {:?}",
20356            children[1].node_type
20357        );
20358        let media = &children[1].content.as_ref().unwrap()[0];
20359        let m_attrs = media.attrs.as_ref().unwrap();
20360        assert_eq!(m_attrs["id"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
20361        assert_eq!(m_attrs["collection"], "contentId-123456");
20362        assert_eq!(m_attrs["height"], 600);
20363        assert_eq!(m_attrs["width"], 800);
20364    }
20365
20366    #[test]
20367    fn issue_490_paragraph_with_hardbreak_then_media_single_ordered_list() {
20368        // Same scenario but in an ordered list.
20369        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20370          {"type":"listItem","content":[
20371            {"type":"paragraph","content":[
20372              {"type":"text","text":"Step with screenshot:"},
20373              {"type":"hardBreak"}
20374            ]},
20375            {"type":"mediaSingle","attrs":{"layout":"center"},
20376             "content":[{"type":"media","attrs":{
20377               "id":"ord-media-id","type":"file","collection":"col-ord","width":640,"height":480
20378             }}]}
20379          ]}
20380        ]}]}"#;
20381        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20382        let md = adf_to_markdown(&doc).unwrap();
20383        let rt = markdown_to_adf(&md).unwrap();
20384
20385        let item = &rt.content[0].content.as_ref().unwrap()[0];
20386        let children = item.content.as_ref().unwrap();
20387        assert_eq!(children.len(), 2, "expected 2 children in listItem");
20388        assert_eq!(children[0].node_type, "paragraph");
20389        assert_eq!(children[1].node_type, "mediaSingle");
20390        let media = &children[1].content.as_ref().unwrap()[0];
20391        assert_eq!(media.attrs.as_ref().unwrap()["id"], "ord-media-id");
20392    }
20393
20394    #[test]
20395    fn issue_490_hardbreak_continuation_does_not_swallow_media_line() {
20396        // Directly tests that collect_hardbreak_continuations stops before
20397        // an indented mediaSingle line.
20398        let md = "- Item with image:\\\n  ![](){type=file id=test-490 collection=col height=100 width=200}\n";
20399        let doc = markdown_to_adf(md).unwrap();
20400
20401        let item = &doc.content[0].content.as_ref().unwrap()[0];
20402        let children = item.content.as_ref().unwrap();
20403        assert_eq!(children.len(), 2, "expected 2 children in listItem");
20404        assert_eq!(children[0].node_type, "paragraph");
20405        assert_eq!(
20406            children[1].node_type, "mediaSingle",
20407            "expected mediaSingle as second child, got {:?}",
20408            children[1].node_type
20409        );
20410        let media = &children[1].content.as_ref().unwrap()[0];
20411        assert_eq!(media.attrs.as_ref().unwrap()["id"], "test-490");
20412    }
20413
20414    #[test]
20415    fn issue_490_hardbreak_continuation_still_works_for_text() {
20416        // Ensure regular hardBreak continuations still work after the fix.
20417        let md = "- first line\\\n  second line\n";
20418        let doc = markdown_to_adf(md).unwrap();
20419
20420        let item = &doc.content[0].content.as_ref().unwrap()[0];
20421        let children = item.content.as_ref().unwrap();
20422        assert_eq!(
20423            children.len(),
20424            1,
20425            "expected 1 child (paragraph) in listItem"
20426        );
20427        assert_eq!(children[0].node_type, "paragraph");
20428        let inlines = children[0].content.as_ref().unwrap();
20429        // Should contain: text("first line"), hardBreak, text("second line")
20430        assert_eq!(inlines.len(), 3);
20431        assert_eq!(inlines[0].node_type, "text");
20432        assert_eq!(inlines[1].node_type, "hardBreak");
20433        assert_eq!(inlines[2].node_type, "text");
20434    }
20435
20436    #[test]
20437    fn issue_490_external_media_after_hardbreak_roundtrip() {
20438        // External image (URL-based) after a paragraph with hardBreak.
20439        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20440          {"type":"listItem","content":[
20441            {"type":"paragraph","content":[
20442              {"type":"text","text":"See image:"},
20443              {"type":"hardBreak"}
20444            ]},
20445            {"type":"mediaSingle","attrs":{"layout":"center"},
20446             "content":[{"type":"media","attrs":{
20447               "type":"external","url":"https://example.com/photo.png","alt":"photo"
20448             }}]}
20449          ]}
20450        ]}]}"#;
20451        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20452        let md = adf_to_markdown(&doc).unwrap();
20453        let rt = markdown_to_adf(&md).unwrap();
20454
20455        let item = &rt.content[0].content.as_ref().unwrap()[0];
20456        let children = item.content.as_ref().unwrap();
20457        assert_eq!(children.len(), 2);
20458        assert_eq!(children[0].node_type, "paragraph");
20459        assert_eq!(children[1].node_type, "mediaSingle");
20460        let media = &children[1].content.as_ref().unwrap()[0];
20461        let m_attrs = media.attrs.as_ref().unwrap();
20462        assert_eq!(m_attrs["url"], "https://example.com/photo.png");
20463    }
20464
20465    #[test]
20466    fn issue_490_multiple_hardbreaks_then_media_single() {
20467        // Paragraph with multiple hardBreaks, then mediaSingle.
20468        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20469          {"type":"listItem","content":[
20470            {"type":"paragraph","content":[
20471              {"type":"text","text":"line one"},
20472              {"type":"hardBreak"},
20473              {"type":"text","text":"line two"},
20474              {"type":"hardBreak"}
20475            ]},
20476            {"type":"mediaSingle","attrs":{"layout":"center"},
20477             "content":[{"type":"media","attrs":{
20478               "type":"file","id":"multi-hb","collection":"col-m","width":320,"height":240
20479             }}]}
20480          ]}
20481        ]}]}"#;
20482        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20483        let md = adf_to_markdown(&doc).unwrap();
20484        let rt = markdown_to_adf(&md).unwrap();
20485
20486        let item = &rt.content[0].content.as_ref().unwrap()[0];
20487        let children = item.content.as_ref().unwrap();
20488        assert_eq!(children.len(), 2, "expected paragraph + mediaSingle");
20489        assert_eq!(children[0].node_type, "paragraph");
20490        assert_eq!(children[1].node_type, "mediaSingle");
20491        let media = &children[1].content.as_ref().unwrap()[0];
20492        assert_eq!(media.attrs.as_ref().unwrap()["id"], "multi-hb");
20493    }
20494
20495    // ── Issue #525: listItem localId dropped when content includes mediaSingle ──
20496
20497    #[test]
20498    fn issue_525_listitem_localid_with_mediasingle_roundtrip() {
20499        // Exact reproducer from issue #525: listItem with UUID localId whose
20500        // content includes mediaSingle + paragraph + nested bulletList.
20501        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"}]}]}]}]}]}]}"#;
20502        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20503        let md = adf_to_markdown(&doc).unwrap();
20504        let rt = markdown_to_adf(&md).unwrap();
20505
20506        let list = &rt.content[0];
20507        assert_eq!(list.node_type, "bulletList");
20508        let item = &list.content.as_ref().unwrap()[0];
20509        // The localId must be preserved on the listItem.
20510        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20511        assert_eq!(
20512            item_attrs["localId"], "aabbccdd-1234-5678-abcd-000000000001",
20513            "listItem localId must survive round-trip"
20514        );
20515        let children = item.content.as_ref().unwrap();
20516        assert_eq!(
20517            children.len(),
20518            3,
20519            "expected mediaSingle + paragraph + bulletList"
20520        );
20521        assert_eq!(children[0].node_type, "mediaSingle");
20522        assert_eq!(children[1].node_type, "paragraph");
20523        assert_eq!(children[2].node_type, "bulletList");
20524    }
20525
20526    #[test]
20527    fn issue_525_listitem_localid_with_mediasingle_only() {
20528        // Minimal case: listItem with localId whose sole child is mediaSingle.
20529        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20530          {"type":"listItem","attrs":{"localId":"li-media-only"},"content":[
20531            {"type":"mediaSingle","attrs":{"layout":"center"},
20532             "content":[{"type":"media","attrs":{"type":"file","id":"m-001","collection":"c1","height":50,"width":100}}]}
20533          ]}
20534        ]}]}"#;
20535        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20536        let md = adf_to_markdown(&doc).unwrap();
20537        let rt = markdown_to_adf(&md).unwrap();
20538
20539        let item = &rt.content[0].content.as_ref().unwrap()[0];
20540        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20541        assert_eq!(
20542            item_attrs["localId"], "li-media-only",
20543            "listItem localId must survive when sole child is mediaSingle"
20544        );
20545        assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20546    }
20547
20548    #[test]
20549    fn issue_525_listitem_localid_with_external_media() {
20550        // External image (URL-based) as first child with listItem localId.
20551        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20552          {"type":"listItem","attrs":{"localId":"li-ext-media"},"content":[
20553            {"type":"mediaSingle","attrs":{"layout":"center"},
20554             "content":[{"type":"media","attrs":{"type":"external","url":"https://example.com/img.png","alt":"photo"}}]}
20555          ]}
20556        ]}]}"#;
20557        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20558        let md = adf_to_markdown(&doc).unwrap();
20559        let rt = markdown_to_adf(&md).unwrap();
20560
20561        let item = &rt.content[0].content.as_ref().unwrap()[0];
20562        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20563        assert_eq!(
20564            item_attrs["localId"], "li-ext-media",
20565            "listItem localId must survive with external mediaSingle"
20566        );
20567    }
20568
20569    #[test]
20570    fn issue_525_listitem_localid_with_mediasingle_in_ordered_list() {
20571        // Same bug in an ordered list.
20572        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20573          {"type":"listItem","attrs":{"localId":"li-ord-media"},"content":[
20574            {"type":"mediaSingle","attrs":{"layout":"center","width":200,"widthType":"pixel"},
20575             "content":[{"type":"media","attrs":{"type":"file","id":"ord-m-001","collection":"col-ord","height":80,"width":160}}]},
20576            {"type":"paragraph","content":[{"type":"text","text":"ordered item text"}]}
20577          ]}
20578        ]}]}"#;
20579        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20580        let md = adf_to_markdown(&doc).unwrap();
20581        let rt = markdown_to_adf(&md).unwrap();
20582
20583        let item = &rt.content[0].content.as_ref().unwrap()[0];
20584        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20585        assert_eq!(
20586            item_attrs["localId"], "li-ord-media",
20587            "listItem localId must survive in ordered list with mediaSingle"
20588        );
20589        let children = item.content.as_ref().unwrap();
20590        assert_eq!(children[0].node_type, "mediaSingle");
20591        assert_eq!(children[1].node_type, "paragraph");
20592    }
20593
20594    #[test]
20595    fn issue_525_jfm_localid_on_mediasingle_line_parses_correctly() {
20596        // Verify JFM→ADF: trailing {localId=...} on a mediaSingle line
20597        // is assigned to the listItem, not the media node.
20598        let md = "- ![](){type=file id=test-525 collection=col height=100 width=200 mediaWidth=100 widthType=pixel} {localId=li-jfm-525}\n";
20599        let doc = markdown_to_adf(md).unwrap();
20600
20601        let item = &doc.content[0].content.as_ref().unwrap()[0];
20602        let item_attrs = item
20603            .attrs
20604            .as_ref()
20605            .expect("listItem attrs must be present from JFM");
20606        assert_eq!(item_attrs["localId"], "li-jfm-525");
20607        assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20608    }
20609
20610    #[test]
20611    fn issue_525_encoding_emits_localid_on_mediasingle_line() {
20612        // Verify the ADF→JFM encoding: localId appears on the same line
20613        // as the mediaSingle image syntax.
20614        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20615          {"type":"listItem","attrs":{"localId":"li-emit-check"},"content":[
20616            {"type":"mediaSingle","attrs":{"layout":"center"},
20617             "content":[{"type":"media","attrs":{"type":"file","id":"m-emit","collection":"c-emit","height":10,"width":20}}]}
20618          ]}
20619        ]}]}"#;
20620        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20621        let md = adf_to_markdown(&doc).unwrap();
20622        assert!(
20623            md.contains("{localId=li-emit-check}"),
20624            "expected localId in JFM output, got: {md}"
20625        );
20626        // The localId must be on the same line as the image
20627        for line in md.lines() {
20628            if line.contains("![") {
20629                assert!(
20630                    line.contains("localId=li-emit-check"),
20631                    "localId must be on the same line as the image: {line}"
20632                );
20633            }
20634        }
20635    }
20636
20637    // ── Placeholder node tests ────────────────────────────────────
20638
20639    #[test]
20640    fn adf_placeholder_to_markdown() {
20641        let doc = AdfDocument {
20642            version: 1,
20643            doc_type: "doc".to_string(),
20644            content: vec![AdfNode::paragraph(vec![AdfNode::placeholder(
20645                "Type something here",
20646            )])],
20647        };
20648        let md = adf_to_markdown(&doc).unwrap();
20649        assert!(
20650            md.contains(":placeholder[Type something here]"),
20651            "expected :placeholder directive, got: {md}"
20652        );
20653    }
20654
20655    #[test]
20656    fn markdown_placeholder_to_adf() {
20657        let doc = markdown_to_adf("Before :placeholder[Enter name] after").unwrap();
20658        let content = doc.content[0].content.as_ref().unwrap();
20659        assert_eq!(content[1].node_type, "placeholder");
20660        let attrs = content[1].attrs.as_ref().unwrap();
20661        assert_eq!(attrs["text"], "Enter name");
20662    }
20663
20664    #[test]
20665    fn placeholder_round_trip() {
20666        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"placeholder","attrs":{"text":"Type something here"}}]}]}"#;
20667        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20668        let md = adf_to_markdown(&doc).unwrap();
20669        let rt = markdown_to_adf(&md).unwrap();
20670        let content = rt.content[0].content.as_ref().unwrap();
20671        assert_eq!(content.len(), 1);
20672        assert_eq!(content[0].node_type, "placeholder");
20673        let attrs = content[0].attrs.as_ref().unwrap();
20674        assert_eq!(attrs["text"], "Type something here");
20675    }
20676
20677    #[test]
20678    fn placeholder_empty_text() {
20679        let doc = AdfDocument {
20680            version: 1,
20681            doc_type: "doc".to_string(),
20682            content: vec![AdfNode::paragraph(vec![AdfNode::placeholder("")])],
20683        };
20684        let md = adf_to_markdown(&doc).unwrap();
20685        assert!(
20686            md.contains(":placeholder[]"),
20687            "expected empty placeholder directive, got: {md}"
20688        );
20689        let rt = markdown_to_adf(&md).unwrap();
20690        let content = rt.content[0].content.as_ref().unwrap();
20691        assert_eq!(content[0].node_type, "placeholder");
20692        assert_eq!(content[0].attrs.as_ref().unwrap()["text"], "");
20693    }
20694
20695    #[test]
20696    fn placeholder_with_surrounding_text() {
20697        let md = "Click :placeholder[here] to continue\n";
20698        let doc = markdown_to_adf(md).unwrap();
20699        let content = doc.content[0].content.as_ref().unwrap();
20700        assert_eq!(content[0].text.as_deref(), Some("Click "));
20701        assert_eq!(content[1].node_type, "placeholder");
20702        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "here");
20703        assert_eq!(content[2].text.as_deref(), Some(" to continue"));
20704    }
20705
20706    #[test]
20707    fn placeholder_missing_attrs() {
20708        // Placeholder node with no attrs should not panic
20709        let doc = AdfDocument {
20710            version: 1,
20711            doc_type: "doc".to_string(),
20712            content: vec![AdfNode::paragraph(vec![AdfNode {
20713                node_type: "placeholder".to_string(),
20714                attrs: None,
20715                content: None,
20716                text: None,
20717                marks: None,
20718                local_id: None,
20719                parameters: None,
20720            }])],
20721        };
20722        let md = adf_to_markdown(&doc).unwrap();
20723        // With no attrs, nothing is emitted for the placeholder
20724        assert!(!md.contains("placeholder"));
20725    }
20726
20727    // Issue #446: mention in table+list loses id and misplaces localId
20728    #[test]
20729    fn mention_in_table_bullet_list_preserves_id_and_local_id() {
20730        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":" "}]}]}]}]}]}]}]}"#;
20731        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20732        let md = adf_to_markdown(&doc).unwrap();
20733        let rt = markdown_to_adf(&md).unwrap();
20734
20735        // Navigate: doc → table → tableRow → tableCell → bulletList → listItem → paragraph
20736        let cell = &rt.content[0].content.as_ref().unwrap()[0]
20737            .content
20738            .as_ref()
20739            .unwrap()[0];
20740        let list = &cell.content.as_ref().unwrap()[0];
20741        let list_item = &list.content.as_ref().unwrap()[0];
20742
20743        // listItem must NOT have a localId attribute
20744        assert!(
20745            list_item
20746                .attrs
20747                .as_ref()
20748                .and_then(|a| a.get("localId"))
20749                .is_none(),
20750            "localId should stay on the mention, not the listItem"
20751        );
20752
20753        let para = &list_item.content.as_ref().unwrap()[0];
20754        let inlines = para.content.as_ref().unwrap();
20755
20756        // Should have: text("prefix text "), mention, text(" ")
20757        assert_eq!(inlines.len(), 3, "expected 3 inline nodes, got {inlines:?}");
20758
20759        assert_eq!(inlines[0].node_type, "text");
20760        assert_eq!(inlines[0].text.as_deref(), Some("prefix text "));
20761
20762        assert_eq!(inlines[1].node_type, "mention");
20763        let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20764        assert_eq!(
20765            mention_attrs["id"], "aabbccdd11223344aabbccdd",
20766            "mention id must be preserved"
20767        );
20768        assert_eq!(
20769            mention_attrs["localId"], "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
20770            "mention localId must be preserved"
20771        );
20772        assert_eq!(mention_attrs["text"], "@Alice Example");
20773
20774        assert_eq!(inlines[2].node_type, "text");
20775        assert_eq!(inlines[2].text.as_deref(), Some(" "));
20776    }
20777
20778    #[test]
20779    fn mention_in_bullet_list_preserves_id_and_local_id() {
20780        // Same bug outside of a table context
20781        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":" "}]}]}]}]}"#;
20782        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20783        let md = adf_to_markdown(&doc).unwrap();
20784        let rt = markdown_to_adf(&md).unwrap();
20785
20786        let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20787        assert!(
20788            list_item
20789                .attrs
20790                .as_ref()
20791                .and_then(|a| a.get("localId"))
20792                .is_none(),
20793            "localId should stay on the mention, not the listItem"
20794        );
20795
20796        let para = &list_item.content.as_ref().unwrap()[0];
20797        let inlines = para.content.as_ref().unwrap();
20798        assert_eq!(inlines[0].node_type, "mention");
20799        let mention_attrs = inlines[0].attrs.as_ref().unwrap();
20800        assert_eq!(mention_attrs["id"], "user123");
20801        assert_eq!(
20802            mention_attrs["localId"],
20803            "11111111-2222-3333-4444-555555555555"
20804        );
20805    }
20806
20807    #[test]
20808    fn mention_in_ordered_list_preserves_id_and_local_id() {
20809        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"}}]}]}]}]}"#;
20810        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20811        let md = adf_to_markdown(&doc).unwrap();
20812        let rt = markdown_to_adf(&md).unwrap();
20813
20814        let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20815        assert!(
20816            list_item
20817                .attrs
20818                .as_ref()
20819                .and_then(|a| a.get("localId"))
20820                .is_none(),
20821            "localId should stay on the mention, not the listItem"
20822        );
20823
20824        let para = &list_item.content.as_ref().unwrap()[0];
20825        let inlines = para.content.as_ref().unwrap();
20826        assert_eq!(inlines[1].node_type, "mention");
20827        let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20828        assert_eq!(mention_attrs["id"], "xyz");
20829        assert_eq!(mention_attrs["localId"], "aaaa-bbbb");
20830    }
20831
20832    #[test]
20833    fn list_item_own_local_id_with_mention_both_preserved() {
20834        // When a listItem has its own localId AND contains a mention with localId,
20835        // both should be preserved independently.
20836        let md = "- hello :mention[@Eve]{id=e1 localId=mention-lid} {localId=item-lid}\n";
20837        let doc = markdown_to_adf(md).unwrap();
20838        let list_item = &doc.content[0].content.as_ref().unwrap()[0];
20839
20840        // listItem should have its own localId
20841        let item_attrs = list_item.attrs.as_ref().unwrap();
20842        assert_eq!(item_attrs["localId"], "item-lid");
20843
20844        // mention should have its own localId
20845        let para = &list_item.content.as_ref().unwrap()[0];
20846        let inlines = para.content.as_ref().unwrap();
20847        let mention = inlines.iter().find(|n| n.node_type == "mention").unwrap();
20848        let mention_attrs = mention.attrs.as_ref().unwrap();
20849        assert_eq!(mention_attrs["id"], "e1");
20850        assert_eq!(mention_attrs["localId"], "mention-lid");
20851    }
20852
20853    #[test]
20854    fn extract_trailing_local_id_ignores_directive_attrs() {
20855        // Directly test the helper: a line ending with a directive's {…}
20856        // should NOT be treated as a trailing localId.
20857        let line = "text :mention[@X]{id=abc localId=uuid}";
20858        let (text, lid, plid) = extract_trailing_local_id(line);
20859        assert_eq!(text, line, "text should be unchanged");
20860        assert!(
20861            lid.is_none(),
20862            "should not extract localId from directive attrs"
20863        );
20864        assert!(plid.is_none());
20865    }
20866
20867    #[test]
20868    fn extract_trailing_local_id_matches_standalone_block() {
20869        // A standalone trailing {localId=…} separated by whitespace should still work.
20870        let line = "some text {localId=abc-123}";
20871        let (text, lid, plid) = extract_trailing_local_id(line);
20872        assert_eq!(text, "some text");
20873        assert_eq!(lid.as_deref(), Some("abc-123"));
20874        assert!(plid.is_none());
20875    }
20876
20877    // --- Issue #454: literal newline in text node inside listItem paragraph ---
20878
20879    #[test]
20880    fn newline_in_text_node_roundtrips_in_bullet_list() {
20881        // A text node containing a literal \n inside a bullet list item
20882        // must round-trip as a single text node with the embedded newline
20883        // preserved, not split into multiple paragraphs or hardBreak nodes.
20884        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"}]}]}]}]}"#;
20885        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20886        let md = adf_to_markdown(&doc).unwrap();
20887        let rt = markdown_to_adf(&md).unwrap();
20888
20889        // Should still be a single bulletList with one listItem
20890        assert_eq!(rt.content.len(), 1);
20891        let list = &rt.content[0];
20892        assert_eq!(list.node_type, "bulletList");
20893        let items = list.content.as_ref().unwrap();
20894        assert_eq!(items.len(), 1);
20895
20896        // The listItem should have exactly one paragraph child
20897        let item_content = items[0].content.as_ref().unwrap();
20898        assert_eq!(
20899            item_content.len(),
20900            1,
20901            "listItem should have exactly one paragraph"
20902        );
20903        assert_eq!(item_content[0].node_type, "paragraph");
20904
20905        // The embedded newline must survive as a single text node
20906        let inlines = item_content[0].content.as_ref().unwrap();
20907        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20908        assert_eq!(
20909            types,
20910            vec!["text", "hardBreak", "text"],
20911            "embedded newline should stay in a single text node, not produce extra hardBreaks"
20912        );
20913        assert_eq!(
20914            inlines[2].text.as_deref(),
20915            Some("first command\nsecond command")
20916        );
20917    }
20918
20919    #[test]
20920    fn newline_in_text_node_roundtrips_in_ordered_list() {
20921        // Same as above but in an ordered list.
20922        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"}]}]}]}]}"#;
20923        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20924        let md = adf_to_markdown(&doc).unwrap();
20925        let rt = markdown_to_adf(&md).unwrap();
20926
20927        let list = &rt.content[0];
20928        assert_eq!(list.node_type, "orderedList");
20929        let items = list.content.as_ref().unwrap();
20930        assert_eq!(items.len(), 1);
20931
20932        let item_content = items[0].content.as_ref().unwrap();
20933        assert_eq!(item_content.len(), 1);
20934        assert_eq!(item_content[0].node_type, "paragraph");
20935
20936        let inlines = item_content[0].content.as_ref().unwrap();
20937        assert_eq!(inlines.len(), 1);
20938        assert_eq!(inlines[0].node_type, "text");
20939        assert_eq!(inlines[0].text.as_deref(), Some("first\nsecond"));
20940    }
20941
20942    #[test]
20943    fn newline_in_text_node_roundtrips_in_paragraph() {
20944        // A text node with \n in a top-level paragraph should render as
20945        // escaped \n and round-trip back to a single text node.
20946        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello\nworld"}]}]}"#;
20947        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20948        let md = adf_to_markdown(&doc).unwrap();
20949        assert!(
20950            md.contains("hello\\nworld"),
20951            "newline in text node should render as escaped \\n: {md:?}"
20952        );
20953
20954        let rt = markdown_to_adf(&md).unwrap();
20955        let inlines = rt.content[0].content.as_ref().unwrap();
20956        assert_eq!(inlines.len(), 1);
20957        assert_eq!(inlines[0].text.as_deref(), Some("hello\nworld"));
20958    }
20959
20960    #[test]
20961    fn multiple_newlines_in_text_node_roundtrip() {
20962        // Multiple \n characters should each round-trip within the same text node.
20963        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a\nb\nc"}]}]}]}]}"#;
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
20968        let item_content = rt.content[0].content.as_ref().unwrap()[0]
20969            .content
20970            .as_ref()
20971            .unwrap();
20972        assert_eq!(item_content.len(), 1);
20973
20974        let inlines = item_content[0].content.as_ref().unwrap();
20975        assert_eq!(inlines.len(), 1);
20976        assert_eq!(inlines[0].text.as_deref(), Some("a\nb\nc"));
20977    }
20978
20979    #[test]
20980    fn newline_in_marked_text_node_roundtrips() {
20981        // A bold text node with \n should round-trip preserving both
20982        // the marks and the embedded newline.
20983        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold\ntext","marks":[{"type":"strong"}]}]}]}"#;
20984        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20985        let md = adf_to_markdown(&doc).unwrap();
20986        assert!(
20987            md.contains("**bold\\ntext**"),
20988            "bold text with embedded newline should stay in one marked run: {md:?}"
20989        );
20990
20991        let rt = markdown_to_adf(&md).unwrap();
20992        let inlines = rt.content[0].content.as_ref().unwrap();
20993        assert_eq!(inlines.len(), 1);
20994        assert_eq!(inlines[0].text.as_deref(), Some("bold\ntext"));
20995        assert!(inlines[0]
20996            .marks
20997            .as_ref()
20998            .unwrap()
20999            .iter()
21000            .any(|m| m.mark_type == "strong"));
21001    }
21002
21003    #[test]
21004    fn trailing_newline_in_text_node_roundtrips() {
21005        // A text node ending with \n should round-trip preserving the
21006        // trailing newline.
21007        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"trailing\n"}]}]}"#;
21008        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21009        let md = adf_to_markdown(&doc).unwrap();
21010        assert!(
21011            md.contains("trailing\\n"),
21012            "trailing newline should be escaped: {md:?}"
21013        );
21014
21015        let rt = markdown_to_adf(&md).unwrap();
21016        let inlines = rt.content[0].content.as_ref().unwrap();
21017        assert_eq!(inlines.len(), 1);
21018        assert_eq!(inlines[0].text.as_deref(), Some("trailing\n"));
21019    }
21020
21021    #[test]
21022    fn hardbreak_and_embedded_newline_are_distinct() {
21023        // A hardBreak node and an embedded \n in a text node must not be
21024        // conflated — each must round-trip to its original form.
21025        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"}]}]}"#;
21026        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21027        let md = adf_to_markdown(&doc).unwrap();
21028        let rt = markdown_to_adf(&md).unwrap();
21029
21030        let inlines = rt.content[0].content.as_ref().unwrap();
21031        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21032        assert_eq!(
21033            types,
21034            vec!["text", "hardBreak", "text", "hardBreak", "text"]
21035        );
21036        assert_eq!(inlines[0].text.as_deref(), Some("before"));
21037        assert_eq!(inlines[2].text.as_deref(), Some("mid\ndle"));
21038        assert_eq!(inlines[4].text.as_deref(), Some("after"));
21039    }
21040
21041    // ---- Issue #472 tests ----
21042
21043    #[test]
21044    fn issue_472_bullet_list_trailing_hardbreak_roundtrips() {
21045        // Issue #472: trailing hardBreak at end of listItem paragraph must
21046        // not split the parent bulletList on round-trip.
21047        let adf_json = r#"{"version":1,"type":"doc","content":[
21048          {"type":"bulletList","content":[
21049            {"type":"listItem","content":[
21050              {"type":"paragraph","content":[
21051                {"type":"text","text":"First item"},
21052                {"type":"hardBreak"}
21053              ]}
21054            ]},
21055            {"type":"listItem","content":[
21056              {"type":"paragraph","content":[
21057                {"type":"text","text":"Second item"}
21058              ]}
21059            ]}
21060          ]}
21061        ]}"#;
21062        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21063        let md = adf_to_markdown(&doc).unwrap();
21064        let rt = markdown_to_adf(&md).unwrap();
21065
21066        // Must remain a single bulletList
21067        assert_eq!(
21068            rt.content.len(),
21069            1,
21070            "Should be 1 block (bulletList), got {}",
21071            rt.content.len()
21072        );
21073        assert_eq!(rt.content[0].node_type, "bulletList");
21074        let items = rt.content[0].content.as_ref().unwrap();
21075        assert_eq!(
21076            items.len(),
21077            2,
21078            "Should have 2 listItems, got {}",
21079            items.len()
21080        );
21081
21082        // First item: text + hardBreak (trailing)
21083        let p1 = items[0].content.as_ref().unwrap()[0]
21084            .content
21085            .as_ref()
21086            .unwrap();
21087        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21088        assert_eq!(types1, vec!["text", "hardBreak"]);
21089        assert_eq!(p1[0].text.as_deref(), Some("First item"));
21090
21091        // Second item: text only
21092        let p2 = items[1].content.as_ref().unwrap()[0]
21093            .content
21094            .as_ref()
21095            .unwrap();
21096        assert_eq!(p2[0].text.as_deref(), Some("Second item"));
21097    }
21098
21099    #[test]
21100    fn issue_472_ordered_list_trailing_hardbreak_roundtrips() {
21101        // Ordered list variant of issue #472.
21102        let adf_json = r#"{"version":1,"type":"doc","content":[
21103          {"type":"orderedList","attrs":{"order":1},"content":[
21104            {"type":"listItem","content":[
21105              {"type":"paragraph","content":[
21106                {"type":"text","text":"Alpha"},
21107                {"type":"hardBreak"}
21108              ]}
21109            ]},
21110            {"type":"listItem","content":[
21111              {"type":"paragraph","content":[
21112                {"type":"text","text":"Beta"}
21113              ]}
21114            ]}
21115          ]}
21116        ]}"#;
21117        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21118        let md = adf_to_markdown(&doc).unwrap();
21119        let rt = markdown_to_adf(&md).unwrap();
21120
21121        assert_eq!(rt.content.len(), 1);
21122        assert_eq!(rt.content[0].node_type, "orderedList");
21123        let items = rt.content[0].content.as_ref().unwrap();
21124        assert_eq!(items.len(), 2);
21125
21126        let p1 = items[0].content.as_ref().unwrap()[0]
21127            .content
21128            .as_ref()
21129            .unwrap();
21130        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21131        assert_eq!(types1, vec!["text", "hardBreak"]);
21132        assert_eq!(p1[0].text.as_deref(), Some("Alpha"));
21133    }
21134
21135    #[test]
21136    fn issue_472_trailing_hardbreak_jfm_no_blank_line() {
21137        // The rendered JFM must not contain a blank line after the
21138        // trailing hardBreak — that would split the list.
21139        let adf_json = r#"{"version":1,"type":"doc","content":[
21140          {"type":"bulletList","content":[
21141            {"type":"listItem","content":[
21142              {"type":"paragraph","content":[
21143                {"type":"text","text":"Hello"},
21144                {"type":"hardBreak"}
21145              ]}
21146            ]},
21147            {"type":"listItem","content":[
21148              {"type":"paragraph","content":[
21149                {"type":"text","text":"World"}
21150              ]}
21151            ]}
21152          ]}
21153        ]}"#;
21154        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21155        let md = adf_to_markdown(&doc).unwrap();
21156
21157        // Should produce "- Hello\\n- World\n" (no blank line between items).
21158        assert_eq!(md, "- Hello\\\n- World\n");
21159    }
21160
21161    #[test]
21162    fn issue_472_multiple_trailing_hardbreaks_roundtrip() {
21163        // Multiple trailing hardBreaks at the end of a listItem paragraph.
21164        let adf_json = r#"{"version":1,"type":"doc","content":[
21165          {"type":"bulletList","content":[
21166            {"type":"listItem","content":[
21167              {"type":"paragraph","content":[
21168                {"type":"text","text":"Item"},
21169                {"type":"hardBreak"},
21170                {"type":"hardBreak"}
21171              ]}
21172            ]},
21173            {"type":"listItem","content":[
21174              {"type":"paragraph","content":[
21175                {"type":"text","text":"Next"}
21176              ]}
21177            ]}
21178          ]}
21179        ]}"#;
21180        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21181        let md = adf_to_markdown(&doc).unwrap();
21182        let rt = markdown_to_adf(&md).unwrap();
21183
21184        // Must remain a single bulletList
21185        assert_eq!(rt.content.len(), 1);
21186        assert_eq!(rt.content[0].node_type, "bulletList");
21187        let items = rt.content[0].content.as_ref().unwrap();
21188        assert_eq!(items.len(), 2);
21189
21190        // First item should preserve both hardBreaks
21191        let p1 = items[0].content.as_ref().unwrap()[0]
21192            .content
21193            .as_ref()
21194            .unwrap();
21195        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21196        assert_eq!(types1, vec!["text", "hardBreak", "hardBreak"]);
21197    }
21198
21199    #[test]
21200    fn issue_472_hardbreak_mid_and_trailing_roundtrip() {
21201        // A hardBreak in the middle AND at the end of a listItem paragraph.
21202        let adf_json = r#"{"version":1,"type":"doc","content":[
21203          {"type":"bulletList","content":[
21204            {"type":"listItem","content":[
21205              {"type":"paragraph","content":[
21206                {"type":"text","text":"Line one"},
21207                {"type":"hardBreak"},
21208                {"type":"text","text":"Line two"},
21209                {"type":"hardBreak"}
21210              ]}
21211            ]},
21212            {"type":"listItem","content":[
21213              {"type":"paragraph","content":[
21214                {"type":"text","text":"Other item"}
21215              ]}
21216            ]}
21217          ]}
21218        ]}"#;
21219        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21220        let md = adf_to_markdown(&doc).unwrap();
21221        let rt = markdown_to_adf(&md).unwrap();
21222
21223        assert_eq!(rt.content.len(), 1);
21224        assert_eq!(rt.content[0].node_type, "bulletList");
21225        let items = rt.content[0].content.as_ref().unwrap();
21226        assert_eq!(items.len(), 2);
21227
21228        let p1 = items[0].content.as_ref().unwrap()[0]
21229            .content
21230            .as_ref()
21231            .unwrap();
21232        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21233        assert_eq!(types1, vec!["text", "hardBreak", "text", "hardBreak"]);
21234        assert_eq!(p1[0].text.as_deref(), Some("Line one"));
21235        assert_eq!(p1[2].text.as_deref(), Some("Line two"));
21236    }
21237
21238    #[test]
21239    fn issue_472_only_hardbreak_in_listitem_paragraph() {
21240        // Edge case: paragraph contains only a hardBreak, no text.
21241        let adf_json = r#"{"version":1,"type":"doc","content":[
21242          {"type":"bulletList","content":[
21243            {"type":"listItem","content":[
21244              {"type":"paragraph","content":[
21245                {"type":"hardBreak"}
21246              ]}
21247            ]},
21248            {"type":"listItem","content":[
21249              {"type":"paragraph","content":[
21250                {"type":"text","text":"After"}
21251              ]}
21252            ]}
21253          ]}
21254        ]}"#;
21255        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21256        let md = adf_to_markdown(&doc).unwrap();
21257        let rt = markdown_to_adf(&md).unwrap();
21258
21259        // Must remain a single bulletList with 2 items
21260        assert_eq!(rt.content.len(), 1);
21261        assert_eq!(rt.content[0].node_type, "bulletList");
21262        let items = rt.content[0].content.as_ref().unwrap();
21263        assert_eq!(items.len(), 2);
21264    }
21265
21266    #[test]
21267    fn issue_472_three_items_middle_has_trailing_hardbreak() {
21268        // Three-item list where only the middle item has a trailing hardBreak.
21269        let adf_json = r#"{"version":1,"type":"doc","content":[
21270          {"type":"bulletList","content":[
21271            {"type":"listItem","content":[
21272              {"type":"paragraph","content":[
21273                {"type":"text","text":"First"}
21274              ]}
21275            ]},
21276            {"type":"listItem","content":[
21277              {"type":"paragraph","content":[
21278                {"type":"text","text":"Second"},
21279                {"type":"hardBreak"}
21280              ]}
21281            ]},
21282            {"type":"listItem","content":[
21283              {"type":"paragraph","content":[
21284                {"type":"text","text":"Third"}
21285              ]}
21286            ]}
21287          ]}
21288        ]}"#;
21289        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21290        let md = adf_to_markdown(&doc).unwrap();
21291        let rt = markdown_to_adf(&md).unwrap();
21292
21293        assert_eq!(rt.content.len(), 1);
21294        assert_eq!(rt.content[0].node_type, "bulletList");
21295        let items = rt.content[0].content.as_ref().unwrap();
21296        assert_eq!(items.len(), 3);
21297        assert_eq!(
21298            items[0].content.as_ref().unwrap()[0]
21299                .content
21300                .as_ref()
21301                .unwrap()[0]
21302                .text
21303                .as_deref(),
21304            Some("First")
21305        );
21306        assert_eq!(
21307            items[2].content.as_ref().unwrap()[0]
21308                .content
21309                .as_ref()
21310                .unwrap()[0]
21311                .text
21312                .as_deref(),
21313            Some("Third")
21314        );
21315    }
21316
21317    // ── Issue #494: trailing space-only text node after hardBreak ────
21318
21319    #[test]
21320    fn issue_494_space_after_hardbreak_roundtrip() {
21321        // The original reproducer from issue #494: a single space text
21322        // node following a hardBreak is silently dropped on round-trip.
21323        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21324          {"type":"text","text":"Some text"},
21325          {"type":"hardBreak"},
21326          {"type":"text","text":" "}
21327        ]}]}"#;
21328        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21329        let md = adf_to_markdown(&doc).unwrap();
21330        let rt = markdown_to_adf(&md).unwrap();
21331        let inlines = rt.content[0].content.as_ref().unwrap();
21332        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21333        assert_eq!(
21334            types,
21335            vec!["text", "hardBreak", "text"],
21336            "space-only text node after hardBreak should survive round-trip"
21337        );
21338        assert_eq!(inlines[2].text.as_deref(), Some(" "));
21339    }
21340
21341    #[test]
21342    fn issue_494_multiple_spaces_after_hardbreak_roundtrip() {
21343        // Multiple spaces after hardBreak should also survive.
21344        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21345          {"type":"text","text":"Hello"},
21346          {"type":"hardBreak"},
21347          {"type":"text","text":"   "}
21348        ]}]}"#;
21349        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21350        let md = adf_to_markdown(&doc).unwrap();
21351        let rt = markdown_to_adf(&md).unwrap();
21352        let inlines = rt.content[0].content.as_ref().unwrap();
21353        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21354        assert_eq!(
21355            types,
21356            vec!["text", "hardBreak", "text"],
21357            "multi-space text node after hardBreak should survive round-trip"
21358        );
21359        assert_eq!(inlines[2].text.as_deref(), Some("   "));
21360    }
21361
21362    #[test]
21363    fn issue_494_space_then_text_after_hardbreak_roundtrip() {
21364        // Space followed by real text after hardBreak — the space should
21365        // be preserved as part of the text node.
21366        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21367          {"type":"text","text":"Before"},
21368          {"type":"hardBreak"},
21369          {"type":"text","text":" After"}
21370        ]}]}"#;
21371        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21372        let md = adf_to_markdown(&doc).unwrap();
21373        let rt = markdown_to_adf(&md).unwrap();
21374        let inlines = rt.content[0].content.as_ref().unwrap();
21375        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21376        assert_eq!(types, vec!["text", "hardBreak", "text"]);
21377        assert_eq!(inlines[2].text.as_deref(), Some(" After"));
21378    }
21379
21380    #[test]
21381    fn issue_494_hardbreak_then_space_then_hardbreak_roundtrip() {
21382        // Space sandwiched between two hardBreaks.
21383        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21384          {"type":"text","text":"A"},
21385          {"type":"hardBreak"},
21386          {"type":"text","text":" "},
21387          {"type":"hardBreak"},
21388          {"type":"text","text":"B"}
21389        ]}]}"#;
21390        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21391        let md = adf_to_markdown(&doc).unwrap();
21392        let rt = markdown_to_adf(&md).unwrap();
21393        let inlines = rt.content[0].content.as_ref().unwrap();
21394        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21395        assert_eq!(
21396            types,
21397            vec!["text", "hardBreak", "text", "hardBreak", "text"],
21398            "space between two hardBreaks should survive round-trip"
21399        );
21400        assert_eq!(inlines[2].text.as_deref(), Some(" "));
21401        assert_eq!(inlines[4].text.as_deref(), Some("B"));
21402    }
21403
21404    #[test]
21405    fn issue_494_trailing_space_hardbreak_style_not_confused() {
21406        // A plain paragraph break (blank line) should still work after
21407        // a line that does NOT end with a hardBreak marker.
21408        let md = "first paragraph\n\nsecond paragraph\n";
21409        let doc = markdown_to_adf(md).unwrap();
21410        assert_eq!(
21411            doc.content.len(),
21412            2,
21413            "blank line should still separate paragraphs"
21414        );
21415    }
21416
21417    #[test]
21418    fn issue_494_space_after_trailing_space_hardbreak_roundtrip() {
21419        // Same bug but with trailing-space style hardBreak (two spaces
21420        // before newline) instead of backslash style.
21421        let md = "line one  \n   \n";
21422        // The above is: "line one" + trailing-space hardBreak + continuation
21423        // line "   " (2-space indent + 1 space content).  The space-only
21424        // continuation should not be treated as a blank paragraph break.
21425        let doc = markdown_to_adf(md).unwrap();
21426        let inlines = doc.content[0].content.as_ref().unwrap();
21427        let has_text_after_break = inlines.iter().any(|n| {
21428            n.node_type == "text"
21429                && n.text
21430                    .as_deref()
21431                    .is_some_and(|t| t.trim().is_empty() && !t.is_empty())
21432        });
21433        assert!(
21434            has_text_after_break || inlines.len() >= 2,
21435            "space-only line after trailing-space hardBreak should be preserved"
21436        );
21437    }
21438
21439    #[test]
21440    fn issue_494_space_after_hardbreak_in_list_item_roundtrip() {
21441        // Exercises the same bug inside a list item context.
21442        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21443          {"type":"listItem","content":[{"type":"paragraph","content":[
21444            {"type":"text","text":"item"},
21445            {"type":"hardBreak"},
21446            {"type":"text","text":" "}
21447          ]}]}
21448        ]}]}"#;
21449        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21450        let md = adf_to_markdown(&doc).unwrap();
21451        let rt = markdown_to_adf(&md).unwrap();
21452        let list = &rt.content[0];
21453        let item = &list.content.as_ref().unwrap()[0];
21454        let para = &item.content.as_ref().unwrap()[0];
21455        let inlines = para.content.as_ref().unwrap();
21456        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21457        assert_eq!(
21458            types,
21459            vec!["text", "hardBreak", "text"],
21460            "space after hardBreak in list item should survive round-trip"
21461        );
21462        assert_eq!(inlines[2].text.as_deref(), Some(" "));
21463    }
21464
21465    // ── Issue #510: trailing spaces in text node should not become hardBreak ──
21466
21467    #[test]
21468    fn issue_510_trailing_double_space_paragraph_roundtrip() {
21469        // Two trailing spaces in a text node must survive round-trip without
21470        // being converted to a hardBreak or merging the next paragraph.
21471        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"}]}]}"#;
21472        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21473        let md = adf_to_markdown(&doc).unwrap();
21474        let rt = markdown_to_adf(&md).unwrap();
21475
21476        // Must produce two separate paragraphs
21477        assert_eq!(
21478            rt.content.len(),
21479            2,
21480            "should produce two paragraphs, got: {}",
21481            rt.content.len()
21482        );
21483        assert_eq!(rt.content[0].node_type, "paragraph");
21484        assert_eq!(rt.content[1].node_type, "paragraph");
21485
21486        // First paragraph text preserves trailing spaces
21487        let p1 = rt.content[0].content.as_ref().unwrap();
21488        assert_eq!(
21489            p1[0].text.as_deref(),
21490            Some("first paragraph with trailing spaces  "),
21491            "trailing spaces should be preserved in first paragraph"
21492        );
21493
21494        // Second paragraph is intact
21495        let p2 = rt.content[1].content.as_ref().unwrap();
21496        assert_eq!(p2[0].text.as_deref(), Some("second paragraph"));
21497
21498        // No hardBreak nodes should exist
21499        let all_types: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21500        assert!(
21501            !all_types.contains(&"hardBreak"),
21502            "trailing spaces should not produce hardBreak, got: {all_types:?}"
21503        );
21504    }
21505
21506    #[test]
21507    fn issue_510_trailing_triple_space_roundtrip() {
21508        // Three trailing spaces also must not become a hardBreak.
21509        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"text   "}]},{"type":"paragraph","content":[{"type":"text","text":"next"}]}]}"#;
21510        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21511        let md = adf_to_markdown(&doc).unwrap();
21512        let rt = markdown_to_adf(&md).unwrap();
21513
21514        assert_eq!(rt.content.len(), 2, "should still be two paragraphs");
21515        let p1 = rt.content[0].content.as_ref().unwrap();
21516        assert_eq!(
21517            p1[0].text.as_deref(),
21518            Some("text   "),
21519            "three trailing spaces should be preserved"
21520        );
21521    }
21522
21523    #[test]
21524    fn issue_510_trailing_spaces_with_backslash_roundtrip() {
21525        // Text ending with backslash + trailing spaces: both must survive.
21526        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\  "}]}]}"#;
21527        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21528        let md = adf_to_markdown(&doc).unwrap();
21529        let rt = markdown_to_adf(&md).unwrap();
21530        let p = rt.content[0].content.as_ref().unwrap();
21531        assert_eq!(
21532            p[0].text.as_deref(),
21533            Some("end\\  "),
21534            "backslash + trailing spaces should both survive"
21535        );
21536    }
21537
21538    #[test]
21539    fn issue_510_jfm_contains_escaped_trailing_space() {
21540        // Verify the serializer actually emits the backslash-space escape.
21541        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello  "}]}]}"#;
21542        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21543        let md = adf_to_markdown(&doc).unwrap();
21544        assert!(
21545            md.contains(r"\ "),
21546            "JFM should contain backslash-space escape for trailing spaces, got: {md:?}"
21547        );
21548        // Must NOT end with two plain spaces before newline
21549        for line in md.lines() {
21550            assert!(
21551                !line.ends_with("  "),
21552                "no JFM line should end with two plain spaces, got: {line:?}"
21553            );
21554        }
21555    }
21556
21557    #[test]
21558    fn issue_510_single_trailing_space_not_escaped() {
21559        // A single trailing space should NOT be escaped (not a hardBreak trigger).
21560        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"word "}]}]}"#;
21561        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21562        let md = adf_to_markdown(&doc).unwrap();
21563        assert!(
21564            !md.contains('\\'),
21565            "single trailing space should not be escaped, got: {md:?}"
21566        );
21567        let rt = markdown_to_adf(&md).unwrap();
21568        let p = rt.content[0].content.as_ref().unwrap();
21569        assert_eq!(p[0].text.as_deref(), Some("word "));
21570    }
21571
21572    #[test]
21573    fn issue_510_trailing_spaces_in_heading_roundtrip() {
21574        // Trailing double-spaces in a heading text node should also survive.
21575        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"heading  "}]}]}"#;
21576        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21577        let md = adf_to_markdown(&doc).unwrap();
21578        let rt = markdown_to_adf(&md).unwrap();
21579        let h = rt.content[0].content.as_ref().unwrap();
21580        assert_eq!(
21581            h[0].text.as_deref(),
21582            Some("heading  "),
21583            "trailing spaces in heading should be preserved"
21584        );
21585    }
21586
21587    #[test]
21588    fn issue_510_trailing_spaces_in_list_item_roundtrip() {
21589        // Trailing double-spaces in a bullet list item text node.
21590        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item  "}]}]}]}]}"#;
21591        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21592        let md = adf_to_markdown(&doc).unwrap();
21593        let rt = markdown_to_adf(&md).unwrap();
21594        let list = &rt.content[0];
21595        let item = &list.content.as_ref().unwrap()[0];
21596        let para = &item.content.as_ref().unwrap()[0];
21597        let inlines = para.content.as_ref().unwrap();
21598        assert_eq!(
21599            inlines[0].text.as_deref(),
21600            Some("item  "),
21601            "trailing spaces in list item should be preserved"
21602        );
21603    }
21604
21605    #[test]
21606    fn issue_510_trailing_spaces_with_bold_mark_roundtrip() {
21607        // Trailing spaces in a bold-marked text node: the closing **
21608        // comes after the spaces, so the line doesn't end with spaces.
21609        // But the escape should still be applied (and be harmless).
21610        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold  ","marks":[{"type":"strong"}]}]}]}"#;
21611        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21612        let md = adf_to_markdown(&doc).unwrap();
21613        let rt = markdown_to_adf(&md).unwrap();
21614        let p = rt.content[0].content.as_ref().unwrap();
21615        assert_eq!(
21616            p[0].text.as_deref(),
21617            Some("bold  "),
21618            "trailing spaces in bold text should be preserved"
21619        );
21620    }
21621
21622    #[test]
21623    fn issue_510_hardbreak_between_paragraphs_still_works() {
21624        // Actual hardBreak nodes must still round-trip correctly.
21625        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"line one"},{"type":"hardBreak"},{"type":"text","text":"line two"}]}]}"#;
21626        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21627        let md = adf_to_markdown(&doc).unwrap();
21628        let rt = markdown_to_adf(&md).unwrap();
21629        let inlines = rt.content[0].content.as_ref().unwrap();
21630        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21631        assert_eq!(
21632            types,
21633            vec!["text", "hardBreak", "text"],
21634            "explicit hardBreak should still round-trip"
21635        );
21636    }
21637
21638    #[test]
21639    fn issue_510_all_spaces_text_node_roundtrip() {
21640        // A text node that is entirely spaces (2+) should survive.
21641        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"  "}]}]}"#;
21642        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21643        let md = adf_to_markdown(&doc).unwrap();
21644        let rt = markdown_to_adf(&md).unwrap();
21645        let p = rt.content[0].content.as_ref().unwrap();
21646        assert_eq!(
21647            p[0].text.as_deref(),
21648            Some("  "),
21649            "space-only text node should survive round-trip"
21650        );
21651    }
21652
21653    // ── Issue #522: listItem multi-paragraph merge ──────────────────────
21654
21655    #[test]
21656    fn issue_522_listitem_hardbreak_then_two_paragraphs_roundtrips() {
21657        // The exact reproducer from issue #522: first paragraph has
21658        // hardBreak nodes, followed by two sibling paragraphs.
21659        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"}]}]}]}]}"#;
21660        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21661        let md = adf_to_markdown(&doc).unwrap();
21662        let rt = markdown_to_adf(&md).unwrap();
21663
21664        let items = rt.content[0].content.as_ref().unwrap();
21665        assert_eq!(items.len(), 1);
21666        let children = items[0].content.as_ref().unwrap();
21667        assert_eq!(
21668            children.len(),
21669            3,
21670            "Expected 3 paragraphs in listItem, got {}",
21671            children.len()
21672        );
21673        assert_eq!(children[0].node_type, "paragraph");
21674        assert_eq!(children[1].node_type, "paragraph");
21675        assert_eq!(children[2].node_type, "paragraph");
21676
21677        // Verify the text content of each paragraph
21678        let text1 = children[1].content.as_ref().unwrap()[0]
21679            .text
21680            .as_deref()
21681            .unwrap();
21682        assert_eq!(text1, "second paragraph");
21683        let text2 = children[2].content.as_ref().unwrap()[0]
21684            .text
21685            .as_deref()
21686            .unwrap();
21687        assert_eq!(text2, "third paragraph");
21688    }
21689
21690    #[test]
21691    fn issue_522_ordered_list_hardbreak_then_paragraphs_roundtrips() {
21692        // Same scenario in an ordered list.
21693        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"}]}]}]}]}"#;
21694        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21695        let md = adf_to_markdown(&doc).unwrap();
21696        let rt = markdown_to_adf(&md).unwrap();
21697
21698        let items = rt.content[0].content.as_ref().unwrap();
21699        let children = items[0].content.as_ref().unwrap();
21700        assert_eq!(
21701            children.len(),
21702            3,
21703            "Expected 3 paragraphs in ordered listItem, got {}",
21704            children.len()
21705        );
21706        assert_eq!(children[1].node_type, "paragraph");
21707        assert_eq!(children[2].node_type, "paragraph");
21708        assert_eq!(
21709            children[1].content.as_ref().unwrap()[0]
21710                .text
21711                .as_deref()
21712                .unwrap(),
21713            "second para"
21714        );
21715        assert_eq!(
21716            children[2].content.as_ref().unwrap()[0]
21717                .text
21718                .as_deref()
21719                .unwrap(),
21720            "third para"
21721        );
21722    }
21723
21724    #[test]
21725    fn issue_522_two_paragraphs_without_hardbreak_roundtrips() {
21726        // Two paragraphs without hardBreak — should also remain separate.
21727        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"}]}]}]}]}"#;
21728        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21729        let md = adf_to_markdown(&doc).unwrap();
21730        let rt = markdown_to_adf(&md).unwrap();
21731
21732        let items = rt.content[0].content.as_ref().unwrap();
21733        let children = items[0].content.as_ref().unwrap();
21734        assert_eq!(
21735            children.len(),
21736            2,
21737            "Expected 2 paragraphs in listItem, got {}",
21738            children.len()
21739        );
21740        assert_eq!(children[0].node_type, "paragraph");
21741        assert_eq!(children[1].node_type, "paragraph");
21742    }
21743
21744    #[test]
21745    fn issue_522_paragraph_then_nested_list_no_spurious_blank() {
21746        // A paragraph followed by a nested list should NOT get a blank
21747        // separator (only paragraph-paragraph transitions need one).
21748        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"}]}]}]}]}]}]}"#;
21749        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21750        let md = adf_to_markdown(&doc).unwrap();
21751        // Should not contain a blank indented line between parent text and sub-list
21752        assert!(
21753            !md.contains("  \n  -"),
21754            "No blank separator between paragraph and nested list"
21755        );
21756        let rt = markdown_to_adf(&md).unwrap();
21757
21758        let items = rt.content[0].content.as_ref().unwrap();
21759        let children = items[0].content.as_ref().unwrap();
21760        assert_eq!(children.len(), 2);
21761        assert_eq!(children[0].node_type, "paragraph");
21762        assert_eq!(children[1].node_type, "bulletList");
21763    }
21764
21765    #[test]
21766    fn issue_522_three_paragraphs_no_hardbreak_roundtrips() {
21767        // Three plain paragraphs (no hardBreak) inside a single listItem.
21768        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"}]}]}]}]}"#;
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 items = rt.content[0].content.as_ref().unwrap();
21774        let children = items[0].content.as_ref().unwrap();
21775        assert_eq!(
21776            children.len(),
21777            3,
21778            "Expected 3 paragraphs, got {}",
21779            children.len()
21780        );
21781        for (i, child) in children.iter().enumerate() {
21782            assert_eq!(
21783                child.node_type, "paragraph",
21784                "Child {i} should be a paragraph"
21785            );
21786        }
21787    }
21788
21789    #[test]
21790    fn issue_522_multiple_list_items_each_with_paragraphs() {
21791        // Multiple list items, each with multiple paragraphs.
21792        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"}]}]}]}]}"#;
21793        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21794        let md = adf_to_markdown(&doc).unwrap();
21795        let rt = markdown_to_adf(&md).unwrap();
21796
21797        let items = rt.content[0].content.as_ref().unwrap();
21798        assert_eq!(items.len(), 2, "Expected 2 list items");
21799
21800        let item1 = items[0].content.as_ref().unwrap();
21801        assert_eq!(item1.len(), 2, "Item 1 should have 2 paragraphs");
21802
21803        let item2 = items[1].content.as_ref().unwrap();
21804        assert_eq!(item2.len(), 2, "Item 2 should have 2 paragraphs");
21805        // Verify hardBreak is preserved in item2's first paragraph
21806        let item2_p1_inlines = item2[0].content.as_ref().unwrap();
21807        let types: Vec<&str> = item2_p1_inlines
21808            .iter()
21809            .map(|n| n.node_type.as_str())
21810            .collect();
21811        assert_eq!(types, vec!["text", "hardBreak", "text"]);
21812    }
21813
21814    #[test]
21815    fn issue_531_blockquote_hardbreak_then_two_paragraphs_roundtrips() {
21816        // The exact reproducer from issue #531: blockquote with first
21817        // paragraph containing hardBreak nodes, followed by two sibling
21818        // paragraphs.
21819        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"}]}]}]}"#;
21820        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21821        let md = adf_to_markdown(&doc).unwrap();
21822        let rt = markdown_to_adf(&md).unwrap();
21823
21824        let children = rt.content[0].content.as_ref().unwrap();
21825        assert_eq!(
21826            children.len(),
21827            3,
21828            "Expected 3 paragraphs in blockquote, got {}",
21829            children.len()
21830        );
21831        assert_eq!(children[0].node_type, "paragraph");
21832        assert_eq!(children[1].node_type, "paragraph");
21833        assert_eq!(children[2].node_type, "paragraph");
21834
21835        let text1 = children[1].content.as_ref().unwrap()[0]
21836            .text
21837            .as_deref()
21838            .unwrap();
21839        assert_eq!(text1, "second paragraph");
21840        let text2 = children[2].content.as_ref().unwrap()[0]
21841            .text
21842            .as_deref()
21843            .unwrap();
21844        assert_eq!(text2, "third paragraph");
21845    }
21846
21847    #[test]
21848    fn issue_531_blockquote_two_paragraphs_without_hardbreak_roundtrips() {
21849        // Two simple paragraphs inside a blockquote, no hardBreak.
21850        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"}]}]}]}"#;
21851        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21852        let md = adf_to_markdown(&doc).unwrap();
21853        let rt = markdown_to_adf(&md).unwrap();
21854
21855        let children = rt.content[0].content.as_ref().unwrap();
21856        assert_eq!(
21857            children.len(),
21858            2,
21859            "Expected 2 paragraphs in blockquote, got {}",
21860            children.len()
21861        );
21862        assert_eq!(children[0].node_type, "paragraph");
21863        assert_eq!(children[1].node_type, "paragraph");
21864    }
21865
21866    #[test]
21867    fn issue_531_blockquote_three_paragraphs_no_hardbreak_roundtrips() {
21868        // Three paragraphs, none with hardBreak.
21869        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"}]}]}]}"#;
21870        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21871        let md = adf_to_markdown(&doc).unwrap();
21872        let rt = markdown_to_adf(&md).unwrap();
21873
21874        let children = rt.content[0].content.as_ref().unwrap();
21875        assert_eq!(
21876            children.len(),
21877            3,
21878            "Expected 3 paragraphs in blockquote, got {}",
21879            children.len()
21880        );
21881        for child in children {
21882            assert_eq!(child.node_type, "paragraph");
21883        }
21884    }
21885
21886    #[test]
21887    fn issue_531_blockquote_paragraph_then_list_no_spurious_blank() {
21888        // A paragraph followed by a nested list inside a blockquote —
21889        // should NOT insert a blank separator line.
21890        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"}]}]}]}]}]}"#;
21891        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21892        let md = adf_to_markdown(&doc).unwrap();
21893        let rt = markdown_to_adf(&md).unwrap();
21894
21895        let children = rt.content[0].content.as_ref().unwrap();
21896        assert_eq!(children[0].node_type, "paragraph");
21897        assert_eq!(children[1].node_type, "bulletList");
21898    }
21899
21900    #[test]
21901    fn issue_531_blockquote_single_paragraph_unchanged() {
21902        // A single paragraph in a blockquote should remain unchanged.
21903        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"solo"}]}]}]}"#;
21904        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21905        let md = adf_to_markdown(&doc).unwrap();
21906        let rt = markdown_to_adf(&md).unwrap();
21907
21908        let children = rt.content[0].content.as_ref().unwrap();
21909        assert_eq!(children.len(), 1);
21910        assert_eq!(children[0].node_type, "paragraph");
21911        let text = children[0].content.as_ref().unwrap()[0]
21912            .text
21913            .as_deref()
21914            .unwrap();
21915        assert_eq!(text, "solo");
21916    }
21917
21918    // ── Issue #554: marks combined with `code` or with each other ──────
21919
21920    /// Helper: roundtrip an ADF document and assert the marks on the first
21921    /// text node match `expected_marks` (in order).
21922    fn assert_roundtrip_marks(adf_json: &str, expected_marks: &[&str]) {
21923        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21924        let md = adf_to_markdown(&doc).unwrap();
21925        let rt = markdown_to_adf(&md).unwrap();
21926        let node = &rt.content[0].content.as_ref().unwrap()[0];
21927        let mark_types: Vec<&str> = node
21928            .marks
21929            .as_ref()
21930            .expect("should have marks")
21931            .iter()
21932            .map(|m| m.mark_type.as_str())
21933            .collect();
21934        assert_eq!(
21935            mark_types, expected_marks,
21936            "mark order mismatch for md={md}"
21937        );
21938    }
21939
21940    #[test]
21941    fn issue_554_code_and_text_color_preserved() {
21942        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21943          {"type":"text","text":"x","marks":[
21944            {"type":"textColor","attrs":{"color":"#008000"}},
21945            {"type":"code"}
21946          ]}
21947        ]}]}"##;
21948        assert_roundtrip_marks(adf_json, &["textColor", "code"]);
21949    }
21950
21951    #[test]
21952    fn issue_554_code_and_bg_color_preserved() {
21953        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21954          {"type":"text","text":"x","marks":[
21955            {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21956            {"type":"code"}
21957          ]}
21958        ]}]}"##;
21959        assert_roundtrip_marks(adf_json, &["backgroundColor", "code"]);
21960    }
21961
21962    #[test]
21963    fn issue_554_code_and_subsup_preserved() {
21964        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21965          {"type":"text","text":"x","marks":[
21966            {"type":"subsup","attrs":{"type":"sub"}},
21967            {"type":"code"}
21968          ]}
21969        ]}]}"#;
21970        assert_roundtrip_marks(adf_json, &["subsup", "code"]);
21971    }
21972
21973    #[test]
21974    fn issue_554_code_and_underline_preserved() {
21975        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21976          {"type":"text","text":"x","marks":[
21977            {"type":"underline"},
21978            {"type":"code"}
21979          ]}
21980        ]}]}"#;
21981        assert_roundtrip_marks(adf_json, &["underline", "code"]);
21982    }
21983
21984    #[test]
21985    fn issue_554_code_textcolor_and_underline_preserved() {
21986        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21987          {"type":"text","text":"x","marks":[
21988            {"type":"textColor","attrs":{"color":"#008000"}},
21989            {"type":"underline"},
21990            {"type":"code"}
21991          ]}
21992        ]}]}"##;
21993        assert_roundtrip_marks(adf_json, &["textColor", "underline", "code"]);
21994    }
21995
21996    #[test]
21997    fn issue_554_textcolor_and_underline_preserved() {
21998        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21999          {"type":"text","text":"x","marks":[
22000            {"type":"textColor","attrs":{"color":"#008000"}},
22001            {"type":"underline"}
22002          ]}
22003        ]}]}"##;
22004        assert_roundtrip_marks(adf_json, &["textColor", "underline"]);
22005    }
22006
22007    #[test]
22008    fn issue_554_underline_and_textcolor_preserved_order_swapped() {
22009        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22010          {"type":"text","text":"x","marks":[
22011            {"type":"underline"},
22012            {"type":"textColor","attrs":{"color":"#008000"}}
22013          ]}
22014        ]}]}"##;
22015        // underline appears first, so it should be the OUTER wrapper.
22016        assert_roundtrip_marks(adf_json, &["underline", "textColor"]);
22017    }
22018
22019    #[test]
22020    fn issue_554_textcolor_and_annotation_preserved() {
22021        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22022          {"type":"text","text":"x","marks":[
22023            {"type":"textColor","attrs":{"color":"#008000"}},
22024            {"type":"annotation","attrs":{"id":"abc-123","annotationType":"inlineComment"}}
22025          ]}
22026        ]}]}"##;
22027        assert_roundtrip_marks(adf_json, &["textColor", "annotation"]);
22028    }
22029
22030    #[test]
22031    fn issue_554_bgcolor_and_underline_preserved() {
22032        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22033          {"type":"text","text":"x","marks":[
22034            {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
22035            {"type":"underline"}
22036          ]}
22037        ]}]}"##;
22038        assert_roundtrip_marks(adf_json, &["backgroundColor", "underline"]);
22039    }
22040
22041    #[test]
22042    fn issue_554_subsup_and_underline_preserved() {
22043        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22044          {"type":"text","text":"x","marks":[
22045            {"type":"subsup","attrs":{"type":"sub"}},
22046            {"type":"underline"}
22047          ]}
22048        ]}]}"#;
22049        assert_roundtrip_marks(adf_json, &["subsup", "underline"]);
22050    }
22051
22052    #[test]
22053    fn issue_554_exact_reproducer_full_match() {
22054        // The exact reproducer from issue #554. The byte-for-byte ADF JSON
22055        // must round-trip through `from-adf | to-adf` unchanged.
22056        let adf_json = r##"{
22057          "version": 1,
22058          "type": "doc",
22059          "content": [
22060            {
22061              "type": "paragraph",
22062              "content": [
22063                {"type":"text","text":"Status: ","marks":[{"type":"strong"}]},
22064                {"type":"text","text":"Approved","marks":[
22065                  {"type":"textColor","attrs":{"color":"#008000"}}
22066                ]},
22067                {"type":"text","text":" — ready to proceed"}
22068              ]
22069            }
22070          ]
22071        }"##;
22072        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22073        let md = adf_to_markdown(&doc).unwrap();
22074        assert!(
22075            md.contains(":span[Approved]{color=#008000}"),
22076            "JFM should contain green span: {md}"
22077        );
22078        let rt = markdown_to_adf(&md).unwrap();
22079        // Find the "Approved" text node and verify color is preserved.
22080        let approved = rt.content[0]
22081            .content
22082            .as_ref()
22083            .unwrap()
22084            .iter()
22085            .find(|n| n.text.as_deref() == Some("Approved"))
22086            .expect("Approved text node");
22087        let marks = approved.marks.as_ref().expect("should have marks");
22088        let color_mark = marks
22089            .iter()
22090            .find(|m| m.mark_type == "textColor")
22091            .expect("textColor mark must be preserved");
22092        assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
22093    }
22094
22095    #[test]
22096    fn issue_554_textcolor_with_code_renders_span_around_code() {
22097        // Verify the rendered JFM uses `:span[`text`]{color=...}` — the
22098        // syntax suggested in the issue.
22099        let doc = AdfDocument {
22100            version: 1,
22101            doc_type: "doc".to_string(),
22102            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
22103                "fn main",
22104                vec![
22105                    AdfMark::text_color("#008000"),
22106                    AdfMark {
22107                        mark_type: "code".to_string(),
22108                        attrs: None,
22109                    },
22110                ],
22111            )])],
22112        };
22113        let md = adf_to_markdown(&doc).unwrap();
22114        assert!(
22115            md.contains(":span[`fn main`]{color=#008000}"),
22116            "expected span-wrapped code, got: {md}"
22117        );
22118    }
22119
22120    #[test]
22121    fn issue_554_underline_with_code_renders_bracketed_around_code() {
22122        let doc = AdfDocument {
22123            version: 1,
22124            doc_type: "doc".to_string(),
22125            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
22126                "fn main",
22127                vec![
22128                    AdfMark::underline(),
22129                    AdfMark {
22130                        mark_type: "code".to_string(),
22131                        attrs: None,
22132                    },
22133                ],
22134            )])],
22135        };
22136        let md = adf_to_markdown(&doc).unwrap();
22137        assert!(
22138            md.contains("[`fn main`]{underline}"),
22139            "expected bracketed-span around code, got: {md}"
22140        );
22141    }
22142
22143    // ── Issue #554 (re-opened): boundary-underscore destroys span directives ──
22144
22145    #[test]
22146    fn issue_554_underscore_adjacent_to_textcolor_span_roundtrip() {
22147        // Reproducer from the re-opened issue: a `_ ` plain-text node followed
22148        // by a textColor span whose text starts with `_` produced JFM that the
22149        // parser saw as an italic delimiter pair, destroying the span and
22150        // losing the textColor mark entirely.
22151        let adf_json = r##"{
22152          "version": 1,
22153          "type": "doc",
22154          "content": [
22155            {
22156              "type": "paragraph",
22157              "content": [
22158                {"type":"text","text":"_ "},
22159                {"type":"text","text":"_Action:*","marks":[
22160                  {"type":"textColor","attrs":{"color":"#008000"}}
22161                ]},
22162                {"type":"text","text":" Complete the setup process."}
22163              ]
22164            }
22165          ]
22166        }"##;
22167        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22168        let md = adf_to_markdown(&doc).unwrap();
22169        // The leading `_` chars must be backslash-escaped so the parser
22170        // doesn't form a false italic pair across the span boundary.
22171        assert!(
22172            md.contains(r"\_ ") && md.contains(r":span[\_Action"),
22173            "underscores at node boundaries should be escaped: {md}"
22174        );
22175        let rt = markdown_to_adf(&md).unwrap();
22176        let para_content = rt.content[0].content.as_ref().unwrap();
22177        // Find the textColor-marked node.
22178        let colored = para_content
22179            .iter()
22180            .find(|n| {
22181                n.marks
22182                    .as_deref()
22183                    .is_some_and(|ms| ms.iter().any(|m| m.mark_type == "textColor"))
22184            })
22185            .expect("textColor node must be preserved");
22186        assert_eq!(colored.text.as_deref(), Some("_Action:*"));
22187        let color_mark = colored
22188            .marks
22189            .as_ref()
22190            .unwrap()
22191            .iter()
22192            .find(|m| m.mark_type == "textColor")
22193            .unwrap();
22194        assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
22195        // Verify no spurious em mark crept in.
22196        for n in para_content {
22197            if let Some(ms) = n.marks.as_deref() {
22198                assert!(
22199                    !ms.iter().any(|m| m.mark_type == "em"),
22200                    "no em mark should appear, got marks {:?}",
22201                    ms.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
22202                );
22203            }
22204        }
22205    }
22206
22207    #[test]
22208    fn issue_554_underscore_intraword_left_unescaped() {
22209        // Sanity check: ordinary intraword underscores like `do_something_useful`
22210        // should NOT be escaped — escaping would still round-trip correctly,
22211        // but produces noisy backslashes in the JFM output.
22212        let doc = AdfDocument {
22213            version: 1,
22214            doc_type: "doc".to_string(),
22215            content: vec![AdfNode::paragraph(vec![AdfNode::text(
22216                "call do_something_useful now",
22217            )])],
22218        };
22219        let md = adf_to_markdown(&doc).unwrap();
22220        assert!(
22221            md.contains("do_something_useful") && !md.contains(r"do\_something\_useful"),
22222            "intraword underscores should not be escaped: {md}"
22223        );
22224    }
22225
22226    #[test]
22227    fn issue_554_code_underline_then_textcolor_bracketed_outer() {
22228        // Mark order [underline, textColor, code] — bracketed-span outer,
22229        // span inner. Exercises wrap_with_attrs (true, true) !span_before.
22230        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22231          {"type":"text","text":"x","marks":[
22232            {"type":"underline"},
22233            {"type":"textColor","attrs":{"color":"#008000"}},
22234            {"type":"code"}
22235          ]}
22236        ]}]}"##;
22237        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22238        let md = adf_to_markdown(&doc).unwrap();
22239        // Bracketed-span should be the outermost wrapper.
22240        assert!(
22241            md.starts_with('[') && md.contains("underline}"),
22242            "bracketed-span should wrap the span, got: {md}"
22243        );
22244        let rt = markdown_to_adf(&md).unwrap();
22245        let node = &rt.content[0].content.as_ref().unwrap()[0];
22246        let mark_types: Vec<&str> = node
22247            .marks
22248            .as_ref()
22249            .unwrap()
22250            .iter()
22251            .map(|m| m.mark_type.as_str())
22252            .collect();
22253        assert_eq!(mark_types, vec!["underline", "textColor", "code"]);
22254    }
22255
22256    #[test]
22257    fn issue_554_textcolor_underline_link_all_preserved() {
22258        // Mark order [textColor, underline, link] — span outer, bracketed
22259        // wraps the link inside. Exercises the span-wraps-link-with-bracketed
22260        // branch.
22261        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22262          {"type":"text","text":"linked","marks":[
22263            {"type":"textColor","attrs":{"color":"#008000"}},
22264            {"type":"underline"},
22265            {"type":"link","attrs":{"href":"https://example.com"}}
22266          ]}
22267        ]}]}"##;
22268        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22269        let md = adf_to_markdown(&doc).unwrap();
22270        let rt = markdown_to_adf(&md).unwrap();
22271        let node = &rt.content[0].content.as_ref().unwrap()[0];
22272        let mark_types: Vec<&str> = node
22273            .marks
22274            .as_ref()
22275            .unwrap()
22276            .iter()
22277            .map(|m| m.mark_type.as_str())
22278            .collect();
22279        assert_eq!(mark_types, vec!["textColor", "underline", "link"]);
22280    }
22281
22282    #[test]
22283    fn issue_554_underline_textcolor_link_bracketed_outer_link_last() {
22284        // Mark order [underline, textColor, link] — bracketed-span outer of
22285        // both span and link. Exercises the bracketed-wraps-everything branch.
22286        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22287          {"type":"text","text":"linked","marks":[
22288            {"type":"underline"},
22289            {"type":"textColor","attrs":{"color":"#008000"}},
22290            {"type":"link","attrs":{"href":"https://example.com"}}
22291          ]}
22292        ]}]}"##;
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        let node = &rt.content[0].content.as_ref().unwrap()[0];
22297        let mark_types: Vec<&str> = node
22298            .marks
22299            .as_ref()
22300            .unwrap()
22301            .iter()
22302            .map(|m| m.mark_type.as_str())
22303            .collect();
22304        assert_eq!(mark_types, vec!["underline", "textColor", "link"]);
22305    }
22306
22307    #[test]
22308    fn issue_554_link_underline_textcolor_link_outer() {
22309        // Mark order [link, underline, textColor] — link outermost, wraps a
22310        // bracketed-span that wraps the span. Exercises the link-wraps-
22311        // bracketed-wraps-span branch.
22312        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22313          {"type":"text","text":"linked","marks":[
22314            {"type":"link","attrs":{"href":"https://example.com"}},
22315            {"type":"underline"},
22316            {"type":"textColor","attrs":{"color":"#008000"}}
22317          ]}
22318        ]}]}"##;
22319        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22320        let md = adf_to_markdown(&doc).unwrap();
22321        assert!(
22322            md.starts_with('[') && md.contains("](https://example.com)"),
22323            "link should be outermost, got: {md}"
22324        );
22325        let rt = markdown_to_adf(&md).unwrap();
22326        let node = &rt.content[0].content.as_ref().unwrap()[0];
22327        let mark_types: Vec<&str> = node
22328            .marks
22329            .as_ref()
22330            .unwrap()
22331            .iter()
22332            .map(|m| m.mark_type.as_str())
22333            .collect();
22334        assert_eq!(mark_types, vec!["link", "underline", "textColor"]);
22335    }
22336
22337    #[test]
22338    fn issue_554_trailing_underscore_then_leading_underscore_round_trip() {
22339        // Two adjacent text nodes where the first ends with `_` and the
22340        // second starts with `_` — without escaping, the JFM parser sees
22341        // an `_..._` pair spanning the boundary.
22342        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22343          {"type":"text","text":"end_"},
22344          {"type":"text","text":"_start"}
22345        ]}]}"#;
22346        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22347        let md = adf_to_markdown(&doc).unwrap();
22348        let rt = markdown_to_adf(&md).unwrap();
22349        // Reassemble all text in the paragraph.
22350        let combined: String = rt.content[0]
22351            .content
22352            .as_ref()
22353            .unwrap()
22354            .iter()
22355            .filter_map(|n| n.text.as_deref())
22356            .collect();
22357        assert_eq!(combined, "end__start");
22358        // No node should have an em mark.
22359        for n in rt.content[0].content.as_ref().unwrap() {
22360            if let Some(ms) = n.marks.as_deref() {
22361                assert!(!ms.iter().any(|m| m.mark_type == "em"));
22362            }
22363        }
22364    }
22365
22366    // ── Nesting depth limit (issue #1130) ──────────────────────────────
22367    //
22368    // Without MAX_NESTING_DEPTH each of these inputs recurses one stack
22369    // frame per nesting level, overflowing the stack — an uncatchable
22370    // abort.  The block-path tests assert a clean error; the inline-path
22371    // tests assert graceful degradation (no abort, valid document).
22372
22373    #[test]
22374    fn blockquote_nesting_beyond_cap_errors() {
22375        let md = format!("{}x", "> ".repeat(200));
22376        let err = markdown_to_adf(&md).unwrap_err();
22377        assert!(err.to_string().contains("maximum depth"));
22378    }
22379
22380    #[test]
22381    fn list_nesting_beyond_cap_errors() {
22382        let mut md = String::new();
22383        for i in 0..200 {
22384            md.push_str(&"  ".repeat(i));
22385            md.push_str("- x\n");
22386        }
22387        let err = markdown_to_adf(&md).unwrap_err();
22388        assert!(err.to_string().contains("maximum depth"));
22389    }
22390
22391    #[test]
22392    fn container_directive_nesting_beyond_cap_errors() {
22393        let mut md = String::new();
22394        for _ in 0..200 {
22395            md.push_str(":::panel{type=info}\n");
22396        }
22397        md.push_str("body\n");
22398        for _ in 0..200 {
22399            md.push_str(":::\n");
22400        }
22401        let err = markdown_to_adf(&md).unwrap_err();
22402        assert!(err.to_string().contains("maximum depth"));
22403    }
22404
22405    #[test]
22406    fn directive_table_cell_nesting_beyond_cap_errors() {
22407        let mut md = String::new();
22408        for _ in 0..200 {
22409            md.push_str("::::table\n:::tr\n:::td\n");
22410        }
22411        md.push_str("cell\n");
22412        for _ in 0..200 {
22413            md.push_str(":::\n:::\n::::\n");
22414        }
22415        let err = markdown_to_adf(&md).unwrap_err();
22416        assert!(err.to_string().contains("maximum depth"));
22417    }
22418
22419    #[test]
22420    fn layout_column_nesting_beyond_cap_errors() {
22421        let mut md = String::new();
22422        for _ in 0..200 {
22423            md.push_str("::::layout\n:::column{width=100}\n");
22424        }
22425        md.push_str("x\n");
22426        for _ in 0..200 {
22427            md.push_str(":::\n::::\n");
22428        }
22429        let err = markdown_to_adf(&md).unwrap_err();
22430        assert!(err.to_string().contains("maximum depth"));
22431    }
22432
22433    #[test]
22434    fn blockquote_nesting_at_cap_boundary() {
22435        // A parser at depth d handles the (d+1)-th `>` level, so exactly
22436        // MAX_NESTING_DEPTH levels succeed and one more errors.
22437        let at_cap = format!("{}x", "> ".repeat(MAX_NESTING_DEPTH));
22438        assert!(markdown_to_adf(&at_cap).is_ok());
22439        let past_cap = format!("{}x", "> ".repeat(MAX_NESTING_DEPTH + 1));
22440        let err = markdown_to_adf(&past_cap).unwrap_err();
22441        assert!(err.to_string().contains("maximum depth"));
22442    }
22443
22444    #[test]
22445    fn list_nesting_at_cap_succeeds() {
22446        // The nested-list path has the largest debug-build stack frames, so
22447        // an at-cap success here proves the cap leaves stack headroom on a
22448        // default 2 MiB test thread (the canary for MAX_NESTING_DEPTH).
22449        let mut md = String::new();
22450        for i in 0..MAX_NESTING_DEPTH {
22451            md.push_str(&"  ".repeat(i));
22452            md.push_str("- x\n");
22453        }
22454        assert!(markdown_to_adf(&md).is_ok());
22455    }
22456
22457    #[test]
22458    fn deep_but_reasonable_nesting_succeeds() {
22459        let md = format!("{}deep", "> ".repeat(20));
22460        let doc = markdown_to_adf(&md).unwrap();
22461        // Walk down the 20 blockquote levels to the paragraph.
22462        let mut node = &doc.content[0];
22463        for _ in 0..20 {
22464            assert_eq!(node.node_type, "blockquote");
22465            node = &node.content.as_ref().unwrap()[0];
22466        }
22467        assert_eq!(node.node_type, "paragraph");
22468    }
22469
22470    #[test]
22471    fn deeply_nested_emphasis_degrades_without_overflow() {
22472        let md = format!("{}x{}", "*_".repeat(400), "_*".repeat(400));
22473        let doc = markdown_to_adf(&md).unwrap();
22474        assert_eq!(doc.content[0].node_type, "paragraph");
22475    }
22476
22477    #[test]
22478    fn deeply_nested_links_degrade_without_overflow() {
22479        let mut md = String::from("x");
22480        for _ in 0..400 {
22481            md = format!("[{md}](https://example.com)");
22482        }
22483        let doc = markdown_to_adf(&md).unwrap();
22484        assert_eq!(doc.content[0].node_type, "paragraph");
22485    }
22486
22487    #[test]
22488    fn deeply_nested_bracketed_spans_degrade_without_overflow() {
22489        let mut md = String::from("x");
22490        for _ in 0..400 {
22491            md = format!("[{md}]{{underline}}");
22492        }
22493        let doc = markdown_to_adf(&md).unwrap();
22494        assert_eq!(doc.content[0].node_type, "paragraph");
22495    }
22496}