use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub fn resolve_data_dir(raw: &str) -> PathBuf {
if raw.starts_with('~')
&& let Some(home) = dirs_home()
{
return home.join(raw.trim_start_matches("~/"));
}
PathBuf::from(raw)
}
pub const DEFAULT_CLOUD_URL: &str = "https://api.mentedb.com";
pub const DEFAULT_ACCOUNT: &str = "default";
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Account {
pub api_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cloud_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct AccountsConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_account: Option<String>,
#[serde(default)]
pub accounts: BTreeMap<String, Account>,
}
impl AccountsConfig {
pub fn from_json_str(raw: &str) -> anyhow::Result<Self> {
if raw.trim().is_empty() {
return Ok(Self::default());
}
let value: serde_json::Value = serde_json::from_str(raw)?;
if value.get("accounts").is_some_and(|a| a.is_object()) {
let mut config: AccountsConfig = serde_json::from_value(value)?;
if let Some(active) = &config.active_account
&& !config.accounts.contains_key(active)
{
config.active_account = None;
}
return Ok(config);
}
let mut config = AccountsConfig::default();
if let Some(token) = value.get("token").and_then(|t| t.as_str())
&& !token.is_empty()
{
let cloud_url = value
.get("api_url")
.and_then(|u| u.as_str())
.filter(|u| !u.is_empty())
.map(str::to_string);
config.accounts.insert(
DEFAULT_ACCOUNT.to_string(),
Account {
api_key: token.to_string(),
cloud_url,
email: None,
},
);
config.active_account = Some(DEFAULT_ACCOUNT.to_string());
}
Ok(config)
}
pub fn active_name(&self) -> Option<String> {
if let Some(active) = &self.active_account
&& self.accounts.contains_key(active)
{
return Some(active.clone());
}
if self.accounts.len() == 1 {
return self.accounts.keys().next().cloned();
}
if self.accounts.contains_key(DEFAULT_ACCOUNT) {
return Some(DEFAULT_ACCOUNT.to_string());
}
None
}
pub fn active_account(&self) -> Option<(&str, &Account)> {
let name = self.active_name()?;
self.accounts
.get_key_value(&name)
.map(|(k, v)| (k.as_str(), v))
}
pub fn to_json_string(&self) -> anyhow::Result<String> {
Ok(serde_json::to_string_pretty(self)?)
}
}
pub fn mask_secret(secret: &str) -> String {
let prefix: String = secret.chars().take(12).collect();
format!("{prefix}...")
}
pub fn credentials_path(config_dir: &Path) -> PathBuf {
config_dir.join("cloud.json")
}
pub fn load_accounts(config_dir: &Path) -> anyhow::Result<AccountsConfig> {
let path = credentials_path(config_dir);
if !path.exists() {
return Ok(AccountsConfig::default());
}
let raw = std::fs::read_to_string(&path)?;
AccountsConfig::from_json_str(&raw)
}
#[cfg(feature = "local")]
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub data_dir: PathBuf,
pub embedding_dim: usize,
pub llm_provider: String,
pub llm_api_key: Option<String>,
pub llm_model: Option<String>,
pub full_tools: bool,
}
#[cfg(feature = "local")]
impl ServerConfig {
pub fn new(
data_dir: PathBuf,
embedding_dim: usize,
llm_provider: String,
llm_api_key: Option<String>,
llm_model: Option<String>,
full_tools: bool,
) -> Self {
Self {
data_dir,
embedding_dim,
llm_provider,
llm_api_key,
llm_model,
full_tools,
}
}
}
fn dirs_home() -> Option<PathBuf> {
std::env::var("HOME").ok().map(PathBuf::from)
}
#[cfg(test)]
mod tests {
use super::*;
fn upsert(dir: &Path, name: &str, api_key: &str, cloud_url: Option<&str>, make_active: bool) {
let mut config = load_accounts(dir).unwrap();
let entry = config.accounts.entry(name.to_string()).or_default();
entry.api_key = api_key.to_string();
if let Some(url) = cloud_url {
entry.cloud_url = Some(url.to_string());
}
if make_active || config.active_account.is_none() {
config.active_account = Some(name.to_string());
}
std::fs::write(credentials_path(dir), config.to_json_string().unwrap()).unwrap();
}
#[test]
fn migrates_legacy_single_key_to_default_account() {
let legacy = r#"{ "api_url": "https://api.example.com", "token": "mdb_legacy_key_123" }"#;
let config = AccountsConfig::from_json_str(legacy).unwrap();
assert_eq!(config.accounts.len(), 1);
let account = config
.accounts
.get(DEFAULT_ACCOUNT)
.expect("default account");
assert_eq!(account.api_key, "mdb_legacy_key_123");
assert_eq!(
account.cloud_url.as_deref(),
Some("https://api.example.com")
);
assert_eq!(config.active_account.as_deref(), Some(DEFAULT_ACCOUNT));
let (name, active) = config.active_account().unwrap();
assert_eq!(name, DEFAULT_ACCOUNT);
assert_eq!(active.api_key, "mdb_legacy_key_123");
}
#[test]
fn migrates_legacy_key_without_api_url() {
let legacy = r#"{ "token": "mdb_only_token" }"#;
let config = AccountsConfig::from_json_str(legacy).unwrap();
let account = config.accounts.get(DEFAULT_ACCOUNT).unwrap();
assert_eq!(account.api_key, "mdb_only_token");
assert!(account.cloud_url.is_none());
assert_eq!(config.active_name().as_deref(), Some(DEFAULT_ACCOUNT));
}
#[test]
fn legacy_migration_persists_on_first_write() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
credentials_path(dir.path()),
r#"{ "api_url": "https://api.example.com", "token": "mdb_legacy" }"#,
)
.unwrap();
upsert(dir.path(), "work", "mdb_work_key", None, true);
let raw = std::fs::read_to_string(credentials_path(dir.path())).unwrap();
assert!(raw.contains("\"accounts\""));
assert!(!raw.contains("\"token\"")); let reloaded = load_accounts(dir.path()).unwrap();
assert_eq!(reloaded.accounts.len(), 2);
assert!(reloaded.accounts.contains_key(DEFAULT_ACCOUNT));
assert!(reloaded.accounts.contains_key("work"));
assert_eq!(reloaded.active_name().as_deref(), Some("work"));
}
#[test]
fn add_use_list_roundtrip() {
let dir = tempfile::tempdir().unwrap();
upsert(
dir.path(),
"work",
"mdb_work_key",
Some("https://work.example.com"),
false,
);
upsert(dir.path(), "personal", "mdb_personal_key", None, false);
let config = load_accounts(dir.path()).unwrap();
assert_eq!(config.accounts.len(), 2);
assert_eq!(config.active_name().as_deref(), Some("work"));
assert_eq!(
config.accounts["work"].cloud_url.as_deref(),
Some("https://work.example.com")
);
assert!(config.accounts["personal"].cloud_url.is_none());
let mut config = load_accounts(dir.path()).unwrap();
config.active_account = Some("personal".to_string());
std::fs::write(
credentials_path(dir.path()),
config.to_json_string().unwrap(),
)
.unwrap();
let reloaded = load_accounts(dir.path()).unwrap();
assert_eq!(reloaded.active_name().as_deref(), Some("personal"));
let (name, active) = reloaded.active_account().unwrap();
assert_eq!(name, "personal");
assert_eq!(active.api_key, "mdb_personal_key");
assert_ne!(active.api_key, reloaded.accounts["work"].api_key);
}
#[test]
fn remove_active_clears_pointer() {
let dir = tempfile::tempdir().unwrap();
upsert(dir.path(), "work", "mdb_work", None, true);
upsert(dir.path(), "personal", "mdb_personal", None, false);
let mut config = load_accounts(dir.path()).unwrap();
config.accounts.remove("work");
config.active_account = None;
std::fs::write(
credentials_path(dir.path()),
config.to_json_string().unwrap(),
)
.unwrap();
let reloaded = load_accounts(dir.path()).unwrap();
assert!(!reloaded.accounts.contains_key("work"));
assert_eq!(reloaded.active_name().as_deref(), Some("personal"));
}
#[test]
fn active_name_falls_back_and_ignores_stale_pointer() {
let mut config = AccountsConfig::default();
config.accounts.insert(
"solo".into(),
Account {
api_key: "k".into(),
..Default::default()
},
);
assert_eq!(config.active_name().as_deref(), Some("solo"));
let json = r#"{
"active_account": "gone",
"accounts": { "a": { "api_key": "ka" }, "b": { "api_key": "kb" } }
}"#;
let config = AccountsConfig::from_json_str(json).unwrap();
assert!(config.active_account.is_none());
assert!(config.active_name().is_none());
}
#[test]
fn empty_or_missing_config_is_empty() {
assert_eq!(
AccountsConfig::from_json_str("").unwrap(),
AccountsConfig::default()
);
assert_eq!(
AccountsConfig::from_json_str(" ").unwrap(),
AccountsConfig::default()
);
let config = AccountsConfig::from_json_str(r#"{ "token": "" }"#).unwrap();
assert!(config.accounts.is_empty());
assert!(config.active_name().is_none());
let dir = tempfile::tempdir().unwrap();
assert_eq!(
load_accounts(dir.path()).unwrap(),
AccountsConfig::default()
);
}
#[test]
fn mask_secret_never_panics_and_hides_tail() {
assert_eq!(mask_secret("mdb_1234567890abcdef"), "mdb_12345678...");
assert_eq!(mask_secret("mdb_"), "mdb_...");
assert_eq!(mask_secret(""), "...");
let secret = "mdb_supersecrettail_zzzzzz";
assert!(!mask_secret(secret).contains("zzzzzz"));
}
}