| 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 |