use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use serde::{Deserialize, Serialize};
/// Connection metadata that is persisted to disk (no password).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionMeta {
pub id: String,
pub name: String,
pub host: String,
pub port: u16,
pub user: String,
}
/// A full connection with password (in-memory only).
#[derive(Debug, Clone)]
pub struct Connection {
pub id: String,
pub name: String,
pub host: String,
pub port: u16,
pub user: String,
pub password: String,
}
impl Connection {
/// Build the full HTTP URL for ClickHouse.
/// Uses HTTPS when the port is 8443 or 443, otherwise plain HTTP.
pub fn url(&self) -> String {
let scheme = if self.port == 8443 || self.port == 443 {
"https"
} else {
"http"
};
format!("{}://{}:{}", scheme, self.host, self.port)
}
/// Build a `clickhouse::Client` from this connection's settings.
pub fn build_client(&self) -> clickhouse::Client {
let mut client = clickhouse::Client::default()
.with_url(self.url())
.with_user(&self.user);
if !self.password.is_empty() {
client = client.with_password(&self.password);
}
client
}
}
/// Serialisable state persisted to disk (passwords are NOT included).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct StoredState {
connections: Vec<ConnectionMeta>,
active_id: Option<String>,
}
/// Thread-safe store of saved connections.
/// Passwords are kept in memory only, not persisted to disk.
#[derive(Debug, Clone)]
pub struct ConnectionStore {
inner: Arc<RwLock<StoredState>>,
/// In-memory only storage for passwords (not persisted).
passwords: Arc<RwLock<HashMap<String, String>>>,
path: PathBuf,
}
impl ConnectionStore {
/// Load from the given file path, creating it (and parent dirs) if absent.
/// Passwords will need to be provided separately via `set_password()`.
pub fn load(path: PathBuf) -> Self {
let state = if path.exists() {
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str::<StoredState>(&s).ok())
.unwrap_or_default()
} else {
StoredState::default()
};
Self {
inner: Arc::new(RwLock::new(state)),
passwords: Arc::new(RwLock::new(HashMap::new())),
path,
}
}
/// Default location: `$HOME/.chtmx/connections.json`
pub fn default_path() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".chtmx").join("connections.json")
}
/// Persist current state to disk (metadata only, passwords stay in memory).
fn save(&self) {
let state = self.inner.read().expect("store read lock poisoned").clone();
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match serde_json::to_string_pretty(&state) {
Ok(json) => {
if let Err(e) = std::fs::write(&self.path, json) {
log::error!("Failed to persist connections: {}", e);
}
}
Err(e) => log::error!("Failed to serialise connections: {}", e),
}
}
/// Return a snapshot of all connection metadata (without passwords).
pub fn all_meta(&self) -> Vec<ConnectionMeta> {
self.inner
.read()
.expect("store read lock poisoned")
.connections
.clone()
}
/// Return a snapshot of all connections (with passwords from memory).
pub fn all(&self) -> Vec<Connection> {
let state = self.inner.read().expect("store read lock poisoned");
let passwords = self.passwords.read().expect("passwords read lock poisoned");
state
.connections
.iter()
.map(|meta| Connection {
id: meta.id.clone(),
name: meta.name.clone(),
host: meta.host.clone(),
port: meta.port,
user: meta.user.clone(),
password: passwords.get(&meta.id).cloned().unwrap_or_default(),
})
.collect()
}
/// Return the currently active connection ID, if any.
pub fn active_id(&self) -> Option<String> {
self.inner
.read()
.expect("store read lock poisoned")
.active_id
.clone()
}
/// Check if a connection has a password set in memory.
pub fn has_password(&self, id: &str) -> bool {
let passwords = self.passwords.read().expect("passwords read lock poisoned");
passwords.get(id).is_some_and(|p| !p.is_empty())
}
/// Get the password for a connection (if set in memory).
pub fn get_password(&self, id: &str) -> Option<String> {
self.passwords
.read()
.expect("passwords read lock poisoned")
.get(id)
.cloned()
}
/// Set or update the password for a connection (in memory only).
pub fn set_password(&self, id: &str, password: String) {
let mut passwords = self
.passwords
.write()
.expect("passwords write lock poisoned");
passwords.insert(id.to_string(), password);
}
/// Return the currently active connection metadata, if any.
pub fn active_meta(&self) -> Option<ConnectionMeta> {
let state = self.inner.read().expect("store read lock poisoned");
state
.active_id
.as_ref()
.and_then(|id| state.connections.iter().find(|c| &c.id == id).cloned())
}
/// Return the currently active connection with password, if any.
pub fn active(&self) -> Option<Connection> {
let meta = self.active_meta()?;
let passwords = self.passwords.read().expect("passwords read lock poisoned");
Some(Connection {
id: meta.id.clone(),
name: meta.name.clone(),
host: meta.host.clone(),
port: meta.port,
user: meta.user.clone(),
password: passwords.get(&meta.id).cloned().unwrap_or_default(),
})
}
/// Build a ClickHouse client for the active connection.
/// Returns `None` when no active connection is set or no password in memory.
pub fn active_client(&self) -> Option<clickhouse::Client> {
self.active().map(|c| c.build_client())
}
/// Add a new connection. Returns the new connection's id.
/// The password is stored in memory only.
pub fn add(
&self,
name: String,
host: String,
port: u16,
user: String,
password: String,
) -> String {
let id = uuid::Uuid::new_v4().to_string();
let meta = ConnectionMeta {
id: id.clone(),
name,
host,
port,
user,
};
let mut state = self.inner.write().expect("store write lock poisoned");
// If this is the first connection, make it active automatically.
if state.connections.is_empty() {
state.active_id = Some(id.clone());
}
state.connections.push(meta);
drop(state);
// Store password in memory only
let mut passwords = self
.passwords
.write()
.expect("passwords write lock poisoned");
passwords.insert(id.clone(), password);
drop(passwords);
self.save();
id
}
/// Remove a connection by id. If it was active, clear the active id.
pub fn remove(&self, id: &str) {
let mut state = self.inner.write().expect("store write lock poisoned");
state.connections.retain(|c| c.id != id);
if state.active_id.as_deref() == Some(id) {
state.active_id = None;
}
drop(state);
// Also remove password from memory
let mut passwords = self
.passwords
.write()
.expect("passwords write lock poisoned");
passwords.remove(id);
drop(passwords);
self.save();
}
/// Set the active connection by id. No-op if the id does not exist.
pub fn set_active(&self, id: &str) {
let mut state = self.inner.write().expect("store write lock poisoned");
if state.connections.iter().any(|c| c.id == id) {
state.active_id = Some(id.to_string());
}
drop(state);
self.save();
}
/// Add a connection from environment variables (metadata to disk, password to memory).
pub fn add_from_env(
&self,
name: String,
host: String,
port: u16,
user: String,
password: String,
) -> String {
self.add(name, host, port, user, password)
}
}