1use convert_case::{Case, Casing};
2use quick_xml::{events::*, reader::Reader, writer::Writer};
3use semver::{BuildMetadata, Prerelease, Version};
4use std::{
5 borrow::Cow,
6 collections::HashMap,
7 fmt, fs,
8 io::Cursor,
9 path::{Path, PathBuf},
10 str::FromStr,
11};
12use thiserror::Error;
13
14#[cfg(test)]
15mod tests;
17
18mod impls;
20pub use impls::*;
21
22mod version_tools;
23pub use version_tools::*;
24
25#[derive(Debug, Error)]
27pub enum ModinfoError {
28 #[error("I/O error occurred: {0}")]
29 IoError(std::io::Error),
30 #[error("Invalid version: {0}")]
31 InvalidVersion(lenient_semver_parser::Error<'static>),
32 #[error("File not found")]
33 FsNotFound,
34 #[error("No modinfo.xml found")]
35 NoModinfo,
36 #[error("No Author found in modinfo.xml")]
37 NoModinfoAuthor,
38 #[error("No Description found in modinfo.xml")]
39 NoModinfoDescription,
40 #[error("No Name found in modinfo.xml")]
41 NoModinfoName,
42 #[error("No Version found in modinfo.xml")]
43 NoModinfoVersion,
44 #[error("Unable to determine the version for modinfo.xml")]
45 NoModinfoValueVersion,
46 #[error("Unknown tag: {0}")]
47 UnknownTag(String),
48 #[error("Could not write modinfo.xml")]
49 WriteError,
50 #[error("Could not parse XML: {0}")]
51 XMLError(quick_xml::Error),
52}
53
54impl From<std::io::Error> for ModinfoError {
55 fn from(err: std::io::Error) -> Self {
56 ModinfoError::IoError(err)
57 }
58}
59impl From<quick_xml::Error> for ModinfoError {
60 fn from(err: quick_xml::Error) -> Self {
61 ModinfoError::XMLError(err)
62 }
63}
64
65impl From<lenient_semver_parser::Error<'static>> for ModinfoError {
66 fn from(err: lenient_semver_parser::Error<'static>) -> Self {
67 ModinfoError::InvalidVersion(err)
68 }
69}
70
71#[derive(Debug, Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
98pub enum ModinfoVersion {
99 V1,
100 V2,
101}
102
103#[derive(Debug, Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
104struct ModinfoValueMeta {
105 version: ModinfoVersion,
106 path: PathBuf,
107}
108
109impl Default for ModinfoValueMeta {
110 fn default() -> Self {
111 ModinfoValueMeta {
112 version: ModinfoVersion::V2,
113 path: PathBuf::new(),
114 }
115 }
116}
117
118#[derive(Debug, Clone, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
119struct ModinfoValue {
120 value: Option<Cow<'static, str>>,
121}
122
123impl fmt::Display for ModinfoValue {
124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125 match self.value {
126 Some(ref value) => write!(f, "{}", value),
127 None => write!(f, ""),
128 }
129 }
130}
131
132#[derive(Debug, Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
133struct ModinfoValueVersion {
134 value: Version,
135 compat: Option<Cow<'static, str>>,
136}
137
138impl fmt::Display for ModinfoValueVersion {
139 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140 let version = &self.value.to_string();
141 let compat = match &self.compat {
142 Some(ref value) => value.to_string(),
143 None => String::new(),
144 };
145
146 if compat.is_empty() {
147 write!(f, "{}", version)
148 } else {
149 write!(f, "{} ({})", version, compat)
150 }
151 }
152}
153
154impl Default for ModinfoValueVersion {
155 fn default() -> Self {
156 ModinfoValueVersion {
157 value: Version::new(0, 1, 0),
158 compat: None,
159 }
160 }
161}
162
163#[derive(Debug, Clone, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
200pub struct Modinfo {
201 author: ModinfoValue,
202 description: ModinfoValue,
203 display_name: ModinfoValue,
204 name: ModinfoValue,
205 version: ModinfoValueVersion,
206 website: ModinfoValue,
207 meta: ModinfoValueMeta,
208}
209
210impl ToString for Modinfo {
211 fn to_string(&self) -> String {
212 let mut writer = Writer::new_with_indent(Cursor::new(Vec::new()), b' ', 2);
213 let is_v2 = ModinfoVersion::V2 == self.meta.version;
214
215 let root_str = match is_v2 {
216 true => String::from("xml"),
217 false => String::from("ModInfo"),
218 };
219
220 if is_v2 {
221 writer
222 .write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))
223 .unwrap();
224 }
225 writer.write_event(Event::Start(BytesStart::new(&root_str))).unwrap();
226
227 for field in ["name", "display_name", "version", "description", "author", "website"] {
229 if !is_v2 && (field == "website" || field == "display_name") {
230 continue;
231 }
232
233 let field_name = field.to_owned().to_case(Case::Pascal);
234 let mut elem = BytesStart::new(field_name);
235 let value = match field {
236 "version" => self.get_version().to_string(),
237 _ => match self.get_value_for(field) {
238 Some(value) => value.to_string(),
239 None => String::new(),
240 },
241 };
242
243 elem.push_attribute(attributes::Attribute {
244 key: quick_xml::name::QName(b"value"),
245 value: Cow::from(value.clone().into_bytes()),
246 });
247
248 if field == "version" && self.version.compat.is_some() {
249 elem.push_attribute(attributes::Attribute {
250 key: quick_xml::name::QName(b"compat"),
251 value: Cow::from(self.version.compat.as_ref().unwrap().as_bytes()),
252 });
253 };
254
255 writer.write_event(Event::Empty(elem)).unwrap();
256 }
257
258 writer.write_event(Event::End(BytesEnd::new(&root_str))).unwrap();
259
260 String::from_utf8(writer.into_inner().into_inner()).unwrap()
261 }
262}
263
264impl FromStr for Modinfo {
265 type Err = ModinfoError;
266
267 fn from_str(xml: &str) -> Result<Self, Self::Err> {
268 let mut modinfo = Modinfo::default();
269 let mut buf: Vec<u8> = Vec::new();
270 let mut reader = Reader::from_str(xml);
271 reader.trim_text(true);
272
273 loop {
274 match reader.read_event_into(&mut buf) {
275 Err(e) => panic!("Error at position {}: {:?}", reader.buffer_position(), e),
276 Ok(Event::Eof) => break,
277 Ok(Event::Start(e)) => {
279 modinfo.meta.version = match e.name().as_ref() {
280 b"xml" => ModinfoVersion::V2,
281 _ => ModinfoVersion::V1,
282 }
283 }
284 Ok(Event::Empty(e)) => {
286 let attributes = parse_attributes(e.attributes());
287 let value = attributes["value"].clone();
288
289 match e.name().as_ref() {
290 b"Author" => {
291 modinfo.author = ModinfoValue {
292 value: Some(value.into()),
293 }
294 }
295 b"Description" => {
296 modinfo.description = ModinfoValue {
297 value: Some(value.into()),
298 }
299 }
300 b"DisplayName" => {
301 modinfo.display_name = ModinfoValue {
302 value: Some(value.into()),
303 }
304 }
305 b"Name" => {
306 if modinfo.display_name.value.is_none() {
307 modinfo.display_name = ModinfoValue {
308 value: Some(value.clone().to_case(Case::Title).into()),
309 }
310 }
311
312 modinfo.name = ModinfoValue {
313 value: Some(value.into()),
314 }
315 }
316 b"Version" => {
317 let mut compat = None;
318
319 if attributes.contains_key("compat") {
320 compat = Some(attributes["compat"].clone().into());
321 }
322 modinfo.version = ModinfoValueVersion {
323 value: match lenient_semver::parse_into::<Version>(&value) {
324 Ok(result) => result.clone(),
325 Err(err) => {
326 lenient_semver::parse_into::<Version>(format!("0.0.0+{}", err).as_ref())
327 .unwrap()
328 }
329 },
330 compat,
331 }
332 }
333 b"Website" => {
334 modinfo.website = ModinfoValue {
335 value: Some(value.into()),
336 }
337 }
338 _ => (),
339 }
340 }
341 Ok(_) => (),
342 }
343
344 buf.clear();
345 }
346
347 Ok(modinfo)
348 }
349}
350
351fn parse_attributes(input: attributes::Attributes) -> HashMap<String, String> {
352 let mut attributes = HashMap::new();
353
354 input.map(|a| a.unwrap()).for_each(|a| {
355 let key: String = String::from_utf8_lossy(a.key.as_ref()).to_lowercase();
356 let value = String::from_utf8(a.value.into_owned()).unwrap();
357
358 attributes.insert(key, value);
359 });
360
361 attributes
362}
363
364pub fn parse(file: impl AsRef<Path>) -> Result<Modinfo, ModinfoError> {
387 let modinfo = match Path::try_exists(file.as_ref()) {
388 Ok(true) => Modinfo::from_str(fs::read_to_string(&file)?.as_ref()),
389 Ok(false) => return Err(ModinfoError::FsNotFound),
390 Err(err) => return Err(ModinfoError::IoError(err)),
391 };
392
393 match modinfo {
394 Ok(mut modinfo) => {
395 if modinfo.author.value.is_none() {
396 return Err(ModinfoError::NoModinfoAuthor);
397 }
398 if modinfo.description.value.is_none() {
399 return Err(ModinfoError::NoModinfoDescription);
400 }
401 if modinfo.name.value.is_none() {
402 return Err(ModinfoError::NoModinfoName);
403 }
404 if modinfo.version.value.to_string().is_empty() {
405 return Err(ModinfoError::NoModinfoVersion);
406 }
407
408 modinfo.meta.path = file.as_ref().to_path_buf();
410
411 Ok(modinfo)
412 }
413 Err(err) => Err(err),
414 }
415}