Skip to main content

ape_decoder/
tag.rs

1use std::io::{Read, Seek, SeekFrom, Write};
2
3use crate::error::{ApeError, ApeResult};
4
5// ---------------------------------------------------------------------------
6// APE tag flag constants
7// ---------------------------------------------------------------------------
8
9pub const APE_TAG_FLAG_CONTAINS_HEADER: u32 = 1 << 31;
10pub const APE_TAG_FLAG_CONTAINS_FOOTER: u32 = 1 << 30;
11pub const APE_TAG_FLAG_IS_HEADER: u32 = 1 << 29;
12
13pub const TAG_FIELD_FLAG_READ_ONLY: u32 = 1 << 0;
14pub const TAG_FIELD_FLAG_DATA_TYPE_MASK: u32 = 0x06;
15pub const TAG_FIELD_FLAG_DATA_TYPE_TEXT_UTF8: u32 = 0 << 1;
16pub const TAG_FIELD_FLAG_DATA_TYPE_BINARY: u32 = 1 << 1;
17pub const TAG_FIELD_FLAG_DATA_TYPE_EXTERNAL_INFO: u32 = 2 << 1;
18pub const TAG_FIELD_FLAG_DATA_TYPE_RESERVED: u32 = 3 << 1;
19
20const APE_TAG_FOOTER_BYTES: u32 = 32;
21const APE_TAG_MAGIC: &[u8; 8] = b"APETAGEX";
22const ID3V1_TAG_BYTES: u64 = 128;
23const MAX_FIELD_DATA_BYTES: u32 = 256 * 1024 * 1024;
24const MAX_TAG_FIELDS: u32 = 65536;
25const MAX_TAG_VERSION: u32 = 2000;
26
27// ---------------------------------------------------------------------------
28// Standard APE tag field names
29// ---------------------------------------------------------------------------
30
31pub mod field_names {
32    pub const TITLE: &str = "Title";
33    pub const ARTIST: &str = "Artist";
34    pub const ALBUM: &str = "Album";
35    pub const ALBUM_ARTIST: &str = "Album Artist";
36    pub const COMMENT: &str = "Comment";
37    pub const YEAR: &str = "Year";
38    pub const TRACK: &str = "Track";
39    pub const DISC: &str = "Disc";
40    pub const GENRE: &str = "Genre";
41    pub const COVER_ART_FRONT: &str = "Cover Art (front)";
42    pub const NOTES: &str = "Notes";
43    pub const LYRICS: &str = "Lyrics";
44    pub const COPYRIGHT: &str = "Copyright";
45    pub const BUY_URL: &str = "Buy URL";
46    pub const ARTIST_URL: &str = "Artist URL";
47    pub const PUBLISHER_URL: &str = "Publisher URL";
48    pub const FILE_URL: &str = "File URL";
49    pub const COPYRIGHT_URL: &str = "Copyright URL";
50    pub const TOOL_NAME: &str = "Tool Name";
51    pub const TOOL_VERSION: &str = "Tool Version";
52    pub const PEAK_LEVEL: &str = "Peak Level";
53    pub const REPLAY_GAIN_RADIO: &str = "Replay Gain (radio)";
54    pub const REPLAY_GAIN_ALBUM: &str = "Replay Gain (album)";
55    pub const COMPOSER: &str = "Composer";
56    pub const CONDUCTOR: &str = "Conductor";
57    pub const ORCHESTRA: &str = "Orchestra";
58    pub const KEYWORDS: &str = "Keywords";
59    pub const RATING: &str = "Rating";
60    pub const PUBLISHER: &str = "Publisher";
61    pub const BPM: &str = "BPM";
62}
63
64// ---------------------------------------------------------------------------
65// TagFieldType enum
66// ---------------------------------------------------------------------------
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum TagFieldType {
70    TextUtf8,
71    Binary,
72    ExternalInfo,
73    Reserved,
74}
75
76// ---------------------------------------------------------------------------
77// ApeTagField
78// ---------------------------------------------------------------------------
79
80#[derive(Debug, Clone)]
81pub struct ApeTagField {
82    pub name: String,
83    pub value: Vec<u8>,
84    pub flags: u32,
85}
86
87impl ApeTagField {
88    /// Returns the data type of this field based on its flags.
89    pub fn field_type(&self) -> TagFieldType {
90        match self.flags & TAG_FIELD_FLAG_DATA_TYPE_MASK {
91            TAG_FIELD_FLAG_DATA_TYPE_TEXT_UTF8 => TagFieldType::TextUtf8,
92            TAG_FIELD_FLAG_DATA_TYPE_BINARY => TagFieldType::Binary,
93            TAG_FIELD_FLAG_DATA_TYPE_EXTERNAL_INFO => TagFieldType::ExternalInfo,
94            _ => TagFieldType::Reserved,
95        }
96    }
97
98    /// Returns true if this field is marked read-only.
99    pub fn is_read_only(&self) -> bool {
100        self.flags & TAG_FIELD_FLAG_READ_ONLY != 0
101    }
102
103    /// Attempts to interpret the value as a UTF-8 string.
104    /// Returns `None` if the field is not a text field or the value is not valid UTF-8.
105    pub fn value_as_str(&self) -> Option<&str> {
106        if self.field_type() != TagFieldType::TextUtf8 {
107            return None;
108        }
109        std::str::from_utf8(&self.value).ok()
110    }
111}
112
113// ---------------------------------------------------------------------------
114// ApeTag
115// ---------------------------------------------------------------------------
116
117#[derive(Debug, Clone)]
118pub struct ApeTag {
119    pub version: u32,
120    pub fields: Vec<ApeTagField>,
121    pub has_header: bool,
122}
123
124impl ApeTag {
125    /// Case-insensitive field lookup by name.
126    pub fn field(&self, name: &str) -> Option<&ApeTagField> {
127        let name_lower = name.to_ascii_lowercase();
128        self.fields
129            .iter()
130            .find(|f| f.name.to_ascii_lowercase() == name_lower)
131    }
132
133    /// Convenience method: get the string value of a text field by name (case-insensitive).
134    pub fn get(&self, name: &str) -> Option<&str> {
135        self.field(name).and_then(|f| f.value_as_str())
136    }
137
138    /// Returns the title field, if present.
139    pub fn title(&self) -> Option<&str> {
140        self.get(field_names::TITLE)
141    }
142
143    /// Returns the artist field, if present.
144    pub fn artist(&self) -> Option<&str> {
145        self.get(field_names::ARTIST)
146    }
147
148    /// Returns the album field, if present.
149    pub fn album(&self) -> Option<&str> {
150        self.get(field_names::ALBUM)
151    }
152
153    /// Returns the year field, if present.
154    pub fn year(&self) -> Option<&str> {
155        self.get(field_names::YEAR)
156    }
157
158    /// Returns the track number field, if present.
159    pub fn track(&self) -> Option<&str> {
160        self.get(field_names::TRACK)
161    }
162
163    /// Returns the genre field, if present.
164    pub fn genre(&self) -> Option<&str> {
165        self.get(field_names::GENRE)
166    }
167
168    /// Returns the comment field, if present.
169    pub fn comment(&self) -> Option<&str> {
170        self.get(field_names::COMMENT)
171    }
172
173    // --- Mutating methods for tag writing ---
174
175    /// Create a new empty APEv2 tag.
176    pub fn new() -> Self {
177        ApeTag {
178            version: 2000,
179            fields: Vec::new(),
180            has_header: true,
181        }
182    }
183
184    /// Set a UTF-8 text field. Creates the field if it doesn't exist,
185    /// or updates the value if it does (case-insensitive name match).
186    pub fn set(&mut self, name: &str, value: &str) {
187        let name_lower = name.to_ascii_lowercase();
188        if let Some(field) = self
189            .fields
190            .iter_mut()
191            .find(|f| f.name.to_ascii_lowercase() == name_lower)
192        {
193            field.value = value.as_bytes().to_vec();
194            field.flags = TAG_FIELD_FLAG_DATA_TYPE_TEXT_UTF8;
195        } else {
196            self.fields.push(ApeTagField {
197                name: name.to_string(),
198                value: value.as_bytes().to_vec(),
199                flags: TAG_FIELD_FLAG_DATA_TYPE_TEXT_UTF8,
200            });
201        }
202    }
203
204    /// Set a binary field. Creates or updates (case-insensitive name match).
205    pub fn set_binary(&mut self, name: &str, value: Vec<u8>) {
206        let name_lower = name.to_ascii_lowercase();
207        if let Some(field) = self
208            .fields
209            .iter_mut()
210            .find(|f| f.name.to_ascii_lowercase() == name_lower)
211        {
212            field.value = value;
213            field.flags = TAG_FIELD_FLAG_DATA_TYPE_BINARY;
214        } else {
215            self.fields.push(ApeTagField {
216                name: name.to_string(),
217                value,
218                flags: TAG_FIELD_FLAG_DATA_TYPE_BINARY,
219            });
220        }
221    }
222
223    /// Remove a field by name (case-insensitive). Returns true if a field was removed.
224    pub fn remove(&mut self, name: &str) -> bool {
225        let name_lower = name.to_ascii_lowercase();
226        let before = self.fields.len();
227        self.fields
228            .retain(|f| f.name.to_ascii_lowercase() != name_lower);
229        self.fields.len() != before
230    }
231
232    /// Serialize the tag to bytes (optional header + field data + footer).
233    pub fn to_bytes(&self) -> Vec<u8> {
234        // Serialize field data
235        let mut field_data = Vec::new();
236        for field in &self.fields {
237            field_data.extend_from_slice(&(field.value.len() as u32).to_le_bytes());
238            field_data.extend_from_slice(&field.flags.to_le_bytes());
239            field_data.extend_from_slice(field.name.as_bytes());
240            field_data.push(0); // null terminator
241            field_data.extend_from_slice(&field.value);
242        }
243
244        let tag_size = field_data.len() as u32 + APE_TAG_FOOTER_BYTES;
245        let tag_flags = APE_TAG_FLAG_CONTAINS_FOOTER
246            | if self.has_header {
247                APE_TAG_FLAG_CONTAINS_HEADER
248            } else {
249                0
250            };
251
252        let mut result = Vec::new();
253
254        // Optional header (same as footer but with IS_HEADER flag)
255        if self.has_header {
256            result.extend_from_slice(APE_TAG_MAGIC);
257            result.extend_from_slice(&self.version.to_le_bytes());
258            result.extend_from_slice(&tag_size.to_le_bytes());
259            result.extend_from_slice(&(self.fields.len() as u32).to_le_bytes());
260            result.extend_from_slice(&(tag_flags | APE_TAG_FLAG_IS_HEADER).to_le_bytes());
261            result.extend_from_slice(&[0u8; 8]); // reserved
262        }
263
264        // Field data
265        result.extend_from_slice(&field_data);
266
267        // Footer
268        result.extend_from_slice(APE_TAG_MAGIC);
269        result.extend_from_slice(&self.version.to_le_bytes());
270        result.extend_from_slice(&tag_size.to_le_bytes());
271        result.extend_from_slice(&(self.fields.len() as u32).to_le_bytes());
272        result.extend_from_slice(&tag_flags.to_le_bytes());
273        result.extend_from_slice(&[0u8; 8]); // reserved
274
275        result
276    }
277}
278
279// ---------------------------------------------------------------------------
280// Tag writing
281// ---------------------------------------------------------------------------
282
283/// Write an APE tag to the end of a file, replacing any existing APE tag.
284/// Preserves any existing ID3v1 tag.
285pub fn write_tag<W: Read + Write + Seek>(writer: &mut W, tag: &ApeTag) -> ApeResult<()> {
286    let file_size = writer.seek(SeekFrom::End(0))?;
287
288    // Detect existing ID3v1 tag
289    let mut id3v1_data: Option<[u8; 128]> = None;
290    if file_size >= ID3V1_TAG_BYTES {
291        writer.seek(SeekFrom::End(-(ID3V1_TAG_BYTES as i64)))?;
292        let mut buf = [0u8; 128];
293        writer.read_exact(&mut buf)?;
294        if &buf[0..3] == b"TAG" {
295            id3v1_data = Some(buf);
296        }
297    }
298
299    // Detect and remove existing APE tag
300    let footer_offset = if id3v1_data.is_some() {
301        file_size - ID3V1_TAG_BYTES - APE_TAG_FOOTER_BYTES as u64
302    } else {
303        file_size - APE_TAG_FOOTER_BYTES as u64
304    };
305
306    let mut truncate_to = if id3v1_data.is_some() {
307        file_size - ID3V1_TAG_BYTES
308    } else {
309        file_size
310    };
311
312    if footer_offset < file_size {
313        writer.seek(SeekFrom::Start(footer_offset))?;
314        let mut footer_buf = [0u8; 32];
315        if writer.read_exact(&mut footer_buf).is_ok() && &footer_buf[0..8] == APE_TAG_MAGIC {
316            // Valid APE footer found — compute total tag size and truncate
317            let existing_size = u32::from_le_bytes([
318                footer_buf[12],
319                footer_buf[13],
320                footer_buf[14],
321                footer_buf[15],
322            ]);
323            let existing_flags = u32::from_le_bytes([
324                footer_buf[20],
325                footer_buf[21],
326                footer_buf[22],
327                footer_buf[23],
328            ]);
329            let has_existing_header = existing_flags & APE_TAG_FLAG_CONTAINS_HEADER != 0;
330            let total_existing = existing_size as u64
331                + if has_existing_header {
332                    APE_TAG_FOOTER_BYTES as u64
333                } else {
334                    0
335                };
336            truncate_to = truncate_to.saturating_sub(total_existing);
337        }
338    }
339
340    // Truncate file to remove old tag
341    writer.seek(SeekFrom::Start(truncate_to))?;
342    // Write new tag
343    let tag_bytes = tag.to_bytes();
344    writer.write_all(&tag_bytes)?;
345
346    // Re-append ID3v1 if it existed
347    if let Some(id3v1) = id3v1_data {
348        writer.write_all(&id3v1)?;
349    }
350
351    // Note: For real files, the caller should truncate to writer.stream_position()
352    // after this call. Cursor<Vec<u8>> doesn't support truncation.
353
354    Ok(())
355}
356
357/// Remove all APE tags from a file. Preserves ID3v1 tags.
358pub fn remove_tag<W: Read + Write + Seek>(writer: &mut W) -> ApeResult<()> {
359    let file_size = writer.seek(SeekFrom::End(0))?;
360
361    // Detect ID3v1
362    let mut has_id3v1 = false;
363    let mut id3v1_data = [0u8; 128];
364    if file_size >= ID3V1_TAG_BYTES {
365        writer.seek(SeekFrom::End(-(ID3V1_TAG_BYTES as i64)))?;
366        writer.read_exact(&mut id3v1_data)?;
367        has_id3v1 = &id3v1_data[0..3] == b"TAG";
368    }
369
370    // Detect APE footer
371    let footer_offset = if has_id3v1 {
372        file_size - ID3V1_TAG_BYTES - APE_TAG_FOOTER_BYTES as u64
373    } else {
374        file_size - APE_TAG_FOOTER_BYTES as u64
375    };
376
377    if footer_offset >= file_size {
378        return Ok(()); // no room for a tag
379    }
380
381    writer.seek(SeekFrom::Start(footer_offset))?;
382    let mut footer_buf = [0u8; 32];
383    if writer.read_exact(&mut footer_buf).is_err() || &footer_buf[0..8] != APE_TAG_MAGIC {
384        return Ok(()); // no APE tag found
385    }
386
387    // Compute tag size
388    let tag_size = u32::from_le_bytes([
389        footer_buf[12],
390        footer_buf[13],
391        footer_buf[14],
392        footer_buf[15],
393    ]);
394    let tag_flags = u32::from_le_bytes([
395        footer_buf[20],
396        footer_buf[21],
397        footer_buf[22],
398        footer_buf[23],
399    ]);
400    let has_header = tag_flags & APE_TAG_FLAG_CONTAINS_HEADER != 0;
401    let total_tag_bytes = tag_size as u64
402        + if has_header {
403            APE_TAG_FOOTER_BYTES as u64
404        } else {
405            0
406        };
407
408    // Truncate to remove tag
409    let base = if has_id3v1 {
410        file_size - ID3V1_TAG_BYTES
411    } else {
412        file_size
413    };
414    let truncate_to = base.saturating_sub(total_tag_bytes);
415    writer.seek(SeekFrom::Start(truncate_to))?;
416
417    // Re-append ID3v1 if it existed
418    if has_id3v1 {
419        writer.write_all(&id3v1_data)?;
420    }
421
422    Ok(())
423}
424
425// ---------------------------------------------------------------------------
426// Tag reading
427// ---------------------------------------------------------------------------
428
429/// Reads an APE tag from the end of a seekable stream.
430///
431/// Returns `Ok(None)` if no APE tag is found (file too small, no valid footer, etc.).
432/// Returns `Ok(Some(tag))` on success, or `Err(...)` on I/O or format errors.
433pub fn read_tag<R: Read + Seek>(reader: &mut R) -> ApeResult<Option<ApeTag>> {
434    // Get file size
435    let file_size = reader.seek(SeekFrom::End(0))?;
436
437    // Need at least 32 bytes for a footer
438    if file_size < APE_TAG_FOOTER_BYTES as u64 {
439        return Ok(None);
440    }
441
442    // Check for ID3v1 tag at end of file
443    let has_id3v1 = if file_size >= ID3V1_TAG_BYTES {
444        reader.seek(SeekFrom::End(-(ID3V1_TAG_BYTES as i64)))?;
445        let mut id3_header = [0u8; 3];
446        reader.read_exact(&mut id3_header)?;
447        &id3_header == b"TAG"
448    } else {
449        false
450    };
451
452    // Determine where the APE tag footer should be
453    let footer_end = if has_id3v1 {
454        file_size - ID3V1_TAG_BYTES
455    } else {
456        file_size
457    };
458
459    if footer_end < APE_TAG_FOOTER_BYTES as u64 {
460        return Ok(None);
461    }
462
463    let footer_start = footer_end - APE_TAG_FOOTER_BYTES as u64;
464
465    // Read the 32-byte footer
466    reader.seek(SeekFrom::Start(footer_start))?;
467    let mut footer_buf = [0u8; 32];
468    reader.read_exact(&mut footer_buf)?;
469
470    // Validate magic
471    if &footer_buf[0..8] != APE_TAG_MAGIC {
472        return Ok(None);
473    }
474
475    // Parse footer fields
476    let version = u32::from_le_bytes(footer_buf[8..12].try_into().unwrap());
477    let size = u32::from_le_bytes(footer_buf[12..16].try_into().unwrap());
478    let num_fields = u32::from_le_bytes(footer_buf[16..20].try_into().unwrap());
479    let flags = u32::from_le_bytes(footer_buf[20..24].try_into().unwrap());
480
481    // The footer itself must not be a header
482    if flags & APE_TAG_FLAG_IS_HEADER != 0 {
483        return Ok(None);
484    }
485
486    // Validate footer
487    if version > MAX_TAG_VERSION {
488        return Err(ApeError::InvalidFormat("APE tag version too high"));
489    }
490    if num_fields > MAX_TAG_FIELDS {
491        return Err(ApeError::InvalidFormat("APE tag has too many fields"));
492    }
493    if size < APE_TAG_FOOTER_BYTES {
494        return Err(ApeError::InvalidFormat("APE tag size too small"));
495    }
496    let field_bytes = size - APE_TAG_FOOTER_BYTES;
497    if field_bytes > MAX_FIELD_DATA_BYTES {
498        return Err(ApeError::InvalidFormat("APE tag field data too large"));
499    }
500
501    let has_header = flags & APE_TAG_FLAG_CONTAINS_HEADER != 0;
502
503    // The tag's size field includes the footer but not the header.
504    // Field data starts at footer_end - size.
505    let field_data_start = footer_end
506        .checked_sub(size as u64)
507        .ok_or(ApeError::InvalidFormat(
508            "APE tag size extends before start of file",
509        ))?;
510
511    if field_bytes == 0 {
512        return Ok(Some(ApeTag {
513            version,
514            fields: Vec::new(),
515            has_header,
516        }));
517    }
518
519    // Read field data
520    reader.seek(SeekFrom::Start(field_data_start))?;
521    let mut field_data = vec![0u8; field_bytes as usize];
522    reader.read_exact(&mut field_data)?;
523
524    // Parse fields
525    let fields = parse_fields(&field_data, num_fields)?;
526
527    Ok(Some(ApeTag {
528        version,
529        fields,
530        has_header,
531    }))
532}
533
534/// Parse tag fields from raw field data bytes.
535fn parse_fields(data: &[u8], num_fields: u32) -> ApeResult<Vec<ApeTagField>> {
536    let mut fields = Vec::with_capacity(num_fields as usize);
537    let mut offset = 0usize;
538
539    for _ in 0..num_fields {
540        // Need at least 8 bytes for value_size + flags
541        if offset + 8 > data.len() {
542            return Err(ApeError::InvalidFormat("APE tag field truncated (header)"));
543        }
544
545        let value_size = u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap());
546        let field_flags = u32::from_le_bytes(data[offset + 4..offset + 8].try_into().unwrap());
547        offset += 8;
548
549        // Find null terminator for the field name
550        let name_start = offset;
551        let name_end = data[name_start..]
552            .iter()
553            .position(|&b| b == 0)
554            .map(|pos| name_start + pos)
555            .ok_or(ApeError::InvalidFormat(
556                "APE tag field name not null-terminated",
557            ))?;
558
559        // Validate field name is printable ASCII (0x20..=0x7E)
560        let name_bytes = &data[name_start..name_end];
561        if name_bytes.is_empty() {
562            return Err(ApeError::InvalidFormat("APE tag field name is empty"));
563        }
564        for &b in name_bytes {
565            if b < 0x20 || b > 0x7E {
566                return Err(ApeError::InvalidFormat(
567                    "APE tag field name contains non-printable character",
568                ));
569            }
570        }
571        let name = String::from_utf8(name_bytes.to_vec())
572            .map_err(|_| ApeError::InvalidFormat("APE tag field name is not valid ASCII"))?;
573
574        // Skip past null terminator
575        offset = name_end + 1;
576
577        // Read value
578        let value_size = value_size as usize;
579        if offset + value_size > data.len() {
580            return Err(ApeError::InvalidFormat("APE tag field value truncated"));
581        }
582        let value = data[offset..offset + value_size].to_vec();
583        offset += value_size;
584
585        fields.push(ApeTagField {
586            name,
587            value,
588            flags: field_flags,
589        });
590    }
591
592    Ok(fields)
593}
594
595// ---------------------------------------------------------------------------
596// Tests
597// ---------------------------------------------------------------------------
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602    use std::io::Cursor;
603
604    /// Helper: build a synthetic APE tag byte stream with the given fields.
605    /// Each field is (name, value, flags). Returns the complete byte buffer
606    /// (field data + footer), optionally followed by an ID3v1 block.
607    fn build_tag(fields: &[(&str, &[u8], u32)], with_id3v1: bool, with_header: bool) -> Vec<u8> {
608        let mut field_data = Vec::new();
609        for &(name, value, flags) in fields {
610            field_data.extend_from_slice(&(value.len() as u32).to_le_bytes());
611            field_data.extend_from_slice(&flags.to_le_bytes());
612            field_data.extend_from_slice(name.as_bytes());
613            field_data.push(0); // null terminator
614            field_data.extend_from_slice(value);
615        }
616
617        let field_bytes = field_data.len() as u32;
618        let tag_size = field_bytes + APE_TAG_FOOTER_BYTES;
619
620        let mut tag_flags = APE_TAG_FLAG_CONTAINS_FOOTER;
621        if with_header {
622            tag_flags |= APE_TAG_FLAG_CONTAINS_HEADER;
623        }
624
625        // Build footer
626        let mut footer = Vec::new();
627        footer.extend_from_slice(APE_TAG_MAGIC);
628        footer.extend_from_slice(&2000u32.to_le_bytes());
629        footer.extend_from_slice(&tag_size.to_le_bytes());
630        footer.extend_from_slice(&(fields.len() as u32).to_le_bytes());
631        footer.extend_from_slice(&tag_flags.to_le_bytes());
632        footer.extend_from_slice(&[0u8; 8]);
633
634        let mut buf = Vec::new();
635
636        // Optional header (same structure as footer but with IS_HEADER flag)
637        if with_header {
638            let mut header = Vec::new();
639            header.extend_from_slice(APE_TAG_MAGIC);
640            header.extend_from_slice(&2000u32.to_le_bytes());
641            header.extend_from_slice(&tag_size.to_le_bytes());
642            header.extend_from_slice(&(fields.len() as u32).to_le_bytes());
643            header.extend_from_slice(&(tag_flags | APE_TAG_FLAG_IS_HEADER).to_le_bytes());
644            header.extend_from_slice(&[0u8; 8]);
645            buf.extend_from_slice(&header);
646        }
647
648        buf.extend_from_slice(&field_data);
649        buf.extend_from_slice(&footer);
650
651        if with_id3v1 {
652            let mut id3v1 = vec![0u8; 128];
653            id3v1[0] = b'T';
654            id3v1[1] = b'A';
655            id3v1[2] = b'G';
656            buf.extend_from_slice(&id3v1);
657        }
658
659        buf
660    }
661
662    /// Build a realistic tag that mimics what the MAC tool writes.
663    fn build_mac_tool_tag() -> Vec<u8> {
664        build_tag(
665            &[
666                (field_names::TOOL_NAME, b"Monkey's Audio", 0),
667                (field_names::TOOL_VERSION, b"10.44", 0),
668                (field_names::TITLE, b"Sine Wave", 0),
669                (field_names::ARTIST, b"Test Generator", 0),
670                (field_names::ALBUM, b"Test Signals", 0),
671                (field_names::YEAR, b"2024", 0),
672                (field_names::TRACK, b"1", 0),
673                (field_names::GENRE, b"Test", 0),
674                (field_names::COMMENT, b"Generated for testing", 0),
675            ],
676            false,
677            false,
678        )
679    }
680
681    #[test]
682    fn read_tag_from_fixture() {
683        // The test fixture files were generated without APE tags.
684        // Verify that read_tag gracefully returns None.
685        let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
686            .join("tests/fixtures/ape/sine_16s_c2000.ape");
687        let mut file = std::fs::File::open(&path).expect("failed to open test fixture");
688        let result = read_tag(&mut file).expect("read_tag should not error");
689        assert!(result.is_none(), "fixture has no APE tag");
690    }
691
692    #[test]
693    fn tool_name_and_version_fields_exist() {
694        let data = build_mac_tool_tag();
695        let mut cursor = Cursor::new(data);
696        let tag = read_tag(&mut cursor)
697            .expect("read_tag failed")
698            .expect("expected tag");
699
700        let tool_name = tag.get(field_names::TOOL_NAME);
701        assert_eq!(tool_name, Some("Monkey's Audio"));
702
703        let tool_version = tag.get(field_names::TOOL_VERSION);
704        assert_eq!(tool_version, Some("10.44"));
705    }
706
707    #[test]
708    fn case_insensitive_field_lookup() {
709        let data = build_mac_tool_tag();
710        let mut cursor = Cursor::new(data);
711        let tag = read_tag(&mut cursor)
712            .expect("read_tag failed")
713            .expect("expected tag");
714
715        // Look up "Tool Name" with different casing
716        let upper = tag.get("TOOL NAME");
717        let lower = tag.get("tool name");
718        let mixed = tag.get("Tool Name");
719
720        assert_eq!(upper, mixed);
721        assert_eq!(lower, mixed);
722        assert_eq!(mixed, Some("Monkey's Audio"));
723
724        // Standard accessors
725        assert_eq!(tag.title(), Some("Sine Wave"));
726        assert_eq!(tag.artist(), Some("Test Generator"));
727        assert_eq!(tag.album(), Some("Test Signals"));
728        assert_eq!(tag.year(), Some("2024"));
729        assert_eq!(tag.track(), Some("1"));
730        assert_eq!(tag.genre(), Some("Test"));
731        assert_eq!(tag.comment(), Some("Generated for testing"));
732    }
733
734    #[test]
735    fn nonexistent_field_returns_none() {
736        let data = build_mac_tool_tag();
737        let mut cursor = Cursor::new(data);
738        let tag = read_tag(&mut cursor)
739            .expect("read_tag failed")
740            .expect("expected tag");
741
742        assert!(tag.field("Nonexistent Field 12345").is_none());
743        assert!(tag.get("Nonexistent Field 12345").is_none());
744    }
745
746    #[test]
747    fn value_as_str_for_text_fields() {
748        let data = build_mac_tool_tag();
749        let mut cursor = Cursor::new(data);
750        let tag = read_tag(&mut cursor)
751            .expect("read_tag failed")
752            .expect("expected tag");
753
754        for field in &tag.fields {
755            if field.field_type() == TagFieldType::TextUtf8 {
756                assert!(
757                    field.value_as_str().is_some(),
758                    "text field '{}' should be valid UTF-8",
759                    field.name
760                );
761            }
762        }
763    }
764
765    #[test]
766    fn value_as_str_returns_none_for_binary() {
767        let field = ApeTagField {
768            name: "test".to_string(),
769            value: vec![0xFF, 0xFE],
770            flags: TAG_FIELD_FLAG_DATA_TYPE_BINARY,
771        };
772        assert!(field.value_as_str().is_none());
773    }
774
775    #[test]
776    fn field_type_classification() {
777        let text = ApeTagField {
778            name: "t".into(),
779            value: vec![],
780            flags: TAG_FIELD_FLAG_DATA_TYPE_TEXT_UTF8,
781        };
782        assert_eq!(text.field_type(), TagFieldType::TextUtf8);
783
784        let binary = ApeTagField {
785            name: "b".into(),
786            value: vec![],
787            flags: TAG_FIELD_FLAG_DATA_TYPE_BINARY,
788        };
789        assert_eq!(binary.field_type(), TagFieldType::Binary);
790
791        let external = ApeTagField {
792            name: "e".into(),
793            value: vec![],
794            flags: TAG_FIELD_FLAG_DATA_TYPE_EXTERNAL_INFO,
795        };
796        assert_eq!(external.field_type(), TagFieldType::ExternalInfo);
797
798        let reserved = ApeTagField {
799            name: "r".into(),
800            value: vec![],
801            flags: TAG_FIELD_FLAG_DATA_TYPE_RESERVED,
802        };
803        assert_eq!(reserved.field_type(), TagFieldType::Reserved);
804    }
805
806    #[test]
807    fn is_read_only_flag() {
808        let ro = ApeTagField {
809            name: "ro".into(),
810            value: vec![],
811            flags: TAG_FIELD_FLAG_READ_ONLY,
812        };
813        assert!(ro.is_read_only());
814
815        let rw = ApeTagField {
816            name: "rw".into(),
817            value: vec![],
818            flags: 0,
819        };
820        assert!(!rw.is_read_only());
821    }
822
823    #[test]
824    fn file_too_small_returns_none() {
825        let data = vec![0u8; 16];
826        let mut cursor = Cursor::new(data);
827        let result = read_tag(&mut cursor).expect("should not error");
828        assert!(result.is_none());
829    }
830
831    #[test]
832    fn no_tag_returns_none() {
833        // 64 bytes of zeros -- no APETAGEX magic
834        let data = vec![0u8; 64];
835        let mut cursor = Cursor::new(data);
836        let result = read_tag(&mut cursor).expect("should not error");
837        assert!(result.is_none());
838    }
839
840    #[test]
841    fn synthetic_minimal_tag() {
842        let data = build_tag(&[("Test", b"Hello", 0)], false, false);
843        let mut cursor = Cursor::new(data);
844        let tag = read_tag(&mut cursor)
845            .expect("read_tag failed")
846            .expect("expected tag");
847
848        assert_eq!(tag.version, 2000);
849        assert_eq!(tag.fields.len(), 1);
850        assert_eq!(tag.get("Test"), Some("Hello"));
851        assert_eq!(tag.get("test"), Some("Hello")); // case-insensitive
852        assert!(!tag.has_header);
853    }
854
855    #[test]
856    fn synthetic_tag_with_id3v1() {
857        let data = build_tag(&[("Foo", b"Bar", 0)], true, false);
858        let mut cursor = Cursor::new(data);
859        let tag = read_tag(&mut cursor)
860            .expect("read_tag failed")
861            .expect("expected tag");
862
863        assert_eq!(tag.get("Foo"), Some("Bar"));
864    }
865
866    #[test]
867    fn synthetic_tag_with_header() {
868        let data = build_tag(&[("Artist", b"Someone", 0)], false, true);
869        let mut cursor = Cursor::new(data);
870        let tag = read_tag(&mut cursor)
871            .expect("read_tag failed")
872            .expect("expected tag");
873
874        assert!(tag.has_header);
875        assert_eq!(tag.artist(), Some("Someone"));
876    }
877
878    #[test]
879    fn multiple_fields_parsed_correctly() {
880        let data = build_mac_tool_tag();
881        let mut cursor = Cursor::new(data);
882        let tag = read_tag(&mut cursor)
883            .expect("read_tag failed")
884            .expect("expected tag");
885
886        assert_eq!(tag.version, 2000);
887        assert_eq!(tag.fields.len(), 9);
888    }
889
890    // --- Tag writing tests ---
891
892    #[test]
893    fn new_tag_is_empty() {
894        let tag = ApeTag::new();
895        assert_eq!(tag.version, 2000);
896        assert!(tag.fields.is_empty());
897        assert!(tag.has_header);
898    }
899
900    #[test]
901    fn set_and_get_text_field() {
902        let mut tag = ApeTag::new();
903        tag.set("Title", "Test Song");
904        tag.set("Artist", "Test Artist");
905
906        assert_eq!(tag.title(), Some("Test Song"));
907        assert_eq!(tag.artist(), Some("Test Artist"));
908    }
909
910    #[test]
911    fn set_updates_existing_field() {
912        let mut tag = ApeTag::new();
913        tag.set("Title", "Original");
914        tag.set("Title", "Updated");
915
916        assert_eq!(tag.title(), Some("Updated"));
917        assert_eq!(tag.fields.len(), 1); // no duplicate
918    }
919
920    #[test]
921    fn remove_field() {
922        let mut tag = ApeTag::new();
923        tag.set("Title", "Song");
924        tag.set("Artist", "Band");
925
926        assert!(tag.remove("title")); // case-insensitive
927        assert_eq!(tag.title(), None);
928        assert_eq!(tag.artist(), Some("Band"));
929        assert!(!tag.remove("Title")); // already removed
930    }
931
932    #[test]
933    fn serialize_and_parse_roundtrip() {
934        let mut tag = ApeTag::new();
935        tag.set("Title", "Round Trip");
936        tag.set("Artist", "Tester");
937        tag.set("Year", "2026");
938
939        let bytes = tag.to_bytes();
940        let mut cursor = Cursor::new(bytes);
941        let parsed = read_tag(&mut cursor).unwrap().expect("should parse");
942
943        assert_eq!(parsed.version, 2000);
944        assert_eq!(parsed.fields.len(), 3);
945        assert_eq!(parsed.get("Title"), Some("Round Trip"));
946        assert_eq!(parsed.get("Artist"), Some("Tester"));
947        assert_eq!(parsed.get("Year"), Some("2026"));
948    }
949
950    #[test]
951    fn serialize_empty_tag() {
952        let tag = ApeTag::new();
953        let bytes = tag.to_bytes();
954        // Header (32) + no fields + footer (32) = 64 bytes
955        assert_eq!(bytes.len(), 64);
956
957        let mut cursor = Cursor::new(bytes);
958        let parsed = read_tag(&mut cursor)
959            .unwrap()
960            .expect("should parse empty tag");
961        assert!(parsed.fields.is_empty());
962    }
963
964    #[test]
965    fn write_tag_to_file() {
966        // Create a minimal "file" with some content
967        let mut file_data = vec![0xAA; 100];
968        let mut cursor = Cursor::new(&mut file_data);
969
970        let mut tag = ApeTag::new();
971        tag.set("Title", "Written");
972        tag.set("Artist", "Writer");
973
974        write_tag(&mut cursor, &tag).unwrap();
975
976        // Read it back
977        let data = cursor.into_inner();
978        let mut read_cursor = Cursor::new(data.as_slice());
979        let parsed = read_tag(&mut read_cursor)
980            .unwrap()
981            .expect("should read written tag");
982
983        assert_eq!(parsed.get("Title"), Some("Written"));
984        assert_eq!(parsed.get("Artist"), Some("Writer"));
985    }
986
987    #[test]
988    fn write_tag_replaces_existing() {
989        // Build initial file with a tag
990        let mut tag1 = ApeTag::new();
991        tag1.set("Title", "First");
992        let tag1_bytes = tag1.to_bytes();
993
994        let mut file_data: Vec<u8> = vec![0xBB; 50];
995        file_data.extend_from_slice(&tag1_bytes);
996
997        let mut cursor = Cursor::new(file_data);
998
999        // Write a new tag (should replace)
1000        let mut tag2 = ApeTag::new();
1001        tag2.set("Title", "Second");
1002        tag2.set("Album", "New Album");
1003        write_tag(&mut cursor, &tag2).unwrap();
1004
1005        // Read back
1006        let data = cursor.into_inner();
1007        let mut read_cursor = Cursor::new(data.as_slice());
1008        let parsed = read_tag(&mut read_cursor)
1009            .unwrap()
1010            .expect("should read replaced tag");
1011
1012        assert_eq!(parsed.get("Title"), Some("Second"));
1013        assert_eq!(parsed.get("Album"), Some("New Album"));
1014        assert_eq!(parsed.fields.len(), 2); // only new fields, not old
1015    }
1016
1017    #[test]
1018    fn remove_tag_from_file() {
1019        // Build file with a tag
1020        let mut tag = ApeTag::new();
1021        tag.set("Title", "To Remove");
1022        let tag_bytes = tag.to_bytes();
1023
1024        let content_size = 50;
1025        let mut file_data: Vec<u8> = vec![0xCC; content_size];
1026        file_data.extend_from_slice(&tag_bytes);
1027
1028        let mut cursor = Cursor::new(file_data);
1029        remove_tag(&mut cursor).unwrap();
1030
1031        // After remove_tag, the cursor position indicates the new logical end.
1032        // For Cursor<Vec<u8>>, we need to manually truncate.
1033        let new_end = cursor.position() as usize;
1034        let mut data = cursor.into_inner();
1035        data.truncate(new_end);
1036
1037        // Read back — should be gone (only original content remains)
1038        let mut read_cursor = Cursor::new(data.as_slice());
1039        let result = read_tag(&mut read_cursor).unwrap();
1040        assert!(result.is_none());
1041        assert_eq!(new_end, content_size); // only original content left
1042    }
1043
1044    #[test]
1045    fn set_binary_field() {
1046        let mut tag = ApeTag::new();
1047        tag.set_binary("Cover Art (front)", vec![0xFF, 0xD8, 0xFF, 0xE0]);
1048
1049        let field = tag.field("Cover Art (front)").unwrap();
1050        assert_eq!(field.field_type(), TagFieldType::Binary);
1051        assert_eq!(field.value, vec![0xFF, 0xD8, 0xFF, 0xE0]);
1052        assert!(field.value_as_str().is_none()); // binary, not text
1053    }
1054}