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