pitboard_core/switch/
adopt.rs1use super::{exclusive, purge};
23use crate::context::Context;
24use crate::error::Result;
25use crate::{audit, state};
26
27#[derive(Debug, PartialEq, Eq)]
29pub struct Adopted {
30 pub accounts: Vec<String>,
33 pub logins_dropped: Vec<String>,
35}
36
37pub fn adopt(ctx: &Context) -> Result<Option<Adopted>> {
40 let _exclusive = exclusive(ctx)?;
41 let (mut state, here) = state::load_any_machine(ctx)?;
42 if here {
43 return Ok(None);
44 }
45
46 let accounts: Vec<String> = state.accounts.iter().map(|a| a.key().typed()).collect();
47 let mut logins_dropped = Vec::new();
48 let mut parks = Vec::new();
49 for account in &state.accounts {
50 if let Some(park) = &account.parked {
51 logins_dropped.push(account.key().typed());
52 parks.push(park.service.clone());
53 }
54 }
55 for service in &parks {
60 state.release(service);
61 }
62 state.active.clear();
63 state.slot.clear();
64 state.machine = state::machine_id();
65 state::save(ctx, &state)?;
66 purge(ctx, &mut state);
67
68 audit::record(ctx, "adopt", "", "ok");
69 Ok(Some(Adopted {
70 accounts,
71 logins_dropped,
72 }))
73}
74
75#[cfg(test)]
76mod tests {
77 use super::*;
78 use crate::provider::ProviderId;
79 use crate::state::{Account, State};
80 use crate::store::memory::MemoryHost;
81 use crate::time::{Clock, FixedClock};
82 use serde_json::json;
83 use std::sync::Arc;
84
85 const NOW: i64 = 1_760_000_000;
86
87 struct Scratch(std::path::PathBuf);
88 impl Drop for Scratch {
89 fn drop(&mut self) {
90 let _ = std::fs::remove_dir_all(&self.0);
91 }
92 }
93
94 fn machine(name: &str) -> (Context, Arc<MemoryHost>, Scratch) {
95 let root = std::env::temp_dir().join(format!(
96 "pitboard-adopt-{name}-{}-{:?}",
97 std::process::id(),
98 std::thread::current().id()
99 ));
100 let _ = std::fs::remove_dir_all(&root);
101 let mem = MemoryHost::new();
102 let ctx = Context::new(root.clone())
103 .with_pitboard_home(root.clone())
104 .with_memory_stores(Arc::clone(&mem))
105 .with_clock(Arc::new(FixedClock::at(NOW)) as Arc<dyn Clock>);
106 crate::home::ensure(&ctx).expect("a home");
107 (ctx, mem, Scratch(root))
108 }
109
110 fn oauth() -> serde_json::Value {
111 json!({
112 "refreshToken": "r",
113 "accessToken": "a",
114 "expiresAt": (NOW + 3600) * 1000,
115 "refreshTokenExpiresAt": (NOW + 30 * 86_400) * 1000
116 })
117 }
118
119 fn from_elsewhere(ctx: &Context, mem: &MemoryHost) -> String {
121 let service = "pitboard-park-acc-1750000000000";
122 mem.vault().plant(service, &oauth().to_string());
123 let mut state = State {
124 machine: "a hash from another computer".into(),
125 ..State::default()
126 };
127 state.accounts.push(Account {
128 last_used_at: None,
129 label: "work".into(),
130 account_uuid: "acc".into(),
131 email: "me@example.com".into(),
132 detail: state::Detail::Claude {
133 organization_uuid: "org".into(),
134 oauth_account: json!({"accountUuid": "acc"}),
135 },
136 parked: Some(crate::park::describe(
137 crate::provider::ProviderId::Claude,
138 service,
139 NOW,
140 &oauth(),
141 )),
142 });
143 state.set_active(ProviderId::Claude, Some("work".into()));
144 let raw = serde_json::to_string(&state).expect("serialisable");
145 std::fs::write(crate::home::dir(ctx).join("state.json"), raw).expect("written");
146 service.to_string()
147 }
148
149 #[test]
150 fn a_home_from_another_machine_keeps_its_accounts_and_loses_its_logins() {
151 let (ctx, mem, _scratch) = machine("takeover");
152 let service = from_elsewhere(&ctx, &mem);
153
154 assert!(
155 matches!(
156 state::load(&ctx),
157 Err(crate::error::Error::StateWrongMachine { .. })
158 ),
159 "every other command still refuses it"
160 );
161
162 let adopted = adopt(&ctx)
163 .expect("adopting")
164 .expect("there was work to do");
165 assert_eq!(adopted.accounts, vec!["work".to_string()]);
166 assert_eq!(adopted.logins_dropped, vec!["work".to_string()]);
167
168 let state = state::load(&ctx).expect("now it is this machine's");
169 let account = state
170 .get(&crate::state::Key::new(
171 crate::provider::ProviderId::Claude,
172 "work",
173 ))
174 .expect("the account is kept");
175 assert_eq!(account.email, "me@example.com");
176 assert_eq!(account.account_uuid, "acc");
177 assert!(
178 account.parked.is_none(),
179 "the login is the one thing that does not move"
180 );
181 assert!(
182 mem.vault().peek(&service).is_none(),
183 "and the copy that came with it is deleted rather than left to be presented"
184 );
185 assert!(
186 state.active.is_empty(),
187 "who was signed in was true elsewhere"
188 );
189 }
190
191 #[test]
192 fn adopting_a_home_that_is_already_this_machines_does_nothing() {
193 let (ctx, _mem, _scratch) = machine("nothing-to-do");
194 let mut state = State::default();
195 state.accounts.push(Account {
196 last_used_at: None,
197 label: "work".into(),
198 account_uuid: "acc".into(),
199 email: "me@example.com".into(),
200 detail: state::Detail::Claude {
201 organization_uuid: "org".into(),
202 oauth_account: json!({}),
203 },
204 parked: None,
205 });
206 state::save(&ctx, &state).expect("saved");
207
208 assert_eq!(adopt(&ctx).expect("adopting"), None);
209 assert_eq!(
210 state::load(&ctx).expect("still readable").accounts.len(),
211 1,
212 "and it changed nothing"
213 );
214 }
215
216 #[test]
217 fn adopting_an_empty_home_is_not_an_error() {
218 let (ctx, _mem, _scratch) = machine("empty");
219 assert_eq!(adopt(&ctx).expect("adopting"), None);
220 }
221}