modde-games 0.2.1

Game plugin implementations for modde
Documentation
//! Managing user-defined game specs on disk: add, remove, read, and detect
//! candidate install directories.

use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, ensure};
use serde::Serialize;
use tracing::warn;

use modde_core::paths;

use super::spec::GameSpec;

/// Outcome of [`add_user_game`]: where the spec was written and whether it existed already.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AddUserGameResult {
    pub path: PathBuf,
    pub existed: bool,
}

/// A candidate game directory found by [`detect_candidates`], with its
/// executables and total size to help rank likely game roots.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectCandidateDir {
    pub relative_dir: String,
    pub exe_names: Vec<String>,
    pub total_size: u64,
}

impl fmt::Display for DetectCandidateDir {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.exe_names.is_empty() {
            f.write_str(&self.relative_dir)
        } else {
            write!(f, "{} ({})", self.relative_dir, self.exe_names.join(", "))
        }
    }
}

#[derive(Serialize)]
struct GameSpecToml<'a> {
    id: &'a str,
    display_name: &'a str,
    #[serde(skip_serializing_if = "Option::is_none")]
    steam_app_id: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    install_dir_name: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    install_path_override: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    mod_dir: Option<String>,
    executable_dir: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    nexus_domain: Option<&'a str>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    proxy_dlls: Vec<&'a str>,
}

/// Directory where user-defined game specs are stored.
#[must_use]
pub fn games_dir() -> PathBuf {
    paths::modde_data_dir().join("games")
}

/// Path to the TOML spec file for the user game with the given `id`.
#[must_use]
pub fn user_game_path(id: &str) -> PathBuf {
    games_dir().join(format!("{id}.toml"))
}

/// Render a path as a forward-slash string suitable for embedding in TOML.
#[must_use]
pub fn path_to_toml_string(path: &Path) -> String {
    let parts: Vec<String> = path
        .components()
        .map(|component| component.as_os_str().to_string_lossy().into_owned())
        .collect();
    if parts.is_empty() {
        ".".to_string()
    } else {
        parts.join("/")
    }
}

fn game_spec_to_toml(spec: &GameSpec) -> GameSpecToml<'_> {
    GameSpecToml {
        id: &spec.id,
        display_name: &spec.display_name,
        steam_app_id: spec.steam_app_id.as_deref(),
        install_dir_name: spec.install_dir_name.as_deref(),
        install_path_override: spec
            .install_path_override
            .as_ref()
            .map(|path| path_to_toml_string(path)),
        mod_dir: spec.mod_dir.as_ref().map(|path| path_to_toml_string(path)),
        executable_dir: path_to_toml_string(&spec.executable_dir),
        nexus_domain: spec.nexus_domain.as_deref(),
        proxy_dlls: spec.proxy_dlls.iter().map(String::as_str).collect(),
    }
}

pub fn read_user_game_spec(id: &str) -> Result<Option<(PathBuf, GameSpec)>> {
    let path = user_game_path(id);
    if !path.exists() {
        return Ok(None);
    }
    let content =
        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
    let spec: GameSpec =
        toml::from_str(&content).with_context(|| format!("failed to parse {}", path.display()))?;
    spec.validate()
        .with_context(|| format!("invalid spec in {}", path.display()))?;
    Ok(Some((path, spec)))
}

pub fn add_user_game(spec: &GameSpec, force: bool) -> Result<AddUserGameResult> {
    spec.validate()
        .with_context(|| format!("invalid game spec for '{}'", spec.id))?;

    let path = user_game_path(&spec.id);
    let existed = path.exists();
    if existed && !force {
        anyhow::bail!(
            "game '{}' already exists at {}. Re-run with --force to overwrite.",
            spec.id,
            path.display()
        );
    }

    fs::create_dir_all(games_dir()).with_context(|| "failed to create user games directory")?;
    let rendered = toml::to_string_pretty(&game_spec_to_toml(spec))
        .context("failed to serialize game spec to TOML")?;
    fs::write(&path, rendered).with_context(|| format!("failed to write {}", path.display()))?;

    Ok(AddUserGameResult { path, existed })
}

pub fn remove_user_game(id: &str) -> Result<PathBuf> {
    let path = user_game_path(id);
    if !path.exists() {
        if crate::resolve_game(id).is_some() {
            anyhow::bail!("game '{id}' is built in and cannot be removed");
        }
        anyhow::bail!("no user-defined game named '{id}' exists");
    }

    fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
    Ok(path)
}

pub fn detect_candidates(install_path: &Path) -> Result<Vec<DetectCandidateDir>> {
    ensure!(
        install_path.is_dir(),
        "install path does not exist: {}",
        install_path.display()
    );

    let mut candidates = Vec::new();
    walk_exe_dirs(install_path, install_path, 0, &mut candidates)?;
    candidates.sort_by(|a, b| {
        b.total_size
            .cmp(&a.total_size)
            .then_with(|| a.relative_dir.cmp(&b.relative_dir))
    });
    Ok(candidates)
}

fn walk_exe_dirs(
    root: &Path,
    dir: &Path,
    depth: usize,
    candidates: &mut Vec<DetectCandidateDir>,
) -> Result<()> {
    let entries = fs::read_dir(dir)
        .with_context(|| format!("failed to read directory: {}", dir.display()))?;

    let mut exe_names = Vec::new();
    let mut total_size = 0u64;
    let mut subdirs = Vec::new();

    for entry in entries.flatten() {
        let path = entry.path();
        let file_type = match entry.file_type() {
            Ok(file_type) => file_type,
            Err(error) => {
                warn!(path = %path.display(), error = %error, "skipping unreadable entry");
                continue;
            }
        };

        if file_type.is_dir() {
            if depth < 4 {
                subdirs.push(path);
            }
            continue;
        }

        if !file_type.is_file() {
            continue;
        }

        if path
            .extension()
            .and_then(|ext| ext.to_str())
            .is_some_and(|ext| ext.eq_ignore_ascii_case("exe"))
        {
            let size = entry.metadata().map(|metadata| metadata.len()).unwrap_or(0);
            total_size += size;
            exe_names.push(
                path.file_name()
                    .map(|name| name.to_string_lossy().into_owned())
                    .unwrap_or_else(|| path.display().to_string()),
            );
        }
    }

    if !exe_names.is_empty() {
        exe_names.sort();
        candidates.push(DetectCandidateDir {
            relative_dir: relative_dir(root, dir),
            exe_names,
            total_size,
        });
    }

    for subdir in subdirs {
        walk_exe_dirs(root, &subdir, depth + 1, candidates)?;
    }

    Ok(())
}

fn relative_dir(root: &Path, dir: &Path) -> String {
    let rel = dir.strip_prefix(root).unwrap_or(dir);
    if rel.as_os_str().is_empty() {
        ".".to_string()
    } else {
        rel.to_string_lossy().into_owned()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn detect_candidates_prefers_larger_executable_dirs() {
        let temp = tempfile::tempdir().expect("tempdir");
        let install = temp.path();
        let game = install.join("Game");
        let support = install.join("Support");
        fs::create_dir_all(&game).expect("game dir");
        fs::create_dir_all(&support).expect("support dir");
        fs::write(game.join("game.exe"), vec![0u8; 8]).expect("write game exe");
        fs::write(support.join("launcher.exe"), vec![0u8; 2]).expect("write support exe");

        let candidates = detect_candidates(install).expect("detect candidates");

        assert_eq!(candidates.len(), 2);
        assert_eq!(candidates[0].relative_dir, "Game");
        assert_eq!(candidates[1].relative_dir, "Support");
    }
}