modde-games 0.2.1

Game plugin implementations for modde
Documentation
//! Reading, parsing, formatting, and writing the Bethesda `plugins.txt` load
//! order file, including locating it inside a game's Steam Proton prefix and
//! representing each line as a [`PluginEntry`].

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

use anyhow::{Context, Result};

/// An entry in plugins.txt.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginEntry {
    pub name: String,
    pub enabled: bool,
}

/// Steam app IDs for supported Bethesda games.
pub const SKYRIM_SE_APP_ID: u32 = 489830;
pub const FALLOUT4_APP_ID: u32 = 377160;
pub const FALLOUT76_APP_ID: u32 = 1151340;
pub const STARFIELD_APP_ID: u32 = 1716740;

/// Read plugins.txt for a Bethesda game running under Steam Proton.
pub fn read_plugins_txt(app_id: u32, game_name: &str) -> Result<Vec<PluginEntry>> {
    let path = plugins_txt_path(app_id, game_name)
        .ok_or_else(|| anyhow::anyhow!("could not determine plugins.txt path"))?;
    read_plugins_txt_from(&path)
}

/// Read plugins.txt from an explicit path, returning `(plugin_filename, enabled)` pairs.
pub fn read_plugins_txt_from(path: &Path) -> Result<Vec<PluginEntry>> {
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("failed to read {}", path.display()))?;

    Ok(parse_plugins_txt(&content))
}

/// Parse the content of a plugins.txt file into entries.
#[must_use]
pub fn parse_plugins_txt(content: &str) -> Vec<PluginEntry> {
    content
        .lines()
        .filter(|line| !line.is_empty() && !line.starts_with('#'))
        .map(|line| {
            if let Some(name) = line.strip_prefix('*') {
                PluginEntry {
                    name: name.to_string(),
                    enabled: true,
                }
            } else {
                PluginEntry {
                    name: line.to_string(),
                    enabled: false,
                }
            }
        })
        .collect()
}

/// Write plugins.txt with the `*` prefix format.
pub fn write_plugins_txt(app_id: u32, game_name: &str, entries: &[PluginEntry]) -> Result<()> {
    let path = plugins_txt_path(app_id, game_name)
        .ok_or_else(|| anyhow::anyhow!("could not determine plugins.txt path"))?;
    write_plugins_txt_to(&path, entries)
}

/// Write plugins.txt to an explicit path.
pub fn write_plugins_txt_to(path: &Path, entries: &[PluginEntry]) -> Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)
            .with_context(|| format!("failed to create {}", parent.display()))?;
    }

    let content = format_plugins_txt(entries);

    std::fs::write(path, &content)
        .with_context(|| format!("failed to write {}", path.display()))?;

    Ok(())
}

/// Format plugin entries into the plugins.txt file content.
#[must_use]
pub fn format_plugins_txt(entries: &[PluginEntry]) -> String {
    let mut content = String::from("# This file is generated by modde. Do not edit manually.\n");
    for entry in entries {
        if entry.enabled {
            content.push('*');
        }
        content.push_str(&entry.name);
        content.push('\n');
    }
    content
}

