Skip to main content

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}