1#![deny(missing_docs)]
22
23use crate::error::{Error, Result};
24use std::fs;
25use std::path::{Path, PathBuf};
26
27pub fn profiles_dir(base: &Path, game: &str) -> PathBuf {
29 base.join(game).join("profiles")
30}
31
32pub fn profile_dir(base: &Path, game: &str, name: &str) -> PathBuf {
34 profiles_dir(base, game).join(name)
35}
36
37pub fn validate_name(name: &str) -> Result<()> {
41 let bad = name.is_empty() || name == "." || name == ".." || name.contains(['/', '\\', '\0']);
42
43 if bad {
44 return Err(Error::Profile(format!("invalid profile name {name:?}")));
45 }
46
47 Ok(())
48}
49
50pub fn list(base: &Path, game: &str) -> Result<Vec<String>> {
53 let dir = profiles_dir(base, game);
54
55 if !dir.is_dir() {
56 return Ok(Vec::new());
57 }
58
59 let mut names = Vec::new();
60
61 for entry in
62 fs::read_dir(&dir).map_err(|e| Error::Profile(format!("reading {}: {}", dir.display(), e)))?
63 {
64 let entry = entry.map_err(|e| Error::Profile(format!("reading an entry: {e}")))?;
65
66 if entry.path().is_dir()
67 && let Some(name) = entry.file_name().to_str()
68 {
69 names.push(name.to_string());
70 }
71 }
72
73 names.sort();
74
75 Ok(names)
76}
77
78pub fn create(base: &Path, game: &str, name: &str) -> Result<PathBuf> {
81 validate_name(name)?;
82
83 let dir = profile_dir(base, game, name);
84
85 if dir.exists() {
86 return Err(Error::Profile(format!("profile {name:?} already exists")));
87 }
88
89 fs::create_dir_all(&dir)
90 .map_err(|e| Error::Profile(format!("creating {}: {}", dir.display(), e)))?;
91
92 Ok(dir)
93}
94
95pub fn delete(base: &Path, game: &str, name: &str) -> Result<()> {
103 validate_name(name)?;
104
105 let dir = profile_dir(base, game, name);
106
107 fs::remove_dir_all(&dir)
108 .map_err(|e| Error::Profile(format!("deleting {}: {}", dir.display(), e)))?;
109
110 if read_selection(base, game).as_deref() == Some(name) {
111 clear_selection(base, game)?;
112 }
113
114 Ok(())
115}
116
117pub fn rename(base: &Path, game: &str, from: &str, to: &str) -> Result<()> {
127 validate_name(from)?;
128 validate_name(to)?;
129
130 let src = profile_dir(base, game, from);
131 let dst = profile_dir(base, game, to);
132
133 if dst.exists() {
134 return Err(Error::Profile(format!("profile {to:?} already exists")));
135 }
136
137 fs::rename(&src, &dst).map_err(|e| {
138 Error::Profile(format!(
139 "renaming {} to {}: {}",
140 src.display(),
141 dst.display(),
142 e
143 ))
144 })?;
145
146 if read_selection(base, game).as_deref() == Some(from) {
150 write_selection(base, game, to)?;
151 }
152
153 Ok(())
154}
155
156pub fn duplicate(base: &Path, game: &str, from: &str, to: &str) -> Result<PathBuf> {
166 validate_name(from)?;
167 validate_name(to)?;
168
169 let src = profile_dir(base, game, from);
170 let dst = profile_dir(base, game, to);
171
172 if dst.exists() {
173 return Err(Error::Profile(format!("profile {to:?} already exists")));
174 }
175
176 copy_tree(&src, &dst)?;
177
178 Ok(dst)
179}
180
181fn copy_tree(src: &Path, dst: &Path) -> Result<()> {
184 fs::create_dir_all(dst)
185 .map_err(|e| Error::Profile(format!("creating {}: {}", dst.display(), e)))?;
186
187 for entry in
188 fs::read_dir(src).map_err(|e| Error::Profile(format!("reading {}: {}", src.display(), e)))?
189 {
190 let entry = entry.map_err(|e| Error::Profile(format!("reading an entry: {e}")))?;
191 let metadata = fs::symlink_metadata(entry.path())
192 .map_err(|e| Error::Profile(format!("inspecting {}: {}", entry.path().display(), e)))?;
193
194 if metadata.file_type().is_symlink() {
195 continue;
196 }
197
198 let target = dst.join(entry.file_name());
199
200 if metadata.is_dir() {
201 copy_tree(&entry.path(), &target)?;
202 } else {
203 if crate::util::same_file(&entry.path(), &target) {
212 continue;
213 }
214
215 fs::copy(entry.path(), &target)
216 .map_err(|e| Error::Profile(format!("copying to {}: {}", target.display(), e)))?;
217 }
218 }
219
220 Ok(())
221}
222
223const SELECTION_FILE: &str = "selected_profile";
225
226pub fn selection_path(base: &Path, game: &str) -> PathBuf {
231 base.join(game).join(SELECTION_FILE)
232}
233
234pub fn read_selection(base: &Path, game: &str) -> Option<String> {
236 let text = fs::read_to_string(selection_path(base, game)).ok()?;
237 let name = text.trim();
238
239 match name.is_empty() {
240 true => None,
241 false => Some(name.to_string()),
242 }
243}
244
245pub fn write_selection(base: &Path, game: &str, name: &str) -> Result<()> {
247 let path = selection_path(base, game);
248
249 if let Some(parent) = path.parent() {
250 fs::create_dir_all(parent)
251 .map_err(|e| Error::Profile(format!("creating {}: {}", parent.display(), e)))?;
252 }
253
254 fs::write(&path, name).map_err(|e| Error::Profile(format!("writing {}: {}", path.display(), e)))
255}
256
257pub fn clear_selection(base: &Path, game: &str) -> Result<()> {
260 let path = selection_path(base, game);
261
262 if !path.exists() {
263 return Ok(());
264 }
265
266 fs::remove_file(&path).map_err(|e| Error::Profile(format!("removing {}: {}", path.display(), e)))
267}
268
269#[derive(Debug)]
284pub enum BadProfile {
285 InvalidName {
288 name: String,
290 },
291 Missing {
293 name: String,
295 dir: PathBuf,
297 },
298}
299
300impl std::fmt::Display for BadProfile {
301 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302 match self {
303 Self::InvalidName { name } => write!(f, "invalid profile name {name:?}"),
304 Self::Missing { name, .. } => write!(f, "there is no profile named {name:?}"),
305 }
306 }
307}
308
309impl std::error::Error for BadProfile {}
310
311pub fn require_profile(
318 base: &Path,
319 game: &str,
320 name: &str,
321) -> std::result::Result<PathBuf, BadProfile> {
322 validate_name(name).map_err(|_| BadProfile::InvalidName {
323 name: name.to_string(),
324 })?;
325
326 let dir = profile_dir(base, game, name);
327
328 if !dir.is_dir() {
329 return Err(BadProfile::Missing {
330 name: name.to_string(),
331 dir,
332 });
333 }
334
335 Ok(dir)
336}
337
338pub fn use_profile(base: &Path, game: &str, name: &str) -> Result<()> {
340 require_profile(base, game, name)?;
341
342 write_selection(base, game, name)
343}
344
345pub fn select_profile(
352 flag: Option<&str>,
353 no_profile: bool,
354 persisted: Option<&str>,
355) -> Option<String> {
356 if let Some(name) = flag {
357 return Some(name.to_string());
358 }
359
360 if no_profile {
361 return None;
362 }
363
364 persisted.map(|name| name.to_string())
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370 use std::fs;
371 use tempfile::tempdir;
372
373 #[test]
374 fn paths_follow_r2modman_layout() {
375 let base = Path::new("/base");
376
377 assert_eq!(
378 profiles_dir(base, "valheim"),
379 Path::new("/base/valheim/profiles")
380 );
381 assert_eq!(
382 profile_dir(base, "valheim", "Main"),
383 Path::new("/base/valheim/profiles/Main")
384 );
385 }
386
387 #[test]
388 fn validate_name_rejects_unsafe() {
389 assert!(validate_name("Main").is_ok());
390 assert!(validate_name("").is_err());
391 assert!(validate_name("..").is_err());
392 assert!(validate_name("a/b").is_err());
393 assert!(validate_name("a\\b").is_err());
394 }
395
396 #[test]
397 fn create_list_rename_duplicate_delete() {
398 let dir = tempdir().unwrap();
399 let base = dir.path();
400
401 assert_eq!(list(base, "valheim").unwrap(), Vec::<String>::new());
402
403 create(base, "valheim", "Main").unwrap();
404
405 assert!(profile_dir(base, "valheim", "Main").is_dir());
406 assert!(create(base, "valheim", "Main").is_err()); fs::write(profile_dir(base, "valheim", "Main").join("mods.yml"), "[]").unwrap();
409
410 duplicate(base, "valheim", "Main", "Copy").unwrap();
411
412 assert!(
413 profile_dir(base, "valheim", "Copy")
414 .join("mods.yml")
415 .exists()
416 );
417
418 rename(base, "valheim", "Copy", "Renamed").unwrap();
419
420 let mut names = list(base, "valheim").unwrap();
421 names.sort();
422
423 assert_eq!(names, vec!["Main".to_string(), "Renamed".to_string()]);
424
425 delete(base, "valheim", "Main").unwrap();
426
427 assert_eq!(list(base, "valheim").unwrap(), vec!["Renamed".to_string()]);
428 }
429
430 #[test]
431 fn the_selection_follows_a_rename_and_clears_on_delete() {
432 let base = tempdir().unwrap();
433 let root = base.path();
434
435 create(root, "valheim", "one").unwrap();
436 create(root, "valheim", "two").unwrap();
437
438 assert_eq!(read_selection(root, "valheim"), None);
439
440 use_profile(root, "valheim", "one").unwrap();
441
442 assert_eq!(read_selection(root, "valheim"), Some("one".to_string()));
443
444 rename(root, "valheim", "one", "renamed").unwrap();
447
448 assert_eq!(read_selection(root, "valheim"), Some("renamed".to_string()));
449
450 rename(root, "valheim", "two", "other").unwrap();
452
453 assert_eq!(read_selection(root, "valheim"), Some("renamed".to_string()));
454
455 delete(root, "valheim", "other").unwrap();
457
458 assert_eq!(read_selection(root, "valheim"), Some("renamed".to_string()));
459
460 delete(root, "valheim", "renamed").unwrap();
462
463 assert_eq!(read_selection(root, "valheim"), None);
464
465 assert!(use_profile(root, "valheim", "ghost").is_err());
467 clear_selection(root, "valheim").unwrap();
469 }
470
471 #[test]
472 fn require_profile_classifies_why_a_name_is_unusable() {
473 let base = tempdir().unwrap();
474 let root = base.path();
475
476 create(root, "valheim", "one").unwrap();
477
478 assert_eq!(
479 require_profile(root, "valheim", "one").unwrap(),
480 profile_dir(root, "valheim", "one")
481 );
482
483 let unsafe_name = require_profile(root, "valheim", "../escape").unwrap_err();
484
485 assert!(
486 matches!(&unsafe_name, BadProfile::InvalidName { name } if name == "../escape"),
487 "an unsafe name must be told apart from an absent one; got: {unsafe_name:?}"
488 );
489
490 let missing = require_profile(root, "valheim", "ghost").unwrap_err();
491
492 assert!(
495 matches!(&missing, BadProfile::Missing { name, dir }
496 if name == "ghost" && dir == &profile_dir(root, "valheim", "ghost")),
497 "a missing profile must carry its name and expected directory; got: {missing:?}"
498 );
499 }
500
501 #[test]
502 fn use_profile_and_resolve_refuse_a_missing_profile_identically() {
503 let base = tempdir().unwrap();
504 let root = base.path();
505 let game_dir = tempdir().unwrap();
506
507 let selecting = use_profile(root, "valheim", "ghost").unwrap_err();
510 let resolving = crate::profile::target::InstallTarget::resolve(
511 root.to_path_buf(),
512 game_dir.path().to_path_buf(),
513 "valheim",
514 Some("ghost"),
515 )
516 .unwrap_err();
517
518 assert_eq!(selecting.to_string(), resolving.to_string());
519 assert_eq!(
520 selecting.to_string(),
521 "Profile error: there is no profile named \"ghost\""
522 );
523
524 assert!(
527 matches!(selecting, Error::BadProfile(BadProfile::Missing { .. }))
528 && matches!(resolving, Error::BadProfile(BadProfile::Missing { .. })),
529 "both must carry the reason, not a flattened message"
530 );
531
532 let selecting = use_profile(root, "valheim", "a/b").unwrap_err();
533 let resolving = crate::profile::target::InstallTarget::resolve(
534 root.to_path_buf(),
535 game_dir.path().to_path_buf(),
536 "valheim",
537 Some("a/b"),
538 )
539 .unwrap_err();
540
541 assert_eq!(selecting.to_string(), resolving.to_string());
542 assert_eq!(
543 selecting.to_string(),
544 "Profile error: invalid profile name \"a/b\""
545 );
546 }
547
548 #[test]
549 fn select_profile_precedence() {
550 assert_eq!(
552 select_profile(Some("experiment"), false, Some("saved")),
553 Some("experiment".to_string())
554 );
555 assert_eq!(select_profile(None, true, Some("saved")), None);
557 assert_eq!(
559 select_profile(None, false, Some("saved")),
560 Some("saved".to_string())
561 );
562 assert_eq!(select_profile(None, false, None), None);
564 }
565}