/// Get the plugins.txt path for a Bethesda game given its Steam app ID and game folder name.
///
/// Returns `None` if the `HOME` environment variable is not set.
///
/// Known mappings:
/// - Skyrim SE/AE: `app_id=489830`, `game_folder_name="Skyrim` Special Edition"
/// - Fallout 4:    `app_id=377160`, `game_folder_name="Fallout4`"
/// - Fallout 76:   `app_id=1151340`, `game_folder_name="Fallout76`"
#[must_use]
pub fn plugins_txt_path(steam_app_id: u32, game_folder_name: &str) -> Option<PathBuf> {
    let home = std::env::var("HOME").ok()?;
    Some(
        PathBuf::from(home)
            .join(".local/share/Steam/steamapps/compatdata")
            .join(steam_app_id.to_string())
            .join("pfx/drive_c/users/steamuser/AppData/Local")
            .join(game_folder_name)
            .join("plugins.txt"),
    )
}

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

    #[test]
    fn test_parse_plugins_txt() {
        let content = "\
# This file is used by the game to determine plugin load order.
*plugin_a.esp
*plugin_b.esp
master.esm
";
        let entries = parse_plugins_txt(content);
        assert_eq!(entries.len(), 3);
        assert_eq!(
            entries[0],
            PluginEntry {
                name: "plugin_a.esp".to_string(),
                enabled: true,
            }
        );
        assert_eq!(
            entries[1],
            PluginEntry {
                name: "plugin_b.esp".to_string(),
                enabled: true,
            }
        );
        assert_eq!(
            entries[2],
            PluginEntry {
                name: "master.esm".to_string(),
                enabled: false,
            }
        );
    }

    #[test]
    fn test_parse_empty_and_comment_lines() {
        let content = "\
# comment

*enabled.esp

disabled.esp
# another comment
";
        let entries = parse_plugins_txt(content);
        assert_eq!(entries.len(), 2);
        assert!(entries[0].enabled);
        assert_eq!(entries[0].name, "enabled.esp");
        assert!(!entries[1].enabled);
        assert_eq!(entries[1].name, "disabled.esp");
    }

    #[test]
    fn test_format_plugins_txt() {
        let entries = vec![
            PluginEntry {
                name: "Skyrim.esm".to_string(),
                enabled: true,
            },
            PluginEntry {
                name: "Update.esm".to_string(),
                enabled: true,
            },
            PluginEntry {
                name: "optional.esp".to_string(),
                enabled: false,
            },
        ];
        let content = format_plugins_txt(&entries);
        assert!(content.starts_with('#'));
        assert!(content.contains("*Skyrim.esm\n"));
        assert!(content.contains("*Update.esm\n"));
        assert!(content.contains("optional.esp\n"));
        assert!(!content.contains("*optional.esp"));
    }

    #[test]
    fn test_roundtrip_write_read() {
        let dir = std::env::temp_dir().join("modde_test_plugins_txt");
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("plugins.txt");

        let entries = vec![
            PluginEntry {
                name: "Skyrim.esm".to_string(),
                enabled: true,
            },
            PluginEntry {
                name: "Update.esm".to_string(),
                enabled: true,
            },
            PluginEntry {
                name: "disabled_mod.esp".to_string(),
                enabled: false,
            },
            PluginEntry {
                name: "cool_mod.esp".to_string(),
                enabled: true,
            },
        ];

        write_plugins_txt_to(&path, &entries).unwrap();
        let read_back = read_plugins_txt_from(&path).unwrap();

        assert_eq!(entries, read_back);

        // Clean up
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn test_write_creates_parent_dirs() {
        let dir = std::env::temp_dir().join("modde_test_plugins_txt_nested/a/b/c");
        let _ = std::fs::remove_dir_all(std::env::temp_dir().join("modde_test_plugins_txt_nested"));
        let path = dir.join("plugins.txt");

        write_plugins_txt_to(&path, &[]).unwrap();
        assert!(path.exists());

        let _ = std::fs::remove_dir_all(std::env::temp_dir().join("modde_test_plugins_txt_nested"));
    }

    #[test]
    fn test_plugins_txt_path_format() {
        // This test just verifies the path structure, not that it exists on disk.
        if let Some(path) = plugins_txt_path(489830, "Skyrim Special Edition") {
            let path_str = path.to_string_lossy();
            assert!(path_str.contains("compatdata/489830/"));
            assert!(path_str.contains("Skyrim Special Edition/plugins.txt"));
        }
    }

    #[test]
    fn test_read_nonexistent_file_returns_error() {
        let result = read_plugins_txt_from(Path::new("/nonexistent/plugins.txt"));
        assert!(result.is_err());
    }
}