rmcl 0.4.0

A fully featured Minecraft TUI launcher
use std::path::{Path, PathBuf};

use thiserror::Error;

#[derive(Debug, Error)]
pub enum ConfigSyncError {
    #[error("Invalid config sync profile: {0}")]
    InvalidProfile(String),
    #[error("Cannot switch config profiles while '{instance}' is running")]
    InstanceRunning { instance: String },
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
}

#[derive(Debug)]
pub struct ConfigSyncLock {
    file: std::fs::File,
}

impl Drop for ConfigSyncLock {
    fn drop(&mut self) {
        if let Err(e) = self.file.unlock() {
            tracing::warn!("Failed to release config sync lock: {}", e);
        }
    }
}

pub fn prepare(
    profile: Option<&str>,
    meta_dir: &Path,
    minecraft_dir: &Path,
) -> Result<bool, ConfigSyncError> {
    let Some(profile) = profile.and_then(normalize_profile) else {
        return Ok(false);
    };
    validate_profile(profile)?;

    let profile_dir = profile_dir(meta_dir, profile);
    if !profile_dir.exists() {
        return Ok(false);
    }
    let _lock = acquire_lock(&profile_dir)?;

    if !profile_payload_exists(&profile_dir)? {
        sync_to_profile(minecraft_dir, &profile_dir)?;
    } else {
        sync_from_profile(&profile_dir, minecraft_dir)?;
    }

    Ok(true)
}

pub fn finish(
    profile: Option<&str>,
    meta_dir: &Path,
    minecraft_dir: &Path,
) -> Result<(), ConfigSyncError> {
    let Some(profile) = profile.and_then(normalize_profile) else {
        return Ok(());
    };
    validate_profile(profile)?;

    let profile_dir = profile_dir(meta_dir, profile);
    let _lock = acquire_lock(&profile_dir)?;
    sync_to_profile(minecraft_dir, &profile_dir)
}

pub fn list_profiles(meta_dir: &Path) -> Result<Vec<String>, ConfigSyncError> {
    let root = profiles_dir(meta_dir);
    let mut profiles = Vec::new();
    if !root.exists() {
        return Ok(profiles);
    }

    for entry in std::fs::read_dir(root)? {
        let entry = entry?;
        if entry.file_type()?.is_dir() {
            let name = entry.file_name().to_string_lossy().to_string();
            if validate_profile(&name).is_ok() {
                profiles.push(name);
            }
        }
    }
    profiles.sort_unstable();
    Ok(profiles)
}

pub fn create_profile(meta_dir: &Path, profile: &str) -> Result<String, ConfigSyncError> {
    let Some(profile) = normalize_profile(profile) else {
        return Err(ConfigSyncError::InvalidProfile(profile.to_string()));
    };
    validate_profile(profile)?;
    std::fs::create_dir_all(profile_dir(meta_dir, profile))?;
    Ok(profile.to_string())
}

pub fn delete_profile(meta_dir: &Path, profile: &str) -> Result<(), ConfigSyncError> {
    validate_profile(profile)?;
    let dir = profile_dir(meta_dir, profile);
    if dir.exists() {
        remove_path(&dir)?;
    }
    Ok(())
}

pub fn switch_profile(
    instance_name: &str,
    current_profile: Option<&str>,
    target_profile: Option<&str>,
    meta_dir: &Path,
    instance_dir: &Path,
) -> Result<Option<String>, ConfigSyncError> {
    if crate::instance::runtime::get(instance_name).is_some() {
        return Err(ConfigSyncError::InstanceRunning {
            instance: instance_name.to_string(),
        });
    }

    let current_profile = current_profile.and_then(normalize_profile);
    let target_profile = target_profile.and_then(normalize_profile);
    if let Some(profile) = current_profile {
        validate_profile(profile)?;
    }
    if let Some(profile) = target_profile {
        validate_profile(profile)?;
    }

    if current_profile == target_profile {
        return Ok(current_profile.map(str::to_string));
    }

    if let Some(profile) = current_profile {
        let profile_dir = profile_dir(meta_dir, profile);
        if profile_dir.exists() {
            let _lock = acquire_lock(&profile_dir)?;
            sync_to_profile(&minecraft_dir(instance_dir), &profile_dir)?;
        }
    }

    match (current_profile, target_profile) {
        (None, Some(_)) => {
            sync_to_profile(
                &minecraft_dir(instance_dir),
                &local_backup_dir(instance_dir),
            )?;
        }
        (Some(_), None) => {
            let backup = local_backup_dir(instance_dir);
            if backup.exists() {
                sync_from_profile(&backup, &minecraft_dir(instance_dir))?;
            }
            return Ok(None);
        }
        _ => {}
    }

    let Some(profile) = target_profile else {
        return Ok(None);
    };

    let profile_dir = profile_dir(meta_dir, profile);
    let _lock = acquire_lock(&profile_dir)?;

    if !profile_payload_exists(&profile_dir)? {
        sync_to_profile(&minecraft_dir(instance_dir), &profile_dir)?;
    }
    sync_from_profile(&profile_dir, &minecraft_dir(instance_dir))?;

    Ok(Some(profile.to_string()))
}

