Skip to main content

thunderstore_engine/profile/
target.rs

1//! Where an install goes.
2//!
3//! Two modes share one type: a named r2modman profile under
4//! `<base>/<game>/profiles/<name>`, or the game directory itself. Every operation
5//! takes [`InstallTarget::dir`](crate::profile::target::InstallTarget::dir), so the modes differ by exactly one path.
6
7#![deny(missing_docs)]
8
9use crate::error::Result;
10use crate::profile::layout;
11use std::path::PathBuf;
12
13/// Where this invocation installs, tracks, and launches from.
14#[derive(Debug, Clone)]
15#[non_exhaustive]
16pub struct InstallTarget {
17  /// The install root. The record and its `_state` directory live here.
18  pub dir: PathBuf,
19  /// The base holding `<game>/{cache,exports,profiles}`.
20  pub base: PathBuf,
21  /// The game root. Known in both modes: profile mode still needs it for
22  /// pre-launch staging and executable resolution.
23  pub game_dir: PathBuf,
24  /// The ecosystem game key.
25  pub game: String,
26  /// `Some(name)` in profile mode, `None` in game-directory mode.
27  pub profile: Option<String>,
28}
29
30impl InstallTarget {
31  /// Resolves the install root, erroring rather than creating a profile
32  /// directory that does not exist yet.
33  ///
34  /// Classifies a profile name through [`layout::require_profile`], the same
35  /// gate [`layout::use_profile`] uses, so selecting a bad name and resolving
36  /// one cannot refuse differently. The reason reaches a caller intact as
37  /// [`Error::BadProfile`](crate::error::Error::BadProfile), so wording the
38  /// refusal does not mean re-checking the name.
39  pub fn resolve(
40    base: PathBuf,
41    game_dir: PathBuf,
42    game: &str,
43    profile: Option<&str>,
44  ) -> Result<Self> {
45    let dir = match profile {
46      Some(name) => layout::require_profile(&base, game, name)?,
47      None => game_dir.clone(),
48    };
49
50    Ok(Self {
51      dir,
52      base,
53      game_dir,
54      game: game.to_string(),
55      profile: profile.map(|name| name.to_string()),
56    })
57  }
58
59  /// This target's record, the authoritative install list.
60  pub fn mods_yml(&self) -> PathBuf {
61    self.dir.join("mods.yml")
62  }
63
64  /// Where `.r2z` exports are written.
65  pub fn exports_dir(&self) -> PathBuf {
66    self.base.join(&self.game).join("exports")
67  }
68
69  /// The name used wherever a profile name is required: the active profile, or
70  /// `default` in game-directory mode.
71  pub fn name(&self) -> &str {
72    self.profile.as_deref().unwrap_or("default")
73  }
74
75  /// Whether pre-launch staging has anywhere to copy to.
76  ///
77  /// False when the install root *is* the game directory, where every root file
78  /// already sits at its staging destination. Staging detects a same-file
79  /// destination itself, so this is a second line of defence and a way to skip a
80  /// pointless directory walk in the common case.
81  ///
82  /// Also load-bearing when the game directory does not exist yet:
83  /// [`crate::profile::launch::stage_for_launch_in`] does not detect that case
84  /// itself and returns the `read_dir` error from listing a missing directory
85  /// rather than an empty list, so a caller should check this first rather
86  /// than call staging unconditionally.
87  pub fn needs_staging(&self) -> bool {
88    let dir = self.dir.canonicalize().unwrap_or_else(|_| self.dir.clone());
89    let game = self
90      .game_dir
91      .canonicalize()
92      .unwrap_or_else(|_| self.game_dir.clone());
93
94    dir != game
95  }
96}
97
98#[cfg(test)]
99mod tests {
100  use super::*;
101  use tempfile::tempdir;
102
103  #[test]
104  fn game_dir_mode_targets_the_game_dir_and_never_stages() {
105    let base = tempdir().unwrap();
106    let game_dir = tempdir().unwrap();
107    let target = InstallTarget::resolve(
108      base.path().to_path_buf(),
109      game_dir.path().to_path_buf(),
110      "valheim",
111      None,
112    )
113    .unwrap();
114
115    assert_eq!(target.dir, game_dir.path());
116    assert_eq!(target.name(), "default");
117    assert_eq!(target.mods_yml(), game_dir.path().join("mods.yml"));
118    // Every root file is already at its staging destination.
119    assert!(!target.needs_staging());
120  }
121
122  #[test]
123  fn profile_mode_targets_the_profile_dir_and_stages() {
124    let base = tempdir().unwrap();
125    let game_dir = tempdir().unwrap();
126
127    crate::profile::layout::create(base.path(), "valheim", "experiment").unwrap();
128
129    let target = InstallTarget::resolve(
130      base.path().to_path_buf(),
131      game_dir.path().to_path_buf(),
132      "valheim",
133      Some("experiment"),
134    )
135    .unwrap();
136
137    assert_eq!(
138      target.dir,
139      base
140        .path()
141        .join("valheim")
142        .join("profiles")
143        .join("experiment")
144    );
145    assert_eq!(target.name(), "experiment");
146    assert!(target.needs_staging());
147    assert_eq!(
148      target.exports_dir(),
149      base.path().join("valheim").join("exports")
150    );
151  }
152
153  #[test]
154  fn resolve_rejects_a_missing_profile() {
155    let base = tempdir().unwrap();
156    let game_dir = tempdir().unwrap();
157
158    let err = InstallTarget::resolve(
159      base.path().to_path_buf(),
160      game_dir.path().to_path_buf(),
161      "valheim",
162      Some("nope"),
163    )
164    .unwrap_err();
165
166    assert!(
167      err.to_string().contains("there is no profile named"),
168      "expected missing profile error, got: {err}"
169    );
170  }
171
172  #[test]
173  fn resolve_rejects_an_unsafe_profile_name() {
174    let base = tempdir().unwrap();
175    let game_dir = tempdir().unwrap();
176
177    let err = InstallTarget::resolve(
178      base.path().to_path_buf(),
179      game_dir.path().to_path_buf(),
180      "valheim",
181      Some("../escape"),
182    )
183    .unwrap_err();
184
185    assert!(
186      err.to_string().contains("invalid profile name"),
187      "expected unsafe name error, got: {err}"
188    );
189  }
190}