lr2-oxytabler 0.10.2

Table manager for Lunatic Rave 2
Documentation
use crate::table::Entry;
use anyhow::Result;
use std::collections::HashMap;

pub trait SongNameAccessor {
    fn load(&self, md5: &str) -> Result<Option<String>>;
}

pub struct DbSongNameAccessor<'a> {
    pub db: &'a rusqlite::Connection,
}
impl SongNameAccessor for DbSongNameAccessor<'_> {
    fn load(&self, md5: &str) -> Result<Option<String>> {
        crate::db::load_song(self.db, md5)
    }
}

pub struct FallbackSongNameAccessor<'a> {
    pub fallback: &'a HashMap<String, String>,
}
impl SongNameAccessor for FallbackSongNameAccessor<'_> {
    fn load(&self, md5: &str) -> Result<Option<String>> {
        Ok(self.fallback.get(md5).cloned())
    }
}

fn load_first(accessors: &[&dyn SongNameAccessor], md5: &str) -> Result<Option<String>> {
    for accessor in accessors {
        if let Some(title) = accessor.load(md5)? {
            return Ok(Some(title));
        }
    }
    Ok(None)
}

#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct TableDiff {
    pub changed: Vec<(String, String, String)>,
    pub deleted: Vec<(String, String)>,
    pub new: Vec<(String, String)>,
}

impl TableDiff {
    #[must_use]
    pub fn new(old: &[Entry], new: &[Entry], folder_order: &[String]) -> Self {
        use alphanumeric_sort::compare_str as acmp;
        use itertools::Itertools;

        let mut changes = std::collections::HashMap::<String, (Vec<String>, Vec<String>)>::new();
        // CAVEAT: ideally instead of 'contains()' we would check `count()` to handle duplicate
        // removal. But in practice there will never be duplicate entries since our parser removes
        // them.
        for e in old {
            if !new.contains(e) {
                changes
                    .entry(e.md5.clone())
                    .or_default()
                    .0
                    .push(e.level.clone());
            }
        }
        for e in new {
            if !old.contains(e) {
                changes
                    .entry(e.md5.clone())
                    .or_default()
                    .1
                    .push(e.level.clone());
            }
        }

        let mut changelog = Self {
            changed: vec![],
            deleted: vec![],
            new: vec![],
        };

        for (md5, (deleted, added)) in changes {
            for pair in deleted.into_iter().zip_longest(added) {
                match pair {
                    itertools::EitherOrBoth::Both(from, to) => {
                        changelog.changed.push((md5.clone(), from, to));
                    }
                    itertools::EitherOrBoth::Left(deleted) => {
                        changelog.deleted.push((md5.clone(), deleted));
                    }
                    itertools::EitherOrBoth::Right(added) => {
                        changelog.new.push((md5.clone(), added));
                    }
                }
            }
        }

        let key_by_order = |e: &str| {
            folder_order
                .iter()
                .position(|o| *o == *e)
                .unwrap_or(usize::MAX)
        };
        let ocmp = |a: &String, b: &String| -> std::cmp::Ordering {
            key_by_order(a).cmp(&key_by_order(b))
        };

        changelog.new.sort_unstable_by(|(lh, ll), (rh, rl)| {
            ocmp(ll, rl)
                .then_with(|| acmp(ll, rl))
                .then_with(|| lh.cmp(rh))
        });
        changelog.deleted.sort_unstable_by(|(lh, ll), (rh, rl)| {
            ocmp(ll, rl)
                .then_with(|| acmp(ll, rl))
                .then_with(|| lh.cmp(rh))
        });
        changelog
            .changed
            .sort_unstable_by(|(lh, _, ll), (rh, _, rl)| {
                ocmp(ll, rl)
                    .then_with(|| acmp(ll, rl))
                    .then_with(|| lh.cmp(rh))
            });

        changelog
    }

    pub fn format_full(
        &self,
        accessors: &[&dyn SongNameAccessor],
        symbol: &str,
        user_symbol: Option<&str>,
    ) -> Result<String> {
        let effective_symbol = user_symbol.unwrap_or(symbol);
        let mut out = String::new();
        if !self.new.is_empty() {
            for (md5, level) in &self.new {
                out += "+";
                out += effective_symbol;
                out += level;
                out += " ";
                out += load_first(accessors, md5)?.as_deref().unwrap_or(md5);
                out += "\n";
            }
        }
        if !self.changed.is_empty() {
            if !out.is_empty() {
                out += "\n";
            }
            for (md5, from, to) in &self.changed {
                out += "~";
                out += effective_symbol;
                out += to;
                out += " <- ";
                out += effective_symbol;
                out += from;
                out += " ";
                out += load_first(accessors, md5)?.as_deref().unwrap_or(md5);
                out += "\n";
            }
        }
        if !self.deleted.is_empty() {
            if !out.is_empty() {
                out += "\n";
            }
            for (md5, level) in &self.deleted {
                out += "-";
                out += effective_symbol;
                out += level;
                out += " ";
                out += load_first(accessors, md5)?.as_deref().unwrap_or(md5);
                out += "\n";
            }
        }
        out.pop();
        Ok(out)
    }

