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
280pub fn adopt_current(
289 home_claude_json: &Path,
290 accounts: &[AnthropicAccount],
291 account: &AnthropicAccount,
292 store: &dyn CredentialStore,
293) -> Result<()> {
294 let lock_path = home_claude_json
297 .parent()
298 .unwrap_or_else(|| Path::new("."))
299 .join(".ai-usagebar-account-switch.lock");
300 let _lock = crate::cache::acquire_lock(&lock_path, Duration::from_secs(2))?;
301
302 let live = oauth_account_in(home_claude_json).ok_or_else(|| {
304 AppError::Credentials(
305 "plain `claude` is not signed in, so there is no login to adopt; run `claude` first"
306 .into(),
307 )
308 })?;
309 let live_uuid = live
310 .get("accountUuid")
311 .and_then(Value::as_str)
312 .filter(|uuid| !uuid.is_empty())
313 .map(str::to_string)
314 .ok_or_else(|| {
315 AppError::Credentials("the live `claude` login records no account uuid".into())
316 })?;
317 if let Some(owner) = accounts.iter().find(|other| {
318 other.label != account.label
319 && account_uuid_in(&marker_path(&other.config_dir())).as_deref() == Some(&live_uuid)
320 }) {
321 return Err(AppError::Credentials(format!(
322 "the live `claude` login already belongs to account {:?}",
323 owner.label
324 )));
325 }
326 if store.read_named(&account.config_dir())?.is_some() {
327 return Err(AppError::Credentials(format!(
328 "{:?} already has its own Claude login; adopting would leave two copies of one \
329 account",
330 account.label
331 )));
332 }
333 if store.read_default()?.is_none() {
334 return Err(AppError::Credentials(
335 "the default `claude` credential slot is empty; run `claude` to sign in first".into(),
336 ));
337 }
338 let marker = marker_path(&account.config_dir());
339 let existing = read_optional(&marker)?;
340 let merged = merge_oauth_account(existing.as_deref().unwrap_or_default(), Some(&live))?;
341 crate::cache::atomic_write(&marker, &merged)
342}
343
344struct RollbackState<'a> {
345 target: &'a AnthropicAccount,
346 target_blob: &'a str,
347 outgoing: Option<&'a AnthropicAccount>,
348 original_outgoing_named: Option<&'a str>,
349 original_default: Option<&'a str>,
350 marker_path: &'a Path,
351 original_marker: Option<&'a [u8]>,
352}
353
354fn rollback_switch(store: &dyn CredentialStore, state: &RollbackState<'_>) -> Result<()> {
355 let mut failures = Vec::new();
356 if let Err(error) = restore_default(store, state.original_default) {
357 failures.push(format!("default credential: {error}"));
358 }
359 if let Err(error) = store.write_named(&state.target.config_dir(), state.target_blob) {
362 failures.push(format!("target credential: {error}"));
363 }
364 if let Some(account) = state.outgoing
365 && let Err(error) =
366 restore_named(store, &account.config_dir(), state.original_outgoing_named)
367 {
368 failures.push(format!("outgoing credential: {error}"));
369 }
370 if let Err(error) = restore_marker(state.marker_path, state.original_marker) {
371 failures.push(format!("identity marker: {error}"));
372 }
373 if failures.is_empty() {
374 Ok(())
375 } else {
376 Err(AppError::Other(failures.join("; ")))
377 }
378}
379
380fn restore_default(store: &dyn CredentialStore, original: Option<&str>) -> Result<()> {
381 match original {
382 Some(blob) => store.write_default(blob),
383 None => store.delete_default(),
384 }
385}
386
387fn restore_named(store: &dyn CredentialStore, path: &Path, original: Option<&str>) -> Result<()> {
388 match original {
389 Some(blob) => store.write_named(path, blob),
390 None => store.delete_named(path),
391 }
392}
393
394fn restore_marker(path: &Path, original: Option<&[u8]>) -> Result<()> {
395 if let Some(bytes) = original {
396 crate::cache::atomic_write(path, bytes)
397 } else {
398 match std::fs::remove_file(path) {
399 Ok(()) => Ok(()),
400 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
401 Err(error) => Err(AppError::io_at(path, error)),
402 }
403 }
404}
405
406fn read_optional(path: &Path) -> Result<Option<Vec<u8>>> {
407 match std::fs::read(path) {
408 Ok(bytes) => Ok(Some(bytes)),
409 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
410 Err(error) => Err(AppError::io_at(path, error)),
411 }
412}
413
414fn with_rollback(error: AppError, rollback: Result<()>) -> AppError {
415 match rollback {
416 Ok(()) => error,
417 Err(rollback) => AppError::Other(format!(
418 "{error}; automatic rollback was incomplete: {rollback}"
419 )),
420 }
421}
422
423fn oauth_account_in(claude_json: &Path) -> Option<Value> {
424 let bytes = std::fs::read(claude_json).ok()?;
425 let document: Value = serde_json::from_slice(&bytes).ok()?;
426 document
427 .get("oauthAccount")
428 .filter(|v| v.is_object())
429 .cloned()
430}
431
432#[cfg(not(target_os = "macos"))]
433fn unsupported() -> AppError {
434 AppError::Credentials(
435 "switching the `claude` CLI login is supported on macOS only (elsewhere, run \
436 `CLAUDE_CONFIG_DIR=<account dir> claude` to use a specific account)"
437 .into(),
438 )
439}
440
441impl CredentialStore for KeychainStore {
442 fn read_default(&self) -> Result<Option<String>> {
443 #[cfg(target_os = "macos")]
444 return super::keychain::read_raw();
445 #[cfg(not(target_os = "macos"))]
446 Err(unsupported())
447 }
448
449 fn write_default(&self, blob: &str) -> Result<()> {
450 #[cfg(target_os = "macos")]
451 return super::keychain::write_raw(blob);
452 #[cfg(not(target_os = "macos"))]
453 {
454 let _ = blob;
455 Err(unsupported())
456 }
457 }
458
459 fn delete_default(&self) -> Result<()> {
460 #[cfg(target_os = "macos")]
461 return super::keychain::delete_raw();
462 #[cfg(not(target_os = "macos"))]
463 Err(unsupported())
464 }
465
466 fn read_named(&self, config_dir: &Path) -> Result<Option<String>> {
467 #[cfg(target_os = "macos")]
468 return super::keychain::read_raw_for(config_dir);
469 #[cfg(not(target_os = "macos"))]
470 {
471 let _ = config_dir;
472 Err(unsupported())
473 }
474 }
475
476 fn write_named(&self, config_dir: &Path, blob: &str) -> Result<()> {
477 #[cfg(target_os = "macos")]
478 return super::keychain::write_raw_for(config_dir, blob);
479 #[cfg(not(target_os = "macos"))]
480 {
481 let _ = (config_dir, blob);
482 Err(unsupported())
483 }
484 }
485
486 fn delete_named(&self, config_dir: &Path) -> Result<()> {
487 #[cfg(target_os = "macos")]
488 return super::keychain::delete_raw_for(config_dir);
489 #[cfg(not(target_os = "macos"))]
490 {
491 let _ = config_dir;
492 Err(unsupported())
493 }
494 }
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500 use std::cell::RefCell;
501 use std::collections::BTreeMap;
502 use std::path::PathBuf;
503
504 #[derive(Default)]
505 struct FakeStore {
506 default: RefCell<Option<String>>,
507 named: RefCell<BTreeMap<PathBuf, String>>,
508 fail_delete_named: RefCell<Option<PathBuf>>,
509 }
510
511 impl CredentialStore for FakeStore {
512 fn read_default(&self) -> Result<Option<String>> {
513 Ok(self.default.borrow().clone())
514 }
515 fn write_default(&self, blob: &str) -> Result<()> {
516 *self.default.borrow_mut() = Some(blob.to_string());
517 Ok(())
518 }
519 fn delete_default(&self) -> Result<()> {
520 *self.default.borrow_mut() = None;
521 Ok(())
522 }
523 fn read_named(&self, config_dir: &Path) -> Result<Option<String>> {
524 Ok(self.named.borrow().get(config_dir).cloned())
525 }
526 fn write_named(&self, config_dir: &Path, blob: &str) -> Result<()> {
527 self.named
528 .borrow_mut()
529 .insert(config_dir.to_path_buf(), blob.to_string());
530 Ok(())
531 }
532 fn delete_named(&self, config_dir: &Path) -> Result<()> {
533 self.named.borrow_mut().remove(config_dir);
534 if self.fail_delete_named.borrow().as_deref() == Some(config_dir) {
535 Err(AppError::Other("injected delete failure".into()))
536 } else {
537 Ok(())
538 }
539 }
540 }
541
542 fn write(path: &Path, contents: &str) {
543 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
544 std::fs::write(path, contents).unwrap();
545 }
546
547 fn marker(uuid: &str, email: &str) -> String {
548 format!(r#"{{"oauthAccount":{{"accountUuid":"{uuid}","emailAddress":"{email}"}}}}"#)
549 }
550
551 struct Fixture {
552 _root: tempfile::TempDir,
553 home: PathBuf,
554 accounts: Vec<AnthropicAccount>,
555 store: FakeStore,
556 }
557
558 fn fixture() -> Fixture {
560 let root = tempfile::TempDir::new().unwrap();
561 let home = root.path().join("home").join(CLAUDE_JSON);
562 write(&home, &marker("uuid-personal", "me@personal.test"));
563
564 let accounts: Vec<AnthropicAccount> = ["work", "personal"]
565 .iter()
566 .map(|label| AnthropicAccount {
567 label: (*label).to_string(),
568 credentials_path: root
569 .path()
570 .join("accounts")
571 .join(label)
572 .join(".credentials.json"),
573 })
574 .collect();
575 write(
576 &accounts[0].config_dir().join(CLAUDE_JSON),
577 &marker("uuid-work", "me@work.test"),
578 );
579 write(
580 &accounts[1].config_dir().join(CLAUDE_JSON),
581 &marker("uuid-personal", "me@personal.test"),
582 );
583
584 let store = FakeStore::default();
585 *store.default.borrow_mut() = Some("personal-live".into());
586 store
587 .named
588 .borrow_mut()
589 .insert(accounts[0].config_dir(), "work-saved".into());
590 store
591 .named
592 .borrow_mut()
593 .insert(accounts[1].config_dir(), "personal-stale".into());
594
595 Fixture {
596 _root: root,
597 home,
598 accounts,
599 store,
600 }
601 }
602
603 #[test]
604 fn the_live_login_resolves_to_its_label() {
605 let f = fixture();
606 assert_eq!(
607 resolve_active_label(&f.home, &f.accounts).as_deref(),
608 Some("personal")
609 );
610 assert_eq!(
611 account_email_in(&f.home).as_deref(),
612 Some("me@personal.test")
613 );
614 }
615
616 #[test]
617 fn adopting_marks_the_live_login_and_leaves_the_credential_alone() {
618 let f = fixture();
619 write(&f.home, &marker("uuid-main", "me@main.test"));
620 let main = AnthropicAccount {
621 label: "main".into(),
622 credentials_path: f
623 .home
624 .parent()
625 .unwrap()
626 .join("main")
627 .join(".credentials.json"),
628 };
629 let mut accounts = f.accounts.clone();
630 accounts.push(main.clone());
631 adopt_current(&f.home, &accounts, &main, &f.store).unwrap();
632 assert_eq!(
633 resolve_active_label(&f.home, &accounts).as_deref(),
634 Some("main")
635 );
636 assert_eq!(f.store.read_named(&main.config_dir()).unwrap(), None);
637 assert_eq!(
638 f.store.read_default().unwrap().as_deref(),
639 Some("personal-live")
640 );
641 }
642
643 #[test]
644 fn adopting_refuses_a_login_another_account_owns() {
645 let f = fixture();
646 let main = AnthropicAccount {
647 label: "main".into(),
648 credentials_path: f
649 .home
650 .parent()
651 .unwrap()
652 .join("main")
653 .join(".credentials.json"),
654 };
655 let error = adopt_current(&f.home, &f.accounts, &main, &f.store).unwrap_err();
656 assert!(error.to_string().contains("\"personal\""), "{error}");
657 }
658
659 #[test]
660 fn adopting_refuses_an_account_with_its_own_login() {
661 let f = fixture();
662 write(&f.home, &marker("uuid-main", "me@main.test"));
663 let error = adopt_current(&f.home, &f.accounts, &f.accounts[0], &f.store).unwrap_err();
664 assert!(error.to_string().contains("two copies"), "{error}");
665 }
666
667 #[test]
668 fn an_unmanaged_login_resolves_to_nothing() {
669 let f = fixture();
670 write(&f.home, &marker("uuid-stranger", "who@example.test"));
671 assert_eq!(resolve_active_label(&f.home, &f.accounts), None);
672 }
673
674 #[test]
675 fn a_missing_marker_file_is_not_an_error() {
676 let f = fixture();
677 std::fs::remove_file(&f.home).unwrap();
678 assert_eq!(resolve_active_label(&f.home, &f.accounts), None);
679 assert_eq!(account_uuid_in(&f.home), None);
680 }
681
682 #[test]
683 fn switching_captures_the_outgoing_credential_first() {
684 let f = fixture();
685
686 let outcome = switch_cli_account(
687 &f.home,
688 &f.accounts,
689 "work",
690 CliSwitchOpts::default(),
691 &f.store,
692 )
693 .unwrap();
694
695 assert_eq!(
696 outcome,
697 CliSwitchOutcome::Switched {
698 outgoing: Some("personal".into())
699 }
700 );
701 assert_eq!(
704 f.store.named.borrow()[&f.accounts[1].config_dir()],
705 "personal-live"
706 );
707 assert_eq!(f.store.default.borrow().as_deref(), Some("work-saved"));
708 assert!(
709 !f.store
710 .named
711 .borrow()
712 .contains_key(&f.accounts[0].config_dir()),
713 "the active credential must be moved, not left as a rotating-token copy"
714 );
715 assert_eq!(
716 resolve_active_label(&f.home, &f.accounts).as_deref(),
717 Some("work")
718 );
719 }
720
721 #[test]
722 fn switching_is_idempotent() {
723 let f = fixture();
724 let outcome = switch_cli_account(
725 &f.home,
726 &f.accounts,
727 "personal",
728 CliSwitchOpts::default(),
729 &f.store,
730 )
731 .unwrap();
732 assert_eq!(outcome, CliSwitchOutcome::RemovedDuplicate);
733 assert_eq!(f.store.default.borrow().as_deref(), Some("personal-live"));
734 assert!(
735 !f.store
736 .named
737 .borrow()
738 .contains_key(&f.accounts[1].config_dir())
739 );
740
741 let second = switch_cli_account(
742 &f.home,
743 &f.accounts,
744 "personal",
745 CliSwitchOpts::default(),
746 &f.store,
747 )
748 .unwrap();
749 assert_eq!(second, CliSwitchOutcome::AlreadyActive);
750 }
751
752 #[test]
753 fn an_active_marker_with_an_empty_default_slot_is_repaired() {
754 let f = fixture();
755 *f.store.default.borrow_mut() = None;
756
757 let outcome = switch_cli_account(
758 &f.home,
759 &f.accounts,
760 "personal",
761 CliSwitchOpts::default(),
762 &f.store,
763 )
764 .unwrap();
765
766 assert_eq!(outcome, CliSwitchOutcome::RepairedActive);
767 assert_eq!(f.store.default.borrow().as_deref(), Some("personal-stale"));
768 assert!(
769 !f.store
770 .named
771 .borrow()
772 .contains_key(&f.accounts[1].config_dir())
773 );
774 }
775
776 #[test]
777 fn a_dry_run_validates_without_writing() {
778 let f = fixture();
779 let before = std::fs::read(&f.home).unwrap();
780
781 let outcome = switch_cli_account(
782 &f.home,
783 &f.accounts,
784 "work",
785 CliSwitchOpts {
786 dry_run: true,
787 ..CliSwitchOpts::default()
788 },
789 &f.store,
790 )
791 .unwrap();
792
793 assert_eq!(
794 outcome,
795 CliSwitchOutcome::WouldSwitch {
796 outgoing: Some("personal".into())
797 }
798 );
799 assert_eq!(f.store.default.borrow().as_deref(), Some("personal-live"));
800 assert_eq!(std::fs::read(&f.home).unwrap(), before);
801 }
802
803 #[test]
804 fn an_unmanaged_live_login_is_refused_without_force() {
805 let f = fixture();
806 write(&f.home, &marker("uuid-stranger", "who@example.test"));
807
808 let error = switch_cli_account(
809 &f.home,
810 &f.accounts,
811 "work",
812 CliSwitchOpts::default(),
813 &f.store,
814 )
815 .unwrap_err();
816 assert!(error.to_string().contains("--force"), "{error}");
817 assert_eq!(f.store.default.borrow().as_deref(), Some("personal-live"));
818
819 switch_cli_account(
820 &f.home,
821 &f.accounts,
822 "work",
823 CliSwitchOpts {
824 force: true,
825 ..CliSwitchOpts::default()
826 },
827 &f.store,
828 )
829 .unwrap();
830 assert_eq!(f.store.default.borrow().as_deref(), Some("work-saved"));
831 assert!(
832 !f.store
833 .named
834 .borrow()
835 .contains_key(&f.accounts[0].config_dir())
836 );
837 }
838
839 #[test]
840 fn an_empty_default_slot_does_not_require_force() {
841 let f = fixture();
842 *f.store.default.borrow_mut() = None;
843 std::fs::remove_file(&f.home).unwrap();
844
845 switch_cli_account(
846 &f.home,
847 &f.accounts,
848 "work",
849 CliSwitchOpts::default(),
850 &f.store,
851 )
852 .unwrap();
853
854 assert_eq!(f.store.default.borrow().as_deref(), Some("work-saved"));
855 assert_eq!(
856 resolve_active_label(&f.home, &f.accounts).as_deref(),
857 Some("work")
858 );
859 }
860
861 #[test]
862 fn a_target_without_an_identity_marker_changes_nothing() {
863 let f = fixture();
864 std::fs::remove_file(f.accounts[0].config_dir().join(CLAUDE_JSON)).unwrap();
865 let before = std::fs::read(&f.home).unwrap();
866
867 let error = switch_cli_account(
868 &f.home,
869 &f.accounts,
870 "work",
871 CliSwitchOpts::default(),
872 &f.store,
873 )
874 .unwrap_err();
875
876 assert!(error.to_string().contains("identity marker"), "{error}");
877 assert_eq!(f.store.default.borrow().as_deref(), Some("personal-live"));
878 assert_eq!(std::fs::read(&f.home).unwrap(), before);
879 }
880
881 #[test]
882 fn a_late_failure_restores_every_credential_slot_and_marker() {
883 let f = fixture();
884 let before_marker = std::fs::read(&f.home).unwrap();
885 *f.store.fail_delete_named.borrow_mut() = Some(f.accounts[0].config_dir());
886
887 let error = switch_cli_account(
888 &f.home,
889 &f.accounts,
890 "work",
891 CliSwitchOpts::default(),
892 &f.store,
893 )
894 .unwrap_err();
895
896 assert!(
897 error.to_string().contains("injected delete failure"),
898 "{error}"
899 );
900 assert_eq!(f.store.default.borrow().as_deref(), Some("personal-live"));
901 assert_eq!(
902 f.store.named.borrow()[&f.accounts[0].config_dir()],
903 "work-saved"
904 );
905 assert_eq!(
906 f.store.named.borrow()[&f.accounts[1].config_dir()],
907 "personal-stale"
908 );
909 assert_eq!(std::fs::read(&f.home).unwrap(), before_marker);
910 }
911
912 #[test]
913 fn switching_to_an_account_that_never_signed_in_changes_nothing() {
914 let f = fixture();
915 f.store
916 .named
917 .borrow_mut()
918 .remove(&f.accounts[0].config_dir());
919
920 let error = switch_cli_account(
921 &f.home,
922 &f.accounts,
923 "work",
924 CliSwitchOpts::default(),
925 &f.store,
926 )
927 .unwrap_err();
928 assert!(error.to_string().contains("account add work"), "{error}");
929 assert_eq!(f.store.default.borrow().as_deref(), Some("personal-live"));
930 }
931
932 #[test]
933 fn merging_the_marker_preserves_unrelated_state() {
934 let existing = br#"{"firstStartTime":"2026-01-01","oauthAccount":{"accountUuid":"old"},
935 "projects":{"/tmp/x":{"allowedTools":[]}}}"#;
936 let replacement = serde_json::json!({"accountUuid": "new", "emailAddress": "a@b.test"});
937
938 let bytes = merge_oauth_account(existing, Some(&replacement)).unwrap();
939 let value: Value = serde_json::from_slice(&bytes).unwrap();
940 assert_eq!(value["oauthAccount"]["accountUuid"], "new");
941 assert_eq!(value["firstStartTime"], "2026-01-01");
942 assert!(value["projects"]["/tmp/x"].is_object());
943 }
944
945 #[test]
946 fn merging_no_marker_clears_a_stale_one() {
947 let existing = br#"{"oauthAccount":{"accountUuid":"old"},"autoUpdates":true}"#;
948 let bytes = merge_oauth_account(existing, None).unwrap();
949 let value: Value = serde_json::from_slice(&bytes).unwrap();
950 assert!(value.get("oauthAccount").is_none());
951 assert_eq!(value["autoUpdates"], true);
952 }
953
954 #[test]
955 fn merging_into_an_absent_file_starts_a_fresh_document() {
956 let replacement = serde_json::json!({"accountUuid": "new"});
957 let bytes = merge_oauth_account(b"", Some(&replacement)).unwrap();
958 let value: Value = serde_json::from_slice(&bytes).unwrap();
959 assert_eq!(value["oauthAccount"]["accountUuid"], "new");
960 }
961}