Skip to main content

pitboard_core/
service.rs

1//! pitboard's operations, each run the way every front end must run it: a change settles any
2//! interrupted switch first and is recorded in the audit log, and what went wrong on the way
3//! is reported alongside the result, whether or not the change then succeeds.
4
5use crate::context::Context;
6use crate::doctor::{self, Diagnosis};
7use crate::error::{Error, Result};
8use crate::state::{self, Account};
9use crate::switch::{self, Enrolled, Outcome, Recovered, Renewal, Settled, SignIn};
10use crate::{audit, status, statusline};
11use std::fmt;
12
13/// Something to know about that did not stop the operation.
14#[derive(Debug)]
15pub enum Warning {
16    /// An earlier switch had been interrupted; this run found what it did and recorded it.
17    Recovered(Recovered),
18    /// The login moved, but Claude Code's config still names the previous account.
19    ConfigNotUpdated(Error),
20    /// Parked logins no longer in use that could not be deleted yet.
21    ParksPendingRemoval(usize),
22    /// Anthropic refuses a parked login for good, so it was dropped.
23    ParkedLoginRefused {
24        label: String,
25    },
26    RenewalFailed(Error),
27}
28
29impl Warning {
30    /// Stable, for a program to branch on.
31    pub fn code(&self) -> &'static str {
32        match self {
33            Warning::Recovered(r) => r.code(),
34            Warning::ConfigNotUpdated(e) | Warning::RenewalFailed(e) => e.code(),
35            Warning::ParksPendingRemoval(_) => "parks_pending_removal",
36            Warning::ParkedLoginRefused { .. } => "parked_login_refused",
37        }
38    }
39}
40
41impl fmt::Display for Warning {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        match self {
44            Warning::Recovered(r) => write!(f, "{r}"),
45            Warning::ConfigNotUpdated(e) | Warning::RenewalFailed(e) => write!(f, "{e}"),
46            Warning::ParksPendingRemoval(count) => write!(
47                f,
48                "{count} parked login(s) no longer in use could not be removed yet; pitboard \
49                 tries again on its next change"
50            ),
51            Warning::ParkedLoginRefused { label } => write!(
52                f,
53                "Anthropic no longer accepts the parked login for `{label}`. Run `pitboard \
54                 enroll {label} --sign-in` to sign in to it again."
55            ),
56        }
57    }
58}
59
60#[derive(Debug)]
61pub struct Done<T> {
62    pub value: T,
63    pub warnings: Vec<Warning>,
64}
65
66/// A change that failed, with what was found on the way: recovering an interrupted switch
67/// is reported even when the change that followed it fails.
68#[derive(Debug)]
69pub struct Failed {
70    pub error: Error,
71    pub warnings: Vec<Warning>,
72}
73
74pub type Changing<T> = std::result::Result<Done<T>, Failed>;
75
76pub struct Pitboard {
77    ctx: Context,
78}
79
80impl Pitboard {
81    pub fn new(ctx: Context) -> Pitboard {
82        Pitboard { ctx }
83    }
84
85    /// Who is signed in and what every account has left. Parked logins whose access has
86    /// lapsed are renewed first, so every account is asked live.
87    pub fn status(&self) -> Result<Done<status::Report>> {
88        let mut warnings = Vec::new();
89        for (label, outcome) in switch::renew_parked(&self.ctx) {
90            audit::record(&self.ctx, "renew", &label, outcome.code());
91            match outcome {
92                Renewal::Refused => warnings.push(Warning::ParkedLoginRefused { label }),
93                Renewal::Failed(e) => warnings.push(Warning::RenewalFailed(e)),
94                Renewal::Renewed | Renewal::Deferred => {}
95            }
96        }
97        // Unreadable is not the same as empty: reporting it as empty would say the enrolled
98        // logins are gone.
99        let state = state::load(&self.ctx)?;
100        Ok(Done {
101            value: status::gather(&self.ctx, &state),
102            warnings,
103        })
104    }
105
106    pub fn doctor(&self) -> Diagnosis {
107        doctor::run(&self.ctx)
108    }
109
110    /// The status line for Claude Code's session JSON. Reads only files.
111    pub fn statusline(&self, session: &str) -> statusline::StatusLine {
112        statusline::read(&self.ctx, session)
113    }
114
115    /// The enrolled account under `label`, if any, read without taking the lock.
116    pub fn account(&self, label: &str) -> Option<Account> {
117        state::load(&self.ctx).ok()?.get(label).cloned()
118    }
119
120    pub fn switch_to(&self, label: &str) -> Changing<Outcome> {
121        self.changing("use", label, |settled| switch::switch(settled, label))
122    }
123
124    pub fn enroll_current(&self, label: &str) -> Changing<Enrolled> {
125        self.changing("enroll", label, |settled| {
126            switch::enroll(settled, label, None).map(|e| (e, Vec::new()))
127        })
128    }
129
130    /// Claude Code's own sign-in in a private directory. It takes no lock but its own, so a
131    /// person taking their time in a browser never holds up a switch.
132    pub fn sign_in(&self, label: &str) -> Result<SignIn> {
133        switch::sign_in(&self.ctx)
134            .inspect_err(|e| audit::record(&self.ctx, "enroll", label, e.code()))
135    }
136
137    pub fn enroll_signed_in(&self, label: &str, login: SignIn) -> Changing<Enrolled> {
138        self.changing("enroll", label, |settled| {
139            switch::enroll(settled, label, Some(login)).map(|e| (e, Vec::new()))
140        })
141    }
142
143    /// Returns the account's email.
144    pub fn forget(&self, label: &str) -> Changing<String> {
145        self.changing("forget", label, |settled| switch::forget(settled, label))
146    }
147
148    /// Returns the account's email.
149    pub fn rename(&self, from: &str, to: &str) -> Changing<String> {
150        self.changing("rename", &format!("{from} -> {to}"), |settled| {
151            switch::rename(settled, from, to).map(|email| (email, Vec::new()))
152        })
153    }
154
155    /// Settles, runs the change, and records it in the audit log.
156    fn changing<T: Audited>(
157        &self,
158        verb: &str,
159        subject: &str,
160        run: impl FnOnce(Settled) -> Result<(T, Vec<Warning>)>,
161    ) -> Changing<T> {
162        let (settled, recovered) = switch::settle(&self.ctx).map_err(|error| {
163            audit::record(&self.ctx, verb, subject, error.code());
164            Failed {
165                error,
166                warnings: Vec::new(),
167            }
168        })?;
169        let mut warnings = Vec::new();
170        if let Some(r) = recovered {
171            audit::record(&self.ctx, "recover", &r.to, r.code());
172            warnings.push(Warning::Recovered(r));
173        }
174        match run(settled) {
175            Ok((value, more)) => {
176                audit::record(&self.ctx, verb, subject, value.audit_code());
177                warnings.extend(more);
178                Ok(Done { value, warnings })
179            }
180            Err(error) => {
181                audit::record(&self.ctx, verb, subject, error.code());
182                Err(Failed { error, warnings })
183            }
184        }
185    }
186}
187
188/// How a successful change is written in the audit log.
189trait Audited {
190    fn audit_code(&self) -> &'static str {
191        "ok"
192    }
193}
194
195impl Audited for Outcome {
196    fn audit_code(&self) -> &'static str {
197        match self {
198            Outcome::Switched { .. } => "ok",
199            Outcome::AlreadyActive { .. } => "already_active",
200        }
201    }
202}
203
204impl Audited for Enrolled {}
205impl Audited for String {}