fn normalize_profile(profile: &str) -> Option<&str> {
    let trimmed = profile.trim();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed)
    }
}

pub fn validate_profile(profile: &str) -> Result<(), ConfigSyncError> {
    if profile.is_empty()
        || profile.len() > 64
        || profile.starts_with('.')
        || profile.contains('/')
        || profile.contains('\\')
        || profile.eq_ignore_ascii_case("default")
        || profile.eq_ignore_ascii_case("none")
        || profile.eq_ignore_ascii_case("local")
        || profile.eq_ignore_ascii_case("instance default")
        || profile.eq_ignore_ascii_case("local default")
        || profile
            .chars()
            .any(|c| c.is_control() || matches!(c, '<' | '>' | ':' | '"' | '|' | '?' | '*'))
    {
        return Err(ConfigSyncError::InvalidProfile(profile.to_string()));
    }
    Ok(())
}

fn profile_dir(meta_dir: &Path, profile: &str) -> PathBuf {
    profiles_dir(meta_dir).join(profile)
}

fn profiles_dir(meta_dir: &Path) -> PathBuf {
    crate::storage::MetadataPaths::new(meta_dir).profiles()
}

fn minecraft_dir(instance_dir: &Path) -> PathBuf {
    instance_dir.join(crate::storage::MINECRAFT_DIR_NAME)
}

fn local_backup_dir(instance_dir: &Path) -> PathBuf {
    crate::storage::InstancePaths::new(instance_dir).local_config()
}

fn acquire_lock(profile_dir: &Path) -> Result<ConfigSyncLock, ConfigSyncError> {
    std::fs::create_dir_all(profile_dir)?;
    let path = profile_dir.join(".lock");
    let file = std::fs::OpenOptions::new()
        .read(true)
        .write(true)
        .create(true)
        .truncate(false)
        .open(path)?;
    file.lock()?;
    Ok(ConfigSyncLock { file })
}

fn mirror_dir(src: &Path, dst: &Path) -> Result<(), ConfigSyncError> {
    if dst.exists() {
        remove_path(dst)?;
    }
    std::fs::create_dir_all(dst)?;

    if !src.exists() {
        return Ok(());
    }

    copy_dir_contents(src, dst)
}

fn sync_to_profile(minecraft_dir: &Path, profile_dir: &Path) -> Result<(), ConfigSyncError> {
    mirror_dir(&minecraft_dir.join("config"), &profile_dir.join("config"))?;
    mirror_options(minecraft_dir, profile_dir)
}

fn sync_from_profile(profile_dir: &Path, minecraft_dir: &Path) -> Result<(), ConfigSyncError> {
    mirror_dir(&profile_dir.join("config"), &minecraft_dir.join("config"))?;
    mirror_options(profile_dir, minecraft_dir)
}

fn profile_payload_exists(profile_dir: &Path) -> Result<bool, ConfigSyncError> {
    if profile_dir.join("config").exists() {
        return Ok(true);
    }
    if !profile_dir.exists() {
        return Ok(false);
    }
    for entry in std::fs::read_dir(profile_dir)? {
        let entry = entry?;
        if entry.file_type()?.is_file() && is_options_file(&entry.file_name().to_string_lossy()) {
            return Ok(true);
        }
    }
    Ok(false)
}

fn mirror_options(src: &Path, dst: &Path) -> Result<(), ConfigSyncError> {
    remove_options(dst)?;
    std::fs::create_dir_all(dst)?;
    if !src.exists() {
        return Ok(());
    }

    for entry in std::fs::read_dir(src)? {
        let entry = entry?;
        let name = entry.file_name();
        if entry.file_type()?.is_file() && is_options_file(&name.to_string_lossy()) {
            std::fs::copy(entry.path(), dst.join(name))?;
        }
    }
    Ok(())
}

fn remove_options(dir: &Path) -> Result<(), ConfigSyncError> {
    if !dir.exists() {
        return Ok(());
    }

    for entry in std::fs::read_dir(dir)? {
        let entry = entry?;
        if is_options_file(&entry.file_name().to_string_lossy()) {
            remove_path(&entry.path())?;
        }
    }
    Ok(())
}

fn is_options_file(name: &str) -> bool {
    name == "options.txt" || name.starts_with("options") && name.ends_with(".txt")
}

fn remove_path(path: &Path) -> Result<(), ConfigSyncError> {
    let meta = std::fs::symlink_metadata(path)?;
    if meta.is_dir() {
        std::fs::remove_dir_all(path)?;
    } else {
        std::fs::remove_file(path)?;
    }
    Ok(())
}

fn copy_dir_contents(src: &Path, dst: &Path) -> Result<(), ConfigSyncError> {
    for entry in std::fs::read_dir(src)? {
        let entry = entry?;
        let source = entry.path();
        let target = dst.join(entry.file_name());
        let file_type = entry.file_type()?;

        if file_type.is_dir() {
            std::fs::create_dir_all(&target)?;
            copy_dir_contents(&source, &target)?;
        } else if file_type.is_file() {
            std::fs::copy(&source, &target)?;
        }
    }

    Ok(())
}

#[cfg(test)]
#[path = "tests/config_sync.rs"]
mod tests;