forge-foundation 0.3.1

Core Magic types for the manabrew engine: colors, mana, card types, zones, phases
Documentation
use super::card_edition::{CardEdition, EditionEntry, EditionType};
use crate::sealed_product::foil_type::FoilType;
use crate::sealed_product::rarity::Rarity;

pub fn parse_edition(body: &str) -> CardEdition {
    let mut edition = CardEdition::default();
    let mut current_section = String::from("metadata");
    let mut custom_sheet_buf: Option<(String, Vec<String>)> = None;

    for raw in body.lines() {
        let line = strip_comments(raw).trim();
        if line.is_empty() {
            continue;
        }

        if let Some(rest) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
            if let Some((name, rows)) = custom_sheet_buf.take() {
                edition.custom_sheets.insert(name, rows);
            }
            current_section = rest.to_ascii_lowercase();
            if !matches!(
                current_section.as_str(),
                "metadata" | "cards" | "tokens" | "removed cards" | "other cards"
            ) {
                custom_sheet_buf = Some((rest.to_string(), Vec::new()));
            }
            continue;
        }

        match current_section.as_str() {
            "metadata" => apply_metadata(&mut edition, line),
            "cards" => {
                if let Some(entry) = parse_card_row(line) {
                    edition.cards.push(entry);
                }
            }
            "tokens" | "removed cards" | "other cards" => {}
            _ => {
                if let Some((_, rows)) = custom_sheet_buf.as_mut() {
                    rows.push(line.to_string());
                }
            }
        }
    }
    if let Some((name, rows)) = custom_sheet_buf.take() {
        edition.custom_sheets.insert(name, rows);
    }
    edition
}

fn strip_comments(line: &str) -> &str {
    if let Some(idx) = line.find('#') {
        if idx == 0 {
            return "";
        }
    }
    line
}

fn apply_metadata(edition: &mut CardEdition, line: &str) {
    let (key, value) = match line.split_once('=') {
        Some(kv) => kv,
        None => return,
    };
    let key = key.trim();
    let value = value.trim();

    match key.to_ascii_lowercase().as_str() {
        "code" => edition.code = value.to_string(),
        "code2" => edition.code2 = Some(value.to_string()),
        "scryfallcode" => edition.scryfall_code = Some(value.to_string()),
        "name" => edition.name = value.to_string(),
        "date" => edition.date = Some(value.to_string()),
        "type" => edition.edition_type = EditionType::parse(value),
        "foiltype" => edition.foil_type = parse_foil_type(value),
        "foilchanceinbooster" => {
            if let Ok(v) = value.parse::<f64>() {
                edition.foil_chance_in_booster = v;
            }
        }
        "foilalwaysincommonslot" => {
            edition.foil_always_in_common_slot = parse_bool(value);
        }
        "additionalsheetforfoils" => {
            edition.additional_sheet_for_foils = nonempty(value);
        }
        "chancereplacecommonwith" => {
            if let Ok(v) = value.parse::<f64>() {
                edition.chance_replace_common_with = v;
            }
        }
        "slotreplacecommonwith" => {
            edition.slot_replace_common_with = nonempty(value);
        }
        "boostermustcontain" => {
            edition.booster_must_contain = nonempty(value);
        }
        "boosterreplaceslotfromprintsheet" => {
            edition.booster_replace_slot_from_print_sheet = nonempty(value);
        }
        "sheetreplacecardfromsheet" => {
            edition.sheet_replace_card_from_sheet = nonempty(value);
        }
        "sheetreplacecardfromsheet2" => {
            edition.sheet_replace_card_from_sheet2 = nonempty(value);
        }
        "booster" => edition.booster = Some(value.to_string()),
        "draftbooster" => edition.draft_booster = Some(value.to_string()),
        other if other.starts_with("booster") && other != "booster" => {
            let suffix = &key["Booster".len()..];
            if !matches!(
                suffix.to_ascii_lowercase().as_str(),
                "covers"
                    | "boxcount"
                    | "musthave"
                    | "mustcontain"
                    | "replaceslotfromprintsheet"
                    | "arts"
            ) {
                edition
                    .extra_boosters
                    .insert(suffix.to_string(), value.to_string());
            }
        }
        "alias" => edition.alias = nonempty(value),
        "boostercovers" => {
            if let Ok(n) = value.parse::<u32>() {
                edition.booster_covers = n;
            }
        }
        "boosterboxcount" => {
            if let Ok(n) = value.parse::<u32>() {
                edition.booster_box_count = n;
            }
        }
        "fatpackcount" => {
            if let Ok(n) = value.parse::<u32>() {
                edition.fat_pack_count = n;
            }
        }
        "prerelease" => edition.prerelease = nonempty(value),
        "additionalunlockset" => edition.additional_unlock_set = nonempty(value),
        "smallsetoverride" => edition.small_set_override = parse_bool(value),
        _ => {}
    }
}

