forge_foundation/edition/
parser.rs1use 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}