use std::path::Path;
use serde::{Deserialize, Serialize};
use super::data_dir;
use crate::Result;
use crate::error::PersistError;
const PROFILE_FILE: &str = "profile.toml";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Profile {
#[serde(default)]
pub host: HostProfile,
#[serde(default)]
pub join: JoinProfile,
#[serde(default)]
pub relay: RelayProfile,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostProfile {
#[serde(default = "default_mc_port")]
pub port: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JoinProfile {
#[serde(default = "default_inlet_port")]
pub port: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_ticket: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RelayProfile {
#[serde(default)]
pub custom: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
impl Default for HostProfile {
fn default() -> Self {
Self {
port: default_mc_port(),
}
}
}
impl Default for JoinProfile {
fn default() -> Self {
Self {
port: default_inlet_port(),
last_ticket: None,
}
}
}
fn default_mc_port() -> u16 {
crate::DEFAULT_MC_PORT
}
fn default_inlet_port() -> u16 {
crate::DEFAULT_INLET_PORT
}
impl Profile {
pub fn path() -> Result<std::path::PathBuf> {
Ok(data_dir()?.join(PROFILE_FILE))
}
pub fn load() -> Result<Self> {
let path = Self::path()?;
Self::load_from(&path)
}
pub fn load_from(path: &Path) -> Result<Self> {
if !path.exists() {
let profile = Self::default();
profile.save_to(path)?;
return Ok(profile);
}
let content = std::fs::read_to_string(path).map_err(|e| PersistError::PathIo {
op: "read profile",
path: path.to_path_buf(),
source: e,
})?;
let profile: Self = toml::from_str(&content).map_err(|e| PersistError::ProfileParse {
path: path.to_path_buf(),
source: e,
})?;
Ok(profile)
}
pub fn save(&self) -> Result<()> {
let path = Self::path()?;
self.save_to(&path)
}
pub fn save_to(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| PersistError::PathIo {
op: "create config dir",
path: parent.to_path_buf(),
source: e,
})?;
}
let content = toml::to_string_pretty(self).map_err(PersistError::ProfileSerialize)?;
std::fs::write(path, content).map_err(|e| PersistError::PathIo {
op: "write profile",
path: path.to_path_buf(),
source: e,
})?;
Ok(())
}
pub fn resolve_relay_url(
&self,
custom: Option<&str>,
) -> Result<Option<crate::types::RelayUrl>> {
let url_str = custom.or(if self.relay.custom {
self.relay.url.as_deref()
} else {
None
});
match url_str {
Some(s) => {
let url: crate::types::RelayUrl = s
.parse::<crate::types::RelayUrl>()
.map_err(|e| PersistError::RelayUrlParse(e.to_string()))?;
Ok(Some(url))
}
None => Ok(None),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_profile_values() {
let p = Profile::default();
assert_eq!(p.host.port, crate::DEFAULT_MC_PORT);
assert_eq!(p.join.port, crate::DEFAULT_INLET_PORT);
assert!(p.join.last_ticket.is_none());
assert!(!p.relay.custom);
assert!(p.relay.url.is_none());
}
#[test]
fn roundtrip_toml() {
let mut p = Profile::default();
p.host.port = 12345;
p.join.last_ticket = Some("sculk://test".to_string());
p.relay.custom = true;
p.relay.url = Some("https://relay.example.com".to_string());
let s_res = toml::to_string_pretty(&p);
assert!(s_res.is_ok(), "serialize profile failed");
let s = if let Ok(v) = s_res { v } else { return };
let p2_res: std::result::Result<Profile, toml::de::Error> = toml::from_str(&s);
assert!(p2_res.is_ok(), "deserialize profile failed");
let p2 = if let Ok(v) = p2_res { v } else { return };
assert_eq!(p2.host.port, 12345);
assert_eq!(p2.join.last_ticket.as_deref(), Some("sculk://test"));
assert!(p2.relay.custom);
assert_eq!(p2.relay.url.as_deref(), Some("https://relay.example.com"));
}
#[test]
fn partial_toml_uses_defaults() {
let s = "[host]\nport = 9999\n";
let p_res: std::result::Result<Profile, toml::de::Error> = toml::from_str(s);
assert!(p_res.is_ok(), "deserialize partial profile failed");
let p = if let Ok(v) = p_res { v } else { return };
assert_eq!(p.host.port, 9999);
assert_eq!(p.join.port, crate::DEFAULT_INLET_PORT);
assert!(p.relay.url.is_none());
}
#[test]
fn save_and_load_file() {
let dir = std::env::temp_dir().join("sculk_test_profile");
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("profile.toml");
let mut p = Profile::default();
p.host.port = 11111;
let save_res = p.save_to(&path);
assert!(save_res.is_ok(), "save profile failed");
let loaded_res = Profile::load_from(&path);
assert!(loaded_res.is_ok(), "load profile failed");
let loaded = if let Ok(v) = loaded_res { v } else { return };
assert_eq!(loaded.host.port, 11111);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_missing_file_creates_default() {
let dir = std::env::temp_dir().join("sculk_test_load_missing");
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("profile.toml");
let p_res = Profile::load_from(&path);
assert!(p_res.is_ok(), "load missing profile failed");
let p = if let Ok(v) = p_res { v } else { return };
assert_eq!(p.host.port, crate::DEFAULT_MC_PORT);
assert!(path.exists());
let _ = std::fs::remove_dir_all(&dir);
}
}