minecli 0.1.0

A CLI for managing Minecraft server mods, datapacks, and plugins.
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

use directories::ProjectDirs;
use serde::{Deserialize, Serialize};

use crate::error::{IoResultExt, MinecliError, Result};

pub const CONFIG_FILE: &str = "config.toml";
pub const SERVERS_FILE: &str = "servers.toml";

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct GlobalConfig {
    #[serde(default)]
    pub default_server: Option<String>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerRegistry {
    #[serde(default)]
    pub servers: BTreeMap<String, RegisteredServer>,
}

impl ServerRegistry {
    pub fn add(&mut self, name: String, path: PathBuf) -> Result<()> {
        if self.servers.contains_key(&name) {
            return Err(MinecliError::message(format!(
                "server `{name}` is already registered"
            )));
        }
        self.servers.insert(name, RegisteredServer { path });
        Ok(())
    }

    pub fn remove(&mut self, name: &str) -> Result<RegisteredServer> {
        self.servers
            .remove(name)
            .ok_or_else(|| MinecliError::message(format!("server `{name}` is not registered")))
    }

    pub fn get(&self, name: &str) -> Result<&RegisteredServer> {
        self.servers
            .get(name)
            .ok_or_else(|| MinecliError::message(format!("server `{name}` is not registered")))
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RegisteredServer {
    pub path: PathBuf,
}

pub fn config_dir(override_path: Option<&Path>) -> Result<PathBuf> {
    if let Some(path) = override_path {
        return Ok(path.to_path_buf());
    }

    if let Some(path) = std::env::var_os("MINECLI_CONFIG_DIR") {
        return Ok(PathBuf::from(path));
    }

    ProjectDirs::from("", "", "minecli")
        .map(|dirs| dirs.config_dir().to_path_buf())
        .ok_or_else(|| MinecliError::message("could not resolve user config directory"))
}

pub fn config_file(config_dir: &Path) -> PathBuf {
    config_dir.join(CONFIG_FILE)
}

pub fn servers_file(config_dir: &Path) -> PathBuf {
    config_dir.join(SERVERS_FILE)
}

pub fn load_global_config(config_dir: &Path) -> Result<GlobalConfig> {
    load_toml_or_default(&config_file(config_dir))
}

pub fn write_global_config(config_dir: &Path, config: &GlobalConfig) -> Result<()> {
    write_toml(config_dir, &config_file(config_dir), config)
}

pub fn load_server_registry(config_dir: &Path) -> Result<ServerRegistry> {
    load_toml_or_default(&servers_file(config_dir))
}

pub fn write_server_registry(config_dir: &Path, registry: &ServerRegistry) -> Result<()> {
    write_toml(config_dir, &servers_file(config_dir), registry)
}

fn load_toml_or_default<T>(path: &Path) -> Result<T>
where
    T: Default + for<'de> Deserialize<'de>,
{
    match fs::read_to_string(path) {
        Ok(contents) => toml::from_str(&contents).map_err(|source| MinecliError::TomlDeserialize {
            path: path.to_path_buf(),
            source,
        }),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(T::default()),
        Err(error) => Err(MinecliError::Io {
            path: path.to_path_buf(),
            source: error,
        }),
    }
}

fn write_toml<T>(config_dir: &Path, path: &Path, value: &T) -> Result<()>
where
    T: Serialize,
{
    fs::create_dir_all(config_dir).at(config_dir)?;
    let contents = toml::to_string_pretty(value).map_err(|source| MinecliError::TomlSerialize {
        path: path.to_path_buf(),
        source,
    })?;
    fs::write(path, contents).at(path)
}

#[cfg(test)]
mod tests {
    use crate::config::{ServerRegistry, config_dir, load_server_registry, write_server_registry};

    #[test]
    fn round_trips_server_registry() {
        let temp = tempfile::tempdir().unwrap();
        let mut registry = ServerRegistry::default();
        registry
            .add("survival".to_owned(), "/srv/minecraft/survival".into())
            .unwrap();

        write_server_registry(temp.path(), &registry).unwrap();
        let loaded = load_server_registry(temp.path()).unwrap();

        assert_eq!(loaded, registry);
    }

    #[test]
    fn rejects_duplicate_servers() {
        let mut registry = ServerRegistry::default();
        registry.add("survival".to_owned(), "/one".into()).unwrap();

        let result = registry.add("survival".to_owned(), "/two".into());

        assert!(result.is_err());
    }

    #[test]
    fn uses_explicit_config_dir_override() {
        let path = std::path::Path::new("/tmp/minecli-test-config");

        assert_eq!(config_dir(Some(path)).unwrap(), path);
    }
}