From aca5be2a9e90ccaae7ecab15b91837c1b0e75e5d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tristan=20Dani=C3=ABl=20Maat?= <tm@tlater.net>
Date: Sun, 9 Mar 2025 06:04:29 +0800
Subject: [PATCH] feat(lib): Implement draft list parser

---
 src/draft_list.rs | 122 ++++++++++++++++++++++++++++++++++++++++++++++
 src/lib.rs        |   1 +
 2 files changed, 123 insertions(+)
 create mode 100644 src/draft_list.rs
 create mode 100644 src/lib.rs

diff --git a/src/draft_list.rs b/src/draft_list.rs
new file mode 100644
index 0000000..14451cb
--- /dev/null
+++ b/src/draft_list.rs
@@ -0,0 +1,122 @@
+use std::{collections::HashMap, io::BufRead};
+use thiserror::Error;
+
+/// Parser for Will's special banlist format that lists extra copies
+/// of cards in comments
+pub struct DraftList {
+    pub title: String,
+    pub cards: HashMap<u32, usize>,
+}
+
+impl DraftList {
+    /// Parse a draft list from a u8 slice.
+    pub fn from_slice(file: &[u8]) -> Result<Self> {
+        let mut title = None;
+        let mut cards = HashMap::new();
+
+        for line in file.lines() {
+            let line = line?;
+
+            fn parse_card(card_id: &str, quantity: &str) -> Result<(u32, usize)> {
+                let id = card_id
+                    .parse()
+                    .map_err(|err| DraftListParseError::InvalidCardID(card_id.to_owned(), err))?;
+
+                let quantity = quantity.parse().map_err(|err| {
+                    DraftListParseError::InvalidCardQuantity(quantity.to_owned(), err)
+                })?;
+
+                Ok((id, quantity))
+            }
+
+            if let Some(spare) = line.strip_prefix("###") {
+                let spare = spare.trim();
+                let Some((id, quantity)) = spare.split_once(' ') else {
+                    // Just assume that this is a comment
+                    continue;
+                };
+
+                let (id, quantity) = parse_card(id, quantity)?;
+                // The spare quantity does *not* include the base 3
+                // cards we should have if we have spares.
+                cards.insert(id, quantity + 3);
+            } else if let Some(t) = line.strip_prefix('!') {
+                title = Some(t.to_owned());
+            } else if line.starts_with('#') {
+                // Skip any comments with less than 3 #
+                continue;
+            } else if let Some((id, quantity)) = line.split_once(' ') {
+                // We record invalid cards with -1, which obviously
+                // doesn't parse to usize, so just skip these
+                if quantity.starts_with('-') {
+                    continue;
+                }
+
+                let (id, quantity) = parse_card(id, quantity)?;
+                cards.insert(id, quantity);
+            } else if line == "$blacklist" {
+                return Err(DraftListParseError::InputIsBlacklist);
+            }
+        }
+
+        Ok(Self {
+            title: title.ok_or(DraftListParseError::MissingTitle)?,
+            cards,
+        })
+    }
+}
+
+#[derive(Debug, Error)]
+pub enum DraftListParseError {
+    #[error("draft list is corrupted")]
+    MalformedInput(#[from] std::io::Error),
+    #[error("draft list is actually a blacklist; currently unsupported")]
+    InputIsBlacklist,
+    #[error("draft list lacks a title")]
+    MissingTitle,
+    #[error("draft list contains an invalid card id: {}", 0)]
+    InvalidCardID(String, #[source] std::num::ParseIntError),
+    #[error("draft list contains an invalid card quantity: {}", 0)]
+    InvalidCardQuantity(String, #[source] std::num::ParseIntError),
+}
+
+type Result<T> = std::result::Result<T, DraftListParseError>;
+
+#[cfg(test)]
+mod tests {
+    use dedent::dedent;
+
+    use super::DraftList;
+
+    #[test]
+    fn simple_list() {
+        let list = dedent!(
+            r#"
+            !Treestan - Mar2024 17
+            $whitelist
+
+            84177693 1
+            70491682 2
+            13361027 3
+
+            # Some irrelevant comment
+            160405002 -1
+
+            ###   10731333 5
+            ###   46136942 3
+            ###   36211150 12
+        "#
+        )
+        .as_bytes();
+
+        let list = DraftList::from_slice(list).expect("draft list must parse correctly");
+
+        assert_eq!(list.title, "Treestan - Mar2024 17");
+        assert_eq!(list.cards.get(&84177693), Some(&1));
+        assert_eq!(list.cards.get(&70491682), Some(&2));
+        assert_eq!(list.cards.get(&13361027), Some(&3));
+        assert_eq!(list.cards.get(&10731333), Some(&8));
+        assert_eq!(list.cards.get(&46136942), Some(&6));
+        assert_eq!(list.cards.get(&36211150), Some(&15));
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..58a3ab2
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1 @@
+pub mod draft_list;