plausible_cli/config/
accounts.rs

1use super::{ConfigError, ConfigPaths};
2use keyring::{Entry, Error as KeyringError};
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use std::env;
6use std::fs;
7use std::io::{Read, Write};
8use std::num::NonZeroU32;
9use std::path::{Path, PathBuf};
10
11const KEYRING_SERVICE: &str = "plausible-cli";
12
13fn keyring_enabled() -> bool {
14    if let Ok(value) = env::var("PLAUSIBLE_CLI_DISABLE_KEYRING") {
15        let v = value.to_lowercase();
16        return !(v == "1" || v == "true");
17    }
18    if let Ok(ci) = env::var("CI") {
19        let ci = ci.to_lowercase();
20        if ci == "1" || ci == "true" {
21            return false;
22        }
23    }
24    true
25}
26
27fn keyring_entry(alias: &str) -> Result<Entry, KeyringError> {
28    Entry::new(KEYRING_SERVICE, alias)
29}
30
31fn store_keyring_secret(alias: &str, secret: &str) -> Result<(), KeyringError> {
32    keyring_entry(alias)?.set_password(secret)
33}
34
35fn read_keyring_secret(alias: &str) -> Result<String, KeyringError> {
36    keyring_entry(alias)?.get_password()
37}
38
39fn delete_keyring_secret(alias: &str) -> Result<(), KeyringError> {
40    match keyring_entry(alias)?.delete_password() {
41        Err(KeyringError::NoEntry) => Ok(()),
42        other => other,
43    }
44}
45
46/// Top-level account store coordinating metadata and secrets.
47#[derive(Debug, Clone)]
48pub struct AccountStore {
49    paths: ConfigPaths,
50}
51
52impl AccountStore {
53    /// Initialise an account store for the provided configuration paths.
54    pub fn new(paths: ConfigPaths) -> Result<Self, AccountStoreError> {
55        paths.ensure_exists()?;
56        Ok(Self { paths })
57    }
58
59    /// Add or update an account entry and store its API key.
60    pub fn add_account(
61        &self,
62        alias: &str,
63        api_key: &str,
64        profile: AccountProfile,
65    ) -> Result<(), AccountStoreError> {
66        validate_alias(alias)?;
67        if api_key.trim().is_empty() {
68            return Err(AccountStoreError::EmptyApiKey);
69        }
70        let mut state = self.read_state()?;
71        if state.accounts.contains_key(alias) {
72            return Err(AccountStoreError::DuplicateAlias(alias.to_string()));
73        }
74        state.accounts.insert(alias.to_string(), profile);
75        state.settings.entry(alias.to_string()).or_default();
76        if state.default_account.is_none() {
77            state.default_account = Some(alias.to_string());
78        }
79        self.write_state(&state)?;
80        self.write_secret(alias, api_key)?;
81        Ok(())
82    }
83
84    /// Retrieve metadata and API key for a given alias.
85    pub fn get_account(&self, alias: &str) -> Result<AccountRecord, AccountStoreError> {
86        let state = self.read_state()?;
87        let profile = state
88            .accounts
89            .get(alias)
90            .cloned()
91            .ok_or_else(|| AccountStoreError::AccountNotFound(alias.to_string()))?;
92        let api_key = self.read_secret(alias)?;
93        Ok(AccountRecord {
94            alias: alias.to_string(),
95            api_key,
96            profile,
97            is_default: state
98                .default_account
99                .as_ref()
100                .map(|default| default == alias)
101                .unwrap_or(false),
102            daily_budget: state.daily_budget(alias),
103        })
104    }
105
106    /// Enumerate stored accounts.
107    pub fn list_accounts(&self) -> Result<Vec<AccountSummary>, AccountStoreError> {
108        let state = self.read_state()?;
109        let mut summaries = Vec::with_capacity(state.accounts.len());
110        for (alias, profile) in state.accounts.iter() {
111            summaries.push(AccountSummary {
112                alias: alias.clone(),
113                profile: profile.clone(),
114                is_default: state
115                    .default_account
116                    .as_ref()
117                    .map(|default| default == alias)
118                    .unwrap_or(false),
119                daily_budget: state.daily_budget(alias),
120            });
121        }
122        Ok(summaries)
123    }
124
125    /// Retrieve the configured default account alias, if any.
126    pub fn default_alias(&self) -> Result<Option<String>, AccountStoreError> {
127        let state = self.read_state()?;
128        Ok(state.default_account)
129    }
130
131    /// Update default account alias.
132    pub fn set_default(&self, alias: &str) -> Result<(), AccountStoreError> {
133        let mut state = self.read_state()?;
134        if !state.accounts.contains_key(alias) {
135            return Err(AccountStoreError::AccountNotFound(alias.to_string()));
136        }
137        state.default_account = Some(alias.to_string());
138        self.write_state(&state)?;
139        Ok(())
140    }
141
142    /// Set or clear the daily budget for an account.
143    pub fn set_daily_budget(
144        &self,
145        alias: &str,
146        budget: Option<NonZeroU32>,
147    ) -> Result<(), AccountStoreError> {
148        let mut state = self.read_state()?;
149        if !state.accounts.contains_key(alias) {
150            return Err(AccountStoreError::AccountNotFound(alias.to_string()));
151        }
152        let entry = state.settings.entry(alias.to_string()).or_default();
153        entry.daily_budget = budget.map(NonZeroU32::get);
154        self.write_state(&state)
155    }
156
157    /// Remove an account and associated secret.
158    pub fn remove_account(&self, alias: &str) -> Result<(), AccountStoreError> {
159        let mut state = self.read_state()?;
160        if state.accounts.remove(alias).is_none() {
161            return Err(AccountStoreError::AccountNotFound(alias.to_string()));
162        }
163        state.settings.remove(alias);
164        if state.default_account.as_deref() == Some(alias) {
165            state.default_account = state.accounts.keys().next().cloned();
166        }
167        self.write_state(&state)?;
168        self.delete_secret(alias)?;
169        Ok(())
170    }
171
172    /// Export account metadata without secrets.
173    pub fn export_accounts(&self) -> Result<Vec<AccountExport>, AccountStoreError> {
174        Ok(self
175            .list_accounts()?
176            .into_iter()
177            .map(|summary| AccountExport {
178                alias: summary.alias,
179                is_default: summary.is_default,
180                label: summary.profile.label,
181                email: summary.profile.email,
182                description: summary.profile.description,
183                daily_budget: summary.daily_budget.map(|v| v.get()),
184            })
185            .collect())
186    }
187
188    fn read_state(&self) -> Result<AccountsState, AccountStoreError> {
189        let path = self.paths.accounts_file();
190        match fs::File::open(&path) {
191            Ok(mut file) => {
192                let mut contents = String::new();
193                file.read_to_string(&mut contents)
194                    .map_err(|source| AccountStoreError::Io {
195                        path: path.clone(),
196                        source,
197                    })?;
198                if contents.trim().is_empty() {
199                    return Ok(AccountsState::default());
200                }
201                serde_json::from_str(&contents).map_err(AccountStoreError::Deserialize)
202            }
203            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(AccountsState::default()),
204            Err(source) => Err(AccountStoreError::Io { path, source }),
205        }
206    }
207
208    fn write_state(&self, state: &AccountsState) -> Result<(), AccountStoreError> {
209        let path = self.paths.accounts_file();
210        let contents = serde_json::to_string_pretty(state).map_err(AccountStoreError::Serialize)?;
211        fs::write(&path, contents).map_err(|source| AccountStoreError::Io { path, source })
212    }
213
214    fn write_secret(&self, alias: &str, api_key: &str) -> Result<(), AccountStoreError> {
215        if keyring_enabled() {
216            match store_keyring_secret(alias, api_key) {
217                Ok(()) => {
218                    let _ = self.delete_secret_file(alias);
219                    return Ok(());
220                }
221                Err(err) => {
222                    if cfg!(debug_assertions) {
223                        eprintln!("keyring write failed for {alias}: {err:?}");
224                    }
225                }
226            }
227        }
228        self.write_secret_file(alias, api_key)
229    }
230
231    fn read_secret(&self, alias: &str) -> Result<String, AccountStoreError> {
232        if keyring_enabled() {
233            match read_keyring_secret(alias) {
234                Ok(secret) => return Ok(secret.trim().to_string()),
235                Err(KeyringError::NoEntry) => {
236                    // fall back to file
237                }
238                Err(err) => {
239                    if cfg!(debug_assertions) {
240                        eprintln!("keyring read failed for {alias}: {err:?}");
241                    }
242                }
243            }
244        }
245        self.read_secret_file(alias)
246    }
247
248    fn delete_secret(&self, alias: &str) -> Result<(), AccountStoreError> {
249        if keyring_enabled() {
250            if let Err(err) = delete_keyring_secret(alias) {
251                if cfg!(debug_assertions) {
252                    eprintln!("keyring delete failed for {alias}: {err:?}");
253                }
254            }
255        }
256        self.delete_secret_file(alias)
257    }
258
259    fn write_secret_file(&self, alias: &str, api_key: &str) -> Result<(), AccountStoreError> {
260        let path = self.secret_path(alias);
261        let mut file = fs::File::create(&path).map_err(|source| AccountStoreError::Io {
262            path: path.clone(),
263            source,
264        })?;
265        set_restricted_permissions(&path, &mut file)?;
266        file.write_all(api_key.as_bytes())
267            .map_err(|source| AccountStoreError::Io {
268                path: path.clone(),
269                source,
270            })
271    }
272
273    fn read_secret_file(&self, alias: &str) -> Result<String, AccountStoreError> {
274        let path = self.secret_path(alias);
275        let contents = fs::read_to_string(&path).map_err(|source| AccountStoreError::Io {
276            path: path.clone(),
277            source,
278        })?;
279        Ok(contents.trim().to_string())
280    }
281
282    fn delete_secret_file(&self, alias: &str) -> Result<(), AccountStoreError> {
283        let path = self.secret_path(alias);
284        match fs::remove_file(&path) {
285            Ok(()) => Ok(()),
286            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
287            Err(source) => Err(AccountStoreError::Io { path, source }),
288        }
289    }
290
291    fn secret_path(&self, alias: &str) -> PathBuf {
292        self.paths.accounts_dir().join(format!("{alias}.key"))
293    }
294}
295
296fn validate_alias(alias: &str) -> Result<(), AccountStoreError> {
297    if alias.trim().is_empty() {
298        return Err(AccountStoreError::InvalidAlias(
299            "alias cannot be empty".to_string(),
300        ));
301    }
302    if alias.contains('/') || alias.contains('\\') {
303        return Err(AccountStoreError::InvalidAlias(
304            "alias may not contain path separators".to_string(),
305        ));
306    }
307    if alias.contains("..") {
308        return Err(AccountStoreError::InvalidAlias(
309            "alias may not contain parent directory segments".to_string(),
310        ));
311    }
312    Ok(())
313}
314
315#[cfg(unix)]
316fn set_restricted_permissions(path: &Path, file: &mut fs::File) -> Result<(), AccountStoreError> {
317    use std::os::unix::fs::PermissionsExt;
318    let mut perms = file
319        .metadata()
320        .map_err(|source| AccountStoreError::Io {
321            path: path.to_path_buf(),
322            source,
323        })?
324        .permissions();
325    perms.set_mode(0o600);
326    fs::set_permissions(path, perms).map_err(|source| AccountStoreError::Io {
327        path: path.to_path_buf(),
328        source,
329    })
330}
331
332#[cfg(not(unix))]
333fn set_restricted_permissions(_path: &Path, _file: &mut fs::File) -> Result<(), AccountStoreError> {
334    Ok(())
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
338pub struct AccountProfile {
339    #[serde(default)]
340    pub label: Option<String>,
341    #[serde(default)]
342    pub email: Option<String>,
343    #[serde(default)]
344    pub description: Option<String>,
345}
346
347/// Metadata exposed when listing accounts.
348#[derive(Debug, Clone, PartialEq, Eq)]
349pub struct AccountSummary {
350    pub alias: String,
351    pub profile: AccountProfile,
352    pub is_default: bool,
353    pub daily_budget: Option<NonZeroU32>,
354}
355
356/// Exported account metadata for sharing without secrets.
357#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
358pub struct AccountExport {
359    pub alias: String,
360    pub is_default: bool,
361    pub label: Option<String>,
362    pub email: Option<String>,
363    pub description: Option<String>,
364    pub daily_budget: Option<u32>,
365}
366
367/// Full account record with secrets.
368#[derive(Debug, Clone, PartialEq, Eq)]
369pub struct AccountRecord {
370    pub alias: String,
371    pub api_key: String,
372    pub profile: AccountProfile,
373    pub is_default: bool,
374    pub daily_budget: Option<NonZeroU32>,
375}
376
377#[derive(Debug, Default, Serialize, Deserialize)]
378struct AccountsState {
379    #[serde(default)]
380    default_account: Option<String>,
381    #[serde(default)]
382    accounts: BTreeMap<String, AccountProfile>,
383    #[serde(default)]
384    settings: BTreeMap<String, AccountSettings>,
385}
386
387#[derive(Debug, Clone, Default, Serialize, Deserialize)]
388struct AccountSettings {
389    #[serde(default)]
390    daily_budget: Option<u32>,
391}
392
393impl AccountsState {
394    fn daily_budget(&self, alias: &str) -> Option<NonZeroU32> {
395        self.settings
396            .get(alias)
397            .and_then(|settings| settings.daily_budget)
398            .and_then(NonZeroU32::new)
399    }
400}
401
402#[derive(thiserror::Error, Debug)]
403pub enum AccountStoreError {
404    #[error("configuration error: {0}")]
405    Config(#[from] ConfigError),
406    #[error("I/O error at {path:?}")]
407    Io {
408        path: PathBuf,
409        #[source]
410        source: std::io::Error,
411    },
412    #[error("failed to parse accounts file: {0}")]
413    Deserialize(serde_json::Error),
414    #[error("failed to serialise accounts file: {0}")]
415    Serialize(serde_json::Error),
416    #[error("account alias already exists: {0}")]
417    DuplicateAlias(String),
418    #[error("account alias is invalid: {0}")]
419    InvalidAlias(String),
420    #[error("account not found: {0}")]
421    AccountNotFound(String),
422    #[error("API key cannot be empty")]
423    EmptyApiKey,
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use tempfile::tempdir;
430
431    fn store_for_test() -> (AccountStore, tempfile::TempDir) {
432        std::env::set_var("PLAUSIBLE_CLI_DISABLE_KEYRING", "1");
433        let tmp = tempdir().expect("tmpdir");
434        let paths = ConfigPaths::from_base_dir(tmp.path());
435        let store = AccountStore::new(paths).expect("store");
436        (store, tmp)
437    }
438
439    #[test]
440    fn add_account_persists_metadata_and_secret() {
441        let (store, _tmp) = store_for_test();
442        store
443            .add_account(
444                "prod",
445                "test-key",
446                AccountProfile {
447                    label: Some("Production".into()),
448                    email: Some("ops@example.com".into()),
449                    description: Some("Primary account".into()),
450                },
451            )
452            .expect("add account");
453
454        let accounts = store.list_accounts().expect("list");
455        assert_eq!(accounts.len(), 1);
456        let summary = &accounts[0];
457        assert_eq!(summary.alias, "prod");
458        assert!(summary.is_default);
459        assert_eq!(summary.profile.label.as_deref(), Some("Production"));
460
461        let record = store.get_account("prod").expect("get account");
462        assert_eq!(record.api_key, "test-key");
463    }
464
465    #[test]
466    fn cannot_add_duplicate_alias() {
467        let (store, _tmp) = store_for_test();
468        store
469            .add_account("dup", "k1", AccountProfile::default())
470            .expect("add first");
471        let err = store
472            .add_account("dup", "k2", AccountProfile::default())
473            .expect_err("duplicate");
474        assert!(matches!(
475            err,
476            AccountStoreError::DuplicateAlias(alias) if alias == "dup"
477        ));
478    }
479
480    #[test]
481    fn set_default_updates_state() {
482        let (store, _tmp) = store_for_test();
483        store
484            .add_account("a", "ka", AccountProfile::default())
485            .expect("add a");
486        store
487            .add_account("b", "kb", AccountProfile::default())
488            .expect("add b");
489
490        store.set_default("b").expect("set default");
491        let accounts = store.list_accounts().expect("list");
492        let defaults: Vec<_> = accounts
493            .into_iter()
494            .filter(|summary| summary.is_default)
495            .collect();
496        assert_eq!(defaults.len(), 1);
497        assert_eq!(defaults[0].alias, "b");
498    }
499
500    #[test]
501    fn remove_account_drops_secret_and_selects_new_default() {
502        let (store, _tmp) = store_for_test();
503        store
504            .add_account("first", "k1", AccountProfile::default())
505            .expect("add first");
506        store
507            .add_account("second", "k2", AccountProfile::default())
508            .expect("add second");
509
510        store.remove_account("first").expect("remove account");
511        let accounts = store.list_accounts().expect("list");
512        assert_eq!(accounts.len(), 1);
513        assert_eq!(accounts[0].alias, "second");
514        assert!(accounts[0].is_default);
515        assert!(store.get_account("first").is_err());
516    }
517
518    #[test]
519    fn set_daily_budget_updates_state() {
520        let (store, _tmp) = store_for_test();
521        store
522            .add_account("acct", "key", AccountProfile::default())
523            .expect("add");
524
525        let budget = NonZeroU32::new(500).unwrap();
526        store
527            .set_daily_budget("acct", Some(budget))
528            .expect("set budget");
529
530        let record = store.get_account("acct").expect("record");
531        assert_eq!(record.daily_budget, Some(budget));
532
533        store.set_daily_budget("acct", None).expect("clear budget");
534        let record = store.get_account("acct").expect("record");
535        assert!(record.daily_budget.is_none());
536    }
537
538    #[test]
539    fn alias_validation_rejects_path_segments() {
540        assert!(validate_alias("valid-alias").is_ok());
541        assert!(validate_alias("nested/alias").is_err());
542        assert!(validate_alias("..").is_err());
543        assert!(validate_alias("").is_err());
544    }
545}