1use anyhow::{bail, Context, Result};
4use std::path::Path;
5use std::process::Command;
6
7pub fn pandoc_version_line() -> Option<String> {
9 let out = Command::new("pandoc").arg("--version").output().ok()?;
10 if !out.status.success() && out.stdout.is_empty() {
11 return None;
12 }
13 String::from_utf8_lossy(&out.stdout)
14 .lines()
15 .next()
16 .map(|s| s.trim().to_string())
17 .filter(|s| !s.is_empty())
18}
19
20pub fn tool_available() -> bool {
22 Command::new("pandoc")
23 .arg("--version")
24 .output()
25 .map(|o| o.status.success())
26 .unwrap_or(false)
27}
28
29fn run_pandoc(from: &str, to: &str, input: &str) -> Result<String> {
30 if !tool_available() {
31 bail!(
32 "pandoc is not installed or not on PATH; install pandoc to convert between markdown and jira markup"
33 );
34 }
35 let mut child = Command::new("pandoc")
36 .args(["-f", from, "-t", to])
37 .stdin(std::process::Stdio::piped())
38 .stdout(std::process::Stdio::piped())
39 .stderr(std::process::Stdio::piped())
40 .spawn()
41 .context("spawn pandoc")?;
42
43 use std::io::Write;
44 {
45 let mut stdin = child.stdin.take().context("open pandoc stdin")?;
46 stdin
47 .write_all(input.as_bytes())
48 .context("write input to pandoc")?;
49 }
50
51 let output = child.wait_with_output().context("wait for pandoc")?;
52 if !output.status.success() {
53 let err = String::from_utf8_lossy(&output.stderr);
54 bail!("pandoc failed (status {}): {err}", output.status);
55 }
56 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
57}
58
59pub fn convert_markdown_to_jira(markdown: &str) -> Result<String> {
61 run_pandoc("markdown", "jira", markdown)
62}
63
64pub fn convert_jira_to_markdown(jira: &str) -> Result<String> {
67 run_pandoc("jira", "markdown", jira)
68}
69
70pub fn convert_path_to_jira(path: &Path) -> Result<String> {
72 let md = std::fs::read_to_string(path)
73 .with_context(|| format!("read {}", path.display()))?;
74 convert_markdown_to_jira(&md)
75}
76
77pub fn pandoc_jira_args() -> Vec<&'static str> {
79 vec!["-f", "markdown", "-t", "jira"]
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85
86 #[test]
87 fn pandoc_args_target_jira_writer() {
88 let args = pandoc_jira_args();
89 assert!(args.contains(&"markdown"));
90 assert!(args.contains(&"jira"));
91 assert_eq!(args, vec!["-f", "markdown", "-t", "jira"]);
92 }
93
94 #[test]
95 fn convert_sample_when_pandoc_present() {
96 if !tool_available() {
97 let err = convert_markdown_to_jira("# Hi").unwrap_err().to_string();
99 assert!(err.to_lowercase().contains("pandoc"));
100 return;
101 }
102 let jira = convert_markdown_to_jira(
103 "# Password reset\n\nUse **self-service** portal:\n\n- step one\n- step two\n",
104 )
105 .unwrap();
106 assert!(
108 jira.contains("h1.") || jira.contains("Password"),
109 "expected jira heading or title, got: {jira:?}"
110 );
111 assert!(
112 jira.contains("*self-service*") || jira.contains("self-service"),
113 "expected bold or body text, got: {jira:?}"
114 );
115 }
116
117 #[test]
118 fn convert_jira_to_markdown_reverses_the_writer() {
119 if !tool_available() {
120 let err = convert_jira_to_markdown("h1. Hi").unwrap_err().to_string();
121 assert!(err.to_lowercase().contains("pandoc"));
122 return;
123 }
124 let markdown =
125 convert_jira_to_markdown("h1. Password reset\n\nUse *self-service* portal.\n")
126 .unwrap();
127 assert!(markdown.contains("# Password reset"), "{markdown:?}");
128 assert!(markdown.contains("**self-service**"), "{markdown:?}");
129 }
130}