Skip to main content

snapper_fmt/parser/pandoc/
cli.rs

1//! Subprocess pandoc backend (`pandoc -t json`).
2//!
3//! Still uses [`super::ast::regions_from_pandoc_json`] for classification so
4//! structure truth is the AST, not a second heuristic pass. Failures are
5//! explicit errors (no silent all-prose fallback).
6
7use std::io::Write;
8use std::process::{Command, Stdio};
9
10use thiserror::Error;
11
12use super::ast::regions_from_pandoc_json;
13use crate::parser::Region;
14
15#[derive(Debug, Error)]
16pub enum CliError {
17    #[error("pandoc CLI unavailable or failed to start: {0}")]
18    Spawn(String),
19    #[error("pandoc CLI exited with failure: {0}")]
20    Exit(String),
21    #[error("pandoc CLI returned invalid AST: {0}")]
22    InvalidAst(String),
23}
24
25/// Check if a `pandoc` executable is on PATH.
26pub fn pandoc_cli_available() -> bool {
27    Command::new("pandoc")
28        .arg("--version")
29        .stdout(Stdio::null())
30        .stderr(Stdio::null())
31        .status()
32        .is_ok_and(|s| s.success())
33}
34
35/// Run `pandoc -f <format> -t json` and classify the AST into regions.
36pub fn parse_via_cli(input: &str, format: &str) -> Result<Vec<Region>, CliError> {
37    let (regions, _) = parse_via_cli_with_json(input, format)?;
38    Ok(regions)
39}
40
41/// Like [`parse_via_cli`], also returns JSON for the content-addressed cache.
42pub fn parse_via_cli_with_json(
43    input: &str,
44    format: &str,
45) -> Result<(Vec<Region>, String), CliError> {
46    let mut child = Command::new("pandoc")
47        .args(["-f", format, "-t", "json"])
48        .stdin(Stdio::piped())
49        .stdout(Stdio::piped())
50        .stderr(Stdio::piped())
51        .spawn()
52        .map_err(|e| CliError::Spawn(e.to_string()))?;
53
54    if let Some(ref mut stdin) = child.stdin {
55        stdin
56            .write_all(input.as_bytes())
57            .map_err(|e| CliError::Spawn(format!("write stdin: {e}")))?;
58    }
59
60    let output = child
61        .wait_with_output()
62        .map_err(|e| CliError::Spawn(e.to_string()))?;
63
64    if !output.status.success() {
65        let stderr = String::from_utf8_lossy(&output.stderr);
66        return Err(CliError::Exit(format!(
67            "status {}: {stderr}",
68            output.status
69        )));
70    }
71
72    let json = String::from_utf8(output.stdout)
73        .map_err(|e| CliError::InvalidAst(format!("stdout not UTF-8: {e}")))?;
74    let regions = regions_from_pandoc_json(&json).map_err(CliError::InvalidAst)?;
75    Ok((regions, json))
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn cli_availability_check_does_not_panic() {
84        let _ = pandoc_cli_available();
85    }
86
87    #[test]
88    fn cli_unknown_format_is_explicit_error() {
89        if !pandoc_cli_available() {
90            return;
91        }
92        let err = parse_via_cli("Hello.", "not-a-real-pandoc-format-xyz").unwrap_err();
93        match err {
94            CliError::Exit(_) | CliError::Spawn(_) | CliError::InvalidAst(_) => {}
95        }
96    }
97}