Skip to main content

forge_foundation/edition/
parser.rs

1use super::card_edition::{CardEdition, EditionEntry, EditionType};
2use crate::sealed_product::foil_type::FoilType;
3use crate::sealed_product::rarity::Rarity;
4
5pub fn parse_edition(body: &str) -> CardEdition {
6    let mut edition = CardEdition::default();
7    let mut current_section = String::from("metadata");
8    let mut custom_sheet_buf: Option<(String, Vec<String>)> = None;
9
10    for raw in body.lines() {
11        let line = strip_comments(raw).trim();
12        if line.is_empty() {
13            continue;
14        }
15
16        if let Some(rest) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
17            if let Some((name, rows)) = custom_sheet_buf.take() {
18                edition.custom_sheets.insert(name, rows);
19            }
20            current_section = rest.to_ascii_lowercase();
21            if !matches!(
22                current_section.as_str(),
23                "metadata" | "cards" | "tokens" | "removed cards" | "other cards"
24            ) {
25                custom_sheet_buf = Some((rest.to_string(), Vec::new()));
26            }
27            continue;
28        }
29
30        match current_section.as_str() {
31            "metadata" => apply_metadata(&mut edition, line),
32            "cards" => {
33                if let Some(entry) = parse_card_row(line) {
34                    edition.cards.push(entry);
35                }
36            }
37            "tokens" | "removed cards" | "other cards" => {}
38            _ => {
39                if let Some((_, rows)) = custom_sheet_buf.as_mut() {
40                    rows.push(line.to_string());
41                }
42            }
43        }
44    }
45    if let Some((name, rows)) = custom_sheet_buf.take() {
46        edition.custom_sheets.insert(name, rows);
47    }
48    edition
49}
50
51fn strip_comments(line: &str) -> &str {
52    if let Some(idx) = line.find('#') {
53        if idx == 0 {
54            return "";
55        }
56    }
57    line
58}
59
60fn apply_metadata(edition: &mut CardEdition, line: &str) {
61    let (key, value) = match line.split_once('=') {
62        Some(kv) => kv,
63        None => return,
64    };
65    let key = key.trim();
66    let value = value.trim();
67
68    match key.to_ascii_lowercase().as_str() {
69        "code" => edition.code = value.to_string(),
70        "code2" => edition.code2 = Some(value.to_string()),
71        "scryfallcode" => edition.scryfall_code = Some(value.to_string()),
72        "name" => edition.name = value.to_string(),
73        "date" => edition.date = Some(value.to_string()),
74        "type" => edition.edition_type = EditionType::parse(value),
75        "foiltype" => edition.foil_type = parse_foil_type(value),
76        "foilchanceinbooster" => {
77            if let Ok(v) = value.parse::<f64>() {
78                edition.foil_chance_in_booster = v;
79            }
80        }
81        "foilalwaysincommonslot" => {
82            edition.foil_always_in_common_slot = parse_bool(value);
83        }
84        "additionalsheetforfoils" => {
85            edition.additional_sheet_for_foils = nonempty(value);
86        }
87        "chancereplacecommonwith" => {
88            if let Ok(v) = value.parse::<f64>() {
89                edition.chance_replace_common_with = v;
90            }
91        }
92        "slotreplacecommonwith" => {
93            edition.slot_replace_common_with = nonempty(value);
94        }
95        "boostermustcontain" => {
96            edition.booster_must_contain = nonempty(value);
97        }
98        "boosterreplaceslotfromprintsheet" => {
99            edition.booster_replace_slot_from_print_sheet = nonempty(value);
100        }
101        "sheetreplacecardfromsheet" => {
102            edition.sheet_replace_card_from_sheet = nonempty(value);
103        }
104        "sheetreplacecardfromsheet2" => {
105            edition.sheet_replace_card_from_sheet2 = nonempty(value);
106        }
107        "booster" => edition.booster = Some(value.to_string()),
108        "draftbooster" => edition.draft_booster = Some(value.to_string()),
109        other if other.starts_with("booster") && other != "booster" => {
110            let suffix = &key["Booster".len()..];
111            if !matches!(
112                suffix.to_ascii_lowercase().as_str(),
113                "covers"
114                    | "boxcount"
115                    | "musthave"
116                    | "mustcontain"
117                    | "replaceslotfromprintsheet"
118                    | "arts"
119            ) {
120                edition
121                    .extra_boosters
122                    .insert(suffix.to_string(), value.to_string());
123            }
124        }
125        "alias" => edition.alias = nonempty(value),
126        "boostercovers" => {
127            if let Ok(n) = value.parse::<u32>() {
128                edition.booster_covers = n;
129            }
130        }
131        "boosterboxcount" => {
132            if let Ok(n) = value.parse::<u32>() {
133                edition.booster_box_count = n;
134            }
135        }
136        "fatpackcount" => {
137            if let Ok(n) = value.parse::<u32>() {
138                edition.fat_pack_count = n;
139            }
140        }
141        "prerelease" => edition.prerelease = nonempty(value),
142        "additionalunlockset" => edition.additional_unlock_set = nonempty(value),
143        "smallsetoverride" => edition.small_set_override = parse_bool(value),
144        _ => {}
145    }
146}
147
148fn parse_foil_type(s: &str) -> FoilType {
149    match s.trim().to_ascii_uppercase().as_str() {
150        "MODERN" => FoilType::Modern,
151        "OLD_STYLE" | "OLDSTYLE" | "OLD" => FoilType::OldStyle,
152        _ => FoilType::NotSupported,
153    }
154}
155
156fn parse_bool(s: &str) -> bool {
157    matches!(
158        s.trim().to_ascii_lowercase().as_str(),
159        "true" | "yes" | "1" | "on"
160    )
161}
162
163fn nonempty(s: &str) -> Option<String> {
164    let t = s.trim();
165    if t.is_empty() {
166        None
167    } else {
168        Some(t.to_string())
169    }
170}
171
172fn parse_card_row(line: &str) -> Option<EditionEntry> {
173    let trimmed = line.trim();
174    if trimmed.is_empty() {
175        return None;
176    }
177    let (number, rest) = trimmed.split_once(' ')?;
178    let rest = rest.trim();
179    let (rarity_str, name_part) = rest.split_once(' ')?;
180    let rarity = parse_rarity_letter(rarity_str)?;
181    let (name, artist) = match name_part.split_once('@') {
182        Some((n, a)) => (n.trim().to_string(), Some(a.trim().to_string())),
183        None => (name_part.trim().to_string(), None),
184    };
185    if name.is_empty() {
186        return None;
187    }
188    Some(EditionEntry {
189        collector_number: number.trim().to_string(),
190        rarity,
191        name,
192        artist,
193    })
194}
195
196fn parse_rarity_letter(letter: &str) -> Option<Rarity> {
197    let trimmed = letter.trim();
198    let head = trimmed.chars().next()?.to_ascii_uppercase();
199    Some(match head {
200        'M' => Rarity::Mythic,
201        'R' => Rarity::Rare,
202        'U' => Rarity::Uncommon,
203        'C' => Rarity::Common,
204        'L' => Rarity::BasicLand,
205        'S' | 'P' => Rarity::Special,
206        'T' => Rarity::Token,
207        _ => return None,
208    })
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    const M21_HEAD: &str = "[metadata]
216Code=M21
217Date=2020-07-03
218Name=Core Set 2021
219Type=Core
220Booster=10 Common:fromSheet(\"M21 cards\"):!fromSheet(\"M21 Lands\"), 3 Uncommon:fromSheet(\"M21 cards\"), 1 RareMythic:fromSheet(\"M21 cards\"), 1 fromSheet(\"M21 Lands\")
221ScryfallCode=M21
222
223[cards]
2241 M Ugin, the Spirit Dragon @Raymond Swanland
2252 C Alpine Watchdog @Forrest Imel
2263 U Angelic Ascension @Volkan Baga
2279 R Basri's Lieutenant @Matt Stewart
228";
229
230    #[test]
231    fn parses_m21_metadata_and_card_rows() {
232        let edition = parse_edition(M21_HEAD);
233        assert_eq!(edition.code, "M21");
234        assert_eq!(edition.name, "Core Set 2021");
235        assert_eq!(edition.scryfall_code.as_deref(), Some("M21"));
236        assert_eq!(edition.edition_type, EditionType::Core);
237        assert_eq!(edition.cards.len(), 4);
238        assert_eq!(edition.cards[0].rarity, Rarity::Mythic);
239        assert_eq!(edition.cards[0].name, "Ugin, the Spirit Dragon");
240        assert!(edition.booster.as_ref().unwrap().contains("RareMythic"));
241    }
242
243    #[test]
244    fn metadata_carries_into_template() {
245        let body = "[metadata]
246Code=M21
247Name=Core Set 2021
248Type=Core
249FoilType=MODERN
250FoilChanceInBooster=0.33
251Booster=10 Common, 3 Uncommon, 1 RareMythic, 1 BasicLand
252
253[cards]
2541 M Sample
255";
256        let edition = parse_edition(body);
257        let tpl = edition.to_sealed_template().expect("booster template");
258        assert_eq!(tpl.foil_type, FoilType::Modern);
259        assert!((tpl.foil_chance - 0.33).abs() < 1e-6);
260        assert_eq!(tpl.slots.len(), 4);
261    }
262
263    #[test]
264    fn captures_custom_print_sheets() {
265        let body = "[metadata]
266Code=ZZ
267Name=Test
268
269[ZZ Lands]
2701 Plains
2711 Island
272
273[ZZ cards]
2741 Sample
275";
276        let edition = parse_edition(body);
277        assert!(edition.custom_sheets.contains_key("ZZ Lands"));
278        assert!(edition.custom_sheets.contains_key("ZZ cards"));
279        assert_eq!(edition.custom_sheets["ZZ Lands"].len(), 2);
280    }
281}