lr2-oxytabler 0.2.0

Table manager for Lunatic Rave 2
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};

const README_FILE_NAME: &str = "README-lr2oxytable.txt";

// No new-type for good playlist folder would work here because of TOCTOU.
pub fn validate_good_playlists_folder(path: &Path) -> Result<()> {
    let entries = std::fs::read_dir(path).context("failed to read dir")?;
    // return is_empty() || contains(README_FILE_NAME);
    let mut empty = true;
    for entry in entries {
        empty = false;
        let entry = entry.context("failed to read dir entry")?;
        if entry.file_name() == README_FILE_NAME {
            return Ok(());
        }
    }
    anyhow::ensure!(
        empty,
        "directory isn't empty and doesn't contain {README_FILE_NAME}"
    );
    Ok(())
}

#[allow(clippy::too_many_lines)]
pub fn write_files(playlists_folder: &Path, tables: &[crate::Table]) -> Result<()> {
    use anyhow::ensure;

    ensure!(
        !playlists_folder.as_os_str().is_empty(),
        "path to playlists folder must not be empty"
    );

    validate_good_playlists_folder(playlists_folder)?; // TOCTOU

    for entry in std::fs::read_dir(playlists_folder).context("failed to read dir")? {
        let entry = entry.context("failed to stat")?;
        let path = entry.path();
        if path.is_file() {
            ensure!(
                path.file_name().is_some_and(|f| f == README_FILE_NAME),
                "Non-readme file found: {path:?}",
            );
            continue;
        }
        for file in std::fs::read_dir(&path).context("failed to read lr2folder dir")? {
            let path = file.context("failed to stat file")?.path();
            ensure!(
                path.extension().is_some_and(|ext| ext == "lr2folder"),
                "Non-.lr2folder file found in a level: {path:?}",
            );
            std::fs::remove_file(path).context("failed to remove file")?;
        }
        log::debug!("Emptied {path:?}");
    }

    {
        let mut path = PathBuf::new();
        path.push(playlists_folder);
        path.push(README_FILE_NAME);
        std::fs::write(
            &path,
            "This folder is now managed by lr2oxytable.
Do NOT put anything here you don't want removed by lr2oxytable.

If you've pressed F8 (song update) on a playlist in-game,
and now the playlist doesn't open, the easiest fix is to delete this folder altogether, then
save playlists again in lr2oxytable.
You may need to set song reload type in LR2's launcher to 'Manual reload'.",
        )?;
    }

    for crate::Table(table, add_data) in tables {
        let playlist_id = add_data.playlist_id.context("missing playlist ID")?;
        ensure!(!table.name.is_empty(), "table name must not be empty");
        ensure!(
            !table.name.contains('\n'),
            "table name must not contain new-lines"
        );
        let effective_symbol = add_data.user_symbol.as_ref().unwrap_or(&table.symbol);
        ensure!(
            !effective_symbol.contains('\n'),
            "table symbol must not contain new-lines"
        );

        if table.entries.is_empty() {
            log::info!("Table {} is empty, skipping folder creation", table.name);
            continue;
        }

        let mut path = PathBuf::new();
        path.push(playlists_folder);
        path.push(table.name.clone());

        std::fs::create_dir_all(&path).with_context(|| format!("failed to create dir {path:?}"))?;

        for (i, folder) in crate::TableEntry::extract_levels(&table.entries, &table.folder_order)
            .into_iter()
            .enumerate()
        {
            ensure!(
                !folder.contains('\n'),
                "folder name must not contain new-lines"
            );
            ensure!(
                !folder.contains('\''),
                "folder name must not contain single quotes"
            );

            let contents = format!("
#COMMAND song.hash in (SELECT md5 FROM lr2oxytabler_playlist_entry WHERE playlist_id = {} AND folder = '{}')
#MAXTRACKS 0
#CATEGORY {}
#TITLE {}{}
#INFORMATION_A {}
#INFORMATION_B {}", playlist_id.0, folder, table.name, effective_symbol, folder, "", "");
            // NOTE: LR2 always expects CP932 in text files.
            let (contents_cp932, _, had_errors) = encoding_rs::SHIFT_JIS.encode(&contents);
            if had_errors {
                log::warn!(
                    "Table {}: failed to encode .lr2folder file to CP932. This will result in ugly transformations. Contents: {}",
                    table.name,
                    contents
                );
            }

            debug_assert!(path.exists()); // Created above.
            path.push(format!("{i:0>4}.lr2folder"));
            std::fs::write(&path, contents_cp932).context("failed to write lr2folder file")?;
            path.pop();
        }
        log::debug!("Filled {path:?}");
    }

    for entry in std::fs::read_dir(playlists_folder).context("failed to read dir")? {
        let path = entry.context("failed to stat")?.path();
        if path.is_dir()
            && std::fs::read_dir(&path)
                .context("failed to read lr2folder dir")?
                .next()
                .is_none()
        {
            std::fs::remove_dir(&path).context("failed to remove empty playlist directory")?;
            log::info!("Removed empty playlist directory: {path:?}");
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use test_log::test;

    #[must_use]
    fn ls_files<P: AsRef<std::path::Path>>(path: P) -> String {
        let mut entries = std::fs::read_dir(path)
            .unwrap()
            .map(|res| res.unwrap().file_name().to_str().unwrap().to_string())
            .collect::<Vec<_>>();
        entries.sort();
        entries.join(",")
    }

    #[must_use]
    fn read_to_string_cp932<P: AsRef<std::path::Path>>(path: P) -> String {
        let contents = std::fs::read(path).unwrap();
        let (out, _, had_errors) = encoding_rs::SHIFT_JIS.decode(&contents);
        assert!(!had_errors);
        out.to_string()
    }

    #[test]
    fn writes_files() {
        use super::write_files;
        use crate::{PlaylistId, Table};
        let tmp = tempfile::tempdir().unwrap();
        let tables = &[Table::empty()
            .with_id(&mut PlaylistId(0))
            .with_url("http://url")
            .with_name("somename")
            .with_symbol("nahfam")
            .with_user_symbol("YO")
            .with_entry()];
        write_files(tmp.path(), tables).unwrap();
        assert_eq!(ls_files(tmp.path()), "README-lr2oxytable.txt,somename");
        assert_eq!(ls_files(tmp.path().join("somename")), "0000.lr2folder");
        assert_eq!(
            std::fs::read_to_string(tmp.path().join("somename").join("0000.lr2folder")).unwrap(),
            "
#COMMAND song.hash in (SELECT md5 FROM lr2oxytabler_playlist_entry WHERE playlist_id = 0 AND folder = '1')
#MAXTRACKS 0
#CATEGORY somename
#TITLE YO1
#INFORMATION_A 
#INFORMATION_B "
        );
    }

    /// E.g. 'Stella' got renamed to 'Stella Verified', also we will have such functionality in the
    /// GUI later.
    #[test]
    fn writing_files_removes_old_table_on_table_name_changed() {
        use super::write_files;
        use crate::{PlaylistId, Table};

        let tmp = tempfile::tempdir().unwrap();

        let mut next_id = PlaylistId(0);
        let old_table = Table::empty()
            .with_id(&mut next_id)
            .with_url("http://url".to_string())
            .with_name("oldname".to_string())
            .with_entry();
        let new_table = old_table.clone().with_id(&mut next_id).with_name("newname");

        write_files(tmp.path(), &[old_table]).unwrap();
        assert_eq!(ls_files(tmp.path()), "README-lr2oxytable.txt,oldname");
        // assume(is_good(oldname)); // per other tests

        write_files(tmp.path(), &[new_table]).unwrap();

        assert_eq!(ls_files(tmp.path()), "README-lr2oxytable.txt,newname");

        assert_eq!(ls_files(tmp.path().join("newname")), "0000.lr2folder");

        assert_eq!(
            std::fs::read_to_string(tmp.path().join("newname").join("0000.lr2folder")).unwrap(),
            "
#COMMAND song.hash in (SELECT md5 FROM lr2oxytabler_playlist_entry WHERE playlist_id = 1 AND folder = '1')
#MAXTRACKS 0
#CATEGORY newname
#TITLE 1
#INFORMATION_A 
#INFORMATION_B "
        );
    }

    #[test]
    fn writing_files_ignores_encoding_errors() {
        use super::write_files;
        use crate::{PlaylistId, Table};
        let tmp = tempfile::tempdir().unwrap();
        let tables = &[Table::empty()
            .with_id(&mut PlaylistId(0))
            .with_url("http://url")
            .with_name("Solomon難易度表")
            .with_symbol("") // this symbol doesn't convert to cp932
            .with_entry()];
        write_files(tmp.path(), tables).unwrap();
        assert_eq!(
            ls_files(tmp.path()),
            "README-lr2oxytable.txt,Solomon難易度表"
        );
        assert_eq!(
            ls_files(tmp.path().join("Solomon難易度表")),
            "0000.lr2folder"
        );
        assert_eq!(
            read_to_string_cp932(tmp.path().join("Solomon難易度表").join("0000.lr2folder")),
            "
#COMMAND song.hash in (SELECT md5 FROM lr2oxytabler_playlist_entry WHERE playlist_id = 0 AND folder = '1')
#MAXTRACKS 0
#CATEGORY Solomon難易度表
#TITLE &#10017;1
#INFORMATION_A 
#INFORMATION_B "
        );
    }

    #[test]
    fn file_writing_disallows_bad_things() {
        use super::write_files;
        use crate::{PlaylistId, Table};

        assert_eq!(
            write_files(&std::path::PathBuf::new(), &[])
                .unwrap_err()
                .to_string(),
            "path to playlists folder must not be empty"
        );

        {
            let tmp = tempfile::tempdir().unwrap();
            std::fs::write(tmp.path().join("some-file"), "").unwrap();
            assert_eq!(
                write_files(tmp.path(), &[]).unwrap_err().to_string(),
                "directory isn't empty and doesn't contain README-lr2oxytable.txt"
            );
        }

        let tmp = tempfile::tempdir().unwrap();
        let run = |table| write_files(tmp.path(), &[table]).unwrap_err().to_string();

        assert_eq!(
            run(Table::empty().with_name("no id\n")),
            "missing playlist ID"
        );

        assert_eq!(
            run(Table::empty().with_id(&mut PlaylistId(0))),
            "table name must not be empty"
        );

        assert_eq!(
            run(Table::empty()
                .with_id(&mut PlaylistId(0))
                .with_name("bad\n")),
            "table name must not contain new-lines"
        );

        assert_eq!(
            run(Table::empty()
                .with_id(&mut PlaylistId(0))
                .with_name("good")
                .with_symbol("bad\n")),
            "table symbol must not contain new-lines"
        );

        assert_eq!(
            run(Table::empty()
                .with_id(&mut PlaylistId(0))
                .with_name("good")
                .with_user_symbol("bad\n")),
            "table symbol must not contain new-lines"
        );

        assert_eq!(
            run(Table::empty()
                .with_id(&mut PlaylistId(0))
                .with_entry_leveled("1\n")
                .with_name("good")),
            "folder name must not contain new-lines"
        );

        assert_eq!(
            run(Table::empty()
                .with_id(&mut PlaylistId(0))
                .with_entry_leveled("1'")
                .with_name("good")),
            "folder name must not contain single quotes"
        );
    }
}