use std::fmt::Display;
use xxhash_rust::xxh64;
use crate::PathHash;
#[derive(Debug, Clone, Default)]
pub struct ChunkPath(String);
impl ChunkPath {
pub fn new(path: impl AsRef<str>) -> Self {
Self(path.as_ref().replace('\\', "/"))
}
pub fn hash(&self) -> PathHash {
PathHash::new(xxh64::xxh64(self.0.to_ascii_lowercase().as_bytes(), 0))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
fn canonical_bytes(&self) -> impl Iterator<Item = u8> + '_ {
self.0.bytes().map(|byte| byte.to_ascii_lowercase())
}
}
impl PartialEq for ChunkPath {
fn eq(&self, other: &Self) -> bool {
self.0.eq_ignore_ascii_case(&other.0)
}
}
impl Eq for ChunkPath {}
impl std::hash::Hash for ChunkPath {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
for byte in self.canonical_bytes() {
state.write_u8(byte);
}
}
}
impl PartialOrd for ChunkPath {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for ChunkPath {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.canonical_bytes().cmp(other.canonical_bytes())
}
}
impl Display for ChunkPath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for ChunkPath {
fn as_ref(&self) -> &str {
&self.0
}
}
impl From<&str> for ChunkPath {
fn from(path: &str) -> Self {
Self::new(path)
}
}
impl From<String> for ChunkPath {
fn from(path: String) -> Self {
Self::new(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
const IN_WAD: &str = "ASSETS/Characters/Aatrox/Skins/Base/Aatrox.dds";
const CANONICAL: &str = "assets/characters/aatrox/skins/base/aatrox.dds";
#[test]
fn new_keeps_the_authored_casing() {
assert_eq!(ChunkPath::new(IN_WAD).as_str(), IN_WAD);
}
#[test]
fn new_converts_backslashes() {
assert_eq!(
ChunkPath::new("ASSETS\\Characters\\Aatrox\\Skins\\Base\\Aatrox.dds").as_str(),
IN_WAD
);
}
#[test]
fn new_is_idempotent() {
let once = ChunkPath::new("ASSETS\\Characters/Aatrox\\Skins/Base/Aatrox.dds");
let twice = ChunkPath::new(once.as_str());
assert_eq!(once.as_str(), twice.as_str());
}
#[test]
fn spellings_of_one_path_are_equal_and_hash_alike() {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let authored = ChunkPath::new(IN_WAD);
let flattened = ChunkPath::new(CANONICAL);
assert_eq!(authored, flattened);
assert_eq!(authored.cmp(&flattened), std::cmp::Ordering::Equal);
let mut first = DefaultHasher::new();
Hash::hash(&authored, &mut first);
let mut second = DefaultHasher::new();
Hash::hash(&flattened, &mut second);
assert_eq!(first.finish(), second.finish());
}
#[test]
fn canonicalization_is_ascii_only() {
assert_ne!(
ChunkPath::new("data/É.bin").hash(),
ChunkPath::new("data/é.bin").hash()
);
assert_ne!(ChunkPath::new("data/É.bin"), ChunkPath::new("data/é.bin"));
}
#[test]
fn ordering_is_case_insensitive() {
assert!(ChunkPath::new("B/x.bin") > ChunkPath::new("a/y.bin"));
assert!(ChunkPath::new("a/x.bin") < ChunkPath::new("B/y.bin"));
}
#[test]
fn hash_is_independent_of_case_and_separator() {
let forward = ChunkPath::new(CANONICAL);
let back = ChunkPath::new("assets\\characters\\aatrox\\skins\\base\\aatrox.dds");
let mixed = ChunkPath::new("ASSETS\\Characters/Aatrox\\Skins/Base/Aatrox.dds");
assert_eq!(forward.hash(), back.hash());
assert_eq!(forward.hash(), mixed.hash());
}
#[test]
fn hash_matches_xxhash64_of_the_canonical_string() {
assert_eq!(
ChunkPath::new(IN_WAD).hash(),
PathHash::new(xxh64::xxh64(CANONICAL.as_bytes(), 0))
);
}
}