use std::collections::{HashMap, HashSet};
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 {
format!(
"{}.{output_extension}",
group_key(source_path, original_relative_path)
)
}
pub(crate) fn group_key(source_path: &str, original_relative_path: &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}-{}", &digest[..NAME_HASH_HEX_LEN])
}
pub(crate) fn sheet_destination_name(
group: &str,
index: usize,
total: usize,
sheet_name: &str,
output_extension: &str,
) -> String {
format!(
"{group}-{index:02}of{total:02}-{}.{output_extension}",
crate::core::data::sheet_slug(sheet_name)
)
}
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)
}
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_single_output_name_is_the_group_key_plus_its_extension() {
let group = group_key("source:png/", "a/b/c.png");
assert_eq!(
compute_destination_name("source:png/", "a/b/c.png", "jpg"),
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");
let first = sheet_destination_name(&group, 1, 3, "Summary", "csv");
let second = sheet_destination_name(&group, 2, 3, "Q1 Detail", "csv");
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"));
let name = sheet_destination_name(&group, 100, 120, &"y".repeat(300), "csv");
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");
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");
let first = sheet_destination_name(&group, 1, 3, "Summary", "csv");
let second = sheet_destination_name(&group, 2, 3, "Detail", "csv");
let third = sheet_destination_name(&group, 3, 3, "Notes", "csv");
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");
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");
let names = vec![
sheet_destination_name(&group, 1, 2, "A", "csv"),
sheet_destination_name(&group, 2, 3, "B", "csv"),
];
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");
let names = vec![
sheet_destination_name(&group, 1, 2, "A", "csv"),
sheet_destination_name(&group, 1, 2, "A", "csv"),
];
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");
assert!(group.starts_with("deadbeefdeadbeef-"));
let names = vec![
sheet_destination_name(&group, 1, 2, "cafebabecafebabe", "csv"),
sheet_destination_name(&group, 2, 2, "Notes", "csv"),
];
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", "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"
);
}
}