    pub fn format_summary(&self) -> Result<String> {
        use std::fmt::Write as _;
        let mut out = String::new();
        if !self.new.is_empty() {
            write!(out, "+{} ", self.new.len())?;
        }
        if !self.changed.is_empty() {
            write!(out, "~{} ", self.changed.len())?;
        }
        if !self.deleted.is_empty() {
            write!(out, "-{} ", self.deleted.len())?;
        }
        out.pop();
        Ok(out)
    }
}

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

    #[test]
    fn calculate_diff() {
        use super::TableDiff;
        use crate::table::Table;
        assert_eq!(
            TableDiff::new(
                &Table::empty()
                    .with_entry(1, "1")
                    .with_entry(1, "1")
                    .with_entry(69, "1")
                    .with_entry(69, "2")
                    .with_entry(0x420, "1")
                    .with_entry(0x1234, "1")
                    .with_entry(0xbeef, "one")
                    .with_entry(0xbeef, "two")
                    .0
                    .entries,
                &Table::empty()
                    .with_entry(0, "10")
                    .with_entry(1, "1")
                    .with_entry(2, "1")
                    .with_entry(2, "1")
                    .with_entry(69, "1")
                    .with_entry(69, "2")
                    .with_entry(0x420, "10")
                    .with_entry(0xbeef, "one")
                    .0
                    .entries,
                &[]
            ),
            TableDiff {
                changed: vec![(
                    "foodfoodfoodfoodfoodfoodfood0420".to_string(),
                    "1".to_string(),
                    "10".to_string()
                )],
                deleted: vec![
                    // ( // not in here and that is fine since our parser removes such duplicates
                    //     "foodfoodfoodfoodfoodfoodfood0001".to_string(),
                    //     "1".to_string()
                    // ),
                    (
                        "foodfoodfoodfoodfoodfoodfood1234".to_string(),
                        "1".to_string()
                    ),
                    (
                        "foodfoodfoodfoodfoodfoodfoodbeef".to_string(),
                        "two".to_string()
                    )
                ],
                new: vec![
                    (
                        "foodfoodfoodfoodfoodfoodfood0002".to_string(),
                        "1".to_string()
                    ),
                    (
                        "foodfoodfoodfoodfoodfoodfood0002".to_string(),
                        "1".to_string()
                    ),
                    (
                        "foodfoodfoodfoodfoodfoodfood0000".to_string(),
                        "10".to_string()
                    ),
                ]
            }
        );
    }

    #[test]
    fn format() {
        let db = crate::db::tests::create_lr2_song_db();
        crate::db::tests::insert_lr2_song(&db, "dbodfoodfoodfoodfoodfoodfoodfood");
        let mut fallback = std::collections::HashMap::new();
        fallback.insert(
            "fbodfoodfoodfoodfoodfoodfoodfood".to_string(),
            "fallback-name".to_string(),
        );
        let accessors: &[&dyn super::SongNameAccessor] = &[
            &super::DbSongNameAccessor { db: &db },
            &super::FallbackSongNameAccessor {
                fallback: &fallback,
            },
        ];

        let changelog = super::TableDiff {
            changed: vec![
                (
                    "dbodfoodfoodfoodfoodfoodfoodfood".to_string(),
                    "from".to_string(),
                    "to".to_string(),
                ),
                (
                    "fbodfoodfoodfoodfoodfoodfoodfood".to_string(),
                    "from".to_string(),
                    "to".to_string(),
                ),
                (
                    "00odfoodfoodfoodfoodfoodfoodfood".to_string(),
                    "from".to_string(),
                    "to".to_string(),
                ),
            ],
            deleted: vec![
                (
                    "dbodfoodfoodfoodfoodfoodfoodfood".to_string(),
                    "del".to_string(),
                ),
                (
                    "fbodfoodfoodfoodfoodfoodfoodfood".to_string(),
                    "del".to_string(),
                ),
                (
                    "00odfoodfoodfoodfoodfoodfoodfood".to_string(),
                    "del".to_string(),
                ),
            ],
            new: vec![
                (
                    "dbodfoodfoodfoodfoodfoodfoodfood".to_string(),
                    "new".to_string(),
                ),
                (
                    "fbodfoodfoodfoodfoodfoodfoodfood".to_string(),
                    "new".to_string(),
                ),
                (
                    "00odfoodfoodfoodfoodfoodfoodfood".to_string(),
                    "new".to_string(),
                ),
            ],
        };
        assert_eq!(changelog.format_summary().unwrap(), "+3 ~3 -3");
        assert_eq!(
            changelog.format_full(accessors, "X", None).unwrap(),
            "+Xnew 3y3s
+Xnew fallback-name
+Xnew 00odfoodfoodfoodfoodfoodfoodfood

~Xto <- Xfrom 3y3s
~Xto <- Xfrom fallback-name
~Xto <- Xfrom 00odfoodfoodfoodfoodfoodfoodfood

-Xdel 3y3s
-Xdel fallback-name
-Xdel 00odfoodfoodfoodfoodfoodfoodfood"
        );
        assert_eq!(
            changelog.format_full(accessors, "X", Some("Z")).unwrap(),
            "+Znew 3y3s
+Znew fallback-name
+Znew 00odfoodfoodfoodfoodfoodfoodfood

~Zto <- Zfrom 3y3s
~Zto <- Zfrom fallback-name
~Zto <- Zfrom 00odfoodfoodfoodfoodfoodfoodfood

-Zdel 3y3s
-Zdel fallback-name
-Zdel 00odfoodfoodfoodfoodfoodfoodfood"
        );
    }
}