minecli 0.1.0

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

use serde::Deserialize;
use zip::ZipArchive;

use crate::core::lockfile::LockFile;
use crate::core::server::{ContentKind, ServerPaths};
use crate::error::{IoResultExt, MinecliError, Result};

pub const DISABLED_DATAPACKS_DIR: &str = "datapacks-disabled";

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DatapackEntry {
    pub id: String,
    pub enabled: bool,
    pub path: PathBuf,
    pub pack_format: Option<i64>,
    pub description: Option<String>,
    pub managed: bool,
}

#[derive(Debug, Deserialize)]
struct PackMcmeta {
    pack: PackSection,
}

#[derive(Debug, Deserialize)]
struct PackSection {
    #[serde(rename = "pack_format")]
    pack_format: Option<i64>,
    description: Option<serde_json::Value>,
}

pub fn disabled_datapacks_path() -> PathBuf {
    PathBuf::from(crate::core::manifest::MINECLI_DIR).join(DISABLED_DATAPACKS_DIR)
}

pub fn discover_datapacks(
    server_dir: &Path,
    paths: &ServerPaths,
    lockfile: &LockFile,
) -> Result<Vec<DatapackEntry>> {
    let mut entries = Vec::new();
    collect_datapacks_from_dir(server_dir, &paths.datapacks, true, lockfile, &mut entries)?;
    collect_datapacks_from_dir(
        server_dir,
        &disabled_datapacks_path(),
        false,
        lockfile,
        &mut entries,
    )?;
    entries.sort_by(|left, right| {
        left.enabled
            .cmp(&right.enabled)
            .reverse()
            .then_with(|| left.id.cmp(&right.id))
    });
    Ok(entries)
}

pub fn disable_datapack(
    server_dir: &Path,
    paths: &ServerPaths,
    lockfile: &mut LockFile,
    query: &str,
) -> Result<DatapackEntry> {
    let entries = discover_datapacks(server_dir, paths, lockfile)?;
    let entry = find_datapack(&entries, query, true)?;
    let target_relative = disabled_datapacks_path().join(filename(&entry.path)?);
    move_datapack(server_dir, &entry.path, &target_relative)?;
    update_lockfile_path(lockfile, &entry.path, &target_relative);

    Ok(DatapackEntry {
        enabled: false,
        path: target_relative,
        ..entry
    })
}

pub fn enable_datapack(
    server_dir: &Path,
    paths: &ServerPaths,
    lockfile: &mut LockFile,
    query: &str,
) -> Result<DatapackEntry> {
    let entries = discover_datapacks(server_dir, paths, lockfile)?;
    let entry = find_datapack(&entries, query, false)?;
    let target_relative = paths.datapacks.join(filename(&entry.path)?);
    move_datapack(server_dir, &entry.path, &target_relative)?;
    update_lockfile_path(lockfile, &entry.path, &target_relative);

    Ok(DatapackEntry {
        enabled: true,
        path: target_relative,
        ..entry
    })
}

fn collect_datapacks_from_dir(
    server_dir: &Path,
    relative_dir: &Path,
    enabled: bool,
    lockfile: &LockFile,
    entries: &mut Vec<DatapackEntry>,
) -> Result<()> {
    let absolute_dir = server_dir.join(relative_dir);
    if !absolute_dir.exists() {
        return Ok(());
    }

    for entry in fs::read_dir(&absolute_dir).at(&absolute_dir)? {
        let entry = entry.at(&absolute_dir)?;
        let absolute_path = entry.path();
        if !is_datapack_candidate(&absolute_path) {
            continue;
        }
        let relative_path = relative_dir.join(entry.file_name());
        let metadata = read_pack_mcmeta(&absolute_path).unwrap_or(None);
        let managed = lockfile.packages.iter().any(|package| {
            package.kind == ContentKind::Datapack && package.installed_path == relative_path
        });
        entries.push(DatapackEntry {
            id: datapack_id(&relative_path),
            enabled,
            path: relative_path,
            pack_format: metadata
                .as_ref()
                .and_then(|metadata| metadata.pack.pack_format),
            description: metadata
                .and_then(|metadata| description_to_string(metadata.pack.description)),
            managed,
        });
    }

    Ok(())
}

fn is_datapack_candidate(path: &Path) -> bool {
    path.is_dir()
        || path
            .extension()
            .and_then(|extension| extension.to_str())
            .is_some_and(|extension| extension.eq_ignore_ascii_case("zip"))
}

fn read_pack_mcmeta(path: &Path) -> Result<Option<PackMcmeta>> {
    let mut contents = String::new();
    if path.is_dir() {
        let metadata_path = path.join("pack.mcmeta");
        if !metadata_path.exists() {
            return Ok(None);
        }
        contents = fs::read_to_string(&metadata_path).at(&metadata_path)?;
    } else {
        let file = File::open(path).at(path)?;
        let mut archive = ZipArchive::new(file).map_err(|error| {
            MinecliError::message(format!("failed to read datapack zip: {error}"))
        })?;
        let Ok(mut metadata) = archive.by_name("pack.mcmeta") else {
            return Ok(None);
        };
        metadata.read_to_string(&mut contents).map_err(|error| {
            MinecliError::message(format!("failed to read pack.mcmeta: {error}"))
        })?;
    }

    serde_json::from_str(&contents)
        .map(Some)
        .map_err(MinecliError::Json)
}

fn description_to_string(description: Option<serde_json::Value>) -> Option<String> {
    match description? {
        serde_json::Value::String(value) => Some(value),
        value => Some(value.to_string()),
    }
}

