kb/markdown.rs
1//! Markdown ingestion: convert GitHub-flavored markdown to the kb AST.
2//!
3//! kb's canonical document model is org-mode (see [`tftio_org::ast`]). Rather
4//! than maintain a second parser, markdown is converted to org text by an
5//! external `pandoc` process and then parsed by [`crate::parser`]. The
6//! dependency on `pandoc` is intentional and documented: a missing binary
7//! yields a clear error rather than a panic, and the conversion is
8//! ingest-only (kb never renders markdown back out).
9
10use std::io::Write;
11use std::process::{Command, Stdio};
12
13use thiserror::Error;
14
15use crate::parser::{self, ParseError};
16use tftio_org::ast::Document;
17
18/// Failure modes of markdown ingestion via `pandoc`.
19///
20/// A caller can branch [`MarkdownError::PandocNotFound`] (a setup /
21/// environment problem the operator must fix) against the input-shaped
22/// failures ([`MarkdownError::PandocFailed`], [`MarkdownError::Parse`],
23/// …) that are attributable to the supplied body.
24#[derive(Debug, Error)]
25pub enum MarkdownError {
26 /// `pandoc` is not on `PATH`. An environment/setup error, not a
27 /// problem with the input body.
28 #[error("pandoc not found on PATH; markdown import requires pandoc")]
29 PandocNotFound,
30
31 /// Spawning the `pandoc` child process failed for a reason other
32 /// than the binary being absent.
33 #[error("failed to spawn pandoc: {0}")]
34 Spawn(std::io::Error),
35
36 /// Waiting on the `pandoc` child process failed.
37 #[error("failed to wait for pandoc: {0}")]
38 Wait(std::io::Error),
39
40 /// `pandoc` exited non-zero. Carries the trimmed stderr for display.
41 #[error("pandoc exited with failure: {stderr}")]
42 PandocFailed {
43 /// Trimmed stderr captured from the failed `pandoc` invocation.
44 stderr: String,
45 },
46
47 /// `pandoc` produced output that was not valid UTF-8.
48 #[error("pandoc produced non-UTF-8 output: {0}")]
49 NonUtf8Output(#[from] std::string::FromUtf8Error),
50
51 /// The org text `pandoc` produced did not parse as a kb document.
52 #[error("org parse failure after pandoc conversion: {0}")]
53 Parse(#[from] ParseError),
54}
55
56/// Convert a GitHub-flavored markdown string into a kb [`Document`].
57///
58/// Shells out to `pandoc -f gfm -t org --wrap=preserve`, then parses the
59/// resulting org text with [`parser::parse_document`].
60///
61/// # Errors
62///
63/// Returns [`MarkdownError`] when `pandoc` is not on `PATH`, exits
64/// non-zero, emits non-UTF-8 output, or produces org text the kb parser
65/// rejects.
66pub fn markdown_to_document(raw: &str) -> Result<Document, MarkdownError> {
67 let org = markdown_to_org(raw)?;
68 Ok(parser::parse_document(&org)?)
69}
70
71/// Convert GitHub-flavored markdown to org text via `pandoc`.
72///
73/// stdin is written from a separate thread so a large conversion cannot
74/// deadlock against a full stdout pipe.
75fn markdown_to_org(raw: &str) -> Result<String, MarkdownError> {
76 let mut child = Command::new("pandoc")
77 .args(["-f", "gfm", "-t", "org", "--wrap=preserve"])
78 .stdin(Stdio::piped())
79 .stdout(Stdio::piped())
80 .stderr(Stdio::piped())
81 .spawn()
82 .map_err(|e| match e.kind() {
83 std::io::ErrorKind::NotFound => MarkdownError::PandocNotFound,
84 _ => MarkdownError::Spawn(e),
85 })?;
86
87 // `Stdio::piped()` above guarantees a stdin handle; take it without a
88 // fallible branch. If it were somehow absent the writer thread simply
89 // would not run and pandoc would see empty input — surfaced as a
90 // PandocFailed/Parse error downstream rather than a panic.
91 let writer = child.stdin.take().map(|mut stdin| {
92 let raw_owned = raw.to_string();
93 std::thread::spawn(move || {
94 // Drop closes the pipe; a write error surfaces as pandoc failure.
95 if let Err(e) = stdin.write_all(raw_owned.as_bytes()) {
96 tracing::warn!("failed to write stdin to pandoc: {e}");
97 }
98 })
99 });
100
101 let output = child.wait_with_output().map_err(MarkdownError::Wait)?;
102 if let Some(writer) = writer {
103 let _ = writer.join();
104 }
105
106 if !output.status.success() {
107 let stderr = String::from_utf8_lossy(&output.stderr);
108 return Err(MarkdownError::PandocFailed {
109 stderr: stderr.trim().to_string(),
110 });
111 }
112
113 Ok(String::from_utf8(output.stdout)?)
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119 use tftio_org::ast::Block;
120
121 /// Skip the test body when `pandoc` is not installed.
122 fn pandoc_available() -> bool {
123 Command::new("pandoc")
124 .arg("--version")
125 .stdout(Stdio::null())
126 .stderr(Stdio::null())
127 .status()
128 .is_ok_and(|s| s.success())
129 }
130
131 #[test]
132 fn markdown_heading_and_paragraph_convert() {
133 if !pandoc_available() {
134 eprintln!("skipping: pandoc not on PATH");
135 return;
136 }
137 let doc = markdown_to_document("# Title\n\nbody text\n").unwrap();
138 assert!(
139 doc.blocks
140 .iter()
141 .any(|b| matches!(b, Block::Heading { .. })),
142 "expected a heading block, got {:?}",
143 doc.blocks
144 );
145 }
146
147 #[test]
148 fn markdown_strikethrough_round_trips_to_inline() {
149 if !pandoc_available() {
150 eprintln!("skipping: pandoc not on PATH");
151 return;
152 }
153 // gfm ~~x~~ -> org +x+ -> Inline::Strikethrough
154 let doc = markdown_to_document("~~struck~~\n").unwrap();
155 let rendered = crate::generator::generate(&doc);
156 assert!(
157 rendered.contains("+struck+"),
158 "expected strikethrough markup, got: {rendered}"
159 );
160 }
161}