Skip to main content

eml_nl/common/
list_data.rs

1use std::num::NonZeroU64;
2
3use thiserror::Error;
4
5use crate::{
6    EMLError, EMLValueResultExt, NS_KR,
7    io::{EMLElement, EMLElementReader, EMLElementWriter, QualifiedName, collect_struct},
8    utils::{ContestId, PublicationLanguage, StringValue, StringValueData},
9};
10
11/// Additional data for affiliation lists.
12#[derive(Debug, Clone)]
13pub struct ListData {
14    /// Whether to publish the genders for this list.
15    pub publish_gender: StringValue<bool>,
16
17    /// The publication language for this list.
18    pub publication_language: Option<StringValue<PublicationLanguage>>,
19
20    /// If this list is of type [`AffiliationType::SetOfEqualLists`](crate::utils::AffiliationType::SetOfEqualLists), the set
21    /// it belongs to.
22    pub belongs_to_set: Option<StringValue<NonZeroU64>>,
23
24    /// If this list is of type [`AffiliationType::GroupOfLists`](crate::utils::AffiliationType::GroupOfLists), the
25    /// combination it belongs to.
26    pub belongs_to_combination: Option<StringValue<ListDataBelongsToCombination>>,
27
28    /// An optional list of contests this list is associated with.
29    pub contests: Vec<ListDataContest>,
30}
31
32impl ListData {
33    /// Create a new `ListData` with default values.
34    pub fn new(publish_gender: bool) -> Self {
35        ListData {
36            publish_gender: StringValue::Parsed(publish_gender),
37            publication_language: None,
38            belongs_to_set: None,
39            belongs_to_combination: None,
40            contests: Vec::new(),
41        }
42    }
43
44    /// Get the publication language, defaulting to [`PublicationLanguage::default()`] if not set or invalid.
45    pub fn get_publication_language(&self) -> PublicationLanguage {
46        self.publication_language
47            .as_ref()
48            .map(|s| match s {
49                StringValue::Parsed(v) => *v,
50                StringValue::Raw(r) => PublicationLanguage::from_eml_value(r).unwrap_or_default(),
51            })
52            .unwrap_or_default()
53    }
54
55    /// Set the publication language for this list.
56    pub fn with_publication_language(mut self, language: PublicationLanguage) -> Self {
57        self.publication_language = Some(StringValue::Parsed(language));
58        self
59    }
60
61    /// Set the set this list belongs to, if it is of type
62    /// [`AffiliationType::SetOfEqualLists`](crate::utils::AffiliationType::SetOfEqualLists).
63    pub fn with_belongs_to_set(mut self, set_id: NonZeroU64) -> Self {
64        self.belongs_to_set = Some(StringValue::Parsed(set_id));
65        self
66    }
67
68    /// Set the combination this list belongs to, if it is of type
69    /// [`AffiliationType::GroupOfLists`](crate::utils::AffiliationType::GroupOfLists).
70    pub fn with_belongs_to_combination(
71        mut self,
72        combination_id: ListDataBelongsToCombination,
73    ) -> Self {
74        self.belongs_to_combination = Some(StringValue::Parsed(combination_id));
75        self
76    }
77}
78
79impl EMLElement for ListData {
80    const EML_NAME: QualifiedName<'_, '_> = QualifiedName::from_static("ListData", Some(NS_KR));
81
82    fn read_eml(elem: &mut EMLElementReader<'_, '_>) -> Result<Self, EMLError> {
83        let publish_gender = elem.string_value_attr("PublishGender", None)?;
84        let publication_language = elem.string_value_attr_opt("PublicationLanguage")?;
85        let belongs_to_set = elem.string_value_attr_opt("BelongsToSet")?;
86        let belongs_to_combination = elem.string_value_attr_opt("BelongsToCombination")?;
87
88        // temporary struct to collect optional contests element
89        struct ListDataContests {
90            contests: Option<Vec<ListDataContest>>,
91        }
92
93        let tmp = collect_struct!(elem, ListDataContests {
94            contests as Option: ("Contests", NS_KR) => |elem| {
95                // Temporary struct to collect contest elements
96                struct Contests {
97                    contests: Vec<ListDataContest>,
98                }
99
100                let res = collect_struct!(elem, Contests {
101                    contests as Vec: ListDataContest::EML_NAME => |elem| ListDataContest::read_eml(elem)?,
102                });
103
104                res.contests
105            },
106        });
107
108        Ok(ListData {
109            publish_gender,
110            publication_language,
111            belongs_to_set,
112            belongs_to_combination,
113            contests: tmp.contests.unwrap_or_default(),
114        })
115    }
116
117    fn write_eml(&self, writer: EMLElementWriter) -> Result<(), EMLError> {
118        let writer = writer
119            .attr("PublishGender", &self.publish_gender.raw())?
120            .attr_opt(
121                "PublicationLanguage",
122                self.publication_language.as_ref().map(|s| s.raw()),
123            )?
124            .attr_opt(
125                "BelongsToSet",
126                self.belongs_to_set.as_ref().map(|s| s.raw()),
127            )?
128            .attr_opt(
129                "BelongsToCombination",
130                self.belongs_to_combination.as_ref().map(|s| s.raw()),
131            )?;
132
133        if self.contests.is_empty() {
134            writer.empty()
135        } else {
136            writer
137                .child(("Contests", NS_KR), |writer| {
138                    writer
139                        .child_elems(ListDataContest::EML_NAME, &self.contests)?
140                        .finish()
141                })?
142                .finish()
143        }
144    }
145}
146
147/// Data for a contest associated with a list.
148#[derive(Debug, Clone)]
149pub struct ListDataContest {
150    /// The contest ID.
151    pub id: StringValue<ContestId>,
152
153    /// An optional name for the contest.
154    pub name: Option<String>,
155}
156
157impl ListDataContest {
158    /// Create a new `ListDataContest` with the given ID and no name.
159    pub fn new(id: ContestId) -> Self {
160        ListDataContest {
161            id: StringValue::from_value(id),
162            name: None,
163        }
164    }
165
166    /// Set the name of the contest.
167    pub fn with_name(mut self, name: impl Into<String>) -> Self {
168        self.name = Some(name.into());
169        self
170    }
171}
172
173impl EMLElement for ListDataContest {
174    const EML_NAME: QualifiedName<'_, '_> = QualifiedName::from_static("Contest", Some(NS_KR));
175
176    fn read_eml(elem: &mut EMLElementReader<'_, '_>) -> Result<Self, EMLError> {
177        Ok(ListDataContest {
178            id: elem.string_value_attr("Id", None)?,
179            name: elem.text_without_children_opt()?,
180        })
181    }
182
183    fn write_eml(&self, writer: EMLElementWriter) -> Result<(), EMLError> {
184        let writer = writer.attr("Id", &self.id.raw())?;
185
186        if let Some(name) = &self.name {
187            writer.text(name)?.finish()
188        } else {
189            writer.empty()
190        }
191    }
192}
193
194/// Type representing the combination a list belongs to.
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct ListDataBelongsToCombination(String);
197
198impl ListDataBelongsToCombination {
199    /// Create a new `ListDataBelongsToCombination` with the given combination identifier.
200    pub fn new(combination_id: impl AsRef<str>) -> Result<Self, EMLError> {
201        ListDataBelongsToCombination::parse_from_str(combination_id.as_ref()).wrap_value_error()
202    }
203
204    /// Get the raw string value of this combination.
205    pub fn value(&self) -> &str {
206        &self.0
207    }
208}
209
210/// Error returned when an invalid list data belongs to combination type string is encountered.
211#[derive(Debug, Clone, Error)]
212#[error("Invalid list data belongs to combination type: {0}")]
213pub struct InvalidListDataBelongsToCombinationError(String);
214
215impl StringValueData for ListDataBelongsToCombination {
216    type Error = InvalidListDataBelongsToCombinationError;
217
218    fn parse_from_str(s: &str) -> Result<Self, Self::Error>
219    where
220        Self: Sized,
221    {
222        // Note: assuming that `|` is not allowed in combination identifiers, unlike the regex in the spec
223        if s.len() == 1
224            && s.chars()
225                .next()
226                .map(|c| c.is_ascii_alphabetic())
227                .unwrap_or(false)
228        {
229            Ok(ListDataBelongsToCombination(s.to_string()))
230        } else {
231            Err(InvalidListDataBelongsToCombinationError(s.to_string()))
232        }
233    }
234
235    fn to_raw_value(&self) -> String {
236        self.0.clone()
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use crate::io::{EMLRead as _, test_write_eml_element, test_xml_fragment};
244
245    #[test]
246    fn test_list_data_construction() {
247        let list_data = ListData::new(true)
248            .with_publication_language(PublicationLanguage::Frisian)
249            .with_belongs_to_set(NonZeroU64::new(1).unwrap())
250            .with_belongs_to_combination(ListDataBelongsToCombination("A".to_string()));
251
252        assert_eq!(list_data.publish_gender.raw(), "true");
253        assert_eq!(
254            list_data.get_publication_language(),
255            PublicationLanguage::Frisian
256        );
257        assert_eq!(list_data.belongs_to_set.as_ref().unwrap().raw(), "1");
258        assert_eq!(
259            list_data.belongs_to_combination.as_ref().unwrap().raw(),
260            "A"
261        );
262    }
263
264    #[test]
265    fn test_list_data_contest_construction() {
266        let contest =
267            ListDataContest::new(ContestId::new("1234").unwrap()).with_name("Test Contest");
268
269        assert_eq!(contest.id.raw(), "1234");
270        assert_eq!(contest.name.as_ref().unwrap(), "Test Contest");
271    }
272
273    #[test]
274    fn test_list_data_parsing() {
275        let xml = test_xml_fragment(
276            r#"
277            <kr:ListData xmlns:kr="http://www.kiesraad.nl/extensions" PublishGender="true" PublicationLanguage="nl" BelongsToSet="1" BelongsToCombination="A">
278                <kr:Contests>
279                    <kr:Contest Id="1234">Test Contest 1</kr:Contest>
280                    <kr:Contest Id="5678">Test Contest 2</kr:Contest>
281                </kr:Contests>
282            </kr:ListData>
283            "#,
284        );
285
286        let list_data = ListData::parse_eml(&xml, crate::io::EMLParsingMode::Strict).unwrap();
287
288        assert_eq!(
289            list_data.belongs_to_combination,
290            Some(StringValue::Parsed(ListDataBelongsToCombination(
291                "A".to_string()
292            )))
293        );
294        assert_eq!(
295            list_data.publication_language,
296            Some(StringValue::Parsed(PublicationLanguage::Dutch))
297        );
298        assert_eq!(
299            list_data.belongs_to_set,
300            Some(StringValue::Parsed(NonZeroU64::new(1).unwrap()))
301        );
302
303        assert_eq!(list_data.contests.len(), 2);
304        assert_eq!(list_data.contests[0].id.raw(), "1234");
305        assert_eq!(
306            list_data.contests[0].name.as_deref(),
307            Some("Test Contest 1")
308        );
309        assert_eq!(list_data.contests[1].id.raw(), "5678");
310        assert_eq!(
311            list_data.contests[1].name.as_deref(),
312            Some("Test Contest 2")
313        );
314
315        let xml_output = test_write_eml_element(&list_data, &[NS_KR]).unwrap();
316        assert_eq!(xml_output, xml);
317    }
318
319    #[test]
320    fn test_list_data_simple_parsing() {
321        let xml = test_xml_fragment(
322            r#"<kr:ListData xmlns:kr="http://www.kiesraad.nl/extensions" PublishGender="false"/>"#,
323        );
324
325        let list_data = ListData::parse_eml(&xml, crate::io::EMLParsingMode::Strict).unwrap();
326
327        assert!(!list_data.publish_gender.value().unwrap().into_owned());
328        assert_eq!(
329            list_data.get_publication_language(),
330            PublicationLanguage::Dutch
331        );
332
333        let xml_output = test_write_eml_element(&list_data, &[NS_KR]).unwrap();
334        assert_eq!(xml_output, xml);
335    }
336}