aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorDaniel Schadt <kingdread@gmx.de>2021-11-06 22:25:23 +0100
committerDaniel Schadt <kingdread@gmx.de>2021-11-06 22:25:23 +0100
commit834a2d4f8a7dbd0e9eb14573c4340eb41af04738 (patch)
treebebd06cc13104fb8e77dc313f2f2ee862eb94a32 /src
downloadmodderbaas-834a2d4f8a7dbd0e9eb14573c4340eb41af04738.tar.gz
modderbaas-834a2d4f8a7dbd0e9eb14573c4340eb41af04738.tar.bz2
modderbaas-834a2d4f8a7dbd0e9eb14573c4340eb41af04738.zip
Initial commit
This is the inital commit of a somewhat working version.
Diffstat (limited to 'src')
-rw-r--r--src/baas.rs214
-rw-r--r--src/contentdb.rs120
-rw-r--r--src/download.rs120
-rw-r--r--src/error.rs39
-rw-r--r--src/game.rs67
-rw-r--r--src/kvstore.rs49
-rw-r--r--src/lib.rs52
-rw-r--r--src/main.rs335
-rw-r--r--src/minemod.rs165
-rw-r--r--src/world.rs86
10 files changed, 1247 insertions, 0 deletions
diff --git a/src/baas.rs b/src/baas.rs
new file mode 100644
index 0000000..36a7aad
--- /dev/null
+++ b/src/baas.rs
@@ -0,0 +1,214 @@
+//! Functions to 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<Vec<PathBuf>> {
+ 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<Vec<PathBuf>> {
+ 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<Vec<PathBuf>> {
+ 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<PathBuf>,
+ game_dirs: Vec<PathBuf>,
+ mod_dirs: Vec<PathBuf>,
+}
+
+impl Baas {
+ /// Create a [`Baas`] with the standard dirs.
+ pub fn with_standard_dirs() -> Result<Baas> {
+ 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<Vec<World>> {
+ 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<Vec<Game>> {
+ 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<Vec<MineMod>> {
+ 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<Snapshot> {
+ 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::<Result<_>>()?,
+ games: games.into_iter().map(|g| (g.technical_name(), g)).collect(),
+ mods: mods
+ .into_iter()
+ .map(|m| Ok((m.mod_id()?, m)))
+ .collect::<Result<_>>()?,
+ })
+ }
+}
+
+/// Snapshot of a [`Baas`] scan.
+///
+/// Every item is indexed by its ID/name.
+#[derive(Debug, Clone)]
+pub struct Snapshot {
+ worlds: HashMap<String, World>,
+ games: HashMap<String, Game>,
+ mods: HashMap<String, MineMod>,
+}
+
+impl Snapshot {
+ pub fn worlds(&self) -> &HashMap<String, World> {
+ &self.worlds
+ }
+
+ pub fn games(&self) -> &HashMap<String, Game> {
+ &self.games
+ }
+
+ pub fn mods(&self) -> &HashMap<String, MineMod> {
+ &self.mods
+ }
+}
diff --git a/src/contentdb.rs b/src/contentdb.rs
new file mode 100644
index 0000000..467f9dc
--- /dev/null
+++ b/src/contentdb.rs
@@ -0,0 +1,120 @@
+//! 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<Url> =
+ Lazy::new(|| Url::parse("https://content.minetest.net/").expect("Invalid default URL"));
+
+/// The metapackage selector to scrape the packages.
+static PROVIDES_SELECTOR: Lazy<Selector> =
+ Lazy::new(|| Selector::parse("ul.d-flex").expect("Invalid selector"));
+
+static A_SELECTOR: Lazy<Selector> = Lazy::new(|| Selector::parse("a").expect("Invalid selector"));
+
+/// (Partial) metadata of a content item, as returned by the Content DB.
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
+pub struct ContentMeta {
+ pub author: String,
+ pub name: String,
+ pub provides: Vec<String>,
+ pub short_description: String,
+ pub title: String,
+ #[serde(rename = "type")]
+ pub typ: String,
+ 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<Vec<ContentMeta>> {
+ 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<ContentId> = 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<Url> {
+ 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<ContentId> {
+ 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
new file mode 100644
index 0000000..8a372ad
--- /dev/null
+++ b/src/download.rs
@@ -0,0 +1,120 @@
+use std::{
+ io::{Cursor, Read},
+ str::FromStr,
+};
+
+use tempdir::TempDir;
+use url::Url;
+use zip::ZipArchive;
+
+use super::{
+ contentdb::{ContentDb, ContentId},
+ error::{Error, Result},
+ minemod::{MineMod, 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<Self, Self::Err> {
+ 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 [`MineMod`] 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 {
+ pub fn new() -> Result<Downloader> {
+ Downloader::with_content_db(Default::default())
+ }
+
+ pub fn with_content_db(content_db: ContentDb) -> Result<Downloader> {
+ let temp_dir = TempDir::new(env!("CARGO_PKG_NAME"))?;
+ Ok(Downloader {
+ temp_dir,
+ content_db,
+ })
+ }
+
+ pub fn download(&self, source: &Source) -> Result<MineMod> {
+ 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.
+ ///
+ /// The mod is extracted to a temporary directory and has to be copied using
+ /// [`MineMod::copy_to`].
+ pub fn download_http(&self, url: &Url) -> Result<MineMod> {
+ 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)?;
+
+ // Here we assume that the zipfile contains only one directory, so we just take the first
+ // name we find and extract the leading directory name.
+ let name = archive
+ .file_names()
+ .next()
+ .and_then(|name| name.split('/').next())
+ .ok_or(Error::EmptyArchive)?
+ .to_string();
+
+ archive.extract(self.temp_dir.path())?;
+
+ let extracted_path = self.temp_dir.path().join(name);
+ MineMod::open(&extracted_path)
+ }
+}
diff --git a/src/error.rs b/src/error.rs
new file mode 100644
index 0000000..d60fabd
--- /dev/null
+++ b/src/error.rs
@@ -0,0 +1,39 @@
+use std::path::PathBuf;
+
+use thiserror::Error;
+
+#[derive(Error, Debug)]
+pub enum Error {
+ #[error("invalid mod id '{0}'")]
+ InvalidModId(String),
+ #[error("underlying HTTP error")]
+ UreqError(#[from] ureq::Error),
+ #[error("underlying I/O error")]
+ IoError(#[from] std::io::Error),
+ #[error("the website returned unexpected data")]
+ InvalidScrape,
+ #[error("'{0}' is not a valid mod directory")]
+ InvalidModDir(PathBuf),
+ #[error("filesystem error")]
+ FsExtraError(#[from] fs_extra::error::Error),
+ #[error("ZIP error")]
+ ZipError(#[from] zip::result::ZipError),
+ #[error("the downloaded file was empty")]
+ EmptyArchive,
+ #[error("'{0}' is not a valid game directory")]
+ InvalidGameDir(PathBuf),
+ #[error("'{0}' is not a valid world directory")]
+ InvalidWorldDir(PathBuf),
+ #[error("'{0}' is not a valid modpack directory")]
+ InvalidModpackDir(PathBuf),
+ #[error("the world has no game ID set")]
+ NoGameSet,
+ #[error("'{0}' does not represent a valid mod source")]
+ InvalidSourceSpec(String),
+ #[error("invalid URL")]
+ UrlError(#[from] url::ParseError),
+ #[error("the mod ID '{0}' does not point to a single mod")]
+ AmbiguousModId(String),
+}
+
+pub type Result<T, E = Error> = std::result::Result<T, E>;
diff --git a/src/game.rs b/src/game.rs
new file mode 100644
index 0000000..f323212
--- /dev/null
+++ b/src/game.rs
@@ -0,0 +1,67 @@
+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<P: AsRef<Path>>(path: P) -> Result<Game> {
+ Game::open_path(path.as_ref())
+ }
+
+ fn open_path(path: &Path) -> Result<Game> {
+ 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<Vec<MineMod>> {
+ 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
new file mode 100644
index 0000000..006bb94
--- /dev/null
+++ b/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<P: AsRef<Path>>(path: P) -> Result<HashMap<String, String>> {
+ read_inner(path.as_ref())
+}
+
+fn read_inner(path: &Path) -> Result<HashMap<String, String>> {
+ let content = fs::read_to_string(path)?;
+
+ Ok(content
+ .lines()
+ .map(|line| line.splitn(2, '=').map(str::trim).collect::<Vec<_>>())
+ .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<P: AsRef<Path>>(data: &HashMap<String, String>, path: P) -> Result<()> {
+ write_inner(data, path.as_ref())
+}
+
+fn write_inner(data: &HashMap<String, String>, path: &Path) -> Result<()> {
+ let mut items = data.iter().collect::<Vec<_>>();
+ 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
new file mode 100644
index 0000000..f169c82
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,52 @@
+use std::{fs, path::Path};
+
+use log::debug;
+
+macro_rules! regex {
+ ($re:literal $(,)?) => {{
+ static RE: once_cell::sync::OnceCell<regex::Regex> = 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 world;
+
+pub use baas::{Baas, Snapshot};
+pub use contentdb::ContentDb;
+pub use download::{Downloader, Source};
+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.
+///
+/// ```rust
+/// use modderbaas::minemod::MineMod;
+/// let mods = scan("/tmp", |p| MineMod::open(p))?;
+/// ```
+pub fn scan<W, P: AsRef<Path>, F: for<'p> Fn(&'p Path) -> Result<W>>(
+ path: P,
+ scanner: F,
+) -> Result<Vec<W>> {
+ 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/main.rs b/src/main.rs
new file mode 100644
index 0000000..5df16a2
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,335 @@
+use std::{
+ fmt::Display,
+ io::{self, Write},
+ path::Path,
+ str::FromStr,
+};
+
+use anyhow::{anyhow, bail, Context, Result};
+use clap::{crate_version, App, Arg, ArgMatches, SubCommand};
+use itertools::Itertools;
+use log::debug;
+use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};
+
+use modderbaas::{Baas, ContentDb, Downloader, MineMod, Snapshot, Source, World};
+
+fn main() -> Result<()> {
+ stderrlog::new()
+ .module(module_path!())
+ //.verbosity(1)
+ .verbosity(5)
+ .init()
+ .unwrap();
+
+ let matches = App::new("ModderBaas")
+ .version(crate_version!())
+ .arg(
+ Arg::with_name("world")
+ .long("world")
+ .short("c")
+ .required(false)
+ .takes_value(true),
+ )
+ .subcommand(
+ SubCommand::with_name("enable")
+ .about("Enables a mod and its dependencies")
+ .arg(Arg::with_name("mod").multiple(true).required(true)),
+ )
+ .subcommand(
+ SubCommand::with_name("install")
+ .about("Installs a mod and its dependencies")
+ .arg(Arg::with_name("mod").multiple(true).required(true))
+ .arg(
+ Arg::with_name("target")
+ .short("t")
+ .long("target-dir")
+ .default_value("."),
+ ),
+ )
+ .get_matches();
+
+ let mut stdout = StandardStream::stdout(ColorChoice::Auto);
+
+ let baas = Baas::with_standard_dirs()?;
+ let snapshot = baas
+ .snapshot()
+ .context("Creating the initial snapshot failed")?;
+
+ let world = select_world(&mut stdout, &matches, &snapshot)?;
+ write!(stdout, "Using world ")?;
+ stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)))?;
+ writeln!(stdout, "{}", world.world_name()?)?;
+ stdout.reset()?;
+
+ if let Some(enable) = matches.subcommand_matches("enable") {
+ let mods = enable.values_of("mod").unwrap().collect::<Vec<_>>();
+ enable_mods(&mut stdout, &snapshot, &world, &mods)?;
+ }
+
+ if let Some(install) = matches.subcommand_matches("install") {
+ let mods = install.values_of("mod").unwrap().collect::<Vec<_>>();
+ let target_dir = install.value_of("target").unwrap();
+ install_mods(&mut stdout, &snapshot, &world, &mods, Path::new(target_dir))?;
+ }
+
+ Ok(())
+}
+
+/// Select the world that we want to work on.
+///
+/// If there is only one world available, we use it.
+/// If there are more worlds, we ask the user for a choice.
+///
+/// If the command line argument is given, it overrides the previous rules.
+fn select_world(
+ output: &mut StandardStream,
+ cli: &ArgMatches,
+ snapshot: &Snapshot,
+) -> Result<World> {
+ debug!("Starting world selection");
+ let worlds = snapshot.worlds();
+
+ if worlds.is_empty() {
+ bail!("No world found");
+ }
+
+ if let Some(world_name) = cli.value_of("world") {
+ if let Some(world) = worlds.get(world_name) {
+ return Ok(world.clone());
+ } else {
+ bail!("Invalid world name given: {}", world_name);
+ }
+ }
+
+ if worlds.len() == 1 {
+ return Ok(worlds
+ .values()
+ .next()
+ .expect("We just checked the length!")
+ .clone());
+ }
+
+ // Here, we cannot do an automatic selection, so ask the user:
+ let mut worlds = worlds.iter().collect::<Vec<_>>();
+ worlds.sort_by_key(|i| i.0);
+ writeln!(
+ output,
+ "The following worlds were found, please select one:"
+ )?;
+ user_choice(&worlds, output).map(|&i| i.clone())
+}
+
+/// Enables the given list of mods and their dependencies.
+///
+/// Fails if any mod has a dependency that can not be satisfied with the locally available mods.
+fn enable_mods(
+ output: &mut StandardStream,
+ snapshot: &Snapshot,
+ world: &World,
+ mods: &[&str],
+) -> Result<()> {
+ let mut wanted = mods.iter().map(|&s| s.to_owned()).collect::<Vec<String>>();
+ let mut to_enable = Vec::<MineMod>::new();
+ let game = snapshot
+ .games()
+ .get(&world.game_id()?)
+ .ok_or_else(|| anyhow!("The game definition was not found"))?;
+ let game_mods = game
+ .mods()?
+ .into_iter()
+ .map(|m| m.mod_id())
+ .collect::<Result<Vec<_>, _>>()?;
+
+ while !wanted.is_empty() {
+ let next_mod = wanted.remove(0);
+ // Do we already have the mod enabled, somehow?
+ if world.mods()?.contains(&next_mod) || game_mods.contains(&next_mod) {
+ continue;
+ }
+
+ match snapshot.mods().get(&next_mod as &str) {
+ Some(m) => {
+ to_enable.push(m.clone());
+ wanted.extend(m.dependencies()?);
+ }
+ None => bail!("Mod {} could not be found", next_mod),
+ }
+ }
+
+ if to_enable.is_empty() {
+ writeln!(output, "Done!")?;
+ return Ok(());
+ }
+
+ writeln!(output, "Enabling {} mods:", to_enable.len())?;
+ writeln!(output, "{}", to_enable.iter().join(", "))?;
+
+ ask_continue(output)?;
+
+ for m in to_enable {
+ let mod_id = m.mod_id()?;
+ world
+ .enable_mod(&mod_id)
+ .context(format!("Error enabling '{}'", mod_id))?;
+ }
+
+ writeln!(output, "Done!")?;
+ Ok(())
+}
+
+/// Install the given mods, installing dependencies if needed.
+fn install_mods(
+ output: &mut StandardStream,
+ snapshot: &Snapshot,
+ world: &World,
+ mods: &[&str],
+ target_dir: &Path,
+) -> Result<()> {
+ let content_db = ContentDb::new();
+ let downloader = Downloader::new()?;
+ let mut wanted = mods
+ .iter()
+ .map(|&s| Source::from_str(s))
+ .collect::<Result<Vec<_>, _>>()?;
+
+ let mut to_install = Vec::<MineMod>::new();
+ let mut to_enable = Vec::<MineMod>::new();
+
+ let game = snapshot
+ .games()
+ .get(&world.game_id()?)
+ .ok_or_else(|| anyhow!("The game definition was not found"))?;
+ let game_mods = game
+ .mods()?
+ .into_iter()
+ .map(|m| m.mod_id())
+ .collect::<Result<Vec<_>, _>>()?;
+
+ while !wanted.is_empty() {
+ let next_mod = wanted.remove(0);
+
+ // Special handling for mods specified by their ID, as those could already exist.
+ if let Source::ModId(ref id) = next_mod {
+ if let Some(m) = snapshot.mods().get(id) {
+ // We have that one, just enable it and its dependencies!
+ to_enable.push(m.clone());
+ wanted.extend(m.dependencies()?.into_iter().map(Source::ModId));
+ continue;
+ } else if game_mods.contains(id) {
+ // This mod is already contained in the game, nothing for us to do
+ continue;
+ }
+
+ // Is this a mod that is already queued for installation?
+ for m in &to_install {
+ if &m.mod_id()? == id {
+ continue;
+ }
+ }
+
+ // This mod is not available, so we search the content DB
+ writeln!(output, "Searching for candidates: {}", id)?;
+ let candidates = content_db.resolve(id)?;
+ if candidates.is_empty() {
+ bail!("Could not find a suitable mod for '{}'", id);
+ } else if candidates.len() == 1 {
+ wanted.push(Source::Http(candidates.into_iter().next().unwrap().url));
+ } else {
+ let items = candidates
+ .into_iter()
+ .map(|c| {
+ (
+ format!("{} by {} - {}", c.title, c.author, c.short_description),
+ c,
+ )
+ })
+ .collect::<Vec<_>>();
+ writeln!(
+ output,
+ "{} candidates found, please select one:",
+ items.len()
+ )?;
+ let candidate = user_choice(&items, output)?;
+ wanted.push(Source::Http(candidate.url.clone()));
+ }
+ } else {
+ let downloaded = downloader
+ .download(&next_mod)
+ .context("Failed to download mod")?;
+ wanted.extend(downloaded.dependencies()?.into_iter().map(Source::ModId));
+ to_install.push(downloaded);
+ }
+ }
+
+ writeln!(output, "Installing {} new mods:", to_install.len())?;
+ writeln!(output, "{}", to_install.iter().join(", "))?;
+
+ ask_continue(output)?;
+
+ for m in to_install {
+ let mod_id = m.mod_id()?;
+ writeln!(output, "Installing {}", mod_id)?;
+ let installed = m
+ .copy_to(target_dir)
+ .context(format!("Error installing '{}'", mod_id))?;
+ to_enable.push(installed);
+ }
+
+ for m in to_enable {
+ let mod_id = m.mod_id()?;
+ world
+ .enable_mod(&mod_id)
+ .context(format!("Error enabling '{}'", mod_id))?;
+ }
+
+ writeln!(output, "Done!")?;
+
+ Ok(())
+}
+
+/// Presents the user with a choice of items and awaits a selection.
+fn user_choice<'i, L: Display, I>(
+ items: &'i [(L, I)],
+ output: &mut StandardStream,
+) -> Result<&'i I> {
+ for (i, (label, _)) in items.iter().enumerate() {
+ output.set_color(ColorSpec::new().set_fg(Some(Color::Blue)))?;
+ write!(output, "[{}]", i)?;
+ output.reset()?;
+ writeln!(output, " {}", label)?;
+ }
+
+ let stdin = io::stdin();
+ loop {
+ write!(output, "Enter a number: ")?;
+ output.flush()?;
+ let mut buffer = String::new();
+ stdin.read_line(&mut buffer)?;
+ if let Ok(number) = buffer.trim().parse::<usize>() {
+ if number < items.len() {
+ return Ok(&items[number].1);
+ }
+ }
+ }
+}
+
+/// Ask the user whether they want to continue.
+///
+/// Returns `Ok(())` if the program should continue, and an error otherwise.
+fn ask_continue(output: &mut StandardStream) -> Result<()> {
+ let stdin = io::stdin();
+ loop {
+ output.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)))?;
+ write!(output, "Continue? [Y/n] ")?;
+ output.reset()?;
+ output.flush()?;
+
+ let mut buffer = String::new();
+ stdin.read_line(&mut buffer)?;
+ if buffer == "\n" || buffer == "Y\n" || buffer == "y\n" {
+ return Ok(());
+ } else if buffer == "N\n" || buffer == "n\n" {
+ bail!("Cancelled by user");
+ }
+ }
+}
diff --git a/src/minemod.rs b/src/minemod.rs
new file mode 100644
index 0000000..6af50e3
--- /dev/null
+++ b/src/minemod.rs
@@ -0,0 +1,165 @@
+use std::{
+ any::Any,
+ collections::HashMap,
+ fmt, fs,
+ path::{Path, PathBuf},
+};
+
+use fs_extra::dir::{self, CopyOptions};
+
+use super::{
+ error::{Error, Result},
+ kvstore, scan,
+};
+
+/// 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 {
+ pub fn open<P: AsRef<Path>>(path: P) -> Result<MineMod> {
+ MineMod::open_path(path.as_ref())
+ }
+
+ fn open_path(path: &Path) -> Result<MineMod> {
+ let conf = path.join("mod.conf");
+ if !conf.is_file() {
+ return Err(Error::InvalidModDir(path.into()));
+ }
+
+ Ok(MineMod { path: path.into() })
+ }
+
+ fn read_conf(&self) -> Result<HashMap<String, String>> {
+ let conf = self.path.join("mod.conf");
+ kvstore::read(&conf)
+ }
+
+ /// Read the mod ID.
+ pub fn mod_id(&self) -> Result<ModId> {
+ 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<Vec<ModId>> {
+ 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<P: AsRef<Path>>(&self, path: P) -> Result<MineMod> {
+ let mut options = CopyOptions::new();
+ options.content_only = true;
+ let path = path.as_ref().join(self.mod_id()?);
+ fs::create_dir_all(&path)?;
+ dir::copy(&self.path, &path, &options)?;
+ 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 {
+ pub fn open<P: AsRef<Path>>(path: P) -> Result<Modpack> {
+ Modpack::open_path(path.as_ref())
+ }
+
+ fn open_path(path: &Path) -> Result<Modpack> {
+ let conf = path.join("modpack.conf");
+ if !conf.is_file() {
+ return Err(Error::InvalidModpackDir(path.into()));
+ }
+
+ Ok(Modpack { path: path.into() })
+ }
+
+ fn conf(&self) -> Result<HashMap<String, String>> {
+ let conf = self.path.join("modpack.conf");
+ kvstore::read(&conf)
+ }
+
+ /// Returns the name of the modpack.
+ pub fn name(&self) -> Result<String> {
+ 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<Vec<MineMod>> {
+ let mut mods = vec![];
+ for container in scan(&self.path, |p| open_mod_or_pack(p))? {
+ mods.extend(container.mods()?);
+ }
+ Ok(mods)
+ }
+}
+
+/// A thing that can contain mods.
+pub trait ModContainer: Any {
+ /// Returns the name of the mod container.
+ fn name(&self) -> Result<String>;
+
+ /// Return all contained mods.
+ fn mods(&self) -> Result<Vec<MineMod>>;
+}
+
+impl ModContainer for MineMod {
+ fn name(&self) -> Result<String> {
+ self.mod_id()
+ }
+
+ fn mods(&self) -> Result<Vec<MineMod>> {
+ Ok(vec![self.clone()])
+ }
+}
+
+impl ModContainer for Modpack {
+ fn name(&self) -> Result<String> {
+ self.name()
+ }
+
+ fn mods(&self) -> Result<Vec<MineMod>> {
+ self.mods()
+ }
+}
+
+/// Attempts to open the given path as either a single mod or a modpack.
+pub fn open_mod_or_pack<P: AsRef<Path>>(path: P) -> Result<Box<dyn ModContainer>> {
+ MineMod::open(path.as_ref())
+ .map(|m| Box::new(m) as Box<dyn ModContainer>)
+ .or_else(|_| Modpack::open(path.as_ref()).map(|p| Box::new(p) as Box<dyn ModContainer>))
+}
diff --git a/src/world.rs b/src/world.rs
new file mode 100644
index 0000000..42a41a4
--- /dev/null
+++ b/src/world.rs
@@ -0,0 +1,86 @@
+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 {
+ pub fn open<P: AsRef<Path>>(path: P) -> Result<World> {
+ World::open_path(path.as_ref())
+ }
+
+ fn open_path(path: &Path) -> Result<World> {
+ 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<HashMap<String, String>> {
+ let conf = self.path.join("world.mt");
+ kvstore::read(&conf)
+ }
+
+ /// Returns the name of the world.
+ pub fn world_name(&self) -> Result<String> {
+ let conf = self.conf()?;
+ conf.get("world_name")
+ .ok_or_else(|| Error::InvalidWorldDir(self.path.clone()))
+ .map(Into::into)
+ }
+
+ /// Extract the game that this world uses.
+ pub fn game_id(&self) -> Result<String> {
+ 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<Vec<ModId>> {
+ 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)?)
+ }
+}