modinfo/
lib.rs

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)]
15// mod tests;
16mod tests;
17
18// Include Modules
19mod impls;
20pub use impls::*;
21
22mod version_tools;
23pub use version_tools::*;
24
25/// Errors that can occur while parsing a ModInfo.xml file
26#[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/// The version of the modinfo.xml file
72///
73/// For reference, here are the two formats:
74///
75/// V1:
76/// ```xml
77/// <ModInfo>
78///   <Name value="SomeMod" />
79///   <Description value="Mod to show format of ModInfo v1" />
80///   <Author value="Name" />
81///   <Version value="0.1.0" />
82/// </ModInfo>
83/// ```
84///
85/// V2:
86/// ```xml
87/// <?xml version="1.0" encoding="utf-8"?>
88/// <xml>
89///   <Name value="SomeMod" />
90///   <DisplayName value="Official Mod Name" />
91///   <Version value="0.1.0" />
92///   <Description value="Mod to show format of ModInfo v2" />
93///   <Author value="Name" />
94///   <Website value="https://example.org" />
95/// </xml>
96/// ```
97#[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/// The main struct for the library
164///
165/// # Fields
166///
167/// * `name` - the name of the modlet
168/// * `display_name` - the display name of the modlet (v2 only)
169/// * `version` - the version of the modlet
170/// * `description` - the description of the modlet
171/// * `author` - the author of the modlet
172/// * `website` - the website of the modlet (v2 only)
173///
174/// Additionally, version supports an optional `compat` field which can be used to indicate the game's version for the compatibility string
175///
176/// # Example
177///
178/// ```rust
179/// use modinfo::Modinfo;
180/// use std::borrow::Cow;
181///
182/// let mut modinfo = Modinfo::new();
183///
184/// modinfo.set_version("0.1.0".to_owned());
185/// modinfo.set_value_for("name", "SomeMod");
186/// modinfo.set_value_for("display_name", "Some Mod");
187/// modinfo.set_value_for("author", "Some Author");
188/// modinfo.set_value_for("description", "Some Description");
189/// modinfo.set_value_for("website", "https://example.org");
190///
191/// assert_eq!(modinfo.get_value_for("name"), Some(&Cow::from("SomeMod")));
192/// assert_eq!(modinfo.get_value_for("display_name"), Some(&Cow::from("Some Mod")));
193/// assert_eq!(modinfo.get_value_for("author"), Some(&Cow::from("Some Author")));
194/// assert_eq!(modinfo.get_value_for("description"), Some(&Cow::from("Some Description")));
195/// assert_eq!(modinfo.get_value_for("website"), Some(&Cow::from("https://example.org")));
196/// assert_eq!(modinfo.get_version(), &semver::Version::new(0, 1, 0));
197/// ```
198///
199#[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        // inject the attributes here
228        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                // Root Element
278                Ok(Event::Start(e)) => {
279                    modinfo.meta.version = match e.name().as_ref() {
280                        b"xml" => ModinfoVersion::V2,
281                        _ => ModinfoVersion::V1,
282                    }
283                }
284                // Child Elements (because they have no children)
285                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
364/// Parses a Modinfo.xml file and produces a Modinfo struct
365///
366/// It will auto-detect the version of the Modinfo.xml file (either V1 or V2)
367///
368/// # Arguments
369///
370/// * `file` - a Path-like object pointing to a ModInfo.xml file
371///
372/// # Returns
373///
374/// A `Result` containing either a `Modinfo` struct or a `ModinfoError`
375///
376/// ## Possible ModinfoError
377///
378/// * `ModinfoError::FsNotFound` - the file does not exist
379/// * `ModinfoError::IoError` - an I/O error occurred
380/// * `ModinfoError::NoModinfoAuthor` - no Author tag found (required)
381/// * `ModinfoError::NoModinfoDescription` - no Description tag found (required)
382/// * `ModinfoError::NoModinfoName` - no Name tag found (required)
383/// * `ModinfoError::NoModinfoVersion` - no Version value found (required)
384/// * `ModinfoError::XMLError` - an error occurred while trying to parse the XML (possibly invalid XML structure?)
385///
386pub 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            // store the original file path in the metadata
409            modinfo.meta.path = file.as_ref().to_path_buf();
410
411            Ok(modinfo)
412        }
413        Err(err) => Err(err),
414    }
415}