1pub mod app;
23pub mod capture;
24pub mod merge;
25
26use std::collections::BTreeSet;
27use std::path::{Path, PathBuf};
28
29use serde::Deserialize;
30
31use crate::config::AnthropicConfig;
32use crate::error::{AppError, Result};
33
34use app::AppControl;
35use merge::{ScheduledMerge, SessionMerge};
36
37const CONFIG_JSON: &str = "config.json";
39const SESSIONS_DIR: &str = "claude-code-sessions";
41const COOKIE_FILES: [&str; 2] = ["Cookies", "Cookies-journal"];
44const LEVELDB_DIRS: [&str; 3] = ["Local Storage", "Session Storage", "IndexedDB"];
46const BRIDGE_FILE: &str = "bridge-state.json";
51const DEVICE_REGISTRY: &str = "ant-device-registry.json";
54
55const TOKEN_CACHE: &str = "config-tokenCache";
57const TOKEN_CACHE_V2: &str = "config-tokenCacheV2";
58const DESKTOP_STATE: &str = "desktop-state";
59const META_JSON: &str = "meta.json";
60pub const ACCOUNT_LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
64
65#[derive(Debug, Clone)]
69pub struct Paths {
70 pub data_dir: PathBuf,
71 pub profiles_dir: PathBuf,
72 pub backups_dir: PathBuf,
73}
74
75impl Paths {
76 pub fn at(data_dir: PathBuf, profiles_dir: PathBuf, backups_dir: PathBuf) -> Self {
78 Self {
79 data_dir,
80 profiles_dir,
81 backups_dir,
82 }
83 }
84
85 pub fn resolve(anthropic: &AnthropicConfig) -> Result<Self> {
88 let home = crate::cache::home_dir()?;
89 let profiles_dir = anthropic
90 .desktop_profiles_dir
91 .clone()
92 .unwrap_or_else(|| home.join(".claude-acc").join("profiles"));
93 let backups_dir = profiles_dir
94 .parent()
95 .map_or_else(|| home.join(".claude-acc"), Path::to_path_buf)
96 .join("backups");
97 Ok(Self {
98 data_dir: home.join("Library/Application Support/Claude"),
99 profiles_dir,
100 backups_dir,
101 })
102 }
103
104 pub fn available(&self) -> bool {
107 self.data_dir.is_dir()
108 }
109
110 pub fn config_json(&self) -> PathBuf {
111 self.data_dir.join(CONFIG_JSON)
112 }
113
114 pub fn sessions_root(&self) -> PathBuf {
115 self.data_dir.join(SESSIONS_DIR)
116 }
117
118 pub fn profile_dir(&self, label: &str) -> PathBuf {
119 self.profiles_dir.join(label)
120 }
121
122 pub fn account_switch_lock(&self) -> PathBuf {
125 self.backups_dir.join(".account-switch.lock")
126 }
127
128 pub fn prelogin_dir(&self) -> PathBuf {
131 self.backups_dir.with_file_name("prelogin-backup")
132 }
133
134 pub fn synced_path(&self) -> PathBuf {
139 self.backups_dir.with_file_name("synced.json")
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct ProfileMeta {
146 pub label: String,
149 pub email: Option<String>,
150 pub account_uuid: String,
151 pub org_uuid: Option<String>,
155 pub has_credentials: bool,
156 pub has_desktop_state: bool,
157}
158
159#[derive(Debug, Deserialize)]
160struct RawMeta {
161 email: Option<String>,
162 #[serde(rename = "accountUuid")]
163 account_uuid: Option<String>,
164 #[serde(rename = "orgUuid")]
165 org_uuid: Option<String>,
166}
167
168pub fn load_profiles(profiles_dir: &Path) -> Vec<ProfileMeta> {
172 let Ok(entries) = std::fs::read_dir(profiles_dir) else {
173 return Vec::new();
174 };
175 let mut profiles: Vec<ProfileMeta> = entries
176 .flatten()
177 .filter_map(|entry| {
178 let dir = entry.path();
179 let label = dir.file_name()?.to_str()?.to_string();
180 let raw: RawMeta =
181 serde_json::from_slice(&std::fs::read(dir.join(META_JSON)).ok()?).ok()?;
182 let account_uuid = raw.account_uuid.filter(|uuid| !uuid.is_empty())?;
183 Some(ProfileMeta {
184 label,
185 email: raw.email.filter(|email| !email.is_empty()),
186 account_uuid,
187 org_uuid: raw.org_uuid.filter(|uuid| !uuid.is_empty()),
188 has_credentials: dir.join(TOKEN_CACHE).is_file()
189 && dir.join(TOKEN_CACHE_V2).is_file(),
190 has_desktop_state: dir.join(DESKTOP_STATE).is_dir(),
191 })
192 })
193 .collect();
194 profiles.sort_by(|a, b| a.label.cmp(&b.label));
195 profiles
196}
197
198pub fn load_synced(path: &Path) -> merge::Synced {
202 std::fs::read(path)
203 .map(|bytes| merge::parse_synced(&bytes))
204 .unwrap_or_default()
205}
206
207pub fn save_synced(path: &Path, synced: &merge::Synced) -> Result<()> {
210 crate::cache::atomic_write(path, &serde_json::to_vec(synced)?)
211}
212
213pub fn active_account_uuid(config_json: &Path) -> Option<String> {
216 let bytes = std::fs::read(config_json).ok()?;
217 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
218 value
219 .get("lastKnownAccountUuid")?
220 .as_str()
221 .filter(|uuid| !uuid.is_empty())
222 .map(str::to_string)
223}
224
225pub fn label_for_uuid<'a>(profiles: &'a [ProfileMeta], account_uuid: &str) -> Option<&'a str> {
226 profiles
227 .iter()
228 .find(|profile| profile.account_uuid == account_uuid)
229 .map(|profile| profile.label.as_str())
230}
231
232pub fn session_count(sessions_root: &Path, profile: &ProfileMeta) -> usize {
234 let Some(org) = &profile.org_uuid else {
235 return 0;
236 };
237 let Ok(entries) = std::fs::read_dir(sessions_root.join(&profile.account_uuid).join(org)) else {
238 return 0;
239 };
240 entries
241 .flatten()
242 .filter(|entry| {
243 entry
244 .path()
245 .extension()
246 .is_some_and(|extension| extension == "json")
247 })
248 .count()
249}
250
251#[derive(Debug, Clone)]
253pub struct SwitchOpts {
254 pub keep_bridge: bool,
258 pub backup_sessions: bool,
263 pub keep_backups: usize,
265}
266
267impl Default for SwitchOpts {
268 fn default() -> Self {
269 Self {
270 keep_bridge: false,
271 backup_sessions: false,
272 keep_backups: 10,
273 }
274 }
275}
276
277#[derive(Debug, Clone, PartialEq, Eq)]
280pub struct SavedTokens {
281 pub token_cache: String,
282 pub token_cache_v2: String,
283}
284
285#[derive(Debug)]
287pub struct SwitchPlan {
288 pub target: ProfileMeta,
289 pub outgoing: Option<String>,
291 pub sessions: SessionMerge,
292 pub scheduled: Option<ScheduledMerge>,
295 pub tokens: SavedTokens,
299 pub archive: PathBuf,
300 pub archive_members: Vec<String>,
301 pub restores_desktop_state: bool,
302 pub opts: SwitchOpts,
303 pub deletions: Vec<merge::DeletionCandidate>,
307 pub confirmed_deletions: BTreeSet<merge::DeletionKey>,
310 pub prior_synced: merge::Synced,
313}
314
315pub fn plan_switch(paths: &Paths, label: &str, opts: SwitchOpts) -> Result<SwitchPlan> {
320 let profiles = load_profiles(&paths.profiles_dir);
321 let target = profiles
322 .iter()
323 .find(|profile| profile.label == label)
324 .cloned()
325 .ok_or_else(|| {
326 let known: Vec<&str> = profiles.iter().map(|p| p.label.as_str()).collect();
327 AppError::Credentials(format!(
328 "no saved Claude Desktop account {label:?} in {}; known: {known:?}. \
329 Capture one with `claude-acc add {label}` \
330 (https://github.com/ohmaseclaro/claude-acc)",
331 paths.profiles_dir.display()
332 ))
333 })?;
334
335 let sessions_root = paths.sessions_root();
336 let synced = load_synced(&paths.synced_path());
337 let (sessions, scheduled) = match &target.org_uuid {
338 Some(org) => (
339 merge::plan_session_merge(&sessions_root, &target.account_uuid, org),
340 Some(merge::plan_scheduled_merge(
341 &sessions_root,
342 &target.account_uuid,
343 org,
344 &synced,
345 )?),
346 ),
347 None => (SessionMerge::default(), None),
348 };
349 let deletions = merge::deletion_candidates(&sessions_root, &synced);
350
351 let outgoing = active_account_uuid(&paths.config_json())
352 .and_then(|uuid| label_for_uuid(&profiles, &uuid).map(str::to_string));
353
354 let profile_dir = paths.profile_dir(label);
355 let token_cache = std::fs::read_to_string(profile_dir.join(TOKEN_CACHE)).map_err(|_| {
356 AppError::Credentials(format!(
357 "no complete saved Desktop credential for {label:?}; capture or sign into that \
358 account before switching"
359 ))
360 })?;
361 let token_cache_v2 =
362 std::fs::read_to_string(profile_dir.join(TOKEN_CACHE_V2)).map_err(|_| {
363 AppError::Credentials(format!(
364 "no complete saved Desktop credential for {label:?}; capture or sign into that \
365 account before switching"
366 ))
367 })?;
368 if token_cache.is_empty() || token_cache_v2.is_empty() {
369 return Err(AppError::Credentials(format!(
370 "the saved Desktop credential for {label:?} is empty; capture it again before switching"
371 )));
372 }
373 let tokens = SavedTokens {
374 token_cache,
375 token_cache_v2,
376 };
377 if !profile_dir.join(DESKTOP_STATE).is_dir() {
378 return Err(AppError::Credentials(format!(
379 "no saved Desktop browser state for {label:?}; capture that account again before switching"
380 )));
381 }
382
383 let stamp = crate::claude_desktop::timestamp();
384 Ok(SwitchPlan {
385 archive: paths
386 .backups_dir
387 .join(format!("switch-{stamp}-{label}.tar.gz")),
388 archive_members: archive_members(paths, &opts),
389 restores_desktop_state: true,
390 target,
391 outgoing,
392 sessions,
393 scheduled,
394 tokens,
395 opts,
396 deletions,
397 confirmed_deletions: BTreeSet::new(),
398 prior_synced: synced,
399 })
400}
401
402pub fn apply_switch(paths: &Paths, plan: &SwitchPlan, app: &dyn AppControl) -> Result<Vec<String>> {
408 let mut notes = Vec::new();
409 let members: Vec<&str> = plan.archive_members.iter().map(String::as_str).collect();
410
411 if let Err(error) = app.quit() {
413 return match app.relaunch() {
418 Ok(()) => Err(error),
419 Err(relaunch) => Err(AppError::Other(format!(
420 "{error}; Claude Desktop also could not be relaunched: {relaunch}"
421 ))),
422 };
423 }
424 let mut archived = false;
425 let mut result = (|| {
426 if !members.is_empty() {
427 app.archive(&plan.archive, &paths.data_dir, &members)?;
428 archived = true;
429 prune_archives(
432 &paths.backups_dir,
433 plan.opts.keep_backups.max(1),
434 &mut notes,
435 );
436 }
437 apply_switch_while_stopped(paths, plan, &mut notes)
438 })();
439
440 if result.is_err()
441 && archived
442 && let Err(rollback) = app.restore(&plan.archive, &paths.data_dir, &identity_members())
443 {
444 let original = result.unwrap_err();
445 result = Err(AppError::Other(format!(
446 "{original}; automatic Desktop rollback was incomplete: {rollback}"
447 )));
448 }
449
450 let relaunch = app.relaunch();
451 match (result, relaunch) {
452 (Ok(()), Ok(())) => Ok(notes),
453 (Err(error), Ok(())) => Err(error),
454 (Ok(()), Err(error)) => Err(error),
455 (Err(error), Err(relaunch)) => Err(AppError::Other(format!(
456 "{error}; Claude Desktop also could not be relaunched: {relaunch}"
457 ))),
458 }
459}
460
461fn apply_switch_while_stopped(
462 paths: &Paths,
463 plan: &SwitchPlan,
464 notes: &mut Vec<String>,
465) -> Result<()> {
466 let display_names = plan
469 .scheduled
470 .as_ref()
471 .map(ScheduledMerge::display_names)
472 .transpose()?;
473
474 for (source, destination) in plan.sessions.copied.iter().chain(&plan.sessions.updated) {
478 copy_file(source, destination)?;
479 }
480 if let Some(scheduled) = &plan.scheduled {
481 crate::cache::atomic_write(&scheduled.target, &scheduled.bytes)?;
482 }
483 match merge::plan_deletion_sweep(&paths.sessions_root(), &plan.confirmed_deletions) {
488 Ok(sweep) => {
489 for (path, bytes) in sweep.rewrites {
490 if let Err(error) = crate::cache::atomic_write(&path, &bytes) {
491 notes.push(format!(
492 "could not apply deletion to {}: {error}",
493 path.display()
494 ));
495 }
496 }
497 for path in sweep.removals {
502 if let Err(error) = std::fs::remove_file(&path) {
503 notes.push(format!("could not remove {}: {error}", path.display()));
504 }
505 }
506 }
507 Err(error) => notes.push(format!("deletion sweep skipped: {error}")),
508 }
509 if let Some(display_names) = &display_names {
515 match merge::plan_name_convergence(&paths.sessions_root(), display_names) {
516 Ok(convergence) => {
517 let mut fully_applied = true;
518 for (path, bytes) in convergence.rewrites {
519 if let Err(error) = crate::cache::atomic_write(&path, &bytes) {
520 fully_applied = false;
521 notes.push(format!(
522 "could not converge routine names in {}: {error}",
523 path.display()
524 ));
525 }
526 }
527 if fully_applied && convergence.converged > 0 {
528 notes.push(format!(
529 "converged {} routine name(s) across accounts",
530 convergence.converged
531 ));
532 }
533 }
534 Err(error) => notes.push(format!("name convergence skipped: {error}")),
535 }
536 }
537 let mut synced = merge::current_state(&paths.sessions_root());
540 let mut canonical = plan
541 .scheduled
542 .as_ref()
543 .map(|scheduled| scheduled.canonical_routines.clone())
544 .unwrap_or_else(|| merge::canonical_routines(&plan.prior_synced));
545 for key in &plan.confirmed_deletions {
546 if key.kind == merge::ConflictKind::Routine {
547 canonical.remove(&key.id);
548 }
549 }
550 let present: BTreeSet<String> = synced
551 .values()
552 .flat_map(|account| account.routines.iter().cloned())
553 .collect();
554 canonical.retain(|id, _| present.contains(id));
555 merge::set_canonical_routines(&mut synced, &canonical);
556 if let Err(error) = save_synced(&paths.synced_path(), &synced) {
557 notes.push(format!("could not record the schedule sync: {error}"));
558 }
559
560 let live_config = paths.config_json();
565 let outgoing = active_account_uuid(&live_config).and_then(|uuid| {
566 label_for_uuid(&load_profiles(&paths.profiles_dir), &uuid).map(str::to_string)
567 });
568 if let Some(label) = &outgoing {
569 snapshot_profile(paths, label, notes)?;
570 }
571
572 swap_credentials(&live_config, &plan.tokens, &plan.target.account_uuid)?;
573
574 restore_desktop_state(paths, &plan.target.label)?;
575
576 if !plan.opts.keep_bridge {
577 let bridge = paths.data_dir.join(BRIDGE_FILE);
578 if bridge.is_file()
579 && let Err(error) = std::fs::remove_file(&bridge)
580 {
581 notes.push(format!("could not clear {BRIDGE_FILE}: {error}"));
582 }
583 }
584 restore_device_registry(paths, &plan.target.label, notes);
585
586 Ok(())
587}
588
589fn merge_history_into(
596 paths: &Paths,
597 account_uuid: &str,
598 org_uuid: &str,
599 notes: &mut Vec<String>,
600) -> (usize, usize) {
601 let sessions_root = paths.sessions_root();
602 let sessions = merge::plan_session_merge(&sessions_root, account_uuid, org_uuid);
603 let mut copied = 0;
604 for (source, destination) in sessions.copied.iter().chain(&sessions.updated) {
605 match copy_file(source, destination) {
606 Ok(()) => copied += 1,
607 Err(error) => notes.push(format!("could not seed {}: {error}", destination.display())),
608 }
609 }
610 let routines = match merge::plan_scheduled_merge(
611 &sessions_root,
612 account_uuid,
613 org_uuid,
614 &load_synced(&paths.synced_path()),
615 ) {
616 Ok(scheduled) => match crate::cache::atomic_write(&scheduled.target, &scheduled.bytes) {
617 Ok(()) => scheduled.added + scheduled.updated,
618 Err(error) => {
619 notes.push(format!("schedule seed skipped: {error}"));
620 0
621 }
622 },
623 Err(error) => {
624 notes.push(format!("schedule seed skipped: {error}"));
625 0
626 }
627 };
628 (copied, routines)
629}
630
631fn snapshot_profile(paths: &Paths, label: &str, notes: &mut Vec<String>) -> Result<()> {
639 let profile_dir = paths.profile_dir(label);
640 std::fs::create_dir_all(&profile_dir).map_err(|e| AppError::io_at(&profile_dir, e))?;
641 restrict(&profile_dir, 0o700, notes);
642
643 let bytes =
644 std::fs::read(paths.config_json()).map_err(|e| AppError::io_at(paths.config_json(), e))?;
645 let value: serde_json::Value = serde_json::from_slice(&bytes)?;
646 let live_tokens = SavedTokens {
647 token_cache: required_token(&value, "oauth:tokenCache")?.to_string(),
648 token_cache_v2: required_token(&value, "oauth:tokenCacheV2")?.to_string(),
649 };
650 write_saved_tokens(&profile_dir, &live_tokens, notes)?;
651
652 let registry = paths.data_dir.join(DEVICE_REGISTRY);
655 if registry.is_file() {
656 let bytes = std::fs::read(®istry).map_err(|e| AppError::io_at(®istry, e))?;
657 crate::cache::atomic_write(&profile_dir.join(DEVICE_REGISTRY), &bytes)?;
658 }
659
660 snapshot_desktop_state(paths, &profile_dir, notes)?;
661 Ok(())
663}
664
665fn required_token<'a>(value: &'a serde_json::Value, key: &str) -> Result<&'a str> {
666 value
667 .get(key)
668 .and_then(serde_json::Value::as_str)
669 .filter(|blob| !blob.is_empty())
670 .ok_or_else(|| {
671 AppError::Credentials(format!(
672 "the live Desktop login is missing {key}; refusing to overwrite its saved profile"
673 ))
674 })
675}
676
677fn write_saved_tokens(
678 profile_dir: &Path,
679 tokens: &SavedTokens,
680 notes: &mut Vec<String>,
681) -> Result<()> {
682 let token_path = profile_dir.join(TOKEN_CACHE);
683 let token_v2_path = profile_dir.join(TOKEN_CACHE_V2);
684 let original = read_optional_file(&token_path)?;
685 let original_v2 = read_optional_file(&token_v2_path)?;
686 let write_result = (|| {
687 crate::cache::atomic_write(&token_path, tokens.token_cache.as_bytes())?;
688 crate::cache::atomic_write(&token_v2_path, tokens.token_cache_v2.as_bytes())?;
689 Ok(())
690 })();
691 if let Err(error) = write_result {
692 let mut rollback = Vec::new();
693 if let Err(failure) = restore_optional_file(&token_path, original.as_deref()) {
694 rollback.push(failure.to_string());
695 }
696 if let Err(failure) = restore_optional_file(&token_v2_path, original_v2.as_deref()) {
697 rollback.push(failure.to_string());
698 }
699 return if rollback.is_empty() {
700 Err(error)
701 } else {
702 Err(AppError::Other(format!(
703 "{error}; saved-token rollback was incomplete: {}",
704 rollback.join("; ")
705 )))
706 };
707 }
708 restrict(&token_path, 0o600, notes);
709 restrict(&token_v2_path, 0o600, notes);
710 Ok(())
711}
712
713fn snapshot_desktop_state(
714 paths: &Paths,
715 profile_dir: &Path,
716 notes: &mut Vec<String>,
717) -> Result<()> {
718 let state_dir = profile_dir.join(DESKTOP_STATE);
719 let previous = profile_dir.join(".desktop-state.previous");
720 if previous.exists() && !state_dir.exists() {
721 std::fs::rename(&previous, &state_dir).map_err(|e| AppError::io_at(&previous, e))?;
722 } else {
723 remove_if_present(&previous)?;
724 }
725
726 let staged = tempfile::Builder::new()
727 .prefix(".desktop-state.pending-")
728 .tempdir_in(profile_dir)
729 .map_err(|e| AppError::io_at(profile_dir, e))?;
730 restrict(staged.path(), 0o700, notes);
731 for name in COOKIE_FILES {
732 let source = paths.data_dir.join(name);
733 let destination = staged.path().join(name);
734 if source.is_file() {
735 copy_file(&source, &destination)?;
736 restrict(&destination, 0o600, notes);
737 }
738 }
739 for name in LEVELDB_DIRS {
740 let source = paths.data_dir.join(name);
741 let destination = staged.path().join(name);
742 if source.is_dir() {
743 copy_dir(&source, &destination)?;
744 }
745 }
746
747 let staged = staged.keep();
748 if state_dir.exists() {
749 std::fs::rename(&state_dir, &previous).map_err(|e| AppError::io_at(&state_dir, e))?;
750 }
751 if let Err(error) = std::fs::rename(&staged, &state_dir) {
752 let restore = if previous.exists() {
753 std::fs::rename(&previous, &state_dir)
754 } else {
755 Ok(())
756 };
757 let _ = remove_if_present(&staged);
758 return match restore {
759 Ok(()) => Err(AppError::io_at(&staged, error)),
760 Err(rollback) => Err(AppError::Other(format!(
761 "could not install the Desktop-state snapshot: {error}; could not restore the previous snapshot: {rollback}"
762 ))),
763 };
764 }
765 if let Err(error) = remove_if_present(&previous) {
766 notes.push(format!(
767 "could not remove the previous Desktop-state snapshot: {error}"
768 ));
769 }
770 Ok(())
771}
772
773fn swap_credentials(config_json: &Path, tokens: &SavedTokens, account_uuid: &str) -> Result<()> {
774 let existing = std::fs::read(config_json).map_err(|e| AppError::io_at(config_json, e))?;
775 let bytes = merge::swap_config_tokens(
776 &existing,
777 &tokens.token_cache,
778 &tokens.token_cache_v2,
779 account_uuid,
780 )?;
781 crate::cache::atomic_write(config_json, &bytes)
784}
785
786fn restore_desktop_state(paths: &Paths, label: &str) -> Result<()> {
787 let state_dir = paths.profile_dir(label).join(DESKTOP_STATE);
788 for name in COOKIE_FILES {
789 let source = state_dir.join(name);
790 let destination = paths.data_dir.join(name);
791 if source.is_file() {
792 copy_file(&source, &destination)?;
793 } else {
794 remove_if_present(&destination)?;
795 }
796 }
797 for name in LEVELDB_DIRS {
798 let source = state_dir.join(name);
799 let destination = paths.data_dir.join(name);
800 if source.is_dir() {
801 replace_dir(&source, &destination)?;
802 } else {
803 remove_if_present(&destination)?;
804 }
805 }
806 Ok(())
807}
808
809fn restore_device_registry(paths: &Paths, label: &str, notes: &mut Vec<String>) {
812 let snapshot = paths.profile_dir(label).join(DEVICE_REGISTRY);
813 let live = paths.data_dir.join(DEVICE_REGISTRY);
814 let (Ok(saved), Ok(current)) = (std::fs::read(&snapshot), std::fs::read(&live)) else {
815 return;
816 };
817 match merge::merge_device_registry(¤t, &saved) {
818 Ok(bytes) if bytes != current => {
819 if let Err(error) = crate::cache::atomic_write(&live, &bytes) {
820 notes.push(format!("could not merge {DEVICE_REGISTRY}: {error}"));
821 }
822 }
823 Ok(_) => {}
824 Err(error) => notes.push(format!("could not merge {DEVICE_REGISTRY}: {error}")),
825 }
826}
827
828fn archive_members(paths: &Paths, opts: &SwitchOpts) -> Vec<String> {
834 let mut members = Vec::new();
835 for name in [CONFIG_JSON, DEVICE_REGISTRY, BRIDGE_FILE] {
836 if paths.data_dir.join(name).exists() {
837 members.push(name.to_string());
838 }
839 }
840 for name in COOKIE_FILES.into_iter().chain(LEVELDB_DIRS) {
841 if paths.data_dir.join(name).exists() {
842 members.push(name.to_string());
843 }
844 }
845 if opts.backup_sessions {
846 if paths.sessions_root().is_dir() {
847 members.push(SESSIONS_DIR.to_string());
848 }
849 return members;
850 }
851 let sessions_root = paths.sessions_root();
852 let Ok(accounts) = std::fs::read_dir(&sessions_root) else {
853 return members;
854 };
855 let mut registries = Vec::new();
856 for account in accounts.flatten() {
857 let Ok(orgs) = std::fs::read_dir(account.path()) else {
858 continue;
859 };
860 for org in orgs.flatten() {
861 let path = org.path().join("scheduled-tasks.json");
862 if path.is_file()
863 && let Ok(relative) = path.strip_prefix(&paths.data_dir)
864 {
865 registries.push(relative.display().to_string());
866 }
867 }
868 }
869 registries.sort();
870 members.extend(registries);
871 members
872}
873
874fn identity_members() -> Vec<&'static str> {
875 [CONFIG_JSON, DEVICE_REGISTRY, BRIDGE_FILE]
876 .into_iter()
877 .chain(COOKIE_FILES)
878 .chain(LEVELDB_DIRS)
879 .collect()
880}
881
882fn prune_archives(backups_dir: &Path, keep: usize, notes: &mut Vec<String>) {
883 let Ok(entries) = std::fs::read_dir(backups_dir) else {
884 return;
885 };
886 let mut archives: Vec<PathBuf> = entries
889 .flatten()
890 .map(|entry| entry.path())
891 .filter(|path| {
892 path.file_name()
893 .and_then(|name| name.to_str())
894 .is_some_and(|name| name.starts_with("switch-") && name.ends_with(".tar.gz"))
895 })
896 .collect();
897 if archives.len() <= keep {
898 return;
899 }
900 archives.sort();
901 let doomed = archives.len() - keep;
902 for path in archives.into_iter().take(doomed) {
903 if let Err(error) = std::fs::remove_file(&path) {
904 notes.push(format!("could not prune {}: {error}", path.display()));
905 }
906 }
907}
908
909fn copy_file(source: &Path, destination: &Path) -> Result<()> {
910 if let Some(parent) = destination.parent() {
911 std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
912 }
913 std::fs::copy(source, destination).map_err(|e| AppError::io_at(source, e))?;
914 Ok(())
915}
916
917fn remove_if_present(path: &Path) -> Result<()> {
918 match std::fs::symlink_metadata(path) {
919 Ok(metadata) if metadata.is_dir() => {
920 std::fs::remove_dir_all(path).map_err(|e| AppError::io_at(path, e))
921 }
922 Ok(_) => std::fs::remove_file(path).map_err(|e| AppError::io_at(path, e)),
923 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
924 Err(error) => Err(AppError::io_at(path, error)),
925 }
926}
927
928fn read_optional_file(path: &Path) -> Result<Option<Vec<u8>>> {
929 match std::fs::read(path) {
930 Ok(bytes) => Ok(Some(bytes)),
931 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
932 Err(error) => Err(AppError::io_at(path, error)),
933 }
934}
935
936fn restore_optional_file(path: &Path, original: Option<&[u8]>) -> Result<()> {
937 match original {
938 Some(bytes) => crate::cache::atomic_write(path, bytes),
939 None => remove_if_present(path),
940 }
941}
942
943fn replace_dir(source: &Path, destination: &Path) -> Result<()> {
947 if destination.exists() {
948 std::fs::remove_dir_all(destination).map_err(|e| AppError::io_at(destination, e))?;
949 }
950 copy_dir(source, destination)
951}
952
953fn copy_dir(source: &Path, destination: &Path) -> Result<()> {
954 std::fs::create_dir_all(destination).map_err(|e| AppError::io_at(destination, e))?;
955 let entries = std::fs::read_dir(source).map_err(|e| AppError::io_at(source, e))?;
956 for entry in entries.flatten() {
957 let child = entry.path();
958 let target = destination.join(entry.file_name());
959 if child.is_dir() {
960 copy_dir(&child, &target)?;
961 } else {
962 std::fs::copy(&child, &target).map_err(|e| AppError::io_at(&child, e))?;
963 }
964 }
965 Ok(())
966}
967
968#[cfg(unix)]
969fn restrict(path: &Path, mode: u32, notes: &mut Vec<String>) {
970 use std::os::unix::fs::PermissionsExt;
971
972 if let Err(error) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) {
973 notes.push(format!("could not restrict {}: {error}", path.display()));
974 }
975}
976
977#[cfg(not(unix))]
978fn restrict(_path: &Path, _mode: u32, _notes: &mut Vec<String>) {}
979
980fn timestamp() -> String {
981 chrono::Local::now().format("%Y%m%d-%H%M%S%.3f").to_string()
982}
983
984#[cfg(test)]
985mod tests {
986 use super::*;
987 use app::Recorder;
988 use std::cell::RefCell;
989
990 struct Fixture {
991 _root: tempfile::TempDir,
992 paths: Paths,
993 }
994
995 #[derive(Default)]
996 struct QuitFailure {
997 steps: RefCell<Vec<&'static str>>,
998 }
999
1000 impl AppControl for QuitFailure {
1001 fn quit(&self) -> Result<()> {
1002 self.steps.borrow_mut().push("quit");
1003 Err(AppError::Other("liveness probe failed".into()))
1004 }
1005
1006 fn relaunch(&self) -> Result<()> {
1007 self.steps.borrow_mut().push("relaunch");
1008 Ok(())
1009 }
1010
1011 fn archive(&self, _archive: &Path, _root: &Path, _members: &[&str]) -> Result<()> {
1012 panic!("archive must not run after an unconfirmed quit")
1013 }
1014
1015 fn restore(&self, _archive: &Path, _root: &Path, _members: &[&str]) -> Result<()> {
1016 panic!("restore must not run before a switch starts")
1017 }
1018 }
1019
1020 fn write(path: &Path, contents: &str) {
1021 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1022 std::fs::write(path, contents).unwrap();
1023 }
1024
1025 fn fixture() -> Fixture {
1027 let root = tempfile::TempDir::new().unwrap();
1028 let data = root.path().join("data");
1029 let profiles = root.path().join("profiles");
1030 let backups = root.path().join("backups");
1031
1032 write(
1033 &data.join(CONFIG_JSON),
1034 r#"{"lastKnownAccountUuid":"uuid-here","oauth:tokenCache":"live-a",
1035 "oauth:tokenCacheV2":"live-b","dxt:allowlistEnabled:org-1":true}"#,
1036 );
1037 write(
1038 &data.join(DEVICE_REGISTRY),
1039 r#"{"uuid-here":{"deviceId":"d1"}}"#,
1040 );
1041 write(
1042 &data.join(BRIDGE_FILE),
1043 r#"{"remoteSessionId":"cse_stale"}"#,
1044 );
1045 write(&data.join("Cookies"), "live-cookies");
1046 write(&data.join("Local Storage/leveldb/CURRENT"), "live-ldb");
1047 write(
1048 &data.join(SESSIONS_DIR).join("uuid-here/org-1/local_x.json"),
1049 r#"{"lastActivityAt":500}"#,
1050 );
1051 write(
1052 &data
1053 .join(SESSIONS_DIR)
1054 .join("uuid-here/org-1/scheduled-tasks.json"),
1055 r#"{"scheduledTasks":[{"id":"t1","createdAt":1}]}"#,
1056 );
1057
1058 write(
1059 &profiles.join("here/meta.json"),
1060 r#"{"label":"here","email":"here@example.com","accountUuid":"uuid-here","orgUuid":"org-1"}"#,
1061 );
1062 write(
1063 &profiles.join("there/meta.json"),
1064 r#"{"label":"there","email":"there@example.com","accountUuid":"uuid-there","orgUuid":"org-2"}"#,
1065 );
1066 write(&profiles.join("there").join(TOKEN_CACHE), "saved-a");
1067 write(&profiles.join("there").join(TOKEN_CACHE_V2), "saved-b");
1068 write(
1069 &profiles.join("there").join(DESKTOP_STATE).join("Cookies"),
1070 "there-cookies",
1071 );
1072 write(
1073 &profiles
1074 .join("there")
1075 .join(DESKTOP_STATE)
1076 .join("Local Storage/leveldb/CURRENT"),
1077 "there-ldb",
1078 );
1079
1080 Fixture {
1081 paths: Paths::at(data, profiles, backups),
1082 _root: root,
1083 }
1084 }
1085
1086 fn manifest(root: &Path) -> Vec<(String, u64)> {
1089 fn walk(dir: &Path, root: &Path, out: &mut Vec<(String, u64)>) {
1090 let Ok(entries) = std::fs::read_dir(dir) else {
1091 return;
1092 };
1093 for entry in entries.flatten() {
1094 let path = entry.path();
1095 if path.is_dir() {
1096 walk(&path, root, out);
1097 } else if let Ok(meta) = entry.metadata() {
1098 let relative = path.strip_prefix(root).unwrap().display().to_string();
1099 out.push((relative, meta.len()));
1100 }
1101 }
1102 }
1103 let mut out = Vec::new();
1104 walk(root, root, &mut out);
1105 out.sort();
1106 out
1107 }
1108
1109 #[test]
1110 fn profiles_load_sorted_with_their_capture_state() {
1111 let fixture = fixture();
1112 let profiles = load_profiles(&fixture.paths.profiles_dir);
1113
1114 assert_eq!(profiles.len(), 2);
1115 assert_eq!(profiles[0].label, "here");
1116 assert_eq!(profiles[0].email.as_deref(), Some("here@example.com"));
1117 assert!(!profiles[0].has_credentials);
1118 assert_eq!(profiles[1].label, "there");
1119 assert!(profiles[1].has_credentials);
1120 assert!(profiles[1].has_desktop_state);
1121 }
1122
1123 #[test]
1124 fn a_malformed_profile_is_skipped_not_fatal() {
1125 let fixture = fixture();
1126 write(
1127 &fixture.paths.profiles_dir.join("broken/meta.json"),
1128 "{ not json",
1129 );
1130 write(
1131 &fixture.paths.profiles_dir.join("no-uuid/meta.json"),
1132 r#"{"label":"x"}"#,
1133 );
1134
1135 let profiles = load_profiles(&fixture.paths.profiles_dir);
1136 let labels: Vec<&str> = profiles.iter().map(|p| p.label.as_str()).collect();
1137 assert_eq!(labels, ["here", "there"]);
1138 }
1139
1140 #[test]
1141 fn the_active_account_resolves_to_its_label() {
1142 let fixture = fixture();
1143 let profiles = load_profiles(&fixture.paths.profiles_dir);
1144 let uuid = active_account_uuid(&fixture.paths.config_json()).unwrap();
1145
1146 assert_eq!(label_for_uuid(&profiles, &uuid), Some("here"));
1147 assert_eq!(label_for_uuid(&profiles, "uuid-nobody"), None);
1148 }
1149
1150 #[test]
1151 fn session_counts_come_from_the_accounts_own_folder() {
1152 let fixture = fixture();
1153 let profiles = load_profiles(&fixture.paths.profiles_dir);
1154 let sessions_root = fixture.paths.sessions_root();
1155
1156 assert_eq!(session_count(&sessions_root, &profiles[0]), 2);
1157 assert_eq!(session_count(&sessions_root, &profiles[1]), 0);
1158 }
1159
1160 #[test]
1161 fn planning_a_switch_changes_nothing_on_disk() {
1162 let fixture = fixture();
1163 let before = manifest(&fixture.paths.data_dir);
1164
1165 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1166
1167 assert_eq!(plan.outgoing.as_deref(), Some("here"));
1168 assert_eq!(plan.tokens.token_cache, "saved-a");
1169 assert!(plan.restores_desktop_state);
1170 assert_eq!(plan.sessions.copied.len(), 1, "{:?}", plan.sessions);
1171 assert_eq!(manifest(&fixture.paths.data_dir), before);
1172 assert!(!fixture.paths.backups_dir.exists());
1173 }
1174
1175 #[test]
1176 fn planning_an_unknown_label_lists_the_known_ones() {
1177 let fixture = fixture();
1178 let error = plan_switch(&fixture.paths, "nope", SwitchOpts::default()).unwrap_err();
1179 let message = error.to_string();
1180 assert!(message.contains("here"), "{message}");
1181 assert!(message.contains("claude-acc"), "{message}");
1182 }
1183
1184 #[test]
1185 fn the_archive_skips_the_session_tree_by_default() {
1186 let fixture = fixture();
1187
1188 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1189 assert!(plan.archive_members.contains(&CONFIG_JSON.to_string()));
1190 assert!(plan.archive_members.contains(&DEVICE_REGISTRY.to_string()));
1191 assert!(plan.archive_members.contains(&BRIDGE_FILE.to_string()));
1192 assert!(plan.archive_members.contains(&"Cookies".to_string()));
1193 assert!(plan.archive_members.contains(&"Local Storage".to_string()));
1194 assert!(!plan.archive_members.contains(&SESSIONS_DIR.to_string()));
1195 assert!(
1196 plan.archive_members
1197 .iter()
1198 .any(|member| member.ends_with("scheduled-tasks.json"))
1199 );
1200
1201 let full = plan_switch(
1202 &fixture.paths,
1203 "there",
1204 SwitchOpts {
1205 backup_sessions: true,
1206 ..SwitchOpts::default()
1207 },
1208 )
1209 .unwrap();
1210 assert!(full.archive_members.contains(&SESSIONS_DIR.to_string()));
1211 }
1212
1213 #[test]
1214 fn applying_a_switch_quits_then_archives_then_relaunches() {
1215 let fixture = fixture();
1216 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1217 let recorder = Recorder::default();
1218
1219 apply_switch(&fixture.paths, &plan, &recorder).unwrap();
1220
1221 let steps = recorder.steps();
1222 assert_eq!(steps[0], "quit");
1223 assert!(steps[1].starts_with("archive "), "{steps:?}");
1224 assert_eq!(steps[2], "relaunch");
1225 }
1226
1227 #[test]
1228 fn a_failed_liveness_probe_relaunches_without_touching_account_data() {
1229 let fixture = fixture();
1230 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1231 let before = std::fs::read(fixture.paths.config_json()).unwrap();
1232 let app = QuitFailure::default();
1233
1234 let error = apply_switch(&fixture.paths, &plan, &app).unwrap_err();
1235
1236 assert!(error.to_string().contains("liveness probe failed"));
1237 assert_eq!(*app.steps.borrow(), ["quit", "relaunch"]);
1238 assert_eq!(std::fs::read(fixture.paths.config_json()).unwrap(), before);
1239 }
1240
1241 #[test]
1246 fn a_confirmed_deletion_reaches_every_account_and_updates_the_record() {
1247 let fixture = fixture();
1248 let sessions = fixture.paths.sessions_root();
1249 write(
1251 &sessions.join("uuid-here/org-1/scheduled-tasks.json"),
1252 r#"{"scheduledTasks":[{"id":"t1","createdAt":1}]}"#,
1253 );
1254 write(
1255 &sessions.join("uuid-there/org-2/scheduled-tasks.json"),
1256 r#"{"scheduledTasks":[{"id":"t1","createdAt":1},{"id":"t2","createdAt":2}]}"#,
1257 );
1258
1259 let mut plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1260 plan.confirmed_deletions = [merge::DeletionKey {
1261 kind: merge::ConflictKind::Routine,
1262 id: "t1".to_string(),
1263 deleted_by: "uuid-here".to_string(),
1264 still_in: vec!["uuid-there".to_string()],
1265 }]
1266 .into_iter()
1267 .collect();
1268 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1269
1270 for registry in [
1271 sessions.join("uuid-here/org-1/scheduled-tasks.json"),
1272 sessions.join("uuid-there/org-2/scheduled-tasks.json"),
1273 ] {
1274 let value: serde_json::Value =
1275 serde_json::from_slice(&std::fs::read(®istry).unwrap()).unwrap();
1276 let ids: Vec<&str> = value["scheduledTasks"]
1277 .as_array()
1278 .unwrap()
1279 .iter()
1280 .filter_map(|task| task["id"].as_str())
1281 .collect();
1282 assert!(
1283 !ids.contains(&"t1"),
1284 "{registry:?} still holds the deleted task"
1285 );
1286 }
1287 let there: serde_json::Value = serde_json::from_slice(
1290 &std::fs::read(sessions.join("uuid-there/org-2/scheduled-tasks.json")).unwrap(),
1291 )
1292 .unwrap();
1293 assert_eq!(there["scheduledTasks"][0]["id"], "t2");
1294
1295 let recorded = load_synced(&fixture.paths.synced_path());
1298 assert!(!recorded.is_empty(), "the sync record was never written");
1299 assert!(
1300 recorded
1301 .values()
1302 .all(|state| !state.routines.contains("t1")),
1303 "{recorded:?} still lists the deleted task"
1304 );
1305 }
1306
1307 #[test]
1312 fn keeping_every_conflict_leaves_other_accounts_untouched() {
1313 let fixture = fixture();
1314 let sessions = fixture.paths.sessions_root();
1315 let bystander = sessions.join("uuid-here/org-1/scheduled-tasks.json");
1316 write(
1317 &bystander,
1318 r#"{"scheduledTasks":[{"id":"t1","createdAt":1}]}"#,
1319 );
1320 let before = std::fs::read(&bystander).unwrap();
1321
1322 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1323 assert!(plan.confirmed_deletions.is_empty());
1324 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1325
1326 assert_eq!(std::fs::read(&bystander).unwrap(), before);
1327 }
1328
1329 #[test]
1333 fn a_switch_converges_routine_names_across_accounts() {
1334 let fixture = fixture();
1335 let sessions = fixture.paths.sessions_root();
1336 let here = sessions.join("uuid-here/org-1/scheduled-tasks.json");
1337 let there = sessions.join("uuid-there/org-2/scheduled-tasks.json");
1338 write(
1340 &here,
1341 r#"{"scheduledTasks":[{"id":"t1","createdAt":1,"displayName":"Home name"}]}"#,
1342 );
1343 write(
1344 &there,
1345 r#"{"scheduledTasks":[{"id":"t1","createdAt":1,"displayName":"There name"}]}"#,
1346 );
1347
1348 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1349 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1350
1351 let name = |path: &std::path::Path| -> String {
1352 let v: serde_json::Value =
1353 serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
1354 v["scheduledTasks"][0]["displayName"]
1355 .as_str()
1356 .unwrap_or_default()
1357 .to_string()
1358 };
1359 assert_eq!(name(&here), name(&there), "names did not converge");
1361 assert!(!name(&here).is_empty());
1362 }
1363
1364 #[test]
1365 fn applying_a_switch_swaps_the_credential_and_carries_history() {
1366 let fixture = fixture();
1367 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1368
1369 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1370
1371 let config: serde_json::Value =
1372 serde_json::from_slice(&std::fs::read(fixture.paths.config_json()).unwrap()).unwrap();
1373 assert_eq!(config["oauth:tokenCache"], "saved-a");
1374 assert_eq!(config["oauth:tokenCacheV2"], "saved-b");
1375 assert_eq!(config["lastKnownAccountUuid"], "uuid-there");
1376 assert_eq!(config["dxt:allowlistEnabled:org-1"], true);
1377
1378 assert!(
1380 fixture
1381 .paths
1382 .sessions_root()
1383 .join("uuid-there/org-2/local_x.json")
1384 .is_file()
1385 );
1386 assert_eq!(
1387 std::fs::read_to_string(fixture.paths.data_dir.join("Cookies")).unwrap(),
1388 "there-cookies"
1389 );
1390 assert_eq!(
1391 std::fs::read_to_string(fixture.paths.data_dir.join("Local Storage/leveldb/CURRENT"))
1392 .unwrap(),
1393 "there-ldb"
1394 );
1395 assert!(!fixture.paths.data_dir.join(BRIDGE_FILE).exists());
1397 }
1398
1399 #[test]
1400 fn the_outgoing_account_is_snapshotted_before_the_swap() {
1401 let fixture = fixture();
1402 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1403
1404 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1405
1406 let here = fixture.paths.profile_dir("here");
1410 assert_eq!(
1411 std::fs::read_to_string(here.join(TOKEN_CACHE)).unwrap(),
1412 "live-a"
1413 );
1414 assert_eq!(
1415 std::fs::read_to_string(here.join(TOKEN_CACHE_V2)).unwrap(),
1416 "live-b"
1417 );
1418 assert_eq!(
1419 std::fs::read_to_string(here.join(DESKTOP_STATE).join("Cookies")).unwrap(),
1420 "live-cookies"
1421 );
1422 let meta = std::fs::read_to_string(here.join(META_JSON)).unwrap();
1424 assert!(meta.contains("here@example.com"), "{meta}");
1425 }
1426
1427 #[test]
1428 fn planning_refuses_an_incomplete_saved_identity_without_touching_the_app() {
1429 let fixture = fixture();
1430 std::fs::remove_file(fixture.paths.profile_dir("there").join(TOKEN_CACHE)).unwrap();
1431 std::fs::remove_dir_all(fixture.paths.profile_dir("there").join(DESKTOP_STATE)).unwrap();
1432 let error = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap_err();
1433 assert!(error.to_string().contains("credential"), "{error}");
1434 }
1435
1436 #[test]
1437 fn planning_refuses_credentials_without_saved_browser_state() {
1438 let fixture = fixture();
1439 std::fs::remove_dir_all(fixture.paths.profile_dir("there").join(DESKTOP_STATE)).unwrap();
1440
1441 let error = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap_err();
1442
1443 assert!(error.to_string().contains("browser state"), "{error}");
1444 }
1445
1446 #[test]
1447 fn a_failed_switch_requests_rollback_and_still_relaunches() {
1448 let fixture = fixture();
1449 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1450 write(&fixture.paths.config_json(), "{ not json");
1451 let recorder = Recorder::default();
1452
1453 let error = apply_switch(&fixture.paths, &plan, &recorder).unwrap_err();
1454
1455 assert!(error.to_string().contains("json"), "{error}");
1456 let steps = recorder.steps();
1457 assert_eq!(steps.first().map(String::as_str), Some("quit"));
1458 assert!(
1459 steps.iter().any(|step| step.starts_with("restore ")),
1460 "{steps:?}"
1461 );
1462 assert_eq!(steps.last().map(String::as_str), Some("relaunch"));
1463 }
1464
1465 #[test]
1466 fn restoring_browser_state_removes_files_the_target_does_not_have() {
1467 let fixture = fixture();
1468 write(&fixture.paths.data_dir.join("Cookies-journal"), "outgoing");
1469 write(
1470 &fixture.paths.data_dir.join("Session Storage/CURRENT"),
1471 "outgoing",
1472 );
1473 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1474
1475 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1476
1477 assert!(!fixture.paths.data_dir.join("Cookies-journal").exists());
1478 assert!(!fixture.paths.data_dir.join("Session Storage").exists());
1479 }
1480
1481 #[test]
1482 fn keeping_the_bridge_leaves_it_in_place() {
1483 let fixture = fixture();
1484 let plan = plan_switch(
1485 &fixture.paths,
1486 "there",
1487 SwitchOpts {
1488 keep_bridge: true,
1489 ..SwitchOpts::default()
1490 },
1491 )
1492 .unwrap();
1493
1494 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1495 assert!(fixture.paths.data_dir.join(BRIDGE_FILE).is_file());
1496 }
1497
1498 #[test]
1499 fn archives_are_pruned_oldest_first() {
1500 let dir = tempfile::TempDir::new().unwrap();
1501 for stamp in ["20260101-000000", "20260102-000000", "20260103-000000"] {
1502 std::fs::write(dir.path().join(format!("switch-{stamp}-x.tar.gz")), "z").unwrap();
1503 }
1504 std::fs::write(dir.path().join("unrelated.txt"), "keep me").unwrap();
1505 let mut notes = Vec::new();
1506
1507 prune_archives(dir.path(), 2, &mut notes);
1508
1509 assert!(notes.is_empty(), "{notes:?}");
1510 assert!(!dir.path().join("switch-20260101-000000-x.tar.gz").exists());
1511 assert!(dir.path().join("switch-20260102-000000-x.tar.gz").exists());
1512 assert!(dir.path().join("switch-20260103-000000-x.tar.gz").exists());
1513 assert!(dir.path().join("unrelated.txt").exists());
1514 }
1515
1516 #[test]
1517 fn pruning_never_discards_the_only_rollback_archive() {
1518 let dir = tempfile::TempDir::new().unwrap();
1519 std::fs::write(dir.path().join("switch-20260101-000000-x.tar.gz"), "z").unwrap();
1520 let mut notes = Vec::new();
1521
1522 prune_archives(dir.path(), 1, &mut notes);
1523
1524 assert!(dir.path().join("switch-20260101-000000-x.tar.gz").exists());
1525 }
1526}