//! Saved server profiles: one entry per `twice-server` the operator manages.
use std::fs;
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt as _;
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result};
use serde::{Deserialize, Serialize};
/// One reachable `twice-server` and the key that opens it.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct ServerProfile {
/// Operator-chosen label shown in the picker.
pub(crate) name: String,
/// Base URL, e.g. `http://127.0.0.1:7373`.
pub(crate) base_url: String,
/// API key for that server.
pub(crate) api_key: String,
}
/// Every profile plus the one currently selected.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct ProfileStore {
/// Known servers.
#[serde(default)]
pub(crate) profiles: Vec<ServerProfile>,
/// Index into [`Self::profiles`].
#[serde(default)]
pub(crate) selected: usize,
}
impl ProfileStore {
/// Loads the store, returning an empty one when the file does not exist.
pub(crate) fn load(path: &Path) -> Result<Self> {
if !path.exists() {
return Ok(Self::default());
}
let raw =
fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
toml::from_str(&raw).with_context(|| format!("parsing {}", path.display()))
}
/// Writes the store back with owner-only permissions; it holds API keys.
pub(crate) fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
let rendered = toml::to_string_pretty(self).context("rendering profiles")?;
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.with_context(|| format!("writing {}", path.display()))?;
file.write_all(rendered.as_bytes())
.with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
/// The currently selected profile, if any.
pub(crate) fn current(&self) -> Option<&ServerProfile> {
self.profiles.get(self.selected)
}
}
/// Rejection reason for operator-typed profile fields.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct InvalidProfile(pub(crate) String);
/// Trims operator-typed fields and rejects a base URL that is not a usable
/// HTTP endpoint.
///
/// A single stray space in the URL field otherwise reaches `reqwest` and
/// surfaces as `builder error: invalid port number`, which tells the operator
/// nothing about what to fix.
pub(crate) fn normalize(draft: &ServerProfile) -> Result<ServerProfile, InvalidProfile> {
let name = draft.name.trim();
let base_url = draft.base_url.trim().trim_end_matches('/');
let api_key = draft.api_key.trim();
if name.is_empty() {
return Err(InvalidProfile("name is required".to_owned()));
}
if api_key.is_empty() {
return Err(InvalidProfile("API key is required".to_owned()));
}
let parsed = reqwest::Url::parse(base_url)
.map_err(|err| InvalidProfile(format!("base URL {base_url:?} is not a URL: {err}")))?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(InvalidProfile(format!(
"base URL must use http or https, got {:?}",
parsed.scheme()
)));
}
if parsed.host_str().is_none() {
return Err(InvalidProfile("base URL has no host".to_owned()));
}
Ok(ServerProfile {
name: name.to_owned(),
base_url: base_url.to_owned(),
api_key: api_key.to_owned(),
})
}
/// Default profile location: `$XDG_CONFIG_HOME/twice/ui.toml`.
pub(crate) fn default_profile_path() -> Result<PathBuf> {
if let Ok(explicit) = std::env::var("TWICE_UI_CONFIG") {
return Ok(PathBuf::from(explicit));
}
let base = std::env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|_ignored| {
std::env::var("HOME")
.map(|home| PathBuf::from(home).join(".config"))
.context("neither XDG_CONFIG_HOME nor HOME is set")
})?;
Ok(base.join("twice").join("ui.toml"))
}
#[cfg(test)]
mod tests {
use std::os::unix::fs::PermissionsExt as _;
use std::sync::atomic::{AtomicU32, Ordering};
use super::*;
fn temp_path() -> PathBuf {
static COUNTER: AtomicU32 = AtomicU32::new(0);
let unique = format!(
"{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
);
std::env::temp_dir()
.join(format!("twice-ui-{unique}"))
.join("ui.toml")
}
#[test]
fn a_missing_file_loads_as_an_empty_store() {
let store = ProfileStore::load(&temp_path()).unwrap();
assert!(store.profiles.is_empty());
assert!(store.current().is_none());
}
#[test]
fn saved_profiles_round_trip_and_stay_owner_only() {
let path = temp_path();
let store = ProfileStore {
profiles: vec![ServerProfile {
name: "prod".to_owned(),
base_url: "https://ops.example.com".to_owned(),
api_key: "secret".to_owned(),
}],
selected: 0,
};
store.save(&path).unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "profiles hold API keys");
let loaded = ProfileStore::load(&path).unwrap();
assert_eq!(
loaded.current().map(|p| p.name.clone()),
Some("prod".to_owned())
);
fs::remove_dir_all(path.parent().unwrap()).unwrap();
}
fn draft(name: &str, base_url: &str, api_key: &str) -> ServerProfile {
ServerProfile {
name: name.to_owned(),
base_url: base_url.to_owned(),
api_key: api_key.to_owned(),
}
}
#[test]
fn stray_whitespace_is_trimmed_instead_of_reaching_the_http_client() {
// Regression: a trailing space in the URL field used to surface as
// "builder error: invalid port number", and a trailing space in the
// name field silently created a duplicate profile.
let cleaned = normalize(&draft(" local ", " http://127.0.0.1:7373 ", " key123 ")).unwrap();
assert_eq!(cleaned.name, "local");
assert_eq!(cleaned.base_url, "http://127.0.0.1:7373");
assert_eq!(cleaned.api_key, "key123");
}
#[test]
fn a_trailing_slash_is_dropped_so_paths_do_not_double_up() {
let cleaned = normalize(&draft("local", "http://127.0.0.1:7373/", "key123")).unwrap();
assert_eq!(cleaned.base_url, "http://127.0.0.1:7373");
}
#[test]
fn a_base_url_that_is_not_a_url_is_rejected_with_a_readable_reason() {
let rejection = normalize(&draft("local", "127.0.0.1:7373", "key123")).unwrap_err();
assert!(
rejection.0.contains("is not a URL"),
"unhelpful message: {}",
rejection.0
);
}
#[test]
fn a_non_http_scheme_is_rejected() {
let rejection = normalize(&draft("local", "ftp://example.com", "key123")).unwrap_err();
assert!(
rejection.0.contains("http or https"),
"unhelpful message: {}",
rejection.0
);
}
#[test]
fn an_empty_name_or_key_is_rejected() {
assert!(normalize(&draft(" ", "http://127.0.0.1:7373", "key123")).is_err());
assert!(normalize(&draft("local", "http://127.0.0.1:7373", " ")).is_err());
}
}