fn parse_foil_type(s: &str) -> FoilType {
    match s.trim().to_ascii_uppercase().as_str() {
        "MODERN" => FoilType::Modern,
        "OLD_STYLE" | "OLDSTYLE" | "OLD" => FoilType::OldStyle,
        _ => FoilType::NotSupported,
    }
}

fn parse_bool(s: &str) -> bool {
    matches!(
        s.trim().to_ascii_lowercase().as_str(),
        "true" | "yes" | "1" | "on"
    )
}

fn nonempty(s: &str) -> Option<String> {
    let t = s.trim();
    if t.is_empty() {
        None
    } else {
        Some(t.to_string())
    }
}

fn parse_card_row(line: &str) -> Option<EditionEntry> {
    let trimmed = line.trim();
    if trimmed.is_empty() {
        return None;
    }
    let (number, rest) = trimmed.split_once(' ')?;
    let rest = rest.trim();
    let (rarity_str, name_part) = rest.split_once(' ')?;
    let rarity = parse_rarity_letter(rarity_str)?;
    let (name, artist) = match name_part.split_once('@') {
        Some((n, a)) => (n.trim().to_string(), Some(a.trim().to_string())),
        None => (name_part.trim().to_string(), None),
    };
    if name.is_empty() {
        return None;
    }
    Some(EditionEntry {
        collector_number: number.trim().to_string(),
        rarity,
        name,
        artist,
    })
}

fn parse_rarity_letter(letter: &str) -> Option<Rarity> {
    let trimmed = letter.trim();
    let head = trimmed.chars().next()?.to_ascii_uppercase();
    Some(match head {
        'M' => Rarity::Mythic,
        'R' => Rarity::Rare,
        'U' => Rarity::Uncommon,
        'C' => Rarity::Common,
        'L' => Rarity::BasicLand,
        'S' | 'P' => Rarity::Special,
        'T' => Rarity::Token,
        _ => return None,
    })
}

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

    const M21_HEAD: &str = "[metadata]
Code=M21
Date=2020-07-03
Name=Core Set 2021
Type=Core
Booster=10 Common:fromSheet(\"M21 cards\"):!fromSheet(\"M21 Lands\"), 3 Uncommon:fromSheet(\"M21 cards\"), 1 RareMythic:fromSheet(\"M21 cards\"), 1 fromSheet(\"M21 Lands\")
ScryfallCode=M21

[cards]
1 M Ugin, the Spirit Dragon @Raymond Swanland
2 C Alpine Watchdog @Forrest Imel
3 U Angelic Ascension @Volkan Baga
9 R Basri's Lieutenant @Matt Stewart
";

    #[test]
    fn parses_m21_metadata_and_card_rows() {
        let edition = parse_edition(M21_HEAD);
        assert_eq!(edition.code, "M21");
        assert_eq!(edition.name, "Core Set 2021");
        assert_eq!(edition.scryfall_code.as_deref(), Some("M21"));
        assert_eq!(edition.edition_type, EditionType::Core);
        assert_eq!(edition.cards.len(), 4);
        assert_eq!(edition.cards[0].rarity, Rarity::Mythic);
        assert_eq!(edition.cards[0].name, "Ugin, the Spirit Dragon");
        assert!(edition.booster.as_ref().unwrap().contains("RareMythic"));
    }

    #[test]
    fn metadata_carries_into_template() {
        let body = "[metadata]
Code=M21
Name=Core Set 2021
Type=Core
FoilType=MODERN
FoilChanceInBooster=0.33
Booster=10 Common, 3 Uncommon, 1 RareMythic, 1 BasicLand

[cards]
1 M Sample
";
        let edition = parse_edition(body);
        let tpl = edition.to_sealed_template().expect("booster template");
        assert_eq!(tpl.foil_type, FoilType::Modern);
        assert!((tpl.foil_chance - 0.33).abs() < 1e-6);
        assert_eq!(tpl.slots.len(), 4);
    }

    #[test]
    fn captures_custom_print_sheets() {
        let body = "[metadata]
Code=ZZ
Name=Test

[ZZ Lands]
1 Plains
1 Island

[ZZ cards]
1 Sample
";
        let edition = parse_edition(body);
        assert!(edition.custom_sheets.contains_key("ZZ Lands"));
        assert!(edition.custom_sheets.contains_key("ZZ cards"));
        assert_eq!(edition.custom_sheets["ZZ Lands"].len(), 2);
    }
}