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