//! Bidirectional conversion between markdown and Atlassian Document Format.
//!
//! Supports Tier 1 (standard GFM) constructs: headings, paragraphs, inline
//! marks (bold, italic, code, strikethrough, links), images, lists, code
//! blocks, blockquotes, horizontal rules, and tables.
use anyhow::Result;
use chrono::NaiveDate;
use tracing::{debug, warn};
use crate::atlassian::adf::{AdfDocument, AdfMark, AdfNode};
use crate::atlassian::attrs::{format_kv, parse_attrs, Attrs};
use crate::atlassian::directive::{
is_container_close, try_parse_container_open, try_parse_inline_directive,
try_parse_leaf_directive,
};
// ── Markdown → ADF ──────────────────────────────────────────────────
/// Converts a markdown string to an ADF document.
pub fn markdown_to_adf(markdown: &str) -> Result<AdfDocument> {
debug!(
"markdown_to_adf: input {} bytes, {} lines",
markdown.len(),
markdown.lines().count()
);
let mut doc = AdfDocument::new();
let mut parser = MarkdownParser::new(markdown);
doc.content = parser.parse_blocks()?;
debug!(
"markdown_to_adf: produced {} top-level ADF nodes",
doc.content.len()
);
Ok(doc)
}
/// Line-oriented state machine for parsing markdown into ADF block nodes.
struct MarkdownParser<'a> {
lines: Vec<&'a str>,
pos: usize,
}
impl<'a> MarkdownParser<'a> {
fn new(input: &'a str) -> Self {
Self {
lines: input.lines().collect(),
pos: 0,
}
}
fn at_end(&self) -> bool {
self.pos >= self.lines.len()
}
fn current_line(&self) -> &'a str {
self.lines[self.pos]
}
fn advance(&mut self) {
self.pos += 1;
}
/// Collects indented continuation lines produced by hardBreaks (issue #402).
///
/// When `full_text` ends with a hardBreak marker (trailing backslash or
/// two trailing spaces), the next 2-space-indented line is appended as a
/// continuation of the same paragraph. The joined text is later fed to
/// `parse_inline`, which converts the `\\\n` or ` \n` sequences back
/// into `hardBreak` nodes.
fn collect_hardbreak_continuations(&mut self, full_text: &mut String) {
while has_trailing_hard_break(full_text) && !self.at_end() {
if !self.try_append_hardbreak_continuation(full_text) {
break;
}
}
}
/// If the current line is a valid hardBreak continuation (2-space indented
/// and not a block-level sibling marker), append it to `full_text` and
/// advance the parser. Returns `true` on success, `false` otherwise.
///
/// Split out from `collect_hardbreak_continuations` so the body appears
/// as its own function in coverage reports (issue #552 PR coverage gap).
fn try_append_hardbreak_continuation(&mut self, full_text: &mut String) -> bool {
// Skip indented block-level siblings — mediaSingle (`![` — issue
// #490), fenced code blocks (```` ``` ```` — issue #552), and
// container directives (`:::`). They must stay available for their
// dedicated block handlers instead of being merged into paragraph
// text.
match self
.current_line()
.strip_prefix(" ")
.filter(|s| !is_block_level_continuation_marker(s.trim_start()))
{
Some(stripped) => {
full_text.push('\n');
full_text.push_str(stripped);
self.advance();
true
}
None => false,
}
}
fn parse_blocks(&mut self) -> Result<Vec<AdfNode>> {
let mut blocks = Vec::new();
while !self.at_end() {
let line = self.current_line();
if line.trim().is_empty() {
self.advance();
continue;
}
let mut node = if let Some(node) = self.try_heading() {
node
} else if let Some(node) = self.try_horizontal_rule() {
node
} else if let Some(node) = self.try_container_directive()? {
node
} else if let Some(node) = self.try_code_block()? {
node
} else if let Some(node) = self.try_table()? {
node
} else if let Some(node) = self.try_blockquote()? {
node
} else if let Some(node) = self.try_list()? {
node
} else if let Some(node) = self.try_leaf_directive() {
node
} else if let Some(node) = self.try_image() {
node
} else {
self.parse_paragraph()?
};
// Check for trailing block-level {attrs} (align, indent, breakout)
self.try_apply_block_attrs(&mut node);
blocks.push(node);
}
Ok(blocks)
}
fn try_heading(&mut self) -> Option<AdfNode> {
let line = self.current_line();
let trimmed = line.trim_start();
if !trimmed.starts_with('#') {
return None;
}
let level = trimmed.chars().take_while(|&c| c == '#').count();
if !(1..=6).contains(&level) || !trimmed[level..].starts_with(' ') {
return None;
}
let mut full_text = trimmed[level + 1..].to_string();
self.advance();
// Collect indented continuation lines produced by hardBreaks (issue #433).
self.collect_hardbreak_continuations(&mut full_text);
let inline_nodes = parse_inline(&full_text);
#[allow(clippy::cast_possible_truncation)]
Some(AdfNode::heading(level as u8, inline_nodes))
}
fn try_horizontal_rule(&mut self) -> Option<AdfNode> {
let line = self.current_line().trim();
let is_rule = (line.starts_with("---") && line.chars().all(|c| c == '-'))
|| (line.starts_with("***") && line.chars().all(|c| c == '*'))
|| (line.starts_with("___") && line.chars().all(|c| c == '_'));
if is_rule && line.len() >= 3 {
self.advance();
Some(AdfNode::rule())
} else {
None
}
}
fn try_code_block(&mut self) -> Result<Option<AdfNode>> {
let line = self.current_line();
if !is_code_fence_opener(line) {
return Ok(None);
}
let language = line[3..].trim();
let language = if language == "\"\"" {
// Explicit empty language attr encoded as ```""
Some(String::new())
} else if language.is_empty() {
None
} else {
Some(language.to_string())
};
self.advance();
let mut code_lines = Vec::new();
while !self.at_end() {
let line = self.current_line();
if line.starts_with("```") {
self.advance();
break;
}
code_lines.push(line);
self.advance();
}
let code_text = code_lines.join("\n");
// If the language is "adf-unsupported", deserialize the JSON back to an AdfNode
if language.as_deref() == Some("adf-unsupported") {
if let Ok(node) = serde_json::from_str::<AdfNode>(&code_text) {
return Ok(Some(node));
}
}
Ok(Some(AdfNode::code_block(language.as_deref(), &code_text)))
}
fn try_blockquote(&mut self) -> Result<Option<AdfNode>> {
let line = self.current_line();
if !line.starts_with('>') {
return Ok(None);
}
let mut quote_lines = Vec::new();
while !self.at_end() {
let line = self.current_line();
if let Some(rest) = line.strip_prefix("> ") {
quote_lines.push(rest);
self.advance();
} else if let Some(rest) = line.strip_prefix('>') {
quote_lines.push(rest);
self.advance();
} else {
break;
}
}
let quote_text = quote_lines.join("\n");
let mut inner_parser = MarkdownParser::new("e_text);
let inner_blocks = inner_parser.parse_blocks()?;
Ok(Some(AdfNode::blockquote(inner_blocks)))
}
fn try_list(&mut self) -> Result<Option<AdfNode>> {
let line = self.current_line();
let trimmed = line.trim_start();
let is_bullet =
trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ");
let ordered_match = parse_ordered_list_marker(trimmed);
if !is_bullet && ordered_match.is_none() {
return Ok(None);
}
if is_bullet {
self.parse_bullet_list()
} else {
let start = ordered_match.map_or(1, |(n, _)| n);
self.parse_ordered_list(start)
}
}
fn parse_bullet_list(&mut self) -> Result<Option<AdfNode>> {
let mut items = Vec::new();
let mut is_task_list = false;
while !self.at_end() {
let line = self.current_line();
let trimmed = line.trim_start();
if !(trimmed.starts_with("- ")
|| trimmed.starts_with("* ")
|| trimmed.starts_with("+ "))
{
break;
}
let after_marker = trimmed[2..].trim_start();
// Detect task list items: - [ ] or - [x]
if let Some((state, text)) = try_parse_task_marker(after_marker) {
is_task_list = true;
self.advance();
// Collect hardBreak continuation lines so that a trailing
// {localId=…} on the last continuation line is found by
// extract_trailing_local_id (issue #507).
let mut full_text = text.to_string();
self.collect_hardbreak_continuations(&mut full_text);
let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
let inline_nodes = parse_inline(item_text);
// If a paraLocalId marker is present the original ADF had a
// paragraph wrapper around the inline content — restore it
// so the round-trip is lossless (issue #478).
let content = if let Some(ref plid) = para_local_id {
let mut para = AdfNode::paragraph(inline_nodes);
if plid != "_" {
para.attrs = Some(serde_json::json!({"localId": plid}));
}
vec![para]
} else {
inline_nodes
};
let mut task = AdfNode::task_item(state, content);
// Override the placeholder localId if one was parsed
if let Some(id) = local_id {
if let Some(ref mut attrs) = task.attrs {
attrs["localId"] = serde_json::Value::String(id);
}
}
// Collect indented sub-content (e.g. nested task lists
// from malformed ADF where taskItem contains taskItem
// children directly — issue #489).
let mut sub_lines: Vec<String> = Vec::new();
while !self.at_end() && self.current_line().starts_with(" ") {
let stripped = &self.current_line()[2..];
sub_lines.push(stripped.to_string());
self.advance();
}
if !sub_lines.is_empty() {
let sub_text = sub_lines.join("\n");
let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
// When the task item has no inline text and its
// sub-content is a single taskList, this is a
// container taskItem from malformed ADF (issue #489).
// Unwrap the taskList so the taskItem children sit
// directly in the container, and drop the spurious
// `state` attr that was injected by the checkbox
// marker.
let is_empty = task.content.as_ref().map_or(true, Vec::is_empty);
if is_empty && nested.len() == 1 && nested[0].node_type == "taskList" {
if let Some(task_items) = nested.remove(0).content {
task.content = Some(task_items);
}
if let Some(ref mut attrs) = task.attrs {
if let Some(obj) = attrs.as_object_mut() {
obj.remove("state");
}
}
items.push(task);
} else {
// Separate nested taskList nodes from other block
// content. Nested taskLists become sibling children
// of the outer taskList rather than children of this
// taskItem, matching ADF's representation of indented
// sub-lists (issue #506).
let mut sibling_task_lists = Vec::new();
let mut child_nodes = Vec::new();
for n in nested {
if n.node_type == "taskList" {
sibling_task_lists.push(n);
} else {
child_nodes.push(n);
}
}
if !child_nodes.is_empty() {
match task.content {
Some(ref mut content) => content.append(&mut child_nodes),
None => task.content = Some(child_nodes),
}
}
items.push(task);
items.append(&mut sibling_task_lists);
}
} else {
items.push(task);
}
} else {
let first_line = &trimmed[2..];
self.advance();
let mut full_text = first_line.to_string();
self.collect_hardbreak_continuations(&mut full_text);
let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
// Collect indented sub-content lines (2-space prefix).
// This captures both nested lists and continuation
// paragraphs that belong to the same list item.
let mut sub_lines: Vec<String> = Vec::new();
while !self.at_end() {
let next = self.current_line();
if let Some(stripped) = next.strip_prefix(" ") {
sub_lines.push(stripped.to_string());
self.advance();
continue;
}
break;
}
let item_content =
parse_list_item_first_line(item_text, sub_lines, local_id, para_local_id)?;
items.push(item_content);
}
}
if items.is_empty() {
Ok(None)
} else if is_task_list {
Ok(Some(AdfNode::task_list(items)))
} else {
Ok(Some(AdfNode::bullet_list(items)))
}
}
fn parse_ordered_list(&mut self, start: u32) -> Result<Option<AdfNode>> {
let mut items = Vec::new();
while !self.at_end() {
let line = self.current_line();
let trimmed = line.trim_start();
if let Some((_, rest)) = parse_ordered_list_marker(trimmed) {
let first_line = rest.trim_start_matches(|c: char| c.is_ascii_whitespace());
self.advance();
let mut full_text = first_line.to_string();
self.collect_hardbreak_continuations(&mut full_text);
let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
// Collect indented sub-content lines (2-space prefix).
let mut sub_lines: Vec<String> = Vec::new();
while !self.at_end() {
let next = self.current_line();
if let Some(stripped) = next.strip_prefix(" ") {
sub_lines.push(stripped.to_string());
self.advance();
continue;
}
break;
}
let item_content =
parse_list_item_first_line(item_text, sub_lines, local_id, para_local_id)?;
items.push(item_content);
} else {
break;
}
}
if items.is_empty() {
Ok(None)
} else {
let order = if start == 1 { None } else { Some(start) };
Ok(Some(AdfNode::ordered_list(items, order)))
}
}
fn try_apply_block_attrs(&mut self, node: &mut AdfNode) {
if self.at_end() {
return;
}
let line = self.current_line().trim();
if !line.starts_with('{') {
return;
}
let Some((_, attrs)) = parse_attrs(line, 0) else {
return;
};
let mut marks = Vec::new();
if let Some(align) = attrs.get("align") {
marks.push(AdfMark::alignment(align));
}
if let Some(indent) = attrs.get("indent") {
if let Ok(level) = indent.parse::<u32>() {
marks.push(AdfMark::indentation(level));
}
}
if let Some(mode) = attrs.get("breakout") {
let width = attrs
.get("breakoutWidth")
.and_then(|w| w.parse::<u32>().ok());
marks.push(AdfMark::breakout(mode, width));
}
// Parse localId from block attrs
let local_id = attrs.get("localId").map(str::to_string);
// Parse explicit order for orderedList nodes (issue #547).
let order = if node.node_type == "orderedList" {
attrs.get("order").and_then(|v| v.parse::<u32>().ok())
} else {
None
};
let has_attrs = !marks.is_empty() || local_id.is_some() || order.is_some();
if has_attrs {
if !marks.is_empty() {
let existing = node.marks.get_or_insert_with(Vec::new);
existing.extend(marks);
}
if let Some(id) = local_id {
let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
node_attrs["localId"] = serde_json::Value::String(id);
}
if let Some(n) = order {
let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
node_attrs["order"] = serde_json::json!(n);
}
self.advance(); // consume the attrs line
}
}
fn try_container_directive(&mut self) -> Result<Option<AdfNode>> {
let line = self.current_line();
let Some((d, colon_count)) = try_parse_container_open(line) else {
return Ok(None);
};
self.advance(); // past opening fence
// Collect inner lines until the matching close fence, tracking nesting
let mut inner_lines = Vec::new();
let mut depth: usize = 0;
while !self.at_end() {
let current = self.current_line();
if try_parse_container_open(current).is_some() {
depth += 1;
} else if depth == 0 && is_container_close(current, colon_count) {
self.advance(); // past closing fence
break;
} else if depth > 0 && is_container_close(current, 3) {
depth -= 1;
}
inner_lines.push(current.to_string());
self.advance();
}
let inner_text = inner_lines.join("\n");
let node = match d.name.as_str() {
"panel" => {
let panel_type = d
.attrs
.as_ref()
.and_then(|a| a.get("type"))
.unwrap_or("info");
let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
let mut node = AdfNode::panel(panel_type, inner_blocks);
// Pass through custom panel attrs (icon, color)
if let Some(ref attrs) = d.attrs {
if let Some(ref mut node_attrs) = node.attrs {
if let Some(icon) = attrs.get("icon") {
node_attrs["panelIcon"] = serde_json::Value::String(icon.to_string());
}
if let Some(color) = attrs.get("color") {
node_attrs["panelColor"] = serde_json::Value::String(color.to_string());
}
}
}
node
}
"expand" => {
let title = d.attrs.as_ref().and_then(|a| a.get("title"));
let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
let mut node = AdfNode::expand(title, inner_blocks);
pass_through_expand_params(&d.attrs, &mut node);
node
}
"nested-expand" => {
let title = d.attrs.as_ref().and_then(|a| a.get("title"));
let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
let mut node = AdfNode::nested_expand(title, inner_blocks);
pass_through_expand_params(&d.attrs, &mut node);
node
}
"layout" => {
// Parse inner content looking for :::column sub-containers
let columns = self.parse_layout_columns(&inner_text)?;
AdfNode::layout_section(columns)
}
"decisions" => {
let items = parse_decision_items(&inner_text);
AdfNode::decision_list(items)
}
"table" => {
let rows = self.parse_directive_table_rows(&inner_text)?;
let mut table_attrs = serde_json::json!({});
if let Some(ref attrs) = d.attrs {
if let Some(layout) = attrs.get("layout") {
table_attrs["layout"] = serde_json::Value::String(layout.to_string());
}
if attrs.has_flag("numbered") {
table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
} else if attrs.get("numbered") == Some("false") {
table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
}
if let Some(tw) = attrs.get("width") {
if let Some(w) = parse_numeric_attr(tw) {
table_attrs["width"] = w;
}
}
if let Some(local_id) = attrs.get("localId") {
table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
}
}
if table_attrs == serde_json::json!({}) {
AdfNode::table(rows)
} else {
AdfNode::table_with_attrs(rows, table_attrs)
}
}
"extension" => {
let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
let mut node = AdfNode::bodied_extension(ext_type, ext_key, inner_blocks);
if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
{
if let Some(layout) = dir_attrs.get("layout") {
node_attrs["layout"] = serde_json::Value::String(layout.to_string());
}
if let Some(local_id) = dir_attrs.get("localId") {
node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
}
if let Some(params_str) = dir_attrs.get("params") {
if let Ok(params_val) =
serde_json::from_str::<serde_json::Value>(params_str)
{
node_attrs["parameters"] = params_val;
}
}
}
node
}
_ => return Ok(None),
};
Ok(Some(node))
}
fn parse_layout_columns(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
let mut columns = Vec::new();
let mut current_column_lines: Vec<String> = Vec::new();
let mut current_width: serde_json::Value = serde_json::json!(50);
let mut current_dir_attrs: Option<crate::atlassian::attrs::Attrs> = None;
let mut in_column = false;
let mut depth: usize = 0;
let lines: Vec<&str> = inner_text.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
if let Some((col_d, _)) = try_parse_container_open(line) {
if col_d.name == "column" && depth == 0 {
// Flush previous column
if in_column && !current_column_lines.is_empty() {
let col_text = current_column_lines.join("\n");
let blocks = MarkdownParser::new(&col_text).parse_blocks()?;
let mut col = AdfNode::layout_column(current_width.clone(), blocks);
pass_through_local_id(¤t_dir_attrs, &mut col);
columns.push(col);
current_column_lines.clear();
}
current_width = col_d
.attrs
.as_ref()
.and_then(|a| a.get("width"))
.and_then(parse_numeric_attr)
.unwrap_or_else(|| serde_json::json!(50));
current_dir_attrs = col_d.attrs;
in_column = true;
i += 1;
continue;
}
if in_column {
depth += 1;
}
}
if in_column && is_container_close(line, 3) {
if depth > 0 {
depth -= 1;
current_column_lines.push(line.to_string());
i += 1;
continue;
}
// End of column
let col_text = current_column_lines.join("\n");
let blocks = MarkdownParser::new(&col_text).parse_blocks()?;
let mut col = AdfNode::layout_column(current_width.clone(), blocks);
pass_through_local_id(¤t_dir_attrs, &mut col);
columns.push(col);
current_column_lines.clear();
current_dir_attrs = None;
in_column = false;
i += 1;
continue;
}
if in_column {
current_column_lines.push(line.to_string());
}
i += 1;
}
// Flush last column if no closing fence
if in_column && !current_column_lines.is_empty() {
let col_text = current_column_lines.join("\n");
let blocks = MarkdownParser::new(&col_text).parse_blocks()?;
let mut col = AdfNode::layout_column(current_width, blocks);
pass_through_local_id(¤t_dir_attrs, &mut col);
columns.push(col);
}
Ok(columns)
}
/// Parses `:::tr` / `:::th` / `:::td` sub-containers inside a `:::table` directive.
fn parse_directive_table_rows(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
debug!(
"parse_directive_table_rows: {} lines of inner text",
inner_text.lines().count()
);
let mut rows = Vec::new();
let lines: Vec<&str> = inner_text.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
if let Some((d, _)) = try_parse_container_open(line) {
if d.name == "tr" {
let tr_attrs = d.attrs.clone();
i += 1;
let (mut row, next_i) = self.parse_directive_table_row(&lines, i)?;
// Pass through localId from :::tr{localId=...}
if let Some(ref attrs) = tr_attrs {
if let Some(local_id) = attrs.get("localId") {
let row_attrs = row.attrs.get_or_insert_with(|| serde_json::json!({}));
row_attrs["localId"] = serde_json::Value::String(local_id.to_string());
}
}
rows.push(row);
i = next_i;
continue;
}
if d.name == "caption" {
let dir_attrs = d.attrs.clone();
i += 1;
let mut caption_lines = Vec::new();
while i < lines.len() {
if is_container_close(lines[i], 3) {
i += 1;
break;
}
caption_lines.push(lines[i]);
i += 1;
}
let caption_text = caption_lines.join("\n");
let inline_nodes = parse_inline(&caption_text);
let mut caption = AdfNode::caption(inline_nodes);
pass_through_local_id(&dir_attrs, &mut caption);
rows.push(caption);
continue;
}
}
i += 1;
}
Ok(rows)
}
/// Parses cells within a `:::tr` container until its closing fence.
fn parse_directive_table_row(&self, lines: &[&str], start: usize) -> Result<(AdfNode, usize)> {
let mut cells = Vec::new();
let mut i = start;
let mut depth: usize = 0;
while i < lines.len() {
let line = lines[i];
if is_container_close(line, 3) {
if depth == 0 {
// End of :::tr
i += 1;
break;
}
depth -= 1;
i += 1;
continue;
}
if let Some((d, _)) = try_parse_container_open(line) {
if depth == 0 && (d.name == "th" || d.name == "td") {
let is_header = d.name == "th";
let cell_attrs = d.attrs.clone();
i += 1;
let (cell, next_i) =
self.parse_directive_table_cell(lines, i, is_header, cell_attrs)?;
cells.push(cell);
i = next_i;
continue;
}
depth += 1;
}
i += 1;
}
if cells.is_empty() {
let context = lines[start.saturating_sub(1)..lines.len().min(start + 3)].to_vec();
warn!(
"Directive table row at line {start} has no cells — \
Confluence requires at least one. Nearby lines: {context:?}"
);
}
debug!("Parsed directive table row: {} cells", cells.len());
Ok((AdfNode::table_row(cells), i))
}
/// Parses the content of a `:::th` or `:::td` cell until its closing fence.
fn parse_directive_table_cell(
&self,
lines: &[&str],
start: usize,
is_header: bool,
cell_attrs: Option<crate::atlassian::attrs::Attrs>,
) -> Result<(AdfNode, usize)> {
let mut cell_lines = Vec::new();
let mut i = start;
let mut depth: usize = 0;
while i < lines.len() {
let line = lines[i];
if try_parse_container_open(line).is_some() {
depth += 1;
} else if is_container_close(line, 3) {
if depth == 0 {
i += 1;
break;
}
depth -= 1;
}
cell_lines.push(line.to_string());
i += 1;
}
let cell_text = cell_lines.join("\n");
let blocks = MarkdownParser::new(&cell_text).parse_blocks()?;
let adf_attrs = cell_attrs.as_ref().map(build_cell_attrs);
let cell_marks = cell_attrs
.as_ref()
.map(build_border_marks)
.unwrap_or_default();
let cell = if cell_marks.is_empty() {
if is_header {
if let Some(attrs) = adf_attrs {
AdfNode::table_header_with_attrs(blocks, attrs)
} else {
AdfNode::table_header(blocks)
}
} else if let Some(attrs) = adf_attrs {
AdfNode::table_cell_with_attrs(blocks, attrs)
} else {
AdfNode::table_cell(blocks)
}
} else if is_header {
AdfNode::table_header_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
} else {
AdfNode::table_cell_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
};
Ok((cell, i))
}
fn try_leaf_directive(&mut self) -> Option<AdfNode> {
let line = self.current_line();
let d = try_parse_leaf_directive(line)?;
let node = match d.name.as_str() {
"card" => {
let content = d.content.as_deref().unwrap_or("");
// Prefer the `url` attribute when present; fall back to the
// bracketed content. The attribute form is used when the URL
// contains characters that would otherwise break
// `::card[URL]` parsing.
let url = match d.attrs.as_ref().and_then(|a| a.get("url")) {
Some(u) => u,
None => content,
};
let mut node = AdfNode::block_card(url);
// Pass through layout/width attrs
if let Some(ref attrs) = d.attrs {
if let Some(ref mut node_attrs) = node.attrs {
if let Some(layout) = attrs.get("layout") {
node_attrs["layout"] = serde_json::Value::String(layout.to_string());
}
if let Some(width) = attrs.get("width") {
if let Ok(w) = width.parse::<u64>() {
node_attrs["width"] = serde_json::json!(w);
}
}
}
}
node
}
"embed" => {
let url = d.content.as_deref().unwrap_or("");
let layout = d.attrs.as_ref().and_then(|a| a.get("layout"));
let original_height = d
.attrs
.as_ref()
.and_then(|a| a.get("originalHeight"))
.and_then(|v| v.parse::<f64>().ok());
let width = d
.attrs
.as_ref()
.and_then(|a| a.get("width"))
.and_then(|w| w.parse::<f64>().ok());
AdfNode::embed_card(url, layout, original_height, width)
}
"extension" => {
let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
let params = d
.attrs
.as_ref()
.and_then(|a| a.get("params"))
.and_then(|p| serde_json::from_str(p).ok());
let mut node = AdfNode::extension(ext_type, ext_key, params);
if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
{
if let Some(layout) = dir_attrs.get("layout") {
node_attrs["layout"] = serde_json::Value::String(layout.to_string());
}
if let Some(local_id) = dir_attrs.get("localId") {
node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
}
}
node
}
"paragraph" => {
let mut node = if let Some(ref text) = d.content {
AdfNode::paragraph(parse_inline(text))
} else {
AdfNode::paragraph(vec![])
};
pass_through_local_id(&d.attrs, &mut node);
node
}
_ => return None,
};
self.advance();
Some(node)
}
fn try_image(&mut self) -> Option<AdfNode> {
let line = self.current_line().trim();
let mut node = try_parse_media_single_from_line(line)?;
self.advance();
// Check for a trailing :::caption directive
if !self.at_end() {
if let Some((d, _)) = try_parse_container_open(self.current_line()) {
if d.name == "caption" {
let dir_attrs = d.attrs;
self.advance(); // past :::caption
let mut caption_lines = Vec::new();
while !self.at_end() {
if is_container_close(self.current_line(), 3) {
self.advance(); // past :::
break;
}
caption_lines.push(self.current_line());
self.advance();
}
let caption_text = caption_lines.join("\n");
let inline_nodes = parse_inline(&caption_text);
let mut caption = AdfNode::caption(inline_nodes);
pass_through_local_id(&dir_attrs, &mut caption);
if let Some(ref mut content) = node.content {
content.push(caption);
}
}
}
}
Some(node)
}
fn try_table(&mut self) -> Result<Option<AdfNode>> {
let line = self.current_line();
if !line.contains('|') || !line.trim_start().starts_with('|') {
return Ok(None);
}
// Peek ahead to check for a separator row (indicates a table)
if self.pos + 1 >= self.lines.len() {
return Ok(None);
}
let next_line = self.lines[self.pos + 1];
if !is_table_separator(next_line) {
return Ok(None);
}
// Parse header row
let header_cells = parse_table_row(line);
self.advance(); // skip header
// Parse separator row for column alignment
let sep_line = self.current_line();
let alignments = parse_table_alignments(sep_line);
self.advance(); // skip separator
let mut rows = Vec::new();
// Header row — parse cell attrs and apply column alignment
let header_adf_cells: Vec<AdfNode> = header_cells
.iter()
.enumerate()
.map(|(col_idx, cell)| {
let (cell_text, cell_attrs) = extract_cell_attrs(cell);
let mut para = AdfNode::paragraph(parse_inline(&cell_text));
apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
if let Some(attrs) = cell_attrs {
AdfNode::table_header_with_attrs(vec![para], attrs)
} else {
AdfNode::table_header(vec![para])
}
})
.collect();
if header_adf_cells.is_empty() {
warn!(
"Pipe table header row at line {} has no cells",
self.pos - 1
);
}
rows.push(AdfNode::table_row(header_adf_cells));
// Body rows
while !self.at_end() {
let line = self.current_line();
if !line.contains('|') || line.trim().is_empty() {
break;
}
let cells = parse_table_row(line);
let adf_cells: Vec<AdfNode> = cells
.iter()
.enumerate()
.map(|(col_idx, cell)| {
let (cell_text, cell_attrs) = extract_cell_attrs(cell);
let mut para = AdfNode::paragraph(parse_inline(&cell_text));
apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
if let Some(attrs) = cell_attrs {
AdfNode::table_cell_with_attrs(vec![para], attrs)
} else {
AdfNode::table_cell(vec![para])
}
})
.collect();
if adf_cells.is_empty() {
warn!("Pipe table body row at line {} has no cells", self.pos);
}
rows.push(AdfNode::table_row(adf_cells));
self.advance();
}
debug!("Parsed pipe table with {} rows", rows.len());
let mut table = AdfNode::table(rows);
// Check for trailing {attrs} on the next line
if !self.at_end() {
let next = self.current_line().trim();
if next.starts_with('{') {
if let Some((_, attrs)) = parse_attrs(next, 0) {
let mut table_attrs = serde_json::json!({});
if let Some(layout) = attrs.get("layout") {
table_attrs["layout"] = serde_json::Value::String(layout.to_string());
}
if attrs.has_flag("numbered") {
table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
} else if attrs.get("numbered") == Some("false") {
table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
}
if let Some(tw) = attrs.get("width") {
if let Some(w) = parse_numeric_attr(tw) {
table_attrs["width"] = w;
}
}
if let Some(local_id) = attrs.get("localId") {
table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
}
if table_attrs != serde_json::json!({}) {
table.attrs = Some(table_attrs);
self.advance(); // consume the attrs line
}
}
}
}
Ok(Some(table))
}
fn parse_paragraph(&mut self) -> Result<AdfNode> {
let mut lines: Vec<&str> = Vec::new();
while !self.at_end() {
let line = self.current_line();
// Only break on block-level patterns if we already have paragraph
// content. This prevents infinite loops when a line looks like a
// block starter but doesn't actually match any block parser (e.g.,
// "#NoSpace" which is not a valid heading).
// Issue #494: A whitespace-only line that follows a hardBreak
// marker (trailing backslash or two trailing spaces) is a
// continuation, not a paragraph break. Let it fall through to
// the `is_hardbreak_cont` check below.
if (line.trim().is_empty()
&& !lines
.last()
.is_some_and(|prev| has_trailing_hard_break(prev)))
|| is_code_fence_opener(line)
|| (is_horizontal_rule(line) && !lines.is_empty())
{
break;
}
// Strip 2-space indent from hardBreak continuation lines so
// the content round-trips correctly (issue #455).
let is_hardbreak_cont = !lines.is_empty()
&& line.starts_with(" ")
&& lines
.last()
.is_some_and(|prev| has_trailing_hard_break(prev));
if is_hardbreak_cont {
lines.push(&line[2..]);
self.advance();
continue;
}
if !lines.is_empty()
&& (line.starts_with('#') || line.starts_with('>') || is_list_start(line))
{
break;
}
// Break on trailing block attrs like {align=center}
if !lines.is_empty() && is_block_attrs_line(line) {
break;
}
lines.push(line);
self.advance();
}
let text = lines.join("\n");
let inline_nodes = parse_inline(&text);
Ok(AdfNode::paragraph(inline_nodes))
}
}
/// Builds ADF cell attributes from JFM directive attrs.
/// Maps: `bg` → `background`, `colspan` → number, `rowspan` → number, `colwidth` → array.
fn build_cell_attrs(attrs: &crate::atlassian::attrs::Attrs) -> serde_json::Value {
let mut adf = serde_json::json!({});
if let Some(bg) = attrs.get("bg") {
adf["background"] = serde_json::Value::String(bg.to_string());
}
if let Some(colspan) = attrs.get("colspan") {
if let Ok(n) = colspan.parse::<u32>() {
adf["colspan"] = serde_json::json!(n);
}
}
if let Some(rowspan) = attrs.get("rowspan") {
if let Ok(n) = rowspan.parse::<u32>() {
adf["rowspan"] = serde_json::json!(n);
}
}
if let Some(colwidth) = attrs.get("colwidth") {
let widths: Vec<serde_json::Value> = colwidth
.split(',')
.filter_map(|s| parse_numeric_attr(s.trim()))
.collect();
if !widths.is_empty() {
adf["colwidth"] = serde_json::Value::Array(widths);
}
}
if let Some(local_id) = attrs.get("localId") {
adf["localId"] = serde_json::Value::String(local_id.to_string());
}
adf
}
/// Extracts border marks from directive attributes (used by table cells and media nodes).
fn build_border_marks(attrs: &crate::atlassian::attrs::Attrs) -> Vec<AdfMark> {
let mut marks = Vec::new();
let border_color = attrs.get("border-color");
let border_size = attrs.get("border-size");
if border_color.is_some() || border_size.is_some() {
let color = border_color.unwrap_or("#000000");
let size = border_size.and_then(|s| s.parse::<u32>().ok()).unwrap_or(1);
marks.push(AdfMark::border(color, size));
}
marks
}
/// Converts an ISO 8601 date string (e.g., "2026-04-15") to epoch milliseconds string.
/// If the input is already numeric (epoch ms), returns it unchanged.
fn iso_date_to_epoch_ms(date_str: &str) -> String {
// If it's already a numeric timestamp, pass through
if date_str.chars().all(|c| c.is_ascii_digit()) {
return date_str.to_string();
}
if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
let epoch_ms = date
.and_hms_opt(0, 0, 0)
.map_or(0, |dt| dt.and_utc().timestamp_millis());
epoch_ms.to_string()
} else {
// Fallback: pass through as-is
date_str.to_string()
}
}
/// Converts an epoch milliseconds string to an ISO 8601 date string.
/// If the input looks like an ISO date already, returns it unchanged.
fn epoch_ms_to_iso_date(timestamp: &str) -> String {
// If it looks like an ISO date already, pass through
if timestamp.contains('-') {
return timestamp.to_string();
}
if let Ok(ms) = timestamp.parse::<i64>() {
let secs = ms / 1000;
if let Some(dt) = chrono::DateTime::from_timestamp(secs, 0) {
return dt.format("%Y-%m-%d").to_string();
}
}
// Fallback: pass through
timestamp.to_string()
}
/// Checks if a line is a standalone block-level attrs line like `{align=center}`.
fn is_block_attrs_line(line: &str) -> bool {
let trimmed = line.trim();
if !trimmed.starts_with('{') || !trimmed.ends_with('}') {
return false;
}
if let Some((_, attrs)) = parse_attrs(trimmed, 0) {
// Only consider it a block attrs line if it has recognized block attrs
attrs.get("align").is_some()
|| attrs.get("indent").is_some()
|| attrs.get("breakout").is_some()
|| attrs.get("breakoutWidth").is_some()
|| attrs.get("localId").is_some()
} else {
false
}
}
/// Parses decision items from the inner content of a `:::decisions` container.
/// Each item starts with `- <> ` prefix.
fn parse_decision_items(text: &str) -> Vec<AdfNode> {
let mut items = Vec::new();
for line in text.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("- <> ") {
let inline_nodes = parse_inline(rest);
items.push(AdfNode::decision_item(
"DECIDED",
vec![AdfNode::paragraph(inline_nodes)],
));
}
}
items
}
/// Tries to parse a task list marker `[ ]`, `[x]`, or `[X]` at the start of text.
/// Returns `("TODO"|"DONE", remaining_text)` on success.
///
/// The marker must be followed by a space or the end of the text, so that
/// empty task items (`- [ ]` with no body) are still recognised as tasks
/// rather than being treated as bullet items containing literal `[ ]` text
/// (issue #548).
fn try_parse_task_marker(text: &str) -> Option<(&str, &str)> {
if let Some(rest) = strip_task_checkbox(text, "[ ]") {
Some(("TODO", rest))
} else if let Some(rest) =
strip_task_checkbox(text, "[x]").or_else(|| strip_task_checkbox(text, "[X]"))
{
Some(("DONE", rest))
} else {
None
}
}
/// Strips a checkbox prefix from `text` if the character after the checkbox
/// is a space or the text ends there. Returns the remaining text (with the
/// separating space consumed, if any).
fn strip_task_checkbox<'a>(text: &'a str, checkbox: &str) -> Option<&'a str> {
let rest = text.strip_prefix(checkbox)?;
if rest.is_empty() {
Some(rest)
} else {
rest.strip_prefix(' ')
}
}
/// Returns true if `s` begins with a sequence the bullet-list parser would
/// interpret as a task checkbox marker (`[ ]`, `[x]`, or `[X]` followed by
/// a space, newline, or end-of-input).
///
/// Used by the `bulletList` renderer to decide whether to escape the leading
/// `[` of an item whose literal text starts with a checkbox-shaped prefix
/// (issue #548).
fn starts_with_task_marker(s: &str) -> bool {
let after = if let Some(rest) = s.strip_prefix("[ ]") {
rest
} else if let Some(rest) = s.strip_prefix("[x]").or_else(|| s.strip_prefix("[X]")) {
rest
} else {
return false;
};
after.is_empty() || after.starts_with(' ') || after.starts_with('\n')
}
/// Parses an ordered list marker like "1. " and returns (number, rest_of_line).
fn parse_ordered_list_marker(line: &str) -> Option<(u32, &str)> {
let digit_end = line.find(|c: char| !c.is_ascii_digit())?;
if digit_end == 0 {
return None;
}
let rest = &line[digit_end..];
let after_marker = rest.strip_prefix(". ")?;
let num: u32 = line[..digit_end].parse().ok()?;
Some((num, after_marker))
}
/// Returns true if a line ends with a hardBreak marker
/// (trailing backslash or two trailing spaces).
fn has_trailing_hard_break(line: &str) -> bool {
line.ends_with('\\') || line.ends_with(" ")
}
/// Returns true if the already-trimmed continuation line starts with a
/// block-level marker that must not be swallowed as a paragraph continuation
/// in `collect_hardbreak_continuations`.
///
/// Covers mediaSingle (`![` — issue #490), fenced code blocks (```` ``` ````
/// — issue #552), and container directives (`:::`). The caller is expected
/// to have already stripped leading whitespace.
fn is_block_level_continuation_marker(trimmed: &str) -> bool {
trimmed.starts_with("![") || trimmed.starts_with("```") || trimmed.starts_with(":::")
}
/// Checks if a line starts a list item.
fn is_list_start(line: &str) -> bool {
let trimmed = line.trim_start();
trimmed.starts_with("- ")
|| trimmed.starts_with("* ")
|| trimmed.starts_with("+ ")
|| parse_ordered_list_marker(trimmed).is_some()
}
/// Escapes asterisk and underscore sequences in text that would otherwise be
/// parsed as CommonMark emphasis (`*…*`, `_…_`) or strong emphasis (`**…**`,
/// `__…__`).
///
/// Asterisks are always escaped (they're rare in prose and the JFM parser
/// will gladly match them across node boundaries). Underscores are escaped
/// per the intraword rule: a `_` is left as-is only when it's clearly
/// intraword *within this text node* (alphanumeric on both sides). At the
/// node boundary or next to non-alphanumeric characters we escape, since
/// adjacent text nodes can supply the other side of an emphasis pair (issue
/// #554: `"_ "` followed by colored `"_Action…"` produced `_ :span[_…` which
/// parsed as italic and destroyed the span directive).
fn escape_emphasis_markers(text: &str) -> String {
escape_emphasis_with(text, false)
}
/// Variant of [`escape_emphasis_markers`] that escapes ALL underscores (even
/// intraword), not just boundary ones.
///
/// Must be used whenever the rendered markdown wraps this text in an `_..._`
/// em delimiter, because an unescaped `_` anywhere in the content would
/// otherwise close the delimiter prematurely (e.g. `_foo_bar_baz_` parses as
/// em("foo") + "bar" + em("baz"), not em("foo_bar_baz")).
fn escape_emphasis_markers_with_underscore(text: &str) -> String {
escape_emphasis_with(text, true)
}
/// Internal: escapes `*` always, and escapes `_` per the CommonMark intraword
/// rule by default — boundary or punctuation-adjacent runs are escaped, fully
/// intraword runs are left as-is. When `escape_underscore_always` is true,
/// every `_` is escaped regardless (used when the surrounding context is an
/// `_..._` em delimiter that any inner `_` would close prematurely).
fn escape_emphasis_with(text: &str, escape_underscore_always: bool) -> String {
let chars: Vec<char> = text.chars().collect();
let mut out = String::with_capacity(text.len());
let mut idx = 0;
while idx < chars.len() {
let ch = chars[idx];
if ch == '*' {
out.push('\\');
out.push(ch);
idx += 1;
} else if ch == '_' {
// Find the extent of this run of underscores. CommonMark treats
// consecutive `_` as a single delimiter run, so the intraword
// check applies to the whole run, not individual characters.
let run_start = idx;
let mut run_end = idx;
while run_end < chars.len() && chars[run_end] == '_' {
run_end += 1;
}
let escape_run = if escape_underscore_always {
true
} else {
let before_alnum = run_start > 0 && chars[run_start - 1].is_alphanumeric();
let after_alnum = chars.get(run_end).is_some_and(|c| c.is_alphanumeric());
!(before_alnum && after_alnum)
};
for _ in run_start..run_end {
if escape_run {
out.push('\\');
}
out.push('_');
}
idx = run_end;
} else {
out.push(ch);
idx += 1;
}
}
out
}
/// Escapes backtick characters in text that would otherwise be parsed as
/// inline code spans (`` `…` ``).
///
/// Each backtick is prefixed with a backslash so that the JFM parser treats
/// it as a literal character rather than an inline-code delimiter.
fn escape_backticks(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
if ch == '`' {
out.push('\\');
}
out.push(ch);
}
out
}
/// Chooses a backtick delimiter length and padding flag for rendering `text`
/// as a CommonMark inline code span.
///
/// Per CommonMark: the delimiter must be a run of backticks not equal in
/// length to any run inside the content, and if both ends of the content
/// would start/end with a space (or with a backtick), a single space of
/// padding is added so the span survives the spec's space-stripping rule.
fn inline_code_delimiter(text: &str) -> (usize, bool) {
let mut max_run = 0usize;
let mut current = 0usize;
for ch in text.chars() {
if ch == '`' {
current += 1;
if current > max_run {
max_run = current;
}
} else {
current = 0;
}
}
let n = max_run + 1;
let starts_bt = text.starts_with('`');
let ends_bt = text.ends_with('`');
let starts_sp = text.starts_with(' ');
let ends_sp = text.ends_with(' ');
let all_sp = !text.is_empty() && text.chars().all(|c| c == ' ');
let needs_pad = starts_bt || ends_bt || (starts_sp && ends_sp && !all_sp);
(n, needs_pad)
}
/// Appends `text` to `output` wrapped in a CommonMark inline code span whose
/// delimiter length allows any embedded backticks to round-trip unambiguously.
fn render_inline_code(text: &str, output: &mut String) {
let (n, pad) = inline_code_delimiter(text);
for _ in 0..n {
output.push('`');
}
if pad {
output.push(' ');
}
output.push_str(text);
if pad {
output.push(' ');
}
for _ in 0..n {
output.push('`');
}
}
/// Escapes pipe characters in text that appears inside a GFM pipe table cell.
///
/// Without this, a literal `|` in cell content (including inside inline code
/// spans) is interpreted as a column separator on round-trip, splitting the
/// cell and corrupting its content (see issue #579). Each `|` is prefixed
/// with a backslash so the table-row parser treats it as literal.
fn escape_pipes_in_cell(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
if ch == '|' {
out.push('\\');
}
out.push(ch);
}
out
}
/// Escapes square brackets (`[` and `]`) in text that will appear inside a
/// markdown link's `[…]` delimiters. Without this, a text node containing a
/// literal `[` or `]` can create ambiguous markdown link syntax on round-trip
/// (see issue #493).
fn escape_link_brackets(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
if ch == '[' || ch == ']' {
out.push('\\');
}
out.push(ch);
}
out
}
/// Escapes bare URLs (`http://` and `https://`) in plain text so they are not
/// parsed as `inlineCard` nodes during round-trip. The leading `h` is
/// backslash-escaped, which is enough to prevent the auto-link detector from
/// matching the URL while the existing backslash-escape handler restores it on
/// re-parse.
fn escape_bare_urls(text: &str) -> String {
let mut result = String::with_capacity(text.len());
for (i, ch) in text.char_indices() {
if ch == 'h' {
let rest = &text[i..];
if rest.starts_with("http://") || rest.starts_with("https://") {
result.push('\\');
}
}
result.push(ch);
}
result
}
/// Returns `true` if the string can be embedded in a `:card[...]` (or similar
/// bracketed inline directive) without breaking the depth-based bracket matcher
/// used by [`try_parse_inline_directive`].
///
/// The parser scans the content enclosed in `[...]` treating `[` as +1 and `]`
/// as −1 on depth, closing when depth returns to zero. A value is safe if
/// every prefix has `count('[') >= count(']') − 1` (i.e., the running depth
/// never dips below zero before the end) and it contains no newline.
fn url_safe_in_bracket_content(s: &str) -> bool {
if s.contains('\n') {
return false;
}
let mut depth: i32 = 1;
for ch in s.chars() {
match ch {
'[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
return false;
}
}
_ => {}
}
}
true
}
/// Escapes emoji shortcode patterns (`:name:`) in plain text so they are not
/// parsed as emoji nodes during round-trip. Only the leading colon is
/// backslash-escaped, which is enough to prevent the parser from matching the
/// pattern while the existing backslash-escape handler restores it on re-parse.
///
/// The character class for the name segment must match `try_parse_emoji_shortcode`
/// exactly (Unicode `is_alphanumeric` plus `_`, `+`, `-`). An ASCII-only escape
/// would leave Unicode patterns like `:Café:` or `:ZBC::Zendesk::配置:` un-escaped
/// while still being detected as emoji on re-parse, splitting the text node
/// (issue #552).
fn escape_emoji_shortcodes(text: &str) -> String {
let mut result = String::with_capacity(text.len());
for (i, ch) in text.char_indices() {
if ch == ':' {
// Check if this is a `:name:` pattern where name matches the
// same character class accepted by `try_parse_emoji_shortcode`.
let after = i + 1;
if after < text.len() {
let name_end = text[after..]
.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')
.map_or(text[after..].len(), |pos| pos);
if name_end > 0
&& after + name_end < text.len()
&& text.as_bytes()[after + name_end] == b':'
{
// Found `:name:` pattern — escape the leading colon
result.push('\\');
}
}
}
result.push(ch);
}
result
}
/// Escapes a leading list-marker pattern on a line so it is not
/// re-parsed as a new list item. `"2. text"` → `"2\. text"`,
/// `"- text"` → `"\- text"`.
fn escape_list_marker(line: &str) -> String {
if let Some(dot_pos) = line.find(". ") {
if parse_ordered_list_marker(line).is_some() {
let mut s = String::with_capacity(line.len() + 1);
s.push_str(&line[..dot_pos]);
s.push('\\');
s.push_str(&line[dot_pos..]);
return s;
}
}
for prefix in &["- ", "* ", "+ "] {
if line.starts_with(prefix) {
let mut s = String::with_capacity(line.len() + 1);
s.push('\\');
s.push_str(line);
return s;
}
}
line.to_string()
}
/// Checks if a line is a valid fenced code block opener.
///
/// Per CommonMark: the opener is a sequence of three or more backticks
/// followed by an info string that must not contain any backtick
/// character, otherwise some inline code spans would be misinterpreted
/// as the beginning of a fenced code block.
fn is_code_fence_opener(line: &str) -> bool {
if !line.starts_with("```") {
return false;
}
!line[3..].contains('`')
}
/// Checks if a line is a horizontal rule.
fn is_horizontal_rule(line: &str) -> bool {
let trimmed = line.trim();
trimmed.len() >= 3
&& ((trimmed.starts_with("---") && trimmed.chars().all(|c| c == '-'))
|| (trimmed.starts_with("***") && trimmed.chars().all(|c| c == '*'))
|| (trimmed.starts_with("___") && trimmed.chars().all(|c| c == '_')))
}
/// Checks if a line is a GFM table separator (e.g., "|---|---|").
fn is_table_separator(line: &str) -> bool {
let trimmed = line.trim();
trimmed.contains('|')
&& trimmed
.chars()
.all(|c| c == '|' || c == '-' || c == ':' || c == ' ')
}
/// Parses a GFM table row into cell contents.
///
/// Splits on unescaped `|` characters; a preceding backslash (`\|`) is
/// interpreted as a literal pipe and unescaped in the emitted cell content
/// (see issue #579). This allows code spans and other inline content that
/// contain literal `|` to survive round-trip through a pipe table.
fn parse_table_row(line: &str) -> Vec<String> {
let trimmed = line.trim();
let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
let mut cells: Vec<String> = Vec::new();
let mut current = String::new();
let mut chars = trimmed.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\\' && chars.peek() == Some(&'|') {
current.push('|');
chars.next();
} else if ch == '|' {
cells.push(std::mem::take(&mut current));
} else {
current.push(ch);
}
}
cells.push(current);
cells
.iter()
.map(|s| {
// Strip exactly one leading and one trailing space (pipe table padding).
// Preserve any additional whitespace as significant content.
let stripped = s.strip_prefix(' ').unwrap_or(s.as_str());
let stripped = stripped.strip_suffix(' ').unwrap_or(stripped);
stripped.to_string()
})
.collect()
}
/// Parses column alignments from a GFM table separator row.
/// Returns a vec of `Option<&str>` where `Some("center")` or `Some("end")` indicate alignment.
fn parse_table_alignments(separator_line: &str) -> Vec<Option<&'static str>> {
let trimmed = separator_line.trim();
let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
trimmed
.split('|')
.map(|cell| {
let cell = cell.trim();
let starts_colon = cell.starts_with(':');
let ends_colon = cell.ends_with(':');
match (starts_colon, ends_colon) {
(true, true) => Some("center"),
(false, true) => Some("end"),
_ => None, // left/default
}
})
.collect()
}
/// Applies an alignment mark to a paragraph node if alignment is specified.
fn apply_column_alignment(para: &mut AdfNode, alignment: Option<&str>) {
if let Some(align) = alignment {
para.marks = Some(vec![AdfMark::alignment(align)]);
}
}
/// Extracts `{attrs}` prefix from a pipe table cell text.
/// Returns `(remaining_text, Option<adf_attrs_json>)`.
fn extract_cell_attrs(cell_text: &str) -> (String, Option<serde_json::Value>) {
let trimmed = cell_text.trim_start();
if !trimmed.starts_with('{') {
return (cell_text.to_string(), None);
}
if let Some((end_pos, attrs)) = parse_attrs(trimmed, 0) {
let remaining = trimmed[end_pos..].trim_start().to_string();
let adf_attrs = build_cell_attrs(&attrs);
(remaining, Some(adf_attrs))
} else {
(cell_text.to_string(), None)
}
}
/// Tries to parse a line as a block-level image and return a mediaSingle ADF node.
/// Used by both `try_image` (top-level blocks) and list item parsing.
fn try_parse_media_single_from_line(line: &str) -> Option<AdfNode> {
let line = line.trim();
if !line.starts_with("? + 1; // index of '('
let img_end = find_closing_paren(line, paren_open)? + 1;
let after_img = line[img_end..].trim_start();
if after_img.starts_with('{') {
if let Some((_, attrs)) = parse_attrs(after_img, 0) {
// Confluence file attachment — reconstruct type:file media node
if attrs.get("type") == Some("file") || attrs.get("id").is_some() {
let mut media_attrs = serde_json::json!({"type": "file"});
if let Some(id) = attrs.get("id") {
media_attrs["id"] = serde_json::Value::String(id.to_string());
}
if let Some(collection) = attrs.get("collection") {
media_attrs["collection"] = serde_json::Value::String(collection.to_string());
}
if let Some(occurrence_key) = attrs.get("occurrenceKey") {
media_attrs["occurrenceKey"] =
serde_json::Value::String(occurrence_key.to_string());
}
if let Some(height) = attrs.get("height") {
if let Some(h) = parse_numeric_attr(height) {
media_attrs["height"] = h;
}
}
if let Some(width) = attrs.get("width") {
if let Some(w) = parse_numeric_attr(width) {
media_attrs["width"] = w;
}
}
if let Some(alt_text) = alt_opt {
media_attrs["alt"] = serde_json::Value::String(alt_text.to_string());
}
if let Some(local_id) = attrs.get("localId") {
media_attrs["localId"] = serde_json::Value::String(local_id.to_string());
}
let mut ms_attrs = serde_json::json!({"layout": "center"});
if let Some(layout) = attrs.get("layout") {
ms_attrs["layout"] = serde_json::Value::String(layout.to_string());
}
if let Some(ms_width) = attrs.get("mediaWidth") {
if let Some(w) = parse_numeric_attr(ms_width) {
ms_attrs["width"] = w;
}
}
if let Some(wt) = attrs.get("widthType") {
ms_attrs["widthType"] = serde_json::Value::String(wt.to_string());
}
if let Some(mode) = attrs.get("mode") {
ms_attrs["mode"] = serde_json::Value::String(mode.to_string());
}
let border_marks = build_border_marks(&attrs);
let media_marks = if border_marks.is_empty() {
None
} else {
Some(border_marks)
};
return Some(AdfNode {
node_type: "mediaSingle".to_string(),
attrs: Some(ms_attrs),
content: Some(vec![AdfNode {
node_type: "media".to_string(),
attrs: Some(media_attrs),
content: None,
text: None,
marks: media_marks,
local_id: None,
parameters: None,
}]),
text: None,
marks: None,
local_id: None,
parameters: None,
});
}
// External image — apply layout/width/widthType to mediaSingle attrs
let mut node = AdfNode::media_single(url, alt_opt);
if let Some(ref mut node_attrs) = node.attrs {
if let Some(layout) = attrs.get("layout") {
node_attrs["layout"] = serde_json::Value::String(layout.to_string());
}
if let Some(width) = attrs.get("width") {
if let Some(w) = parse_numeric_attr(width) {
node_attrs["width"] = w;
}
}
if let Some(wt) = attrs.get("widthType") {
node_attrs["widthType"] = serde_json::Value::String(wt.to_string());
}
if let Some(mode) = attrs.get("mode") {
node_attrs["mode"] = serde_json::Value::String(mode.to_string());
}
}
if let Some(ref mut content) = node.content {
if let Some(media) = content.first_mut() {
if let Some(local_id) = attrs.get("localId") {
if let Some(ref mut media_attrs) = media.attrs {
media_attrs["localId"] =
serde_json::Value::String(local_id.to_string());
}
}
let border_marks = build_border_marks(&attrs);
if !border_marks.is_empty() {
media.marks = Some(border_marks);
}
}
}
return Some(node);
}
}
Some(AdfNode::media_single(url, alt_opt))
}
/// Parses `` image syntax.
fn parse_image_syntax(line: &str) -> Option<(&str, &str)> {
let line = line.trim();
if !line.starts_with("?;
let alt = &line[2..alt_end];
let paren_start = alt_end + 1; // index of the '('
let url_end = find_closing_paren(line, paren_start)?;
let url = &line[paren_start + 1..url_end];
Some((alt, url))
}
// ── Inline Parsing ──────────────────────────────────────────────────
/// Parses inline markdown content into ADF inline nodes.
///
/// Detects bare URLs (e.g., `https://example.com`) and promotes them to
/// `inlineCard` nodes. Call this at the top level (paragraph, heading, cell,
/// list item) where a bare URL represents a smart link.
fn parse_inline(text: &str) -> Vec<AdfNode> {
parse_inline_impl(text, true)
}
/// Parses inline markdown content without promoting bare URLs to `inlineCard`.
///
/// Used when recursing into mark-wrapping constructs such as emphasis, strike,
/// bracketed spans, or links. In those contexts, the enclosing syntax already
/// declares the semantic role of the content — a URL inside `[url]{underline}`
/// or `**url**` is the user's text, not a smart link (issue #553).
fn parse_inline_no_auto_cards(text: &str) -> Vec<AdfNode> {
parse_inline_impl(text, false)
}
/// Implementation backing [`parse_inline`] and [`parse_inline_no_auto_cards`].
///
/// When `auto_inline_card` is `false`, bare `http://`/`https://` URLs are
/// treated as plain text instead of being promoted to `inlineCard` nodes.
fn parse_inline_impl(text: &str, auto_inline_card: bool) -> Vec<AdfNode> {
let mut nodes = Vec::new();
let mut chars = text.char_indices().peekable();
let mut plain_start = 0;
while let Some(&(i, ch)) = chars.peek() {
match ch {
'*' | '_' => {
if let Some((end, content, is_bold)) = try_parse_emphasis(text, i) {
flush_plain(text, plain_start, i, &mut nodes);
let mark = if is_bold {
AdfMark::strong()
} else {
AdfMark::em()
};
let inner = parse_inline_no_auto_cards(content);
for mut node in inner {
prepend_mark(&mut node, mark.clone());
nodes.push(node);
}
// Advance past the consumed characters
while chars.peek().is_some_and(|&(idx, _)| idx < end) {
chars.next();
}
plain_start = end;
continue;
}
// For underscores, skip the entire delimiter run so that
// individual `_` chars within a `__` or `___` run are not
// re-tried as separate emphasis openers (CommonMark treats
// consecutive underscores as a single delimiter run).
if ch == '_' {
while chars.peek().is_some_and(|&(_, c)| c == '_') {
chars.next();
}
} else {
chars.next();
}
}
'~' => {
if let Some((end, content)) = try_parse_strikethrough(text, i) {
flush_plain(text, plain_start, i, &mut nodes);
let inner = parse_inline_no_auto_cards(content);
for mut node in inner {
prepend_mark(&mut node, AdfMark::strike());
nodes.push(node);
}
while chars.peek().is_some_and(|&(idx, _)| idx < end) {
chars.next();
}
plain_start = end;
continue;
}
chars.next();
}
'`' => {
if let Some((end, content)) = try_parse_inline_code(text, i) {
flush_plain(text, plain_start, i, &mut nodes);
nodes.push(AdfNode::text_with_marks(content, vec![AdfMark::code()]));
while chars.peek().is_some_and(|&(idx, _)| idx < end) {
chars.next();
}
plain_start = end;
continue;
}
// No code span starting here; skip past the entire backtick
// run so a longer opening run isn't retried as a shorter one.
while chars.peek().is_some_and(|&(_, c)| c == '`') {
chars.next();
}
}
'[' => {
if let Some((end, link_text, href)) = try_parse_link(text, i) {
flush_plain(text, plain_start, i, &mut nodes);
if link_text.starts_with("http://") || link_text.starts_with("https://") {
// URL-as-link-text: emit as text with link mark,
// not via parse_inline which would produce an inlineCard.
// Covers both exact matches and trailing-slash mismatches
// (issue #523).
nodes.push(AdfNode::text_with_marks(
link_text,
vec![AdfMark::link(href)],
));
} else {
let inner = parse_inline_no_auto_cards(link_text);
for mut node in inner {
prepend_mark(&mut node, AdfMark::link(href));
nodes.push(node);
}
}
while chars.peek().is_some_and(|&(idx, _)| idx < end) {
chars.next();
}
plain_start = end;
continue;
}
// Try bracketed span with attributes: [text]{underline}
if let Some((end, span_nodes)) = try_parse_bracketed_span(text, i) {
flush_plain(text, plain_start, i, &mut nodes);
nodes.extend(span_nodes);
while chars.peek().is_some_and(|&(idx, _)| idx < end) {
chars.next();
}
plain_start = end;
continue;
}
chars.next();
}
':' => {
// Try generic inline directive (:card[url], :status[text]{attrs}, etc.)
if let Some(node) = try_dispatch_inline_directive(text, i) {
flush_plain(text, plain_start, i, &mut nodes);
let end = node.1;
nodes.push(node.0);
while chars.peek().is_some_and(|&(idx, _)| idx < end) {
chars.next();
}
plain_start = end;
continue;
}
// Try emoji shortcode :name: with optional {attrs}
if let Some((end, short_name)) = try_parse_emoji_shortcode(text, i) {
flush_plain(text, plain_start, i, &mut nodes);
let (final_end, emoji_node) = parse_emoji_with_attrs(text, end, short_name);
nodes.push(emoji_node);
while chars.peek().is_some_and(|&(idx, _)| idx < final_end) {
chars.next();
}
plain_start = final_end;
continue;
}
chars.next();
}
' ' if text[i..].starts_with(" \n") => {
// Trailing-space line break → hardBreak node.
// Flush preceding text (without the trailing spaces).
flush_plain(text, plain_start, i, &mut nodes);
nodes.push(AdfNode::hard_break());
// Skip past all spaces and the newline
while chars.peek().is_some_and(|&(_, c)| c == ' ') {
chars.next();
}
// Skip the newline
if chars.peek().is_some_and(|&(_, c)| c == '\n') {
chars.next();
}
plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
}
'!' if text[i..].starts_with("![") => {
// Inline image — skip the ! and let [ handle it next iteration
// (Images at block level are handled by try_image; inline images
// degrade to link text in ADF since inline media is complex)
chars.next();
}
'h' if auto_inline_card
&& (text[i..].starts_with("http://") || text[i..].starts_with("https://")) =>
{
if let Some((end, url)) = try_parse_bare_url(text, i) {
flush_plain(text, plain_start, i, &mut nodes);
nodes.push(AdfNode::inline_card(url));
while chars.peek().is_some_and(|&(idx, _)| idx < end) {
chars.next();
}
plain_start = end;
continue;
}
chars.next();
}
'\\' if text.as_bytes().get(i + 1) == Some(&b'n')
&& text.as_bytes().get(i + 2) != Some(&b'\n') =>
{
// Issue #454: `\n` (backslash + letter n) encodes a literal
// newline inside a text node. Emit the newline as a separate
// text node so merge_adjacent_text can reassemble it.
flush_plain(text, plain_start, i, &mut nodes);
nodes.push(AdfNode::text("\n"));
chars.next(); // consume the '\'
chars.next(); // consume the 'n'
plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
}
'\\' if i + 1 < text.len() && !text[i..].starts_with("\\\n") => {
// Backslash escape: skip the backslash and treat the next
// character as literal text (e.g. `\\` → `\`,
// `2\. text` → `2. text`, `\*word\*` → `*word*` without
// emphasis, `\:fire:` → `:fire:` without emoji parsing).
flush_plain(text, plain_start, i, &mut nodes);
chars.next(); // consume the backslash
// Set plain_start to the escaped character so it is included
// in the next plain-text run, then advance past it so it is
// not re-interpreted as a special character (e.g. `*`, `_`, `:`).
plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
chars.next(); // consume the escaped character
}
'\\' if text[i..].starts_with("\\\n") => {
// Backslash line break → hardBreak node.
flush_plain(text, plain_start, i, &mut nodes);
nodes.push(AdfNode::hard_break());
chars.next(); // consume the '\'
// Skip the newline
if chars.peek().is_some_and(|&(_, c)| c == '\n') {
chars.next();
}
plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
}
'\\' if i + 1 == text.len() => {
// Trailing backslash at end of paragraph text → hardBreak node.
flush_plain(text, plain_start, i, &mut nodes);
nodes.push(AdfNode::hard_break());
chars.next(); // consume the '\'
plain_start = text.len();
}
_ => {
chars.next();
}
}
}
// Flush remaining plain text
if plain_start < text.len() {
let remaining = &text[plain_start..];
if !remaining.is_empty() {
nodes.push(AdfNode::text(remaining));
}
}
// Merge adjacent unmarked text nodes that can arise from backslash
// escape handling (e.g. `"2"` + `". text"` → `"2. text"`).
merge_adjacent_text(&mut nodes);
nodes
}
/// Merges consecutive unmarked text nodes in-place.
fn merge_adjacent_text(nodes: &mut Vec<AdfNode>) {
let mut i = 0;
while i + 1 < nodes.len() {
if nodes[i].node_type == "text"
&& nodes[i + 1].node_type == "text"
&& nodes[i].marks.is_none()
&& nodes[i + 1].marks.is_none()
{
let next_text = nodes[i + 1].text.clone().unwrap_or_default();
if let Some(ref mut t) = nodes[i].text {
t.push_str(&next_text);
}
nodes.remove(i + 1);
} else {
i += 1;
}
}
}
/// Flushes accumulated plain text as a text node.
fn flush_plain(text: &str, start: usize, end: usize, nodes: &mut Vec<AdfNode>) {
if start < end {
let plain = &text[start..end];
if !plain.is_empty() {
nodes.push(AdfNode::text(plain));
}
}
}
/// Adds a mark to a node (creates marks vec if needed).
#[cfg(test)]
fn add_mark(node: &mut AdfNode, mark: AdfMark) {
if let Some(ref mut marks) = node.marks {
marks.push(mark);
} else {
node.marks = Some(vec![mark]);
}
}
/// Prepends a mark before existing marks to preserve outside-in ordering.
fn prepend_mark(node: &mut AdfNode, mark: AdfMark) {
if let Some(ref mut marks) = node.marks {
marks.insert(0, mark);
} else {
node.marks = Some(vec![mark]);
}
}
/// Returns `true` when an underscore delimiter run of `len` bytes starting at
/// byte position `delim_pos` in `text` is flanked by alphanumeric characters on
/// **both** sides — meaning it sits inside a word and must NOT open or close an
/// emphasis span per CommonMark.
fn is_intraword_underscore(text: &str, delim_pos: usize, len: usize) -> bool {
let before = text[..delim_pos]
.chars()
.next_back()
.is_some_and(char::is_alphanumeric);
let after = text[delim_pos + len..]
.chars()
.next()
.is_some_and(char::is_alphanumeric);
before && after
}
/// Finds the first occurrence of `needle` in `haystack`, skipping over
/// backslash-escaped characters (e.g. `\*` is not matched when searching
/// for `*`).
fn find_unescaped(haystack: &str, needle: &str) -> Option<usize> {
let needle_bytes = needle.as_bytes();
let hay_bytes = haystack.as_bytes();
let mut i = 0;
while i < hay_bytes.len() {
if hay_bytes[i] == b'\\' {
i += 2; // skip escaped character
continue;
}
if hay_bytes[i..].starts_with(needle_bytes) {
return Some(i);
}
i += 1;
}
None
}
/// Finds the first occurrence of a single byte `ch` in `haystack`, skipping
/// over backslash-escaped characters.
fn find_unescaped_char(haystack: &str, ch: u8) -> Option<usize> {
let hay_bytes = haystack.as_bytes();
let mut i = 0;
while i < hay_bytes.len() {
if hay_bytes[i] == b'\\' {
i += 2;
continue;
}
if hay_bytes[i] == ch {
return Some(i);
}
i += 1;
}
None
}
/// Tries to parse ***bold+italic***, **bold**, *italic* (or underscore variants) starting at position `i`.
/// Returns (end_position, inner_content, is_bold).
///
/// The triple-delimiter case (`***` / `___`) is checked first so that `***text***` is parsed as
/// bold wrapping italic content, rather than having the `**` branch consume the wrong closing
/// delimiter and leave stray `*` characters in the text (see issue #401).
///
/// For underscore delimiters, intraword positions are rejected per CommonMark: a `_` flanked
/// by alphanumeric characters on both sides must not open or close emphasis (see issue #438).
fn try_parse_emphasis(text: &str, i: usize) -> Option<(usize, &str, bool)> {
let rest = &text[i..];
// Bold+italic: *** or ___
// Parse as bold wrapping italic: the inner content will be recursively parsed and pick up
// the inner * / _ as an em mark.
if rest.starts_with("***") || rest.starts_with("___") {
let is_underscore = rest.starts_with("___");
if is_underscore && is_intraword_underscore(text, i, 3) {
return None;
}
let triple = &rest[..3];
let after = &rest[3..];
if let Some(close) = find_unescaped(after, triple) {
if close > 0 {
let close_pos = i + 3 + close;
if is_underscore && is_intraword_underscore(text, close_pos, 3) {
return None;
}
// Return a slice that includes the inner italic delimiters from the
// original text: for `***text***`, return `*text*`. The recursive
// parse_inline call will then pick up the inner `*…*` as an em mark.
let content = &rest[2..=3 + close];
let end = i + 3 + close + 3;
return Some((end, content, true));
}
}
}
// Bold: ** or __
if rest.starts_with("**") || rest.starts_with("__") {
let is_underscore = rest.starts_with("__");
if is_underscore && is_intraword_underscore(text, i, 2) {
return None;
}
let delimiter = &rest[..2];
let after = &rest[2..];
let close = find_unescaped(after, delimiter)?;
if close == 0 {
return None;
}
let close_pos = i + 2 + close;
if is_underscore && is_intraword_underscore(text, close_pos, 2) {
return None;
}
let content = &after[..close];
let end = i + 2 + close + 2;
return Some((end, content, true));
}
// Italic: * or _
if rest.starts_with('*') || rest.starts_with('_') {
let delim_char = rest.as_bytes()[0];
let is_underscore = delim_char == b'_';
if is_underscore && is_intraword_underscore(text, i, 1) {
return None;
}
let after = &rest[1..];
let close = find_unescaped_char(after, delim_char)?;
if close == 0 {
return None;
}
let close_pos = i + 1 + close;
if is_underscore && is_intraword_underscore(text, close_pos, 1) {
return None;
}
let content = &after[..close];
let end = i + 1 + close + 1;
return Some((end, content, false));
}
None
}
/// Tries to parse ~~strikethrough~~ starting at position `i`.
fn try_parse_strikethrough(text: &str, i: usize) -> Option<(usize, &str)> {
let rest = &text[i..];
if !rest.starts_with("~~") {
return None;
}
let after = &rest[2..];
let close = after.find("~~")?;
if close == 0 {
return None;
}
let content = &after[..close];
Some((i + 2 + close + 2, content))
}
/// Tries to parse a CommonMark inline code span starting at position `i`.
///
/// Supports multi-backtick delimiters: the opening run of N backticks must
/// be matched by a closing run of exactly N backticks. If both ends of the
/// enclosed content begin and end with a space and the content is not all
/// spaces, one space is stripped from each side per the CommonMark spec.
fn try_parse_inline_code(text: &str, i: usize) -> Option<(usize, &str)> {
let rest = &text[i..];
let bytes = rest.as_bytes();
if bytes.is_empty() || bytes[0] != b'`' {
return None;
}
let mut opening = 0usize;
while opening < bytes.len() && bytes[opening] == b'`' {
opening += 1;
}
let mut j = opening;
while j < bytes.len() {
if bytes[j] == b'`' {
let run_start = j;
while j < bytes.len() && bytes[j] == b'`' {
j += 1;
}
if j - run_start == opening {
let content = &rest[opening..run_start];
let content = strip_code_span_padding(content);
return Some((i + j, content));
}
} else {
j += 1;
}
}
None
}
/// Implements the CommonMark code-span space-stripping rule: if the content
/// both begins and ends with a space character and is not composed entirely
/// of spaces, one space character is removed from each side.
fn strip_code_span_padding(content: &str) -> &str {
let bytes = content.as_bytes();
if bytes.len() >= 2
&& bytes[0] == b' '
&& bytes[bytes.len() - 1] == b' '
&& content.bytes().any(|b| b != b' ')
{
&content[1..content.len() - 1]
} else {
content
}
}
/// Tries to parse a bracketed span `[text]{attrs}` starting at position `i`.
/// Used for `[text]{underline}` and similar constructs.
fn try_parse_bracketed_span(text: &str, i: usize) -> Option<(usize, Vec<AdfNode>)> {
let rest = &text[i..];
if !rest.starts_with('[') {
return None;
}
// Find the matching ] by counting bracket depth (supports nested brackets
// such as [[text](url)]{underline} for underline-before-link ordering).
// Backslash-escaped brackets are skipped (issue #493).
let mut depth: usize = 0;
let mut bracket_close = None;
let bs_bytes = rest.as_bytes();
for (j, ch) in rest.char_indices() {
match ch {
'\\' if j + 1 < bs_bytes.len()
&& (bs_bytes[j + 1] == b'[' || bs_bytes[j + 1] == b']') => {}
'[' if j == 0 || bs_bytes[j - 1] != b'\\' => depth += 1,
']' if j == 0 || bs_bytes[j - 1] != b'\\' => {
depth -= 1;
if depth == 0 {
bracket_close = Some(j);
break;
}
}
_ => {}
}
}
let bracket_close = bracket_close?;
// Make sure this isn't a link: next char after ] must be { not (
let after_bracket = &rest[bracket_close + 1..];
if !after_bracket.starts_with('{') {
return None;
}
let span_text = &rest[1..bracket_close];
let attrs_start = i + bracket_close + 1;
let (attrs_end, attrs) = parse_attrs(text, attrs_start)?;
let mut marks = Vec::new();
if attrs.has_flag("underline") {
marks.push(AdfMark::underline());
}
let ann_ids = attrs.get_all("annotation-id");
let ann_types = attrs.get_all("annotation-type");
for (idx, ann_id) in ann_ids.iter().enumerate() {
let ann_type = ann_types.get(idx).copied().unwrap_or("inlineComment");
marks.push(AdfMark::annotation(ann_id, ann_type));
}
if marks.is_empty() {
return None; // no recognized marks
}
let inner = parse_inline_no_auto_cards(span_text);
let result: Vec<AdfNode> = inner
.into_iter()
.map(|mut node| {
// Prepend bracket marks before inner marks to preserve original
// ADF mark ordering (e.g., [underline, strong] not [strong, underline]).
let mut combined = marks.clone();
if let Some(ref existing) = node.marks {
combined.extend(existing.iter().cloned());
}
node.marks = Some(combined);
node
})
.collect();
Some((attrs_end, result))
}
/// Dispatches an inline directive to the appropriate ADF node constructor.
/// Returns `(AdfNode, end_pos)` on success.
fn try_dispatch_inline_directive(text: &str, pos: usize) -> Option<(AdfNode, usize)> {
let d = try_parse_inline_directive(text, pos)?;
let content = d.content.as_deref().unwrap_or("");
let node = match d.name.as_str() {
"card" => {
// Prefer the `url` attribute when present; fall back to the
// bracketed content. The attribute form is used when the URL
// contains characters (such as `]` or `\n`) that would otherwise
// break `:card[URL]` parsing.
let url = d
.attrs
.as_ref()
.and_then(|a| a.get("url"))
.unwrap_or(content);
let mut node = AdfNode::inline_card(url);
pass_through_local_id(&d.attrs, &mut node);
node
}
"status" => {
let color = d
.attrs
.as_ref()
.and_then(|a| a.get("color"))
.unwrap_or("neutral");
let mut node = AdfNode::status(content, color);
// Pass through style and localId if present
if let Some(ref attrs) = d.attrs {
if let Some(ref mut node_attrs) = node.attrs {
if let Some(style) = attrs.get("style") {
node_attrs["style"] = serde_json::Value::String(style.to_string());
}
if let Some(local_id) = attrs.get("localId") {
node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
}
}
}
node
}
"date" => {
let timestamp = d
.attrs
.as_ref()
.and_then(|a| a.get("timestamp"))
.map_or_else(|| iso_date_to_epoch_ms(content), ToString::to_string);
let mut node = AdfNode::date(×tamp);
pass_through_local_id(&d.attrs, &mut node);
node
}
"mention" => {
let id = d.attrs.as_ref().and_then(|a| a.get("id")).unwrap_or("");
let mut node = AdfNode::mention(id, content);
// Pass through optional userType and accessLevel
if let Some(ref attrs) = d.attrs {
if let (Some(ref mut node_attrs), true) = (
&mut node.attrs,
attrs.get("userType").is_some() || attrs.get("accessLevel").is_some(),
) {
if let Some(ut) = attrs.get("userType") {
node_attrs["userType"] = serde_json::Value::String(ut.to_string());
}
if let Some(al) = attrs.get("accessLevel") {
node_attrs["accessLevel"] = serde_json::Value::String(al.to_string());
}
}
}
pass_through_local_id(&d.attrs, &mut node);
node
}
"span" => {
let mut marks = Vec::new();
if let Some(ref attrs) = d.attrs {
if let Some(color) = attrs.get("color") {
marks.push(AdfMark::text_color(color));
}
if let Some(bg) = attrs.get("bg") {
marks.push(AdfMark::background_color(bg));
}
if attrs.has_flag("sub") {
marks.push(AdfMark::subsup("sub"));
}
if attrs.has_flag("sup") {
marks.push(AdfMark::subsup("sup"));
}
}
if marks.is_empty() {
AdfNode::text(content)
} else {
// Parse inner content to handle nested syntax (e.g., links).
// Prepend span marks before inner marks to preserve ordering.
let inner = parse_inline_no_auto_cards(content);
let mut nodes: Vec<AdfNode> = inner
.into_iter()
.map(|mut node| {
let mut combined = marks.clone();
if let Some(ref existing) = node.marks {
combined.extend(existing.iter().cloned());
}
node.marks = Some(combined);
node
})
.collect();
// Return the first marked node (typical case is a single node).
nodes.remove(0)
}
}
"placeholder" => AdfNode::placeholder(content),
"media-inline" => {
let mut json_attrs = serde_json::Map::new();
if let Some(ref attrs) = d.attrs {
for key in &["type", "id", "collection", "url", "alt", "width", "height"] {
if let Some(val) = attrs.get(key) {
if *key == "width" || *key == "height" {
if let Ok(n) = val.parse::<u64>() {
json_attrs.insert(
(*key).to_string(),
serde_json::Value::Number(n.into()),
);
continue;
}
}
json_attrs.insert(
(*key).to_string(),
serde_json::Value::String(val.to_string()),
);
}
}
if let Some(local_id) = attrs.get("localId") {
json_attrs.insert(
"localId".to_string(),
serde_json::Value::String(local_id.to_string()),
);
}
}
AdfNode::media_inline(serde_json::Value::Object(json_attrs))
}
"extension" => {
let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
AdfNode::inline_extension(ext_type, ext_key, Some(content))
}
_ => return None, // unknown directive — fall through to plain text
};
Some((node, d.end_pos))
}
/// Tries to parse a bare URL (`http://` or `https://`) starting at position `i`.
/// Scans forward until whitespace, `)`, `]`, or end of string.
fn try_parse_bare_url(text: &str, i: usize) -> Option<(usize, &str)> {
let rest = &text[i..];
if !rest.starts_with("http://") && !rest.starts_with("https://") {
return None;
}
// URL extends to the next whitespace or delimiter
let end = rest
.find(|c: char| c.is_whitespace() || c == ')' || c == ']' || c == '>')
.unwrap_or(rest.len());
// Strip trailing punctuation that's likely not part of the URL
let url = rest[..end].trim_end_matches(['.', ',', ';', '!', '?']);
if url.len() <= "https://".len() {
return None; // too short to be a real URL
}
Some((i + url.len(), url))
}
/// Tries to parse an emoji shortcode `:name:` starting at position `i`.
/// The name must match `[a-zA-Z0-9_+-]+`.
fn try_parse_emoji_shortcode(text: &str, i: usize) -> Option<(usize, &str)> {
let rest = &text[i..];
if !rest.starts_with(':') {
return None;
}
let after = &rest[1..];
let name_end =
after.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')?;
if name_end == 0 {
return None;
}
if after.as_bytes().get(name_end) != Some(&b':') {
return None;
}
let name = &after[..name_end];
Some((i + 1 + name_end + 1, name))
}
/// Parses an emoji shortcode that has already been matched, then checks for
/// trailing `{id="..." text="..."}` attributes to preserve round-trip fidelity.
fn parse_emoji_with_attrs(text: &str, shortcode_end: usize, short_name: &str) -> (usize, AdfNode) {
// Issue #576: An emoji with a combined shortName like `:slightly_smiling_face::bow:`
// is emitted as `:slightly_smiling_face::bow:{shortName="..." ...}`. Extend the
// match through any adjacent `:name:` shortcodes so that a trailing directive
// attaches to the whole chain as a single emoji, using the directive's shortName.
let mut chain_end = shortcode_end;
while let Some((next_end, _)) = try_parse_emoji_shortcode(text, chain_end) {
chain_end = next_end;
}
if chain_end > shortcode_end {
if let Some((attr_end, attrs)) = parse_attrs(text, chain_end) {
return (attr_end, build_emoji_node(&attrs, short_name));
}
}
if let Some((attr_end, attrs)) = parse_attrs(text, shortcode_end) {
(attr_end, build_emoji_node(&attrs, short_name))
} else {
(shortcode_end, AdfNode::emoji(&format!(":{short_name}:")))
}
}
/// Builds an emoji `AdfNode` from parsed directive attrs, falling back to
/// the matched shortcode name when `shortName` is absent from the directive.
fn build_emoji_node(attrs: &Attrs, short_name: &str) -> AdfNode {
let resolved_name = attrs
.get("shortName")
.map_or_else(|| format!(":{short_name}:"), str::to_string);
let mut emoji_attrs = serde_json::json!({ "shortName": resolved_name });
if let Some(id) = attrs.get("id") {
emoji_attrs["id"] = serde_json::Value::String(id.to_string());
}
if let Some(t) = attrs.get("text") {
emoji_attrs["text"] = serde_json::Value::String(t.to_string());
}
if let Some(lid) = attrs.get("localId") {
emoji_attrs["localId"] = serde_json::Value::String(lid.to_string());
}
AdfNode {
node_type: "emoji".to_string(),
attrs: Some(emoji_attrs),
content: None,
text: None,
marks: None,
local_id: None,
parameters: None,
}
}
/// Finds the closing `)` that matches the opening `(` at position `open`,
/// counting nested parentheses so that URLs containing `(` and `)` are
/// handled correctly. Returns the index of the matching `)` relative to
/// the start of `s`, or `None` if no match is found.
fn find_closing_paren(s: &str, open: usize) -> Option<usize> {
let mut depth: usize = 0;
for (j, ch) in s[open..].char_indices() {
match ch {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return Some(open + j);
}
}
_ => {}
}
}
None
}
/// Tries to parse [text](url) starting at position `i`.
///
/// Uses bracket depth counting to find the matching `]`, so that `[` characters
/// inside the text (e.g. `[Task] some text ([Link](url))`) don't cause a false
/// match on an earlier `](`.
fn try_parse_link(text: &str, i: usize) -> Option<(usize, &str, &str)> {
let rest = &text[i..];
if !rest.starts_with('[') {
return None;
}
// Find the matching ] by counting bracket depth, skipping escaped brackets
let mut depth: usize = 0;
let mut text_end = None;
let bytes = rest.as_bytes();
for (j, ch) in rest.char_indices() {
match ch {
'\\' if j + 1 < bytes.len() && (bytes[j + 1] == b'[' || bytes[j + 1] == b']') => {
// Skip backslash-escaped bracket (issue #493)
}
'[' if j == 0 || bytes[j - 1] != b'\\' => depth += 1,
']' if j == 0 || bytes[j - 1] != b'\\' => {
depth -= 1;
if depth == 0 {
text_end = Some(j);
break;
}
}
_ => {}
}
}
let text_end = text_end?;
let link_text = &rest[1..text_end];
// Must be immediately followed by (
let after_bracket = &rest[text_end + 1..];
if !after_bracket.starts_with('(') {
return None;
}
let url_start = text_end + 1; // index of the '('
let url_end = find_closing_paren(rest, url_start)?;
let href = &rest[url_start + 1..url_end];
Some((i + url_end + 1, link_text, href))
}
// ── ADF → Markdown ──────────────────────────────────────────────────
/// Options for ADF-to-markdown rendering.
#[derive(Debug, Clone, Default)]
pub struct RenderOptions {
/// When true, omit `localId` attributes from directive output.
pub strip_local_ids: bool,
}
/// Converts an ADF document to a markdown string.
pub fn adf_to_markdown(doc: &AdfDocument) -> Result<String> {
adf_to_markdown_with_options(doc, &RenderOptions::default())
}
/// Converts an ADF document to a markdown string with options.
pub fn adf_to_markdown_with_options(doc: &AdfDocument, opts: &RenderOptions) -> Result<String> {
let mut output = String::new();
for (i, node) in doc.content.iter().enumerate() {
if i > 0 {
output.push('\n');
}
render_block_node(node, &mut output, opts);
}
Ok(output)
}
/// Pushes a `localId=<value>` entry to an attribute parts vec,
/// unless `opts.strip_local_ids` is set or the value is a placeholder.
/// Copies `localId` from parsed directive attrs to an ADF node's attrs if present.
fn pass_through_local_id(dir_attrs: &Option<crate::atlassian::attrs::Attrs>, node: &mut AdfNode) {
if let Some(ref attrs) = dir_attrs {
if let Some(local_id) = attrs.get("localId") {
if let Some(ref mut node_attrs) = node.attrs {
node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
} else {
node.attrs = Some(serde_json::json!({"localId": local_id}));
}
}
}
}
/// Copies `localId` from directive attrs to the node's top-level `local_id` field,
/// and parses `params` JSON from directive attrs into the node's `parameters` field.
fn pass_through_expand_params(
dir_attrs: &Option<crate::atlassian::attrs::Attrs>,
node: &mut AdfNode,
) {
if let Some(ref attrs) = dir_attrs {
if let Some(local_id) = attrs.get("localId") {
node.local_id = Some(local_id.to_string());
}
if let Some(params_str) = attrs.get("params") {
if let Ok(params) = serde_json::from_str(params_str) {
node.parameters = Some(params);
}
}
}
}
// listItem localId is emitted as trailing inline attrs on the item line
// (e.g., `- item text {localId=...}`) and parsed back by extracting
// trailing attrs from the list item text. This avoids the block-attrs
// promotion issue where {localId=...} on a separate line would be
// applied to the parent list node.
/// Extracts trailing `{localId=... paraLocalId=...}` from list item text.
/// Returns the text without the trailing attrs, the listItem localId, and
/// the paragraph localId if found.
fn extract_trailing_local_id(text: &str) -> (&str, Option<String>, Option<String>) {
let trimmed = text.trim_end();
if !trimmed.ends_with('}') {
return (text, None, None);
}
// Find the opening brace. Only match a standalone `{…}` block that is
// preceded by whitespace (or is at the start of the string). A `{` that
// immediately follows `]` is part of an inline directive (e.g.
// `:mention[text]{id=… localId=…}`) and must NOT be consumed here.
if let Some(brace_pos) = trimmed.rfind('{') {
if brace_pos > 0 && !trimmed.as_bytes()[brace_pos - 1].is_ascii_whitespace() {
return (text, None, None);
}
let attr_str = &trimmed[brace_pos..];
if let Some((_, attrs)) = parse_attrs(attr_str, 0) {
let local_id = attrs.get("localId").map(str::to_string);
let para_local_id = attrs.get("paraLocalId").map(str::to_string);
if local_id.is_some() || para_local_id.is_some() {
let before = trimmed[..brace_pos]
.strip_suffix(' ')
.unwrap_or(&trimmed[..brace_pos]);
return (before, local_id, para_local_id);
}
}
}
(text, None, None)
}
/// Creates a `listItem` node, optionally with a `localId` attribute
/// and a `paraLocalId` on its first paragraph child.
/// Parses the first line of a list item and any indented sub-content into
/// an `AdfNode::list_item`. When the first line is a code fence opener
/// (`` ``` ``), the line is folded into the sub-content so the block-level
/// code fence parser handles it correctly (issue #511).
fn parse_list_item_first_line(
item_text: &str,
sub_lines: Vec<String>,
local_id: Option<String>,
para_local_id: Option<String>,
) -> Result<AdfNode> {
if item_text.starts_with("```") {
// Treat the code fence opener + indented body as block content.
let mut all_lines = vec![item_text.to_string()];
all_lines.extend(sub_lines);
let combined = all_lines.join("\n");
let nested = MarkdownParser::new(&combined).parse_blocks()?;
Ok(list_item_with_local_id(nested, local_id, para_local_id))
} else if let Some(media) = try_parse_media_single_from_line(item_text) {
// Block-level image (issue #430).
if sub_lines.is_empty() {
Ok(list_item_with_local_id(
vec![media],
local_id,
para_local_id,
))
} else {
let sub_text = sub_lines.join("\n");
let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
let mut content = vec![media];
content.append(&mut nested);
Ok(list_item_with_local_id(content, local_id, para_local_id))
}
} else {
let first_node = AdfNode::paragraph(parse_inline(item_text));
if sub_lines.is_empty() {
Ok(list_item_with_local_id(
vec![first_node],
local_id,
para_local_id,
))
} else {
let sub_text = sub_lines.join("\n");
let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
let mut content = vec![first_node];
content.append(&mut nested);
Ok(list_item_with_local_id(content, local_id, para_local_id))
}
}
}
fn list_item_with_local_id(
mut content: Vec<AdfNode>,
local_id: Option<String>,
para_local_id: Option<String>,
) -> AdfNode {
if let Some(id) = ¶_local_id {
if let Some(first) = content.first_mut() {
if first.node_type == "paragraph" {
let node_attrs = first.attrs.get_or_insert_with(|| serde_json::json!({}));
node_attrs["localId"] = serde_json::Value::String(id.clone());
}
}
}
let mut item = AdfNode::list_item(content);
if let Some(id) = local_id {
item.attrs = Some(serde_json::json!({"localId": id}));
}
item
}
fn maybe_push_local_id(attrs: &serde_json::Value, parts: &mut Vec<String>, opts: &RenderOptions) {
if opts.strip_local_ids {
return;
}
if let Some(local_id) = attrs.get("localId").and_then(serde_json::Value::as_str) {
if !local_id.is_empty() && local_id != "00000000-0000-0000-0000-000000000000" {
parts.push(format_kv("localId", local_id));
}
}
}
/// Renders a sequence of block nodes with blank-line separators between them.
fn render_block_children(children: &[AdfNode], output: &mut String, opts: &RenderOptions) {
for (i, child) in children.iter().enumerate() {
if i > 0 {
output.push('\n');
}
render_block_node(child, output, opts);
}
}
/// Formats a float as an integer string when it has no fractional part,
/// otherwise as a regular float string.
fn fmt_f64_attr(v: f64) -> String {
if v.fract() == 0.0 {
format!("{}", v as i64)
} else {
v.to_string()
}
}
/// Parses a numeric attribute value string into a JSON number value that
/// preserves the original integer/float distinction. Returns `None` if the
/// string cannot be parsed as a number.
///
/// Strings without a `.` or exponent are parsed as integers (so `"800"` stays
/// `800`, not `800.0`); strings with a decimal point are parsed as floats.
fn parse_numeric_attr(s: &str) -> Option<serde_json::Value> {
if s.contains('.') || s.contains('e') || s.contains('E') {
s.parse::<f64>().ok().map(serde_json::Value::from)
} else {
s.parse::<i64>().ok().map(serde_json::Value::from)
}
}
/// Formats a JSON numeric value as a markdown attribute string, preserving
/// whether the source was stored as an integer or a float.
///
/// Returns `None` if `v` is not a number. Integer values emit as `800`;
/// floating-point values emit as `800.0` (or `66.66` for non-integer floats),
/// so that a subsequent [`parse_numeric_attr`] round-trip recovers the same
/// JSON type.
fn fmt_numeric_attr(v: &serde_json::Value) -> Option<String> {
if let Some(n) = v.as_i64() {
return Some(n.to_string());
}
if let Some(n) = v.as_u64() {
return Some(n.to_string());
}
if let Some(n) = v.as_f64() {
if n.fract() == 0.0 && n.is_finite() {
return Some(format!("{n:.1}"));
}
return Some(n.to_string());
}
None
}
/// Renders a block-level ADF node to markdown.
fn render_block_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
match node.node_type.as_str() {
"paragraph" => {
let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
// Build directive attr string for localId when using ::paragraph form
let dir_attrs = {
let mut parts = Vec::new();
if let Some(ref attrs) = node.attrs {
maybe_push_local_id(attrs, &mut parts, opts);
}
if parts.is_empty() {
String::new()
} else {
format!("{{{}}}", parts.join(" "))
}
};
if is_empty {
output.push_str(&format!("::paragraph{dir_attrs}\n"));
} else {
// Render to a buffer first to check if content is whitespace-only
let mut buf = String::new();
render_inline_content(node, &mut buf, opts);
if buf.trim().is_empty() && !buf.is_empty() {
// Whitespace-only content (e.g. NBSP) would be lost as a plain
// line — use the ::paragraph[content]{attrs} directive form
output.push_str(&format!("::paragraph[{buf}]{dir_attrs}\n"));
} else {
// Escape a leading list-marker pattern so paragraph
// text is not re-parsed as a list item (issue #402).
// Indent continuation lines produced by hardBreaks so
// they are not re-parsed as list items (issue #455).
let mut is_first_line = true;
for line in buf.split('\n') {
if is_first_line {
if is_list_start(line) {
output.push_str(&escape_list_marker(line));
} else {
output.push_str(line);
}
is_first_line = false;
} else {
output.push('\n');
if !line.is_empty() {
output.push_str(" ");
}
output.push_str(line);
}
}
output.push('\n');
}
}
}
"heading" => {
let level = node
.attrs
.as_ref()
.and_then(|a| a.get("level"))
.and_then(serde_json::Value::as_u64)
.unwrap_or(1);
for _ in 0..level {
output.push('#');
}
output.push(' ');
let mut buf = String::new();
render_inline_content(node, &mut buf, opts);
// Indent continuation lines produced by hardBreaks so they stay
// within the heading when re-parsed (issue #433).
let mut is_first_line = true;
for line in buf.split('\n') {
if is_first_line {
output.push_str(line);
is_first_line = false;
} else {
output.push('\n');
if !line.is_empty() {
output.push_str(" ");
}
output.push_str(line);
}
}
output.push('\n');
}
"codeBlock" => {
let language_value = node.attrs.as_ref().and_then(|a| a.get("language"));
let language = language_value
.and_then(serde_json::Value::as_str)
.unwrap_or("");
output.push_str("```");
if language.is_empty() && language_value.is_some() {
// Explicit empty language attr: encode as ```"" to distinguish
// from a codeBlock with no attrs at all (plain ```).
output.push_str("\"\"");
} else {
output.push_str(language);
}
output.push('\n');
if let Some(ref content) = node.content {
for child in content {
if let Some(ref text) = child.text {
output.push_str(text);
}
}
}
output.push_str("\n```\n");
}
"blockquote" => {
if let Some(ref content) = node.content {
for (i, child) in content.iter().enumerate() {
// Separate consecutive paragraph siblings with a blank
// blockquote-prefixed line so they re-parse as distinct
// paragraphs rather than being merged into one (issue #531).
if i > 0
&& child.node_type == "paragraph"
&& content[i - 1].node_type == "paragraph"
{
output.push_str(">\n");
}
let mut inner = String::new();
render_block_node(child, &mut inner, opts);
for line in inner.lines() {
output.push_str("> ");
output.push_str(line);
output.push('\n');
}
}
}
}
"bulletList" => {
if let Some(ref items) = node.content {
for item in items {
output.push_str("- ");
let content_start = output.len();
render_list_item_content(item, output, opts);
// If the rendered content begins with a sequence the
// bullet-list parser would interpret as a task checkbox
// marker, escape the leading `[` so the round-trip
// preserves this as a `bulletList` rather than promoting
// it to a `taskList` (issue #548).
if starts_with_task_marker(&output[content_start..]) {
output.insert(content_start, '\\');
}
}
}
}
"orderedList" => {
let start = node
.attrs
.as_ref()
.and_then(|a| a.get("order"))
.and_then(serde_json::Value::as_u64)
.unwrap_or(1);
if let Some(ref items) = node.content {
for (i, item) in items.iter().enumerate() {
let num = start + i as u64;
output.push_str(&format!("{num}. "));
render_list_item_content(item, output, opts);
}
}
}
"taskList" => {
if let Some(ref items) = node.content {
for item in items {
if item.node_type == "taskList" {
// A nested taskList is a sibling child of the outer
// taskList — render it indented so it round-trips back
// as a taskList, not a taskItem (issue #506).
let mut nested = String::new();
render_block_node(item, &mut nested, opts);
for line in nested.lines() {
output.push_str(" ");
output.push_str(line);
output.push('\n');
}
} else {
let state = item
.attrs
.as_ref()
.and_then(|a| a.get("state"))
.and_then(serde_json::Value::as_str)
.unwrap_or("TODO");
if state == "DONE" {
output.push_str("- [x] ");
} else {
output.push_str("- [ ] ");
}
render_list_item_content(item, output, opts);
}
}
}
}
"rule" => {
output.push_str("---\n");
}
"table" => {
render_table(node, output, opts);
}
"mediaSingle" => {
if let Some(ref content) = node.content {
for child in content {
if child.node_type == "media" {
render_media(child, node.attrs.as_ref(), output, opts);
}
}
for child in content {
if child.node_type == "caption" {
let mut cap_parts = Vec::new();
if let Some(ref attrs) = child.attrs {
maybe_push_local_id(attrs, &mut cap_parts, opts);
}
if cap_parts.is_empty() {
output.push_str(":::caption\n");
} else {
output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
}
if let Some(ref caption_content) = child.content {
for inline in caption_content {
render_inline_node(inline, output, opts);
}
output.push('\n');
}
output.push_str(":::\n");
}
}
}
}
"blockCard" => {
if let Some(ref attrs) = node.attrs {
let url = attrs
.get("url")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let mut attr_parts = Vec::new();
if url_safe_in_bracket_content(url) {
output.push_str(&format!("::card[{url}]"));
} else {
// URL would break `::card[URL]` parsing; use quoted attr form.
output.push_str("::card[]");
let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
attr_parts.push(format!("url=\"{escaped}\""));
}
if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
attr_parts.push(format!("layout={layout}"));
}
if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
attr_parts.push(format!("width={width}"));
}
if !attr_parts.is_empty() {
output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
}
output.push('\n');
}
}
"embedCard" => {
if let Some(ref attrs) = node.attrs {
let url = attrs
.get("url")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
output.push_str(&format!("::embed[{url}]"));
let mut attr_parts = Vec::new();
if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
attr_parts.push(format!("layout={layout}"));
}
if let Some(h) = attrs
.get("originalHeight")
.and_then(serde_json::Value::as_f64)
{
attr_parts.push(format!("originalHeight={}", fmt_f64_attr(h)));
}
if let Some(w) = attrs.get("width").and_then(serde_json::Value::as_f64) {
attr_parts.push(format!("width={}", fmt_f64_attr(w)));
}
if !attr_parts.is_empty() {
output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
}
output.push('\n');
}
}
"extension" => {
if let Some(ref attrs) = node.attrs {
let ext_type = attrs
.get("extensionType")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let ext_key = attrs
.get("extensionKey")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
attr_parts.push(format!("layout={layout}"));
}
if let Some(params) = attrs.get("parameters") {
if let Ok(json_str) = serde_json::to_string(params) {
attr_parts.push(format!("params='{json_str}'"));
}
}
maybe_push_local_id(attrs, &mut attr_parts, opts);
output.push_str(&format!("::extension{{{}}}\n", attr_parts.join(" ")));
}
}
"panel" => {
let panel_type = node
.attrs
.as_ref()
.and_then(|a| a.get("panelType"))
.and_then(serde_json::Value::as_str)
.unwrap_or("info");
let mut attr_parts = vec![format!("type={panel_type}")];
if let Some(ref attrs) = node.attrs {
if let Some(icon) = attrs.get("panelIcon").and_then(serde_json::Value::as_str) {
attr_parts.push(format!("icon=\"{icon}\""));
}
if let Some(color) = attrs.get("panelColor").and_then(serde_json::Value::as_str) {
attr_parts.push(format!("color=\"{color}\""));
}
}
output.push_str(&format!(":::panel{{{}}}\n", attr_parts.join(" ")));
if let Some(ref content) = node.content {
render_block_children(content, output, opts);
}
output.push_str(":::\n");
}
"expand" | "nestedExpand" => {
let directive_name = if node.node_type == "nestedExpand" {
"nested-expand"
} else {
"expand"
};
let mut attr_parts = Vec::new();
if let Some(t) = node
.attrs
.as_ref()
.and_then(|a| a.get("title"))
.and_then(serde_json::Value::as_str)
{
attr_parts.push(format!("title=\"{t}\""));
}
// Check top-level localId first, then fall back to attrs.localId
if let Some(ref lid) = node.local_id {
if !opts.strip_local_ids && lid != "00000000-0000-0000-0000-000000000000" {
attr_parts.push(format!("localId={lid}"));
}
} else if let Some(ref attrs) = node.attrs {
maybe_push_local_id(attrs, &mut attr_parts, opts);
}
// Emit top-level parameters as params='...'
if let Some(ref params) = node.parameters {
if let Ok(json_str) = serde_json::to_string(params) {
attr_parts.push(format!("params='{json_str}'"));
}
}
if attr_parts.is_empty() {
output.push_str(&format!(":::{directive_name}\n"));
} else {
output.push_str(&format!(
":::{directive_name}{{{}}}\n",
attr_parts.join(" ")
));
}
if let Some(ref content) = node.content {
render_block_children(content, output, opts);
}
output.push_str(":::\n");
}
"layoutSection" => {
output.push_str("::::layout\n");
if let Some(ref content) = node.content {
for child in content {
if child.node_type == "layoutColumn" {
let width_str = child
.attrs
.as_ref()
.and_then(|a| a.get("width"))
.and_then(fmt_numeric_attr)
.unwrap_or_else(|| "50".to_string());
let mut parts = vec![format!("width={width_str}")];
if let Some(ref attrs) = child.attrs {
maybe_push_local_id(attrs, &mut parts, opts);
}
output.push_str(&format!(":::column{{{}}}\n", parts.join(" ")));
if let Some(ref col_content) = child.content {
render_block_children(col_content, output, opts);
}
output.push_str(":::\n");
}
}
}
output.push_str("::::\n");
}
"decisionList" => {
output.push_str(":::decisions\n");
if let Some(ref content) = node.content {
for item in content {
output.push_str("- <> ");
render_list_item_content(item, output, opts);
}
}
output.push_str(":::\n");
}
"bodiedExtension" => {
if let Some(ref attrs) = node.attrs {
let ext_type = attrs
.get("extensionType")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let ext_key = attrs
.get("extensionKey")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
attr_parts.push(format!("layout={layout}"));
}
if let Some(params) = attrs.get("parameters") {
if let Ok(json_str) = serde_json::to_string(params) {
attr_parts.push(format!("params='{json_str}'"));
}
}
maybe_push_local_id(attrs, &mut attr_parts, opts);
output.push_str(&format!(":::extension{{{}}}\n", attr_parts.join(" ")));
if let Some(ref content) = node.content {
render_block_children(content, output, opts);
}
output.push_str(":::\n");
}
}
_ => {
// Preserve unsupported nodes as JSON in adf-unsupported code blocks
if let Ok(json) = serde_json::to_string_pretty(node) {
output.push_str("```adf-unsupported\n");
output.push_str(&json);
output.push_str("\n```\n");
}
}
}
// Emit block-level attribute marks (align, indent, breakout) and localId
let mut parts = Vec::new();
if let Some(ref marks) = node.marks {
for mark in marks {
match mark.mark_type.as_str() {
"alignment" => {
if let Some(align) = mark
.attrs
.as_ref()
.and_then(|a| a.get("align"))
.and_then(serde_json::Value::as_str)
{
parts.push(format!("align={align}"));
}
}
"indentation" => {
if let Some(level) = mark
.attrs
.as_ref()
.and_then(|a| a.get("level"))
.and_then(serde_json::Value::as_u64)
{
parts.push(format!("indent={level}"));
}
}
"breakout" => {
if let Some(mode) = mark
.attrs
.as_ref()
.and_then(|a| a.get("mode"))
.and_then(serde_json::Value::as_str)
{
parts.push(format!("breakout={mode}"));
}
if let Some(width) = mark
.attrs
.as_ref()
.and_then(|a| a.get("width"))
.and_then(serde_json::Value::as_u64)
{
parts.push(format!("breakoutWidth={width}"));
}
}
_ => {}
}
}
}
// Skip localId for node types that already include it in their directive attrs.
// For paragraphs, localId is included in the ::paragraph directive when the
// paragraph uses directive form (empty or whitespace-only content).
let para_used_directive = node.node_type == "paragraph" && {
let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
if is_empty {
true
} else {
let mut buf = String::new();
render_inline_content(node, &mut buf, opts);
buf.trim().is_empty() && !buf.is_empty()
}
};
if !matches!(node.node_type.as_str(), "expand" | "nestedExpand") && !para_used_directive {
if let Some(ref attrs) = node.attrs {
maybe_push_local_id(attrs, &mut parts, opts);
}
}
// orderedList with explicit `attrs.order=1` needs a trailing `{order=1}`
// signal so the round-trip can distinguish explicit default from omitted
// attrs (issue #547). Values other than 1 are already encoded by the
// list marker, so no signal is needed.
if node.node_type == "orderedList" {
if let Some(ref attrs) = node.attrs {
if attrs.get("order").and_then(serde_json::Value::as_u64) == Some(1) {
parts.push("order=1".to_string());
}
}
}
if !parts.is_empty() {
output.push_str(&format!("{{{}}}\n", parts.join(" ")));
}
}
/// Renders the content of a list item (unwraps the paragraph layer).
/// Nested block children (e.g. sub-lists) are indented with two spaces.
///
/// Some ADF producers (e.g. Confluence) emit `taskItem` content without a
/// paragraph wrapper — the inline nodes sit directly inside the item. We
/// detect this by checking whether the first child is an inline node type
/// and, if so, render *all* leading inline children on the first line.
fn render_list_item_content(item: &AdfNode, output: &mut String, opts: &RenderOptions) {
let Some(ref content) = item.content else {
// Still emit localId and newline for items with no content (e.g. empty taskItem).
let bare = AdfNode::text("");
emit_list_item_local_ids(item, &bare, output, opts);
output.push('\n');
return;
};
if content.is_empty() {
let bare = AdfNode::text("");
emit_list_item_local_ids(item, &bare, output, opts);
output.push('\n');
return;
}
let first = &content[0];
let rest_start;
if first.node_type == "paragraph" {
let mut buf = String::new();
render_inline_content(first, &mut buf, opts);
// A trailing hardBreak produces a trailing `\\\n` in the buffer.
// Strip the final newline so it doesn't create a blank line after
// the list item marker, which would split the list on re-parse
// (issue #472). The `\` is kept so round-trip preserves the
// hardBreak, and `output.push('\n')` below supplies the terminator.
let buf = buf.trim_end_matches('\n');
// Indent continuation lines produced by hardBreaks so they stay
// within the list item when re-parsed (issue #402).
let mut is_first_line = true;
for line in buf.split('\n') {
if is_first_line {
output.push_str(line);
is_first_line = false;
} else {
output.push('\n');
if !line.is_empty() {
output.push_str(" ");
}
output.push_str(line);
}
}
// Emit paragraph + listItem localIds as trailing inline attrs on the first line
emit_list_item_local_ids(item, first, output, opts);
output.push('\n');
rest_start = 1;
} else if is_inline_node_type(&first.node_type) {
// Inline nodes without a paragraph wrapper — render them directly.
rest_start = content
.iter()
.position(|c| !is_inline_node_type(&c.node_type))
.unwrap_or(content.len());
let mut buf = String::new();
for child in &content[..rest_start] {
render_inline_node(child, &mut buf, opts);
}
// Indent continuation lines produced by hardBreaks so they stay
// within the list item when re-parsed (issue #521).
let buf = buf.trim_end_matches('\n');
let mut is_first_line = true;
for line in buf.split('\n') {
if is_first_line {
output.push_str(line);
is_first_line = false;
} else {
output.push('\n');
if !line.is_empty() {
output.push_str(" ");
}
output.push_str(line);
}
}
// No paragraph wrapper — pass a bare node so paraLocalId is omitted.
let bare = AdfNode::text("");
emit_list_item_local_ids(item, &bare, output, opts);
output.push('\n');
// Any remaining children are block nodes — fall through to the
// indented-block loop below.
} else if first.node_type == "taskItem" {
// Malformed ADF: taskItem.content contains nested taskItem nodes
// directly (seen in some Confluence pages). Render them as an
// indented nested task list to preserve the data without
// corrupting the surrounding structure (issue #489).
let bare = AdfNode::text("");
emit_list_item_local_ids(item, &bare, output, opts);
output.push('\n');
for child in content {
if child.node_type == "taskItem" {
let state = child
.attrs
.as_ref()
.and_then(|a| a.get("state"))
.and_then(serde_json::Value::as_str)
.unwrap_or("TODO");
let marker = if state == "DONE" { "- [x] " } else { "- [ ] " };
output.push_str(" ");
output.push_str(marker);
render_list_item_content(child, output, opts);
} else {
let mut nested = String::new();
render_block_node(child, &mut nested, opts);
for line in nested.lines() {
output.push_str(" ");
output.push_str(line);
output.push('\n');
}
}
}
return;
} else {
// Block-level first child (e.g. codeBlock, mediaSingle).
// Render to a buffer so we can:
// 1. Append listItem localId attrs to the first line (issue #525)
// 2. Indent continuation lines so multi-line blocks stay inside
// the list item (issue #511)
let mut buf = String::new();
render_block_node(first, &mut buf, opts);
let bare = AdfNode::text("");
let mut is_first = true;
for line in buf.lines() {
if is_first {
output.push_str(line);
emit_list_item_local_ids(item, &bare, output, opts);
output.push('\n');
is_first = false;
} else {
output.push_str(" ");
output.push_str(line);
output.push('\n');
}
}
rest_start = 1;
}
let rest = &content[rest_start..];
for (i, child) in rest.iter().enumerate() {
// Separate consecutive paragraph siblings with a blank indented
// line so they re-parse as distinct paragraphs rather than being
// merged into one (issue #522).
if child.node_type == "paragraph" {
let prev_is_para = if i == 0 {
// First rest child — check whether the first-line node
// (rendered above) was a paragraph.
first.node_type == "paragraph"
} else {
rest[i - 1].node_type == "paragraph"
};
if prev_is_para {
output.push_str(" \n");
}
}
let mut nested = String::new();
render_block_node(child, &mut nested, opts);
for line in nested.lines() {
output.push_str(" ");
output.push_str(line);
output.push('\n');
}
}
}
/// Returns `true` if the given ADF node type is an inline node.
fn is_inline_node_type(node_type: &str) -> bool {
matches!(
node_type,
"text"
| "hardBreak"
| "inlineCard"
| "emoji"
| "mention"
| "status"
| "date"
| "placeholder"
| "mediaInline"
)
}
/// Emits trailing `{localId=... paraLocalId=...}` on a list item line
/// for both the listItem and its first (unwrapped) paragraph.
fn emit_list_item_local_ids(
item: &AdfNode,
paragraph: &AdfNode,
output: &mut String,
opts: &RenderOptions,
) {
if opts.strip_local_ids {
return;
}
let mut parts = Vec::new();
if let Some(ref attrs) = item.attrs {
maybe_push_local_id(attrs, &mut parts, opts);
}
if paragraph.node_type == "paragraph" {
let has_real_id = paragraph
.attrs
.as_ref()
.and_then(|a| a.get("localId"))
.and_then(serde_json::Value::as_str)
.filter(|id| !id.is_empty() && *id != "00000000-0000-0000-0000-000000000000");
if let Some(local_id) = has_real_id {
parts.push(format!("paraLocalId={local_id}"));
} else if item.node_type == "taskItem" {
// taskItem content may or may not have a paragraph wrapper;
// emit a sentinel so the round-trip can distinguish the two
// forms and restore the wrapper (issue #478).
parts.push("paraLocalId=_".to_string());
}
}
if !parts.is_empty() {
output.push_str(&format!(" {{{}}}", parts.join(" ")));
}
}
/// Renders a table node, choosing between pipe table and directive table form.
fn render_table(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
let Some(ref rows) = node.content else {
return;
};
if table_qualifies_for_pipe_syntax(rows) {
render_pipe_table(node, rows, output, opts);
} else {
render_directive_table(node, rows, output, opts);
}
}
/// Checks whether all cells qualify for GFM pipe table syntax:
/// - Every cell has exactly one paragraph child with only inline nodes
/// - All `tableHeader` nodes appear exclusively in the first row
/// - The first row must contain at least one `tableHeader` (pipe tables
/// always treat the first row as headers, so `tableCell`-only first rows
/// must use directive form to preserve the cell type)
fn table_qualifies_for_pipe_syntax(rows: &[AdfNode]) -> bool {
// Tables with caption nodes must use directive form
if rows.iter().any(|n| n.node_type == "caption") {
return false;
}
let mut first_row_has_header = false;
for (row_idx, row) in rows.iter().enumerate() {
let Some(ref cells) = row.content else {
continue;
};
for cell in cells {
// Header cells outside first row → must use directive form
if row_idx > 0 && cell.node_type == "tableHeader" {
return false;
}
if row_idx == 0 && cell.node_type == "tableHeader" {
first_row_has_header = true;
}
// Check cell has exactly one paragraph with only inline content
let Some(ref content) = cell.content else {
continue;
};
if content.len() != 1 || content[0].node_type != "paragraph" {
return false;
}
// hardBreak inside a cell produces a newline that breaks pipe
// table syntax — fall back to directive form
if cell_contains_hard_break(&content[0]) {
return false;
}
// Cell-level marks (e.g., border) cannot be represented in pipe
// form — fall back to directive form
if cell.marks.as_ref().is_some_and(|m| !m.is_empty()) {
return false;
}
// Paragraph-level localId would be lost in pipe form (the paragraph
// is unwrapped into the cell text) — fall back to directive form
if content[0]
.attrs
.as_ref()
.and_then(|a| a.get("localId"))
.is_some()
{
return false;
}
}
}
// First row must have at least one tableHeader for pipe syntax;
// otherwise the round-trip would convert tableCell → tableHeader.
first_row_has_header
}
/// Returns true if a paragraph node contains any `hardBreak` inline nodes.
fn cell_contains_hard_break(paragraph: &AdfNode) -> bool {
paragraph
.content
.as_ref()
.is_some_and(|nodes| nodes.iter().any(|n| n.node_type == "hardBreak"))
}
/// Renders a table as GFM pipe syntax.
fn render_pipe_table(node: &AdfNode, rows: &[AdfNode], output: &mut String, opts: &RenderOptions) {
for (row_idx, row) in rows.iter().enumerate() {
let Some(ref cells) = row.content else {
continue;
};
output.push('|');
for cell in cells {
output.push(' ');
let mut cell_buf = String::new();
render_cell_attrs_prefix(cell, &mut cell_buf);
render_inline_content_from_first_paragraph(cell, &mut cell_buf, opts);
output.push_str(&escape_pipes_in_cell(&cell_buf));
output.push_str(" |");
}
output.push('\n');
// Add separator after header row
if row_idx == 0 {
output.push('|');
for cell in cells {
let align = get_cell_paragraph_alignment(cell);
match align {
Some("center") => output.push_str(" :---: |"),
Some("end") => output.push_str(" ---: |"),
_ => output.push_str(" --- |"),
}
}
output.push('\n');
}
}
// Emit table-level attrs if present
render_table_level_attrs(node, output, opts);
}
/// Renders a table as `::::table` directive syntax (block-content cells).
fn render_directive_table(
node: &AdfNode,
rows: &[AdfNode],
output: &mut String,
opts: &RenderOptions,
) {
// Opening fence with attrs
let mut attr_parts = Vec::new();
if let Some(ref attrs) = node.attrs {
if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
attr_parts.push(format!("layout={layout}"));
}
if let Some(numbered) = attrs
.get("isNumberColumnEnabled")
.and_then(serde_json::Value::as_bool)
{
if numbered {
attr_parts.push("numbered".to_string());
} else {
attr_parts.push("numbered=false".to_string());
}
}
if let Some(tw) = attrs.get("width").and_then(serde_json::Value::as_f64) {
let tw_str = if tw.fract() == 0.0 {
(tw as u64).to_string()
} else {
tw.to_string()
};
attr_parts.push(format!("width={tw_str}"));
}
maybe_push_local_id(attrs, &mut attr_parts, opts);
}
if attr_parts.is_empty() {
output.push_str("::::table\n");
} else {
output.push_str(&format!("::::table{{{}}}\n", attr_parts.join(" ")));
}
for row in rows {
if row.node_type == "caption" {
let mut cap_parts = Vec::new();
if let Some(ref attrs) = row.attrs {
maybe_push_local_id(attrs, &mut cap_parts, opts);
}
if cap_parts.is_empty() {
output.push_str(":::caption\n");
} else {
output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
}
if let Some(ref content) = row.content {
for child in content {
render_inline_node(child, output, opts);
}
output.push('\n');
}
output.push_str(":::\n");
continue;
}
let Some(ref cells) = row.content else {
continue;
};
// Emit :::tr with optional localId
let mut tr_attrs = Vec::new();
if let Some(ref attrs) = row.attrs {
maybe_push_local_id(attrs, &mut tr_attrs, opts);
}
if tr_attrs.is_empty() {
output.push_str(":::tr\n");
} else {
output.push_str(&format!(":::tr{{{}}}\n", tr_attrs.join(" ")));
}
for cell in cells {
let directive_name = if cell.node_type == "tableHeader" {
"th"
} else {
"td"
};
let mut cell_attr_str = build_cell_attrs_string(cell);
// Append localId to cell attrs if present
if let Some(ref attrs) = cell.attrs {
let mut lid_parts = Vec::new();
maybe_push_local_id(attrs, &mut lid_parts, opts);
if !lid_parts.is_empty() {
if !cell_attr_str.is_empty() {
cell_attr_str.push(' ');
}
cell_attr_str.push_str(&lid_parts.join(" "));
}
}
// Append border mark attrs if present
if let Some(ref marks) = cell.marks {
for mark in marks {
if mark.mark_type == "border" {
if let Some(ref attrs) = mark.attrs {
if let Some(color) =
attrs.get("color").and_then(serde_json::Value::as_str)
{
if !cell_attr_str.is_empty() {
cell_attr_str.push(' ');
}
cell_attr_str.push_str(&format!("border-color={color}"));
}
if let Some(size) =
attrs.get("size").and_then(serde_json::Value::as_u64)
{
if !cell_attr_str.is_empty() {
cell_attr_str.push(' ');
}
cell_attr_str.push_str(&format!("border-size={size}"));
}
}
}
}
}
let has_marks = cell.marks.as_ref().is_some_and(|m| !m.is_empty());
if cell_attr_str.is_empty() && cell.attrs.is_none() && !has_marks {
output.push_str(&format!(":::{directive_name}\n"));
} else {
output.push_str(&format!(":::{directive_name}{{{cell_attr_str}}}\n"));
}
if let Some(ref content) = cell.content {
render_block_children(content, output, opts);
}
output.push_str(":::\n");
}
output.push_str(":::\n");
}
output.push_str("::::\n");
}
/// Returns `true` when an attribute value must be quoted to survive round-trip
/// through the `{key=value}` attribute parser (which stops unquoted values at
/// whitespace or `}`).
fn needs_attr_quoting(value: &str) -> bool {
value.contains(|c: char| c.is_whitespace() || c == '}' || c == '(' || c == ')' || c == ',')
}
/// Builds a JFM attribute string from ADF cell attributes.
fn build_cell_attrs_string(cell: &AdfNode) -> String {
let Some(ref attrs) = cell.attrs else {
return String::new();
};
let mut parts = Vec::new();
if let Some(colspan) = attrs.get("colspan").and_then(serde_json::Value::as_u64) {
parts.push(format!("colspan={colspan}"));
}
if let Some(rowspan) = attrs.get("rowspan").and_then(serde_json::Value::as_u64) {
parts.push(format!("rowspan={rowspan}"));
}
if let Some(bg) = attrs.get("background").and_then(serde_json::Value::as_str) {
if needs_attr_quoting(bg) {
let escaped = bg.replace('\\', "\\\\").replace('"', "\\\"");
parts.push(format!("bg=\"{escaped}\""));
} else {
parts.push(format!("bg={bg}"));
}
}
if let Some(colwidth) = attrs.get("colwidth").and_then(serde_json::Value::as_array) {
let widths: Vec<String> = colwidth
.iter()
.filter_map(|v| {
// Preserve the original number type: integers stay as integers,
// floats stay as floats (e.g. Confluence's 254.0 representation).
if let Some(n) = v.as_u64() {
Some(n.to_string())
} else if let Some(n) = v.as_f64() {
if n.fract() == 0.0 {
format!("{n:.1}")
} else {
n.to_string()
}
.into()
} else {
None
}
})
.collect();
if !widths.is_empty() {
parts.push(format!("colwidth={}", widths.join(",")));
}
}
parts.join(" ")
}
/// Renders `{attrs}` prefix for a pipe table cell (background, colspan, etc.).
fn render_cell_attrs_prefix(cell: &AdfNode, output: &mut String) {
let Some(ref _attrs) = cell.attrs else {
return;
};
let attr_str = build_cell_attrs_string(cell);
if attr_str.is_empty() {
output.push_str("{} ");
} else {
output.push_str(&format!("{{{attr_str}}} "));
}
}
/// Gets the alignment mark value from the paragraph inside a table cell.
fn get_cell_paragraph_alignment(cell: &AdfNode) -> Option<&str> {
let content = cell.content.as_ref()?;
let para = content.first()?;
let marks = para.marks.as_ref()?;
marks.iter().find_map(|m| {
if m.mark_type == "alignment" {
m.attrs
.as_ref()
.and_then(|a| a.get("align"))
.and_then(serde_json::Value::as_str)
} else {
None
}
})
}
/// Emits table-level attributes if present.
fn render_table_level_attrs(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
if let Some(ref attrs) = node.attrs {
let mut parts = Vec::new();
if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
parts.push(format!("layout={layout}"));
}
if let Some(numbered) = attrs
.get("isNumberColumnEnabled")
.and_then(serde_json::Value::as_bool)
{
if numbered {
parts.push("numbered".to_string());
} else {
parts.push("numbered=false".to_string());
}
}
if let Some(tw_str) = attrs.get("width").and_then(fmt_numeric_attr) {
parts.push(format!("width={tw_str}"));
}
maybe_push_local_id(attrs, &mut parts, opts);
if !parts.is_empty() {
output.push_str(&format!("{{{}}}\n", parts.join(" ")));
}
}
}
/// Renders inline content from the first paragraph child of a table cell.
fn render_inline_content_from_first_paragraph(
cell: &AdfNode,
output: &mut String,
opts: &RenderOptions,
) {
if let Some(ref content) = cell.content {
if let Some(first) = content.first() {
if first.node_type == "paragraph" {
render_inline_content(first, output, opts);
}
}
}
}
/// Appends border mark attributes (border-color, border-size) to a parts vec.
fn push_border_mark_attrs(marks: &Option<Vec<AdfMark>>, parts: &mut Vec<String>) {
if let Some(ref marks) = marks {
for mark in marks {
if mark.mark_type == "border" {
if let Some(ref attrs) = mark.attrs {
if let Some(color) = attrs.get("color").and_then(serde_json::Value::as_str) {
parts.push(format!("border-color={color}"));
}
if let Some(size) = attrs.get("size").and_then(serde_json::Value::as_u64) {
parts.push(format!("border-size={size}"));
}
}
}
}
}
}
/// Renders a media node as a markdown image, with optional parent (mediaSingle) attrs.
fn render_media(
node: &AdfNode,
parent_attrs: Option<&serde_json::Value>,
output: &mut String,
opts: &RenderOptions,
) {
if let Some(ref attrs) = node.attrs {
let media_type = attrs
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or("external");
let alt = attrs
.get("alt")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
if media_type == "file" {
// Confluence file attachment — encode metadata in {attrs} block so it survives round-trip
output.push_str(&format!("![{alt}]()"));
let mut parts = vec!["type=file".to_string()];
if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
parts.push(format_kv("id", id));
}
if let Some(collection) = attrs.get("collection").and_then(serde_json::Value::as_str) {
parts.push(format_kv("collection", collection));
}
if let Some(occurrence_key) = attrs
.get("occurrenceKey")
.and_then(serde_json::Value::as_str)
{
parts.push(format_kv("occurrenceKey", occurrence_key));
}
if let Some(height_str) = attrs.get("height").and_then(fmt_numeric_attr) {
parts.push(format!("height={height_str}"));
}
if let Some(width_str) = attrs.get("width").and_then(fmt_numeric_attr) {
parts.push(format!("width={width_str}"));
}
maybe_push_local_id(attrs, &mut parts, opts);
// Encode mediaSingle layout/width/widthType if non-default
if let Some(p_attrs) = parent_attrs {
if let Some(layout) = p_attrs.get("layout").and_then(serde_json::Value::as_str) {
if layout != "center" {
parts.push(format!("layout={layout}"));
}
}
if let Some(ms_width_str) = p_attrs.get("width").and_then(fmt_numeric_attr) {
parts.push(format!("mediaWidth={ms_width_str}"));
}
if let Some(wt) = p_attrs.get("widthType").and_then(serde_json::Value::as_str) {
parts.push(format!("widthType={wt}"));
}
if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
parts.push(format!("mode={mode}"));
}
}
push_border_mark_attrs(&node.marks, &mut parts);
output.push_str(&format!("{{{}}}", parts.join(" ")));
} else {
// External image
let url = attrs
.get("url")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
output.push_str(&format!(""));
// Emit {layout=... width=... widthType=... mode=... localId=...} if non-default attrs present
{
let mut parts = Vec::new();
if let Some(p_attrs) = parent_attrs {
let layout = p_attrs.get("layout").and_then(serde_json::Value::as_str);
let width_str = p_attrs.get("width").and_then(fmt_numeric_attr);
let width_type = p_attrs.get("widthType").and_then(serde_json::Value::as_str);
if let Some(l) = layout {
if l != "center" {
parts.push(format!("layout={l}"));
}
}
if let Some(w) = width_str {
parts.push(format!("width={w}"));
}
if let Some(wt) = width_type {
parts.push(format!("widthType={wt}"));
}
if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
parts.push(format!("mode={mode}"));
}
}
maybe_push_local_id(attrs, &mut parts, opts);
push_border_mark_attrs(&node.marks, &mut parts);
if !parts.is_empty() {
output.push_str(&format!("{{{}}}", parts.join(" ")));
}
}
}
output.push('\n');
}
}
/// Renders inline content (text nodes with marks) from a block node's children.
fn render_inline_content(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
if let Some(ref content) = node.content {
for child in content {
render_inline_node(child, output, opts);
}
}
}
/// Renders a single inline ADF node to markdown.
fn render_inline_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
match node.node_type.as_str() {
"text" => {
let text = node.text.as_deref().unwrap_or("");
let marks = node.marks.as_deref().unwrap_or(&[]);
let has_code = marks.iter().any(|m| m.mark_type == "code");
// Issue #477: Escape literal backslashes before the newline
// encoding below so they are not consumed as JFM escape
// sequences on round-trip. Code marks emit content verbatim,
// so backslash escaping is skipped for them.
let owned;
let text = if !has_code {
owned = text.replace('\\', "\\\\");
owned.as_str()
} else {
text
};
// Issue #454: A literal newline inside a text node is escaped
// as the two-character sequence `\n` so it survives round-trip
// as a single text node instead of splitting into paragraphs or
// being converted to hardBreak nodes.
let owned_nl;
let text = if text.contains('\n') {
owned_nl = text.replace('\n', "\\n");
owned_nl.as_str()
} else {
text
};
// Issue #510: Two or more trailing spaces at the end of a text
// node would be misinterpreted as a hardBreak marker on
// round-trip (and collapse the following paragraph). Escape the
// last space with a backslash so the parser treats it as a
// literal space instead of a line-break signal.
let owned_ts;
let text = if !has_code && text.ends_with(" ") {
let mut s = text.to_string();
// Insert backslash before the final space: "foo " → "foo \ "
s.insert(s.len() - 1, '\\');
owned_ts = s;
owned_ts.as_str()
} else {
text
};
render_marked_text(text, marks, output);
}
"hardBreak" => {
output.push_str("\\\n");
}
other => {
// Issue #471: Non-text inline nodes (emoji, status, date, mention, etc.)
// may carry annotation marks. Render the node body first, then wrap it
// in bracketed-span syntax if annotation marks are present.
let mut body = String::new();
render_non_text_inline_body(other, node, &mut body, opts);
let annotations: Vec<&AdfMark> = node
.marks
.as_deref()
.unwrap_or(&[])
.iter()
.filter(|m| m.mark_type == "annotation")
.collect();
if annotations.is_empty() {
output.push_str(&body);
} else {
let mut attr_parts = Vec::new();
for ann in &annotations {
if let Some(ref attrs) = ann.attrs {
if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
attr_parts.push(format!("annotation-id=\"{escaped}\""));
}
if let Some(at) = attrs
.get("annotationType")
.and_then(serde_json::Value::as_str)
{
attr_parts.push(format!("annotation-type={at}"));
}
}
}
output.push('[');
output.push_str(&body);
output.push_str("]{");
output.push_str(&attr_parts.join(" "));
output.push('}');
}
}
}
}
/// Renders the body of a non-text inline node (without mark wrapping).
fn render_non_text_inline_body(
node_type: &str,
node: &AdfNode,
output: &mut String,
opts: &RenderOptions,
) {
match node_type {
"inlineCard" => {
if let Some(ref attrs) = node.attrs {
if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
let mut attr_parts = Vec::new();
if url_safe_in_bracket_content(url) {
output.push_str(":card[");
output.push_str(url);
output.push(']');
} else {
// URL would break `:card[URL]` parsing (e.g. contains an
// unbalanced `]` or a newline). Fall back to quoted
// attribute form so the URL round-trips losslessly.
output.push_str(":card[]");
let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
attr_parts.push(format!("url=\"{escaped}\""));
}
maybe_push_local_id(attrs, &mut attr_parts, opts);
if !attr_parts.is_empty() {
output.push('{');
output.push_str(&attr_parts.join(" "));
output.push('}');
}
}
}
}
"emoji" => {
if let Some(ref attrs) = node.attrs {
if let Some(short_name) = attrs.get("shortName").and_then(serde_json::Value::as_str)
{
output.push(':');
let name = short_name.strip_prefix(':').unwrap_or(short_name);
let name = name.strip_suffix(':').unwrap_or(name);
output.push_str(name);
output.push(':');
let mut parts = Vec::new();
let escaped_sn = short_name.replace('\\', "\\\\").replace('"', "\\\"");
parts.push(format!("shortName=\"{escaped_sn}\""));
if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
parts.push(format!("id=\"{escaped}\""));
}
if let Some(text) = attrs.get("text").and_then(serde_json::Value::as_str) {
let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
parts.push(format!("text=\"{escaped}\""));
}
maybe_push_local_id(attrs, &mut parts, opts);
output.push('{');
output.push_str(&parts.join(" "));
output.push('}');
}
}
}
"status" => {
if let Some(ref attrs) = node.attrs {
let text = attrs
.get("text")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let color = attrs
.get("color")
.and_then(serde_json::Value::as_str)
.unwrap_or("neutral");
let mut attr_parts = vec![format!("color={color}")];
if let Some(style) = attrs.get("style").and_then(serde_json::Value::as_str) {
attr_parts.push(format!("style={style}"));
}
maybe_push_local_id(attrs, &mut attr_parts, opts);
output.push_str(&format!(":status[{text}]{{{}}}", attr_parts.join(" ")));
}
}
"date" => {
if let Some(ref attrs) = node.attrs {
if let Some(timestamp) = attrs.get("timestamp").and_then(serde_json::Value::as_str)
{
let display = epoch_ms_to_iso_date(timestamp);
let mut attr_parts = vec![format!("timestamp={timestamp}")];
maybe_push_local_id(attrs, &mut attr_parts, opts);
output.push_str(&format!(":date[{display}]{{{}}}", attr_parts.join(" ")));
}
}
}
"mention" => {
if let Some(ref attrs) = node.attrs {
let id = attrs
.get("id")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let text = attrs
.get("text")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let mut attr_parts = vec![format!("id={id}")];
if let Some(ut) = attrs.get("userType").and_then(serde_json::Value::as_str) {
attr_parts.push(format!("userType={ut}"));
}
if let Some(al) = attrs.get("accessLevel").and_then(serde_json::Value::as_str) {
attr_parts.push(format!("accessLevel={al}"));
}
maybe_push_local_id(attrs, &mut attr_parts, opts);
output.push_str(&format!(":mention[{text}]{{{}}}", attr_parts.join(" ")));
}
}
"placeholder" => {
if let Some(ref attrs) = node.attrs {
let text = attrs
.get("text")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
output.push_str(&format!(":placeholder[{text}]"));
}
}
"inlineExtension" => {
if let Some(ref attrs) = node.attrs {
let ext_type = attrs
.get("extensionType")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let ext_key = attrs
.get("extensionKey")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let fallback = node.text.as_deref().unwrap_or("");
output.push_str(&format!(
":extension[{fallback}]{{type={ext_type} key={ext_key}}}"
));
}
}
"mediaInline" => {
if let Some(ref attrs) = node.attrs {
let mut attr_parts = Vec::new();
if let Some(media_type) = attrs.get("type").and_then(serde_json::Value::as_str) {
attr_parts.push(format_kv("type", media_type));
}
if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
attr_parts.push(format_kv("id", id));
}
if let Some(collection) =
attrs.get("collection").and_then(serde_json::Value::as_str)
{
attr_parts.push(format_kv("collection", collection));
}
if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
attr_parts.push(format_kv("url", url));
}
if let Some(alt) = attrs.get("alt").and_then(serde_json::Value::as_str) {
attr_parts.push(format_kv("alt", alt));
}
if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
attr_parts.push(format!("width={width}"));
}
if let Some(height) = attrs.get("height").and_then(serde_json::Value::as_u64) {
attr_parts.push(format!("height={height}"));
}
maybe_push_local_id(attrs, &mut attr_parts, opts);
output.push_str(&format!(":media-inline[]{{{}}}", attr_parts.join(" ")));
}
}
_ => {
output.push_str(&format!("<!-- unsupported inline: {} -->", node.node_type));
}
}
}
/// Renders text with ADF marks applied as markdown syntax.
///
/// Mark ordering is preserved by walking the marks array in order and emitting
/// one wrapper per mark (outermost first, innermost last). The resulting
/// markdown round-trips back to the original mark sequence because the parser
/// reconstructs marks outside-in from the nested delimiter structure.
///
/// When both `strong` and `em` are present, em is rendered with `_` instead of
/// `*` to avoid ambiguity (e.g., `_**text**_` rather than `***text***`). The
/// single exception is `[strong, em]` (exactly those two marks in that order),
/// which is rendered as `***text***` to preserve the familiar compact form;
/// the parser's triple-delimiter rule round-trips it back to `[strong, em]`.
fn render_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
if marks.iter().any(|m| m.mark_type == "code") {
render_code_marked_text(text, marks, output);
return;
}
let has_link = marks.iter().any(|m| m.mark_type == "link");
let has_strong = marks.iter().any(|m| m.mark_type == "strong");
let has_em = marks.iter().any(|m| m.mark_type == "em");
// Compact form for the common [strong, em] case: ***text***. em is
// rendered with `*` here (as part of the `***` triple delimiter), so
// underscores in the content don't need escaping.
if marks.len() == 2 && marks[0].mark_type == "strong" && marks[1].mark_type == "em" {
let escaped = escape_emphasis_markers(text);
let escaped = escape_emoji_shortcodes(&escaped);
let escaped = escape_backticks(&escaped);
let escaped = escape_bare_urls(&escaped);
output.push_str("***");
output.push_str(&escaped);
output.push_str("***");
return;
}
// When both strong and em are present (in any order), em uses `_` instead
// of `*` to avoid the `***` triple-delimiter ambiguity. Otherwise em uses
// `*`, which sidesteps intraword-underscore pitfalls for plain em text.
let em_delim = if has_strong && has_em { "_" } else { "*" };
// Text must also escape `_` when em renders as `_..._` — otherwise any
// underscore in the content would close the emphasis span early.
let escaped = if em_delim == "_" {
escape_emphasis_markers_with_underscore(text)
} else {
escape_emphasis_markers(text)
};
let escaped = escape_emoji_shortcodes(&escaped);
let escaped = escape_backticks(&escaped);
// Always escape bare URLs so they are not re-parsed as `inlineCard`
// nodes on round-trip. When the text carries a link mark, also escape
// `[` and `]` so they do not terminate the enclosing `[…]` link syntax
// (issue #493). Escaping bare URLs inside link text additionally
// prevents `\[`/`\]` escapes from leaking through the URL-as-link-text
// fast path and from corrupting an auto-detected bare URL inside the
// link display text (issue #551).
let escaped = escape_bare_urls(&escaped);
let escaped = if has_link {
escape_link_brackets(&escaped)
} else {
escaped
};
// Collect (open, close) wrappers in mark order, outermost first. Consecutive
// span-attr or bracketed-span marks that happen to be in the parser's
// canonical order (so the merged wrapper parses back to the same mark
// sequence) are merged into a single wrapper; otherwise each mark gets its
// own nested wrapper so that the mark ordering survives the round-trip.
let mut wrappers: Vec<(String, String)> = Vec::new();
let mut i = 0;
while i < marks.len() {
match marks[i].mark_type.as_str() {
"em" => {
wrappers.push((em_delim.to_string(), em_delim.to_string()));
i += 1;
}
"strong" => {
wrappers.push(("**".to_string(), "**".to_string()));
i += 1;
}
"strike" => {
wrappers.push(("~~".to_string(), "~~".to_string()));
i += 1;
}
"link" => {
let href = link_href(&marks[i]);
wrappers.push(("[".to_string(), format!("]({href})")));
i += 1;
}
"textColor" | "backgroundColor" | "subsup" => {
let start = i;
while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
i += 1;
}
emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
}
"underline" | "annotation" => {
let start = i;
while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
i += 1;
}
emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
}
_ => {
i += 1;
}
}
}
// Apply wrappers from innermost (last) to outermost (first).
let mut result = escaped;
for (open, close) in wrappers.iter().rev() {
result.insert_str(0, open);
result.push_str(close);
}
output.push_str(&result);
}
/// Renders a text node with a `code` mark. Code content is emitted verbatim
/// inside backticks, optionally wrapped by a link and/or by `:span`/bracketed-
/// span carrying span-attr (`textColor`, `backgroundColor`, `subsup`) and
/// bracketed-span (`underline`, `annotation`) marks. No `em`/`strong`/`strike`
/// formatting is applied because markdown code spans do not support nested
/// emphasis (issue #554: previously textColor/bg/subsup/underline were
/// silently dropped when combined with a code mark).
fn render_code_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
let link_mark = marks.iter().find(|m| m.mark_type == "link");
let mut code_str = String::new();
if let Some(link_mark) = link_mark {
let href = link_href(link_mark);
code_str.push('[');
render_inline_code(text, &mut code_str);
code_str.push_str("](");
code_str.push_str(href);
code_str.push(')');
} else {
render_inline_code(text, &mut code_str);
}
// Build wrappers (outermost first) for span-attr and bracketed-span runs,
// walking marks in order so the round-trip preserves mark ordering.
let mut wrappers: Vec<(String, String)> = Vec::new();
let mut i = 0;
while i < marks.len() {
match marks[i].mark_type.as_str() {
"textColor" | "backgroundColor" | "subsup" => {
let start = i;
while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
i += 1;
}
emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
}
"underline" | "annotation" => {
let start = i;
while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
i += 1;
}
emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
}
_ => {
i += 1;
}
}
}
// Apply wrappers from innermost (last) to outermost (first).
let mut result = code_str;
for (open, close) in wrappers.iter().rev() {
result.insert_str(0, open);
result.push_str(close);
}
output.push_str(&result);
}
/// Collects `:span` attribute fragments (color, bg, sub/sup) for a single mark.
fn collect_span_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
match mark.mark_type.as_str() {
"textColor" => {
if let Some(c) = mark
.attrs
.as_ref()
.and_then(|a| a.get("color"))
.and_then(serde_json::Value::as_str)
{
attrs.push(format!("color={c}"));
}
}
"backgroundColor" => {
if let Some(c) = mark
.attrs
.as_ref()
.and_then(|a| a.get("color"))
.and_then(serde_json::Value::as_str)
{
attrs.push(format!("bg={c}"));
}
}
"subsup" => {
if let Some(kind) = mark
.attrs
.as_ref()
.and_then(|a| a.get("type"))
.and_then(serde_json::Value::as_str)
{
attrs.push(kind.to_string());
}
}
_ => {}
}
}
/// Collects bracketed-span attribute fragments for an `underline` or `annotation` mark.
fn collect_bracketed_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
match mark.mark_type.as_str() {
"underline" => attrs.push("underline".to_string()),
"annotation" => {
if let Some(ref a) = mark.attrs {
if let Some(id) = a.get("id").and_then(serde_json::Value::as_str) {
let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
attrs.push(format!("annotation-id=\"{escaped}\""));
}
if let Some(at) = a.get("annotationType").and_then(serde_json::Value::as_str) {
attrs.push(format!("annotation-type={at}"));
}
}
}
_ => {}
}
}
fn is_span_attr_mark(mark_type: &str) -> bool {
matches!(mark_type, "textColor" | "backgroundColor" | "subsup")
}
fn is_bracketed_span_mark(mark_type: &str) -> bool {
matches!(mark_type, "underline" | "annotation")
}
/// Canonical ordering for span-attr marks, matching the order in which the
/// `:span` directive parser reads attributes (`color`, then `bg`, then
/// `sub`/`sup`).
fn span_attr_order(mark_type: &str) -> u8 {
match mark_type {
"textColor" => 0,
"backgroundColor" => 1,
"subsup" => 2,
_ => u8::MAX,
}
}
/// Returns `true` if the run of span-attr marks is in the canonical order the
/// `:span` parser would produce. A canonical run can be merged into one
/// `:span[...]{...}` wrapper; a non-canonical run must be split into one
/// nested wrapper per mark so the ordering survives the round-trip.
fn span_run_is_canonical(run: &[AdfMark]) -> bool {
let mut prev = 0;
for m in run {
let order = span_attr_order(&m.mark_type);
if order == u8::MAX || order < prev {
return false;
}
prev = order;
}
true
}
/// Returns `true` if the run of `underline`/`annotation` marks is in the
/// canonical order the bracketed-span parser produces (`underline` first,
/// followed by annotations). A canonical run can be merged into one
/// `[...]{underline annotation-id=...}` wrapper.
fn bracketed_run_is_canonical(run: &[AdfMark]) -> bool {
let mut seen_annotation = false;
for m in run {
match m.mark_type.as_str() {
"underline" => {
if seen_annotation {
return false;
}
}
"annotation" => seen_annotation = true,
_ => return false,
}
}
true
}
/// Emits one or more `:span[...]{...}` wrappers for a run of span-attr marks.
/// Canonical-order runs collapse into a single wrapper; non-canonical runs
/// emit one wrapper per mark so the order round-trips.
fn emit_span_attr_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
if span_run_is_canonical(run) {
let mut attrs = Vec::new();
for m in run {
collect_span_attr(m, &mut attrs);
}
wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
return;
}
for m in run {
let mut attrs = Vec::new();
collect_span_attr(m, &mut attrs);
wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
}
}
/// Emits one or more `[...]{...}` wrappers for a run of `underline`/`annotation`
/// marks. Canonical-order runs collapse into a single wrapper; non-canonical
/// runs emit one wrapper per mark so the order round-trips.
fn emit_bracketed_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
if bracketed_run_is_canonical(run) {
let mut attrs = Vec::new();
for m in run {
collect_bracketed_attr(m, &mut attrs);
}
wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
return;
}
for m in run {
let mut attrs = Vec::new();
collect_bracketed_attr(m, &mut attrs);
wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
}
}
/// Extracts the href from a link mark.
fn link_href(mark: &AdfMark) -> &str {
mark.attrs
.as_ref()
.and_then(|a| a.get("href"))
.and_then(serde_json::Value::as_str)
.unwrap_or("")
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
// ── markdown_to_adf tests ───────────────────────────────────────
#[test]
fn paragraph() {
let doc = markdown_to_adf("Hello world").unwrap();
assert_eq!(doc.content.len(), 1);
assert_eq!(doc.content[0].node_type, "paragraph");
}
#[test]
fn heading_levels() {
for level in 1..=6 {
let hashes = "#".repeat(level);
let md = format!("{hashes} Title");
let doc = markdown_to_adf(&md).unwrap();
assert_eq!(doc.content[0].node_type, "heading");
let attrs = doc.content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["level"], level as u64);
}
}
#[test]
fn code_block() {
let md = "```rust\nfn main() {}\n```";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content[0].node_type, "codeBlock");
let attrs = doc.content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["language"], "rust");
}
#[test]
fn code_block_no_language() {
let md = "```\nsome code\n```";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content[0].node_type, "codeBlock");
assert!(doc.content[0].attrs.is_none());
}
#[test]
fn code_block_empty_language() {
let md = "```\"\"\nsome code\n```";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content[0].node_type, "codeBlock");
let attrs = doc.content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["language"], "");
}
#[test]
fn horizontal_rule() {
let doc = markdown_to_adf("---").unwrap();
assert_eq!(doc.content[0].node_type, "rule");
}
#[test]
fn horizontal_rule_stars() {
let doc = markdown_to_adf("***").unwrap();
assert_eq!(doc.content[0].node_type, "rule");
}
#[test]
fn blockquote() {
let md = "> This is a quote\n> Second line";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content[0].node_type, "blockquote");
}
#[test]
fn bullet_list() {
let md = "- Item 1\n- Item 2\n- Item 3";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content[0].node_type, "bulletList");
let items = doc.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 3);
}
#[test]
fn ordered_list() {
let md = "1. First\n2. Second\n3. Third";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content[0].node_type, "orderedList");
let items = doc.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 3);
}
#[test]
fn task_list() {
let md = "- [ ] Todo item\n- [x] Done item";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content[0].node_type, "taskList");
let items = doc.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].node_type, "taskItem");
assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
}
#[test]
fn task_list_uppercase_x() {
let md = "- [X] Done item";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content[0].node_type, "taskList");
let item = &doc.content[0].content.as_ref().unwrap()[0];
assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
}
/// Issue #548: an empty task marker (no trailing space) must still be
/// parsed as a `taskList` rather than a `bulletList` with `[ ]` text.
#[test]
fn task_list_empty_todo_no_trailing_space() {
let md = "- [ ]";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content[0].node_type, "taskList");
let items = doc.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].node_type, "taskItem");
assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
assert!(items[0].content.is_none());
}
/// Issue #548: likewise for a done checkbox with no body.
#[test]
fn task_list_empty_done_no_trailing_space() {
let md = "- [x]\n- [X]";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content[0].node_type, "taskList");
let items = doc.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
}
/// Issue #548: the body of `- [ ] text` must not have a spurious leading
/// space introduced by relaxing the trailing-space requirement.
#[test]
fn task_list_body_has_no_leading_space() {
let md = "- [ ] Buy groceries";
let doc = markdown_to_adf(md).unwrap();
let item = &doc.content[0].content.as_ref().unwrap()[0];
let text = item.content.as_ref().unwrap()[0].text.as_deref().unwrap();
assert_eq!(text, "Buy groceries");
}
/// Issue #548: round-trip from ADF with empty taskItems should preserve
/// the `taskList` structure even if trailing spaces are stripped from the
/// intermediate markdown (as many editors do).
#[test]
fn round_trip_empty_task_items_stripped_trailing_spaces() {
let json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "taskList",
"attrs": {"localId": "abc"},
"content": [
{"type": "taskItem", "attrs": {"localId": "def", "state": "TODO"}},
{"type": "taskItem", "attrs": {"localId": "ghi", "state": "DONE"}}
]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let stripped: String = md
.lines()
.map(|l| l.trim_end())
.collect::<Vec<_>>()
.join("\n");
let parsed = markdown_to_adf(&stripped).unwrap();
assert_eq!(parsed.content[0].node_type, "taskList");
let items = parsed.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].node_type, "taskItem");
assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
assert_eq!(items[1].node_type, "taskItem");
assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
}
#[test]
fn try_parse_task_marker_accepts_bare_checkbox() {
assert_eq!(try_parse_task_marker("[ ]"), Some(("TODO", "")));
assert_eq!(try_parse_task_marker("[x]"), Some(("DONE", "")));
assert_eq!(try_parse_task_marker("[X]"), Some(("DONE", "")));
assert_eq!(try_parse_task_marker("[ ] foo"), Some(("TODO", "foo")));
assert_eq!(try_parse_task_marker("[x] foo"), Some(("DONE", "foo")));
assert_eq!(try_parse_task_marker("[ ]foo"), None);
assert_eq!(try_parse_task_marker("[x]foo"), None);
assert_eq!(try_parse_task_marker("[y] foo"), None);
}
#[test]
fn starts_with_task_marker_matches_parser() {
// Anything `try_parse_task_marker` recognises must also be flagged
// here so the renderer escapes it.
assert!(starts_with_task_marker("[ ]"));
assert!(starts_with_task_marker("[x]"));
assert!(starts_with_task_marker("[X]"));
assert!(starts_with_task_marker("[ ] foo"));
assert!(starts_with_task_marker("[x] foo\n"));
assert!(starts_with_task_marker("[ ]\n"));
// No collision when the bracket is followed by non-whitespace.
assert!(!starts_with_task_marker("[ ]foo"));
assert!(!starts_with_task_marker("[y] foo"));
assert!(!starts_with_task_marker("foo [ ] bar"));
assert!(!starts_with_task_marker(""));
}
/// Issue #548: a `bulletList` whose item starts with literal `[ ]` text
/// must round-trip through markdown without being promoted to a
/// `taskList`.
#[test]
fn round_trip_bullet_list_with_literal_checkbox_text() {
let json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "bulletList",
"content": [{
"type": "listItem",
"content": [{
"type": "paragraph",
"content": [
{"type": "text", "text": "[ ] Review the "},
{"type": "text", "text": "config.yaml", "marks": [{"type": "code"}]},
{"type": "text", "text": " file"}
]
}]
}]
}]
}"#;
let original: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&original).unwrap();
// Renderer must escape the leading bracket.
assert!(
md.contains(r"- \[ ] Review the "),
"rendered markdown: {md:?}"
);
let parsed = markdown_to_adf(&md).unwrap();
assert_eq!(parsed.content[0].node_type, "bulletList");
let item = &parsed.content[0].content.as_ref().unwrap()[0];
assert_eq!(item.node_type, "listItem");
let para = &item.content.as_ref().unwrap()[0];
assert_eq!(para.node_type, "paragraph");
let text_nodes = para.content.as_ref().unwrap();
assert_eq!(text_nodes[0].text.as_deref().unwrap(), "[ ] Review the ");
assert_eq!(text_nodes[1].text.as_deref().unwrap(), "config.yaml");
assert_eq!(text_nodes[2].text.as_deref().unwrap(), " file");
}
/// Issue #548: the same problem with a `[x]` marker.
#[test]
fn round_trip_bullet_list_with_literal_done_checkbox_text() {
let json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "bulletList",
"content": [{
"type": "listItem",
"content": [{
"type": "paragraph",
"content": [{"type": "text", "text": "[x] not actually done"}]
}]
}]
}]
}"#;
let original: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&original).unwrap();
assert!(md.contains(r"- \[x] "), "rendered markdown: {md:?}");
let parsed = markdown_to_adf(&md).unwrap();
assert_eq!(parsed.content[0].node_type, "bulletList");
let item = &parsed.content[0].content.as_ref().unwrap()[0];
let para = &item.content.as_ref().unwrap()[0];
let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
assert_eq!(text, "[x] not actually done");
}
/// Issue #548: `bulletList` item whose entire content is literal `[ ]`.
#[test]
fn round_trip_bullet_list_with_bare_literal_checkbox() {
let json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "bulletList",
"content": [{
"type": "listItem",
"content": [{
"type": "paragraph",
"content": [{"type": "text", "text": "[ ]"}]
}]
}]
}]
}"#;
let original: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&original).unwrap();
let parsed = markdown_to_adf(&md).unwrap();
assert_eq!(parsed.content[0].node_type, "bulletList");
let item = &parsed.content[0].content.as_ref().unwrap()[0];
let para = &item.content.as_ref().unwrap()[0];
let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
assert_eq!(text, "[ ]");
}
/// Issue #548: a `bulletList` with a non-task `[?]` prefix should not be
/// escaped — that would just produce noise.
#[test]
fn bullet_list_non_task_bracket_text_not_escaped() {
let json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "bulletList",
"content": [{
"type": "listItem",
"content": [{
"type": "paragraph",
"content": [{"type": "text", "text": "[?] unsure"}]
}]
}]
}]
}"#;
let original: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&original).unwrap();
assert!(!md.contains(r"\["), "should not escape: {md:?}");
assert!(md.contains("- [?] unsure"), "rendered: {md:?}");
}
/// Issue #548: nested `bulletList` items inside another `bulletList`
/// must also have their literal `[ ]` text escaped.
#[test]
fn round_trip_nested_bullet_list_with_literal_checkbox_text() {
let json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "bulletList",
"content": [{
"type": "listItem",
"content": [
{"type": "paragraph", "content": [{"type": "text", "text": "outer"}]},
{"type": "bulletList", "content": [{
"type": "listItem",
"content": [{
"type": "paragraph",
"content": [{"type": "text", "text": "[ ] inner literal"}]
}]
}]}
]
}]
}]
}"#;
let original: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&original).unwrap();
let parsed = markdown_to_adf(&md).unwrap();
let outer = &parsed.content[0];
assert_eq!(outer.node_type, "bulletList");
let outer_item = &outer.content.as_ref().unwrap()[0];
let inner_list = &outer_item.content.as_ref().unwrap()[1];
assert_eq!(inner_list.node_type, "bulletList");
let inner_item = &inner_list.content.as_ref().unwrap()[0];
assert_eq!(inner_item.node_type, "listItem");
let para = &inner_item.content.as_ref().unwrap()[0];
let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
assert_eq!(text, "[ ] inner literal");
}
#[test]
fn adf_task_list_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::task_list(vec![
AdfNode::task_item(
"TODO",
vec![AdfNode::paragraph(vec![AdfNode::text("Todo")])],
),
AdfNode::task_item(
"DONE",
vec![AdfNode::paragraph(vec![AdfNode::text("Done")])],
),
])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("- [ ] Todo"));
assert!(md.contains("- [x] Done"));
}
#[test]
fn round_trip_task_list() {
let md = "- [ ] Todo item\n- [x] Done item\n";
let doc = markdown_to_adf(md).unwrap();
let result = adf_to_markdown(&doc).unwrap();
assert!(result.contains("- [ ] Todo item"));
assert!(result.contains("- [x] Done item"));
}
/// Issue #408: taskItem content with inline nodes directly (no paragraph wrapper).
#[test]
fn adf_task_item_unwrapped_inline_content() {
// Real Confluence ADF: taskItem contains text nodes directly, no paragraph.
let json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "taskList",
"attrs": {"localId": "list-001"},
"content": [{
"type": "taskItem",
"attrs": {"localId": "task-001", "state": "TODO"},
"content": [{"type": "text", "text": "Do something"}]
}]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("- [ ] Do something"), "got: {md}");
assert!(!md.contains("adf-unsupported"), "got: {md}");
}
/// Issue #408: multiple taskItems with unwrapped inline content.
#[test]
fn adf_task_list_multiple_unwrapped_items() {
let json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "taskList",
"attrs": {"localId": "list-001"},
"content": [
{
"type": "taskItem",
"attrs": {"localId": "task-001", "state": "TODO"},
"content": [{"type": "text", "text": "First task"}]
},
{
"type": "taskItem",
"attrs": {"localId": "task-002", "state": "DONE"},
"content": [{"type": "text", "text": "Second task"}]
}
]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("- [ ] First task"), "got: {md}");
assert!(md.contains("- [x] Second task"), "got: {md}");
assert!(!md.contains("adf-unsupported"), "got: {md}");
}
/// Issue #408: unwrapped inline content with marks (bold text).
#[test]
fn adf_task_item_unwrapped_inline_with_marks() {
let json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "taskList",
"attrs": {"localId": "list-001"},
"content": [{
"type": "taskItem",
"attrs": {"localId": "task-001", "state": "TODO"},
"content": [
{"type": "text", "text": "Buy "},
{"type": "text", "text": "groceries", "marks": [{"type": "strong"}]},
{"type": "text", "text": " today"}
]
}]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("- [ ] Buy **groceries** today"), "got: {md}");
}
/// Issue #408: taskItem localId is preserved for unwrapped inline content.
#[test]
fn adf_task_item_unwrapped_preserves_local_id() {
let json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "taskList",
"attrs": {"localId": "list-001"},
"content": [{
"type": "taskItem",
"attrs": {"localId": "task-001", "state": "TODO"},
"content": [{"type": "text", "text": "Do something"}]
}]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("{localId=task-001}"), "got: {md}");
assert!(md.contains("{localId=list-001}"), "got: {md}");
}
/// Issue #408: round-trip from Confluence ADF with unwrapped taskItem content.
#[test]
fn round_trip_task_list_unwrapped_inline() {
let json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "taskList",
"attrs": {"localId": "list-001"},
"content": [
{
"type": "taskItem",
"attrs": {"localId": "task-001", "state": "TODO"},
"content": [{"type": "text", "text": "Do something"}]
},
{
"type": "taskItem",
"attrs": {"localId": "task-002", "state": "DONE"},
"content": [{"type": "text", "text": "Already done"}]
}
]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
// Round-trip: markdown back to ADF
let doc2 = markdown_to_adf(&md).unwrap();
assert_eq!(doc2.content[0].node_type, "taskList");
let items = doc2.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
// localIds preserved
assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "task-002");
assert_eq!(
doc2.content[0].attrs.as_ref().unwrap()["localId"],
"list-001"
);
}
/// Issue #408: taskItem with inline content followed by a nested block (sub-list).
#[test]
fn adf_task_item_unwrapped_inline_then_block() {
let json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "taskList",
"attrs": {"localId": "list-001"},
"content": [{
"type": "taskItem",
"attrs": {"localId": "task-001", "state": "TODO"},
"content": [
{"type": "text", "text": "Parent task"},
{
"type": "bulletList",
"content": [{
"type": "listItem",
"content": [{
"type": "paragraph",
"content": [{"type": "text", "text": "sub-item"}]
}]
}]
}
]
}]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("- [ ] Parent task"), "got: {md}");
assert!(md.contains(" - sub-item"), "got: {md}");
assert!(!md.contains("adf-unsupported"), "got: {md}");
}
/// Issue #408: taskItem with empty content array renders without panic.
#[test]
fn adf_task_item_empty_content() {
let json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "taskList",
"attrs": {"localId": "list-001"},
"content": [{
"type": "taskItem",
"attrs": {"localId": "task-001", "state": "TODO"},
"content": []
}]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("- [ ] "), "got: {md}");
assert!(!md.contains("adf-unsupported"), "got: {md}");
}
/// Issue #489: nested taskItem inside taskItem.content renders as indented
/// task items instead of corrupting the surrounding taskList.
#[test]
fn adf_nested_task_item_renders_without_corruption() {
let json = r#"{
"type": "doc",
"version": 1,
"content": [{
"type": "taskList",
"attrs": {"localId": ""},
"content": [
{
"type": "taskItem",
"attrs": {"localId": "aabbccdd-1234-5678-abcd-aabbccdd1234", "state": "TODO"},
"content": [{"type": "text", "text": "Normal task"}]
},
{
"type": "taskItem",
"attrs": {"localId": ""},
"content": [
{
"type": "taskItem",
"attrs": {"localId": "bbccddee-2345-6789-bcde-bbccddee2345", "state": "TODO"},
"content": [{"type": "text", "text": "Nested task one"}]
},
{
"type": "taskItem",
"attrs": {"localId": "ccddee11-3456-7890-cdef-ccddee113456", "state": "DONE"},
"content": [{"type": "text", "text": "Nested task two"}]
}
]
}
]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
// Normal task preserved
assert!(md.contains("- [ ] Normal task"), "got: {md}");
// Nested tasks rendered as indented task items, not adf-unsupported
assert!(!md.contains("adf-unsupported"), "got: {md}");
assert!(md.contains(" - [ ] Nested task one"), "got: {md}");
assert!(md.contains(" - [x] Nested task two"), "got: {md}");
}
/// Issue #489: round-trip of nested taskItem preserves data.
#[test]
fn round_trip_nested_task_item() {
let json = r#"{
"type": "doc",
"version": 1,
"content": [{
"type": "taskList",
"attrs": {"localId": ""},
"content": [
{
"type": "taskItem",
"attrs": {"localId": "task-001", "state": "TODO"},
"content": [{"type": "text", "text": "Normal task"}]
},
{
"type": "taskItem",
"attrs": {"localId": ""},
"content": [
{
"type": "taskItem",
"attrs": {"localId": "task-002", "state": "TODO"},
"content": [{"type": "text", "text": "Nested one"}]
},
{
"type": "taskItem",
"attrs": {"localId": "task-003", "state": "DONE"},
"content": [{"type": "text", "text": "Nested two"}]
}
]
}
]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let doc2 = markdown_to_adf(&md).unwrap();
// Top-level structure: taskList with 2 items
assert_eq!(doc2.content[0].node_type, "taskList");
let items = doc2.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 2, "expected 2 top-level items, got: {items:?}");
// First item: normal task preserved
assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
let first_content = items[0].content.as_ref().unwrap();
assert_eq!(first_content[0].text.as_deref(), Some("Normal task"));
// Second item: container taskItem — no spurious `state` attr
let container = &items[1];
assert_eq!(container.node_type, "taskItem");
let c_attrs = container.attrs.as_ref().unwrap();
assert!(
c_attrs.get("state").is_none(),
"container should have no state attr, got: {c_attrs:?}"
);
// Children are bare taskItems, NOT wrapped in a taskList
let container_content = container.content.as_ref().unwrap();
assert_eq!(
container_content.len(),
2,
"expected 2 bare taskItem children"
);
assert_eq!(container_content[0].node_type, "taskItem");
assert_eq!(
container_content[0].attrs.as_ref().unwrap()["state"],
"TODO"
);
assert_eq!(
container_content[0].attrs.as_ref().unwrap()["localId"],
"task-002"
);
assert_eq!(container_content[1].node_type, "taskItem");
assert_eq!(
container_content[1].attrs.as_ref().unwrap()["state"],
"DONE"
);
assert_eq!(
container_content[1].attrs.as_ref().unwrap()["localId"],
"task-003"
);
}
/// Issue #489: nested taskItem with localIds on both container and children.
#[test]
fn adf_nested_task_item_preserves_local_ids() {
let json = r#"{
"type": "doc",
"version": 1,
"content": [{
"type": "taskList",
"attrs": {"localId": "list-001"},
"content": [{
"type": "taskItem",
"attrs": {"localId": "container-001", "state": "TODO"},
"content": [{
"type": "taskItem",
"attrs": {"localId": "child-001", "state": "DONE"},
"content": [{"type": "text", "text": "Nested child"}]
}]
}]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
// Container localId is emitted
assert!(
md.contains("localId=container-001"),
"container localId missing: {md}"
);
// Child localId is emitted
assert!(
md.contains("localId=child-001"),
"child localId missing: {md}"
);
assert!(!md.contains("adf-unsupported"), "got: {md}");
}
/// Issue #489: nested taskItem content mixed with a non-taskItem block node.
/// Covers the else branch in the renderer where a child is not a taskItem.
#[test]
fn adf_nested_task_item_mixed_with_block_node() {
let json = r#"{
"type": "doc",
"version": 1,
"content": [{
"type": "taskList",
"attrs": {"localId": ""},
"content": [{
"type": "taskItem",
"attrs": {"localId": "", "state": "TODO"},
"content": [
{
"type": "taskItem",
"attrs": {"localId": "", "state": "TODO"},
"content": [{"type": "text", "text": "A nested task"}]
},
{
"type": "paragraph",
"content": [{"type": "text", "text": "Stray paragraph"}]
}
]
}]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains(" - [ ] A nested task"), "got: {md}");
assert!(md.contains(" Stray paragraph"), "got: {md}");
assert!(!md.contains("adf-unsupported"), "got: {md}");
}
/// Issue #489: task item with inline text AND indented sub-content.
/// Covers the parser's `Some` branch when appending nested blocks to
/// an existing content vec.
#[test]
fn task_item_with_text_and_nested_sub_content() {
let md = "- [ ] Parent task\n - [ ] Sub task\n";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content[0].node_type, "taskList");
let items = doc.content[0].content.as_ref().unwrap();
// Issue #506: the nested taskList is a sibling of the taskItem,
// not a child — matching ADF's canonical structure.
assert_eq!(items.len(), 2, "got: {items:?}");
let parent = &items[0];
assert_eq!(parent.attrs.as_ref().unwrap()["state"], "TODO");
let parent_content = parent.content.as_ref().unwrap();
assert_eq!(parent_content[0].text.as_deref(), Some("Parent task"));
// Second item: nested taskList (sibling)
assert_eq!(items[1].node_type, "taskList");
let nested = items[1].content.as_ref().unwrap();
assert_eq!(nested.len(), 1);
assert_eq!(nested[0].attrs.as_ref().unwrap()["state"], "TODO");
}
/// Issue #489: empty task item with non-taskList sub-content (e.g. a
/// paragraph). Exercises the `None` branch when the sub-content does
/// not qualify for container-unwrap.
#[test]
fn task_item_empty_with_non_tasklist_sub_content() {
let md = "- [ ] \n Some paragraph text\n";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content[0].node_type, "taskList");
let items = doc.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 1);
let item = &items[0];
assert_eq!(item.attrs.as_ref().unwrap()["state"], "TODO");
let content = item.content.as_ref().unwrap();
// Sub-content is a paragraph (not unwrapped since it's not a taskList)
assert_eq!(content[0].node_type, "paragraph");
}
/// Issue #489: single nested taskItem (edge case — only one child).
#[test]
fn adf_nested_task_item_single_child() {
let json = r#"{
"type": "doc",
"version": 1,
"content": [{
"type": "taskList",
"attrs": {"localId": ""},
"content": [{
"type": "taskItem",
"attrs": {"localId": "", "state": "TODO"},
"content": [{
"type": "taskItem",
"attrs": {"localId": "", "state": "DONE"},
"content": [{"type": "text", "text": "Only child"}]
}]
}]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains(" - [x] Only child"), "got: {md}");
assert!(!md.contains("adf-unsupported"), "got: {md}");
}
/// Issue #506: nested taskList as direct child of outer taskList is
/// rendered indented so it round-trips back as taskList, not taskItem.
#[test]
fn adf_nested_tasklist_sibling_renders_indented() {
let json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "taskList",
"attrs": {"localId": ""},
"content": [
{
"type": "taskItem",
"attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000001", "state": "TODO"},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
},
{
"type": "taskList",
"attrs": {"localId": ""},
"content": [{
"type": "taskItem",
"attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000002", "state": "TODO"},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
}]
},
{
"type": "taskItem",
"attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000003", "state": "TODO"},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
}
]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
// The nested taskList should be indented under the preceding item.
assert!(md.contains("- [ ] parent task one"), "got: {md}");
assert!(md.contains(" - [ ] nested sub-task"), "got: {md}");
assert!(md.contains("- [ ] parent task two"), "got: {md}");
}
/// Issue #506: round-trip preserves nested taskList type.
#[test]
fn round_trip_nested_tasklist_preserves_type() {
let json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "taskList",
"attrs": {"localId": ""},
"content": [
{
"type": "taskItem",
"attrs": {"localId": "", "state": "TODO"},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
},
{
"type": "taskList",
"attrs": {"localId": ""},
"content": [{
"type": "taskItem",
"attrs": {"localId": "", "state": "TODO"},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
}]
},
{
"type": "taskItem",
"attrs": {"localId": "", "state": "TODO"},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
}
]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt_doc = markdown_to_adf(&md).unwrap();
// The outer taskList should still be present.
assert_eq!(rt_doc.content[0].node_type, "taskList");
let items = rt_doc.content[0].content.as_ref().unwrap();
// The nested taskList is a sibling of the taskItem nodes,
// matching the original ADF structure (issue #506).
assert_eq!(items.len(), 3, "got: {items:?}");
assert_eq!(items[0].node_type, "taskItem");
assert_eq!(
items[1].node_type, "taskList",
"nested taskList should survive round-trip"
);
assert_eq!(items[2].node_type, "taskItem");
let nested_items = items[1].content.as_ref().unwrap();
assert_eq!(nested_items[0].attrs.as_ref().unwrap()["state"], "TODO");
}
/// Issue #506: nested taskList with DONE state preserves checkbox.
#[test]
fn adf_nested_tasklist_done_state() {
let json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "taskList",
"attrs": {"localId": ""},
"content": [
{
"type": "taskItem",
"attrs": {"localId": "", "state": "TODO"},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
},
{
"type": "taskList",
"attrs": {"localId": ""},
"content": [{
"type": "taskItem",
"attrs": {"localId": "", "state": "DONE"},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "done child"}]}]
}]
}
]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains(" - [x] done child"), "got: {md}");
// Round-trip preserves DONE state — nested taskList is a sibling.
let rt_doc = markdown_to_adf(&md).unwrap();
let items = rt_doc.content[0].content.as_ref().unwrap();
assert_eq!(
items[1].node_type, "taskList",
"nested taskList should survive round-trip"
);
let nested_item = &items[1].content.as_ref().unwrap()[0];
assert_eq!(nested_item.attrs.as_ref().unwrap()["state"], "DONE");
}
/// Issue #506: multiple nested taskLists at the same level.
#[test]
fn adf_multiple_nested_tasklists() {
let json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "taskList",
"attrs": {"localId": ""},
"content": [
{
"type": "taskItem",
"attrs": {"localId": "", "state": "TODO"},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "first parent"}]}]
},
{
"type": "taskList",
"attrs": {"localId": ""},
"content": [{
"type": "taskItem",
"attrs": {"localId": "", "state": "TODO"},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "child A"}]}]
}]
},
{
"type": "taskItem",
"attrs": {"localId": "", "state": "TODO"},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "second parent"}]}]
},
{
"type": "taskList",
"attrs": {"localId": ""},
"content": [{
"type": "taskItem",
"attrs": {"localId": "", "state": "DONE"},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "child B"}]}]
}]
}
]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("- [ ] first parent"), "got: {md}");
assert!(md.contains(" - [ ] child A"), "got: {md}");
assert!(md.contains("- [ ] second parent"), "got: {md}");
assert!(md.contains(" - [x] child B"), "got: {md}");
}
/// Issue #506: second round-trip is stable (idempotent after first
/// structural normalisation).
#[test]
fn round_trip_nested_tasklist_stable() {
let json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "taskList",
"attrs": {"localId": ""},
"content": [
{
"type": "taskItem",
"attrs": {"localId": "", "state": "TODO"},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
},
{
"type": "taskList",
"attrs": {"localId": ""},
"content": [{
"type": "taskItem",
"attrs": {"localId": "", "state": "TODO"},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "child"}]}]
}]
}
]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(json).unwrap();
// First round-trip.
let md1 = adf_to_markdown(&doc).unwrap();
let rt1 = markdown_to_adf(&md1).unwrap();
// Second round-trip.
let md2 = adf_to_markdown(&rt1).unwrap();
let rt2 = markdown_to_adf(&md2).unwrap();
// Markdown output should be identical after first normalisation.
assert_eq!(md1, md2, "markdown should be stable across round-trips");
// ADF structure should also be stable.
let rt1_json = serde_json::to_string(&rt1).unwrap();
let rt2_json = serde_json::to_string(&rt2).unwrap();
assert_eq!(
rt1_json, rt2_json,
"ADF should be stable across round-trips"
);
}
/// Issue #506: task item with text and mixed indented sub-content
/// (taskList + non-taskList block). Exercises the `child_nodes` branch
/// where non-taskList blocks stay as children of the taskItem while
/// taskLists are promoted to siblings.
#[test]
fn task_item_mixed_sub_content_splits_siblings() {
let md = "- [ ] Parent task\n - [ ] Sub task\n Some paragraph\n";
let doc = markdown_to_adf(md).unwrap();
let items = doc.content[0].content.as_ref().unwrap();
// taskItem + sibling taskList
assert_eq!(items.len(), 2, "got: {items:?}");
assert_eq!(items[0].node_type, "taskItem");
let parent_content = items[0].content.as_ref().unwrap();
// Inline text + paragraph block (the non-taskList sub-content)
assert!(
parent_content.iter().any(|n| n.node_type == "paragraph"),
"non-taskList sub-content should stay as child: {parent_content:?}"
);
// Sibling taskList
assert_eq!(items[1].node_type, "taskList");
}
/// Issue #506: empty task item with mixed indented sub-content hits the
/// `None` arm of the `task.content` match when promoting taskLists to
/// siblings.
#[test]
fn empty_task_item_mixed_sub_content_none_arm() {
let md = "- [ ] \n Some paragraph\n - [ ] Sub task\n";
let doc = markdown_to_adf(md).unwrap();
let items = doc.content[0].content.as_ref().unwrap();
// taskItem (with paragraph child) + sibling taskList
assert_eq!(items.len(), 2, "got: {items:?}");
assert_eq!(items[0].node_type, "taskItem");
let parent_content = items[0].content.as_ref().unwrap();
assert!(
parent_content.iter().any(|n| n.node_type == "paragraph"),
"paragraph should be assigned to taskItem: {parent_content:?}"
);
assert_eq!(items[1].node_type, "taskList");
}
/// Issue #506: task item with text and only non-taskList sub-content
/// (no sibling taskLists). Exercises the fall-through path where
/// `sibling_task_lists` is empty and child_nodes are appended to
/// the existing task content (Some arm).
#[test]
fn task_item_text_with_non_tasklist_sub_content_only() {
let md = "- [ ] My task\n Extra paragraph content\n";
let doc = markdown_to_adf(md).unwrap();
let items = doc.content[0].content.as_ref().unwrap();
// Single taskItem — no sibling taskLists to extract.
assert_eq!(items.len(), 1, "got: {items:?}");
assert_eq!(items[0].node_type, "taskItem");
let content = items[0].content.as_ref().unwrap();
// Inline text + sub-paragraph
assert!(
content.iter().any(|n| n.node_type == "paragraph"),
"paragraph sub-content should be a child of taskItem: {content:?}"
);
}
/// Covers the else branch in render_list_item_content where the first
/// child of a list item is a block node (not paragraph, not inline).
#[test]
fn adf_list_item_leading_block_node() {
let json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "bulletList",
"content": [{
"type": "listItem",
"content": [{
"type": "codeBlock",
"attrs": {"language": "rust"},
"content": [{"type": "text", "text": "let x = 1;"}]
}]
}]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("```rust"), "got: {md}");
assert!(md.contains("let x = 1;"), "got: {md}");
// Continuation lines must be indented so the block stays inside
// the list item on round-trip (issue #511).
for line in md.lines() {
if line.starts_with("- ") {
continue; // first line with list marker
}
if line.trim().is_empty() {
continue;
}
assert!(
line.starts_with(" "),
"continuation line not indented: {line:?}"
);
}
}
/// Round-trip a codeBlock inside a listItem whose content contains a
/// backtick character — the exact reproducer from issue #511.
#[test]
fn code_block_in_list_item_backtick_roundtrip() {
let json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "bulletList",
"content": [{
"type": "listItem",
"content": [{
"type": "codeBlock",
"attrs": {"language": ""},
"content": [{"type": "text", "text": "error: some value with a backtick ` at end"}]
}]
}]
}]
}"#;
let original: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&original).unwrap();
let roundtripped = markdown_to_adf(&md).unwrap();
let list = &roundtripped.content[0];
assert_eq!(list.node_type, "bulletList", "top node: {}", list.node_type);
let item = &list.content.as_ref().unwrap()[0];
let first_child = &item.content.as_ref().unwrap()[0];
assert_eq!(
first_child.node_type, "codeBlock",
"expected codeBlock, got: {}",
first_child.node_type
);
let text = first_child.content.as_ref().unwrap()[0]
.text
.as_deref()
.unwrap();
assert_eq!(text, "error: some value with a backtick ` at end");
}
/// Code block with language tag inside a list item round-trips.
#[test]
fn code_block_with_language_in_list_item_roundtrip() {
let json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "bulletList",
"content": [{
"type": "listItem",
"content": [{
"type": "codeBlock",
"attrs": {"language": "rust"},
"content": [{"type": "text", "text": "fn main() {\n println!(\"hello\");\n}"}]
}]
}]
}]
}"#;
let original: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&original).unwrap();
let roundtripped = markdown_to_adf(&md).unwrap();
let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
let code = &item.content.as_ref().unwrap()[0];
assert_eq!(code.node_type, "codeBlock");
let lang = code
.attrs
.as_ref()
.and_then(|a| a.get("language"))
.and_then(serde_json::Value::as_str)
.unwrap_or("");
assert_eq!(lang, "rust");
let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
assert!(text.contains("println!"), "code content: {text}");
}
/// Code block in an ordered list item round-trips correctly.
#[test]
fn code_block_in_ordered_list_item_roundtrip() {
let json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "orderedList",
"attrs": {"order": 1},
"content": [{
"type": "listItem",
"content": [{
"type": "codeBlock",
"attrs": {"language": ""},
"content": [{"type": "text", "text": "backtick ` here"}]
}]
}]
}]
}"#;
let original: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&original).unwrap();
let roundtripped = markdown_to_adf(&md).unwrap();
let list = &roundtripped.content[0];
assert_eq!(list.node_type, "orderedList");
let item = &list.content.as_ref().unwrap()[0];
let code = &item.content.as_ref().unwrap()[0];
assert_eq!(code.node_type, "codeBlock");
let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
assert_eq!(text, "backtick ` here");
}
/// A list item with a code block followed by a paragraph round-trips.
#[test]
fn code_block_then_paragraph_in_list_item() {
let json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "bulletList",
"content": [{
"type": "listItem",
"content": [
{
"type": "codeBlock",
"attrs": {"language": ""},
"content": [{"type": "text", "text": "code with ` backtick"}]
},
{
"type": "paragraph",
"content": [{"type": "text", "text": "description"}]
}
]
}]
}]
}"#;
let original: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&original).unwrap();
let roundtripped = markdown_to_adf(&md).unwrap();
let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
let children = item.content.as_ref().unwrap();
assert_eq!(children[0].node_type, "codeBlock");
assert_eq!(children[1].node_type, "paragraph");
}
/// Multiple backticks in code block content round-trip.
#[test]
fn code_block_multiple_backticks_in_list_item() {
let json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "bulletList",
"content": [{
"type": "listItem",
"content": [{
"type": "codeBlock",
"attrs": {"language": ""},
"content": [{"type": "text", "text": "a ` b `` c ``` d"}]
}]
}]
}]
}"#;
let original: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&original).unwrap();
let roundtripped = markdown_to_adf(&md).unwrap();
let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
let code = &item.content.as_ref().unwrap()[0];
assert_eq!(code.node_type, "codeBlock");
let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
assert_eq!(text, "a ` b `` c ``` d");
}
/// Media as the first child of a list item with a subsequent paragraph
/// exercises the media + sub_lines branch in `parse_list_item_first_line`.
#[test]
fn media_first_child_with_sub_content_in_list_item() {
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
{"type":"listItem","content":[
{"type":"mediaSingle","attrs":{"layout":"center"},
"content":[{"type":"media","attrs":{"type":"file","id":"img-99","collection":"col-x","height":50,"width":100}}]},
{"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let item = &rt.content[0].content.as_ref().unwrap()[0];
let children = item.content.as_ref().unwrap();
assert_eq!(
children.len(),
2,
"expected 2 children, got {}",
children.len()
);
assert_eq!(children[0].node_type, "mediaSingle");
let media = &children[0].content.as_ref().unwrap()[0];
assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-99");
assert_eq!(children[1].node_type, "paragraph");
}
#[test]
fn inline_bold() {
let doc = markdown_to_adf("Some **bold** text").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert!(content.len() >= 3);
let bold_node = &content[1];
assert_eq!(bold_node.text.as_deref(), Some("bold"));
let marks = bold_node.marks.as_ref().unwrap();
assert_eq!(marks[0].mark_type, "strong");
}
#[test]
fn inline_italic() {
let doc = markdown_to_adf("Some *italic* text").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
let italic_node = &content[1];
assert_eq!(italic_node.text.as_deref(), Some("italic"));
let marks = italic_node.marks.as_ref().unwrap();
assert_eq!(marks[0].mark_type, "em");
}
#[test]
fn inline_code() {
let doc = markdown_to_adf("Use `code` here").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
let code_node = &content[1];
assert_eq!(code_node.text.as_deref(), Some("code"));
let marks = code_node.marks.as_ref().unwrap();
assert_eq!(marks[0].mark_type, "code");
}
/// Issue #578: a code-marked text with an internal backtick must be
/// emitted using double-backtick delimiters so it round-trips as a
/// single node rather than being split on the inner backtick.
#[test]
fn inline_code_with_backtick_emitted_with_double_delimiters() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![
AdfNode::text("Run "),
AdfNode::text_with_marks(
"ADD `custom_threshold` TEXT NOT NULL",
vec![AdfMark::code()],
),
AdfNode::text(" to update the schema."),
])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("``ADD `custom_threshold` TEXT NOT NULL``"),
"expected double-backtick delimiters, got: {md}"
);
}
/// Issue #578: double-backtick delimited code spans parse as a single
/// code-marked text node that preserves the embedded single backticks.
#[test]
fn inline_code_double_backtick_delimiters_parse() {
let doc = markdown_to_adf("Run ``ADD `custom_threshold` TEXT NOT NULL`` now").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 3, "content: {content:?}");
let code_node = &content[1];
assert_eq!(
code_node.text.as_deref(),
Some("ADD `custom_threshold` TEXT NOT NULL")
);
let marks = code_node.marks.as_ref().unwrap();
assert_eq!(marks[0].mark_type, "code");
}
/// Issue #578: the full reproducer — a code-marked text with inner
/// backticks survives ADF → JFM → ADF round-trip intact.
#[test]
fn inline_code_with_backtick_roundtrip() {
let json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "paragraph",
"content": [
{"type": "text", "text": "Run "},
{
"type": "text",
"text": "ADD `custom_threshold` TEXT NOT NULL",
"marks": [{"type": "code"}]
},
{"type": "text", "text": " to update the schema."}
]
}]
}"#;
let original: AdfDocument = serde_json::from_str(json).unwrap();
let md = adf_to_markdown(&original).unwrap();
let roundtripped = markdown_to_adf(&md).unwrap();
let para = &roundtripped.content[0];
let children = para.content.as_ref().unwrap();
assert_eq!(children.len(), 3, "expected 3 children, got: {children:?}");
assert_eq!(children[0].text.as_deref(), Some("Run "));
assert_eq!(
children[1].text.as_deref(),
Some("ADD `custom_threshold` TEXT NOT NULL")
);
let marks = children[1].marks.as_ref().unwrap();
assert_eq!(marks.len(), 1);
assert_eq!(marks[0].mark_type, "code");
assert_eq!(children[2].text.as_deref(), Some(" to update the schema."));
}
/// A code-marked text containing a run of two backticks should be
/// emitted with triple-backtick delimiters and round-trip intact —
/// the first line of the paragraph also starts with the fence so this
/// exercises the info-string-with-backtick fence-opener rejection.
#[test]
fn inline_code_with_double_backtick_roundtrip() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"x `` y",
vec![AdfMark::code()],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
let roundtripped = markdown_to_adf(&md).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0].text.as_deref(), Some("x `` y"));
let marks = content[0].marks.as_ref().unwrap();
assert_eq!(marks[0].mark_type, "code");
}
/// A code-marked text that begins with a backtick must be padded on
/// both sides so the CommonMark space-stripping rule reconstructs it.
#[test]
fn inline_code_leading_backtick_roundtrip() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"`start",
vec![AdfMark::code()],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
let roundtripped = markdown_to_adf(&md).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(content[0].text.as_deref(), Some("`start"));
assert_eq!(content[0].marks.as_ref().unwrap()[0].mark_type, "code");
}
/// A code-marked text that ends with a backtick must also survive.
#[test]
fn inline_code_trailing_backtick_roundtrip() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"end`",
vec![AdfMark::code()],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
let roundtripped = markdown_to_adf(&md).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(content[0].text.as_deref(), Some("end`"));
}
/// Content that both begins and ends with a space (but is not all
/// spaces) needs padding so the stripping rule leaves it intact.
#[test]
fn inline_code_space_padded_content_roundtrip() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
" foo ",
vec![AdfMark::code()],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
let roundtripped = markdown_to_adf(&md).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(content[0].text.as_deref(), Some(" foo "));
}
/// All-space content must round-trip without the stripping rule
/// kicking in (per CommonMark: all-space content is not stripped).
#[test]
fn inline_code_all_spaces_roundtrip() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
" ",
vec![AdfMark::code()],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
let roundtripped = markdown_to_adf(&md).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(content[0].text.as_deref(), Some(" "));
}
/// A code+link mark where the code text contains a backtick must also
/// round-trip — verifies the link branch of code-span rendering.
#[test]
fn inline_code_with_link_and_backtick_roundtrip() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"fn `inner`",
vec![AdfMark::code(), AdfMark::link("https://example.com")],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("`` fn `inner` ``"),
"expected padded double-backtick delimiters inside link, got: {md}"
);
let roundtripped = markdown_to_adf(&md).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(content[0].text.as_deref(), Some("fn `inner`"));
let mark_types: Vec<&str> = content[0]
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert!(mark_types.contains(&"code"));
assert!(mark_types.contains(&"link"));
}
/// Unmatched opening backticks must not be parsed as a code span.
#[test]
fn inline_code_unmatched_run_is_plain_text() {
let doc = markdown_to_adf("foo ``bar baz").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0].text.as_deref(), Some("foo ``bar baz"));
assert!(content[0].marks.is_none());
}
/// Mismatched delimiter lengths must not form a code span. Per
/// CommonMark the opening 2-backtick run and the trailing 1-backtick
/// run never form a valid code span and the characters stay literal.
#[test]
fn inline_code_mismatched_delimiters_is_plain_text() {
let doc = markdown_to_adf("``foo` bar").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0].text.as_deref(), Some("``foo` bar"));
assert!(content[0].marks.is_none());
}
#[test]
fn inline_code_delimiter_chooses_correct_length() {
assert_eq!(inline_code_delimiter("no ticks"), (1, false));
assert_eq!(inline_code_delimiter("one ` here"), (2, false));
assert_eq!(inline_code_delimiter("two `` here"), (3, false));
assert_eq!(inline_code_delimiter("three ``` here"), (4, false));
assert_eq!(inline_code_delimiter("`leading"), (2, true));
assert_eq!(inline_code_delimiter("trailing`"), (2, true));
assert_eq!(inline_code_delimiter(" foo "), (1, true));
assert_eq!(inline_code_delimiter(" "), (1, false));
assert_eq!(inline_code_delimiter(" "), (1, false));
assert_eq!(inline_code_delimiter(" foo"), (1, false));
}
#[test]
fn try_parse_inline_code_strips_paired_spaces() {
let (end, content) = try_parse_inline_code("`` `foo` ``", 0).unwrap();
assert_eq!(end, 11);
assert_eq!(content, "`foo`");
}
#[test]
fn try_parse_inline_code_all_space_content_is_preserved() {
let (_end, content) = try_parse_inline_code("` `", 0).unwrap();
assert_eq!(content, " ");
}
#[test]
fn try_parse_inline_code_single_run_matches_first_close() {
let (end, content) = try_parse_inline_code("`foo` tail", 0).unwrap();
assert_eq!(end, 5);
assert_eq!(content, "foo");
}
#[test]
fn try_parse_inline_code_no_match_returns_none() {
assert!(try_parse_inline_code("``unmatched", 0).is_none());
assert!(try_parse_inline_code("plain text", 0).is_none());
}
#[test]
fn is_code_fence_opener_rejects_info_with_backtick() {
assert!(is_code_fence_opener("```"));
assert!(is_code_fence_opener("```rust"));
assert!(is_code_fence_opener("```\"\""));
assert!(!is_code_fence_opener("```x `` y```"));
assert!(!is_code_fence_opener("``not-enough"));
assert!(!is_code_fence_opener("no fence"));
}
#[test]
fn inline_strikethrough() {
let doc = markdown_to_adf("Some ~~deleted~~ text").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
let strike_node = &content[1];
assert_eq!(strike_node.text.as_deref(), Some("deleted"));
let marks = strike_node.marks.as_ref().unwrap();
assert_eq!(marks[0].mark_type, "strike");
}
#[test]
fn inline_link() {
let doc = markdown_to_adf("Click [here](https://example.com) now").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
let link_node = &content[1];
assert_eq!(link_node.text.as_deref(), Some("here"));
let marks = link_node.marks.as_ref().unwrap();
assert_eq!(marks[0].mark_type, "link");
}
#[test]
fn block_image() {
let md = "";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content[0].node_type, "mediaSingle");
}
#[test]
fn table() {
let md = "| A | B |\n| --- | --- |\n| 1 | 2 |";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content[0].node_type, "table");
let rows = doc.content[0].content.as_ref().unwrap();
assert_eq!(rows.len(), 2); // header + 1 body row
}
// ── adf_to_markdown tests ───────────────────────────────────────
#[test]
fn adf_paragraph_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text("Hello world")])],
};
let md = adf_to_markdown(&doc).unwrap();
assert_eq!(md.trim(), "Hello world");
}
#[test]
fn adf_heading_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::heading(2, vec![AdfNode::text("Title")])],
};
let md = adf_to_markdown(&doc).unwrap();
assert_eq!(md.trim(), "## Title");
}
#[test]
fn adf_bold_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![
AdfNode::text("Normal "),
AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
AdfNode::text(" text"),
])],
};
let md = adf_to_markdown(&doc).unwrap();
assert_eq!(md.trim(), "Normal **bold** text");
}
#[test]
fn adf_code_block_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::code_block(Some("rust"), "let x = 1;")],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("```rust"));
assert!(md.contains("let x = 1;"));
assert!(md.contains("```"));
}
#[test]
fn adf_rule_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::rule()],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("---"));
}
#[test]
fn adf_bullet_list_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::bullet_list(vec![
AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("A")])]),
AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("B")])]),
])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("- A"));
assert!(md.contains("- B"));
}
#[test]
fn adf_link_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"click",
vec![AdfMark::link("https://example.com")],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert_eq!(md.trim(), "[click](https://example.com)");
}
#[test]
fn unsupported_block_preserved_as_json() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode {
node_type: "unknownBlock".to_string(),
attrs: Some(serde_json::json!({"key": "value"})),
content: None,
text: None,
marks: None,
local_id: None,
parameters: None,
}],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("```adf-unsupported"));
assert!(md.contains("\"unknownBlock\""));
}
#[test]
fn unsupported_block_round_trips() {
let original = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode {
node_type: "unknownBlock".to_string(),
attrs: Some(serde_json::json!({"key": "value"})),
content: None,
text: None,
marks: None,
local_id: None,
parameters: None,
}],
};
let md = adf_to_markdown(&original).unwrap();
let restored = markdown_to_adf(&md).unwrap();
assert_eq!(restored.content[0].node_type, "unknownBlock");
assert_eq!(restored.content[0].attrs.as_ref().unwrap()["key"], "value");
}
// ── Round-trip tests ────────────────────────────────────────────
#[test]
fn round_trip_simple_document() {
let md = "# Hello\n\nSome text with **bold** and *italic*.\n\n- Item 1\n- Item 2\n";
let adf = markdown_to_adf(md).unwrap();
let restored = adf_to_markdown(&adf).unwrap();
assert!(restored.contains("# Hello"));
assert!(restored.contains("**bold**"));
assert!(restored.contains("*italic*"));
assert!(restored.contains("- Item 1"));
assert!(restored.contains("- Item 2"));
}
#[test]
fn round_trip_code_block() {
let md = "```python\nprint('hello')\n```\n";
let adf = markdown_to_adf(md).unwrap();
let restored = adf_to_markdown(&adf).unwrap();
assert!(restored.contains("```python"));
assert!(restored.contains("print('hello')"));
}
#[test]
fn round_trip_code_block_no_attrs() {
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"codeBlock","content":[{"type":"text","text":"plain code"}]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
assert!(doc.content[0].attrs.is_none());
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
assert!(round_tripped.content[0].attrs.is_none());
}
#[test]
fn round_trip_code_block_empty_language() {
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"codeBlock","attrs":{"language":""},"content":[{"type":"text","text":"simple code block no backtick"}]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let attrs = doc.content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["language"], "");
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
assert_eq!(rt_attrs["language"], "");
}
#[test]
fn round_trip_code_block_with_language() {
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"codeBlock","attrs":{"language":"python"},"content":[{"type":"text","text":"print('hi')"}]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
assert_eq!(rt_attrs["language"], "python");
}
#[test]
fn multiple_paragraphs() {
let md = "First paragraph.\n\nSecond paragraph.\n";
let adf = markdown_to_adf(md).unwrap();
assert_eq!(adf.content.len(), 2);
assert_eq!(adf.content[0].node_type, "paragraph");
assert_eq!(adf.content[1].node_type, "paragraph");
}
// ── Additional markdown_to_adf tests ───────────────────────────────
#[test]
fn horizontal_rule_underscores() {
let doc = markdown_to_adf("___").unwrap();
assert_eq!(doc.content[0].node_type, "rule");
}
#[test]
fn not_a_horizontal_rule_too_short() {
let doc = markdown_to_adf("--").unwrap();
assert_eq!(doc.content[0].node_type, "paragraph");
}
#[test]
fn bullet_list_star_marker() {
let md = "* Apple\n* Banana";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content[0].node_type, "bulletList");
let items = doc.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 2);
}
#[test]
fn bullet_list_plus_marker() {
let md = "+ One\n+ Two";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content[0].node_type, "bulletList");
}
#[test]
fn ordered_list_non_one_start() {
let md = "5. Fifth\n6. Sixth";
let doc = markdown_to_adf(md).unwrap();
let node = &doc.content[0];
assert_eq!(node.node_type, "orderedList");
let attrs = node.attrs.as_ref().unwrap();
assert_eq!(attrs["order"], 5);
}
#[test]
fn ordered_list_start_at_one_omits_order_attr() {
// Issue #547: order=1 is the default and must be omitted from attrs
// so that ADF→JFM→ADF round-trip is byte-identical for the common
// case where the source ADF has no attrs object on orderedList.
let md = "1. First\n2. Second";
let doc = markdown_to_adf(md).unwrap();
let node = &doc.content[0];
assert_eq!(node.node_type, "orderedList");
assert!(
node.attrs.is_none(),
"attrs should be omitted when order=1, got: {:?}",
node.attrs
);
}
#[test]
fn blockquote_bare_marker() {
// ">" with no space after
let md = ">quoted text";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content[0].node_type, "blockquote");
}
#[test]
fn image_no_alt() {
let md = "";
let doc = markdown_to_adf(md).unwrap();
let node = &doc.content[0];
assert_eq!(node.node_type, "mediaSingle");
// media child should have no alt attr
let media = &node.content.as_ref().unwrap()[0];
let attrs = media.attrs.as_ref().unwrap();
assert!(attrs.get("alt").is_none());
}
#[test]
fn image_with_alt() {
let md = "";
let doc = markdown_to_adf(md).unwrap();
let media = &doc.content[0].content.as_ref().unwrap()[0];
let attrs = media.attrs.as_ref().unwrap();
assert_eq!(attrs["alt"], "A photo");
}
#[test]
fn table_multi_body_rows() {
let md = "| H1 | H2 |\n| --- | --- |\n| a | b |\n| c | d |";
let doc = markdown_to_adf(md).unwrap();
let rows = doc.content[0].content.as_ref().unwrap();
assert_eq!(rows.len(), 3); // header + 2 body rows
// First row cells are tableHeader
let header_cells = rows[0].content.as_ref().unwrap();
assert_eq!(header_cells[0].node_type, "tableHeader");
// Body row cells are tableCell
let body_cells = rows[1].content.as_ref().unwrap();
assert_eq!(body_cells[0].node_type, "tableCell");
}
#[test]
fn table_no_separator_is_not_table() {
// Pipe characters without a separator row should not parse as table
let md = "| not | a table |";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content[0].node_type, "paragraph");
}
#[test]
fn inline_underscore_bold() {
let doc = markdown_to_adf("Some __bold__ text").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
let bold_node = &content[1];
assert_eq!(bold_node.text.as_deref(), Some("bold"));
let marks = bold_node.marks.as_ref().unwrap();
assert_eq!(marks[0].mark_type, "strong");
}
#[test]
fn inline_underscore_italic() {
let doc = markdown_to_adf("Some _italic_ text").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
let italic_node = &content[1];
assert_eq!(italic_node.text.as_deref(), Some("italic"));
let marks = italic_node.marks.as_ref().unwrap();
assert_eq!(marks[0].mark_type, "em");
}
#[test]
fn intraword_underscore_not_emphasis() {
// Single intraword underscore pair: do_something_useful
let doc = markdown_to_adf("call do_something_useful now").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1, "should be a single text node");
assert_eq!(
content[0].text.as_deref(),
Some("call do_something_useful now")
);
assert!(content[0].marks.is_none());
}
#[test]
fn intraword_underscore_multiple() {
// Multiple intraword underscores: a_b_c_d
let doc = markdown_to_adf("use a_b_c_d here").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0].text.as_deref(), Some("use a_b_c_d here"));
assert!(content[0].marks.is_none());
}
#[test]
fn intraword_double_underscore_not_bold() {
// Intraword double underscores: foo__bar__baz
let doc = markdown_to_adf("foo__bar__baz").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0].text.as_deref(), Some("foo__bar__baz"));
assert!(content[0].marks.is_none());
}
#[test]
fn intraword_triple_underscore_not_bold_italic() {
// Intraword triple underscores: x___y___z
let doc = markdown_to_adf("x___y___z").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0].text.as_deref(), Some("x___y___z"));
assert!(content[0].marks.is_none());
}
#[test]
fn underscore_emphasis_still_works_with_spaces() {
// Normal emphasis with spaces around delimiters should still work
let doc = markdown_to_adf("some _italic_ here").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 3);
assert_eq!(content[1].text.as_deref(), Some("italic"));
let marks = content[1].marks.as_ref().unwrap();
assert_eq!(marks[0].mark_type, "em");
}
#[test]
fn underscore_bold_still_works_with_spaces() {
// Normal bold with spaces around delimiters should still work
let doc = markdown_to_adf("some __bold__ here").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 3);
assert_eq!(content[1].text.as_deref(), Some("bold"));
let marks = content[1].marks.as_ref().unwrap();
assert_eq!(marks[0].mark_type, "strong");
}
#[test]
fn intraword_underscore_closing_only() {
// Opening _ is valid (preceded by space) but closing _ is intraword: _foo_bar
let doc = markdown_to_adf("_foo_bar").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0].text.as_deref(), Some("_foo_bar"));
}
#[test]
fn intraword_double_underscore_closing_only() {
// Opening __ is valid (at start) but closing __ is intraword: __foo__bar
let doc = markdown_to_adf("__foo__bar").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0].text.as_deref(), Some("__foo__bar"));
}
#[test]
fn intraword_triple_underscore_closing_only() {
// Opening ___ is valid (at start) but closing ___ is intraword: ___foo___bar
let doc = markdown_to_adf("___foo___bar").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0].text.as_deref(), Some("___foo___bar"));
}
#[test]
fn asterisk_emphasis_unaffected_by_intraword_fix() {
// Asterisks should still work for intraword emphasis (CommonMark allows this)
let doc = markdown_to_adf("foo*bar*baz").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
// Asterisks CAN be intraword emphasis per CommonMark
assert!(content.len() > 1 || content[0].marks.is_some());
}
#[test]
fn intraword_underscore_at_start_of_text() {
// Underscore at start of text is not intraword (no preceding alphanumeric)
let doc = markdown_to_adf("_italic_ word").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content[0].text.as_deref(), Some("italic"));
let marks = content[0].marks.as_ref().unwrap();
assert_eq!(marks[0].mark_type, "em");
}
#[test]
fn intraword_underscore_at_end_of_text() {
// Underscore at end of text is not intraword (no following alphanumeric)
let doc = markdown_to_adf("word _italic_").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
let last = content.last().unwrap();
assert_eq!(last.text.as_deref(), Some("italic"));
let marks = last.marks.as_ref().unwrap();
assert_eq!(marks[0].mark_type, "em");
}
#[test]
fn intraword_underscore_opening_only() {
// Opening underscore is intraword but closing is not: a_b c_d
// The first _ is intraword (a_b), so it shouldn't open emphasis
let doc = markdown_to_adf("a_b c_d").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0].text.as_deref(), Some("a_b c_d"));
}
#[test]
fn intraword_underscore_roundtrip() {
// The original reproducer from issue #438
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call the do_something_useful function"}]}]}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1, "should round-trip as a single text node");
assert_eq!(
content[0].text.as_deref(),
Some("call the do_something_useful function")
);
assert!(content[0].marks.is_none());
}
#[test]
fn asterisk_emphasis_roundtrip() {
// The original reproducer from issue #452
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status: *confirmed* and active"}]}]}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1, "should round-trip as a single text node");
assert_eq!(
content[0].text.as_deref(),
Some("Status: *confirmed* and active")
);
assert!(content[0].marks.is_none());
}
#[test]
fn double_asterisk_roundtrip() {
// **bold** delimiters in plain text should not become strong marks
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use **kwargs in Python"}]}]}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1, "should round-trip as a single text node");
assert_eq!(content[0].text.as_deref(), Some("Use **kwargs in Python"));
assert!(content[0].marks.is_none());
}
#[test]
fn asterisk_with_em_mark_roundtrip() {
// Text that already has an em mark should preserve both the mark and escaped content
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a*b","marks":[{"type":"em"}]}]}]}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
// Find the node with em mark
let em_node = content.iter().find(|n| {
n.marks
.as_ref()
.is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
});
assert!(em_node.is_some(), "should have an em-marked node");
assert_eq!(em_node.unwrap().text.as_deref(), Some("a*b"));
}
#[test]
fn lone_asterisk_roundtrip() {
// A single asterisk that cannot form emphasis should round-trip
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"rating: 5 * stars"}]}]}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1, "should round-trip as a single text node");
assert_eq!(content[0].text.as_deref(), Some("rating: 5 * stars"));
}
#[test]
fn escape_emphasis_markers_unit() {
assert_eq!(escape_emphasis_markers("hello"), "hello");
assert_eq!(escape_emphasis_markers("*bold*"), r"\*bold\*");
assert_eq!(escape_emphasis_markers("**strong**"), r"\*\*strong\*\*");
assert_eq!(escape_emphasis_markers("no stars"), "no stars");
assert_eq!(escape_emphasis_markers("a * b"), r"a \* b");
assert_eq!(escape_emphasis_markers(""), "");
}
#[test]
fn escape_emphasis_markers_underscore_intraword() {
// Intraword underscores (alnum on both sides within the node) are
// left as-is — the JFM parser will reject them as emphasis.
assert_eq!(escape_emphasis_markers("foo_bar"), "foo_bar");
assert_eq!(escape_emphasis_markers("a_b_c"), "a_b_c");
assert_eq!(escape_emphasis_markers("foo__bar"), "foo__bar");
assert_eq!(
escape_emphasis_markers("call do_something_useful"),
"call do_something_useful"
);
}
#[test]
fn escape_emphasis_markers_underscore_at_boundary() {
// Leading and trailing underscores get escaped — adjacent text nodes
// could supply the alphanumeric needed to close emphasis (issue #554).
assert_eq!(escape_emphasis_markers("_Action"), r"\_Action");
assert_eq!(escape_emphasis_markers("Action_"), r"Action\_");
assert_eq!(escape_emphasis_markers("_ "), r"\_ ");
assert_eq!(escape_emphasis_markers(" _"), r" \_");
assert_eq!(escape_emphasis_markers("_"), r"\_");
}
#[test]
fn escape_emphasis_markers_underscore_with_punctuation() {
// Underscores adjacent to punctuation (not alphanumeric) get escaped.
assert_eq!(escape_emphasis_markers("foo _bar"), r"foo \_bar");
assert_eq!(escape_emphasis_markers("foo_ bar"), r"foo\_ bar");
assert_eq!(escape_emphasis_markers("(_x_)"), r"(\_x\_)");
}
#[test]
fn find_unescaped_skips_backslash_escaped() {
// Escaped `**` should not be found
assert_eq!(find_unescaped(r"a\*\*b**", "**"), Some(6));
// No unescaped match at all
assert_eq!(find_unescaped(r"a\*\*b", "**"), None);
// Plain match without any escaping
assert_eq!(find_unescaped("a**b", "**"), Some(1));
// Empty haystack
assert_eq!(find_unescaped("", "**"), None);
}
#[test]
fn find_unescaped_char_skips_backslash_escaped() {
// Escaped `*` should not be found
assert_eq!(find_unescaped_char(r"a\*b*", b'*'), Some(4));
// No unescaped match at all
assert_eq!(find_unescaped_char(r"\*", b'*'), None);
// Plain match
assert_eq!(find_unescaped_char("a*b", b'*'), Some(1));
// Empty haystack
assert_eq!(find_unescaped_char("", b'*'), None);
}
#[test]
fn double_asterisk_in_strong_mark_roundtrip() {
// Text with ** inside a strong mark should preserve the literal **
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call **kwargs","marks":[{"type":"strong"}]}]}]}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
let strong_node = content.iter().find(|n| {
n.marks
.as_ref()
.is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
});
assert!(strong_node.is_some(), "should have a strong-marked node");
assert_eq!(strong_node.unwrap().text.as_deref(), Some("call **kwargs"));
}
#[test]
fn backtick_code_roundtrip() {
// The original reproducer from issue #453
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Set `max_retries` to 3 in the config"}]}]}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1, "should round-trip as a single text node");
assert_eq!(
content[0].text.as_deref(),
Some("Set `max_retries` to 3 in the config")
);
assert!(content[0].marks.is_none());
}
#[test]
fn multiple_backtick_spans_roundtrip() {
// Multiple backtick-delimited spans in a single text node
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use `foo` and `bar` together"}]}]}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1, "should round-trip as a single text node");
assert_eq!(
content[0].text.as_deref(),
Some("Use `foo` and `bar` together")
);
assert!(content[0].marks.is_none());
}
#[test]
fn lone_backtick_roundtrip() {
// A single backtick that cannot form a code span should round-trip
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use a ` character"}]}]}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1, "should round-trip as a single text node");
assert_eq!(content[0].text.as_deref(), Some("Use a ` character"));
assert!(content[0].marks.is_none());
}
#[test]
fn backtick_with_code_mark_roundtrip() {
// Text that already has a code mark should preserve both the mark and content
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"max_retries","marks":[{"type":"code"}]}]}]}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
assert_eq!(jfm.trim(), "`max_retries`");
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
let code_node = content.iter().find(|n| {
n.marks
.as_ref()
.is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
});
assert!(code_node.is_some(), "should have a code-marked node");
assert_eq!(code_node.unwrap().text.as_deref(), Some("max_retries"));
}
#[test]
fn backtick_with_em_mark_roundtrip() {
// Backtick inside em-marked text should preserve both
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"use `cfg`","marks":[{"type":"em"}]}]}]}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
let em_node = content.iter().find(|n| {
n.marks
.as_ref()
.is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
});
assert!(em_node.is_some(), "should have an em-marked node");
assert_eq!(em_node.unwrap().text.as_deref(), Some("use `cfg`"));
}
#[test]
fn escape_pipes_in_cell_unit() {
assert_eq!(escape_pipes_in_cell("hello"), "hello");
assert_eq!(escape_pipes_in_cell("a|b"), r"a\|b");
assert_eq!(escape_pipes_in_cell("|"), r"\|");
assert_eq!(escape_pipes_in_cell("|a|b|"), r"\|a\|b\|");
assert_eq!(escape_pipes_in_cell(""), "");
assert_eq!(
escape_pipes_in_cell("`parser.decode[T|json]`"),
r"`parser.decode[T\|json]`"
);
}
#[test]
fn escape_backticks_unit() {
assert_eq!(escape_backticks("hello"), "hello");
assert_eq!(escape_backticks("`code`"), r"\`code\`");
assert_eq!(escape_backticks("no ticks"), "no ticks");
assert_eq!(escape_backticks("a ` b"), r"a \` b");
assert_eq!(escape_backticks(""), "");
assert_eq!(escape_backticks("`a` and `b`"), r"\`a\` and \`b\`");
}
// ── Issue #477: backslash escaping ──────────────────────────────
#[test]
fn backslash_in_text_roundtrip() {
// The original reproducer from issue #477
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"The path is C:\\Users\\admin\\file.txt"}]}]}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1, "should round-trip as a single text node");
assert_eq!(
content[0].text.as_deref(),
Some(r"The path is C:\Users\admin\file.txt")
);
}
#[test]
fn backslash_emitted_as_double_backslash() {
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\b"}]}]}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
assert!(
jfm.contains(r"a\\b"),
"JFM should contain escaped backslash: {jfm}"
);
}
#[test]
fn consecutive_backslashes_roundtrip() {
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\\\b"}]}]}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(
content[0].text.as_deref(),
Some(r"a\\b"),
"consecutive backslashes should survive round-trip"
);
}
#[test]
fn backslash_with_strong_mark_roundtrip() {
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"strong"}]}]}]}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
let strong_node = content.iter().find(|n| {
n.marks
.as_ref()
.is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
});
assert!(strong_node.is_some(), "should have a strong-marked node");
assert_eq!(strong_node.unwrap().text.as_deref(), Some(r"C:\Users"));
}
#[test]
fn backslash_with_code_mark_not_escaped() {
// Code marks emit content verbatim — backslashes should NOT be escaped
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"code"}]}]}]}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
assert_eq!(jfm.trim(), r"`C:\Users`");
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
let code_node = content.iter().find(|n| {
n.marks
.as_ref()
.is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
});
assert!(code_node.is_some(), "should have a code-marked node");
assert_eq!(code_node.unwrap().text.as_deref(), Some(r"C:\Users"));
}
#[test]
fn backslash_before_special_chars_roundtrip() {
// Backslash before characters that are themselves escaped (* ` :)
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"\\*not bold\\*"}]}]}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(
content[0].text.as_deref(),
Some(r"\*not bold\*"),
"backslash before special char should survive round-trip"
);
}
#[test]
fn backslash_and_newline_in_text_roundtrip() {
// Text with both backslashes and embedded newlines
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\path\nline2"}]}]}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(
content[0].text.as_deref(),
Some("C:\\path\nline2"),
"backslash and newline should both survive round-trip"
);
}
#[test]
fn lone_backslash_roundtrip() {
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a \\ b"}]}]}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(content[0].text.as_deref(), Some(r"a \ b"));
}
#[test]
fn trailing_backslash_in_text_roundtrip() {
// A trailing backslash in text content (not a hardBreak) should round-trip
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\"}]}]}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(
content[0].text.as_deref(),
Some(r"end\"),
"trailing backslash should survive round-trip"
);
}
#[test]
fn escape_bare_urls_unit() {
assert_eq!(escape_bare_urls("hello"), "hello");
assert_eq!(escape_bare_urls(""), "");
assert_eq!(
escape_bare_urls("https://example.com"),
r"\https://example.com"
);
assert_eq!(
escape_bare_urls("http://example.com"),
r"\http://example.com"
);
assert_eq!(
escape_bare_urls("see https://a.com and https://b.com"),
r"see \https://a.com and \https://b.com"
);
// "http" without "://" is not a URL scheme — leave untouched
assert_eq!(escape_bare_urls("http header"), "http header");
assert_eq!(escape_bare_urls("https is secure"), "https is secure");
}
#[test]
fn heading_not_valid_without_space() {
// "#Title" without space should be a paragraph, not heading
let doc = markdown_to_adf("#Title").unwrap();
assert_eq!(doc.content[0].node_type, "paragraph");
}
#[test]
fn heading_level_too_high() {
// ####### (7 hashes) is not a valid heading
let doc = markdown_to_adf("####### Not a heading").unwrap();
assert_eq!(doc.content[0].node_type, "paragraph");
}
#[test]
fn empty_document() {
let doc = markdown_to_adf("").unwrap();
assert!(doc.content.is_empty());
}
#[test]
fn only_blank_lines() {
let doc = markdown_to_adf("\n\n\n").unwrap();
assert!(doc.content.is_empty());
}
#[test]
fn code_block_unterminated() {
// Code block without closing fence
let md = "```rust\nfn main() {}";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content[0].node_type, "codeBlock");
}
#[test]
fn mixed_document() {
let md = "# Title\n\nA paragraph.\n\n- Item\n\n```\ncode\n```\n\n> quote\n\n---\n\n1. numbered\n";
let doc = markdown_to_adf(md).unwrap();
let types: Vec<&str> = doc.content.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(
types,
vec![
"heading",
"paragraph",
"bulletList",
"codeBlock",
"blockquote",
"rule",
"orderedList",
]
);
}
// ── Additional adf_to_markdown tests ───────────────────────────────
#[test]
fn adf_ordered_list_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::ordered_list(
vec![
AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("First")])]),
AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("Second")])]),
],
None,
)],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("1. First"));
assert!(md.contains("2. Second"));
}
#[test]
fn adf_ordered_list_custom_start() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::ordered_list(
vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
AdfNode::text("Third"),
])])],
Some(3),
)],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("3. Third"));
}
#[test]
fn adf_blockquote_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::blockquote(vec![AdfNode::paragraph(vec![
AdfNode::text("A quote"),
])])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("> A quote"));
}
#[test]
fn adf_table_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::table(vec![
AdfNode::table_row(vec![
AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Name")])]),
AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Value")])]),
]),
AdfNode::table_row(vec![
AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a")])]),
AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("1")])]),
]),
])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("| Name | Value |"));
assert!(md.contains("| --- | --- |"));
assert!(md.contains("| a | 1 |"));
}
#[test]
fn adf_media_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::media_single(
"https://example.com/img.png",
Some("Alt"),
)],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains(""));
}
#[test]
fn adf_media_no_alt_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::media_single("https://example.com/img.png", None)],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains(""));
}
#[test]
fn adf_italic_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"emphasis",
vec![AdfMark::em()],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert_eq!(md.trim(), "*emphasis*");
}
#[test]
fn adf_strikethrough_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"deleted",
vec![AdfMark::strike()],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert_eq!(md.trim(), "~~deleted~~");
}
#[test]
fn adf_inline_code_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"code",
vec![AdfMark::code()],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert_eq!(md.trim(), "`code`");
}
#[test]
fn adf_code_with_link_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"func",
vec![AdfMark::code(), AdfMark::link("https://example.com")],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert_eq!(md.trim(), "[`func`](https://example.com)");
}
#[test]
fn adf_bold_italic_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"both",
vec![AdfMark::strong(), AdfMark::em()],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert_eq!(md.trim(), "***both***");
}
#[test]
fn adf_bold_link_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"bold link",
vec![AdfMark::strong(), AdfMark::link("https://example.com")],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert_eq!(md.trim(), "**[bold link](https://example.com)**");
}
#[test]
fn adf_strikethrough_bold_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"struck",
vec![AdfMark::strike(), AdfMark::strong()],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert_eq!(md.trim(), "~~**struck**~~");
}
#[test]
fn adf_hard_break_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![
AdfNode::text("Line 1"),
AdfNode::hard_break(),
AdfNode::text("Line 2"),
])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("Line 1\\\n Line 2"));
}
#[test]
#[test]
fn adf_unsupported_inline_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode {
node_type: "unknownInline".to_string(),
attrs: None,
content: None,
text: None,
marks: None,
local_id: None,
parameters: None,
}])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("<!-- unsupported inline: unknownInline -->"));
}
// ── mediaInline tests (issue #476) ─────────────────────────────────
#[test]
fn adf_media_inline_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![
AdfNode::text("see "),
AdfNode::media_inline(serde_json::json!({
"type": "image",
"id": "abcdef01-2345-6789-abcd-abcdef012345",
"collection": "contentId-111111",
"width": 200,
"height": 100
})),
AdfNode::text(" for details"),
])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains(":media-inline[]{"), "got: {md}");
assert!(md.contains("type=image"));
assert!(md.contains("id=abcdef01-2345-6789-abcd-abcdef012345"));
assert!(md.contains("collection=contentId-111111"));
assert!(md.contains("width=200"));
assert!(md.contains("height=100"));
assert!(!md.contains("<!-- unsupported inline"));
}
#[test]
fn media_inline_round_trip() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![
AdfNode::text("see "),
AdfNode::media_inline(serde_json::json!({
"type": "image",
"id": "abcdef01-2345-6789-abcd-abcdef012345",
"collection": "contentId-111111",
"width": 200,
"height": 100
})),
AdfNode::text(" for details"),
])],
};
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let content = rt.content[0].content.as_ref().unwrap();
assert_eq!(content[0].text.as_deref(), Some("see "));
assert_eq!(content[1].node_type, "mediaInline");
let attrs = content[1].attrs.as_ref().unwrap();
assert_eq!(attrs["type"], "image");
assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
assert_eq!(attrs["collection"], "contentId-111111");
assert_eq!(attrs["width"], 200);
assert_eq!(attrs["height"], 100);
assert_eq!(content[2].text.as_deref(), Some(" for details"));
}
#[test]
fn media_inline_external_url_round_trip() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
serde_json::json!({
"type": "external",
"url": "https://example.com/image.png",
"alt": "example",
"width": 400,
"height": 300
}),
)])],
};
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let content = rt.content[0].content.as_ref().unwrap();
assert_eq!(content[0].node_type, "mediaInline");
let attrs = content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["type"], "external");
assert_eq!(attrs["url"], "https://example.com/image.png");
assert_eq!(attrs["alt"], "example");
assert_eq!(attrs["width"], 400);
assert_eq!(attrs["height"], 300);
}
#[test]
fn media_inline_minimal_attrs() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
serde_json::json!({"type": "file", "id": "abc-123"}),
)])],
};
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let content = rt.content[0].content.as_ref().unwrap();
assert_eq!(content[0].node_type, "mediaInline");
let attrs = content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["type"], "file");
assert_eq!(attrs["id"], "abc-123");
}
#[test]
fn media_inline_from_issue_476_reproducer() {
// Exact reproducer from issue #476
let adf_json: serde_json::Value = serde_json::json!({
"type": "doc",
"version": 1,
"content": [
{
"type": "paragraph",
"content": [
{"type": "text", "text": "see "},
{
"type": "mediaInline",
"attrs": {
"collection": "contentId-111111",
"height": 100,
"id": "abcdef01-2345-6789-abcd-abcdef012345",
"localId": "aabbccdd-1234-5678-abcd-aabbccdd1234",
"type": "image",
"width": 200
}
},
{"type": "text", "text": " for details"}
]
}
]
});
let doc: AdfDocument = serde_json::from_value(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
!md.contains("<!-- unsupported inline"),
"mediaInline should not be unsupported; got: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let content = rt.content[0].content.as_ref().unwrap();
assert_eq!(content[1].node_type, "mediaInline");
let attrs = content[1].attrs.as_ref().unwrap();
assert_eq!(attrs["type"], "image");
assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
assert_eq!(attrs["collection"], "contentId-111111");
assert_eq!(attrs["width"], 200);
assert_eq!(attrs["height"], 100);
assert_eq!(attrs["localId"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
}
#[test]
fn emoji_shortcode() {
let doc = markdown_to_adf("Hello :wave: world").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content[0].text.as_deref(), Some("Hello "));
assert_eq!(content[1].node_type, "emoji");
assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":wave:");
assert_eq!(content[2].text.as_deref(), Some(" world"));
}
#[test]
fn adf_emoji_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::emoji("thumbsup")])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains(":thumbsup:"));
}
#[test]
fn adf_emoji_with_colon_prefix_to_markdown() {
// JIRA stores shortName as ":thumbsup:" with colons
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode {
node_type: "emoji".to_string(),
attrs: Some(serde_json::json!({"shortName": ":thumbsup:"})),
content: None,
text: None,
marks: None,
local_id: None,
parameters: None,
}])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains(":thumbsup:"));
// Should not produce ::thumbsup:: (double colons)
assert!(!md.contains("::thumbsup::"));
}
#[test]
fn round_trip_emoji() {
let md = "Hello :wave: world\n";
let doc = markdown_to_adf(md).unwrap();
let result = adf_to_markdown(&doc).unwrap();
assert!(result.contains(":wave:"));
}
#[test]
fn emoji_with_id_and_text_round_trips() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode {
node_type: "emoji".to_string(),
attrs: Some(
serde_json::json!({"shortName": ":check_mark:", "id": "2705", "text": "✅"}),
),
content: None,
text: None,
marks: None,
local_id: None,
parameters: None,
}])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains(":check_mark:"), "shortcode present: {md}");
assert!(md.contains("id="), "id attr present: {md}");
assert!(md.contains("text="), "text attr present: {md}");
// Round-trip back to ADF
let round_tripped = markdown_to_adf(&md).unwrap();
let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
let attrs = emoji.attrs.as_ref().unwrap();
assert_eq!(attrs["shortName"], ":check_mark:");
assert_eq!(attrs["id"], "2705");
assert_eq!(attrs["text"], "✅");
}
#[test]
fn emoji_without_extra_attrs_still_works() {
let md = "Hello :wave: world\n";
let doc = markdown_to_adf(md).unwrap();
let emoji = &doc.content[0].content.as_ref().unwrap()[1];
assert_eq!(emoji.attrs.as_ref().unwrap()["shortName"], ":wave:");
// No id or text attrs when not provided
assert!(emoji.attrs.as_ref().unwrap().get("id").is_none());
}
#[test]
fn emoji_shortname_preserves_colons_round_trip() {
// Issue #362: emoji shortName colons stripped during round-trip
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"emoji","attrs":{"shortName":":cross_mark:","id":"atlassian-cross_mark","text":"❌"}}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
// ADF → markdown → ADF round-trip
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
let attrs = emoji.attrs.as_ref().unwrap();
assert_eq!(
attrs["shortName"], ":cross_mark:",
"shortName should preserve colons, got: {}",
attrs["shortName"]
);
assert_eq!(attrs["id"], "atlassian-cross_mark");
assert_eq!(attrs["text"], "❌");
}
#[test]
fn emoji_shortname_without_colons_preserved() {
// Issue #379: shortName without colons should not gain colons
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"emoji","attrs":{"shortName":"white_check_mark","id":"2705","text":"✅"}}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
let attrs = emoji.attrs.as_ref().unwrap();
assert_eq!(
attrs["shortName"], "white_check_mark",
"shortName without colons should stay without colons, got: {}",
attrs["shortName"]
);
}
#[test]
fn colon_in_text_not_emoji() {
// A lone colon should not trigger emoji parsing
let doc = markdown_to_adf("Time is 10:30 today").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0].node_type, "text");
}
#[test]
fn text_with_shortcode_pattern_round_trips_as_text() {
// Issue #450: `:fire:` in a text node must not become an emoji node
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Alert :fire: triggered on pod:pod42"}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let content = round_tripped.content[0].content.as_ref().unwrap();
assert_eq!(
content.len(),
1,
"should be a single text node, got: {content:?}"
);
assert_eq!(content[0].node_type, "text");
assert_eq!(
content[0].text.as_deref().unwrap(),
"Alert :fire: triggered on pod:pod42"
);
}
#[test]
fn double_colon_pattern_round_trips_as_text() {
// Issue #450: `::Active::` should not be parsed as emoji `:Active:`
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status::Active::Running"}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let content = round_tripped.content[0].content.as_ref().unwrap();
assert_eq!(
content.len(),
1,
"should be a single text node, got: {content:?}"
);
assert_eq!(content[0].node_type, "text");
assert_eq!(
content[0].text.as_deref().unwrap(),
"Status::Active::Running"
);
}
#[test]
fn real_emoji_node_still_round_trips() {
// Ensure actual emoji ADF nodes still work after the escaping fix
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"Hello "},
{"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
{"type":"text","text":" world"}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let content = round_tripped.content[0].content.as_ref().unwrap();
// Should have: text("Hello ") + emoji(:fire:) + text(" world")
assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
assert_eq!(content[0].text.as_deref(), Some("Hello "));
assert_eq!(content[1].node_type, "emoji");
assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":fire:");
assert_eq!(content[2].text.as_deref(), Some(" world"));
}
#[test]
fn combined_emoji_shortname_round_trips_as_single_node() {
// Issue #576: an emoji node whose shortName is a combination of two
// shortcodes (e.g. ":slightly_smiling_face::bow:") must survive the
// ADF → markdown → ADF round-trip as a single emoji node rather than
// being split into two.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"Thanks for the help "},
{"type":"emoji","attrs":{"shortName":":slightly_smiling_face::bow:","id":"","text":""}}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let content = round_tripped.content[0].content.as_ref().unwrap();
assert_eq!(
content.len(),
2,
"should have text + single combined emoji: {content:?}"
);
assert_eq!(content[0].text.as_deref(), Some("Thanks for the help "));
assert_eq!(content[1].node_type, "emoji");
let attrs = content[1].attrs.as_ref().unwrap();
assert_eq!(attrs["shortName"], ":slightly_smiling_face::bow:");
assert_eq!(attrs["id"], "");
assert_eq!(attrs["text"], "");
}
#[test]
fn triple_combined_emoji_shortname_round_trips_as_single_node() {
// Three-part combined shortName must also survive round-trip.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"emoji","attrs":{"shortName":":a::b::c:","id":"x","text":""}}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let content = round_tripped.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
assert_eq!(content[0].node_type, "emoji");
let attrs = content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["shortName"], ":a::b::c:");
assert_eq!(attrs["id"], "x");
}
#[test]
fn consecutive_emojis_remain_separate_nodes() {
// Two independent emoji nodes (each with their own directive) must
// remain two separate nodes — the combined-chain logic must not
// swallow the second emoji.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
{"type":"emoji","attrs":{"shortName":":water:","id":"1f4a7","text":"💧"}}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let content = round_tripped.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 2, "should be two emoji nodes: {content:?}");
assert_eq!(content[0].node_type, "emoji");
assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
assert_eq!(content[1].node_type, "emoji");
assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
}
#[test]
fn adjacent_shortcodes_without_directive_parse_as_two_emojis() {
// Raw markdown with two adjacent shortcodes and no directive should
// still parse as two separate emoji nodes.
let md = ":fire::water:";
let doc = markdown_to_adf(md).unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 2, "should be two emojis: {content:?}");
assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
}
#[test]
fn combined_emoji_shortname_preserves_local_id() {
// The directive's localId should be preserved when the chain is
// collapsed into a single emoji node.
let md = r#":a::b:{shortName=":a::b:" id="x" text="y" localId="abc"}"#;
let doc = markdown_to_adf(md).unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
let attrs = content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["shortName"], ":a::b:");
assert_eq!(attrs["id"], "x");
assert_eq!(attrs["text"], "y");
assert_eq!(attrs["localId"], "abc");
}
#[test]
fn text_shortcode_with_marks_round_trips() {
// Bold text containing an emoji-like shortcode should round-trip as bold text
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"Alert :fire: triggered","marks":[{"type":"strong"}]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let content = round_tripped.content[0].content.as_ref().unwrap();
assert_eq!(
content.len(),
1,
"should be single bold text node: {content:?}"
);
assert_eq!(content[0].node_type, "text");
assert_eq!(
content[0].text.as_deref().unwrap(),
"Alert :fire: triggered"
);
assert!(content[0]
.marks
.as_ref()
.is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong")));
}
#[test]
fn mixed_emoji_node_and_text_shortcode_round_trips() {
// Real emoji node adjacent to text containing a shortcode-like pattern
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"emoji","attrs":{"shortName":":wave:","id":"1f44b","text":"👋"}},
{"type":"text","text":" says :hello: to you"}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let content = round_tripped.content[0].content.as_ref().unwrap();
// Should be: emoji(:wave:) + text(" says :hello: to you")
assert_eq!(content.len(), 2, "should have 2 nodes: {content:?}");
assert_eq!(content[0].node_type, "emoji");
assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":wave:");
assert_eq!(content[1].node_type, "text");
assert_eq!(content[1].text.as_deref().unwrap(), " says :hello: to you");
}
#[test]
fn code_block_with_shortcode_pattern_round_trips() {
// Issue #552: Code block content containing `::Name::` patterns must not
// be re-parsed as emoji shortcodes.
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"codeBlock","attrs":{"language":"ruby"},"content":[
{"type":"text","text":"module Foo::Bar::Baz\n def hello\n puts 'world'\n end\nend"}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
assert_eq!(
round_tripped.content.len(),
1,
"should be a single codeBlock"
);
let cb = &round_tripped.content[0];
assert_eq!(cb.node_type, "codeBlock");
let content = cb.content.as_ref().expect("codeBlock content");
assert_eq!(
content.len(),
1,
"should be a single text node: {content:?}"
);
assert_eq!(content[0].node_type, "text");
assert_eq!(
content[0].text.as_deref().unwrap(),
"module Foo::Bar::Baz\n def hello\n puts 'world'\n end\nend"
);
assert!(
content.iter().all(|n| n.node_type != "emoji"),
"no emoji nodes should be present, got: {content:?}"
);
}
#[test]
fn code_block_with_exact_zendesk_shortcode_pattern_round_trips() {
// Issue #552: Use the exact pattern from the bug report.
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"codeBlock","attrs":{"language":"ruby"},"content":[
{"type":"text","text":"class ZBC::Zendesk::PlanType::Professional < Base"}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let cb = &round_tripped.content[0];
assert_eq!(cb.node_type, "codeBlock");
let content = cb.content.as_ref().expect("codeBlock content");
assert_eq!(content.len(), 1, "should be a single text node");
assert_eq!(
content[0].text.as_deref().unwrap(),
"class ZBC::Zendesk::PlanType::Professional < Base"
);
}
#[test]
fn code_block_with_literal_shortcode_round_trips() {
// Issue #552: Content that is exactly a shortcode (`:fire:`) inside a
// code block should survive the round-trip as literal text, not emoji.
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"codeBlock","attrs":{"language":"text"},"content":[
{"type":"text","text":":fire: :wave: :thumbsup:"}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let cb = &round_tripped.content[0];
assert_eq!(cb.node_type, "codeBlock");
let content = cb.content.as_ref().expect("codeBlock content");
assert_eq!(
content.len(),
1,
"should be a single text node: {content:?}"
);
assert_eq!(content[0].node_type, "text");
assert_eq!(
content[0].text.as_deref().unwrap(),
":fire: :wave: :thumbsup:"
);
}
#[test]
fn inline_code_with_shortcode_pattern_round_trips() {
// Issue #552: Inline code containing `::Name::` patterns must not be
// re-parsed as emoji shortcodes.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"See "},
{"type":"text","text":"Foo::Bar::Baz","marks":[{"type":"code"}]},
{"type":"text","text":" for details"}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let content = round_tripped.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 3, "should have 3 text nodes: {content:?}");
assert_eq!(content[0].text.as_deref(), Some("See "));
assert_eq!(content[1].text.as_deref(), Some("Foo::Bar::Baz"));
assert!(content[1]
.marks
.as_ref()
.is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
assert_eq!(content[2].text.as_deref(), Some(" for details"));
assert!(
content.iter().all(|n| n.node_type != "emoji"),
"no emoji nodes should be present"
);
}
#[test]
fn inline_code_with_literal_shortcode_round_trips() {
// Issue #552: Inline code whose content is exactly a shortcode must be
// preserved as code, not converted to an emoji.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":":fire:","marks":[{"type":"code"}]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let content = round_tripped.content[0].content.as_ref().unwrap();
assert_eq!(
content.len(),
1,
"should be a single code node: {content:?}"
);
assert_eq!(content[0].node_type, "text");
assert_eq!(content[0].text.as_deref(), Some(":fire:"));
assert!(content[0]
.marks
.as_ref()
.is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
}
#[test]
fn code_block_in_list_with_shortcode_pattern_round_trips() {
// Issue #552: A code block containing shortcode-like patterns nested in
// a list should still survive round-trip without emoji corruption.
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"bulletList","content":[
{"type":"listItem","content":[
{"type":"codeBlock","attrs":{"language":"ruby"},"content":[
{"type":"text","text":"Foo::Bar::Baz"}
]}
]}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let list = &round_tripped.content[0];
assert_eq!(list.node_type, "bulletList");
let item = &list.content.as_ref().unwrap()[0];
assert_eq!(item.node_type, "listItem");
let cb = &item.content.as_ref().unwrap()[0];
assert_eq!(cb.node_type, "codeBlock");
let cb_content = cb.content.as_ref().unwrap();
assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
assert_eq!(cb_content[0].node_type, "text");
}
#[test]
fn code_block_with_unicode_shortcode_pattern_round_trips() {
// Issue #552: Code block content with non-ASCII colon-delimited words
// (e.g. CJK or accented characters) must round-trip without splitting
// or emoji corruption.
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"codeBlock","attrs":{"language":"ruby"},"content":[
{"type":"text","text":"class ZBC::配置::Production < Base"}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let cb = &round_tripped.content[0];
assert_eq!(cb.node_type, "codeBlock");
let content = cb.content.as_ref().expect("codeBlock content");
assert_eq!(content.len(), 1);
assert_eq!(
content[0].text.as_deref().unwrap(),
"class ZBC::配置::Production < Base"
);
}
#[test]
fn list_item_hardbreak_then_code_block_round_trips() {
// Issue #552: A list item whose first paragraph ends with a hardBreak
// followed by a codeBlock must round-trip correctly. Previously, the
// hardBreak's `\` continuation swallowed the 2-space-indented code
// fence line, turning the whole block into a paragraph where `::Bar::`
// was re-parsed as an emoji.
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"bulletList","content":[
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"Consider removing this check."},
{"type":"hardBreak"}
]},
{"type":"codeBlock","content":[
{"type":"text","text":"x = Foo::Bar::Baz.new"}
]}
]}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let list = &round_tripped.content[0];
assert_eq!(list.node_type, "bulletList");
let item = &list.content.as_ref().unwrap()[0];
assert_eq!(item.node_type, "listItem");
let item_content = item.content.as_ref().unwrap();
assert_eq!(
item_content.len(),
2,
"listItem should have paragraph + codeBlock, got: {item_content:?}"
);
assert_eq!(item_content[0].node_type, "paragraph");
assert_eq!(item_content[1].node_type, "codeBlock");
// The code block text must survive verbatim — no emoji splitting.
let cb_content = item_content[1].content.as_ref().unwrap();
assert_eq!(cb_content[0].node_type, "text");
assert_eq!(
cb_content[0].text.as_deref().unwrap(),
"x = Foo::Bar::Baz.new"
);
// And there should be no emoji node anywhere in the tree.
assert!(
item_content
.iter()
.flat_map(|n| n.content.iter().flat_map(|c| c.iter()))
.all(|n| n.node_type != "emoji"),
"no emoji nodes should be present, got: {item_content:?}"
);
}
#[test]
fn list_item_hardbreak_then_nested_list_still_works() {
// Ensure the hardBreak continuation fix doesn't break nested list
// handling — an indented `- item` line after a hardBreak is still a
// nested list, not a code fence.
let md = "- first\\\n continuation text\\\n - nested item\n";
let doc = markdown_to_adf(md).unwrap();
let list = &doc.content[0];
assert_eq!(list.node_type, "bulletList");
let item = &list.content.as_ref().unwrap()[0];
// First paragraph should contain the continuation text joined via hardBreaks
let item_content = item.content.as_ref().unwrap();
let para = &item_content[0];
assert_eq!(para.node_type, "paragraph");
let para_nodes = para.content.as_ref().unwrap();
assert!(
para_nodes
.iter()
.any(|n| n.text.as_deref() == Some("continuation text")),
"continuation text should survive: {para_nodes:?}"
);
}
#[test]
fn list_item_hardbreak_then_image_still_works() {
// Regression check for issue #490: the image-skip behaviour in
// collect_hardbreak_continuations must still hold after the code-fence
// fix. The `` line must remain a block-level mediaSingle, not
// be swallowed into the paragraph.
let md = "- first\\\n {type=file id=x}\n";
let doc = markdown_to_adf(md).unwrap();
let list = &doc.content[0];
let item = &list.content.as_ref().unwrap()[0];
let item_content = item.content.as_ref().unwrap();
// The image should be a sibling block, not part of the first paragraph.
assert!(
item_content.iter().any(|n| n.node_type == "mediaSingle"),
"mediaSingle should still be a block-level sibling, got: {item_content:?}"
);
}
#[test]
fn list_item_hardbreak_then_container_directive_round_trips() {
// Issue #552: Same hardBreak-swallows-block-siblings bug class — a
// container directive (`:::panel`) after a hardBreak must also not be
// consumed as a continuation line.
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"bulletList","content":[
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"intro"},
{"type":"hardBreak"}
]},
{"type":"panel","attrs":{"panelType":"info"},"content":[
{"type":"paragraph","content":[
{"type":"text","text":"panel body"}
]}
]}
]}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let item = &round_tripped.content[0].content.as_ref().unwrap()[0];
let item_content = item.content.as_ref().unwrap();
assert!(
item_content.iter().any(|n| n.node_type == "panel"),
"panel should survive as block-level sibling, got: {item_content:?}"
);
}
#[test]
fn inline_code_with_unicode_shortcode_pattern_round_trips() {
// Issue #552: Inline code containing non-ASCII colon-delimited words
// must round-trip without emoji corruption.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"See "},
{"type":"text","text":"ZBC::配置::Production","marks":[{"type":"code"}]},
{"type":"text","text":" for prod"}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let content = round_tripped.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
assert_eq!(content[1].text.as_deref(), Some("ZBC::配置::Production"));
assert!(content[1]
.marks
.as_ref()
.is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
}
#[test]
fn code_block_followed_by_shortcode_text_round_trips() {
// Issue #552: A code block with colon-delimited content followed by a
// paragraph containing emoji-like text should not confuse parsing.
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"codeBlock","attrs":{"language":"ruby"},"content":[
{"type":"text","text":"Foo::Bar::Baz"}
]},
{"type":"paragraph","content":[
{"type":"text","text":"Status::Active::Running"}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
assert_eq!(round_tripped.content.len(), 2);
let cb = &round_tripped.content[0];
assert_eq!(cb.node_type, "codeBlock");
let cb_content = cb.content.as_ref().unwrap();
assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
let para = &round_tripped.content[1];
assert_eq!(para.node_type, "paragraph");
let para_content = para.content.as_ref().unwrap();
assert_eq!(para_content.len(), 1);
assert_eq!(para_content[0].node_type, "text");
assert_eq!(
para_content[0].text.as_deref(),
Some("Status::Active::Running")
);
}
#[test]
fn adf_inline_card_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode {
node_type: "inlineCard".to_string(),
attrs: Some(
serde_json::json!({"url": "https://org.atlassian.net/browse/ACCS-4382"}),
),
content: None,
text: None,
marks: None,
local_id: None,
parameters: None,
}])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
assert!(!md.contains("<!-- unsupported inline"));
}
#[test]
fn inline_card_directive_round_trips() {
// inlineCard → :card[url] → inlineCard
let original = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::inline_card(
"https://org.atlassian.net/browse/ACCS-4382",
)])],
};
let md = adf_to_markdown(&original).unwrap();
assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
let restored = markdown_to_adf(&md).unwrap();
let node = &restored.content[0].content.as_ref().unwrap()[0];
assert_eq!(node.node_type, "inlineCard");
assert_eq!(
node.attrs.as_ref().unwrap()["url"],
"https://org.atlassian.net/browse/ACCS-4382"
);
}
#[test]
fn inline_card_directive_parsed_from_jfm() {
// :card[url] in JFM → inlineCard in ADF
let doc = markdown_to_adf("See :card[https://example.com/issue/123] for details.").unwrap();
let nodes = doc.content[0].content.as_ref().unwrap();
assert_eq!(nodes[0].node_type, "text");
assert_eq!(nodes[0].text.as_deref(), Some("See "));
assert_eq!(nodes[1].node_type, "inlineCard");
assert_eq!(
nodes[1].attrs.as_ref().unwrap()["url"],
"https://example.com/issue/123"
);
assert_eq!(nodes[2].node_type, "text");
assert_eq!(nodes[2].text.as_deref(), Some(" for details."));
}
#[test]
fn self_link_becomes_link_mark_not_inline_card() {
// Issue #378: [url](url) should produce a link mark, not inlineCard.
// inlineCard is only for :card[url] directives and bare URLs.
let doc = markdown_to_adf("[https://example.com](https://example.com)").unwrap();
let node = &doc.content[0].content.as_ref().unwrap()[0];
assert_eq!(node.node_type, "text");
assert_eq!(node.text.as_deref(), Some("https://example.com"));
let mark = &node.marks.as_ref().unwrap()[0];
assert_eq!(mark.mark_type, "link");
assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
}
#[test]
fn url_link_text_with_trailing_slash_mismatch_becomes_link_mark() {
// Issue #523: [url](url/) where text and href differ only by trailing
// slash should produce a text node with link mark, not an inlineCard.
let doc =
markdown_to_adf("[https://octopz.example.com](https://octopz.example.com/)").unwrap();
let node = &doc.content[0].content.as_ref().unwrap()[0];
assert_eq!(node.node_type, "text");
assert_eq!(node.text.as_deref(), Some("https://octopz.example.com"));
let mark = &node.marks.as_ref().unwrap()[0];
assert_eq!(mark.mark_type, "link");
assert_eq!(
mark.attrs.as_ref().unwrap()["href"],
"https://octopz.example.com/"
);
}
#[test]
fn named_link_does_not_become_inline_card() {
// [#4668](url) — text differs from url, stays as a link mark
let doc = markdown_to_adf("[#4668](https://github.com/org/repo/pull/4668)").unwrap();
let node = &doc.content[0].content.as_ref().unwrap()[0];
assert_eq!(node.node_type, "text");
assert_eq!(node.text.as_deref(), Some("#4668"));
let mark = &node.marks.as_ref().unwrap()[0];
assert_eq!(mark.mark_type, "link");
}
#[test]
fn adf_inline_card_no_url_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode {
node_type: "inlineCard".to_string(),
attrs: Some(serde_json::json!({})),
content: None,
text: None,
marks: None,
local_id: None,
parameters: None,
}])],
};
let md = adf_to_markdown(&doc).unwrap();
// No url attr — renders nothing (not a comment)
assert!(!md.contains("<!-- unsupported inline"));
}
#[test]
fn adf_code_block_no_language_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::code_block(None, "plain code")],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("```\n"));
assert!(md.contains("plain code"));
}
#[test]
fn adf_code_block_empty_language_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::code_block(Some(""), "plain code")],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("```\"\"\n"));
assert!(md.contains("plain code"));
}
// ── Additional round-trip tests ────────────────────────────────────
#[test]
fn round_trip_table() {
let md = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
let adf = markdown_to_adf(md).unwrap();
let restored = adf_to_markdown(&adf).unwrap();
assert!(restored.contains("| A | B |"));
assert!(restored.contains("| 1 | 2 |"));
}
#[test]
fn round_trip_blockquote() {
let md = "> This is quoted\n";
let adf = markdown_to_adf(md).unwrap();
let restored = adf_to_markdown(&adf).unwrap();
assert!(restored.contains("> This is quoted"));
}
#[test]
fn round_trip_image() {
let md = "\n";
let adf = markdown_to_adf(md).unwrap();
let restored = adf_to_markdown(&adf).unwrap();
assert!(restored.contains(""));
}
#[test]
fn round_trip_ordered_list() {
let md = "1. A\n2. B\n3. C\n";
let adf = markdown_to_adf(md).unwrap();
let restored = adf_to_markdown(&adf).unwrap();
assert!(restored.contains("1. A"));
assert!(restored.contains("2. B"));
assert!(restored.contains("3. C"));
}
#[test]
fn round_trip_inline_marks() {
let md = "Text with `code` and ~~strike~~ and [link](https://x.com).\n";
let adf = markdown_to_adf(md).unwrap();
let restored = adf_to_markdown(&adf).unwrap();
assert!(restored.contains("`code`"));
assert!(restored.contains("~~strike~~"));
assert!(restored.contains("[link](https://x.com)"));
}
// ── Container directive tests (Tier 2) ───────────────────────────
#[test]
fn panel_info() {
let md = ":::panel{type=info}\nThis is informational.\n:::";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content[0].node_type, "panel");
assert_eq!(doc.content[0].attrs.as_ref().unwrap()["panelType"], "info");
let inner = doc.content[0].content.as_ref().unwrap();
assert_eq!(inner[0].node_type, "paragraph");
}
#[test]
fn adf_panel_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::panel(
"warning",
vec![AdfNode::paragraph(vec![AdfNode::text("Be careful.")])],
)],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains(":::panel{type=warning}"));
assert!(md.contains("Be careful."));
assert!(md.contains(":::"));
}
#[test]
fn round_trip_panel() {
let md = ":::panel{type=info}\nThis is informational.\n:::\n";
let doc = markdown_to_adf(md).unwrap();
let result = adf_to_markdown(&doc).unwrap();
assert!(result.contains(":::panel{type=info}"));
assert!(result.contains("This is informational."));
}
#[test]
fn expand_with_title() {
let md = ":::expand{title=\"Click me\"}\nHidden content.\n:::";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content[0].node_type, "expand");
assert_eq!(doc.content[0].attrs.as_ref().unwrap()["title"], "Click me");
}
#[test]
fn adf_expand_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::expand(
Some("Details"),
vec![AdfNode::paragraph(vec![AdfNode::text("Inner.")])],
)],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains(":::expand{title=\"Details\"}"));
assert!(md.contains("Inner."));
}
#[test]
fn round_trip_expand() {
let md = ":::expand{title=\"Details\"}\nInner content.\n:::\n";
let doc = markdown_to_adf(md).unwrap();
let result = adf_to_markdown(&doc).unwrap();
assert!(result.contains(":::expand{title=\"Details\"}"));
assert!(result.contains("Inner content."));
}
#[test]
fn layout_two_columns() {
let md =
"::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content[0].node_type, "layoutSection");
let columns = doc.content[0].content.as_ref().unwrap();
assert_eq!(columns.len(), 2);
assert_eq!(columns[0].node_type, "layoutColumn");
assert_eq!(columns[1].node_type, "layoutColumn");
}
#[test]
fn adf_layout_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::layout_section(vec![
AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Left.")])]),
AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Right.")])]),
])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("::::layout"));
assert!(md.contains(":::column{width=50}"));
assert!(md.contains("Left."));
assert!(md.contains("Right."));
}
#[test]
fn layout_column_localid_roundtrip() {
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "layoutSection",
"content": [
{
"type": "layoutColumn",
"attrs": {"width": 50.0, "localId": "aabb112233cc"},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Left"}]}]
},
{
"type": "layoutColumn",
"attrs": {"width": 50.0, "localId": "ddeeff445566"},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Right"}]}]
}
]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("localId=aabb112233cc"),
"first column localId should appear in markdown: {md}"
);
assert!(
md.contains("localId=ddeeff445566"),
"second column localId should appear in markdown: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let cols = rt.content[0].content.as_ref().unwrap();
assert_eq!(
cols[0].attrs.as_ref().unwrap()["localId"],
"aabb112233cc",
"first column localId should round-trip"
);
assert_eq!(
cols[1].attrs.as_ref().unwrap()["localId"],
"ddeeff445566",
"second column localId should round-trip"
);
}
#[test]
fn layout_column_without_localid() {
let md =
"::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
let doc = markdown_to_adf(md).unwrap();
let cols = doc.content[0].content.as_ref().unwrap();
assert!(
cols[0].attrs.as_ref().unwrap().get("localId").is_none(),
"column without localId should not gain one"
);
let md2 = adf_to_markdown(&doc).unwrap();
assert!(
!md2.contains("localId"),
"no localId should appear in output: {md2}"
);
}
#[test]
fn layout_column_localid_stripped_when_option_set() {
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "layoutSection",
"content": [{
"type": "layoutColumn",
"attrs": {"width": 50.0, "localId": "aabb112233cc"},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Col"}]}]
}]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let opts = RenderOptions {
strip_local_ids: true,
..Default::default()
};
let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
assert!(!md.contains("localId"), "localId should be stripped: {md}");
}
#[test]
fn layout_column_localid_flush_previous() {
// Columns open without explicit `:::` close → flush-previous path
let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nLeft.\n:::column{width=50 localId=ddeeff445566}\nRight.\n:::\n::::";
let doc = markdown_to_adf(md).unwrap();
let cols = doc.content[0].content.as_ref().unwrap();
assert_eq!(
cols[0].attrs.as_ref().unwrap()["localId"],
"aabb112233cc",
"flush-previous column should preserve localId"
);
assert_eq!(
cols[1].attrs.as_ref().unwrap()["localId"],
"ddeeff445566",
"second column localId should be preserved"
);
}
#[test]
fn layout_column_localid_flush_last() {
// Layout with no closing fence → column never explicitly closed → flush-last path
let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nOnly column.";
let doc = markdown_to_adf(md).unwrap();
let cols = doc.content[0].content.as_ref().unwrap();
assert_eq!(
cols[0].attrs.as_ref().unwrap()["localId"],
"aabb112233cc",
"flush-last column should preserve localId"
);
}
/// Issue #555: `layoutColumn` fractional `width` must round-trip byte-for-byte.
#[test]
fn issue_555_layout_column_fractional_width_roundtrip() {
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "layoutSection",
"content": [
{
"type": "layoutColumn",
"attrs": {"width": 66.66},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
},
{
"type": "layoutColumn",
"attrs": {"width": 33.34},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
}
]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("width=66.66"), "fractional width in md: {md}");
assert!(md.contains("width=33.34"), "fractional width in md: {md}");
let rt = markdown_to_adf(&md).unwrap();
let cols = rt.content[0].content.as_ref().unwrap();
assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 66.66);
assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 33.34);
}
/// Issue #555: `layoutColumn` 5/6 widths (`83.33`) round-trip without precision loss.
#[test]
fn issue_555_layout_column_five_sixths_width_roundtrip() {
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "layoutSection",
"content": [
{
"type": "layoutColumn",
"attrs": {"width": 83.33},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
},
{
"type": "layoutColumn",
"attrs": {"width": 16.67},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
}
]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let cols = rt.content[0].content.as_ref().unwrap();
assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 83.33);
assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 16.67);
}
/// Issue #555: `layoutColumn` integer widths must NOT be coerced to floats on round-trip.
#[test]
fn issue_555_layout_column_integer_width_preserved() {
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "layoutSection",
"content": [
{
"type": "layoutColumn",
"attrs": {"width": 50},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "A"}]}]
},
{
"type": "layoutColumn",
"attrs": {"width": 50},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "B"}]}]
}
]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("width=50") && !md.contains("width=50."),
"integer width should render without decimal: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let cols = rt.content[0].content.as_ref().unwrap();
let w0 = &cols[0].attrs.as_ref().unwrap()["width"];
assert!(
w0.is_i64() || w0.is_u64(),
"width should remain a JSON integer, got: {w0}"
);
assert_eq!(w0.as_i64(), Some(50));
}
/// Issue #555: `mediaSingle` integer `width` must NOT be coerced to a float on round-trip.
#[test]
fn issue_555_media_single_integer_width_preserved() {
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "mediaSingle",
"attrs": {"layout": "center", "width": 800},
"content": [
{"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("width=800") && !md.contains("width=800."),
"integer width should render without decimal: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
let w = &ms_attrs["width"];
assert!(
w.is_i64() || w.is_u64(),
"mediaSingle width should remain JSON integer, got: {w}"
);
assert_eq!(w.as_i64(), Some(800));
}
/// Issue #555 (follow-up): fractional `mediaSingle` width (e.g. `66.5`, a
/// percentage-based size common in Jira layouts) must survive `from-adf`
/// instead of being silently dropped.
#[test]
fn issue_555_media_single_fractional_width_preserved() {
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "mediaSingle",
"attrs": {"layout": "center", "width": 66.5},
"content": [
{"type": "media", "attrs": {"type": "external", "url": "https://example.com/diagram.png"}}
]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("width=66.5"),
"fractional width must appear in JFM: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
assert_eq!(ms_attrs["width"], 66.5);
}
/// Issue #555: `mediaSingle` float `width` must not be dropped during ADF→JFM→ADF.
#[test]
fn issue_555_media_single_float_width_preserved() {
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "mediaSingle",
"attrs": {"layout": "center", "width": 800.0},
"content": [
{"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("width=800.0"),
"float width should render with decimal: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
let w = &ms_attrs["width"];
assert!(
w.is_f64(),
"mediaSingle float width should stay a JSON float, got: {w}"
);
assert_eq!(w.as_f64(), Some(800.0));
}
/// Issue #555: `mediaSingle` with `layout=wide` and integer width must round-trip.
#[test]
fn issue_555_media_single_wide_layout_integer_width_roundtrip() {
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "mediaSingle",
"attrs": {"layout": "wide", "width": 1420},
"content": [
{"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
assert_eq!(ms_attrs["layout"], "wide");
let w = &ms_attrs["width"];
assert!(
w.is_i64() || w.is_u64(),
"mediaSingle width should remain JSON integer, got: {w}"
);
assert_eq!(w.as_i64(), Some(1420));
}
/// Issue #555: Confluence file-attachment `mediaSingle` with integer `mediaWidth`
/// must round-trip without float coercion.
#[test]
fn issue_555_file_media_single_integer_width_preserved() {
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "mediaSingle",
"attrs": {"layout": "wide", "width": 1420},
"content": [
{"type": "media", "attrs": {"id": "abc-123", "type": "file", "collection": "col-1", "width": 1200, "height": 800}}
]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
let ms_w = &ms_attrs["width"];
assert!(ms_w.is_i64() || ms_w.is_u64(), "ms width: {ms_w}");
assert_eq!(ms_w.as_i64(), Some(1420));
let media = &rt.content[0].content.as_ref().unwrap()[0];
let media_attrs = media.attrs.as_ref().unwrap();
let mw = &media_attrs["width"];
assert!(mw.is_i64() || mw.is_u64(), "media width: {mw}");
assert_eq!(mw.as_i64(), Some(1200));
let mh = &media_attrs["height"];
assert!(mh.is_i64() || mh.is_u64(), "media height: {mh}");
assert_eq!(mh.as_i64(), Some(800));
}
/// Issue #555: `fmt_numeric_attr` preserves the original integer/float JSON type.
#[test]
fn issue_555_fmt_numeric_attr_preserves_type() {
assert_eq!(
fmt_numeric_attr(&serde_json::json!(50)).as_deref(),
Some("50")
);
assert_eq!(
fmt_numeric_attr(&serde_json::json!(50.0)).as_deref(),
Some("50.0")
);
assert_eq!(
fmt_numeric_attr(&serde_json::json!(66.66)).as_deref(),
Some("66.66")
);
assert_eq!(
fmt_numeric_attr(&serde_json::json!(-5)).as_deref(),
Some("-5")
);
assert_eq!(fmt_numeric_attr(&serde_json::json!("not a number")), None);
// u64 values above i64::MAX exercise the u64-only branch.
let big = serde_json::Value::Number(serde_json::Number::from(u64::MAX));
assert_eq!(
fmt_numeric_attr(&big).as_deref(),
Some("18446744073709551615")
);
// Null is not a number.
assert_eq!(fmt_numeric_attr(&serde_json::Value::Null), None);
}
/// Issue #555: `parse_numeric_attr` distinguishes integer vs float strings.
#[test]
fn issue_555_parse_numeric_attr_detects_type() {
let v = parse_numeric_attr("800").unwrap();
assert!(v.is_i64() || v.is_u64(), "'800' should parse as integer");
assert_eq!(v.as_i64(), Some(800));
let v = parse_numeric_attr("800.0").unwrap();
assert!(v.is_f64(), "'800.0' should parse as float");
assert_eq!(v.as_f64(), Some(800.0));
let v = parse_numeric_attr("66.66").unwrap();
assert!(v.is_f64());
assert_eq!(v.as_f64(), Some(66.66));
// Scientific notation is treated as float (matches JSON semantics).
let v = parse_numeric_attr("1e2").unwrap();
assert!(v.is_f64());
assert_eq!(v.as_f64(), Some(100.0));
let v = parse_numeric_attr("1E2").unwrap();
assert!(v.is_f64());
assert_eq!(v.as_f64(), Some(100.0));
// Negative integer.
let v = parse_numeric_attr("-42").unwrap();
assert!(v.is_i64());
assert_eq!(v.as_i64(), Some(-42));
assert!(parse_numeric_attr("not-a-number").is_none());
assert!(parse_numeric_attr("").is_none());
assert!(parse_numeric_attr("1.2.3").is_none());
}
/// Issue #555: fractional `mediaSingle` width with non-default `layout=wide`
/// must preserve both the layout and the fractional width through round-trip.
#[test]
fn issue_555_media_single_wide_layout_fractional_width_roundtrip() {
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "mediaSingle",
"attrs": {"layout": "wide", "width": 83.33},
"content": [
{"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("layout=wide"), "layout must appear in md: {md}");
assert!(md.contains("width=83.33"), "width must appear in md: {md}");
let rt = markdown_to_adf(&md).unwrap();
let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
assert_eq!(ms_attrs["layout"], "wide");
assert_eq!(ms_attrs["width"], 83.33);
}
/// Issue #555: fractional `mediaWidth` on a Confluence file-attachment
/// `mediaSingle` must round-trip (exercises the file-branch `mediaWidth`
/// render path, which previously used `as_u64` and silently dropped floats).
#[test]
fn issue_555_file_media_single_fractional_media_width_preserved() {
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "mediaSingle",
"attrs": {"layout": "wide", "width": 66.5},
"content": [
{"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c"}}
]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("mediaWidth=66.5"), "mediaWidth in md: {md}");
let rt = markdown_to_adf(&md).unwrap();
let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
assert_eq!(ms_attrs["width"], 66.5);
}
/// Issue #555: fractional inner `media` width/height on a file attachment
/// must round-trip (exercises the file-branch inner `width`/`height` render
/// path, which previously used `as_u64` and silently dropped floats).
#[test]
fn issue_555_file_media_fractional_inner_dimensions_preserved() {
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "mediaSingle",
"attrs": {"layout": "center"},
"content": [
{"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c", "width": 1200.5, "height": 800.25}}
]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("width=1200.5"), "width in md: {md}");
assert!(md.contains("height=800.25"), "height in md: {md}");
let rt = markdown_to_adf(&md).unwrap();
let media = &rt.content[0].content.as_ref().unwrap()[0];
let attrs = media.attrs.as_ref().unwrap();
assert_eq!(attrs["width"], 1200.5);
assert_eq!(attrs["height"], 800.25);
}
#[test]
fn decisions_list() {
let md = ":::decisions\n- <> Use PostgreSQL\n- <> REST API\n:::";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content[0].node_type, "decisionList");
let items = doc.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DECIDED");
}
#[test]
fn adf_decisions_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::decision_list(vec![AdfNode::decision_item(
"DECIDED",
vec![AdfNode::paragraph(vec![AdfNode::text("Use PostgreSQL")])],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains(":::decisions"));
assert!(md.contains("- <> Use PostgreSQL"));
}
#[test]
fn bodied_extension_container() {
let md = ":::extension{type=com.forge key=my-macro}\nContent.\n:::";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content[0].node_type, "bodiedExtension");
assert_eq!(
doc.content[0].attrs.as_ref().unwrap()["extensionType"],
"com.forge"
);
}
#[test]
fn adf_bodied_extension_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::bodied_extension(
"com.forge",
"my-macro",
vec![AdfNode::paragraph(vec![AdfNode::text("Content.")])],
)],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains(":::extension{type=com.forge key=my-macro}"));
assert!(md.contains("Content."));
}
// ── Leaf block directive tests (Tier 3) ──────────────────────────
#[test]
fn leaf_block_card() {
let doc = markdown_to_adf("::card[https://example.com/browse/PROJ-123]").unwrap();
assert_eq!(doc.content[0].node_type, "blockCard");
assert_eq!(
doc.content[0].attrs.as_ref().unwrap()["url"],
"https://example.com/browse/PROJ-123"
);
}
#[test]
fn adf_block_card_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::block_card("https://example.com/browse/PROJ-123")],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("::card[https://example.com/browse/PROJ-123]"));
}
#[test]
fn round_trip_block_card() {
let md = "::card[https://example.com/browse/PROJ-123]\n";
let doc = markdown_to_adf(md).unwrap();
let result = adf_to_markdown(&doc).unwrap();
assert!(result.contains("::card[https://example.com/browse/PROJ-123]"));
}
#[test]
fn leaf_embed_card() {
let doc =
markdown_to_adf("::embed[https://figma.com/file/abc]{layout=wide width=80}").unwrap();
assert_eq!(doc.content[0].node_type, "embedCard");
let attrs = doc.content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["url"], "https://figma.com/file/abc");
assert_eq!(attrs["layout"], "wide");
assert_eq!(attrs["width"], 80.0);
}
#[test]
fn leaf_embed_card_with_original_height() {
let doc = markdown_to_adf(
"::embed[https://example.com]{layout=center originalHeight=732 width=100}",
)
.unwrap();
assert_eq!(doc.content[0].node_type, "embedCard");
let attrs = doc.content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["url"], "https://example.com");
assert_eq!(attrs["layout"], "center");
assert_eq!(attrs["originalHeight"], 732.0);
assert_eq!(attrs["width"], 100.0);
}
#[test]
fn adf_embed_card_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::embed_card(
"https://figma.com/file/abc",
Some("wide"),
None,
Some(80.0),
)],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("::embed[https://figma.com/file/abc]{layout=wide width=80}"));
}
#[test]
fn adf_embed_card_original_height_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::embed_card(
"https://example.com",
Some("center"),
Some(732.0),
Some(100.0),
)],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("::embed[https://example.com]{layout=center originalHeight=732 width=100}"),
"expected originalHeight and width in md: {md}"
);
}
#[test]
fn embed_card_roundtrip_with_all_attrs() {
let adf_json = r#"{"version":1,"type":"doc","content":[{
"type":"embedCard",
"attrs":{"layout":"center","originalHeight":732.0,"url":"https://example.com","width":100.0}
}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("originalHeight=732"),
"originalHeight missing from md: {md}"
);
assert!(md.contains("width=100"), "width missing from md: {md}");
let rt = markdown_to_adf(&md).unwrap();
let attrs = rt.content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["originalHeight"], 732.0);
assert_eq!(attrs["width"], 100.0);
assert_eq!(attrs["layout"], "center");
assert_eq!(attrs["url"], "https://example.com");
}
#[test]
fn embed_card_fractional_dimensions() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::embed_card(
"https://example.com",
Some("center"),
Some(732.5),
Some(99.9),
)],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("originalHeight=732.5"),
"fractional originalHeight missing: {md}"
);
assert!(md.contains("width=99.9"), "fractional width missing: {md}");
let rt = markdown_to_adf(&md).unwrap();
let attrs = rt.content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["originalHeight"], 732.5);
assert_eq!(attrs["width"], 99.9);
}
#[test]
fn embed_card_integer_width_in_json() {
// JSON integer (not float) should also be extracted via as_f64()
let adf_json = r#"{"version":1,"type":"doc","content":[{
"type":"embedCard",
"attrs":{"url":"https://example.com","width":100}
}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("width=100"),
"integer width missing from md: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(rt.content[0].attrs.as_ref().unwrap()["width"], 100.0);
}
#[test]
fn embed_card_only_original_height() {
// originalHeight without width
let adf_json = r#"{"version":1,"type":"doc","content":[{
"type":"embedCard",
"attrs":{"url":"https://example.com","originalHeight":500.0}
}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("originalHeight=500"),
"originalHeight missing: {md}"
);
assert!(!md.contains("width="), "width should not appear: {md}");
let rt = markdown_to_adf(&md).unwrap();
let attrs = rt.content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["originalHeight"], 500.0);
assert!(attrs.get("width").is_none());
}
#[test]
fn leaf_void_extension() {
let doc = markdown_to_adf("::extension{type=com.atlassian.macro key=jira-chart}").unwrap();
assert_eq!(doc.content[0].node_type, "extension");
assert_eq!(
doc.content[0].attrs.as_ref().unwrap()["extensionType"],
"com.atlassian.macro"
);
assert_eq!(
doc.content[0].attrs.as_ref().unwrap()["extensionKey"],
"jira-chart"
);
}
#[test]
fn adf_void_extension_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::extension(
"com.atlassian.macro",
"jira-chart",
None,
)],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("::extension{type=com.atlassian.macro key=jira-chart}"));
}
// ── Bare URL autolink tests ──────────────────────────────────────
#[test]
fn bare_url_autolink() {
let doc = markdown_to_adf("Visit https://example.com today").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content[0].text.as_deref(), Some("Visit "));
assert_eq!(content[1].node_type, "inlineCard");
assert_eq!(
content[1].attrs.as_ref().unwrap()["url"],
"https://example.com"
);
assert_eq!(content[2].text.as_deref(), Some(" today"));
}
#[test]
fn bare_url_strips_trailing_punctuation() {
let doc = markdown_to_adf("See https://example.com.").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(
content[1].attrs.as_ref().unwrap()["url"],
"https://example.com"
);
}
#[test]
fn bare_url_round_trip() {
let doc = markdown_to_adf("Visit https://example.com/path today").unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains(":card[https://example.com/path]"));
}
// ── Issue #475: plain-text URL must not become inlineCard ─────────
#[test]
fn plain_text_url_round_trips_as_text() {
// A text node whose content is a bare URL (no link mark) must
// survive ADF→JFM→ADF as a text node, not an inlineCard.
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "paragraph",
"content": [
{"type": "text", "text": "https://example.com/some/path/to/resource"}
]
}]
}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1, "should be a single node");
assert_eq!(content[0].node_type, "text");
assert_eq!(
content[0].text.as_deref(),
Some("https://example.com/some/path/to/resource")
);
}
#[test]
fn url_text_with_link_mark_round_trips_as_text_node() {
// Issue #523: A text node whose content is a URL with a link mark
// (href differs by trailing slash) must round-trip as text+link,
// not become an inlineCard.
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "paragraph",
"content": [{
"type": "text",
"text": "https://octopz.example.com",
"marks": [{"type": "link", "attrs": {"href": "https://octopz.example.com/"}}]
}]
}]
}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1, "should be a single node");
assert_eq!(content[0].node_type, "text", "must be text, not inlineCard");
assert_eq!(
content[0].text.as_deref(),
Some("https://octopz.example.com")
);
let mark = &content[0].marks.as_ref().unwrap()[0];
assert_eq!(mark.mark_type, "link");
assert_eq!(
mark.attrs.as_ref().unwrap()["href"],
"https://octopz.example.com/"
);
}
#[test]
fn url_text_with_exact_link_mark_round_trips() {
// Variant: text and href are identical (no trailing slash difference).
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "paragraph",
"content": [{
"type": "text",
"text": "https://example.com/path",
"marks": [{"type": "link", "attrs": {"href": "https://example.com/path"}}]
}]
}]
}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1, "should be a single node");
assert_eq!(content[0].node_type, "text");
assert_eq!(content[0].text.as_deref(), Some("https://example.com/path"));
let mark = &content[0].marks.as_ref().unwrap()[0];
assert_eq!(mark.mark_type, "link");
}
#[test]
fn plain_text_url_amid_text_round_trips() {
// URL embedded in surrounding text, without link mark.
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "paragraph",
"content": [
{"type": "text", "text": "see https://example.com for info"}
]
}]
}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0].node_type, "text");
assert_eq!(
content[0].text.as_deref(),
Some("see https://example.com for info")
);
}
#[test]
fn plain_text_multiple_urls_round_trips() {
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "paragraph",
"content": [
{"type": "text", "text": "http://a.com and https://b.com"}
]
}]
}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0].node_type, "text");
assert_eq!(
content[0].text.as_deref(),
Some("http://a.com and https://b.com")
);
}
#[test]
fn plain_text_http_prefix_no_url_unchanged() {
// "http" without "://" should not be escaped or altered.
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "paragraph",
"content": [
{"type": "text", "text": "the http header is important"}
]
}]
}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(
content[0].text.as_deref(),
Some("the http header is important")
);
}
#[test]
fn linked_url_text_round_trips() {
// A text node that is exactly a URL with a link mark pointing to the
// same URL must round-trip as a single text node with a link mark
// (no inlineCard, no lost/split content).
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "paragraph",
"content": [{
"type": "text",
"text": "https://example.com",
"marks": [{"type": "link", "attrs": {"href": "https://example.com"}}]
}]
}]
}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0].node_type, "text");
assert_eq!(content[0].text.as_deref(), Some("https://example.com"));
let mark = &content[0].marks.as_ref().unwrap()[0];
assert_eq!(mark.mark_type, "link");
assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
}
// ── Issue #493: bracket-link ambiguity ─────────────────────────────
#[test]
fn escape_link_brackets_unit() {
assert_eq!(escape_link_brackets("hello"), "hello");
assert_eq!(escape_link_brackets("["), "\\[");
assert_eq!(escape_link_brackets("]"), "\\]");
assert_eq!(escape_link_brackets("[PROJ-456]"), "\\[PROJ-456\\]");
assert_eq!(escape_link_brackets("a[b]c"), "a\\[b\\]c");
}
#[test]
fn bracket_text_with_link_mark_escapes_brackets() {
// A text node whose content is "[" with a link mark should escape
// the bracket so it does not create ambiguous markdown link syntax.
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"[",
vec![AdfMark::link("https://example.com")],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert_eq!(md.trim(), "[\\[](https://example.com)");
}
#[test]
fn bracket_text_with_link_mark_round_trips() {
// Issue #493 reproducer: adjacent text nodes sharing a link mark
// where the first node's content is "[".
let adf_json = r#"{
"type": "doc",
"version": 1,
"content": [{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "[",
"marks": [{"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}]
},
{
"type": "text",
"text": "PROJ-456] Fix the auth bug",
"marks": [
{"type": "underline"},
{"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}
]
}
]
}]
}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
// The markdown should contain escaped brackets inside the link
assert!(jfm.contains("\\["), "opening bracket should be escaped");
// Round-trip: both text nodes must survive with link marks
let rt = markdown_to_adf(&jfm).unwrap();
let content = rt.content[0].content.as_ref().unwrap();
// All text nodes that were part of the link must still carry a link mark
let link_nodes: Vec<_> = content
.iter()
.filter(|n| {
n.marks
.as_ref()
.is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
})
.collect();
assert!(
!link_nodes.is_empty(),
"link mark must be preserved on round-trip"
);
// The combined text across all nodes should contain the original content
let all_text: String = content.iter().filter_map(|n| n.text.as_deref()).collect();
assert!(
all_text.contains('['),
"literal '[' must survive round-trip"
);
assert!(
all_text.contains("PROJ-456]"),
"continuation text must survive round-trip"
);
}
#[test]
fn closing_bracket_in_link_text_round_trips() {
// A text node containing "]" inside a link should be escaped and
// survive round-trip without breaking the link syntax.
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"item]",
vec![AdfMark::link("https://example.com")],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert_eq!(md.trim(), "[item\\]](https://example.com)");
let rt = markdown_to_adf(&md).unwrap();
let content = rt.content[0].content.as_ref().unwrap();
assert_eq!(content[0].text.as_deref(), Some("item]"));
assert!(content[0]
.marks
.as_ref()
.unwrap()
.iter()
.any(|m| m.mark_type == "link"));
}
#[test]
fn brackets_in_link_text_round_trip() {
// Text containing both [ and ] inside a link should round-trip.
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"[PROJ-123]",
vec![AdfMark::link("https://example.com")],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert_eq!(md.trim(), "[\\[PROJ-123\\]](https://example.com)");
let rt = markdown_to_adf(&md).unwrap();
let content = rt.content[0].content.as_ref().unwrap();
assert_eq!(content[0].text.as_deref(), Some("[PROJ-123]"));
assert!(content[0]
.marks
.as_ref()
.unwrap()
.iter()
.any(|m| m.mark_type == "link"));
}
#[test]
fn plain_text_brackets_not_escaped() {
// Brackets in plain text (no link mark) must NOT be escaped.
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text(
"see [PROJ-123] for details",
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert_eq!(md.trim(), "see [PROJ-123] for details");
}
#[test]
fn link_with_no_brackets_unchanged() {
// A normal link with no brackets in the text should be unaffected.
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"click here",
vec![AdfMark::link("https://example.com")],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert_eq!(md.trim(), "[click here](https://example.com)");
}
// ── Issue #551: URL brackets in link-marked text round-trip ────────
#[test]
fn url_with_brackets_as_link_text_round_trips() {
// Issue #551: a text node whose content is a URL containing square
// brackets and which carries a link mark must round-trip verbatim.
// Previously the URL-as-link-text fast path preserved `\[` and `\]`
// escapes in the emitted text, corrupting the text content.
let href = "https://example.com/dashboard?filter[0]=active&filter[1]=pending";
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
href,
vec![AdfMark::link(href)],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let content = rt.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0].node_type, "text");
assert_eq!(content[0].text.as_deref(), Some(href));
let mark = &content[0].marks.as_ref().unwrap()[0];
assert_eq!(mark.mark_type, "link");
assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
}
#[test]
fn url_with_brackets_embedded_in_link_text_round_trips() {
// Issue #551 updated reproducer: a link-marked text node containing
// both prose and an embedded URL with brackets must round-trip
// without the embedded URL being detected as a bare-URL inlineCard
// or the brackets terminating the link syntax early. This mirrors
// the comment reproducer which uses an ellipsis character between
// the brackets and a distinct href value.
let href = "https://example.com/logs?query=service%20environment%20data&from=100&to=200";
let text =
"See the logs: https://example.com/logs?query=service[\u{2026}]data&from=100&to=200";
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
text,
vec![AdfMark::link(href)],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let content = rt.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1, "content split unexpectedly: {content:?}");
assert_eq!(content[0].node_type, "text");
assert_eq!(content[0].text.as_deref(), Some(text));
let mark = &content[0].marks.as_ref().unwrap()[0];
assert_eq!(mark.mark_type, "link");
assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
}
#[test]
fn url_with_brackets_plain_text_round_trips() {
// Issue #551 original reproducer: plain text with an embedded URL
// that contains square brackets must round-trip verbatim.
let text =
"See the dashboard: https://example.com/dashboard?filter[0]=active&filter[1]=pending";
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text(text)])],
};
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let content = rt.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0].node_type, "text");
assert_eq!(content[0].text.as_deref(), Some(text));
assert!(content[0].marks.is_none());
}
#[test]
fn url_with_link_mark_embedded_no_brackets_round_trips() {
// Regression guard: embedding a bare URL inside link-marked text
// (no brackets) must not create an inlineCard on round-trip.
let href = "https://example.com/";
let text = "See https://example.com/ for more";
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
text,
vec![AdfMark::link(href)],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let content = rt.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0].node_type, "text");
assert_eq!(content[0].text.as_deref(), Some(text));
let mark = &content[0].marks.as_ref().unwrap()[0];
assert_eq!(mark.mark_type, "link");
assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
}
#[test]
fn nested_brackets_in_link_text_round_trip() {
// Regression guard: nested brackets in link-marked text must
// round-trip without corrupting the content.
let href = "https://x.com";
let text = "foo [a[b]c] bar";
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
text,
vec![AdfMark::link(href)],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let content = rt.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0].node_type, "text");
assert_eq!(content[0].text.as_deref(), Some(text));
}
#[test]
fn bracket_url_bracket_in_link_text_round_trips() {
// Regression guard: a link-marked text containing brackets on both
// sides of an embedded URL (with brackets of its own) must
// round-trip intact. This exercises interaction between the
// URL-as-link-text fast path, bare-URL detection, and bracket
// escape handling.
let href = "https://y.com";
let text = "[see https://x.com/a[0]=1 here]";
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
text,
vec![AdfMark::link(href)],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let content = rt.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0].node_type, "text");
assert_eq!(content[0].text.as_deref(), Some(text));
let mark = &content[0].marks.as_ref().unwrap()[0];
assert_eq!(mark.mark_type, "link");
assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
}
#[test]
fn escape_bare_urls_applied_inside_link_text() {
// White-box: when a text node carries a link mark, bare URLs in the
// text must still be escaped with `\h` so the parser does not
// auto-link them into an inlineCard inside the link. Without this,
// round-trip of link-marked prose containing an embedded URL
// silently corrupts on re-parse (issue #551).
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"See https://example.com/",
vec![AdfMark::link("https://target.example.com/")],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains(r"\https://example.com/"),
"bare URL inside link text must be escaped, got: {md}"
);
}
#[test]
fn inline_card_still_round_trips() {
// An actual inlineCard node should still round-trip correctly
// (it uses :card[url] syntax, not bare URL).
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "paragraph",
"content": [
{"type": "inlineCard", "attrs": {"url": "https://example.com/page"}}
]
}]
}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
assert!(jfm.contains(":card[https://example.com/page]"));
let roundtripped = markdown_to_adf(&jfm).unwrap();
let content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(content[0].node_type, "inlineCard");
assert_eq!(
content[0].attrs.as_ref().unwrap()["url"],
"https://example.com/page"
);
}
// ── Issue #553: inlineCard round-trip with problematic URLs ───────
#[test]
fn url_safe_in_bracket_content_balanced() {
// Balanced brackets — depth never returns to zero mid-string.
assert!(url_safe_in_bracket_content("https://example.com"));
assert!(url_safe_in_bracket_content("https://example.com/[id]"));
assert!(url_safe_in_bracket_content("a[b[c]d]e"));
assert!(url_safe_in_bracket_content(""));
}
#[test]
fn url_safe_in_bracket_content_unbalanced() {
// A `]` with no prior `[` would close `:card[...]` early.
assert!(!url_safe_in_bracket_content("a]b"));
assert!(!url_safe_in_bracket_content("https://example.com/path]end"));
// Embedded newline breaks inline directive parsing.
assert!(!url_safe_in_bracket_content("a\nb"));
}
#[test]
fn inline_card_url_with_closing_bracket_round_trip() {
// Issue #553 defensive fix: a URL that contains `]` (unbalanced) must
// round-trip without truncation. The renderer must switch to the
// quoted attribute form `:card[]{url="..."}` so the parser's
// depth-based bracket matcher does not terminate the directive early.
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "paragraph",
"content": [
{"type": "text", "text": "See: "},
{"type": "inlineCard", "attrs": {"url": "https://example.com/path]end/?q=1"}}
]
}]
}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
assert!(
jfm.contains(r#":card[]{url="https://example.com/path]end/?q=1"}"#),
"expected attr-form for URL with `]`, got: {jfm}"
);
let rt = markdown_to_adf(&jfm).unwrap();
let content = rt.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 2, "expected 2 inline nodes, got {content:?}");
assert_eq!(content[0].node_type, "text");
assert_eq!(content[0].text.as_deref(), Some("See: "));
assert_eq!(content[1].node_type, "inlineCard");
assert_eq!(
content[1].attrs.as_ref().unwrap()["url"],
"https://example.com/path]end/?q=1"
);
}
#[test]
fn inline_card_url_with_closing_bracket_preserves_local_id() {
// Attr-form `:card[]{url=... localId=...}` must preserve localId too.
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "paragraph",
"content": [
{"type": "inlineCard", "attrs": {
"url": "https://example.com/a]b",
"localId": "c-77"
}}
]
}]
}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
assert!(
jfm.contains(r#"url="https://example.com/a]b""#),
"jfm: {jfm}"
);
assert!(jfm.contains("localId=c-77"), "jfm: {jfm}");
let rt = markdown_to_adf(&jfm).unwrap();
let card = &rt.content[0].content.as_ref().unwrap()[0];
assert_eq!(card.node_type, "inlineCard");
assert_eq!(
card.attrs.as_ref().unwrap()["url"],
"https://example.com/a]b"
);
assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-77");
}
#[test]
fn block_card_url_with_closing_bracket_round_trip() {
// Same defensive fix applied to the leaf directive `::card`.
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [
{"type": "blockCard", "attrs": {"url": "https://example.com/path]end"}}
]
}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
assert!(
jfm.contains(r#"::card[]{url="https://example.com/path]end"}"#),
"expected attr-form for blockCard with `]`, got: {jfm}"
);
let rt = markdown_to_adf(&jfm).unwrap();
assert_eq!(rt.content[0].node_type, "blockCard");
assert_eq!(
rt.content[0].attrs.as_ref().unwrap()["url"],
"https://example.com/path]end"
);
}
#[test]
fn block_card_attr_form_parses_without_renderer() {
// Directly parsing `::card[]{url="..."}` exercises the attr-URL
// fallback in the leaf-directive dispatcher (covers the `url` lookup
// path independently of the ADF→JFM renderer).
let doc = markdown_to_adf(r#"::card[]{url="https://example.com/a"}"#).unwrap();
assert_eq!(doc.content[0].node_type, "blockCard");
assert_eq!(
doc.content[0].attrs.as_ref().unwrap()["url"],
"https://example.com/a"
);
}
#[test]
fn block_card_attr_form_url_overrides_content() {
// When both bracket-content and `url=` attribute are present on
// `::card`, the attribute wins. Mirrors the inline-directive
// behaviour and keeps hand-edited JFM forgiving.
let doc =
markdown_to_adf(r#"::card[https://old.example.com]{url="https://new.example.com"}"#)
.unwrap();
assert_eq!(doc.content[0].node_type, "blockCard");
assert_eq!(
doc.content[0].attrs.as_ref().unwrap()["url"],
"https://new.example.com"
);
}
#[test]
fn block_card_attr_form_with_layout_and_width() {
// Attr-URL form combined with layout/width attrs — ensures all
// sibling attrs still pass through after the URL lookup.
let doc =
markdown_to_adf(r#"::card[]{url="https://example.com/a]b" layout=wide width=80}"#)
.unwrap();
let attrs = doc.content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["url"], "https://example.com/a]b");
assert_eq!(attrs["layout"], "wide");
assert_eq!(attrs["width"], 80);
}
#[test]
fn inline_card_issue_553_reproducer() {
// Verbatim reproducer from issue #553: an inlineCard in a paragraph
// with preceding text must round-trip as an inlineCard, not degrade to
// a text node with a link mark.
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "paragraph",
"content": [
{"type": "text", "text": "See the related page: "},
{"type": "inlineCard", "attrs": {
"url": "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
}}
]
}]
}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let rt = markdown_to_adf(&jfm).unwrap();
let content = rt.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(content[0].node_type, "text");
assert_eq!(content[1].node_type, "inlineCard");
assert_eq!(
content[1].attrs.as_ref().unwrap()["url"],
"https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
);
}
#[test]
fn inline_card_attr_form_parses_even_without_renderer() {
// Directly parsing `:card[]{url="..."}` should yield an inlineCard.
let doc = markdown_to_adf(r#":card[]{url="https://example.com/a"}"#).unwrap();
let node = &doc.content[0].content.as_ref().unwrap()[0];
assert_eq!(node.node_type, "inlineCard");
assert_eq!(node.attrs.as_ref().unwrap()["url"], "https://example.com/a");
}
#[test]
fn inline_card_attr_form_url_overrides_content() {
// When both bracket-content and `url=` attr are present, attr wins.
// This keeps the parser forgiving of hand-edited JFM where a user
// copied an old bracket form but added attrs.
let doc =
markdown_to_adf(r#":card[https://old.example.com]{url="https://new.example.com"}"#)
.unwrap();
let node = &doc.content[0].content.as_ref().unwrap()[0];
assert_eq!(node.node_type, "inlineCard");
assert_eq!(
node.attrs.as_ref().unwrap()["url"],
"https://new.example.com"
);
}
// ── Issue #553 (updated): mark-wrapped URL must not become inlineCard ──
#[test]
fn url_with_link_and_underline_marks_round_trip() {
// Issue #553 (updated reproducer): a `text` node whose content is a
// URL and that carries both `link` and `underline` marks must round-
// trip as text+marks, not be promoted to an `inlineCard`.
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "paragraph",
"content": [
{"type": "text", "text": "See results at: "},
{"type": "text",
"text": "https://example.com/projects/abc123/analytics",
"marks": [
{"type": "link", "attrs": {"href": "https://example.com/projects/abc123/analytics"}},
{"type": "underline"}
]},
{"type": "text", "text": " for details."}
]
}]
}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let rt = markdown_to_adf(&jfm).unwrap();
let content = rt.content[0].content.as_ref().unwrap();
assert_eq!(
content.len(),
3,
"expected 3 inline nodes, got: {content:?}"
);
assert_eq!(content[0].node_type, "text");
assert_eq!(
content[1].node_type, "text",
"must stay text, not inlineCard"
);
assert_eq!(
content[1].text.as_deref(),
Some("https://example.com/projects/abc123/analytics")
);
let mark_types: Vec<&str> = content[1]
.marks
.as_deref()
.unwrap_or(&[])
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(mark_types, vec!["link", "underline"], "marks lost");
assert_eq!(content[2].node_type, "text");
}
#[test]
fn url_inside_bracketed_span_stays_text() {
// `[URL]{underline}` in JFM means "underline this URL text", not
// "create a smart link that's underlined". The nested parse_inline
// call must not auto-promote the bare URL to an inlineCard.
let doc = markdown_to_adf("[https://example.com]{underline}").unwrap();
let node = &doc.content[0].content.as_ref().unwrap()[0];
assert_eq!(node.node_type, "text");
assert_eq!(node.text.as_deref(), Some("https://example.com"));
let mark_types: Vec<&str> = node
.marks
.as_deref()
.unwrap_or(&[])
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(mark_types, vec!["underline"]);
}
#[test]
fn url_inside_emphasis_stays_text() {
// Bold, italic, and strike-wrapped URLs should remain as text nodes,
// not get promoted to inlineCards by the nested inline parser.
for (md, mark) in [
("**https://example.com**", "strong"),
("*https://example.com*", "em"),
("~~https://example.com~~", "strike"),
] {
let doc = markdown_to_adf(md).unwrap();
let node = &doc.content[0].content.as_ref().unwrap()[0];
assert_eq!(node.node_type, "text", "md={md}: must be text");
assert_eq!(node.text.as_deref(), Some("https://example.com"));
let mark_types: Vec<&str> = node
.marks
.as_deref()
.unwrap_or(&[])
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(mark_types, vec![mark], "md={md}: wrong marks");
}
}
#[test]
fn url_inside_span_directive_stays_text() {
// `:span[URL]{color=red}` should not promote the URL to an inlineCard.
let doc = markdown_to_adf(":span[https://example.com]{color=red}").unwrap();
let node = &doc.content[0].content.as_ref().unwrap()[0];
assert_eq!(node.node_type, "text");
assert_eq!(node.text.as_deref(), Some("https://example.com"));
let mark = &node.marks.as_ref().unwrap()[0];
assert_eq!(mark.mark_type, "textColor");
}
#[test]
fn url_as_link_text_with_underline_after_link_mark_order() {
// Reverse mark order — underline appears BEFORE link in the ADF array.
// The JFM form is `[[text](url)]{underline}`; the nested parser must
// still keep the URL as plain text.
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "paragraph",
"content": [
{"type": "text",
"text": "https://example.com",
"marks": [
{"type": "underline"},
{"type": "link", "attrs": {"href": "https://example.com"}}
]}
]
}]
}"#;
let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
let jfm = adf_to_markdown(&adf).unwrap();
let rt = markdown_to_adf(&jfm).unwrap();
let node = &rt.content[0].content.as_ref().unwrap()[0];
assert_eq!(node.node_type, "text", "must stay text, got: {node:?}");
assert_eq!(node.text.as_deref(), Some("https://example.com"));
let mark_types: Vec<&str> = node
.marks
.as_deref()
.unwrap_or(&[])
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(mark_types, vec!["underline", "link"]);
}
#[test]
fn bare_url_at_top_level_still_becomes_inline_card() {
// Regression guard: the suppression only applies inside mark-wrapping
// constructs. A bare URL in ordinary paragraph text must still be
// detected and promoted to an inlineCard.
let doc = markdown_to_adf("Visit https://example.com today").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 3);
assert_eq!(content[0].node_type, "text");
assert_eq!(content[1].node_type, "inlineCard");
assert_eq!(
content[1].attrs.as_ref().unwrap()["url"],
"https://example.com"
);
assert_eq!(content[2].node_type, "text");
}
// ── Block-level attribute marks (Tier 5/6) ───────────────────────
#[test]
fn paragraph_align_center() {
let md = "Centered text.\n{align=center}";
let doc = markdown_to_adf(md).unwrap();
let marks = doc.content[0].marks.as_ref().unwrap();
assert_eq!(marks[0].mark_type, "alignment");
assert_eq!(marks[0].attrs.as_ref().unwrap()["align"], "center");
}
#[test]
fn adf_alignment_to_markdown() {
let mut node = AdfNode::paragraph(vec![AdfNode::text("Centered.")]);
node.marks = Some(vec![AdfMark::alignment("center")]);
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![node],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("Centered."));
assert!(md.contains("{align=center}"));
}
#[test]
fn round_trip_alignment() {
let md = "Centered.\n{align=center}\n";
let doc = markdown_to_adf(md).unwrap();
let result = adf_to_markdown(&doc).unwrap();
assert!(result.contains("{align=center}"));
}
#[test]
fn paragraph_indent() {
let md = "Indented.\n{indent=2}";
let doc = markdown_to_adf(md).unwrap();
let marks = doc.content[0].marks.as_ref().unwrap();
assert_eq!(marks[0].mark_type, "indentation");
assert_eq!(marks[0].attrs.as_ref().unwrap()["level"], 2);
}
#[test]
fn code_block_breakout() {
let md = "```python\ndef f(): pass\n```\n{breakout=wide}";
let doc = markdown_to_adf(md).unwrap();
let marks = doc.content[0].marks.as_ref().unwrap();
assert_eq!(marks[0].mark_type, "breakout");
assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
assert!(marks[0].attrs.as_ref().unwrap().get("width").is_none());
}
#[test]
fn code_block_breakout_with_width() {
let md = "```python\ndef f(): pass\n```\n{breakout=wide breakoutWidth=1200}";
let doc = markdown_to_adf(md).unwrap();
let marks = doc.content[0].marks.as_ref().unwrap();
assert_eq!(marks[0].mark_type, "breakout");
assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
assert_eq!(marks[0].attrs.as_ref().unwrap()["width"], 1200);
}
#[test]
fn adf_breakout_to_markdown() {
let mut node = AdfNode::code_block(Some("python"), "pass");
node.marks = Some(vec![AdfMark::breakout("wide", None)]);
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![node],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("{breakout=wide}"));
assert!(!md.contains("breakoutWidth"));
}
#[test]
fn adf_breakout_with_width_to_markdown() {
let mut node = AdfNode::code_block(Some("python"), "pass");
node.marks = Some(vec![AdfMark::breakout("wide", Some(1200))]);
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![node],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("breakout=wide"));
assert!(md.contains("breakoutWidth=1200"));
}
#[test]
fn breakout_width_round_trip() {
let adf_json = r#"{"version":1,"type":"doc","content":[{
"type":"codeBlock",
"attrs":{"language":"text"},
"marks":[{"type":"breakout","attrs":{"mode":"wide","width":1200}}],
"content":[{"type":"text","text":"some code"}]
}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("breakout=wide"));
assert!(md.contains("breakoutWidth=1200"));
let round_tripped = markdown_to_adf(&md).unwrap();
let marks = round_tripped.content[0].marks.as_ref().unwrap();
let breakout = marks.iter().find(|m| m.mark_type == "breakout").unwrap();
assert_eq!(breakout.attrs.as_ref().unwrap()["mode"], "wide");
assert_eq!(breakout.attrs.as_ref().unwrap()["width"], 1200);
}
// ── Attribute extensions — media & table (Tier 5) ────────────────
#[test]
fn image_with_layout_attrs() {
let doc = markdown_to_adf("{layout=wide width=80}").unwrap();
let node = &doc.content[0];
assert_eq!(node.node_type, "mediaSingle");
let attrs = node.attrs.as_ref().unwrap();
assert_eq!(attrs["layout"], "wide");
assert_eq!(attrs["width"], 80);
}
#[test]
fn adf_image_with_layout_to_markdown() {
let mut node = AdfNode::media_single("url", Some("alt"));
node.attrs.as_mut().unwrap()["layout"] = serde_json::json!("wide");
node.attrs.as_mut().unwrap()["width"] = serde_json::json!(80);
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![node],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("{layout=wide width=80}"));
}
#[test]
fn table_with_layout_attrs() {
let md = "| H |\n| --- |\n| C |\n{layout=wide numbered}";
let doc = markdown_to_adf(md).unwrap();
let table = &doc.content[0];
assert_eq!(table.node_type, "table");
let attrs = table.attrs.as_ref().unwrap();
assert_eq!(attrs["layout"], "wide");
assert_eq!(attrs["isNumberColumnEnabled"], true);
}
#[test]
fn adf_table_with_attrs_to_markdown() {
let mut table = AdfNode::table(vec![
AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
AdfNode::text("H"),
])])]),
AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
AdfNode::text("C"),
])])]),
]);
table.attrs = Some(serde_json::json!({"layout": "wide", "isNumberColumnEnabled": true}));
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![table],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("{layout=wide numbered}"));
}
// ── Attribute extensions — inline marks (Tier 5) ─────────────────
#[test]
fn underline_bracketed_span() {
let doc = markdown_to_adf("This is [underlined text]{underline} here.").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content[1].text.as_deref(), Some("underlined text"));
let marks = content[1].marks.as_ref().unwrap();
assert_eq!(marks[0].mark_type, "underline");
}
#[test]
fn adf_underline_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"underlined",
vec![AdfMark::underline()],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("[underlined]{underline}"));
}
#[test]
fn round_trip_underline() {
let md = "This is [underlined text]{underline} here.\n";
let doc = markdown_to_adf(md).unwrap();
let result = adf_to_markdown(&doc).unwrap();
assert!(result.contains("[underlined text]{underline}"));
}
#[test]
fn mark_ordering_underline_strong_preserved() {
// Issue #383: mark ordering was non-deterministic
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"bold and underlined","marks":[{"type":"underline"},{"type":"strong"}]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let mark_types: Vec<&str> = node
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(
mark_types,
vec!["underline", "strong"],
"mark order should be preserved, got: {mark_types:?}"
);
}
#[test]
fn mark_ordering_link_strong_preserved() {
// Issue #403: link+strong mark order was swapped on round-trip
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"bold link","marks":[
{"type":"link","attrs":{"href":"https://example.com"}},
{"type":"strong"}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let mark_types: Vec<&str> = node
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(
mark_types,
vec!["link", "strong"],
"mark order should be preserved, got: {mark_types:?}"
);
}
#[test]
fn mark_ordering_link_textcolor_preserved() {
// Issue #403 comment: link+textColor mark order was swapped on round-trip
let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"red link","marks":[
{"type":"link","attrs":{"href":"https://example.com"}},
{"type":"textColor","attrs":{"color":"#ff0000"}}
]}
]}]}"##;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let mark_types: Vec<&str> = node
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(
mark_types,
vec!["link", "textColor"],
"mark order should be preserved, got: {mark_types:?}"
);
}
#[test]
fn mark_ordering_link_em_preserved() {
// Issue #403: link+em mark order should be preserved
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"italic link","marks":[
{"type":"link","attrs":{"href":"https://example.com"}},
{"type":"em"}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let mark_types: Vec<&str> = node
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(
mark_types,
vec!["link", "em"],
"mark order should be preserved, got: {mark_types:?}"
);
}
#[test]
fn mark_ordering_link_strike_preserved() {
// Issue #403: link+strike mark order should be preserved
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"struck link","marks":[
{"type":"link","attrs":{"href":"https://example.com"}},
{"type":"strike"}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let mark_types: Vec<&str> = node
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(
mark_types,
vec!["link", "strike"],
"mark order should be preserved, got: {mark_types:?}"
);
}
#[test]
fn mark_ordering_strong_link_preserved() {
// Issue #403: [strong, link] order must also be preserved (reverse direction)
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"bold link","marks":[
{"type":"strong"},
{"type":"link","attrs":{"href":"https://example.com"}}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let mark_types: Vec<&str> = node
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(
mark_types,
vec!["strong", "link"],
"mark order should be preserved, got: {mark_types:?}"
);
}
#[test]
fn mark_ordering_em_link_preserved() {
// Issue #403: [em, link] order must also be preserved
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"italic link","marks":[
{"type":"em"},
{"type":"link","attrs":{"href":"https://example.com"}}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let mark_types: Vec<&str> = node
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(
mark_types,
vec!["em", "link"],
"mark order should be preserved, got: {mark_types:?}"
);
}
#[test]
fn mark_ordering_strike_link_preserved() {
// Issue #403: [strike, link] order must also be preserved
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"struck link","marks":[
{"type":"strike"},
{"type":"link","attrs":{"href":"https://example.com"}}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let mark_types: Vec<&str> = node
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(
mark_types,
vec!["strike", "link"],
"mark order should be preserved, got: {mark_types:?}"
);
}
#[test]
fn mark_ordering_underline_link_preserved() {
// Issue #403: [underline, link] order must be preserved
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"click here","marks":[
{"type":"underline"},
{"type":"link","attrs":{"href":"https://example.com"}}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let mark_types: Vec<&str> = node
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(
mark_types,
vec!["underline", "link"],
"mark order should be preserved, got: {mark_types:?}"
);
}
#[test]
fn mark_ordering_textcolor_link_preserved() {
// Issue #403: [textColor, link] order must be preserved
let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"red link","marks":[
{"type":"textColor","attrs":{"color":"#ff0000"}},
{"type":"link","attrs":{"href":"https://example.com"}}
]}
]}]}"##;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let mark_types: Vec<&str> = node
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(
mark_types,
vec!["textColor", "link"],
"mark order should be preserved, got: {mark_types:?}"
);
}
#[test]
fn mark_ordering_link_underline_preserved() {
// Issue #403: [link, underline] order must be preserved (link wraps bracketed span)
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"click here","marks":[
{"type":"link","attrs":{"href":"https://example.com"}},
{"type":"underline"}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
// Link should wrap the underline bracketed span: [[click here]{underline}](url)
assert!(
md.contains("](https://example.com)"),
"should have link: {md}"
);
assert!(md.contains("underline"), "should have underline: {md}");
let round_tripped = markdown_to_adf(&md).unwrap();
let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let mark_types: Vec<&str> = node
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(
mark_types,
vec!["link", "underline"],
"mark order should be preserved, got: {mark_types:?}"
);
}
#[test]
fn mark_ordering_underline_strong_link_preserved() {
// Issue #491: [underline, strong, link] reordered to [strong, underline, link]
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"bold underlined link","marks":[
{"type":"underline"},
{"type":"strong"},
{"type":"link","attrs":{"href":"https://example.com/page"}}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let mark_types: Vec<&str> = node
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(
mark_types,
vec!["underline", "strong", "link"],
"mark order should be preserved, got: {mark_types:?}"
);
}
#[test]
fn mark_ordering_strong_underline_link_preserved() {
// Issue #491: verify [strong, underline, link] is preserved
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"bold underlined link","marks":[
{"type":"strong"},
{"type":"underline"},
{"type":"link","attrs":{"href":"https://example.com/page"}}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let mark_types: Vec<&str> = node
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(
mark_types,
vec!["strong", "underline", "link"],
"mark order should be preserved, got: {mark_types:?}"
);
}
#[test]
fn mark_ordering_underline_em_link_preserved() {
// Issue #491: verify [underline, em, link] is preserved
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"italic underlined link","marks":[
{"type":"underline"},
{"type":"em"},
{"type":"link","attrs":{"href":"https://example.com/page"}}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let mark_types: Vec<&str> = node
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(
mark_types,
vec!["underline", "em", "link"],
"mark order should be preserved, got: {mark_types:?}"
);
}
#[test]
fn mark_ordering_underline_strike_link_preserved() {
// Issue #491: verify [underline, strike, link] is preserved
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"struck underlined link","marks":[
{"type":"underline"},
{"type":"strike"},
{"type":"link","attrs":{"href":"https://example.com/page"}}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let mark_types: Vec<&str> = node
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(
mark_types,
vec!["underline", "strike", "link"],
"mark order should be preserved, got: {mark_types:?}"
);
}
#[test]
fn mark_ordering_underline_strong_em_link_preserved() {
// Issue #491: verify four-mark combo [underline, strong, em, link] is preserved
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"all the marks","marks":[
{"type":"underline"},
{"type":"strong"},
{"type":"em"},
{"type":"link","attrs":{"href":"https://example.com/page"}}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let mark_types: Vec<&str> = node
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(
mark_types,
vec!["underline", "strong", "em", "link"],
"mark order should be preserved, got: {mark_types:?}"
);
}
#[test]
fn em_strong_round_trip() {
// Issue #401: em mark dropped when combined with strong
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"bold and italic","marks":[{"type":"strong"},{"type":"em"}]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert_eq!(md.trim(), "***bold and italic***");
let round_tripped = markdown_to_adf(&md).unwrap();
let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
assert_eq!(node.text.as_deref(), Some("bold and italic"));
let mark_types: Vec<&str> = node
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(
mark_types,
vec!["strong", "em"],
"both strong and em marks should be preserved, got: {mark_types:?}"
);
}
#[test]
fn em_strong_round_trip_em_first() {
// Issue #549: [em, strong] mark order must be preserved on round-trip.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"italic and bold","marks":[{"type":"em"},{"type":"strong"}]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
assert_eq!(node.text.as_deref(), Some("italic and bold"));
let mark_types: Vec<&str> = node
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(
mark_types,
vec!["em", "strong"],
"mark order [em, strong] should be preserved, got: {mark_types:?}"
);
}
/// Round-trips an inline text node with the given marks through ADF → JFM → ADF
/// and asserts the resulting mark types match `expected`.
fn assert_mark_order_round_trip(marks: Vec<AdfMark>, expected: &[&str]) {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"text", marks,
)])],
};
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let mark_types: Vec<&str> = node
.marks
.as_ref()
.expect("round-tripped node should have marks")
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(
mark_types, expected,
"marks should round-trip in order via {md:?}"
);
}
#[test]
fn round_trip_em_strong_mark_order() {
// Issue #549: em + strong in either order must round-trip.
assert_mark_order_round_trip(vec![AdfMark::em(), AdfMark::strong()], &["em", "strong"]);
assert_mark_order_round_trip(vec![AdfMark::strong(), AdfMark::em()], &["strong", "em"]);
}
#[test]
fn round_trip_strong_underline_mark_order() {
// Issue #549: strong + underline in either order must round-trip.
assert_mark_order_round_trip(
vec![AdfMark::strong(), AdfMark::underline()],
&["strong", "underline"],
);
assert_mark_order_round_trip(
vec![AdfMark::underline(), AdfMark::strong()],
&["underline", "strong"],
);
}
#[test]
fn round_trip_em_underline_mark_order() {
assert_mark_order_round_trip(
vec![AdfMark::em(), AdfMark::underline()],
&["em", "underline"],
);
assert_mark_order_round_trip(
vec![AdfMark::underline(), AdfMark::em()],
&["underline", "em"],
);
}
#[test]
fn round_trip_strike_strong_em_permutations() {
// Each permutation of {strike, strong, em} must round-trip the mark order
// exactly, because the Atlassian ADF spec does not define a canonical mark
// ordering and we preserve whatever ordering Jira delivered.
assert_mark_order_round_trip(
vec![AdfMark::strike(), AdfMark::strong(), AdfMark::em()],
&["strike", "strong", "em"],
);
assert_mark_order_round_trip(
vec![AdfMark::strike(), AdfMark::em(), AdfMark::strong()],
&["strike", "em", "strong"],
);
assert_mark_order_round_trip(
vec![AdfMark::strong(), AdfMark::strike(), AdfMark::em()],
&["strong", "strike", "em"],
);
assert_mark_order_round_trip(
vec![AdfMark::strong(), AdfMark::em(), AdfMark::strike()],
&["strong", "em", "strike"],
);
assert_mark_order_round_trip(
vec![AdfMark::em(), AdfMark::strike(), AdfMark::strong()],
&["em", "strike", "strong"],
);
assert_mark_order_round_trip(
vec![AdfMark::em(), AdfMark::strong(), AdfMark::strike()],
&["em", "strong", "strike"],
);
}
#[test]
fn round_trip_underline_nested_with_strong_em() {
// Underline may sit outside, between, or inside strong/em — each position
// must round-trip.
assert_mark_order_round_trip(
vec![AdfMark::underline(), AdfMark::strong(), AdfMark::em()],
&["underline", "strong", "em"],
);
assert_mark_order_round_trip(
vec![AdfMark::strong(), AdfMark::underline(), AdfMark::em()],
&["strong", "underline", "em"],
);
assert_mark_order_round_trip(
vec![AdfMark::strong(), AdfMark::em(), AdfMark::underline()],
&["strong", "em", "underline"],
);
}
#[test]
fn round_trip_span_attr_order_preserved() {
// Issue #549: the `:span` directive always parses color/bg/subsup
// attrs in a fixed order, so non-canonical orderings must be emitted
// as nested :span wrappers rather than a single merged wrapper.
assert_mark_order_round_trip(
vec![
AdfMark::background_color("#ffff00"),
AdfMark::text_color("#ff0000"),
],
&["backgroundColor", "textColor"],
);
assert_mark_order_round_trip(
vec![AdfMark::subsup("sub"), AdfMark::text_color("#ff0000")],
&["subsup", "textColor"],
);
assert_mark_order_round_trip(
vec![
AdfMark::text_color("#ff0000"),
AdfMark::background_color("#ffff00"),
],
&["textColor", "backgroundColor"],
);
}
#[test]
fn round_trip_annotation_before_underline() {
// Issue #549: the bracketed-span parser reads `underline` before any
// annotation-ids, so `[annotation, underline]` must be emitted as
// nested wrappers rather than one merged `[text]{underline annotation-id=X}`.
assert_mark_order_round_trip(
vec![
AdfMark::annotation("ann-1", "inlineComment"),
AdfMark::underline(),
],
&["annotation", "underline"],
);
assert_mark_order_round_trip(
vec![
AdfMark::annotation("ann-1", "inlineComment"),
AdfMark::underline(),
AdfMark::annotation("ann-2", "inlineComment"),
],
&["annotation", "underline", "annotation"],
);
}
#[test]
fn round_trip_em_content_with_underscores() {
// When em renders as `_..._` (to disambiguate from strong), any literal
// underscores in the text must be escaped so they don't close the
// emphasis span early. Text like "foo_bar_baz" with [em, strong] must
// survive round-trip with the underscores intact.
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"foo _bar_ baz",
vec![AdfMark::em(), AdfMark::strong()],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
assert_eq!(node.text.as_deref(), Some("foo _bar_ baz"));
let mark_types: Vec<&str> = node
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(mark_types, vec!["em", "strong"]);
}
#[test]
fn round_trip_link_nested_with_formatting_marks() {
// Link may sit at any position in the marks array relative to em,
// strong, strike, and underline — each position must round-trip.
assert_mark_order_round_trip(
vec![
AdfMark::link("https://example.com"),
AdfMark::strong(),
AdfMark::em(),
],
&["link", "strong", "em"],
);
assert_mark_order_round_trip(
vec![
AdfMark::em(),
AdfMark::strong(),
AdfMark::link("https://example.com"),
],
&["em", "strong", "link"],
);
assert_mark_order_round_trip(
vec![
AdfMark::underline(),
AdfMark::link("https://example.com"),
AdfMark::strong(),
],
&["underline", "link", "strong"],
);
}
/// Builds an `AdfMark` with the given type and no attrs, bypassing the
/// usual constructors so we can exercise the defensive branches in the
/// render helpers (the constructors always populate `attrs`).
fn bare_mark(mark_type: &str) -> AdfMark {
AdfMark {
mark_type: mark_type.to_string(),
attrs: None,
}
}
#[test]
fn collect_span_attr_handles_missing_attrs() {
// `textColor`/`backgroundColor`/`subsup` marks without the expected
// `color`/`type` attr must not emit a fragment (the `if let` falls
// through without pushing). This exercises the inner-None branches
// that the typed-constructor tests otherwise skip.
let mut attrs = Vec::new();
collect_span_attr(&bare_mark("textColor"), &mut attrs);
collect_span_attr(&bare_mark("backgroundColor"), &mut attrs);
collect_span_attr(&bare_mark("subsup"), &mut attrs);
collect_span_attr(&bare_mark("link"), &mut attrs);
assert!(attrs.is_empty(), "got: {attrs:?}");
}
#[test]
fn collect_bracketed_attr_handles_missing_attrs() {
// An annotation mark with no attrs map at all must silently produce
// no fragments — this covers the outer `if let Some(ref a)` None arm.
let mut attrs = Vec::new();
collect_bracketed_attr(&bare_mark("annotation"), &mut attrs);
collect_bracketed_attr(&bare_mark("strong"), &mut attrs);
assert!(attrs.is_empty(), "got: {attrs:?}");
}
#[test]
fn collect_bracketed_attr_handles_annotation_without_id() {
// An annotation mark with attrs present but missing `id` and
// `annotationType` keys still emits nothing — exercises the inner
// None branches of each `if let` in the annotation arm.
let mark = AdfMark {
mark_type: "annotation".to_string(),
attrs: Some(serde_json::json!({})),
};
let mut attrs = Vec::new();
collect_bracketed_attr(&mark, &mut attrs);
assert!(attrs.is_empty(), "got: {attrs:?}");
}
#[test]
fn span_attr_order_rejects_unknown_types() {
// `span_attr_order` must classify unknown mark types as the sentinel
// value, and `span_run_is_canonical` must reject a run that contains
// any such unknown type.
assert_eq!(span_attr_order("textColor"), 0);
assert_eq!(span_attr_order("backgroundColor"), 1);
assert_eq!(span_attr_order("subsup"), 2);
assert_eq!(span_attr_order("strong"), u8::MAX);
assert!(!span_run_is_canonical(&[bare_mark("strong")]));
}
#[test]
fn bracketed_run_rejects_unknown_types() {
// `bracketed_run_is_canonical` only accepts `underline` and
// `annotation`; any other mark type in the run short-circuits to
// `false` so the caller emits nested wrappers.
assert!(bracketed_run_is_canonical(&[
AdfMark::underline(),
AdfMark::annotation("x", "inlineComment")
]));
assert!(!bracketed_run_is_canonical(&[
AdfMark::annotation("x", "inlineComment"),
AdfMark::underline()
]));
assert!(!bracketed_run_is_canonical(&[bare_mark("strong")]));
}
#[test]
fn render_marked_text_ignores_unknown_mark_types() {
// Unknown mark types fall through `render_marked_text`'s `_ =>`
// arm and are dropped; the rendered JFM must still produce the
// underlying text (and round-trip back to an unmarked text node).
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"hello",
vec![bare_mark("futureMark"), AdfMark::strong()],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert_eq!(md.trim(), "**hello**");
let rt = markdown_to_adf(&md).unwrap();
let node = &rt.content[0].content.as_ref().unwrap()[0];
assert_eq!(node.text.as_deref(), Some("hello"));
let mark_types: Vec<&str> = node
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(mark_types, vec!["strong"]);
}
#[test]
fn triple_asterisk_parse_to_adf() {
// Issue #401: ***text*** should parse as text with strong+em marks
let md = "***bold and italic***\n";
let doc = markdown_to_adf(md).unwrap();
let node = &doc.content[0].content.as_ref().unwrap()[0];
assert_eq!(node.text.as_deref(), Some("bold and italic"));
let mark_types: Vec<&str> = node
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert!(
mark_types.contains(&"strong") && mark_types.contains(&"em"),
"***text*** should produce both strong and em marks, got: {mark_types:?}"
);
}
#[test]
fn triple_asterisk_with_surrounding_text() {
// Issue #401: surrounding text should not be affected
let md = "before ***bold italic*** after\n";
let doc = markdown_to_adf(md).unwrap();
let nodes = doc.content[0].content.as_ref().unwrap();
// Should have: "before " (plain), "bold italic" (strong+em), " after" (plain)
assert!(
nodes.len() >= 3,
"expected at least 3 nodes, got {}",
nodes.len()
);
assert_eq!(nodes[0].text.as_deref(), Some("before "));
assert_eq!(nodes[1].text.as_deref(), Some("bold italic"));
let mark_types: Vec<&str> = nodes[1]
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert!(
mark_types.contains(&"strong") && mark_types.contains(&"em"),
"middle node should have strong+em, got: {mark_types:?}"
);
assert_eq!(nodes[2].text.as_deref(), Some(" after"));
}
#[test]
fn annotation_mark_round_trip() {
// Issue #364: annotation marks were silently dropped
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"highlighted text","marks":[
{"type":"annotation","attrs":{"id":"abc123","annotationType":"inlineComment"}}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("annotation-id="),
"JFM should contain annotation-id, got: {md}"
);
let round_tripped = markdown_to_adf(&md).unwrap();
let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
assert_eq!(text_node.text.as_deref(), Some("highlighted text"));
let marks = text_node.marks.as_ref().expect("should have marks");
let ann = marks
.iter()
.find(|m| m.mark_type == "annotation")
.expect("should have annotation mark");
let attrs = ann.attrs.as_ref().unwrap();
assert_eq!(attrs["id"], "abc123");
assert_eq!(attrs["annotationType"], "inlineComment");
}
#[test]
fn annotation_mark_with_bold() {
// Annotation + bold should both survive round-trip
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"bold comment",
vec![
AdfMark::strong(),
AdfMark::annotation("def456", "inlineComment"),
],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let marks = text_node.marks.as_ref().expect("should have marks");
assert!(
marks.iter().any(|m| m.mark_type == "strong"),
"should have strong mark"
);
assert!(
marks.iter().any(|m| m.mark_type == "annotation"),
"should have annotation mark"
);
}
#[test]
fn annotation_and_link_marks_both_preserved() {
// Issue #390: text with both annotation and link marks loses link on round-trip
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"HANGUL-8","marks":[
{"type":"annotation","attrs":{"annotationType":"inlineComment","id":"5ca7425e-34cd-48d3-b4eb-9873ac8b20e0"}},
{"type":"link","attrs":{"href":"https://zd.atlassian.net/browse/HANG-8"}}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
// Should contain both annotation attrs and link syntax
assert!(
md.contains("annotation-id="),
"JFM should contain annotation-id, got: {md}"
);
assert!(
md.contains("](https://"),
"JFM should contain link href, got: {md}"
);
let round_tripped = markdown_to_adf(&md).unwrap();
let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let marks = text_node.marks.as_ref().expect("should have marks");
assert!(
marks.iter().any(|m| m.mark_type == "annotation"),
"should have annotation mark, got: {:?}",
marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
);
assert!(
marks.iter().any(|m| m.mark_type == "link"),
"should have link mark, got: {:?}",
marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
);
}
#[test]
fn annotation_and_code_marks_both_preserved() {
// Issue #508: annotation mark dropped when combined with code mark
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"some text with "},
{"type":"text","text":"annotated code","marks":[
{"type":"annotation","attrs":{"annotationType":"inlineComment","id":"aabbccdd-1234-5678-abcd-000000000001"}},
{"type":"code"}
]},
{"type":"text","text":" remaining text"}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("annotation-id="),
"JFM should contain annotation-id, got: {md}"
);
assert!(
md.contains('`'),
"JFM should contain backticks for code, got: {md}"
);
let round_tripped = markdown_to_adf(&md).unwrap();
let nodes = round_tripped.content[0].content.as_ref().unwrap();
// Find the text node with "annotated code"
let code_node = nodes
.iter()
.find(|n| n.text.as_deref() == Some("annotated code"))
.expect("should have 'annotated code' text node");
let marks = code_node.marks.as_ref().expect("should have marks");
assert!(
marks.iter().any(|m| m.mark_type == "annotation"),
"should have annotation mark, got: {:?}",
marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
);
assert!(
marks.iter().any(|m| m.mark_type == "code"),
"should have code mark, got: {:?}",
marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
);
let ann = marks.iter().find(|m| m.mark_type == "annotation").unwrap();
let attrs = ann.attrs.as_ref().unwrap();
assert_eq!(attrs["id"], "aabbccdd-1234-5678-abcd-000000000001");
assert_eq!(attrs["annotationType"], "inlineComment");
}
#[test]
fn annotation_and_code_and_link_marks_all_preserved() {
// annotation + code + link should all survive round-trip
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"linked code",
vec![
AdfMark::annotation("ann-001", "inlineComment"),
AdfMark::code(),
AdfMark::link("https://example.com"),
],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("annotation-id="),
"JFM should contain annotation-id, got: {md}"
);
assert!(md.contains('`'), "JFM should contain backticks, got: {md}");
assert!(
md.contains("](https://example.com)"),
"JFM should contain link, got: {md}"
);
let round_tripped = markdown_to_adf(&md).unwrap();
let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let marks = text_node.marks.as_ref().expect("should have marks");
assert!(
marks.iter().any(|m| m.mark_type == "annotation"),
"should have annotation mark, got: {:?}",
marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
);
assert!(
marks.iter().any(|m| m.mark_type == "code"),
"should have code mark, got: {:?}",
marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
);
assert!(
marks.iter().any(|m| m.mark_type == "link"),
"should have link mark, got: {:?}",
marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
);
}
#[test]
fn multiple_annotations_and_code_mark_preserved() {
// Multiple annotation marks on a code node should all survive
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"doubly annotated",
vec![
AdfMark::annotation("ann-aaa", "inlineComment"),
AdfMark::annotation("ann-bbb", "inlineComment"),
AdfMark::code(),
],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("ann-aaa"),
"JFM should contain first annotation id, got: {md}"
);
assert!(
md.contains("ann-bbb"),
"JFM should contain second annotation id, got: {md}"
);
let round_tripped = markdown_to_adf(&md).unwrap();
let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let marks = text_node.marks.as_ref().expect("should have marks");
let ann_marks: Vec<_> = marks
.iter()
.filter(|m| m.mark_type == "annotation")
.collect();
assert_eq!(
ann_marks.len(),
2,
"should have 2 annotation marks, got: {}",
ann_marks.len()
);
assert!(
marks.iter().any(|m| m.mark_type == "code"),
"should have code mark"
);
}
#[test]
fn underline_and_link_marks_both_preserved() {
// Underline + link should also coexist
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"click here",
vec![AdfMark::underline(), AdfMark::link("https://example.com")],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("underline"), "should have underline attr: {md}");
assert!(
md.contains("](https://example.com)"),
"should have link: {md}"
);
let round_tripped = markdown_to_adf(&md).unwrap();
let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let marks = text_node.marks.as_ref().expect("should have marks");
assert!(marks.iter().any(|m| m.mark_type == "underline"));
assert!(marks.iter().any(|m| m.mark_type == "link"));
}
#[test]
fn annotation_link_and_bold_all_preserved() {
// All three marks should coexist
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"important","marks":[
{"type":"annotation","attrs":{"annotationType":"inlineComment","id":"abc"}},
{"type":"link","attrs":{"href":"https://example.com"}},
{"type":"strong"}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let marks = text_node.marks.as_ref().expect("should have marks");
assert!(
marks.iter().any(|m| m.mark_type == "annotation"),
"should have annotation"
);
assert!(
marks.iter().any(|m| m.mark_type == "link"),
"should have link"
);
assert!(
marks.iter().any(|m| m.mark_type == "strong"),
"should have strong"
);
}
#[test]
fn multiple_annotation_marks_round_trip() {
// Issue #439: multiple annotation marks on same text node — all but last dropped
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"some annotated text","marks":[
{"type":"annotation","attrs":{"id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","annotationType":"inlineComment"}},
{"type":"annotation","attrs":{"id":"ffffffff-1111-2222-3333-444444444444","annotationType":"inlineComment"}}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
"JFM should contain first annotation id, got: {md}"
);
assert!(
md.contains("ffffffff-1111-2222-3333-444444444444"),
"JFM should contain second annotation id, got: {md}"
);
let round_tripped = markdown_to_adf(&md).unwrap();
let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
assert_eq!(text_node.text.as_deref(), Some("some annotated text"));
let marks = text_node.marks.as_ref().expect("should have marks");
let annotations: Vec<_> = marks
.iter()
.filter(|m| m.mark_type == "annotation")
.collect();
assert_eq!(
annotations.len(),
2,
"should have 2 annotation marks, got: {annotations:?}"
);
let ids: Vec<_> = annotations
.iter()
.map(|a| a.attrs.as_ref().unwrap()["id"].as_str().unwrap())
.collect();
assert!(ids.contains(&"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
assert!(ids.contains(&"ffffffff-1111-2222-3333-444444444444"));
}
#[test]
fn three_annotation_marks_round_trip() {
// Verify three overlapping annotations all survive
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"triple annotated",
vec![
AdfMark::annotation("id-1", "inlineComment"),
AdfMark::annotation("id-2", "inlineComment"),
AdfMark::annotation("id-3", "inlineComment"),
],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let marks = text_node.marks.as_ref().expect("should have marks");
let annotations: Vec<_> = marks
.iter()
.filter(|m| m.mark_type == "annotation")
.collect();
assert_eq!(
annotations.len(),
3,
"should have 3 annotation marks, got: {annotations:?}"
);
}
#[test]
fn multiple_annotations_with_bold_round_trip() {
// Multiple annotations + bold should all survive
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"bold double annotated",
vec![
AdfMark::strong(),
AdfMark::annotation("ann-a", "inlineComment"),
AdfMark::annotation("ann-b", "inlineComment"),
],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let marks = text_node.marks.as_ref().expect("should have marks");
assert!(
marks.iter().any(|m| m.mark_type == "strong"),
"should have strong mark"
);
let annotations: Vec<_> = marks
.iter()
.filter(|m| m.mark_type == "annotation")
.collect();
assert_eq!(
annotations.len(),
2,
"should have 2 annotation marks, got: {annotations:?}"
);
}
#[test]
fn multiple_annotations_with_link_round_trip() {
// Multiple annotations + link should all survive
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"linked text","marks":[
{"type":"annotation","attrs":{"id":"ann-x","annotationType":"inlineComment"}},
{"type":"annotation","attrs":{"id":"ann-y","annotationType":"inlineComment"}},
{"type":"link","attrs":{"href":"https://example.com"}}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
let marks = text_node.marks.as_ref().expect("should have marks");
assert!(
marks.iter().any(|m| m.mark_type == "link"),
"should have link mark"
);
let annotations: Vec<_> = marks
.iter()
.filter(|m| m.mark_type == "annotation")
.collect();
assert_eq!(
annotations.len(),
2,
"should have 2 annotation marks, got: {annotations:?}"
);
}
// ── Issue #471: annotation marks on non-text inline nodes ─────────
#[test]
fn annotation_on_emoji_round_trip() {
// Issue #471: annotation mark on emoji node should survive round-trip
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"emoji","attrs":{"id":"1f4dd","shortName":":memo:","text":"📝"},"marks":[
{"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
]},
{"type":"text","text":" annotated text","marks":[
{"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("annotation-id="),
"JFM should contain annotation-id for emoji, got: {md}"
);
let round_tripped = markdown_to_adf(&md).unwrap();
let nodes = round_tripped.content[0].content.as_ref().unwrap();
// Emoji node should retain annotation mark
let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
let emoji_marks = emoji_node.marks.as_ref().expect("emoji should have marks");
assert!(
emoji_marks.iter().any(|m| m.mark_type == "annotation"),
"emoji should have annotation mark, got: {emoji_marks:?}"
);
let ann = emoji_marks
.iter()
.find(|m| m.mark_type == "annotation")
.unwrap();
assert_eq!(
ann.attrs.as_ref().unwrap()["id"],
"ccddee11-2233-4455-aabb-ccddee112233"
);
// Text node should also retain annotation mark
let text_node = nodes.iter().find(|n| n.node_type == "text").unwrap();
let text_marks = text_node.marks.as_ref().expect("text should have marks");
assert!(
text_marks.iter().any(|m| m.mark_type == "annotation"),
"text should have annotation mark"
);
}
#[test]
fn annotation_on_status_round_trip() {
let mut status = AdfNode::status("In Progress", "blue");
status.marks = Some(vec![AdfMark::annotation("ann-status-1", "inlineComment")]);
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![status])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("annotation-id="),
"JFM should contain annotation-id for status, got: {md}"
);
let round_tripped = markdown_to_adf(&md).unwrap();
let nodes = round_tripped.content[0].content.as_ref().unwrap();
let status_node = nodes.iter().find(|n| n.node_type == "status").unwrap();
let marks = status_node
.marks
.as_ref()
.expect("status should have marks");
assert!(
marks.iter().any(|m| m.mark_type == "annotation"),
"status should have annotation mark, got: {marks:?}"
);
}
#[test]
fn annotation_on_date_round_trip() {
let mut date = AdfNode::date("1704067200000");
date.marks = Some(vec![AdfMark::annotation("ann-date-1", "inlineComment")]);
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![date])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("annotation-id="),
"JFM should contain annotation-id for date, got: {md}"
);
let round_tripped = markdown_to_adf(&md).unwrap();
let nodes = round_tripped.content[0].content.as_ref().unwrap();
let date_node = nodes.iter().find(|n| n.node_type == "date").unwrap();
let marks = date_node.marks.as_ref().expect("date should have marks");
assert!(
marks.iter().any(|m| m.mark_type == "annotation"),
"date should have annotation mark, got: {marks:?}"
);
}
#[test]
fn annotation_on_mention_round_trip() {
let mut mention = AdfNode::mention("user-123", "@Alice");
mention.marks = Some(vec![AdfMark::annotation("ann-mention-1", "inlineComment")]);
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![mention])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("annotation-id="),
"JFM should contain annotation-id for mention, got: {md}"
);
let round_tripped = markdown_to_adf(&md).unwrap();
let nodes = round_tripped.content[0].content.as_ref().unwrap();
let mention_node = nodes.iter().find(|n| n.node_type == "mention").unwrap();
let marks = mention_node
.marks
.as_ref()
.expect("mention should have marks");
assert!(
marks.iter().any(|m| m.mark_type == "annotation"),
"mention should have annotation mark, got: {marks:?}"
);
}
#[test]
fn annotation_on_inline_card_round_trip() {
let mut card = AdfNode::inline_card("https://example.com");
card.marks = Some(vec![AdfMark::annotation("ann-card-1", "inlineComment")]);
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![card])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("annotation-id="),
"JFM should contain annotation-id for inlineCard, got: {md}"
);
let round_tripped = markdown_to_adf(&md).unwrap();
let nodes = round_tripped.content[0].content.as_ref().unwrap();
let card_node = nodes.iter().find(|n| n.node_type == "inlineCard").unwrap();
let marks = card_node
.marks
.as_ref()
.expect("inlineCard should have marks");
assert!(
marks.iter().any(|m| m.mark_type == "annotation"),
"inlineCard should have annotation mark, got: {marks:?}"
);
}
#[test]
fn annotation_on_placeholder_round_trip() {
let mut placeholder = AdfNode::placeholder("Enter text here");
placeholder.marks = Some(vec![AdfMark::annotation("ann-ph-1", "inlineComment")]);
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![placeholder])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("annotation-id="),
"JFM should contain annotation-id for placeholder, got: {md}"
);
let round_tripped = markdown_to_adf(&md).unwrap();
let nodes = round_tripped.content[0].content.as_ref().unwrap();
let ph_node = nodes.iter().find(|n| n.node_type == "placeholder").unwrap();
let marks = ph_node
.marks
.as_ref()
.expect("placeholder should have marks");
assert!(
marks.iter().any(|m| m.mark_type == "annotation"),
"placeholder should have annotation mark, got: {marks:?}"
);
}
#[test]
fn multiple_annotations_on_emoji_round_trip() {
// Multiple annotation marks on a single emoji node
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"emoji","attrs":{"shortName":":fire:","text":"🔥"},"marks":[
{"type":"annotation","attrs":{"id":"ann-1","annotationType":"inlineComment"}},
{"type":"annotation","attrs":{"id":"ann-2","annotationType":"inlineComment"}}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let nodes = round_tripped.content[0].content.as_ref().unwrap();
let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
let marks = emoji_node.marks.as_ref().expect("emoji should have marks");
let annotations: Vec<_> = marks
.iter()
.filter(|m| m.mark_type == "annotation")
.collect();
assert_eq!(
annotations.len(),
2,
"emoji should have 2 annotation marks, got: {annotations:?}"
);
}
#[test]
fn emoji_without_annotation_unchanged() {
// Ensure emoji nodes without annotation marks are not affected
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::emoji(":fire:")])],
};
let md = adf_to_markdown(&doc).unwrap();
// Should NOT have bracketed span wrapping
assert!(
!md.contains('['),
"emoji without annotation should not be wrapped in brackets, got: {md}"
);
assert!(md.contains(":fire:"));
}
// ── Inline directive tests (Tier 4) ───────────────────────────────
#[test]
fn status_directive() {
let doc = markdown_to_adf("The ticket is :status[In Progress]{color=blue}.").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content[1].node_type, "status");
assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "In Progress");
assert_eq!(content[1].attrs.as_ref().unwrap()["color"], "blue");
}
#[test]
fn adf_status_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::status("Done", "green")])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains(":status[Done]{color=green}"));
}
#[test]
fn round_trip_status() {
let md = "The ticket is :status[In Progress]{color=blue}.\n";
let doc = markdown_to_adf(md).unwrap();
let result = adf_to_markdown(&doc).unwrap();
assert!(result.contains(":status[In Progress]{color=blue}"));
}
#[test]
fn status_with_style_and_localid_roundtrips() {
let adf = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![{
let mut node = AdfNode::status("open", "green");
node.attrs.as_mut().unwrap()["style"] =
serde_json::Value::String("bold".to_string());
node.attrs.as_mut().unwrap()["localId"] =
serde_json::Value::String("d2205ca5-84b9-4950-a730-bfe550fc146b".to_string());
node
}])],
};
let md = adf_to_markdown(&adf).unwrap();
assert!(
md.contains("style=bold"),
"Markdown should contain style attr: {md}"
);
assert!(
md.contains("localId=d2205ca5"),
"Markdown should contain localId attr: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let status = &rt.content[0].content.as_ref().unwrap()[0];
let attrs = status.attrs.as_ref().unwrap();
assert_eq!(attrs["text"], "open");
assert_eq!(attrs["color"], "green");
assert_eq!(attrs["style"], "bold");
assert_eq!(
attrs["localId"], "d2205ca5-84b9-4950-a730-bfe550fc146b",
"localId should be preserved, got: {}",
attrs["localId"]
);
}
#[test]
fn status_without_style_still_works() {
let md = ":status[Done]{color=green}\n";
let doc = markdown_to_adf(md).unwrap();
let status = &doc.content[0].content.as_ref().unwrap()[0];
let attrs = status.attrs.as_ref().unwrap();
assert_eq!(attrs["text"], "Done");
assert_eq!(attrs["color"], "green");
// No style attr — should not be present
assert!(
attrs.get("style").is_none() || attrs["style"].is_null(),
"style should not be set when not provided"
);
}
#[test]
fn strip_local_ids_removes_localid_from_status() {
let adf = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![{
let mut node = AdfNode::status("open", "green");
node.attrs.as_mut().unwrap()["localId"] =
serde_json::Value::String("real-uuid-here".to_string());
node
}])],
};
let opts = RenderOptions {
strip_local_ids: true,
};
let md = adf_to_markdown_with_options(&adf, &opts).unwrap();
assert!(
!md.contains("localId"),
"localId should be stripped, got: {md}"
);
assert!(md.contains("color=green"), "color should be preserved");
}
#[test]
fn strip_local_ids_removes_localid_from_table() {
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"}]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let opts = RenderOptions {
strip_local_ids: true,
};
let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
assert!(
!md.contains("localId"),
"localId should be stripped from table, got: {md}"
);
assert!(md.contains("layout=default"), "layout should be preserved");
}
#[test]
fn default_options_preserve_localid() {
let adf = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![{
let mut node = AdfNode::status("open", "green");
node.attrs.as_mut().unwrap()["localId"] =
serde_json::Value::String("real-uuid-here".to_string());
node
}])],
};
let md = adf_to_markdown(&adf).unwrap();
assert!(
md.contains("localId=real-uuid-here"),
"Default should preserve localId, got: {md}"
);
}
#[test]
fn mention_localid_roundtrip() {
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("localId=m-001"),
"mention should have localId in md: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let mention = &rt.content[0].content.as_ref().unwrap()[0];
assert_eq!(mention.attrs.as_ref().unwrap()["localId"], "m-001");
}
#[test]
fn date_localid_roundtrip() {
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("localId=d-001"),
"date should have localId in md: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let date = &rt.content[0].content.as_ref().unwrap()[0];
assert_eq!(date.attrs.as_ref().unwrap()["localId"], "d-001");
}
#[test]
fn emoji_localid_roundtrip() {
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"emoji","attrs":{"shortName":":smile:","localId":"e-001"}}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("localId=e-001"),
"emoji should have localId in md: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let emoji = &rt.content[0].content.as_ref().unwrap()[0];
assert_eq!(emoji.attrs.as_ref().unwrap()["localId"], "e-001");
}
#[test]
fn inline_card_localid_roundtrip() {
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"inlineCard","attrs":{"url":"https://example.com","localId":"c-001"}}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("localId=c-001"),
"inlineCard should have localId in md: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let card = &rt.content[0].content.as_ref().unwrap()[0];
assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-001");
}
#[test]
fn strip_local_ids_removes_from_mention() {
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let opts = RenderOptions {
strip_local_ids: true,
};
let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
assert!(
!md.contains("localId"),
"localId should be stripped from mention: {md}"
);
assert!(md.contains("id=user123"), "other attrs should be preserved");
}
#[test]
fn strip_local_ids_removes_from_date() {
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let opts = RenderOptions {
strip_local_ids: true,
};
let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
assert!(
!md.contains("localId"),
"localId should be stripped from date: {md}"
);
}
#[test]
fn strip_local_ids_removes_from_block_attrs() {
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"hello"}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let opts = RenderOptions {
strip_local_ids: true,
};
let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
assert!(
!md.contains("localId"),
"localId should be stripped from block attrs: {md}"
);
}
#[test]
fn table_cell_localid_roundtrip() {
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"}]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("localId=tc-001"),
"tableCell should have localId in md: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let cell = &rt.content[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap()[0];
assert_eq!(
cell.attrs.as_ref().unwrap()["localId"],
"tc-001",
"tableCell localId should round-trip"
);
}
#[test]
fn table_cell_border_mark_roundtrip() {
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"}]}]}]}]}]}"##;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("border-color=#ff000033"),
"tableCell should have border-color in md: {md}"
);
assert!(
md.contains("border-size=2"),
"tableCell should have border-size in md: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let cell = &rt.content[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap()[0];
let marks = cell.marks.as_ref().expect("tableCell should have marks");
assert_eq!(marks.len(), 1);
assert_eq!(marks[0].mark_type, "border");
let attrs = marks[0].attrs.as_ref().unwrap();
assert_eq!(attrs["color"], "#ff000033");
assert_eq!(attrs["size"], 2);
}
#[test]
fn table_header_border_mark_roundtrip() {
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"}]}]}]}]}]}"##;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("border-color=#0000ff"), "md: {md}");
assert!(md.contains("border-size=3"), "md: {md}");
let rt = markdown_to_adf(&md).unwrap();
let cell = &rt.content[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap()[0];
assert_eq!(cell.node_type, "tableHeader");
let marks = cell.marks.as_ref().expect("tableHeader should have marks");
assert_eq!(marks[0].mark_type, "border");
assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#0000ff");
assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
}
#[test]
fn table_cell_border_mark_with_attrs_roundtrip() {
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"}]}]}]}]}]}"##;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("bg=#e6fcff"), "md: {md}");
assert!(md.contains("colspan=2"), "md: {md}");
assert!(md.contains("border-color=#ff000033"), "md: {md}");
let rt = markdown_to_adf(&md).unwrap();
let cell = &rt.content[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap()[0];
assert_eq!(cell.attrs.as_ref().unwrap()["background"], "#e6fcff");
assert_eq!(cell.attrs.as_ref().unwrap()["colspan"], 2);
let marks = cell.marks.as_ref().expect("should have marks");
assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff000033");
}
#[test]
fn table_cell_no_border_mark_unchanged() {
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"}]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
!md.contains("border-color"),
"no border attrs expected: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let cell = &rt.content[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap()[0];
assert!(cell.marks.is_none(), "no marks expected on plain cell");
}
#[test]
fn table_cell_border_size_only_defaults_color() {
// border-size without border-color should still produce a border mark
// with the default color
let md = "::::table\n:::tr\n:::td{border-size=3}\ncell\n:::\n:::\n::::\n";
let doc = markdown_to_adf(md).unwrap();
let cell = &doc.content[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap()[0];
let marks = cell.marks.as_ref().expect("should have border mark");
assert_eq!(marks[0].mark_type, "border");
assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
}
#[test]
fn table_cell_border_color_only_defaults_size() {
// border-color without border-size should default size to 1
let md = "::::table\n:::tr\n:::td{border-color=#ff0000}\ncell\n:::\n:::\n::::\n";
let doc = markdown_to_adf(md).unwrap();
let cell = &doc.content[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap()[0];
let marks = cell.marks.as_ref().expect("should have border mark");
assert_eq!(marks[0].mark_type, "border");
assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
}
#[test]
fn media_file_border_mark_roundtrip() {
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}}]}]}]}"##;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("border-color=#091e4224"),
"media should have border-color in md: {md}"
);
assert!(
md.contains("border-size=2"),
"media should have border-size in md: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let media_single = &rt.content[0];
let media = &media_single.content.as_ref().unwrap()[0];
assert_eq!(media.node_type, "media");
let marks = media.marks.as_ref().expect("media should have marks");
assert_eq!(marks.len(), 1);
assert_eq!(marks[0].mark_type, "border");
let attrs = marks[0].attrs.as_ref().unwrap();
assert_eq!(attrs["color"], "#091e4224");
assert_eq!(attrs["size"], 2);
}
#[test]
fn media_external_border_mark_roundtrip() {
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}}]}]}]}"##;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("border-color=#ff0000"),
"external media should have border-color in md: {md}"
);
assert!(
md.contains("border-size=3"),
"external media should have border-size in md: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let media = &rt.content[0].content.as_ref().unwrap()[0];
let marks = media.marks.as_ref().expect("media should have marks");
assert_eq!(marks[0].mark_type, "border");
assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
}
#[test]
fn media_file_no_border_mark_unchanged() {
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}}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
!md.contains("border-color"),
"no border attrs expected: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let media = &rt.content[0].content.as_ref().unwrap()[0];
assert!(media.marks.is_none(), "no marks expected on plain media");
}
#[test]
fn media_border_size_only_defaults_color() {
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}}]}]}]}"##;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("border-size=4"), "md: {md}");
let rt = markdown_to_adf(&md).unwrap();
let media = &rt.content[0].content.as_ref().unwrap()[0];
let marks = media.marks.as_ref().expect("should have border mark");
assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 4);
}
#[test]
fn media_border_color_only_defaults_size() {
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"}}]}]}]}"##;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("border-color=#00ff00"), "md: {md}");
let rt = markdown_to_adf(&md).unwrap();
let media = &rt.content[0].content.as_ref().unwrap()[0];
let marks = media.marks.as_ref().expect("should have border mark");
assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#00ff00");
assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
}
#[test]
fn media_border_with_other_attrs_roundtrip() {
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}}]}]}]}"##;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("layout=wide"), "md: {md}");
assert!(md.contains("mediaWidth=600"), "md: {md}");
assert!(md.contains("border-color=#091e4224"), "md: {md}");
assert!(md.contains("border-size=2"), "md: {md}");
let rt = markdown_to_adf(&md).unwrap();
let ms = &rt.content[0];
assert_eq!(ms.attrs.as_ref().unwrap()["layout"], "wide");
let media = &ms.content.as_ref().unwrap()[0];
let marks = media.marks.as_ref().expect("should have marks");
assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#091e4224");
assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 2);
}
#[test]
fn table_row_localid_roundtrip() {
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"}]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("localId=tr-001"),
"tableRow should have localId in md: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let row = &rt.content[0].content.as_ref().unwrap()[0];
assert_eq!(
row.attrs.as_ref().unwrap()["localId"],
"tr-001",
"tableRow localId should round-trip"
);
}
#[test]
fn list_item_localid_roundtrip() {
// listItem localId is emitted as trailing inline attrs and parsed back
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"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("localId=li-001"),
"listItem should have localId in md: {md}"
);
// Verify localId is on the listItem, NOT promoted to bulletList
let rt = markdown_to_adf(&md).unwrap();
let list = &rt.content[0];
assert!(
list.attrs.is_none() || list.attrs.as_ref().unwrap().get("localId").is_none(),
"bulletList should NOT have localId: {:?}",
list.attrs
);
let item = &list.content.as_ref().unwrap()[0];
assert_eq!(
item.attrs.as_ref().unwrap()["localId"],
"li-001",
"listItem should have localId=li-001"
);
}
#[test]
fn list_item_localid_not_promoted_to_parent() {
// Verify localId stays on listItem and doesn't leak to parent list
let md = "- item {localId=li-002}\n";
let doc = markdown_to_adf(md).unwrap();
let list = &doc.content[0];
assert!(
list.attrs.is_none(),
"bulletList should have no attrs: {:?}",
list.attrs
);
let item = &list.content.as_ref().unwrap()[0];
assert_eq!(item.attrs.as_ref().unwrap()["localId"], "li-002");
}
#[test]
fn ordered_list_item_localid_roundtrip() {
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"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("localId=oli-001"), "md: {md}");
let rt = markdown_to_adf(&md).unwrap();
let item = &rt.content[0].content.as_ref().unwrap()[0];
assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
}
#[test]
fn task_item_localid_roundtrip() {
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"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("localId=ti-001"), "md: {md}");
let rt = markdown_to_adf(&md).unwrap();
let item = &rt.content[0].content.as_ref().unwrap()[0];
assert_eq!(item.attrs.as_ref().unwrap()["localId"], "ti-001");
}
/// Issue #447: taskList with empty-string localId and taskItems with
/// short numeric localIds must survive a full round-trip.
#[test]
fn task_list_short_localid_roundtrip() {
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"}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
// Both taskItem localIds should appear in the markdown
assert!(md.contains("localId=42"), "localId=42 missing: {md}");
assert!(md.contains("localId=99"), "localId=99 missing: {md}");
// Empty-string localId should NOT appear as {localId=}
assert!(
!md.contains("localId=}"),
"empty localId should not be emitted: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let task_list = &rt.content[0];
assert_eq!(task_list.node_type, "taskList");
// No spurious extra nodes from {localId=}
assert_eq!(rt.content.len(), 1, "should be exactly one top-level node");
let items = task_list.content.as_ref().unwrap();
assert_eq!(items.len(), 2);
// First taskItem: localId=42, state=TODO, no content
assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
assert!(
items[0].content.is_none(),
"empty taskItem should have no content: {:?}",
items[0].content
);
// Second taskItem: localId=99, state=DONE, content with text
assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "99");
assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
let content = items[1].content.as_ref().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0].text.as_deref(), Some("done task"));
}
/// Issue #507: numeric localId on taskItem with hardBreak must survive
/// round-trip — the {localId=…} suffix lands on the continuation line
/// and must still be extracted by the parser.
#[test]
fn task_item_numeric_localid_with_hardbreak_roundtrip() {
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!!)"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
// localId must appear in the markdown output
assert!(md.contains("localId=42"), "localId=42 missing: {md}");
// Round-trip back to ADF
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(rt.content.len(), 1, "exactly one top-level node");
let task_list = &rt.content[0];
assert_eq!(task_list.node_type, "taskList");
let items = task_list.content.as_ref().unwrap();
assert_eq!(items.len(), 1);
// localId preserved
assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
// Content structure preserved: paragraph with link + hardBreak + text
let para = &items[0].content.as_ref().unwrap()[0];
assert_eq!(para.node_type, "paragraph");
let inlines = para.content.as_ref().unwrap();
assert_eq!(inlines[0].node_type, "text");
assert_eq!(
inlines[0].text.as_deref(),
Some("Engineering Onboarding Link")
);
assert_eq!(inlines[1].node_type, "hardBreak");
assert_eq!(inlines[2].node_type, "text");
assert_eq!(
inlines[2].text.as_deref(),
Some("(This has links to all the various useful tools!!)")
);
// The {localId=…} must not appear as literal text in the ADF output
let rt_json = serde_json::to_string(&rt).unwrap();
assert!(
!rt_json.contains("{localId="),
"localId attr syntax should not leak into ADF text: {rt_json}"
);
}
/// Issue #507: multiple taskItems with hardBreaks and numeric localIds.
#[test]
fn task_item_multiple_hardbreak_localids_roundtrip() {
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"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("localId=42"), "localId=42 missing: {md}");
assert!(md.contains("localId=67"), "localId=67 missing: {md}");
let rt = markdown_to_adf(&md).unwrap();
let items = rt.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "67");
// Verify hardBreak content structure for both items
for item in items {
let para = &item.content.as_ref().unwrap()[0];
assert_eq!(para.node_type, "paragraph");
let inlines = para.content.as_ref().unwrap();
assert_eq!(inlines[1].node_type, "hardBreak");
}
}
/// Issue #521: sibling taskItems with numeric localIds and hardBreak —
/// unwrapped inline content. The hardBreak continuation line must be
/// indented so it stays within the list item, and both localIds must
/// survive the round-trip.
#[test]
fn task_item_sibling_localid_hardbreak_unwrapped_roundtrip() {
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"}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
// Continuation line must be indented
assert!(
md.contains(" (parenthetical"),
"continuation line should be 2-space indented: {md}"
);
assert!(md.contains("localId=42"), "localId=42 missing: {md}");
assert!(md.contains("localId=69"), "localId=69 missing: {md}");
let rt = markdown_to_adf(&md).unwrap();
// Must remain a single taskList with 2 items
assert_eq!(
rt.content.len(),
1,
"should be one taskList: {:#?}",
rt.content
);
assert_eq!(rt.content[0].node_type, "taskList");
let items = rt.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 2, "should have 2 taskItems");
assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
// Verify first item has hardBreak
let first_content = items[0].content.as_ref().unwrap();
assert!(
first_content.iter().any(|n| n.node_type == "hardBreak"),
"first item should contain hardBreak"
);
// Verify second item content
let second_content = items[1].content.as_ref().unwrap();
assert_eq!(second_content[0].node_type, "text");
assert_eq!(
second_content[0].text.as_deref().unwrap(),
"second task item"
);
}
/// Issue #521: sibling taskItems with paragraph-wrapped content and
/// hardBreak — localIds must not be swapped or lost.
#[test]
fn task_item_sibling_localid_hardbreak_paragraph_roundtrip() {
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"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(
rt.content.len(),
1,
"should be one taskList: {:#?}",
rt.content
);
let items = rt.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
}
/// Issue #521: three sibling taskItems — the middle one has a hardBreak.
/// Ensures localIds don't leak between adjacent items.
#[test]
fn task_item_three_siblings_middle_hardbreak_roundtrip() {
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"}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(rt.content.len(), 1);
let items = rt.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 3);
assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "10");
assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "20");
assert_eq!(items[2].attrs.as_ref().unwrap()["localId"], "30");
// Middle item should have hardBreak
let mid_content = items[1].content.as_ref().unwrap();
assert!(mid_content.iter().any(|n| n.node_type == "hardBreak"));
}
/// Issue #447: regression — taskList with empty localId must not inject
/// a spurious paragraph.
#[test]
fn task_list_empty_localid_no_spurious_paragraph() {
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"}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
!md.contains("{localId=}"),
"empty localId should not be emitted: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(
rt.content.len(),
1,
"no spurious paragraph: {:#?}",
rt.content
);
assert_eq!(rt.content[0].node_type, "taskList");
}
/// Issue #447: taskList localId should be stripped when strip_local_ids is set.
#[test]
fn task_list_localid_stripped() {
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"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let opts = RenderOptions {
strip_local_ids: true,
};
let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
assert!(!md.contains("localId"), "localId should be stripped: {md}");
}
/// Issue #447: taskItem with no content still emits localId.
#[test]
fn task_item_no_content_emits_localid() {
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"}}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("localId=abc"),
"localId should be emitted even without content: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let item = &rt.content[0].content.as_ref().unwrap()[0];
assert_eq!(item.attrs.as_ref().unwrap()["localId"], "abc");
assert!(item.content.is_none(), "should have no content");
}
/// Issue #447: taskList localId roundtrips through block attrs.
#[test]
fn task_list_localid_roundtrip() {
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"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("localId=tl-xyz"),
"taskList localId missing: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(
rt.content[0].attrs.as_ref().unwrap()["localId"],
"tl-xyz",
"taskList localId should survive round-trip"
);
}
/// Issue #478: taskItem with paragraph wrapper (no localId) preserves wrapper on round-trip.
#[test]
fn task_item_paragraph_wrapper_roundtrip_no_localid() {
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"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("paraLocalId=_"),
"should emit paraLocalId=_ sentinel: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let item = &rt.content[0].content.as_ref().unwrap()[0];
let content = item.content.as_ref().unwrap();
assert_eq!(content.len(), 1, "should have one child: {content:#?}");
assert_eq!(
content[0].node_type, "paragraph",
"child should be a paragraph: {content:#?}"
);
let para_content = content[0].content.as_ref().unwrap();
assert_eq!(
para_content[0].text.as_deref(),
Some("A task with paragraph wrapper")
);
// Paragraph should have no attrs (localId was absent in the original)
assert!(
content[0].attrs.is_none(),
"paragraph should have no attrs: {:?}",
content[0].attrs
);
}
/// Issue #478: taskItem with paragraph wrapper AND paraLocalId preserves both.
#[test]
fn task_item_paragraph_wrapper_roundtrip_with_localid() {
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"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("paraLocalId=p-001"),
"should emit paraLocalId=p-001: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let item = &rt.content[0].content.as_ref().unwrap()[0];
let content = item.content.as_ref().unwrap();
assert_eq!(content[0].node_type, "paragraph");
assert_eq!(
content[0].attrs.as_ref().unwrap()["localId"],
"p-001",
"paragraph localId should be preserved"
);
}
/// Issue #478: taskItem WITHOUT paragraph wrapper (unwrapped inline) still round-trips correctly.
#[test]
fn task_item_unwrapped_inline_no_paragraph_on_roundtrip() {
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"}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
!md.contains("paraLocalId"),
"should NOT emit paraLocalId for unwrapped inline: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let item = &rt.content[0].content.as_ref().unwrap()[0];
let content = item.content.as_ref().unwrap();
assert_eq!(
content[0].node_type, "text",
"should remain unwrapped: {content:#?}"
);
}
/// Issue #478: DONE taskItem with paragraph wrapper round-trips.
#[test]
fn task_item_done_paragraph_wrapper_roundtrip() {
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"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("- [x]"), "should render as done: {md}");
let rt = markdown_to_adf(&md).unwrap();
let item = &rt.content[0].content.as_ref().unwrap()[0];
assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
let content = item.content.as_ref().unwrap();
assert_eq!(content[0].node_type, "paragraph");
}
/// Issue #478: mixed taskItems — some with paragraph wrapper, some without.
#[test]
fn task_item_mixed_paragraph_and_unwrapped_roundtrip() {
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"}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let items = rt.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 2);
// First item: paragraph wrapper preserved
let c1 = items[0].content.as_ref().unwrap();
assert_eq!(
c1[0].node_type, "paragraph",
"first item should have paragraph wrapper"
);
// Second item: no paragraph wrapper
let c2 = items[1].content.as_ref().unwrap();
assert_eq!(
c2[0].node_type, "text",
"second item should remain unwrapped"
);
}
/// Issue #478: taskItem with paragraph wrapper containing marks round-trips.
#[test]
fn task_item_paragraph_wrapper_with_marks_roundtrip() {
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"}]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let item = &rt.content[0].content.as_ref().unwrap()[0];
let content = item.content.as_ref().unwrap();
assert_eq!(content[0].node_type, "paragraph");
let para_children = content[0].content.as_ref().unwrap();
assert!(
para_children.len() >= 2,
"paragraph should contain multiple inline nodes"
);
}
/// Issue #478: strip_local_ids suppresses the paraLocalId=_ sentinel too.
#[test]
fn task_item_paragraph_wrapper_stripped_with_option() {
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"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let opts = RenderOptions {
strip_local_ids: true,
};
let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
assert!(
!md.contains("paraLocalId"),
"paraLocalId should be stripped: {md}"
);
assert!(
!md.contains("localId"),
"all localIds should be stripped: {md}"
);
}
#[test]
fn trailing_space_preserved_with_hex_localid() {
// Issue #449: trailing whitespace stripped from text node
// when listItem has a hex-format localId (no hyphens)
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 "}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let item = &rt.content[0].content.as_ref().unwrap()[0];
assert_eq!(
item.attrs.as_ref().unwrap()["localId"],
"aabb112233cc",
"localId should round-trip"
);
let para = &item.content.as_ref().unwrap()[0];
let inlines = para.content.as_ref().unwrap();
let last = inlines.last().unwrap();
assert!(
last.text.as_deref().unwrap_or("").ends_with(' '),
"trailing space should be preserved, got nodes: {:?}",
inlines
.iter()
.map(|n| (&n.node_type, &n.text))
.collect::<Vec<_>>()
);
}
#[test]
fn extract_trailing_local_id_preserves_trailing_space() {
// Issue #449: only strip the single separator space before {localId=...}
let (before, lid, _) = extract_trailing_local_id("trailing space {localId=aabb112233cc}");
assert_eq!(before, "trailing space ");
assert_eq!(lid.as_deref(), Some("aabb112233cc"));
}
#[test]
fn extract_trailing_local_id_no_trailing_space() {
let (before, lid, _) = extract_trailing_local_id("text {localId=abc123}");
assert_eq!(before, "text");
assert_eq!(lid.as_deref(), Some("abc123"));
}
#[test]
fn extract_trailing_local_id_no_attrs() {
let (before, lid, pid) = extract_trailing_local_id("plain text");
assert_eq!(before, "plain text");
assert!(lid.is_none());
assert!(pid.is_none());
}
#[test]
fn list_item_localid_stripped() {
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"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let opts = RenderOptions {
strip_local_ids: true,
};
let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
assert!(!md.contains("localId"), "localId should be stripped: {md}");
}
#[test]
fn paragraph_localid_in_list_item_roundtrip() {
// Issue #417: paragraph.attrs.localId dropped in listItem context
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"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("paraLocalId=para-001"),
"paragraph localId should be in md: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let item = &rt.content[0].content.as_ref().unwrap()[0];
assert_eq!(
item.attrs.as_ref().unwrap()["localId"],
"item-001",
"listItem localId should survive"
);
let para = &item.content.as_ref().unwrap()[0];
assert_eq!(
para.attrs.as_ref().unwrap()["localId"],
"para-001",
"paragraph localId should survive round-trip"
);
}
#[test]
fn paragraph_localid_in_ordered_list_item_roundtrip() {
// Issue #417: paragraph localId in ordered list
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"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("paraLocalId=op-001"), "md: {md}");
let rt = markdown_to_adf(&md).unwrap();
let item = &rt.content[0].content.as_ref().unwrap()[0];
assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
let para = &item.content.as_ref().unwrap()[0];
assert_eq!(para.attrs.as_ref().unwrap()["localId"], "op-001");
}
#[test]
fn paragraph_localid_only_in_list_item() {
// paragraph has localId but listItem does not
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"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("paraLocalId=para-only"),
"paragraph localId should be emitted: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let item = &rt.content[0].content.as_ref().unwrap()[0];
assert!(item.attrs.is_none(), "listItem should have no attrs");
let para = &item.content.as_ref().unwrap()[0];
assert_eq!(para.attrs.as_ref().unwrap()["localId"], "para-only");
}
#[test]
fn paragraph_localid_in_table_header_roundtrip() {
// Issue #417: paragraph.attrs.localId dropped in tableHeader context
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"}]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
// Should use directive form (not pipe table) to preserve paragraph localId
assert!(
md.contains("localId=aaaa-aaaa"),
"paragraph localId should be in md: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let cell = &rt.content[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap()[0];
let para = &cell.content.as_ref().unwrap()[0];
assert_eq!(
para.attrs.as_ref().unwrap()["localId"],
"aaaa-aaaa",
"paragraph localId should survive round-trip in tableHeader"
);
}
#[test]
fn paragraph_localid_in_table_cell_roundtrip() {
// Issue #417: paragraph localId in tableCell forces directive table
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"}]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("localId=cell-para"),
"paragraph localId should be in md: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
// Data row -> cell -> paragraph
let cell = &rt.content[0].content.as_ref().unwrap()[1]
.content
.as_ref()
.unwrap()[0];
let para = &cell.content.as_ref().unwrap()[0];
assert_eq!(
para.attrs.as_ref().unwrap()["localId"],
"cell-para",
"paragraph localId should survive round-trip in tableCell"
);
}
#[test]
fn nbsp_paragraph_with_localid_roundtrip() {
// Issue #417: nbsp paragraph localId emitted as text instead of attrs
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"nbsp-para"},"content":[{"type":"text","text":"\u00a0"}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("::paragraph["),
"nbsp should use directive form: {md}"
);
assert!(
md.contains("localId=nbsp-para"),
"localId should be in directive: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let para = &rt.content[0];
assert_eq!(
para.attrs.as_ref().unwrap()["localId"],
"nbsp-para",
"localId should survive round-trip"
);
let text = para.content.as_ref().unwrap()[0].text.as_ref().unwrap();
assert_eq!(text, "\u{00a0}", "nbsp should survive");
}
#[test]
fn empty_paragraph_with_localid_roundtrip() {
// Empty paragraph directive with localId
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"empty-para"}}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("::paragraph{localId=empty-para}"),
"empty paragraph should include localId in directive: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(
rt.content[0].attrs.as_ref().unwrap()["localId"],
"empty-para"
);
}
#[test]
fn paragraph_localid_stripped_from_list_item() {
// strip_local_ids should also strip paraLocalId
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"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let opts = RenderOptions {
strip_local_ids: true,
};
let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
assert!(!md.contains("localId"), "localId should be stripped: {md}");
assert!(
!md.contains("paraLocalId"),
"paraLocalId should be stripped: {md}"
);
}
#[test]
fn date_directive() {
let doc = markdown_to_adf("Due by :date[2026-04-15].").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content[1].node_type, "date");
// ISO date is converted to epoch milliseconds
assert_eq!(
content[1].attrs.as_ref().unwrap()["timestamp"],
"1776211200000"
);
}
#[test]
fn adf_date_to_markdown() {
// ADF dates use epoch ms; renderer converts back to ISO with timestamp attr
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::date("1776211200000")])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains(":date[2026-04-15]{timestamp=1776211200000}"));
}
#[test]
fn adf_date_iso_passthrough() {
// If ADF already has ISO date (legacy), pass through
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::date("2026-04-15")])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains(":date[2026-04-15]{timestamp=2026-04-15}"));
}
#[test]
fn round_trip_date() {
let md = "Due by :date[2026-04-15].\n";
let doc = markdown_to_adf(md).unwrap();
let result = adf_to_markdown(&doc).unwrap();
assert!(result.contains(":date[2026-04-15]"));
}
#[test]
fn round_trip_date_non_midnight_timestamp() {
// Issue #409: non-midnight timestamps must survive round-trip
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000"}}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
// JFM should include the original timestamp
assert!(
md.contains("timestamp=1700000000000"),
"JFM should preserve original timestamp: {md}"
);
// Round-trip back to ADF
let doc2 = markdown_to_adf(&md).unwrap();
let content = doc2.content[0].content.as_ref().unwrap();
assert_eq!(
content[0].attrs.as_ref().unwrap()["timestamp"],
"1700000000000",
"Round-trip must preserve original non-midnight timestamp"
);
}
#[test]
fn date_epoch_ms_passthrough() {
// If JFM date is already epoch ms, pass through
let doc = markdown_to_adf("Due by :date[1776211200000].").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(
content[1].attrs.as_ref().unwrap()["timestamp"],
"1776211200000"
);
}
#[test]
fn date_timestamp_attr_preferred_over_content() {
// When timestamp attr is present, it takes priority over the display date
let md = ":date[2023-11-14]{timestamp=1700000000000}\n";
let doc = markdown_to_adf(md).unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(
content[0].attrs.as_ref().unwrap()["timestamp"],
"1700000000000",
"timestamp attr should be used directly"
);
}
#[test]
fn date_without_timestamp_attr_backward_compat() {
// Legacy JFM without timestamp attr still works via iso_date_to_epoch_ms
let md = ":date[2026-04-15]\n";
let doc = markdown_to_adf(md).unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(
content[0].attrs.as_ref().unwrap()["timestamp"],
"1776211200000",
"Should fall back to computing timestamp from date string"
);
}
#[test]
fn date_with_local_id_and_timestamp() {
// Both localId and timestamp should round-trip
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("timestamp=1700000000000"),
"Should contain timestamp: {md}"
);
assert!(md.contains("localId=d-001"), "Should contain localId: {md}");
// Round-trip
let doc2 = markdown_to_adf(&md).unwrap();
let content = doc2.content[0].content.as_ref().unwrap();
let attrs = content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["timestamp"], "1700000000000");
assert_eq!(attrs["localId"], "d-001");
}
#[test]
fn mention_directive() {
let doc = markdown_to_adf("Assigned to :mention[Alice]{id=abc123}.").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content[1].node_type, "mention");
assert_eq!(content[1].attrs.as_ref().unwrap()["id"], "abc123");
assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "Alice");
}
#[test]
fn adf_mention_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::mention(
"abc123", "Alice",
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains(":mention[Alice]{id=abc123}"));
}
#[test]
fn round_trip_mention() {
let md = "Assigned to :mention[Alice]{id=abc123}.\n";
let doc = markdown_to_adf(md).unwrap();
let result = adf_to_markdown(&doc).unwrap();
assert!(result.contains(":mention[Alice]{id=abc123}"));
}
#[test]
fn mention_with_empty_access_level_round_trips() {
// Issue #363: accessLevel="" produces accessLevel= which failed to parse
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"mention","attrs":{"id":"61921b41c15977006af2b1d1","text":"@Javier Inchausti","accessLevel":""}}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let mention = &round_tripped.content[0].content.as_ref().unwrap()[0];
assert_eq!(
mention.node_type, "mention",
"mention with empty accessLevel was not parsed as mention, got: {}",
mention.node_type
);
}
#[test]
fn span_with_color() {
let doc = markdown_to_adf("This is :span[red text]{color=#ff5630}.").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content[1].node_type, "text");
assert_eq!(content[1].text.as_deref(), Some("red text"));
let marks = content[1].marks.as_ref().unwrap();
assert_eq!(marks[0].mark_type, "textColor");
}
#[test]
fn adf_text_color_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"red text",
vec![AdfMark::text_color("#ff5630")],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains(":span[red text]{color=#ff5630}"));
}
#[test]
fn round_trip_span_color() {
let md = "This is :span[red text]{color=#ff5630}.\n";
let doc = markdown_to_adf(md).unwrap();
let result = adf_to_markdown(&doc).unwrap();
assert!(result.contains(":span[red text]{color=#ff5630}"));
}
#[test]
fn text_color_and_link_marks_both_preserved() {
// Issue #405: text with both textColor and link marks loses link on round-trip
let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"red link","marks":[
{"type":"link","attrs":{"href":"https://example.com"}},
{"type":"textColor","attrs":{"color":"#ff0000"}}
]}
]}]}"##;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains(":span[red link]{color=#ff0000}"),
"JFM should contain span with color, got: {md}"
);
assert!(
md.contains("](https://example.com)"),
"JFM should contain link href, got: {md}"
);
// Full round-trip: both marks survive
let rt = markdown_to_adf(&md).unwrap();
let text_node = &rt.content[0].content.as_ref().unwrap()[0];
let marks = text_node.marks.as_ref().expect("should have marks");
assert!(
marks.iter().any(|m| m.mark_type == "textColor"),
"should have textColor mark, got: {:?}",
marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
);
assert!(
marks.iter().any(|m| m.mark_type == "link"),
"should have link mark, got: {:?}",
marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
);
// Verify attribute values survive
let link_mark = marks.iter().find(|m| m.mark_type == "link").unwrap();
assert_eq!(
link_mark.attrs.as_ref().unwrap()["href"],
"https://example.com"
);
let color_mark = marks.iter().find(|m| m.mark_type == "textColor").unwrap();
assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#ff0000");
}
#[test]
fn bg_color_and_link_marks_both_preserved() {
let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"highlighted link","marks":[
{"type":"link","attrs":{"href":"https://example.com"}},
{"type":"backgroundColor","attrs":{"color":"#ffff00"}}
]}
]}]}"##;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("bg=#ffff00"), "should have bg color: {md}");
assert!(
md.contains("](https://example.com)"),
"should have link: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let text_node = &rt.content[0].content.as_ref().unwrap()[0];
let marks = text_node.marks.as_ref().expect("should have marks");
assert!(marks.iter().any(|m| m.mark_type == "backgroundColor"));
assert!(marks.iter().any(|m| m.mark_type == "link"));
}
#[test]
fn text_color_link_and_strong_rendering() {
// Verify textColor + link + strong renders all three formatting elements
let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"bold red link","marks":[
{"type":"strong"},
{"type":"link","attrs":{"href":"https://example.com"}},
{"type":"textColor","attrs":{"color":"#ff0000"}}
]}
]}]}"##;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.starts_with("**") && md.trim().ends_with("**"),
"should have bold wrapping: {md}"
);
assert!(md.contains("color=#ff0000"), "should have color: {md}");
assert!(
md.contains("](https://example.com)"),
"should have link: {md}"
);
}
#[test]
fn subsup_and_link_marks_both_preserved() {
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"note","marks":[
{"type":"link","attrs":{"href":"https://example.com"}},
{"type":"subsup","attrs":{"type":"sup"}}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("sup"), "should have sup: {md}");
assert!(
md.contains("](https://example.com)"),
"should have link: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let text_node = &rt.content[0].content.as_ref().unwrap()[0];
let marks = text_node.marks.as_ref().expect("should have marks");
assert!(marks.iter().any(|m| m.mark_type == "subsup"));
assert!(marks.iter().any(|m| m.mark_type == "link"));
}
#[test]
fn text_color_without_link_unchanged() {
// Regression guard: textColor without link should still work
let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"just red","marks":[
{"type":"textColor","attrs":{"color":"#ff0000"}}
]}
]}]}"##;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains(":span[just red]{color=#ff0000}"), "md: {md}");
assert!(!md.contains("](http"), "should NOT have link syntax: {md}");
}
#[test]
fn inline_extension_directive() {
let doc =
markdown_to_adf("See :extension[fallback]{type=com.app key=widget} here.").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content[1].node_type, "inlineExtension");
assert_eq!(
content[1].attrs.as_ref().unwrap()["extensionType"],
"com.app"
);
assert_eq!(content[1].attrs.as_ref().unwrap()["extensionKey"], "widget");
}
#[test]
fn adf_inline_extension_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::inline_extension(
"com.app",
"widget",
Some("fallback"),
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains(":extension[fallback]{type=com.app key=widget}"));
}
// ── Helper function tests ──────────────────────────────────────────
#[test]
fn parse_ordered_list_marker_valid() {
let result = parse_ordered_list_marker("1. Hello");
assert_eq!(result, Some((1, "Hello")));
}
#[test]
fn parse_ordered_list_marker_high_number() {
let result = parse_ordered_list_marker("42. Item");
assert_eq!(result, Some((42, "Item")));
}
#[test]
fn parse_ordered_list_marker_not_a_list() {
assert!(parse_ordered_list_marker("not a list").is_none());
assert!(parse_ordered_list_marker("1.no space").is_none());
}
#[test]
fn is_list_start_various() {
assert!(is_list_start("- item"));
assert!(is_list_start("* item"));
assert!(is_list_start("+ item"));
assert!(is_list_start("1. item"));
assert!(!is_list_start("not a list"));
}
#[test]
fn is_horizontal_rule_various() {
assert!(is_horizontal_rule("---"));
assert!(is_horizontal_rule("***"));
assert!(is_horizontal_rule("___"));
assert!(is_horizontal_rule("------"));
assert!(!is_horizontal_rule("--"));
assert!(!is_horizontal_rule("abc"));
}
#[test]
fn is_table_separator_valid() {
assert!(is_table_separator("| --- | --- |"));
assert!(is_table_separator("|:---:|:---|"));
assert!(!is_table_separator("no pipes here"));
}
#[test]
fn parse_table_row_cells() {
let cells = parse_table_row("| A | B | C |");
assert_eq!(cells, vec!["A", "B", "C"]);
}
#[test]
fn parse_table_row_escaped_pipe_in_cell() {
// Issue #579: `\|` inside a cell is a literal pipe, not a column separator.
let cells = parse_table_row(r"| a\|b | c |");
assert_eq!(cells, vec!["a|b", "c"]);
}
#[test]
fn parse_table_row_escaped_pipe_in_code_span() {
// Issue #579: `\|` inside an inline code span is unescaped at the row level.
let cells = parse_table_row(r"| `parser.decode[T\|json]` | other |");
assert_eq!(cells, vec!["`parser.decode[T|json]`", "other"]);
}
#[test]
fn parse_table_row_preserves_other_backslashes() {
// Only `\|` is special at the row-splitting level; other backslashes pass through.
let cells = parse_table_row(r"| a\\b | c\*d |");
assert_eq!(cells, vec![r"a\\b", r"c\*d"]);
}
#[test]
fn parse_image_syntax_valid() {
let result = parse_image_syntax("");
assert_eq!(result, Some(("alt", "url")));
}
#[test]
fn parse_image_syntax_not_image() {
assert!(parse_image_syntax("not an image").is_none());
}
// ── find_closing_paren tests ────────────────────────────────────
#[test]
fn find_closing_paren_simple() {
assert_eq!(find_closing_paren("(hello)", 0), Some(6));
}
#[test]
fn find_closing_paren_nested() {
assert_eq!(find_closing_paren("(a(b)c)", 0), Some(6));
}
#[test]
fn find_closing_paren_unmatched() {
assert_eq!(find_closing_paren("(no close", 0), None);
}
#[test]
fn find_closing_paren_offset() {
// Start scanning from the second '('
assert_eq!(find_closing_paren("xx(inner)", 2), Some(8));
}
// ── Parentheses-in-URL tests (issue #509) ──────────────────────
#[test]
fn try_parse_link_url_with_parens() {
let input = "[here](https://example.com/faq#access-(permissions)-rest)";
let result = try_parse_link(input, 0);
assert_eq!(
result,
Some((
input.len(),
"here",
"https://example.com/faq#access-(permissions)-rest"
))
);
}
#[test]
fn try_parse_link_url_no_parens() {
let input = "[text](https://example.com)";
let result = try_parse_link(input, 0);
assert_eq!(result, Some((input.len(), "text", "https://example.com")));
}
#[test]
fn try_parse_link_url_with_multiple_nested_parens() {
let input = "[x](http://en.wikipedia.org/wiki/Foo_(bar_(baz)))";
let result = try_parse_link(input, 0);
assert_eq!(
result,
Some((
input.len(),
"x",
"http://en.wikipedia.org/wiki/Foo_(bar_(baz))"
))
);
}
#[test]
fn parse_image_syntax_url_with_parens() {
let result = parse_image_syntax(")");
assert_eq!(result, Some(("alt", "https://example.com/page_(1)")));
}
#[test]
fn parse_image_syntax_url_no_parens() {
let result = parse_image_syntax("");
assert_eq!(result, Some(("alt", "https://example.com")));
}
#[test]
fn link_with_parens_round_trip() {
let href = "https://example.com/faq#I-need-access-(permissions)-added-in-Monitor";
let mut text_node = AdfNode::text("here");
text_node.marks = Some(vec![AdfMark::link(href)]);
let adf_input = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![text_node])],
};
let jfm = adf_to_markdown(&adf_input).unwrap();
let adf_output = markdown_to_adf(&jfm).unwrap();
// Extract the href from the round-tripped ADF
let para = &adf_output.content[0];
let text_node = ¶.content.as_ref().unwrap()[0];
let mark = &text_node.marks.as_ref().unwrap()[0];
let result_href = mark.attrs.as_ref().unwrap()["href"].as_str().unwrap();
assert_eq!(result_href, href);
}
#[test]
fn flush_plain_empty_range() {
let mut nodes = Vec::new();
flush_plain("hello", 3, 3, &mut nodes);
assert!(nodes.is_empty());
}
#[test]
fn add_mark_to_unmarked_node() {
let mut node = AdfNode::text("test");
add_mark(&mut node, AdfMark::strong());
assert_eq!(node.marks.as_ref().unwrap().len(), 1);
}
#[test]
fn add_mark_to_marked_node() {
let mut node = AdfNode::text_with_marks("test", vec![AdfMark::strong()]);
add_mark(&mut node, AdfMark::em());
assert_eq!(node.marks.as_ref().unwrap().len(), 2);
}
// ── Directive table tests ──────────────────────────────────────
#[test]
fn directive_table_basic() {
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";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content[0].node_type, "table");
let rows = doc.content[0].content.as_ref().unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(
rows[0].content.as_ref().unwrap()[0].node_type,
"tableHeader"
);
assert_eq!(rows[1].content.as_ref().unwrap()[0].node_type, "tableCell");
}
#[test]
fn directive_table_with_block_content() {
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";
let doc = markdown_to_adf(md).unwrap();
let rows = doc.content[0].content.as_ref().unwrap();
let cell = &rows[0].content.as_ref().unwrap()[0];
// Cell should have block content (paragraph + bullet list)
let content = cell.content.as_ref().unwrap();
assert!(content.len() >= 2);
assert_eq!(content[1].node_type, "bulletList");
}
#[test]
fn directive_table_with_cell_attrs() {
let md = "::::table\n:::tr\n:::td{colspan=2 bg=#DEEBFF}\nSpanning cell\n:::\n:::\n::::\n";
let doc = markdown_to_adf(md).unwrap();
let cell = &doc.content[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap()[0];
let attrs = cell.attrs.as_ref().unwrap();
assert_eq!(attrs["colspan"], 2);
assert_eq!(attrs["background"], "#DEEBFF");
}
#[test]
fn directive_table_with_css_var_background() {
let bg = "var(--ds-background-accent-gray-subtlest, var(--ds-background-accent-gray-subtlest, #F1F2F4))";
let md = format!("::::table\n:::tr\n:::th{{bg=\"{bg}\"}}\nHeader\n:::\n:::\n::::\n");
let doc = markdown_to_adf(&md).unwrap();
let row = &doc.content[0].content.as_ref().unwrap()[0];
let cells = row.content.as_ref().unwrap();
assert_eq!(cells.len(), 1, "row must have at least one cell");
let attrs = cells[0].attrs.as_ref().unwrap();
assert_eq!(attrs["background"], bg);
}
#[test]
fn css_var_background_round_trips() {
let bg = "var(--ds-background-accent-gray-subtlest, #F1F2F4)";
let adf = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
AdfNode::table_header_with_attrs(
vec![AdfNode::paragraph(vec![AdfNode::text("Header")])],
serde_json::json!({"background": bg}),
),
])])],
};
let md = adf_to_markdown(&adf).unwrap();
assert!(
md.contains(&format!("bg=\"{bg}\"")),
"bg value must be quoted in markdown: {md}"
);
let round_tripped = markdown_to_adf(&md).unwrap();
let row = &round_tripped.content[0].content.as_ref().unwrap()[0];
let cells = row.content.as_ref().unwrap();
assert_eq!(cells.len(), 1, "round-tripped row must have one cell");
let rt_attrs = cells[0].attrs.as_ref().unwrap();
assert_eq!(rt_attrs["background"], bg);
}
#[test]
fn directive_table_with_table_attrs() {
let md = "::::table{layout=wide numbered}\n:::tr\n:::td\nCell\n:::\n:::\n::::\n";
let doc = markdown_to_adf(md).unwrap();
let attrs = doc.content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["layout"], "wide");
assert_eq!(attrs["isNumberColumnEnabled"], true);
}
#[test]
fn adf_table_with_block_content_renders_directive_form() {
// Table with a bullet list in a cell → should render as ::::table directive
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
AdfNode::table_cell(vec![
AdfNode::paragraph(vec![AdfNode::text("Cell with list:")]),
AdfNode::bullet_list(vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
AdfNode::text("Item 1"),
])])]),
]),
])])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("::::table"));
assert!(md.contains(":::td"));
assert!(md.contains("- Item 1"));
}
#[test]
fn adf_table_inline_only_renders_pipe_form() {
// Table with only inline content → pipe table
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::table(vec![
AdfNode::table_row(vec![
AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
]),
AdfNode::table_row(vec![
AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
]),
])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("| H1 | H2 |"));
assert!(!md.contains("::::table"));
}
#[test]
fn adf_table_header_outside_first_row_renders_directive() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::table(vec![
AdfNode::table_row(vec![
AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H")])]),
AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C")])]),
]),
AdfNode::table_row(vec![
AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
]),
])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("::::table"));
assert!(md.contains(":::th"));
}
#[test]
fn adf_table_cell_attrs_rendered() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::table(vec![
AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
AdfNode::text("H"),
])])]),
AdfNode::table_row(vec![AdfNode::table_cell_with_attrs(
vec![AdfNode::paragraph(vec![AdfNode::text("C")])],
serde_json::json!({"background": "#DEEBFF", "colspan": 2}),
)]),
])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("{colspan=2 bg=#DEEBFF}"));
}
// ── Pipe table cell attrs tests ────────────────────────────────
#[test]
fn pipe_table_cell_attrs() {
let md = "| H1 | H2 |\n|---|---|\n| {bg=#DEEBFF} highlighted | normal |\n";
let doc = markdown_to_adf(md).unwrap();
let rows = doc.content[0].content.as_ref().unwrap();
let cell = &rows[1].content.as_ref().unwrap()[0];
let attrs = cell.attrs.as_ref().unwrap();
assert_eq!(attrs["background"], "#DEEBFF");
}
#[test]
fn pipe_table_cell_colspan() {
let md = "| H1 | H2 |\n|---|---|\n| {colspan=2} spanning |\n";
let doc = markdown_to_adf(md).unwrap();
let rows = doc.content[0].content.as_ref().unwrap();
let cell = &rows[1].content.as_ref().unwrap()[0];
let attrs = cell.attrs.as_ref().unwrap();
assert_eq!(attrs["colspan"], 2);
}
#[test]
fn trailing_space_after_mention_in_table_cell_preserved() {
// Issue #372: trailing space after mention in table cell was dropped
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":"mention","attrs":{"id":"aaa","text":"@Rob"}},
{"type":"text","text":" "}
]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap()[0];
let para = &cell.content.as_ref().unwrap()[0];
let inlines = para.content.as_ref().unwrap();
assert!(
inlines.len() >= 2,
"expected mention + text(' ') nodes, got {} nodes: {:?}",
inlines.len(),
inlines.iter().map(|n| &n.node_type).collect::<Vec<_>>()
);
assert_eq!(inlines[0].node_type, "mention");
assert_eq!(inlines[1].node_type, "text");
assert_eq!(inlines[1].text.as_deref(), Some(" "));
}
// ── Column alignment tests ─────────────────────────────────────
#[test]
fn pipe_table_column_alignment() {
let md = "| Left | Center | Right |\n|:---|:---:|---:|\n| L | C | R |\n";
let doc = markdown_to_adf(md).unwrap();
let rows = doc.content[0].content.as_ref().unwrap();
// Header row
let h_cells = rows[0].content.as_ref().unwrap();
// Left → no mark
assert!(h_cells[0].content.as_ref().unwrap()[0].marks.is_none());
// Center → alignment center
let center_marks = h_cells[1].content.as_ref().unwrap()[0]
.marks
.as_ref()
.unwrap();
assert_eq!(center_marks[0].attrs.as_ref().unwrap()["align"], "center");
// Right → alignment end
let right_marks = h_cells[2].content.as_ref().unwrap()[0]
.marks
.as_ref()
.unwrap();
assert_eq!(right_marks[0].attrs.as_ref().unwrap()["align"], "end");
}
#[test]
fn adf_table_alignment_roundtrip() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::table(vec![
AdfNode::table_row(vec![
AdfNode::table_header(vec![{
let mut p = AdfNode::paragraph(vec![AdfNode::text("Center")]);
p.marks = Some(vec![AdfMark::alignment("center")]);
p
}]),
AdfNode::table_header(vec![{
let mut p = AdfNode::paragraph(vec![AdfNode::text("Right")]);
p.marks = Some(vec![AdfMark::alignment("end")]);
p
}]),
]),
AdfNode::table_row(vec![
AdfNode::table_cell(vec![{
let mut p = AdfNode::paragraph(vec![AdfNode::text("C")]);
p.marks = Some(vec![AdfMark::alignment("center")]);
p
}]),
AdfNode::table_cell(vec![{
let mut p = AdfNode::paragraph(vec![AdfNode::text("R")]);
p.marks = Some(vec![AdfMark::alignment("end")]);
p
}]),
]),
])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains(":---:"));
assert!(md.contains("---:"));
}
// ── Panel custom attrs tests ───────────────────────────────────
#[test]
fn panel_custom_attrs_round_trip() {
let md = ":::panel{type=custom icon=\":star:\" color=\"#DEEBFF\"}\nContent\n:::\n";
let doc = markdown_to_adf(md).unwrap();
let panel = &doc.content[0];
let attrs = panel.attrs.as_ref().unwrap();
assert_eq!(attrs["panelType"], "custom");
assert_eq!(attrs["panelIcon"], ":star:");
assert_eq!(attrs["panelColor"], "#DEEBFF");
let result = adf_to_markdown(&doc).unwrap();
assert!(result.contains("type=custom"));
assert!(result.contains("icon="));
assert!(result.contains("color="));
}
// ── Block card with attrs tests ────────────────────────────────
#[test]
fn block_card_with_layout() {
let md = "::card[https://example.com]{layout=wide}\n";
let doc = markdown_to_adf(md).unwrap();
let attrs = doc.content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["layout"], "wide");
let result = adf_to_markdown(&doc).unwrap();
assert!(result.contains("::card[https://example.com]{layout=wide}"));
}
// ── Extension params test ──────────────────────────────────────
#[test]
fn extension_with_params() {
let md = r#"::extension{type=com.atlassian.macro key=jira-chart params='{"jql":"project=PROJ"}'}"#;
let doc = markdown_to_adf(&format!("{md}\n")).unwrap();
let attrs = doc.content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["parameters"]["jql"], "project=PROJ");
}
#[test]
fn leaf_extension_layout_preserved_in_roundtrip() {
// Issue #381: layout attr on extension nodes was dropped
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"extension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"toc","layout":"default","parameters":{}}}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("layout=default"),
"JFM should contain layout=default, got: {md}"
);
let round_tripped = markdown_to_adf(&md).unwrap();
let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["layout"], "default", "layout should be preserved");
assert_eq!(attrs["extensionKey"], "toc");
}
#[test]
fn bodied_extension_layout_preserved_in_roundtrip() {
// Bodied extension with layout
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"bodiedExtension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"expand","layout":"wide"},
"content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("layout=wide"),
"JFM should contain layout=wide, got: {md}"
);
let round_tripped = markdown_to_adf(&md).unwrap();
let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["layout"], "wide", "layout should be preserved");
}
#[test]
fn bodied_extension_parameters_preserved_in_roundtrip() {
// Issue #473: parameters block inside bodiedExtension.attrs was dropped
let adf_json = r#"{"version":1,"type":"doc","content":[
{"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":{}}},
"content":[{"type":"paragraph","content":[{"type":"text","text":"Content inside bodied extension"}]}]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("params="),
"JFM should contain params attribute, got: {md}"
);
let round_tripped = markdown_to_adf(&md).unwrap();
let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
assert_eq!(
attrs["parameters"]["macroMetadata"]["title"], "Page Properties",
"parameters should be preserved in round-trip"
);
assert_eq!(attrs["extensionKey"], "details");
assert_eq!(attrs["layout"], "default");
assert_eq!(attrs["localId"], "aabbccdd-1234");
}
#[test]
fn bodied_extension_malformed_params_ignored() {
// Malformed params JSON should be silently ignored, not crash
let md = ":::extension{type=com.atlassian.macro key=details params='not-valid-json'}\nContent\n:::\n";
let doc = markdown_to_adf(md).unwrap();
let attrs = doc.content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["extensionKey"], "details");
// parameters should be absent since the JSON was invalid
assert!(attrs.get("parameters").is_none());
}
#[test]
fn leaf_extension_localid_preserved_in_roundtrip() {
// Extension with both layout and localId
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"extension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"toc","layout":"default","localId":"abc-123"}}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["layout"], "default");
assert_eq!(attrs["localId"], "abc-123");
}
// ── Mention with userType test ─────────────────────────────────
#[test]
fn mention_with_user_type() {
let md = "Hi :mention[Alice]{id=abc123 userType=DEFAULT}.\n";
let doc = markdown_to_adf(md).unwrap();
let mention = &doc.content[0].content.as_ref().unwrap()[1];
assert_eq!(mention.attrs.as_ref().unwrap()["userType"], "DEFAULT");
let result = adf_to_markdown(&doc).unwrap();
assert!(result.contains("userType=DEFAULT"));
}
// ── Colwidth tests ─────────────────────────────────────────────
#[test]
fn directive_table_colwidth() {
let md = "::::table\n:::tr\n:::td{colwidth=100,200}\nCell\n:::\n:::\n::::\n";
let doc = markdown_to_adf(md).unwrap();
let cell = &doc.content[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap()[0];
let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
assert_eq!(colwidth, &[serde_json::json!(100), serde_json::json!(200)]);
}
#[test]
fn directive_table_colwidth_float_roundtrip() {
// Confluence returns colwidth as floats (e.g. 157.0, 863.0).
// adf_to_markdown must preserve them so markdown_to_adf can restore them.
let adf_doc = serde_json::json!({
"type": "doc",
"version": 1,
"content": [{
"type": "table",
"content": [{
"type": "tableRow",
"content": [
{
"type": "tableHeader",
"attrs": { "colwidth": [157.0] },
"content": [{ "type": "paragraph" }]
},
{
"type": "tableHeader",
"attrs": { "colwidth": [863.0] },
"content": [{ "type": "paragraph" }]
}
]
}]
}]
});
let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("colwidth=157.0"),
"expected colwidth=157.0 in markdown, got: {md}"
);
assert!(
md.contains("colwidth=863.0"),
"expected colwidth=863.0 in markdown, got: {md}"
);
// Round-trip back to ADF
let doc2 = markdown_to_adf(&md).unwrap();
let row = &doc2.content[0].content.as_ref().unwrap()[0];
let header1 = &row.content.as_ref().unwrap()[0];
let header2 = &row.content.as_ref().unwrap()[1];
assert_eq!(
header1.attrs.as_ref().unwrap()["colwidth"]
.as_array()
.unwrap(),
&[serde_json::json!(157.0)]
);
assert_eq!(
header2.attrs.as_ref().unwrap()["colwidth"]
.as_array()
.unwrap(),
&[serde_json::json!(863.0)]
);
}
#[test]
fn colwidth_float_preserved_in_roundtrip() {
// Issue #369: colwidth 254.0 was coerced to integer 254
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":[]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap()[0];
let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
assert_eq!(
colwidth,
&[serde_json::json!(254.0), serde_json::json!(416.0)],
"colwidth should preserve float values"
);
}
#[test]
fn colwidth_integer_preserved_in_roundtrip() {
// Issue #459: colwidth integer values emitted as floats after round-trip
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"}]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("colwidth=150"),
"expected colwidth=150 (no decimal) in markdown, got: {md}"
);
assert!(
!md.contains("colwidth=150.0"),
"colwidth should not have .0 suffix for integers, got: {md}"
);
// Round-trip back to ADF
let round_tripped = markdown_to_adf(&md).unwrap();
let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap()[0];
let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
assert_eq!(
colwidth,
&[serde_json::json!(150)],
"colwidth should preserve integer values"
);
// Verify JSON serialization uses integer, not float
let json_output = serde_json::to_string(&round_tripped).unwrap();
assert!(
json_output.contains(r#""colwidth":[150]"#),
"JSON should contain integer colwidth, got: {json_output}"
);
}
#[test]
fn colwidth_mixed_int_and_float_roundtrip() {
// Integer colwidth from standard ADF and float colwidth from Confluence
// should each preserve their original type through round-trip.
let int_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100,200]}}]}]}]}"#;
let float_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100.0,200.0]}}]}]}]}"#;
// Integer input → integer output
let int_doc: AdfDocument = serde_json::from_str(int_json).unwrap();
let int_md = adf_to_markdown(&int_doc).unwrap();
assert!(
int_md.contains("colwidth=100,200"),
"integer colwidth in md: {int_md}"
);
let int_rt = markdown_to_adf(&int_md).unwrap();
let int_serial = serde_json::to_string(&int_rt).unwrap();
assert!(
int_serial.contains(r#""colwidth":[100,200]"#),
"integer colwidth in JSON: {int_serial}"
);
// Float input → float output
let float_doc: AdfDocument = serde_json::from_str(float_json).unwrap();
let float_md = adf_to_markdown(&float_doc).unwrap();
assert!(
float_md.contains("colwidth=100.0,200.0"),
"float colwidth in md: {float_md}"
);
let float_rt = markdown_to_adf(&float_md).unwrap();
let float_serial = serde_json::to_string(&float_rt).unwrap();
assert!(
float_serial.contains(r#""colwidth":[100.0,200.0]"#),
"float colwidth in JSON: {float_serial}"
);
}
#[test]
fn colwidth_fractional_float_preserved() {
// Covers the fractional-float branch (n.fract() != 0.0) in build_cell_attrs_string
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"}]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("colwidth=100.5"),
"expected colwidth=100.5 in markdown, got: {md}"
);
}
#[test]
fn colwidth_non_numeric_values_skipped() {
// Covers the None branch for non-numeric colwidth entries in build_cell_attrs_string
let adf_doc = serde_json::json!({
"type": "doc",
"version": 1,
"content": [{
"type": "table",
"content": [{
"type": "tableRow",
"content": [{
"type": "tableCell",
"attrs": { "colwidth": ["invalid"] },
"content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "cell" }] }]
}]
}]
}]
});
let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
let md = adf_to_markdown(&doc).unwrap();
// Non-numeric values are filtered out, so colwidth should not appear
assert!(
!md.contains("colwidth"),
"non-numeric colwidth should be filtered out, got: {md}"
);
}
#[test]
fn default_rowspan_colspan_preserved_in_roundtrip() {
// Issue #369: rowspan=1 and colspan=1 were elided
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"}]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap()[0];
let attrs = cell.attrs.as_ref().unwrap();
assert_eq!(attrs["rowspan"], 1, "rowspan=1 should be preserved");
assert_eq!(attrs["colspan"], 1, "colspan=1 should be preserved");
}
// ── Nested list tests ──────────────────────────────────────────────
#[test]
fn table_localid_preserved_in_roundtrip() {
// Issue #374: localId on table nodes was dropped
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"}]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("localId="),
"JFM should contain localId, got: {md}"
);
let round_tripped = markdown_to_adf(&md).unwrap();
let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
assert_eq!(
attrs["localId"], "7afd4550-e66c-4b12-875f-a91c6c7b62c7",
"localId should be preserved"
);
}
#[test]
fn paragraph_localid_preserved_in_roundtrip() {
// Issue #399: localId on paragraph nodes was dropped
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"paragraph","attrs":{"localId":"abc-123"},"content":[{"type":"text","text":"hello"}]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("localId=abc-123"),
"JFM should contain localId, got: {md}"
);
let round_tripped = markdown_to_adf(&md).unwrap();
let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["localId"], "abc-123", "localId should be preserved");
}
#[test]
fn heading_localid_preserved_in_roundtrip() {
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"heading","attrs":{"level":2,"localId":"h-456"},"content":[{"type":"text","text":"Title"}]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["localId"], "h-456");
}
#[test]
fn localid_with_alignment_preserved() {
// localId and alignment marks should coexist in the same {attrs} block
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"paragraph","attrs":{"localId":"p-789"},"marks":[{"type":"alignment","attrs":{"align":"center"}}],
"content":[{"type":"text","text":"centered"}]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("localId=p-789"), "should have localId: {md}");
assert!(md.contains("align=center"), "should have align: {md}");
let round_tripped = markdown_to_adf(&md).unwrap();
let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["localId"], "p-789");
let marks = round_tripped.content[0].marks.as_ref().unwrap();
assert!(marks.iter().any(|m| m.mark_type == "alignment"));
}
#[test]
fn table_layout_default_preserved_in_roundtrip() {
// Issue #380: layout='default' was elided
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"}]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
assert_eq!(
attrs["layout"], "default",
"layout='default' should be preserved"
);
}
#[test]
fn table_is_number_column_enabled_false_preserved() {
// Issue #380: isNumberColumnEnabled=false was elided
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"}]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
assert_eq!(
attrs["isNumberColumnEnabled"], false,
"isNumberColumnEnabled=false should be preserved"
);
}
#[test]
fn table_is_number_column_enabled_true_preserved() {
// Regression check: isNumberColumnEnabled=true should still work
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"}]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
assert_eq!(
attrs["isNumberColumnEnabled"], true,
"isNumberColumnEnabled=true should be preserved"
);
}
#[test]
fn directive_table_is_number_column_enabled_false_preserved() {
// Covers render_directive_table + directive table parsing for numbered=false.
// Multi-paragraph cell forces directive table form.
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":"line one"}]},
{"type":"paragraph","content":[{"type":"text","text":"line two"}]}
]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("::::table"), "should use directive table form");
assert!(
md.contains("numbered=false"),
"should contain numbered=false, got: {md}"
);
let round_tripped = markdown_to_adf(&md).unwrap();
let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["isNumberColumnEnabled"], false);
assert_eq!(attrs["layout"], "default");
}
#[test]
fn directive_table_is_number_column_enabled_true_preserved() {
// Covers render_directive_table + directive table parsing for numbered (true).
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":"line one"}]},
{"type":"paragraph","content":[{"type":"text","text":"line two"}]}
]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("::::table"), "should use directive table form");
assert!(
md.contains("numbered}") || md.contains("numbered "),
"should contain numbered flag, got: {md}"
);
let round_tripped = markdown_to_adf(&md).unwrap();
let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["isNumberColumnEnabled"], true);
}
#[test]
fn trailing_space_in_bullet_list_item_preserved() {
// Issue #394: trailing space text node in list item dropped
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
{"type":"listItem","content":[{"type":"paragraph","content":[
{"type":"text","text":"Before link "},
{"type":"text","text":"link text","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
{"type":"text","text":" "}
]}]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let list = &round_tripped.content[0];
let item = &list.content.as_ref().unwrap()[0];
let para = &item.content.as_ref().unwrap()[0];
let inlines = para.content.as_ref().unwrap();
let last = inlines.last().unwrap();
assert_eq!(
last.text.as_deref(),
Some(" "),
"trailing space text node should be preserved, got nodes: {:?}",
inlines
.iter()
.map(|n| (&n.node_type, &n.text))
.collect::<Vec<_>>()
);
}
#[test]
fn trailing_space_after_mention_in_bullet_list_preserved() {
// Mention + trailing space in list item
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
{"type":"listItem","content":[{"type":"paragraph","content":[
{"type":"mention","attrs":{"id":"abc","text":"@Alice"}},
{"type":"text","text":" "}
]}]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap()[0];
let inlines = para.content.as_ref().unwrap();
assert!(
inlines.len() >= 2,
"should have mention + trailing space, got {} nodes",
inlines.len()
);
assert_eq!(inlines.last().unwrap().text.as_deref(), Some(" "));
}
#[test]
fn trailing_space_in_ordered_list_item_preserved() {
// Same issue in ordered list context
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
{"type":"listItem","content":[{"type":"paragraph","content":[
{"type":"text","text":"item "},
{"type":"text","text":"link","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
{"type":"text","text":" "}
]}]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap()[0];
let inlines = para.content.as_ref().unwrap();
let last = inlines.last().unwrap();
assert_eq!(
last.text.as_deref(),
Some(" "),
"trailing space should be preserved in ordered list item"
);
}
#[test]
fn trailing_space_in_heading_text_preserved() {
// Issue #400: trailing space in heading text node trimmed on round-trip
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[
{"type":"text","text":"Firefighting Engineers "}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let inlines = round_tripped.content[0].content.as_ref().unwrap();
assert_eq!(
inlines[0].text.as_deref(),
Some("Firefighting Engineers "),
"trailing space in heading should be preserved"
);
}
#[test]
fn trailing_space_in_heading_before_bold_preserved() {
// Issue #400: trailing space before bold sibling in heading
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
{"type":"text","text":"Classic "},
{"type":"text","text":"bold","marks":[{"type":"strong"}]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let inlines = round_tripped.content[0].content.as_ref().unwrap();
assert_eq!(
inlines[0].text.as_deref(),
Some("Classic "),
"trailing space in heading text before bold should be preserved"
);
}
#[test]
fn leading_space_in_heading_text_preserved() {
// Issue #492: leading spaces in heading text node stripped on round-trip
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":3},"content":[
{"type":"text","text":" #general-channel"}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let inlines = round_tripped.content[0].content.as_ref().unwrap();
assert_eq!(
inlines[0].text.as_deref(),
Some(" #general-channel"),
"leading spaces in heading text should be preserved"
);
}
#[test]
fn leading_space_in_heading_before_bold_preserved() {
// Issue #492: leading space before bold sibling in heading
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
{"type":"text","text":" indented"},
{"type":"text","text":" bold","marks":[{"type":"strong"}]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let inlines = round_tripped.content[0].content.as_ref().unwrap();
assert_eq!(
inlines[0].text.as_deref(),
Some(" indented"),
"leading spaces in heading text before bold should be preserved"
);
}
#[test]
fn heading_multiple_leading_spaces_markdown_parse() {
// Issue #492: verify JFM parsing preserves leading spaces
let md = "### \t #general-channel";
let doc = markdown_to_adf(md).unwrap();
let inlines = doc.content[0].content.as_ref().unwrap();
assert_eq!(
inlines[0].text.as_deref(),
Some("\t #general-channel"),
"leading whitespace in heading text should be preserved during JFM parsing"
);
}
#[test]
fn trailing_space_in_paragraph_text_preserved() {
// Issue #400: trailing space in paragraph text node preserved on round-trip
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"word followed by space "},
{"type":"text","text":"next node","marks":[{"type":"strong"}]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let inlines = round_tripped.content[0].content.as_ref().unwrap();
assert_eq!(
inlines[0].text.as_deref(),
Some("word followed by space "),
"trailing space in paragraph text should be preserved"
);
}
#[test]
fn nested_bullet_list_roundtrip() {
// ADF with a listItem containing a paragraph + nested bulletList
let adf_doc = serde_json::json!({
"type": "doc",
"version": 1,
"content": [{
"type": "bulletList",
"content": [{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [{"type": "text", "text": "parent item"}]
},
{
"type": "bulletList",
"content": [
{
"type": "listItem",
"content": [{
"type": "paragraph",
"content": [{"type": "text", "text": "sub item 1"}]
}]
},
{
"type": "listItem",
"content": [{
"type": "paragraph",
"content": [{"type": "text", "text": "sub item 2"}]
}]
}
]
}
]
}]
}]
});
let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("- parent item\n"),
"expected top-level item in markdown, got: {md}"
);
assert!(
md.contains(" - sub item 1\n"),
"expected indented sub item 1 in markdown, got: {md}"
);
assert!(
md.contains(" - sub item 2\n"),
"expected indented sub item 2 in markdown, got: {md}"
);
// Round-trip back
let doc2 = markdown_to_adf(&md).unwrap();
let list = &doc2.content[0];
assert_eq!(list.node_type, "bulletList");
let item = &list.content.as_ref().unwrap()[0];
assert_eq!(item.node_type, "listItem");
let item_content = item.content.as_ref().unwrap();
assert_eq!(
item_content.len(),
2,
"listItem should have paragraph + nested list"
);
assert_eq!(item_content[0].node_type, "paragraph");
assert_eq!(item_content[1].node_type, "bulletList");
let sub_items = item_content[1].content.as_ref().unwrap();
assert_eq!(sub_items.len(), 2);
}
#[test]
fn nested_bullet_in_table_cell_roundtrip() {
let md = "::::table\n:::tr\n:::td\n- parent\n - child\n:::\n:::\n::::\n";
let doc = markdown_to_adf(md).unwrap();
let table = &doc.content[0];
let row = &table.content.as_ref().unwrap()[0];
let cell = &row.content.as_ref().unwrap()[0];
let list = &cell.content.as_ref().unwrap()[0];
assert_eq!(list.node_type, "bulletList");
let item = &list.content.as_ref().unwrap()[0];
let item_content = item.content.as_ref().unwrap();
assert_eq!(
item_content.len(),
2,
"listItem should have paragraph + nested list"
);
assert_eq!(item_content[1].node_type, "bulletList");
// Round-trip: adf→md→adf should preserve the nested list
let md2 = adf_to_markdown(&doc).unwrap();
assert!(
md2.contains(" - child"),
"expected indented child in round-tripped markdown, got: {md2}"
);
}
#[test]
fn nested_ordered_list_roundtrip() {
// Issue #389: nested orderedList inside listItem flattened
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
{"type":"listItem","content":[
{"type":"paragraph","content":[{"type":"text","text":"Top level"}]},
{"type":"orderedList","attrs":{"order":1},"content":[
{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 1"}]}]},
{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 2"}]}]}
]}
]},
{"type":"listItem","content":[
{"type":"paragraph","content":[{"type":"text","text":"Second top"}]}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
// Outer list should have 2 items
let outer = &round_tripped.content[0];
assert_eq!(outer.node_type, "orderedList");
assert_eq!(
outer.attrs.as_ref().unwrap()["order"],
1,
"explicit order=1 must be preserved via trailing {{order=1}} (issue #547)"
);
let outer_items = outer.content.as_ref().unwrap();
assert_eq!(
outer_items.len(),
2,
"outer list should have 2 items, got {}",
outer_items.len()
);
// First item should have paragraph + nested orderedList
let first_item = &outer_items[0];
let first_content = first_item.content.as_ref().unwrap();
assert_eq!(
first_content.len(),
2,
"first listItem should have paragraph + nested list, got {}",
first_content.len()
);
assert_eq!(first_content[0].node_type, "paragraph");
assert_eq!(first_content[1].node_type, "orderedList");
let nested_items = first_content[1].content.as_ref().unwrap();
assert_eq!(nested_items.len(), 2, "nested list should have 2 items");
}
#[test]
fn nested_ordered_list_markdown_parsing() {
// Direct markdown parsing of nested ordered list
let md = "1. Top level\n 1. Nested 1\n 2. Nested 2\n2. Second top\n";
let doc = markdown_to_adf(md).unwrap();
let outer = &doc.content[0];
assert_eq!(outer.node_type, "orderedList");
let outer_items = outer.content.as_ref().unwrap();
assert_eq!(outer_items.len(), 2, "should have 2 top-level items");
let first_content = outer_items[0].content.as_ref().unwrap();
assert_eq!(
first_content.len(),
2,
"first item should have paragraph + nested list"
);
assert_eq!(first_content[1].node_type, "orderedList");
}
#[test]
fn bullet_list_nested_inside_ordered_list() {
// Mixed nesting: bullet list nested inside ordered list
let md = "1. Ordered item\n - Bullet child 1\n - Bullet child 2\n2. Second ordered\n";
let doc = markdown_to_adf(md).unwrap();
let outer = &doc.content[0];
assert_eq!(outer.node_type, "orderedList");
let outer_items = outer.content.as_ref().unwrap();
assert_eq!(outer_items.len(), 2);
let first_content = outer_items[0].content.as_ref().unwrap();
assert_eq!(
first_content.len(),
2,
"first item should have paragraph + nested list"
);
assert_eq!(first_content[1].node_type, "bulletList");
let sub_items = first_content[1].content.as_ref().unwrap();
assert_eq!(sub_items.len(), 2, "nested bullet list should have 2 items");
}
#[test]
fn ordered_list_order_attr_one_is_elided() {
// Issue #547: order=1 is the default and must be elided from attrs
// for round-trip fidelity with ADF documents that omit the attrs
// object on orderedList.
let md = "1. A\n2. B\n";
let doc = markdown_to_adf(md).unwrap();
assert!(
doc.content[0].attrs.is_none(),
"attrs should be elided when order=1"
);
// Round-trip should preserve the elision
let md2 = adf_to_markdown(&doc).unwrap();
let doc2 = markdown_to_adf(&md2).unwrap();
assert!(
doc2.content[0].attrs.is_none(),
"attrs should remain elided after round-trip"
);
}
#[test]
fn issue_547_ordered_list_no_attrs_roundtrip_byte_identical() {
// Issue #547: ADF orderedList without an attrs field must round-trip
// (ADF → JFM → ADF) without gaining a spurious {"order": 1} attrs.
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"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
assert!(
rt.content[0].attrs.is_none(),
"round-tripped orderedList should not have attrs, got: {:?}",
rt.content[0].attrs
);
// Serialized JSON must also omit attrs entirely for byte fidelity.
let rt_json = serde_json::to_string(&rt).unwrap();
assert!(
!rt_json.contains("\"order\""),
"round-tripped JSON should not contain \"order\", got: {rt_json}"
);
}
// ── Issue #547: orderedList byte-identical roundtrip coverage ───────
/// Assert that ADF → JFM → ADF produces a document whose serialized JSON
/// (as a sorted-key canonical form) matches the source JSON. Mirrors the
/// `jq --sort-keys` comparison used in the issue's reproducer.
fn assert_roundtrip_byte_identical(adf_json: &str) {
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let canonical_src: serde_json::Value = serde_json::from_str(adf_json).unwrap();
let canonical_rt: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&rt).unwrap()).unwrap();
assert_eq!(
canonical_src, canonical_rt,
"round-trip diverged\n src: {canonical_src}\n rt: {canonical_rt}\n md: {md:?}"
);
}
#[test]
fn issue_547_single_item_no_attrs_roundtrip() {
assert_roundtrip_byte_identical(
r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"only"}]}]}]}]}"#,
);
}
#[test]
fn issue_547_many_items_no_attrs_roundtrip() {
assert_roundtrip_byte_identical(
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"}]}]}]}]}"#,
);
}
#[test]
fn issue_547_non_default_order_preserved() {
// When order != 1, attrs must still be serialized (fix must not
// over-eagerly drop attrs).
assert_roundtrip_byte_identical(
r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":5},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"fifth"}]}]}]}]}"#,
);
}
#[test]
fn issue_547_nested_ordered_in_ordered_no_attrs_roundtrip() {
// Outer and inner both omit attrs; fix must apply at every level.
assert_roundtrip_byte_identical(
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"}]}]}]}]}]}]}"#,
);
}
#[test]
fn issue_547_ordered_nested_in_bullet_no_attrs_roundtrip() {
assert_roundtrip_byte_identical(
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"}]}]}]}]}]}]}"#,
);
}
#[test]
fn issue_547_bullet_nested_in_ordered_no_attrs_roundtrip() {
assert_roundtrip_byte_identical(
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"}]}]}]}]}]}]}"#,
);
}
#[test]
fn issue_547_ordered_list_between_paragraphs_roundtrip() {
assert_roundtrip_byte_identical(
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"}]}]}"#,
);
}
#[test]
fn issue_547_ordered_list_with_marked_text_roundtrip() {
assert_roundtrip_byte_identical(
r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"bold","marks":[{"type":"strong"}]}]}]}]}]}"#,
);
}
#[test]
fn issue_547_ordered_list_with_link_roundtrip() {
assert_roundtrip_byte_identical(
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"}}]}]}]}]}]}"#,
);
}
#[test]
fn issue_547_ordered_list_with_hardbreak_roundtrip() {
assert_roundtrip_byte_identical(
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"}]}]}]}]}"#,
);
}
#[test]
fn issue_547_triple_nested_ordered_roundtrip() {
assert_roundtrip_byte_identical(
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"}]}]}]}]}]}]}]}]}"#,
);
}
#[test]
fn issue_547_ordered_list_heading_rule_mix_roundtrip() {
assert_roundtrip_byte_identical(
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"}]}"#,
);
}
#[test]
fn issue_547_ordered_list_listitem_localid_roundtrip() {
// listItem attrs must coexist with the no-attrs outer orderedList.
assert_roundtrip_byte_identical(
r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]}]}]}"#,
);
}
#[test]
fn issue_547_explicit_order_one_preserved_roundtrip() {
// Inverse regression (see PR #562 comment 4266630848): when the source
// ADF has an explicit `"attrs": {"order": 1}` the round-trip must
// preserve it, not strip it. A trailing `{order=1}` signal on the
// rendered markdown distinguishes explicit-default from omitted attrs.
assert_roundtrip_byte_identical(
r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"First item"}]}]}]}]}"#,
);
}
#[test]
fn issue_547_explicit_order_one_nested_preserved_roundtrip() {
// Both outer and inner orderedList have explicit `order: 1`; both must
// be preserved across the round-trip independently.
assert_roundtrip_byte_identical(
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"}]}]}]}]}]}]}"#,
);
}
#[test]
fn issue_547_mixed_explicit_and_implicit_order_roundtrip() {
// Sibling orderedLists with different attrs presence must round-trip
// independently: first has explicit `order: 1`, second omits attrs.
assert_roundtrip_byte_identical(
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"}]}]}]}]}"#,
);
}
#[test]
fn issue_547_explicit_order_one_with_listitem_localid_roundtrip() {
// Explicit `order: 1` outer, plus a listItem `localId` inside — the
// trailing `{order=1}` line must not swallow or collide with listItem
// attrs.
assert_roundtrip_byte_identical(
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"}]}]}]}]}"#,
);
}
#[test]
fn issue_547_order_attr_signal_appears_only_for_explicit_one() {
// Render-layer guard: `{order=1}` appears in markdown only when the
// source ADF has explicit `attrs.order=1`. No signal for attrs=None,
// no signal for attrs.order>1 (marker already encodes the value).
let no_attrs = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}]}"#;
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"}]}]}]}]}"#;
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"}]}]}]}]}"#;
let md_no =
adf_to_markdown(&serde_json::from_str::<AdfDocument>(no_attrs).unwrap()).unwrap();
let md_one =
adf_to_markdown(&serde_json::from_str::<AdfDocument>(explicit_one).unwrap()).unwrap();
let md_five =
adf_to_markdown(&serde_json::from_str::<AdfDocument>(order_five).unwrap()).unwrap();
assert!(
!md_no.contains("{order="),
"no-attrs source must not emit order signal, got: {md_no:?}"
);
assert!(
md_one.contains("{order=1}"),
"explicit order=1 must emit trailing signal, got: {md_one:?}"
);
assert!(
!md_five.contains("{order="),
"order=5 is already encoded by marker; must not emit signal, got: {md_five:?}"
);
}
// ── File media round-trip tests ─────────────────────────────────────
#[test]
fn file_media_roundtrip() {
// ADF with a Confluence file attachment (type:file media)
let adf_doc = serde_json::json!({
"type": "doc",
"version": 1,
"content": [{
"type": "mediaSingle",
"attrs": {"layout": "center"},
"content": [{
"type": "media",
"attrs": {
"type": "file",
"id": "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d",
"collection": "contentId-8220672100",
"height": 56,
"width": 312,
"alt": "Screenshot.png"
}
}]
}]
});
let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("type=file"),
"expected type=file in markdown, got: {md}"
);
assert!(
md.contains("id=6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d"),
"expected id in markdown, got: {md}"
);
assert!(
md.contains("collection=contentId-8220672100"),
"expected collection in markdown, got: {md}"
);
// Round-trip back to ADF
let doc2 = markdown_to_adf(&md).unwrap();
let ms = &doc2.content[0];
assert_eq!(ms.node_type, "mediaSingle");
let media = &ms.content.as_ref().unwrap()[0];
assert_eq!(media.node_type, "media");
let attrs = media.attrs.as_ref().unwrap();
assert_eq!(attrs["type"], "file");
assert_eq!(attrs["id"], "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d");
assert_eq!(attrs["collection"], "contentId-8220672100");
assert_eq!(attrs["height"], 56);
assert_eq!(attrs["width"], 312);
assert_eq!(attrs["alt"], "Screenshot.png");
}
/// Issue #550: roundtrip of mediaSingle with file-type media preserves all
/// file attributes (type, id, collection, width, height). Regression guard
/// for the exact reproducer in the issue body.
#[test]
fn file_media_roundtrip_issue_550_reproducer() {
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [
{
"type": "mediaSingle",
"attrs": {"layout": "center"},
"content": [
{
"type": "media",
"attrs": {
"type": "file",
"id": "abc-123-def-456",
"collection": "my-collection",
"width": 941,
"height": 655
}
}
]
}
]
}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
let actual = serde_json::to_value(&rt).unwrap();
assert_eq!(
actual, expected,
"roundtrip should preserve file media attrs; md was:\n{md}"
);
}
/// Issue #550 (updated reproducer): roundtrip of a file-media `id`
/// containing spaces must not truncate the value. Before the fix, the
/// JFM renderer emitted `id=abc 123 def 456` unquoted and the parser
/// treated the first space as a value terminator, so the `id` became
/// `"abc"` after round-trip.
#[test]
fn file_media_roundtrip_id_with_spaces() {
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [
{
"type": "mediaSingle",
"attrs": {"layout": "center"},
"content": [
{
"type": "media",
"attrs": {
"type": "file",
"id": "abc 123 def 456",
"collection": "my-collection",
"width": 800,
"height": 600
}
}
]
}
]
}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains(r#"id="abc 123 def 456""#),
"id with spaces should be quoted in JFM, got:\n{md}"
);
let rt = markdown_to_adf(&md).unwrap();
let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
let actual = serde_json::to_value(&rt).unwrap();
assert_eq!(
actual, expected,
"space-containing id must round-trip; md was:\n{md}"
);
}
/// Space-containing `collection` values must round-trip.
#[test]
fn file_media_roundtrip_collection_with_spaces() {
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [
{
"type": "mediaSingle",
"attrs": {"layout": "center"},
"content": [
{
"type": "media",
"attrs": {
"type": "file",
"id": "abc-123",
"collection": "my collection with spaces"
}
}
]
}
]
}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let media = &rt.content[0].content.as_ref().unwrap()[0];
assert_eq!(
media.attrs.as_ref().unwrap()["collection"],
"my collection with spaces"
);
}
/// Space-containing `occurrenceKey` values must round-trip.
#[test]
fn file_media_roundtrip_occurrence_key_with_spaces() {
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [
{
"type": "mediaSingle",
"attrs": {"layout": "center"},
"content": [
{
"type": "media",
"attrs": {
"type": "file",
"id": "x",
"collection": "y",
"occurrenceKey": "key with spaces"
}
}
]
}
]
}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let media = &rt.content[0].content.as_ref().unwrap()[0];
assert_eq!(
media.attrs.as_ref().unwrap()["occurrenceKey"],
"key with spaces"
);
}
/// Values with embedded `"` must be escape-quoted and round-trip.
#[test]
fn file_media_roundtrip_id_with_quote_char() {
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [
{
"type": "mediaSingle",
"attrs": {"layout": "center"},
"content": [
{
"type": "media",
"attrs": {
"type": "file",
"id": "a\"b\"c",
"collection": "col"
}
}
]
}
]
}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let media = &rt.content[0].content.as_ref().unwrap()[0];
assert_eq!(media.attrs.as_ref().unwrap()["id"], "a\"b\"c");
}
/// `mediaInline` string attrs with spaces must round-trip (parallel fix
/// for the inline-directive rendering path).
#[test]
fn media_inline_roundtrip_id_with_spaces() {
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{"type": "text", "text": "before "},
{
"type": "mediaInline",
"attrs": {
"type": "file",
"id": "a b c",
"collection": "my col"
}
},
{"type": "text", "text": " after"}
]
}
]
}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let inline = &rt.content[0].content.as_ref().unwrap()[1];
assert_eq!(inline.node_type, "mediaInline");
let attrs = inline.attrs.as_ref().unwrap();
assert_eq!(attrs["id"], "a b c");
assert_eq!(attrs["collection"], "my col");
}
/// Issue #550: `occurrenceKey` attribute is a standard ADF media attr and
/// must be preserved through ADF→JFM→ADF roundtrip.
#[test]
fn file_media_roundtrip_preserves_occurrence_key() {
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [
{
"type": "mediaSingle",
"attrs": {"layout": "center"},
"content": [
{
"type": "media",
"attrs": {
"type": "file",
"id": "abc-123",
"collection": "my-collection",
"occurrenceKey": "unique-key-xyz",
"width": 200,
"height": 100
}
}
]
}
]
}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("occurrenceKey=unique-key-xyz"),
"expected occurrenceKey in markdown, got: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let media = &rt.content[0].content.as_ref().unwrap()[0];
let attrs = media.attrs.as_ref().unwrap();
assert_eq!(attrs["occurrenceKey"], "unique-key-xyz");
assert_eq!(attrs["type"], "file");
assert_eq!(attrs["id"], "abc-123");
assert_eq!(attrs["collection"], "my-collection");
}
// ── mediaSingle caption tests (issue #470) ──────────────────────────
#[test]
fn media_single_caption_adf_to_markdown() {
let adf_doc = serde_json::json!({
"type": "doc",
"version": 1,
"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
}
},
{
"type": "caption",
"content": [{"type": "text", "text": "An image caption here"}]
}
]
}]
});
let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains(":::caption"),
"expected :::caption in markdown, got: {md}"
);
assert!(
md.contains("An image caption here"),
"expected caption text in markdown, got: {md}"
);
}
#[test]
fn media_single_caption_markdown_to_adf() {
let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nAn image caption here\n:::\n";
let doc = markdown_to_adf(md).unwrap();
let ms = &doc.content[0];
assert_eq!(ms.node_type, "mediaSingle");
let content = ms.content.as_ref().unwrap();
assert_eq!(content.len(), 2, "expected media + caption children");
assert_eq!(content[0].node_type, "media");
assert_eq!(content[1].node_type, "caption");
let caption_content = content[1].content.as_ref().unwrap();
assert_eq!(
caption_content[0].text.as_deref(),
Some("An image caption here")
);
}
#[test]
fn media_single_caption_round_trip() {
// Full round-trip: ADF → JFM → ADF preserves caption
let adf_doc = serde_json::json!({
"type": "doc",
"version": 1,
"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
}
},
{
"type": "caption",
"content": [{"type": "text", "text": "An image caption here"}]
}
]
}]
});
let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let doc2 = markdown_to_adf(&md).unwrap();
let ms = &doc2.content[0];
assert_eq!(ms.node_type, "mediaSingle");
let content = ms.content.as_ref().unwrap();
assert_eq!(
content.len(),
2,
"expected media + caption after round-trip"
);
assert_eq!(content[1].node_type, "caption");
let caption_content = content[1].content.as_ref().unwrap();
assert_eq!(
caption_content[0].text.as_deref(),
Some("An image caption here")
);
}
#[test]
fn media_single_caption_with_inline_marks() {
let adf_doc = serde_json::json!({
"type": "doc",
"version": 1,
"content": [{
"type": "mediaSingle",
"attrs": {"layout": "center"},
"content": [
{
"type": "media",
"attrs": {"type": "external", "url": "https://example.com/img.png"}
},
{
"type": "caption",
"content": [
{"type": "text", "text": "A "},
{"type": "text", "text": "bold", "marks": [{"type": "strong"}]},
{"type": "text", "text": " caption"}
]
}
]
}]
});
let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("**bold**"),
"expected bold in caption, got: {md}"
);
let doc2 = markdown_to_adf(&md).unwrap();
let content = doc2.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 2, "expected media + caption");
assert_eq!(content[1].node_type, "caption");
let caption_inlines = content[1].content.as_ref().unwrap();
let bold_node = caption_inlines
.iter()
.find(|n| n.text.as_deref() == Some("bold"))
.unwrap();
let marks = bold_node.marks.as_ref().unwrap();
assert_eq!(marks[0].mark_type, "strong");
}
#[test]
fn media_single_no_caption_unaffected() {
// Existing mediaSingle without caption should be unaffected
let adf_doc = serde_json::json!({
"type": "doc",
"version": 1,
"content": [{
"type": "mediaSingle",
"attrs": {"layout": "center"},
"content": [{
"type": "media",
"attrs": {"type": "external", "url": "https://example.com/img.png"}
}]
}]
});
let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
!md.contains(":::caption"),
"should not emit caption when none present"
);
let doc2 = markdown_to_adf(&md).unwrap();
let content = doc2.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1, "should only have media child");
assert_eq!(content[0].node_type, "media");
}
#[test]
fn media_single_empty_caption_round_trip() {
// Caption node with no content should still round-trip
let adf_doc = serde_json::json!({
"type": "doc",
"version": 1,
"content": [{
"type": "mediaSingle",
"attrs": {"layout": "center"},
"content": [
{
"type": "media",
"attrs": {"type": "external", "url": "https://example.com/img.png"}
},
{
"type": "caption"
}
]
}]
});
let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains(":::caption"),
"expected :::caption even for empty caption, got: {md}"
);
assert!(
md.contains(":::\n"),
"expected closing ::: fence, got: {md}"
);
}
#[test]
fn media_single_external_caption_round_trip() {
// External image with caption round-trips
let md = "\n:::caption\nImage description\n:::\n";
let doc = markdown_to_adf(md).unwrap();
let ms = &doc.content[0];
assert_eq!(ms.node_type, "mediaSingle");
let content = ms.content.as_ref().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(content[0].node_type, "media");
assert_eq!(content[1].node_type, "caption");
let md2 = adf_to_markdown(&doc).unwrap();
let doc2 = markdown_to_adf(&md2).unwrap();
let content2 = doc2.content[0].content.as_ref().unwrap();
assert_eq!(content2.len(), 2);
assert_eq!(content2[1].node_type, "caption");
let caption_text = content2[1].content.as_ref().unwrap();
assert_eq!(caption_text[0].text.as_deref(), Some("Image description"));
}
// ── mediaSingle caption localId tests (issue #524) ─────────────────
#[test]
fn media_single_caption_localid_roundtrip() {
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"}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("localId=9da8c2104471"),
"caption localId should appear in markdown: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let content = rt.content[0].content.as_ref().unwrap();
let caption = &content[1];
assert_eq!(caption.node_type, "caption");
assert_eq!(
caption.attrs.as_ref().unwrap()["localId"],
"9da8c2104471",
"caption localId should round-trip"
);
}
#[test]
fn media_single_caption_without_localid() {
let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nPlain caption\n:::\n";
let doc = markdown_to_adf(md).unwrap();
let caption = &doc.content[0].content.as_ref().unwrap()[1];
assert_eq!(caption.node_type, "caption");
assert!(
caption.attrs.is_none(),
"caption without localId should not gain attrs"
);
let md2 = adf_to_markdown(&doc).unwrap();
assert!(
!md2.contains("localId"),
"no localId should appear in output: {md2}"
);
}
#[test]
fn media_single_caption_localid_stripped_when_option_set() {
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"}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let opts = RenderOptions {
strip_local_ids: true,
..Default::default()
};
let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
assert!(!md.contains("localId"), "localId should be stripped: {md}");
}
#[test]
fn table_width_roundtrip() {
// ADF table with width attribute
let adf_doc = serde_json::json!({
"type": "doc",
"version": 1,
"content": [{
"type": "table",
"attrs": {"layout": "default", "width": 760.0},
"content": [{
"type": "tableRow",
"content": [{
"type": "tableHeader",
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
}]
}]
}]
});
let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("width=760.0"),
"expected width=760.0 in markdown (float preserved), got: {md}"
);
// Round-trip back to ADF
let doc2 = markdown_to_adf(&md).unwrap();
let table = &doc2.content[0];
assert_eq!(table.node_type, "table");
let table_attrs = table.attrs.as_ref().unwrap();
assert_eq!(table_attrs["width"], 760.0);
assert!(
table_attrs["width"].is_f64(),
"expected float width to be preserved as f64, got: {:?}",
table_attrs["width"]
);
}
#[test]
fn table_integer_width_roundtrip_preserves_integer() {
// Issue #577: Integer width in ADF must survive roundtrip without being
// coerced to a float.
let adf_doc = serde_json::json!({
"type": "doc",
"version": 1,
"content": [{
"type": "table",
"attrs": {
"isNumberColumnEnabled": false,
"layout": "center",
"localId": "abc-123",
"width": 1420
},
"content": [{
"type": "tableRow",
"content": [{
"type": "tableCell",
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Cell"}]}]
}]
}]
}]
});
let doc: crate::atlassian::adf::AdfDocument =
serde_json::from_value(adf_doc.clone()).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("width=1420"),
"expected width=1420 in markdown, got: {md}"
);
assert!(
!md.contains("width=1420.0"),
"integer width should not be rendered with decimal: {md}"
);
let doc2 = markdown_to_adf(&md).unwrap();
let table = &doc2.content[0];
assert_eq!(table.node_type, "table");
let table_attrs = table.attrs.as_ref().unwrap();
assert_eq!(table_attrs["width"], 1420);
assert!(
table_attrs["width"].is_u64() || table_attrs["width"].is_i64(),
"width should remain an integer, got: {:?}",
table_attrs["width"]
);
assert!(
!table_attrs["width"].is_f64(),
"width should not be a float, got: {:?}",
table_attrs["width"]
);
// Full byte-fidelity: re-serialized ADF should match original JSON.
let roundtripped = serde_json::to_value(&doc2).unwrap();
let orig_width = &adf_doc["content"][0]["attrs"]["width"];
let rt_width = &roundtripped["content"][0]["attrs"]["width"];
assert_eq!(
orig_width, rt_width,
"width value must roundtrip byte-for-byte"
);
}
#[test]
fn table_fractional_width_roundtrip() {
// Fractional float widths should also roundtrip faithfully.
let adf_doc = serde_json::json!({
"type": "doc",
"version": 1,
"content": [{
"type": "table",
"attrs": {"layout": "default", "width": 760.5},
"content": [{
"type": "tableRow",
"content": [{
"type": "tableHeader",
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
}]
}]
}]
});
let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("width=760.5"),
"expected width=760.5 in markdown, got: {md}"
);
let doc2 = markdown_to_adf(&md).unwrap();
let table_attrs = doc2.content[0].attrs.as_ref().unwrap();
assert_eq!(table_attrs["width"], 760.5);
assert!(table_attrs["width"].is_f64());
}
#[test]
fn pipe_table_integer_width_roundtrip() {
// Exercises the try_table() attrs-on-next-line parsing path.
let md = "| A | B |\n|---|---|\n| 1 | 2 |\n{layout=default width=1420}\n";
let doc = markdown_to_adf(md).unwrap();
let table = &doc.content[0];
assert_eq!(table.node_type, "table");
let attrs = table.attrs.as_ref().unwrap();
assert_eq!(attrs["width"], 1420);
assert!(
attrs["width"].is_u64() || attrs["width"].is_i64(),
"pipe-table width must stay integer, got: {:?}",
attrs["width"]
);
}
#[test]
fn file_media_width_type_roundtrip() {
// mediaSingle with widthType:pixel should survive round-trip
let adf_doc = serde_json::json!({
"type": "doc",
"version": 1,
"content": [{
"type": "mediaSingle",
"attrs": {"layout": "center", "width": 312, "widthType": "pixel"},
"content": [{
"type": "media",
"attrs": {
"type": "file",
"id": "abc123",
"collection": "contentId-999",
"height": 56,
"width": 312
}
}]
}]
});
let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("widthType=pixel"),
"expected widthType=pixel in markdown, got: {md}"
);
let doc2 = markdown_to_adf(&md).unwrap();
let ms = &doc2.content[0];
let ms_attrs = ms.attrs.as_ref().unwrap();
assert_eq!(ms_attrs["widthType"], "pixel");
assert_eq!(ms_attrs["width"], 312);
}
#[test]
fn file_media_mode_roundtrip() {
// mediaSingle with mode attr should survive round-trip (issue #431)
let adf_doc = serde_json::json!({
"type": "doc",
"version": 1,
"content": [{
"type": "mediaSingle",
"attrs": {"layout": "wide", "mode": "wide", "width": 1200},
"content": [{
"type": "media",
"attrs": {
"type": "file",
"id": "abc123",
"collection": "test",
"width": 1200,
"height": 600
}
}]
}]
});
let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("mode=wide"),
"expected mode=wide in markdown, got: {md}"
);
let doc2 = markdown_to_adf(&md).unwrap();
let ms = &doc2.content[0];
let ms_attrs = ms.attrs.as_ref().unwrap();
assert_eq!(ms_attrs["mode"], "wide");
assert_eq!(ms_attrs["layout"], "wide");
assert_eq!(ms_attrs["width"], 1200);
}
#[test]
fn external_media_mode_roundtrip() {
// External mediaSingle with mode attr should survive round-trip (issue #431)
let adf_doc = serde_json::json!({
"type": "doc",
"version": 1,
"content": [{
"type": "mediaSingle",
"attrs": {"layout": "wide", "mode": "wide"},
"content": [{
"type": "media",
"attrs": {
"type": "external",
"url": "https://example.com/image.png"
}
}]
}]
});
let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("mode=wide"),
"expected mode=wide in markdown, got: {md}"
);
let doc2 = markdown_to_adf(&md).unwrap();
let ms = &doc2.content[0];
let ms_attrs = ms.attrs.as_ref().unwrap();
assert_eq!(ms_attrs["mode"], "wide");
assert_eq!(ms_attrs["layout"], "wide");
}
#[test]
fn media_mode_only_roundtrip() {
// mediaSingle with mode but default layout should still preserve mode (issue #431)
let adf_doc = serde_json::json!({
"type": "doc",
"version": 1,
"content": [{
"type": "mediaSingle",
"attrs": {"layout": "center", "mode": "default"},
"content": [{
"type": "media",
"attrs": {
"type": "external",
"url": "https://example.com/image.png"
}
}]
}]
});
let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("mode=default"),
"expected mode=default in markdown, got: {md}"
);
let doc2 = markdown_to_adf(&md).unwrap();
let ms = &doc2.content[0];
let ms_attrs = ms.attrs.as_ref().unwrap();
assert_eq!(ms_attrs["mode"], "default");
}
#[test]
fn file_media_hex_localid_roundtrip() {
// Issue #432: short hex localId (non-UUID) must survive round-trip
let adf_doc = serde_json::json!({
"type": "doc",
"version": 1,
"content": [{
"type": "mediaSingle",
"attrs": {"layout": "wide", "width": 1200, "widthType": "pixel"},
"content": [{
"type": "media",
"attrs": {
"type": "file",
"id": "eb7a9c3b-314e-4458-8200-4b22b67b122e",
"collection": "contentId-123",
"height": 484,
"width": 915,
"alt": "image.png",
"localId": "0e79f58ac382"
}
}]
}]
});
let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("localId=0e79f58ac382"),
"expected localId=0e79f58ac382 in markdown, got: {md}"
);
let doc2 = markdown_to_adf(&md).unwrap();
let ms = &doc2.content[0];
let media = &ms.content.as_ref().unwrap()[0];
let attrs = media.attrs.as_ref().unwrap();
assert_eq!(attrs["localId"], "0e79f58ac382");
}
#[test]
fn file_media_uuid_localid_roundtrip() {
// UUID-format localId must also survive round-trip
let adf_doc = serde_json::json!({
"type": "doc",
"version": 1,
"content": [{
"type": "mediaSingle",
"attrs": {"layout": "center"},
"content": [{
"type": "media",
"attrs": {
"type": "file",
"id": "abc-123",
"collection": "contentId-456",
"height": 100,
"width": 200,
"localId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}]
}]
});
let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("localId=a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
"expected UUID localId in markdown, got: {md}"
);
let doc2 = markdown_to_adf(&md).unwrap();
let media = &doc2.content[0].content.as_ref().unwrap()[0];
let attrs = media.attrs.as_ref().unwrap();
assert_eq!(attrs["localId"], "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
}
#[test]
fn file_media_null_uuid_localid_stripped() {
// Null UUID localId should be stripped (consistent with other node types)
let adf_doc = serde_json::json!({
"type": "doc",
"version": 1,
"content": [{
"type": "mediaSingle",
"attrs": {"layout": "center"},
"content": [{
"type": "media",
"attrs": {
"type": "file",
"id": "abc-123",
"collection": "contentId-456",
"height": 100,
"width": 200,
"localId": "00000000-0000-0000-0000-000000000000"
}
}]
}]
});
let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
!md.contains("localId="),
"null UUID localId should be stripped, got: {md}"
);
}
#[test]
fn file_media_localid_stripped_when_option_set() {
// localId should be stripped when strip_local_ids option is enabled
let adf_doc = serde_json::json!({
"type": "doc",
"version": 1,
"content": [{
"type": "mediaSingle",
"attrs": {"layout": "center"},
"content": [{
"type": "media",
"attrs": {
"type": "file",
"id": "abc-123",
"collection": "contentId-456",
"height": 100,
"width": 200,
"localId": "0e79f58ac382"
}
}]
}]
});
let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
let opts = RenderOptions {
strip_local_ids: true,
..Default::default()
};
let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
assert!(
!md.contains("localId="),
"localId should be stripped with strip_local_ids, got: {md}"
);
}
#[test]
fn external_media_localid_roundtrip() {
// localId on external media nodes must also survive round-trip
let adf_doc = serde_json::json!({
"type": "doc",
"version": 1,
"content": [{
"type": "mediaSingle",
"attrs": {"layout": "center"},
"content": [{
"type": "media",
"attrs": {
"type": "external",
"url": "https://example.com/image.png",
"alt": "test",
"localId": "deadbeef1234"
}
}]
}]
});
let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("localId=deadbeef1234"),
"expected localId in markdown for external media, got: {md}"
);
let doc2 = markdown_to_adf(&md).unwrap();
let media = &doc2.content[0].content.as_ref().unwrap()[0];
let attrs = media.attrs.as_ref().unwrap();
assert_eq!(attrs["localId"], "deadbeef1234");
}
#[test]
fn bracket_in_text_not_parsed_as_link() {
// "[Task] some text (Link)" — the [Task] must NOT be treated as a link anchor
let md = ":check_mark: [Task] Unable to start trial ([Link](https://example.com/link))";
let doc = markdown_to_adf(md).unwrap();
let para = &doc.content[0];
assert_eq!(para.node_type, "paragraph");
let content = para.content.as_ref().unwrap();
// Find the text node containing "[Task]"
let text_nodes: Vec<_> = content.iter().filter(|n| n.node_type == "text").collect();
let has_task_bracket = text_nodes
.iter()
.any(|n| n.text.as_deref().unwrap_or("").contains("[Task]"));
assert!(
has_task_bracket,
"expected [Task] in plain text, nodes: {content:?}"
);
// Also verify the (Link) is a proper link
let link_nodes: Vec<_> = content
.iter()
.filter(|n| {
n.marks
.as_ref()
.is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
})
.collect();
assert!(!link_nodes.is_empty(), "expected a link node");
assert_eq!(
link_nodes[0].text.as_deref(),
Some("Link"),
"link text should be 'Link'"
);
}
#[test]
fn empty_paragraph_roundtrip() {
// An empty ADF paragraph node should survive a round-trip through markdown
let mut adf_in = AdfDocument::new();
adf_in.content = vec![
AdfNode::paragraph(vec![AdfNode::text("before")]),
AdfNode::paragraph(vec![]),
AdfNode::paragraph(vec![AdfNode::text("after")]),
];
let md = adf_to_markdown(&adf_in).unwrap();
let adf_out = markdown_to_adf(&md).unwrap();
assert_eq!(
adf_out.content.len(),
3,
"should have 3 blocks, markdown:\n{md}"
);
assert_eq!(adf_out.content[0].node_type, "paragraph");
assert_eq!(adf_out.content[1].node_type, "paragraph");
assert!(
adf_out.content[1].content.is_none(),
"middle paragraph should be empty"
);
assert_eq!(adf_out.content[2].node_type, "paragraph");
}
#[test]
fn nbsp_paragraph_roundtrip() {
// Issue #411: paragraph with only NBSP should survive round-trip
let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}";
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("::paragraph["),
"NBSP paragraph should use directive form: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(rt.content.len(), 1, "should have 1 block");
assert_eq!(rt.content[0].node_type, "paragraph");
let text = rt.content[0].content.as_ref().unwrap()[0]
.text
.as_deref()
.unwrap_or("");
assert_eq!(text, "\u{00a0}", "NBSP should survive round-trip");
}
#[test]
fn nbsp_in_nested_expand_roundtrip() {
// Issue #411 real-world case: NBSP paragraph inside nestedExpand
let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"nestedExpand\",\"attrs\":{\"title\":\"Section\"},\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}]}";
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let ne = &rt.content[0];
assert_eq!(ne.node_type, "nestedExpand");
let inner = ne.content.as_ref().unwrap();
assert_eq!(inner.len(), 1, "should have 1 inner block");
assert_eq!(inner[0].node_type, "paragraph");
let content = inner[0].content.as_ref().unwrap();
assert!(!content.is_empty(), "paragraph should not be empty");
let text = content[0].text.as_deref().unwrap_or("");
assert_eq!(text, "\u{00a0}", "NBSP should survive in nestedExpand");
}
#[test]
fn nbsp_followed_by_content() {
// NBSP paragraph followed by regular content should not interfere
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\"}]}]}";
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
assert!(rt.content.len() >= 2, "should have at least 2 blocks");
// The second block should be a paragraph with "after"
let after_para = rt.content.iter().find(|n| {
n.node_type == "paragraph"
&& n.content
.as_ref()
.and_then(|c| c.first())
.and_then(|n| n.text.as_deref())
.map_or(false, |t| t.contains("after"))
});
assert!(after_para.is_some(), "should have paragraph with 'after'");
}
#[test]
fn nbsp_paragraph_with_marks_survives() {
// NBSP with bold marks renders as `** **` which contains non-whitespace
// chars and thus doesn't need the directive form — it round-trips naturally
let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\",\"marks\":[{\"type\":\"strong\"}]}]}]}";
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("**"), "should have bold markers: {md}");
let rt = markdown_to_adf(&md).unwrap();
let content = rt.content[0].content.as_ref().unwrap();
assert!(!content.is_empty(), "should preserve content");
}
#[test]
fn regular_paragraph_unchanged() {
// Regression guard: normal paragraphs should NOT use directive form
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
!md.contains("::paragraph"),
"regular paragraphs should not use directive form: {md}"
);
assert!(md.contains("hello"));
}
#[test]
fn paragraph_directive_with_content_parsed() {
// ::paragraph[content] should parse to a paragraph with inline nodes
let md = "::paragraph[\u{00a0}]\n";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content.len(), 1);
assert_eq!(doc.content[0].node_type, "paragraph");
let content = doc.content[0].content.as_ref().unwrap();
assert!(!content.is_empty(), "should have inline content");
assert_eq!(content[0].text.as_deref().unwrap(), "\u{00a0}");
}
#[test]
fn nbsp_paragraph_in_list_item_with_nested_list() {
// Issue #448: NBSP paragraph content lost inside listItem with nested bulletList
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"}]}]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let list = &rt.content[0];
assert_eq!(list.node_type, "bulletList");
let item = &list.content.as_ref().unwrap()[0];
let item_content = item.content.as_ref().unwrap();
assert_eq!(
item_content.len(),
2,
"listItem should have paragraph + nested list, got: {item_content:?}"
);
let para = &item_content[0];
assert_eq!(para.node_type, "paragraph");
let para_content = para
.content
.as_ref()
.expect("paragraph should have content");
assert!(
!para_content.is_empty(),
"NBSP paragraph content should not be empty"
);
assert_eq!(
para_content[0].text.as_deref().unwrap(),
"\u{00a0}",
"NBSP should survive round-trip inside listItem"
);
}
#[test]
fn nbsp_paragraph_in_list_item_with_local_ids() {
// Issue #448: NBSP paragraph with localIds inside listItem with nested list
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"}]}]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let list = &rt.content[0];
let item = &list.content.as_ref().unwrap()[0];
// Check listItem localId
assert_eq!(
item.attrs.as_ref().unwrap()["localId"],
"li-001",
"listItem localId should survive"
);
let item_content = item.content.as_ref().unwrap();
assert_eq!(item_content.len(), 2);
// Check paragraph localId and NBSP content
let para = &item_content[0];
assert_eq!(
para.attrs.as_ref().unwrap()["localId"],
"p-001",
"paragraph localId should survive"
);
let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
assert_eq!(text, "\u{00a0}", "NBSP should survive with localIds");
}
#[test]
fn nbsp_paragraph_in_list_item_without_nested_list() {
// NBSP paragraph in a simple listItem (no nested list)
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"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let list = &rt.content[0];
let item = &list.content.as_ref().unwrap()[0];
let para = &item.content.as_ref().unwrap()[0];
let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
assert_eq!(text, "\u{00a0}", "NBSP should survive in simple list item");
}
#[test]
fn nbsp_paragraph_in_ordered_list_item_with_nested_list() {
// NBSP paragraph in ordered listItem with nested bulletList
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"}]}]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let list = &rt.content[0];
let item = &list.content.as_ref().unwrap()[0];
let item_content = item.content.as_ref().unwrap();
assert_eq!(item_content.len(), 2);
let para = &item_content[0];
let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
assert_eq!(text, "\u{00a0}", "NBSP should survive in ordered list item");
}
#[test]
fn list_item_leading_space_preserved() {
// Leading space in list item text must not be stripped
let md = "- hello world\n- - text";
let doc = markdown_to_adf(md).unwrap();
let list = &doc.content[0];
assert_eq!(list.node_type, "bulletList");
let items = list.content.as_ref().unwrap();
// First item: "hello world" (no leading space, unchanged)
let first_para = &items[0].content.as_ref().unwrap()[0];
let first_text = &first_para.content.as_ref().unwrap()[0];
assert_eq!(first_text.text.as_deref(), Some("hello world"));
}
#[test]
fn list_item_leading_space_not_stripped() {
// When the markdown list item content has a leading space (e.g. " :emoji:"),
// that space must reach parse_inline as-is.
let md = "- leading space text";
let doc = markdown_to_adf(md).unwrap();
let list = &doc.content[0];
let items = list.content.as_ref().unwrap();
let para = &items[0].content.as_ref().unwrap()[0];
let text_node = ¶.content.as_ref().unwrap()[0];
// After "- " (2 chars), trim_end keeps the leading space: " leading space text"
assert_eq!(
text_node.text.as_deref(),
Some(" leading space text"),
"leading space should be preserved"
);
}
// ── Nested container directive tests ───────────────────────────
// ── hardBreak in table cell tests ────────────────────────────
#[test]
fn hardbreak_in_cell_uses_directive_table() {
// A table cell with a hardBreak should NOT use pipe syntax
// because the newline would break the row
let adf = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
AdfNode::table_cell(vec![AdfNode::paragraph(vec![
AdfNode::text("before"),
AdfNode::hard_break(),
AdfNode::text("after"),
])]),
])])],
};
let md = adf_to_markdown(&adf).unwrap();
// Should render as directive table, not pipe table
assert!(
md.contains(":::td") || md.contains("::::table"),
"Table with hardBreak should use directive form, got:\n{md}"
);
assert!(
!md.contains("| before"),
"Should NOT use pipe syntax with hardBreak"
);
}
#[test]
fn hardbreak_in_cell_roundtrips() {
// Verify the directive table form preserves the hardBreak on round-trip
let adf = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
AdfNode::table_cell(vec![AdfNode::paragraph(vec![
AdfNode::text("line one"),
AdfNode::hard_break(),
AdfNode::text("line two"),
])]),
])])],
};
let md = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&md).unwrap();
// Should still have one table with one row with one cell
assert_eq!(roundtripped.content.len(), 1);
assert_eq!(roundtripped.content[0].node_type, "table");
let rows = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(
rows.len(),
1,
"Should have exactly 1 row, got {}",
rows.len()
);
}
#[test]
fn hardbreak_in_paragraph_roundtrips() {
// Issue #373: hardBreak absorbed into preceding text node
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"line one"},
{"type":"hardBreak"},
{"type":"text","text":"line two"}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let inlines = round_tripped.content[0].content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(
types,
vec!["text", "hardBreak", "text"],
"hardBreak should be preserved, got: {types:?}"
);
assert_eq!(inlines[0].text.as_deref(), Some("line one"));
assert_eq!(inlines[2].text.as_deref(), Some("line two"));
}
#[test]
fn consecutive_hardbreaks_in_paragraph_roundtrip() {
// Issue #410: consecutive hardBreak nodes collapsed on round-trip
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"before"},
{"type":"hardBreak"},
{"type":"hardBreak"},
{"type":"text","text":"after"}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
assert_eq!(
round_tripped.content.len(),
1,
"Should remain a single paragraph, got {} blocks",
round_tripped.content.len()
);
let inlines = round_tripped.content[0].content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(
types,
vec!["text", "hardBreak", "hardBreak", "text"],
"Both hardBreaks should be preserved, got: {types:?}"
);
assert_eq!(inlines[0].text.as_deref(), Some("before"));
assert_eq!(inlines[3].text.as_deref(), Some("after"));
}
#[test]
fn hardbreak_only_paragraph_roundtrips() {
// Issue #410: paragraph whose only content is a hardBreak is dropped
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"paragraph","content":[{"type":"hardBreak"}]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
assert_eq!(
round_tripped.content.len(),
1,
"Paragraph should not be dropped, got {} blocks",
round_tripped.content.len()
);
let inlines = round_tripped.content[0].content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(
types,
vec!["hardBreak"],
"hardBreak-only paragraph should preserve its content, got: {types:?}"
);
}
#[test]
fn issue_410_full_reproducer_roundtrips() {
// Full reproducer from issue #410: consecutive hardBreaks + hardBreak-only paragraph
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"paragraph","content":[
{"type":"text","text":"before"},
{"type":"hardBreak"},
{"type":"hardBreak"},
{"type":"text","text":"after"}
]},
{"type":"paragraph","content":[
{"type":"hardBreak"}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
assert_eq!(
round_tripped.content.len(),
2,
"Should have exactly 2 paragraphs, got {}",
round_tripped.content.len()
);
// First paragraph: text, hardBreak, hardBreak, text
let p1 = round_tripped.content[0].content.as_ref().unwrap();
let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types1, vec!["text", "hardBreak", "hardBreak", "text"]);
// Second paragraph: hardBreak only
let p2 = round_tripped.content[1].content.as_ref().unwrap();
let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types2, vec!["hardBreak"]);
}
#[test]
fn trailing_space_hardbreak_still_parsed() {
// Backward compatibility: trailing-space hardBreak (old JFM format) still parses
let md = "line one \nline two\n";
let doc = markdown_to_adf(md).unwrap();
let inlines = doc.content[0].content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(
types,
vec!["text", "hardBreak", "text"],
"Trailing-space hardBreak should still parse, got: {types:?}"
);
}
#[test]
fn trailing_hardbreak_at_end_of_paragraph_roundtrips() {
// A paragraph ending with a hardBreak (no text after it)
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"text"},
{"type":"hardBreak"}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let inlines = round_tripped.content[0].content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(
types,
vec!["text", "hardBreak"],
"Trailing hardBreak should be preserved, got: {types:?}"
);
}
#[test]
#[test]
fn table_with_header_row_uses_pipe_syntax() {
// A table with tableHeader in the first row should use pipe syntax
let adf = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("header cell")])]),
])])],
};
let md = adf_to_markdown(&adf).unwrap();
assert!(
md.contains("| header cell |"),
"Table with header row should use pipe syntax, got:\n{md}"
);
}
#[test]
fn table_without_header_row_uses_directive_syntax() {
// Issue #392: tableCell-only first row must use directive syntax
// to avoid converting tableCell → tableHeader on round-trip
let adf = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("simple cell")])]),
])])],
};
let md = adf_to_markdown(&adf).unwrap();
assert!(
md.contains("::::table"),
"Table without header row should use directive syntax, got:\n{md}"
);
}
#[test]
fn tablecell_first_row_preserved_on_roundtrip() {
// Issue #392: tableCell in first row round-trips as tableHeader
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{},"content":[
{"type":"tableRow","content":[
{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row1 cell"}]}]}
]},
{"type":"tableRow","content":[
{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row2 cell"}]}]}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let rows = round_tripped.content[0].content.as_ref().unwrap();
let row0_cell = &rows[0].content.as_ref().unwrap()[0];
assert_eq!(
row0_cell.node_type, "tableCell",
"first row cell should remain tableCell, got: {}",
row0_cell.node_type
);
let row1_cell = &rows[1].content.as_ref().unwrap()[0];
assert_eq!(row1_cell.node_type, "tableCell");
}
#[test]
fn mixed_header_and_cell_first_row_uses_pipe() {
// A first row with at least one tableHeader qualifies for pipe syntax
let adf = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::table(vec![
AdfNode::table_row(vec![
AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
]),
AdfNode::table_row(vec![
AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
]),
])],
};
let md = adf_to_markdown(&adf).unwrap();
assert!(
md.contains("| H1 |"),
"Table with header first row should use pipe syntax, got:\n{md}"
);
assert!(!md.contains("::::table"), "should not use directive syntax");
}
// ── Issue #579: pipes in pipe-table cells ─────────────────────
#[test]
fn render_pipe_table_escapes_pipe_in_code_span_cell() {
// A code-marked text node with a literal `|` in a pipe-table cell
// must emit `\|` so the column separator is unambiguous.
let adf = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::table(vec![
AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
AdfNode::text("Header"),
])])]),
AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
AdfNode::text_with_marks("a|b", vec![AdfMark::code()]),
])])]),
])],
};
let md = adf_to_markdown(&adf).unwrap();
assert!(
md.contains(r"`a\|b`"),
"Pipe inside code span must be escaped, got:\n{md}"
);
}
#[test]
fn render_pipe_table_escapes_pipe_in_plain_text_cell() {
let adf = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::table(vec![
AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
AdfNode::text("Header"),
])])]),
AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
AdfNode::text("x|y"),
])])]),
])],
};
let md = adf_to_markdown(&adf).unwrap();
assert!(
md.contains(r"x\|y"),
"Pipe inside plain-text cell must be escaped, got:\n{md}"
);
}
#[test]
fn code_span_with_pipe_in_table_cell_roundtrips() {
// Issue #579 reproducer: code span containing `|` in a pipe-table cell.
let adf_json = r#"{
"version": 1,
"type": "doc",
"content": [{
"type": "table",
"attrs": {"isNumberColumnEnabled": false, "layout": "default", "localId": "abc-789"},
"content": [
{"type": "tableRow", "content": [
{"type": "tableHeader", "attrs": {}, "content": [
{"type": "paragraph", "content": [{"type": "text", "text": "Before"}]}
]},
{"type": "tableHeader", "attrs": {}, "content": [
{"type": "paragraph", "content": [{"type": "text", "text": "After"}]}
]}
]},
{"type": "tableRow", "content": [
{"type": "tableCell", "attrs": {}, "content": [
{"type": "paragraph", "content": [
{"type": "text", "text": "parse(json).extract[T]", "marks": [{"type": "code"}]}
]}
]},
{"type": "tableCell", "attrs": {}, "content": [
{"type": "paragraph", "content": [
{"type": "text", "text": "parser.decode[T|json]", "marks": [{"type": "code"}]}
]}
]}
]}
]
}]
}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let rows = round_tripped.content[0].content.as_ref().unwrap();
assert_eq!(
rows.len(),
2,
"Table should have 2 rows, got: {}",
rows.len()
);
let body_row = rows[1].content.as_ref().unwrap();
assert_eq!(
body_row.len(),
2,
"Body row should have 2 cells (not split by the pipe), got: {}",
body_row.len()
);
let second_cell = &body_row[1];
let para = second_cell.content.as_ref().unwrap().first().unwrap();
let inlines = para.content.as_ref().unwrap();
assert_eq!(inlines.len(), 1, "Cell should have a single text node");
assert_eq!(
inlines[0].text.as_deref(),
Some("parser.decode[T|json]"),
"Code-span text must be preserved with literal pipe"
);
let marks = inlines[0]
.marks
.as_ref()
.expect("code mark must be preserved");
assert!(
marks.iter().any(|m| m.mark_type == "code"),
"text node should carry the code mark"
);
}
#[test]
fn plain_text_pipe_in_table_cell_roundtrips() {
// Plain text with `|` in a pipe-table cell should also survive.
let adf = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::table(vec![
AdfNode::table_row(vec![
AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
]),
AdfNode::table_row(vec![
AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a|b")])]),
AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("c")])]),
]),
])],
};
let md = adf_to_markdown(&adf).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let rows = round_tripped.content[0].content.as_ref().unwrap();
let body_row = rows[1].content.as_ref().unwrap();
assert_eq!(
body_row.len(),
2,
"Body row should keep 2 cells, got: {}",
body_row.len()
);
let first_cell_text = body_row[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap()[0]
.text
.as_deref();
assert_eq!(first_cell_text, Some("a|b"));
}
#[test]
fn cell_contains_hard_break_true() {
let para = AdfNode::paragraph(vec![
AdfNode::text("a"),
AdfNode::hard_break(),
AdfNode::text("b"),
]);
assert!(cell_contains_hard_break(¶));
}
#[test]
fn cell_contains_hard_break_false() {
let para = AdfNode::paragraph(vec![AdfNode::text("no break here")]);
assert!(!cell_contains_hard_break(¶));
}
#[test]
fn cell_contains_hard_break_empty() {
let para = AdfNode::paragraph(vec![]);
assert!(!cell_contains_hard_break(¶));
}
// ── Multi-paragraph container tests ──────────────────────────
#[test]
fn multi_paragraph_panel_roundtrips() {
let adf = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode {
node_type: "panel".to_string(),
attrs: Some(serde_json::json!({"panelType": "info"})),
content: Some(vec![
AdfNode::paragraph(vec![AdfNode::text("First paragraph.")]),
AdfNode::paragraph(vec![AdfNode::text("Second paragraph.")]),
]),
text: None,
marks: None,
local_id: None,
parameters: None,
}],
};
let md = adf_to_markdown(&adf).unwrap();
// Should have blank line between paragraphs inside the panel
assert!(
md.contains("First paragraph.\n\nSecond paragraph."),
"Panel should have blank line between paragraphs, got:\n{md}"
);
// Round-trip should preserve two separate paragraphs
let roundtripped = markdown_to_adf(&md).unwrap();
assert_eq!(roundtripped.content.len(), 1);
assert_eq!(roundtripped.content[0].node_type, "panel");
let panel_content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(
panel_content.len(),
2,
"Panel should have 2 paragraphs after round-trip, got {}",
panel_content.len()
);
}
#[test]
fn multi_paragraph_expand_roundtrips() {
let adf = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode {
node_type: "expand".to_string(),
attrs: Some(serde_json::json!({"title": "Details"})),
content: Some(vec![
AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
]),
text: None,
marks: None,
local_id: None,
parameters: None,
}],
};
let md = adf_to_markdown(&adf).unwrap();
let roundtripped = markdown_to_adf(&md).unwrap();
let expand_content = roundtripped.content[0].content.as_ref().unwrap();
assert_eq!(
expand_content.len(),
2,
"Expand should have 2 paragraphs after round-trip, got {}",
expand_content.len()
);
}
#[test]
fn consecutive_nested_expands_in_table_cell_roundtrip() {
let cell_content = vec![
AdfNode {
node_type: "nestedExpand".to_string(),
attrs: Some(serde_json::json!({"title": "First"})),
content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 1")])]),
text: None,
marks: None,
local_id: None,
parameters: None,
},
AdfNode {
node_type: "nestedExpand".to_string(),
attrs: Some(serde_json::json!({"title": "Second"})),
content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 2")])]),
text: None,
marks: None,
local_id: None,
parameters: None,
},
];
let adf = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
AdfNode::table_cell(cell_content),
])])],
};
let md = adf_to_markdown(&adf).unwrap();
assert!(
md.contains(":::\n\n:::nested-expand"),
"Should have blank line between consecutive nested-expands in cell, got:\n{md}"
);
let rt = markdown_to_adf(&md).unwrap();
let cell = &rt.content[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap()[0];
let cell_nodes = cell.content.as_ref().unwrap();
let expand_count = cell_nodes
.iter()
.filter(|n| n.node_type == "nestedExpand")
.count();
assert_eq!(
expand_count, 2,
"Both nested-expands should survive round-trip, got {expand_count}"
);
}
#[test]
fn multi_paragraph_in_table_cell_roundtrip() {
// Two paragraphs inside a directive table cell should survive round-trip
let adf = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
AdfNode::table_cell(vec![
AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
]),
])])],
};
let md = adf_to_markdown(&adf).unwrap();
assert!(
md.contains("Para one.\n\nPara two."),
"Should have blank line between paragraphs in cell, got:\n{md}"
);
let rt = markdown_to_adf(&md).unwrap();
let cell = &rt.content[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap()[0];
let para_count = cell
.content
.as_ref()
.unwrap()
.iter()
.filter(|n| n.node_type == "paragraph")
.count();
assert_eq!(para_count, 2, "Both paragraphs should survive round-trip");
}
#[test]
fn panel_inside_table_cell_roundtrip() {
// A panel inside a directive table cell
let adf = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
AdfNode::table_cell(vec![
AdfNode::paragraph(vec![AdfNode::text("Before panel.")]),
AdfNode {
node_type: "panel".to_string(),
attrs: Some(serde_json::json!({"panelType": "info"})),
content: Some(vec![AdfNode::paragraph(vec![AdfNode::text(
"Panel content",
)])]),
text: None,
marks: None,
local_id: None,
parameters: None,
},
]),
])])],
};
let md = adf_to_markdown(&adf).unwrap();
assert!(
md.contains(":::panel"),
"Should contain panel directive, got:\n{md}"
);
let rt = markdown_to_adf(&md).unwrap();
let cell = &rt.content[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap()[0];
let has_panel = cell
.content
.as_ref()
.unwrap()
.iter()
.any(|n| n.node_type == "panel");
assert!(has_panel, "Panel should survive round-trip in table cell");
}
#[test]
fn three_consecutive_expands_in_table_cell() {
let make_expand = |title: &str| AdfNode {
node_type: "nestedExpand".to_string(),
attrs: Some(serde_json::json!({"title": title})),
content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("content")])]),
text: None,
marks: None,
local_id: None,
parameters: None,
};
let adf = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
AdfNode::table_cell(vec![
make_expand("First"),
make_expand("Second"),
make_expand("Third"),
]),
])])],
};
let md = adf_to_markdown(&adf).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let cell = &rt.content[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap()[0];
let expand_count = cell
.content
.as_ref()
.unwrap()
.iter()
.filter(|n| n.node_type == "nestedExpand")
.count();
assert_eq!(expand_count, 3, "All 3 expands should survive round-trip");
}
// ── Nested container directive tests ───────────────────────────
#[test]
fn nested_expand_inside_panel() {
let md = ":::panel{type=info}\n:::expand{title=\"Details\"}\nHidden content\n:::\nMore panel content\n:::";
let adf = markdown_to_adf(md).unwrap();
// Should produce a panel node
assert_eq!(adf.content.len(), 1);
assert_eq!(adf.content[0].node_type, "panel");
// Panel should contain the expand AND "More panel content"
let panel_content = adf.content[0].content.as_ref().unwrap();
assert!(
panel_content.len() >= 2,
"Panel should contain expand + paragraph, got {} nodes",
panel_content.len()
);
}
#[test]
fn nested_expand_inside_table_cell() {
let md = "::::table\n:::tr\n:::td\n:::expand{title=\"Details\"}\nExpand content\n:::\n:::\n:::\n::::";
let adf = markdown_to_adf(md).unwrap();
// Should produce a table
assert_eq!(adf.content.len(), 1);
assert_eq!(adf.content[0].node_type, "table");
// Table -> row -> cell -> should contain an expand node
let rows = adf.content[0].content.as_ref().unwrap();
assert_eq!(rows.len(), 1);
let cells = rows[0].content.as_ref().unwrap();
assert_eq!(cells.len(), 1);
let cell_content = cells[0].content.as_ref().unwrap();
assert!(
cell_content.iter().any(|n| n.node_type == "expand"),
"Cell should contain an expand node, got: {:?}",
cell_content
.iter()
.map(|n| &n.node_type)
.collect::<Vec<_>>()
);
}
#[test]
fn nested_expand_inside_layout_column() {
let md = ":::layout\n:::column{width=100}\n:::expand{title=\"Col Expand\"}\nExpanded\n:::\n:::\n:::";
let adf = markdown_to_adf(md).unwrap();
assert_eq!(adf.content.len(), 1);
assert_eq!(adf.content[0].node_type, "layoutSection");
let columns = adf.content[0].content.as_ref().unwrap();
assert_eq!(columns.len(), 1);
let col_content = columns[0].content.as_ref().unwrap();
assert!(
col_content.iter().any(|n| n.node_type == "expand"),
"Column should contain an expand node, got: {:?}",
col_content.iter().map(|n| &n.node_type).collect::<Vec<_>>()
);
}
#[test]
fn expand_localid_in_directive_attrs() {
// Issue #412: localId should be in directive attrs, not trailing text
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
{"type":"paragraph","content":[{"type":"text","text":"body"}]}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("localId=exp-001"),
"should contain localId: {md}"
);
assert!(
md.contains(":::expand{"),
"should have expand directive with attrs: {md}"
);
assert!(
!md.contains(":::\n{localId="),
"localId should NOT be trailing: {md}"
);
}
#[test]
fn expand_localid_roundtrip() {
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
{"type":"paragraph","content":[{"type":"text","text":"body"}]}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let expand = &rt.content[0];
assert_eq!(expand.node_type, "expand");
assert_eq!(
expand.local_id.as_deref(),
Some("exp-001"),
"expand localId should survive round-trip"
);
assert_eq!(
expand.attrs.as_ref().unwrap()["title"],
"Details",
"expand title should survive round-trip"
);
}
#[test]
fn nested_expand_localid_roundtrip() {
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"nestedExpand","attrs":{"localId":"ne-001","title":"S"},"content":[
{"type":"paragraph","content":[{"type":"text","text":"content"}]}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains(":::nested-expand{"),
"should have directive: {md}"
);
assert!(md.contains("localId=ne-001"), "should have localId: {md}");
let rt = markdown_to_adf(&md).unwrap();
let ne = &rt.content[0];
assert_eq!(ne.node_type, "nestedExpand");
assert_eq!(ne.local_id.as_deref(), Some("ne-001"));
}
#[test]
fn nested_expand_localid_followed_by_content() {
// Issue #412 reproducer: localId must not leak into following paragraph
let adf_json = "{\
\"version\":1,\"type\":\"doc\",\"content\":[\
{\"type\":\"nestedExpand\",\"attrs\":{\"localId\":\"exp-001\",\"title\":\"S\"},\"content\":[\
{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}\
]},\
{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]}\
]}";
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
// nestedExpand should have localId
let ne = &rt.content[0];
assert_eq!(ne.node_type, "nestedExpand");
assert_eq!(
ne.local_id.as_deref(),
Some("exp-001"),
"nestedExpand should preserve localId"
);
// Following paragraph should contain "after", not "{localId=...}"
let para = &rt.content[1];
assert_eq!(para.node_type, "paragraph");
let text = para.content.as_ref().unwrap()[0]
.text
.as_deref()
.unwrap_or("");
assert!(
!text.contains("localId"),
"following paragraph should not contain localId: {text}"
);
assert!(
text.contains("after"),
"following paragraph should contain 'after': {text}"
);
}
#[test]
fn expand_localid_without_title() {
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"expand","attrs":{"localId":"exp-002"},"content":[
{"type":"paragraph","content":[{"type":"text","text":"no title"}]}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains(":::expand{localId=exp-002}"),
"should have localId without title: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(rt.content[0].local_id.as_deref(), Some("exp-002"));
}
#[test]
fn expand_localid_stripped() {
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"expand","attrs":{"localId":"exp-001","title":"X"},"content":[
{"type":"paragraph","content":[{"type":"text","text":"body"}]}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let opts = RenderOptions {
strip_local_ids: true,
};
let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
assert!(!md.contains("localId"), "localId should be stripped: {md}");
assert!(
md.contains(":::expand{title=\"X\"}"),
"title should remain: {md}"
);
}
// ── Issue #444: top-level localId and parameters on expand ──
#[test]
fn expand_top_level_localid_roundtrip() {
// localId as a top-level field (not inside attrs) should survive round-trip
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"expand","attrs":{"title":"My Section"},"localId":"abc-123","content":[
{"type":"paragraph","content":[{"type":"text","text":"hello"}]}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
assert_eq!(doc.content[0].local_id.as_deref(), Some("abc-123"));
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("localId=abc-123"),
"JFM should contain localId: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let expand = &rt.content[0];
assert_eq!(expand.node_type, "expand");
assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
assert_eq!(
expand.attrs.as_ref().unwrap()["title"],
"My Section",
"title should survive round-trip"
);
}
#[test]
fn expand_parameters_roundtrip() {
// parameters (macroMetadata) should survive round-trip
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"expand","attrs":{"title":"Props"},"parameters":{"macroMetadata":{"macroId":{"value":"m-001"},"schemaVersion":{"value":"1"}}},"content":[
{"type":"paragraph","content":[{"type":"text","text":"body"}]}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
assert!(doc.content[0].parameters.is_some());
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("params="), "JFM should contain params: {md}");
let rt = markdown_to_adf(&md).unwrap();
let expand = &rt.content[0];
let params = expand
.parameters
.as_ref()
.expect("parameters should survive round-trip");
assert_eq!(params["macroMetadata"]["macroId"]["value"], "m-001");
assert_eq!(params["macroMetadata"]["schemaVersion"]["value"], "1");
}
#[test]
fn expand_localid_and_parameters_roundtrip() {
// Issue #444: both localId and parameters on expand should survive round-trip
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"}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let expand = &rt.content[0];
assert_eq!(expand.node_type, "expand");
assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
assert_eq!(expand.attrs.as_ref().unwrap()["title"], "My Section");
let params = expand
.parameters
.as_ref()
.expect("parameters should survive");
assert_eq!(params["macroMetadata"]["macroId"]["value"], "macro-001");
assert_eq!(params["macroMetadata"]["title"], "Page Properties");
}
#[test]
fn nested_expand_top_level_localid_and_parameters_roundtrip() {
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"nestedExpand","attrs":{"title":"Nested"},"localId":"ne-100","parameters":{"macroMetadata":{"macroId":{"value":"nm-001"}}},"content":[
{"type":"paragraph","content":[{"type":"text","text":"inner"}]}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains(":::nested-expand{"),
"should use nested-expand: {md}"
);
assert!(md.contains("localId=ne-100"), "should have localId: {md}");
assert!(md.contains("params="), "should have params: {md}");
let rt = markdown_to_adf(&md).unwrap();
let ne = &rt.content[0];
assert_eq!(ne.node_type, "nestedExpand");
assert_eq!(ne.local_id.as_deref(), Some("ne-100"));
assert_eq!(
ne.parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
"nm-001"
);
}
#[test]
fn expand_top_level_localid_stripped() {
// strip_local_ids should strip top-level localId too
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"expand","attrs":{"title":"X"},"localId":"exp-strip","content":[
{"type":"paragraph","content":[{"type":"text","text":"body"}]}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let opts = RenderOptions {
strip_local_ids: true,
};
let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
assert!(!md.contains("localId"), "localId should be stripped: {md}");
assert!(
md.contains(":::expand{title=\"X\"}"),
"title should remain: {md}"
);
}
#[test]
fn expand_parameters_without_localid() {
// parameters without localId should work
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"expand","attrs":{"title":"P"},"parameters":{"macroMetadata":{"macroId":{"value":"solo"}}},"content":[
{"type":"paragraph","content":[{"type":"text","text":"data"}]}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(!md.contains("localId"), "no localId: {md}");
assert!(md.contains("params="), "has params: {md}");
let rt = markdown_to_adf(&md).unwrap();
assert!(rt.content[0].local_id.is_none());
assert_eq!(
rt.content[0].parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
"solo"
);
}
#[test]
fn expand_localid_without_parameters() {
// top-level localId without parameters should work
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"expand","attrs":{"title":"L"},"localId":"lid-only","content":[
{"type":"paragraph","content":[{"type":"text","text":"txt"}]}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("localId=lid-only"), "has localId: {md}");
assert!(!md.contains("params="), "no params: {md}");
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(rt.content[0].local_id.as_deref(), Some("lid-only"));
assert!(rt.content[0].parameters.is_none());
}
#[test]
fn nested_panel_inside_panel() {
let md = ":::panel{type=info}\n:::panel{type=warning}\nInner warning\n:::\n:::";
let adf = markdown_to_adf(md).unwrap();
// Outer panel should exist
assert_eq!(adf.content.len(), 1);
assert_eq!(adf.content[0].node_type, "panel");
// Outer panel should contain an inner panel (not have it truncated)
let panel_content = adf.content[0].content.as_ref().unwrap();
assert!(
panel_content.iter().any(|n| n.node_type == "panel"),
"Outer panel should contain an inner panel, got: {:?}",
panel_content
.iter()
.map(|n| &n.node_type)
.collect::<Vec<_>>()
);
}
#[test]
fn content_after_directive_table_is_preserved() {
// Issue #361: content after a ::::table block was silently dropped
let md = "\
## Before table
::::table{layout=default}
:::tr
:::th{}
Cell
:::
:::
::::
## After table
Paragraph after.";
let adf = markdown_to_adf(md).unwrap();
let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(
types,
vec!["heading", "table", "heading", "paragraph"],
"Content after table was dropped: got {types:?}"
);
}
#[test]
fn paragraph_after_directive_table_is_preserved() {
// Issue #361: minimal reproducer — paragraph after table
let md = "\
::::table{layout=default}
:::tr
:::th{}
Header
:::
:::
::::
Just a paragraph.";
let adf = markdown_to_adf(md).unwrap();
let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(
types,
vec!["table", "paragraph"],
"Paragraph after table was dropped: got {types:?}"
);
}
#[test]
fn extension_after_directive_table_is_preserved() {
// Issue #361: extension after table
let md = "\
::::table{layout=default}
:::tr
:::th{}
Header
:::
:::
::::
::extension{type=com.atlassian.confluence.macro.core key=toc}";
let adf = markdown_to_adf(md).unwrap();
let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(
types,
vec!["table", "extension"],
"Extension after table was dropped: got {types:?}"
);
}
#[test]
fn multiple_blocks_after_directive_table() {
// Issue #361: multiple blocks after table, including another table
let md = "\
## Heading 1
::::table{layout=default}
:::tr
:::td{}
A
:::
:::td{}
B
:::
:::
::::
## Heading 2
Some text.
---
::::table{layout=default}
:::tr
:::th{}
C
:::
:::
::::
## Heading 3";
let adf = markdown_to_adf(md).unwrap();
let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(
types,
vec![
"heading",
"table",
"heading",
"paragraph",
"rule",
"table",
"heading"
],
"Content after tables was dropped: got {types:?}"
);
}
// ── Table caption tests (issue #382) ────────────────────────────
#[test]
fn adf_table_caption_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::table(vec![
AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
AdfNode::text("cell"),
])])]),
AdfNode::caption(vec![AdfNode::text("Table caption")]),
])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("::::table"),
"table with caption must use directive form"
);
assert!(
md.contains(":::caption"),
"caption directive missing, got: {md}"
);
assert!(
md.contains("Table caption"),
"caption text missing, got: {md}"
);
}
#[test]
fn directive_table_caption_parses() {
let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nTable caption\n:::\n::::\n";
let doc = markdown_to_adf(md).unwrap();
let table = &doc.content[0];
assert_eq!(table.node_type, "table");
let children = table.content.as_ref().unwrap();
assert_eq!(children.len(), 2, "expected row + caption");
assert_eq!(children[0].node_type, "tableRow");
assert_eq!(children[1].node_type, "caption");
let caption_content = children[1].content.as_ref().unwrap();
assert_eq!(caption_content[0].text.as_deref(), Some("Table caption"));
}
#[test]
fn table_caption_round_trip_from_adf_json() {
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"}]}]}]},
{"type":"caption","content":[{"type":"text","text":"Table caption"}]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("Table caption"), "caption text lost in ADF→JFM");
let round_tripped = markdown_to_adf(&md).unwrap();
let children = round_tripped.content[0].content.as_ref().unwrap();
let caption = children.iter().find(|n| n.node_type == "caption");
assert!(caption.is_some(), "caption lost on round-trip");
let caption_text = caption.unwrap().content.as_ref().unwrap();
assert_eq!(caption_text[0].text.as_deref(), Some("Table caption"));
}
#[test]
fn table_caption_with_inline_marks_round_trips() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::table(vec![
AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
AdfNode::text("data"),
])])]),
AdfNode::caption(vec![
AdfNode::text("Caption with "),
AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
]),
])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(md.contains("**bold**"), "bold mark missing in caption");
let round_tripped = markdown_to_adf(&md).unwrap();
let caption = round_tripped.content[0]
.content
.as_ref()
.unwrap()
.iter()
.find(|n| n.node_type == "caption")
.expect("caption node missing after round-trip");
let inlines = caption.content.as_ref().unwrap();
let bold_node = inlines.iter().find(|n| {
n.marks
.as_ref()
.is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
});
assert!(bold_node.is_some(), "bold mark lost in caption round-trip");
}
// ── table caption localId tests (issue #524) ──────────────────────
#[test]
fn table_caption_localid_roundtrip() {
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"}]}]}]},
{"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Table with localId"}]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("localId=abcdef123456"),
"table caption localId should appear in markdown: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let caption = rt.content[0]
.content
.as_ref()
.unwrap()
.iter()
.find(|n| n.node_type == "caption")
.expect("caption should survive round-trip");
assert_eq!(
caption.attrs.as_ref().unwrap()["localId"],
"abcdef123456",
"table caption localId should round-trip"
);
}
#[test]
fn table_caption_without_localid_unchanged() {
let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nPlain caption\n:::\n::::\n";
let doc = markdown_to_adf(md).unwrap();
let caption = doc.content[0]
.content
.as_ref()
.unwrap()
.iter()
.find(|n| n.node_type == "caption")
.unwrap();
assert!(
caption.attrs.is_none(),
"table caption without localId should not gain attrs"
);
let md2 = adf_to_markdown(&doc).unwrap();
assert!(!md2.contains("localId"), "no localId should appear: {md2}");
}
#[test]
fn table_caption_localid_stripped_when_option_set() {
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"}]}]}]},
{"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Stripped"}]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let opts = RenderOptions {
strip_local_ids: true,
..Default::default()
};
let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
assert!(
!md.contains("localId"),
"table caption localId should be stripped: {md}"
);
}
#[test]
#[test]
fn tablecell_empty_attrs_preserved_on_roundtrip() {
// Issue #385: tableCell with empty attrs:{} dropped on round-trip
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"}]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let rows = round_tripped.content[0].content.as_ref().unwrap();
let cell = &rows[0].content.as_ref().unwrap()[0];
assert!(
cell.attrs.is_some(),
"tableCell attrs should be preserved, got None"
);
assert_eq!(
cell.attrs.as_ref().unwrap(),
&serde_json::json!({}),
"tableCell attrs should be an empty object"
);
}
#[test]
fn tablecell_empty_attrs_serialized_in_json() {
// Issue #385: ensure the serialized JSON includes "attrs":{}
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"}]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let json = serde_json::to_string(&round_tripped).unwrap();
assert!(
json.contains(r#""attrs":{}"#),
"serialized JSON should contain \"attrs\":{{}}, got: {json}"
);
}
#[test]
fn tablecell_empty_attrs_renders_braces_in_markdown() {
// Issue #385: tableCell with empty attrs should render {} prefix in pipe tables
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"}]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
// Cell with attrs:{} should have {} prefix, cell without attrs should not
assert!(
md.contains("{} hello"),
"cell with empty attrs should render '{{}} hello', got: {md}"
);
assert!(
!md.contains("{} world"),
"cell without attrs should not render '{{}}', got: {md}"
);
}
#[test]
fn tablecell_no_attrs_unchanged_on_roundtrip() {
// Ensure tableCell without attrs stays without attrs
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"}]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let rows = round_tripped.content[0].content.as_ref().unwrap();
let cell = &rows[0].content.as_ref().unwrap()[0];
assert!(
cell.attrs.is_none(),
"tableCell without attrs should stay None, got: {:?}",
cell.attrs
);
}
#[test]
fn tablecell_nonempty_attrs_preserved_on_roundtrip() {
// Ensure tableCell with non-empty attrs still works
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"}]}]}]}]}]}"##;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let rows = round_tripped.content[0].content.as_ref().unwrap();
let cell = &rows[1].content.as_ref().unwrap()[0];
let attrs = cell.attrs.as_ref().unwrap();
assert_eq!(attrs["background"], "#DEEBFF");
assert_eq!(attrs["colspan"], 2);
}
#[test]
fn pipe_table_not_used_when_caption_present() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::table(vec![
AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
AdfNode::text("H"),
])])]),
AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
AdfNode::text("D"),
])])]),
AdfNode::caption(vec![AdfNode::text("cap")]),
])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("::::table"),
"pipe syntax should not be used when caption is present"
);
}
// ── Issue #402: ordered-list-like text in list item hardBreak ──
#[test]
fn hardbreak_with_ordered_marker_in_bullet_item_roundtrips() {
// Issue #402: text starting with "2. " after a hardBreak inside a
// bullet list item must not be re-parsed as a new ordered list.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
{"type":"listItem","content":[{"type":"paragraph","content":[
{"type":"text","text":"1. First item"},
{"type":"hardBreak"},
{"type":"text","text":"2. Honouring existing commitments"}
]}]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
// The continuation line must be indented so it stays within the list item.
assert!(
md.contains(" 2. Honouring"),
"Continuation line should be indented, got:\n{md}"
);
// Round-trip back to ADF
let rt = markdown_to_adf(&md).unwrap();
let list = &rt.content[0];
assert_eq!(list.node_type, "bulletList");
let items = list.content.as_ref().unwrap();
assert_eq!(
items.len(),
1,
"Should be one list item, got {}",
items.len()
);
let para = &items[0].content.as_ref().unwrap()[0];
let inlines = para.content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(
types,
vec!["text", "hardBreak", "text"],
"Expected text+hardBreak+text, got {types:?}"
);
assert_eq!(
inlines[2].text.as_deref().unwrap(),
"2. Honouring existing commitments"
);
}
#[test]
fn hardbreak_with_ordered_marker_in_ordered_item_roundtrips() {
// Same as above but inside an ordered list.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
{"type":"listItem","content":[{"type":"paragraph","content":[
{"type":"text","text":"Introduction "},
{"type":"hardBreak"},
{"type":"text","text":"3. Third point"}
]}]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let list = &rt.content[0];
assert_eq!(list.node_type, "orderedList");
let items = list.content.as_ref().unwrap();
assert_eq!(items.len(), 1);
let para = &items[0].content.as_ref().unwrap()[0];
let inlines = para.content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types, vec!["text", "hardBreak", "text"]);
assert_eq!(inlines[2].text.as_deref().unwrap(), "3. Third point");
}
#[test]
fn hardbreak_with_bullet_marker_in_bullet_item_roundtrips() {
// Text starting with "- " after a hardBreak must not become a nested bullet list.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
{"type":"listItem","content":[{"type":"paragraph","content":[
{"type":"text","text":"Header "},
{"type":"hardBreak"},
{"type":"text","text":"- not a sub-item"}
]}]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let list = &rt.content[0];
assert_eq!(list.node_type, "bulletList");
let items = list.content.as_ref().unwrap();
assert_eq!(
items.len(),
1,
"Should be one list item, not {}",
items.len()
);
let para = &items[0].content.as_ref().unwrap()[0];
let inlines = para.content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types, vec!["text", "hardBreak", "text"]);
assert_eq!(inlines[2].text.as_deref().unwrap(), "- not a sub-item");
}
#[test]
fn hardbreak_continuation_followed_by_sub_list() {
// A hardBreak continuation line followed by a real sub-list.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"Main item "},
{"type":"hardBreak"},
{"type":"text","text":"continued here"}
]},
{"type":"bulletList","content":[
{"type":"listItem","content":[{"type":"paragraph","content":[
{"type":"text","text":"sub-item"}
]}]}
]}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let list = &rt.content[0];
let items = list.content.as_ref().unwrap();
assert_eq!(items.len(), 1);
let item_content = items[0].content.as_ref().unwrap();
assert_eq!(item_content.len(), 2, "Expected paragraph + nested list");
assert_eq!(item_content[0].node_type, "paragraph");
assert_eq!(item_content[1].node_type, "bulletList");
// Check the paragraph has hardBreak
let inlines = item_content[0].content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types, vec!["text", "hardBreak", "text"]);
}
#[test]
fn multiple_hardbreaks_with_numbered_text_roundtrip() {
// Multiple hardBreaks where each continuation resembles an ordered list.
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":"1. Alpha "},
{"type":"hardBreak"},
{"type":"text","text":"2. Bravo"}
]}]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let items = rt.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 1);
let inlines = items[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(
types,
vec!["text", "hardBreak", "text", "hardBreak", "text"]
);
}
#[test]
fn trailing_hardbreak_in_bullet_item_roundtrips() {
// A hardBreak as the last inline node with no text after it.
// Exercises the `break` path in the continuation loop and the
// empty-line rendering branch.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
{"type":"listItem","content":[{"type":"paragraph","content":[
{"type":"text","text":"ends with break"},
{"type":"hardBreak"}
]}]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let list = &rt.content[0];
assert_eq!(list.node_type, "bulletList");
let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types, vec!["text", "hardBreak"]);
}
#[test]
fn trailing_hardbreak_in_ordered_item_roundtrips() {
// Same as above but in an ordered list, covering the ordered-list
// continuation `break` path.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
{"type":"listItem","content":[{"type":"paragraph","content":[
{"type":"text","text":"ends with break"},
{"type":"hardBreak"}
]}]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let list = &rt.content[0];
assert_eq!(list.node_type, "orderedList");
let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types, vec!["text", "hardBreak"]);
}
#[test]
fn trailing_space_hardbreak_continuation_in_bullet_item() {
// Exercises the `ends_with(" ")` path in `has_trailing_hard_break`
// by parsing hand-written markdown that uses trailing-space style
// hardBreaks instead of backslash style.
let md = "- first line \n 2. continued\n";
let doc = markdown_to_adf(md).unwrap();
let list = &doc.content[0];
assert_eq!(list.node_type, "bulletList");
let items = list.content.as_ref().unwrap();
assert_eq!(
items.len(),
1,
"Should be one list item, got {}",
items.len()
);
let para = &items[0].content.as_ref().unwrap()[0];
let inlines = para.content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types, vec!["text", "hardBreak", "text"]);
assert_eq!(inlines[2].text.as_deref().unwrap(), "2. continued");
}
#[test]
fn trailing_space_hardbreak_continuation_in_ordered_item() {
// Same as above but for ordered list, exercising the trailing-space
// path in the ordered-list continuation loop.
let md = "1. first line \n - continued\n";
let doc = markdown_to_adf(md).unwrap();
let list = &doc.content[0];
assert_eq!(list.node_type, "orderedList");
let items = list.content.as_ref().unwrap();
assert_eq!(
items.len(),
1,
"Should be one list item, got {}",
items.len()
);
let para = &items[0].content.as_ref().unwrap()[0];
let inlines = para.content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types, vec!["text", "hardBreak", "text"]);
assert_eq!(inlines[2].text.as_deref().unwrap(), "- continued");
}
#[test]
fn multi_paragraph_list_item_with_ordered_marker_roundtrips() {
// Issue #402 comment: a listItem with a second paragraph starting
// with "2. " must not become a separate orderedList.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
{"type":"listItem","content":[
{"type":"paragraph","content":[{"type":"text","text":"some preamble"}]},
{"type":"paragraph","content":[{"type":"text","text":"2. Honouring existing commitments"}]}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(rt.content.len(), 1, "Should be one top-level block");
let list = &rt.content[0];
assert_eq!(list.node_type, "bulletList");
let items = list.content.as_ref().unwrap();
assert_eq!(items.len(), 1);
let item_content = items[0].content.as_ref().unwrap();
assert_eq!(
item_content.len(),
2,
"Expected 2 paragraphs inside the list item, got {}",
item_content.len()
);
assert_eq!(item_content[0].node_type, "paragraph");
assert_eq!(item_content[1].node_type, "paragraph");
let text = item_content[1].content.as_ref().unwrap()[0]
.text
.as_deref()
.unwrap();
assert_eq!(text, "2. Honouring existing commitments");
}
#[test]
fn multi_paragraph_list_item_with_bullet_marker_roundtrips() {
// Paragraph starting with "- " inside a list item.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
{"type":"listItem","content":[
{"type":"paragraph","content":[{"type":"text","text":"preamble"}]},
{"type":"paragraph","content":[{"type":"text","text":"- not a sub-item"}]}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let items = rt.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 1);
let item_content = items[0].content.as_ref().unwrap();
assert_eq!(item_content.len(), 2);
assert_eq!(item_content[1].node_type, "paragraph");
let text = item_content[1].content.as_ref().unwrap()[0]
.text
.as_deref()
.unwrap();
assert_eq!(text, "- not a sub-item");
}
#[test]
fn backslash_escape_in_inline_text() {
// Verify that `\. ` is unescaped to `. ` in inline parsing.
let nodes = parse_inline(r"2\. text");
assert_eq!(nodes.len(), 1, "Should be one text node");
assert_eq!(nodes[0].text.as_deref().unwrap(), "2. text");
}
#[test]
fn escape_list_marker_ordered() {
assert_eq!(escape_list_marker("2. text"), r"2\. text");
assert_eq!(escape_list_marker("10. tenth"), r"10\. tenth");
}
#[test]
fn escape_list_marker_bullet() {
assert_eq!(escape_list_marker("- text"), r"\- text");
assert_eq!(escape_list_marker("* text"), r"\* text");
assert_eq!(escape_list_marker("+ text"), r"\+ text");
}
#[test]
fn escape_list_marker_plain() {
assert_eq!(escape_list_marker("plain text"), "plain text");
assert_eq!(escape_list_marker("no. marker"), "no. marker");
}
#[test]
fn escape_emoji_shortcodes_basic() {
assert_eq!(escape_emoji_shortcodes(":fire:"), r"\:fire:");
assert_eq!(
escape_emoji_shortcodes("hello :wave: world"),
r"hello \:wave: world"
);
}
#[test]
fn escape_emoji_shortcodes_double_colon() {
// Only the colon that starts `:Active:` needs escaping
assert_eq!(
escape_emoji_shortcodes("Status::Active::Running"),
r"Status:\:Active::Running"
);
}
#[test]
fn escape_emoji_shortcodes_no_match() {
// Lone colons, numeric-only between colons like 10:30
assert_eq!(escape_emoji_shortcodes("Time is 10:30"), "Time is 10:30");
assert_eq!(escape_emoji_shortcodes("no colons here"), "no colons here");
assert_eq!(escape_emoji_shortcodes("trailing:"), "trailing:");
assert_eq!(escape_emoji_shortcodes(":"), ":");
}
#[test]
fn escape_emoji_shortcodes_mixed() {
assert_eq!(
escape_emoji_shortcodes("Alert :fire: on pod:pod42"),
r"Alert \:fire: on pod:pod42"
);
}
#[test]
fn escape_emoji_shortcodes_unicode() {
// Issue #552: Unicode alphanumeric chars must be escaped to match
// `try_parse_emoji_shortcode`, which uses `is_alphanumeric` (not the
// ASCII-only variant). Without this, `:Café:` rendered un-escaped
// would be re-parsed as an emoji on round-trip.
assert_eq!(escape_emoji_shortcodes(":Café:"), r"\:Café:");
assert_eq!(escape_emoji_shortcodes(":über:"), r"\:über:");
assert_eq!(escape_emoji_shortcodes(":配置:"), r"\:配置:");
assert_eq!(
escape_emoji_shortcodes("ZBC::配置::Production"),
r"ZBC:\:配置::Production"
);
}
#[test]
fn escape_emoji_shortcodes_mixed_script_name() {
// Issue #552: A name that mixes ASCII and Unicode alphanumerics is
// still a single valid shortcode under `is_alphanumeric`.
assert_eq!(escape_emoji_shortcodes(":abc配置:"), r"\:abc配置:");
assert_eq!(escape_emoji_shortcodes(":配置abc:"), r"\:配置abc:");
}
#[test]
fn escape_emoji_shortcodes_unicode_followed_by_non_colon() {
// `:Café world:` — `Café` is alphanumeric but the terminator is a
// space, not `:`, so the `after + name_end < text.len()` path is
// exercised but the final `== b':'` check bails out and nothing
// gets escaped. Guards the negative branch of the predicate.
assert_eq!(escape_emoji_shortcodes(":Café world:"), ":Café world:");
}
#[test]
fn escape_emoji_shortcodes_name_runs_to_end() {
// Colon followed by alphanumerics to end of string: `.find(...)` returns
// `None`, so `name_end` falls back to the full remaining length via
// `map_or`. The `after + name_end < text.len()` check then fails and
// nothing is escaped. Exercises the `map_or` default branch for both
// ASCII and Unicode names.
assert_eq!(escape_emoji_shortcodes(":abc"), ":abc");
assert_eq!(escape_emoji_shortcodes(":配置"), ":配置");
}
#[test]
fn unicode_shortcode_pattern_text_round_trips_as_text() {
// Issue #552: A text node containing `:Café:` must round-trip as text,
// not be split into text + emoji nodes.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"Visit :Café: today"}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let content = round_tripped.content[0].content.as_ref().unwrap();
assert_eq!(
content.len(),
1,
"should be a single text node, got: {content:?}"
);
assert_eq!(content[0].node_type, "text");
assert_eq!(content[0].text.as_deref().unwrap(), "Visit :Café: today");
}
#[test]
fn unicode_double_colon_pattern_text_round_trips() {
// Issue #552: `ZBC::配置::Production` should round-trip without splitting.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"Use ZBC::配置::Production for prod"}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let round_tripped = markdown_to_adf(&md).unwrap();
let content = round_tripped.content[0].content.as_ref().unwrap();
assert_eq!(
content.len(),
1,
"should be a single text node, got: {content:?}"
);
assert_eq!(
content[0].text.as_deref().unwrap(),
"Use ZBC::配置::Production for prod"
);
}
#[test]
fn merge_adjacent_text_nodes() {
let mut nodes = vec![AdfNode::text("a"), AdfNode::text("b"), AdfNode::text("c")];
merge_adjacent_text(&mut nodes);
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].text.as_deref().unwrap(), "abc");
}
// ── Issue #455: text after hardBreak in paragraph re-parsed as list ──
#[test]
fn issue_455_paragraph_hardbreak_ordered_marker_roundtrips() {
// Issue #455: "1. text" after a hardBreak in a paragraph must not
// become an ordered list.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"Introduction: "},
{"type":"hardBreak"},
{"type":"text","text":"1. This text follows a hardBreak"}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(rt.content.len(), 1, "Should remain one block");
assert_eq!(rt.content[0].node_type, "paragraph");
let inlines = rt.content[0].content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types, vec!["text", "hardBreak", "text"]);
assert_eq!(
inlines[2].text.as_deref(),
Some("1. This text follows a hardBreak")
);
}
#[test]
fn issue_455_paragraph_hardbreak_bullet_marker_roundtrips() {
// Issue #455 variant: "- text" after a hardBreak in a paragraph.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"Intro"},
{"type":"hardBreak"},
{"type":"text","text":"- not a list item"}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(rt.content.len(), 1);
assert_eq!(rt.content[0].node_type, "paragraph");
let inlines = rt.content[0].content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types, vec!["text", "hardBreak", "text"]);
assert_eq!(inlines[2].text.as_deref(), Some("- not a list item"));
}
#[test]
fn issue_455_paragraph_hardbreak_heading_marker_roundtrips() {
// Issue #455 variant: "# text" after a hardBreak in a paragraph.
let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"Intro"},
{"type":"hardBreak"},
{"type":"text","text":"# not a heading"}
]}]}"##;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(rt.content.len(), 1);
assert_eq!(rt.content[0].node_type, "paragraph");
let inlines = rt.content[0].content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types, vec!["text", "hardBreak", "text"]);
assert_eq!(inlines[2].text.as_deref(), Some("# not a heading"));
}
#[test]
fn issue_455_paragraph_hardbreak_blockquote_marker_roundtrips() {
// Issue #455 variant: "> text" after a hardBreak in a paragraph.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"Intro"},
{"type":"hardBreak"},
{"type":"text","text":"> not a blockquote"}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(rt.content.len(), 1);
assert_eq!(rt.content[0].node_type, "paragraph");
let inlines = rt.content[0].content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types, vec!["text", "hardBreak", "text"]);
assert_eq!(inlines[2].text.as_deref(), Some("> not a blockquote"));
}
#[test]
fn issue_455_paragraph_multiple_hardbreaks_with_ordered_markers() {
// Multiple hardBreaks in a paragraph, each followed by "N. text".
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"Preamble"},
{"type":"hardBreak"},
{"type":"text","text":"1. First"},
{"type":"hardBreak"},
{"type":"text","text":"2. Second"},
{"type":"hardBreak"},
{"type":"text","text":"3. Third"}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(rt.content.len(), 1);
assert_eq!(rt.content[0].node_type, "paragraph");
let inlines = rt.content[0].content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(
types,
vec![
"text",
"hardBreak",
"text",
"hardBreak",
"text",
"hardBreak",
"text"
]
);
assert_eq!(inlines[2].text.as_deref(), Some("1. First"));
assert_eq!(inlines[4].text.as_deref(), Some("2. Second"));
assert_eq!(inlines[6].text.as_deref(), Some("3. Third"));
}
#[test]
fn issue_455_paragraph_hardbreak_jfm_indentation() {
// Verify that ADF→JFM output indents continuation lines.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"Intro"},
{"type":"hardBreak"},
{"type":"text","text":"1. continued"}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("Intro\\\n 1. continued"),
"Continuation should be 2-space-indented, got: {md:?}"
);
}
#[test]
fn issue_455_paragraph_hardbreak_from_jfm() {
// Verify that JFM with 2-space-indented continuation is parsed
// back as a single paragraph with hardBreak.
let md = "Intro\\\n 1. This is continuation text\n";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content.len(), 1);
assert_eq!(doc.content[0].node_type, "paragraph");
let inlines = doc.content[0].content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types, vec!["text", "hardBreak", "text"]);
assert_eq!(
inlines[2].text.as_deref(),
Some("1. This is continuation text")
);
}
#[test]
fn issue_455_paragraph_starts_with_ordered_marker_and_hardbreak() {
// Coverage: first line IS a list marker AND paragraph has hardBreaks.
// Exercises the escape_list_marker path on the first line of a
// multi-line paragraph buf in the rendering code.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"1. Starting with a number"},
{"type":"hardBreak"},
{"type":"text","text":"continuation after break"}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
// First line should be escaped so it's not parsed as ordered list
assert!(
md.contains(r"1\. Starting with a number"),
"First line should have escaped list marker, got: {md:?}"
);
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(rt.content.len(), 1);
assert_eq!(rt.content[0].node_type, "paragraph");
let inlines = rt.content[0].content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types, vec!["text", "hardBreak", "text"]);
assert_eq!(
inlines[0].text.as_deref(),
Some("1. Starting with a number")
);
assert_eq!(inlines[2].text.as_deref(), Some("continuation after break"));
}
#[test]
fn ordered_marker_paragraph_in_table_cell_roundtrips() {
// Issue #402: paragraph with "2. " text inside a tableCell must
// not be re-parsed as an ordered list.
let adf_json = r#"{"version":1,"type":"doc","content":[{
"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},
"content":[{"type":"tableRow","content":[{
"type":"tableCell","attrs":{"colspan":1,"rowspan":1},
"content":[{"type":"paragraph","content":[
{"type":"text","text":"2. Honouring existing commitments"}
]}]
}]}]
}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let table = &rt.content[0];
let cell = &table.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0];
let para = &cell.content.as_ref().unwrap()[0];
assert_eq!(para.node_type, "paragraph");
let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
assert_eq!(text, "2. Honouring existing commitments");
}
#[test]
fn bullet_marker_paragraph_standalone_roundtrips() {
// A top-level paragraph starting with "- " must round-trip as
// a paragraph, not a bullet list.
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"paragraph","content":[
{"type":"text","text":"- not a list item"}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains(r"\- not a list item"),
"Should escape the leading dash, got:\n{md}"
);
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(rt.content[0].node_type, "paragraph");
let text = rt.content[0].content.as_ref().unwrap()[0]
.text
.as_deref()
.unwrap();
assert_eq!(text, "- not a list item");
}
#[test]
fn merge_adjacent_text_skips_non_text_nodes() {
// Exercises the `else { i += 1 }` branch when adjacent nodes
// are not both plain text.
let mut nodes = vec![
AdfNode::text("a"),
AdfNode::hard_break(),
AdfNode::text("b"),
];
merge_adjacent_text(&mut nodes);
assert_eq!(nodes.len(), 3);
}
#[test]
fn star_bullet_paragraph_roundtrips() {
// Paragraph starting with "* " must round-trip without becoming
// a bullet list.
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"paragraph","content":[
{"type":"text","text":"* starred"}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(rt.content[0].node_type, "paragraph");
assert_eq!(
rt.content[0].content.as_ref().unwrap()[0]
.text
.as_deref()
.unwrap(),
"* starred"
);
}
// ---- Issue #388 tests ----
#[test]
fn issue_388_ordered_list_with_strong_hardbreak_roundtrips() {
// Issue #388: orderedList with 2 listItems, each containing
// strong-marked text + hardBreak + plain text.
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"orderedList","attrs":{"order":1},"content":[
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"Bold heading","marks":[{"type":"strong"}]},
{"type":"hardBreak"},
{"type":"text","text":"Content after break"}
]}
]},
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"Second item","marks":[{"type":"strong"}]},
{"type":"hardBreak"},
{"type":"text","text":"More content"}
]}
]}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
// Must remain a single orderedList
assert_eq!(
rt.content.len(),
1,
"Should be 1 block (orderedList), got {}",
rt.content.len()
);
assert_eq!(rt.content[0].node_type, "orderedList");
let items = rt.content[0].content.as_ref().unwrap();
assert_eq!(
items.len(),
2,
"Should have 2 listItems, got {}",
items.len()
);
// First item: text(strong) + hardBreak + text
let p1 = items[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap();
let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types1, vec!["text", "hardBreak", "text"]);
assert_eq!(p1[0].text.as_deref(), Some("Bold heading"));
assert_eq!(p1[2].text.as_deref(), Some("Content after break"));
// Second item: text(strong) + hardBreak + text
let p2 = items[1].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap();
let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types2, vec!["text", "hardBreak", "text"]);
assert_eq!(p2[0].text.as_deref(), Some("Second item"));
assert_eq!(p2[2].text.as_deref(), Some("More content"));
}
#[test]
fn issue_388_bullet_list_with_strong_hardbreak_roundtrips() {
// Bullet list variant of issue #388.
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"bulletList","content":[
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"First","marks":[{"type":"strong"}]},
{"type":"hardBreak"},
{"type":"text","text":"details"}
]}
]},
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"Second","marks":[{"type":"em"}]},
{"type":"hardBreak"},
{"type":"text","text":"more details"}
]}
]}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(rt.content.len(), 1);
assert_eq!(rt.content[0].node_type, "bulletList");
let items = rt.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 2);
let p1 = items[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap();
assert_eq!(p1[0].text.as_deref(), Some("First"));
assert_eq!(p1[2].text.as_deref(), Some("details"));
let p2 = items[1].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap();
assert_eq!(p2[0].text.as_deref(), Some("Second"));
assert_eq!(p2[2].text.as_deref(), Some("more details"));
}
#[test]
fn issue_388_ordered_list_hardbreak_jfm_indentation() {
// Verify the JFM output has properly indented continuation lines.
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"orderedList","attrs":{"order":1},"content":[
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"heading","marks":[{"type":"strong"}]},
{"type":"hardBreak"},
{"type":"text","text":"body"}
]}
]}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("1. **heading**\\\n body"),
"Continuation should be indented, got:\n{md}"
);
}
#[test]
fn issue_388_ordered_list_hardbreak_from_jfm() {
// Direct JFM → ADF: ordered list with hardBreak continuation.
let md = "1. **bold**\\\n continued\n2. **also bold**\\\n also continued\n";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content.len(), 1);
assert_eq!(doc.content[0].node_type, "orderedList");
let items = doc.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 2);
let p1 = items[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap();
let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types1, vec!["text", "hardBreak", "text"]);
assert_eq!(p1[0].text.as_deref(), Some("bold"));
assert_eq!(p1[2].text.as_deref(), Some("continued"));
let p2 = items[1].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap();
let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types2, vec!["text", "hardBreak", "text"]);
}
#[test]
fn issue_388_bullet_list_hardbreak_from_jfm() {
// Direct JFM → ADF: bullet list with hardBreak continuation.
let md = "- first\\\n second\n- third\\\n fourth\n";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content.len(), 1);
assert_eq!(doc.content[0].node_type, "bulletList");
let items = doc.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 2);
for (i, expected) in [("first", "second"), ("third", "fourth")]
.iter()
.enumerate()
{
let p = items[i].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap();
let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types, vec!["text", "hardBreak", "text"]);
assert_eq!(p[0].text.as_deref(), Some(expected.0));
assert_eq!(p[2].text.as_deref(), Some(expected.1));
}
}
#[test]
fn issue_433_heading_hardbreak_roundtrips() {
// Issue #433: hardBreak inside heading splits into heading + paragraph.
let adf_json = r#"{"version":1,"type":"doc","content":[{
"type":"heading",
"attrs":{"level":1},
"content":[
{"type":"text","text":"Line one"},
{"type":"hardBreak"},
{"type":"text","text":"Line two"}
]
}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(
rt.content.len(),
1,
"Should remain a single heading, got {} blocks",
rt.content.len()
);
assert_eq!(rt.content[0].node_type, "heading");
let inlines = rt.content[0].content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(
types,
vec!["text", "hardBreak", "text"],
"hardBreak should be preserved, got: {types:?}"
);
assert_eq!(inlines[0].text.as_deref(), Some("Line one"));
assert_eq!(inlines[2].text.as_deref(), Some("Line two"));
}
#[test]
fn issue_433_heading_hardbreak_jfm_indentation() {
// Verify the JFM output has properly indented continuation lines.
let adf_json = r#"{"version":1,"type":"doc","content":[{
"type":"heading",
"attrs":{"level":2},
"content":[
{"type":"text","text":"Title"},
{"type":"hardBreak"},
{"type":"text","text":"Subtitle"}
]
}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("## Title\\\n Subtitle"),
"Continuation should be indented, got:\n{md}"
);
}
#[test]
fn issue_433_heading_hardbreak_from_jfm() {
// Direct JFM → ADF: heading with hardBreak continuation.
let md = "# First\\\n Second\n";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(doc.content.len(), 1);
assert_eq!(doc.content[0].node_type, "heading");
let inlines = doc.content[0].content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types, vec!["text", "hardBreak", "text"]);
assert_eq!(inlines[0].text.as_deref(), Some("First"));
assert_eq!(inlines[2].text.as_deref(), Some("Second"));
}
#[test]
fn issue_433_heading_consecutive_hardbreaks_roundtrip() {
// Consecutive hardBreaks in a heading.
let adf_json = r#"{"version":1,"type":"doc","content":[{
"type":"heading",
"attrs":{"level":3},
"content":[
{"type":"text","text":"A"},
{"type":"hardBreak"},
{"type":"hardBreak"},
{"type":"text","text":"B"}
]
}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(rt.content.len(), 1, "Should remain a single heading");
assert_eq!(rt.content[0].node_type, "heading");
let inlines = rt.content[0].content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types, vec!["text", "hardBreak", "hardBreak", "text"]);
}
#[test]
fn issue_433_heading_with_strong_and_hardbreak_roundtrips() {
// Heading with strong-marked text + hardBreak + plain text.
let adf_json = r#"{"version":1,"type":"doc","content":[{
"type":"heading",
"attrs":{"level":1},
"content":[
{"type":"text","text":"Bold title","marks":[{"type":"strong"}]},
{"type":"hardBreak"},
{"type":"text","text":"plain continuation"}
]
}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(rt.content.len(), 1);
assert_eq!(rt.content[0].node_type, "heading");
let inlines = rt.content[0].content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types, vec!["text", "hardBreak", "text"]);
assert_eq!(inlines[0].text.as_deref(), Some("Bold title"));
assert_eq!(inlines[2].text.as_deref(), Some("plain continuation"));
}
#[test]
fn issue_433_heading_with_link_and_hardbreak_roundtrips() {
// Real-world pattern: heading with link + hardBreak + text.
let adf_json = r#"{"version":1,"type":"doc","content":[{
"type":"heading",
"attrs":{"level":1},
"content":[
{"type":"text","text":"Click here","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
{"type":"hardBreak"},
{"type":"text","text":"Subtitle text"}
]
}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(rt.content.len(), 1);
assert_eq!(rt.content[0].node_type, "heading");
let inlines = rt.content[0].content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types, vec!["text", "hardBreak", "text"]);
assert_eq!(inlines[2].text.as_deref(), Some("Subtitle text"));
}
#[test]
fn has_trailing_hard_break_backslash() {
assert!(has_trailing_hard_break("text\\"));
assert!(has_trailing_hard_break("**bold**\\"));
}
#[test]
fn has_trailing_hard_break_trailing_spaces() {
assert!(has_trailing_hard_break("text "));
assert!(has_trailing_hard_break("word "));
}
#[test]
fn has_trailing_hard_break_false() {
assert!(!has_trailing_hard_break("plain text"));
assert!(!has_trailing_hard_break("text "));
assert!(!has_trailing_hard_break(""));
}
#[test]
fn collect_hardbreak_continuations_collects_indented() {
// A line ending with `\` followed by 2-space-indented continuation.
// Only one line is collected because the result no longer ends with `\`.
let input = "first\\\n second\n third\n";
let mut parser = MarkdownParser::new(input);
parser.advance(); // skip first line
let mut text = "first\\".to_string();
parser.collect_hardbreak_continuations(&mut text);
assert_eq!(text, "first\\\nsecond");
}
#[test]
fn collect_hardbreak_continuations_stops_at_non_indented() {
let input = "first\\\nnot indented\n";
let mut parser = MarkdownParser::new(input);
parser.advance();
let mut text = "first\\".to_string();
parser.collect_hardbreak_continuations(&mut text);
// Should NOT collect the non-indented line
assert_eq!(text, "first\\");
}
#[test]
fn collect_hardbreak_continuations_no_trailing_break() {
// If the text doesn't end with a hardBreak marker, nothing is collected.
let input = "plain\n indented\n";
let mut parser = MarkdownParser::new(input);
parser.advance();
let mut text = "plain".to_string();
parser.collect_hardbreak_continuations(&mut text);
assert_eq!(text, "plain");
}
#[test]
fn collect_hardbreak_continuations_chained() {
// Multiple continuation lines chained via repeated hardBreaks.
let input = "a\\\n b\\\n c\\\n d\n";
let mut parser = MarkdownParser::new(input);
parser.advance();
let mut text = "a\\".to_string();
parser.collect_hardbreak_continuations(&mut text);
assert_eq!(text, "a\\\nb\\\nc\\\nd");
}
#[test]
fn collect_hardbreak_continuations_stops_before_image_line() {
// An indented continuation that starts with `{type=file id=x}\n";
let mut parser = MarkdownParser::new(input);
parser.advance(); // skip first line
let mut text = "text\\".to_string();
parser.collect_hardbreak_continuations(&mut text);
// The image line should NOT have been consumed.
assert_eq!(text, "text\\");
// Parser should still be on the image line (not past it).
assert!(!parser.at_end());
assert!(parser.current_line().contains(""));
}
#[test]
fn is_block_level_continuation_marker_positive_cases() {
// Each marker that forces `collect_hardbreak_continuations` to stop.
assert!(is_block_level_continuation_marker(""));
assert!(is_block_level_continuation_marker("```ruby"));
assert!(is_block_level_continuation_marker(":::panel{type=info}"));
}
#[test]
fn is_block_level_continuation_marker_negative_cases() {
// Plain continuation text must NOT look like a block-level marker.
assert!(!is_block_level_continuation_marker("plain text"));
assert!(!is_block_level_continuation_marker("- nested item"));
assert!(!is_block_level_continuation_marker("continuation\\"));
assert!(!is_block_level_continuation_marker(""));
// Double-colon `::` is not a container directive.
assert!(!is_block_level_continuation_marker("::partial"));
// Single backticks are inline code, not a fence.
assert!(!is_block_level_continuation_marker("`inline`"));
}
#[test]
fn collect_hardbreak_continuations_stops_before_code_fence() {
// Issue #552: An indented continuation that opens a fenced code block
// must NOT be swallowed as a paragraph continuation — it has to stay
// available for `try_code_block` on the next parse iteration.
let input = "text\\\n ```ruby\n Foo::Bar::Baz\n ```\n";
let mut parser = MarkdownParser::new(input);
parser.advance();
let mut text = "text\\".to_string();
parser.collect_hardbreak_continuations(&mut text);
assert_eq!(text, "text\\");
assert!(!parser.at_end());
assert!(parser.current_line().starts_with(" ```"));
}
#[test]
fn collect_hardbreak_continuations_stops_before_container_directive() {
// Issue #552: An indented continuation that opens a `:::` container
// directive (panel, expand, etc.) must also stay available for the
// directive parser.
let input = "text\\\n :::panel{type=info}\n body\n :::\n";
let mut parser = MarkdownParser::new(input);
parser.advance();
let mut text = "text\\".to_string();
parser.collect_hardbreak_continuations(&mut text);
assert_eq!(text, "text\\");
assert!(!parser.at_end());
assert!(parser.current_line().contains(":::panel"));
}
#[test]
fn collect_hardbreak_continuations_stops_before_indented_code_fence() {
// Variant: extra leading whitespace on the code-fence line (so the
// stripped tail is ` ```` rather than a bare ` ``` `) must still be
// recognised by the `trim_start().starts_with("```")` check.
let input = "text\\\n ```text\n :fire:\n ```\n";
let mut parser = MarkdownParser::new(input);
parser.advance();
let mut text = "text\\".to_string();
parser.collect_hardbreak_continuations(&mut text);
assert_eq!(text, "text\\");
assert!(!parser.at_end());
assert!(parser.current_line().contains("```text"));
}
#[test]
fn ordered_list_with_sub_content_after_hardbreak() {
// Exercises the sub-content collection loop in parse_ordered_list
// (lines 339-347) with a hardBreak item that also has a nested list.
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"orderedList","attrs":{"order":1},"content":[
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"parent"},
{"type":"hardBreak"},
{"type":"text","text":"continued"}
]},
{"type":"bulletList","content":[
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"child"}
]}
]}
]}
]}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(rt.content.len(), 1);
assert_eq!(rt.content[0].node_type, "orderedList");
let item_content = rt.content[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap();
// Paragraph with hardBreak
let p = item_content[0].content.as_ref().unwrap();
let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types, vec!["text", "hardBreak", "text"]);
assert_eq!(p[0].text.as_deref(), Some("parent"));
assert_eq!(p[2].text.as_deref(), Some("continued"));
// Nested bullet list
assert_eq!(item_content[1].node_type, "bulletList");
}
#[test]
fn render_list_item_content_no_content() {
// A listItem with content: None should produce just a newline.
let item = AdfNode {
node_type: "listItem".to_string(),
attrs: None,
content: None,
text: None,
marks: None,
local_id: None,
parameters: None,
};
let mut output = String::new();
let opts = RenderOptions::default();
render_list_item_content(&item, &mut output, &opts);
assert_eq!(output, "\n");
}
#[test]
fn render_list_item_content_empty_content() {
// A listItem with content: Some(vec![]) should produce just a newline.
let item = AdfNode::list_item(vec![]);
let mut output = String::new();
let opts = RenderOptions::default();
render_list_item_content(&item, &mut output, &opts);
assert_eq!(output, "\n");
}
#[test]
fn plus_bullet_paragraph_roundtrips() {
// Paragraph starting with "+ " must round-trip without becoming
// a bullet list.
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"paragraph","content":[
{"type":"text","text":"+ plus"}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(rt.content[0].node_type, "paragraph");
assert_eq!(
rt.content[0].content.as_ref().unwrap()[0]
.text
.as_deref()
.unwrap(),
"+ plus"
);
}
// ---- Issue #430 tests: mediaSingle inside listItem ----
#[test]
fn issue_430_file_media_in_bullet_list_roundtrip() {
// Issue #430: mediaSingle (type:file) as direct child of listItem
// in a bulletList must survive round-trip.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
{"type":"listItem","content":[{
"type":"mediaSingle",
"attrs":{"layout":"center","width":1009,"widthType":"pixel"},
"content":[{
"type":"media",
"attrs":{"collection":"contentId-123","height":576,"id":"00066e8e-554e-4d7e-af59-a0ef2888bdb6","type":"file","width":1009}
}]
}]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let list = &rt.content[0];
assert_eq!(list.node_type, "bulletList");
let item = &list.content.as_ref().unwrap()[0];
assert_eq!(item.node_type, "listItem");
let ms = &item.content.as_ref().unwrap()[0];
assert_eq!(ms.node_type, "mediaSingle");
let ms_attrs = ms.attrs.as_ref().unwrap();
assert_eq!(ms_attrs["layout"], "center");
assert_eq!(ms_attrs["width"], 1009);
assert_eq!(ms_attrs["widthType"], "pixel");
let media = &ms.content.as_ref().unwrap()[0];
assert_eq!(media.node_type, "media");
let m_attrs = media.attrs.as_ref().unwrap();
assert_eq!(m_attrs["type"], "file");
assert_eq!(m_attrs["id"], "00066e8e-554e-4d7e-af59-a0ef2888bdb6");
assert_eq!(m_attrs["collection"], "contentId-123");
assert_eq!(m_attrs["height"], 576);
assert_eq!(m_attrs["width"], 1009);
}
#[test]
fn issue_430_file_media_in_ordered_list_roundtrip() {
// Same as above but inside an orderedList.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
{"type":"listItem","content":[{
"type":"mediaSingle",
"attrs":{"layout":"center"},
"content":[{
"type":"media",
"attrs":{"type":"file","id":"abc-123","collection":"contentId-456","height":100,"width":200}
}]
}]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let list = &rt.content[0];
assert_eq!(list.node_type, "orderedList");
let item = &list.content.as_ref().unwrap()[0];
assert_eq!(item.node_type, "listItem");
let ms = &item.content.as_ref().unwrap()[0];
assert_eq!(ms.node_type, "mediaSingle");
let media = &ms.content.as_ref().unwrap()[0];
assert_eq!(media.node_type, "media");
let m_attrs = media.attrs.as_ref().unwrap();
assert_eq!(m_attrs["type"], "file");
assert_eq!(m_attrs["id"], "abc-123");
assert_eq!(m_attrs["collection"], "contentId-456");
}
#[test]
fn issue_430_external_media_in_bullet_list_roundtrip() {
// External image (type:external) inside a bullet list item.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
{"type":"listItem","content":[{
"type":"mediaSingle",
"attrs":{"layout":"center"},
"content":[{
"type":"media",
"attrs":{"type":"external","url":"https://example.com/img.png","alt":"Photo"}
}]
}]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let list = &rt.content[0];
assert_eq!(list.node_type, "bulletList");
let item = &list.content.as_ref().unwrap()[0];
let ms = &item.content.as_ref().unwrap()[0];
assert_eq!(ms.node_type, "mediaSingle");
let media = &ms.content.as_ref().unwrap()[0];
assert_eq!(media.node_type, "media");
let m_attrs = media.attrs.as_ref().unwrap();
assert_eq!(m_attrs["type"], "external");
assert_eq!(m_attrs["url"], "https://example.com/img.png");
}
#[test]
fn issue_430_media_with_paragraph_siblings_in_list_item() {
// listItem containing a paragraph followed by a mediaSingle.
// Both children must survive round-trip.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
{"type":"listItem","content":[
{"type":"paragraph","content":[{"type":"text","text":"Caption:"}]},
{"type":"mediaSingle","attrs":{"layout":"center"},
"content":[{"type":"media","attrs":{"type":"file","id":"img-001","collection":"col-1","height":50,"width":100}}]}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let item = &rt.content[0].content.as_ref().unwrap()[0];
let children = item.content.as_ref().unwrap();
assert_eq!(children.len(), 2, "expected 2 children in listItem");
assert_eq!(children[0].node_type, "paragraph");
assert_eq!(children[1].node_type, "mediaSingle");
let media = &children[1].content.as_ref().unwrap()[0];
assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-001");
}
#[test]
fn issue_430_multiple_media_in_list_items() {
// Multiple list items each containing mediaSingle.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
{"type":"listItem","content":[{
"type":"mediaSingle","attrs":{"layout":"center"},
"content":[{"type":"media","attrs":{"type":"file","id":"img-a","collection":"c1","height":10,"width":20}}]
}]},
{"type":"listItem","content":[{
"type":"mediaSingle","attrs":{"layout":"center"},
"content":[{"type":"media","attrs":{"type":"file","id":"img-b","collection":"c2","height":30,"width":40}}]
}]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let items = rt.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 2);
for (i, expected_id) in [("img-a", "c1"), ("img-b", "c2")].iter().enumerate() {
let ms = &items[i].content.as_ref().unwrap()[0];
assert_eq!(ms.node_type, "mediaSingle");
let m_attrs = ms.content.as_ref().unwrap()[0].attrs.as_ref().unwrap();
assert_eq!(m_attrs["id"], expected_id.0);
assert_eq!(m_attrs["collection"], expected_id.1);
}
}
#[test]
fn issue_430_jfm_to_adf_media_in_bullet_item() {
// Parse JFM directly: image syntax on the first line of a bullet item
// must produce mediaSingle, not a paragraph with corrupted text.
let md = "- ![](){type=file id=test-id collection=col-1 height=100 width=200}\n";
let doc = markdown_to_adf(md).unwrap();
let list = &doc.content[0];
assert_eq!(list.node_type, "bulletList");
let item = &list.content.as_ref().unwrap()[0];
let ms = &item.content.as_ref().unwrap()[0];
assert_eq!(
ms.node_type, "mediaSingle",
"expected mediaSingle, got {}",
ms.node_type
);
let media = &ms.content.as_ref().unwrap()[0];
assert_eq!(media.node_type, "media");
let m_attrs = media.attrs.as_ref().unwrap();
assert_eq!(m_attrs["type"], "file");
assert_eq!(m_attrs["id"], "test-id");
}
#[test]
fn issue_430_jfm_to_adf_media_in_ordered_item() {
// Parse JFM directly: image syntax on the first line of an ordered list item.
let md = "1. \n";
let doc = markdown_to_adf(md).unwrap();
let list = &doc.content[0];
assert_eq!(list.node_type, "orderedList");
let item = &list.content.as_ref().unwrap()[0];
let ms = &item.content.as_ref().unwrap()[0];
assert_eq!(
ms.node_type, "mediaSingle",
"expected mediaSingle, got {}",
ms.node_type
);
}
#[test]
fn issue_430_media_then_paragraph_in_bullet_list_roundtrip() {
// listItem with mediaSingle as first child followed by a paragraph.
// Exercises the sub_lines non-empty path when first_node is mediaSingle.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
{"type":"listItem","content":[
{"type":"mediaSingle","attrs":{"layout":"center"},
"content":[{"type":"media","attrs":{"type":"file","id":"img-first","collection":"col-1","height":50,"width":100}}]},
{"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let item = &rt.content[0].content.as_ref().unwrap()[0];
let children = item.content.as_ref().unwrap();
assert_eq!(children.len(), 2, "expected 2 children in listItem");
assert_eq!(children[0].node_type, "mediaSingle");
let media = &children[0].content.as_ref().unwrap()[0];
assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-first");
assert_eq!(children[1].node_type, "paragraph");
}
#[test]
fn issue_430_media_then_paragraph_in_ordered_list_roundtrip() {
// Same as above but for ordered lists.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
{"type":"listItem","content":[
{"type":"mediaSingle","attrs":{"layout":"center"},
"content":[{"type":"media","attrs":{"type":"file","id":"img-ord","collection":"col-2","height":60,"width":120}}]},
{"type":"paragraph","content":[{"type":"text","text":"Description"}]}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let item = &rt.content[0].content.as_ref().unwrap()[0];
let children = item.content.as_ref().unwrap();
assert_eq!(children.len(), 2, "expected 2 children in listItem");
assert_eq!(children[0].node_type, "mediaSingle");
assert_eq!(children[1].node_type, "paragraph");
}
#[test]
fn issue_430_external_media_with_width_type_roundtrip() {
// External image with widthType attr must survive round-trip.
let adf_json = r#"{"version":1,"type":"doc","content":[{
"type":"mediaSingle",
"attrs":{"layout":"wide","width":800,"widthType":"pixel"},
"content":[{
"type":"media",
"attrs":{"type":"external","url":"https://example.com/photo.png","alt":"wide photo"}
}]
}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("widthType=pixel"),
"expected widthType=pixel in markdown, got: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let ms = &rt.content[0];
assert_eq!(ms.node_type, "mediaSingle");
let ms_attrs = ms.attrs.as_ref().unwrap();
assert_eq!(ms_attrs["widthType"], "pixel");
assert_eq!(ms_attrs["width"], 800);
assert_eq!(ms_attrs["layout"], "wide");
}
// ── Issue #490: mediaSingle after hardBreak in listItem ─────
#[test]
fn issue_490_paragraph_with_hardbreak_then_media_single_roundtrip() {
// Reproducer from issue #490: paragraph with trailing hardBreak
// followed by mediaSingle inside a listItem.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"Item with image:"},
{"type":"hardBreak"}
]},
{"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
}}]}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let item = &rt.content[0].content.as_ref().unwrap()[0];
let children = item.content.as_ref().unwrap();
assert_eq!(children.len(), 2, "expected 2 children in listItem");
assert_eq!(children[0].node_type, "paragraph");
assert_eq!(
children[1].node_type, "mediaSingle",
"expected mediaSingle, got {:?}",
children[1].node_type
);
let media = &children[1].content.as_ref().unwrap()[0];
let m_attrs = media.attrs.as_ref().unwrap();
assert_eq!(m_attrs["id"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
assert_eq!(m_attrs["collection"], "contentId-123456");
assert_eq!(m_attrs["height"], 600);
assert_eq!(m_attrs["width"], 800);
}
#[test]
fn issue_490_paragraph_with_hardbreak_then_media_single_ordered_list() {
// Same scenario but in an ordered list.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"Step with screenshot:"},
{"type":"hardBreak"}
]},
{"type":"mediaSingle","attrs":{"layout":"center"},
"content":[{"type":"media","attrs":{
"id":"ord-media-id","type":"file","collection":"col-ord","width":640,"height":480
}}]}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let item = &rt.content[0].content.as_ref().unwrap()[0];
let children = item.content.as_ref().unwrap();
assert_eq!(children.len(), 2, "expected 2 children in listItem");
assert_eq!(children[0].node_type, "paragraph");
assert_eq!(children[1].node_type, "mediaSingle");
let media = &children[1].content.as_ref().unwrap()[0];
assert_eq!(media.attrs.as_ref().unwrap()["id"], "ord-media-id");
}
#[test]
fn issue_490_hardbreak_continuation_does_not_swallow_media_line() {
// Directly tests that collect_hardbreak_continuations stops before
// an indented mediaSingle line.
let md = "- Item with image:\\\n ![](){type=file id=test-490 collection=col height=100 width=200}\n";
let doc = markdown_to_adf(md).unwrap();
let item = &doc.content[0].content.as_ref().unwrap()[0];
let children = item.content.as_ref().unwrap();
assert_eq!(children.len(), 2, "expected 2 children in listItem");
assert_eq!(children[0].node_type, "paragraph");
assert_eq!(
children[1].node_type, "mediaSingle",
"expected mediaSingle as second child, got {:?}",
children[1].node_type
);
let media = &children[1].content.as_ref().unwrap()[0];
assert_eq!(media.attrs.as_ref().unwrap()["id"], "test-490");
}
#[test]
fn issue_490_hardbreak_continuation_still_works_for_text() {
// Ensure regular hardBreak continuations still work after the fix.
let md = "- first line\\\n second line\n";
let doc = markdown_to_adf(md).unwrap();
let item = &doc.content[0].content.as_ref().unwrap()[0];
let children = item.content.as_ref().unwrap();
assert_eq!(
children.len(),
1,
"expected 1 child (paragraph) in listItem"
);
assert_eq!(children[0].node_type, "paragraph");
let inlines = children[0].content.as_ref().unwrap();
// Should contain: text("first line"), hardBreak, text("second line")
assert_eq!(inlines.len(), 3);
assert_eq!(inlines[0].node_type, "text");
assert_eq!(inlines[1].node_type, "hardBreak");
assert_eq!(inlines[2].node_type, "text");
}
#[test]
fn issue_490_external_media_after_hardbreak_roundtrip() {
// External image (URL-based) after a paragraph with hardBreak.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"See image:"},
{"type":"hardBreak"}
]},
{"type":"mediaSingle","attrs":{"layout":"center"},
"content":[{"type":"media","attrs":{
"type":"external","url":"https://example.com/photo.png","alt":"photo"
}}]}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let item = &rt.content[0].content.as_ref().unwrap()[0];
let children = item.content.as_ref().unwrap();
assert_eq!(children.len(), 2);
assert_eq!(children[0].node_type, "paragraph");
assert_eq!(children[1].node_type, "mediaSingle");
let media = &children[1].content.as_ref().unwrap()[0];
let m_attrs = media.attrs.as_ref().unwrap();
assert_eq!(m_attrs["url"], "https://example.com/photo.png");
}
#[test]
fn issue_490_multiple_hardbreaks_then_media_single() {
// Paragraph with multiple hardBreaks, then mediaSingle.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"line one"},
{"type":"hardBreak"},
{"type":"text","text":"line two"},
{"type":"hardBreak"}
]},
{"type":"mediaSingle","attrs":{"layout":"center"},
"content":[{"type":"media","attrs":{
"type":"file","id":"multi-hb","collection":"col-m","width":320,"height":240
}}]}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let item = &rt.content[0].content.as_ref().unwrap()[0];
let children = item.content.as_ref().unwrap();
assert_eq!(children.len(), 2, "expected paragraph + mediaSingle");
assert_eq!(children[0].node_type, "paragraph");
assert_eq!(children[1].node_type, "mediaSingle");
let media = &children[1].content.as_ref().unwrap()[0];
assert_eq!(media.attrs.as_ref().unwrap()["id"], "multi-hb");
}
// ── Issue #525: listItem localId dropped when content includes mediaSingle ──
#[test]
fn issue_525_listitem_localid_with_mediasingle_roundtrip() {
// Exact reproducer from issue #525: listItem with UUID localId whose
// content includes mediaSingle + paragraph + nested bulletList.
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"}]}]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let list = &rt.content[0];
assert_eq!(list.node_type, "bulletList");
let item = &list.content.as_ref().unwrap()[0];
// The localId must be preserved on the listItem.
let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
assert_eq!(
item_attrs["localId"], "aabbccdd-1234-5678-abcd-000000000001",
"listItem localId must survive round-trip"
);
let children = item.content.as_ref().unwrap();
assert_eq!(
children.len(),
3,
"expected mediaSingle + paragraph + bulletList"
);
assert_eq!(children[0].node_type, "mediaSingle");
assert_eq!(children[1].node_type, "paragraph");
assert_eq!(children[2].node_type, "bulletList");
}
#[test]
fn issue_525_listitem_localid_with_mediasingle_only() {
// Minimal case: listItem with localId whose sole child is mediaSingle.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
{"type":"listItem","attrs":{"localId":"li-media-only"},"content":[
{"type":"mediaSingle","attrs":{"layout":"center"},
"content":[{"type":"media","attrs":{"type":"file","id":"m-001","collection":"c1","height":50,"width":100}}]}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let item = &rt.content[0].content.as_ref().unwrap()[0];
let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
assert_eq!(
item_attrs["localId"], "li-media-only",
"listItem localId must survive when sole child is mediaSingle"
);
assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
}
#[test]
fn issue_525_listitem_localid_with_external_media() {
// External image (URL-based) as first child with listItem localId.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
{"type":"listItem","attrs":{"localId":"li-ext-media"},"content":[
{"type":"mediaSingle","attrs":{"layout":"center"},
"content":[{"type":"media","attrs":{"type":"external","url":"https://example.com/img.png","alt":"photo"}}]}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let item = &rt.content[0].content.as_ref().unwrap()[0];
let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
assert_eq!(
item_attrs["localId"], "li-ext-media",
"listItem localId must survive with external mediaSingle"
);
}
#[test]
fn issue_525_listitem_localid_with_mediasingle_in_ordered_list() {
// Same bug in an ordered list.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
{"type":"listItem","attrs":{"localId":"li-ord-media"},"content":[
{"type":"mediaSingle","attrs":{"layout":"center","width":200,"widthType":"pixel"},
"content":[{"type":"media","attrs":{"type":"file","id":"ord-m-001","collection":"col-ord","height":80,"width":160}}]},
{"type":"paragraph","content":[{"type":"text","text":"ordered item text"}]}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let item = &rt.content[0].content.as_ref().unwrap()[0];
let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
assert_eq!(
item_attrs["localId"], "li-ord-media",
"listItem localId must survive in ordered list with mediaSingle"
);
let children = item.content.as_ref().unwrap();
assert_eq!(children[0].node_type, "mediaSingle");
assert_eq!(children[1].node_type, "paragraph");
}
#[test]
fn issue_525_jfm_localid_on_mediasingle_line_parses_correctly() {
// Verify JFM→ADF: trailing {localId=...} on a mediaSingle line
// is assigned to the listItem, not the media node.
let md = "- ![](){type=file id=test-525 collection=col height=100 width=200 mediaWidth=100 widthType=pixel} {localId=li-jfm-525}\n";
let doc = markdown_to_adf(md).unwrap();
let item = &doc.content[0].content.as_ref().unwrap()[0];
let item_attrs = item
.attrs
.as_ref()
.expect("listItem attrs must be present from JFM");
assert_eq!(item_attrs["localId"], "li-jfm-525");
assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
}
#[test]
fn issue_525_encoding_emits_localid_on_mediasingle_line() {
// Verify the ADF→JFM encoding: localId appears on the same line
// as the mediaSingle image syntax.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
{"type":"listItem","attrs":{"localId":"li-emit-check"},"content":[
{"type":"mediaSingle","attrs":{"layout":"center"},
"content":[{"type":"media","attrs":{"type":"file","id":"m-emit","collection":"c-emit","height":10,"width":20}}]}
]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("{localId=li-emit-check}"),
"expected localId in JFM output, got: {md}"
);
// The localId must be on the same line as the image
for line in md.lines() {
if line.contains("![") {
assert!(
line.contains("localId=li-emit-check"),
"localId must be on the same line as the image: {line}"
);
}
}
}
// ── Placeholder node tests ────────────────────────────────────
#[test]
fn adf_placeholder_to_markdown() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::placeholder(
"Type something here",
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains(":placeholder[Type something here]"),
"expected :placeholder directive, got: {md}"
);
}
#[test]
fn markdown_placeholder_to_adf() {
let doc = markdown_to_adf("Before :placeholder[Enter name] after").unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content[1].node_type, "placeholder");
let attrs = content[1].attrs.as_ref().unwrap();
assert_eq!(attrs["text"], "Enter name");
}
#[test]
fn placeholder_round_trip() {
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"placeholder","attrs":{"text":"Type something here"}}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let content = rt.content[0].content.as_ref().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0].node_type, "placeholder");
let attrs = content[0].attrs.as_ref().unwrap();
assert_eq!(attrs["text"], "Type something here");
}
#[test]
fn placeholder_empty_text() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::placeholder("")])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains(":placeholder[]"),
"expected empty placeholder directive, got: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let content = rt.content[0].content.as_ref().unwrap();
assert_eq!(content[0].node_type, "placeholder");
assert_eq!(content[0].attrs.as_ref().unwrap()["text"], "");
}
#[test]
fn placeholder_with_surrounding_text() {
let md = "Click :placeholder[here] to continue\n";
let doc = markdown_to_adf(md).unwrap();
let content = doc.content[0].content.as_ref().unwrap();
assert_eq!(content[0].text.as_deref(), Some("Click "));
assert_eq!(content[1].node_type, "placeholder");
assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "here");
assert_eq!(content[2].text.as_deref(), Some(" to continue"));
}
#[test]
fn placeholder_missing_attrs() {
// Placeholder node with no attrs should not panic
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode {
node_type: "placeholder".to_string(),
attrs: None,
content: None,
text: None,
marks: None,
local_id: None,
parameters: None,
}])],
};
let md = adf_to_markdown(&doc).unwrap();
// With no attrs, nothing is emitted for the placeholder
assert!(!md.contains("placeholder"));
}
// Issue #446: mention in table+list loses id and misplaces localId
#[test]
fn mention_in_table_bullet_list_preserves_id_and_local_id() {
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":" "}]}]}]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
// Navigate: doc → table → tableRow → tableCell → bulletList → listItem → paragraph
let cell = &rt.content[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap()[0];
let list = &cell.content.as_ref().unwrap()[0];
let list_item = &list.content.as_ref().unwrap()[0];
// listItem must NOT have a localId attribute
assert!(
list_item
.attrs
.as_ref()
.and_then(|a| a.get("localId"))
.is_none(),
"localId should stay on the mention, not the listItem"
);
let para = &list_item.content.as_ref().unwrap()[0];
let inlines = para.content.as_ref().unwrap();
// Should have: text("prefix text "), mention, text(" ")
assert_eq!(inlines.len(), 3, "expected 3 inline nodes, got {inlines:?}");
assert_eq!(inlines[0].node_type, "text");
assert_eq!(inlines[0].text.as_deref(), Some("prefix text "));
assert_eq!(inlines[1].node_type, "mention");
let mention_attrs = inlines[1].attrs.as_ref().unwrap();
assert_eq!(
mention_attrs["id"], "aabbccdd11223344aabbccdd",
"mention id must be preserved"
);
assert_eq!(
mention_attrs["localId"], "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"mention localId must be preserved"
);
assert_eq!(mention_attrs["text"], "@Alice Example");
assert_eq!(inlines[2].node_type, "text");
assert_eq!(inlines[2].text.as_deref(), Some(" "));
}
#[test]
fn mention_in_bullet_list_preserves_id_and_local_id() {
// Same bug outside of a table context
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":" "}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let list_item = &rt.content[0].content.as_ref().unwrap()[0];
assert!(
list_item
.attrs
.as_ref()
.and_then(|a| a.get("localId"))
.is_none(),
"localId should stay on the mention, not the listItem"
);
let para = &list_item.content.as_ref().unwrap()[0];
let inlines = para.content.as_ref().unwrap();
assert_eq!(inlines[0].node_type, "mention");
let mention_attrs = inlines[0].attrs.as_ref().unwrap();
assert_eq!(mention_attrs["id"], "user123");
assert_eq!(
mention_attrs["localId"],
"11111111-2222-3333-4444-555555555555"
);
}
#[test]
fn mention_in_ordered_list_preserves_id_and_local_id() {
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"}}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let list_item = &rt.content[0].content.as_ref().unwrap()[0];
assert!(
list_item
.attrs
.as_ref()
.and_then(|a| a.get("localId"))
.is_none(),
"localId should stay on the mention, not the listItem"
);
let para = &list_item.content.as_ref().unwrap()[0];
let inlines = para.content.as_ref().unwrap();
assert_eq!(inlines[1].node_type, "mention");
let mention_attrs = inlines[1].attrs.as_ref().unwrap();
assert_eq!(mention_attrs["id"], "xyz");
assert_eq!(mention_attrs["localId"], "aaaa-bbbb");
}
#[test]
fn list_item_own_local_id_with_mention_both_preserved() {
// When a listItem has its own localId AND contains a mention with localId,
// both should be preserved independently.
let md = "- hello :mention[@Eve]{id=e1 localId=mention-lid} {localId=item-lid}\n";
let doc = markdown_to_adf(md).unwrap();
let list_item = &doc.content[0].content.as_ref().unwrap()[0];
// listItem should have its own localId
let item_attrs = list_item.attrs.as_ref().unwrap();
assert_eq!(item_attrs["localId"], "item-lid");
// mention should have its own localId
let para = &list_item.content.as_ref().unwrap()[0];
let inlines = para.content.as_ref().unwrap();
let mention = inlines.iter().find(|n| n.node_type == "mention").unwrap();
let mention_attrs = mention.attrs.as_ref().unwrap();
assert_eq!(mention_attrs["id"], "e1");
assert_eq!(mention_attrs["localId"], "mention-lid");
}
#[test]
fn extract_trailing_local_id_ignores_directive_attrs() {
// Directly test the helper: a line ending with a directive's {…}
// should NOT be treated as a trailing localId.
let line = "text :mention[@X]{id=abc localId=uuid}";
let (text, lid, plid) = extract_trailing_local_id(line);
assert_eq!(text, line, "text should be unchanged");
assert!(
lid.is_none(),
"should not extract localId from directive attrs"
);
assert!(plid.is_none());
}
#[test]
fn extract_trailing_local_id_matches_standalone_block() {
// A standalone trailing {localId=…} separated by whitespace should still work.
let line = "some text {localId=abc-123}";
let (text, lid, plid) = extract_trailing_local_id(line);
assert_eq!(text, "some text");
assert_eq!(lid.as_deref(), Some("abc-123"));
assert!(plid.is_none());
}
// --- Issue #454: literal newline in text node inside listItem paragraph ---
#[test]
fn newline_in_text_node_roundtrips_in_bullet_list() {
// A text node containing a literal \n inside a bullet list item
// must round-trip as a single text node with the embedded newline
// preserved, not split into multiple paragraphs or hardBreak nodes.
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"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
// Should still be a single bulletList with one listItem
assert_eq!(rt.content.len(), 1);
let list = &rt.content[0];
assert_eq!(list.node_type, "bulletList");
let items = list.content.as_ref().unwrap();
assert_eq!(items.len(), 1);
// The listItem should have exactly one paragraph child
let item_content = items[0].content.as_ref().unwrap();
assert_eq!(
item_content.len(),
1,
"listItem should have exactly one paragraph"
);
assert_eq!(item_content[0].node_type, "paragraph");
// The embedded newline must survive as a single text node
let inlines = item_content[0].content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(
types,
vec!["text", "hardBreak", "text"],
"embedded newline should stay in a single text node, not produce extra hardBreaks"
);
assert_eq!(
inlines[2].text.as_deref(),
Some("first command\nsecond command")
);
}
#[test]
fn newline_in_text_node_roundtrips_in_ordered_list() {
// Same as above but in an ordered list.
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"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let list = &rt.content[0];
assert_eq!(list.node_type, "orderedList");
let items = list.content.as_ref().unwrap();
assert_eq!(items.len(), 1);
let item_content = items[0].content.as_ref().unwrap();
assert_eq!(item_content.len(), 1);
assert_eq!(item_content[0].node_type, "paragraph");
let inlines = item_content[0].content.as_ref().unwrap();
assert_eq!(inlines.len(), 1);
assert_eq!(inlines[0].node_type, "text");
assert_eq!(inlines[0].text.as_deref(), Some("first\nsecond"));
}
#[test]
fn newline_in_text_node_roundtrips_in_paragraph() {
// A text node with \n in a top-level paragraph should render as
// escaped \n and round-trip back to a single text node.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello\nworld"}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("hello\\nworld"),
"newline in text node should render as escaped \\n: {md:?}"
);
let rt = markdown_to_adf(&md).unwrap();
let inlines = rt.content[0].content.as_ref().unwrap();
assert_eq!(inlines.len(), 1);
assert_eq!(inlines[0].text.as_deref(), Some("hello\nworld"));
}
#[test]
fn multiple_newlines_in_text_node_roundtrip() {
// Multiple \n characters should each round-trip within the same text node.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a\nb\nc"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let item_content = rt.content[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap();
assert_eq!(item_content.len(), 1);
let inlines = item_content[0].content.as_ref().unwrap();
assert_eq!(inlines.len(), 1);
assert_eq!(inlines[0].text.as_deref(), Some("a\nb\nc"));
}
#[test]
fn newline_in_marked_text_node_roundtrips() {
// A bold text node with \n should round-trip preserving both
// the marks and the embedded newline.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold\ntext","marks":[{"type":"strong"}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("**bold\\ntext**"),
"bold text with embedded newline should stay in one marked run: {md:?}"
);
let rt = markdown_to_adf(&md).unwrap();
let inlines = rt.content[0].content.as_ref().unwrap();
assert_eq!(inlines.len(), 1);
assert_eq!(inlines[0].text.as_deref(), Some("bold\ntext"));
assert!(inlines[0]
.marks
.as_ref()
.unwrap()
.iter()
.any(|m| m.mark_type == "strong"));
}
#[test]
fn trailing_newline_in_text_node_roundtrips() {
// A text node ending with \n should round-trip preserving the
// trailing newline.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"trailing\n"}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("trailing\\n"),
"trailing newline should be escaped: {md:?}"
);
let rt = markdown_to_adf(&md).unwrap();
let inlines = rt.content[0].content.as_ref().unwrap();
assert_eq!(inlines.len(), 1);
assert_eq!(inlines[0].text.as_deref(), Some("trailing\n"));
}
#[test]
fn hardbreak_and_embedded_newline_are_distinct() {
// A hardBreak node and an embedded \n in a text node must not be
// conflated — each must round-trip to its original form.
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"}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let inlines = rt.content[0].content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(
types,
vec!["text", "hardBreak", "text", "hardBreak", "text"]
);
assert_eq!(inlines[0].text.as_deref(), Some("before"));
assert_eq!(inlines[2].text.as_deref(), Some("mid\ndle"));
assert_eq!(inlines[4].text.as_deref(), Some("after"));
}
// ---- Issue #472 tests ----
#[test]
fn issue_472_bullet_list_trailing_hardbreak_roundtrips() {
// Issue #472: trailing hardBreak at end of listItem paragraph must
// not split the parent bulletList on round-trip.
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"bulletList","content":[
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"First item"},
{"type":"hardBreak"}
]}
]},
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"Second item"}
]}
]}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
// Must remain a single bulletList
assert_eq!(
rt.content.len(),
1,
"Should be 1 block (bulletList), got {}",
rt.content.len()
);
assert_eq!(rt.content[0].node_type, "bulletList");
let items = rt.content[0].content.as_ref().unwrap();
assert_eq!(
items.len(),
2,
"Should have 2 listItems, got {}",
items.len()
);
// First item: text + hardBreak (trailing)
let p1 = items[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap();
let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types1, vec!["text", "hardBreak"]);
assert_eq!(p1[0].text.as_deref(), Some("First item"));
// Second item: text only
let p2 = items[1].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap();
assert_eq!(p2[0].text.as_deref(), Some("Second item"));
}
#[test]
fn issue_472_ordered_list_trailing_hardbreak_roundtrips() {
// Ordered list variant of issue #472.
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"orderedList","attrs":{"order":1},"content":[
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"Alpha"},
{"type":"hardBreak"}
]}
]},
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"Beta"}
]}
]}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(rt.content.len(), 1);
assert_eq!(rt.content[0].node_type, "orderedList");
let items = rt.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 2);
let p1 = items[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap();
let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types1, vec!["text", "hardBreak"]);
assert_eq!(p1[0].text.as_deref(), Some("Alpha"));
}
#[test]
fn issue_472_trailing_hardbreak_jfm_no_blank_line() {
// The rendered JFM must not contain a blank line after the
// trailing hardBreak — that would split the list.
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"bulletList","content":[
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"Hello"},
{"type":"hardBreak"}
]}
]},
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"World"}
]}
]}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
// Should produce "- Hello\\n- World\n" (no blank line between items).
assert_eq!(md, "- Hello\\\n- World\n");
}
#[test]
fn issue_472_multiple_trailing_hardbreaks_roundtrip() {
// Multiple trailing hardBreaks at the end of a listItem paragraph.
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"bulletList","content":[
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"Item"},
{"type":"hardBreak"},
{"type":"hardBreak"}
]}
]},
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"Next"}
]}
]}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
// Must remain a single bulletList
assert_eq!(rt.content.len(), 1);
assert_eq!(rt.content[0].node_type, "bulletList");
let items = rt.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 2);
// First item should preserve both hardBreaks
let p1 = items[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap();
let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types1, vec!["text", "hardBreak", "hardBreak"]);
}
#[test]
fn issue_472_hardbreak_mid_and_trailing_roundtrip() {
// A hardBreak in the middle AND at the end of a listItem paragraph.
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"bulletList","content":[
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"Line one"},
{"type":"hardBreak"},
{"type":"text","text":"Line two"},
{"type":"hardBreak"}
]}
]},
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"Other item"}
]}
]}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(rt.content.len(), 1);
assert_eq!(rt.content[0].node_type, "bulletList");
let items = rt.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 2);
let p1 = items[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap();
let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types1, vec!["text", "hardBreak", "text", "hardBreak"]);
assert_eq!(p1[0].text.as_deref(), Some("Line one"));
assert_eq!(p1[2].text.as_deref(), Some("Line two"));
}
#[test]
fn issue_472_only_hardbreak_in_listitem_paragraph() {
// Edge case: paragraph contains only a hardBreak, no text.
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"bulletList","content":[
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"hardBreak"}
]}
]},
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"After"}
]}
]}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
// Must remain a single bulletList with 2 items
assert_eq!(rt.content.len(), 1);
assert_eq!(rt.content[0].node_type, "bulletList");
let items = rt.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 2);
}
#[test]
fn issue_472_three_items_middle_has_trailing_hardbreak() {
// Three-item list where only the middle item has a trailing hardBreak.
let adf_json = r#"{"version":1,"type":"doc","content":[
{"type":"bulletList","content":[
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"First"}
]}
]},
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"Second"},
{"type":"hardBreak"}
]}
]},
{"type":"listItem","content":[
{"type":"paragraph","content":[
{"type":"text","text":"Third"}
]}
]}
]}
]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(rt.content.len(), 1);
assert_eq!(rt.content[0].node_type, "bulletList");
let items = rt.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 3);
assert_eq!(
items[0].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap()[0]
.text
.as_deref(),
Some("First")
);
assert_eq!(
items[2].content.as_ref().unwrap()[0]
.content
.as_ref()
.unwrap()[0]
.text
.as_deref(),
Some("Third")
);
}
// ── Issue #494: trailing space-only text node after hardBreak ────
#[test]
fn issue_494_space_after_hardbreak_roundtrip() {
// The original reproducer from issue #494: a single space text
// node following a hardBreak is silently dropped on round-trip.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"Some text"},
{"type":"hardBreak"},
{"type":"text","text":" "}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let inlines = rt.content[0].content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(
types,
vec!["text", "hardBreak", "text"],
"space-only text node after hardBreak should survive round-trip"
);
assert_eq!(inlines[2].text.as_deref(), Some(" "));
}
#[test]
fn issue_494_multiple_spaces_after_hardbreak_roundtrip() {
// Multiple spaces after hardBreak should also survive.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"Hello"},
{"type":"hardBreak"},
{"type":"text","text":" "}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let inlines = rt.content[0].content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(
types,
vec!["text", "hardBreak", "text"],
"multi-space text node after hardBreak should survive round-trip"
);
assert_eq!(inlines[2].text.as_deref(), Some(" "));
}
#[test]
fn issue_494_space_then_text_after_hardbreak_roundtrip() {
// Space followed by real text after hardBreak — the space should
// be preserved as part of the text node.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"Before"},
{"type":"hardBreak"},
{"type":"text","text":" After"}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let inlines = rt.content[0].content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(types, vec!["text", "hardBreak", "text"]);
assert_eq!(inlines[2].text.as_deref(), Some(" After"));
}
#[test]
fn issue_494_hardbreak_then_space_then_hardbreak_roundtrip() {
// Space sandwiched between two hardBreaks.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"A"},
{"type":"hardBreak"},
{"type":"text","text":" "},
{"type":"hardBreak"},
{"type":"text","text":"B"}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let inlines = rt.content[0].content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(
types,
vec!["text", "hardBreak", "text", "hardBreak", "text"],
"space between two hardBreaks should survive round-trip"
);
assert_eq!(inlines[2].text.as_deref(), Some(" "));
assert_eq!(inlines[4].text.as_deref(), Some("B"));
}
#[test]
fn issue_494_trailing_space_hardbreak_style_not_confused() {
// A plain paragraph break (blank line) should still work after
// a line that does NOT end with a hardBreak marker.
let md = "first paragraph\n\nsecond paragraph\n";
let doc = markdown_to_adf(md).unwrap();
assert_eq!(
doc.content.len(),
2,
"blank line should still separate paragraphs"
);
}
#[test]
fn issue_494_space_after_trailing_space_hardbreak_roundtrip() {
// Same bug but with trailing-space style hardBreak (two spaces
// before newline) instead of backslash style.
let md = "line one \n \n";
// The above is: "line one" + trailing-space hardBreak + continuation
// line " " (2-space indent + 1 space content). The space-only
// continuation should not be treated as a blank paragraph break.
let doc = markdown_to_adf(md).unwrap();
let inlines = doc.content[0].content.as_ref().unwrap();
let has_text_after_break = inlines.iter().any(|n| {
n.node_type == "text"
&& n.text
.as_deref()
.is_some_and(|t| t.trim().is_empty() && !t.is_empty())
});
assert!(
has_text_after_break || inlines.len() >= 2,
"space-only line after trailing-space hardBreak should be preserved"
);
}
#[test]
fn issue_494_space_after_hardbreak_in_list_item_roundtrip() {
// Exercises the same bug inside a list item context.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
{"type":"listItem","content":[{"type":"paragraph","content":[
{"type":"text","text":"item"},
{"type":"hardBreak"},
{"type":"text","text":" "}
]}]}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let list = &rt.content[0];
let item = &list.content.as_ref().unwrap()[0];
let para = &item.content.as_ref().unwrap()[0];
let inlines = para.content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(
types,
vec!["text", "hardBreak", "text"],
"space after hardBreak in list item should survive round-trip"
);
assert_eq!(inlines[2].text.as_deref(), Some(" "));
}
// ── Issue #510: trailing spaces in text node should not become hardBreak ──
#[test]
fn issue_510_trailing_double_space_paragraph_roundtrip() {
// Two trailing spaces in a text node must survive round-trip without
// being converted to a hardBreak or merging the next paragraph.
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"}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
// Must produce two separate paragraphs
assert_eq!(
rt.content.len(),
2,
"should produce two paragraphs, got: {}",
rt.content.len()
);
assert_eq!(rt.content[0].node_type, "paragraph");
assert_eq!(rt.content[1].node_type, "paragraph");
// First paragraph text preserves trailing spaces
let p1 = rt.content[0].content.as_ref().unwrap();
assert_eq!(
p1[0].text.as_deref(),
Some("first paragraph with trailing spaces "),
"trailing spaces should be preserved in first paragraph"
);
// Second paragraph is intact
let p2 = rt.content[1].content.as_ref().unwrap();
assert_eq!(p2[0].text.as_deref(), Some("second paragraph"));
// No hardBreak nodes should exist
let all_types: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
assert!(
!all_types.contains(&"hardBreak"),
"trailing spaces should not produce hardBreak, got: {all_types:?}"
);
}
#[test]
fn issue_510_trailing_triple_space_roundtrip() {
// Three trailing spaces also must not become a hardBreak.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"text "}]},{"type":"paragraph","content":[{"type":"text","text":"next"}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
assert_eq!(rt.content.len(), 2, "should still be two paragraphs");
let p1 = rt.content[0].content.as_ref().unwrap();
assert_eq!(
p1[0].text.as_deref(),
Some("text "),
"three trailing spaces should be preserved"
);
}
#[test]
fn issue_510_trailing_spaces_with_backslash_roundtrip() {
// Text ending with backslash + trailing spaces: both must survive.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\ "}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let p = rt.content[0].content.as_ref().unwrap();
assert_eq!(
p[0].text.as_deref(),
Some("end\\ "),
"backslash + trailing spaces should both survive"
);
}
#[test]
fn issue_510_jfm_contains_escaped_trailing_space() {
// Verify the serializer actually emits the backslash-space escape.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello "}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains(r"\ "),
"JFM should contain backslash-space escape for trailing spaces, got: {md:?}"
);
// Must NOT end with two plain spaces before newline
for line in md.lines() {
assert!(
!line.ends_with(" "),
"no JFM line should end with two plain spaces, got: {line:?}"
);
}
}
#[test]
fn issue_510_single_trailing_space_not_escaped() {
// A single trailing space should NOT be escaped (not a hardBreak trigger).
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"word "}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
!md.contains('\\'),
"single trailing space should not be escaped, got: {md:?}"
);
let rt = markdown_to_adf(&md).unwrap();
let p = rt.content[0].content.as_ref().unwrap();
assert_eq!(p[0].text.as_deref(), Some("word "));
}
#[test]
fn issue_510_trailing_spaces_in_heading_roundtrip() {
// Trailing double-spaces in a heading text node should also survive.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"heading "}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let h = rt.content[0].content.as_ref().unwrap();
assert_eq!(
h[0].text.as_deref(),
Some("heading "),
"trailing spaces in heading should be preserved"
);
}
#[test]
fn issue_510_trailing_spaces_in_list_item_roundtrip() {
// Trailing double-spaces in a bullet list item text node.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item "}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let list = &rt.content[0];
let item = &list.content.as_ref().unwrap()[0];
let para = &item.content.as_ref().unwrap()[0];
let inlines = para.content.as_ref().unwrap();
assert_eq!(
inlines[0].text.as_deref(),
Some("item "),
"trailing spaces in list item should be preserved"
);
}
#[test]
fn issue_510_trailing_spaces_with_bold_mark_roundtrip() {
// Trailing spaces in a bold-marked text node: the closing **
// comes after the spaces, so the line doesn't end with spaces.
// But the escape should still be applied (and be harmless).
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold ","marks":[{"type":"strong"}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let p = rt.content[0].content.as_ref().unwrap();
assert_eq!(
p[0].text.as_deref(),
Some("bold "),
"trailing spaces in bold text should be preserved"
);
}
#[test]
fn issue_510_hardbreak_between_paragraphs_still_works() {
// Actual hardBreak nodes must still round-trip correctly.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"line one"},{"type":"hardBreak"},{"type":"text","text":"line two"}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let inlines = rt.content[0].content.as_ref().unwrap();
let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
assert_eq!(
types,
vec!["text", "hardBreak", "text"],
"explicit hardBreak should still round-trip"
);
}
#[test]
fn issue_510_all_spaces_text_node_roundtrip() {
// A text node that is entirely spaces (2+) should survive.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":" "}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let p = rt.content[0].content.as_ref().unwrap();
assert_eq!(
p[0].text.as_deref(),
Some(" "),
"space-only text node should survive round-trip"
);
}
// ── Issue #522: listItem multi-paragraph merge ──────────────────────
#[test]
fn issue_522_listitem_hardbreak_then_two_paragraphs_roundtrips() {
// The exact reproducer from issue #522: first paragraph has
// hardBreak nodes, followed by two sibling paragraphs.
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"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let items = rt.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 1);
let children = items[0].content.as_ref().unwrap();
assert_eq!(
children.len(),
3,
"Expected 3 paragraphs in listItem, got {}",
children.len()
);
assert_eq!(children[0].node_type, "paragraph");
assert_eq!(children[1].node_type, "paragraph");
assert_eq!(children[2].node_type, "paragraph");
// Verify the text content of each paragraph
let text1 = children[1].content.as_ref().unwrap()[0]
.text
.as_deref()
.unwrap();
assert_eq!(text1, "second paragraph");
let text2 = children[2].content.as_ref().unwrap()[0]
.text
.as_deref()
.unwrap();
assert_eq!(text2, "third paragraph");
}
#[test]
fn issue_522_ordered_list_hardbreak_then_paragraphs_roundtrips() {
// Same scenario in an ordered list.
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"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let items = rt.content[0].content.as_ref().unwrap();
let children = items[0].content.as_ref().unwrap();
assert_eq!(
children.len(),
3,
"Expected 3 paragraphs in ordered listItem, got {}",
children.len()
);
assert_eq!(children[1].node_type, "paragraph");
assert_eq!(children[2].node_type, "paragraph");
assert_eq!(
children[1].content.as_ref().unwrap()[0]
.text
.as_deref()
.unwrap(),
"second para"
);
assert_eq!(
children[2].content.as_ref().unwrap()[0]
.text
.as_deref()
.unwrap(),
"third para"
);
}
#[test]
fn issue_522_two_paragraphs_without_hardbreak_roundtrips() {
// Two paragraphs without hardBreak — should also remain separate.
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"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let items = rt.content[0].content.as_ref().unwrap();
let children = items[0].content.as_ref().unwrap();
assert_eq!(
children.len(),
2,
"Expected 2 paragraphs in listItem, got {}",
children.len()
);
assert_eq!(children[0].node_type, "paragraph");
assert_eq!(children[1].node_type, "paragraph");
}
#[test]
fn issue_522_paragraph_then_nested_list_no_spurious_blank() {
// A paragraph followed by a nested list should NOT get a blank
// separator (only paragraph-paragraph transitions need one).
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"}]}]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
// Should not contain a blank indented line between parent text and sub-list
assert!(
!md.contains(" \n -"),
"No blank separator between paragraph and nested list"
);
let rt = markdown_to_adf(&md).unwrap();
let items = rt.content[0].content.as_ref().unwrap();
let children = items[0].content.as_ref().unwrap();
assert_eq!(children.len(), 2);
assert_eq!(children[0].node_type, "paragraph");
assert_eq!(children[1].node_type, "bulletList");
}
#[test]
fn issue_522_three_paragraphs_no_hardbreak_roundtrips() {
// Three plain paragraphs (no hardBreak) inside a single listItem.
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"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let items = rt.content[0].content.as_ref().unwrap();
let children = items[0].content.as_ref().unwrap();
assert_eq!(
children.len(),
3,
"Expected 3 paragraphs, got {}",
children.len()
);
for (i, child) in children.iter().enumerate() {
assert_eq!(
child.node_type, "paragraph",
"Child {} should be a paragraph",
i
);
}
}
#[test]
fn issue_522_multiple_list_items_each_with_paragraphs() {
// Multiple list items, each with multiple paragraphs.
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"}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let items = rt.content[0].content.as_ref().unwrap();
assert_eq!(items.len(), 2, "Expected 2 list items");
let item1 = items[0].content.as_ref().unwrap();
assert_eq!(item1.len(), 2, "Item 1 should have 2 paragraphs");
let item2 = items[1].content.as_ref().unwrap();
assert_eq!(item2.len(), 2, "Item 2 should have 2 paragraphs");
// Verify hardBreak is preserved in item2's first paragraph
let item2_p1_inlines = item2[0].content.as_ref().unwrap();
let types: Vec<&str> = item2_p1_inlines
.iter()
.map(|n| n.node_type.as_str())
.collect();
assert_eq!(types, vec!["text", "hardBreak", "text"]);
}
#[test]
fn issue_531_blockquote_hardbreak_then_two_paragraphs_roundtrips() {
// The exact reproducer from issue #531: blockquote with first
// paragraph containing hardBreak nodes, followed by two sibling
// paragraphs.
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"}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let children = rt.content[0].content.as_ref().unwrap();
assert_eq!(
children.len(),
3,
"Expected 3 paragraphs in blockquote, got {}",
children.len()
);
assert_eq!(children[0].node_type, "paragraph");
assert_eq!(children[1].node_type, "paragraph");
assert_eq!(children[2].node_type, "paragraph");
let text1 = children[1].content.as_ref().unwrap()[0]
.text
.as_deref()
.unwrap();
assert_eq!(text1, "second paragraph");
let text2 = children[2].content.as_ref().unwrap()[0]
.text
.as_deref()
.unwrap();
assert_eq!(text2, "third paragraph");
}
#[test]
fn issue_531_blockquote_two_paragraphs_without_hardbreak_roundtrips() {
// Two simple paragraphs inside a blockquote, no hardBreak.
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"}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let children = rt.content[0].content.as_ref().unwrap();
assert_eq!(
children.len(),
2,
"Expected 2 paragraphs in blockquote, got {}",
children.len()
);
assert_eq!(children[0].node_type, "paragraph");
assert_eq!(children[1].node_type, "paragraph");
}
#[test]
fn issue_531_blockquote_three_paragraphs_no_hardbreak_roundtrips() {
// Three paragraphs, none with hardBreak.
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"}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let children = rt.content[0].content.as_ref().unwrap();
assert_eq!(
children.len(),
3,
"Expected 3 paragraphs in blockquote, got {}",
children.len()
);
for child in children {
assert_eq!(child.node_type, "paragraph");
}
}
#[test]
fn issue_531_blockquote_paragraph_then_list_no_spurious_blank() {
// A paragraph followed by a nested list inside a blockquote —
// should NOT insert a blank separator line.
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"}]}]}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let children = rt.content[0].content.as_ref().unwrap();
assert_eq!(children[0].node_type, "paragraph");
assert_eq!(children[1].node_type, "bulletList");
}
#[test]
fn issue_531_blockquote_single_paragraph_unchanged() {
// A single paragraph in a blockquote should remain unchanged.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"solo"}]}]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let children = rt.content[0].content.as_ref().unwrap();
assert_eq!(children.len(), 1);
assert_eq!(children[0].node_type, "paragraph");
let text = children[0].content.as_ref().unwrap()[0]
.text
.as_deref()
.unwrap();
assert_eq!(text, "solo");
}
// ── Issue #554: marks combined with `code` or with each other ──────
/// Helper: roundtrip an ADF document and assert the marks on the first
/// text node match `expected_marks` (in order).
fn assert_roundtrip_marks(adf_json: &str, expected_marks: &[&str]) {
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let node = &rt.content[0].content.as_ref().unwrap()[0];
let mark_types: Vec<&str> = node
.marks
.as_ref()
.expect("should have marks")
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(
mark_types, expected_marks,
"mark order mismatch for md={md}"
);
}
#[test]
fn issue_554_code_and_text_color_preserved() {
let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"x","marks":[
{"type":"textColor","attrs":{"color":"#008000"}},
{"type":"code"}
]}
]}]}"##;
assert_roundtrip_marks(adf_json, &["textColor", "code"]);
}
#[test]
fn issue_554_code_and_bg_color_preserved() {
let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"x","marks":[
{"type":"backgroundColor","attrs":{"color":"#FF0000"}},
{"type":"code"}
]}
]}]}"##;
assert_roundtrip_marks(adf_json, &["backgroundColor", "code"]);
}
#[test]
fn issue_554_code_and_subsup_preserved() {
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"x","marks":[
{"type":"subsup","attrs":{"type":"sub"}},
{"type":"code"}
]}
]}]}"#;
assert_roundtrip_marks(adf_json, &["subsup", "code"]);
}
#[test]
fn issue_554_code_and_underline_preserved() {
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"x","marks":[
{"type":"underline"},
{"type":"code"}
]}
]}]}"#;
assert_roundtrip_marks(adf_json, &["underline", "code"]);
}
#[test]
fn issue_554_code_textcolor_and_underline_preserved() {
let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"x","marks":[
{"type":"textColor","attrs":{"color":"#008000"}},
{"type":"underline"},
{"type":"code"}
]}
]}]}"##;
assert_roundtrip_marks(adf_json, &["textColor", "underline", "code"]);
}
#[test]
fn issue_554_textcolor_and_underline_preserved() {
let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"x","marks":[
{"type":"textColor","attrs":{"color":"#008000"}},
{"type":"underline"}
]}
]}]}"##;
assert_roundtrip_marks(adf_json, &["textColor", "underline"]);
}
#[test]
fn issue_554_underline_and_textcolor_preserved_order_swapped() {
let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"x","marks":[
{"type":"underline"},
{"type":"textColor","attrs":{"color":"#008000"}}
]}
]}]}"##;
// underline appears first, so it should be the OUTER wrapper.
assert_roundtrip_marks(adf_json, &["underline", "textColor"]);
}
#[test]
fn issue_554_textcolor_and_annotation_preserved() {
let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"x","marks":[
{"type":"textColor","attrs":{"color":"#008000"}},
{"type":"annotation","attrs":{"id":"abc-123","annotationType":"inlineComment"}}
]}
]}]}"##;
assert_roundtrip_marks(adf_json, &["textColor", "annotation"]);
}
#[test]
fn issue_554_bgcolor_and_underline_preserved() {
let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"x","marks":[
{"type":"backgroundColor","attrs":{"color":"#FF0000"}},
{"type":"underline"}
]}
]}]}"##;
assert_roundtrip_marks(adf_json, &["backgroundColor", "underline"]);
}
#[test]
fn issue_554_subsup_and_underline_preserved() {
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"x","marks":[
{"type":"subsup","attrs":{"type":"sub"}},
{"type":"underline"}
]}
]}]}"#;
assert_roundtrip_marks(adf_json, &["subsup", "underline"]);
}
#[test]
fn issue_554_exact_reproducer_full_match() {
// The exact reproducer from issue #554. The byte-for-byte ADF JSON
// must round-trip through `from-adf | to-adf` unchanged.
let adf_json = r##"{
"version": 1,
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{"type":"text","text":"Status: ","marks":[{"type":"strong"}]},
{"type":"text","text":"Approved","marks":[
{"type":"textColor","attrs":{"color":"#008000"}}
]},
{"type":"text","text":" — ready to proceed"}
]
}
]
}"##;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains(":span[Approved]{color=#008000}"),
"JFM should contain green span: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
// Find the "Approved" text node and verify color is preserved.
let approved = rt.content[0]
.content
.as_ref()
.unwrap()
.iter()
.find(|n| n.text.as_deref() == Some("Approved"))
.expect("Approved text node");
let marks = approved.marks.as_ref().expect("should have marks");
let color_mark = marks
.iter()
.find(|m| m.mark_type == "textColor")
.expect("textColor mark must be preserved");
assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
}
#[test]
fn issue_554_textcolor_with_code_renders_span_around_code() {
// Verify the rendered JFM uses `:span[`text`]{color=...}` — the
// syntax suggested in the issue.
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"fn main",
vec![
AdfMark::text_color("#008000"),
AdfMark {
mark_type: "code".to_string(),
attrs: None,
},
],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains(":span[`fn main`]{color=#008000}"),
"expected span-wrapped code, got: {md}"
);
}
#[test]
fn issue_554_underline_with_code_renders_bracketed_around_code() {
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
"fn main",
vec![
AdfMark::underline(),
AdfMark {
mark_type: "code".to_string(),
attrs: None,
},
],
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("[`fn main`]{underline}"),
"expected bracketed-span around code, got: {md}"
);
}
// ── Issue #554 (re-opened): boundary-underscore destroys span directives ──
#[test]
fn issue_554_underscore_adjacent_to_textcolor_span_roundtrip() {
// Reproducer from the re-opened issue: a `_ ` plain-text node followed
// by a textColor span whose text starts with `_` produced JFM that the
// parser saw as an italic delimiter pair, destroying the span and
// losing the textColor mark entirely.
let adf_json = r##"{
"version": 1,
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{"type":"text","text":"_ "},
{"type":"text","text":"_Action:*","marks":[
{"type":"textColor","attrs":{"color":"#008000"}}
]},
{"type":"text","text":" Complete the setup process."}
]
}
]
}"##;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
// The leading `_` chars must be backslash-escaped so the parser
// doesn't form a false italic pair across the span boundary.
assert!(
md.contains(r"\_ ") && md.contains(r":span[\_Action"),
"underscores at node boundaries should be escaped: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let para_content = rt.content[0].content.as_ref().unwrap();
// Find the textColor-marked node.
let colored = para_content
.iter()
.find(|n| {
n.marks
.as_deref()
.is_some_and(|ms| ms.iter().any(|m| m.mark_type == "textColor"))
})
.expect("textColor node must be preserved");
assert_eq!(colored.text.as_deref(), Some("_Action:*"));
let color_mark = colored
.marks
.as_ref()
.unwrap()
.iter()
.find(|m| m.mark_type == "textColor")
.unwrap();
assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
// Verify no spurious em mark crept in.
for n in para_content {
if let Some(ms) = n.marks.as_deref() {
assert!(
!ms.iter().any(|m| m.mark_type == "em"),
"no em mark should appear, got marks {:?}",
ms.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
);
}
}
}
#[test]
fn issue_554_underscore_intraword_left_unescaped() {
// Sanity check: ordinary intraword underscores like `do_something_useful`
// should NOT be escaped — escaping would still round-trip correctly,
// but produces noisy backslashes in the JFM output.
let doc = AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode::paragraph(vec![AdfNode::text(
"call do_something_useful now",
)])],
};
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.contains("do_something_useful") && !md.contains(r"do\_something\_useful"),
"intraword underscores should not be escaped: {md}"
);
}
#[test]
fn issue_554_code_underline_then_textcolor_bracketed_outer() {
// Mark order [underline, textColor, code] — bracketed-span outer,
// span inner. Exercises wrap_with_attrs (true, true) !span_before.
let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"x","marks":[
{"type":"underline"},
{"type":"textColor","attrs":{"color":"#008000"}},
{"type":"code"}
]}
]}]}"##;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
// Bracketed-span should be the outermost wrapper.
assert!(
md.starts_with('[') && md.contains("underline}"),
"bracketed-span should wrap the span, got: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let node = &rt.content[0].content.as_ref().unwrap()[0];
let mark_types: Vec<&str> = node
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(mark_types, vec!["underline", "textColor", "code"]);
}
#[test]
fn issue_554_textcolor_underline_link_all_preserved() {
// Mark order [textColor, underline, link] — span outer, bracketed
// wraps the link inside. Exercises the span-wraps-link-with-bracketed
// branch.
let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"linked","marks":[
{"type":"textColor","attrs":{"color":"#008000"}},
{"type":"underline"},
{"type":"link","attrs":{"href":"https://example.com"}}
]}
]}]}"##;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let node = &rt.content[0].content.as_ref().unwrap()[0];
let mark_types: Vec<&str> = node
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(mark_types, vec!["textColor", "underline", "link"]);
}
#[test]
fn issue_554_underline_textcolor_link_bracketed_outer_link_last() {
// Mark order [underline, textColor, link] — bracketed-span outer of
// both span and link. Exercises the bracketed-wraps-everything branch.
let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"linked","marks":[
{"type":"underline"},
{"type":"textColor","attrs":{"color":"#008000"}},
{"type":"link","attrs":{"href":"https://example.com"}}
]}
]}]}"##;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
let node = &rt.content[0].content.as_ref().unwrap()[0];
let mark_types: Vec<&str> = node
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(mark_types, vec!["underline", "textColor", "link"]);
}
#[test]
fn issue_554_link_underline_textcolor_link_outer() {
// Mark order [link, underline, textColor] — link outermost, wraps a
// bracketed-span that wraps the span. Exercises the link-wraps-
// bracketed-wraps-span branch.
let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"linked","marks":[
{"type":"link","attrs":{"href":"https://example.com"}},
{"type":"underline"},
{"type":"textColor","attrs":{"color":"#008000"}}
]}
]}]}"##;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
assert!(
md.starts_with('[') && md.contains("](https://example.com)"),
"link should be outermost, got: {md}"
);
let rt = markdown_to_adf(&md).unwrap();
let node = &rt.content[0].content.as_ref().unwrap()[0];
let mark_types: Vec<&str> = node
.marks
.as_ref()
.unwrap()
.iter()
.map(|m| m.mark_type.as_str())
.collect();
assert_eq!(mark_types, vec!["link", "underline", "textColor"]);
}
#[test]
fn issue_554_trailing_underscore_then_leading_underscore_round_trip() {
// Two adjacent text nodes where the first ends with `_` and the
// second starts with `_` — without escaping, the JFM parser sees
// an `_..._` pair spanning the boundary.
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"end_"},
{"type":"text","text":"_start"}
]}]}"#;
let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
let md = adf_to_markdown(&doc).unwrap();
let rt = markdown_to_adf(&md).unwrap();
// Reassemble all text in the paragraph.
let combined: String = rt.content[0]
.content
.as_ref()
.unwrap()
.iter()
.filter_map(|n| n.text.as_deref())
.collect();
assert_eq!(combined, "end__start");
// No node should have an em mark.
for n in rt.content[0].content.as_ref().unwrap() {
if let Some(ms) = n.marks.as_deref() {
assert!(!ms.iter().any(|m| m.mark_type == "em"));
}
}
}
}