use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use super::format::InputFileType;
use crate::commands::job::local;
use crate::core::data::{sanitize_filename, strict_stem};
const NAME_HASH_HEX_LEN: usize = 16;
pub(crate) fn compute_destination_name(
source_path: &str,
original_relative_path: &str,
input_file_type: InputFileType,
) -> String {
format!(
"{}.{}",
group_key(source_path, original_relative_path, input_file_type),
input_file_type.output_extension()
)
}
pub(crate) fn group_key(
source_path: &str,
original_relative_path: &str,
input_file_type: InputFileType,
) -> String {
let raw_stem = Path::new(original_relative_path)
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("file");
let stem = if input_file_type.is_spreadsheet() {
strict_stem(raw_stem)
} else {
sanitize_filename(raw_stem)
};
let digest =
local::sha256_hex(format!("{source_path}\u{1}{original_relative_path}").as_bytes());
format!("{stem}-{}", &digest[..NAME_HASH_HEX_LEN])
}
pub(crate) fn sheet_destination_name(
group: &str,
index: usize,
total: usize,
sheet_name: &str,
input_file_type: InputFileType,
) -> String {
format!(
"{group}-{index:02}of{total:02}-{}.{}",
crate::core::data::sheet_slug(sheet_name),
input_file_type.output_extension()
)
}
pub(crate) fn place_one(
result_dir: &Path,
scratch_path: &Path,
source_path: &str,
original_relative_path: &str,
input_file_type: InputFileType,
) -> 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,
input_file_type,
));
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)
}
pub(crate) fn place_sheet(
result_dir: &Path,
scratch_path: &Path,
destination_name: &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(destination_name);
if final_path.exists() {
let _ = std::fs::remove_file(scratch_path);
return Ok(final_path);
}
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)
}
#[derive(Debug, Default)]
pub(crate) struct DestinationGroups {
groups: HashMap<String, GroupState>,
}
#[derive(Debug, Default)]
struct GroupState {
plain: bool,
members: HashSet<usize>,
declared_total: Option<usize>,
inconsistent: bool,
}
impl DestinationGroups {
pub(crate) fn index<'a, I: IntoIterator<Item = &'a String>>(names: I) -> Self {
let mut groups: HashMap<String, GroupState> = HashMap::new();
for name in names {
let stem = name
.rsplit_once('.')
.map_or(name.as_str(), |(head, _)| head);
for (group, remainder) in candidate_groups(stem) {
let state = groups.entry(group.to_string()).or_default();
if remainder.is_empty() {
state.plain = true;
} else if let Some((index, total)) = parse_sheet_marker(remainder) {
state.members.insert(index);
match state.declared_total {
Some(seen) if seen != total => state.inconsistent = true,
Some(_) => {}
None => state.declared_total = Some(total),
}
}
}
}
Self { groups }
}
pub(crate) fn is_complete(&self, group: &str) -> bool {
let Some(state) = self.groups.get(group) else {
return false;
};
if state.plain {
return true;
}
if state.inconsistent {
return false;
}
state.declared_total == Some(state.members.len())
}
}
fn candidate_groups(stem: &str) -> Vec<(&str, &str)> {
let bytes = stem.as_bytes();
let mut found = Vec::new();
for (dash, _) in stem.char_indices().filter(|(_, ch)| *ch == '-') {
let digest_start = dash + 1;
let digest_end = digest_start + NAME_HASH_HEX_LEN;
if digest_end > bytes.len() {
break;
}
if !bytes[digest_start..digest_end]
.iter()
.all(|b| b.is_ascii_hexdigit())
{
continue;
}
if digest_end != bytes.len() && bytes[digest_end] != b'-' {
continue;
}
found.push((&stem[..digest_end], &stem[digest_end..]));
}
found
}
fn parse_sheet_marker(remainder: &str) -> Option<(usize, usize)> {
let tail = remainder.strip_prefix('-')?;
let (marker, _slug) = tail.split_once('-')?;
let (index, total) = marker.split_once("of")?;
if index.is_empty() || total.is_empty() {
return None;
}
if !index.bytes().all(|b| b.is_ascii_digit()) || !total.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let index: usize = index.parse().ok()?;
let total: usize = total.parse().ok()?;
if index == 0 || total == 0 || index > total {
return None;
}
Some((index, total))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_spreadsheet_destination_name_carries_no_report_breaking_character() {
let name = compute_destination_name(
"source:xlsx/",
"Budget (Normal, Reduced, Crisis).xlsx",
InputFileType::Xlsx,
);
assert_eq!(name, "Budget-Normal-Reduced-Crisis-ee7a9c8a4c2e5342.csv");
for forbidden in [',', '\t', '\n', ' ', '(', ')'] {
assert!(!name.contains(forbidden), "{name} contains {forbidden:?}");
}
}
#[test]
fn a_media_destination_name_is_unaffected_by_the_strict_stem() {
for (relative, kind, expected_ext) in [
(
"Budget (Normal, Reduced, Crisis).png",
InputFileType::Png,
"jpg",
),
("My Photo (1).jpeg", InputFileType::Jpeg, "jpg"),
("Holiday Video, final.mov", InputFileType::Mov, "mp4"),
("Voice Note (3).mp4", InputFileType::Mp4Audio, "opus"),
] {
let name = compute_destination_name("source:x/", relative, kind);
let stem = std::path::Path::new(relative)
.file_stem()
.unwrap()
.to_str()
.unwrap();
assert!(
name.starts_with(stem),
"{kind} name {name} must still start with the raw stem {stem}"
);
assert!(name.ends_with(&format!(".{expected_ext}")), "{name}");
}
}
#[test]
fn the_digest_does_not_depend_on_how_the_stem_is_rendered() {
let strict = group_key("source:x/", "A B,C.xlsx", InputFileType::Xlsx);
let lenient = group_key("source:x/", "A B,C.png", InputFileType::Png);
let digest_of = |key: &str| key.rsplit('-').next().unwrap().to_string();
for key in [&strict, &lenient] {
let digest = digest_of(key);
assert_eq!(digest.len(), NAME_HASH_HEX_LEN);
assert!(digest.bytes().all(|b| b.is_ascii_hexdigit()));
}
assert!(strict.starts_with("A-B-C-"));
assert!(lenient.starts_with("A B,C-"));
}
#[test]
fn a_strict_stem_group_is_still_recognised_by_the_index() {
let group = group_key(
"source:xlsx/",
"Budget (Normal, Reduced, Crisis).xlsx",
InputFileType::Xlsx,
);
let names = vec![
sheet_destination_name(&group, 1, 2, "Summary", InputFileType::Xlsx),
sheet_destination_name(&group, 2, 2, "Detail", InputFileType::Xlsx),
];
assert!(DestinationGroups::index(&names).is_complete(&group));
}
#[test]
fn a_single_output_name_is_the_group_key_plus_its_extension() {
let group = group_key("source:png/", "a/b/c.png", InputFileType::Png);
assert_eq!(
compute_destination_name("source:png/", "a/b/c.png", InputFileType::Png),
format!("{group}.jpg")
);
let (stem, digest) = group.rsplit_once('-').unwrap();
assert_eq!(stem, "c");
assert_eq!(digest.len(), NAME_HASH_HEX_LEN);
assert!(digest.bytes().all(|b| b.is_ascii_hexdigit()));
}
#[test]
fn every_sheet_of_one_input_shares_its_group_key() {
let group = group_key("source:xlsx/", "Budget.xlsx", InputFileType::Xlsx);
let first = sheet_destination_name(&group, 1, 3, "Summary", InputFileType::Xlsx);
let second = sheet_destination_name(&group, 2, 3, "Q1 Detail", InputFileType::Xlsx);
assert!(first.starts_with(&format!("{group}-")));
assert!(second.starts_with(&format!("{group}-")));
assert_ne!(first, second);
assert_eq!(first, format!("{group}-01of03-Summary.csv"));
assert_eq!(second, format!("{group}-02of03-Q1-Detail.csv"));
}
#[test]
fn a_worst_case_sheet_name_stays_within_the_filename_budget() {
let long_stem = "x".repeat(300);
let group = group_key(
"source:xlsx/",
&format!("{long_stem}.xlsx"),
InputFileType::Xlsx,
);
let name = sheet_destination_name(&group, 100, 120, &"y".repeat(300), InputFileType::Xlsx);
assert!(name.len() <= 255, "{} bytes: {name}", name.len());
}
#[test]
fn parse_sheet_marker_reads_an_index_and_total() {
assert_eq!(parse_sheet_marker("-01of03-Summary"), Some((1, 3)));
assert_eq!(parse_sheet_marker("-12of120-x"), Some((12, 120)));
assert_eq!(parse_sheet_marker("-02of03-Q1-Detail"), Some((2, 3)));
}
#[test]
fn parse_sheet_marker_rejects_anything_malformed() {
for value in [
"",
"-",
"01of03-x", "-01of03", "-of03-x", "-01of-x", "-aofb-x", "-00of03-x", "-04of03-x", "-0of0-x",
] {
assert_eq!(parse_sheet_marker(value), None, "{value:?}");
}
}
#[test]
fn a_plain_name_completes_its_group() {
let group = group_key("source:png/", "a.png", InputFileType::Png);
let names = vec![format!("{group}.jpg")];
assert!(DestinationGroups::index(&names).is_complete(&group));
}
#[test]
fn a_partial_sheet_group_is_incomplete_until_every_member_is_present() {
let group = group_key("source:xlsx/", "Budget.xlsx", InputFileType::Xlsx);
let first = sheet_destination_name(&group, 1, 3, "Summary", InputFileType::Xlsx);
let second = sheet_destination_name(&group, 2, 3, "Detail", InputFileType::Xlsx);
let third = sheet_destination_name(&group, 3, 3, "Notes", InputFileType::Xlsx);
let partial = vec![first.clone(), second.clone()];
assert!(!DestinationGroups::index(&partial).is_complete(&group));
let whole = vec![first, second, third];
assert!(DestinationGroups::index(&whole).is_complete(&group));
}
#[test]
fn an_unseen_group_is_never_complete() {
let group = group_key("source:xlsx/", "Budget.xlsx", InputFileType::Xlsx);
assert!(!DestinationGroups::default().is_complete(&group));
let other = vec!["something-else.csv".to_string()];
assert!(!DestinationGroups::index(&other).is_complete(&group));
}
#[test]
fn a_group_whose_members_disagree_about_the_total_is_incomplete() {
let group = group_key("source:xlsx/", "Budget.xlsx", InputFileType::Xlsx);
let names = vec![
sheet_destination_name(&group, 1, 2, "A", InputFileType::Xlsx),
sheet_destination_name(&group, 2, 3, "B", InputFileType::Xlsx),
];
assert!(!DestinationGroups::index(&names).is_complete(&group));
}
#[test]
fn a_repeated_member_index_does_not_complete_a_group() {
let group = group_key("source:xlsx/", "Budget.xlsx", InputFileType::Xlsx);
let names = vec![
sheet_destination_name(&group, 1, 2, "A", InputFileType::Xlsx),
sheet_destination_name(&group, 1, 2, "A", InputFileType::Xlsx),
];
assert!(!DestinationGroups::index(&names).is_complete(&group));
}
#[test]
fn names_that_are_not_group_shaped_are_ignored() {
let names = vec![
"README.txt".to_string(),
"no-digest-here.csv".to_string(),
"deadbeef.csv".to_string(),
];
let groups = DestinationGroups::index(&names);
assert!(!groups.is_complete("README"));
assert!(!groups.is_complete("no-digest-here"));
}
#[test]
fn a_hex_looking_stem_or_slug_does_not_shadow_the_real_group() {
let group = group_key("source:xlsx/", "deadbeefdeadbeef.xlsx", InputFileType::Xlsx);
assert!(group.starts_with("deadbeefdeadbeef-"));
let names = vec![
sheet_destination_name(&group, 1, 2, "cafebabecafebabe", InputFileType::Xlsx),
sheet_destination_name(&group, 2, 2, "Notes", InputFileType::Xlsx),
];
let groups = DestinationGroups::index(&names);
assert!(
groups.is_complete(&group),
"the real group must resolve despite hex-looking neighbours"
);
}
#[test]
fn candidate_groups_finds_every_group_shaped_prefix() {
let found = candidate_groups("photo-0123456789abcdef");
assert_eq!(found, vec![("photo-0123456789abcdef", "")]);
let found = candidate_groups("deadbeefdeadbeef-0123456789abcdef-01of02-x");
assert_eq!(
found,
vec![("deadbeefdeadbeef-0123456789abcdef", "-01of02-x")]
);
let found = candidate_groups("Budget-0123456789abcdef-01of02-cafebabecafebabe");
assert_eq!(
found,
vec![
("Budget-0123456789abcdef", "-01of02-cafebabecafebabe"),
("Budget-0123456789abcdef-01of02-cafebabecafebabe", ""),
]
);
assert!(candidate_groups("photo-0123456789abcde").is_empty());
}
#[test]
fn place_sheet_treats_an_existing_target_as_a_resume_not_a_collision() {
let dir = tempfile::tempdir().unwrap();
let result_dir = dir.path().join("result");
std::fs::create_dir_all(&result_dir).unwrap();
std::fs::write(result_dir.join("x-01of02-A.csv"), b"from a prior run").unwrap();
let scratch_path = dir.path().join("scratch.csv");
std::fs::write(&scratch_path, b"reconverted").unwrap();
let placed = place_sheet(&result_dir, &scratch_path, "x-01of02-A.csv").unwrap();
assert_eq!(
std::fs::read(&placed).unwrap(),
b"from a prior run",
"an existing output must not be overwritten"
);
assert!(!scratch_path.exists());
}
#[test]
fn place_sheet_moves_a_new_output_into_place() {
let dir = tempfile::tempdir().unwrap();
let result_dir = dir.path().join("result");
let scratch_path = dir.path().join("scratch.csv");
std::fs::write(&scratch_path, b"fresh").unwrap();
let placed = place_sheet(&result_dir, &scratch_path, "x-01of02-A.csv").unwrap();
assert_eq!(placed, result_dir.join("x-01of02-A.csv"));
assert_eq!(std::fs::read(&placed).unwrap(), b"fresh");
assert!(!scratch_path.exists());
}
#[test]
fn compute_destination_name_is_deterministic() {
let first = compute_destination_name(
"source:png/",
"screenshots/IMG_0001.png",
InputFileType::Png,
);
let second = compute_destination_name(
"source:png/",
"screenshots/IMG_0001.png",
InputFileType::Png,
);
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",
InputFileType::Png,
);
let b = compute_destination_name("source:png/", "photos/IMG_0001.png", InputFileType::Png);
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", InputFileType::Png);
let b = compute_destination_name("source-b:png/", "IMG_0001.png", InputFileType::Png);
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", InputFileType::Heic);
assert!(jpg.ends_with(".jpg"));
let mp4 = compute_destination_name("source:mov/", "a/b/c.mov", InputFileType::Mov);
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",
InputFileType::Png,
)
.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", InputFileType::Png);
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",
InputFileType::Png,
);
assert!(result.is_err());
assert_eq!(
std::fs::read(result_dir.join(&colliding_name)).unwrap(),
b"already here"
);
}
}