Skip to main content

loadsmith_loader/
context.rs

1use std::{borrow::Cow, fmt::Debug, fs, path::Path};
2
3use camino::{Utf8Path, Utf8PathBuf};
4use globset::GlobSet;
5use loadsmith_core::{Checksum, ChecksumAlgorithm};
6use tracing::trace;
7use walkdir::WalkDir;
8
9use crate::Result;
10
11/// Information about the game and profile directories used when launching a
12/// loader.
13///
14/// A `LaunchContext` holds the paths to the profile directory (where mod files
15/// are staged) and the game directory (where the game executable lives),
16/// together with a flag indicating whether the game runs under Proton.
17///
18/// # Examples
19///
20/// ```rust
21/// use loadsmith_loader::LaunchContext;
22/// use camino::Utf8Path;
23///
24/// let ctx = LaunchContext::new(
25///     Utf8Path::new("/tmp/profile"),
26///     Utf8Path::new("/tmp/game"),
27///     false,
28/// );
29///
30/// assert_eq!(ctx.profile_path(), Utf8Path::new("/tmp/profile"));
31/// assert_eq!(ctx.game_path(), Utf8Path::new("/tmp/game"));
32/// assert!(!ctx.is_proton());
33/// ```
34#[derive(Debug, Clone)]
35pub struct LaunchContext<'a> {
36    profile_path: Cow<'a, Utf8Path>,
37    game_path: Cow<'a, Utf8Path>,
38    is_proton: bool,
39}
40
41impl<'a> LaunchContext<'a> {
42    /// Creates a new launch context.
43    ///
44    /// * `profile_path` - directory where mod and loader files are staged.
45    /// * `game_path` - directory containing the game executable.
46    /// * `is_proton` - whether the game runs via Proton (used for path
47    ///   translation).
48    pub fn new(
49        profile_path: impl Into<Cow<'a, Utf8Path>>,
50        game_path: impl Into<Cow<'a, Utf8Path>>,
51        is_proton: bool,
52    ) -> Self {
53        Self {
54            profile_path: profile_path.into(),
55            game_path: game_path.into(),
56            is_proton,
57        }
58    }
59
60    /// Returns the profile directory path.
61    pub fn profile_path(&self) -> &Utf8Path {
62        &self.profile_path
63    }
64
65    /// Returns the game directory path.
66    pub fn game_path(&self) -> &Utf8Path {
67        &self.game_path
68    }
69
70    /// Returns `true` when the game runs under Proton.
71    pub fn is_proton(&self) -> bool {
72        self.is_proton
73    }
74
75    #[cfg(test)]
76    pub(crate) fn in_tempdir(tempdir: &tempfile::TempDir, is_proton: bool) -> Result<Self> {
77        use std::fs;
78
79        let path = Utf8Path::from_path(tempdir.path()).expect("temp path should be UTF-8");
80
81        let profile = path.join("profile");
82        let game = path.join("game");
83
84        fs::create_dir_all(&profile)?;
85        fs::create_dir_all(&game)?;
86
87        Ok(Self::new(profile, game, is_proton))
88    }
89
90    /// Formats a path for use with Proton, prepending `Z:` when
91    /// [`is_proton`](LaunchContext::is_proton) is `true`.
92    pub fn format_proton_path(&self, path: impl Into<Utf8PathBuf>) -> Utf8PathBuf {
93        if self.is_proton {
94            let path = format!("Z:{}", path.into());
95            Utf8PathBuf::from(path)
96        } else {
97            path.into()
98        }
99    }
100
101    /// Copies a single file from the profile to the game directory.
102    ///
103    /// Skips the copy when the destination already exists and has the same
104    /// content (determined by hash).
105    pub fn copy_file_to_game(
106        &self,
107        relative_source: impl AsRef<Path>,
108        relative_target: impl AsRef<Path>,
109    ) -> Result<()> {
110        let src = relative_source.as_ref();
111        let target = relative_target.as_ref();
112
113        let profile_path = self.profile_path.as_std_path().join(src);
114        let game_path = self.game_path.as_std_path().join(target);
115
116        if game_path.is_file() {
117            if Checksum::compute_from_path(&profile_path, ChecksumAlgorithm::Blake3)?
118                == Checksum::compute_from_path(&game_path, ChecksumAlgorithm::Blake3)?
119            {
120                trace!(
121                    src = %src.display(),
122                    target = %target.display(),
123                    "skipping copy, file already exists and is identical"
124                );
125
126                return Ok(());
127            }
128        }
129
130        trace!(
131            src = %src.display(),
132            target = %target.display(),
133            "copy file to game directory"
134        );
135
136        loadsmith_util::create_parent_dirs(&game_path)?;
137
138        fs::copy(profile_path, game_path)?;
139
140        Ok(())
141    }
142
143    /// Copies all files in the profile matching a glob set to the game
144    /// directory, preserving relative paths.
145    pub fn copy_glob_to_game(&self, patterns: &GlobSet) -> Result<()> {
146        self.copy_files_to_game(|path| patterns.is_match(path))
147    }
148
149    /// Copies all files in the profile for which `filter` returns `true` to
150    /// the game directory, preserving relative paths.
151    pub fn copy_files_to_game<F>(&self, filter: F) -> Result<()>
152    where
153        F: Fn(&Path) -> bool,
154    {
155        WalkDir::new(&*self.profile_path)
156            .into_iter()
157            .filter_map(|entry| entry.ok())
158            .filter(|entry| entry.file_type().is_file())
159            .map(|entry| {
160                entry
161                    .into_path()
162                    .strip_prefix(&*self.profile_path)
163                    .expect("profile path should be a prefix of the file path")
164                    .to_path_buf()
165            })
166            .filter(|relative_path| filter(relative_path))
167            .try_for_each(|relative_path| self.copy_file_to_game(&relative_path, &relative_path))
168    }
169}