melors 0.2.2

Keyboard-first terminal MP3 player with queue, search, and tag editing
use super::*;

impl App {
    pub fn write_track_tags(
        &mut self,
        track_id: i64,
        title: &str,
        artist: &str,
        album: &str,
    ) -> Result<()> {
        let (old_path, new_path) = match self.track_by_id(track_id) {
            Some(t) => {
                let old_path = t.path.clone();
                let ext = old_path.extension().and_then(|e| e.to_str()).unwrap_or("");
                let safe_stem = build_filename_stem(title, artist);
                let new_filename = if ext.is_empty() {
                    safe_stem
                } else {
                    format!("{}.{}", safe_stem, ext)
                };
                let new_path = old_path
                    .parent()
                    .unwrap_or(Path::new("."))
                    .join(&new_filename);
                (old_path, new_path)
            }
            None => return Ok(()),
        };

        if new_path != old_path {
            std::fs::rename(&old_path, &new_path)?;
        }

        crate::services::metadata::write_tag(
            &new_path,
            title,
            if artist.is_empty() {
                None
            } else {
                Some(artist)
            },
            if album.is_empty() { None } else { Some(album) },
        )?;
        self.storage
            .rename_track(track_id, title, &new_path.to_string_lossy())?;
        self.storage.rename_artist(track_id, artist)?;
        self.storage.rename_album(track_id, album)?;
        self.reload_session_state()
    }
}

fn sanitize_filename_part(input: &str) -> String {
    let mut out = String::with_capacity(input.len());
    for ch in input.chars() {
        let mapped = match ch {
            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
            c if c.is_control() => '_',
            c => c,
        };
        out.push(mapped);
    }

    out.trim().trim_end_matches('.').to_string()
}

fn build_filename_stem(title: &str, artist: &str) -> String {
    let safe_title = sanitize_filename_part(title);
    let safe_artist = sanitize_filename_part(artist);

    let combined = if safe_artist.is_empty() {
        safe_title
    } else if safe_title.is_empty() {
        safe_artist
    } else {
        format!("{} - {}", safe_artist, safe_title)
    };

    let trimmed = combined.trim().trim_end_matches('.');
    if trimmed.is_empty() {
        String::from("untitled")
    } else {
        trimmed.to_string()
    }
}

#[cfg(test)]
mod tests {
    use super::{build_filename_stem, sanitize_filename_part};

    #[test]
    fn sanitize_filename_part_replaces_invalid_chars() {
        let value = sanitize_filename_part("A/B:C*D?E\"F<G>H|I");
        assert_eq!(value, "A_B_C_D_E_F_G_H_I");
    }

    #[test]
    fn build_filename_stem_uses_artist_and_title() {
        let value = build_filename_stem("Song Name", "Artist Name");
        assert_eq!(value, "Artist Name - Song Name");
    }

    #[test]
    fn build_filename_stem_falls_back_when_empty() {
        let value = build_filename_stem("...   ", "");
        assert_eq!(value, "untitled");
    }
}