use super::{ConfigError, ConfigPaths};
use keyring::{Entry, Error as KeyringError};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::io::{Read, Write};
use std::num::NonZeroU32;
use std::path::{Path, PathBuf};
const KEYRING_SERVICE: &str = "plausible-cli";
fn keyring_enabled() -> bool {
if let Ok(value) = env::var("PLAUSIBLE_CLI_DISABLE_KEYRING") {
let v = value.to_lowercase();
return !(v == "1" || v == "true");
}
if let Ok(ci) = env::var("CI") {
let ci = ci.to_lowercase();
if ci == "1" || ci == "true" {
return false;
}
}
true
}
fn keyring_entry(alias: &str) -> Result<Entry, KeyringError> {
Entry::new(KEYRING_SERVICE, alias)
}
fn store_keyring_secret(alias: &str, secret: &str) -> Result<(), KeyringError> {
keyring_entry(alias)?.set_password(secret)
}
fn read_keyring_secret(alias: &str) -> Result<String, KeyringError> {
keyring_entry(alias)?.get_password()
}
fn delete_keyring_secret(alias: &str) -> Result<(), KeyringError> {
match keyring_entry(alias)?.delete_password() {
Err(KeyringError::NoEntry) => Ok(()),
other => other,
}
}
#[derive(Debug, Clone)]
pub struct AccountStore {
paths: ConfigPaths,
}
impl AccountStore {
pub fn new(paths: ConfigPaths) -> Result<Self, AccountStoreError> {
paths.ensure_exists()?;
Ok(Self { paths })
}
pub fn add_account(
&self,
alias: &str,
api_key: &str,
profile: AccountProfile,
) -> Result<(), AccountStoreError> {
validate_alias(alias)?;
if api_key.trim().is_empty() {
return Err(AccountStoreError::EmptyApiKey);
}
let mut state = self.read_state()?;
if state.accounts.contains_key(alias) {
return Err(AccountStoreError::DuplicateAlias(alias.to_string()));
}
state.accounts.insert(alias.to_string(), profile);
state.settings.entry(alias.to_string()).or_default();
if state.default_account.is_none() {
state.default_account = Some(alias.to_string());
}
self.write_state(&state)?;
self.write_secret(alias, api_key)?;
Ok(())
}
pub fn get_account(&self, alias: &str) -> Result<AccountRecord, AccountStoreError> {
let state = self.read_state()?;
let profile = state
.accounts
.get(alias)
.cloned()
.ok_or_else(|| AccountStoreError::AccountNotFound(alias.to_string()))?;
let api_key = self.read_secret(alias)?;
Ok(AccountRecord {
alias: alias.to_string(),
api_key,
profile,
is_default: state
.default_account
.as_ref()
.map(|default| default == alias)
.unwrap_or(false),
daily_budget: state.daily_budget(alias),
})
}
pub fn list_accounts(&self) -> Result<Vec<AccountSummary>, AccountStoreError> {
let state = self.read_state()?;
let mut summaries = Vec::with_capacity(state.accounts.len());
for (alias, profile) in state.accounts.iter() {
summaries.push(AccountSummary {
alias: alias.clone(),
profile: profile.clone(),
is_default: state
.default_account
.as_ref()
.map(|default| default == alias)
.unwrap_or(false),
daily_budget: state.daily_budget(alias),
});
}
Ok(summaries)
}
pub fn default_alias(&self) -> Result<Option<String>, AccountStoreError> {
let state = self.read_state()?;
Ok(state.default_account)
}
pub fn set_default(&self, alias: &str) -> Result<(), AccountStoreError> {
let mut state = self.read_state()?;
if !state.accounts.contains_key(alias) {
return Err(AccountStoreError::AccountNotFound(alias.to_string()));
}
state.default_account = Some(alias.to_string());
self.write_state(&state)?;
Ok(())
}
pub fn set_daily_budget(
&self,
alias: &str,
budget: Option<NonZeroU32>,
) -> Result<(), AccountStoreError> {
let mut state = self.read_state()?;
if !state.accounts.contains_key(alias) {
return Err(AccountStoreError::AccountNotFound(alias.to_string()));
}
let entry = state.settings.entry(alias.to_string()).or_default();
entry.daily_budget = budget.map(NonZeroU32::get);
self.write_state(&state)
}
pub fn remove_account(&self, alias: &str) -> Result<(), AccountStoreError> {
let mut state = self.read_state()?;
if state.accounts.remove(alias).is_none() {
return Err(AccountStoreError::AccountNotFound(alias.to_string()));
}
state.settings.remove(alias);
if state.default_account.as_deref() == Some(alias) {
state.default_account = state.accounts.keys().next().cloned();
}
self.write_state(&state)?;
self.delete_secret(alias)?;
Ok(())
}
pub fn export_accounts(&self) -> Result<Vec<AccountExport>, AccountStoreError> {
Ok(self
.list_accounts()?
.into_iter()
.map(|summary| AccountExport {
alias: summary.alias,
is_default: summary.is_default,
label: summary.profile.label,
email: summary.profile.email,
description: summary.profile.description,
daily_budget: summary.daily_budget.map(|v| v.get()),
})
.collect())
}
fn read_state(&self) -> Result<AccountsState, AccountStoreError> {
let path = self.paths.accounts_file();
match fs::File::open(&path) {
Ok(mut file) => {
let mut contents = String::new();
file.read_to_string(&mut contents)
.map_err(|source| AccountStoreError::Io {
path: path.clone(),
source,
})?;
if contents.trim().is_empty() {
return Ok(AccountsState::default());
}
serde_json::from_str(&contents).map_err(AccountStoreError::Deserialize)
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(AccountsState::default()),
Err(source) => Err(AccountStoreError::Io { path, source }),
}
}
fn write_state(&self, state: &AccountsState) -> Result<(), AccountStoreError> {
let path = self.paths.accounts_file();
let contents = serde_json::to_string_pretty(state).map_err(AccountStoreError::Serialize)?;
fs::write(&path, contents).map_err(|source| AccountStoreError::Io { path, source })
}
fn write_secret(&self, alias: &str, api_key: &str) -> Result<(), AccountStoreError> {
if keyring_enabled() {
match store_keyring_secret(alias, api_key) {
Ok(()) => {
let _ = self.delete_secret_file(alias);
return Ok(());
}
Err(err) => {
if cfg!(debug_assertions) {
eprintln!("keyring write failed for {alias}: {err:?}");
}
}
}
}
self.write_secret_file(alias, api_key)
}
fn read_secret(&self, alias: &str) -> Result<String, AccountStoreError> {
if keyring_enabled() {
match read_keyring_secret(alias) {
Ok(secret) => return Ok(secret.trim().to_string()),
Err(KeyringError::NoEntry) => {
}
Err(err) => {
if cfg!(debug_assertions) {
eprintln!("keyring read failed for {alias}: {err:?}");
}
}
}
}
self.read_secret_file(alias)
}
fn delete_secret(&self, alias: &str) -> Result<(), AccountStoreError> {
if keyring_enabled() {
if let Err(err) = delete_keyring_secret(alias) {
if cfg!(debug_assertions) {
eprintln!("keyring delete failed for {alias}: {err:?}");
}
}
}
self.delete_secret_file(alias)
}
fn write_secret_file(&self, alias: &str, api_key: &str) -> Result<(), AccountStoreError> {
let path = self.secret_path(alias);
let mut file = fs::File::create(&path).map_err(|source| AccountStoreError::Io {
path: path.clone(),
source,
})?;
set_restricted_permissions(&path, &mut file)?;
file.write_all(api_key.as_bytes())
.map_err(|source| AccountStoreError::Io {
path: path.clone(),
source,
})
}
fn read_secret_file(&self, alias: &str) -> Result<String, AccountStoreError> {
let path = self.secret_path(alias);
let contents = fs::read_to_string(&path).map_err(|source| AccountStoreError::Io {
path: path.clone(),
source,
})?;
Ok(contents.trim().to_string())
}
fn delete_secret_file(&self, alias: &str) -> Result<(), AccountStoreError> {
let path = self.secret_path(alias);
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(source) => Err(AccountStoreError::Io { path, source }),
}
}
fn secret_path(&self, alias: &str) -> PathBuf {
self.paths.accounts_dir().join(format!("{alias}.key"))
}
}
fn validate_alias(alias: &str) -> Result<(), AccountStoreError> {
if alias.trim().is_empty() {
return Err(AccountStoreError::InvalidAlias(
"alias cannot be empty".to_string(),
));
}
if alias.contains('/') || alias.contains('\\') {
return Err(AccountStoreError::InvalidAlias(
"alias may not contain path separators".to_string(),
));
}
if alias.contains("..") {
return Err(AccountStoreError::InvalidAlias(
"alias may not contain parent directory segments".to_string(),
));
}
Ok(())
}
#[cfg(unix)]
fn set_restricted_permissions(path: &Path, file: &mut fs::File) -> Result<(), AccountStoreError> {
use std::os::unix::fs::PermissionsExt;
let mut perms = file
.metadata()
.map_err(|source| AccountStoreError::Io {
path: path.to_path_buf(),
source,
})?
.permissions();
perms.set_mode(0o600);
fs::set_permissions(path, perms).map_err(|source| AccountStoreError::Io {
path: path.to_path_buf(),
source,
})
}
#[cfg(not(unix))]
fn set_restricted_permissions(_path: &Path, _file: &mut fs::File) -> Result<(), AccountStoreError> {
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct AccountProfile {
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountSummary {
pub alias: String,
pub profile: AccountProfile,
pub is_default: bool,
pub daily_budget: Option<NonZeroU32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AccountExport {
pub alias: String,
pub is_default: bool,
pub label: Option<String>,
pub email: Option<String>,
pub description: Option<String>,
pub daily_budget: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountRecord {
pub alias: String,
pub api_key: String,
pub profile: AccountProfile,
pub is_default: bool,
pub daily_budget: Option<NonZeroU32>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct AccountsState {
#[serde(default)]
default_account: Option<String>,
#[serde(default)]
accounts: BTreeMap<String, AccountProfile>,
#[serde(default)]
settings: BTreeMap<String, AccountSettings>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct AccountSettings {
#[serde(default)]
daily_budget: Option<u32>,
}
impl AccountsState {
fn daily_budget(&self, alias: &str) -> Option<NonZeroU32> {
self.settings
.get(alias)
.and_then(|settings| settings.daily_budget)
.and_then(NonZeroU32::new)
}
}
#[derive(thiserror::Error, Debug)]
pub enum AccountStoreError {
#[error("configuration error: {0}")]
Config(#[from] ConfigError),
#[error("I/O error at {path:?}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse accounts file: {0}")]
Deserialize(serde_json::Error),
#[error("failed to serialise accounts file: {0}")]
Serialize(serde_json::Error),
#[error("account alias already exists: {0}")]
DuplicateAlias(String),
#[error("account alias is invalid: {0}")]
InvalidAlias(String),
#[error("account not found: {0}")]
AccountNotFound(String),
#[error("API key cannot be empty")]
EmptyApiKey,
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn store_for_test() -> (AccountStore, tempfile::TempDir) {
std::env::set_var("PLAUSIBLE_CLI_DISABLE_KEYRING", "1");
let tmp = tempdir().expect("tmpdir");
let paths = ConfigPaths::from_base_dir(tmp.path());
let store = AccountStore::new(paths).expect("store");
(store, tmp)
}
#[test]
fn add_account_persists_metadata_and_secret() {
let (store, _tmp) = store_for_test();
store
.add_account(
"prod",
"test-key",
AccountProfile {
label: Some("Production".into()),
email: Some("ops@example.com".into()),
description: Some("Primary account".into()),
},
)
.expect("add account");
let accounts = store.list_accounts().expect("list");
assert_eq!(accounts.len(), 1);
let summary = &accounts[0];
assert_eq!(summary.alias, "prod");
assert!(summary.is_default);
assert_eq!(summary.profile.label.as_deref(), Some("Production"));
let record = store.get_account("prod").expect("get account");
assert_eq!(record.api_key, "test-key");
}
#[test]
fn cannot_add_duplicate_alias() {
let (store, _tmp) = store_for_test();
store
.add_account("dup", "k1", AccountProfile::default())
.expect("add first");
let err = store
.add_account("dup", "k2", AccountProfile::default())
.expect_err("duplicate");
assert!(matches!(
err,
AccountStoreError::DuplicateAlias(alias) if alias == "dup"
));
}
#[test]
fn set_default_updates_state() {
let (store, _tmp) = store_for_test();
store
.add_account("a", "ka", AccountProfile::default())
.expect("add a");
store
.add_account("b", "kb", AccountProfile::default())
.expect("add b");
store.set_default("b").expect("set default");
let accounts = store.list_accounts().expect("list");
let defaults: Vec<_> = accounts
.into_iter()
.filter(|summary| summary.is_default)
.collect();
assert_eq!(defaults.len(), 1);
assert_eq!(defaults[0].alias, "b");
}
#[test]
fn remove_account_drops_secret_and_selects_new_default() {
let (store, _tmp) = store_for_test();
store
.add_account("first", "k1", AccountProfile::default())
.expect("add first");
store
.add_account("second", "k2", AccountProfile::default())
.expect("add second");
store.remove_account("first").expect("remove account");
let accounts = store.list_accounts().expect("list");
assert_eq!(accounts.len(), 1);
assert_eq!(accounts[0].alias, "second");
assert!(accounts[0].is_default);
assert!(store.get_account("first").is_err());
}
#[test]
fn set_daily_budget_updates_state() {
let (store, _tmp) = store_for_test();
store
.add_account("acct", "key", AccountProfile::default())
.expect("add");
let budget = NonZeroU32::new(500).unwrap();
store
.set_daily_budget("acct", Some(budget))
.expect("set budget");
let record = store.get_account("acct").expect("record");
assert_eq!(record.daily_budget, Some(budget));
store.set_daily_budget("acct", None).expect("clear budget");
let record = store.get_account("acct").expect("record");
assert!(record.daily_budget.is_none());
}
#[test]
fn alias_validation_rejects_path_segments() {
assert!(validate_alias("valid-alias").is_ok());
assert!(validate_alias("nested/alias").is_err());
assert!(validate_alias("..").is_err());
assert!(validate_alias("").is_err());
}
}