From d21c5dc9c53b02620fce916ffc1a2695e9d3f698 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Tue, 9 Nov 2021 12:46:43 +0100 Subject: Separate the binary and library This uses the workspace feature of cargo, with the benefit that 1) We can more cleanly group the binary (user facing) code from the library 2) We can have dependencies that apply to the binary only The first point could've been achieved without workspaces (Cargo supports both binaries and libraries in a crate), but the second point is what really makes this approach a lot better. --- Cargo.lock | 28 ++--- Cargo.toml | 21 ++-- modderbaas/Cargo.toml | 25 +++++ modderbaas/src/baas.rs | 224 ++++++++++++++++++++++++++++++++++++++++ modderbaas/src/contentdb.rs | 127 +++++++++++++++++++++++ modderbaas/src/download.rs | 161 +++++++++++++++++++++++++++++ modderbaas/src/error.rs | 64 ++++++++++++ modderbaas/src/game.rs | 74 ++++++++++++++ modderbaas/src/kvstore.rs | 49 +++++++++ modderbaas/src/lib.rs | 104 +++++++++++++++++++ modderbaas/src/minemod.rs | 242 ++++++++++++++++++++++++++++++++++++++++++++ modderbaas/src/util.rs | 62 ++++++++++++ modderbaas/src/world.rs | 97 ++++++++++++++++++ src/baas.rs | 224 ---------------------------------------- src/contentdb.rs | 127 ----------------------- src/download.rs | 161 ----------------------------- src/error.rs | 64 ------------ src/game.rs | 74 -------------- src/kvstore.rs | 49 --------- src/lib.rs | 104 ------------------- src/minemod.rs | 242 -------------------------------------------- src/util.rs | 62 ------------ src/world.rs | 97 ------------------ 23 files changed, 1251 insertions(+), 1231 deletions(-) create mode 100644 modderbaas/Cargo.toml create mode 100644 modderbaas/src/baas.rs create mode 100644 modderbaas/src/contentdb.rs create mode 100644 modderbaas/src/download.rs create mode 100644 modderbaas/src/error.rs create mode 100644 modderbaas/src/game.rs create mode 100644 modderbaas/src/kvstore.rs create mode 100644 modderbaas/src/lib.rs create mode 100644 modderbaas/src/minemod.rs create mode 100644 modderbaas/src/util.rs create mode 100644 modderbaas/src/world.rs delete mode 100644 src/baas.rs delete mode 100644 src/contentdb.rs delete mode 100644 src/download.rs delete mode 100644 src/error.rs delete mode 100644 src/game.rs delete mode 100644 src/kvstore.rs delete mode 100644 src/lib.rs delete mode 100644 src/minemod.rs delete mode 100644 src/util.rs delete mode 100644 src/world.rs diff --git a/Cargo.lock b/Cargo.lock index f100a34..0140ae6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -472,8 +472,6 @@ dependencies = [ name = "modderbaas" version = "0.1.0" dependencies = [ - "anyhow", - "clap", "dirs", "itertools", "log", @@ -482,17 +480,28 @@ dependencies = [ "regex", "scraper", "serde", - "stderrlog", "tempdir", - "termcolor", "thiserror", - "toml", "ureq", "url", "uuid", "zip", ] +[[package]] +name = "modderbaas-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "itertools", + "log", + "modderbaas", + "nix", + "stderrlog", + "termcolor", +] + [[package]] name = "new_debug_unreachable" version = "1.0.4" @@ -1145,15 +1154,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" -[[package]] -name = "toml" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa" -dependencies = [ - "serde", -] - [[package]] name = "ucd-trie" version = "0.1.3" diff --git a/Cargo.toml b/Cargo.toml index 158273a..dd64e8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,30 +1,25 @@ [package] -name = "modderbaas" +name = "modderbaas-cli" version = "0.1.0" authors = ["Daniel Schadt "] edition = "2018" +[[bin]] +name = "modderbaas" +path = "src/main.rs" + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[workspace] + [dependencies] anyhow = "1.0.45" clap = "2.33.3" -dirs = "4.0.0" itertools = "0.10.1" log = "0.4.14" -once_cell = "1.8.0" -regex = "1.5.4" -scraper = "0.12.0" -serde = { version = "1.0.130", features = ["derive"] } +modderbaas = { path = "modderbaas" } stderrlog = "0.5.1" -tempdir = "0.3.7" termcolor = "1.1.2" -thiserror = "1.0.30" -toml = "0.5.8" -ureq = { version = "2.3.0", features = ["json"] } -url = { version = "2.2.2", features = ["serde"] } -uuid = { version = "0.8.2", features = ["v4"] } -zip = "0.5.13" [target.'cfg(unix)'.dependencies] nix = "0.23.0" diff --git a/modderbaas/Cargo.toml b/modderbaas/Cargo.toml new file mode 100644 index 0000000..c07ba82 --- /dev/null +++ b/modderbaas/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "modderbaas" +version = "0.1.0" +authors = ["Daniel Schadt "] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +dirs = "4.0.0" +itertools = "0.10.1" +log = "0.4.14" +once_cell = "1.8.0" +regex = "1.5.4" +scraper = "0.12.0" +serde = { version = "1.0.130", features = ["derive"] } +tempdir = "0.3.7" +thiserror = "1.0.30" +ureq = { version = "2.3.0", features = ["json"] } +url = { version = "2.2.2", features = ["serde"] } +uuid = { version = "0.8.2", features = ["v4"] } +zip = "0.5.13" + +[target.'cfg(unix)'.dependencies] +nix = "0.23.0" diff --git a/modderbaas/src/baas.rs b/modderbaas/src/baas.rs new file mode 100644 index 0000000..938b4c4 --- /dev/null +++ b/modderbaas/src/baas.rs @@ -0,0 +1,224 @@ +//! This module contains functions to query & manipulate the global Minetest installation. +use std::{collections::HashMap, path::PathBuf}; + +use dirs; +use log::debug; + +use super::{ + error::Result, + game::Game, + minemod::{self, MineMod}, + scan, + world::World, +}; + +/// Returns a list of folders in which worlds are expected. +/// +/// Note that not all of these folders need to exist. +/// +/// This returns the following locations: +/// +/// * `$HOME/.minetest/worlds` +/// * `/var/games/minetest-server/.minetest/worlds` +pub fn world_dirs() -> Result> { + let mut paths = vec!["/var/games/minetest-server/.minetest/worlds".into()]; + if let Some(home) = dirs::home_dir() { + paths.push(home.join(".minetest").join("worlds")) + } + Ok(paths) +} + +/// Returns a list of folders in which games are expected. +/// +/// Note that not all of these folders need to exist. +/// +/// This returns the following locations: +/// +/// * `$HOME/.minetest/games` +/// * `/var/games/minetest-server/.minetest/games` +/// * `/usr/share/minetest/games` +/// * `/usr/share/games/minetest/games` +pub fn game_dirs() -> Result> { + let mut paths = vec![ + "/var/games/minetest-server/.minetest/games".into(), + "/usr/share/minetest/games".into(), + "/usr/share/games/minetest/games".into(), + ]; + if let Some(home) = dirs::home_dir() { + paths.push(home.join(".minetest").join("games")) + } + Ok(paths) +} + +/// Returns a list of folders in which mods are expected. +/// +/// Note that not all of these folders need to exist. +/// +/// This returns the following locations: +/// +/// * `$HOME/.minetest/mods` +/// * `/var/games/minetest-server/.minetest/mods` +/// * `/usr/share/games/minetest/mods` +/// * `/usr/share/minetest/mods` +pub fn mod_dirs() -> Result> { + let mut paths = vec![ + "/var/games/minetest-server/.minetest/mods".into(), + "/usr/share/games/minetest/mods".into(), + "/usr/share/minetest/mods".into(), + ]; + if let Some(home) = dirs::home_dir() { + paths.push(home.join(".minetest").join("mods")) + } + Ok(paths) +} + +/// The [`Baas`] provides a way to list all worlds, games and mods on the system and allows access +/// via the [`World`], [`Game`] and [`MineMod`] wrappers. +#[derive(Debug, Default, Clone)] +pub struct Baas { + world_dirs: Vec, + game_dirs: Vec, + mod_dirs: Vec, +} + +impl Baas { + /// Create a [`Baas`] with the standard dirs. + pub fn with_standard_dirs() -> Result { + Ok(Baas::default() + .with_world_dirs(world_dirs()?) + .with_game_dirs(game_dirs()?) + .with_mod_dirs(mod_dirs()?)) + } + + /// Replace the world dirs with the given list of world dirs. + pub fn with_world_dirs(self, world_dirs: Vec) -> Baas { + Baas { world_dirs, ..self } + } + + /// The list of directories which are searched for worlds. + #[inline] + pub fn world_dirs(&self) -> &[PathBuf] { + self.world_dirs.as_slice() + } + + /// Replace the game dirs with the given list of game dirs. + pub fn with_game_dirs(self, game_dirs: Vec) -> Baas { + Baas { game_dirs, ..self } + } + + /// The list of directories which are searched for games. + #[inline] + pub fn game_dirs(&self) -> &[PathBuf] { + self.game_dirs.as_slice() + } + + /// Replace the mod dirs with the given list of mod dirs. + pub fn with_mod_dirs(self, mod_dirs: Vec) -> Baas { + Baas { mod_dirs, ..self } + } + + /// The list of directories which are searched for mods. + #[inline] + pub fn mod_dirs(&self) -> &[PathBuf] { + self.mod_dirs.as_slice() + } + + /// Returns a vector of all words that were found in the world dirs. + pub fn worlds(&self) -> Result> { + let mut worlds = vec![]; + for dir in self.world_dirs() { + match scan(&dir, |p| World::open(p)) { + Ok(w) => worlds.extend(w), + Err(e) => debug!("Cannot scan {:?}: {}", dir, e), + } + } + Ok(worlds) + } + + /// Returns a vector of all games that were found in the game dirs. + pub fn games(&self) -> Result> { + let mut games = vec![]; + for dir in self.game_dirs() { + match scan(&dir, |p| Game::open(p)) { + Ok(g) => games.extend(g), + Err(e) => debug!("Cannot scan {:?}: {}", dir, e), + } + } + Ok(games) + } + + /// Returns a vector of all mods that were found in the mod dirs. + /// + /// Note that modpacks are flattened into mods. + pub fn mods(&self) -> Result> { + let mut mods = vec![]; + for dir in self.mod_dirs() { + match scan(&dir, |p| minemod::open_mod_or_pack(p)) { + Ok(m) => { + for container in m { + mods.extend(container.mods()?); + } + } + Err(e) => debug!("Cannot scan {:?}: {}", dir, e), + } + } + Ok(mods) + } + + /// Return a snapshot of the current state. + /// + /// A snapshot "freezes" the lists of worlds, mods and games in time. It is useful to avoid + /// unnecessary I/O when it is known that the state should not have changed. It also allows + /// fast searching of items by their name. + pub fn snapshot(&self) -> Result { + let worlds = self.worlds()?; + let games = self.games()?; + let mods = self.mods()?; + + Ok(Snapshot { + worlds: worlds + .into_iter() + .map(|w| Ok((w.world_name()?, w))) + .collect::>()?, + games: games.into_iter().map(|g| (g.technical_name(), g)).collect(), + mods: mods + .into_iter() + .map(|m| Ok((m.mod_id()?, m))) + .collect::>()?, + }) + } +} + +/// Snapshot of a [`Baas`] scan. +/// +/// A snapshot is created through the [`Baas::snapshot`] method and gives a frozen view on the +/// installed objects. +#[derive(Debug, Clone)] +pub struct Snapshot { + worlds: HashMap, + games: HashMap, + mods: HashMap, +} + +impl Snapshot { + /// Return all worlds that were found. + /// + /// The map maps the world's name to the [`World`] object. + pub fn worlds(&self) -> &HashMap { + &self.worlds + } + + /// Return all games that were found. + /// + /// The map maps the game ID to the [`Game`] object. + pub fn games(&self) -> &HashMap { + &self.games + } + + /// Return the available mods that were found. + /// + /// The map maps the mod name to the [`MineMod`] object. + pub fn mods(&self) -> &HashMap { + &self.mods + } +} diff --git a/modderbaas/src/contentdb.rs b/modderbaas/src/contentdb.rs new file mode 100644 index 0000000..d9c4688 --- /dev/null +++ b/modderbaas/src/contentdb.rs @@ -0,0 +1,127 @@ +//! Module to interact with the Minetest Content DB website. + +use once_cell::sync::Lazy; +use scraper::{Html, Selector}; +use serde::{Deserialize, Serialize}; +use url::Url; + +use super::error::{Error, Result}; + +/// The identification of content on Content DB. Consists of the username and the package name. +pub type ContentId = (String, String); + +/// The URL of the default Content DB website to use. +pub static DEFAULT_INSTANCE: Lazy = + Lazy::new(|| Url::parse("https://content.minetest.net/").expect("Invalid default URL")); + +/// The metapackage selector to scrape the packages. +static PROVIDES_SELECTOR: Lazy = + Lazy::new(|| Selector::parse("ul.d-flex").expect("Invalid selector")); + +static A_SELECTOR: Lazy = Lazy::new(|| Selector::parse("a").expect("Invalid selector")); + +/// (Partial) metadata of a content item, as returned by the Content DB API. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] +pub struct ContentMeta { + /// Username of the author. + pub author: String, + /// Name of the package. + pub name: String, + /// A list of mods that are provided by this package. + pub provides: Vec, + /// The short description of the package. + pub short_description: String, + /// The (human-readable) title of this package. + pub title: String, + /// The type of the package ("mod", "game", "txp") + #[serde(rename = "type")] + pub typ: String, + /// The download URL of the package. + pub url: Url, +} + +/// The main access point for Content DB queries. +#[derive(Debug, Clone)] +pub struct ContentDb { + base_url: Url, +} + +impl Default for ContentDb { + fn default() -> Self { + Self::new() + } +} + +impl ContentDb { + /// Create a new Content DB accessor pointing to the default instance. + pub fn new() -> ContentDb { + ContentDb { + base_url: DEFAULT_INSTANCE.clone(), + } + } + + /// Find suitable candidates that provide the given modname. + pub fn resolve(&self, modname: &str) -> Result> { + let path = format!("metapackages/{}", modname); + let endpoint = self + .base_url + .join(&path) + .map_err(|_| Error::InvalidModId(modname.into()))?; + + let body = ureq::request_url("GET", &endpoint).call()?.into_string()?; + + let dom = Html::parse_document(&body); + let provides = dom + .select(&PROVIDES_SELECTOR) + .next() + .ok_or(Error::InvalidScrape)?; + + let candidates: Vec = provides + .select(&A_SELECTOR) + .filter_map(|a| a.value().attr("href")) + .filter_map(extract_content_id) + .collect(); + + let mut good_ones = Vec::new(); + + for (user, package) in candidates { + let path = format!("api/packages/{}/{}/", user, package); + let endpoint = self + .base_url + .join(&path) + .expect("The parsed path was wrong"); + let response: ContentMeta = ureq::request_url("GET", &endpoint).call()?.into_json()?; + + // While resolving, we only care about actual mods that we can install. If a game + // provides a certain metapackage, it is pretty much useless for us (and often just + // there because a mod in that game provides the metapackage). + if response.typ == "mod" { + good_ones.push(response) + } + } + + Ok(good_ones) + } + + /// Retrieve the download url for a given package. + pub fn download_url(&self, user: &str, package: &str) -> Result { + let path = format!("api/packages/{}/{}/", user, package); + let endpoint = self + .base_url + .join(&path) + .expect("The parsed path was wrong"); + let response: ContentMeta = ureq::request_url("GET", &endpoint).call()?.into_json()?; + Ok(response.url) + } +} + +fn extract_content_id(path: &str) -> Option { + regex!("/packages/([^/]+)/([^/]+)/$") + .captures(path) + .map(|c| { + ( + c.get(1).unwrap().as_str().into(), + c.get(2).unwrap().as_str().into(), + ) + }) +} diff --git a/modderbaas/src/download.rs b/modderbaas/src/download.rs new file mode 100644 index 0000000..b9507b7 --- /dev/null +++ b/modderbaas/src/download.rs @@ -0,0 +1,161 @@ +//! Module to download mods from the internet. +//! +//! This module allows to download mods from various sources in the internet. Source specification +//! is done through the [`Source`] enum: +//! +//! * [`Source::Http`]: Download straight from an URL. It is expected that the URL points to a zip +//! archive which contains the mod, either directly or in a subfolder. +//! * [`Source::ContentDb`]: Refers to a package on the ContentDB. The [`Downloader`] will consult +//! the API to get the right download URL. +//! * [`Source::ModId`]: Refers to a simple mod name. Note that this specification can be +//! ambiguous, in which case the [`Downloader`] will return an error. +//! +//! The actual download work is done by a [`Downloader`]. Each [`Downloader`] has its own temporary +//! directory, in which any mods are downloaded and extracted. If you drop the [`Downloader`], +//! those mods will be deleted and the objects pointing to the now-gone directories are no longer +//! useful. + +use std::{ + fs, + io::{Cursor, Read}, + str::FromStr, +}; + +use tempdir::TempDir; +use url::Url; +use uuid::Uuid; +use zip::ZipArchive; + +use super::{ + contentdb::{ContentDb, ContentId}, + error::{Error, Result}, + minemod::{self, ModContainer, ModId}, +}; + +/// A source determines where a mod should be loaded from. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Source { + /// Download a mod archive through HTTP. + Http(Url), + /// Download a mod from the Content DB, using the given user- and package name. + ContentDb(ContentId), + /// Search the Content DB for a given mod ID. + /// + /// The download may fail if there are multiple mods providing the same ID. + ModId(ModId), +} + +impl FromStr for Source { + type Err = Error; + + fn from_str(s: &str) -> Result { + if s.starts_with("http://") || s.starts_with("https://") { + let url = Url::parse(s)?; + return Ok(Source::Http(url)); + } + let groups = regex!("^([^/]+)/([^/]+)$").captures(s); + if let Some(groups) = groups { + return Ok(Source::ContentDb(( + groups.get(1).unwrap().as_str().into(), + groups.get(2).unwrap().as_str().into(), + ))); + } + + if !s.contains(' ') { + return Ok(Source::ModId(s.into())); + } + + Err(Error::InvalidSourceSpec(s.into())) + } +} + +/// A downloader is responsible for downloading mods from various sources. +/// +/// Note that the objects that the [`Downloader`] creates will not work after the downloader has +/// been destroyed, as the temporary files will be lost. +#[derive(Debug)] +pub struct Downloader { + temp_dir: TempDir, + content_db: ContentDb, +} + +impl Downloader { + /// Create a new [`Downloader`], refering to the default ContentDB. + pub fn new() -> Result { + Downloader::with_content_db(Default::default()) + } + + /// Create a new [`Downloader`] that points to a specific ContentDB instance. + pub fn with_content_db(content_db: ContentDb) -> Result { + let temp_dir = TempDir::new(env!("CARGO_PKG_NAME"))?; + Ok(Downloader { + temp_dir, + content_db, + }) + } + + /// Download a mod from the given source. + /// + /// This function may download either a mod ([`minemod::MineMod`]) or a modpack + /// ([`minemod::Modpack`]), therefore it returns a trait object that can be queried for the + /// required information. + /// + /// Note that the object will be useless when the [`Downloader`] is dropped, as the temporary + /// directory containing the downloaded data will be lost. Use [`ModContainer::install_to`] to + /// copy the mod content to a different directory. + pub fn download(&self, source: &Source) -> Result> { + match *source { + Source::Http(ref url) => self.download_http(url), + Source::ContentDb((ref user, ref package)) => { + let url = self.content_db.download_url(user, package)?; + self.download_http(&url) + } + Source::ModId(ref id) => { + let candidates = self.content_db.resolve(id)?; + if candidates.len() != 1 { + return Err(Error::AmbiguousModId(id.into())); + } + self.download_http(&candidates[0].url) + } + } + } + + /// Downloads a mod given a HTTP link. + /// + /// The [`Downloader`] expects to receive a zipfile containing the mod directory on this link. + /// + /// Refer to the module level documentation and [`Downloader::download`] for more information. + pub fn download_http(&self, url: &Url) -> Result> { + let mut reader = ureq::request_url("GET", url).call()?.into_reader(); + let mut data = Vec::new(); + reader.read_to_end(&mut data)?; + let data = Cursor::new(data); + let mut archive = ZipArchive::new(data)?; + + let dir = self + .temp_dir + .path() + .join(&Uuid::new_v4().to_hyphenated().to_string()); + fs::create_dir(&dir)?; + + archive.extract(&dir)?; + + // Some archives contain the mod files directly, so try to open it: + if let Ok(pack) = minemod::open_mod_or_pack(&dir) { + return Ok(pack); + } + + // If the archive does not contain the mod directly, we instead try the subdirectories that + // we've extracted. + for entry in fs::read_dir(&dir)? { + let entry = entry?; + let metadata = fs::metadata(&entry.path())?; + if metadata.is_dir() { + if let Ok(pack) = minemod::open_mod_or_pack(&entry.path()) { + return Ok(pack); + } + } + } + Err(Error::InvalidModDir(dir)) + } +} diff --git a/modderbaas/src/error.rs b/modderbaas/src/error.rs new file mode 100644 index 0000000..5dbd6b6 --- /dev/null +++ b/modderbaas/src/error.rs @@ -0,0 +1,64 @@ +//! Error definitions for ModderBaas. +//! +//! The type alias [`Result`] can be used, which defaults the error type to [`enum@Error`]. Any function +//! that introduces errors should return a [`Result`] — unless it is clear that a more narrow error +//! will suffice, such as [`crate::util::copy_recursive`]. +use std::path::PathBuf; + +use thiserror::Error; + +/// The main error type. +#[derive(Error, Debug)] +pub enum Error { + /// A malformed or otherwise invalid mod ID has been given. + #[error("invalid mod id '{0}'")] + InvalidModId(String), + /// The ContentDB website returned invalid data. + #[error("the website returned unexpected data")] + InvalidScrape, + /// The directory does not contain a valid Minetest mod. + #[error("'{0}' is not a valid mod directory")] + InvalidModDir(PathBuf), + /// The directory does not contain a valid Minetest game. + #[error("'{0}' is not a valid game directory")] + InvalidGameDir(PathBuf), + /// The directory does not contain a valid Minetest world. + #[error("'{0}' is not a valid world directory")] + InvalidWorldDir(PathBuf), + /// The directory does not contain a valid Minetest modpack. + #[error("'{0}' is not a valid modpack directory")] + InvalidModpackDir(PathBuf), + /// The given source string can not be parsed into a [`crate::download::Source`]. + #[error("'{0}' does not represent a valid mod source")] + InvalidSourceSpec(String), + + /// An empty ZIP archive was downloaded. + #[error("the downloaded file was empty")] + EmptyArchive, + /// The given world does not have a game ID set. + #[error("the world has no game ID set")] + NoGameSet, + /// ContentDB returned more than one fitting mod for the query. + #[error("the mod ID '{0}' does not point to a single mod")] + AmbiguousModId(String), + + /// Wrapper for HTTP errors. + #[error("underlying HTTP error")] + UreqError(#[from] ureq::Error), + /// Wrapper for generic I/O errors. + #[error("underlying I/O error")] + IoError(#[from] std::io::Error), + /// Wrapper for ZIP errors. + #[error("ZIP error")] + ZipError(#[from] zip::result::ZipError), + /// Wrapper for URL parsing errors. + #[error("invalid URL")] + UrlError(#[from] url::ParseError), + /// Wrapper for Unix errors. + #[cfg(unix)] + #[error("underlying Unix error")] + NixError(#[from] nix::errno::Errno), +} + +/// A [`Result`] with the error type defaulted to [`enum@Error`]. +pub type Result = std::result::Result; diff --git a/modderbaas/src/game.rs b/modderbaas/src/game.rs new file mode 100644 index 0000000..0d2405d --- /dev/null +++ b/modderbaas/src/game.rs @@ -0,0 +1,74 @@ +//! Module to interact with installed games. +//! +//! The main type of this module is a [`Game`], which can be constructed by opening installed +//! Minetest games through [`Game::open`]. +//! +//! Valid game directories have a `game.conf` configuration file which contains some metadata about +//! the game. +use std::{ + fmt, + path::{Path, PathBuf}, +}; + +use super::{ + error::{Error, Result}, + minemod::{self, MineMod}, + scan, +}; + +/// Represents an on-disk Minetest game. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Game { + path: PathBuf, +} + +impl Game { + /// Open the given directory as a Minetest game. + /// + /// Note that the path may be relative, but only to the parent directory of the actual game. + /// This is because Minetest uses the game's directory name to identify the game + /// ([`Game::technical_name`]), so we need this information. + pub fn open>(path: P) -> Result { + Game::open_path(path.as_ref()) + } + + fn open_path(path: &Path) -> Result { + if path.file_name().is_none() { + return Err(Error::InvalidGameDir(path.into())); + } + let conf = path.join("game.conf"); + if !conf.is_file() { + return Err(Error::InvalidGameDir(path.into())); + } + + Ok(Game { path: path.into() }) + } + + /// Returns the technical name of this game. + /// + /// This is the name that is used by minetest to identify the game. + pub fn technical_name(&self) -> String { + self.path + .file_name() + .expect("Somebody constructed an invalid `Game`") + .to_str() + .expect("Non-UTF8 directory encountered") + .into() + } + + /// Returns all mods that this game provides. + pub fn mods(&self) -> Result> { + let path = self.path.join("mods"); + let mut mods = vec![]; + for container in scan(&path, |p| minemod::open_mod_or_pack(p))? { + mods.extend(container.mods()?); + } + Ok(mods) + } +} + +impl fmt::Display for Game { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.technical_name()) + } +} diff --git a/modderbaas/src/kvstore.rs b/modderbaas/src/kvstore.rs new file mode 100644 index 0000000..006bb94 --- /dev/null +++ b/modderbaas/src/kvstore.rs @@ -0,0 +1,49 @@ +//! Support module for writing `key=value` stores. +//! +//! These files are used by minetest mods (`mod.conf`), games (`game.conf`) and worlds +//! (`world.mt`). +//! +//! Key-Value-Stores (KVStores) are represented by a [`std::collections::HashMap`] on the Rust +//! side. +use std::{collections::HashMap, fs, io::Write, path::Path}; + +use super::error::Result; + +/// Read the given file as a KVStore. +pub fn read>(path: P) -> Result> { + read_inner(path.as_ref()) +} + +fn read_inner(path: &Path) -> Result> { + let content = fs::read_to_string(path)?; + + Ok(content + .lines() + .map(|line| line.splitn(2, '=').map(str::trim).collect::>()) + .map(|v| (v[0].into(), v[1].into())) + .collect()) +} + +/// Write the given KVStore back to the file. +/// +/// The order of the keys is guaranteed to be the following: +/// +/// 1. All options that *don't* start with `"load_mod_"` are saved in alphabetical order. +/// 2. All remaining options are saved in alphabetical order. +/// +/// Note that this function will **override** existing files! +pub fn write>(data: &HashMap, path: P) -> Result<()> { + write_inner(data, path.as_ref()) +} + +fn write_inner(data: &HashMap, path: &Path) -> Result<()> { + let mut items = data.iter().collect::>(); + items.sort_by_key(|i| (if i.0.starts_with("load_mod_") { 1 } else { 0 }, i.0)); + + let mut output = fs::File::create(path)?; + for (key, value) in items { + writeln!(output, "{} = {}", key, value)?; + } + + Ok(()) +} diff --git a/modderbaas/src/lib.rs b/modderbaas/src/lib.rs new file mode 100644 index 0000000..f9b1c8b --- /dev/null +++ b/modderbaas/src/lib.rs @@ -0,0 +1,104 @@ +//! ModderBaas is a library that allows you to manage Minetest mods. +//! +//! The main point of the library is to be used by the `modderbaas` CLI application, however it can +//! also be used in other Rust programs that want a (limited) API to interact with Minetest +//! content. +//! +//! # Representation +//! +//! Most objects ([`Game`], [`World`], [`MineMod`], [`Modpack`]) are represented by the path to the +//! on-disk directory that contains the corresponding game/world/mod/modpack. The initializers take +//! care that the directory contains a valid object. +//! +//! Many of the query operations do not do any caching, so avoid calling them in tight loops. If +//! you want to do a lot of name queries (e.g. to find installed mods), consider using a +//! [`Snapshot`]. +//! +//! # Racing +//! +//! ModderBaas expects that during the time of its access, no other application meddles with the +//! directories. It will *not* lead to crashes, but you will get a lot more error return values. +//! +//! # Mutability +//! +//! Some objects support mutating their state. This is usually done by directly writing the data +//! into the corresponding file. Therefore, those methods (even though they mutate state) only take +//! a shared reference (`&self`) — keep that in mind! +//! +//! # Crate Structure +//! +//! Most game objects are implemented in their own module and re-exported at the crate root: +//! +//! * [`game`] +//! * [`minemod`] +//! * [`world`] +//! +//! Interacting with mods from the internet/ContentDB is done through two modules: +//! +//! * [`contentdb`] +//! * [`download`] +//! +//! Some modules contain auxiliary functions: +//! +//! * [`kvstore`] +//! * [`util`] +//! +//! Error definitions are in [`error`]. +//! +//! [`baas`] contains functions and data to interact with the global Minetest installation. +use std::{fs, path::Path}; + +use log::debug; + +macro_rules! regex { + ($re:literal $(,)?) => {{ + static RE: once_cell::sync::OnceCell = once_cell::sync::OnceCell::new(); + RE.get_or_init(|| regex::Regex::new($re).unwrap()) + }}; +} + +pub mod baas; +pub mod contentdb; +pub mod download; +pub mod error; +pub mod game; +pub mod kvstore; +pub mod minemod; +pub mod util; +pub mod world; + +pub use baas::{Baas, Snapshot}; +pub use contentdb::ContentDb; +pub use download::{Downloader, Source}; +pub use error::Error; +pub use game::Game; +pub use minemod::{MineMod, Modpack}; +pub use world::World; + +use error::Result; + +/// Scan all files in the given directory. +/// +/// Files for which `scanner` returns `Ok(..)` will be collected and returned. Files for which +/// `scanner` returns `Err(..)` will be silently discarded. +/// +/// This function is useful to iterate through the items in a directory and find fitting objects: +/// +/// ```rust +/// use modderbaas::minemod::MineMod; +/// let mods = scan("/tmp", |p| MineMod::open(p))?; +/// ``` +pub fn scan, F: for<'p> Fn(&'p Path) -> Result>( + path: P, + scanner: F, +) -> Result> { + debug!("Scanning through {:?}", path.as_ref()); + let mut good_ones = vec![]; + for entry in fs::read_dir(path)? { + let entry = entry?; + if let Ok(i) = scanner(&entry.path()) { + good_ones.push(i); + } + } + Ok(good_ones) +} diff --git a/modderbaas/src/minemod.rs b/modderbaas/src/minemod.rs new file mode 100644 index 0000000..456d1c6 --- /dev/null +++ b/modderbaas/src/minemod.rs @@ -0,0 +1,242 @@ +//! Module to interact with installed mods and modpacks. +//! +//! Due to technical reasons (`mod` being a Rust keyword), this module is called `minemod` and the +//! mod objects are called [`MineMod`]. +//! +//! Simple mods are represented by [`MineMod`], which can be opened by [`MineMod::open`]. Valid +//! mods are identified by their `mod.conf`, which contains metadata about the mod. +//! +//! Modpacks can be represented by [`Modpack`] and loaded through [`Modpack::open`]. A modpack is +//! just a collection of mods grouped together, and the modpack directory needs to have a +//! `modpack.conf` in its directory. +//! +//! # Mods and Packs United +//! +//! In some cases, we cannot know in advance whether we are dealing with a mod or a modpack (e.g. +//! when downloading content from ContentDB). Therefore, the trait [`ModContainer`] exists, which +//! can be used as a trait object (`Box`). It provides the most important methods +//! and allows downcasting through `Any`. +//! +//! If you want to work with the mods directly, you can use [`ModContainer::mods`], which returns +//! the mod itself for [`MineMod`]s, and all contained mods for [`Modpack`]s. +//! +//! If you want to open a directory as a [`ModContainer`], you can use [`open_mod_or_pack`]. + +use std::{ + any::Any, + collections::HashMap, + fmt, fs, + path::{Path, PathBuf}, +}; + +use super::{ + error::{Error, Result}, + kvstore, scan, util, +}; + +/// The type of the ID that is used to identify Minetest mods. +pub type ModId = String; + +/// A minemod is a mod that is saved somewhere on disk. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct MineMod { + path: PathBuf, +} + +impl MineMod { + /// Opens the given directory as a mod. + pub fn open>(path: P) -> Result { + MineMod::open_path(path.as_ref()) + } + + fn open_path(path: &Path) -> Result { + let conf = path.join("mod.conf"); + if !conf.is_file() { + return Err(Error::InvalidModDir(path.into())); + } + + Ok(MineMod { path: path.into() }) + } + + /// Returns the path of this mod. + pub fn path(&self) -> &Path { + &self.path + } + + fn read_conf(&self) -> Result> { + let conf = self.path.join("mod.conf"); + kvstore::read(&conf) + } + + /// Read the mod ID. + pub fn mod_id(&self) -> Result { + let conf = self.read_conf()?; + conf.get("name") + .map(Into::into) + .ok_or_else(|| Error::InvalidModDir(self.path.clone())) + } + + /// Returns all dependencies of this mod. + pub fn dependencies(&self) -> Result> { + let conf = self.read_conf()?; + static EMPTY: String = String::new(); + let depstr = conf.get("depends").unwrap_or(&EMPTY); + Ok(depstr + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(Into::into) + .collect()) + } + + /// Copies the mod to the given path. + /// + /// Note that the path should not include the mod directory, that will be appended + /// automatically. + /// + /// Returns a new [`MineMod`] object pointing to the copy. + pub fn copy_to>(&self, path: P) -> Result { + let path = path.as_ref().join(self.mod_id()?); + fs::create_dir_all(&path)?; + util::copy_recursive(&self.path, &path)?; + MineMod::open(&path) + } +} + +impl fmt::Display for MineMod { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.mod_id().map_err(|_| fmt::Error)?) + } +} + +/// Represents an on-disk modpack. +/// +/// We don't support many modpack operations besides listing the modpack contents. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Modpack { + path: PathBuf, +} + +impl Modpack { + /// Opens the given directory as a modpack. + pub fn open>(path: P) -> Result { + Modpack::open_path(path.as_ref()) + } + + fn open_path(path: &Path) -> Result { + let conf = path.join("modpack.conf"); + if !conf.is_file() { + return Err(Error::InvalidModpackDir(path.into())); + } + + Ok(Modpack { path: path.into() }) + } + + /// Returns the path of this modpack. + pub fn path(&self) -> &Path { + &self.path + } + + fn conf(&self) -> Result> { + let conf = self.path.join("modpack.conf"); + kvstore::read(&conf) + } + + /// Returns the name of the modpack. + pub fn name(&self) -> Result { + self.conf()? + .get("name") + .map(Into::into) + .ok_or_else(|| Error::InvalidModDir(self.path.clone())) + } + + /// Return all mods contained in this modpack. + pub fn mods(&self) -> Result> { + let mut mods = vec![]; + for container in scan(&self.path, |p| open_mod_or_pack(p))? { + mods.extend(container.mods()?); + } + Ok(mods) + } + + /// Copies the modpack to the given path. + /// + /// Note that the path should not include the modpack directory, that will be appended + /// automatically. + /// + /// Returns a new [`Modpack`] object pointing to the copy. + pub fn copy_to>(&self, path: P) -> Result { + let path = path.as_ref().join(self.name()?); + fs::create_dir_all(&path)?; + util::copy_recursive(&self.path, &path)?; + Modpack::open(&path) + } +} + +impl fmt::Display for Modpack { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} (pack)", self.name().map_err(|_| fmt::Error)?) + } +} + +/// A thing that can contain mods. +/// +/// This is useful for code that should deal with both mods and modpacks. +pub trait ModContainer: Any + fmt::Display { + /// Returns the name of the mod container. + fn name(&self) -> Result; + + /// Returns the on-disk path of this mod container. + fn path(&self) -> &Path; + + /// Return all contained mods. + fn mods(&self) -> Result>; + + /// Copies the content to the given directory. + fn install_to(&self, path: &Path) -> Result>; +} + +impl ModContainer for MineMod { + fn name(&self) -> Result { + self.mod_id() + } + + fn path(&self) -> &Path { + self.path() + } + + fn mods(&self) -> Result> { + Ok(vec![self.clone()]) + } + + fn install_to(&self, path: &Path) -> Result> { + self.copy_to(path) + .map(|x| Box::new(x) as Box) + } +} + +impl ModContainer for Modpack { + fn name(&self) -> Result { + self.name() + } + + fn path(&self) -> &Path { + self.path() + } + + fn mods(&self) -> Result> { + self.mods() + } + + fn install_to(&self, path: &Path) -> Result> { + self.copy_to(path) + .map(|x| Box::new(x) as Box) + } +} + +/// Attempts to open the given path as either a single mod or a modpack. +pub fn open_mod_or_pack>(path: P) -> Result> { + MineMod::open(path.as_ref()) + .map(|m| Box::new(m) as Box) + .or_else(|_| Modpack::open(path.as_ref()).map(|p| Box::new(p) as Box)) +} diff --git a/modderbaas/src/util.rs b/modderbaas/src/util.rs new file mode 100644 index 0000000..ea401ba --- /dev/null +++ b/modderbaas/src/util.rs @@ -0,0 +1,62 @@ +//! Utility functions. +use std::{fs, io, path::Path}; + +#[cfg(unix)] +use nix::unistd::{self, Gid, Uid}; + +use super::error::Result; + +#[cfg(not(unix))] +pub mod nix { + //! Stub mod on non-unix systems. + pub mod unistd { + pub enum Uid {} + pub enum Gid {} + } +} + +/// Recursively copy the *contents* of the given directory to the given path. +pub fn copy_recursive, D: AsRef>(source: S, destination: D) -> io::Result<()> { + copy_inner(source.as_ref(), destination.as_ref()) +} + +fn copy_inner(source: &Path, destination: &Path) -> io::Result<()> { + for item in fs::read_dir(source)? { + let item = item?; + let metadata = item.metadata()?; + let item_destination = destination.join(item.file_name()); + if metadata.is_file() { + fs::copy(&item.path(), &item_destination)?; + } else if metadata.is_dir() { + fs::create_dir(&item_destination)?; + copy_inner(&item.path(), &item_destination)?; + } + } + Ok(()) +} + +/// Recursively change the owner of the given path to the given ones. +/// +/// Note that this function only works on Unix systems. **It will panic on other systems!** +pub fn chown_recursive>(path: P, uid: Option, gid: Option) -> Result<()> { + chown_inner(path.as_ref(), uid, gid) +} + +#[cfg(unix)] +fn chown_inner(path: &Path, uid: Option, gid: Option) -> Result<()> { + unistd::chown(path, uid, gid)?; + + let metadata = fs::metadata(path)?; + if metadata.is_dir() { + for item in fs::read_dir(path)? { + let item = item?; + chown_inner(&item.path(), uid, gid)?; + } + } + Ok(()) +} + +#[cfg(not(unix))] +fn chown_inner(_: &Path, _: Option, _: Option) -> Result<()> { + panic!("chown() is not available on non-Unix systems!"); +} diff --git a/modderbaas/src/world.rs b/modderbaas/src/world.rs new file mode 100644 index 0000000..5dce6d0 --- /dev/null +++ b/modderbaas/src/world.rs @@ -0,0 +1,97 @@ +//! Module to interact with Minetest worlds (savegames). +//! +//! The main object is [`World`], which represents a Minetest world on-disk. +use std::{ + collections::HashMap, + fmt, + path::{Path, PathBuf}, +}; + +use super::{ + error::{Error, Result}, + kvstore, + minemod::ModId, +}; + +/// Represents an on-disk Minetest world. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct World { + path: PathBuf, +} + +impl World { + /// Open the given directory as a [`World`]. + pub fn open>(path: P) -> Result { + World::open_path(path.as_ref()) + } + + fn open_path(path: &Path) -> Result { + let conf = path.join("world.mt"); + if !conf.is_file() { + return Err(Error::InvalidWorldDir(path.into())); + } + + Ok(World { path: path.into() }) + } + + fn conf(&self) -> Result> { + let conf = self.path.join("world.mt"); + kvstore::read(&conf) + } + + /// Returns the name of the world. + pub fn world_name(&self) -> Result { + let fallback = self + .path + .file_name() + .map(|s| s.to_str().expect("Non-UTF8 directory encountered")); + + let conf = self.conf()?; + conf.get("world_name") + .map(String::as_str) + .or(fallback) + .ok_or_else(|| Error::InvalidWorldDir(self.path.clone())) + .map(Into::into) + } + + /// Extract the game that this world uses. + pub fn game_id(&self) -> Result { + let conf = self.conf()?; + conf.get("gameid").ok_or(Error::NoGameSet).map(Into::into) + } + + /// Returns all mods that are loaded in this world. + /// + /// This returns mods that are explicitely loaded in the config, but not mods that are loaded + /// through the game. + pub fn mods(&self) -> Result> { + let conf = self.conf()?; + const PREFIX_LEN: usize = "load_mod_".len(); + Ok(conf + .iter() + .filter(|(k, _)| k.starts_with("load_mod_")) + .filter(|(_, v)| *v == "true") + .map(|i| i.0[PREFIX_LEN..].into()) + .collect()) + } + + /// Enable the given mod. + /// + /// Note that this function does not ensure that the mod exists on-disk, nor does it do any + /// dependency checks. It simply adds the right `load_mod`-line to the world configuration + /// file. + pub fn enable_mod(&self, mod_id: &str) -> Result<()> { + let mut conf = self.conf()?; + let key = format!("load_mod_{}", mod_id); + conf.entry(key) + .and_modify(|e| *e = "true".into()) + .or_insert_with(|| "true".into()); + kvstore::write(&conf, &self.path.join("world.mt")) + } +} + +impl fmt::Display for World { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.world_name().map_err(|_| fmt::Error)?) + } +} diff --git a/src/baas.rs b/src/baas.rs deleted file mode 100644 index 938b4c4..0000000 --- a/src/baas.rs +++ /dev/null @@ -1,224 +0,0 @@ -//! This module contains functions to query & manipulate the global Minetest installation. -use std::{collections::HashMap, path::PathBuf}; - -use dirs; -use log::debug; - -use super::{ - error::Result, - game::Game, - minemod::{self, MineMod}, - scan, - world::World, -}; - -/// Returns a list of folders in which worlds are expected. -/// -/// Note that not all of these folders need to exist. -/// -/// This returns the following locations: -/// -/// * `$HOME/.minetest/worlds` -/// * `/var/games/minetest-server/.minetest/worlds` -pub fn world_dirs() -> Result> { - let mut paths = vec!["/var/games/minetest-server/.minetest/worlds".into()]; - if let Some(home) = dirs::home_dir() { - paths.push(home.join(".minetest").join("worlds")) - } - Ok(paths) -} - -/// Returns a list of folders in which games are expected. -/// -/// Note that not all of these folders need to exist. -/// -/// This returns the following locations: -/// -/// * `$HOME/.minetest/games` -/// * `/var/games/minetest-server/.minetest/games` -/// * `/usr/share/minetest/games` -/// * `/usr/share/games/minetest/games` -pub fn game_dirs() -> Result> { - let mut paths = vec![ - "/var/games/minetest-server/.minetest/games".into(), - "/usr/share/minetest/games".into(), - "/usr/share/games/minetest/games".into(), - ]; - if let Some(home) = dirs::home_dir() { - paths.push(home.join(".minetest").join("games")) - } - Ok(paths) -} - -/// Returns a list of folders in which mods are expected. -/// -/// Note that not all of these folders need to exist. -/// -/// This returns the following locations: -/// -/// * `$HOME/.minetest/mods` -/// * `/var/games/minetest-server/.minetest/mods` -/// * `/usr/share/games/minetest/mods` -/// * `/usr/share/minetest/mods` -pub fn mod_dirs() -> Result> { - let mut paths = vec![ - "/var/games/minetest-server/.minetest/mods".into(), - "/usr/share/games/minetest/mods".into(), - "/usr/share/minetest/mods".into(), - ]; - if let Some(home) = dirs::home_dir() { - paths.push(home.join(".minetest").join("mods")) - } - Ok(paths) -} - -/// The [`Baas`] provides a way to list all worlds, games and mods on the system and allows access -/// via the [`World`], [`Game`] and [`MineMod`] wrappers. -#[derive(Debug, Default, Clone)] -pub struct Baas { - world_dirs: Vec, - game_dirs: Vec, - mod_dirs: Vec, -} - -impl Baas { - /// Create a [`Baas`] with the standard dirs. - pub fn with_standard_dirs() -> Result { - Ok(Baas::default() - .with_world_dirs(world_dirs()?) - .with_game_dirs(game_dirs()?) - .with_mod_dirs(mod_dirs()?)) - } - - /// Replace the world dirs with the given list of world dirs. - pub fn with_world_dirs(self, world_dirs: Vec) -> Baas { - Baas { world_dirs, ..self } - } - - /// The list of directories which are searched for worlds. - #[inline] - pub fn world_dirs(&self) -> &[PathBuf] { - self.world_dirs.as_slice() - } - - /// Replace the game dirs with the given list of game dirs. - pub fn with_game_dirs(self, game_dirs: Vec) -> Baas { - Baas { game_dirs, ..self } - } - - /// The list of directories which are searched for games. - #[inline] - pub fn game_dirs(&self) -> &[PathBuf] { - self.game_dirs.as_slice() - } - - /// Replace the mod dirs with the given list of mod dirs. - pub fn with_mod_dirs(self, mod_dirs: Vec) -> Baas { - Baas { mod_dirs, ..self } - } - - /// The list of directories which are searched for mods. - #[inline] - pub fn mod_dirs(&self) -> &[PathBuf] { - self.mod_dirs.as_slice() - } - - /// Returns a vector of all words that were found in the world dirs. - pub fn worlds(&self) -> Result> { - let mut worlds = vec![]; - for dir in self.world_dirs() { - match scan(&dir, |p| World::open(p)) { - Ok(w) => worlds.extend(w), - Err(e) => debug!("Cannot scan {:?}: {}", dir, e), - } - } - Ok(worlds) - } - - /// Returns a vector of all games that were found in the game dirs. - pub fn games(&self) -> Result> { - let mut games = vec![]; - for dir in self.game_dirs() { - match scan(&dir, |p| Game::open(p)) { - Ok(g) => games.extend(g), - Err(e) => debug!("Cannot scan {:?}: {}", dir, e), - } - } - Ok(games) - } - - /// Returns a vector of all mods that were found in the mod dirs. - /// - /// Note that modpacks are flattened into mods. - pub fn mods(&self) -> Result> { - let mut mods = vec![]; - for dir in self.mod_dirs() { - match scan(&dir, |p| minemod::open_mod_or_pack(p)) { - Ok(m) => { - for container in m { - mods.extend(container.mods()?); - } - } - Err(e) => debug!("Cannot scan {:?}: {}", dir, e), - } - } - Ok(mods) - } - - /// Return a snapshot of the current state. - /// - /// A snapshot "freezes" the lists of worlds, mods and games in time. It is useful to avoid - /// unnecessary I/O when it is known that the state should not have changed. It also allows - /// fast searching of items by their name. - pub fn snapshot(&self) -> Result { - let worlds = self.worlds()?; - let games = self.games()?; - let mods = self.mods()?; - - Ok(Snapshot { - worlds: worlds - .into_iter() - .map(|w| Ok((w.world_name()?, w))) - .collect::>()?, - games: games.into_iter().map(|g| (g.technical_name(), g)).collect(), - mods: mods - .into_iter() - .map(|m| Ok((m.mod_id()?, m))) - .collect::>()?, - }) - } -} - -/// Snapshot of a [`Baas`] scan. -/// -/// A snapshot is created through the [`Baas::snapshot`] method and gives a frozen view on the -/// installed objects. -#[derive(Debug, Clone)] -pub struct Snapshot { - worlds: HashMap, - games: HashMap, - mods: HashMap, -} - -impl Snapshot { - /// Return all worlds that were found. - /// - /// The map maps the world's name to the [`World`] object. - pub fn worlds(&self) -> &HashMap { - &self.worlds - } - - /// Return all games that were found. - /// - /// The map maps the game ID to the [`Game`] object. - pub fn games(&self) -> &HashMap { - &self.games - } - - /// Return the available mods that were found. - /// - /// The map maps the mod name to the [`MineMod`] object. - pub fn mods(&self) -> &HashMap { - &self.mods - } -} diff --git a/src/contentdb.rs b/src/contentdb.rs deleted file mode 100644 index d9c4688..0000000 --- a/src/contentdb.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! Module to interact with the Minetest Content DB website. - -use once_cell::sync::Lazy; -use scraper::{Html, Selector}; -use serde::{Deserialize, Serialize}; -use url::Url; - -use super::error::{Error, Result}; - -/// The identification of content on Content DB. Consists of the username and the package name. -pub type ContentId = (String, String); - -/// The URL of the default Content DB website to use. -pub static DEFAULT_INSTANCE: Lazy = - Lazy::new(|| Url::parse("https://content.minetest.net/").expect("Invalid default URL")); - -/// The metapackage selector to scrape the packages. -static PROVIDES_SELECTOR: Lazy = - Lazy::new(|| Selector::parse("ul.d-flex").expect("Invalid selector")); - -static A_SELECTOR: Lazy = Lazy::new(|| Selector::parse("a").expect("Invalid selector")); - -/// (Partial) metadata of a content item, as returned by the Content DB API. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] -pub struct ContentMeta { - /// Username of the author. - pub author: String, - /// Name of the package. - pub name: String, - /// A list of mods that are provided by this package. - pub provides: Vec, - /// The short description of the package. - pub short_description: String, - /// The (human-readable) title of this package. - pub title: String, - /// The type of the package ("mod", "game", "txp") - #[serde(rename = "type")] - pub typ: String, - /// The download URL of the package. - pub url: Url, -} - -/// The main access point for Content DB queries. -#[derive(Debug, Clone)] -pub struct ContentDb { - base_url: Url, -} - -impl Default for ContentDb { - fn default() -> Self { - Self::new() - } -} - -impl ContentDb { - /// Create a new Content DB accessor pointing to the default instance. - pub fn new() -> ContentDb { - ContentDb { - base_url: DEFAULT_INSTANCE.clone(), - } - } - - /// Find suitable candidates that provide the given modname. - pub fn resolve(&self, modname: &str) -> Result> { - let path = format!("metapackages/{}", modname); - let endpoint = self - .base_url - .join(&path) - .map_err(|_| Error::InvalidModId(modname.into()))?; - - let body = ureq::request_url("GET", &endpoint).call()?.into_string()?; - - let dom = Html::parse_document(&body); - let provides = dom - .select(&PROVIDES_SELECTOR) - .next() - .ok_or(Error::InvalidScrape)?; - - let candidates: Vec = provides - .select(&A_SELECTOR) - .filter_map(|a| a.value().attr("href")) - .filter_map(extract_content_id) - .collect(); - - let mut good_ones = Vec::new(); - - for (user, package) in candidates { - let path = format!("api/packages/{}/{}/", user, package); - let endpoint = self - .base_url - .join(&path) - .expect("The parsed path was wrong"); - let response: ContentMeta = ureq::request_url("GET", &endpoint).call()?.into_json()?; - - // While resolving, we only care about actual mods that we can install. If a game - // provides a certain metapackage, it is pretty much useless for us (and often just - // there because a mod in that game provides the metapackage). - if response.typ == "mod" { - good_ones.push(response) - } - } - - Ok(good_ones) - } - - /// Retrieve the download url for a given package. - pub fn download_url(&self, user: &str, package: &str) -> Result { - let path = format!("api/packages/{}/{}/", user, package); - let endpoint = self - .base_url - .join(&path) - .expect("The parsed path was wrong"); - let response: ContentMeta = ureq::request_url("GET", &endpoint).call()?.into_json()?; - Ok(response.url) - } -} - -fn extract_content_id(path: &str) -> Option { - regex!("/packages/([^/]+)/([^/]+)/$") - .captures(path) - .map(|c| { - ( - c.get(1).unwrap().as_str().into(), - c.get(2).unwrap().as_str().into(), - ) - }) -} diff --git a/src/download.rs b/src/download.rs deleted file mode 100644 index b9507b7..0000000 --- a/src/download.rs +++ /dev/null @@ -1,161 +0,0 @@ -//! Module to download mods from the internet. -//! -//! This module allows to download mods from various sources in the internet. Source specification -//! is done through the [`Source`] enum: -//! -//! * [`Source::Http`]: Download straight from an URL. It is expected that the URL points to a zip -//! archive which contains the mod, either directly or in a subfolder. -//! * [`Source::ContentDb`]: Refers to a package on the ContentDB. The [`Downloader`] will consult -//! the API to get the right download URL. -//! * [`Source::ModId`]: Refers to a simple mod name. Note that this specification can be -//! ambiguous, in which case the [`Downloader`] will return an error. -//! -//! The actual download work is done by a [`Downloader`]. Each [`Downloader`] has its own temporary -//! directory, in which any mods are downloaded and extracted. If you drop the [`Downloader`], -//! those mods will be deleted and the objects pointing to the now-gone directories are no longer -//! useful. - -use std::{ - fs, - io::{Cursor, Read}, - str::FromStr, -}; - -use tempdir::TempDir; -use url::Url; -use uuid::Uuid; -use zip::ZipArchive; - -use super::{ - contentdb::{ContentDb, ContentId}, - error::{Error, Result}, - minemod::{self, ModContainer, ModId}, -}; - -/// A source determines where a mod should be loaded from. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum Source { - /// Download a mod archive through HTTP. - Http(Url), - /// Download a mod from the Content DB, using the given user- and package name. - ContentDb(ContentId), - /// Search the Content DB for a given mod ID. - /// - /// The download may fail if there are multiple mods providing the same ID. - ModId(ModId), -} - -impl FromStr for Source { - type Err = Error; - - fn from_str(s: &str) -> Result { - if s.starts_with("http://") || s.starts_with("https://") { - let url = Url::parse(s)?; - return Ok(Source::Http(url)); - } - let groups = regex!("^([^/]+)/([^/]+)$").captures(s); - if let Some(groups) = groups { - return Ok(Source::ContentDb(( - groups.get(1).unwrap().as_str().into(), - groups.get(2).unwrap().as_str().into(), - ))); - } - - if !s.contains(' ') { - return Ok(Source::ModId(s.into())); - } - - Err(Error::InvalidSourceSpec(s.into())) - } -} - -/// A downloader is responsible for downloading mods from various sources. -/// -/// Note that the objects that the [`Downloader`] creates will not work after the downloader has -/// been destroyed, as the temporary files will be lost. -#[derive(Debug)] -pub struct Downloader { - temp_dir: TempDir, - content_db: ContentDb, -} - -impl Downloader { - /// Create a new [`Downloader`], refering to the default ContentDB. - pub fn new() -> Result { - Downloader::with_content_db(Default::default()) - } - - /// Create a new [`Downloader`] that points to a specific ContentDB instance. - pub fn with_content_db(content_db: ContentDb) -> Result { - let temp_dir = TempDir::new(env!("CARGO_PKG_NAME"))?; - Ok(Downloader { - temp_dir, - content_db, - }) - } - - /// Download a mod from the given source. - /// - /// This function may download either a mod ([`minemod::MineMod`]) or a modpack - /// ([`minemod::Modpack`]), therefore it returns a trait object that can be queried for the - /// required information. - /// - /// Note that the object will be useless when the [`Downloader`] is dropped, as the temporary - /// directory containing the downloaded data will be lost. Use [`ModContainer::install_to`] to - /// copy the mod content to a different directory. - pub fn download(&self, source: &Source) -> Result> { - match *source { - Source::Http(ref url) => self.download_http(url), - Source::ContentDb((ref user, ref package)) => { - let url = self.content_db.download_url(user, package)?; - self.download_http(&url) - } - Source::ModId(ref id) => { - let candidates = self.content_db.resolve(id)?; - if candidates.len() != 1 { - return Err(Error::AmbiguousModId(id.into())); - } - self.download_http(&candidates[0].url) - } - } - } - - /// Downloads a mod given a HTTP link. - /// - /// The [`Downloader`] expects to receive a zipfile containing the mod directory on this link. - /// - /// Refer to the module level documentation and [`Downloader::download`] for more information. - pub fn download_http(&self, url: &Url) -> Result> { - let mut reader = ureq::request_url("GET", url).call()?.into_reader(); - let mut data = Vec::new(); - reader.read_to_end(&mut data)?; - let data = Cursor::new(data); - let mut archive = ZipArchive::new(data)?; - - let dir = self - .temp_dir - .path() - .join(&Uuid::new_v4().to_hyphenated().to_string()); - fs::create_dir(&dir)?; - - archive.extract(&dir)?; - - // Some archives contain the mod files directly, so try to open it: - if let Ok(pack) = minemod::open_mod_or_pack(&dir) { - return Ok(pack); - } - - // If the archive does not contain the mod directly, we instead try the subdirectories that - // we've extracted. - for entry in fs::read_dir(&dir)? { - let entry = entry?; - let metadata = fs::metadata(&entry.path())?; - if metadata.is_dir() { - if let Ok(pack) = minemod::open_mod_or_pack(&entry.path()) { - return Ok(pack); - } - } - } - Err(Error::InvalidModDir(dir)) - } -} diff --git a/src/error.rs b/src/error.rs deleted file mode 100644 index 5dbd6b6..0000000 --- a/src/error.rs +++ /dev/null @@ -1,64 +0,0 @@ -//! Error definitions for ModderBaas. -//! -//! The type alias [`Result`] can be used, which defaults the error type to [`enum@Error`]. Any function -//! that introduces errors should return a [`Result`] — unless it is clear that a more narrow error -//! will suffice, such as [`crate::util::copy_recursive`]. -use std::path::PathBuf; - -use thiserror::Error; - -/// The main error type. -#[derive(Error, Debug)] -pub enum Error { - /// A malformed or otherwise invalid mod ID has been given. - #[error("invalid mod id '{0}'")] - InvalidModId(String), - /// The ContentDB website returned invalid data. - #[error("the website returned unexpected data")] - InvalidScrape, - /// The directory does not contain a valid Minetest mod. - #[error("'{0}' is not a valid mod directory")] - InvalidModDir(PathBuf), - /// The directory does not contain a valid Minetest game. - #[error("'{0}' is not a valid game directory")] - InvalidGameDir(PathBuf), - /// The directory does not contain a valid Minetest world. - #[error("'{0}' is not a valid world directory")] - InvalidWorldDir(PathBuf), - /// The directory does not contain a valid Minetest modpack. - #[error("'{0}' is not a valid modpack directory")] - InvalidModpackDir(PathBuf), - /// The given source string can not be parsed into a [`crate::download::Source`]. - #[error("'{0}' does not represent a valid mod source")] - InvalidSourceSpec(String), - - /// An empty ZIP archive was downloaded. - #[error("the downloaded file was empty")] - EmptyArchive, - /// The given world does not have a game ID set. - #[error("the world has no game ID set")] - NoGameSet, - /// ContentDB returned more than one fitting mod for the query. - #[error("the mod ID '{0}' does not point to a single mod")] - AmbiguousModId(String), - - /// Wrapper for HTTP errors. - #[error("underlying HTTP error")] - UreqError(#[from] ureq::Error), - /// Wrapper for generic I/O errors. - #[error("underlying I/O error")] - IoError(#[from] std::io::Error), - /// Wrapper for ZIP errors. - #[error("ZIP error")] - ZipError(#[from] zip::result::ZipError), - /// Wrapper for URL parsing errors. - #[error("invalid URL")] - UrlError(#[from] url::ParseError), - /// Wrapper for Unix errors. - #[cfg(unix)] - #[error("underlying Unix error")] - NixError(#[from] nix::errno::Errno), -} - -/// A [`Result`] with the error type defaulted to [`enum@Error`]. -pub type Result = std::result::Result; diff --git a/src/game.rs b/src/game.rs deleted file mode 100644 index 0d2405d..0000000 --- a/src/game.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Module to interact with installed games. -//! -//! The main type of this module is a [`Game`], which can be constructed by opening installed -//! Minetest games through [`Game::open`]. -//! -//! Valid game directories have a `game.conf` configuration file which contains some metadata about -//! the game. -use std::{ - fmt, - path::{Path, PathBuf}, -}; - -use super::{ - error::{Error, Result}, - minemod::{self, MineMod}, - scan, -}; - -/// Represents an on-disk Minetest game. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct Game { - path: PathBuf, -} - -impl Game { - /// Open the given directory as a Minetest game. - /// - /// Note that the path may be relative, but only to the parent directory of the actual game. - /// This is because Minetest uses the game's directory name to identify the game - /// ([`Game::technical_name`]), so we need this information. - pub fn open>(path: P) -> Result { - Game::open_path(path.as_ref()) - } - - fn open_path(path: &Path) -> Result { - if path.file_name().is_none() { - return Err(Error::InvalidGameDir(path.into())); - } - let conf = path.join("game.conf"); - if !conf.is_file() { - return Err(Error::InvalidGameDir(path.into())); - } - - Ok(Game { path: path.into() }) - } - - /// Returns the technical name of this game. - /// - /// This is the name that is used by minetest to identify the game. - pub fn technical_name(&self) -> String { - self.path - .file_name() - .expect("Somebody constructed an invalid `Game`") - .to_str() - .expect("Non-UTF8 directory encountered") - .into() - } - - /// Returns all mods that this game provides. - pub fn mods(&self) -> Result> { - let path = self.path.join("mods"); - let mut mods = vec![]; - for container in scan(&path, |p| minemod::open_mod_or_pack(p))? { - mods.extend(container.mods()?); - } - Ok(mods) - } -} - -impl fmt::Display for Game { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.technical_name()) - } -} diff --git a/src/kvstore.rs b/src/kvstore.rs deleted file mode 100644 index 006bb94..0000000 --- a/src/kvstore.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! Support module for writing `key=value` stores. -//! -//! These files are used by minetest mods (`mod.conf`), games (`game.conf`) and worlds -//! (`world.mt`). -//! -//! Key-Value-Stores (KVStores) are represented by a [`std::collections::HashMap`] on the Rust -//! side. -use std::{collections::HashMap, fs, io::Write, path::Path}; - -use super::error::Result; - -/// Read the given file as a KVStore. -pub fn read>(path: P) -> Result> { - read_inner(path.as_ref()) -} - -fn read_inner(path: &Path) -> Result> { - let content = fs::read_to_string(path)?; - - Ok(content - .lines() - .map(|line| line.splitn(2, '=').map(str::trim).collect::>()) - .map(|v| (v[0].into(), v[1].into())) - .collect()) -} - -/// Write the given KVStore back to the file. -/// -/// The order of the keys is guaranteed to be the following: -/// -/// 1. All options that *don't* start with `"load_mod_"` are saved in alphabetical order. -/// 2. All remaining options are saved in alphabetical order. -/// -/// Note that this function will **override** existing files! -pub fn write>(data: &HashMap, path: P) -> Result<()> { - write_inner(data, path.as_ref()) -} - -fn write_inner(data: &HashMap, path: &Path) -> Result<()> { - let mut items = data.iter().collect::>(); - items.sort_by_key(|i| (if i.0.starts_with("load_mod_") { 1 } else { 0 }, i.0)); - - let mut output = fs::File::create(path)?; - for (key, value) in items { - writeln!(output, "{} = {}", key, value)?; - } - - Ok(()) -} diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index f9b1c8b..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,104 +0,0 @@ -//! ModderBaas is a library that allows you to manage Minetest mods. -//! -//! The main point of the library is to be used by the `modderbaas` CLI application, however it can -//! also be used in other Rust programs that want a (limited) API to interact with Minetest -//! content. -//! -//! # Representation -//! -//! Most objects ([`Game`], [`World`], [`MineMod`], [`Modpack`]) are represented by the path to the -//! on-disk directory that contains the corresponding game/world/mod/modpack. The initializers take -//! care that the directory contains a valid object. -//! -//! Many of the query operations do not do any caching, so avoid calling them in tight loops. If -//! you want to do a lot of name queries (e.g. to find installed mods), consider using a -//! [`Snapshot`]. -//! -//! # Racing -//! -//! ModderBaas expects that during the time of its access, no other application meddles with the -//! directories. It will *not* lead to crashes, but you will get a lot more error return values. -//! -//! # Mutability -//! -//! Some objects support mutating their state. This is usually done by directly writing the data -//! into the corresponding file. Therefore, those methods (even though they mutate state) only take -//! a shared reference (`&self`) — keep that in mind! -//! -//! # Crate Structure -//! -//! Most game objects are implemented in their own module and re-exported at the crate root: -//! -//! * [`game`] -//! * [`minemod`] -//! * [`world`] -//! -//! Interacting with mods from the internet/ContentDB is done through two modules: -//! -//! * [`contentdb`] -//! * [`download`] -//! -//! Some modules contain auxiliary functions: -//! -//! * [`kvstore`] -//! * [`util`] -//! -//! Error definitions are in [`error`]. -//! -//! [`baas`] contains functions and data to interact with the global Minetest installation. -use std::{fs, path::Path}; - -use log::debug; - -macro_rules! regex { - ($re:literal $(,)?) => {{ - static RE: once_cell::sync::OnceCell = once_cell::sync::OnceCell::new(); - RE.get_or_init(|| regex::Regex::new($re).unwrap()) - }}; -} - -pub mod baas; -pub mod contentdb; -pub mod download; -pub mod error; -pub mod game; -pub mod kvstore; -pub mod minemod; -pub mod util; -pub mod world; - -pub use baas::{Baas, Snapshot}; -pub use contentdb::ContentDb; -pub use download::{Downloader, Source}; -pub use error::Error; -pub use game::Game; -pub use minemod::{MineMod, Modpack}; -pub use world::World; - -use error::Result; - -/// Scan all files in the given directory. -/// -/// Files for which `scanner` returns `Ok(..)` will be collected and returned. Files for which -/// `scanner` returns `Err(..)` will be silently discarded. -/// -/// This function is useful to iterate through the items in a directory and find fitting objects: -/// -/// ```rust -/// use modderbaas::minemod::MineMod; -/// let mods = scan("/tmp", |p| MineMod::open(p))?; -/// ``` -pub fn scan, F: for<'p> Fn(&'p Path) -> Result>( - path: P, - scanner: F, -) -> Result> { - debug!("Scanning through {:?}", path.as_ref()); - let mut good_ones = vec![]; - for entry in fs::read_dir(path)? { - let entry = entry?; - if let Ok(i) = scanner(&entry.path()) { - good_ones.push(i); - } - } - Ok(good_ones) -} diff --git a/src/minemod.rs b/src/minemod.rs deleted file mode 100644 index 456d1c6..0000000 --- a/src/minemod.rs +++ /dev/null @@ -1,242 +0,0 @@ -//! Module to interact with installed mods and modpacks. -//! -//! Due to technical reasons (`mod` being a Rust keyword), this module is called `minemod` and the -//! mod objects are called [`MineMod`]. -//! -//! Simple mods are represented by [`MineMod`], which can be opened by [`MineMod::open`]. Valid -//! mods are identified by their `mod.conf`, which contains metadata about the mod. -//! -//! Modpacks can be represented by [`Modpack`] and loaded through [`Modpack::open`]. A modpack is -//! just a collection of mods grouped together, and the modpack directory needs to have a -//! `modpack.conf` in its directory. -//! -//! # Mods and Packs United -//! -//! In some cases, we cannot know in advance whether we are dealing with a mod or a modpack (e.g. -//! when downloading content from ContentDB). Therefore, the trait [`ModContainer`] exists, which -//! can be used as a trait object (`Box`). It provides the most important methods -//! and allows downcasting through `Any`. -//! -//! If you want to work with the mods directly, you can use [`ModContainer::mods`], which returns -//! the mod itself for [`MineMod`]s, and all contained mods for [`Modpack`]s. -//! -//! If you want to open a directory as a [`ModContainer`], you can use [`open_mod_or_pack`]. - -use std::{ - any::Any, - collections::HashMap, - fmt, fs, - path::{Path, PathBuf}, -}; - -use super::{ - error::{Error, Result}, - kvstore, scan, util, -}; - -/// The type of the ID that is used to identify Minetest mods. -pub type ModId = String; - -/// A minemod is a mod that is saved somewhere on disk. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct MineMod { - path: PathBuf, -} - -impl MineMod { - /// Opens the given directory as a mod. - pub fn open>(path: P) -> Result { - MineMod::open_path(path.as_ref()) - } - - fn open_path(path: &Path) -> Result { - let conf = path.join("mod.conf"); - if !conf.is_file() { - return Err(Error::InvalidModDir(path.into())); - } - - Ok(MineMod { path: path.into() }) - } - - /// Returns the path of this mod. - pub fn path(&self) -> &Path { - &self.path - } - - fn read_conf(&self) -> Result> { - let conf = self.path.join("mod.conf"); - kvstore::read(&conf) - } - - /// Read the mod ID. - pub fn mod_id(&self) -> Result { - let conf = self.read_conf()?; - conf.get("name") - .map(Into::into) - .ok_or_else(|| Error::InvalidModDir(self.path.clone())) - } - - /// Returns all dependencies of this mod. - pub fn dependencies(&self) -> Result> { - let conf = self.read_conf()?; - static EMPTY: String = String::new(); - let depstr = conf.get("depends").unwrap_or(&EMPTY); - Ok(depstr - .split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(Into::into) - .collect()) - } - - /// Copies the mod to the given path. - /// - /// Note that the path should not include the mod directory, that will be appended - /// automatically. - /// - /// Returns a new [`MineMod`] object pointing to the copy. - pub fn copy_to>(&self, path: P) -> Result { - let path = path.as_ref().join(self.mod_id()?); - fs::create_dir_all(&path)?; - util::copy_recursive(&self.path, &path)?; - MineMod::open(&path) - } -} - -impl fmt::Display for MineMod { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.mod_id().map_err(|_| fmt::Error)?) - } -} - -/// Represents an on-disk modpack. -/// -/// We don't support many modpack operations besides listing the modpack contents. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct Modpack { - path: PathBuf, -} - -impl Modpack { - /// Opens the given directory as a modpack. - pub fn open>(path: P) -> Result { - Modpack::open_path(path.as_ref()) - } - - fn open_path(path: &Path) -> Result { - let conf = path.join("modpack.conf"); - if !conf.is_file() { - return Err(Error::InvalidModpackDir(path.into())); - } - - Ok(Modpack { path: path.into() }) - } - - /// Returns the path of this modpack. - pub fn path(&self) -> &Path { - &self.path - } - - fn conf(&self) -> Result> { - let conf = self.path.join("modpack.conf"); - kvstore::read(&conf) - } - - /// Returns the name of the modpack. - pub fn name(&self) -> Result { - self.conf()? - .get("name") - .map(Into::into) - .ok_or_else(|| Error::InvalidModDir(self.path.clone())) - } - - /// Return all mods contained in this modpack. - pub fn mods(&self) -> Result> { - let mut mods = vec![]; - for container in scan(&self.path, |p| open_mod_or_pack(p))? { - mods.extend(container.mods()?); - } - Ok(mods) - } - - /// Copies the modpack to the given path. - /// - /// Note that the path should not include the modpack directory, that will be appended - /// automatically. - /// - /// Returns a new [`Modpack`] object pointing to the copy. - pub fn copy_to>(&self, path: P) -> Result { - let path = path.as_ref().join(self.name()?); - fs::create_dir_all(&path)?; - util::copy_recursive(&self.path, &path)?; - Modpack::open(&path) - } -} - -impl fmt::Display for Modpack { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{} (pack)", self.name().map_err(|_| fmt::Error)?) - } -} - -/// A thing that can contain mods. -/// -/// This is useful for code that should deal with both mods and modpacks. -pub trait ModContainer: Any + fmt::Display { - /// Returns the name of the mod container. - fn name(&self) -> Result; - - /// Returns the on-disk path of this mod container. - fn path(&self) -> &Path; - - /// Return all contained mods. - fn mods(&self) -> Result>; - - /// Copies the content to the given directory. - fn install_to(&self, path: &Path) -> Result>; -} - -impl ModContainer for MineMod { - fn name(&self) -> Result { - self.mod_id() - } - - fn path(&self) -> &Path { - self.path() - } - - fn mods(&self) -> Result> { - Ok(vec![self.clone()]) - } - - fn install_to(&self, path: &Path) -> Result> { - self.copy_to(path) - .map(|x| Box::new(x) as Box) - } -} - -impl ModContainer for Modpack { - fn name(&self) -> Result { - self.name() - } - - fn path(&self) -> &Path { - self.path() - } - - fn mods(&self) -> Result> { - self.mods() - } - - fn install_to(&self, path: &Path) -> Result> { - self.copy_to(path) - .map(|x| Box::new(x) as Box) - } -} - -/// Attempts to open the given path as either a single mod or a modpack. -pub fn open_mod_or_pack>(path: P) -> Result> { - MineMod::open(path.as_ref()) - .map(|m| Box::new(m) as Box) - .or_else(|_| Modpack::open(path.as_ref()).map(|p| Box::new(p) as Box)) -} diff --git a/src/util.rs b/src/util.rs deleted file mode 100644 index ea401ba..0000000 --- a/src/util.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! Utility functions. -use std::{fs, io, path::Path}; - -#[cfg(unix)] -use nix::unistd::{self, Gid, Uid}; - -use super::error::Result; - -#[cfg(not(unix))] -pub mod nix { - //! Stub mod on non-unix systems. - pub mod unistd { - pub enum Uid {} - pub enum Gid {} - } -} - -/// Recursively copy the *contents* of the given directory to the given path. -pub fn copy_recursive, D: AsRef>(source: S, destination: D) -> io::Result<()> { - copy_inner(source.as_ref(), destination.as_ref()) -} - -fn copy_inner(source: &Path, destination: &Path) -> io::Result<()> { - for item in fs::read_dir(source)? { - let item = item?; - let metadata = item.metadata()?; - let item_destination = destination.join(item.file_name()); - if metadata.is_file() { - fs::copy(&item.path(), &item_destination)?; - } else if metadata.is_dir() { - fs::create_dir(&item_destination)?; - copy_inner(&item.path(), &item_destination)?; - } - } - Ok(()) -} - -/// Recursively change the owner of the given path to the given ones. -/// -/// Note that this function only works on Unix systems. **It will panic on other systems!** -pub fn chown_recursive>(path: P, uid: Option, gid: Option) -> Result<()> { - chown_inner(path.as_ref(), uid, gid) -} - -#[cfg(unix)] -fn chown_inner(path: &Path, uid: Option, gid: Option) -> Result<()> { - unistd::chown(path, uid, gid)?; - - let metadata = fs::metadata(path)?; - if metadata.is_dir() { - for item in fs::read_dir(path)? { - let item = item?; - chown_inner(&item.path(), uid, gid)?; - } - } - Ok(()) -} - -#[cfg(not(unix))] -fn chown_inner(_: &Path, _: Option, _: Option) -> Result<()> { - panic!("chown() is not available on non-Unix systems!"); -} diff --git a/src/world.rs b/src/world.rs deleted file mode 100644 index 5dce6d0..0000000 --- a/src/world.rs +++ /dev/null @@ -1,97 +0,0 @@ -//! Module to interact with Minetest worlds (savegames). -//! -//! The main object is [`World`], which represents a Minetest world on-disk. -use std::{ - collections::HashMap, - fmt, - path::{Path, PathBuf}, -}; - -use super::{ - error::{Error, Result}, - kvstore, - minemod::ModId, -}; - -/// Represents an on-disk Minetest world. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct World { - path: PathBuf, -} - -impl World { - /// Open the given directory as a [`World`]. - pub fn open>(path: P) -> Result { - World::open_path(path.as_ref()) - } - - fn open_path(path: &Path) -> Result { - let conf = path.join("world.mt"); - if !conf.is_file() { - return Err(Error::InvalidWorldDir(path.into())); - } - - Ok(World { path: path.into() }) - } - - fn conf(&self) -> Result> { - let conf = self.path.join("world.mt"); - kvstore::read(&conf) - } - - /// Returns the name of the world. - pub fn world_name(&self) -> Result { - let fallback = self - .path - .file_name() - .map(|s| s.to_str().expect("Non-UTF8 directory encountered")); - - let conf = self.conf()?; - conf.get("world_name") - .map(String::as_str) - .or(fallback) - .ok_or_else(|| Error::InvalidWorldDir(self.path.clone())) - .map(Into::into) - } - - /// Extract the game that this world uses. - pub fn game_id(&self) -> Result { - let conf = self.conf()?; - conf.get("gameid").ok_or(Error::NoGameSet).map(Into::into) - } - - /// Returns all mods that are loaded in this world. - /// - /// This returns mods that are explicitely loaded in the config, but not mods that are loaded - /// through the game. - pub fn mods(&self) -> Result> { - let conf = self.conf()?; - const PREFIX_LEN: usize = "load_mod_".len(); - Ok(conf - .iter() - .filter(|(k, _)| k.starts_with("load_mod_")) - .filter(|(_, v)| *v == "true") - .map(|i| i.0[PREFIX_LEN..].into()) - .collect()) - } - - /// Enable the given mod. - /// - /// Note that this function does not ensure that the mod exists on-disk, nor does it do any - /// dependency checks. It simply adds the right `load_mod`-line to the world configuration - /// file. - pub fn enable_mod(&self, mod_id: &str) -> Result<()> { - let mut conf = self.conf()?; - let key = format!("load_mod_{}", mod_id); - conf.entry(key) - .and_modify(|e| *e = "true".into()) - .or_insert_with(|| "true".into()); - kvstore::write(&conf, &self.path.join("world.mt")) - } -} - -impl fmt::Display for World { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.world_name().map_err(|_| fmt::Error)?) - } -} -- cgit v1.2.3