Skip to main content

oximedia_container/metadata/
editor.rs

1//! High-level metadata editing API.
2//!
3//! Provides a convenient interface for reading and modifying metadata
4//! in media files.
5
6use crate::ContainerFormat;
7
8use super::tags::{StandardTag, TagMap, TagValue};
9
10#[cfg(not(target_arch = "wasm32"))]
11use oximedia_core::OxiResult;
12#[cfg(not(target_arch = "wasm32"))]
13use oximedia_io::FileSource;
14#[cfg(not(target_arch = "wasm32"))]
15use std::path::{Path, PathBuf};
16
17#[cfg(not(target_arch = "wasm32"))]
18use super::reader::{detect_format, FlacMetadataReader, MatroskaMetadataReader, MetadataReader};
19#[cfg(not(target_arch = "wasm32"))]
20use super::util::MediaSourceExt;
21#[cfg(not(target_arch = "wasm32"))]
22use super::writer::{
23    FlacMetadataWriter, MatroskaMetadataWriter, MetadataWriter, OggMetadataWriter,
24};
25#[cfg(not(target_arch = "wasm32"))]
26use crate::demux::Demuxer;
27#[cfg(not(target_arch = "wasm32"))]
28use crate::demux::MatroskaDemuxer;
29
30/// Metadata format detection result.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum MetadataFormat {
33    /// FLAC uses Vorbis comments.
34    Flac,
35    /// Ogg (Vorbis, Opus) uses Vorbis comments.
36    Ogg,
37    /// Matroska/WebM uses native tags.
38    Matroska,
39    /// `WebM` (subset of Matroska).
40    WebM,
41}
42
43impl From<ContainerFormat> for MetadataFormat {
44    fn from(format: ContainerFormat) -> Self {
45        match format {
46            ContainerFormat::Flac => Self::Flac,
47            ContainerFormat::Ogg => Self::Ogg,
48            ContainerFormat::WebM => Self::WebM,
49            _ => Self::Matroska, // Default fallback (includes Matroska)
50        }
51    }
52}
53
54#[cfg(not(target_arch = "wasm32"))]
55/// A metadata editor for media files.
56///
57/// Provides high-level operations for reading and writing metadata tags.
58///
59/// # Example
60///
61/// ```ignore
62/// use oximedia_container::metadata::MetadataEditor;
63///
64/// let mut editor = MetadataEditor::open("audio.flac").await?;
65///
66/// // Read existing tags
67/// if let Some(title) = editor.get_text("TITLE") {
68///     println!("Current title: {}", title);
69/// }
70///
71/// // Modify tags
72/// editor.set("TITLE", "New Title");
73/// editor.set("ARTIST", "New Artist");
74/// editor.remove("COMMENT");
75///
76/// // Save changes
77/// editor.save().await?;
78/// ```
79pub struct MetadataEditor {
80    /// Path to the media file.
81    path: PathBuf,
82    /// Detected metadata format.
83    format: MetadataFormat,
84    /// Current tag map.
85    tags: TagMap,
86    /// Whether tags have been modified.
87    modified: bool,
88}
89
90#[cfg(not(target_arch = "wasm32"))]
91impl MetadataEditor {
92    /// Opens a media file for metadata editing.
93    ///
94    /// # Errors
95    ///
96    /// Returns an error if:
97    /// - The file cannot be opened
98    /// - The format is not supported
99    /// - Reading metadata fails
100    pub async fn open(path: impl AsRef<Path>) -> OxiResult<Self> {
101        let path = path.as_ref().to_path_buf();
102
103        // Detect format
104        let mut magic = [0u8; 8];
105        let mut source_clone = FileSource::open(&path).await?;
106        source_clone.read_exact(&mut magic).await?;
107
108        let container_format = detect_format(&magic)?;
109        let format = MetadataFormat::from(container_format);
110
111        // Read metadata based on format
112        let tags = match format {
113            MetadataFormat::Flac => {
114                let source = FileSource::open(&path).await?;
115                FlacMetadataReader::read(source).await?
116            }
117            MetadataFormat::Ogg => {
118                // For Ogg, we would need to use OggDemuxer
119                // This is a simplified placeholder
120                TagMap::new()
121            }
122            MetadataFormat::Matroska | MetadataFormat::WebM => {
123                let source = FileSource::open(&path).await?;
124                let mut demuxer = MatroskaDemuxer::new(source);
125                demuxer.probe().await?;
126
127                let tags = demuxer.tags();
128                MatroskaMetadataReader::convert_tags(tags)
129            }
130        };
131
132        Ok(Self {
133            path,
134            format,
135            tags,
136            modified: false,
137        })
138    }
139
140    /// Returns the metadata format of the file.
141    #[must_use]
142    pub const fn format(&self) -> MetadataFormat {
143        self.format
144    }
145
146    /// Returns true if tags have been modified.
147    #[must_use]
148    pub const fn is_modified(&self) -> bool {
149        self.modified
150    }
151
152    /// Gets a tag value by key.
153    #[must_use]
154    pub fn get(&self, key: &str) -> Option<&TagValue> {
155        self.tags.get(key)
156    }
157
158    /// Gets a text tag value by key.
159    #[must_use]
160    pub fn get_text(&self, key: &str) -> Option<&str> {
161        self.tags.get_text(key)
162    }
163
164    /// Gets all values for a tag key.
165    #[must_use]
166    pub fn get_all(&self, key: &str) -> &[TagValue] {
167        self.tags.get_all(key)
168    }
169
170    /// Gets a standard tag value.
171    #[must_use]
172    pub fn get_standard(&self, tag: StandardTag) -> Option<&TagValue> {
173        self.tags.get_standard(tag)
174    }
175
176    /// Sets a tag value, replacing any existing values.
177    pub fn set(&mut self, key: impl AsRef<str>, value: impl Into<TagValue>) {
178        self.tags.set(key, value);
179        self.modified = true;
180    }
181
182    /// Adds a tag value without removing existing values.
183    pub fn add(&mut self, key: impl AsRef<str>, value: impl Into<TagValue>) {
184        self.tags.add(key, value);
185        self.modified = true;
186    }
187
188    /// Sets a standard tag value.
189    pub fn set_standard(&mut self, tag: StandardTag, value: impl Into<TagValue>) {
190        self.tags.set_standard(tag, value);
191        self.modified = true;
192    }
193
194    /// Removes a tag and all its values.
195    ///
196    /// Returns true if the tag existed.
197    pub fn remove(&mut self, key: &str) -> bool {
198        let removed = self.tags.remove(key);
199        if removed {
200            self.modified = true;
201        }
202        removed
203    }
204
205    /// Clears all tags.
206    pub fn clear(&mut self) {
207        if !self.tags.is_empty() {
208            self.tags.clear();
209            self.modified = true;
210        }
211    }
212
213    /// Returns an iterator over all tag keys.
214    pub fn keys(&self) -> impl Iterator<Item = &str> {
215        self.tags.keys()
216    }
217
218    /// Returns an iterator over all tag entries.
219    pub fn iter(&self) -> impl Iterator<Item = (&str, &TagValue)> {
220        self.tags.iter()
221    }
222
223    /// Returns a reference to the tag map.
224    #[must_use]
225    pub const fn tags(&self) -> &TagMap {
226        &self.tags
227    }
228
229    /// Returns a mutable reference to the tag map.
230    pub fn tags_mut(&mut self) -> &mut TagMap {
231        self.modified = true;
232        &mut self.tags
233    }
234
235    /// Saves metadata changes to the file.
236    ///
237    /// # Errors
238    ///
239    /// Returns an error if:
240    /// - The file cannot be opened for writing
241    /// - Writing fails
242    /// - The format doesn't support metadata writing
243    pub async fn save(&mut self) -> OxiResult<()> {
244        if !self.modified {
245            return Ok(());
246        }
247
248        let mut source = FileSource::open(&self.path).await?;
249
250        match self.format {
251            MetadataFormat::Flac => {
252                FlacMetadataWriter::write(&mut source, &self.tags).await?;
253            }
254            MetadataFormat::Ogg => {
255                OggMetadataWriter::write(&mut source, &self.tags).await?;
256            }
257            MetadataFormat::Matroska | MetadataFormat::WebM => {
258                MatroskaMetadataWriter::write(&mut source, &self.tags).await?;
259            }
260        }
261
262        self.modified = false;
263        Ok(())
264    }
265
266    /// Discards any unsaved changes and reloads metadata from the file.
267    ///
268    /// # Errors
269    ///
270    /// Returns an error if reading fails.
271    pub async fn reload(&mut self) -> OxiResult<()> {
272        let new_editor = Self::open(&self.path).await?;
273        self.tags = new_editor.tags;
274        self.modified = false;
275        Ok(())
276    }
277
278    // ─── Batch operations ───────────────────────────────────────────────
279
280    /// Copies all tags from `source` into this editor, replacing existing values.
281    ///
282    /// This is equivalent to merging the source's tag map into this editor's.
283    pub fn copy_all_from(&mut self, source: &TagMap) {
284        self.tags.merge(source);
285        self.modified = true;
286    }
287
288    /// Copies specific tags from `source` into this editor.
289    ///
290    /// Only tags whose keys are in `tag_keys` will be copied.
291    /// Existing values for those keys will be replaced.
292    pub fn copy_tags_from(&mut self, source: &TagMap, tag_keys: &[&str]) {
293        for &key in tag_keys {
294            let values = source.get_all(key);
295            if !values.is_empty() {
296                // Replace existing
297                self.tags.remove(key);
298                for val in values {
299                    self.tags.add(key, val.clone());
300                }
301                self.modified = true;
302            }
303        }
304    }
305
306    /// Copies standard tags from `source` into this editor.
307    ///
308    /// Only the specified standard tags will be copied.
309    pub fn copy_standard_tags_from(&mut self, source: &TagMap, tags: &[StandardTag]) {
310        for &tag in tags {
311            if let Some(value) = source.get_standard(tag) {
312                self.tags.set_standard(tag, value.clone());
313                self.modified = true;
314            }
315        }
316    }
317
318    /// Applies a batch of tag operations atomically.
319    ///
320    /// All operations are applied in order. If any operation would produce
321    /// no change, it is silently skipped.
322    pub fn apply_batch(&mut self, operations: &[BatchTagOperation]) {
323        let mut any_change = false;
324        for op in operations {
325            match op {
326                BatchTagOperation::Set { key, value } => {
327                    self.tags.set(key.as_str(), value.clone());
328                    any_change = true;
329                }
330                BatchTagOperation::Add { key, value } => {
331                    self.tags.add(key.as_str(), value.clone());
332                    any_change = true;
333                }
334                BatchTagOperation::Remove { key } => {
335                    if self.tags.remove(key) {
336                        any_change = true;
337                    }
338                }
339                BatchTagOperation::Rename { from, to } => {
340                    let values: Vec<TagValue> = self.tags.get_all(from).to_vec();
341                    if !values.is_empty() {
342                        self.tags.remove(from);
343                        for val in values {
344                            self.tags.add(to.as_str(), val);
345                        }
346                        any_change = true;
347                    }
348                }
349                BatchTagOperation::SetStandard { tag, value } => {
350                    self.tags.set_standard(*tag, value.clone());
351                    any_change = true;
352                }
353                BatchTagOperation::RemoveAll => {
354                    if !self.tags.is_empty() {
355                        self.tags.clear();
356                        any_change = true;
357                    }
358                }
359                BatchTagOperation::ReplaceValue {
360                    key,
361                    old_value,
362                    new_value,
363                } => {
364                    let values: Vec<TagValue> = self.tags.get_all(key).to_vec();
365                    let old_text = old_value.as_str();
366                    let has_match = values.iter().any(|v| v.as_text() == Some(old_text));
367                    if has_match {
368                        self.tags.remove(key);
369                        for val in values {
370                            if val.as_text() == Some(old_text) {
371                                self.tags.add(key.as_str(), new_value.clone());
372                            } else {
373                                self.tags.add(key.as_str(), val);
374                            }
375                        }
376                        any_change = true;
377                    }
378                }
379                BatchTagOperation::PrefixValues { key, prefix } => {
380                    let values: Vec<TagValue> = self.tags.get_all(key).to_vec();
381                    if !values.is_empty() {
382                        self.tags.remove(key);
383                        for val in values {
384                            if let Some(text) = val.as_text() {
385                                let new_text = format!("{prefix}{text}");
386                                self.tags.add(key.as_str(), TagValue::Text(new_text));
387                            } else {
388                                self.tags.add(key.as_str(), val);
389                            }
390                        }
391                        any_change = true;
392                    }
393                }
394            }
395        }
396        if any_change {
397            self.modified = true;
398        }
399    }
400
401    /// Returns a diff between this editor's tags and another tag map.
402    ///
403    /// Returns a list of changes needed to transform `other` into this editor's tags.
404    #[must_use]
405    pub fn diff(&self, other: &TagMap) -> Vec<TagDiff> {
406        let mut diffs = Vec::new();
407
408        // Tags present in self but not in other (added)
409        for (key, value) in self.tags.iter() {
410            if other.get(key).is_none() {
411                diffs.push(TagDiff::Added {
412                    key: key.to_string(),
413                    value: value.clone(),
414                });
415            }
416        }
417
418        // Tags present in other but not in self (removed)
419        for (key, _value) in other.iter() {
420            if self.tags.get(key).is_none() {
421                diffs.push(TagDiff::Removed {
422                    key: key.to_string(),
423                });
424            }
425        }
426
427        // Tags present in both but with different values (modified)
428        for (key, self_value) in self.tags.iter() {
429            if let Some(other_value) = other.get(key) {
430                if self_value != other_value {
431                    diffs.push(TagDiff::Modified {
432                        key: key.to_string(),
433                        old_value: other_value.clone(),
434                        new_value: self_value.clone(),
435                    });
436                }
437            }
438        }
439
440        diffs
441    }
442}
443
444/// A batch tag operation to apply to a metadata editor.
445///
446/// Operations are applied in order via [`MetadataEditor::apply_batch`].
447#[derive(Debug, Clone)]
448pub enum BatchTagOperation {
449    /// Set a tag value (replaces existing).
450    Set {
451        /// Tag key.
452        key: String,
453        /// New value.
454        value: TagValue,
455    },
456    /// Add a tag value (preserves existing).
457    Add {
458        /// Tag key.
459        key: String,
460        /// Value to add.
461        value: TagValue,
462    },
463    /// Remove a tag entirely.
464    Remove {
465        /// Tag key to remove.
466        key: String,
467    },
468    /// Rename a tag key (preserves values).
469    Rename {
470        /// Original key.
471        from: String,
472        /// New key.
473        to: String,
474    },
475    /// Set a standard tag value.
476    SetStandard {
477        /// Standard tag identifier.
478        tag: StandardTag,
479        /// New value.
480        value: TagValue,
481    },
482    /// Remove all tags.
483    RemoveAll,
484    /// Replace a specific text value within a tag.
485    ReplaceValue {
486        /// Tag key.
487        key: String,
488        /// Old text value to find.
489        old_value: String,
490        /// New value to replace with.
491        new_value: TagValue,
492    },
493    /// Prefix all text values of a tag with a string.
494    PrefixValues {
495        /// Tag key.
496        key: String,
497        /// Prefix to add.
498        prefix: String,
499    },
500}
501
502impl BatchTagOperation {
503    /// Creates a Set operation.
504    #[must_use]
505    pub fn set(key: impl Into<String>, value: impl Into<TagValue>) -> Self {
506        Self::Set {
507            key: key.into(),
508            value: value.into(),
509        }
510    }
511
512    /// Creates an Add operation.
513    #[must_use]
514    pub fn add(key: impl Into<String>, value: impl Into<TagValue>) -> Self {
515        Self::Add {
516            key: key.into(),
517            value: value.into(),
518        }
519    }
520
521    /// Creates a Remove operation.
522    #[must_use]
523    pub fn remove(key: impl Into<String>) -> Self {
524        Self::Remove { key: key.into() }
525    }
526
527    /// Creates a Rename operation.
528    #[must_use]
529    pub fn rename(from: impl Into<String>, to: impl Into<String>) -> Self {
530        Self::Rename {
531            from: from.into(),
532            to: to.into(),
533        }
534    }
535
536    /// Creates a `SetStandard` operation.
537    #[must_use]
538    pub fn set_standard(tag: StandardTag, value: impl Into<TagValue>) -> Self {
539        Self::SetStandard {
540            tag,
541            value: value.into(),
542        }
543    }
544
545    /// Creates a `RemoveAll` operation.
546    #[must_use]
547    pub const fn remove_all() -> Self {
548        Self::RemoveAll
549    }
550
551    /// Creates a `ReplaceValue` operation.
552    #[must_use]
553    pub fn replace_value(
554        key: impl Into<String>,
555        old_value: impl Into<String>,
556        new_value: impl Into<TagValue>,
557    ) -> Self {
558        Self::ReplaceValue {
559            key: key.into(),
560            old_value: old_value.into(),
561            new_value: new_value.into(),
562        }
563    }
564
565    /// Creates a `PrefixValues` operation.
566    #[must_use]
567    pub fn prefix_values(key: impl Into<String>, prefix: impl Into<String>) -> Self {
568        Self::PrefixValues {
569            key: key.into(),
570            prefix: prefix.into(),
571        }
572    }
573}
574
575/// A diff entry describing a change between two tag maps.
576#[derive(Debug, Clone, PartialEq)]
577pub enum TagDiff {
578    /// A tag was added (present in new, absent in old).
579    Added {
580        /// Tag key.
581        key: String,
582        /// Added value.
583        value: TagValue,
584    },
585    /// A tag was removed (present in old, absent in new).
586    Removed {
587        /// Tag key.
588        key: String,
589    },
590    /// A tag value was modified.
591    Modified {
592        /// Tag key.
593        key: String,
594        /// Old value.
595        old_value: TagValue,
596        /// New value.
597        new_value: TagValue,
598    },
599}
600
601impl TagDiff {
602    /// Returns the key of this diff entry.
603    #[must_use]
604    pub fn key(&self) -> &str {
605        match self {
606            Self::Added { key, .. } | Self::Removed { key, .. } | Self::Modified { key, .. } => key,
607        }
608    }
609
610    /// Returns true if this is an addition.
611    #[must_use]
612    pub const fn is_added(&self) -> bool {
613        matches!(self, Self::Added { .. })
614    }
615
616    /// Returns true if this is a removal.
617    #[must_use]
618    pub const fn is_removed(&self) -> bool {
619        matches!(self, Self::Removed { .. })
620    }
621
622    /// Returns true if this is a modification.
623    #[must_use]
624    pub const fn is_modified(&self) -> bool {
625        matches!(self, Self::Modified { .. })
626    }
627}
628
629// ─────────────────────────────────────────────────────────────────────────────
630// BatchMetadataEditor
631// ─────────────────────────────────────────────────────────────────────────────
632
633/// A single operation in a [`BatchMetadataEditor`] pipeline.
634#[derive(Debug, Clone)]
635pub enum MetadataOp {
636    /// Set `key` to `value`, replacing any existing value.
637    Set {
638        /// Tag key (will be uppercased on application).
639        key: String,
640        /// New value.
641        value: TagValue,
642    },
643    /// Remove `key` entirely.  A no-op if the key is absent.
644    Remove {
645        /// Tag key to remove.
646        key: String,
647    },
648    /// Rename a key from `from` to `to`, preserving its value.
649    /// Silently skipped if `from` is absent.
650    Rename {
651        /// Original key.
652        from: String,
653        /// Replacement key.
654        to: String,
655    },
656    /// Set `key` to `value` only if the key is absent.
657    SetIfAbsent {
658        /// Tag key.
659        key: String,
660        /// Default value.
661        value: TagValue,
662    },
663}
664
665/// Builder-style batch metadata editor.
666///
667/// Accumulates a sequence of [`MetadataOp`] operations and applies them
668/// atomically to a `HashMap<String, TagValue>` or to a media file via
669/// [`apply_to_file`].
670///
671/// # Example
672///
673/// ```ignore
674/// use std::collections::HashMap;
675/// use oximedia_container::metadata::{BatchMetadataEditor, TagValue};
676///
677/// let mut map: HashMap<String, TagValue> = HashMap::new();
678/// map.insert("TITLE".to_string(), "Old".into());
679///
680/// let applied = BatchMetadataEditor::new()
681///     .set("TITLE", "New")
682///     .set("ARTIST", "Artist")
683///     .remove("COMMENT")
684///     .apply(&mut map)
685///     .expect("apply should succeed");
686///
687/// assert_eq!(applied, 2); // TITLE changed + ARTIST inserted
688/// ```
689///
690/// [`apply_to_file`]: BatchMetadataEditor::apply_to_file
691#[derive(Debug, Default)]
692pub struct BatchMetadataEditor {
693    operations: Vec<MetadataOp>,
694}
695
696impl BatchMetadataEditor {
697    /// Creates a new empty editor.
698    #[must_use]
699    pub fn new() -> Self {
700        Self::default()
701    }
702
703    /// Appends a [`MetadataOp::Set`] operation.
704    #[must_use]
705    pub fn set(mut self, key: impl Into<String>, value: impl Into<TagValue>) -> Self {
706        self.operations.push(MetadataOp::Set {
707            key: key.into(),
708            value: value.into(),
709        });
710        self
711    }
712
713    /// Appends a [`MetadataOp::Remove`] operation.
714    #[must_use]
715    pub fn remove(mut self, key: impl Into<String>) -> Self {
716        self.operations.push(MetadataOp::Remove { key: key.into() });
717        self
718    }
719
720    /// Appends a [`MetadataOp::Rename`] operation.
721    #[must_use]
722    pub fn rename(mut self, from: impl Into<String>, to: impl Into<String>) -> Self {
723        self.operations.push(MetadataOp::Rename {
724            from: from.into(),
725            to: to.into(),
726        });
727        self
728    }
729
730    /// Appends a [`MetadataOp::SetIfAbsent`] operation.
731    #[must_use]
732    pub fn set_if_absent(mut self, key: impl Into<String>, value: impl Into<TagValue>) -> Self {
733        self.operations.push(MetadataOp::SetIfAbsent {
734            key: key.into(),
735            value: value.into(),
736        });
737        self
738    }
739
740    /// Applies all operations to `metadata` in order.
741    ///
742    /// Returns the number of operations that actually mutated the map (e.g.
743    /// a `Set` that writes the same value that was already present does not
744    /// count; a `Remove` on an absent key does not count).
745    ///
746    /// # Errors
747    ///
748    /// Currently infallible (returns `Ok`), but the `Result` return type
749    /// allows future versions to add validating operations.
750    pub fn apply(
751        &self,
752        metadata: &mut std::collections::HashMap<String, TagValue>,
753    ) -> oximedia_core::OxiResult<usize> {
754        let mut applied: usize = 0;
755        for op in &self.operations {
756            match op {
757                MetadataOp::Set { key, value } => {
758                    let changed = metadata
759                        .get(key.as_str())
760                        .map_or(true, |existing| existing != value);
761                    metadata.insert(key.clone(), value.clone());
762                    if changed {
763                        applied += 1;
764                    }
765                }
766                MetadataOp::Remove { key } => {
767                    if metadata.remove(key.as_str()).is_some() {
768                        applied += 1;
769                    }
770                }
771                MetadataOp::Rename { from, to } => {
772                    if let Some(value) = metadata.remove(from.as_str()) {
773                        metadata.insert(to.clone(), value);
774                        applied += 1;
775                    }
776                    // Absent `from` key → silently skip
777                }
778                MetadataOp::SetIfAbsent { key, value } => {
779                    if !metadata.contains_key(key.as_str()) {
780                        metadata.insert(key.clone(), value.clone());
781                        applied += 1;
782                    }
783                }
784            }
785        }
786        Ok(applied)
787    }
788
789    /// Applies all operations to the tags of the media file at `path`.
790    ///
791    /// The method:
792    /// 1. Opens the file and reads its current tag map.
793    /// 2. Copies tags into a `HashMap<String, TagValue>`.
794    /// 3. Applies all operations via [`apply`].
795    /// 4. Writes the modified tags back to the file.
796    ///
797    /// Returns the count of operations that changed the tag map.
798    ///
799    /// # Errors
800    ///
801    /// Returns an error if the file cannot be opened, read, or written.
802    ///
803    /// [`apply`]: BatchMetadataEditor::apply
804    #[cfg(not(target_arch = "wasm32"))]
805    pub fn apply_to_file(&self, path: &std::path::Path) -> oximedia_core::OxiResult<usize> {
806        use oximedia_core::OxiError;
807
808        let rt = tokio::runtime::Builder::new_current_thread()
809            .enable_all()
810            .build()
811            .map_err(|e| {
812                OxiError::Io(std::io::Error::new(
813                    std::io::ErrorKind::Other,
814                    e.to_string(),
815                ))
816            })?;
817
818        rt.block_on(async {
819            let mut editor = MetadataEditor::open(path).await?;
820
821            // Snapshot current tags into a HashMap
822            let mut map: std::collections::HashMap<String, TagValue> = editor
823                .iter()
824                .map(|(k, v)| (k.to_string(), v.clone()))
825                .collect();
826
827            let count = self.apply(&mut map)?;
828
829            // Re-sync the editor from the modified HashMap
830            editor.clear();
831            for (k, v) in &map {
832                editor.set(k, v.clone());
833            }
834
835            editor.save().await?;
836            Ok(count)
837        })
838    }
839
840    /// Returns the number of pending operations.
841    #[must_use]
842    pub fn len(&self) -> usize {
843        self.operations.len()
844    }
845
846    /// Returns `true` if no operations have been added.
847    #[must_use]
848    pub fn is_empty(&self) -> bool {
849        self.operations.is_empty()
850    }
851}
852
853#[cfg(not(target_arch = "wasm32"))]
854/// Reads metadata from a media file without creating an editor.
855///
856/// This is a convenience function for read-only metadata access.
857///
858/// # Errors
859///
860/// Returns an error if reading or parsing fails.
861pub async fn read_metadata(path: impl AsRef<Path>) -> OxiResult<TagMap> {
862    let editor = MetadataEditor::open(path).await?;
863    Ok(editor.tags)
864}
865
866#[cfg(not(target_arch = "wasm32"))]
867/// Writes metadata to a file.
868///
869/// This is a convenience function for updating metadata without
870/// reading existing tags first.
871///
872/// # Errors
873///
874/// Returns an error if writing fails.
875pub async fn write_metadata(path: impl AsRef<Path>, tags: &TagMap) -> OxiResult<()> {
876    let mut editor = MetadataEditor::open(path).await?;
877    editor.tags = tags.clone();
878    editor.modified = true;
879    editor.save().await
880}
881
882#[cfg(all(test, not(target_arch = "wasm32")))]
883mod tests {
884    use super::*;
885
886    #[test]
887    fn test_metadata_format_from_container_format() {
888        assert_eq!(
889            MetadataFormat::from(ContainerFormat::Flac),
890            MetadataFormat::Flac
891        );
892        assert_eq!(
893            MetadataFormat::from(ContainerFormat::Ogg),
894            MetadataFormat::Ogg
895        );
896        assert_eq!(
897            MetadataFormat::from(ContainerFormat::Matroska),
898            MetadataFormat::Matroska
899        );
900        assert_eq!(
901            MetadataFormat::from(ContainerFormat::WebM),
902            MetadataFormat::WebM
903        );
904    }
905
906    #[test]
907    fn test_metadata_editor_modification_tracking() {
908        let editor = MetadataEditor {
909            path: PathBuf::from("test.flac"),
910            format: MetadataFormat::Flac,
911            tags: TagMap::new(),
912            modified: false,
913        };
914
915        assert!(!editor.is_modified());
916    }
917
918    #[test]
919    fn test_metadata_editor_set() {
920        let mut editor = MetadataEditor {
921            path: PathBuf::from("test.flac"),
922            format: MetadataFormat::Flac,
923            tags: TagMap::new(),
924            modified: false,
925        };
926
927        editor.set("TITLE", "Test");
928        assert!(editor.is_modified());
929        assert_eq!(editor.get_text("TITLE"), Some("Test"));
930    }
931
932    #[test]
933    fn test_metadata_editor_add() {
934        let mut editor = MetadataEditor {
935            path: PathBuf::from("test.flac"),
936            format: MetadataFormat::Flac,
937            tags: TagMap::new(),
938            modified: false,
939        };
940
941        editor.add("ARTIST", "Artist 1");
942        editor.add("ARTIST", "Artist 2");
943
944        assert!(editor.is_modified());
945        let artists = editor.get_all("ARTIST");
946        assert_eq!(artists.len(), 2);
947    }
948
949    #[test]
950    fn test_metadata_editor_remove() {
951        let mut editor = MetadataEditor {
952            path: PathBuf::from("test.flac"),
953            format: MetadataFormat::Flac,
954            tags: TagMap::new(),
955            modified: false,
956        };
957
958        editor.set("TITLE", "Test");
959        editor.modified = false; // Reset flag
960
961        assert!(editor.remove("TITLE"));
962        assert!(editor.is_modified());
963        assert!(!editor.remove("TITLE"));
964    }
965
966    #[test]
967    fn test_metadata_editor_clear() {
968        let mut editor = MetadataEditor {
969            path: PathBuf::from("test.flac"),
970            format: MetadataFormat::Flac,
971            tags: TagMap::new(),
972            modified: false,
973        };
974
975        editor.set("TITLE", "Test");
976        editor.set("ARTIST", "Test");
977        editor.modified = false;
978
979        editor.clear();
980        assert!(editor.is_modified());
981        assert!(editor.tags.is_empty());
982    }
983
984    #[test]
985    fn test_metadata_editor_standard_tags() {
986        let mut editor = MetadataEditor {
987            path: PathBuf::from("test.flac"),
988            format: MetadataFormat::Flac,
989            tags: TagMap::new(),
990            modified: false,
991        };
992
993        editor.set_standard(StandardTag::Title, "Test Title");
994        assert_eq!(
995            editor
996                .get_standard(StandardTag::Title)
997                .and_then(|v| v.as_text()),
998            Some("Test Title")
999        );
1000    }
1001
1002    #[test]
1003    fn test_metadata_editor_iter() {
1004        let mut editor = MetadataEditor {
1005            path: PathBuf::from("test.flac"),
1006            format: MetadataFormat::Flac,
1007            tags: TagMap::new(),
1008            modified: false,
1009        };
1010
1011        editor.set("TITLE", "Title");
1012        editor.set("ARTIST", "Artist");
1013
1014        let entries: Vec<_> = editor.iter().collect();
1015        assert_eq!(entries.len(), 2);
1016    }
1017
1018    #[test]
1019    fn test_metadata_editor_keys() {
1020        let mut editor = MetadataEditor {
1021            path: PathBuf::from("test.flac"),
1022            format: MetadataFormat::Flac,
1023            tags: TagMap::new(),
1024            modified: false,
1025        };
1026
1027        editor.set("TITLE", "Title");
1028        editor.set("ARTIST", "Artist");
1029
1030        let keys: Vec<_> = editor.keys().collect();
1031        assert_eq!(keys.len(), 2);
1032        assert!(keys.contains(&"TITLE"));
1033        assert!(keys.contains(&"ARTIST"));
1034    }
1035
1036    // ── Batch operation tests ───────────────────────────────────────────
1037
1038    #[test]
1039    fn test_copy_all_from() {
1040        let mut editor = MetadataEditor {
1041            path: PathBuf::from("test.flac"),
1042            format: MetadataFormat::Flac,
1043            tags: TagMap::new(),
1044            modified: false,
1045        };
1046        editor.set("TITLE", "Original");
1047
1048        let mut source = TagMap::new();
1049        source.set("TITLE", "Copied");
1050        source.set("ARTIST", "New Artist");
1051        source.set("ALBUM", "New Album");
1052
1053        editor.copy_all_from(&source);
1054
1055        assert!(editor.is_modified());
1056        assert_eq!(editor.get_text("TITLE"), Some("Copied")); // replaced
1057        assert_eq!(editor.get_text("ARTIST"), Some("New Artist"));
1058        assert_eq!(editor.get_text("ALBUM"), Some("New Album"));
1059    }
1060
1061    #[test]
1062    fn test_copy_tags_from_selective() {
1063        let mut editor = MetadataEditor {
1064            path: PathBuf::from("test.flac"),
1065            format: MetadataFormat::Flac,
1066            tags: TagMap::new(),
1067            modified: false,
1068        };
1069
1070        let mut source = TagMap::new();
1071        source.set("TITLE", "Source Title");
1072        source.set("ARTIST", "Source Artist");
1073        source.set("ALBUM", "Source Album");
1074
1075        editor.copy_tags_from(&source, &["TITLE", "ALBUM"]);
1076
1077        assert!(editor.is_modified());
1078        assert_eq!(editor.get_text("TITLE"), Some("Source Title"));
1079        assert_eq!(editor.get_text("ALBUM"), Some("Source Album"));
1080        assert!(editor.get_text("ARTIST").is_none()); // not copied
1081    }
1082
1083    #[test]
1084    fn test_copy_tags_from_nonexistent() {
1085        let mut editor = MetadataEditor {
1086            path: PathBuf::from("test.flac"),
1087            format: MetadataFormat::Flac,
1088            tags: TagMap::new(),
1089            modified: false,
1090        };
1091
1092        let source = TagMap::new();
1093        editor.copy_tags_from(&source, &["TITLE"]);
1094
1095        assert!(!editor.is_modified()); // Nothing copied
1096    }
1097
1098    #[test]
1099    fn test_copy_standard_tags_from() {
1100        let mut editor = MetadataEditor {
1101            path: PathBuf::from("test.flac"),
1102            format: MetadataFormat::Flac,
1103            tags: TagMap::new(),
1104            modified: false,
1105        };
1106
1107        let mut source = TagMap::new();
1108        source.set_standard(StandardTag::Title, "Std Title");
1109        source.set_standard(StandardTag::Artist, "Std Artist");
1110        source.set_standard(StandardTag::Album, "Std Album");
1111
1112        editor.copy_standard_tags_from(&source, &[StandardTag::Title, StandardTag::Album]);
1113
1114        assert!(editor.is_modified());
1115        assert_eq!(
1116            editor
1117                .get_standard(StandardTag::Title)
1118                .and_then(|v| v.as_text()),
1119            Some("Std Title")
1120        );
1121        assert_eq!(
1122            editor
1123                .get_standard(StandardTag::Album)
1124                .and_then(|v| v.as_text()),
1125            Some("Std Album")
1126        );
1127        assert!(editor.get_standard(StandardTag::Artist).is_none());
1128    }
1129
1130    #[test]
1131    fn test_apply_batch_set_and_add() {
1132        let mut editor = MetadataEditor {
1133            path: PathBuf::from("test.flac"),
1134            format: MetadataFormat::Flac,
1135            tags: TagMap::new(),
1136            modified: false,
1137        };
1138
1139        let ops = vec![
1140            BatchTagOperation::set("TITLE", "Batch Title"),
1141            BatchTagOperation::set("ARTIST", "Main Artist"),
1142            BatchTagOperation::add("ARTIST", "Featured Artist"),
1143        ];
1144
1145        editor.apply_batch(&ops);
1146
1147        assert!(editor.is_modified());
1148        assert_eq!(editor.get_text("TITLE"), Some("Batch Title"));
1149        assert_eq!(editor.get_all("ARTIST").len(), 2);
1150    }
1151
1152    #[test]
1153    fn test_apply_batch_remove() {
1154        let mut editor = MetadataEditor {
1155            path: PathBuf::from("test.flac"),
1156            format: MetadataFormat::Flac,
1157            tags: TagMap::new(),
1158            modified: false,
1159        };
1160
1161        editor.set("TITLE", "Test");
1162        editor.set("ARTIST", "Test");
1163        editor.modified = false;
1164
1165        let ops = vec![BatchTagOperation::remove("TITLE")];
1166        editor.apply_batch(&ops);
1167
1168        assert!(editor.is_modified());
1169        assert!(editor.get_text("TITLE").is_none());
1170        assert_eq!(editor.get_text("ARTIST"), Some("Test")); // untouched
1171    }
1172
1173    #[test]
1174    fn test_apply_batch_rename() {
1175        let mut editor = MetadataEditor {
1176            path: PathBuf::from("test.flac"),
1177            format: MetadataFormat::Flac,
1178            tags: TagMap::new(),
1179            modified: false,
1180        };
1181
1182        editor.set("COMMENT", "My comment");
1183        editor.modified = false;
1184
1185        let ops = vec![BatchTagOperation::rename("COMMENT", "DESCRIPTION")];
1186        editor.apply_batch(&ops);
1187
1188        assert!(editor.is_modified());
1189        assert!(editor.get_text("COMMENT").is_none());
1190        assert_eq!(editor.get_text("DESCRIPTION"), Some("My comment"));
1191    }
1192
1193    #[test]
1194    fn test_apply_batch_set_standard() {
1195        let mut editor = MetadataEditor {
1196            path: PathBuf::from("test.flac"),
1197            format: MetadataFormat::Flac,
1198            tags: TagMap::new(),
1199            modified: false,
1200        };
1201
1202        let ops = vec![
1203            BatchTagOperation::set_standard(StandardTag::Title, "Std Batch"),
1204            BatchTagOperation::set_standard(StandardTag::Genre, "Rock"),
1205        ];
1206
1207        editor.apply_batch(&ops);
1208
1209        assert!(editor.is_modified());
1210        assert_eq!(
1211            editor
1212                .get_standard(StandardTag::Title)
1213                .and_then(|v| v.as_text()),
1214            Some("Std Batch")
1215        );
1216        assert_eq!(
1217            editor
1218                .get_standard(StandardTag::Genre)
1219                .and_then(|v| v.as_text()),
1220            Some("Rock")
1221        );
1222    }
1223
1224    #[test]
1225    fn test_apply_batch_remove_all() {
1226        let mut editor = MetadataEditor {
1227            path: PathBuf::from("test.flac"),
1228            format: MetadataFormat::Flac,
1229            tags: TagMap::new(),
1230            modified: false,
1231        };
1232
1233        editor.set("TITLE", "Title");
1234        editor.set("ARTIST", "Artist");
1235        editor.modified = false;
1236
1237        let ops = vec![BatchTagOperation::remove_all()];
1238        editor.apply_batch(&ops);
1239
1240        assert!(editor.is_modified());
1241        assert!(editor.tags().is_empty());
1242    }
1243
1244    #[test]
1245    fn test_apply_batch_replace_value() {
1246        let mut editor = MetadataEditor {
1247            path: PathBuf::from("test.flac"),
1248            format: MetadataFormat::Flac,
1249            tags: TagMap::new(),
1250            modified: false,
1251        };
1252
1253        editor.add("ARTIST", "Old Artist");
1254        editor.add("ARTIST", "Keep This");
1255        editor.modified = false;
1256
1257        let ops = vec![BatchTagOperation::replace_value(
1258            "ARTIST",
1259            "Old Artist",
1260            "New Artist",
1261        )];
1262        editor.apply_batch(&ops);
1263
1264        assert!(editor.is_modified());
1265        let artists = editor.get_all("ARTIST");
1266        assert_eq!(artists.len(), 2);
1267        // One should be "New Artist", other "Keep This"
1268        let texts: Vec<_> = artists.iter().filter_map(|v| v.as_text()).collect();
1269        assert!(texts.contains(&"New Artist"));
1270        assert!(texts.contains(&"Keep This"));
1271    }
1272
1273    #[test]
1274    fn test_apply_batch_prefix_values() {
1275        let mut editor = MetadataEditor {
1276            path: PathBuf::from("test.flac"),
1277            format: MetadataFormat::Flac,
1278            tags: TagMap::new(),
1279            modified: false,
1280        };
1281
1282        editor.add("GENRE", "Rock");
1283        editor.add("GENRE", "Metal");
1284        editor.modified = false;
1285
1286        let ops = vec![BatchTagOperation::prefix_values("GENRE", "Heavy ")];
1287        editor.apply_batch(&ops);
1288
1289        assert!(editor.is_modified());
1290        let genres = editor.get_all("GENRE");
1291        let texts: Vec<_> = genres.iter().filter_map(|v| v.as_text()).collect();
1292        assert!(texts.contains(&"Heavy Rock"));
1293        assert!(texts.contains(&"Heavy Metal"));
1294    }
1295
1296    #[test]
1297    fn test_apply_batch_no_change() {
1298        let mut editor = MetadataEditor {
1299            path: PathBuf::from("test.flac"),
1300            format: MetadataFormat::Flac,
1301            tags: TagMap::new(),
1302            modified: false,
1303        };
1304
1305        // Remove a non-existent tag and clear empty tags
1306        let ops = vec![BatchTagOperation::remove("NONEXISTENT")];
1307        editor.apply_batch(&ops);
1308
1309        assert!(!editor.is_modified());
1310    }
1311
1312    #[test]
1313    fn test_apply_batch_complex_workflow() {
1314        let mut editor = MetadataEditor {
1315            path: PathBuf::from("test.flac"),
1316            format: MetadataFormat::Flac,
1317            tags: TagMap::new(),
1318            modified: false,
1319        };
1320
1321        // Simulate a complex tag editing workflow
1322        let ops = vec![
1323            BatchTagOperation::set("TITLE", "My Song"),
1324            BatchTagOperation::set("ARTIST", "Band Name"),
1325            BatchTagOperation::set("ALBUM", "Album Title"),
1326            BatchTagOperation::set("DATE", "2024"),
1327            BatchTagOperation::set_standard(StandardTag::Genre, "Alternative"),
1328            BatchTagOperation::set("TRACKNUMBER", "5"),
1329            BatchTagOperation::set("TOTALTRACKS", "12"),
1330        ];
1331
1332        editor.apply_batch(&ops);
1333
1334        assert!(editor.is_modified());
1335        assert_eq!(editor.get_text("TITLE"), Some("My Song"));
1336        assert_eq!(editor.get_text("ARTIST"), Some("Band Name"));
1337        assert_eq!(editor.get_text("ALBUM"), Some("Album Title"));
1338        assert_eq!(editor.get_text("DATE"), Some("2024"));
1339        assert_eq!(editor.get_text("TRACKNUMBER"), Some("5"));
1340    }
1341
1342    // ── TagDiff tests ───────────────────────────────────────────────────
1343
1344    #[test]
1345    fn test_diff_added() {
1346        let mut editor = MetadataEditor {
1347            path: PathBuf::from("test.flac"),
1348            format: MetadataFormat::Flac,
1349            tags: TagMap::new(),
1350            modified: false,
1351        };
1352        editor.set("TITLE", "New");
1353
1354        let other = TagMap::new();
1355        let diffs = editor.diff(&other);
1356
1357        assert!(!diffs.is_empty());
1358        assert!(diffs.iter().any(|d| d.is_added() && d.key() == "TITLE"));
1359    }
1360
1361    #[test]
1362    fn test_diff_removed() {
1363        let editor = MetadataEditor {
1364            path: PathBuf::from("test.flac"),
1365            format: MetadataFormat::Flac,
1366            tags: TagMap::new(),
1367            modified: false,
1368        };
1369
1370        let mut other = TagMap::new();
1371        other.set("TITLE", "Old");
1372
1373        let diffs = editor.diff(&other);
1374        assert!(diffs.iter().any(|d| d.is_removed() && d.key() == "TITLE"));
1375    }
1376
1377    #[test]
1378    fn test_diff_modified() {
1379        let mut editor = MetadataEditor {
1380            path: PathBuf::from("test.flac"),
1381            format: MetadataFormat::Flac,
1382            tags: TagMap::new(),
1383            modified: false,
1384        };
1385        editor.set("TITLE", "New");
1386
1387        let mut other = TagMap::new();
1388        other.set("TITLE", "Old");
1389
1390        let diffs = editor.diff(&other);
1391        assert!(diffs.iter().any(|d| d.is_modified() && d.key() == "TITLE"));
1392    }
1393
1394    #[test]
1395    fn test_diff_no_changes() {
1396        let mut editor = MetadataEditor {
1397            path: PathBuf::from("test.flac"),
1398            format: MetadataFormat::Flac,
1399            tags: TagMap::new(),
1400            modified: false,
1401        };
1402        editor.set("TITLE", "Same");
1403
1404        let mut other = TagMap::new();
1405        other.set("TITLE", "Same");
1406
1407        let diffs = editor.diff(&other);
1408        assert!(diffs.is_empty());
1409    }
1410
1411    #[test]
1412    fn test_tag_diff_methods() {
1413        let added = TagDiff::Added {
1414            key: "TITLE".to_string(),
1415            value: TagValue::Text("Test".to_string()),
1416        };
1417        assert!(added.is_added());
1418        assert!(!added.is_removed());
1419        assert!(!added.is_modified());
1420        assert_eq!(added.key(), "TITLE");
1421
1422        let removed = TagDiff::Removed {
1423            key: "ARTIST".to_string(),
1424        };
1425        assert!(removed.is_removed());
1426
1427        let modified = TagDiff::Modified {
1428            key: "ALBUM".to_string(),
1429            old_value: TagValue::Text("Old".to_string()),
1430            new_value: TagValue::Text("New".to_string()),
1431        };
1432        assert!(modified.is_modified());
1433    }
1434
1435    // ── BatchTagOperation constructor tests ─────────────────────────────
1436
1437    #[test]
1438    fn test_batch_op_constructors() {
1439        let _set = BatchTagOperation::set("TITLE", "Test");
1440        let _add = BatchTagOperation::add("ARTIST", "Test");
1441        let _remove = BatchTagOperation::remove("COMMENT");
1442        let _rename = BatchTagOperation::rename("OLD", "NEW");
1443        let _std = BatchTagOperation::set_standard(StandardTag::Title, "Test");
1444        let _clear = BatchTagOperation::remove_all();
1445        let _replace = BatchTagOperation::replace_value("ARTIST", "Old", "New");
1446        let _prefix = BatchTagOperation::prefix_values("GENRE", "Classic ");
1447    }
1448
1449    // ── BatchMetadataEditor tests ────────────────────────────────────────
1450
1451    #[test]
1452    fn test_batch_metadata_editor_set() {
1453        let mut map: std::collections::HashMap<String, TagValue> = std::collections::HashMap::new();
1454        let count = BatchMetadataEditor::new()
1455            .set("TITLE", TagValue::Text("Hello".to_string()))
1456            .set("ARTIST", TagValue::Text("World".to_string()))
1457            .apply(&mut map)
1458            .expect("apply failed");
1459        assert_eq!(count, 2);
1460        assert_eq!(map.get("TITLE").and_then(|v| v.as_text()), Some("Hello"));
1461        assert_eq!(map.get("ARTIST").and_then(|v| v.as_text()), Some("World"));
1462    }
1463
1464    #[test]
1465    fn test_batch_metadata_editor_remove() {
1466        let mut map: std::collections::HashMap<String, TagValue> = std::collections::HashMap::new();
1467        map.insert("COMMENT".to_string(), TagValue::Text("old".to_string()));
1468        let count = BatchMetadataEditor::new()
1469            .remove("COMMENT")
1470            .apply(&mut map)
1471            .expect("apply failed");
1472        assert_eq!(count, 1);
1473        assert!(!map.contains_key("COMMENT"));
1474    }
1475
1476    #[test]
1477    fn test_batch_metadata_editor_remove_absent() {
1478        let mut map: std::collections::HashMap<String, TagValue> = std::collections::HashMap::new();
1479        let count = BatchMetadataEditor::new()
1480            .remove("NONEXISTENT")
1481            .apply(&mut map)
1482            .expect("apply failed");
1483        assert_eq!(count, 0);
1484    }
1485
1486    #[test]
1487    fn test_batch_metadata_editor_rename() {
1488        let mut map: std::collections::HashMap<String, TagValue> = std::collections::HashMap::new();
1489        map.insert("OLD_KEY".to_string(), TagValue::Text("value".to_string()));
1490        let count = BatchMetadataEditor::new()
1491            .rename("OLD_KEY", "NEW_KEY")
1492            .apply(&mut map)
1493            .expect("apply failed");
1494        assert_eq!(count, 1);
1495        assert!(!map.contains_key("OLD_KEY"));
1496        assert_eq!(map.get("NEW_KEY").and_then(|v| v.as_text()), Some("value"));
1497    }
1498
1499    #[test]
1500    fn test_batch_metadata_editor_rename_absent() {
1501        let mut map: std::collections::HashMap<String, TagValue> = std::collections::HashMap::new();
1502        let count = BatchMetadataEditor::new()
1503            .rename("MISSING", "TARGET")
1504            .apply(&mut map)
1505            .expect("apply failed");
1506        assert_eq!(count, 0);
1507        assert!(!map.contains_key("TARGET"));
1508    }
1509
1510    #[test]
1511    fn test_batch_metadata_editor_set_if_absent_missing() {
1512        let mut map: std::collections::HashMap<String, TagValue> = std::collections::HashMap::new();
1513        let count = BatchMetadataEditor::new()
1514            .set_if_absent("TITLE", TagValue::Text("Default".to_string()))
1515            .apply(&mut map)
1516            .expect("apply failed");
1517        assert_eq!(count, 1);
1518        assert_eq!(map.get("TITLE").and_then(|v| v.as_text()), Some("Default"));
1519    }
1520
1521    #[test]
1522    fn test_batch_metadata_editor_set_if_absent_present() {
1523        let mut map: std::collections::HashMap<String, TagValue> = std::collections::HashMap::new();
1524        map.insert("TITLE".to_string(), TagValue::Text("Existing".to_string()));
1525        let count = BatchMetadataEditor::new()
1526            .set_if_absent("TITLE", TagValue::Text("Default".to_string()))
1527            .apply(&mut map)
1528            .expect("apply failed");
1529        assert_eq!(count, 0);
1530        assert_eq!(map.get("TITLE").and_then(|v| v.as_text()), Some("Existing"));
1531    }
1532
1533    #[test]
1534    fn test_batch_metadata_editor_set_same_value_no_count() {
1535        let mut map: std::collections::HashMap<String, TagValue> = std::collections::HashMap::new();
1536        map.insert("TITLE".to_string(), TagValue::Text("Same".to_string()));
1537        let count = BatchMetadataEditor::new()
1538            .set("TITLE", TagValue::Text("Same".to_string()))
1539            .apply(&mut map)
1540            .expect("apply failed");
1541        assert_eq!(count, 0);
1542    }
1543
1544    #[test]
1545    fn test_batch_metadata_editor_combined() {
1546        let mut map: std::collections::HashMap<String, TagValue> = std::collections::HashMap::new();
1547        map.insert("TITLE".to_string(), TagValue::Text("Old".to_string()));
1548        map.insert("DELETE_ME".to_string(), TagValue::Text("bye".to_string()));
1549
1550        let count = BatchMetadataEditor::new()
1551            .set("TITLE", TagValue::Text("New".to_string()))
1552            .remove("DELETE_ME")
1553            .set_if_absent("ARTIST", TagValue::Text("Unknown".to_string()))
1554            .apply(&mut map)
1555            .expect("apply failed");
1556
1557        assert_eq!(count, 3);
1558        assert_eq!(map.get("TITLE").and_then(|v| v.as_text()), Some("New"));
1559        assert!(!map.contains_key("DELETE_ME"));
1560        assert_eq!(map.get("ARTIST").and_then(|v| v.as_text()), Some("Unknown"));
1561    }
1562
1563    #[test]
1564    fn test_batch_metadata_editor_empty() {
1565        let mut map: std::collections::HashMap<String, TagValue> = std::collections::HashMap::new();
1566        let editor = BatchMetadataEditor::new();
1567        assert!(editor.is_empty());
1568        assert_eq!(editor.len(), 0);
1569        let count = editor.apply(&mut map).expect("apply failed");
1570        assert_eq!(count, 0);
1571    }
1572
1573    #[test]
1574    fn test_batch_metadata_editor_len() {
1575        let editor = BatchMetadataEditor::new()
1576            .set("A", TagValue::Text("1".to_string()))
1577            .remove("B")
1578            .rename("C", "D")
1579            .set_if_absent("E", TagValue::Text("5".to_string()));
1580        assert_eq!(editor.len(), 4);
1581        assert!(!editor.is_empty());
1582    }
1583}