use std::collections::BTreeMap;
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct ArchiveFileName {
pub file_name: String,
pub archive_number: u32,
pub file_generation: char,
}
impl ArchiveFileName {
#[must_use]
pub fn parse(file_name: &str) -> Option<Self> {
let middle = file_name.strip_prefix("data")?.strip_suffix(".tar")?;
let (digits, file_generation) = match middle.as_bytes().last()? {
b'a'..=b'z' => (
&middle[..middle.len() - 1],
middle.as_bytes()[middle.len() - 1] as char,
),
b'0'..=b'9' => (middle, 'a'),
_ => return None,
};
if digits.len() < 5 || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
if digits.len() > 5 && digits.starts_with('0') {
return None;
}
let archive_number: u32 = digits.parse().ok()?;
Some(Self {
file_name: file_name.to_owned(),
archive_number,
file_generation,
})
}
}
pub fn select_newest_file_generations(
file_names: &[String],
) -> crate::error::Result<Vec<ArchiveFileName>> {
Ok(group_file_generations_newest_first(file_names)?
.into_iter()
.filter_map(|mut group| {
if group.is_empty() {
None
} else {
Some(group.remove(0))
}
})
.collect())
}
pub fn group_file_generations_newest_first(
file_names: &[String],
) -> crate::error::Result<Vec<Vec<ArchiveFileName>>> {
let mut seen_names: BTreeMap<(u32, char), String> = BTreeMap::new();
let mut by_number: BTreeMap<u32, Vec<ArchiveFileName>> = BTreeMap::new();
for file_name in file_names {
let Some(parsed) = ArchiveFileName::parse(file_name) else {
continue;
};
if let Some(existing_name) = seen_names.insert(
(parsed.archive_number, parsed.file_generation),
parsed.file_name.clone(),
) {
return Err(crate::error::Error::InvalidFormat {
details: format!(
"archives {} and {} both claim number {} generation {:?}",
existing_name, parsed.file_name, parsed.archive_number, parsed.file_generation
),
});
}
by_number
.entry(parsed.archive_number)
.or_default()
.push(parsed);
}
Ok(by_number
.into_values()
.rev()
.map(|mut group| {
group.sort_by_key(|name| std::cmp::Reverse(name.file_generation));
group
})
.collect())
}
#[cfg(test)]
mod tests {
use super::{ArchiveFileName, select_newest_file_generations};
#[test]
fn parses_standard_names() {
let parsed = ArchiveFileName::parse("data00000a.tar").expect("valid name");
assert_eq!(parsed.archive_number, 0);
assert_eq!(parsed.file_generation, 'a');
let parsed = ArchiveFileName::parse("data00012b.tar").expect("valid name");
assert_eq!(parsed.archive_number, 12);
assert_eq!(parsed.file_generation, 'b');
}
#[test]
fn missing_generation_letter_defaults_to_a() {
let parsed = ArchiveFileName::parse("data00003.tar").expect("valid name");
assert_eq!(parsed.archive_number, 3);
assert_eq!(parsed.file_generation, 'a');
}
#[test]
fn parses_six_digit_numbers() {
let parsed = ArchiveFileName::parse("data123456a.tar").expect("valid name");
assert_eq!(parsed.archive_number, 123_456);
}
#[test]
fn rejects_invalid_names() {
for name in [
"data0000a.tar", "data000000a.tar", "data00000A.tar", "data00000a.bak", "info00000a.tar", "data00000a.tar.bak",
"journal.log",
] {
assert!(
ArchiveFileName::parse(name).is_none(),
"{name} must be rejected"
);
}
}
#[test]
fn selects_highest_generation_letter_newest_first() {
let file_names = vec![
"data00000a.tar".to_owned(),
"data00000b.tar".to_owned(),
"data00001a.tar".to_owned(),
"journal.log".to_owned(),
];
let selected = select_newest_file_generations(&file_names).expect("no duplicates");
let names: Vec<&str> = selected
.iter()
.map(|entry| entry.file_name.as_str())
.collect();
assert_eq!(names, ["data00001a.tar", "data00000b.tar"]);
}
#[test]
fn duplicate_number_and_generation_is_fatal() {
let file_names = vec!["data00000.tar".to_owned(), "data00000a.tar".to_owned()];
assert!(select_newest_file_generations(&file_names).is_err());
}
}