use std::io::Write;
use std::process::{Command, Stdio};
use thiserror::Error;
use crate::ast::Document;
use crate::parser::{self, ParseError};
#[derive(Debug, Error)]
pub enum MarkdownError {
#[error("pandoc not found on PATH; markdown import requires pandoc")]
PandocNotFound,
#[error("failed to spawn pandoc: {0}")]
Spawn(std::io::Error),
#[error("failed to wait for pandoc: {0}")]
Wait(std::io::Error),
#[error("pandoc exited with failure: {stderr}")]
PandocFailed {
stderr: String,
},
#[error("pandoc produced non-UTF-8 output: {0}")]
NonUtf8Output(#[from] std::string::FromUtf8Error),
#[error("org parse failure after pandoc conversion: {0}")]
Parse(#[from] ParseError),
}
pub fn markdown_to_document(raw: &str) -> Result<Document, MarkdownError> {
let org = markdown_to_org(raw)?;
Ok(parser::parse_document(&org)?)
}
fn markdown_to_org(raw: &str) -> Result<String, MarkdownError> {
let mut child = Command::new("pandoc")
.args(["-f", "gfm", "-t", "org", "--wrap=preserve"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => MarkdownError::PandocNotFound,
_ => MarkdownError::Spawn(e),
})?;
let writer = child.stdin.take().map(|mut stdin| {
let raw_owned = raw.to_string();
std::thread::spawn(move || {
if let Err(e) = stdin.write_all(raw_owned.as_bytes()) {
tracing::warn!("failed to write stdin to pandoc: {e}");
}
})
});
let output = child.wait_with_output().map_err(MarkdownError::Wait)?;
if let Some(writer) = writer {
let _ = writer.join();
}
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(MarkdownError::PandocFailed {
stderr: stderr.trim().to_string(),
});
}
Ok(String::from_utf8(output.stdout)?)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::Block;
fn pandoc_available() -> bool {
Command::new("pandoc")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
#[test]
fn markdown_heading_and_paragraph_convert() {
if !pandoc_available() {
eprintln!("skipping: pandoc not on PATH");
return;
}
let doc = markdown_to_document("# Title\n\nbody text\n").unwrap();
assert!(
doc.blocks
.iter()
.any(|b| matches!(b, Block::Heading { .. })),
"expected a heading block, got {:?}",
doc.blocks
);
}
#[test]
fn markdown_strikethrough_round_trips_to_inline() {
if !pandoc_available() {
eprintln!("skipping: pandoc not on PATH");
return;
}
let doc = markdown_to_document("~~struck~~\n").unwrap();
let rendered = crate::generator::generate(&doc);
assert!(
rendered.contains("+struck+"),
"expected strikethrough markup, got: {rendered}"
);
}
}