1use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
2
3fn deserialize_vec_u32<'de, D>(deserializer: D) -> Result<Vec<u32>, D::Error>
4where
5 D: Deserializer<'de>,
6{
7 let s = String::deserialize(deserializer)?;
8 let numbers = s
9 .split(',')
10 .map(|num| num.trim())
11 .filter(|num| !num.is_empty())
12 .map(|num| num.parse::<u32>().map_err(de::Error::custom))
13 .collect::<Result<Vec<_>, _>>()?;
14 Ok(numbers)
15}
16
17fn serialize_vec_u32_as_comma<S>(value: &[u32], serializer: S) -> Result<S::Ok, S::Error>
18where
19 S: Serializer,
20{
21 let joined = value
22 .iter()
23 .map(|num| num.to_string())
24 .collect::<Vec<_>>()
25 .join(",");
26 serializer.serialize_str(&joined)
27}
28
29fn deserialize_vec_str<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
30where
31 D: Deserializer<'de>,
32{
33 let s = String::deserialize(deserializer)?;
34 let numbers = s.split(',').map(|i| i.to_string()).collect::<Vec<_>>();
35 Ok(numbers)
36}
37
38fn serialize_vec_str_as_comma<S>(value: &[String], serializer: S) -> Result<S::Ok, S::Error>
39where
40 S: Serializer,
41{
42 let joined = value.join(",");
43 serializer.serialize_str(&joined)
44}
45
46#[derive(PartialEq, Debug, Deserialize, Serialize)]
47#[serde(rename = "opml")]
48pub struct Opml {
49 #[serde(rename = "@version")]
50 pub version: String,
51 pub head: Head,
52 pub body: Body,
53}
54
55#[derive(PartialEq, Debug, Deserialize, Serialize)]
56#[serde(rename = "head")]
57pub struct Head {
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub title: Option<String>,
60 #[serde(rename = "dateCreated", skip_serializing_if = "Option::is_none")]
61 pub date_created: Option<String>,
62 #[serde(rename = "dateModified", skip_serializing_if = "Option::is_none")]
63 pub date_modified: Option<String>,
64 #[serde(rename = "ownerName", skip_serializing_if = "Option::is_none")]
65 pub owner_name: Option<String>,
66 #[serde(rename = "ownerEmail", skip_serializing_if = "Option::is_none")]
67 pub owner_email: Option<String>,
68
69 #[serde(
70 rename = "expansionState",
71 deserialize_with = "deserialize_vec_u32",
72 serialize_with = "serialize_vec_u32_as_comma",
73 skip_serializing_if = "Vec::is_empty",
74 default
75 )]
76 pub expansion_state: Vec<u32>,
77
78 #[serde(rename = "vertScrollState", skip_serializing_if = "Option::is_none")]
79 pub vert_scroll_state: Option<i32>,
80 #[serde(rename = "windowTop", skip_serializing_if = "Option::is_none")]
81 pub window_top: Option<i32>,
82 #[serde(rename = "windowLeft", skip_serializing_if = "Option::is_none")]
83 pub window_left: Option<i32>,
84 #[serde(rename = "windowBottom", skip_serializing_if = "Option::is_none")]
85 pub window_bottom: Option<i32>,
86 #[serde(rename = "windowRight", skip_serializing_if = "Option::is_none")]
87 pub window_right: Option<i32>,
88}
89
90#[derive(Debug, Deserialize, PartialEq, Serialize)]
91#[serde(rename = "body")]
92pub struct Body {
93 #[serde(rename = "outline")]
94 outlines: Vec<Outline>,
95}
96
97#[derive(PartialEq, Debug, Deserialize, Serialize)]
98#[serde(rename = "outline")]
99pub struct Outline {
100 #[serde(rename = "@text")]
101 pub text: String,
102
103 #[serde(
104 rename = "@category",
105 skip_serializing_if = "Vec::is_empty",
106 deserialize_with = "deserialize_vec_str",
107 serialize_with = "serialize_vec_str_as_comma",
108 default
109 )]
110 pub category: Vec<String>,
111
112 #[serde(rename = "@created", skip_serializing_if = "Option::is_none")]
113 pub created: Option<String>,
114
115 #[serde(rename = "@isComment", skip_serializing_if = "Option::is_none")]
116 pub is_comment: Option<bool>,
117
118 #[serde(rename = "@isBreakpoint", skip_serializing_if = "Option::is_none")]
119 pub is_breakpoint: Option<bool>,
120
121 #[serde(rename = "@description", skip_serializing_if = "Option::is_none")]
122 pub description: Option<String>,
123
124 #[serde(rename = "@htmlUrl", skip_serializing_if = "Option::is_none")]
125 pub html_url: Option<String>,
126
127 #[serde(rename = "@language", skip_serializing_if = "Option::is_none")]
128 pub language: Option<String>,
129
130 #[serde(rename = "@title", skip_serializing_if = "Option::is_none")]
131 pub title: Option<String>,
132
133 #[serde(rename = "@type", skip_serializing_if = "Option::is_none")]
134 pub ty: Option<String>,
135
136 #[serde(rename = "@version", skip_serializing_if = "Option::is_none")]
137 pub version: Option<String>,
138
139 #[serde(rename = "@xmlUrl", skip_serializing_if = "Option::is_none")]
140 pub xml_url: Option<String>,
141
142 #[serde(rename = "@url", skip_serializing_if = "Option::is_none")]
143 pub url: Option<String>,
144
145 #[serde(rename = "outline", skip_serializing_if = "Vec::is_empty", default)]
146 outlines: Vec<Outline>,
147}
148
149#[cfg(test)]
150mod test {
151
152 use quick_xml::{de::from_str, se::to_string};
153
154 use crate::{Head, Opml, Outline};
155
156 #[test]
157 fn test_outline() {
158 let s = r#"<outline text="The Mets are the best team in baseball." category="/Philosophy/Baseball/Mets,/Tourism/New York" created="Mon, 31 Oct 2005 18:21:33 GMT"/>"#;
159 let outline: Outline = from_str(s).unwrap();
160 assert!(outline.text == "The Mets are the best team in baseball.");
161 println!("{:?}", outline.category);
162
163 assert!(outline.category.len() == 2);
164 assert!(outline.created.unwrap() == "Mon, 31 Oct 2005 18:21:33 GMT");
165
166 let s = r#" <outline text="x" type="link" url="http://hosting.opml.org/dave/mySites.opml" isComment="true" isBreakpoint="true"
167 htmlUrl="http://www.infoworld.com/news/index.html" language="unknown"
168 title="x" version="RSS2"
169 xmlUrl="http://www.infoworld.com/rss/news.xml"
170 >
171 <outline text="x" isBreakpoint="true"/>
172 <outline text="x"/>
173 </outline>"#;
174 let outline: Outline = from_str(s).unwrap();
175 assert_eq!(
176 outline.url.unwrap(),
177 "http://hosting.opml.org/dave/mySites.opml"
178 );
179 assert_eq!(outline.title.unwrap(), "x");
180 assert_eq!(outline.ty.unwrap(), "link");
181 assert_eq!(outline.version.unwrap(), "RSS2");
182 assert!(outline.is_comment.unwrap());
183 assert!(outline.is_breakpoint.unwrap());
184 assert!(outline.outlines[0].is_breakpoint.unwrap());
185 assert_eq!(outline.outlines[1].text, "x");
186 }
187 #[test]
188 fn test_head() {
189 let s = r#"<head>
190 <title>states.opml</title>
191 <dateCreated>Tue, 15 Mar 2005 16:35:45 GMT</dateCreated>
192 <dateModified>Thu, 14 Jul 2005 23:41:05 GMT</dateModified>
193 <ownerName>Dave Winer</ownerName>
194 <ownerEmail>dave@scripting.com</ownerEmail>
195 <expansionState>1, 6, 13, 16,18,20</expansionState>
196 <vertScrollState>1</vertScrollState>
197 <windowTop>106</windowTop>
198 <windowLeft>106</windowLeft>
199 <windowBottom>558</windowBottom>
200 <windowRight>479</windowRight>
201 </head>"#;
202 let head: Head = from_str(s).unwrap();
203 assert_eq!(head.title.unwrap(), "states.opml");
204 assert_eq!(head.date_created.unwrap(), "Tue, 15 Mar 2005 16:35:45 GMT");
205 assert_eq!(head.date_modified.unwrap(), "Thu, 14 Jul 2005 23:41:05 GMT");
206 assert_eq!(head.owner_name.unwrap(), "Dave Winer");
207 assert_eq!(head.owner_email.unwrap(), "dave@scripting.com");
208 assert_eq!(head.expansion_state.len(), 6);
209 assert_eq!(head.vert_scroll_state.unwrap(), 1);
210 assert_eq!(head.window_top.unwrap(), 106);
211 assert_eq!(head.window_left.unwrap(), 106);
212 assert_eq!(head.window_bottom.unwrap(), 558);
213 assert_eq!(head.window_right.unwrap(), 479);
214 }
215
216 #[test]
217 fn test_assets() {
218 for name in std::fs::read_dir("assets").unwrap() {
219 let txt = std::fs::read_to_string(name.unwrap().path()).unwrap();
220 let opml: Opml = from_str(&txt).unwrap();
221 assert!(opml.version == "2.0");
222 let xml = to_string(&opml).unwrap();
223 let opml2: Opml = from_str(&xml).unwrap();
224 assert_eq!(opml, opml2);
225 }
226 }
227}