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    pub fn from_links(staging_dir: PathBuf, links: HashMap<String, PathBuf>) -> Self {
36        Self { staging_dir, links, _state: PhantomData }
37    }
38
39    /// Build a symlink farm from a resolved load order and the content-addressed store.
40    ///
41    /// `mod_files` maps each mod ID to its list of `(relative_path, store_entry_path)` pairs.
42    /// `overrides` (if provided) are layered on top — override files win over all mods.
43    /// `hidden` is a set of `(mod_id, rel_path)` pairs to exclude from the farm.
44    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        // Process mods in load order; later mods override earlier for the same path
56        for mod_id in &resolved.order {
57            if let Some(files) = mod_files.get(mod_id) {
58                for (rel_path, source) in files {
59                    // Skip hidden files
60                    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        // Profile-level overrides win over all mods
71        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    /// Materialize the symlink farm on disk, transitioning to `Materialized` state.
81    pub async fn materialize(self) -> Result<SymlinkFarm<Materialized>> {
82        // Clean existing staging dir
83        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    /// Deploy the materialized staging directory into the game's mod directory.
112    ///
113    /// Creates symlinks in `target` pointing to the corresponding files in `staging_dir`.
114    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
139/// Atomically swap the active staging directory pointer for a profile.
140pub 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    /// Helper: construct a `Built` farm directly for testing.
180    fn test_farm(staging_dir: PathBuf, links: HashMap<String, PathBuf>) -> SymlinkFarm<Built> {
181        SymlinkFarm::from_links(staging_dir, links)
182    }
183
184    // ========================================================================
185    // Unit tests for build()
186    // ========================================================================
187
188    #[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        // Later mod in load order should override earlier mod for the same relative path.
215        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        // mod_b is later, so it wins
232        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        // A mod in resolved.order that has no entry in mod_files should be silently skipped.
260        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        // Hide mod_b's sky.dds — mod_a's version should win
314        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        // mod_a's sky.dds should win since mod_b's is hidden
320        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    // ========================================================================
325    // Integration tests for materialize()
326    // ========================================================================
327
328    #[tokio::test]
329    async fn test_materialize_creates_symlinks() {
330        let tmp = TempDir::new().unwrap();
331
332        // Create a real source file so the symlink target exists
333        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        // Directory should be empty
359        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        // Create a pre-existing staging dir with some leftover file
390        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        // Old file should be gone
403        assert!(!staging_dir.join("old_file.txt").exists());
404        // New symlink should exist
405        assert!(staging_dir.join("new_file.txt").symlink_metadata().unwrap().file_type().is_symlink());
406    }
407
408    // ========================================================================
409    // Integration tests for deploy()
410    // ========================================================================
411
412    #[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    // ========================================================================
506    // Tests for rollback()
507    // ========================================================================
508
509    #[tokio::test]
510    async fn test_rollback_no_backup() {
511        let tmp = TempDir::new().unwrap();
512        // Point XDG_DATA_HOME to our temp dir so dirs_path() resolves there
513        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        // Write marker files so we can verify the swap
541        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        // After rollback, staging should contain the backup's content
547        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        // The old staging (with current.txt) should have been removed
553        assert!(!staging.join("current.txt").exists());
554        // staging.bak should no longer exist (it was renamed to staging)
555        assert!(!backup.exists());
556    }
557}