diff --git a/crates/engine/src/cmd/curves.rs b/crates/engine/src/cmd/curves.rs index 77b061b78..7388f8e57 100644 --- a/crates/engine/src/cmd/curves.rs +++ b/crates/engine/src/cmd/curves.rs @@ -349,9 +349,8 @@ fn export_presets(s: &mut Session, p: &Value) -> Result { if chosen.is_empty() { return Err(bad(C, "no curve presets to export (save one first, or pass names)")); } - if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) { - std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?; - } - std::fs::write(&path, to_file(&chosen)).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?; + let target = path.to_string_lossy(); + s.check_write_target(&target).map_err(|e| bad(C, e))?; + crate::export::write_file_durable(&target, to_file(&chosen).as_bytes()).map_err(crate::EngineError::Other)?; Ok(json!({"path": path.display().to_string(), "count": chosen.len()})) } diff --git a/crates/engine/src/cmd/preset_files.rs b/crates/engine/src/cmd/preset_files.rs index fc385f34c..0ae615f69 100644 --- a/crates/engine/src/cmd/preset_files.rs +++ b/crates/engine/src/cmd/preset_files.rs @@ -69,10 +69,9 @@ fn export(s: &mut Session, p: &Value) -> Result { if chosen.is_empty() { return Err(bad("preset.export", "no presets to export (create one first, or pass ids/group)")); } - if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) { - std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?; - } - std::fs::write(&path, to_lcpreset(&chosen)).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?; + let target = path.to_string_lossy(); + s.check_write_target(&target).map_err(|e| bad("preset.export", e))?; + crate::export::write_file_durable(&target, to_lcpreset(&chosen).as_bytes()).map_err(crate::EngineError::Other)?; Ok(json!({"path": path.display().to_string(), "count": chosen.len()})) } diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 592f7349d..a13a38dec 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -236,6 +236,69 @@ fn library_with_jpeg(tag: &str) -> (Session, lightcraft_catalog::PhotoId, std::p (s, id, dir, src, bytes) } +fn preset_export_preserves_originals(command: &str, selection: serde_json::Value, tag: &str) { + let (mut s, _, dir, src, original) = library_with_jpeg(tag); + let sidecar = src.with_extension("xmp"); + let appended_sidecar = dir.join("IMG_1.jpg.xmp"); + std::fs::write(&sidecar, b"existing sidecar").unwrap(); + std::fs::write(&appended_sidecar, b"appended sidecar").unwrap(); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + let mut targets = vec![src.clone(), dir.join("sub/../IMG_1.jpg"), sidecar.clone()]; + targets.push(appended_sidecar.clone()); + #[cfg(unix)] + { + let alias = dir.join("alias.jpg"); + std::os::unix::fs::symlink(&src, &alias).unwrap(); + targets.push(alias); + let alias = dir.join("alias.xmp"); + std::os::unix::fs::symlink(&sidecar, &alias).unwrap(); + targets.push(alias); + } + for path in targets { + let mut params = selection.clone(); + params["path"] = json!(path.to_string_lossy()); + let error = s.execute(command, ¶ms).unwrap_err().to_string(); + assert!(error.contains("never writes over an original"), "{error}"); + assert_eq!(std::fs::read(&src).unwrap(), original); + assert_eq!(std::fs::read(&sidecar).unwrap(), b"existing sidecar"); + assert_eq!(std::fs::read(&appended_sidecar).unwrap(), b"appended sidecar"); + } + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn preset_export_never_overwrites_originals_or_sidecars() { + preset_export_preserves_originals("preset.export", json!({"ids": ["lc.warm-glow"]}), "preset-export-guard"); +} + +#[test] +fn curve_preset_export_never_overwrites_originals_or_sidecars() { + preset_export_preserves_originals("curve.exportPresets", json!({"names": ["Linear"]}), "curve-export-guard"); +} + +#[test] +fn preset_exports_preserve_previous_files_on_write_failure() { + let dir = temp_dir("preset-export-failure"); + let mut s = Session::new(); + for (command, selection, file) in + [("preset.export", json!({"ids": ["lc.warm-glow"]}), "look.lcpreset"), ("curve.exportPresets", json!({"names": ["Linear"]}), "curve.lccurve")] + { + let path = dir.join(file); + let mut params = selection; + params["path"] = json!(path.to_string_lossy()); + s.execute(command, ¶ms).unwrap(); + let previous = std::fs::read(&path).unwrap(); + { + let _fault = lightcraft_catalog::safe_file::fail_writes_after(4); + assert!(s.execute(command, ¶ms).is_err(), "{command} must report the failed write"); + } + assert_eq!(std::fs::read(&path).unwrap(), previous, "{command} preserves the prior export"); + assert_eq!(s.execute(command, ¶ms).unwrap()["count"], 1, "replacement still succeeds"); + } + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2, "no temporary files remain"); + let _ = std::fs::remove_dir_all(dir); +} + fn disk_batch( s: &mut Session, id: lightcraft_catalog::PhotoId,