valheim_mod_manager/
target.rs1use crate::error::{AppError, AppResult};
2use std::path::PathBuf;
3use thunderstore_engine::profile::layout::BadProfile;
4
5pub const GAME: &str = "valheim";
7
8pub type Target = thunderstore_engine::profile::target::InstallTarget;
11
12pub fn describe(target: &Target) -> String {
19 match &target.profile {
20 Some(name) => format!("profile {name:?}"),
21 None => "the game directory".to_string(),
22 }
23}
24
25pub fn resolve(base: PathBuf, game_dir: PathBuf, profile: Option<&str>) -> AppResult<Target> {
31 Target::resolve(base, game_dir, GAME, profile).map_err(profile_error)
32}
33
34pub fn profile_error(error: thunderstore_engine::Error) -> AppError {
45 match error {
46 thunderstore_engine::Error::BadProfile(BadProfile::Missing { name, .. }) => {
47 missing_profile_error(&name)
48 }
49 other => other.into(),
50 }
51}
52
53fn is_bare_shell_word(name: &str) -> bool {
68 !name.is_empty()
69 && !name.starts_with('-')
70 && name
71 .chars()
72 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
73}
74
75pub fn missing_profile_error(name: &str) -> AppError {
85 let create = match is_bare_shell_word(name) {
86 true => format!("vmm profile create {name}"),
87 false => "vmm profile create <name>".to_string(),
88 };
89
90 AppError::advice(
91 format!("there is no profile named {name:?}."),
92 "Nothing was changed. Create it first, or list the profiles you have.",
93 &[create.as_str(), "vmm profile list"],
94 )
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100 use tempfile::tempdir;
101
102 #[test]
103 fn describe_names_the_profile_or_the_game_directory() {
104 let base = tempdir().unwrap();
105 let game_dir = tempdir().unwrap();
106
107 let game = Target::resolve(
108 base.path().to_path_buf(),
109 game_dir.path().to_path_buf(),
110 GAME,
111 None,
112 )
113 .unwrap();
114
115 assert_eq!(describe(&game), "the game directory");
116
117 thunderstore_engine::profile::layout::create(base.path(), GAME, "experiment").unwrap();
118
119 let profile = Target::resolve(
120 base.path().to_path_buf(),
121 game_dir.path().to_path_buf(),
122 GAME,
123 Some("experiment"),
124 )
125 .unwrap();
126
127 assert_eq!(describe(&profile), "profile \"experiment\"");
128 }
129
130 #[test]
131 fn resolving_an_unsafe_profile_name_still_refuses() {
132 let base = tempdir().unwrap();
133 let game_dir = tempdir().unwrap();
134
135 let error = resolve(
138 base.path().to_path_buf(),
139 game_dir.path().to_path_buf(),
140 Some("../escape"),
141 )
142 .unwrap_err();
143
144 assert_eq!(
145 error.to_string(),
146 "Profile error: invalid profile name \"../escape\""
147 );
148 }
149
150 #[test]
151 fn resolve_returns_a_target_for_a_profile_that_exists() {
152 let base = tempdir().unwrap();
153 let game_dir = tempdir().unwrap();
154
155 thunderstore_engine::profile::layout::create(base.path(), GAME, "experiment").unwrap();
156
157 let target = resolve(
158 base.path().to_path_buf(),
159 game_dir.path().to_path_buf(),
160 Some("experiment"),
161 )
162 .unwrap();
163
164 assert_eq!(target.profile.as_deref(), Some("experiment"));
165 assert_eq!(
166 target.dir,
167 thunderstore_engine::profile::layout::profile_dir(base.path(), GAME, "experiment")
168 );
169 }
170
171 #[test]
172 fn an_ordinary_profile_name_is_offered_as_a_command_to_copy() {
173 let message = missing_profile_error("experiment").to_string();
174
175 assert!(
176 message.contains("vmm profile create experiment"),
177 "a name that needs no quoting must stay concrete; got: {message}"
178 );
179 assert!(message.contains("vmm profile list"), "got: {message}");
180 }
181
182 #[test]
183 fn a_name_needing_quoting_is_never_offered_as_a_command_to_copy() {
184 for name in [
188 "work profile",
189 "main; rm -rf ~",
190 "$(whoami)",
191 "a`id`b",
192 "-all",
193 ] {
194 let message = missing_profile_error(name).to_string();
195
196 assert!(
197 message.contains("vmm profile create <name>"),
198 "{name:?} must degrade to the placeholder; got: {message}"
199 );
200 assert!(
201 !message.contains(&format!("vmm profile create {name}")),
202 "{name:?} must not appear in a copyable command; got: {message}"
203 );
204 assert!(
207 message.contains("there is no profile named"),
208 "got: {message}"
209 );
210 assert!(message.contains("vmm profile list"), "got: {message}");
211 }
212 }
213}