Skip to content
//! 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());
    }
}