Skip to main content

modde_core/vfs/
mod.rs

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
12// ── Typestate markers ────────────────────────────────────────────
13
14/// Typestate: farm has been built but not yet written to disk.
15pub struct Built;
16/// Typestate: farm has been materialized on disk and is ready for deployment.
17pub struct Materialized;
18
19/// A symlink farm mapping relative file paths to their source locations.
20///
21/// Uses a typestate pattern (`Built` → `Materialized`) to enforce at
22/// compile time that `materialize()` must be called before `deploy_to()`.
23/// The `PhantomData<S>` marker is zero-sized and erased at runtime.
24#[derive(Debug, Clone)]
25pub struct SymlinkFarm<S = Materialized> {
26    /// The staging directory for this profile.
27    pub staging_dir: PathBuf,
28    /// Map of relative path -> absolute source path (winner after priority resolution).
29    pub links: HashMap<String, PathBuf>,
30    _state: PhantomData<S>,
31}
32
33impl SymlinkFarm<Built> {
34    /// Construct a `Built` farm directly from a staging directory and link map.
35    #[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    /// Build a symlink farm from a resolved load order and the content-addressed store.
45    ///
46    /// `mod_files` maps each mod ID to its list of `(relative_path, store_entry_path)` pairs.
47    /// `overrides` (if provided) are layered on top — override files win over all mods.
48    /// `hidden` is a set of `(mod_id, rel_path)` pairs to exclude from the farm.
49    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        // Process mods in load order; later mods override earlier for the same path
61        for mod_id in &resolved.order {
62            if let Some(files) = mod_files.get(mod_id) {
63                for (rel_path, source) in files {
64                    // Skip hidden files
65                    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        // Profile-level overrides win over all mods
76        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    /// Materialize the symlink farm on disk, transitioning to `Materialized` state.
90    pub async fn materialize(self) -> Result<SymlinkFarm<Materialized>> {
91        // Clean existing staging dir
92        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    /// Deploy the materialized staging directory into the game's mod directory.
121    ///
122    /// Creates symlinks in `target` pointing to the corresponding files in `staging_dir`.
123    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
148/// Atomically swap the active staging directory pointer for a profile.
149pub 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    /// Helper: construct a `Built` farm directly for testing.
188    fn test_farm(staging_dir: PathBuf, links: HashMap<String, PathBuf>) -> SymlinkFarm<Built> {
189        SymlinkFarm::from_links(staging_dir, links)
190    }
191
192    // ========================================================================
193    // Unit tests for build()
194    // ========================================================================
195
196    #[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        // Later mod in load order should override earlier mod for the same relative path.
223        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        // mod_b is later, so it wins
240        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        // A mod in resolved.order that has no entry in mod_files should be silently skipped.
268        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        // Hide mod_b's sky.dds — mod_a's version should win
325        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        // mod_a's sky.dds should win since mod_b's is hidden
332        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    // ========================================================================
337    // Integration tests for materialize()
338    // ========================================================================
339
340    #[tokio::test]
341    async fn test_materialize_creates_symlinks() {
342        let tmp = TempDir::new().unwrap();
343
344        // Create a real source file so the symlink target exists
345        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        // Directory should be empty
377        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        // Create a pre-existing staging dir with some leftover file
414        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        // Old file should be gone
427        assert!(!staging_dir.join("old_file.txt").exists());
428        // New symlink should exist
429        assert!(
430            staging_dir
431                .join("new_file.txt")
432                .symlink_metadata()
433                .unwrap()
434                .file_type()
435                .is_symlink()
436        );
437    }
438
439    // ========================================================================
440    // Integration tests for deploy()
441    // ========================================================================
442
443    #[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    // ========================================================================
563    // Tests for rollback()
564    // ========================================================================
565
566    #[tokio::test]
567    async fn test_rollback_no_backup() {
568        let tmp = TempDir::new().unwrap();
569        // Point XDG_DATA_HOME to our temp dir so dirs_path() resolves there
570        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        // Write marker files so we can verify the swap
602        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        // After rollback, staging should contain the backup's content
608        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        // The old staging (with current.txt) should have been removed
614        assert!(!staging.join("current.txt").exists());
615        // staging.bak should no longer exist (it was renamed to staging)
616        assert!(!backup.exists());
617    }
618}