lr2-oxytabler 0.8.0

Table manager for Lunatic Rave 2
use crate::{OutputFolder, OutputFolderKey, Table};
use anyhow::{Context, Result, ensure};

#[derive(Clone, Debug)]
pub struct TableMassEditLineData {
    web_url: String,
    output: OutputFolderKey,
    symbol: Option<String>,
}

impl std::fmt::Display for TableMassEditLineData {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_fmt(format_args!(
            "{}{}{}{}{}",
            self.web_url,
            Self::SEPARATOR,
            self.output,
            Self::SEPARATOR,
            self.symbol.as_deref().unwrap_or(Self::DEFAULT_SYMBOL),
        ))
    }
}

impl TableMassEditLineData {
    const DEFAULT_SYMBOL: &str = "DEFAULT";
    const SEPARATOR: &str = "    ";

    fn try_from_table(table: &Table) -> Result<Self> {
        let out = Self {
            web_url: table.0.web_url.0.clone(),
            output: table.1.output.clone(),
            symbol: table.1.user_symbol.clone(),
        };
        ensure!(
            !out.web_url.contains(Self::SEPARATOR) && !out.web_url.contains('\n'),
            "Table URL must not contain separator"
        );
        ensure!(
            !out.output.0.contains(Self::SEPARATOR) && !out.output.0.contains('\n'),
            "Table output must not contain separator"
        );
        if let Some(symbol) = out.symbol.as_ref() {
            ensure!(
                !symbol.contains(Self::SEPARATOR) && !symbol.contains('\n'),
                "Table symbol must not contain separator"
            );
        }
        Ok(out)
    }

    fn try_from_string(s: &str) -> Result<Self> {
        let split = s.split(Self::SEPARATOR).collect::<Vec<&str>>();
        ensure!(split.len() == 3, "invalid entity count in string: {s}");
        Ok(Self {
            web_url: split[0].to_string(),
            output: OutputFolderKey(split[1].to_string()),
            symbol: (split[2] != Self::DEFAULT_SYMBOL).then(|| split[2].to_string()),
        })
    }
}

pub fn with_mass_edit_changes(
    old_tables: &[Table],
    edits: Vec<TableMassEditLineData>,
) -> Result<Vec<Table>> {
    let mut tables = old_tables.to_vec();
    for t in &mut *tables {
        if !edits.iter().any(|line| *line.web_url == t.0.web_url.0) {
            t.1.pending_removal = true;
        }
    }
    for edit in edits {
        match tables.iter_mut().find(|t| t.0.web_url.0 == *edit.web_url) {
            Some(t) => {
                t.1.output = edit.output;
                t.1.user_symbol = edit.symbol;
            }
            None => crate::add_table(&mut tables, edit.web_url.try_into()?, edit.output)?,
        }
    }
    Ok(tables)
}

pub fn parse_mass_edit_lines(s: &str) -> Result<Vec<TableMassEditLineData>> {
    s.split(['\r', '\n'])
        .filter(|s| !s.is_empty())
        .filter(|s| !s.starts_with('#'))
        .map(TableMassEditLineData::try_from_string)
        .collect::<Result<Vec<_>>>()
        .context("failed to parse mass edit lines")
}

pub fn validate_mass_edits(
    edits: &[TableMassEditLineData],
    outputs: &[OutputFolder],
) -> Result<()> {
    for edit in edits {
        ensure!(
            outputs.iter().any(|o| o.0 == edit.output),
            "edit of '{}' references undefined output '{}'",
            edit.web_url,
            edit.output.0
        );
    }
    Ok(())
}

