use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginEntry {
pub name: String,
pub enabled: bool,
}
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;
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)
}
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))
}
#[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()
}
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)
}
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(())
}
#[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
}
#[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);
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() {
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());
}
}