facti_lib/
modinfo.rs

1use std::{
2    fmt::{self, Display, Formatter},
3    path::PathBuf,
4};
5
6use serde::{Deserialize, Serialize};
7use url::Url;
8
9use super::{dependency::Dependency, version::Version, FactorioVersion};
10
11/// The info.json file identifies the mod and defines its version.
12#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
13pub struct ModInfo {
14    /// The internal name of mod.
15    ///
16    /// The game accepts anything as a mod name, however the mod portal
17    /// restricts mod names to only consist of alphanumeric characters,
18    /// dashes and underscores. Note that the mod folder or mod zip file
19    /// name has to contain the mod name, where the restrictions of the
20    /// file system apply.
21    ///
22    /// The game accepts mod names with a maximum length of 100 characters.
23    /// The mod portal only accepts mods with names that are longer than
24    /// 3 characters and shorter than 50 characters.
25    pub name: String,
26
27    /// Defines the version of the mod.
28    pub version: Version,
29
30    /// The display name of the mod, so it is not recommended to use
31    /// someUgly_pRoGrAmMeR-name here.
32    ///
33    /// Can be overwritten with a locale entry in the `mod-name` category,
34    /// using the internal mod name as the key.
35    ///
36    /// The game will reject a title field that is longer than 100 characters.
37    /// However, this can be worked around by using the locale entry.
38    /// The mod portal does not restrict mod title length.
39    pub title: String,
40
41    /// The author of the mod.
42    ///
43    /// This field does not have restrictions, it can also be a list of
44    /// authors etc. The mod portal ignores this field, it will simply display
45    /// the uploader's name as the author.
46    pub author: String,
47
48    /// How the mod author can be contacted, for example an email address.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub contact: Option<String>,
51
52    /// Where the mod can be found on the internet.
53    ///
54    /// Note that the in-game mod browser shows the mod portal link additionally
55    /// to this field. Please don't put the string `"None"` here,
56    /// it makes the field on the mod portal website look ugly.
57    /// Just leave the field empty if the mod doesn't have
58    /// a website/forum thread/discord.
59    ///
60    /// **Note:** The [`None`] variant is perfectly valid to use.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub homepage: Option<Url>,
63
64    /// A short description of what your mod does.
65    ///
66    /// This is all that people get to see in-game.
67    /// Can be overwritten with a locale entry in the `mod-description` category,
68    /// using the internal mod name as the key.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub description: Option<String>,
71
72    /// The Factorio version that this mod supports.
73    ///
74    /// This can only be one Factorio version, not multiple.
75    /// However, it includes all `.sub` (`.patch`) versions.
76    /// While the field is optional, usually mods are developed for versions
77    /// higher than the default 0.12, so the field has to be added anyway.
78    ///
79    /// Adding a sub (patch) part, e.g. "0.18.27" will make the mod portal
80    /// reject the mod and the game act weirdly.
81    /// That means this shouldn't be done;
82    /// use only the major and minor components "major.minor", for example "1.0".
83    ///
84    /// Mods with the factorio_version "0.18" can also be loaded in 1.0
85    /// and the mod portal will return them
86    /// when queried for factorio_version 1.0 mods.
87    #[serde(default)]
88    pub factorio_version: FactorioVersion,
89
90    /// Mods that this mod depends on or is incompatible with.
91    ///
92    /// If this mod depends on another, the other mod will load first,
93    /// see [Data-Lifecycle][].
94    /// An empty [`Vec`] allows to work around the default
95    /// and have no dependencies at all.
96    ///
97    /// [data-lifecycle]: https://lua-api.factorio.com/latest/Data-Lifecycle.html
98    #[serde(default, skip_serializing_if = "Vec::is_empty")]
99    pub dependencies: Vec<Dependency>,
100
101    /// Unofficial extensions to the `info.json` format.
102    ///
103    /// Used by third party packaging tools (such as [Facti][]).
104    ///
105    /// [facti]: https://facti.rs
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub package: Option<ModPackageInfo>,
108}
109
110/// Contains unofficial extensions to the `info.json` format.
111///
112/// Used by third party packaging tools.
113#[derive(Default, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
114pub struct ModPackageInfo {
115    /// The path to a file that should be used as the content for
116    /// the "information" page on the mod portal.
117    ///
118    /// The mod portal supports markdown formatting, so it's recommended
119    /// to specify the path to a markdown file here.
120    ///
121    /// Note that this path is considered relative to the `info.json` file
122    /// (unless an absolute path is specified).
123    #[serde(rename = "information", skip_serializing_if = "Option::is_none")]
124    pub readme_path: Option<PathBuf>,
125
126    /// The path to a file that should be used as the content for
127    /// the "FAQ" page on the mod portal.
128    ///
129    /// The mod portal supports markdown formatting, so it's recommended
130    /// to specify the path to a markdown file here.
131    ///
132    /// Note that this path is considered relative to the `info.json` file
133    /// (unless an absolute path is specified).
134    #[serde(rename = "faq", skip_serializing_if = "Option::is_none")]
135    pub faq_path: Option<PathBuf>,
136
137    /// Paths to images that should be displayed on the mod portal.
138    ///
139    /// If mod details are updated with Facti, the images will be displayed
140    /// on the mod portal in the same order that they are specified in this
141    /// [`Vec`].
142    ///
143    /// Note that these paths are considered relative to the `info.json` file
144    /// (unless absolute paths are specified).
145    #[serde(default, rename = "gallery", skip_serializing_if = "Vec::is_empty")]
146    pub gallery_paths: Vec<PathBuf>,
147}
148
149impl ModInfo {
150    /// Creates a builder to more conveniently construct a [`ModInfo`] struct.
151    pub fn builder<T, U, V>(name: T, version: Version, title: U, author: V) -> ModInfoBuilder
152    where
153        T: Into<String>,
154        U: Into<String>,
155        V: Into<String>,
156    {
157        ModInfoBuilder::new(name, version, title, author)
158    }
159}
160
161impl Display for ModInfo {
162    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
163        write!(f, "{} v{} by {}", self.name, self.version, self.author)
164    }
165}
166
167/// Contains an internal [`ModInfo`] value that is used to later construct a
168/// finished [`ModInfo`]`.
169pub struct ModInfoBuilder {
170    info: ModInfo,
171}
172
173/// Contains methods to successively build up a [`ModInfo`] struct.
174impl ModInfoBuilder {
175    fn new<T, V, U, X>(name: T, version: V, title: U, author: X) -> Self
176    where
177        T: Into<String>,
178        V: Into<Version>,
179        U: Into<String>,
180        X: Into<String>,
181    {
182        Self {
183            info: ModInfo {
184                name: name.into(),
185                version: version.into(),
186                title: title.into(),
187                author: author.into(),
188                contact: None,
189                homepage: None,
190                description: None,
191                factorio_version: Default::default(),
192                dependencies: Vec::new(),
193                package: None,
194            },
195        }
196    }
197
198    /// Sets the [`contact`][ModInfo::contact] field.
199    pub fn contact<T: Into<String>>(&mut self, contact: T) -> &mut Self {
200        self.info.contact = Some(contact.into());
201        self
202    }
203
204    /// Sets the [`homepage`][ModInfo::homepage] field.
205    pub fn homepage(&mut self, homepage: Url) -> &mut Self {
206        self.info.homepage = Some(homepage);
207        self
208    }
209
210    /// Sets the [`description`][ModInfo::description] field.
211    pub fn description<T: Into<String>>(&mut self, description: T) -> &mut Self {
212        self.info.description = Some(description.into());
213        self
214    }
215
216    /// Sets the [`factorio_version`][ModInfo::factorio_version] field.
217    pub fn factorio_version(&mut self, factorio_version: FactorioVersion) -> &mut Self {
218        self.info.factorio_version = factorio_version;
219        self
220    }
221
222    /// Adds a dependency to [`dependencies`][ModInfo::dependencies].
223    pub fn dependency(&mut self, dependency: Dependency) -> &mut Self {
224        self.info.dependencies.push(dependency);
225        self
226    }
227
228    /// Adds multiple dependencies at once to [`dependencies`][ModInfo::dependencies].
229    pub fn dependencies(&mut self, dependencies: &[Dependency]) -> &mut Self {
230        self.info.dependencies.extend_from_slice(dependencies);
231        self
232    }
233
234    /// Sets a path to the readme file that should be displayed
235    /// on the mod portal.
236    ///
237    /// The path is relative to where the `info.json` file is located.
238    ///
239    /// **Note:** This is an unofficial extension to the `info.json` format.
240    pub fn information_path<T: Into<PathBuf>>(&mut self, path: T) -> &mut Self {
241        self.info
242            .package
243            .get_or_insert_with(Default::default)
244            .readme_path = Some(path.into());
245        self
246    }
247
248    /// Sets a path to the FAQ file that should be displayed
249    /// on the mod portal.
250    ///
251    /// The path is relative to where the `info.json` file is located.
252    ///
253    ///
254    pub fn faq_path<T: Into<PathBuf>>(&mut self, path: T) -> &mut Self {
255        self.info
256            .package
257            .get_or_insert_with(Default::default)
258            .faq_path = Some(path.into());
259        self
260    }
261
262    /// Adds a path to the collection of gallery images.
263    pub fn gallery<T: Into<PathBuf>>(&mut self, gallery: T) -> &mut Self {
264        self.info
265            .package
266            .get_or_insert_with(Default::default)
267            .gallery_paths
268            .push(gallery.into());
269        self
270    }
271
272    /// Builds a finished [`ModInfo`] from the builder.
273    pub fn build(&mut self) -> ModInfo {
274        self.info.clone()
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use crate::{modinfo::ModPackageInfo, version::VersionReq};
281
282    use super::*;
283
284    #[test]
285    fn test_builder() {
286        let expected = ModInfo {
287            name: "boblibrary".to_string(),
288            version: Version::parse("0.17.0").unwrap(),
289            title: "Bob's Library".to_string(),
290            author: "Bob".to_string(),
291            contact: None,
292            homepage: None,
293            description: None,
294            factorio_version: Default::default(),
295            dependencies: vec![Dependency::required(
296                "angel".to_string(),
297                VersionReq::Latest,
298            )],
299            package: Some(ModPackageInfo {
300                readme_path: Some(PathBuf::from("README.md")),
301                ..Default::default()
302            }),
303        };
304
305        let mut builder = ModInfoBuilder::new(
306            "boblibrary".to_string(),
307            Version::parse("0.17.0").unwrap(),
308            "Bob's Library".to_string(),
309            "Bob".to_string(),
310        );
311        builder.dependency(Dependency::required(
312            "angel".to_string(),
313            VersionReq::Latest,
314        ));
315        builder.information_path("README.md");
316        let built = builder.build();
317
318        assert_eq!(built, expected);
319    }
320}