pub fn to_mass_edit_lines(tables: &[Table]) -> Result<String> {
    let mut lines = Vec::<String>::with_capacity(tables.len() + 1);
    lines.push("# '    ' delimited.\n# URL    OUTPUT    SYMBOL ('DEFAULT' for none)".to_string());
    for entry in tables {
        lines.push(format!("# {}", entry.0.name));
        lines.push(
            TableMassEditLineData::try_from_table(entry)
                .context("Failed to convert table to mass edit line")?
                .to_string(),
        );
    }
    Ok(lines.as_slice().join("\n"))
}

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

    #[test]
    fn to_mass_edit_lines() {
        use super::to_mass_edit_lines as f;
        use crate::Table;

        assert_eq!(
            // Technically an invalid URL, they can't have white-spaces
            f(&[Table::empty().with_url("https://?a=    &b=")])
                .unwrap_err()
                .to_string(),
            "Failed to convert table to mass edit line"
        );

        assert_eq!(
            // Uhh, is that even OK for outputs?
            f(&[Table::empty().with_output("    ")])
                .unwrap_err()
                .to_string(),
            "Failed to convert table to mass edit line"
        );

        assert_eq!(
            f(&[Table::empty().with_user_symbol("    ")])
                .unwrap_err()
                .to_string(),
            "Failed to convert table to mass edit line"
        );

        assert_eq!(
            f(&[]).unwrap(),
            "# '    ' delimited.\n# URL    OUTPUT    SYMBOL ('DEFAULT' for none)"
        );

        assert_eq!(
            f(&[
                Table::empty().with_url("https://whatever"),
                Table::empty()
                    .with_name("Solomon難易度表")
                    .with_url("https://solomon")
                    .with_user_symbol("jew")
                    .with_output("output"),
            ])
            .unwrap(),
            "# '    ' delimited.
# URL    OUTPUT    SYMBOL ('DEFAULT' for none)
# 
https://whatever        DEFAULT
# Solomon難易度表
https://solomon    output    jew"
        );
    }

    #[test]
    fn parse_mass_edit_lines() {
        use super::{TableMassEditLineData, parse_mass_edit_lines as f};

        let to_str = |t: &[TableMassEditLineData]| {
            t.iter()
                .map(ToString::to_string)
                .collect::<Vec<_>>()
                .join("\n")
        };

        assert_eq!(f("\n# abracadabra this is ignored\r\n\n").unwrap().len(), 0);
        assert_eq!(
            to_str(&f("#\nbad-protocol://    output    DEFAULT\r\n").unwrap()),
            "bad-protocol://    output    DEFAULT"
        );
        assert_eq!(
            to_str(&f("#\nbad-protocol://        DEFAULT").unwrap()),
            "bad-protocol://        DEFAULT"
        );
        assert_eq!(
            &f("bad").unwrap_err().to_string(),
            "failed to parse mass edit lines"
        );
        assert_eq!(
            &f("a            b").unwrap_err().to_string(),
            "failed to parse mass edit lines"
        );
    }

    #[test]
    fn validate_mass_edits() {
        use super::{TableMassEditLineData, validate_mass_edits as f};
        use crate::{OutputFolder, OutputFolderKey};

        f(&[], &[]).unwrap();

        f(
            &[TableMassEditLineData {
                web_url: "http://".to_string(),
                output: OutputFolderKey("some".into()),
                symbol: None,
            }],
            &[OutputFolder(OutputFolderKey("some".into()), "/tmp".into())],
        )
        .unwrap();

        assert_eq!(
            f(
                &[TableMassEditLineData {
                    web_url: "http://".to_string(),
                    output: OutputFolderKey("some".into()),
                    symbol: None,
                }],
                &[OutputFolder(
                    OutputFolderKey("another".into()),
                    "/tmp".into(),
                )],
            )
            .unwrap_err()
            .to_string(),
            "edit of 'http://' references undefined output 'some'"
        );
    }

    #[test]
    fn with_mass_edit_changes() {
        use super::{TableMassEditLineData, with_mass_edit_changes as f};
        use crate::{OutputFolderKey, Table};

        assert_eq!(
            f(
                &[],
                vec![TableMassEditLineData {
                    web_url: "bad-url".to_string(),
                    output: OutputFolderKey("".into()),
                    symbol: None
                }]
            )
            .unwrap_err()
            .to_string(),
            "Invalid protocol in table URL: bad-url"
        );
        {
            let mut tables = f(
                &[
                    Table::empty().with_url("http://1").with_name("1"),
                    Table::empty()
                        .with_url("http://2")
                        .with_name("2")
                        .with_user_symbol("old"),
                    Table::empty().with_url("http://3").with_name("3"),
                ],
                vec![
                    TableMassEditLineData {
                        web_url: "http://2".to_string(),
                        output: OutputFolderKey("out-for-2".into()),
                        symbol: Some("new".to_string()),
                    },
                    TableMassEditLineData {
                        web_url: "http://3".to_string(),
                        output: OutputFolderKey("out-for-3".into()),
                        symbol: None,
                    },
                    TableMassEditLineData {
                        web_url: "http://4".to_string(),
                        output: OutputFolderKey("out-for-4".into()),
                        symbol: None,
                    },
                ],
            )
            .unwrap();
            Table::commit_removals(&mut tables);
            assert_eq!(
                tables
                    .iter()
                    .map(|t| format!(
                        "{} {} {}",
                        t.0.web_url.0,
                        t.1.output.0,
                        t.1.user_symbol.as_deref().unwrap_or("nil")
                    ))
                    .collect::<Vec<_>>()
                    .as_slice()
                    .join(","),
                "http://2 out-for-2 new,http://3 out-for-3 nil,http://4 out-for-4 nil"
            );
        }
    }
}