返回 CodeWhale
pandoc.rs
根目录 / crates / tui / src / tools / pandoc.rs
1 //! `pandoc_convert` tool — universal document conversion via the
2 //! `pandoc` binary (<https://pandoc.org>).
3 //!
4 //! Pandoc is the de-facto Swiss Army knife for moving prose between
5 //! the formats writers and engineers actually use: Markdown to HTML,
6 //! HTML to Markdown, anything to LaTeX or DOCX, RST to Markdown,
7 //! ReST imports, etc. Surfacing it as a model-callable tool unblocks
8 //! a large class of "rewrite this report as ..." / "publish this
9 //! changelog as ..." workflows that previously required the user
10 //! to drop into a terminal between turns.
11 //!
12 //! Registration is gated by [`crate::dependencies::resolve_pandoc`]
13 //! (see [`crate::tools::registry::ToolRegistryBuilder::with_pandoc_tools`]).
14 //! When pandoc isn't installed the tool simply doesn't appear in the
15 //! catalog, so the model never sees a binary it can't actually use.
16 //!
17 //! ## Format whitelist
18 //!
19 //! Pandoc supports ~30 input and ~50 output formats, and exposing
20 //! every one of them as a free-text string would let the model
21 //! ask for `pdf` (which needs LaTeX installed), `epub3` (works
22 //! everywhere but ambiguous vs. `epub`), or typos like `markown`.
23 //! The whitelist below is the curated subset that a) covers ~95%
24 //! of real document-handling needs and b) doesn't require additional
25 //! system dependencies (LaTeX engines, ImageMagick) beyond pandoc
26 //! itself.
27 //!
28 //! Adding a format: append to [`SUPPORTED_TARGET_FORMATS`] and the
29 //! schema description; the dispatch logic is whitelist-driven so
30 //! anything in the list goes through unchanged.
31
32 use std::path::PathBuf;
33 use std::process::{Command, Stdio};
34
35 use async_trait::async_trait;
36 use serde_json::{Value, json};
37
38 use super::spec::{
39 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
40 optional_str, required_str,
41 };
42
43 /// Curated whitelist of pandoc target formats. Each entry corresponds
44 /// to a `--to=<format>` value pandoc accepts natively without
45 /// additional system tooling. Keep this list short and intentional —
46 /// the schema description below references it verbatim.
47 pub(crate) const SUPPORTED_TARGET_FORMATS: &[&str] = &[
48 "markdown", // Pandoc-flavored Markdown (the safe round-trip default)
49 "gfm", // GitHub-Flavored Markdown
50 "commonmark", // strict CommonMark
51 "html", // HTML5
52 "rst", // reStructuredText
53 "latex", // LaTeX source (does not require a TeX install to *generate*)
54 "docx", // Microsoft Word .docx
55 "odt", // OpenDocument Text
56 "epub", // EPUB 2/3
57 "plain", // plain text (formatting stripped)
58 "asciidoc", // AsciiDoc
59 ];
60
61 /// Tool implementing `pandoc_convert`. Converts a source file into
62 /// a target format and either writes the output to disk or returns
63 /// the converted text inline.
64 pub struct PandocConvertTool;
65
66 #[async_trait]
67 impl ToolSpec for PandocConvertTool {
68 fn name(&self) -> &'static str {
69 "pandoc_convert"
70 }
71
72 fn description(&self) -> &'static str {
73 "Convert a document between formats via pandoc. Reads `source_path` (any pandoc-supported input format — pandoc autodetects from extension), converts to `target_format`, and either writes the result to `output_path` (when provided) or returns the converted text inline. Supported targets: markdown, gfm, commonmark, html, rst, latex, docx, odt, epub, plain, asciidoc. Use this instead of shelling out to pandoc via `Bash` — no approval prompt for output_path-less reads, structured errors, and a curated format whitelist."
74 }
75
76 fn input_schema(&self) -> Value {
77 json!({
78 "type": "object",
79 "properties": {
80 "source_path": {
81 "type": "string",
82 "description": "Path to the source document (relative to workspace or absolute). Pandoc autodetects the input format from the file extension."
83 },
84 "target_format": {
85 "type": "string",
86 "description": "One of: markdown, gfm, commonmark, html, rst, latex, docx, odt, epub, plain, asciidoc.",
87 "enum": SUPPORTED_TARGET_FORMATS,
88 },
89 "output_path": {
90 "type": "string",
91 "description": "Optional path to write the converted document to. When omitted, the converted text is returned inline (text formats only — binary formats like docx/odt/epub require output_path)."
92 }
93 },
94 "required": ["source_path", "target_format"]
95 })
96 }
97
98 fn capabilities(&self) -> Vec<ToolCapability> {
99 vec![
100 ToolCapability::WritesFiles,
101 ToolCapability::Sandboxable,
102 ToolCapability::RequiresApproval,
103 ]
104 }
105
106 fn approval_requirement(&self) -> ApprovalRequirement {
107 ApprovalRequirement::Suggest
108 }
109
110 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
111 let source_path_str = required_str(&input, "source_path")?;
112 let target_format = required_str(&input, "target_format")?.trim().to_lowercase();
113 let output_path_str = optional_str(&input, "output_path")?;
114
115 if !SUPPORTED_TARGET_FORMATS.contains(&target_format.as_str()) {
116 return Err(ToolError::invalid_input(format!(
117 "unsupported target_format `{target_format}`. Pick one of: {}",
118 SUPPORTED_TARGET_FORMATS.join(", ")
119 )));
120 }
121
122 let source_path = context.resolve_path(source_path_str)?;
123 if !source_path.exists() {
124 return Err(ToolError::execution_failed(format!(
125 "source_path does not exist: {}",
126 source_path.display()
127 )));
128 }
129
130 let resolved_output_path: Option<PathBuf> = match output_path_str {
131 Some(p) => Some(context.resolve_path(p)?),
132 None => None,
133 };
134
135 // Binary formats can't round-trip through stdout reliably —
136 // require an output_path so the bytes survive the trip.
137 if resolved_output_path.is_none() && format_is_binary(&target_format) {
138 return Err(ToolError::invalid_input(format!(
139 "target_format `{target_format}` is binary; provide an `output_path` to write the converted file."
140 )));
141 }
142
143 // Resolve the pandoc binary at execution time too — registration
144 // gated on resolve_pandoc(), but a concurrent uninstall between
145 // catalog build and the model's call should produce a clear
146 // error rather than the cryptic "program not found" from raw
147 // Command::spawn.
148 let pandoc = crate::dependencies::resolve_pandoc().ok_or_else(|| {
149 ToolError::execution_failed(
150 "pandoc_convert: pandoc binary not found on PATH. \
151 Install pandoc (macOS: `brew install pandoc`; \
152 Debian/Ubuntu: `apt install pandoc`; \
153 Windows: `winget install JohnMacFarlane.Pandoc`) and restart codewhale.",
154 )
155 })?;
156
157 let mut cmd = Command::new(&pandoc);
158 cmd.arg(&source_path);
159 cmd.arg("--to").arg(&target_format);
160 if let Some(out) = resolved_output_path.as_ref() {
161 cmd.arg("--output").arg(out);
162 }
163 cmd.stdin(Stdio::null())
164 .stdout(Stdio::piped())
165 .stderr(Stdio::piped());
166
167 let output = cmd
168 .output()
169 .map_err(|e| ToolError::execution_failed(format!("failed to launch pandoc: {e}")))?;
170
171 if !output.status.success() {
172 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
173 return Err(ToolError::execution_failed(format!(
174 "pandoc failed (exit {:?}): {stderr}",
175 output.status.code()
176 )));
177 }
178
179 let summary = if let Some(out) = resolved_output_path {
180 format!(
181 "Converted {} → {} via pandoc; wrote {}",
182 source_path.display(),
183 target_format,
184 out.display()
185 )
186 } else {
187 let text = String::from_utf8_lossy(&output.stdout).to_string();
188 return Ok(ToolResult::success(text));
189 };
190 Ok(ToolResult::success(summary))
191 }
192 }
193
194 /// Whitelist of target formats whose output is binary (and therefore
195 /// can't be returned as inline text). `docx`, `odt`, and `epub` are
196 /// ZIP archives; everything else in [`SUPPORTED_TARGET_FORMATS`]
197 /// renders to UTF-8 text.
198 pub(crate) fn format_is_binary(target_format: &str) -> bool {
199 matches!(target_format, "docx" | "odt" | "epub")
200 }
201
202 #[cfg(test)]
203 mod tests {
204 use super::*;
205 use std::fs;
206 use tempfile::tempdir;
207
208 fn pandoc_present() -> bool {
209 crate::dependencies::resolve_pandoc().is_some()
210 }
211
212 fn pandoc_environment_unavailable(err: &ToolError) -> bool {
213 let msg = err.to_string();
214 msg.contains("getXdgDirectory") || msg.contains("sHGetFolderPath")
215 }
216
217 // Test-only skip diagnostic; the module-wide print_stderr deny targets prod code.
218 #[allow(clippy::print_stderr)]
219 async fn execute_pandoc_or_skip(input: Value, ctx: &ToolContext) -> Option<ToolResult> {
220 match PandocConvertTool.execute(input, ctx).await {
221 Ok(result) => Some(result),
222 Err(err) if pandoc_environment_unavailable(&err) => {
223 eprintln!("skipping pandoc integration assertion: {err}");
224 None
225 }
226 Err(err) => panic!("execute: {err:?}"),
227 }
228 }
229
230 #[test]
231 fn supported_target_formats_match_schema_enum() {
232 let tool = PandocConvertTool;
233 let schema = tool.input_schema();
234 let enum_vals = schema
235 .get("properties")
236 .and_then(|p| p.get("target_format"))
237 .and_then(|t| t.get("enum"))
238 .and_then(|e| e.as_array())
239 .expect("target_format enum must be present in schema");
240 let from_schema: Vec<&str> = enum_vals.iter().filter_map(|v| v.as_str()).collect();
241 assert_eq!(
242 from_schema, SUPPORTED_TARGET_FORMATS,
243 "schema enum must mirror the SUPPORTED_TARGET_FORMATS constant exactly",
244 );
245 }
246
247 #[test]
248 fn binary_formats_require_output_path() {
249 for fmt in ["docx", "odt", "epub"] {
250 assert!(format_is_binary(fmt));
251 }
252 for fmt in [
253 "markdown",
254 "html",
255 "rst",
256 "latex",
257 "plain",
258 "gfm",
259 "commonmark",
260 ] {
261 assert!(!format_is_binary(fmt));
262 }
263 }
264
265 #[tokio::test]
266 async fn pandoc_convert_rejects_unsupported_target_format() {
267 let tmp = tempdir().expect("tempdir");
268 let src = tmp.path().join("in.md");
269 fs::write(&src, "# hi").unwrap();
270 let ctx = ToolContext::new(tmp.path().to_path_buf());
271 let err = PandocConvertTool
272 .execute(
273 json!({"source_path": "in.md", "target_format": "definitely-not-real"}),
274 &ctx,
275 )
276 .await
277 .expect_err("unsupported target format must reject before pandoc spawn");
278 assert!(
279 err.to_string().contains("unsupported target_format"),
280 "error must call out the unsupported format; got {err}"
281 );
282 }
283
284 #[tokio::test]
285 async fn pandoc_convert_rejects_inline_request_for_binary_format() {
286 let tmp = tempdir().expect("tempdir");
287 let src = tmp.path().join("in.md");
288 fs::write(&src, "# hi").unwrap();
289 let ctx = ToolContext::new(tmp.path().to_path_buf());
290 let err = PandocConvertTool
291 .execute(
292 json!({"source_path": "in.md", "target_format": "docx"}),
293 &ctx,
294 )
295 .await
296 .expect_err("missing output_path for docx must reject");
297 assert!(
298 err.to_string().contains("binary") && err.to_string().contains("output_path"),
299 "error must explain why output_path is required; got {err}"
300 );
301 }
302
303 #[tokio::test]
304 async fn pandoc_convert_roundtrips_markdown_to_html_inline() {
305 if !pandoc_present() {
306 // Tool wouldn't be registered without pandoc; mirror the
307 // catalog-build behaviour.
308 return;
309 }
310 let tmp = tempdir().expect("tempdir");
311 let src = tmp.path().join("note.md");
312 fs::write(&src, "# Title\n\nA paragraph with `inline code`.\n").unwrap();
313 let ctx = ToolContext::new(tmp.path().to_path_buf());
314 let Some(result) = execute_pandoc_or_skip(
315 json!({"source_path": "note.md", "target_format": "html"}),
316 &ctx,
317 )
318 .await
319 else {
320 return;
321 };
322 assert!(result.success);
323 assert!(
324 result.content.contains("<h1") && result.content.contains("Title"),
325 "html output must contain the heading; got {}",
326 result.content
327 );
328 assert!(
329 result.content.contains("<code") || result.content.contains("inline code"),
330 "html output must preserve inline code; got {}",
331 result.content
332 );
333 }
334
335 #[tokio::test]
336 async fn pandoc_convert_writes_output_path_and_reports_summary() {
337 if !pandoc_present() {
338 return;
339 }
340 let tmp = tempdir().expect("tempdir");
341 let src = tmp.path().join("note.md");
342 fs::write(&src, "# Title\n").unwrap();
343 let ctx = ToolContext::new(tmp.path().to_path_buf());
344 let Some(result) = execute_pandoc_or_skip(
345 json!({
346 "source_path": "note.md",
347 "target_format": "html",
348 "output_path": "out.html",
349 }),
350 &ctx,
351 )
352 .await
353 else {
354 return;
355 };
356 assert!(result.success);
357 assert!(result.content.contains("wrote"));
358 let written = fs::read_to_string(tmp.path().join("out.html")).expect("read");
359 assert!(
360 written.contains("Title"),
361 "written file must contain converted body; got {written}"
362 );
363 }
364
365 #[tokio::test]
366 async fn pandoc_convert_surfaces_missing_source_path_clearly() {
367 let tmp = tempdir().expect("tempdir");
368 let ctx = ToolContext::new(tmp.path().to_path_buf());
369 let err = PandocConvertTool
370 .execute(
371 json!({"source_path": "missing.md", "target_format": "html"}),
372 &ctx,
373 )
374 .await
375 .expect_err("nonexistent source must reject");
376 assert!(
377 err.to_string().contains("source_path") && err.to_string().contains("does not exist"),
378 "error must call out missing source; got {err}"
379 );
380 }
381 }
382
382 lines RUST