1mod migration;
11mod tokens;
12pub mod types;
13
14use std::collections::BTreeMap;
15use std::fs;
16use std::path::PathBuf;
17
18#[allow(unused_imports)] pub use types::{App, OAuth1Token, OAuth2Token, Token, TokenType};
20
21use crate::error::{Result, XurlError};
22
23pub struct TokenStore {
44 pub apps: BTreeMap<String, App>,
46 pub default_app: String,
48 pub file_path: PathBuf,
50}
51
52impl Default for TokenStore {
53 fn default() -> Self {
56 Self::new()
57 }
58}
59
60#[allow(dead_code)] impl TokenStore {
62 #[must_use]
64 pub fn new() -> Self {
65 Self::with_credentials("", "")
66 }
67
68 #[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 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 if store.apps.is_empty() {
107 store.default_app = "default".to_string();
108 store.apps.insert("default".to_string(), App::new());
109 }
110
111 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 #[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 #[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 #[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 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 #[must_use]
200 pub fn load_from_path(path: &str) -> Self {
201 Self::new_with_path(path)
202 }
203
204 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 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 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 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 #[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 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 #[must_use]
329 pub fn list_apps(&self) -> Vec<String> {
330 self.apps.keys().cloned().collect()
331 }
332
333 #[must_use]
335 pub fn get_app(&self, name: &str) -> Option<&App> {
336 self.apps.get(name)
337 }
338
339 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 #[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 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 #[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 #[must_use]
405 pub fn get_default_app(&self) -> &str {
406 &self.default_app
407 }
408
409 #[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 pub(crate) fn active_app(&self) -> Option<&App> {
421 self.apps.get(&self.default_app)
422 }
423
424 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 #[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 self.apps.get(&self.default_app).unwrap_or_else(|| {
450 static EMPTY: std::sync::LazyLock<App> = std::sync::LazyLock::new(App::new);
452 &EMPTY
453 })
454 }
455
456 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 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 #[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}