Skip to main content

xurl/store/
mod.rs

1//! Token persistence layer — multi-app YAML store at `~/.xurl`.
2//!
3//! Supports:
4//! - Multi-app credential and token management
5//! - `OAuth2`, `OAuth1`, and Bearer token types
6//! - Legacy JSON migration (auto-converts old format)
7//! - `.twurlrc` import (legacy Twitter CLI compatibility)
8//! - Credential backfill from environment variables
9
10mod migration;
11mod tokens;
12pub mod types;
13
14use std::collections::BTreeMap;
15use std::fs;
16use std::path::PathBuf;
17
18#[allow(unused_imports)] // Re-exported for library consumers and integration tests
19pub use types::{App, OAuth1Token, OAuth2Token, Token, TokenType};
20
21use crate::error::{Result, XurlError};
22
23// ── TokenStore ───────────────────────────────────────────────────────
24
25/// Manages authentication tokens across multiple apps.
26///
27/// The in-memory shape mirrors the on-disk YAML at `~/.xurl`. Library
28/// consumers construct via [`TokenStore::new`] (legacy default path),
29/// [`TokenStore::new_with_path`] (explicit path, no auto-import), or
30/// [`TokenStore::with_credentials`] (auto-backfill).
31///
32/// # Example
33///
34/// ```rust,no_run
35/// use xurl::store::TokenStore;
36///
37/// let store = TokenStore::new_with_path("/tmp/my-xurl-store.yaml");
38/// let active = store.get_default_app();
39/// if let Some(app) = store.get_app(active) {
40///     println!("active app {active} has {} oauth2 users", app.oauth2_tokens.len());
41/// }
42/// ```
43pub struct TokenStore {
44    /// All registered apps, keyed by name.
45    pub apps: BTreeMap<String, App>,
46    /// Name of the default app selected when `--app` is not supplied.
47    pub default_app: String,
48    /// Path to the YAML file backing this store.
49    pub file_path: PathBuf,
50}
51
52impl Default for TokenStore {
53    /// Constructs a `TokenStore` from the default location, identical to
54    /// calling [`TokenStore::new`].
55    fn default() -> Self {
56        Self::new()
57    }
58}
59
60#[allow(dead_code)] // Public library API — used by consumers and integration tests
61impl TokenStore {
62    /// Creates a new `TokenStore`, loading from `~/.xurl` (auto-migrating legacy JSON).
63    #[must_use]
64    pub fn new() -> Self {
65        Self::with_credentials("", "")
66    }
67
68    /// Creates a `TokenStore` and backfills the given client credentials into any
69    /// app that was migrated without them.
70    #[must_use]
71    pub fn with_credentials(client_id: &str, client_secret: &str) -> Self {
72        let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
73        let file_path = home_dir.join(".xurl");
74
75        let mut store = TokenStore {
76            apps: BTreeMap::new(),
77            default_app: String::new(),
78            file_path,
79        };
80
81        if let Ok(data) = fs::read(&store.file_path) {
82            store.load_from_data(&data);
83        }
84
85        // Backfill credentials into any app that has tokens but no client ID/secret
86        if !client_id.is_empty() || !client_secret.is_empty() {
87            let mut dirty = false;
88            for app in store.apps.values_mut() {
89                if app.has_tokens() {
90                    if app.client_id.is_empty() && !client_id.is_empty() {
91                        app.client_id = client_id.to_string();
92                        dirty = true;
93                    }
94                    if app.client_secret.is_empty() && !client_secret.is_empty() {
95                        app.client_secret = client_secret.to_string();
96                        dirty = true;
97                    }
98                }
99            }
100            if dirty {
101                let _ = store.save_to_file();
102            }
103        }
104
105        // Ensure a default app exists (matches Go: NewTokenStore always returns a usable store)
106        if store.apps.is_empty() {
107            store.default_app = "default".to_string();
108            store.apps.insert("default".to_string(), App::new());
109        }
110
111        // Import from .twurlrc if we have no apps or the default app is missing OAuth1/Bearer
112        let needs_import = match store.active_app() {
113            None => true,
114            Some(app) => app.oauth1_token.is_none() || app.bearer_token.is_none(),
115        };
116        if needs_import {
117            let twurlrc_path = home_dir.join(".twurlrc");
118            if twurlrc_path.exists()
119                && let Err(e) = store.import_from_twurlrc(&twurlrc_path)
120            {
121                crate::output::warn_stderr(&format!("error importing from .twurlrc: {e}"));
122            }
123        }
124
125        store
126    }
127
128    /// Creates a `TokenStore` from a specific file path (no auto-import).
129    #[must_use]
130    pub fn new_with_path(path: &str) -> Self {
131        let file_path = PathBuf::from(path);
132        let mut store = TokenStore {
133            apps: BTreeMap::new(),
134            default_app: String::new(),
135            file_path,
136        };
137        if let Ok(data) = fs::read(&store.file_path) {
138            store.load_from_data(&data);
139        }
140        if store.apps.is_empty() {
141            store.apps.insert("default".to_string(), App::new());
142            store.default_app = "default".to_string();
143        }
144        store
145    }
146
147    /// Creates a `TokenStore` from a specific file path with credential backfill.
148    #[must_use]
149    pub fn new_with_credentials_and_path(client_id: &str, client_secret: &str, path: &str) -> Self {
150        let mut store = Self::new_with_path(path);
151        if !client_id.is_empty() || !client_secret.is_empty() {
152            for app in store.apps.values_mut() {
153                if app.has_tokens() || app.client_id.is_empty() {
154                    if app.client_id.is_empty() && !client_id.is_empty() {
155                        app.client_id = client_id.to_string();
156                    }
157                    if app.client_secret.is_empty() && !client_secret.is_empty() {
158                        app.client_secret = client_secret.to_string();
159                    }
160                }
161            }
162            let _ = store.save_to_file();
163        }
164        store
165    }
166
167    /// Creates a `TokenStore` using a custom home directory (for testing).
168    #[must_use]
169    pub fn new_with_home(home: &str) -> Self {
170        let home_path = PathBuf::from(home);
171        let file_path = home_path.join(".xurl");
172        let mut store = TokenStore {
173            apps: BTreeMap::new(),
174            default_app: String::new(),
175            file_path,
176        };
177        if let Ok(data) = fs::read(&store.file_path) {
178            store.load_from_data(&data);
179        }
180        if store.apps.is_empty() {
181            store.apps.insert("default".to_string(), App::new());
182            store.default_app = "default".to_string();
183        }
184        // Auto-import from .twurlrc if needed
185        let needs_import = match store.active_app() {
186            None => true,
187            Some(app) => app.oauth1_token.is_none(),
188        };
189        if needs_import {
190            let twurlrc_path = home_path.join(".twurlrc");
191            if twurlrc_path.exists() {
192                let _ = store.import_from_twurlrc(&twurlrc_path);
193            }
194        }
195        store
196    }
197
198    /// Loads a `TokenStore` from a specific file path (alias for `new_with_path`).
199    #[must_use]
200    pub fn load_from_path(path: &str) -> Self {
201        Self::new_with_path(path)
202    }
203
204    // ── App management ───────────────────────────────────────────────
205
206    /// Registers a new application. If it's the only app it becomes default.
207    ///
208    /// # Errors
209    ///
210    /// Returns an error if the app name already exists or the store cannot be saved.
211    pub fn add_app(&mut self, name: &str, client_id: &str, client_secret: &str) -> Result<()> {
212        if self.apps.contains_key(name) {
213            return Err(XurlError::token_store(format!(
214                "app {name:?} already exists"
215            )));
216        }
217        self.apps.insert(
218            name.to_string(),
219            App::with_credentials(client_id, client_secret),
220        );
221        if self.apps.len() == 1 {
222            self.default_app = name.to_string();
223        }
224        self.save_to_file()
225    }
226
227    /// Updates the credentials of an existing application.
228    ///
229    /// # Errors
230    ///
231    /// Returns an error if the app is not found or the store cannot be saved.
232    pub fn update_app(&mut self, name: &str, client_id: &str, client_secret: &str) -> Result<()> {
233        let app = self
234            .apps
235            .get_mut(name)
236            .ok_or_else(|| XurlError::token_store(format!("app {name:?} not found")))?;
237        if !client_id.is_empty() {
238            app.client_id = client_id.to_string();
239        }
240        if !client_secret.is_empty() {
241            app.client_secret = client_secret.to_string();
242        }
243        self.save_to_file()
244    }
245
246    /// Removes a registered application and its tokens.
247    ///
248    /// # Errors
249    ///
250    /// Returns an error if the app is not found or the store cannot be saved.
251    pub fn remove_app(&mut self, name: &str) -> Result<()> {
252        if !self.apps.contains_key(name) {
253            return Err(XurlError::token_store(format!("app {name:?} not found")));
254        }
255        self.apps.remove(name);
256        if self.default_app == name {
257            self.default_app = self.apps.keys().next().cloned().unwrap_or_default();
258        }
259        self.save_to_file()
260    }
261
262    /// Sets the default application by name.
263    ///
264    /// # Errors
265    ///
266    /// Returns an error if the app is not found or the store cannot be saved.
267    pub fn set_default_app(&mut self, name: &str) -> Result<()> {
268        if !self.apps.contains_key(name) {
269            return Err(XurlError::token_store(format!("app {name:?} not found")));
270        }
271        self.default_app = name.to_string();
272        self.save_to_file()
273    }
274
275    /// Returns `true` when the currently-resolved default app holds no
276    /// credentials of any kind: no `OAuth2` user tokens, no `OAuth1` token,
277    /// no bearer token, and no unnamed `OAuth2` salvage token. The signal
278    /// used by [`Self::promote_to_default_if_first_credentialed`] to detect
279    /// the placeholder default that a fresh install starts with so the very
280    /// first authenticated app can transparently take over.
281    #[must_use]
282    pub fn default_app_is_uninitialized(&self) -> bool {
283        let app = self.resolve_app("");
284        app.oauth2_tokens.is_empty()
285            && app.oauth1_token.is_none()
286            && app.bearer_token.is_none()
287            && app.unnamed_oauth2_token.is_none()
288    }
289
290    /// Promotes `candidate_app` to the default app iff the current default
291    /// is uninitialized (per [`Self::default_app_is_uninitialized`]) and
292    /// `candidate_app` is registered and different from the current default.
293    /// Returns the new default name when promotion happened, `None` otherwise.
294    ///
295    /// Idempotent: callers can invoke unconditionally after any
296    /// authentication save. No-op on already-credentialed defaults, on
297    /// unknown candidates, on empty candidate names, and when the candidate
298    /// already IS the default.
299    ///
300    /// Drives the "first signed-in app becomes the default" UX so a fresh
301    /// `xr auth oauth2 --app NAME` (or `oauth1`, or `auth app --bearer-token
302    /// --app NAME`) does not leave the placeholder `default` ahead of NAME
303    /// in the resolution chain. Users who want a different default later
304    /// can still run `xr auth default <name>` explicitly; this helper only
305    /// fires on the first authenticated save.
306    ///
307    /// # Errors
308    ///
309    /// Returns an error if [`Self::set_default_app`] fails to persist.
310    pub fn promote_to_default_if_first_credentialed(
311        &mut self,
312        candidate_app: &str,
313    ) -> Result<Option<String>> {
314        if candidate_app.is_empty() || candidate_app == self.default_app {
315            return Ok(None);
316        }
317        if !self.apps.contains_key(candidate_app) {
318            return Ok(None);
319        }
320        if !self.default_app_is_uninitialized() {
321            return Ok(None);
322        }
323        self.set_default_app(candidate_app)?;
324        Ok(Some(candidate_app.to_string()))
325    }
326
327    /// Returns sorted app names.
328    #[must_use]
329    pub fn list_apps(&self) -> Vec<String> {
330        self.apps.keys().cloned().collect()
331    }
332
333    /// Returns an app by name.
334    #[must_use]
335    pub fn get_app(&self, name: &str) -> Option<&App> {
336        self.apps.get(name)
337    }
338
339    /// Sets the default `OAuth2` user for the named (or default) app.
340    ///
341    /// # Errors
342    ///
343    /// Returns an error if the username is not found in the app or the store cannot be saved.
344    pub fn set_default_user(&mut self, app_name: &str, username: &str) -> Result<()> {
345        let app = self.resolve_app_mut(app_name);
346        if !app.oauth2_tokens.contains_key(username) {
347            return Err(XurlError::token_store(format!(
348                "user {username:?} not found in app"
349            )));
350        }
351        app.default_user = username.to_string();
352        self.save_to_file()
353    }
354
355    /// Returns the default `OAuth2` user for the named (or default) app.
356    #[must_use]
357    pub fn get_default_user(&self, app_name: &str) -> &str {
358        let app = self.resolve_app(app_name);
359        &app.default_user
360    }
361
362    /// Sets the stored `OAuth2` redirect URI for the named (or default) app.
363    ///
364    /// An empty `uri` clears the stored value; the next serialize omits the
365    /// field thanks to `#[serde(skip_serializing_if = "String::is_empty")]`.
366    /// A non-empty `uri` is validated via [`crate::config::Config::validate_redirect_uri`]
367    /// before persisting; on validation failure the store is not modified.
368    ///
369    /// # Errors
370    ///
371    /// Returns an error if the URI fails validation or the store cannot be saved.
372    pub fn set_app_redirect_uri(&mut self, name: &str, uri: &str) -> Result<()> {
373        if !name.is_empty() && !self.apps.contains_key(name) {
374            return Err(XurlError::token_store(format!("app {name:?} not found")));
375        }
376
377        if uri.is_empty() {
378            let app = self.resolve_app_mut(name);
379            app.redirect_uri.clear();
380            return self.save_to_file();
381        }
382
383        let _ = crate::config::Config::validate_redirect_uri(uri)?;
384
385        let app = self.resolve_app_mut(name);
386        app.redirect_uri = uri.to_string();
387        self.save_to_file()
388    }
389
390    /// Returns the stored `OAuth2` redirect URI for the named (or default) app.
391    ///
392    /// Returns `None` when the app is absent or its stored URI is empty.
393    #[must_use]
394    pub fn get_app_redirect_uri(&self, name: &str) -> Option<&str> {
395        let app = self.resolve_app(name);
396        if app.redirect_uri.is_empty() {
397            None
398        } else {
399            Some(app.redirect_uri.as_str())
400        }
401    }
402
403    /// Returns the default app name.
404    #[must_use]
405    pub fn get_default_app(&self) -> &str {
406        &self.default_app
407    }
408
409    /// Returns the name of the active app (explicit or default).
410    #[must_use]
411    pub fn get_active_app_name<'a>(&'a self, explicit: &'a str) -> &'a str {
412        if explicit.is_empty() {
413            &self.default_app
414        } else {
415            explicit
416        }
417    }
418
419    /// Returns the current default App, or None.
420    pub(crate) fn active_app(&self) -> Option<&App> {
421        self.apps.get(&self.default_app)
422    }
423
424    /// Returns the active app; creates "default" if none exist.
425    pub(crate) fn active_app_or_create(&mut self) -> &mut App {
426        if !self.apps.contains_key(&self.default_app) {
427            self.apps.insert("default".to_string(), App::new());
428            if self.default_app.is_empty() {
429                self.default_app = "default".to_string();
430            }
431        }
432        let key = if self.apps.contains_key(&self.default_app) {
433            self.default_app.clone()
434        } else {
435            "default".to_string()
436        };
437        self.apps.get_mut(&key).expect("just inserted")
438    }
439
440    /// Returns the app for the given name, or the default app.
441    #[must_use]
442    pub fn resolve_app(&self, name: &str) -> &App {
443        if !name.is_empty()
444            && let Some(app) = self.apps.get(name)
445        {
446            return app;
447        }
448        // Fall back to default app, or a static empty app
449        self.apps.get(&self.default_app).unwrap_or_else(|| {
450            // This is a fallback — should rarely happen
451            static EMPTY: std::sync::LazyLock<App> = std::sync::LazyLock::new(App::new);
452            &EMPTY
453        })
454    }
455
456    /// Returns the app for the given name (mutable), or the default app.
457    ///
458    /// # Panics
459    ///
460    /// Panics if the internal app map is in an inconsistent state (should never
461    /// happen as `active_app_or_create` always inserts a default).
462    pub fn resolve_app_mut(&mut self, name: &str) -> &mut App {
463        if !name.is_empty() && self.apps.contains_key(name) {
464            return self.apps.get_mut(name).expect("just checked");
465        }
466        self.active_app_or_create()
467    }
468
469    // ── Persistence ──────────────────────────────────────────────────
470
471    /// Saves the token store to `~/.xurl` in YAML format.
472    pub(crate) fn save_to_file(&self) -> Result<()> {
473        let sf = types::StoreFile {
474            apps: self.apps.clone(),
475            default_app: self.default_app.clone(),
476        };
477        let data = serde_yaml::to_string(&sf).map_err(|e| XurlError::Json(e.to_string()))?;
478        fs::write(&self.file_path, data)?;
479
480        // Match Go's 0600 permissions
481        #[cfg(unix)]
482        {
483            use std::os::unix::fs::PermissionsExt;
484            let perms = std::fs::Permissions::from_mode(0o600);
485            fs::set_permissions(&self.file_path, perms)?;
486        }
487
488        Ok(())
489    }
490}