use std::path::{Path, PathBuf};
use crate::commands::job::local;
use crate::core::data::sanitize_filename;
const NAME_HASH_HEX_LEN: usize = 16;
pub(crate) fn compute_destination_name(
source_path: &str,
original_relative_path: &str,
output_extension: &str,
) -> String {
let stem = sanitize_filename(
Path::new(original_relative_path)
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("file"),
);
let digest =
local::sha256_hex(format!("{source_path}\u{1}{original_relative_path}").as_bytes());
format!("{stem}-{}.{output_extension}", &digest[..NAME_HASH_HEX_LEN])
}
pub(crate) fn place_one(
result_dir: &Path,
scratch_path: &Path,
source_path: &str,
original_relative_path: &str,
output_extension: &str,
) -> Result<PathBuf, String> {
std::fs::create_dir_all(result_dir)
.map_err(|err| format!("failed to create {}: {err}", result_dir.display()))?;
let final_path = result_dir.join(compute_destination_name(
source_path,
original_relative_path,
output_extension,
));
if final_path.exists() {
return Err(format!(
"destination name collision placing '{original_relative_path}' at {} -- \
this should be statistically impossible with the current naming scheme \
and indicates a bug",
final_path.display()
));
}
std::fs::rename(scratch_path, &final_path).map_err(|err| {
format!(
"failed to move {} to {}: {err}",
scratch_path.display(),
final_path.display()
)
})?;
Ok(final_path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compute_destination_name_is_deterministic() {
let first = compute_destination_name("source:png/", "screenshots/IMG_0001.png", "jpg");
let second = compute_destination_name("source:png/", "screenshots/IMG_0001.png", "jpg");
assert_eq!(first, second);
}
#[test]
fn compute_destination_name_differs_for_relative_paths_sharing_a_basename() {
let a = compute_destination_name("source:png/", "screenshots/IMG_0001.png", "jpg");
let b = compute_destination_name("source:png/", "photos/IMG_0001.png", "jpg");
assert_ne!(a, b);
assert!(a.starts_with("IMG_0001-"));
assert!(b.starts_with("IMG_0001-"));
}
#[test]
fn compute_destination_name_differs_across_source_paths_with_an_identical_relative_path() {
let a = compute_destination_name("source-a:png/", "IMG_0001.png", "jpg");
let b = compute_destination_name("source-b:png/", "IMG_0001.png", "jpg");
assert_ne!(a, b);
}
#[test]
fn compute_destination_name_uses_the_given_output_extension() {
let jpg = compute_destination_name("source:heic/", "a/b/c.heic", "jpg");
assert!(jpg.ends_with(".jpg"));
let mp4 = compute_destination_name("source:mov/", "a/b/c.mov", "mp4");
assert!(mp4.ends_with(".mp4"));
}
#[test]
fn place_one_moves_the_scratch_file_into_result_dir() {
let dir = tempfile::tempdir().unwrap();
let result_dir = dir.path().join("result");
let scratch_path = dir.path().join("scratch.jpg");
std::fs::write(&scratch_path, b"jpeg bytes").unwrap();
let final_path =
place_one(&result_dir, &scratch_path, "source:png/", "a.png", "jpg").unwrap();
assert!(final_path.exists());
assert!(!scratch_path.exists());
assert_eq!(std::fs::read(&final_path).unwrap(), b"jpeg bytes");
}
#[test]
fn place_one_hard_errors_on_a_forced_collision_instead_of_silently_renaming() {
let dir = tempfile::tempdir().unwrap();
let result_dir = dir.path().join("result");
std::fs::create_dir_all(&result_dir).unwrap();
let colliding_name = compute_destination_name("source:png/", "a.png", "jpg");
std::fs::write(result_dir.join(&colliding_name), b"already here").unwrap();
let scratch_path = dir.path().join("scratch.jpg");
std::fs::write(&scratch_path, b"new content").unwrap();
let result = place_one(&result_dir, &scratch_path, "source:png/", "a.png", "jpg");
assert!(result.is_err());
assert_eq!(
std::fs::read(result_dir.join(&colliding_name)).unwrap(),
b"already here"
);
}
}