lr2-oxytabler 0.10.2

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

#[derive(Clone, Debug)]
pub struct TableMassEditLineData {
    web_url: String,
    output: OutputFolderKey,
    name: Option<String>,
    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.name.as_deref().unwrap_or(Self::DEFAULT_SYMBOL),
            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.as_str().to_string(),
            output: table.1.output.clone(),
            name: table.1.user_name.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 or newline",
            out.web_url
        );
        ensure!(
            !out.output.0.contains(Self::SEPARATOR) && !out.output.0.contains('\n'),
            "Table output '{}' must not contain separator or newline",
            out.output.0
        );
        if let Some(name) = out.name.as_ref() {
            ensure!(
                !name.contains(Self::SEPARATOR) && !name.contains('\n'),
                "Table name '{name}' must not contain separator or newline",
            );
        }
        if let Some(symbol) = out.symbol.as_ref() {
            ensure!(
                !symbol.contains(Self::SEPARATOR) && !symbol.contains('\n'),
                "Table symbol '{symbol}' must not contain separator or newline",
            );
        }
        Ok(out)
    }

    fn try_from_string(s: &str) -> Result<Self> {
        let split = s.split(Self::SEPARATOR).collect::<Vec<&str>>();
        ensure!(split.len() == 4, "invalid entity count in string: {s}");
        Ok(Self {
            web_url: split[0].to_string(),
            output: OutputFolderKey(split[1].to_string()),
            name: (split[2] != Self::DEFAULT_SYMBOL).then(|| split[2].to_string()),
            symbol: (split[3] != Self::DEFAULT_SYMBOL).then(|| split[3].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.as_str())
        {
            t.1.pending_removal = true;
        }
    }
    for edit in edits {
        match tables
            .iter_mut()
            .find(|t| t.0.web_url.as_str() == edit.web_url)
        {
            Some(t) => {
                t.1.output = edit.output;
                t.1.user_name = edit.name;
                t.1.user_symbol = edit.symbol;
            }
            None => Table::insert_new(&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    NAME ('DEFAULT' for none)    SYMBOL ('DEFAULT' for none)".to_string(),
    );
    for entry in tables {
        lines.push(format!("# {} ({})", entry.0.name, entry.0.symbol));
        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::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    NAME ('DEFAULT' for none)    SYMBOL ('DEFAULT' for none)"
        );

        assert_eq!(
            f(&[
                Table::empty()
                    .with_name("name")
                    .with_user_name("user-name")
                    .with_url("https://url")
                    .with_symbol("symbol")
                    .with_user_symbol("user-symbol")
                    .with_output("output"),
                Table::empty().with_url("https://url"),
            ])
            .unwrap(),
            "# '    ' delimited.
# URL    OUTPUT    NAME ('DEFAULT' for none)    SYMBOL ('DEFAULT' for none)
# name (symbol)
https://url    output    user-name    user-symbol
#  ()
https://url        DEFAULT    DEFAULT"
        );
    }

    #[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<_>>();

        assert_eq!(
            to_str(
                &f("# comment line ignored\r\nbad-protocol://    output    DEFAULT    DEFAULT")
                    .unwrap()
            ),
            ["bad-protocol://    output    DEFAULT    DEFAULT"]
        );
        assert_eq!(
            &f("bad").unwrap_err().to_string(),
            "failed to parse mass edit lines"
        );
        assert_eq!(
            &f(".    .    .    .    excessive-field")
                .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()),
                name: None,
                symbol: None,
            }],
            &[OutputFolder(OutputFolderKey("some".into()), "/tmp".into())],
        )
        .unwrap();

        assert_eq!(
            f(
                &[TableMassEditLineData {
                    web_url: "http://".to_string(),
                    output: OutputFolderKey("some".into()),
                    name: None,
                    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::Table};

        assert_eq!(
            f(
                &[],
                vec![TableMassEditLineData {
                    web_url: "bad-url".to_string(),
                    output: OutputFolderKey(String::new()),
                    name: None,
                    symbol: None,
                }]
            )
            .unwrap_err()
            .to_string(),
            "Invalid protocol in 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()),
                        name: Some("new".to_string()),
                        symbol: Some("new".to_string()),
                    },
                    TableMassEditLineData {
                        web_url: "http://3".to_string(),
                        output: OutputFolderKey("out-for-3".into()),
                        name: None,
                        symbol: None,
                    },
                    TableMassEditLineData {
                        web_url: "http://4".to_string(),
                        output: OutputFolderKey("out-for-4".into()),
                        name: None,
                        symbol: None,
                    },
                ],
            )
            .unwrap();
            Table::commit_removals(&mut tables);
            assert_eq!(
                tables
                    .iter()
                    .map(|t| format!(
                        "{} {} {} {}",
                        t.0.web_url.as_str(),
                        t.1.output.0,
                        t.1.user_name.as_deref().unwrap_or("nil"),
                        t.1.user_symbol.as_deref().unwrap_or("nil")
                    ))
                    .collect::<Vec<_>>(),
                [
                    "http://2 out-for-2 new new",
                    "http://3 out-for-3 nil nil",
                    "http://4 out-for-4 nil nil"
                ]
            );
        }
    }
}