use crate::error::DecayError;
use std::path::Path;
pub const MAGIC: [u8; 4] = *b"DCYF";
pub const VERSION: u8 = 0x02;
pub const FILE_TYPE_IMAGE: u8 = 0x01;
pub const FILE_TYPE_TEXT: u8 = 0x02;
pub const FILE_TYPE_AUDIO: u8 = 0x03;
pub const IMAGE_EXTENSION_PREFIX: &str = "idcy";
pub const TEXT_EXTENSION_PREFIX: &str = "tdcy";
pub const AUDIO_EXTENSION_PREFIX: &str = "adcy";
pub const AUDIO_BITS_PER_SAMPLE: u8 = 16;
pub const AUDIO_BYTES_PER_SAMPLE: usize = (AUDIO_BITS_PER_SAMPLE as usize) / 8;
const WIDTH_OFFSET: usize = 6;
const HEIGHT_OFFSET: usize = 10;
const SAMPLE_RATE_OFFSET: usize = 6;
const CHANNELS_OFFSET: usize = 10;
const BITS_PER_SAMPLE_OFFSET: usize = 11;
const RESERVED_OFFSET: usize = 14;
const RESERVED_LEN: usize = 2;
pub const HEADER_SIZE: usize = RESERVED_OFFSET + RESERVED_LEN;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileType {
Image,
Text,
Audio,
}
impl FileType {
fn to_byte(self) -> u8 {
match self {
FileType::Image => FILE_TYPE_IMAGE,
FileType::Text => FILE_TYPE_TEXT,
FileType::Audio => FILE_TYPE_AUDIO,
}
}
fn from_byte(byte: u8) -> Result<FileType, DecayError> {
match byte {
FILE_TYPE_IMAGE => Ok(FileType::Image),
FILE_TYPE_TEXT => Ok(FileType::Text),
FILE_TYPE_AUDIO => Ok(FileType::Audio),
other => Err(DecayError::UnsupportedFileType { found: other }),
}
}
pub fn label(self) -> &'static str {
match self {
FileType::Image => "image",
FileType::Text => "text",
FileType::Audio => "audio",
}
}
}
pub fn parse_filename(path: &Path) -> Result<(FileType, f64), DecayError> {
let extension = path.extension().and_then(|raw| raw.to_str()).unwrap_or("");
let (file_type, digits) = if let Some(rest) = extension.strip_prefix(IMAGE_EXTENSION_PREFIX) {
(FileType::Image, rest)
} else if let Some(rest) = extension.strip_prefix(TEXT_EXTENSION_PREFIX) {
(FileType::Text, rest)
} else if let Some(rest) = extension.strip_prefix(AUDIO_EXTENSION_PREFIX) {
(FileType::Audio, rest)
} else {
return Err(DecayError::UnrecognizedExtension {
extension: extension.to_string(),
});
};
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(DecayError::FilenameNoX {
filename: path.to_string_lossy().into_owned(),
});
}
match digits.parse::<u32>() {
Ok(0) => Err(DecayError::XNotPositive { value: 0.0 }),
Ok(value) => Ok((file_type, f64::from(value))),
Err(_) => Err(DecayError::XOutOfRange {
value: digits.to_string(),
}),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ImageDimensions {
pub width: u32,
pub height: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AudioSpec {
pub sample_rate: u32,
pub channels: u8,
pub bits_per_sample: u8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Metadata {
Image(ImageDimensions),
Text,
Audio(AudioSpec),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Header {
pub file_type: FileType,
pub metadata: Metadata,
}
fn read_u32_le(buffer: &[u8], offset: usize) -> u32 {
u32::from_le_bytes([
buffer[offset],
buffer[offset + 1],
buffer[offset + 2],
buffer[offset + 3],
])
}
impl Header {
pub fn for_image(width: u32, height: u32) -> Header {
Header {
file_type: FileType::Image,
metadata: Metadata::Image(ImageDimensions { width, height }),
}
}
pub fn for_text() -> Header {
Header {
file_type: FileType::Text,
metadata: Metadata::Text,
}
}
pub fn for_audio(sample_rate: u32, channels: u8, bits_per_sample: u8) -> Header {
Header {
file_type: FileType::Audio,
metadata: Metadata::Audio(AudioSpec {
sample_rate,
channels,
bits_per_sample,
}),
}
}
pub fn image_dimensions(&self) -> Option<ImageDimensions> {
match self.metadata {
Metadata::Image(dimensions) => Some(dimensions),
_ => None,
}
}
pub fn audio_spec(&self) -> Option<AudioSpec> {
match self.metadata {
Metadata::Audio(spec) => Some(spec),
_ => None,
}
}
pub fn write(&self) -> [u8; HEADER_SIZE] {
let mut bytes = [0u8; HEADER_SIZE];
bytes[0..4].copy_from_slice(&MAGIC);
bytes[4] = VERSION;
bytes[5] = self.file_type.to_byte();
match self.metadata {
Metadata::Image(dimensions) => {
bytes[WIDTH_OFFSET..WIDTH_OFFSET + 4]
.copy_from_slice(&dimensions.width.to_le_bytes());
bytes[HEIGHT_OFFSET..HEIGHT_OFFSET + 4]
.copy_from_slice(&dimensions.height.to_le_bytes());
}
Metadata::Audio(spec) => {
bytes[SAMPLE_RATE_OFFSET..SAMPLE_RATE_OFFSET + 4]
.copy_from_slice(&spec.sample_rate.to_le_bytes());
bytes[CHANNELS_OFFSET] = spec.channels;
bytes[BITS_PER_SAMPLE_OFFSET] = spec.bits_per_sample;
}
Metadata::Text => {}
}
bytes
}
pub fn read(buffer: &[u8]) -> Result<Header, DecayError> {
if buffer.len() < HEADER_SIZE {
return Err(DecayError::PayloadTooSmall {
found: buffer.len(),
needed: HEADER_SIZE,
});
}
let mut found_magic = [0u8; 4];
found_magic.copy_from_slice(&buffer[0..4]);
if found_magic != MAGIC {
return Err(DecayError::WrongMagic { found: found_magic });
}
let version = buffer[4];
if version != VERSION {
return Err(DecayError::UnsupportedVersion { found: version });
}
let file_type = FileType::from_byte(buffer[5])?;
let metadata = match file_type {
FileType::Image => Metadata::Image(ImageDimensions {
width: read_u32_le(buffer, WIDTH_OFFSET),
height: read_u32_le(buffer, HEIGHT_OFFSET),
}),
FileType::Text => Metadata::Text,
FileType::Audio => Metadata::Audio(AudioSpec {
sample_rate: read_u32_le(buffer, SAMPLE_RATE_OFFSET),
channels: buffer[CHANNELS_OFFSET],
bits_per_sample: buffer[BITS_PER_SAMPLE_OFFSET],
}),
};
Ok(Header {
file_type,
metadata,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_image_header() -> Vec<u8> {
Header::for_image(640, 480).write().to_vec()
}
#[test]
fn headers_round_trip_with_their_own_metadata() {
let image = Header::for_image(640, 480);
let text = Header::for_text();
let audio = Header::for_audio(44_100, 2, AUDIO_BITS_PER_SAMPLE);
for original in [image, text, audio] {
let parsed = Header::read(&original.write()).expect("valid header must parse");
assert_eq!(parsed, original, "round-trip changed the header");
}
let image = Header::read(&image.write()).expect("image header must parse");
assert_eq!(
image.image_dimensions(),
Some(ImageDimensions {
width: 640,
height: 480
})
);
assert!(
image.audio_spec().is_none(),
"an image carries no audio spec"
);
let text = Header::read(&text.write()).expect("text header must parse");
assert!(
text.image_dimensions().is_none(),
"text carries no dimensions"
);
assert!(text.audio_spec().is_none(), "text carries no audio spec");
let audio = Header::read(&audio.write()).expect("audio header must parse");
assert_eq!(
audio.audio_spec(),
Some(AudioSpec {
sample_rate: 44_100,
channels: 2,
bits_per_sample: AUDIO_BITS_PER_SAMPLE,
})
);
assert!(
audio.image_dimensions().is_none(),
"audio carries no image dimensions"
);
}
#[test]
fn dimensions_are_written_little_endian() {
let bytes = Header::for_image(0x0403_0201, 0x0807_0605).write();
assert_eq!(
&bytes[6..10],
&[0x01, 0x02, 0x03, 0x04],
"width must be little-endian"
);
assert_eq!(
&bytes[10..14],
&[0x05, 0x06, 0x07, 0x08],
"height must be little-endian"
);
}
#[test]
fn reserved_bytes_are_zero_on_write() {
let bytes = Header::for_image(640, 480).write();
assert!(
bytes[14..HEADER_SIZE].iter().all(|&b| b == 0),
"reserved region must be zero-filled on write"
);
}
#[test]
fn magic_and_version_bytes_are_exact() {
let bytes = Header::for_image(2, 2).write();
assert_eq!(&bytes[0..4], b"DCYF", "magic bytes must be DCYF");
assert_eq!(bytes[4], 0x02, "version byte must be 0x02");
assert_eq!(bytes[5], FILE_TYPE_IMAGE, "file_type byte must be image");
}
#[test]
fn wrong_magic_is_refused() {
let mut bytes = valid_image_header();
bytes[0] = b'X';
match Header::read(&bytes) {
Err(DecayError::WrongMagic { found }) => assert_eq!(found[0], b'X'),
other => panic!("expected WrongMagic, got {:?}", other),
}
}
#[test]
fn unsupported_versions_are_refused() {
for version in [0x01, 0x03] {
let mut bytes = valid_image_header();
bytes[4] = version;
match Header::read(&bytes) {
Err(DecayError::UnsupportedVersion { found }) => assert_eq!(found, version),
other => panic!("expected UnsupportedVersion for 0x{version:02x}, got {other:?}"),
}
}
}
#[test]
fn audio_sample_rate_is_written_little_endian() {
let bytes = Header::for_audio(44_100, 2, AUDIO_BITS_PER_SAMPLE).write();
assert_eq!(
&bytes[6..10],
&44_100u32.to_le_bytes(),
"sample rate must be little-endian"
);
assert_eq!(bytes[10], 2, "channel count must follow the sample rate");
assert_eq!(
bytes[11], AUDIO_BITS_PER_SAMPLE,
"bits per sample must follow the channel count"
);
}
#[test]
fn unknown_file_type_is_refused() {
let mut bytes = valid_image_header();
bytes[5] = 0x09;
match Header::read(&bytes) {
Err(DecayError::UnsupportedFileType { found }) => assert_eq!(found, 0x09),
other => panic!("expected UnsupportedFileType, got {:?}", other),
}
}
#[test]
fn buffer_smaller_than_header_is_refused() {
let short = [0u8; HEADER_SIZE - 1];
match Header::read(&short) {
Err(DecayError::PayloadTooSmall { found, needed }) => {
assert_eq!(found, HEADER_SIZE - 1);
assert_eq!(needed, HEADER_SIZE);
}
other => panic!("expected PayloadTooSmall, got {:?}", other),
}
}
#[test]
fn reserved_bytes_are_ignored_on_read() {
let mut bytes = valid_image_header();
for b in bytes.iter_mut().take(HEADER_SIZE).skip(RESERVED_OFFSET) {
*b = 0xFF;
}
let parsed = Header::read(&bytes).expect("reserved bytes must be ignored");
assert_eq!(parsed.file_type, FileType::Image);
assert_eq!(
parsed.image_dimensions(),
Some(ImageDimensions {
width: 640,
height: 480
})
);
}
#[test]
fn parse_filename_reads_type_and_x() {
for (name, expected) in [
("photo.idcy3", (FileType::Image, 3.0)),
("note.tdcy12", (FileType::Text, 12.0)),
("clip.adcy5", (FileType::Audio, 5.0)),
] {
assert_eq!(
parse_filename(Path::new(name)).expect("valid name must parse"),
expected,
"'{name}' did not parse to its type and x"
);
}
}
#[test]
fn parse_filename_refuses_every_malformed_name() {
for name in ["photo.png", "note.txt", "no_extension"] {
assert!(
matches!(
parse_filename(Path::new(name)),
Err(DecayError::UnrecognizedExtension { .. })
),
"'{name}' should be an unrecognized extension"
);
}
for name in ["photo.idcy", "note.tdcyx", "photo.idcy3a"] {
assert!(
matches!(
parse_filename(Path::new(name)),
Err(DecayError::FilenameNoX { .. })
),
"'{name}' should yield FilenameNoX"
);
}
assert!(matches!(
parse_filename(Path::new("photo.idcy0")),
Err(DecayError::XNotPositive { .. })
));
assert!(matches!(
parse_filename(Path::new("photo.idcy99999999999")),
Err(DecayError::XOutOfRange { .. })
));
}
}