From 483c191e242c3e3bf8edfdf0a9c21aa600a13477 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 19:13:07 -0700 Subject: [PATCH] Batch: keep a backslash in a source file name on Linux and macOS Destination and Override Save/Export paths reject only the current platform's path separators, so a folder batch writes `a\b.svg` into the destination folder on systems where that is one file name. --- crates/engine/src/cmd/fileio/action_batch.rs | 72 +++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 565710f9e..6481191ef 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -326,11 +326,11 @@ fn safe_extension(ext: &str) -> bool { #[cfg(not(target_arch = "wasm32"))] fn output_path(folder: &str, stem: &str, ext: &str) -> Option { use std::path::Path; - if stem.is_empty() || matches!(stem, "." | "..") || stem.contains(['/', '\\']) || !safe_extension(ext) { + if stem.is_empty() || matches!(stem, "." | "..") || stem.contains(std::path::is_separator) || !safe_extension(ext) { return None; } let name = format!("{stem}.{ext}"); - if name.contains(['/', '\\']) { + if name.contains(std::path::is_separator) { return None; } let path = Path::new(folder).join(name); @@ -511,6 +511,74 @@ mod tests { assert!(dest.join("b.svg").is_file(), "b.svg written"); } + #[cfg(unix)] + #[test] + fn batch_folder_keeps_backslash_in_source_file_name() { + let src = tmp("backslash-src"); + let dest = tmp("backslash-dest"); + write_svg(&src, "a\\b.svg", "#f00"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "source": "folder", + "folder": src.to_string_lossy(), + "destination": "folder", + "destinationFolder": dest.to_string_lossy(), + "stopOnError": true, + "commands": [{"command": "object.unlockAll", "params": {}}] + }), + ) + .unwrap(); + assert_eq!(r["count"], 1, "{r}"); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(r["stopped"], false, "{r}"); + assert!(dest.join("a\\b.svg").is_file(), "{r}"); + } + + #[cfg(unix)] + #[test] + fn batch_folder_override_export_keeps_backslash_in_source_file_name() { + let src = tmp("backslash-export-src"); + let dest = tmp("backslash-export-dest"); + write_svg(&src, "a\\b.svg", "#f00"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "source": "folder", + "folder": src.to_string_lossy(), + "destination": "folder", + "destinationFolder": dest.to_string_lossy(), + "stopOnError": true, + "commands": [{"command": "document.export", "params": {"format": "svg"}}] + }), + ) + .unwrap(); + assert_eq!(r["count"], 1, "{r}"); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(r["stopped"], false, "{r}"); + assert_eq!(file_names(&dest), ["a\\b.svg"], "{r}"); + } + + #[test] + fn output_path_accepts_only_platform_file_name_separators() { + let folder = tmp("output-path"); + let folder = folder.to_string_lossy(); + let backslash = output_path(&folder, "a\\b", "svg"); + #[cfg(windows)] + assert!(backslash.is_none()); + #[cfg(not(windows))] + assert_eq!(backslash, Some(std::path::Path::new(folder.as_ref()).join("a\\b.svg"))); + for stem in ["a/b", ".", "..", ""] { + assert!(output_path(&folder, stem, "svg").is_none(), "{stem:?}"); + } + #[cfg(windows)] + assert_eq!(std::path::Path::new("a\\b.svg").file_stem(), Some(std::ffi::OsStr::new("b"))); + } + #[test] fn batch_skips_open_steps_when_override_open() { let src = tmp("open");