modde_games/bethesda/
plugins_txt.rs1use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct PluginEntry {
12 pub name: String,
13 pub enabled: bool,
14}
15
16pub 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
22pub 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
29pub 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#[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
59pub 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
66pub 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#[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#[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 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 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}