#![deny(missing_docs)]
use crate::error::{Error, Result};
use std::fs;
use std::path::{Path, PathBuf};
pub fn profiles_dir(base: &Path, game: &str) -> PathBuf {
base.join(game).join("profiles")
}
pub fn profile_dir(base: &Path, game: &str, name: &str) -> PathBuf {
profiles_dir(base, game).join(name)
}
pub fn validate_name(name: &str) -> Result<()> {
let bad = name.is_empty() || name == "." || name == ".." || name.contains(['/', '\\', '\0']);
if bad {
return Err(Error::Profile(format!("invalid profile name {name:?}")));
}
Ok(())
}
pub fn list(base: &Path, game: &str) -> Result<Vec<String>> {
let dir = profiles_dir(base, game);
if !dir.is_dir() {
return Ok(Vec::new());
}
let mut names = Vec::new();
for entry in
fs::read_dir(&dir).map_err(|e| Error::Profile(format!("reading {}: {}", dir.display(), e)))?
{
let entry = entry.map_err(|e| Error::Profile(format!("reading an entry: {e}")))?;
if entry.path().is_dir()
&& let Some(name) = entry.file_name().to_str()
{
names.push(name.to_string());
}
}
names.sort();
Ok(names)
}
pub fn create(base: &Path, game: &str, name: &str) -> Result<PathBuf> {
validate_name(name)?;
let dir = profile_dir(base, game, name);
if dir.exists() {
return Err(Error::Profile(format!("profile {name:?} already exists")));
}
fs::create_dir_all(&dir)
.map_err(|e| Error::Profile(format!("creating {}: {}", dir.display(), e)))?;
Ok(dir)
}
pub fn delete(base: &Path, game: &str, name: &str) -> Result<()> {
validate_name(name)?;
let dir = profile_dir(base, game, name);
fs::remove_dir_all(&dir)
.map_err(|e| Error::Profile(format!("deleting {}: {}", dir.display(), e)))?;
if read_selection(base, game).as_deref() == Some(name) {
clear_selection(base, game)?;
}
Ok(())
}
pub fn rename(base: &Path, game: &str, from: &str, to: &str) -> Result<()> {
validate_name(from)?;
validate_name(to)?;
let src = profile_dir(base, game, from);
let dst = profile_dir(base, game, to);
if dst.exists() {
return Err(Error::Profile(format!("profile {to:?} already exists")));
}
fs::rename(&src, &dst).map_err(|e| {
Error::Profile(format!(
"renaming {} to {}: {}",
src.display(),
dst.display(),
e
))
})?;
if read_selection(base, game).as_deref() == Some(from) {
write_selection(base, game, to)?;
}
Ok(())
}
pub fn duplicate(base: &Path, game: &str, from: &str, to: &str) -> Result<PathBuf> {
validate_name(from)?;
validate_name(to)?;
let src = profile_dir(base, game, from);
let dst = profile_dir(base, game, to);
if dst.exists() {
return Err(Error::Profile(format!("profile {to:?} already exists")));
}
copy_tree(&src, &dst)?;
Ok(dst)
}
fn copy_tree(src: &Path, dst: &Path) -> Result<()> {
fs::create_dir_all(dst)
.map_err(|e| Error::Profile(format!("creating {}: {}", dst.display(), e)))?;
for entry in
fs::read_dir(src).map_err(|e| Error::Profile(format!("reading {}: {}", src.display(), e)))?
{
let entry = entry.map_err(|e| Error::Profile(format!("reading an entry: {e}")))?;
let metadata = fs::symlink_metadata(entry.path())
.map_err(|e| Error::Profile(format!("inspecting {}: {}", entry.path().display(), e)))?;
if metadata.file_type().is_symlink() {
continue;
}
let target = dst.join(entry.file_name());
if metadata.is_dir() {
copy_tree(&entry.path(), &target)?;
} else {
if crate::util::same_file(&entry.path(), &target) {
continue;
}
fs::copy(entry.path(), &target)
.map_err(|e| Error::Profile(format!("copying to {}: {}", target.display(), e)))?;
}
}
Ok(())
}
const SELECTION_FILE: &str = "selected_profile";
pub fn selection_path(base: &Path, game: &str) -> PathBuf {
base.join(game).join(SELECTION_FILE)
}
pub fn read_selection(base: &Path, game: &str) -> Option<String> {
let text = fs::read_to_string(selection_path(base, game)).ok()?;
let name = text.trim();
match name.is_empty() {
true => None,
false => Some(name.to_string()),
}
}
pub fn write_selection(base: &Path, game: &str, name: &str) -> Result<()> {
let path = selection_path(base, game);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| Error::Profile(format!("creating {}: {}", parent.display(), e)))?;
}
fs::write(&path, name).map_err(|e| Error::Profile(format!("writing {}: {}", path.display(), e)))
}
pub fn clear_selection(base: &Path, game: &str) -> Result<()> {
let path = selection_path(base, game);
if !path.exists() {
return Ok(());
}
fs::remove_file(&path).map_err(|e| Error::Profile(format!("removing {}: {}", path.display(), e)))
}
#[derive(Debug)]
pub enum BadProfile {
InvalidName {
name: String,
},
Missing {
name: String,
dir: PathBuf,
},
}
impl std::fmt::Display for BadProfile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidName { name } => write!(f, "invalid profile name {name:?}"),
Self::Missing { name, .. } => write!(f, "there is no profile named {name:?}"),
}
}
}
impl std::error::Error for BadProfile {}
pub fn require_profile(
base: &Path,
game: &str,
name: &str,
) -> std::result::Result<PathBuf, BadProfile> {
validate_name(name).map_err(|_| BadProfile::InvalidName {
name: name.to_string(),
})?;
let dir = profile_dir(base, game, name);
if !dir.is_dir() {
return Err(BadProfile::Missing {
name: name.to_string(),
dir,
});
}
Ok(dir)
}
pub fn use_profile(base: &Path, game: &str, name: &str) -> Result<()> {
require_profile(base, game, name)?;
write_selection(base, game, name)
}
pub fn select_profile(
flag: Option<&str>,
no_profile: bool,
persisted: Option<&str>,
) -> Option<String> {
if let Some(name) = flag {
return Some(name.to_string());
}
if no_profile {
return None;
}
persisted.map(|name| name.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn paths_follow_r2modman_layout() {
let base = Path::new("/base");
assert_eq!(
profiles_dir(base, "valheim"),
Path::new("/base/valheim/profiles")
);
assert_eq!(
profile_dir(base, "valheim", "Main"),
Path::new("/base/valheim/profiles/Main")
);
}
#[test]
fn validate_name_rejects_unsafe() {
assert!(validate_name("Main").is_ok());
assert!(validate_name("").is_err());
assert!(validate_name("..").is_err());
assert!(validate_name("a/b").is_err());
assert!(validate_name("a\\b").is_err());
}
#[test]
fn create_list_rename_duplicate_delete() {
let dir = tempdir().unwrap();
let base = dir.path();
assert_eq!(list(base, "valheim").unwrap(), Vec::<String>::new());
create(base, "valheim", "Main").unwrap();
assert!(profile_dir(base, "valheim", "Main").is_dir());
assert!(create(base, "valheim", "Main").is_err());
fs::write(profile_dir(base, "valheim", "Main").join("mods.yml"), "[]").unwrap();
duplicate(base, "valheim", "Main", "Copy").unwrap();
assert!(
profile_dir(base, "valheim", "Copy")
.join("mods.yml")
.exists()
);
rename(base, "valheim", "Copy", "Renamed").unwrap();
let mut names = list(base, "valheim").unwrap();
names.sort();
assert_eq!(names, vec!["Main".to_string(), "Renamed".to_string()]);
delete(base, "valheim", "Main").unwrap();
assert_eq!(list(base, "valheim").unwrap(), vec!["Renamed".to_string()]);
}
#[test]
fn the_selection_follows_a_rename_and_clears_on_delete() {
let base = tempdir().unwrap();
let root = base.path();
create(root, "valheim", "one").unwrap();
create(root, "valheim", "two").unwrap();
assert_eq!(read_selection(root, "valheim"), None);
use_profile(root, "valheim", "one").unwrap();
assert_eq!(read_selection(root, "valheim"), Some("one".to_string()));
rename(root, "valheim", "one", "renamed").unwrap();
assert_eq!(read_selection(root, "valheim"), Some("renamed".to_string()));
rename(root, "valheim", "two", "other").unwrap();
assert_eq!(read_selection(root, "valheim"), Some("renamed".to_string()));
delete(root, "valheim", "other").unwrap();
assert_eq!(read_selection(root, "valheim"), Some("renamed".to_string()));
delete(root, "valheim", "renamed").unwrap();
assert_eq!(read_selection(root, "valheim"), None);
assert!(use_profile(root, "valheim", "ghost").is_err());
clear_selection(root, "valheim").unwrap();
}
#[test]
fn require_profile_classifies_why_a_name_is_unusable() {
let base = tempdir().unwrap();
let root = base.path();
create(root, "valheim", "one").unwrap();
assert_eq!(
require_profile(root, "valheim", "one").unwrap(),
profile_dir(root, "valheim", "one")
);
let unsafe_name = require_profile(root, "valheim", "../escape").unwrap_err();
assert!(
matches!(&unsafe_name, BadProfile::InvalidName { name } if name == "../escape"),
"an unsafe name must be told apart from an absent one; got: {unsafe_name:?}"
);
let missing = require_profile(root, "valheim", "ghost").unwrap_err();
assert!(
matches!(&missing, BadProfile::Missing { name, dir }
if name == "ghost" && dir == &profile_dir(root, "valheim", "ghost")),
"a missing profile must carry its name and expected directory; got: {missing:?}"
);
}
#[test]
fn use_profile_and_resolve_refuse_a_missing_profile_identically() {
let base = tempdir().unwrap();
let root = base.path();
let game_dir = tempdir().unwrap();
let selecting = use_profile(root, "valheim", "ghost").unwrap_err();
let resolving = crate::profile::target::InstallTarget::resolve(
root.to_path_buf(),
game_dir.path().to_path_buf(),
"valheim",
Some("ghost"),
)
.unwrap_err();
assert_eq!(selecting.to_string(), resolving.to_string());
assert_eq!(
selecting.to_string(),
"Profile error: there is no profile named \"ghost\""
);
assert!(
matches!(selecting, Error::BadProfile(BadProfile::Missing { .. }))
&& matches!(resolving, Error::BadProfile(BadProfile::Missing { .. })),
"both must carry the reason, not a flattened message"
);
let selecting = use_profile(root, "valheim", "a/b").unwrap_err();
let resolving = crate::profile::target::InstallTarget::resolve(
root.to_path_buf(),
game_dir.path().to_path_buf(),
"valheim",
Some("a/b"),
)
.unwrap_err();
assert_eq!(selecting.to_string(), resolving.to_string());
assert_eq!(
selecting.to_string(),
"Profile error: invalid profile name \"a/b\""
);
}
#[test]
fn select_profile_precedence() {
assert_eq!(
select_profile(Some("experiment"), false, Some("saved")),
Some("experiment".to_string())
);
assert_eq!(select_profile(None, true, Some("saved")), None);
assert_eq!(
select_profile(None, false, Some("saved")),
Some("saved".to_string())
);
assert_eq!(select_profile(None, false, None), None);
}
}