fn find_datapack(entries: &[DatapackEntry], query: &str, enabled: bool) -> Result<DatapackEntry> {
    entries
        .iter()
        .find(|entry| {
            entry.enabled == enabled
                && (entry.id == query
                    || entry.path == Path::new(query)
                    || filename(&entry.path)
                        .is_ok_and(|filename| filename.to_string_lossy() == query))
        })
        .cloned()
        .ok_or_else(|| {
            MinecliError::message(format!(
                "{} datapack `{query}` was not found",
                if enabled { "enabled" } else { "disabled" }
            ))
        })
}

fn move_datapack(server_dir: &Path, source: &Path, target: &Path) -> Result<()> {
    let absolute_source = server_dir.join(source);
    let absolute_target = server_dir.join(target);
    if absolute_target.exists() {
        return Err(MinecliError::message(format!(
            "target datapack already exists: {}",
            target.display()
        )));
    }
    if let Some(parent) = absolute_target.parent() {
        fs::create_dir_all(parent).at(parent)?;
    }
    fs::rename(&absolute_source, &absolute_target).at(&absolute_target)
}

fn update_lockfile_path(lockfile: &mut LockFile, source: &Path, target: &Path) {
    for package in &mut lockfile.packages {
        if package.kind == ContentKind::Datapack && package.installed_path == source {
            package.installed_path = target.to_path_buf();
        }
    }
}

fn datapack_id(path: &Path) -> String {
    path.file_stem()
        .or_else(|| path.file_name())
        .map(|name| name.to_string_lossy().to_string())
        .unwrap_or_else(|| path.display().to_string())
}

fn filename(path: &Path) -> Result<std::ffi::OsString> {
    path.file_name()
        .map(ToOwned::to_owned)
        .ok_or_else(|| MinecliError::message(format!("invalid datapack path: {}", path.display())))
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeMap;
    use std::io::Write;
    use std::path::PathBuf;

    use zip::write::FileOptions;

    use crate::core::datapack::{disable_datapack, discover_datapacks, enable_datapack};
    use crate::core::lockfile::{LockFile, LockedPackage};
    use crate::core::server::{ContentKind, ServerPaths};

    #[test]
    fn discovers_folder_and_zip_datapacks() {
        let temp = tempfile::tempdir().unwrap();
        let paths = ServerPaths::defaults("world");
        std::fs::create_dir_all(temp.path().join("world/datapacks/folder-pack")).unwrap();
        std::fs::write(
            temp.path().join("world/datapacks/folder-pack/pack.mcmeta"),
            r#"{"pack":{"pack_format":48,"description":"Folder pack"}}"#,
        )
        .unwrap();
        write_zip_pack(&temp.path().join("world/datapacks/zip-pack.zip"));

        let entries = discover_datapacks(temp.path(), &paths, &LockFile::default()).unwrap();

        assert_eq!(entries.len(), 2);
        assert!(entries.iter().any(|entry| entry.id == "folder-pack"));
        assert!(entries.iter().any(|entry| entry.id == "zip-pack"));
    }

    #[test]
    fn disable_and_enable_datapack_moves_file_and_updates_lockfile() {
        let temp = tempfile::tempdir().unwrap();
        let paths = ServerPaths::defaults("world");
        std::fs::create_dir_all(temp.path().join("world/datapacks")).unwrap();
        write_zip_pack(&temp.path().join("world/datapacks/zip-pack.zip"));
        let mut lockfile = LockFile {
            packages: vec![datapack_package("world/datapacks/zip-pack.zip")],
        };

        let disabled = disable_datapack(temp.path(), &paths, &mut lockfile, "zip-pack").unwrap();

        assert!(!disabled.enabled);
        assert!(
            temp.path()
                .join(".minecli/datapacks-disabled/zip-pack.zip")
                .exists()
        );
        assert_eq!(
            lockfile.packages[0].installed_path,
            PathBuf::from(".minecli/datapacks-disabled/zip-pack.zip")
        );

        let enabled = enable_datapack(temp.path(), &paths, &mut lockfile, "zip-pack").unwrap();

        assert!(enabled.enabled);
        assert!(temp.path().join("world/datapacks/zip-pack.zip").exists());
        assert_eq!(
            lockfile.packages[0].installed_path,
            PathBuf::from("world/datapacks/zip-pack.zip")
        );
    }

    fn write_zip_pack(path: &std::path::Path) {
        let parent = path.parent().unwrap();
        std::fs::create_dir_all(parent).unwrap();
        let file = std::fs::File::create(path).unwrap();
        let mut writer = zip::ZipWriter::new(file);
        writer
            .start_file::<_, ()>("pack.mcmeta", FileOptions::default())
            .unwrap();
        writer
            .write_all(br#"{"pack":{"pack_format":48,"description":"Zip pack"}}"#)
            .unwrap();
        writer.finish().unwrap();
    }

    fn datapack_package(path: &str) -> LockedPackage {
        LockedPackage {
            source: "local-file".to_owned(),
            project_id: "local:zip-pack".to_owned(),
            source_project_id: Some("zip-pack".to_owned()),
            slug: "zip-pack".to_owned(),
            title: "zip-pack".to_owned(),
            kind: ContentKind::Datapack,
            loader: Some("datapack".to_owned()),
            version_id: "local".to_owned(),
            source_version_id: Some("local".to_owned()),
            version_number: "local".to_owned(),
            filename: "zip-pack.zip".to_owned(),
            hashes: BTreeMap::new(),
            installed_path: PathBuf::from(path),
            dependencies: vec![],
            installed_as_dependency: false,
        }
    }
}