Skip to main content

ai_usagebar/claude_desktop/
mod.rs

1//! Switching which Claude account the **Claude Desktop app** is signed in as,
2//! carrying local history along so the account you land on shows the union of
3//! everything rather than only its own conversations.
4//!
5//! The Claude Desktop internals this relies on — the data-directory layout, the
6//! `oauth:tokenCache` / `oauth:tokenCacheV2` / `lastKnownAccountUuid` fields in
7//! `config.json`, which cookie and LevelDB stores carry the renderer's "who am
8//! I", the newest-wins rule for session indexes, and the reason
9//! `bridge-state.json` must be deleted rather than restored — were
10//! reverse-engineered by **claude-acc** (<https://github.com/ohmaseclaro/claude-acc>,
11//! MIT). [`plan_switch`]/[`apply_switch`] are a port of its `cmd_switch`, and
12//! they read and write its profile store so the two tools stay interchangeable.
13//! claude-acc still owns capturing (`add`), forgetting (`remove`), and chat
14//! filtering (`only`/`reset`); this module deliberately covers read + switch.
15//!
16//! Nothing here is compiled out on Linux. Every path is injected through
17//! [`Paths`], the platform commands live behind [`app::AppControl`], and the
18//! whole thing simply finds no data directory on a machine with no Claude
19//! Desktop app — which keeps the logic under CI's clippy and its tests running
20//! everywhere.
21
22pub mod app;
23pub mod capture;
24pub mod merge;
25#[cfg(any(target_os = "macos", test))]
26pub mod prepare;
27
28use std::collections::BTreeSet;
29use std::path::{Path, PathBuf};
30
31use serde::Deserialize;
32
33use crate::config::AnthropicConfig;
34use crate::display::sanitize_untrusted_path;
35use crate::error::{AppError, Result};
36
37use app::AppControl;
38use merge::{ScheduledMerge, SessionMerge};
39
40/// Claude Desktop's own state file: OAuth token caches plus the account pointer.
41const CONFIG_JSON: &str = "config.json";
42/// Root of the per-account session-index tree, `<root>/<account>/<org>/`.
43const SESSIONS_DIR: &str = "claude-code-sessions";
44/// Chromium-style cookie jar — part of the renderer's identity, not just
45/// `config.json`'s oauth fields.
46const COOKIE_FILES: [&str; 2] = ["Cookies", "Cookies-journal"];
47/// LevelDB stores that carry the rest of that identity.
48const LEVELDB_DIRS: [&str; 3] = ["Local Storage", "Session Storage", "IndexedDB"];
49/// Remote-control / cloud-session bridge. Holds a volatile `cse_…` session id
50/// that goes stale fast, so it is deleted on every switch and *never* saved
51/// into a profile: restoring a dead id makes `/remote-control` fail to
52/// disconnect.
53const BRIDGE_FILE: &str = "bridge-state.json";
54/// Account-keyed map of browser-extension device registrations. Additive only —
55/// see [`merge::merge_device_registry`].
56const DEVICE_REGISTRY: &str = "ant-device-registry.json";
57
58/// Profile-store filenames, owned by claude-acc's layout.
59const TOKEN_CACHE: &str = "config-tokenCache";
60const TOKEN_CACHE_V2: &str = "config-tokenCacheV2";
61const DESKTOP_STATE: &str = "desktop-state";
62const META_JSON: &str = "meta.json";
63/// One credential mutation can include a remote OAuth refresh. Account
64/// switching waits on the same lock rather than racing or failing after the
65/// old two-second window.
66pub const ACCOUNT_LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
67
68/// Where the Claude Desktop app and the saved profiles live.
69///
70/// Constructed with [`Paths::at`] in tests so nothing reads a real `$HOME`.
71#[derive(Debug, Clone)]
72pub struct Paths {
73    pub data_dir: PathBuf,
74    pub profiles_dir: PathBuf,
75    pub backups_dir: PathBuf,
76}
77
78impl Paths {
79    /// Test seam: every root explicit.
80    pub fn at(data_dir: PathBuf, profiles_dir: PathBuf, backups_dir: PathBuf) -> Self {
81        Self {
82            data_dir,
83            profiles_dir,
84            backups_dir,
85        }
86    }
87
88    /// Production paths. `desktop_profiles_dir` overrides the claude-acc
89    /// default; rollback archives land beside the profile store.
90    pub fn resolve(anthropic: &AnthropicConfig) -> Result<Self> {
91        let home = crate::cache::home_dir()?;
92        let profiles_dir = anthropic
93            .desktop_profiles_dir
94            .clone()
95            .unwrap_or_else(|| home.join(".claude-acc").join("profiles"));
96        let backups_dir = profiles_dir
97            .parent()
98            .map_or_else(|| home.join(".claude-acc"), Path::to_path_buf)
99            .join("backups");
100        Ok(Self {
101            data_dir: home.join("Library/Application Support/Claude"),
102            profiles_dir,
103            backups_dir,
104        })
105    }
106
107    /// Production paths against an explicit profile directory, for a relocated
108    /// Claude Desktop profile (one launched with `--user-data-dir`).
109    ///
110    /// `profiles_dir` stays shared: it is the label-keyed store of saved
111    /// credentials, and the same accounts back every profile.
112    ///
113    /// `backups_dir` is deliberately NOT shared, and must never be "simplified"
114    /// back to one location. [`Self::synced_path`] derives from it, and that
115    /// ledger records what each account held *after the last merge*, which
116    /// [`merge::deletion_candidates`] diffs live state against. Two profiles
117    /// sharing one ledger would each overwrite it with their own narrower view,
118    /// so keys merely absent from one profile would later read as intentional
119    /// deletions — phantom candidates that an interactive switch can then
120    /// answer, and therefore delete for real.
121    pub fn for_data_dir(data_dir: PathBuf, anthropic: &AnthropicConfig) -> Result<Self> {
122        let home = crate::cache::home_dir()?;
123        let profiles_dir = anthropic
124            .desktop_profiles_dir
125            .clone()
126            .unwrap_or_else(|| home.join(".claude-acc").join("profiles"));
127        // A sibling of the profile, never inside it: nothing Claude Desktop
128        // reads should gain directories we own.
129        let mut sibling = data_dir.clone().into_os_string();
130        sibling.push("-ai-usagebar");
131        Ok(Self {
132            data_dir,
133            profiles_dir,
134            backups_dir: PathBuf::from(sibling).join("backups"),
135        })
136    }
137
138    /// Whether there is a Claude Desktop app installation to act on at all.
139    /// False on Linux, and on a Mac where the app has never run.
140    pub fn available(&self) -> bool {
141        self.data_dir.is_dir()
142    }
143
144    pub fn config_json(&self) -> PathBuf {
145        self.data_dir.join(CONFIG_JSON)
146    }
147
148    pub fn sessions_root(&self) -> PathBuf {
149        self.data_dir.join(SESSIONS_DIR)
150    }
151
152    pub fn profile_dir(&self, label: &str) -> PathBuf {
153        self.profiles_dir.join(label)
154    }
155
156    /// Shared by Desktop account switching and inactive-profile OAuth refresh.
157    /// Both operations can rotate or install the same saved credential.
158    pub fn account_switch_lock(&self) -> PathBuf {
159        self.backups_dir.join(".account-switch.lock")
160    }
161
162    /// Where [`capture`] parks the live login before clearing it, so a
163    /// cancelled capture can put the account back. Sits beside the archives.
164    pub fn prelogin_dir(&self) -> PathBuf {
165        self.backups_dir.with_file_name("prelogin-backup")
166    }
167
168    /// What each account's schedule registry held after the last merge, keyed
169    /// by account UUID. Kept beside the profile store rather than inside a
170    /// profile so both this tool and claude-acc read and write one record, and
171    /// so accounts without a captured profile are still tracked.
172    pub fn synced_path(&self) -> PathBuf {
173        self.backups_dir.with_file_name("synced.json")
174    }
175}
176
177/// One saved account in the profile store.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct ProfileMeta {
180    /// The profile directory's name — authoritative, so a hand-edited
181    /// `meta.json` can never make a profile answer to the wrong label.
182    pub label: String,
183    pub email: Option<String>,
184    pub account_uuid: String,
185    /// Absent until the app has created a session folder for the account;
186    /// without it the history merges are skipped but the credential swap
187    /// still works.
188    pub org_uuid: Option<String>,
189    pub has_credentials: bool,
190    pub has_desktop_state: bool,
191}
192
193#[derive(Debug, Deserialize)]
194struct RawMeta {
195    email: Option<String>,
196    #[serde(rename = "accountUuid")]
197    account_uuid: Option<String>,
198    #[serde(rename = "orgUuid")]
199    org_uuid: Option<String>,
200}
201
202/// Every saved profile, sorted by label. Best-effort by design: one unreadable
203/// or hand-mangled `meta.json` is skipped rather than failing `account status`
204/// for every other account.
205pub fn load_profiles(profiles_dir: &Path) -> Vec<ProfileMeta> {
206    let Ok(entries) = std::fs::read_dir(profiles_dir) else {
207        return Vec::new();
208    };
209    let mut profiles: Vec<ProfileMeta> = entries
210        .flatten()
211        .filter_map(|entry| {
212            let dir = entry.path();
213            let label = dir.file_name()?.to_str()?.to_string();
214            let raw: RawMeta =
215                serde_json::from_slice(&std::fs::read(dir.join(META_JSON)).ok()?).ok()?;
216            let account_uuid = raw.account_uuid.filter(|uuid| !uuid.is_empty())?;
217            Some(ProfileMeta {
218                label,
219                email: raw.email.filter(|email| !email.is_empty()),
220                account_uuid,
221                org_uuid: raw.org_uuid.filter(|uuid| !uuid.is_empty()),
222                has_credentials: dir.join(TOKEN_CACHE).is_file()
223                    && dir.join(TOKEN_CACHE_V2).is_file(),
224                has_desktop_state: dir.join(DESKTOP_STATE).is_dir(),
225            })
226        })
227        .collect();
228    profiles.sort_by(|a, b| a.label.cmp(&b.label));
229    profiles
230}
231
232/// Read the schedule sync record. Absent or malformed reads as empty, which
233/// makes every task look new — so a first run, or a corrupted record, reports
234/// no deletions rather than inventing them.
235pub fn load_synced(path: &Path) -> merge::Synced {
236    std::fs::read(path)
237        .map(|bytes| merge::parse_synced(&bytes))
238        .unwrap_or_default()
239}
240
241/// Record what every account holds now, so the next switch can tell a deletion
242/// from a task that account never had.
243pub fn save_synced(path: &Path, synced: &merge::Synced) -> Result<()> {
244    crate::cache::atomic_write(path, &serde_json::to_vec(synced)?)
245}
246
247/// Which account the Desktop app currently believes it is. This is the app's
248/// own pointer, not a guess from file timestamps.
249pub fn active_account_uuid(config_json: &Path) -> Option<String> {
250    let bytes = std::fs::read(config_json).ok()?;
251    let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
252    value
253        .get("lastKnownAccountUuid")?
254        .as_str()
255        .filter(|uuid| !uuid.is_empty())
256        .map(str::to_string)
257}
258
259pub fn label_for_uuid<'a>(profiles: &'a [ProfileMeta], account_uuid: &str) -> Option<&'a str> {
260    profiles
261        .iter()
262        .find(|profile| profile.account_uuid == account_uuid)
263        .map(|profile| profile.label.as_str())
264}
265
266/// The saved accounts the Desktop app can switch to, and the one it is signed
267/// in as; `None` without the app's data or a saved account.
268pub fn switchable(paths: &Paths) -> Option<(Vec<String>, Option<String>)> {
269    if !paths.available() {
270        return None;
271    }
272    let profiles = load_profiles(&paths.profiles_dir);
273    if profiles.is_empty() {
274        return None;
275    }
276    let active = active_account_uuid(&paths.config_json())
277        .and_then(|uuid| label_for_uuid(&profiles, &uuid).map(str::to_string));
278    Some((
279        profiles.into_iter().map(|profile| profile.label).collect(),
280        active,
281    ))
282}
283
284/// How many conversations the account's history folder holds.
285pub fn session_count(sessions_root: &Path, profile: &ProfileMeta) -> usize {
286    let Some(org) = &profile.org_uuid else {
287        return 0;
288    };
289    let Ok(entries) = std::fs::read_dir(sessions_root.join(&profile.account_uuid).join(org)) else {
290        return 0;
291    };
292    entries
293        .flatten()
294        .filter(|entry| {
295            entry
296                .path()
297                .extension()
298                .is_some_and(|extension| extension == "json")
299        })
300        .count()
301}
302
303/// Knobs that change what a switch does rather than which account it targets.
304#[derive(Debug, Clone)]
305pub struct SwitchOpts {
306    /// Keep `bridge-state.json` instead of deleting it. Off by default, so the
307    /// shipped behaviour matches claude-acc; on, it turns "does the remote
308    /// bridge cause the browser disconnect?" into a one-command experiment.
309    pub keep_bridge: bool,
310    /// Also archive the whole session tree. Off by default: the session merge
311    /// is additive (the older copy always survives in its source folder), so a
312    /// full-tree archive costs tens of megabytes per switch to protect against
313    /// nothing. On, it matches claude-acc byte for byte.
314    pub backup_sessions: bool,
315    /// Rollback archives to retain.
316    pub keep_backups: usize,
317}
318
319impl Default for SwitchOpts {
320    fn default() -> Self {
321        Self {
322            keep_bridge: false,
323            backup_sessions: false,
324            keep_backups: 10,
325        }
326    }
327}
328
329/// The saved OAuth token caches for the account being switched to. Opaque
330/// blobs; never logged or printed.
331#[derive(Debug, Clone, PartialEq, Eq)]
332pub struct SavedTokens {
333    pub token_cache: String,
334    pub token_cache_v2: String,
335}
336
337/// Everything a switch would do, decided before anything is touched.
338#[derive(Debug)]
339pub struct SwitchPlan {
340    pub target: ProfileMeta,
341    /// The account being switched away from, when it is one we manage.
342    pub outgoing: Option<String>,
343    pub sessions: SessionMerge,
344    /// Absent when the target has no known org yet, so there is no history
345    /// folder to merge into.
346    pub scheduled: Option<ScheduledMerge>,
347    /// A switch is only valid with both saved Desktop credential blobs. Mixing
348    /// a target account's browser state with the current account's credential
349    /// would create an incoherent, destructive half-switch.
350    pub tokens: SavedTokens,
351    pub archive: PathBuf,
352    pub archive_members: Vec<String>,
353    pub restores_desktop_state: bool,
354    pub opts: SwitchOpts,
355    /// Schedules an account deleted that others still hold, so this merge would
356    /// resurrect them. The caller decides — a terminal prompt or the menu bar —
357    /// and records the verdict in `confirmed_deletions`.
358    pub deletions: Vec<merge::DeletionCandidate>,
359    /// Type-scoped items the user confirmed should go everywhere. Empty means
360    /// keep them all, which is also what a non-interactive switch must do.
361    pub confirmed_deletions: BTreeSet<merge::DeletionKey>,
362    /// Baseline used to plan this switch. Apply combines it with the actual
363    /// post-write state so unresolved definitions remain unresolved safely.
364    pub prior_synced: merge::Synced,
365}
366
367/// Decide the whole switch without performing any of it.
368///
369/// This is what makes `--dry-run` free, and a test asserts it leaves the data
370/// directory byte-identical.
371pub fn plan_switch(paths: &Paths, label: &str, opts: SwitchOpts) -> Result<SwitchPlan> {
372    let profiles = load_profiles(&paths.profiles_dir);
373    let target = profiles
374        .iter()
375        .find(|profile| profile.label == label)
376        .cloned()
377        .ok_or_else(|| {
378            let known: Vec<&str> = profiles.iter().map(|p| p.label.as_str()).collect();
379            AppError::Credentials(format!(
380                "no saved Claude Desktop account {label:?} in {}; known: {known:?}. \
381                 Capture one with `claude-acc add {label}` \
382                 (https://github.com/ohmaseclaro/claude-acc)",
383                sanitize_untrusted_path(&paths.profiles_dir)
384            ))
385        })?;
386
387    let sessions_root = paths.sessions_root();
388    let synced = load_synced(&paths.synced_path());
389    let (sessions, scheduled) = match &target.org_uuid {
390        Some(org) => (
391            merge::plan_session_merge(&sessions_root, &target.account_uuid, org),
392            Some(merge::plan_scheduled_merge(
393                &sessions_root,
394                &target.account_uuid,
395                org,
396                &synced,
397            )?),
398        ),
399        None => (SessionMerge::default(), None),
400    };
401    let deletions = merge::deletion_candidates(&sessions_root, &synced);
402
403    let outgoing = active_account_uuid(&paths.config_json())
404        .and_then(|uuid| label_for_uuid(&profiles, &uuid).map(str::to_string));
405
406    let profile_dir = paths.profile_dir(label);
407    let token_cache = std::fs::read_to_string(profile_dir.join(TOKEN_CACHE)).map_err(|_| {
408        AppError::Credentials(format!(
409            "no complete saved Desktop credential for {label:?}; capture or sign into that \
410             account before switching"
411        ))
412    })?;
413    let token_cache_v2 =
414        std::fs::read_to_string(profile_dir.join(TOKEN_CACHE_V2)).map_err(|_| {
415            AppError::Credentials(format!(
416                "no complete saved Desktop credential for {label:?}; capture or sign into that \
417                 account before switching"
418            ))
419        })?;
420    if token_cache.is_empty() || token_cache_v2.is_empty() {
421        return Err(AppError::Credentials(format!(
422            "the saved Desktop credential for {label:?} is empty; capture it again before switching"
423        )));
424    }
425    let tokens = SavedTokens {
426        token_cache,
427        token_cache_v2,
428    };
429    if !profile_dir.join(DESKTOP_STATE).is_dir() {
430        return Err(AppError::Credentials(format!(
431            "no saved Desktop browser state for {label:?}; capture that account again before switching"
432        )));
433    }
434
435    let stamp = crate::claude_desktop::timestamp();
436    Ok(SwitchPlan {
437        archive: paths
438            .backups_dir
439            .join(format!("switch-{stamp}-{label}.tar.gz")),
440        archive_members: archive_members(paths, &opts),
441        restores_desktop_state: true,
442        target,
443        outgoing,
444        sessions,
445        scheduled,
446        tokens,
447        opts,
448        deletions,
449        confirmed_deletions: BTreeSet::new(),
450        prior_synced: synced,
451    })
452}
453
454/// A history-only merge: bring every account's sessions and schedules into
455/// whichever account this profile is already signed into.
456///
457/// Deliberately narrower than [`SwitchPlan`] — no credential swap, no saved
458/// profile needed, no app control — so it is safe to run automatically before
459/// a relocated profile's app starts. Quitting "the Claude app" by name is
460/// ambiguous once several bundles share a `CFBundleName`, and nothing can
461/// answer a prompt during a double-click.
462#[derive(Debug)]
463pub struct HistoryMerge {
464    pub account_uuid: String,
465    /// Absent when the account has no history folder yet, so there is nothing
466    /// to merge into.
467    pub org_uuid: Option<String>,
468    pub sessions: SessionMerge,
469    pub scheduled: Option<ScheduledMerge>,
470    prior_synced: merge::Synced,
471}
472
473impl HistoryMerge {
474    #[must_use]
475    pub fn is_empty(&self) -> bool {
476        let scheduled_idle = match &self.scheduled {
477            Some(scheduled) => scheduled.added == 0 && scheduled.updated == 0,
478            None => true,
479        };
480        self.sessions.is_empty() && scheduled_idle
481    }
482}
483
484/// Which organisation folder to merge into. An account normally has exactly
485/// one; when several exist the live config's own orgs win, because writing
486/// history into a stale org scatters it where the app will not look.
487fn resolve_org(sessions_root: &Path, account_uuid: &str, config: &[u8]) -> Option<String> {
488    let dir = sessions_root.join(account_uuid);
489    let mut orgs: Vec<String> = std::fs::read_dir(&dir)
490        .ok()?
491        .flatten()
492        .filter(|entry| entry.path().is_dir())
493        .filter_map(|entry| entry.file_name().to_str().map(str::to_string))
494        .collect();
495    orgs.sort();
496    if orgs.len() > 1 {
497        let known = merge::orgs_in_config(config);
498        let mut narrowed: Vec<String> = orgs
499            .iter()
500            .filter(|org| known.contains(org))
501            .cloned()
502            .collect();
503        if narrowed.len() == 1 {
504            return narrowed.pop();
505        }
506        orgs.sort_by_key(|org| {
507            std::fs::metadata(dir.join(org))
508                .and_then(|meta| meta.modified())
509                .ok()
510        });
511    }
512    orgs.pop()
513}
514
515/// Which account and organisation a profile's history merges into.
516pub fn history_target(paths: &Paths) -> Result<(String, Option<String>)> {
517    let config_json = paths.config_json();
518    let config = std::fs::read(&config_json).map_err(|e| AppError::io_at(&config_json, e))?;
519    let account_uuid = merge::logged_in_account(&config).ok_or_else(|| {
520        AppError::Credentials(format!(
521            "no account is signed into {}; sign in before merging history",
522            sanitize_untrusted_path(&paths.data_dir)
523        ))
524    })?;
525    let org_uuid = resolve_org(&paths.sessions_root(), &account_uuid, &config);
526    Ok((account_uuid, org_uuid))
527}
528
529/// What a staging pass placed into the target profile.
530#[derive(Debug, Default)]
531pub struct Staged {
532    /// Session indexes newly placed or refreshed.
533    pub sessions: usize,
534    /// Account-level schedule registries placed or refreshed.
535    pub registries: usize,
536    /// Sources that exist but could not be read. Never folded into "empty":
537    /// an absent source and an unreadable one must not look alike, or a
538    /// partial failure silently shrinks history.
539    pub unreadable: Vec<PathBuf>,
540}
541
542/// Bring other profiles' per-account history into this profile's session tree,
543/// so [`plan_history_merge`] can then union it into the signed-in account.
544///
545/// Sources are opened for READING ONLY. Nothing — no ledger, lock, or
546/// temporary file — is ever written beneath a source, and a source is never
547/// required to be idle: reading an index out of a running profile is safe,
548/// writing into one is not. Every destination is checked to lie inside this
549/// profile's own session tree before it is written.
550///
551/// Idempotent, because every launch runs it: an index is placed only when the
552/// destination is absent or strictly older, so a second consecutive run copies
553/// nothing. `own` is the target account's own `<account>/<org>` directory,
554/// whose schedule registry is only ever placed when absent — overwriting it
555/// from a source could drop a routine that exists only here, before the merge
556/// has had a chance to reconcile it.
557pub fn stage_history_sources(
558    paths: &Paths,
559    sources: &[PathBuf],
560    own: Option<(&str, &str)>,
561    dry_run: bool,
562) -> Staged {
563    let mut staged = Staged::default();
564    let root = paths.sessions_root();
565    let own_dir = own.map(|(account, org)| root.join(account).join(org));
566
567    for source in sources {
568        let source_root = source.join(SESSIONS_DIR);
569        let Ok(accounts) = std::fs::read_dir(&source_root) else {
570            if source_root.exists() {
571                staged.unreadable.push(source_root);
572            }
573            continue;
574        };
575        for account in accounts.flatten() {
576            let Ok(orgs) = std::fs::read_dir(account.path()) else {
577                staged.unreadable.push(account.path());
578                continue;
579            };
580            for org in orgs.flatten().filter(|org| org.path().is_dir()) {
581                let Some(account_name) = account.file_name().to_str().map(str::to_string) else {
582                    continue;
583                };
584                let Some(org_name) = org.file_name().to_str().map(str::to_string) else {
585                    continue;
586                };
587                let destination_dir = root.join(&account_name).join(&org_name);
588                // Read-only contract: never write outside our own tree.
589                if !destination_dir.starts_with(&root) {
590                    continue;
591                }
592                let Ok(entries) = std::fs::read_dir(org.path()) else {
593                    staged.unreadable.push(org.path());
594                    continue;
595                };
596                for entry in entries.flatten() {
597                    let from = entry.path();
598                    let Some(name) = from.file_name().and_then(|n| n.to_str()) else {
599                        continue;
600                    };
601                    let is_index = name.starts_with("local_") && name.ends_with(".json");
602                    let is_registry = name == merge::SCHEDULED_TASKS;
603                    if !is_index && !is_registry {
604                        continue;
605                    }
606                    let to = destination_dir.join(name);
607                    // The target's own registry is authoritative until the
608                    // merge reconciles it: place it only if it is missing.
609                    let absent_only =
610                        is_registry && own_dir.as_deref() == Some(destination_dir.as_path());
611                    if !should_place(&from, &to, absent_only) {
612                        continue;
613                    }
614                    if dry_run || copy_file(&from, &to).is_ok() {
615                        if is_index {
616                            staged.sessions += 1;
617                        } else {
618                            staged.registries += 1;
619                        }
620                    } else {
621                        staged.unreadable.push(from);
622                    }
623                }
624            }
625        }
626    }
627    staged
628}
629
630/// Copy-if-absent-or-newer. Never overwrites a destination that is at least as
631/// new as the source, so re-running places nothing and a locally-advanced
632/// index is not rolled back.
633fn should_place(from: &Path, to: &Path, absent_only: bool) -> bool {
634    let Ok(destination) = std::fs::metadata(to) else {
635        return true;
636    };
637    if absent_only {
638        return false;
639    }
640    let newer = |meta: std::fs::Metadata| meta.modified().ok();
641    match (
642        std::fs::metadata(from).ok().and_then(newer),
643        newer(destination),
644    ) {
645        (Some(source), Some(existing)) => source > existing,
646        _ => false,
647    }
648}
649
650/// Plan a history-only merge for the profile at `paths`.
651pub fn plan_history_merge(paths: &Paths) -> Result<HistoryMerge> {
652    let config_json = paths.config_json();
653    let config = std::fs::read(&config_json).map_err(|e| AppError::io_at(&config_json, e))?;
654    let account_uuid = merge::logged_in_account(&config).ok_or_else(|| {
655        AppError::Credentials(format!(
656            "no account is signed into {}; sign in before merging history",
657            sanitize_untrusted_path(&paths.data_dir)
658        ))
659    })?;
660
661    let sessions_root = paths.sessions_root();
662    let synced = load_synced(&paths.synced_path());
663    let org_uuid = resolve_org(&sessions_root, &account_uuid, &config);
664    let (sessions, scheduled) = match &org_uuid {
665        Some(org) => (
666            merge::plan_session_merge(&sessions_root, &account_uuid, org),
667            Some(merge::plan_scheduled_merge(
668                &sessions_root,
669                &account_uuid,
670                org,
671                &synced,
672            )?),
673        ),
674        None => (SessionMerge::default(), None),
675    };
676
677    Ok(HistoryMerge {
678        account_uuid,
679        org_uuid,
680        sessions,
681        scheduled,
682        prior_synced: synced,
683    })
684}
685
686/// Apply a planned history merge.
687///
688/// Strictly additive: indexes are copied and registries rewritten, and no
689/// deletion sweep runs. Removing a routine stays reachable only from an
690/// answered prompt in `account switch`, which is what makes an automatic
691/// pre-launch merge unable to lose history.
692pub fn apply_history_merge(paths: &Paths, plan: &HistoryMerge) -> Result<Vec<String>> {
693    let mut notes = Vec::new();
694    let display_names = plan
695        .scheduled
696        .as_ref()
697        .map(ScheduledMerge::display_names)
698        .transpose()?;
699
700    for (source, destination) in plan.sessions.copied.iter().chain(&plan.sessions.updated) {
701        copy_file(source, destination)?;
702    }
703    if let Some(scheduled) = &plan.scheduled {
704        crate::cache::atomic_write(&scheduled.target, &scheduled.bytes)?;
705    }
706    if let Some(display_names) = &display_names {
707        match merge::plan_name_convergence(&paths.sessions_root(), display_names) {
708            Ok(convergence) => {
709                for (path, bytes) in convergence.rewrites {
710                    if let Err(error) = crate::cache::atomic_write(&path, &bytes) {
711                        notes.push(format!(
712                            "could not converge routine names in {}: {error}",
713                            sanitize_untrusted_path(&path)
714                        ));
715                    }
716                }
717            }
718            Err(error) => notes.push(format!("name convergence skipped: {error}")),
719        }
720    }
721
722    // Record what every account holds now, so a later switch can tell an
723    // intentional deletion from a task this profile simply never received.
724    let mut synced = merge::current_state(&paths.sessions_root());
725    let mut canonical = plan
726        .scheduled
727        .as_ref()
728        .map(|scheduled| scheduled.canonical_routines.clone())
729        .unwrap_or_else(|| merge::canonical_routines(&plan.prior_synced));
730    let present: BTreeSet<String> = synced
731        .values()
732        .flat_map(|account| account.routines.iter().cloned())
733        .collect();
734    canonical.retain(|id, _| present.contains(id));
735    merge::set_canonical_routines(&mut synced, &canonical);
736    if let Err(error) = save_synced(&paths.synced_path(), &synced) {
737        notes.push(format!("could not record the schedule sync: {error}"));
738    }
739    Ok(notes)
740}
741
742/// Perform a planned switch.
743///
744/// Claude is always relaunched after it has been stopped. If an operation fails
745/// after the rollback archive is written, the live Desktop identity is restored
746/// from that archive before relaunching.
747pub fn apply_switch(paths: &Paths, plan: &SwitchPlan, app: &dyn AppControl) -> Result<Vec<String>> {
748    let mut notes = Vec::new();
749    let members: Vec<&str> = plan.archive_members.iter().map(String::as_str).collect();
750
751    // Stop before archiving so SQLite/LevelDB and config.json are quiescent.
752    if let Err(error) = app.quit() {
753        // A fail-closed liveness probe can report an error after the graceful
754        // quit request already stopped Claude. Relaunch is harmless if it was
755        // still running and preserves the invariant that an attempted switch
756        // never leaves the app closed solely because verification failed.
757        return match app.relaunch() {
758            Ok(()) => Err(error),
759            Err(relaunch) => Err(AppError::Other(format!(
760                "{error}; Claude Desktop also could not be relaunched: {relaunch}"
761            ))),
762        };
763    }
764    let mut archived = false;
765    let mut result = (|| {
766        if !members.is_empty() {
767            app.archive(&plan.archive, &paths.data_dir, &members)?;
768            archived = true;
769            // Never prune away the archive needed for this switch's rollback,
770            // even if the caller supplied --keep-backups 0.
771            prune_archives(
772                &paths.backups_dir,
773                plan.opts.keep_backups.max(1),
774                &mut notes,
775            );
776        }
777        apply_switch_while_stopped(paths, plan, &mut notes)
778    })();
779
780    if result.is_err()
781        && archived
782        && let Err(rollback) = app.restore(&plan.archive, &paths.data_dir, &identity_members())
783    {
784        let original = result.unwrap_err();
785        result = Err(AppError::Other(format!(
786            "{original}; automatic Desktop rollback was incomplete: {rollback}"
787        )));
788    }
789
790    let relaunch = app.relaunch();
791    match (result, relaunch) {
792        (Ok(()), Ok(())) => Ok(notes),
793        (Err(error), Ok(())) => Err(error),
794        (Ok(()), Err(error)) => Err(error),
795        (Err(error), Err(relaunch)) => Err(AppError::Other(format!(
796            "{error}; Claude Desktop also could not be relaunched: {relaunch}"
797        ))),
798    }
799}
800
801fn apply_switch_while_stopped(
802    paths: &Paths,
803    plan: &SwitchPlan,
804    notes: &mut Vec<String>,
805) -> Result<()> {
806    // Derive convergence input from the immutable plan before touching any
807    // registries. Later writes must not be able to change the selected title.
808    let display_names = plan
809        .scheduled
810        .as_ref()
811        .map(ScheduledMerge::display_names)
812        .transpose()?;
813
814    // History merges abort too — a partial merge is recoverable, but doing it
815    // after the credential swap would strand the user on an account whose
816    // history never arrived.
817    for (source, destination) in plan.sessions.copied.iter().chain(&plan.sessions.updated) {
818        copy_file(source, destination)?;
819    }
820    if let Some(scheduled) = &plan.scheduled {
821        crate::cache::atomic_write(&scheduled.target, &scheduled.bytes)?;
822    }
823    // A confirmed deletion has to leave every registry, or the copy still
824    // sitting in another account hands it back on the next switch and the same
825    // prompt returns forever. Runs after the merge so it also strips anything
826    // the merge just re-added.
827    match merge::plan_deletion_sweep(&paths.sessions_root(), &plan.confirmed_deletions) {
828        Ok(sweep) => {
829            for (path, bytes) in sweep.rewrites {
830                if let Err(error) = crate::cache::atomic_write(&path, &bytes) {
831                    notes.push(format!(
832                        "could not apply deletion to {}: {error}",
833                        sanitize_untrusted_path(&path)
834                    ));
835                }
836            }
837            // Only the chat's *index* goes. Its transcript lives in the
838            // account-agnostic `~/.claude/projects/` and is never touched, so a
839            // confirmed chat stops following you between accounts without the
840            // conversation itself being destroyed.
841            for path in sweep.removals {
842                if let Err(error) = std::fs::remove_file(&path) {
843                    notes.push(format!(
844                        "could not remove {}: {error}",
845                        sanitize_untrusted_path(&path)
846                    ));
847                }
848            }
849        }
850        Err(error) => notes.push(format!("deletion sweep skipped: {error}")),
851    }
852    // Make every account agree on each routine's name. The merge selects the
853    // winning definitions while the switch is planned; carry those names into
854    // every registry so writes above cannot change the decision.
855    // Runs after the merge and the deletion sweep so it neither renames a
856    // just-deleted routine nor is undone by a re-added copy.
857    if let Some(display_names) = &display_names {
858        match merge::plan_name_convergence(&paths.sessions_root(), display_names) {
859            Ok(convergence) => {
860                let mut fully_applied = true;
861                for (path, bytes) in convergence.rewrites {
862                    if let Err(error) = crate::cache::atomic_write(&path, &bytes) {
863                        fully_applied = false;
864                        notes.push(format!(
865                            "could not converge routine names in {}: {error}",
866                            sanitize_untrusted_path(&path)
867                        ));
868                    }
869                }
870                if fully_applied && convergence.converged > 0 {
871                    notes.push(format!(
872                        "converged {} routine name(s) across accounts",
873                        convergence.converged
874                    ));
875                }
876            }
877            Err(error) => notes.push(format!("name convergence skipped: {error}")),
878        }
879    }
880    // Record what every account holds now, so the next switch can tell an
881    // intentional deletion from a task that account simply never received.
882    let mut synced = merge::current_state(&paths.sessions_root());
883    let mut canonical = plan
884        .scheduled
885        .as_ref()
886        .map(|scheduled| scheduled.canonical_routines.clone())
887        .unwrap_or_else(|| merge::canonical_routines(&plan.prior_synced));
888    for key in &plan.confirmed_deletions {
889        if key.kind == merge::ConflictKind::Routine {
890            canonical.remove(&key.id);
891        }
892    }
893    let present: BTreeSet<String> = synced
894        .values()
895        .flat_map(|account| account.routines.iter().cloned())
896        .collect();
897    canonical.retain(|id, _| present.contains(id));
898    merge::set_canonical_routines(&mut synced, &canonical);
899    if let Err(error) = save_synced(&paths.synced_path(), &synced) {
900        notes.push(format!("could not record the schedule sync: {error}"));
901    }
902
903    // Identify the outgoing account from the *live* config, after the quit:
904    // the app rewrites this file on shutdown. Snapshotting it must also happen
905    // strictly before the swap below, or the outgoing tokens get filed under
906    // the incoming label and an account is destroyed.
907    let live_config = paths.config_json();
908    let outgoing = active_account_uuid(&live_config).and_then(|uuid| {
909        label_for_uuid(&load_profiles(&paths.profiles_dir), &uuid).map(str::to_string)
910    });
911    if let Some(label) = &outgoing {
912        snapshot_profile(paths, label, notes)?;
913    }
914
915    swap_credentials(&live_config, &plan.tokens, &plan.target.account_uuid)?;
916
917    restore_desktop_state(paths, &plan.target.label)?;
918
919    if !plan.opts.keep_bridge {
920        let bridge = paths.data_dir.join(BRIDGE_FILE);
921        if bridge.is_file()
922            && let Err(error) = std::fs::remove_file(&bridge)
923        {
924            notes.push(format!("could not clear {BRIDGE_FILE}: {error}"));
925        }
926    }
927    restore_device_registry(paths, &plan.target.label, notes);
928
929    Ok(())
930}
931
932/// Copy every other account's history into this one's folder, so it shows the
933/// union of everything. Used when capturing a brand-new account, whose first
934/// login would otherwise open on an empty sidebar; a switch uses the
935/// pre-computed plan instead so `--dry-run` can report it.
936///
937/// Returns `(session indexes, routines)` brought in.
938fn merge_history_into(
939    paths: &Paths,
940    account_uuid: &str,
941    org_uuid: &str,
942    notes: &mut Vec<String>,
943) -> (usize, usize) {
944    let sessions_root = paths.sessions_root();
945    let sessions = merge::plan_session_merge(&sessions_root, account_uuid, org_uuid);
946    let mut copied = 0;
947    for (source, destination) in sessions.copied.iter().chain(&sessions.updated) {
948        match copy_file(source, destination) {
949            Ok(()) => copied += 1,
950            Err(error) => notes.push(format!(
951                "could not seed {}: {error}",
952                sanitize_untrusted_path(destination)
953            )),
954        }
955    }
956    let routines = match merge::plan_scheduled_merge(
957        &sessions_root,
958        account_uuid,
959        org_uuid,
960        &load_synced(&paths.synced_path()),
961    ) {
962        Ok(scheduled) => match crate::cache::atomic_write(&scheduled.target, &scheduled.bytes) {
963            Ok(()) => scheduled.added + scheduled.updated,
964            Err(error) => {
965                notes.push(format!("schedule seed skipped: {error}"));
966                0
967            }
968        },
969        Err(error) => {
970            notes.push(format!("schedule seed skipped: {error}"));
971            0
972        }
973    };
974    (copied, routines)
975}
976
977/// Save the outgoing account's live credential and browser state back into its
978/// profile, so switching to it later restores what it looked like just now.
979/// Only safe with the app fully quit — copying a live SQLite/LevelDB store
980/// risks grabbing it mid-write.
981///
982/// Deliberately never writes `meta.json`: that file belongs to `claude-acc add`,
983/// and two tools writing it is how the stores drift apart.
984fn snapshot_profile(paths: &Paths, label: &str, notes: &mut Vec<String>) -> Result<()> {
985    let profile_dir = paths.profile_dir(label);
986    std::fs::create_dir_all(&profile_dir).map_err(|e| AppError::io_at(&profile_dir, e))?;
987    restrict(&profile_dir, 0o700, notes);
988
989    let bytes =
990        std::fs::read(paths.config_json()).map_err(|e| AppError::io_at(paths.config_json(), e))?;
991    let value: serde_json::Value = serde_json::from_slice(&bytes)?;
992    let live_tokens = SavedTokens {
993        token_cache: required_token(&value, "oauth:tokenCache")?.to_string(),
994        token_cache_v2: required_token(&value, "oauth:tokenCacheV2")?.to_string(),
995    };
996    write_saved_tokens(&profile_dir, &live_tokens, notes)?;
997
998    // The registry is account-keyed and shared, so it is snapshotted (never
999    // moved) purely so a lost key can be folded back in later.
1000    let registry = paths.data_dir.join(DEVICE_REGISTRY);
1001    if registry.is_file() {
1002        let bytes = std::fs::read(&registry).map_err(|e| AppError::io_at(&registry, e))?;
1003        crate::cache::atomic_write(&profile_dir.join(DEVICE_REGISTRY), &bytes)?;
1004    }
1005
1006    snapshot_desktop_state(paths, &profile_dir, notes)?;
1007    // bridge-state.json is deliberately not snapshotted — see BRIDGE_FILE.
1008    Ok(())
1009}
1010
1011fn required_token<'a>(value: &'a serde_json::Value, key: &str) -> Result<&'a str> {
1012    value
1013        .get(key)
1014        .and_then(serde_json::Value::as_str)
1015        .filter(|blob| !blob.is_empty())
1016        .ok_or_else(|| {
1017            AppError::Credentials(format!(
1018                "the live Desktop login is missing {key}; refusing to overwrite its saved profile"
1019            ))
1020        })
1021}
1022
1023fn write_saved_tokens(
1024    profile_dir: &Path,
1025    tokens: &SavedTokens,
1026    notes: &mut Vec<String>,
1027) -> Result<()> {
1028    let token_path = profile_dir.join(TOKEN_CACHE);
1029    let token_v2_path = profile_dir.join(TOKEN_CACHE_V2);
1030    let original = read_optional_file(&token_path)?;
1031    let original_v2 = read_optional_file(&token_v2_path)?;
1032    let write_result = (|| {
1033        crate::cache::atomic_write(&token_path, tokens.token_cache.as_bytes())?;
1034        crate::cache::atomic_write(&token_v2_path, tokens.token_cache_v2.as_bytes())?;
1035        Ok(())
1036    })();
1037    if let Err(error) = write_result {
1038        let mut rollback = Vec::new();
1039        if let Err(failure) = restore_optional_file(&token_path, original.as_deref()) {
1040            rollback.push(failure.to_string());
1041        }
1042        if let Err(failure) = restore_optional_file(&token_v2_path, original_v2.as_deref()) {
1043            rollback.push(failure.to_string());
1044        }
1045        return if rollback.is_empty() {
1046            Err(error)
1047        } else {
1048            Err(AppError::Other(format!(
1049                "{error}; saved-token rollback was incomplete: {}",
1050                rollback.join("; ")
1051            )))
1052        };
1053    }
1054    restrict(&token_path, 0o600, notes);
1055    restrict(&token_v2_path, 0o600, notes);
1056    Ok(())
1057}
1058
1059fn snapshot_desktop_state(
1060    paths: &Paths,
1061    profile_dir: &Path,
1062    notes: &mut Vec<String>,
1063) -> Result<()> {
1064    let state_dir = profile_dir.join(DESKTOP_STATE);
1065    let previous = profile_dir.join(".desktop-state.previous");
1066    if previous.exists() && !state_dir.exists() {
1067        std::fs::rename(&previous, &state_dir).map_err(|e| AppError::io_at(&previous, e))?;
1068    } else {
1069        remove_if_present(&previous)?;
1070    }
1071
1072    let staged = tempfile::Builder::new()
1073        .prefix(".desktop-state.pending-")
1074        .tempdir_in(profile_dir)
1075        .map_err(|e| AppError::io_at(profile_dir, e))?;
1076    restrict(staged.path(), 0o700, notes);
1077    for name in COOKIE_FILES {
1078        let source = paths.data_dir.join(name);
1079        let destination = staged.path().join(name);
1080        if source.is_file() {
1081            copy_file(&source, &destination)?;
1082            restrict(&destination, 0o600, notes);
1083        }
1084    }
1085    for name in LEVELDB_DIRS {
1086        let source = paths.data_dir.join(name);
1087        let destination = staged.path().join(name);
1088        if source.is_dir() {
1089            copy_dir(&source, &destination)?;
1090        }
1091    }
1092
1093    let staged = staged.keep();
1094    if state_dir.exists() {
1095        std::fs::rename(&state_dir, &previous).map_err(|e| AppError::io_at(&state_dir, e))?;
1096    }
1097    if let Err(error) = std::fs::rename(&staged, &state_dir) {
1098        let restore = if previous.exists() {
1099            std::fs::rename(&previous, &state_dir)
1100        } else {
1101            Ok(())
1102        };
1103        let _ = remove_if_present(&staged);
1104        return match restore {
1105            Ok(()) => Err(AppError::io_at(&staged, error)),
1106            Err(rollback) => Err(AppError::Other(format!(
1107                "could not install the Desktop-state snapshot: {error}; could not restore the previous snapshot: {rollback}"
1108            ))),
1109        };
1110    }
1111    if let Err(error) = remove_if_present(&previous) {
1112        notes.push(format!(
1113            "could not remove the previous Desktop-state snapshot: {error}"
1114        ));
1115    }
1116    Ok(())
1117}
1118
1119fn swap_credentials(config_json: &Path, tokens: &SavedTokens, account_uuid: &str) -> Result<()> {
1120    let existing = std::fs::read(config_json).map_err(|e| AppError::io_at(config_json, e))?;
1121    let bytes = merge::swap_config_tokens(
1122        &existing,
1123        &tokens.token_cache,
1124        &tokens.token_cache_v2,
1125        account_uuid,
1126    )?;
1127    // Atomic, unlike claude-acc's truncating write: a crash mid-write here
1128    // would take every account's tokens with it.
1129    crate::cache::atomic_write(config_json, &bytes)
1130}
1131
1132fn restore_desktop_state(paths: &Paths, label: &str) -> Result<()> {
1133    let state_dir = paths.profile_dir(label).join(DESKTOP_STATE);
1134    for name in COOKIE_FILES {
1135        let source = state_dir.join(name);
1136        let destination = paths.data_dir.join(name);
1137        if source.is_file() {
1138            copy_file(&source, &destination)?;
1139        } else {
1140            remove_if_present(&destination)?;
1141        }
1142    }
1143    for name in LEVELDB_DIRS {
1144        let source = state_dir.join(name);
1145        let destination = paths.data_dir.join(name);
1146        if source.is_dir() {
1147            replace_dir(&source, &destination)?;
1148        } else {
1149            remove_if_present(&destination)?;
1150        }
1151    }
1152    Ok(())
1153}
1154
1155/// Fold a snapshotted device registry back into the live one. Purely additive
1156/// (live wins every conflict), so this can only ever restore a lost entry.
1157fn restore_device_registry(paths: &Paths, label: &str, notes: &mut Vec<String>) {
1158    let snapshot = paths.profile_dir(label).join(DEVICE_REGISTRY);
1159    let live = paths.data_dir.join(DEVICE_REGISTRY);
1160    let (Ok(saved), Ok(current)) = (std::fs::read(&snapshot), std::fs::read(&live)) else {
1161        return;
1162    };
1163    match merge::merge_device_registry(&current, &saved) {
1164        Ok(bytes) if bytes != current => {
1165            if let Err(error) = crate::cache::atomic_write(&live, &bytes) {
1166                notes.push(format!("could not merge {DEVICE_REGISTRY}: {error}"));
1167            }
1168        }
1169        Ok(_) => {}
1170        Err(error) => notes.push(format!("could not merge {DEVICE_REGISTRY}: {error}")),
1171    }
1172}
1173
1174/// What goes into the rollback archive, relative to the data directory.
1175///
1176/// Only what a switch can actually destroy: the credential pointer, browser
1177/// identity, bridge, schedule registries, and device registry. Missing entries
1178/// are dropped, because `tar` fails the whole archive on one of them.
1179fn archive_members(paths: &Paths, opts: &SwitchOpts) -> Vec<String> {
1180    let mut members = Vec::new();
1181    for name in [CONFIG_JSON, DEVICE_REGISTRY, BRIDGE_FILE] {
1182        if paths.data_dir.join(name).exists() {
1183            members.push(name.to_string());
1184        }
1185    }
1186    for name in COOKIE_FILES.into_iter().chain(LEVELDB_DIRS) {
1187        if paths.data_dir.join(name).exists() {
1188            members.push(name.to_string());
1189        }
1190    }
1191    if opts.backup_sessions {
1192        if paths.sessions_root().is_dir() {
1193            members.push(SESSIONS_DIR.to_string());
1194        }
1195        return members;
1196    }
1197    let sessions_root = paths.sessions_root();
1198    let Ok(accounts) = std::fs::read_dir(&sessions_root) else {
1199        return members;
1200    };
1201    let mut registries = Vec::new();
1202    for account in accounts.flatten() {
1203        let Ok(orgs) = std::fs::read_dir(account.path()) else {
1204            continue;
1205        };
1206        for org in orgs.flatten() {
1207            let path = org.path().join("scheduled-tasks.json");
1208            if path.is_file()
1209                && let Ok(relative) = path.strip_prefix(&paths.data_dir)
1210            {
1211                registries.push(relative.display().to_string());
1212            }
1213        }
1214    }
1215    registries.sort();
1216    members.extend(registries);
1217    members
1218}
1219
1220fn identity_members() -> Vec<&'static str> {
1221    [CONFIG_JSON, DEVICE_REGISTRY, BRIDGE_FILE]
1222        .into_iter()
1223        .chain(COOKIE_FILES)
1224        .chain(LEVELDB_DIRS)
1225        .collect()
1226}
1227
1228fn prune_archives(backups_dir: &Path, keep: usize, notes: &mut Vec<String>) {
1229    let Ok(entries) = std::fs::read_dir(backups_dir) else {
1230        return;
1231    };
1232    // The name embeds a sortable timestamp right after the constant prefix, so
1233    // lexicographic order is chronological order.
1234    let mut archives: Vec<PathBuf> = entries
1235        .flatten()
1236        .map(|entry| entry.path())
1237        .filter(|path| {
1238            path.file_name()
1239                .and_then(|name| name.to_str())
1240                .is_some_and(|name| name.starts_with("switch-") && name.ends_with(".tar.gz"))
1241        })
1242        .collect();
1243    if archives.len() <= keep {
1244        return;
1245    }
1246    archives.sort();
1247    let doomed = archives.len() - keep;
1248    for path in archives.into_iter().take(doomed) {
1249        if let Err(error) = std::fs::remove_file(&path) {
1250            notes.push(format!(
1251                "could not prune {}: {error}",
1252                sanitize_untrusted_path(&path)
1253            ));
1254        }
1255    }
1256}
1257
1258fn copy_file(source: &Path, destination: &Path) -> Result<()> {
1259    if let Some(parent) = destination.parent() {
1260        std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
1261    }
1262    std::fs::copy(source, destination).map_err(|e| AppError::io_at(source, e))?;
1263    Ok(())
1264}
1265
1266fn remove_if_present(path: &Path) -> Result<()> {
1267    match std::fs::symlink_metadata(path) {
1268        Ok(metadata) if metadata.is_dir() => {
1269            std::fs::remove_dir_all(path).map_err(|e| AppError::io_at(path, e))
1270        }
1271        Ok(_) => std::fs::remove_file(path).map_err(|e| AppError::io_at(path, e)),
1272        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1273        Err(error) => Err(AppError::io_at(path, error)),
1274    }
1275}
1276
1277fn read_optional_file(path: &Path) -> Result<Option<Vec<u8>>> {
1278    match std::fs::read(path) {
1279        Ok(bytes) => Ok(Some(bytes)),
1280        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1281        Err(error) => Err(AppError::io_at(path, error)),
1282    }
1283}
1284
1285fn restore_optional_file(path: &Path, original: Option<&[u8]>) -> Result<()> {
1286    match original {
1287        Some(bytes) => crate::cache::atomic_write(path, bytes),
1288        None => remove_if_present(path),
1289    }
1290}
1291
1292/// Replace `destination` with a fresh copy of `source`. LevelDB stores must not
1293/// be merged file-by-file — a stale manifest beside fresh log files is a
1294/// corrupt store — so the old directory goes first.
1295fn replace_dir(source: &Path, destination: &Path) -> Result<()> {
1296    if destination.exists() {
1297        std::fs::remove_dir_all(destination).map_err(|e| AppError::io_at(destination, e))?;
1298    }
1299    copy_dir(source, destination)
1300}
1301
1302fn copy_dir(source: &Path, destination: &Path) -> Result<()> {
1303    std::fs::create_dir_all(destination).map_err(|e| AppError::io_at(destination, e))?;
1304    let entries = std::fs::read_dir(source).map_err(|e| AppError::io_at(source, e))?;
1305    for entry in entries.flatten() {
1306        let child = entry.path();
1307        let target = destination.join(entry.file_name());
1308        if child.is_dir() {
1309            copy_dir(&child, &target)?;
1310        } else {
1311            std::fs::copy(&child, &target).map_err(|e| AppError::io_at(&child, e))?;
1312        }
1313    }
1314    Ok(())
1315}
1316
1317#[cfg(unix)]
1318fn restrict(path: &Path, mode: u32, notes: &mut Vec<String>) {
1319    use std::os::unix::fs::PermissionsExt;
1320
1321    if let Err(error) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) {
1322        notes.push(format!(
1323            "could not restrict {}: {error}",
1324            sanitize_untrusted_path(path)
1325        ));
1326    }
1327}
1328
1329#[cfg(not(unix))]
1330fn restrict(_path: &Path, _mode: u32, _notes: &mut Vec<String>) {}
1331
1332fn timestamp() -> String {
1333    chrono::Local::now().format("%Y%m%d-%H%M%S%.3f").to_string()
1334}
1335
1336#[cfg(test)]
1337mod tests {
1338    use super::*;
1339    use app::Recorder;
1340    use std::cell::RefCell;
1341
1342    struct Fixture {
1343        _root: tempfile::TempDir,
1344        paths: Paths,
1345    }
1346
1347    #[derive(Default)]
1348    struct QuitFailure {
1349        steps: RefCell<Vec<&'static str>>,
1350    }
1351
1352    impl AppControl for QuitFailure {
1353        fn quit(&self) -> Result<()> {
1354            self.steps.borrow_mut().push("quit");
1355            Err(AppError::Other("liveness probe failed".into()))
1356        }
1357
1358        fn relaunch(&self) -> Result<()> {
1359            self.steps.borrow_mut().push("relaunch");
1360            Ok(())
1361        }
1362
1363        fn archive(&self, _archive: &Path, _root: &Path, _members: &[&str]) -> Result<()> {
1364            panic!("archive must not run after an unconfirmed quit")
1365        }
1366
1367        fn restore(&self, _archive: &Path, _root: &Path, _members: &[&str]) -> Result<()> {
1368            panic!("restore must not run before a switch starts")
1369        }
1370    }
1371
1372    fn write(path: &Path, contents: &str) {
1373        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1374        std::fs::write(path, contents).unwrap();
1375    }
1376
1377    /// Two accounts: `here` (active) and `there` (fully captured).
1378    fn fixture() -> Fixture {
1379        let root = tempfile::TempDir::new().unwrap();
1380        let data = root.path().join("data");
1381        let profiles = root.path().join("profiles");
1382        let backups = root.path().join("backups");
1383
1384        write(
1385            &data.join(CONFIG_JSON),
1386            r#"{"lastKnownAccountUuid":"uuid-here","oauth:tokenCache":"live-a",
1387                "oauth:tokenCacheV2":"live-b","dxt:allowlistEnabled:org-1":true}"#,
1388        );
1389        write(
1390            &data.join(DEVICE_REGISTRY),
1391            r#"{"uuid-here":{"deviceId":"d1"}}"#,
1392        );
1393        write(
1394            &data.join(BRIDGE_FILE),
1395            r#"{"remoteSessionId":"cse_stale"}"#,
1396        );
1397        write(&data.join("Cookies"), "live-cookies");
1398        write(&data.join("Local Storage/leveldb/CURRENT"), "live-ldb");
1399        write(
1400            &data.join(SESSIONS_DIR).join("uuid-here/org-1/local_x.json"),
1401            r#"{"lastActivityAt":500}"#,
1402        );
1403        write(
1404            &data
1405                .join(SESSIONS_DIR)
1406                .join("uuid-here/org-1/scheduled-tasks.json"),
1407            r#"{"scheduledTasks":[{"id":"t1","createdAt":1}]}"#,
1408        );
1409
1410        write(
1411            &profiles.join("here/meta.json"),
1412            r#"{"label":"here","email":"here@example.com","accountUuid":"uuid-here","orgUuid":"org-1"}"#,
1413        );
1414        write(
1415            &profiles.join("there/meta.json"),
1416            r#"{"label":"there","email":"there@example.com","accountUuid":"uuid-there","orgUuid":"org-2"}"#,
1417        );
1418        write(&profiles.join("there").join(TOKEN_CACHE), "saved-a");
1419        write(&profiles.join("there").join(TOKEN_CACHE_V2), "saved-b");
1420        write(
1421            &profiles.join("there").join(DESKTOP_STATE).join("Cookies"),
1422            "there-cookies",
1423        );
1424        write(
1425            &profiles
1426                .join("there")
1427                .join(DESKTOP_STATE)
1428                .join("Local Storage/leveldb/CURRENT"),
1429            "there-ldb",
1430        );
1431
1432        Fixture {
1433            paths: Paths::at(data, profiles, backups),
1434            _root: root,
1435        }
1436    }
1437
1438    #[test]
1439    fn switchable_lists_saved_accounts_and_the_signed_in_one() {
1440        let f = fixture();
1441        assert_eq!(
1442            switchable(&f.paths),
1443            Some((vec!["here".into(), "there".into()], Some("here".into())))
1444        );
1445    }
1446
1447    #[test]
1448    fn switchable_is_none_without_the_app_or_a_saved_account() {
1449        let f = fixture();
1450        let no_app = Paths::at(
1451            f.paths.data_dir.join("missing"),
1452            f.paths.profiles_dir.clone(),
1453            f.paths.backups_dir.clone(),
1454        );
1455        assert_eq!(switchable(&no_app), None);
1456        let no_profiles = Paths::at(
1457            f.paths.data_dir.clone(),
1458            f.paths.profiles_dir.join("missing"),
1459            f.paths.backups_dir.clone(),
1460        );
1461        assert_eq!(switchable(&no_profiles), None);
1462    }
1463
1464    /// `(relative path, length)` for every file under a root, so a test can
1465    /// assert nothing changed.
1466    fn manifest(root: &Path) -> Vec<(String, u64)> {
1467        fn walk(dir: &Path, root: &Path, out: &mut Vec<(String, u64)>) {
1468            let Ok(entries) = std::fs::read_dir(dir) else {
1469                return;
1470            };
1471            for entry in entries.flatten() {
1472                let path = entry.path();
1473                if path.is_dir() {
1474                    walk(&path, root, out);
1475                } else if let Ok(meta) = entry.metadata() {
1476                    let relative = path.strip_prefix(root).unwrap().display().to_string();
1477                    out.push((relative, meta.len()));
1478                }
1479            }
1480        }
1481        let mut out = Vec::new();
1482        walk(root, root, &mut out);
1483        out.sort();
1484        out
1485    }
1486
1487    #[test]
1488    fn profiles_load_sorted_with_their_capture_state() {
1489        let fixture = fixture();
1490        let profiles = load_profiles(&fixture.paths.profiles_dir);
1491
1492        assert_eq!(profiles.len(), 2);
1493        assert_eq!(profiles[0].label, "here");
1494        assert_eq!(profiles[0].email.as_deref(), Some("here@example.com"));
1495        assert!(!profiles[0].has_credentials);
1496        assert_eq!(profiles[1].label, "there");
1497        assert!(profiles[1].has_credentials);
1498        assert!(profiles[1].has_desktop_state);
1499    }
1500
1501    #[test]
1502    fn a_malformed_profile_is_skipped_not_fatal() {
1503        let fixture = fixture();
1504        write(
1505            &fixture.paths.profiles_dir.join("broken/meta.json"),
1506            "{ not json",
1507        );
1508        write(
1509            &fixture.paths.profiles_dir.join("no-uuid/meta.json"),
1510            r#"{"label":"x"}"#,
1511        );
1512
1513        let profiles = load_profiles(&fixture.paths.profiles_dir);
1514        let labels: Vec<&str> = profiles.iter().map(|p| p.label.as_str()).collect();
1515        assert_eq!(labels, ["here", "there"]);
1516    }
1517
1518    #[test]
1519    fn the_active_account_resolves_to_its_label() {
1520        let fixture = fixture();
1521        let profiles = load_profiles(&fixture.paths.profiles_dir);
1522        let uuid = active_account_uuid(&fixture.paths.config_json()).unwrap();
1523
1524        assert_eq!(label_for_uuid(&profiles, &uuid), Some("here"));
1525        assert_eq!(label_for_uuid(&profiles, "uuid-nobody"), None);
1526    }
1527
1528    #[test]
1529    fn session_counts_come_from_the_accounts_own_folder() {
1530        let fixture = fixture();
1531        let profiles = load_profiles(&fixture.paths.profiles_dir);
1532        let sessions_root = fixture.paths.sessions_root();
1533
1534        assert_eq!(session_count(&sessions_root, &profiles[0]), 2);
1535        assert_eq!(session_count(&sessions_root, &profiles[1]), 0);
1536    }
1537
1538    #[test]
1539    fn planning_a_switch_changes_nothing_on_disk() {
1540        let fixture = fixture();
1541        let before = manifest(&fixture.paths.data_dir);
1542
1543        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1544
1545        assert_eq!(plan.outgoing.as_deref(), Some("here"));
1546        assert_eq!(plan.tokens.token_cache, "saved-a");
1547        assert!(plan.restores_desktop_state);
1548        assert_eq!(plan.sessions.copied.len(), 1, "{:?}", plan.sessions);
1549        assert_eq!(manifest(&fixture.paths.data_dir), before);
1550        assert!(!fixture.paths.backups_dir.exists());
1551    }
1552
1553    #[test]
1554    fn planning_an_unknown_label_lists_the_known_ones() {
1555        let fixture = fixture();
1556        let error = plan_switch(&fixture.paths, "nope", SwitchOpts::default()).unwrap_err();
1557        let message = error.to_string();
1558        assert!(message.contains("here"), "{message}");
1559        assert!(message.contains("claude-acc"), "{message}");
1560    }
1561
1562    #[test]
1563    fn the_archive_skips_the_session_tree_by_default() {
1564        let fixture = fixture();
1565
1566        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1567        assert!(plan.archive_members.contains(&CONFIG_JSON.to_string()));
1568        assert!(plan.archive_members.contains(&DEVICE_REGISTRY.to_string()));
1569        assert!(plan.archive_members.contains(&BRIDGE_FILE.to_string()));
1570        assert!(plan.archive_members.contains(&"Cookies".to_string()));
1571        assert!(plan.archive_members.contains(&"Local Storage".to_string()));
1572        assert!(!plan.archive_members.contains(&SESSIONS_DIR.to_string()));
1573        assert!(
1574            plan.archive_members
1575                .iter()
1576                .any(|member| member.ends_with("scheduled-tasks.json"))
1577        );
1578
1579        let full = plan_switch(
1580            &fixture.paths,
1581            "there",
1582            SwitchOpts {
1583                backup_sessions: true,
1584                ..SwitchOpts::default()
1585            },
1586        )
1587        .unwrap();
1588        assert!(full.archive_members.contains(&SESSIONS_DIR.to_string()));
1589    }
1590
1591    #[test]
1592    fn applying_a_switch_quits_then_archives_then_relaunches() {
1593        let fixture = fixture();
1594        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1595        let recorder = Recorder::default();
1596
1597        apply_switch(&fixture.paths, &plan, &recorder).unwrap();
1598
1599        let steps = recorder.steps();
1600        assert_eq!(steps[0], "quit");
1601        assert!(steps[1].starts_with("archive "), "{steps:?}");
1602        assert_eq!(steps[2], "relaunch");
1603    }
1604
1605    #[test]
1606    fn a_failed_liveness_probe_relaunches_without_touching_account_data() {
1607        let fixture = fixture();
1608        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1609        let before = std::fs::read(fixture.paths.config_json()).unwrap();
1610        let app = QuitFailure::default();
1611
1612        let error = apply_switch(&fixture.paths, &plan, &app).unwrap_err();
1613
1614        assert!(error.to_string().contains("liveness probe failed"));
1615        assert_eq!(*app.steps.borrow(), ["quit", "relaunch"]);
1616        assert_eq!(std::fs::read(fixture.paths.config_json()).unwrap(), before);
1617    }
1618
1619    /// The wiring, not the pieces: a confirmed deletion must actually reach
1620    /// every registry through `apply_switch`, and the sync record must be
1621    /// written so the next switch can tell deletions from new tasks. Testing
1622    /// `plan_deletion_sweep` alone would pass even if nothing called it.
1623    #[test]
1624    fn a_confirmed_deletion_reaches_every_account_and_updates_the_record() {
1625        let fixture = fixture();
1626        let sessions = fixture.paths.sessions_root();
1627        // Both accounts hold t1; only `there` holds t2.
1628        write(
1629            &sessions.join("uuid-here/org-1/scheduled-tasks.json"),
1630            r#"{"scheduledTasks":[{"id":"t1","createdAt":1}]}"#,
1631        );
1632        write(
1633            &sessions.join("uuid-there/org-2/scheduled-tasks.json"),
1634            r#"{"scheduledTasks":[{"id":"t1","createdAt":1},{"id":"t2","createdAt":2}]}"#,
1635        );
1636
1637        let mut plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1638        plan.confirmed_deletions = [merge::DeletionKey {
1639            kind: merge::ConflictKind::Routine,
1640            id: "t1".to_string(),
1641            deleted_by: "uuid-here".to_string(),
1642            still_in: vec!["uuid-there".to_string()],
1643        }]
1644        .into_iter()
1645        .collect();
1646        apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1647
1648        for registry in [
1649            sessions.join("uuid-here/org-1/scheduled-tasks.json"),
1650            sessions.join("uuid-there/org-2/scheduled-tasks.json"),
1651        ] {
1652            let value: serde_json::Value =
1653                serde_json::from_slice(&std::fs::read(&registry).unwrap()).unwrap();
1654            let ids: Vec<&str> = value["scheduledTasks"]
1655                .as_array()
1656                .unwrap()
1657                .iter()
1658                .filter_map(|task| task["id"].as_str())
1659                .collect();
1660            assert!(
1661                !ids.contains(&"t1"),
1662                "{registry:?} still holds the deleted task"
1663            );
1664        }
1665        // The unrelated task survives, so the sweep is targeted rather than a
1666        // blanket wipe of the registry.
1667        let there: serde_json::Value = serde_json::from_slice(
1668            &std::fs::read(sessions.join("uuid-there/org-2/scheduled-tasks.json")).unwrap(),
1669        )
1670        .unwrap();
1671        assert_eq!(there["scheduledTasks"][0]["id"], "t2");
1672
1673        // And the record now reflects reality, so t1 is not reported as a
1674        // deletion for ever after.
1675        let recorded = load_synced(&fixture.paths.synced_path());
1676        assert!(!recorded.is_empty(), "the sync record was never written");
1677        assert!(
1678            recorded
1679                .values()
1680                .all(|state| !state.routines.contains("t1")),
1681            "{recorded:?} still lists the deleted task"
1682        );
1683    }
1684
1685    /// Keeping everything must leave *other* accounts' registries untouched —
1686    /// the safe answer has to be genuinely inert, not "delete nothing but
1687    /// rewrite everything anyway". The switch target is excluded because the
1688    /// ordinary merge rewrites it either way.
1689    #[test]
1690    fn keeping_every_conflict_leaves_other_accounts_untouched() {
1691        let fixture = fixture();
1692        let sessions = fixture.paths.sessions_root();
1693        let bystander = sessions.join("uuid-here/org-1/scheduled-tasks.json");
1694        write(
1695            &bystander,
1696            r#"{"scheduledTasks":[{"id":"t1","createdAt":1}]}"#,
1697        );
1698        let before = std::fs::read(&bystander).unwrap();
1699
1700        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1701        assert!(plan.confirmed_deletions.is_empty());
1702        apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1703
1704        assert_eq!(std::fs::read(&bystander).unwrap(), before);
1705    }
1706
1707    /// The wiring: a switch must actually converge routine names across every
1708    /// account, not just build the plan. Testing `plan_name_convergence` alone
1709    /// would pass even if `apply_switch` never called it.
1710    #[test]
1711    fn a_switch_converges_routine_names_across_accounts() {
1712        let fixture = fixture();
1713        let sessions = fixture.paths.sessions_root();
1714        let here = sessions.join("uuid-here/org-1/scheduled-tasks.json");
1715        let there = sessions.join("uuid-there/org-2/scheduled-tasks.json");
1716        // Same routine id, two different titles — the non-converging case.
1717        write(
1718            &here,
1719            r#"{"scheduledTasks":[{"id":"t1","createdAt":1,"displayName":"Home name"}]}"#,
1720        );
1721        write(
1722            &there,
1723            r#"{"scheduledTasks":[{"id":"t1","createdAt":1,"displayName":"There name"}]}"#,
1724        );
1725
1726        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1727        apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1728
1729        let name = |path: &std::path::Path| -> String {
1730            let v: serde_json::Value =
1731                serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
1732            v["scheduledTasks"][0]["displayName"]
1733                .as_str()
1734                .unwrap_or_default()
1735                .to_string()
1736        };
1737        // The point is that both accounts agree afterwards, not which name won.
1738        assert_eq!(name(&here), name(&there), "names did not converge");
1739        assert!(!name(&here).is_empty());
1740    }
1741
1742    #[test]
1743    fn applying_a_switch_swaps_the_credential_and_carries_history() {
1744        let fixture = fixture();
1745        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1746
1747        apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1748
1749        let config: serde_json::Value =
1750            serde_json::from_slice(&std::fs::read(fixture.paths.config_json()).unwrap()).unwrap();
1751        assert_eq!(config["oauth:tokenCache"], "saved-a");
1752        assert_eq!(config["oauth:tokenCacheV2"], "saved-b");
1753        assert_eq!(config["lastKnownAccountUuid"], "uuid-there");
1754        assert_eq!(config["dxt:allowlistEnabled:org-1"], true);
1755
1756        // History followed the switch, and the browser state came back.
1757        assert!(
1758            fixture
1759                .paths
1760                .sessions_root()
1761                .join("uuid-there/org-2/local_x.json")
1762                .is_file()
1763        );
1764        assert_eq!(
1765            std::fs::read_to_string(fixture.paths.data_dir.join("Cookies")).unwrap(),
1766            "there-cookies"
1767        );
1768        assert_eq!(
1769            std::fs::read_to_string(fixture.paths.data_dir.join("Local Storage/leveldb/CURRENT"))
1770                .unwrap(),
1771            "there-ldb"
1772        );
1773        // The volatile remote-control bridge is cleared, never restored.
1774        assert!(!fixture.paths.data_dir.join(BRIDGE_FILE).exists());
1775    }
1776
1777    #[test]
1778    fn the_outgoing_account_is_snapshotted_before_the_swap() {
1779        let fixture = fixture();
1780        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1781
1782        apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1783
1784        // `here` must have captured the credential that was live *before* the
1785        // swap. Reading it back as "saved-a" would mean we filed the incoming
1786        // account's tokens under the outgoing label.
1787        let here = fixture.paths.profile_dir("here");
1788        assert_eq!(
1789            std::fs::read_to_string(here.join(TOKEN_CACHE)).unwrap(),
1790            "live-a"
1791        );
1792        assert_eq!(
1793            std::fs::read_to_string(here.join(TOKEN_CACHE_V2)).unwrap(),
1794            "live-b"
1795        );
1796        assert_eq!(
1797            std::fs::read_to_string(here.join(DESKTOP_STATE).join("Cookies")).unwrap(),
1798            "live-cookies"
1799        );
1800        // meta.json belongs to claude-acc; we must never rewrite it.
1801        let meta = std::fs::read_to_string(here.join(META_JSON)).unwrap();
1802        assert!(meta.contains("here@example.com"), "{meta}");
1803    }
1804
1805    #[test]
1806    fn planning_refuses_an_incomplete_saved_identity_without_touching_the_app() {
1807        let fixture = fixture();
1808        std::fs::remove_file(fixture.paths.profile_dir("there").join(TOKEN_CACHE)).unwrap();
1809        std::fs::remove_dir_all(fixture.paths.profile_dir("there").join(DESKTOP_STATE)).unwrap();
1810        let error = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap_err();
1811        assert!(error.to_string().contains("credential"), "{error}");
1812    }
1813
1814    #[test]
1815    fn planning_refuses_credentials_without_saved_browser_state() {
1816        let fixture = fixture();
1817        std::fs::remove_dir_all(fixture.paths.profile_dir("there").join(DESKTOP_STATE)).unwrap();
1818
1819        let error = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap_err();
1820
1821        assert!(error.to_string().contains("browser state"), "{error}");
1822    }
1823
1824    #[test]
1825    fn a_failed_switch_requests_rollback_and_still_relaunches() {
1826        let fixture = fixture();
1827        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1828        write(&fixture.paths.config_json(), "{ not json");
1829        let recorder = Recorder::default();
1830
1831        let error = apply_switch(&fixture.paths, &plan, &recorder).unwrap_err();
1832
1833        assert!(error.to_string().contains("json"), "{error}");
1834        let steps = recorder.steps();
1835        assert_eq!(steps.first().map(String::as_str), Some("quit"));
1836        assert!(
1837            steps.iter().any(|step| step.starts_with("restore ")),
1838            "{steps:?}"
1839        );
1840        assert_eq!(steps.last().map(String::as_str), Some("relaunch"));
1841    }
1842
1843    #[test]
1844    fn restoring_browser_state_removes_files_the_target_does_not_have() {
1845        let fixture = fixture();
1846        write(&fixture.paths.data_dir.join("Cookies-journal"), "outgoing");
1847        write(
1848            &fixture.paths.data_dir.join("Session Storage/CURRENT"),
1849            "outgoing",
1850        );
1851        let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1852
1853        apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1854
1855        assert!(!fixture.paths.data_dir.join("Cookies-journal").exists());
1856        assert!(!fixture.paths.data_dir.join("Session Storage").exists());
1857    }
1858
1859    #[test]
1860    fn keeping_the_bridge_leaves_it_in_place() {
1861        let fixture = fixture();
1862        let plan = plan_switch(
1863            &fixture.paths,
1864            "there",
1865            SwitchOpts {
1866                keep_bridge: true,
1867                ..SwitchOpts::default()
1868            },
1869        )
1870        .unwrap();
1871
1872        apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1873        assert!(fixture.paths.data_dir.join(BRIDGE_FILE).is_file());
1874    }
1875
1876    #[test]
1877    fn archives_are_pruned_oldest_first() {
1878        let dir = tempfile::TempDir::new().unwrap();
1879        for stamp in ["20260101-000000", "20260102-000000", "20260103-000000"] {
1880            std::fs::write(dir.path().join(format!("switch-{stamp}-x.tar.gz")), "z").unwrap();
1881        }
1882        std::fs::write(dir.path().join("unrelated.txt"), "keep me").unwrap();
1883        let mut notes = Vec::new();
1884
1885        prune_archives(dir.path(), 2, &mut notes);
1886
1887        assert!(notes.is_empty(), "{notes:?}");
1888        assert!(!dir.path().join("switch-20260101-000000-x.tar.gz").exists());
1889        assert!(dir.path().join("switch-20260102-000000-x.tar.gz").exists());
1890        assert!(dir.path().join("switch-20260103-000000-x.tar.gz").exists());
1891        assert!(dir.path().join("unrelated.txt").exists());
1892    }
1893
1894    /// Notes are printed verbatim by `account`, so a path entering one must go
1895    /// through `sanitize_untrusted_path` — `Display for Path` escapes nothing.
1896    ///
1897    /// Scoped to `notes.push` rather than to the file, because elsewhere in
1898    /// this module a bare `.display()` is the *correct* call:
1899    /// `collect_registry_members` builds tar arguments, and sanitizing one
1900    /// would corrupt the filename actually handed to `tar`. The distinction is
1901    /// whether the string is read by a person or by a program.
1902    #[test]
1903    fn no_note_interpolates_an_unsanitized_path() {
1904        let mut sites = Vec::new();
1905        for file in crate::guard::rs_files_in("src") {
1906            let source = std::fs::read_to_string(&file).expect("readable module");
1907            let body = crate::guard::production_code(&source);
1908            sites.extend(
1909                crate::guard::calls(&body, "notes.push(")
1910                    .into_iter()
1911                    .filter(|call| call.contains(".display()"))
1912                    .map(|call| format!("{}: {call}", file.display())),
1913            );
1914        }
1915        assert!(
1916            sites.is_empty(),
1917            "a note reaches the terminal verbatim; render its path with \
1918             `sanitize_untrusted_path`. Found: {sites:#?}"
1919        );
1920    }
1921
1922    /// `account` prints every note straight to the terminal, so a path that
1923    /// reaches one must not carry an escape sequence there. Making the doomed
1924    /// archive a directory is the shortest route to the failure branch that
1925    /// builds a note: `remove_file` refuses it.
1926    ///
1927    /// Unix-only because a `\x1b` is not a legal filename byte on Windows.
1928    #[cfg(unix)]
1929    #[test]
1930    fn a_note_does_not_carry_a_terminal_escape_out_of_a_path() {
1931        let dir = tempfile::TempDir::new().unwrap();
1932        std::fs::create_dir(dir.path().join("switch-20260101-000000-\x1b[2Kx.tar.gz")).unwrap();
1933        std::fs::write(dir.path().join("switch-20260102-000000-y.tar.gz"), "z").unwrap();
1934        let mut notes = Vec::new();
1935
1936        prune_archives(dir.path(), 1, &mut notes);
1937
1938        assert_eq!(notes.len(), 1, "{notes:?}");
1939        assert!(!notes[0].contains('\u{1b}'), "{:?}", notes[0]);
1940        assert!(!notes[0].contains('\n'), "{:?}", notes[0]);
1941        assert!(notes[0].contains("could not prune"), "{}", notes[0]);
1942    }
1943
1944    #[test]
1945    fn pruning_never_discards_the_only_rollback_archive() {
1946        let dir = tempfile::TempDir::new().unwrap();
1947        std::fs::write(dir.path().join("switch-20260101-000000-x.tar.gz"), "z").unwrap();
1948        let mut notes = Vec::new();
1949
1950        prune_archives(dir.path(), 1, &mut notes);
1951
1952        assert!(dir.path().join("switch-20260101-000000-x.tar.gz").exists());
1953    }
1954}