1use std::path::Path;
29use std::time::Duration;
30
31use serde_json::{Map, Value};
32
33use crate::config::AnthropicAccount;
34use crate::error::{AppError, Result};
35
36const CLAUDE_JSON: &str = ".claude.json";
39
40pub trait CredentialStore {
43 fn read_default(&self) -> Result<Option<String>>;
44 fn write_default(&self, blob: &str) -> Result<()>;
45 fn delete_default(&self) -> Result<()>;
46 fn read_named(&self, config_dir: &Path) -> Result<Option<String>>;
47 fn write_named(&self, config_dir: &Path, blob: &str) -> Result<()>;
48 fn delete_named(&self, config_dir: &Path) -> Result<()>;
49}
50
51pub struct KeychainStore;
55
56pub fn home_claude_json() -> Result<std::path::PathBuf> {
58 Ok(crate::cache::home_dir()?.join(CLAUDE_JSON))
59}
60
61pub fn marker_path(config_dir: &Path) -> std::path::PathBuf {
63 config_dir.join(CLAUDE_JSON)
64}
65
66pub fn account_uuid_in(claude_json: &Path) -> Option<String> {
68 oauth_account_in(claude_json)?
69 .get("accountUuid")?
70 .as_str()
71 .filter(|uuid| !uuid.is_empty())
72 .map(str::to_string)
73}
74
75pub fn account_email_in(claude_json: &Path) -> Option<String> {
77 oauth_account_in(claude_json)?
78 .get("emailAddress")?
79 .as_str()
80 .filter(|email| !email.is_empty())
81 .map(str::to_string)
82}
83
84pub fn resolve_active_label(
91 home_claude_json: &Path,
92 accounts: &[AnthropicAccount],
93) -> Option<String> {
94 let live = account_uuid_in(home_claude_json)?;
95 accounts
96 .iter()
97 .find(|account| {
98 account_uuid_in(&account.config_dir().join(CLAUDE_JSON)) == Some(live.clone())
99 })
100 .map(|account| account.label.clone())
101}
102
103pub fn merge_oauth_account(existing: &[u8], oauth_account: Option<&Value>) -> Result<Vec<u8>> {
107 let mut document: Value = if existing.iter().all(u8::is_ascii_whitespace) {
108 Value::Object(Map::new())
109 } else {
110 serde_json::from_slice(existing)?
111 };
112 let object = document
113 .as_object_mut()
114 .ok_or_else(|| AppError::Other(format!("{CLAUDE_JSON} is not a JSON object")))?;
115 match oauth_account {
116 Some(account) => {
117 object.insert("oauthAccount".into(), account.clone());
118 }
119 None => {
121 object.remove("oauthAccount");
122 }
123 }
124 Ok(serde_json::to_vec(&document)?)
125}
126
127#[derive(Debug, Clone, Copy, Default)]
128pub struct CliSwitchOpts {
129 pub force: bool,
133 pub dry_run: bool,
135}
136
137#[derive(Debug, PartialEq, Eq)]
138pub enum CliSwitchOutcome {
139 AlreadyActive,
140 RemovedDuplicate,
143 WouldRemoveDuplicate,
145 RepairedActive,
148 Switched {
151 outgoing: Option<String>,
152 },
153 WouldSwitch {
155 outgoing: Option<String>,
156 },
157}
158
159pub fn switch_cli_account(
167 home_claude_json: &Path,
168 accounts: &[AnthropicAccount],
169 label: &str,
170 opts: CliSwitchOpts,
171 store: &dyn CredentialStore,
172) -> Result<CliSwitchOutcome> {
173 let lock_path = home_claude_json
174 .parent()
175 .unwrap_or_else(|| Path::new("."))
176 .join(".ai-usagebar-account-switch.lock");
177 let _lock = crate::cache::acquire_lock(&lock_path, Duration::from_secs(2))?;
178
179 let target = accounts
180 .iter()
181 .find(|account| account.label == label)
182 .ok_or_else(|| {
183 let known: Vec<&str> = accounts.iter().map(|a| a.label.as_str()).collect();
184 AppError::Credentials(format!(
185 "no Claude CLI account {label:?} in [[anthropic.accounts]] or accounts_dir; \
186 known: {known:?}"
187 ))
188 })?;
189
190 let active = resolve_active_label(home_claude_json, accounts);
191
192 let original_default = store.read_default()?;
196 if active.as_deref() == Some(label) && original_default.is_some() {
197 if store.read_named(&target.config_dir())?.is_none() {
198 return Ok(CliSwitchOutcome::AlreadyActive);
199 }
200 if opts.dry_run {
201 return Ok(CliSwitchOutcome::WouldRemoveDuplicate);
202 }
203 store.delete_named(&target.config_dir())?;
204 return Ok(CliSwitchOutcome::RemovedDuplicate);
205 }
206 if active.is_none() && original_default.is_some() && !opts.force {
207 return Err(AppError::Credentials(format!(
208 "the `claude` CLI is signed into an account that is not managed here, so \
209 switching to {label:?} would overwrite a login that cannot be saved first. \
210 Register it with `ai-usagebar account add <label>`, or pass --force to \
211 discard it."
212 )));
213 }
214
215 let target_blob = store.read_named(&target.config_dir())?.ok_or_else(|| {
217 AppError::Credentials(format!(
218 "no stored credential for {label:?}; sign it in once with \
219 `ai-usagebar account add {label}`"
220 ))
221 })?;
222 let oauth_account =
223 oauth_account_in(&target.config_dir().join(CLAUDE_JSON)).ok_or_else(|| {
224 AppError::Credentials(format!(
225 "the identity marker for {label:?} is missing or invalid; sign it in again with \
226 `ai-usagebar account add {label}` before switching"
227 ))
228 })?;
229 let original_marker = read_optional(home_claude_json)?;
230 let merged_marker = merge_oauth_account(
231 original_marker.as_deref().unwrap_or_default(),
232 Some(&oauth_account),
233 )?;
234
235 let outgoing_account = active
236 .as_deref()
237 .and_then(|outgoing| accounts.iter().find(|account| account.label == outgoing));
238 let original_outgoing_named = match outgoing_account {
239 Some(account) => store.read_named(&account.config_dir())?,
240 None => None,
241 };
242
243 if opts.dry_run {
244 return Ok(CliSwitchOutcome::WouldSwitch { outgoing: active });
245 }
246
247 let rollback = RollbackState {
248 target,
249 target_blob: &target_blob,
250 outgoing: outgoing_account,
251 original_outgoing_named: original_outgoing_named.as_deref(),
252 original_default: original_default.as_deref(),
253 marker_path: home_claude_json,
254 original_marker: original_marker.as_deref(),
255 };
256
257 if let (Some(account), Some(blob)) = (outgoing_account, original_default.as_deref())
258 && let Err(error) = store.write_named(&account.config_dir(), blob)
259 {
260 return Err(with_rollback(error, rollback_switch(store, &rollback)));
261 }
262 if let Err(error) = store.write_default(&target_blob) {
263 return Err(with_rollback(error, rollback_switch(store, &rollback)));
264 }
265
266 if let Err(error) = crate::cache::atomic_write(home_claude_json, &merged_marker) {
267 return Err(with_rollback(error, rollback_switch(store, &rollback)));
268 }
269 if let Err(error) = store.delete_named(&target.config_dir()) {
270 return Err(with_rollback(error, rollback_switch(store, &rollback)));
271 }
272
273 if active.as_deref() == Some(label) {
274 Ok(CliSwitchOutcome::RepairedActive)
275 } else {
276 Ok(CliSwitchOutcome::Switched { outgoing: active })
277 }
278}
279
280struct RollbackState<'a> {
281 target: &'a AnthropicAccount,
282 target_blob: &'a str,
283 outgoing: Option<&'a AnthropicAccount>,
284 original_outgoing_named: Option<&'a str>,
285 original_default: Option<&'a str>,
286 marker_path: &'a Path,
287 original_marker: Option<&'a [u8]>,
288}
289
290fn rollback_switch(store: &dyn CredentialStore, state: &RollbackState<'_>) -> Result<()> {
291 let mut failures = Vec::new();
292 if let Err(error) = restore_default(store, state.original_default) {
293 failures.push(format!("default credential: {error}"));
294 }
295 if let Err(error) = store.write_named(&state.target.config_dir(), state.target_blob) {
298 failures.push(format!("target credential: {error}"));
299 }
300 if let Some(account) = state.outgoing
301 && let Err(error) =
302 restore_named(store, &account.config_dir(), state.original_outgoing_named)
303 {
304 failures.push(format!("outgoing credential: {error}"));
305 }
306 if let Err(error) = restore_marker(state.marker_path, state.original_marker) {
307 failures.push(format!("identity marker: {error}"));
308 }
309 if failures.is_empty() {
310 Ok(())
311 } else {
312 Err(AppError::Other(failures.join("; ")))
313 }
314}
315
316fn restore_default(store: &dyn CredentialStore, original: Option<&str>) -> Result<()> {
317 match original {
318 Some(blob) => store.write_default(blob),
319 None => store.delete_default(),
320 }
321}
322
323fn restore_named(store: &dyn CredentialStore, path: &Path, original: Option<&str>) -> Result<()> {
324 match original {
325 Some(blob) => store.write_named(path, blob),
326 None => store.delete_named(path),
327 }
328}
329
330fn restore_marker(path: &Path, original: Option<&[u8]>) -> Result<()> {
331 if let Some(bytes) = original {
332 crate::cache::atomic_write(path, bytes)
333 } else {
334 match std::fs::remove_file(path) {
335 Ok(()) => Ok(()),
336 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
337 Err(error) => Err(AppError::io_at(path, error)),
338 }
339 }
340}
341
342fn read_optional(path: &Path) -> Result<Option<Vec<u8>>> {
343 match std::fs::read(path) {
344 Ok(bytes) => Ok(Some(bytes)),
345 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
346 Err(error) => Err(AppError::io_at(path, error)),
347 }
348}
349
350fn with_rollback(error: AppError, rollback: Result<()>) -> AppError {
351 match rollback {
352 Ok(()) => error,
353 Err(rollback) => AppError::Other(format!(
354 "{error}; automatic rollback was incomplete: {rollback}"
355 )),
356 }
357}
358
359fn oauth_account_in(claude_json: &Path) -> Option<Value> {
360 let bytes = std::fs::read(claude_json).ok()?;
361 let document: Value = serde_json::from_slice(&bytes).ok()?;
362 document
363 .get("oauthAccount")
364 .filter(|v| v.is_object())
365 .cloned()
366}
367
368#[cfg(not(target_os = "macos"))]
369fn unsupported() -> AppError {
370 AppError::Credentials(
371 "switching the `claude` CLI login is supported on macOS only (elsewhere, run \
372 `CLAUDE_CONFIG_DIR=<account dir> claude` to use a specific account)"
373 .into(),
374 )
375}
376
377impl CredentialStore for KeychainStore {
378 fn read_default(&self) -> Result<Option<String>> {
379 #[cfg(target_os = "macos")]
380 return super::keychain::read_raw();
381 #[cfg(not(target_os = "macos"))]
382 Err(unsupported())
383 }
384
385 fn write_default(&self, blob: &str) -> Result<()> {
386 #[cfg(target_os = "macos")]
387 return super::keychain::write_raw(blob);
388 #[cfg(not(target_os = "macos"))]
389 {
390 let _ = blob;
391 Err(unsupported())
392 }
393 }
394
395 fn delete_default(&self) -> Result<()> {
396 #[cfg(target_os = "macos")]
397 return super::keychain::delete_raw();
398 #[cfg(not(target_os = "macos"))]
399 Err(unsupported())
400 }
401
402 fn read_named(&self, config_dir: &Path) -> Result<Option<String>> {
403 #[cfg(target_os = "macos")]
404 return super::keychain::read_raw_for(config_dir);
405 #[cfg(not(target_os = "macos"))]
406 {
407 let _ = config_dir;
408 Err(unsupported())
409 }
410 }
411
412 fn write_named(&self, config_dir: &Path, blob: &str) -> Result<()> {
413 #[cfg(target_os = "macos")]
414 return super::keychain::write_raw_for(config_dir, blob);
415 #[cfg(not(target_os = "macos"))]
416 {
417 let _ = (config_dir, blob);
418 Err(unsupported())
419 }
420 }
421
422 fn delete_named(&self, config_dir: &Path) -> Result<()> {
423 #[cfg(target_os = "macos")]
424 return super::keychain::delete_raw_for(config_dir);
425 #[cfg(not(target_os = "macos"))]
426 {
427 let _ = config_dir;
428 Err(unsupported())
429 }
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use std::cell::RefCell;
437 use std::collections::BTreeMap;
438 use std::path::PathBuf;
439
440 #[derive(Default)]
441 struct FakeStore {
442 default: RefCell<Option<String>>,
443 named: RefCell<BTreeMap<PathBuf, String>>,
444 fail_delete_named: RefCell<Option<PathBuf>>,
445 }
446
447 impl CredentialStore for FakeStore {
448 fn read_default(&self) -> Result<Option<String>> {
449 Ok(self.default.borrow().clone())
450 }
451 fn write_default(&self, blob: &str) -> Result<()> {
452 *self.default.borrow_mut() = Some(blob.to_string());
453 Ok(())
454 }
455 fn delete_default(&self) -> Result<()> {
456 *self.default.borrow_mut() = None;
457 Ok(())
458 }
459 fn read_named(&self, config_dir: &Path) -> Result<Option<String>> {
460 Ok(self.named.borrow().get(config_dir).cloned())
461 }
462 fn write_named(&self, config_dir: &Path, blob: &str) -> Result<()> {
463 self.named
464 .borrow_mut()
465 .insert(config_dir.to_path_buf(), blob.to_string());
466 Ok(())
467 }
468 fn delete_named(&self, config_dir: &Path) -> Result<()> {
469 self.named.borrow_mut().remove(config_dir);
470 if self.fail_delete_named.borrow().as_deref() == Some(config_dir) {
471 Err(AppError::Other("injected delete failure".into()))
472 } else {
473 Ok(())
474 }
475 }
476 }
477
478 fn write(path: &Path, contents: &str) {
479 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
480 std::fs::write(path, contents).unwrap();
481 }
482
483 fn marker(uuid: &str, email: &str) -> String {
484 format!(r#"{{"oauthAccount":{{"accountUuid":"{uuid}","emailAddress":"{email}"}}}}"#)
485 }
486
487 struct Fixture {
488 _root: tempfile::TempDir,
489 home: PathBuf,
490 accounts: Vec<AnthropicAccount>,
491 store: FakeStore,
492 }
493
494 fn fixture() -> Fixture {
496 let root = tempfile::TempDir::new().unwrap();
497 let home = root.path().join("home").join(CLAUDE_JSON);
498 write(&home, &marker("uuid-personal", "me@personal.test"));
499
500 let accounts: Vec<AnthropicAccount> = ["work", "personal"]
501 .iter()
502 .map(|label| AnthropicAccount {
503 label: (*label).to_string(),
504 credentials_path: root
505 .path()
506 .join("accounts")
507 .join(label)
508 .join(".credentials.json"),
509 })
510 .collect();
511 write(
512 &accounts[0].config_dir().join(CLAUDE_JSON),
513 &marker("uuid-work", "me@work.test"),
514 );
515 write(
516 &accounts[1].config_dir().join(CLAUDE_JSON),
517 &marker("uuid-personal", "me@personal.test"),
518 );
519
520 let store = FakeStore::default();
521 *store.default.borrow_mut() = Some("personal-live".into());
522 store
523 .named
524 .borrow_mut()
525 .insert(accounts[0].config_dir(), "work-saved".into());
526 store
527 .named
528 .borrow_mut()
529 .insert(accounts[1].config_dir(), "personal-stale".into());
530
531 Fixture {
532 _root: root,
533 home,
534 accounts,
535 store,
536 }
537 }
538
539 #[test]
540 fn the_live_login_resolves_to_its_label() {
541 let f = fixture();
542 assert_eq!(
543 resolve_active_label(&f.home, &f.accounts).as_deref(),
544 Some("personal")
545 );
546 assert_eq!(
547 account_email_in(&f.home).as_deref(),
548 Some("me@personal.test")
549 );
550 }
551
552 #[test]
553 fn an_unmanaged_login_resolves_to_nothing() {
554 let f = fixture();
555 write(&f.home, &marker("uuid-stranger", "who@example.test"));
556 assert_eq!(resolve_active_label(&f.home, &f.accounts), None);
557 }
558
559 #[test]
560 fn a_missing_marker_file_is_not_an_error() {
561 let f = fixture();
562 std::fs::remove_file(&f.home).unwrap();
563 assert_eq!(resolve_active_label(&f.home, &f.accounts), None);
564 assert_eq!(account_uuid_in(&f.home), None);
565 }
566
567 #[test]
568 fn switching_captures_the_outgoing_credential_first() {
569 let f = fixture();
570
571 let outcome = switch_cli_account(
572 &f.home,
573 &f.accounts,
574 "work",
575 CliSwitchOpts::default(),
576 &f.store,
577 )
578 .unwrap();
579
580 assert_eq!(
581 outcome,
582 CliSwitchOutcome::Switched {
583 outgoing: Some("personal".into())
584 }
585 );
586 assert_eq!(
589 f.store.named.borrow()[&f.accounts[1].config_dir()],
590 "personal-live"
591 );
592 assert_eq!(f.store.default.borrow().as_deref(), Some("work-saved"));
593 assert!(
594 !f.store
595 .named
596 .borrow()
597 .contains_key(&f.accounts[0].config_dir()),
598 "the active credential must be moved, not left as a rotating-token copy"
599 );
600 assert_eq!(
601 resolve_active_label(&f.home, &f.accounts).as_deref(),
602 Some("work")
603 );
604 }
605
606 #[test]
607 fn switching_is_idempotent() {
608 let f = fixture();
609 let outcome = switch_cli_account(
610 &f.home,
611 &f.accounts,
612 "personal",
613 CliSwitchOpts::default(),
614 &f.store,
615 )
616 .unwrap();
617 assert_eq!(outcome, CliSwitchOutcome::RemovedDuplicate);
618 assert_eq!(f.store.default.borrow().as_deref(), Some("personal-live"));
619 assert!(
620 !f.store
621 .named
622 .borrow()
623 .contains_key(&f.accounts[1].config_dir())
624 );
625
626 let second = switch_cli_account(
627 &f.home,
628 &f.accounts,
629 "personal",
630 CliSwitchOpts::default(),
631 &f.store,
632 )
633 .unwrap();
634 assert_eq!(second, CliSwitchOutcome::AlreadyActive);
635 }
636
637 #[test]
638 fn an_active_marker_with_an_empty_default_slot_is_repaired() {
639 let f = fixture();
640 *f.store.default.borrow_mut() = None;
641
642 let outcome = switch_cli_account(
643 &f.home,
644 &f.accounts,
645 "personal",
646 CliSwitchOpts::default(),
647 &f.store,
648 )
649 .unwrap();
650
651 assert_eq!(outcome, CliSwitchOutcome::RepairedActive);
652 assert_eq!(f.store.default.borrow().as_deref(), Some("personal-stale"));
653 assert!(
654 !f.store
655 .named
656 .borrow()
657 .contains_key(&f.accounts[1].config_dir())
658 );
659 }
660
661 #[test]
662 fn a_dry_run_validates_without_writing() {
663 let f = fixture();
664 let before = std::fs::read(&f.home).unwrap();
665
666 let outcome = switch_cli_account(
667 &f.home,
668 &f.accounts,
669 "work",
670 CliSwitchOpts {
671 dry_run: true,
672 ..CliSwitchOpts::default()
673 },
674 &f.store,
675 )
676 .unwrap();
677
678 assert_eq!(
679 outcome,
680 CliSwitchOutcome::WouldSwitch {
681 outgoing: Some("personal".into())
682 }
683 );
684 assert_eq!(f.store.default.borrow().as_deref(), Some("personal-live"));
685 assert_eq!(std::fs::read(&f.home).unwrap(), before);
686 }
687
688 #[test]
689 fn an_unmanaged_live_login_is_refused_without_force() {
690 let f = fixture();
691 write(&f.home, &marker("uuid-stranger", "who@example.test"));
692
693 let error = switch_cli_account(
694 &f.home,
695 &f.accounts,
696 "work",
697 CliSwitchOpts::default(),
698 &f.store,
699 )
700 .unwrap_err();
701 assert!(error.to_string().contains("--force"), "{error}");
702 assert_eq!(f.store.default.borrow().as_deref(), Some("personal-live"));
703
704 switch_cli_account(
705 &f.home,
706 &f.accounts,
707 "work",
708 CliSwitchOpts {
709 force: true,
710 ..CliSwitchOpts::default()
711 },
712 &f.store,
713 )
714 .unwrap();
715 assert_eq!(f.store.default.borrow().as_deref(), Some("work-saved"));
716 assert!(
717 !f.store
718 .named
719 .borrow()
720 .contains_key(&f.accounts[0].config_dir())
721 );
722 }
723
724 #[test]
725 fn an_empty_default_slot_does_not_require_force() {
726 let f = fixture();
727 *f.store.default.borrow_mut() = None;
728 std::fs::remove_file(&f.home).unwrap();
729
730 switch_cli_account(
731 &f.home,
732 &f.accounts,
733 "work",
734 CliSwitchOpts::default(),
735 &f.store,
736 )
737 .unwrap();
738
739 assert_eq!(f.store.default.borrow().as_deref(), Some("work-saved"));
740 assert_eq!(
741 resolve_active_label(&f.home, &f.accounts).as_deref(),
742 Some("work")
743 );
744 }
745
746 #[test]
747 fn a_target_without_an_identity_marker_changes_nothing() {
748 let f = fixture();
749 std::fs::remove_file(f.accounts[0].config_dir().join(CLAUDE_JSON)).unwrap();
750 let before = std::fs::read(&f.home).unwrap();
751
752 let error = switch_cli_account(
753 &f.home,
754 &f.accounts,
755 "work",
756 CliSwitchOpts::default(),
757 &f.store,
758 )
759 .unwrap_err();
760
761 assert!(error.to_string().contains("identity marker"), "{error}");
762 assert_eq!(f.store.default.borrow().as_deref(), Some("personal-live"));
763 assert_eq!(std::fs::read(&f.home).unwrap(), before);
764 }
765
766 #[test]
767 fn a_late_failure_restores_every_credential_slot_and_marker() {
768 let f = fixture();
769 let before_marker = std::fs::read(&f.home).unwrap();
770 *f.store.fail_delete_named.borrow_mut() = Some(f.accounts[0].config_dir());
771
772 let error = switch_cli_account(
773 &f.home,
774 &f.accounts,
775 "work",
776 CliSwitchOpts::default(),
777 &f.store,
778 )
779 .unwrap_err();
780
781 assert!(
782 error.to_string().contains("injected delete failure"),
783 "{error}"
784 );
785 assert_eq!(f.store.default.borrow().as_deref(), Some("personal-live"));
786 assert_eq!(
787 f.store.named.borrow()[&f.accounts[0].config_dir()],
788 "work-saved"
789 );
790 assert_eq!(
791 f.store.named.borrow()[&f.accounts[1].config_dir()],
792 "personal-stale"
793 );
794 assert_eq!(std::fs::read(&f.home).unwrap(), before_marker);
795 }
796
797 #[test]
798 fn switching_to_an_account_that_never_signed_in_changes_nothing() {
799 let f = fixture();
800 f.store
801 .named
802 .borrow_mut()
803 .remove(&f.accounts[0].config_dir());
804
805 let error = switch_cli_account(
806 &f.home,
807 &f.accounts,
808 "work",
809 CliSwitchOpts::default(),
810 &f.store,
811 )
812 .unwrap_err();
813 assert!(error.to_string().contains("account add work"), "{error}");
814 assert_eq!(f.store.default.borrow().as_deref(), Some("personal-live"));
815 }
816
817 #[test]
818 fn merging_the_marker_preserves_unrelated_state() {
819 let existing = br#"{"firstStartTime":"2026-01-01","oauthAccount":{"accountUuid":"old"},
820 "projects":{"/tmp/x":{"allowedTools":[]}}}"#;
821 let replacement = serde_json::json!({"accountUuid": "new", "emailAddress": "a@b.test"});
822
823 let bytes = merge_oauth_account(existing, Some(&replacement)).unwrap();
824 let value: Value = serde_json::from_slice(&bytes).unwrap();
825 assert_eq!(value["oauthAccount"]["accountUuid"], "new");
826 assert_eq!(value["firstStartTime"], "2026-01-01");
827 assert!(value["projects"]["/tmp/x"].is_object());
828 }
829
830 #[test]
831 fn merging_no_marker_clears_a_stale_one() {
832 let existing = br#"{"oauthAccount":{"accountUuid":"old"},"autoUpdates":true}"#;
833 let bytes = merge_oauth_account(existing, None).unwrap();
834 let value: Value = serde_json::from_slice(&bytes).unwrap();
835 assert!(value.get("oauthAccount").is_none());
836 assert_eq!(value["autoUpdates"], true);
837 }
838
839 #[test]
840 fn merging_into_an_absent_file_starts_a_fresh_document() {
841 let replacement = serde_json::json!({"accountUuid": "new"});
842 let bytes = merge_oauth_account(b"", Some(&replacement)).unwrap();
843 let value: Value = serde_json::from_slice(&bytes).unwrap();
844 assert_eq!(value["oauthAccount"]["accountUuid"], "new");
845 }
846}