use h2md::convert;
fn h(input: &str) -> String {
let mut out = Vec::new();
convert(input.as_bytes(), &mut out).expect("conversion failed");
String::from_utf8(out)
.expect("output was not valid UTF-8")
.trim()
.to_owned()
}
fn h_raw(input: &str) -> String {
let mut out = Vec::new();
convert(input.as_bytes(), &mut out).expect("conversion failed");
String::from_utf8(out).expect("output was not valid UTF-8")
}
#[test]
fn heading_levels() {
for level in 1..=6 {
let tag = format!("h{level}");
let html = format!("<{tag}>Hello</{tag}>");
let hashes = "#".repeat(level);
assert_eq!(h(&html), format!("{hashes} Hello"));
}
}
#[test]
fn heading_with_inline_markup() {
assert_eq!(h("<h2>Bold <strong>move</strong></h2>"), "## Bold **move**");
}
#[test]
fn plain_paragraph() {
assert_eq!(h("<p>Hello world</p>"), "Hello world");
}
#[test]
fn consecutive_paragraphs() {
assert_eq!(h("<p>First</p><p>Second</p>"), "First\n\nSecond");
}
#[test]
fn paragraph_with_inline() {
assert_eq!(
h("<p>This is <strong>bold</strong> and <em>italic</em>.</p>"),
"This is **bold** and *italic*."
);
}
#[test]
fn bold_strong() {
assert_eq!(h("<strong>x</strong>"), "**x**");
assert_eq!(h("<b>x</b>"), "**x**");
}
#[test]
fn italic_em() {
assert_eq!(h("<em>x</em>"), "*x*");
assert_eq!(h("<i>x</i>"), "*x*");
}
#[test]
fn strikethrough() {
assert_eq!(h("<del>x</del>"), "~~x~~");
assert_eq!(h("<s>x</s>"), "~~x~~");
assert_eq!(h("<strike>x</strike>"), "~~x~~");
}
#[test]
fn link() {
assert_eq!(
h(r#"<a href="https://example.com">click</a>"#),
"[click](https://example.com)"
);
}
#[test]
fn link_without_href() {
assert_eq!(h("<a>click</a>"), "[click]()");
}
#[test]
fn image() {
assert_eq!(
h(r#"<img src="a.png" alt="Alt text">"#),
""
);
}
#[test]
fn image_without_alt() {
assert_eq!(h(r#"<img src="a.png">"#), "");
}
#[test]
fn inline_code() {
assert_eq!(h("<code>let x = 1;</code>"), "`let x = 1;`");
}
#[test]
fn inline_code_with_backticks() {
assert_eq!(h("<code>a`b</code>"), "``a`b``");
}
#[test]
fn unordered_list() {
assert_eq!(
h("<ul><li>one</li><li>two</li><li>three</li></ul>"),
"- one\n- two\n- three"
);
}
#[test]
fn ordered_list() {
assert_eq!(
h("<ol><li>first</li><li>second</li><li>third</li></ol>"),
"1. first\n2. second\n3. third"
);
}
#[test]
fn nested_list() {
let html = "<ul>\
<li>A\
<ul><li>A1</li><li>A2</li></ul>\
</li>\
<li>B</li>\
</ul>";
let md = h(html);
assert!(md.contains("- A\n"), "outer item: {md:?}");
assert!(md.contains(" - A1\n"), "nested item A1: {md:?}");
assert!(md.contains(" - A2"), "nested item A2: {md:?}");
assert!(md.contains("- B"), "outer item B: {md:?}");
}
#[test]
fn nested_ordered_in_unordered() {
let html = "<ul>\
<li>Items\
<ol><li>one</li><li>two</li></ol>\
</li>\
</ul>";
let md = h(html);
assert!(md.contains("- Items\n"), "outer item: {md:?}");
assert!(md.contains(" 1. one\n"), "nested item one: {md:?}");
assert!(md.contains(" 2. two"), "nested item two: {md:?}");
}
#[test]
fn blockquote_single_line() {
assert_eq!(h("<blockquote>Hello</blockquote>"), "> Hello");
}
#[test]
fn blockquote_multiline() {
assert_eq!(
h("<blockquote><p>Line one</p><p>Line two</p></blockquote>"),
"> Line one\n> \n> Line two"
);
}
#[test]
fn blockquote_with_inline() {
assert_eq!(
h("<blockquote><strong>Bold quote</strong></blockquote>"),
"> **Bold quote**"
);
}
#[test]
fn pre_block() {
assert_eq!(h("<pre>fn main() {}\n</pre>"), "```\nfn main() {}\n```");
}
#[test]
fn pre_with_language() {
assert_eq!(
h("<pre><code class=\"language-rust\">let x = 1;</code></pre>"),
"```rust\nlet x = 1;\n```"
);
}
#[test]
fn pre_preserves_whitespace() {
assert_eq!(
h("<pre> indented\n more\n</pre>"),
"```\n indented\n more\n```"
);
}
#[test]
fn table_with_headers() {
let md = h("<table>\
<tr><th>Name</th><th>Age</th></tr>\
<tr><td>Alice</td><td>30</td></tr>\
<tr><td>Bob</td><td>25</td></tr>\
</table>");
assert!(md.contains("| Name | Age |"), "header row: {md:?}");
assert!(md.contains("| ----- | --- |"), "separator: {md:?}");
assert!(md.contains("| Alice | 30 |"), "data row 1: {md:?}");
assert!(md.contains("| Bob | 25 |"), "data row 2: {md:?}");
}
#[test]
fn table_without_explicit_headers() {
let md = h("<table>\
<tr><td>A</td><td>B</td></tr>\
<tr><td>C</td><td>D</td></tr>\
</table>");
assert!(md.contains("| A"), "row with A: {md:?}");
assert!(md.contains("| B"), "row with B: {md:?}");
assert!(md.contains("| C"), "row with C: {md:?}");
assert!(md.contains("| D"), "row with D: {md:?}");
assert!(md.contains("| ---"), "separator: {md:?}");
}
#[test]
fn table_with_thead() {
let md = h("<table>\
<thead><tr><th>X</th></tr></thead>\
<tbody><tr><td>1</td></tr></tbody>\
</table>");
assert!(md.contains("| X"), "header: {md:?}");
assert!(md.contains("| ---"), "separator: {md:?}");
assert!(md.contains("| 1"), "data: {md:?}");
}
#[test]
fn table_with_ragged_rows() {
let md = h("<table>\
<tr><td>A</td><td>B</td><td>C</td></tr>\
<tr><td>D</td></tr>\
</table>");
assert!(md.contains("| A"), "row 1 col 1: {md:?}");
assert!(md.contains("| D"), "row 2 col 1: {md:?}");
}
#[test]
fn horizontal_rule() {
let md = h("<p>above</p><hr><p>below</p>");
assert!(md.contains("above"), "above text: {md:?}");
assert!(md.contains("---"), "horizontal rule: {md:?}");
assert!(md.contains("below"), "below text: {md:?}");
}
#[test]
fn br_tag() {
let md = h("<p>line one<br>line two</p>");
assert!(
md.contains("line one \n"),
"br as two trailing spaces: {md:?}"
);
assert!(md.contains("line two"), "second line: {md:?}");
}
#[test]
fn br_inside_pre() {
let md = h("<pre>a<br>b</pre>");
assert!(md.contains("a\nb"), "br as newline in pre: {md:?}");
}
#[test]
fn collapses_whitespace() {
assert_eq!(h("<p>Hello world</p>"), "Hello world");
}
#[test]
fn trims_leading_whitespace() {
assert_eq!(h("<p> Hello</p>"), "Hello");
}
#[test]
fn ignores_multiple_newlines_in_text() {
assert_eq!(h("<p>Hello\n\n\nworld</p>"), "Hello world");
}
#[test]
fn div_as_block() {
let md = h("<p>before</p><div><p>inside</p></div><p>after</p>");
assert!(md.contains("before"), "before: {md:?}");
assert!(md.contains("inside"), "inside: {md:?}");
assert!(md.contains("after"), "after: {md:?}");
let before_pos = md.find("before").expect("before");
let inside_pos = md.find("inside").expect("inside");
let after_pos = md.find("after").expect("after");
assert!(before_pos < inside_pos, "before comes before inside");
assert!(inside_pos < after_pos, "inside comes before after");
}
#[test]
fn section_as_block() {
assert_eq!(h("<section><p>content</p></section>"), "content");
}
#[test]
fn script_stripped() {
assert_eq!(h("<p>visible</p><script>alert('xss')</script>"), "visible");
}
#[test]
fn style_stripped() {
assert_eq!(h("<p>visible</p><style>body{}</style>"), "visible");
}
#[test]
fn head_stripped() {
assert_eq!(
h("<html><head><title>x</title></head><body><p>visible</p></body></html>"),
"visible"
);
}
#[test]
fn html_comment_stripped() {
assert_eq!(h("<p>visible</p><!-- a comment -->"), "visible");
}
#[test]
fn unknown_tag_children_rendered() {
assert_eq!(h("<foo><p>Hello</p></foo>"), "Hello");
}
#[test]
fn full_document() {
let html = concat!(
"<h1>Title</h1>",
"<p>A <strong>bold</strong> paragraph.</p>",
"<ul><li>one</li><li>two</li></ul>",
"<blockquote>Nice quote</blockquote>",
);
let md = h(html);
assert!(md.contains("# Title"), "heading: {md:?}");
assert!(md.contains("A **bold** paragraph."), "paragraph: {md:?}");
assert!(md.contains("- one\n- two"), "list items: {md:?}");
assert!(md.contains("> Nice quote"), "blockquote: {md:?}");
}
#[test]
fn nested_inline_elements() {
assert_eq!(
h("<p><strong><em>bold italic</em></strong></p>"),
"***bold italic***"
);
}
#[test]
fn link_with_bold_text() {
assert_eq!(
h(r#"<a href="https://example.com"><strong>link</strong></a>"#),
"[**link**](https://example.com)"
);
}
#[test]
fn image_in_paragraph() {
assert_eq!(
h(r#"<p><img src="photo.jpg" alt="A photo"></p>"#),
""
);
}
#[test]
fn multiple_paragraphs_with_mixed_content() {
let html = concat!(
"<h2>Section</h2>",
"<p>First paragraph.</p>",
"<p>Second with <a href=\"/url\">a link</a>.</p>",
"<hr>",
"<p>After rule.</p>",
);
let md = h(html);
assert!(md.contains("## Section"), "heading: {md:?}");
assert!(md.contains("First paragraph."), "first para: {md:?}");
assert!(md.contains("[a link](/url)"), "link: {md:?}");
assert!(md.contains("---"), "hr: {md:?}");
assert!(md.contains("After rule."), "last para: {md:?}");
}
#[test]
fn block_output_has_blank_line_prefix_and_suffix() {
let raw = h_raw("<p>Hello</p>");
assert!(
raw.starts_with("\n\n"),
"block output starts with blank line: {raw:?}"
);
assert!(
raw.ends_with("\n\n"),
"block output ends with blank line: {raw:?}"
);
}
#[test]
fn inline_output_has_trailing_newline_only() {
let raw = h_raw("<strong>x</strong>");
assert_eq!(raw, "**x**\n");
}
#[test]
fn finalize_appends_newline_when_missing() {
let raw = h_raw("<strong>x</strong>");
assert!(
raw.ends_with('\n'),
"finalize ensures trailing newline: {raw:?}"
);
}
#[test]
fn nested_blockquote() {
let md = h("<blockquote><blockquote>inner</blockquote></blockquote>");
assert!(md.contains("> > inner"), "nested blockquote: {md:?}");
}
#[test]
fn pre_inside_blockquote() {
let md = h("<blockquote><pre>code\n</pre></blockquote>");
assert!(
md.contains("> ```"),
"opening fence inside blockquote: {md:?}"
);
assert!(
md.contains("> code"),
"code content inside blockquote: {md:?}"
);
assert!(
md.contains("> ```"),
"closing fence inside blockquote: {md:?}"
);
}
#[test]
fn blockquote_with_pre_and_text() {
let md = h("<blockquote><p>before</p><pre>code\n</pre><p>after</p></blockquote>");
assert!(md.contains("> before"), "text before pre: {md:?}");
assert!(md.contains("> code"), "code content: {md:?}");
assert!(md.contains("> after"), "text after pre: {md:?}");
}
#[test]
fn th_outside_thead() {
let md = h("<table>\
<tr><th>Name</th><th>Age</th></tr>\
<tr><td>Alice</td><td>30</td></tr>\
</table>");
assert!(md.contains("| Name"), "header cell Name: {md:?}");
assert!(md.contains("| ---"), "separator row: {md:?}");
assert!(md.contains("| Alice"), "data cell: {md:?}");
}
#[test]
fn inline_with_marker_chars() {
let md = h("<strong>a ** b</strong>");
assert!(
md.contains("__a ** b__"),
"uses alternative delimiter: {md:?}"
);
}
#[test]
fn link_with_parentheses() {
let md = h(r#"<a href="http://example.com/(foo)">click</a>"#);
assert!(
md.contains("<http://example.com/(foo)>"),
"URL wrapped in angle brackets: {md:?}"
);
}
#[test]
fn no_excessive_blank_lines_with_empty_paragraphs() {
let raw = h_raw("<p></p><p>Hello</p><p></p>");
assert!(
!raw.contains("\n\n\n"),
"excessive blank lines in raw output: {raw:?}"
);
}
#[test]
fn no_excessive_blank_lines_with_empty_heading() {
let raw = h_raw("<h1></h1><p>Hello</p>");
assert!(
!raw.contains("\n\n\n"),
"excessive blank lines in raw output: {raw:?}"
);
}
#[test]
fn no_excessive_blank_lines_with_empty_blockquote() {
let raw = h_raw("<blockquote></blockquote><p>Hello</p>");
assert!(
!raw.contains("\n\n\n"),
"excessive blank lines in raw output: {raw:?}"
);
}
#[test]
fn no_excessive_blank_lines_with_multiple_empty_blocks() {
let raw = h_raw("<h1></h1><p></p><div></div><p>Hello</p>");
assert!(
!raw.contains("\n\n\n"),
"excessive blank lines in raw output: {raw:?}"
);
}
#[test]
fn code_with_double_backticks() {
let md = h("<code>``</code>");
assert!(
md.contains("```") || md.contains("````"),
"should use 3+ backtick delimiters for content with 2 backticks: {md:?}"
);
}
#[test]
fn code_with_backticks_in_middle() {
let md = h("<code>a``b</code>");
assert!(
md.contains("```") || md.contains("````"),
"should use 3+ backtick delimiters when content has 2 consecutive backticks: {md:?}"
);
}
#[test]
fn code_with_leading_trailing_spaces() {
let md = h("<code> hello </code>");
assert!(
md.starts_with("``") && !md.starts_with("````"),
"should use double backtick delimiters for content with leading/trailing spaces: {md:?}"
);
assert!(
md.contains("`` hello ``"),
"should have padding spaces around content: {md:?}"
);
}
#[test]
fn code_with_only_spaces() {
let md = h("<code> </code>");
assert_eq!(md, "`` ``");
}
#[test]
fn ol_start_attribute() {
let md = h("<ol start=\"5\"><li>fifth</li><li>sixth</li></ol>");
assert!(
md.contains("5. fifth"),
"should respect start attribute: {md:?}"
);
assert!(
md.contains("6. sixth"),
"should increment from start value: {md:?}"
);
}
#[test]
fn link_with_space_in_url() {
let md = h(r#"<a href="http://example.com/path with spaces">click</a>"#);
assert!(
md.contains("<http://example.com/path with spaces>"),
"URL with spaces should be wrapped in angle brackets: {md:?}"
);
}
#[test]
fn image_with_space_in_src() {
let md = h(r#"<img src="http://example.com/img photo.png" alt="photo">"#);
assert!(
md.contains("<http://example.com/img photo.png>"),
"image src with spaces should be wrapped in angle brackets: {md:?}"
);
}
#[test]
fn deeply_nested_html_returns_error() {
let mut html = String::from("<ul><li>");
for _ in 0..250 {
html.push_str("<ul><li>");
}
html.push_str("content");
for _ in 0..250 {
html.push_str("</li></ul>");
}
html.push_str("</li></ul>");
let mut out = Vec::new();
let result = h2md::convert(html.as_bytes(), &mut out);
assert!(result.is_err(), "deeply nested HTML should return error");
if let Err(e) = result {
assert!(
e.to_string().contains("exceeds maximum depth"),
"error should mention depth: {e}"
);
}
}