use super::{Error, Result, identify};
use crate::state::{Park, State};
use crate::{atomic, claude, home, park, state, store};
use serde_json::Value;
use std::path::PathBuf;
#[derive(serde::Serialize, serde::Deserialize)]
pub(super) struct Journal {
pub(super) started_at: i64,
pub(super) from_label: String,
pub(super) from_uuid: String,
pub(super) to_label: String,
pub(super) to_uuid: String,
pub(super) park_service: String,
pub(super) incoming_service: String,
}
#[derive(Debug)]
pub struct Recovered {
pub from: String,
pub to: String,
pub finished: bool,
}
impl Recovered {
pub fn code(&self) -> &'static str {
if self.finished {
"interrupted_switch_finished"
} else {
"interrupted_switch_undone"
}
}
}
impl std::fmt::Display for Recovered {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"an earlier switch from `{}` to `{}` was interrupted; {}",
self.from,
self.to,
if self.finished {
"it had in fact finished, and pitboard has recorded that"
} else {
"it had not finished, and nothing was lost"
}
)
}
}
fn journal_path() -> PathBuf {
home::dir().join("journal.json")
}
pub(super) fn write_journal(entry: &Journal) -> Result<()> {
let path = journal_path();
let fail = |source| Error::RecoveryFailed {
path: path.clone(),
source,
};
home::ensure().map_err(fail)?;
let body = serde_json::to_string(entry).expect("a journal entry is always serialisable");
atomic::write(&path, body.as_bytes(), atomic::Perms::Secret).map_err(fail)
}
pub fn pending() -> bool {
journal_path().exists()
}
pub(super) fn clear_journal() {
let _ = std::fs::remove_file(journal_path());
}
struct Found {
parked: Option<Option<Value>>,
live_owner: Option<String>,
}
#[derive(Default, Debug, PartialEq)]
struct Repair {
hold: Option<(String, Park)>,
drop: bool,
landed: bool,
}
fn repair_for(state: &State, journal: &Journal, found: &Found) -> Option<Repair> {
let parked = found.parked.as_ref()?;
let owner = found.live_owner.as_deref()?;
let mut repair = Repair {
landed: owner == journal.to_uuid,
..Repair::default()
};
if owner == journal.from_uuid {
repair.drop = parked.is_some();
} else if let Some(oauth) = parked
&& !state.references(&journal.park_service)
{
repair.hold = Some((
journal.from_uuid.clone(),
park::describe(&journal.park_service, journal.started_at, oauth),
));
}
Some(repair)
}
fn apply(state: &mut State, journal: &Journal, repair: Repair) {
if repair.drop {
state.discard(&journal.park_service);
}
if let Some((uuid, park)) = repair.hold
&& let Some(label) = state.by_uuid(&uuid).map(|a| a.label.clone())
{
state.park(&label, park);
}
if repair.landed && state.get(&journal.to_label).is_some() {
state.active = Some(journal.to_label.clone());
state.discard(&journal.incoming_service);
}
}
fn read_park(service: &str) -> Option<Option<Value>> {
match store::vault_read(service) {
Ok(raw) => Some(raw.and_then(|r| serde_json::from_str(&r).ok())),
Err(_) => None,
}
}
fn live_owner() -> std::result::Result<String, String> {
let live = store::read(&claude::live_service())
.map_err(|e| e.to_string())?
.ok_or("nothing is signed in")?;
let token = live["claudeAiOauth"]["accessToken"]
.as_str()
.ok_or("the signed-in credential has no access token")?;
identify(token)
.map(|owner| owner.account_uuid)
.map_err(|e| e.to_string())
}
pub(super) fn reconcile(state: &mut State) -> Result<Option<Recovered>> {
let path = journal_path();
let raw = match std::fs::read_to_string(&path) {
Ok(r) => r,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => return Err(Error::RecoveryFailed { path, source }),
};
let journal = serde_json::from_str::<Journal>(&raw)
.map_err(|source| Error::RecoveryRecordCorrupt { path, source })?;
let owner = live_owner();
let found = Found {
parked: read_park(&journal.park_service),
live_owner: owner.as_ref().ok().cloned(),
};
let Some(repair) = repair_for(state, &journal, &found) else {
return Err(Error::RecoveryUndetermined {
from: journal.from_label,
to: journal.to_label,
detail: owner
.err()
.unwrap_or_else(|| "its parked login could not be read".into()),
});
};
let finished = repair.landed;
apply(state, &journal, repair);
state::save(state)?;
clear_journal();
Ok(Some(Recovered {
from: journal.from_label,
to: journal.to_label,
finished,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::Account;
const PARK: &str = "pitboard-park-from-uuid-1700000000000";
const INCOMING: &str = "pitboard-park-to-uuid-1690000000000";
fn journal() -> Journal {
Journal {
started_at: 1_700_000_000,
from_label: "from".into(),
from_uuid: "from-uuid".into(),
to_label: "to".into(),
to_uuid: "to-uuid".into(),
park_service: PARK.into(),
incoming_service: INCOMING.into(),
}
}
fn account(label: &str, parked: Option<&str>) -> Account {
Account {
label: label.into(),
account_uuid: format!("{label}-uuid"),
email: format!("{label}@example.com"),
organization_uuid: format!("{label}-org"),
oauth_account: serde_json::json!({}),
parked: parked.map(|s| Park {
service: s.into(),
parked_at: 1_699_000_000,
refresh_fingerprint: "f".into(),
access_expires_at: None,
refresh_expires_at: None,
}),
}
}
fn before() -> State {
State {
accounts: vec![account("from", None), account("to", Some(INCOMING))],
..State::default()
}
}
fn written() -> Option<Option<Value>> {
Some(Some(
serde_json::json!({"refreshToken": "outgoing", "accessToken": "a"}),
))
}
fn found(parked: Option<Option<Value>>, owner: Option<&str>) -> Found {
Found {
parked,
live_owner: owner.map(str::to_owned),
}
}
#[test]
fn nothing_parked_and_nothing_installed_changes_nothing() {
let repair = repair_for(&before(), &journal(), &found(Some(None), Some("from-uuid")));
assert_eq!(repair, Some(Repair::default()));
}
#[test]
fn a_park_of_a_login_still_signed_in_is_dropped_not_kept() {
for s in [before(), {
let mut recorded = before();
recorded.park("from", account("x", Some(PARK)).parked.unwrap());
recorded
}] {
let repair = repair_for(&s, &journal(), &found(written(), Some("from-uuid"))).unwrap();
assert!(repair.drop && repair.hold.is_none() && !repair.landed);
let mut applied = s;
apply(&mut applied, &journal(), repair);
assert!(!applied.references(PARK));
assert!(applied.discarded.contains(&PARK.to_string()));
}
}
#[test]
fn a_landed_switch_holds_the_outgoing_login_and_consumes_the_incoming_one() {
let mut s = before();
let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
let (uuid, park) = repair.hold.clone().expect("the orphan must be recovered");
assert_eq!(uuid, "from-uuid", "held by account id, never by a label");
assert_eq!(park.service, PARK);
assert!(repair.landed);
apply(&mut s, &journal(), repair);
assert_eq!(s.active.as_deref(), Some("to"));
assert_eq!(
s.get("from").unwrap().parked.as_ref().unwrap().service,
PARK
);
assert!(
s.get("to").unwrap().parked.is_none(),
"the copy now live must never be offered again"
);
assert!(s.discarded.contains(&INCOMING.to_string()));
}
#[test]
fn a_third_account_signed_in_since_keeps_both_parks() {
let mut s = before();
let repair = repair_for(&s, &journal(), &found(written(), Some("other-uuid"))).unwrap();
apply(&mut s, &journal(), repair);
assert!(s.references(PARK) && s.references(INCOMING));
assert!(s.discarded.is_empty());
}
#[test]
fn an_already_recorded_park_is_not_held_twice() {
let mut s = before();
s.park("from", account("x", Some(PARK)).parked.unwrap());
let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
assert_eq!(repair.hold, None);
}
#[test]
fn an_unknown_outcome_changes_nothing_and_keeps_the_record() {
for unknown in [found(written(), None), found(None, Some("to-uuid"))] {
assert_eq!(
repair_for(&before(), &journal(), &unknown),
None,
"could-not-tell must never be read as nothing-there"
);
}
}
#[test]
fn a_park_whose_account_was_forgotten_is_not_filed_under_another() {
let mut s = State {
accounts: vec![account("other", None)],
..State::default()
};
let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
apply(&mut s, &journal(), repair);
assert!(
!s.references(PARK),
"a park must never be filed under whatever account happens to hold a label"
);
assert_eq!(
s.active, None,
"a destination that is gone is not made active"
);
}
}