返回 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::ffi::OsString;
33 use std::path::{Path, PathBuf};
34 use std::process::Stdio;
35 use std::time::Duration;
36
37 use async_trait::async_trait;
38 use serde_json::{Value, json};
39 use tokio::process::Command as TokioCommand;
40
41 use super::spec::{
42 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
43 optional_str, required_str,
44 };
45
46 /// Curated whitelist of pandoc target formats. Each entry corresponds
47 /// to a `--to=<format>` value pandoc accepts natively without
48 /// additional system tooling. Keep this list short and intentional —
49 /// the schema description below references it verbatim.
50 pub(crate) const SUPPORTED_TARGET_FORMATS: &[&str] = &[
51 "markdown", // Pandoc-flavored Markdown (the safe round-trip default)
52 "gfm", // GitHub-Flavored Markdown
53 "commonmark", // strict CommonMark
54 "html", // HTML5
55 "rst", // reStructuredText
56 "latex", // LaTeX source (does not require a TeX install to *generate*)
57 "docx", // Microsoft Word .docx
58 "odt", // OpenDocument Text
59 "epub", // EPUB 2/3
60 "plain", // plain text (formatting stripped)
61 "asciidoc", // AsciiDoc
62 ];
63
64 /// Wall-clock bound for one pandoc conversion: a pathological document
65 /// (giant epub, pathological LaTeX) would otherwise hold the call for as
66 /// long as pandoc felt like taking. Mirrors the 600s interpreter budget
67 /// used by js_execution.
68 const PANDOC_TIMEOUT: Duration = Duration::from_secs(600);
69
70 /// Bound for the one-shot `pandoc --version` probe. A probe that cannot
71 /// finish in 10s means the binary itself is wedged; like an unparseable
72 /// banner, the gate then lets the conversion through and pandoc reports
73 /// any flag it does not know itself.
74 const PANDOC_VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(10);
75
76 /// Tool implementing `pandoc_convert`. Converts a source file into
77 /// a target format and either writes the output to disk or returns
78 /// the converted text inline.
79 pub struct PandocConvertTool;
80
81 #[async_trait]
82 impl ToolSpec for PandocConvertTool {
83 fn name(&self) -> &'static str {
84 "pandoc_convert"
85 }
86
87 fn description(&self) -> &'static str {
88 "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."
89 }
90
91 fn input_schema(&self) -> Value {
92 json!({
93 "type": "object",
94 "properties": {
95 "source_path": {
96 "type": "string",
97 "description": "Path to the source document (relative to workspace or absolute). Pandoc autodetects the input format from the file extension."
98 },
99 "target_format": {
100 "type": "string",
101 "description": "One of: markdown, gfm, commonmark, html, rst, latex, docx, odt, epub, plain, asciidoc.",
102 "enum": SUPPORTED_TARGET_FORMATS,
103 },
104 "output_path": {
105 "type": "string",
106 "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)."
107 }
108 },
109 "required": ["source_path", "target_format"]
110 })
111 }
112
113 fn capabilities(&self) -> Vec<ToolCapability> {
114 vec![
115 ToolCapability::WritesFiles,
116 ToolCapability::Sandboxable,
117 ToolCapability::RequiresApproval,
118 ]
119 }
120
121 fn approval_requirement(&self) -> ApprovalRequirement {
122 ApprovalRequirement::Suggest
123 }
124
125 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
126 let source_path_str = required_str(&input, "source_path")?;
127 let target_format = required_str(&input, "target_format")?.trim().to_lowercase();
128 let output_path_str = optional_str(&input, "output_path")?;
129
130 if !SUPPORTED_TARGET_FORMATS.contains(&target_format.as_str()) {
131 return Err(ToolError::invalid_input(format!(
132 "unsupported target_format `{target_format}`. Pick one of: {}",
133 SUPPORTED_TARGET_FORMATS.join(", ")
134 )));
135 }
136
137 // pandoc reads the source in full and its output goes to the model,
138 // so it takes the same read guards as `read`.
139 let source_path = crate::tools::file::resolve_guarded_read_path(
140 context,
141 source_path_str,
142 "pandoc_convert",
143 )?;
144 if !source_path.exists() {
145 return Err(ToolError::execution_failed(format!(
146 "source_path does not exist: {}",
147 source_path.display()
148 )));
149 }
150
151 let resolved_output_path: Option<PathBuf> = match output_path_str {
152 Some(p) => Some(context.resolve_path(p)?),
153 None => None,
154 };
155
156 // Binary formats can't round-trip through stdout reliably —
157 // require an output_path so the bytes survive the trip.
158 if resolved_output_path.is_none() && format_is_binary(&target_format) {
159 return Err(ToolError::invalid_input(format!(
160 "target_format `{target_format}` is binary; provide an `output_path` to write the converted file."
161 )));
162 }
163
164 // Resolve the pandoc binary at execution time too — registration
165 // gated on resolve_pandoc(), but a concurrent uninstall between
166 // catalog build and the model's call should produce a clear
167 // error rather than the cryptic "program not found" from raw
168 // Command::spawn.
169 let pandoc = crate::dependencies::resolve_pandoc().ok_or_else(|| {
170 ToolError::execution_failed(
171 "pandoc_convert: pandoc binary not found on PATH. \
172 Install pandoc 2.15 or newer (macOS: `brew install pandoc`; \
173 Linux: the release package from https://pandoc.org/installing.html, \
174 since older distro packages predate 2.15; \
175 Windows: `winget install JohnMacFarlane.Pandoc`) and restart codewhale.",
176 )
177 })?;
178 require_sandbox_support(&pandoc).await?;
179
180 let mut cmd = TokioCommand::new(&pandoc);
181 cmd.args(pandoc_args(
182 &source_path,
183 &target_format,
184 resolved_output_path.as_deref(),
185 ));
186 // Kill the converter if the timeout below drops the output()
187 // future: pandoc on a pathological document would otherwise keep
188 // running orphaned after the call already failed.
189 cmd.kill_on_drop(true);
190 cmd.stdin(Stdio::null())
191 .stdout(Stdio::piped())
192 .stderr(Stdio::piped());
193
194 let output = tokio::time::timeout(PANDOC_TIMEOUT, cmd.output())
195 .await
196 .map_err(|_| ToolError::Timeout {
197 seconds: PANDOC_TIMEOUT.as_secs(),
198 })?
199 .map_err(|e| ToolError::execution_failed(format!("failed to launch pandoc: {e}")))?;
200
201 if !output.status.success() {
202 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
203 return Err(ToolError::execution_failed(format!(
204 "pandoc failed (exit {:?}): {stderr}",
205 output.status.code()
206 )));
207 }
208
209 let summary = if let Some(out) = resolved_output_path {
210 format!(
211 "Converted {} → {} via pandoc; wrote {}",
212 source_path.display(),
213 target_format,
214 out.display()
215 )
216 } else {
217 let text = String::from_utf8_lossy(&output.stdout).to_string();
218 return Ok(ToolResult::success(text));
219 };
220 Ok(ToolResult::success(summary))
221 }
222 }
223
224 /// First pandoc release that understands `--sandbox`.
225 const MIN_SANDBOX_VERSION: (u32, u32) = (2, 15);
226
227 /// Arguments for one conversion. `--sandbox` stops readers and writers from
228 /// touching any file but the named source and output: no include
229 /// directives, no embedded images or other resources fetched from disk or
230 /// the network. It is always present; a pandoc without it is refused by
231 /// [`require_sandbox_support`] rather than run without it.
232 fn pandoc_args(source: &Path, target_format: &str, output: Option<&Path>) -> Vec<OsString> {
233 let mut args: Vec<OsString> = vec![
234 "--sandbox".into(),
235 source.as_os_str().to_owned(),
236 "--to".into(),
237 target_format.into(),
238 ];
239 if let Some(out) = output {
240 args.push("--output".into());
241 args.push(out.as_os_str().to_owned());
242 }
243 args
244 }
245
246 /// Parse `major.minor` from the first line of `pandoc --version`
247 /// (`pandoc 3.1.9`, `pandoc.exe 2.9.2.1`).
248 fn parse_pandoc_version(banner: &str) -> Option<(u32, u32)> {
249 let version = banner.lines().next()?.split_whitespace().nth(1)?;
250 let mut parts = version.split('.');
251 let major = parts.next()?.parse().ok()?;
252 let minor = parts.next().map_or(Some(0), |m| m.parse().ok())?;
253 Some((major, minor))
254 }
255
256 /// Refuse a pandoc older than 2.15 with an actionable message instead of
257 /// pandoc's own "Unknown option --sandbox". The version probe runs once per
258 /// process; an unparseable banner — or a probe that outlives its bound — is
259 /// let through, and pandoc itself then rejects the flag if it does not know
260 /// it. The child is async and kill-on-drop so a wedged `--version` can
261 /// neither block the executor nor outlive the probe.
262 async fn require_sandbox_support(pandoc: &str) -> Result<(), ToolError> {
263 static VERSION: tokio::sync::OnceCell<Option<(u32, u32)>> = tokio::sync::OnceCell::const_new();
264 let version = *VERSION
265 .get_or_init(|| async {
266 let mut cmd = TokioCommand::new(pandoc);
267 cmd.arg("--version")
268 .stdin(Stdio::null())
269 .stdout(Stdio::piped())
270 .stderr(Stdio::null())
271 .kill_on_drop(true);
272 let out = tokio::time::timeout(PANDOC_VERSION_PROBE_TIMEOUT, cmd.output())
273 .await
274 .ok()?
275 .ok()?;
276 parse_pandoc_version(&String::from_utf8_lossy(&out.stdout))
277 })
278 .await;
279 match version {
280 Some(found) if found < MIN_SANDBOX_VERSION => Err(ToolError::execution_failed(format!(
281 "pandoc_convert: pandoc {}.{} or newer is required (found {}.{}). \
282 Upgrade from https://pandoc.org/installing.html and restart codewhale.",
283 MIN_SANDBOX_VERSION.0, MIN_SANDBOX_VERSION.1, found.0, found.1
284 ))),
285 _ => Ok(()),
286 }
287 }
288
289 /// Whitelist of target formats whose output is binary (and therefore
290 /// can't be returned as inline text). `docx`, `odt`, and `epub` are
291 /// ZIP archives; everything else in [`SUPPORTED_TARGET_FORMATS`]
292 /// renders to UTF-8 text.
293 pub(crate) fn format_is_binary(target_format: &str) -> bool {
294 matches!(target_format, "docx" | "odt" | "epub")
295 }
296
297 #[cfg(test)]
298 mod tests {
299 use super::*;
300 use std::fs;
301 use tempfile::tempdir;
302
303 fn pandoc_present() -> bool {
304 crate::dependencies::resolve_pandoc().is_some()
305 }
306
307 fn pandoc_environment_unavailable(err: &ToolError) -> bool {
308 let msg = err.to_string();
309 msg.contains("getXdgDirectory") || msg.contains("sHGetFolderPath")
310 }
311
312 // Test-only skip diagnostic; the module-wide print_stderr deny targets prod code.
313 #[allow(clippy::print_stderr)]
314 async fn execute_pandoc_or_skip(input: Value, ctx: &ToolContext) -> Option<ToolResult> {
315 match PandocConvertTool.execute(input, ctx).await {
316 Ok(result) => Some(result),
317 Err(err) if pandoc_environment_unavailable(&err) => {
318 eprintln!("skipping pandoc integration assertion: {err}");
319 None
320 }
321 Err(err) => panic!("execute: {err:?}"),
322 }
323 }
324
325 #[test]
326 fn supported_target_formats_match_schema_enum() {
327 let tool = PandocConvertTool;
328 let schema = tool.input_schema();
329 let enum_vals = schema
330 .get("properties")
331 .and_then(|p| p.get("target_format"))
332 .and_then(|t| t.get("enum"))
333 .and_then(|e| e.as_array())
334 .expect("target_format enum must be present in schema");
335 let from_schema: Vec<&str> = enum_vals.iter().filter_map(|v| v.as_str()).collect();
336 assert_eq!(
337 from_schema, SUPPORTED_TARGET_FORMATS,
338 "schema enum must mirror the SUPPORTED_TARGET_FORMATS constant exactly",
339 );
340 }
341
342 #[test]
343 fn binary_formats_require_output_path() {
344 for fmt in ["docx", "odt", "epub"] {
345 assert!(format_is_binary(fmt));
346 }
347 for fmt in [
348 "markdown",
349 "html",
350 "rst",
351 "latex",
352 "plain",
353 "gfm",
354 "commonmark",
355 ] {
356 assert!(!format_is_binary(fmt));
357 }
358 }
359
360 #[tokio::test]
361 async fn pandoc_convert_refuses_deny_listed_sources() {
362 // `.env` is on the default read deny-list; the refusal comes before
363 // pandoc would run, so this holds with or without pandoc installed.
364 let tmp = tempdir().expect("tempdir");
365 fs::write(tmp.path().join(".env"), "TOKEN=SECRET_PANDOC_VALUE\n").unwrap();
366 #[cfg(unix)]
367 std::os::unix::fs::symlink(tmp.path().join(".env"), tmp.path().join("notes.md")).unwrap();
368 let ctx = ToolContext::new(tmp.path().to_path_buf());
369 let mut sources = vec![".env"];
370 if cfg!(unix) {
371 sources.push("notes.md");
372 }
373 for source in sources {
374 let err = PandocConvertTool
375 .execute(
376 json!({"source_path": source, "target_format": "plain"}),
377 &ctx,
378 )
379 .await
380 .expect_err("a deny-listed source must be refused");
381 assert!(
382 matches!(err, ToolError::PermissionDenied { .. }),
383 "{source}: {err:?}"
384 );
385 assert!(!err.to_string().contains("SECRET_PANDOC_VALUE"));
386 }
387 }
388
389 #[tokio::test]
390 async fn pandoc_convert_does_not_follow_include_directives() {
391 if !pandoc_present() {
392 return;
393 }
394 let outside = tempdir().expect("outside");
395 let secret = outside.path().join("outside.txt");
396 fs::write(&secret, "SECRET_INCLUDED_VALUE\n").unwrap();
397 let tmp = tempdir().expect("tempdir");
398 fs::write(
399 tmp.path().join("p.rst"),
400 format!("Intro\n\n.. include:: {}\n", secret.display()),
401 )
402 .unwrap();
403 let ctx = ToolContext::new(tmp.path().to_path_buf());
404 let result = PandocConvertTool
405 .execute(
406 json!({"source_path": "p.rst", "target_format": "plain"}),
407 &ctx,
408 )
409 .await;
410 if let Err(err) = &result
411 && pandoc_environment_unavailable(err)
412 {
413 return;
414 }
415 if let Ok(result) = result {
416 assert!(
417 !result.content.contains("SECRET_INCLUDED_VALUE"),
418 "include directive must not pull in outside files: {}",
419 result.content
420 );
421 }
422 }
423
424 #[test]
425 fn pandoc_args_always_include_sandbox() {
426 for output in [None, Some(Path::new("/w/out.docx"))] {
427 let args = pandoc_args(Path::new("/w/in.md"), "docx", output);
428 assert_eq!(
429 args.first().map(OsString::as_os_str),
430 Some("--sandbox".as_ref())
431 );
432 assert_eq!(args.iter().filter(|a| *a == "--sandbox").count(), 1);
433 }
434 }
435
436 #[test]
437 fn pandoc_version_gate_matches_sandbox_release() {
438 assert_eq!(
439 parse_pandoc_version("pandoc 2.9.2.1\nCompiled with"),
440 Some((2, 9))
441 );
442 assert_eq!(parse_pandoc_version("pandoc.exe 3.1.9\n"), Some((3, 1)));
443 assert_eq!(parse_pandoc_version("pandoc 3\n"), Some((3, 0)));
444 assert_eq!(parse_pandoc_version("garbage"), None);
445 assert!((2, 9) < MIN_SANDBOX_VERSION);
446 assert!((2, 14) < MIN_SANDBOX_VERSION);
447 assert!((2, 15) >= MIN_SANDBOX_VERSION);
448 assert!((3, 0) >= MIN_SANDBOX_VERSION);
449 }
450
451 // Both invocations are bounded in time; a conversion that outlives its
452 // budget is answered with ToolError::Timeout and its child killed, but
453 // exercising that path would need a wedged `pandoc` injected past the
454 // process-global resolve_pandoc() cache, so the bounds are pinned here
455 // instead.
456 #[test]
457 fn pandoc_invocations_carry_time_bounds() {
458 assert_eq!(PANDOC_TIMEOUT, Duration::from_secs(600));
459 assert_eq!(PANDOC_VERSION_PROBE_TIMEOUT, Duration::from_secs(10));
460 assert!(
461 PANDOC_VERSION_PROBE_TIMEOUT < PANDOC_TIMEOUT,
462 "a wedged version probe must fail fast, not ride the conversion budget"
463 );
464 }
465
466 #[tokio::test]
467 async fn pandoc_convert_rejects_unsupported_target_format() {
468 let tmp = tempdir().expect("tempdir");
469 let src = tmp.path().join("in.md");
470 fs::write(&src, "# hi").unwrap();
471 let ctx = ToolContext::new(tmp.path().to_path_buf());
472 let err = PandocConvertTool
473 .execute(
474 json!({"source_path": "in.md", "target_format": "definitely-not-real"}),
475 &ctx,
476 )
477 .await
478 .expect_err("unsupported target format must reject before pandoc spawn");
479 assert!(
480 err.to_string().contains("unsupported target_format"),
481 "error must call out the unsupported format; got {err}"
482 );
483 }
484
485 #[tokio::test]
486 async fn pandoc_convert_rejects_inline_request_for_binary_format() {
487 let tmp = tempdir().expect("tempdir");
488 let src = tmp.path().join("in.md");
489 fs::write(&src, "# hi").unwrap();
490 let ctx = ToolContext::new(tmp.path().to_path_buf());
491 let err = PandocConvertTool
492 .execute(
493 json!({"source_path": "in.md", "target_format": "docx"}),
494 &ctx,
495 )
496 .await
497 .expect_err("missing output_path for docx must reject");
498 assert!(
499 err.to_string().contains("binary") && err.to_string().contains("output_path"),
500 "error must explain why output_path is required; got {err}"
501 );
502 }
503
504 #[tokio::test]
505 async fn pandoc_convert_roundtrips_markdown_to_html_inline() {
506 if !pandoc_present() {
507 // Tool wouldn't be registered without pandoc; mirror the
508 // catalog-build behaviour.
509 return;
510 }
511 let tmp = tempdir().expect("tempdir");
512 let src = tmp.path().join("note.md");
513 fs::write(&src, "# Title\n\nA paragraph with `inline code`.\n").unwrap();
514 let ctx = ToolContext::new(tmp.path().to_path_buf());
515 let Some(result) = execute_pandoc_or_skip(
516 json!({"source_path": "note.md", "target_format": "html"}),
517 &ctx,
518 )
519 .await
520 else {
521 return;
522 };
523 assert!(result.success);
524 assert!(
525 result.content.contains("<h1") && result.content.contains("Title"),
526 "html output must contain the heading; got {}",
527 result.content
528 );
529 assert!(
530 result.content.contains("<code") || result.content.contains("inline code"),
531 "html output must preserve inline code; got {}",
532 result.content
533 );
534 }
535
536 #[tokio::test]
537 async fn pandoc_convert_writes_output_path_and_reports_summary() {
538 if !pandoc_present() {
539 return;
540 }
541 let tmp = tempdir().expect("tempdir");
542 let src = tmp.path().join("note.md");
543 fs::write(&src, "# Title\n").unwrap();
544 let ctx = ToolContext::new(tmp.path().to_path_buf());
545 let Some(result) = execute_pandoc_or_skip(
546 json!({
547 "source_path": "note.md",
548 "target_format": "html",
549 "output_path": "out.html",
550 }),
551 &ctx,
552 )
553 .await
554 else {
555 return;
556 };
557 assert!(result.success);
558 assert!(result.content.contains("wrote"));
559 let written = fs::read_to_string(tmp.path().join("out.html")).expect("read");
560 assert!(
561 written.contains("Title"),
562 "written file must contain converted body; got {written}"
563 );
564 }
565
566 #[tokio::test]
567 async fn pandoc_convert_surfaces_missing_source_path_clearly() {
568 let tmp = tempdir().expect("tempdir");
569 let ctx = ToolContext::new(tmp.path().to_path_buf());
570 let err = PandocConvertTool
571 .execute(
572 json!({"source_path": "missing.md", "target_format": "html"}),
573 &ctx,
574 )
575 .await
576 .expect_err("nonexistent source must reject");
577 assert!(
578 err.to_string().contains("source_path") && err.to_string().contains("does not exist"),
579 "error must call out missing source; got {err}"
580 );
581 }
582 }
583
583 lines RUST