1use super::{Error, Result, identity_of_document};
9use crate::context::Context;
10use crate::in_use::{self, InUse};
11use crate::provider::{self, LoginKind, Provider, ProviderId};
12use crate::service::Permit;
13use crate::state::{Account, Key, Park, ParkKind, State};
14use crate::{atomic, home, park, state, store};
15use serde_json::Value;
16use std::path::PathBuf;
17
18#[derive(serde::Serialize, serde::Deserialize)]
19pub(super) struct Journal {
20 #[serde(default = "claude")]
26 pub(super) provider: ProviderId,
27 pub(super) started_at: i64,
28 pub(super) from_label: String,
29 #[serde(rename = "from_uuid")]
31 pub(super) from_id: String,
32 pub(super) to_label: String,
33 #[serde(rename = "to_uuid")]
34 pub(super) to_id: String,
35 pub(super) park_service: String,
36 pub(super) incoming_service: String,
38 #[serde(default)]
44 pub(super) from_fingerprint: String,
45 #[serde(default)]
46 pub(super) to_fingerprint: String,
47 #[serde(default)]
54 pub(super) slot: Option<String>,
55}
56
57#[derive(Debug)]
59pub struct Recovered {
60 pub from: String,
61 pub to: String,
62 pub finished: bool,
63}
64
65impl Recovered {
66 pub fn code(&self) -> &'static str {
67 if self.finished {
68 "interrupted_switch_finished"
69 } else {
70 "interrupted_switch_undone"
71 }
72 }
73}
74
75impl std::fmt::Display for Recovered {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 let from = if self.from.is_empty() {
78 String::new()
79 } else {
80 format!(" from `{}`", self.from)
81 };
82 write!(
83 f,
84 "an earlier switch{from} to `{}` was interrupted; {}",
85 self.to,
86 if self.finished {
87 "it had in fact finished, and Pitboard has recorded that"
88 } else {
89 "it had not finished, and nothing was lost"
90 }
91 )
92 }
93}
94
95fn claude() -> ProviderId {
96 ProviderId::Claude
97}
98
99impl Journal {
100 fn from(&self) -> Key {
101 Key::new(self.provider, self.from_label.clone())
102 }
103
104 fn to(&self) -> Key {
105 Key::new(self.provider, self.to_label.clone())
106 }
107
108 fn replaces(&self) -> bool {
110 self.from_id.is_empty()
111 }
112}
113
114fn journal_path(ctx: &Context) -> PathBuf {
115 home::dir(ctx).join("journal.json")
116}
117
118pub(super) fn write_journal(ctx: &Context, permit: Permit, entry: &Journal) -> Result<()> {
121 let path = journal_path(ctx);
122 let fail = |source| Error::RecoveryFailed {
123 path: path.clone(),
124 source,
125 };
126 home::ensure(ctx, permit).map_err(fail)?;
127 let body = serde_json::to_string(entry).expect("a journal entry is always serialisable");
128 atomic::write(permit, &path, body.as_bytes(), atomic::Perms::Secret).map_err(fail)
129}
130
131pub(crate) fn pending(ctx: &Context) -> bool {
133 journal_path(ctx).exists()
134}
135
136pub(crate) fn interrupted_tool(ctx: &Context) -> Option<ProviderId> {
138 let raw = std::fs::read_to_string(journal_path(ctx)).ok()?;
139 serde_json::from_str::<Journal>(&raw)
140 .ok()
141 .map(|journal| journal.provider)
142}
143
144pub(super) fn clear_journal(ctx: &Context, permit: Permit) {
146 let _ = crate::host::fs::remove_file(permit, &journal_path(ctx));
147}
148
149struct Found {
150 parked: Option<Option<Value>>,
152 live_owner: Option<String>,
154}
155
156#[derive(Default, Debug, PartialEq)]
157struct Repair {
158 hold: Option<(String, Park)>,
161 drop: bool,
164 landed: bool,
166}
167
168struct Seen {
171 named: Option<String>,
173 login: String,
176}
177
178fn repair_for(
180 state: &State,
181 tool: &'static dyn Provider,
182 journal: &Journal,
183 found: &Found,
184) -> Option<Repair> {
185 let parked = found.parked.as_ref()?;
186 let owner = found.live_owner.as_deref()?;
187 let mut repair = Repair {
188 landed: owner == journal.to_id,
189 ..Repair::default()
190 };
191 if owner == journal.from_id {
192 repair.drop = parked.is_some();
193 } else if let Some(oauth) = parked
194 && !state.references(&journal.park_service)
195 {
196 repair.hold = Some((
197 journal.from_id.clone(),
198 park::describe(tool, &journal.park_service, journal.started_at, oauth),
199 ));
200 }
201 Some(repair)
202}
203
204fn apply(state: &mut State, journal: &Journal, repair: Repair, seen: Seen, at: i64) {
207 if repair.drop {
208 state.discard(&journal.park_service, ParkKind::Vault);
209 }
210 if let Some((id, park)) = repair.hold {
211 match state.by_id(journal.provider, &id).map(Account::key) {
212 Some(key) => state.park(&key, park),
213 None => state.release(&park.service),
216 }
217 }
218 if !repair.landed {
219 return;
220 }
221 match state
222 .by_id(journal.provider, &journal.to_id)
223 .map(Account::owner)
224 {
225 Some(owner) => {
226 let installed = InUse {
227 owner: Some(owner),
228 login: seen.login,
229 known_at: at,
230 named: seen.named,
231 };
232 state.identified(journal.provider, installed, at);
233 state.discard(&journal.incoming_service, ParkKind::Vault);
234 }
235 None => {
239 state.in_use.remove(journal.provider.code());
240 }
241 }
242}
243
244fn read_park(ctx: &Context, service: &str) -> Option<Option<Value>> {
245 match store::vault_read(ctx, service) {
246 Ok(raw) => Some(raw.and_then(|r| serde_json::from_str(&r).ok())),
247 Err(_) => None,
248 }
249}
250
251enum Owner {
253 Is(String),
255 Unknown(String),
257 Unasked(Value),
259 Unfinished(String),
262}
263
264fn live_owner(ctx: &Context, tool: &'static dyn Provider, journal: &Journal) -> (Owner, String) {
270 match tool.read_live(ctx) {
271 Err(e) => match left_unfinished(ctx, tool) {
272 Some(why) => (Owner::Unfinished(why), String::new()),
273 None => (Owner::Unknown(e.to_string()), String::new()),
274 },
275 Ok(None) if journal.replaces() => (Owner::Is(String::new()), String::new()),
277 Ok(None) => (Owner::Unknown("nothing is signed in".into()), String::new()),
278 Ok(Some(live)) => {
279 let login = tool.fingerprint(&live.raw);
280 let owner = match live_owner_by_fingerprint(journal, &login) {
281 Some(id) => Owner::Is(id),
282 None => Owner::Unasked(live.raw),
283 };
284 (owner, login)
285 }
286 }
287}
288
289fn left_unfinished(ctx: &Context, tool: &'static dyn Provider) -> Option<String> {
292 let live = tool.live(ctx).ok()?;
293 match store::read_raw(&live.chain, &live.service) {
294 Err(store::Error::Unfinished(why)) => Some(why),
295 _ => None,
296 }
297}
298
299fn not_finished(state: &State, journal: &Journal, why: &str, detail: impl ToString) -> Error {
300 Error::RecoveryUnfinished {
301 tool: journal.provider,
302 from: state.typed(&journal.from()),
303 to: state.typed(&journal.to()),
304 detail: format!("{why}; {}", detail.to_string()),
305 }
306}
307
308fn finishing(
312 ctx: &Context,
313 state: &State,
314 tool: &'static dyn Provider,
315 journal: &Journal,
316 why: &str,
317) -> Result<String> {
318 let Some(Some(incoming)) = read_park(ctx, &journal.incoming_service) else {
319 return Err(not_finished(
320 state,
321 journal,
322 why,
323 "its parked login could not be read",
324 ));
325 };
326 tool.splice(&Value::Object(serde_json::Map::new()), &incoming)
327 .map(|next| super::to_body(&next))
328 .map_err(|e| not_finished(state, journal, why, e))
329}
330
331fn finish(ctx: &Context, permit: Permit, tool: &'static dyn Provider, body: &str) -> Result<()> {
333 let live = tool.live(ctx).map_err(|e| super::shape(tool.id(), e))?;
334 let _guard = super::write_lock(ctx, permit, tool)?;
335 Ok(store::write_raw(&live.chain, permit, &live.service, body)?)
336}
337
338fn ask(
341 ctx: &Context,
342 state: &State,
343 tool: &'static dyn Provider,
344 owner: Owner,
345) -> std::result::Result<String, String> {
346 match owner {
347 Owner::Is(id) => Ok(id),
348 Owner::Unknown(why) | Owner::Unfinished(why) => Err(why),
349 Owner::Unasked(live) => identity_of_document(ctx, tool, &live)
350 .map(|identity| state.id_for(tool.id(), &identity))
351 .map_err(|e| e.to_string()),
352 }
353}
354
355fn live_owner_by_fingerprint(journal: &Journal, found: &str) -> Option<String> {
368 if journal.replaces() && found.is_empty() {
371 return Some(journal.from_id.clone());
372 }
373 if journal.from_fingerprint.is_empty() || journal.to_fingerprint.is_empty() {
374 return None;
375 }
376 if journal.from_fingerprint == journal.to_fingerprint {
377 return None;
378 }
379 if found.is_empty() {
380 return None;
381 }
382 if found == journal.to_fingerprint {
383 Some(journal.to_id.clone())
384 } else if found == journal.from_fingerprint {
385 Some(journal.from_id.clone())
386 } else {
387 None
388 }
389}
390
391#[derive(Debug)]
393pub struct Abandoned {
394 pub from: String,
395 pub to: String,
396 pub kept: usize,
398}
399
400pub(super) fn abandon(
412 ctx: &Context,
413 permit: Permit,
414 state: &mut State,
415) -> Result<Option<Abandoned>> {
416 let path = journal_path(ctx);
417 let raw = match std::fs::read_to_string(&path) {
418 Ok(r) => r,
419 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
420 Err(source) => return Err(Error::RecoveryFailed { path, source }),
421 };
422 let journal =
423 serde_json::from_str::<Journal>(&raw).map_err(|source| Error::RecoveryRecordCorrupt {
424 path: path.clone(),
425 source,
426 })?;
427 let tool = match provider::of(journal.provider) {
428 LoginKind::Document(tool) => tool,
429 LoginKind::Folder(_) => {
430 return Err(Error::RecoveryRecordCorrupt {
431 path,
432 source: serde::de::Error::custom(
433 "it names a tool whose login no switch records here",
434 ),
435 });
436 }
437 };
438
439 let mut kept = 0;
442 if let Some(Some(document)) = read_park(ctx, &journal.park_service)
443 && park::is_live_twin(ctx, tool, &document)
444 {
445 state.discard(&journal.park_service, ParkKind::Vault);
446 } else if let Some(Some(document)) = read_park(ctx, &journal.park_service)
447 && let Some(key) = state
448 .by_id(journal.provider, &journal.from_id)
449 .map(Account::key)
450 {
451 state.park(
452 &key,
453 park::describe(tool, &journal.park_service, ctx.now(), &document),
454 );
455 kept += 1;
456 }
457 let incoming = read_park(ctx, &journal.incoming_service).flatten();
458 if incoming.is_some_and(|document| park::is_live_twin(ctx, tool, &document)) {
459 state.discard(&journal.incoming_service, ParkKind::Vault);
460 } else if state
461 .by_id(journal.provider, &journal.to_id)
462 .and_then(|a| a.parked.as_ref())
463 .is_some()
464 {
465 kept += 1;
466 }
467 state::save(ctx, permit, state)?;
468 clear_journal(ctx, permit);
469 Ok(Some(Abandoned {
470 from: state.typed(&journal.from()),
471 to: state.typed(&journal.to()),
472 kept,
473 }))
474}
475
476struct Waiting {
479 tool: &'static dyn Provider,
480 journal: Journal,
481 parked: Option<Option<Value>>,
483 owner: Owner,
484 seen: Seen,
485}
486
487fn read(ctx: &Context, state: &State) -> Result<Option<Waiting>> {
490 let path = journal_path(ctx);
491 let raw = match std::fs::read_to_string(&path) {
492 Ok(r) => r,
493 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
494 Err(source) => return Err(Error::RecoveryFailed { path, source }),
495 };
496 let journal =
499 serde_json::from_str::<Journal>(&raw).map_err(|source| Error::RecoveryRecordCorrupt {
500 path: path.clone(),
501 source,
502 })?;
503 let tool = match provider::of(journal.provider) {
504 LoginKind::Document(tool) => tool,
505 LoginKind::Folder(_) => {
506 return Err(Error::RecoveryRecordCorrupt {
507 path,
508 source: serde::de::Error::custom(
509 "it names a tool whose login no switch records here",
510 ),
511 });
512 }
513 };
514
515 if let Some(slot) = &journal.slot {
516 let here = tool.slot(ctx);
517 if *slot != here {
518 return Err(Error::RecoveryElsewhere {
519 tool: journal.provider,
520 from: state.typed(&journal.from()),
521 to: state.typed(&journal.to()),
522 slot: slot.clone(),
523 });
524 }
525 }
526
527 let named = in_use::named(ctx, journal.provider);
528 let (owner, login) = live_owner(ctx, tool, &journal);
529 let parked = if journal.park_service.is_empty() {
531 Some(None)
532 } else {
533 read_park(ctx, &journal.park_service)
534 };
535 Ok(Some(Waiting {
536 tool,
537 parked,
538 journal,
539 owner,
540 seen: Seen { named, login },
541 }))
542}
543
544fn decide(
547 state: &State,
548 tool: &'static dyn Provider,
549 journal: &Journal,
550 parked: Option<Option<Value>>,
551 owner: std::result::Result<String, String>,
552) -> Result<Repair> {
553 let found = Found {
554 parked,
555 live_owner: owner.as_ref().ok().cloned(),
556 };
557 repair_for(state, tool, journal, &found).ok_or_else(|| Error::RecoveryUndetermined {
558 tool: journal.provider,
559 from: state.typed(&journal.from()),
560 to: state.typed(&journal.to()),
561 detail: owner
562 .err()
563 .unwrap_or_else(|| "its parked login could not be read".into()),
564 })
565}
566
567pub(super) fn reconcile(
568 ctx: &Context,
569 permit: Permit,
570 state: &mut State,
571) -> Result<Option<Recovered>> {
572 let Some(mut waiting) = read(ctx, state)? else {
573 return Ok(None);
574 };
575 if let Owner::Unfinished(why) = &waiting.owner {
576 let (tool, journal) = (waiting.tool, &waiting.journal);
577 let body = finishing(ctx, state, tool, journal, why)?;
578 finish(ctx, permit, tool, &body).map_err(|e| not_finished(state, journal, why, e))?;
579 (waiting.owner, waiting.seen.login) = live_owner(ctx, tool, journal);
580 }
581 let Waiting {
582 tool,
583 journal,
584 parked,
585 owner,
586 seen,
587 } = waiting;
588 let owner = ask(ctx, state, tool, owner);
589 let repair = decide(state, tool, &journal, parked, owner)?;
590 let finished = repair.landed;
591 apply(state, &journal, repair, seen, ctx.now());
592 state::save(ctx, permit, state)?;
593 clear_journal(ctx, permit);
594
595 Ok(Some(Recovered {
596 from: state.typed(&journal.from()),
597 to: state.typed(&journal.to()),
598 finished,
599 }))
600}
601
602#[derive(Clone, Copy, Debug, PartialEq, Eq)]
608pub(crate) enum Asking {
609 Service,
610 Nobody,
611}
612
613pub(super) fn refusal(ctx: &Context, state: &State, asking: Asking) -> Option<Error> {
621 let Waiting {
622 tool,
623 journal,
624 parked,
625 owner,
626 ..
627 } = read(ctx, state).ok()??;
628 if let Owner::Unfinished(why) = &owner {
629 return finishing(ctx, state, tool, &journal, why).err();
630 }
631 if asking == Asking::Nobody
632 && matches!(owner, Owner::Unasked(_))
633 && !tool.identifies_by_itself()
634 {
635 return None;
636 }
637 let owner = ask(ctx, state, tool, owner);
638 decide(state, tool, &journal, parked, owner).err()
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644
645 const NOW: i64 = 1_700_000_100;
646 const PARK: &str = "pitboard-park-from-uuid-1700000000000";
647 const INCOMING: &str = "pitboard-park-to-uuid-1690000000000";
648
649 #[test]
652 fn a_record_0_8_0_wrote_names_each_side_by_its_id() {
653 let written: Journal = serde_json::from_value(serde_json::json!({
654 "provider": "claude",
655 "started_at": 1_700_000_000,
656 "from_label": "from",
657 "from_uuid": "from-uuid",
658 "to_label": "to",
659 "to_uuid": "to-uuid",
660 "park_service": PARK,
661 "incoming_service": INCOMING,
662 "from_fingerprint": "f",
663 "to_fingerprint": "t",
664 "slot": "Claude Code-credentials"
665 }))
666 .expect("still read");
667 assert_eq!(
668 (written.from_id.as_str(), written.to_id.as_str()),
669 ("from-uuid", "to-uuid")
670 );
671 }
672
673 fn journal() -> Journal {
674 Journal {
675 provider: ProviderId::Claude,
676 started_at: 1_700_000_000,
677 from_label: "from".into(),
678 from_id: "from-uuid".into(),
679 to_label: "to".into(),
680 to_id: "to-uuid".into(),
681 park_service: PARK.into(),
682 incoming_service: INCOMING.into(),
683 from_fingerprint: "ffffffffffffffff".into(),
684 to_fingerprint: "0000000000000000".into(),
685 slot: None,
686 }
687 }
688
689 #[test]
692 fn a_record_from_before_the_fingerprints_falls_back_to_asking() {
693 let mut j = journal();
694 j.from_fingerprint = String::new();
695 j.to_fingerprint = String::new();
696 assert_eq!(live_owner_by_fingerprint(&j, &fingerprint("refresh")), None);
697 }
698
699 #[test]
702 fn identical_fingerprints_settle_nothing() {
703 let mut j = journal();
704 j.from_fingerprint = fingerprint("refresh");
705 j.to_fingerprint = j.from_fingerprint.clone();
706 assert_eq!(live_owner_by_fingerprint(&j, &fingerprint("refresh")), None);
707 }
708
709 fn fingerprint(refresh: &str) -> String {
711 let live =
712 serde_json::json!({"claudeAiOauth": {"refreshToken": refresh, "accessToken": "a"}});
713 provider::document(ProviderId::Claude).fingerprint(&live)
714 }
715
716 fn seen(login: &str) -> Seen {
719 Seen {
720 named: None,
721 login: login.into(),
722 }
723 }
724
725 fn identified_from(s: &mut State) {
728 let from = InUse {
729 owner: Some(account("from", None).owner()),
730 login: journal().from_fingerprint,
731 known_at: NOW - 60,
732 named: None,
733 };
734 s.identified(ProviderId::Claude, from, NOW - 60);
735 }
736
737 fn account(label: &str, parked: Option<&str>) -> Account {
738 Account {
739 last_used_at: None,
740 replaced_at: None,
741 plan: None,
742 label: label.into(),
743 id: format!("{label}-uuid"),
744 account_uuid: format!("{label}-uuid"),
745 email: format!("{label}@example.com"),
746 detail: state::Detail::Claude {
747 organization_uuid: format!("{label}-org"),
748 oauth_account: serde_json::json!({}),
749 },
750 parked: parked.map(|s| Park {
751 service: s.into(),
752 parked_at: 1_699_000_000,
753 kind: ParkKind::Vault,
754 login: "f".into(),
755 access_expires_at: None,
756 refresh_expires_at: None,
757 session_expires_at: None,
758 }),
759 }
760 }
761
762 fn before() -> State {
764 State {
765 accounts: vec![account("from", None), account("to", Some(INCOMING))],
766 ..State::default()
767 }
768 }
769
770 fn written() -> Option<Option<Value>> {
771 Some(Some(
772 serde_json::json!({"refreshToken": "outgoing", "accessToken": "a"}),
773 ))
774 }
775
776 fn found(parked: Option<Option<Value>>, owner: Option<&str>) -> Found {
777 Found {
778 parked,
779 live_owner: owner.map(str::to_owned),
780 }
781 }
782
783 #[test]
785 fn nothing_parked_and_nothing_installed_changes_nothing() {
786 let repair = repair_for(
787 &before(),
788 provider::document(ProviderId::Claude),
789 &journal(),
790 &found(Some(None), Some("from-uuid")),
791 );
792 assert_eq!(repair, Some(Repair::default()));
793 }
794
795 #[test]
798 fn a_park_of_a_login_still_signed_in_is_dropped_not_kept() {
799 for s in [before(), {
800 let mut recorded = before();
801 recorded.park(
802 &Key::new(ProviderId::Claude, "from"),
803 account("x", Some(PARK)).parked.unwrap(),
804 );
805 recorded
806 }] {
807 let repair = repair_for(
808 &s,
809 provider::document(ProviderId::Claude),
810 &journal(),
811 &found(written(), Some("from-uuid")),
812 )
813 .unwrap();
814 assert!(repair.drop && repair.hold.is_none() && !repair.landed);
815
816 let mut applied = s;
817 apply(
818 &mut applied,
819 &journal(),
820 repair,
821 seen(&journal().from_fingerprint),
822 NOW,
823 );
824 assert!(!applied.references(PARK));
825 assert!(applied.discarded.iter().any(|d| d.service == PARK));
826 }
827 }
828
829 #[test]
832 fn a_landed_switch_holds_the_outgoing_login_and_consumes_the_incoming_one() {
833 let mut s = before();
834 identified_from(&mut s);
835 let repair = repair_for(
836 &s,
837 provider::document(ProviderId::Claude),
838 &journal(),
839 &found(written(), Some("to-uuid")),
840 )
841 .unwrap();
842 let (uuid, park) = repair.hold.clone().expect("the orphan must be recovered");
843 assert_eq!(uuid, "from-uuid", "held by account id, never by a label");
844 assert_eq!(park.service, PARK);
845 assert!(repair.landed);
846
847 let rotated = fingerprint("rotated-since");
848 apply(&mut s, &journal(), repair, seen(&rotated), NOW);
849 assert_eq!(
850 s.account_in_use(ProviderId::Claude)
851 .map(|a| a.label.as_str()),
852 Some("to")
853 );
854 assert_eq!(
855 s.in_use(ProviderId::Claude)
856 .map(|r| (r.login.as_str(), r.known_at)),
857 Some((rotated.as_str(), NOW)),
858 "the login the store holds, which Claude Code may have rotated since the install"
859 );
860 let from = s.get(&Key::new(ProviderId::Claude, "from")).unwrap();
861 assert_eq!(from.parked.as_ref().unwrap().service, PARK);
862 assert_eq!(
863 from.replaced_at, None,
864 "its login is parked, so nothing of it was lost"
865 );
866 assert!(
867 s.get(&Key::new(ProviderId::Claude, "to"))
868 .unwrap()
869 .parked
870 .is_none(),
871 "the copy now live must never be offered again"
872 );
873 assert!(s.discarded.iter().any(|d| d.service == INCOMING));
874 }
875
876 #[test]
879 fn a_third_account_signed_in_since_keeps_both_parks() {
880 let mut s = before();
881 let repair = repair_for(
882 &s,
883 provider::document(ProviderId::Claude),
884 &journal(),
885 &found(written(), Some("other-uuid")),
886 )
887 .unwrap();
888 apply(&mut s, &journal(), repair, seen(&fingerprint("other")), NOW);
889 assert!(s.references(PARK) && s.references(INCOMING));
890 assert!(s.discarded.is_empty());
891 }
892
893 #[test]
894 fn an_already_recorded_park_is_not_held_twice() {
895 let mut s = before();
896 s.park(
897 &Key::new(ProviderId::Claude, "from"),
898 account("x", Some(PARK)).parked.unwrap(),
899 );
900 let repair = repair_for(
901 &s,
902 provider::document(ProviderId::Claude),
903 &journal(),
904 &found(written(), Some("to-uuid")),
905 )
906 .unwrap();
907 assert_eq!(repair.hold, None);
908 }
909
910 #[test]
911 fn an_unknown_outcome_changes_nothing_and_keeps_the_record() {
912 for unknown in [found(written(), None), found(None, Some("to-uuid"))] {
913 assert_eq!(
914 repair_for(
915 &before(),
916 provider::document(ProviderId::Claude),
917 &journal(),
918 &unknown
919 ),
920 None,
921 "could-not-tell must never be read as nothing-there"
922 );
923 }
924 }
925
926 #[test]
927 fn a_park_whose_account_was_forgotten_is_not_filed_under_another() {
928 let mut s = State {
929 accounts: vec![account("other", None)],
930 ..State::default()
931 };
932 identified_from(&mut s);
933 let repair = repair_for(
934 &s,
935 provider::document(ProviderId::Claude),
936 &journal(),
937 &found(written(), Some("to-uuid")),
938 )
939 .unwrap();
940 apply(
941 &mut s,
942 &journal(),
943 repair,
944 seen(&journal().to_fingerprint),
945 NOW,
946 );
947 assert!(
948 !s.references(PARK),
949 "a park must never be filed under whatever account happens to hold a label"
950 );
951 assert_eq!(
952 s.in_use(ProviderId::Claude),
953 None,
954 "the store no longer holds `from`'s login, and nobody here says whose it holds"
955 );
956 }
957
958 #[test]
962 fn a_label_given_to_another_account_since_is_not_the_destination() {
963 const THEIRS: &str = "pitboard-park-someone-uuid-1695000000000";
964 let someone = Account {
965 id: "someone-uuid".into(),
966 account_uuid: "someone-uuid".into(),
967 ..account("to", Some(THEIRS))
968 };
969 let mut s = State {
970 accounts: vec![account("from", None), someone],
971 ..State::default()
972 };
973 identified_from(&mut s);
974 let repair = repair_for(
975 &s,
976 provider::document(ProviderId::Claude),
977 &journal(),
978 &found(written(), Some("to-uuid")),
979 )
980 .unwrap();
981 apply(
982 &mut s,
983 &journal(),
984 repair,
985 seen(&journal().to_fingerprint),
986 NOW,
987 );
988
989 assert_eq!(
990 s.in_use(ProviderId::Claude),
991 None,
992 "the login installed is not the account that holds the label now"
993 );
994 let to = s.get(&Key::new(ProviderId::Claude, "to")).unwrap();
995 assert_eq!(to.last_used_at, None, "never in use");
996 assert_eq!(to.parked.as_ref().map(|p| p.service.as_str()), Some(THEIRS));
997 assert!(
998 !s.discarded.iter().any(|d| d.service == INCOMING),
999 "the destination's park is no account's here to consume"
1000 );
1001 }
1002}