1use std::collections::{HashMap, HashSet};
2use std::marker::PhantomData;
3use std::path::{Path, PathBuf};
4
5use tracing::{info, warn};
6
7use crate::error::{CoreError, Result};
8use crate::fs::symlink_async;
9use crate::paths;
10use crate::resolver::{ModId, ResolvedLoadOrder};
11
12pub struct Built;
16pub struct Materialized;
18
19#[derive(Debug, Clone)]
25pub struct SymlinkFarm<S = Materialized> {
26 pub staging_dir: PathBuf,
28 pub links: HashMap<String, PathBuf>,
30 _state: PhantomData<S>,
31}
32
33impl SymlinkFarm<Built> {
34 pub fn from_links(staging_dir: PathBuf, links: HashMap<String, PathBuf>) -> Self {
36 Self { staging_dir, links, _state: PhantomData }
37 }
38
39 pub fn build(
45 profile_name: &str,
46 resolved: &ResolvedLoadOrder,
47 mod_files: &HashMap<ModId, Vec<(String, PathBuf)>>,
48 overrides: Option<&[(String, PathBuf)]>,
49 hidden: Option<&HashSet<(String, String)>>,
50 ) -> Result<Self> {
51 let staging_dir = paths::profiles_dir().join(profile_name).join("staging");
52
53 let mut links: HashMap<String, PathBuf> = HashMap::new();
54
55 for mod_id in &resolved.order {
57 if let Some(files) = mod_files.get(mod_id) {
58 for (rel_path, source) in files {
59 if let Some(hidden) = hidden {
61 if hidden.contains(&(mod_id.0.clone(), rel_path.clone())) {
62 continue;
63 }
64 }
65 links.insert(rel_path.clone(), source.clone());
66 }
67 }
68 }
69
70 if let Some(overrides) = overrides {
72 for (rel_path, source) in overrides {
73 links.insert(rel_path.clone(), source.clone());
74 }
75 }
76
77 Ok(Self { staging_dir, links, _state: PhantomData })
78 }
79
80 pub async fn materialize(self) -> Result<SymlinkFarm<Materialized>> {
82 if self.staging_dir.exists() {
84 tokio::fs::remove_dir_all(&self.staging_dir).await?;
85 }
86 tokio::fs::create_dir_all(&self.staging_dir).await?;
87
88 for (rel_path, source) in &self.links {
89 let target = self.staging_dir.join(rel_path);
90 if let Some(parent) = target.parent() {
91 tokio::fs::create_dir_all(parent).await?;
92 }
93 symlink_async(source, &target).await?;
94 }
95
96 info!(
97 staging_dir = %self.staging_dir.display(),
98 link_count = self.links.len(),
99 "symlink farm materialized"
100 );
101
102 Ok(SymlinkFarm {
103 staging_dir: self.staging_dir,
104 links: self.links,
105 _state: PhantomData,
106 })
107 }
108}
109
110impl SymlinkFarm<Materialized> {
111 pub async fn deploy_to(&self, target: &Path) -> Result<()> {
115 if !target.exists() {
116 tokio::fs::create_dir_all(target).await?;
117 }
118
119 for rel_path in self.links.keys() {
120 let src = self.staging_dir.join(rel_path);
121 let dst = target.join(rel_path);
122
123 if let Some(parent) = dst.parent() {
124 tokio::fs::create_dir_all(parent).await?;
125 }
126
127 if dst.symlink_metadata().is_ok() {
128 tokio::fs::remove_file(&dst).await?;
129 }
130
131 symlink_async(&src, &dst).await?;
132 }
133
134 info!(target = %target.display(), "deployment complete");
135 Ok(())
136 }
137}
138
139pub async fn rollback(profile_name: &str) -> Result<()> {
141 let profile_dir = paths::profiles_dir().join(profile_name);
142 let staging = profile_dir.join("staging");
143 let backup = profile_dir.join("staging.bak");
144
145 if !backup.exists() {
146 return Err(CoreError::Other(format!(
147 "no backup staging found for profile '{profile_name}'"
148 ).into()));
149 }
150
151 if staging.exists() {
152 let tmp = profile_dir.join("staging.old");
153 tokio::fs::rename(&staging, &tmp).await?;
154 tokio::fs::rename(&backup, &staging).await?;
155 tokio::fs::remove_dir_all(&tmp).await?;
156 } else {
157 tokio::fs::rename(&backup, &staging).await?;
158 }
159
160 warn!(profile = profile_name, "rolled back to previous staging");
161
162 Ok(())
163}
164
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use std::collections::HashMap;
170 use crate::resolver::{ModId, ResolvedLoadOrder};
171 use tempfile::TempDir;
172
173 fn make_resolved(order: Vec<&str>) -> ResolvedLoadOrder {
174 ResolvedLoadOrder {
175 order: order.into_iter().map(ModId::from).collect(),
176 }
177 }
178
179 fn test_farm(staging_dir: PathBuf, links: HashMap<String, PathBuf>) -> SymlinkFarm<Built> {
181 SymlinkFarm::from_links(staging_dir, links)
182 }
183
184 #[test]
189 fn test_build_empty_mod_files() {
190 let resolved = make_resolved(vec![]);
191 let mod_files: HashMap<ModId, Vec<(String, PathBuf)>> = HashMap::new();
192
193 let farm = SymlinkFarm::build("test_profile", &resolved, &mod_files, None, None).unwrap();
194 assert!(farm.links.is_empty());
195 }
196
197 #[test]
198 fn test_build_single_mod() {
199 let resolved = make_resolved(vec!["mod_a"]);
200 let source = PathBuf::from("/store/mod_a/textures/sky.dds");
201 let mut mod_files: HashMap<ModId, Vec<(String, PathBuf)>> = HashMap::new();
202 mod_files.insert(
203 "mod_a".into(),
204 vec![("textures/sky.dds".into(), source.clone())],
205 );
206
207 let farm = SymlinkFarm::build("test_profile", &resolved, &mod_files, None, None).unwrap();
208 assert_eq!(farm.links.len(), 1);
209 assert_eq!(farm.links.get("textures/sky.dds").unwrap(), &source);
210 }
211
212 #[test]
213 fn test_build_override_order() {
214 let resolved = make_resolved(vec!["mod_a", "mod_b"]);
216 let source_a = PathBuf::from("/store/mod_a/meshes/body.nif");
217 let source_b = PathBuf::from("/store/mod_b/meshes/body.nif");
218
219 let mut mod_files: HashMap<ModId, Vec<(String, PathBuf)>> = HashMap::new();
220 mod_files.insert(
221 "mod_a".into(),
222 vec![("meshes/body.nif".into(), source_a.clone())],
223 );
224 mod_files.insert(
225 "mod_b".into(),
226 vec![("meshes/body.nif".into(), source_b.clone())],
227 );
228
229 let farm = SymlinkFarm::build("test_profile", &resolved, &mod_files, None, None).unwrap();
230 assert_eq!(farm.links.len(), 1);
231 assert_eq!(farm.links.get("meshes/body.nif").unwrap(), &source_b);
233 }
234
235 #[test]
236 fn test_build_multiple_mods_different_files() {
237 let resolved = make_resolved(vec!["mod_a", "mod_b"]);
238 let source_a = PathBuf::from("/store/mod_a/textures/sky.dds");
239 let source_b = PathBuf::from("/store/mod_b/meshes/tree.nif");
240
241 let mut mod_files: HashMap<ModId, Vec<(String, PathBuf)>> = HashMap::new();
242 mod_files.insert(
243 "mod_a".into(),
244 vec![("textures/sky.dds".into(), source_a.clone())],
245 );
246 mod_files.insert(
247 "mod_b".into(),
248 vec![("meshes/tree.nif".into(), source_b.clone())],
249 );
250
251 let farm = SymlinkFarm::build("test_profile", &resolved, &mod_files, None, None).unwrap();
252 assert_eq!(farm.links.len(), 2);
253 assert_eq!(farm.links.get("textures/sky.dds").unwrap(), &source_a);
254 assert_eq!(farm.links.get("meshes/tree.nif").unwrap(), &source_b);
255 }
256
257 #[test]
258 fn test_build_mod_not_in_mod_files() {
259 let resolved = make_resolved(vec!["mod_a", "mod_missing", "mod_b"]);
261 let source_a = PathBuf::from("/store/mod_a/file_a.txt");
262 let source_b = PathBuf::from("/store/mod_b/file_b.txt");
263
264 let mut mod_files: HashMap<ModId, Vec<(String, PathBuf)>> = HashMap::new();
265 mod_files.insert("mod_a".into(), vec![("file_a.txt".into(), source_a.clone())]);
266 mod_files.insert("mod_b".into(), vec![("file_b.txt".into(), source_b.clone())]);
267
268 let farm = SymlinkFarm::build("test_profile", &resolved, &mod_files, None, None).unwrap();
269 assert_eq!(farm.links.len(), 2);
270 assert_eq!(farm.links.get("file_a.txt").unwrap(), &source_a);
271 assert_eq!(farm.links.get("file_b.txt").unwrap(), &source_b);
272 }
273
274 #[test]
275 fn test_build_deep_nested_paths() {
276 let resolved = make_resolved(vec!["mod_a"]);
277 let source = PathBuf::from("/store/mod_a/a/b/c/d/e/deep_file.esp");
278
279 let mut mod_files: HashMap<ModId, Vec<(String, PathBuf)>> = HashMap::new();
280 mod_files.insert(
281 "mod_a".into(),
282 vec![("a/b/c/d/e/deep_file.esp".into(), source.clone())],
283 );
284
285 let farm = SymlinkFarm::build("test_profile", &resolved, &mod_files, None, None).unwrap();
286 assert_eq!(farm.links.len(), 1);
287 assert_eq!(
288 farm.links.get("a/b/c/d/e/deep_file.esp").unwrap(),
289 &source
290 );
291 }
292
293 #[test]
294 fn test_build_hidden_files() {
295 let resolved = make_resolved(vec!["mod_a", "mod_b"]);
296 let source_a1 = PathBuf::from("/store/mod_a/textures/sky.dds");
297 let source_a2 = PathBuf::from("/store/mod_a/meshes/tree.nif");
298 let source_b = PathBuf::from("/store/mod_b/textures/sky.dds");
299
300 let mut mod_files: HashMap<ModId, Vec<(String, PathBuf)>> = HashMap::new();
301 mod_files.insert(
302 "mod_a".into(),
303 vec![
304 ("textures/sky.dds".into(), source_a1.clone()),
305 ("meshes/tree.nif".into(), source_a2.clone()),
306 ],
307 );
308 mod_files.insert(
309 "mod_b".into(),
310 vec![("textures/sky.dds".into(), source_b.clone())],
311 );
312
313 let mut hidden = HashSet::new();
315 hidden.insert(("mod_b".to_string(), "textures/sky.dds".to_string()));
316
317 let farm = SymlinkFarm::build("test_profile", &resolved, &mod_files, None, Some(&hidden)).unwrap();
318 assert_eq!(farm.links.len(), 2);
319 assert_eq!(farm.links.get("textures/sky.dds").unwrap(), &source_a1);
321 assert_eq!(farm.links.get("meshes/tree.nif").unwrap(), &source_a2);
322 }
323
324 #[tokio::test]
329 async fn test_materialize_creates_symlinks() {
330 let tmp = TempDir::new().unwrap();
331
332 let source_file = tmp.path().join("source.txt");
334 std::fs::write(&source_file, "hello").unwrap();
335
336 let staging_dir = tmp.path().join("staging");
337 let mut links = HashMap::new();
338 links.insert("data/source.txt".to_string(), source_file.clone());
339
340 let farm = test_farm(staging_dir.clone(), links);
341 let farm = farm.materialize().await.unwrap();
342
343 let link_path = staging_dir.join("data/source.txt");
344 assert!(link_path.symlink_metadata().unwrap().file_type().is_symlink());
345 assert_eq!(std::fs::read_link(&link_path).unwrap(), source_file);
346 assert_eq!(std::fs::read_to_string(&link_path).unwrap(), "hello");
347 }
348
349 #[tokio::test]
350 async fn test_materialize_empty_links() {
351 let tmp = TempDir::new().unwrap();
352 let staging_dir = tmp.path().join("staging");
353
354 let farm = test_farm(staging_dir.clone(), HashMap::new());
355 farm.materialize().await.unwrap();
356
357 assert!(staging_dir.exists());
358 let entries: Vec<_> = std::fs::read_dir(&staging_dir).unwrap().collect();
360 assert!(entries.is_empty());
361 }
362
363 #[tokio::test]
364 async fn test_materialize_deep_subdirectories() {
365 let tmp = TempDir::new().unwrap();
366 let source_file = tmp.path().join("original.dds");
367 std::fs::write(&source_file, "texture data").unwrap();
368
369 let staging_dir = tmp.path().join("staging");
370 let mut links = HashMap::new();
371 links.insert(
372 "textures/landscape/snow/detail.dds".to_string(),
373 source_file.clone(),
374 );
375
376 let farm = test_farm(staging_dir.clone(), links);
377 farm.materialize().await.unwrap();
378
379 let link_path = staging_dir.join("textures/landscape/snow/detail.dds");
380 assert!(link_path.symlink_metadata().unwrap().file_type().is_symlink());
381 assert_eq!(std::fs::read_to_string(&link_path).unwrap(), "texture data");
382 }
383
384 #[tokio::test]
385 async fn test_materialize_cleans_existing_staging() {
386 let tmp = TempDir::new().unwrap();
387 let staging_dir = tmp.path().join("staging");
388
389 std::fs::create_dir_all(&staging_dir).unwrap();
391 std::fs::write(staging_dir.join("old_file.txt"), "stale").unwrap();
392
393 let source_file = tmp.path().join("new_source.txt");
394 std::fs::write(&source_file, "fresh").unwrap();
395
396 let mut links = HashMap::new();
397 links.insert("new_file.txt".to_string(), source_file.clone());
398
399 let farm = test_farm(staging_dir.clone(), links);
400 farm.materialize().await.unwrap();
401
402 assert!(!staging_dir.join("old_file.txt").exists());
404 assert!(staging_dir.join("new_file.txt").symlink_metadata().unwrap().file_type().is_symlink());
406 }
407
408 #[tokio::test]
413 async fn test_deploy_creates_target_dir() {
414 let tmp = TempDir::new().unwrap();
415 let staging_dir = tmp.path().join("staging");
416 let target_dir = tmp.path().join("game/mods");
417
418 let source_file = tmp.path().join("source.esp");
419 std::fs::write(&source_file, "plugin data").unwrap();
420
421 let mut links = HashMap::new();
422 links.insert("mod.esp".to_string(), source_file.clone());
423
424 let farm = test_farm(staging_dir.clone(), links);
425 let farm = farm.materialize().await.unwrap();
426
427 assert!(!target_dir.exists());
428 farm.deploy_to(&target_dir).await.unwrap();
429 assert!(target_dir.exists());
430 assert!(target_dir.is_dir());
431 }
432
433 #[tokio::test]
434 async fn test_deploy_creates_symlinks_in_target() {
435 let tmp = TempDir::new().unwrap();
436 let staging_dir = tmp.path().join("staging");
437 let target_dir = tmp.path().join("game/mods");
438
439 let source_file = tmp.path().join("source.esp");
440 std::fs::write(&source_file, "plugin").unwrap();
441
442 let mut links = HashMap::new();
443 links.insert("plugin.esp".to_string(), source_file.clone());
444
445 let farm = test_farm(staging_dir.clone(), links);
446 let farm = farm.materialize().await.unwrap();
447
448 farm.deploy_to(&target_dir).await.unwrap();
449
450 let deployed = target_dir.join("plugin.esp");
451 assert!(deployed.symlink_metadata().unwrap().file_type().is_symlink());
452 let link_target = std::fs::read_link(&deployed).unwrap();
453 assert_eq!(link_target, staging_dir.join("plugin.esp"));
454 }
455
456 #[tokio::test]
457 async fn test_deploy_overwrites_existing() {
458 let tmp = TempDir::new().unwrap();
459 let staging_dir = tmp.path().join("staging");
460 let target_dir = tmp.path().join("game/mods");
461
462 std::fs::create_dir_all(&target_dir).unwrap();
463 std::fs::write(target_dir.join("replaceme.txt"), "old content").unwrap();
464
465 let source_file = tmp.path().join("new_source.txt");
466 std::fs::write(&source_file, "new content").unwrap();
467
468 let mut links = HashMap::new();
469 links.insert("replaceme.txt".to_string(), source_file.clone());
470
471 let farm = test_farm(staging_dir.clone(), links);
472 let farm = farm.materialize().await.unwrap();
473
474 farm.deploy_to(&target_dir).await.unwrap();
475
476 let deployed = target_dir.join("replaceme.txt");
477 assert!(deployed.symlink_metadata().unwrap().file_type().is_symlink());
478 assert_eq!(std::fs::read_to_string(&deployed).unwrap(), "new content");
479 }
480
481 #[tokio::test]
482 async fn test_deploy_nested_structure() {
483 let tmp = TempDir::new().unwrap();
484 let staging_dir = tmp.path().join("staging");
485 let target_dir = tmp.path().join("game/mods");
486
487 let src1 = tmp.path().join("src1.dds");
488 let src2 = tmp.path().join("src2.nif");
489 std::fs::write(&src1, "texture").unwrap();
490 std::fs::write(&src2, "mesh").unwrap();
491
492 let mut links = HashMap::new();
493 links.insert("textures/landscape/dirt.dds".to_string(), src1.clone());
494 links.insert("meshes/architecture/wall.nif".to_string(), src2.clone());
495
496 let farm = test_farm(staging_dir.clone(), links);
497 let farm = farm.materialize().await.unwrap();
498
499 farm.deploy_to(&target_dir).await.unwrap();
500
501 assert!(target_dir.join("textures/landscape/dirt.dds").symlink_metadata().unwrap().file_type().is_symlink());
502 assert!(target_dir.join("meshes/architecture/wall.nif").symlink_metadata().unwrap().file_type().is_symlink());
503 }
504
505 #[tokio::test]
510 async fn test_rollback_no_backup() {
511 let tmp = TempDir::new().unwrap();
512 unsafe { std::env::set_var("XDG_DATA_HOME", tmp.path()); }
514
515 let profile_dir = tmp.path().join("modde/profiles/rollback_test_no_bak");
516 std::fs::create_dir_all(profile_dir.join("staging")).unwrap();
517
518 let result = rollback("rollback_test_no_bak").await;
519 assert!(result.is_err());
520 let err_msg = format!("{}", result.unwrap_err());
521 assert!(
522 err_msg.contains("no backup staging found"),
523 "unexpected error: {err_msg}"
524 );
525 }
526
527 #[tokio::test]
528 #[ignore = "env var race: XDG_DATA_HOME set_var is not thread-safe across parallel tests"]
529 async fn test_rollback_swaps_dirs() {
530 let tmp = TempDir::new().unwrap();
531 unsafe { std::env::set_var("XDG_DATA_HOME", tmp.path()); }
532
533 let profile_dir = tmp.path().join("modde/profiles/rollback_test_swap");
534 let staging = profile_dir.join("staging");
535 let backup = profile_dir.join("staging.bak");
536
537 std::fs::create_dir_all(&staging).unwrap();
538 std::fs::create_dir_all(&backup).unwrap();
539
540 std::fs::write(staging.join("current.txt"), "I am current").unwrap();
542 std::fs::write(backup.join("backup.txt"), "I am backup").unwrap();
543
544 rollback("rollback_test_swap").await.unwrap();
545
546 assert!(staging.join("backup.txt").exists());
548 assert_eq!(
549 std::fs::read_to_string(staging.join("backup.txt")).unwrap(),
550 "I am backup"
551 );
552 assert!(!staging.join("current.txt").exists());
554 assert!(!backup.exists());
556 }
557}