Skip to main content

modde_games/bethesda/
plugins_txt.rs

1//! Reading, parsing, formatting, and writing the Bethesda `plugins.txt` load
2//! order file, including locating it inside a game's Steam Proton prefix and
3//! representing each line as a [`PluginEntry`].
4
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8
9/// An entry in plugins.txt.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct PluginEntry {
12    pub name: String,
13    pub enabled: bool,
14}
15
16/// Steam app IDs for supported Bethesda games.
17pub const SKYRIM_SE_APP_ID: u32 = 489830;
18pub const FALLOUT4_APP_ID: u32 = 377160;
19pub const FALLOUT76_APP_ID: u32 = 1151340;
20pub const STARFIELD_APP_ID: u32 = 1716740;
21
22/// Read plugins.txt for a Bethesda game running under Steam Proton.
23pub fn read_plugins_txt(app_id: u32, game_name: &str) -> Result<Vec<PluginEntry>> {
24    let path = plugins_txt_path(app_id, game_name)
25        .ok_or_else(|| anyhow::anyhow!("could not determine plugins.txt path"))?;
26    read_plugins_txt_from(&path)
27}
28
29/// Read plugins.txt from an explicit path, returning `(plugin_filename, enabled)` pairs.
30pub fn read_plugins_txt_from(path: &Path) -> Result<Vec<PluginEntry>> {
31    let content = std::fs::read_to_string(path)
32        .with_context(|| format!("failed to read {}", path.display()))?;
33
34    Ok(parse_plugins_txt(&content))
35}
36
37/// Parse the content of a plugins.txt file into entries.
38#[must_use]
39pub fn parse_plugins_txt(content: &str) -> Vec<PluginEntry> {
40    content
41        .lines()
42        .filter(|line| !line.is_empty() && !line.starts_with('#'))
43        .map(|line| {
44            if let Some(name) = line.strip_prefix('*') {
45                PluginEntry {
46                    name: name.to_string(),
47                    enabled: true,
48                }
49            } else {
50                PluginEntry {
51                    name: line.to_string(),
52                    enabled: false,
53                }
54            }
55        })
56        .collect()
57}
58
59/// Write plugins.txt with the `*` prefix format.
60pub fn write_plugins_txt(app_id: u32, game_name: &str, entries: &[PluginEntry]) -> Result<()> {
61    let path = plugins_txt_path(app_id, game_name)
62        .ok_or_else(|| anyhow::anyhow!("could not determine plugins.txt path"))?;
63    write_plugins_txt_to(&path, entries)
64}
65
66/// Write plugins.txt to an explicit path.
67pub fn write_plugins_txt_to(path: &Path, entries: &[PluginEntry]) -> Result<()> {
68    if let Some(parent) = path.parent() {
69        std::fs::create_dir_all(parent)
70            .with_context(|| format!("failed to create {}", parent.display()))?;
71    }
72
73    let content = format_plugins_txt(entries);
74
75    std::fs::write(path, &content)
76        .with_context(|| format!("failed to write {}", path.display()))?;
77
78    Ok(())
79}
80
81/// Format plugin entries into the plugins.txt file content.
82#[must_use]
83pub fn format_plugins_txt(entries: &[PluginEntry]) -> String {
84    let mut content = String::from("# This file is generated by modde. Do not edit manually.\n");
85    for entry in entries {
86        if entry.enabled {
87            content.push('*');
88        }
89        content.push_str(&entry.name);
90        content.push('\n');
91    }
92    content
93}
94
95/// Get the plugins.txt path for a Bethesda game given its Steam app ID and game folder name.
96///
97/// Returns `None` if the `HOME` environment variable is not set.
98///
99/// Known mappings:
100/// - Skyrim SE/AE: `app_id=489830`, `game_folder_name="Skyrim` Special Edition"
101/// - Fallout 4:    `app_id=377160`, `game_folder_name="Fallout4`"
102/// - Fallout 76:   `app_id=1151340`, `game_folder_name="Fallout76`"
103#[must_use]
104pub fn plugins_txt_path(steam_app_id: u32, game_folder_name: &str) -> Option<PathBuf> {
105    let home = std::env::var("HOME").ok()?;
106    Some(
107        PathBuf::from(home)
108            .join(".local/share/Steam/steamapps/compatdata")
109            .join(steam_app_id.to_string())
110            .join("pfx/drive_c/users/steamuser/AppData/Local")
111            .join(game_folder_name)
112            .join("plugins.txt"),
113    )
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn test_parse_plugins_txt() {
122        let content = "\
123# This file is used by the game to determine plugin load order.
124*plugin_a.esp
125*plugin_b.esp
126master.esm
127";
128        let entries = parse_plugins_txt(content);
129        assert_eq!(entries.len(), 3);
130        assert_eq!(
131            entries[0],
132            PluginEntry {
133                name: "plugin_a.esp".to_string(),
134                enabled: true,
135            }
136        );
137        assert_eq!(
138            entries[1],
139            PluginEntry {
140                name: "plugin_b.esp".to_string(),
141                enabled: true,
142            }
143        );
144        assert_eq!(
145            entries[2],
146            PluginEntry {
147                name: "master.esm".to_string(),
148                enabled: false,
149            }
150        );
151    }
152
153    #[test]
154    fn test_parse_empty_and_comment_lines() {
155        let content = "\
156# comment
157
158*enabled.esp
159
160disabled.esp
161# another comment
162";
163        let entries = parse_plugins_txt(content);
164        assert_eq!(entries.len(), 2);
165        assert!(entries[0].enabled);
166        assert_eq!(entries[0].name, "enabled.esp");
167        assert!(!entries[1].enabled);
168        assert_eq!(entries[1].name, "disabled.esp");
169    }
170
171    #[test]
172    fn test_format_plugins_txt() {
173        let entries = vec![
174            PluginEntry {
175                name: "Skyrim.esm".to_string(),
176                enabled: true,
177            },
178            PluginEntry {
179                name: "Update.esm".to_string(),
180                enabled: true,
181            },
182            PluginEntry {
183                name: "optional.esp".to_string(),
184                enabled: false,
185            },
186        ];
187        let content = format_plugins_txt(&entries);
188        assert!(content.starts_with('#'));
189        assert!(content.contains("*Skyrim.esm\n"));
190        assert!(content.contains("*Update.esm\n"));
191        assert!(content.contains("optional.esp\n"));
192        assert!(!content.contains("*optional.esp"));
193    }
194
195    #[test]
196    fn test_roundtrip_write_read() {
197        let dir = std::env::temp_dir().join("modde_test_plugins_txt");
198        let _ = std::fs::remove_dir_all(&dir);
199        std::fs::create_dir_all(&dir).unwrap();
200        let path = dir.join("plugins.txt");
201
202        let entries = vec![
203            PluginEntry {
204                name: "Skyrim.esm".to_string(),
205                enabled: true,
206            },
207            PluginEntry {
208                name: "Update.esm".to_string(),
209                enabled: true,
210            },
211            PluginEntry {
212                name: "disabled_mod.esp".to_string(),
213                enabled: false,
214            },
215            PluginEntry {
216                name: "cool_mod.esp".to_string(),
217                enabled: true,
218            },
219        ];
220
221        write_plugins_txt_to(&path, &entries).unwrap();
222        let read_back = read_plugins_txt_from(&path).unwrap();
223
224        assert_eq!(entries, read_back);
225
226        // Clean up
227        let _ = std::fs::remove_dir_all(&dir);
228    }
229
230    #[test]
231    fn test_write_creates_parent_dirs() {
232        let dir = std::env::temp_dir().join("modde_test_plugins_txt_nested/a/b/c");
233        let _ = std::fs::remove_dir_all(std::env::temp_dir().join("modde_test_plugins_txt_nested"));
234        let path = dir.join("plugins.txt");
235
236        write_plugins_txt_to(&path, &[]).unwrap();
237        assert!(path.exists());
238
239        let _ = std::fs::remove_dir_all(std::env::temp_dir().join("modde_test_plugins_txt_nested"));
240    }
241
242    #[test]
243    fn test_plugins_txt_path_format() {
244        // This test just verifies the path structure, not that it exists on disk.
245        if let Some(path) = plugins_txt_path(489830, "Skyrim Special Edition") {
246            let path_str = path.to_string_lossy();
247            assert!(path_str.contains("compatdata/489830/"));
248            assert!(path_str.contains("Skyrim Special Edition/plugins.txt"));
249        }
250    }
251
252    #[test]
253    fn test_read_nonexistent_file_returns_error() {
254        let result = read_plugins_txt_from(Path::new("/nonexistent/plugins.txt"));
255        assert!(result.is_err());
256    }
257}