Skip to main content

manabrew_engine/keyword/
keyword_collection.rs

1//! Keyword collection management.
2//!
3//! Ported from Java's `KeywordCollection.java` in `forge/game/keyword/`.
4
5use std::collections::HashMap;
6
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8
9use super::keyword_instance::{Keyword, KeywordInstanceData};
10
11/// A collection of keyword instances on a card.
12/// Mirrors Java's `KeywordCollection` which uses a Multimap<Keyword, KeywordInterface>.
13///
14/// Serializes as a `Vec<String>` of original keyword strings for backward compatibility.
15#[derive(Debug, Clone, Default)]
16pub struct KeywordCollection {
17    /// Map from keyword enum to list of keyword instance original strings.
18    map: HashMap<Keyword, Vec<KeywordInstanceData>>,
19}
20
21impl Serialize for KeywordCollection {
22    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
23        self.as_string_list().serialize(serializer)
24    }
25}
26
27impl<'de> Deserialize<'de> for KeywordCollection {
28    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
29        let strings: Vec<String> = Vec::deserialize(deserializer)?;
30        let mut coll = KeywordCollection::new();
31        for s in &strings {
32            coll.add(s);
33        }
34        Ok(coll)
35    }
36}
37
38impl KeywordCollection {
39    /// Create a new empty keyword collection.
40    pub fn new() -> Self {
41        Self {
42            map: HashMap::new(),
43        }
44    }
45
46    /// Build a `KeywordCollection` from a `Vec<String>`.
47    pub fn from_strings(strings: &[String]) -> Self {
48        let mut coll = Self::new();
49        for s in strings {
50            coll.add(s);
51        }
52        coll
53    }
54
55    /// Whether the collection contains any instance of the given keyword.
56    pub fn contains_keyword(&self, keyword: Keyword) -> bool {
57        self.map.contains_key(&keyword)
58    }
59
60    /// Whether the collection is empty.
61    pub fn is_empty(&self) -> bool {
62        self.map.values().all(|v| v.is_empty())
63    }
64
65    /// Total number of keyword instances.
66    pub fn size(&self) -> usize {
67        self.map.values().map(|v| v.len()).sum()
68    }
69
70    /// Get the total amount for a keyword (sum of all instances).
71    pub fn get_amount(&self, keyword: Keyword) -> i32 {
72        self.map
73            .get(&keyword)
74            .map(|instances| instances.len() as i32)
75            .unwrap_or(0)
76    }
77
78    /// Insert a keyword instance data. Returns true if it was added.
79    pub fn insert(&mut self, inst: KeywordInstanceData) -> bool {
80        let keyword = inst.keyword;
81        let list = self.map.entry(keyword).or_default();
82        if keyword.is_multiple_redundant() {
83            // Check if already present
84            for existing in list.iter() {
85                if existing.original == inst.original {
86                    return false;
87                }
88            }
89        }
90        list.push(inst);
91        true
92    }
93
94    /// Add a keyword from a string, parsing it into the appropriate type.
95    pub fn add(&mut self, k: &str) -> bool {
96        let (keyword, _details) = parse_keyword_string(k);
97        let inst = KeywordInstanceData::new(keyword, k.to_string());
98        self.insert(inst)
99    }
100
101    /// Add all keywords from string iterator.
102    pub fn add_all<'a>(&mut self, keywords: impl IntoIterator<Item = &'a str>) {
103        for k in keywords {
104            self.add(k);
105        }
106    }
107
108    /// Remove all instances whose original string starts with the given prefix.
109    pub fn remove(&mut self, keyword: &str) -> bool {
110        let mut result = false;
111        for list in self.map.values_mut() {
112            let before = list.len();
113            list.retain(|inst| !inst.original.starts_with(keyword));
114            if list.len() != before {
115                result = true;
116            }
117        }
118        result
119    }
120
121    /// Remove all instances of a keyword enum variant.
122    pub fn remove_all(&mut self, keyword: Keyword) -> bool {
123        self.map
124            .remove(&keyword)
125            .map(|v| !v.is_empty())
126            .unwrap_or(false)
127    }
128
129    /// Remove keywords matching any of the given strings.
130    pub fn remove_strings<'a>(&mut self, keywords: impl IntoIterator<Item = &'a str>) -> bool {
131        let mut result = false;
132        for k in keywords {
133            if self.remove(k) {
134                result = true;
135            }
136        }
137        result
138    }
139
140    /// Clear all keywords.
141    pub fn clear(&mut self) {
142        self.map.clear();
143    }
144
145    /// Check if collection contains a keyword by exact original string match.
146    pub fn contains_string(&self, keyword: &str) -> bool {
147        self.map
148            .values()
149            .any(|list| list.iter().any(|inst| inst.original == keyword))
150    }
151
152    /// Check if collection contains a keyword by case-insensitive original string match.
153    pub fn contains_string_ignore_case(&self, keyword: &str) -> bool {
154        self.map.values().any(|list| {
155            list.iter()
156                .any(|inst| inst.original.eq_ignore_ascii_case(keyword))
157        })
158    }
159
160    /// Check if any keyword's original string starts with the given prefix.
161    pub fn any_starts_with(&self, prefix: &str) -> bool {
162        self.map
163            .values()
164            .any(|list| list.iter().any(|inst| inst.original.starts_with(prefix)))
165    }
166
167    /// Check if any keyword's original string starts with the given prefix (case-insensitive).
168    pub fn any_starts_with_ignore_case(&self, prefix: &str) -> bool {
169        let lower = prefix.to_lowercase();
170        self.map.values().any(|list| {
171            list.iter()
172                .any(|inst| inst.original.to_lowercase().starts_with(&lower))
173        })
174    }
175
176    /// Find the first keyword whose original string starts with the given prefix.
177    pub fn find_with_prefix(&self, prefix: &str) -> Option<&str> {
178        for list in self.map.values() {
179            for inst in list {
180                if inst.original.starts_with(prefix) {
181                    return Some(&inst.original);
182                }
183            }
184        }
185        None
186    }
187
188    /// Iterate over original keyword strings.
189    pub fn iter_strings(&self) -> impl Iterator<Item = &str> {
190        self.map
191            .values()
192            .flat_map(|v| v.iter().map(|inst| inst.original.as_str()))
193    }
194
195    /// Retain only keywords matching a predicate on the original string.
196    pub fn retain<F: Fn(&str) -> bool>(&mut self, f: F) {
197        for list in self.map.values_mut() {
198            list.retain(|inst| f(&inst.original));
199        }
200        // Remove empty entries
201        self.map.retain(|_, v| !v.is_empty());
202    }
203
204    /// Extend this collection with keywords from another collection.
205    pub fn extend(&mut self, other: impl IntoIterator<Item = String>) {
206        for s in other {
207            self.add(&s);
208        }
209    }
210
211    /// Get all keyword instance data as a flat list.
212    pub fn get_values(&self) -> Vec<&KeywordInstanceData> {
213        self.map.values().flat_map(|v| v.iter()).collect()
214    }
215
216    /// Get all keyword instances for a specific keyword.
217    pub fn get_values_for(&self, keyword: Keyword) -> Vec<&KeywordInstanceData> {
218        self.map
219            .get(&keyword)
220            .map(|v| v.iter().collect())
221            .unwrap_or_default()
222    }
223
224    /// Check if the collection contains a keyword by string prefix match.
225    /// Mirrors Java's `KeywordCollection.contains(String)`.
226    pub fn contains(&self, keyword: &str) -> bool {
227        self.map
228            .values()
229            .any(|list| list.iter().any(|inst| inst.original.starts_with(keyword)))
230    }
231
232    /// Insert all keyword instance data from a slice.
233    /// Mirrors Java's `KeywordCollection.insertAll(Iterable<KeywordInterface>)`.
234    pub fn insert_all(&mut self, keywords: &[KeywordInstanceData]) {
235        for inst in keywords {
236            self.insert(inst.clone());
237        }
238    }
239
240    /// Remove all instances of a specific keyword string (exact match).
241    /// Mirrors Java's `KeywordCollection.removeInstances(Keyword, String)`.
242    pub fn remove_instances(&mut self, keyword: &str) {
243        for list in self.map.values_mut() {
244            list.retain(|inst| inst.original != keyword);
245        }
246        self.map.retain(|_, v| !v.is_empty());
247    }
248
249    /// Apply a batch of keyword additions and removals.
250    /// Mirrors Java's `KeywordCollection.applyChanges(KeywordsChange)`.
251    pub fn apply_changes(&mut self, additions: &[String], removals: &[String]) {
252        for r in removals {
253            self.remove(r);
254        }
255        for a in additions {
256            self.add(a);
257        }
258    }
259
260    /// Iterate over keyword strings. Alias for `iter_strings` for parity
261    /// with Java's `KeywordCollection.iterator()`.
262    pub fn iterator(&self) -> impl Iterator<Item = &str> {
263        self.iter_strings()
264    }
265
266    /// Get all keywords as a list of original strings.
267    pub fn as_string_list(&self) -> Vec<String> {
268        self.map
269            .values()
270            .flat_map(|v| v.iter().map(|inst| inst.original.clone()))
271            .collect()
272    }
273}
274
275/// Parse a keyword string into a (Keyword, details) pair.
276/// Mirrors Java's `Keyword.getKeywordDetails`.
277pub(crate) fn parse_keyword_string(k: &str) -> (Keyword, String) {
278    if k.contains(':') {
279        let parts: Vec<&str> = k.splitn(2, ':').collect();
280        let keyword = Keyword::smart_value_of(parts[0]);
281        let mut details = parts[1].to_string();
282        // Remove flavor text
283        if let Some(idx) = details.find(":Flavor ") {
284            details.truncate(idx);
285        }
286        (keyword, details)
287    } else if k.contains(' ') {
288        // Try full string first (e.g. "First Strike", "Double Strike")
289        let keyword = Keyword::smart_value_of(k);
290        if keyword != Keyword::Undefined {
291            return (keyword, String::new());
292        }
293        // Try first word (e.g. "Enchant creature")
294        let parts: Vec<&str> = k.splitn(2, ' ').collect();
295        let keyword = Keyword::smart_value_of(parts[0]);
296        if keyword != Keyword::Undefined {
297            (keyword, parts[1].to_string())
298        } else {
299            (Keyword::Undefined, k.to_string())
300        }
301    } else {
302        let keyword = Keyword::smart_value_of(k);
303        (keyword, String::new())
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn test_add_simple_keyword() {
313        let mut coll = KeywordCollection::new();
314        assert!(coll.add("Flying"));
315        assert!(coll.contains_keyword(Keyword::Flying));
316        assert!(coll.contains_string("Flying"));
317    }
318
319    #[test]
320    fn test_redundant_keyword_not_added_twice() {
321        let mut coll = KeywordCollection::new();
322        assert!(coll.add("Flying"));
323        assert!(!coll.add("Flying"));
324        assert_eq!(coll.size(), 1);
325    }
326
327    #[test]
328    fn test_keyword_with_cost() {
329        let mut coll = KeywordCollection::new();
330        assert!(coll.add("Kicker:1 R"));
331        assert!(coll.contains_keyword(Keyword::Kicker));
332    }
333
334    #[test]
335    fn test_remove_keyword() {
336        let mut coll = KeywordCollection::new();
337        coll.add("Flying");
338        coll.add("Haste");
339        assert!(coll.remove("Flying"));
340        assert!(!coll.contains_string("Flying"));
341        assert!(coll.contains_string("Haste"));
342    }
343
344    #[test]
345    fn test_clear() {
346        let mut coll = KeywordCollection::new();
347        coll.add("Flying");
348        coll.add("Haste");
349        coll.clear();
350        assert!(coll.is_empty());
351    }
352
353    #[test]
354    fn test_contains_string_ignore_case() {
355        let mut coll = KeywordCollection::new();
356        coll.add("Flying");
357        assert!(coll.contains_string_ignore_case("flying"));
358        assert!(coll.contains_string_ignore_case("FLYING"));
359        assert!(!coll.contains_string_ignore_case("Haste"));
360    }
361
362    #[test]
363    fn test_any_starts_with() {
364        let mut coll = KeywordCollection::new();
365        coll.add("Protection from red");
366        assert!(coll.any_starts_with("Protection from "));
367        assert!(!coll.any_starts_with("Flying"));
368    }
369
370    #[test]
371    fn test_iter_strings() {
372        let mut coll = KeywordCollection::new();
373        coll.add("Flying");
374        coll.add("Haste");
375        let strings: Vec<&str> = coll.iter_strings().collect();
376        assert_eq!(strings.len(), 2);
377        assert!(strings.contains(&"Flying"));
378        assert!(strings.contains(&"Haste"));
379    }
380
381    #[test]
382    fn test_retain() {
383        let mut coll = KeywordCollection::new();
384        coll.add("Flying");
385        coll.add("Haste");
386        coll.add("Menace");
387        coll.retain(|k| k != "Menace");
388        assert!(coll.contains_string("Flying"));
389        assert!(coll.contains_string("Haste"));
390        assert!(!coll.contains_string("Menace"));
391    }
392
393    #[test]
394    fn test_serde_roundtrip() {
395        let mut coll = KeywordCollection::new();
396        coll.add("Flying");
397        coll.add("Haste");
398        let json = serde_json::to_string(&coll).unwrap();
399        let deserialized: KeywordCollection = serde_json::from_str(&json).unwrap();
400        assert!(deserialized.contains_keyword(Keyword::Flying));
401        assert!(deserialized.contains_keyword(Keyword::Haste));
402    }
403}