use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
const README_FILE_NAME: &str = "README-lr2oxytable.txt";
pub fn validate_good_playlists_folder(path: &Path) -> Result<()> {
anyhow::ensure!(
!path.as_os_str().is_empty(),
"path to playlists folder must not be empty"
);
if !path.exists() {
anyhow::ensure!(
path.parent().is_some_and(std::path::Path::exists),
"path does not exists and neither does it's parent: path={}",
path.display()
);
return Ok(());
}
let entries = std::fs::read_dir(path).context("failed to read dir")?;
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(())
}
fn file_path_for_writing(s: &str) -> &str {
s.trim_matches(' ')
}
#[allow(clippy::too_many_lines)]
pub fn write_files(outputs: &[crate::OutputFolder], tables: &[crate::Table]) -> Result<()> {
use anyhow::ensure;
for crate::OutputFolder(_key, playlists_folder) in outputs {
validate_good_playlists_folder(playlists_folder)?;
std::fs::create_dir_all(playlists_folder)?;
}
for crate::OutputFolder(_key, playlists_folder) in outputs {
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.display()
);
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.display()
);
std::fs::remove_file(path).context("failed to remove file")?;
}
log::debug!("Emptied {}", path.display());
}
}
for crate::OutputFolder(_key, playlists_folder) in outputs {
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'.",
)?;
}
let mut table_name_in_fs = std::collections::BTreeMap::<String, usize>::new();
for crate::Table(table, _add_data) in tables.iter().filter(|t| !t.0.entries.is_empty()) {
*table_name_in_fs
.entry(file_path_for_writing(&table.name).to_string())
.or_insert(0) += 1;
}
table_name_in_fs.retain(|_k, v| *v > 1);
let table_name_in_fs = table_name_in_fs
.keys()
.map(String::as_str)
.collect::<Vec<_>>()
.join(",");
ensure!(
table_name_in_fs.is_empty(),
"must not have duplicate table names: {table_name_in_fs}",
);
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 playlists_folder = &outputs
.iter()
.find(|o| o.0 == add_data.output)
.with_context(|| {
format!(
"missing output '{}' referenced by table, out of outputs: {}",
add_data.output.0,
outputs
.iter()
.map(|o| o.0.0.clone())
.collect::<Vec<_>>()
.join(",")
)
})?
.1;
let mut path = PathBuf::new();
path.push(playlists_folder);
path.push(file_path_for_writing(&table.name));
std::fs::create_dir_all(&path)
.with_context(|| format!("failed to create dir {}", path.display()))?;
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, "", "");
let (contents_cp932, _, had_errors) = encoding_rs::SHIFT_JIS.encode(&contents);
if had_errors {
log::warn!(
"{}: failed to encode .lr2folder file to CP932, this will result in ugly transformations",
table.name,
);
log::debug!("Contents we failed to encode: {contents}");
}
debug_assert!(path.exists()); 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.display());
}
for crate::OutputFolder(_key, playlists_folder) in outputs {
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.display());
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use test_log::test;
pub fn write_files_with_empty_output(
playlists_folder: &std::path::Path,
tables: &[crate::Table],
) -> anyhow::Result<()> {
super::write_files(
&[crate::OutputFolder(
crate::OutputFolderKey("".into()),
playlists_folder.to_path_buf(),
)],
tables,
)
}
#[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 crate::{PlaylistId, Table};
let tables = &[
Table::empty()
.with_id(&mut PlaylistId(0))
.with_url("http://sp")
.with_name(" sp-name ")
.with_symbol(" sp-symbol ")
.with_user_symbol(" sp-user-symbol ")
.with_entry_leveled(" 1 ")
.with_output("SP"),
Table::empty()
.with_id(&mut PlaylistId(1))
.with_url("http://dp")
.with_name("dp-name")
.with_symbol("dp-symbol")
.with_entry()
.with_output("DP"),
];
let tmp1 = tempfile::tempdir().unwrap();
let tmp2 = tempfile::tempdir().unwrap();
super::write_files(
&[
crate::OutputFolder(
crate::OutputFolderKey("SP".into()),
tmp1.path().to_path_buf(),
),
crate::OutputFolder(
crate::OutputFolderKey("DP".into()),
tmp2.path().to_path_buf(),
),
],
tables,
)
.unwrap();
assert_eq!(ls_files(tmp1.path()), "README-lr2oxytable.txt,sp-name");
assert_eq!(ls_files(tmp1.path().join("sp-name")), "0000.lr2folder");
assert_eq!(
std::fs::read_to_string(tmp1.path().join("sp-name").join("0000.lr2folder")).unwrap(),
"#COMMAND song.hash in (SELECT md5 FROM lr2oxytabler_playlist_entry WHERE playlist_id = 0 AND folder = ' 1 ')
#MAXTRACKS 0
#CATEGORY sp-name
#TITLE sp-user-symbol 1
#INFORMATION_A
#INFORMATION_B "
);
assert_eq!(ls_files(tmp2.path()), "README-lr2oxytable.txt,dp-name");
assert_eq!(ls_files(tmp2.path().join("dp-name")), "0000.lr2folder");
assert_eq!(
std::fs::read_to_string(tmp2.path().join("dp-name").join("0000.lr2folder")).unwrap(),
"#COMMAND song.hash in (SELECT md5 FROM lr2oxytabler_playlist_entry WHERE playlist_id = 1 AND folder = '1')
#MAXTRACKS 0
#CATEGORY dp-name
#TITLE dp-symbol1
#INFORMATION_A
#INFORMATION_B "
);
}
#[test]
fn writing_files_removes_old_tables() {
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");
let deleted_table = Table::empty()
.with_id(&mut next_id)
.with_url("http://del".to_string())
.with_name("del".to_string())
.with_entry();
write_files_with_empty_output(tmp.path(), &[old_table, deleted_table]).unwrap();
assert_eq!(ls_files(tmp.path()), "README-lr2oxytable.txt,del,oldname");
write_files_with_empty_output(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 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("✡") .with_entry()];
write_files_with_empty_output(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 ✡1
#INFORMATION_A
#INFORMATION_B "
);
}
#[test]
fn file_writing_disallows_bad_things() {
use crate::{PlaylistId, Table};
assert_eq!(
write_files_with_empty_output(&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_with_empty_output(tmp.path(), &[])
.unwrap_err()
.to_string(),
"directory isn't empty and doesn't contain README-lr2oxytable.txt"
);
}
{
let tmp = tempfile::tempdir().unwrap();
let nest1 = tmp.path().join("not-existing");
let nest2 = nest1.join("not-existing-nested");
let e = write_files_with_empty_output(&nest2, &[])
.unwrap_err()
.to_string();
assert!(
e.starts_with("path does not exists and neither does it's parent: path="),
"e={}",
e
);
assert!(!std::fs::exists(&nest1).unwrap());
write_files_with_empty_output(&nest1, &[]).unwrap();
assert!(std::fs::exists(&nest1).unwrap());
write_files_with_empty_output(&nest2, &[]).unwrap();
assert!(std::fs::exists(&nest2).unwrap());
}
let tmp = tempfile::tempdir().unwrap();
let outputs = &[
crate::OutputFolder(crate::OutputFolderKey("".into()), tmp.path().to_path_buf()),
crate::OutputFolder(crate::OutputFolderKey("2".into()), tmp.path().to_path_buf()),
crate::OutputFolder(crate::OutputFolderKey("3".into()), tmp.path().to_path_buf()),
];
let run = |table| {
super::write_files(outputs, &[table])
.unwrap_err()
.to_string()
};
let run2 = |tables| super::write_files(outputs, tables).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"
);
assert_eq!(
run(Table::empty()
.with_id(&mut PlaylistId(0))
.with_name("good")
.with_entry_leveled(0)
.with_output("no-such-output")),
"missing output 'no-such-output' referenced by table, out of outputs: ,2,3"
);
assert_eq!(
run2(&[
Table::empty().with_name(" name").with_entry(),
Table::empty().with_name("name ").with_entry(),
Table::empty().with_name("name2").with_entry(),
Table::empty().with_name("name2").with_entry(),
Table::empty().with_name("NEW TABLE"),
Table::empty().with_name("NEW TABLE"),
]),
"must not have duplicate table names: name,name2"
);
}
}