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::duty::autoswitch::{Auto, Look, Threshold};
8use crate::error::{Error, Result};
9use crate::holder::{self, capitalised};
10use crate::host::{Elevation, Floor, Os};
11use crate::in_use::{self, Doubt, InUse, Known};
12use crate::provider::{Held, LoginKind, Provider, ProviderId};
13use crate::state::{self, Account, Key, new_id};
14use crate::switch::{self, Enrolled, Outcome, Recovered, Renewal, Settled, SignIn};
15use crate::usage::readings;
16use crate::{audit, schedule, status, statusline};
17use std::fmt;
18
19/// Proof that this process may change something on this machine, which only the one gate
20/// every change passes makes ([`Pitboard::permit`]).
21///
22/// Everything that changes anything takes one as an argument: the durable write of a file,
23/// every private file or directory made and every one removed, a write to or a delete from
24/// any store of logins, the keychain among them, a change to the system's scheduler, a
25/// tool's sign-in or warm-up run started, and the token exchange that renews a parked login. So a change
26/// that did not ask the gate does not compile, and nothing rotates a refresh token it could
27/// not then write down.
28#[derive(Debug, Clone, Copy)]
29pub struct Permit {
30    _only_the_gate_makes_one: (),
31}
32
33#[cfg(test)]
34impl Permit {
35    /// One for a unit test of what takes it, below the gate. A test of the gate itself, or
36    /// of a change through [`Pitboard`], asks the gate.
37    pub(crate) fn for_a_test() -> Permit {
38        Permit {
39            _only_the_gate_makes_one: (),
40        }
41    }
42}
43
44/// The one gate every change passes: whether this process may change anything here, as the
45/// host says. A process that runs as root or under sudo, or elevated on Windows, changes
46/// nothing, and nor does one the host cannot place: a file it wrote would be root's or the
47/// administrators', and a keychain item might be, where the person's own runs might never
48/// read, replace or remove it again.
49///
50/// Nor does one whose environment names a home that is empty or relative
51/// ([`crate::home::check_absolute`]): what it wrote would land under whichever folder it
52/// was run from. Asked after elevation, so `sudo` is what a run under it is told first.
53///
54/// Before those, a build that may do nothing at all changes nothing: a Windows build of a
55/// release made before Pitboard for Windows is released ([`crate::release`]). So no writer
56/// and no token exchange runs there, even for a caller that forgot to ask. Then a Windows
57/// older than 11 24H2 ([`Floor`]): asked before elevation, which cannot change it.
58pub(crate) fn gate(ctx: &Context) -> Result<Permit> {
59    crate::release::check()?;
60    match ctx.host().floor() {
61        Floor::Met => {}
62        Floor::Below { build } => return Err(Error::SystemTooOld { build: Some(build) }),
63        Floor::Unknown => return Err(Error::SystemTooOld { build: None }),
64    }
65    match ctx.host().elevation(ctx) {
66        Elevation::Normal => {}
67        Elevation::Elevated { why } => return Err(Error::Elevated { why: Some(why) }),
68        Elevation::Unknown => return Err(Error::Elevated { why: None }),
69    }
70    crate::home::check_absolute(ctx)?;
71    Ok(Permit {
72        _only_the_gate_makes_one: (),
73    })
74}
75
76/// Something to know about that did not stop the operation.
77#[derive(Debug)]
78#[non_exhaustive]
79pub enum Warning {
80    /// An earlier switch had been interrupted; this run found what it did and recorded it.
81    Recovered(Recovered),
82    /// The login moved, but what the tool caches about who is signed in still names the
83    /// previous account.
84    ConfigNotUpdated(Error),
85    /// Parked logins no longer in use that could not be deleted yet.
86    ParksPendingRemoval(usize),
87    /// The service refuses a parked login for good, so it was dropped.
88    ParkedLoginRefused {
89        tool: ProviderId,
90        label: String,
91    },
92    RenewalFailed(Error),
93    /// The tool's write lock, or its refresh lock, stopped being Pitboard's while a change was
94    /// under way.
95    LockCompromised {
96        tool: ProviderId,
97    },
98    /// The environment authenticates the tool some other way, so the login Pitboard moved
99    /// is not the one a session will use.
100    AuthOverridden {
101        tool: ProviderId,
102        names: Vec<String>,
103    },
104    /// A file of the tool's sits behind the store in use. While it is there a session
105    /// already running keeps its account after a switch until its login is next renewed.
106    /// Where it holds a login, a session that cannot read that store signs in with it, and
107    /// no switch reaches it.
108    FallbackLogin {
109        tool: ProviderId,
110        path: std::path::PathBuf,
111        held: Held,
112    },
113    /// The login was too large for `security`'s stdin, so it went on the argument line.
114    WrittenOnTheCommandLine {
115        tool: ProviderId,
116        bytes: usize,
117        limit: usize,
118    },
119    /// Sessions of a tool that never follows a switch on its own were running when it
120    /// happened, and go on using the account they started with until they are restarted.
121    /// `holding` is what was running, by kind, never empty.
122    SessionsStillRunning {
123        from: String,
124        holding: Vec<holder::Holding>,
125    },
126    /// A sign-in put a new login in use in place of the old one of the same account, and
127    /// sessions of a tool that never reads its login again were running with the old one.
128    SessionsKeepTheOldLogin {
129        label: String,
130        holding: Vec<holder::Holding>,
131    },
132    /// A switch of a tool that never follows one on its own, where the process list could
133    /// not be read: nobody can say whether anything of the tool started before it is still
134    /// running, and still using `from`. Not the same as nothing running, which says nothing.
135    SessionsUnknown {
136        tool: ProviderId,
137        from: String,
138    },
139    /// A sign-in that put a new login of `label` in use, of a tool that never reads its login
140    /// again, where the process list could not be read: nobody can say whether anything of
141    /// the tool is still running with the old login.
142    SessionsUnknownAfterSignIn {
143        tool: ProviderId,
144        label: String,
145    },
146    /// A sign-in was parked rather than put in use, because nobody could say whose login the
147    /// tool has in use, for `why`.
148    SignInParkedNotInUse {
149        tool: ProviderId,
150        label: String,
151        why: String,
152    },
153    /// An interrupted switch is waiting that the next change cannot finish, and every change
154    /// stops at it until it can be finished or is given up on. The refusal that change would
155    /// make, said by a read so that nobody has to make a change to find out.
156    SwitchStuck(Error),
157    /// A sign-in outside Pitboard replaced the only login of `label`, and Pitboard holds none
158    /// for it: said by every read until it is signed in again, put in use again or forgotten.
159    /// `now` is whose login the tool has stored instead, as last recorded, which every switch
160    /// changes. `id` is the account's, which stays while the words do not.
161    LoginReplaced {
162        tool: ProviderId,
163        id: String,
164        label: String,
165        now: Stored,
166    },
167    /// The tool's own record names `named` since its service last named the login stored,
168    /// `last`'s, as a sign-in leaves it and as another process starting on another login
169    /// can: another login may be stored. Said by a read that could not ask whose it is.
170    /// `asked` is false for a record brought forward from an earlier Pitboard, whose `last`
171    /// is the account it last switched to, which the service never named, and whose own
172    /// record names another account.
173    InUseUnconfirmed {
174        tool: ProviderId,
175        last: Stored,
176        named: Stored,
177        asked: bool,
178    },
179    /// The tool's own record names `config`, and the login stored is `in_use`'s, as its
180    /// service said: what `/status` in Claude Code shows is not the account in use. Using
181    /// `in_use` writes it there again.
182    ConfigNamesAnother {
183        tool: ProviderId,
184        config: Stored,
185        in_use: Stored,
186    },
187    // A login filed under the account the app names: one Pitboard had not seen, or one it had
188    // recorded as `recorded`'s.
189    TakenAsNamed {
190        tool: ProviderId,
191        label: String,
192        recorded: Option<String>,
193    },
194    // A folder kept under `parks`, deleting nothing.
195    SetAside {
196        tool: ProviderId,
197        path: std::path::PathBuf,
198        label: Option<String>,
199        why: state::SetAside,
200    },
201    // A folder kept under `parks` that holds only the keys `label` had before the app rewrote
202    // them, deleting nothing.
203    KeysSetAside {
204        tool: ProviderId,
205        path: std::path::PathBuf,
206        label: Option<String>,
207    },
208    // The app's settings say what a Log out leaves since the account was read in use.
209    LooksSignedOut {
210        tool: ProviderId,
211        label: String,
212    },
213    // A move an earlier run left was settled before this change.
214    MoveSettled(switch::MoveSettled),
215    // A move waits in the index: the next change to the tool settles it first.
216    MoveWaiting {
217        tool: ProviderId,
218        label: String,
219    },
220    // `abandon` gave up a switch, then could not give up this move, for `why`.
221    MoveNotGivenUp {
222        tool: ProviderId,
223        label: String,
224        why: Error,
225    },
226    // `repair` could not settle this move, for `why`, and repaired the rest.
227    MoveNotSettled {
228        tool: ProviderId,
229        label: String,
230        why: Error,
231    },
232    // The app is signed out for a sign-in inside it, with `put_back` parked. Labels as typed.
233    SignInAwaited {
234        tool: ProviderId,
235        label: String,
236        put_back: String,
237        since: i64,
238        // a Sign In Again of `label`, not an add
239        again: bool,
240    },
241    // An item of the login was in neither place after its move, which went on without it.
242    ItemGone {
243        tool: ProviderId,
244        label: String,
245        item: String,
246    },
247    /// This process may change nothing, because it runs as root or under sudo, or elevated on
248    /// Windows, or nobody could tell whether it does, so a read answers from what Pitboard
249    /// last measured: it renews nothing, asks nobody and writes nothing. `why` is how it runs,
250    /// as the host said it, and `None` where the host could not say.
251    ReadOnly {
252        why: Option<&'static str>,
253    },
254    ReadOnlyBelowTheFloor {
255        build: Option<u32>,
256    },
257}
258
259impl Warning {
260    fn read_only(refused: &Error) -> Option<Warning> {
261        match *refused {
262            Error::Elevated { why } => Some(Warning::ReadOnly { why }),
263            Error::SystemTooOld { build } => Some(Warning::ReadOnlyBelowTheFloor { build }),
264            _ => None,
265        }
266    }
267
268    /// Stable, for a program to branch on.
269    pub fn code(&self) -> &'static str {
270        match self {
271            Warning::Recovered(r) => r.code(),
272            Warning::LockCompromised { .. } => "lock_compromised",
273            Warning::ConfigNotUpdated(e) | Warning::RenewalFailed(e) | Warning::SwitchStuck(e) => {
274                e.code()
275            }
276            Warning::ParksPendingRemoval(_) => "parks_pending_removal",
277            Warning::ParkedLoginRefused { .. } => "parked_login_refused",
278            Warning::AuthOverridden { .. } => "auth_overridden",
279            Warning::FallbackLogin { .. } => "fallback_login",
280            Warning::WrittenOnTheCommandLine { .. } => "written_on_the_command_line",
281            Warning::SessionsStillRunning { .. } => "sessions_still_running",
282            Warning::SessionsKeepTheOldLogin { .. } => "sessions_keep_old_login",
283            Warning::SessionsUnknown { .. } | Warning::SessionsUnknownAfterSignIn { .. } => {
284                "sessions_unknown"
285            }
286            Warning::SignInParkedNotInUse { .. } => "sign_in_parked_not_in_use",
287            Warning::LoginReplaced { .. } => "login_replaced",
288            Warning::InUseUnconfirmed { .. } => "in_use_unconfirmed",
289            Warning::ConfigNamesAnother { .. } => "config_names_another",
290            Warning::TakenAsNamed { .. } => "login_taken_as_named",
291            Warning::SetAside { .. } | Warning::KeysSetAside { .. } => "set_aside",
292            Warning::LooksSignedOut { .. } => "looks_signed_out",
293            Warning::MoveSettled(moved) => match moved.ended {
294                switch::Ended::Undone => "interrupted_move_undone",
295                switch::Ended::Finished => "interrupted_move_finished",
296            },
297            Warning::MoveWaiting { .. } => "move_waiting",
298            Warning::MoveNotGivenUp { .. } => "move_not_given_up",
299            Warning::MoveNotSettled { .. } => "move_not_settled",
300            Warning::SignInAwaited { .. } => "sign_in_awaited",
301            Warning::ItemGone { .. } => "login_item_gone",
302            Warning::ReadOnly { .. } | Warning::ReadOnlyBelowTheFloor { .. } => "read_only",
303        }
304    }
305
306    pub fn tool(&self) -> Option<ProviderId> {
307        match self {
308            Warning::ParkedLoginRefused { tool, .. }
309            | Warning::LockCompromised { tool }
310            | Warning::AuthOverridden { tool, .. }
311            | Warning::FallbackLogin { tool, .. }
312            | Warning::WrittenOnTheCommandLine { tool, .. }
313            | Warning::SessionsUnknown { tool, .. }
314            | Warning::SessionsUnknownAfterSignIn { tool, .. }
315            | Warning::SignInParkedNotInUse { tool, .. }
316            | Warning::LoginReplaced { tool, .. }
317            | Warning::InUseUnconfirmed { tool, .. }
318            | Warning::ConfigNamesAnother { tool, .. }
319            | Warning::TakenAsNamed { tool, .. }
320            | Warning::SetAside { tool, .. }
321            | Warning::KeysSetAside { tool, .. }
322            | Warning::LooksSignedOut { tool, .. }
323            | Warning::MoveWaiting { tool, .. }
324            | Warning::MoveNotGivenUp { tool, .. }
325            | Warning::MoveNotSettled { tool, .. }
326            | Warning::SignInAwaited { tool, .. }
327            | Warning::ItemGone { tool, .. } => Some(*tool),
328            Warning::MoveSettled(moved) => Some(moved.tool),
329            Warning::Recovered(_)
330            | Warning::ConfigNotUpdated(_)
331            | Warning::ParksPendingRemoval(_)
332            | Warning::RenewalFailed(_)
333            | Warning::SessionsStillRunning { .. }
334            | Warning::SessionsKeepTheOldLogin { .. }
335            | Warning::SwitchStuck(_)
336            | Warning::ReadOnly { .. }
337            | Warning::ReadOnlyBelowTheFloor { .. } => None,
338        }
339    }
340}
341
342impl fmt::Display for Warning {
343    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
344        match self {
345            Warning::Recovered(r) => write!(f, "{r}"),
346            Warning::LockCompromised { tool } => write!(
347                f,
348                "{} reclaimed a credential lock while this change was under way, so it may \
349                 have written or renewed a login at the same time. Pitboard read the slot back \
350                 and the change stood, but check with `pitboard` that the right account is \
351                 signed in. A login Pitboard parked meanwhile may need signing in to again \
352                 when it is next used.",
353                tool.name()
354            ),
355            Warning::ConfigNotUpdated(e) | Warning::RenewalFailed(e) | Warning::SwitchStuck(e) => {
356                write!(f, "{e}")
357            }
358            Warning::ParksPendingRemoval(count) => write!(
359                f,
360                "{count} parked login(s) no longer in use could not be removed yet; Pitboard \
361                 tries again on its next change"
362            ),
363            Warning::ParkedLoginRefused { tool, label } => write!(
364                f,
365                "{} no longer accepts the parked login for `{label}`. Run `pitboard enroll \
366                 {label} --sign-in` to sign in to it again.",
367                tool.service()
368            ),
369            Warning::WrittenOnTheCommandLine { tool, bytes, limit } => {
370                write!(
371                    f,
372                    "this login needs {bytes} bytes and `security` reads {limit} from stdin, \
373                     so it was written on the argument line, where a process running as you \
374                     could have read it while the call lasted."
375                )?;
376                if *tool == ProviderId::Claude {
377                    write!(
378                        f,
379                        " Claude Code writes this same login the same way whenever it \
380                         refreshes the token."
381                    )?;
382                }
383                Ok(())
384            }
385            Warning::AuthOverridden { tool, names } => write!(
386                f,
387                "{} is set, so {} signs in with it and not with the login Pitboard moved. \
388                 Unset it for the switch to take effect.",
389                names.join(" and "),
390                tool.name()
391            ),
392            Warning::FallbackLogin {
393                tool,
394                path,
395                held: Held::Login,
396            } => write!(
397                f,
398                "{path} holds another {tool} login, which {reader} signs in with. No switch \
399                 reaches it. While it is there, {tool} sessions already running at a switch \
400                 {running}. `pitboard stow` keeps that login for its account, where Pitboard \
401                 holds no other it can switch to, then deletes the file.",
402                path = path.display(),
403                tool = tool.name(),
404                reader = crate::words::reads_only_the_file(crate::host::OS),
405                running = crate::words::kept_until_renewed(),
406            ),
407            Warning::FallbackLogin {
408                tool,
409                path,
410                held: Held::NoLogin,
411            } => write!(
412                f,
413                "{path} is there with no {tool} login in it. While it is, {tool} sessions \
414                 already running at a switch {running}. `pitboard stow` deletes it, which lets \
415                 them follow a switch.",
416                path = path.display(),
417                tool = tool.name(),
418                running = crate::words::kept_until_renewed(),
419            ),
420            Warning::FallbackLogin {
421                tool,
422                path,
423                held: Held::Unreadable,
424            } => write!(
425                f,
426                "{path} is there, and Pitboard could not read it. While it is, {tool} sessions \
427                 already running at a switch {running}. `pitboard doctor` says why, and what to \
428                 do.",
429                path = path.display(),
430                tool = tool.name(),
431                running = crate::words::kept_until_renewed(),
432            ),
433            Warning::SessionsStillRunning { from, holding } => write!(
434                f,
435                "{} started before this switch {} still running and still using `{from}`. {} \
436                 Do not sign out in {}: signing out there revokes `{from}`'s login, which \
437                 Pitboard has just parked.",
438                capitalised(&holder::described(holding)),
439                if holder::plural(holding) { "are" } else { "is" },
440                holder::remedies(holding, "to use the new account"),
441                if holder::plural(holding) {
442                    "any of them"
443                } else {
444                    "it"
445                },
446            ),
447            Warning::SessionsKeepTheOldLogin { label, holding } => write!(
448                f,
449                "{} started before this sign-in {} still running and still using `{label}`'s \
450                 old login. {} Otherwise one of them can put the old login back in place of \
451                 the new one when it refreshes its token.",
452                capitalised(&holder::described(holding)),
453                if holder::plural(holding) { "are" } else { "is" },
454                holder::remedies(holding, "to use the new one"),
455            ),
456            Warning::SessionsUnknown { tool, from } => write!(
457                f,
458                "Pitboard could not tell whether {} sessions started before this switch are \
459                 still running, because it could not read the list of processes. Do not sign \
460                 out in one that is: signing out there revokes `{from}`'s login, which \
461                 Pitboard has just parked.",
462                tool.name()
463            ),
464            Warning::SessionsUnknownAfterSignIn { tool, label } => write!(
465                f,
466                "Pitboard could not tell whether {} sessions started before this sign-in are \
467                 still running, because it could not read the list of processes. Quit any \
468                 that are still using `{label}`'s old login and start them again. Otherwise \
469                 one of them can put the old login back in place of the new one when it \
470                 refreshes its token.",
471                tool.name()
472            ),
473            Warning::SignInParkedNotInUse { tool, label, why } => write!(
474                f,
475                "{} goes on with the login it has: Pitboard could not tell whose it is \
476                 ({why}), so it parked the new login for `{label}` rather than write over \
477                 that one. If that login no longer works, {} and sign in to `{label}` \
478                 there.",
479                tool.name(),
480                crate::words::sign_in_yourself(*tool, false)
481            ),
482            Warning::LoginReplaced {
483                tool, label, now, ..
484            } => match (tool, now) {
485                (ProviderId::Desktop, Stored::Nothing) => write!(
486                    f,
487                    "`{label}` was signed out in Claude, which ends its login at claude.ai, and \
488                     Pitboard holds no login for it. Run `pitboard enroll {label} --sign-in` to \
489                     sign in to it again."
490                ),
491                (ProviderId::Claude | ProviderId::Codex | ProviderId::Desktop, now) => {
492                    write!(
493                        f,
494                        "`{label}`'s login was replaced by a sign-in outside Pitboard"
495                    )?;
496                    let tool = tool.name();
497                    match now {
498                        Stored::Account(now) => {
499                            write!(f, ": {tool} now has `{now}`'s login stored,")?;
500                        }
501                        Stored::Unenrolled(email) if email.is_empty() => write!(
502                            f,
503                            ": {tool} now has the login of an account Pitboard has not enrolled,"
504                        )?,
505                        Stored::Unenrolled(email) => {
506                            write!(f, ": {tool} now has {email}'s login stored,")?;
507                        }
508                        Stored::Nothing => write!(f, ": {tool} now has no login stored,")?,
509                        Stored::Unknown => write!(f, ",")?,
510                    }
511                    write!(
512                        f,
513                        " and Pitboard holds no login for `{label}`. Run `pitboard enroll \
514                         {label} --sign-in` to sign in to it again."
515                    )
516                }
517            },
518            Warning::InUseUnconfirmed {
519                tool,
520                last,
521                named,
522                asked,
523            } => match tool {
524                ProviderId::Desktop => desktop_named(f, named, last),
525                ProviderId::Claude | ProviderId::Codex if !asked => write!(
526                    f,
527                    "{name}'s config names {named}, and Pitboard has not asked {service} whose \
528                     login {name} has stored since Pitboard was updated, so another login may be \
529                     stored. The account it last switched to is {last}. `pitboard status` asks.",
530                    name = tool.name(),
531                    named = named.said("no account"),
532                    service = tool.service(),
533                    last = last.said("none"),
534                ),
535                ProviderId::Claude | ProviderId::Codex => {
536                    write!(
537                        f,
538                        "{name}'s config has named {named} since Pitboard last asked {service} \
539                         whose login {name} has stored, so another login may be stored.",
540                        name = tool.name(),
541                        named = named.said("no account"),
542                        service = tool.service(),
543                    )?;
544                    match last {
545                        Stored::Account(_) | Stored::Unenrolled(_) => {
546                            write!(f, " The one stored then was {}'s.", last.said(""))?;
547                        }
548                        Stored::Nothing => write!(f, " None was stored then.")?,
549                        Stored::Unknown => {}
550                    }
551                    write!(f, " `pitboard status` asks again.")
552                }
553            },
554            Warning::ConfigNamesAnother {
555                tool,
556                config,
557                in_use,
558            } => match tool {
559                ProviderId::Desktop => desktop_named(f, config, in_use),
560                ProviderId::Claude | ProviderId::Codex => {
561                    let config = config.said("no account");
562                    write!(
563                        f,
564                        "{name}'s config names {config}, and the login {name} has stored is \
565                         {}'s, as {} said. `/status` in {name} shows {config}.",
566                        in_use.said("nobody"),
567                        tool.service(),
568                        name = tool.name(),
569                    )?;
570                    write!(f, " {}", in_use.how_to_name())
571                }
572            },
573            Warning::TakenAsNamed {
574                label,
575                recorded: None,
576                ..
577            } => write!(
578                f,
579                "Pitboard had not seen the login Claude held, and took it as `{label}`'s because \
580                 Claude names that account. If Claude showed another account, run `pitboard use \
581                 {label}`, check in Claude, quit it, and enrol it under its own name."
582            ),
583            Warning::TakenAsNamed {
584                label,
585                recorded: Some(recorded),
586                ..
587            } => write!(
588                f,
589                "Pitboard had recorded the login Claude held as `{recorded}`'s, and filed it as \
590                 `{label}`'s because Claude names that account. Open Claude and check which \
591                 account it shows. If it is another, quit Claude with Command-Q, then run \
592                 `pitboard enroll desktop/<label>` under that account's name."
593            ),
594            Warning::SetAside {
595                path, label, why, ..
596            } => {
597                let path = path.display();
598                match (why, label) {
599                    (state::SetAside::Superseded, Some(label)) => write!(
600                        f,
601                        "Pitboard kept `{label}`'s earlier parked login in {path} and deleted \
602                         nothing. `pitboard doctor` lists it."
603                    ),
604                    (state::SetAside::Superseded, None) => write!(
605                        f,
606                        "Pitboard kept an earlier parked login in {path} and deleted nothing. \
607                         `pitboard doctor` lists it."
608                    ),
609                    (state::SetAside::GivenUp, Some(label)) => write!(
610                        f,
611                        "Pitboard moved the parts of `{label}`'s login left in Claude's folder \
612                         into {path} and deleted nothing. `pitboard doctor` lists them."
613                    ),
614                    (state::SetAside::GivenUp, None) => write!(
615                        f,
616                        "Pitboard moved the parts of a login left in Claude's folder into {path} \
617                         and deleted nothing. `pitboard doctor` lists them."
618                    ),
619                    (
620                        state::SetAside::InTheWay
621                        | state::SetAside::AnotherMachine
622                        | state::SetAside::Orphan,
623                        _,
624                    ) => write!(
625                        f,
626                        "Pitboard moved what it found in the way into {path} and deleted nothing."
627                    ),
628                }
629            }
630            Warning::KeysSetAside { path, label, .. } => {
631                let whose = label
632                    .as_ref()
633                    .map_or_else(|| "a login".to_string(), |label| format!("`{label}`"));
634                write!(
635                    f,
636                    "Claude rewrote {whose}'s keys. Pitboard kept the earlier ones in {} and \
637                     deleted nothing. `pitboard doctor` lists them.",
638                    path.display()
639                )
640            }
641            Warning::LooksSignedOut { label, .. } => write!(
642                f,
643                "Claude looks signed out since Pitboard last read `{label}` in use. The next \
644                 change to a Claude Desktop account checks."
645            ),
646            Warning::MoveSettled(moved) => {
647                let label = Key::new(moved.tool, moved.label.clone()).typed();
648                f.write_str(&crate::words::move_settled(&label, moved.half, moved.ended))
649            }
650            Warning::MoveWaiting { label, .. } => f.write_str(&crate::words::move_waiting(label)),
651            Warning::MoveNotGivenUp { label, why, .. } => {
652                f.write_str(&crate::words::move_not_given_up(label, why))
653            }
654            Warning::MoveNotSettled { label, why, .. } => {
655                f.write_str(&crate::words::move_not_settled(label, why))
656            }
657            Warning::SignInAwaited {
658                label,
659                put_back,
660                again,
661                ..
662            } => f.write_str(&crate::words::sign_in_awaited(label, put_back, *again)),
663            Warning::ItemGone { label, item, .. } => {
664                f.write_str(&crate::words::item_gone(label, item))
665            }
666            Warning::ReadOnly { why } => {
667                read_only(f, &crate::words::elevated(crate::host::OS, *why))
668            }
669            Warning::ReadOnlyBelowTheFloor { build } => {
670                read_only(f, &crate::words::too_old(*build))
671            }
672        }
673    }
674}
675
676/// An account as a warning or a doctor check names it: whose login a tool has stored, as
677/// Pitboard last recorded it, or whom the tool's own record names.
678#[derive(Debug, Clone, PartialEq, Eq)]
679pub enum Stored {
680    /// An enrolled account's, by the name to type for it.
681    Account(String),
682    /// An account nobody enrolled, by its email.
683    Unenrolled(String),
684    /// None.
685    Nothing,
686    /// Nothing is recorded of the tool's store here.
687    Unknown,
688}
689
690impl Stored {
691    /// `owner`'s account of `tool`, or none.
692    pub(crate) fn of(
693        state: &state::State,
694        tool: ProviderId,
695        owner: Option<&crate::provider::Owner>,
696    ) -> Stored {
697        match owner {
698            None => Stored::Nothing,
699            Some(owner) => state.account_of(tool, owner).map_or_else(
700                || Stored::Unenrolled(owner.email.clone()),
701                |account| Stored::Account(state.typed(&account.key())),
702            ),
703        }
704    }
705
706    /// How a sentence names it: the name to type, in backticks, the email, or `nobody`.
707    pub(crate) fn said(&self, nobody: &str) -> String {
708        match self {
709            Stored::Account(name) => format!("`{name}`"),
710            Stored::Unenrolled(email) if email.is_empty() => {
711                "an account Pitboard has not enrolled".into()
712            }
713            Stored::Unenrolled(email) => email.clone(),
714            Stored::Nothing | Stored::Unknown => nobody.to_string(),
715        }
716    }
717
718    /// The sentence that says what writes this account, whose login the tool has stored, into
719    /// the tool's own record again: `pitboard use` where it is enrolled, and otherwise the
720    /// next switch.
721    pub(crate) fn how_to_name(&self) -> String {
722        match self {
723            Stored::Account(name) => {
724                format!("`pitboard use {name}` writes `{name}` into the config.")
725            }
726            _ => "A switch writes the account it puts in use into the config.".into(),
727        }
728    }
729}
730
731/// Each account whose only login a sign-in outside Pitboard replaced, from Pitboard's own
732/// record alone.
733fn replaced(state: &state::State) -> Vec<Warning> {
734    state
735        .accounts
736        .iter()
737        .filter(|account| account.replaced_at.is_some() && account.parked.is_none())
738        .map(|account| {
739            let tool = account.provider();
740            let now = state.in_use(tool).map_or(Stored::Unknown, |record| {
741                Stored::of(state, tool, record.owner.as_ref())
742            });
743            Warning::LoginReplaced {
744                tool,
745                id: account.id.clone(),
746                label: state.typed(&account.key()),
747                now,
748            }
749        })
750        .collect()
751}
752
753/// What `known` says of a tool whose own record is apart from its login: that the record
754/// moved since its service last named the login stored, or names another account than one
755/// the service never named, or that it names another account than that login's.
756fn in_doubt(state: &state::State, tool: ProviderId, known: &Known) -> Option<Warning> {
757    let named = Stored::of(state, tool, known.own_record.as_ref());
758    let unconfirmed = |asked| {
759        let last = known.last.as_ref().map_or(Stored::Unknown, |record| {
760            Stored::of(state, tool, record.owner.as_ref())
761        });
762        Some(Warning::InUseUnconfirmed {
763            tool,
764            last,
765            named: named.clone(),
766            asked,
767        })
768    };
769    match known.doubt {
770        Some(Doubt::NamedMoved) => return unconfirmed(true),
771        Some(Doubt::NeverEstablished) => {
772            let owner = known.owner()?;
773            let config = known.own_record.as_ref()?;
774            return if new_id(tool, config) == new_id(tool, owner) {
775                None
776            } else {
777                unconfirmed(false)
778            };
779        }
780        Some(Doubt::LoginUnreadable) | None => {}
781    }
782    known
783        .named_another(tool)
784        .map(|_| Warning::ConfigNamesAnother {
785            tool,
786            config: named,
787            in_use: Stored::of(state, tool, known.owner()),
788        })
789}
790
791fn desktop_named(f: &mut fmt::Formatter<'_>, named: &Stored, last: &Stored) -> fmt::Result {
792    write!(
793        f,
794        "{}. Open Claude, check which account it shows, quit it with Command-Q, then run \
795         `pitboard enroll desktop/<label>` under that account's name.",
796        crate::words::desktop_named(named, last)
797    )
798}
799
800fn read_only(f: &mut fmt::Formatter<'_>, said: &crate::words::ChangesNothing) -> fmt::Result {
801    write!(
802        f,
803        "{}, so it changes nothing: these are the numbers it last measured, and it renews no \
804         parked login and asks nobody. {}",
805        said.because, said.to_ask_again
806    )
807}
808
809#[derive(Debug)]
810pub struct Done<T> {
811    pub value: T,
812    pub warnings: Vec<Warning>,
813}
814
815// The sign-in a read says is waited for, with the account parked for it.
816fn sign_in_awaited(state: &state::State, tool: ProviderId) -> Option<Warning> {
817    let awaited = state.awaiting(tool)?;
818    let put_back = state.by_id(tool, &awaited.put_back)?.key();
819    Some(Warning::SignInAwaited {
820        tool,
821        label: state.typed(&Key::new(tool, awaited.label.clone())),
822        put_back: state.typed(&put_back),
823        since: awaited.since,
824        again: awaited.account.is_some(),
825    })
826}
827
828/// A change that failed, with what was found on the way: recovering an interrupted switch
829/// is reported even when the change that followed it fails.
830#[derive(Debug)]
831pub struct Failed {
832    pub error: Error,
833    pub warnings: Vec<Warning>,
834    // how many of `warnings`, from the first, were done before the refusal
835    pub said_first: usize,
836}
837
838impl Failed {
839    // A change's refusal beside what was `found` before it ran. What was done first leads: an
840    // interrupted switch settled before the change, then what the change did.
841    fn of(mut error: Error, found: Vec<Warning>) -> Failed {
842        let (mut warnings, found): (Vec<Warning>, Vec<Warning>) = found
843            .into_iter()
844            .partition(|warning| matches!(warning, Warning::Recovered(_)));
845        warnings.extend(error.take_said_first());
846        let said_first = warnings.len();
847        warnings.extend(found);
848        warnings.extend(error.take_warnings());
849        Failed {
850            error,
851            warnings,
852            said_first,
853        }
854    }
855}
856
857pub type Changing<T> = std::result::Result<Done<T>, Failed>;
858
859#[derive(Debug)]
860pub struct Pitboard {
861    ctx: Context,
862}
863
864impl Pitboard {
865    pub fn new(ctx: Context) -> Pitboard {
866        Pitboard { ctx }
867    }
868
869    /// Whether this process may change anything here, as the one gate every change passes
870    /// says, with the proof it hands out where it may. Every change below asks it first,
871    /// before it reads, locks or records anything, so a change refused here leaves the
872    /// Pitboard directory, the stores of logins and the scheduler exactly as they were, the
873    /// audit log included. A front end asks it too before it writes a file of its own
874    /// through [`crate::app::write_file`], or before it asks a question whose answer could
875    /// only be refused.
876    pub fn permit(&self) -> Result<Permit> {
877        gate(&self.ctx)
878    }
879
880    // None where this process may change nothing here, or another app holds it.
881    pub fn hold_open(&self) -> Option<crate::app::Open> {
882        crate::app::Open::hold(&self.ctx, self.permit().ok()?)
883    }
884
885    /// Whether every home the environment names is a full path, as the gate and every read
886    /// of the accounts ask. A front end asks it before anything else that reads under one,
887    /// such as the audit log, the schedule's file or the status line's session records, so
888    /// a home that is empty or relative is refused there too, never read as the folder the
889    /// front end runs in.
890    pub fn check_homes(&self) -> Result<()> {
891        crate::home::check_absolute(&self.ctx)
892    }
893
894    /// The gate's answer as the refusal of a change, which has found nothing on the way.
895    fn permitted(&self) -> std::result::Result<Permit, Failed> {
896        self.permit().map_err(|error| Failed {
897            error,
898            warnings: Vec::new(),
899            said_first: 0,
900        })
901    }
902
903    /// Who is signed in and what every account has left. Parked logins whose access has
904    /// lapsed are renewed first, so every account is asked live, and so is a tool's stored
905    /// login where only its service can tell whose it is
906    /// ([`switch::identify::told_by_a_read`]).
907    ///
908    /// `fresh` asks Anthropic about every account whatever was asked recently. Ordinarily
909    /// false: a number is only asked for again once the tightest limit it describes could
910    /// have moved by a percentage point, which collapses several front ends on one machine
911    /// to one request per account per few minutes.
912    ///
913    /// An interrupted switch the next change cannot finish is said as that change would
914    /// refuse over it, and nothing is done about it: every change stops at it until it can
915    /// be finished or is given up on, and a front end that waited for a change to be
916    /// refused could not offer to give up on it until then. One the next change finishes by
917    /// itself is not said. Nobody has anything to do about it: the rows already say who is
918    /// signed in from each tool's login rather than from Pitboard's record, and the change
919    /// that finishes it says what it found.
920    ///
921    /// A file behind a tool's store is said as a change says it, since while it is there
922    /// every switch reaches running sessions only at their login's next renewal, and a read
923    /// is where somebody looks before they switch.
924    ///
925    /// Whose login each tool has stored is recorded where the read found it changed, as
926    /// [`switch::identify::record_read`] says, before the report is made, so a sign-in outside
927    /// Pitboard that replaced the only login of an account is said from that read on. Where
928    /// Claude Code's config names another account than that login's, that is said too, since
929    /// `/status` in Claude Code shows the config's.
930    ///
931    /// Where this process may change nothing, it answers what [`status_offline`] answers,
932    /// with a `read_only` warning that says why: renewing a parked login rotates its refresh
933    /// token, a live read records what it measured, and neither may happen then.
934    ///
935    /// [`status_offline`]: Pitboard::status_offline
936    pub fn status(&self, fresh: bool) -> Result<Done<status::Report>> {
937        let permit = match self.permit() {
938            Ok(permit) => permit,
939            Err(refused) => {
940                let Some(warning) = Warning::read_only(&refused) else {
941                    return Err(refused);
942                };
943                let mut read = self.status_offline()?;
944                read.warnings.push(warning);
945                return Ok(read);
946            }
947        };
948        let renewed = switch::renew_parked(&self.ctx, permit);
949        // Unreadable is not the same as empty: reporting it as empty would say the enrolled
950        // logins are gone.
951        let state = state::load(&self.ctx)?;
952        // Alongside the answers rather than before them. Where only the service can say
953        // whose the live login is, both ask it, and asked one after the other, a service that
954        // did not answer held the read for its timeout twice.
955        let (stuck, answers) = std::thread::scope(|scope| {
956            let stuck = scope.spawn(|| switch::stuck(&self.ctx, &state, switch::Asking::Service));
957            let answers = status::ask(&self.ctx, permit, &state, fresh);
958            (stuck.join().ok().flatten(), answers)
959        });
960        switch::identify::record_read(&self.ctx, permit, &answers.found, &answers.plans);
961        let found = answers.found.clone();
962        // Again, so the rows and what stands are of whose login each tool has stored as it
963        // was just recorded.
964        let state = state::load(&self.ctx)?;
965        let report = status::report(&self.ctx, permit, &state, answers);
966        let mut warnings: Vec<Warning> = stuck.map(Warning::SwitchStuck).into_iter().collect();
967        for (key, outcome) in renewed {
968            audit::record(&self.ctx, permit, "renew", &key.typed(), outcome.code());
969            match outcome {
970                Renewal::Refused => warnings.push(Warning::ParkedLoginRefused {
971                    tool: key.provider,
972                    label: state.typed(&key),
973                }),
974                Renewal::Failed(e) => warnings.push(Warning::RenewalFailed(e)),
975                Renewal::Renewed | Renewal::Deferred => {}
976            }
977        }
978        warnings.extend(
979            ProviderId::ALL
980                .iter()
981                .filter_map(|&tool| self.left_behind(tool)),
982        );
983        warnings.extend(self.standing(&state, &found));
984        Ok(Done {
985            value: report,
986            warnings,
987        })
988    }
989
990    /// What a read says for as long as it stands: each account whose only login a sign-in
991    /// outside Pitboard replaced, and, of a tool that keeps a record of its own apart from its
992    /// login, that the record has moved since its service last named the login stored, or
993    /// that it names another account than that login's.
994    ///
995    /// `found` is whose login each tool's store holds as this read just learned it, which a
996    /// read that asks nobody has none of. For every other tool, what Pitboard recorded
997    /// stands, so a read that could not ask says what one that asks nobody says.
998    fn standing(&self, state: &state::State, found: &[(ProviderId, InUse)]) -> Vec<Warning> {
999        let mut warnings = replaced(state);
1000        // So a Mac with no Claude Desktop account enrolled reads nothing of Claude.
1001        for &tool in ProviderId::ALL
1002            .iter()
1003            .filter(|&&tool| status::shown(state, tool))
1004        {
1005            let (known, signed_out) = match crate::provider::of(tool) {
1006                LoginKind::Document(driver) if driver.identifies_by_itself() => continue,
1007                LoginKind::Document(_) => {
1008                    let known = match found.iter().find(|(which, _)| *which == tool) {
1009                        Some((_, found)) => in_use::judged(&self.ctx, tool, Some(found.clone())),
1010                        None => in_use::known(&self.ctx, state, tool),
1011                    };
1012                    (known, None)
1013                }
1014                LoginKind::Folder(app) => {
1015                    warnings.extend(sign_in_awaited(state, tool));
1016                    // A move under way leaves the settings between two accounts: only it is said.
1017                    if let Some(moving) = state.moving(tool) {
1018                        warnings.push(Warning::MoveWaiting {
1019                            tool,
1020                            label: state.typed(&Key::new(tool, moving.label.clone())),
1021                        });
1022                        continue;
1023                    }
1024                    // No read learns whose an app's login is, so `found` names none.
1025                    switch::folder::read(&self.ctx, state, app)
1026                }
1027            };
1028            warnings.extend(in_doubt(state, tool, &known));
1029            warnings.extend(signed_out.map(|key| Warning::LooksSignedOut {
1030                tool,
1031                label: state.typed(&key),
1032            }));
1033        }
1034        warnings
1035    }
1036
1037    pub fn doctor(&self) -> Diagnosis {
1038        self.doctor_with(&crate::time::moment)
1039    }
1040
1041    /// The same checks, each moment one names written as `moment` writes it, given the moment
1042    /// and when the checks are made: an app's clock, where the command line's is
1043    /// [`crate::time::moment`].
1044    pub fn doctor_with(&self, moment: &dyn Fn(i64, i64) -> String) -> Diagnosis {
1045        doctor::run(&self.ctx, moment)
1046    }
1047
1048    /// The same report without asking anyone: the last numbers Pitboard measured, and whose
1049    /// login each tool has stored as its service last said. Nothing is renewed and nothing is
1050    /// asked, so it answers at once wherever there is no network. Where Claude Code's config
1051    /// has named another account since, as a sign-in leaves it, that is said, and a read that
1052    /// asks settles it.
1053    ///
1054    /// An interrupted switch the next change cannot finish is said here too, as [`status`]
1055    /// says it, wherever that can be told without a request: a Claude Code login renewed
1056    /// since the switch stopped is one only Anthropic can say whose it is, and then nothing
1057    /// is said. Telling reads the tool's login and the copy the switch parked, which on
1058    /// macOS are keychain items, and only while a switch is waiting: otherwise one look at
1059    /// whether its record is there is the whole of it. So nothing is said of a file behind
1060    /// the keychain, which only a read of the keychain can tell. What Pitboard's own record
1061    /// holds is said as [`status`] says it: an account whose only login was replaced outside
1062    /// Pitboard, and Claude Code's config naming another account than the one in use.
1063    ///
1064    /// [`status`]: Pitboard::status
1065    pub fn status_offline(&self) -> Result<Done<status::Report>> {
1066        let state = state::load(&self.ctx)?;
1067        let warnings = switch::stuck(&self.ctx, &state, switch::Asking::Nobody)
1068            .map(Warning::SwitchStuck)
1069            .into_iter()
1070            .chain(self.standing(&state, &[]))
1071            .collect();
1072        Ok(Done {
1073            value: status::gather_offline(&self.ctx, &state),
1074            warnings,
1075        })
1076    }
1077
1078    /// The status line for Claude Code's session JSON. Reads only files, and writes only
1079    /// Pitboard's own: what the session passed, for its next run to compare with, and the
1080    /// usage readings, which take what moved since its last run for the account its windows
1081    /// prove it is on, where it moves a limit Anthropic gave that account. Where this process
1082    /// may change nothing it writes neither, and draws the line from the files as they are.
1083    pub fn statusline(&self, session: &str) -> statusline::StatusLine {
1084        statusline::read(&self.ctx, self.permit().ok(), session)
1085    }
1086
1087    /// The enrolled account under `typed`, if any, read without taking the lock.
1088    pub fn account(&self, typed: &str) -> Option<Account> {
1089        let state = state::load(&self.ctx).ok()?;
1090        crate::label::resolve(&state, typed).ok().cloned()
1091    }
1092
1093    pub fn switch_to(&self, typed: &str) -> Changing<Outcome> {
1094        let permit = self.permitted()?;
1095        let key = self.named(permit, "use", typed)?;
1096        self.changing(permit, "use", &key.typed(), Some(key.provider), |settled| {
1097            switch::switch(settled, &key)
1098        })
1099    }
1100
1101    /// What [`switch_to`] does once somebody has agreed to put `typed`'s login in place of a
1102    /// login in use its service refuses for good, which it refused over: parks nothing.
1103    /// Where the login stored can be told by then, it is a switch.
1104    ///
1105    /// [`switch_to`]: Pitboard::switch_to
1106    pub fn replace_with(&self, typed: &str) -> Changing<Outcome> {
1107        let permit = self.permitted()?;
1108        let key = self.named(permit, "use", typed)?;
1109        self.changing(permit, "use", &key.typed(), Some(key.provider), |settled| {
1110            switch::replace(settled, &key)
1111        })
1112    }
1113
1114    /// What [`switch_to`] does for the account in use, for a front end's button drawn while
1115    /// `typed` was: recorded as `use`, and refused, moving nothing, where another account's
1116    /// login is stored by then.
1117    ///
1118    /// [`switch_to`]: Pitboard::switch_to
1119    pub fn update_config(&self, typed: &str) -> Changing<Outcome> {
1120        let permit = self.permitted()?;
1121        let key = self.named(permit, "use", typed)?;
1122        self.changing(permit, "use", &key.typed(), Some(key.provider), |settled| {
1123            switch::update_config(settled, &key)
1124        })
1125    }
1126
1127    /// Switches Claude Code by itself where a limit of the account in use has reached
1128    /// `threshold` and another account has room, as [`crate::duty::autoswitch`] says. For a front
1129    /// end somebody asked to do that: the app with its setting on, or `pitboard watch`.
1130    ///
1131    /// Looks first from files alone ([`auto_look`]), so a look that finds nothing to do takes
1132    /// no lock, asks nobody and records nothing. Under the lock whose login Claude Code has
1133    /// stored is told, and the switch decided once, as [`switch::automatically`] says, which
1134    /// records in the audit log what it came to. A settle that failed first is recorded as
1135    /// `auto-switch` of `claude`.
1136    ///
1137    /// [`auto_look`]: Pitboard::auto_look
1138    pub fn auto_switch(&self, threshold: Threshold) -> Changing<Auto> {
1139        let permit = self.permitted()?;
1140        let looked = self.auto_look(threshold).map_err(|error| Failed {
1141            error,
1142            warnings: Vec::new(),
1143            said_first: 0,
1144        })?;
1145        if let Look::Stands(value) = looked {
1146            return Ok(Done {
1147                value: *value,
1148                warnings: Vec::new(),
1149            });
1150        }
1151        let tool = ProviderId::Claude;
1152        let (settled, mut warnings) = self.settled_for(permit, "auto-switch", "", Some(tool))?;
1153        match switch::automatically(settled, threshold) {
1154            Ok((value, more)) => {
1155                warnings.extend(more);
1156                Ok(Done { value, warnings })
1157            }
1158            Err(error) => Err(Failed::of(error, warnings)),
1159        }
1160    }
1161
1162    /// What switching Claude Code by itself would come to now, from files alone: what
1163    /// stands, or that only a decision under the lock can say, which [`auto_switch`] makes.
1164    /// Waits on no lock, reads no keychain and asks nobody, so a front end may look as often
1165    /// as it likes. While another run is in the middle of a switch, only the decision can
1166    /// say, which waits for that run.
1167    ///
1168    /// [`auto_switch`]: Pitboard::auto_switch
1169    pub fn auto_look(&self, threshold: Threshold) -> Result<Look> {
1170        let state = state::load(&self.ctx)?;
1171        Ok(crate::duty::autoswitch::look(&self.ctx, &state, threshold))
1172    }
1173
1174    /// Which account somebody meant, as the key the engine looks accounts up by.
1175    ///
1176    /// Resolving here rather than deeper down means every command takes `codex/work` and
1177    /// a bare `work` on the same terms, and the one place that decides what an ambiguous
1178    /// bare label does is the one place that knows every provider's accounts.
1179    fn named(&self, permit: Permit, verb: &str, typed: &str) -> std::result::Result<Key, Failed> {
1180        let state = state::load(&self.ctx).map_err(|error| Failed {
1181            error,
1182            warnings: Vec::new(),
1183            said_first: 0,
1184        })?;
1185        crate::label::resolve(&state, typed)
1186            .map(Account::key)
1187            .map_err(|error| self.refused(permit, verb, typed, error.code(), error))
1188    }
1189
1190    /// `typed` may name a tool, as in `claude/work`. A bare name means the default tool.
1191    pub fn enroll_current(&self, typed: &str) -> Changing<Enrolled> {
1192        let permit = self.permitted()?;
1193        let key = self.chosen(permit, "enroll", typed)?;
1194        self.changing(
1195            permit,
1196            "enroll",
1197            &key.typed(),
1198            Some(key.provider),
1199            |settled| switch::enroll(settled, &key, None),
1200        )
1201    }
1202
1203    /// Which tool a new account is for, and what it is called there.
1204    ///
1205    /// Split here rather than deeper down so nothing below ever sees a name with a tool
1206    /// still stuck to the front of it, which would enrol an account literally called
1207    /// `claude/work`.
1208    fn chosen(&self, permit: Permit, verb: &str, typed: &str) -> std::result::Result<Key, Failed> {
1209        self.enrolling(typed)
1210            .map_err(|error| self.refused(permit, verb, typed, "label_unusable", error))
1211    }
1212
1213    /// A change refused over the name it was given, which happens before it settles.
1214    ///
1215    /// A mistyped name takes no lock and writes nothing but its line in the audit log. But
1216    /// every change settles an interrupted switch first, and one refused here would leave
1217    /// that switch for whatever runs next and say nothing about it. So where a switch was
1218    /// interrupted, this settles for the tool that switch was of, which a custom OAuth
1219    /// endpoint allows or refuses exactly as it would a change to that tool, and reports what
1220    /// it found beside the refusal. Only as far as it can: a recovery that cannot finish is
1221    /// the next change's to report, and what this one reports is why it was refused.
1222    fn refused(
1223        &self,
1224        permit: Permit,
1225        verb: &str,
1226        subject: &str,
1227        code: &str,
1228        error: Error,
1229    ) -> Failed {
1230        let recovered = if switch::interrupted(&self.ctx) {
1231            switch::settle(&self.ctx, permit, switch::interrupted_tool(&self.ctx))
1232                .ok()
1233                .and_then(|(_, recovered)| recovered)
1234        } else {
1235            None
1236        };
1237        let mut warnings = Vec::new();
1238        if let Some(r) = recovered {
1239            audit::record(&self.ctx, permit, "recover", &r.to, r.code());
1240            warnings.push(Warning::Recovered(r));
1241        }
1242        audit::record(&self.ctx, permit, verb, subject, code);
1243        Failed::of(error, warnings)
1244    }
1245
1246    // An account enrolled already keeps the name it has, which a new one may no longer get:
1247    // 0.1.x wrote labels with a slash, and the app took labels with a space.
1248    fn enrolling(&self, typed: &str) -> Result<Key> {
1249        if let Ok(state) = state::load(&self.ctx) {
1250            let whole = state
1251                .accounts
1252                .iter()
1253                .find(|a| typed.contains(crate::label::SEPARATOR) && a.label == typed);
1254            let named = match crate::label::parse(typed) {
1255                Ok(crate::label::Spec::Bare(label)) => {
1256                    state.get(&Key::new(crate::label::DEFAULT, label))
1257                }
1258                Ok(crate::label::Spec::Qualified(provider, label)) => {
1259                    state.get(&Key::new(provider, label))
1260                }
1261                Err(_) => None,
1262            };
1263            if let Some(existing) = whole.or(named) {
1264                return Ok(existing.key());
1265            }
1266        }
1267        crate::label::choose(typed)
1268            .map(|chosen| Key::new(chosen.provider, chosen.label))
1269            .map_err(Error::Usage)
1270    }
1271
1272    /// The enrolled account `pitboard enroll <typed>` would sign in to again, if it names
1273    /// one, for saying whose login to sign in with before a browser opens.
1274    pub fn account_to_enroll(&self, typed: &str) -> Option<Account> {
1275        let key = self.enrolling(typed).ok()?;
1276        state::load(&self.ctx).ok()?.get(&key).cloned()
1277    }
1278
1279    /// What to type to name the account `pitboard enroll <typed>` is about, on this
1280    /// machine: bare where that names it alone, qualified where another tool shares it.
1281    pub fn name_to_type(&self, typed: &str) -> String {
1282        let Ok(key) = self.enrolling(typed) else {
1283            return typed.to_string();
1284        };
1285        state::load(&self.ctx).map_or_else(|_| key.typed(), |state| state.typed(&key))
1286    }
1287
1288    // What the tool already runs, for a front end to print before a program sign-in, since the
1289    // core prints nothing. `None` off Windows, and for a tool with no holders.
1290    pub fn running_before_sign_in(&self, typed: &str) -> Option<String> {
1291        let which = crate::label::choose(typed)
1292            .map(|chosen| chosen.provider)
1293            .unwrap_or(crate::label::DEFAULT);
1294        switch::running_already(&self.ctx, which)
1295    }
1296
1297    // A program's sign-in runs in a private directory and takes no lock but its own.
1298    pub fn sign_in(&self, typed: &str) -> std::result::Result<SignIn, Failed> {
1299        let permit = self.permitted()?;
1300        let key = self.chosen(permit, "enroll", typed)?;
1301        let driver = match crate::provider::of(key.provider) {
1302            LoginKind::Document(driver) => driver,
1303            // The wait is the core's, and it prints nothing: a front end reads `said`.
1304            LoginKind::Folder(_) => {
1305                return self
1306                    .sign_in_watched(typed)?
1307                    .finish()
1308                    .map_err(|error| self.not_started(permit, typed, error));
1309            }
1310        };
1311        self.ready_to_sign_in(driver)
1312            .and_then(|()| switch::sign_in(&self.ctx, permit, driver))
1313            .map_err(|error| self.not_started(permit, typed, error))
1314    }
1315
1316    // Watched: the caller shows what is said, and types a code back where the tool reads one.
1317    pub fn sign_in_watched(
1318        &self,
1319        typed: &str,
1320    ) -> std::result::Result<switch::WatchedSignIn, Failed> {
1321        let permit = self.permitted()?;
1322        let key = self.chosen(permit, "enroll", typed)?;
1323        let driver = match crate::provider::of(key.provider) {
1324            LoginKind::Document(driver) => driver,
1325            // Inside the app: the account in use is parked, then a thread waits for the app
1326            // to quit signed in. `signin.lock` is taken before `state.lock`, as every sign-in's.
1327            LoginKind::Folder(app) => {
1328                let one_at_a_time = switch::one_at_a_time(&self.ctx, permit)
1329                    .map_err(|error| self.not_started(permit, typed, error))?;
1330                let subject = key.typed();
1331                let settling = self.settled_for(permit, "park", &subject, Some(key.provider))?;
1332                // A sign-in returns no warnings, so what settling found is said first.
1333                let first = settling
1334                    .1
1335                    .iter()
1336                    .map(|warning| format!("{warning}\n"))
1337                    .collect();
1338                return self
1339                    .change(permit, "park", &subject, settling, |settled| {
1340                        switch::folder::awaited::start(settled, &key, app, one_at_a_time, first)
1341                    })
1342                    .map(|started| started.value);
1343            }
1344        };
1345        self.ready_to_sign_in(driver)
1346            .and_then(|()| switch::sign_in_watched_as(&self.ctx, permit, driver, &key))
1347            .map_err(|error| self.not_started(permit, typed, error))
1348    }
1349
1350    // The tools a sign-in could start here now, by the rule `ready_to_sign_in` keeps.
1351    pub fn installed(&self) -> Vec<ProviderId> {
1352        crate::provider::installed_among(&self.ctx, crate::host::proc::can_run)
1353    }
1354
1355    /// A sign-in that did not start, or did not finish, for a reason other than its name.
1356    fn not_started(&self, permit: Permit, typed: &str, error: Error) -> Failed {
1357        audit::record(&self.ctx, permit, "enroll", typed, error.code());
1358        Failed {
1359            error,
1360            warnings: Vec::new(),
1361            said_first: 0,
1362        }
1363    }
1364
1365    /// Checked before a sign-in starts, so a person does not sign in through a browser
1366    /// only to be told the state file belongs to another machine, that the tool is not
1367    /// installed, or that the account could never be switched to afterwards.
1368    fn ready_to_sign_in(&self, driver: &'static dyn Provider) -> Result<()> {
1369        let tool = driver.id();
1370        if tool == ProviderId::Claude && self.ctx.custom_oauth() {
1371            return Err(Error::CustomOauthEndpoint);
1372        }
1373        state::load(&self.ctx)?;
1374        // An account signed in here is one to switch to later, which needs a live store
1375        // Pitboard can write. Asked now rather than after a browser round trip.
1376        switch::live_store(&self.ctx, driver)?;
1377        // A private sign-in works by pointing the tool's own login at a scratch directory
1378        // through its home variable. Where that does not really isolate it, running one
1379        // would write over the login somebody is using, and there is no override: forcing
1380        // past "this could touch your live login" is what the rule exists to prevent.
1381        if let crate::provider::Isolation::NotIsolated { reason } =
1382            driver.private_signin_isolation(&self.ctx)
1383        {
1384            return Err(Error::SignInNotIsolated { reason });
1385        }
1386        if driver.program(&self.ctx).is_none() {
1387            return Err(Error::ProgramMissing {
1388                tool,
1389                program: self
1390                    .ctx
1391                    .program_for(tool)
1392                    .unwrap_or(std::path::Path::new(tool.program()))
1393                    .display()
1394                    .to_string(),
1395            });
1396        }
1397        Ok(())
1398    }
1399
1400    pub fn enroll_signed_in(&self, typed: &str, login: SignIn) -> Changing<Enrolled> {
1401        let permit = self.permitted()?;
1402        let key = self.chosen(permit, "enroll", typed)?;
1403        self.changing(
1404            permit,
1405            "enroll",
1406            &key.typed(),
1407            Some(key.provider),
1408            |settled| switch::enroll(settled, &key, Some(login)),
1409        )
1410    }
1411
1412    /// Returns the account's email.
1413    pub fn forget(&self, typed: &str) -> Changing<String> {
1414        let permit = self.permitted()?;
1415        let key = self.named(permit, "forget", typed)?;
1416        self.changing(
1417            permit,
1418            "forget",
1419            &key.typed(),
1420            Some(key.provider),
1421            |settled| switch::forget(settled, &key),
1422        )
1423    }
1424
1425    // The way out of a switch recovery cannot finish and of a move it cannot settle.
1426    pub fn abandon_recovery(&self) -> Result<switch::GaveUp> {
1427        let permit = self.permit()?;
1428        let outcome = switch::abandon(&self.ctx, permit);
1429        audit::record(
1430            &self.ctx,
1431            permit,
1432            "abandon",
1433            "",
1434            match &outcome {
1435                Ok(_) => "ok",
1436                Err(e) => e.code(),
1437            },
1438        );
1439        outcome
1440    }
1441
1442    /// Renew every parked login that is due, and nothing else. No switch, no usage, and
1443    /// no request but the token exchange. This is what the schedule runs.
1444    ///
1445    /// Refused where this process may change nothing, as every change is, so a run that
1446    /// renewed nothing for that reason says so rather than reading as one where nothing was
1447    /// due.
1448    ///
1449    /// A run the schedule started renews the default home, whatever `PITBOARD_HOME` says
1450    /// ([`schedule::for_its_run`]): the schedule is that home's alone.
1451    pub fn renew(&self) -> Result<Vec<(Key, Renewal)>> {
1452        let ctx = schedule::for_its_run(&self.ctx);
1453        let permit = gate(&ctx)?;
1454        let outcomes = switch::renew_due(&ctx, permit, switch::Due::ToStayAlive);
1455        for (key, outcome) in &outcomes {
1456            audit::record(&ctx, permit, "renew", &key.typed(), outcome.code());
1457        }
1458        Ok(outcomes)
1459    }
1460
1461    /// Whether anything is keeping parked logins alive on this machine without somebody
1462    /// running a command.
1463    pub fn schedule(&self) -> schedule::Installed {
1464        schedule::status(&self.ctx)
1465    }
1466
1467    /// Whether the schedule renews this Pitboard's own parked logins: whether its home is
1468    /// the default home, `~/.pitboard`, compared as a path. The schedule renews that home
1469    /// alone, and `schedule_install` is refused from any other.
1470    pub fn schedule_renews_this_home(&self) -> bool {
1471        schedule::serves(&self.ctx)
1472    }
1473
1474    /// Ask the platform's own scheduler to run `renew` daily. Opt-in, and stays opt-in.
1475    /// Refused with `schedule_not_default_home` where this is not the default home
1476    /// ([`Pitboard::schedule_renews_this_home`]).
1477    pub fn schedule_install(&self) -> Result<schedule::Location> {
1478        schedule::install(&self.ctx, self.permit()?)
1479    }
1480
1481    /// Take it away. `false` when there was nothing installed.
1482    pub fn schedule_uninstall(&self) -> Result<bool> {
1483        schedule::uninstall(&self.ctx, self.permit()?)
1484    }
1485
1486    /// Point a schedule an app up to 0.3.0 wrote, which runs the app itself, or on Windows a
1487    /// task whose Pitboard is gone and whose settings are as Pitboard wrote them, at the
1488    /// command line this context names. `true` when it did; nothing changes otherwise.
1489    pub fn schedule_repair(&self) -> Result<bool> {
1490        schedule::repair(&self.ctx, self.permit()?)
1491    }
1492
1493    /// Take over a Pitboard directory another machine wrote: keep the accounts, drop the
1494    /// logins that came with them. `None` when the directory was already this machine's.
1495    ///
1496    /// The one change that does not settle first, because a stamp from elsewhere is what
1497    /// stops settling. Everything after it settles normally.
1498    pub fn adopt(&self) -> Result<Option<switch::Adopted>> {
1499        switch::adopt(&self.ctx, self.permit()?)
1500    }
1501
1502    /// Ask the credential store what parked logins are on this machine, and give back or
1503    /// delete every one Pitboard's own records do not name. Ordinarily there is nothing to
1504    /// do: every change resolves the names it wrote down. This is for a machine whose state
1505    /// file was lost or restored from a backup, where the store is the only record left.
1506    pub fn repair(&self) -> Changing<switch::Reclaimed> {
1507        let permit = self.permitted()?;
1508        self.changing(permit, "repair", "", None, switch::repair)
1509    }
1510
1511    /// What is running `which`'s tool with a login in memory that a switch would leave it
1512    /// on, by kind: for a front end to say so, or to offer to quit an app, before switching.
1513    /// Empty where nothing is, where the tool follows a switch by itself, or where nobody
1514    /// could tell. Reads the process list and nothing else.
1515    pub fn holding(&self, which: ProviderId) -> Vec<holder::Holding> {
1516        match switch::still_holding(&self.ctx, which) {
1517            switch::StillHolding::These(holding) => holding,
1518            switch::StillHolding::Nothing | switch::StillHolding::Unknown => Vec::new(),
1519        }
1520    }
1521
1522    // Whether `which`'s app may be opened again after a switch or a refusal, from the index
1523    // alone. Not while a move waits, nor where it would open signed out with every account
1524    // parked and no sign-in waited for.
1525    pub fn whole(&self, which: ProviderId) -> bool {
1526        match crate::provider::of(which) {
1527            LoginKind::Document(_) => true,
1528            LoginKind::Folder(_) => {
1529                let Ok(state) = state::load(&self.ctx) else {
1530                    return false;
1531                };
1532                let parked = state.accounts.iter().any(|a| {
1533                    a.provider() == which
1534                        && a.parked
1535                            .as_ref()
1536                            .is_some_and(|park| park.kind == state::ParkKind::Folder)
1537                });
1538                let nobody = state
1539                    .in_use(which)
1540                    .is_some_and(|record| record.owner.is_none());
1541                let left_signed_out = nobody && parked && state.awaiting(which).is_none();
1542                state.moving(which).is_none() && !left_signed_out
1543            }
1544        }
1545    }
1546
1547    // Whether a move of `which`'s login waits, from the index alone: asked before its app is
1548    // opened for a sign-in inside it, where it is meant to be signed out.
1549    pub fn moving(&self, which: ProviderId) -> bool {
1550        match crate::provider::of(which) {
1551            LoginKind::Document(_) => false,
1552            LoginKind::Folder(_) => {
1553                state::load(&self.ctx).map_or(true, |state| state.moving(which).is_some())
1554            }
1555        }
1556    }
1557
1558    // Where an app's parked logins and the folders set aside are kept, for a front end to name.
1559    pub fn parks_folder(&self) -> std::path::PathBuf {
1560        crate::store::folder::parks_dir(&self.ctx)
1561    }
1562
1563    // `PITBOARD_HOME`, or the default in the home.
1564    pub fn directory(&self) -> std::path::PathBuf {
1565        crate::home::dir(&self.ctx)
1566    }
1567
1568    // The one the renewal schedule renews, whatever `PITBOARD_HOME` says.
1569    pub fn default_directory(&self) -> std::path::PathBuf {
1570        self.ctx.default_pitboard_home()
1571    }
1572
1573    // As a person reads a folder: under the home it starts `~` on macOS and Linux.
1574    pub fn shown(&self, folder: &std::path::Path) -> String {
1575        let home = self.ctx.home();
1576        match folder.strip_prefix(home) {
1577            Ok(rest) if self.ctx.os != Os::Windows && !home.as_os_str().is_empty() => {
1578                std::path::Path::new("~").join(rest).display().to_string()
1579            }
1580            _ => folder.display().to_string(),
1581        }
1582    }
1583
1584    /// When Pitboard's account index last changed, in epoch milliseconds, for a front end
1585    /// that wants to know whether another one has done something without asking Anthropic
1586    /// about it.
1587    pub fn changed_at(&self) -> i64 {
1588        state::changed_at(&self.ctx)
1589    }
1590
1591    /// When Pitboard's usage readings last changed, in epoch milliseconds, for a front end
1592    /// that shows them to follow what the others record without asking anyone.
1593    pub fn readings_changed_at(&self) -> i64 {
1594        readings::changed_at(&self.ctx)
1595    }
1596
1597    /// What the app keeps in `file` in Pitboard's directory, or `None` where it keeps nothing
1598    /// there. One that is there and cannot be read is an error.
1599    pub fn app_file(&self, file: crate::app::AppFile) -> std::io::Result<Option<String>> {
1600        crate::app::read_app_file(&self.ctx, file)
1601    }
1602
1603    /// Keeps `body` in the app's `file` in Pitboard's directory, private and whole, as the
1604    /// core writes its own files.
1605    pub fn keep_app_file(&self, file: crate::app::AppFile, body: &str) -> Result<()> {
1606        crate::app::write_app_file(&self.ctx, self.permit()?, file, body)
1607    }
1608
1609    /// The changes Pitboard has made, newest last.
1610    pub fn log(&self, limit: usize) -> Vec<audit::Entry> {
1611        audit::read(&self.ctx, limit)
1612    }
1613
1614    /// Takes away the daily renewal schedule, deletes every parked login this Pitboard
1615    /// wrote, and removes Pitboard's own directory. Each tool's login is left alone: whoever
1616    /// is signed in stays signed in.
1617    pub fn uninstall(&self) -> Changing<switch::Removed> {
1618        let permit = self.permitted()?;
1619        self.changing(permit, "uninstall", "", None, |settled| {
1620            switch::uninstall(settled).map(|mut removed| {
1621                let said = std::mem::take(&mut removed.warnings);
1622                (removed, said)
1623            })
1624        })
1625    }
1626
1627    /// Returns the account's email.
1628    /// `from` may be qualified; `to` is a plain name, and stays inside whichever provider
1629    /// the account already belongs to. Renaming cannot move an account between tools.
1630    pub fn rename(&self, from: &str, to: &str) -> Changing<String> {
1631        let permit = self.permitted()?;
1632        let from = self.named(permit, "rename", from)?;
1633        let chosen = crate::label::choose(to)
1634            .map(|chosen| Key::new(chosen.provider, chosen.label))
1635            .map_err(|refusal| {
1636                self.refused(
1637                    permit,
1638                    "rename",
1639                    to,
1640                    "label_unusable",
1641                    Error::Usage(refusal),
1642                )
1643            })?;
1644        // Only a prefix somebody actually typed can disagree: a bare new name stays inside
1645        // the account's own tool whatever tool a bare name would mean for a new account.
1646        if to.contains(crate::label::SEPARATOR) && chosen.provider != from.provider {
1647            let error = Error::Usage(format!(
1648                "`{from}` is a {} account, and a rename cannot move it to {}. Sign in to that \
1649                 tool and enrol the account there instead.",
1650                from.provider, chosen.provider
1651            ));
1652            return Err(self.refused(permit, "rename", &from.typed(), error.code(), error));
1653        }
1654        let to = chosen.label;
1655        self.changing(
1656            permit,
1657            "rename",
1658            &format!("{from} -> {to}"),
1659            Some(from.provider),
1660            |settled| switch::rename(settled, &from, &to).map(|email| (email, Vec::new())),
1661        )
1662    }
1663
1664    /// Each Claude Code account, whether Pitboard warms it up, and within which span of each
1665    /// day, from Pitboard's files alone: no lock, and nobody asked.
1666    pub fn warm_overview(&self) -> Result<crate::duty::warm::Overview> {
1667        let state = state::load(&self.ctx)?;
1668        let setting = crate::duty::warm::load(&self.ctx)?;
1669        Ok(crate::duty::warm::overview(&self.ctx, &state, &setting))
1670    }
1671
1672    /// Warms up the account `typed` names now, whatever the span of the day and the attempts
1673    /// already made today, where its five-hour period is not running and nothing holds it back:
1674    /// one short request through Claude Code's own headless mode, with that account's access
1675    /// token. Recorded in the activity log as `warm-up` of the account.
1676    pub fn warm_now(&self, typed: &str) -> Changing<switch::warm::WarmUp> {
1677        let permit = self.permitted()?;
1678        let key = self.named(permit, "warm-up", typed)?;
1679        let settled = self.settled_to_warm(permit, "warm-up", &key.typed())?;
1680        Self::warming(settled, switch::warm::Target::Account(key))
1681    }
1682
1683    /// Warms up the first account turned on that is due, as [`crate::duty::warm`] says, for a front
1684    /// end somebody asked to: the app, or `pitboard watch`. `Pitboard::warm_look` says
1685    /// whether one may be, waiting on no lock.
1686    pub fn warm_up(&self) -> Changing<switch::warm::WarmUp> {
1687        let permit = self.permitted()?;
1688        let looked = self.warm_look().map_err(|error| Failed {
1689            error,
1690            warnings: Vec::new(),
1691            said_first: 0,
1692        })?;
1693        if let crate::duty::Look::Stands(overview) = looked {
1694            return Ok(Done {
1695                value: switch::warm::WarmUp::Stands(overview),
1696                warnings: Vec::new(),
1697            });
1698        }
1699        let settled = self.settled_to_warm(permit, "warm-up", "")?;
1700        Self::warming(settled, switch::warm::Target::Due)
1701    }
1702
1703    /// What warming up would come to now, from files alone: what stands, with where each
1704    /// account turned on stands, or that only [`Pitboard::warm_up`] can say. Waits on no lock,
1705    /// reads no keychain and asks nobody, so a front end may look as often as it likes.
1706    pub(crate) fn warm_look(&self) -> Result<crate::duty::Look<crate::duty::warm::Overview>> {
1707        let state = state::load(&self.ctx)?;
1708        crate::duty::warm::look(&self.ctx, &state)
1709    }
1710
1711    /// Settled for a warm-up, with an interrupted switch settling finished. Without what a
1712    /// change of Claude Code's login warns of: a warm-up's run is given nothing of this
1713    /// process's environment and no file behind the keychain, and what would make it sign in
1714    /// another way stops it outright.
1715    fn settled_to_warm(
1716        &self,
1717        permit: Permit,
1718        verb: &str,
1719        subject: &str,
1720    ) -> std::result::Result<(Settled, Vec<Warning>), Failed> {
1721        let (settled, recovered) = self.settled(permit, verb, subject, Some(ProviderId::Claude))?;
1722        Ok((settled, recovered.into_iter().collect()))
1723    }
1724
1725    fn warming(
1726        (settled, mut warnings): (Settled, Vec<Warning>),
1727        target: switch::warm::Target,
1728    ) -> Changing<switch::warm::WarmUp> {
1729        match switch::warm::warm_up(settled, target) {
1730            Ok((value, more)) => {
1731                warnings.extend(more);
1732                Ok(Done { value, warnings })
1733            }
1734            Err(error) => Err(Failed::of(error, warnings)),
1735        }
1736    }
1737
1738    /// Warms up the account `typed` names, or stops, recorded in the activity log as
1739    /// `warm-on` or `warm-off` of that account. Only a Claude Code account is warmed up
1740    /// (`warm_up_unsupported`); any may be turned off. Whether that changed anything.
1741    pub fn warm_turn(&self, typed: &str, on: bool) -> Changing<bool> {
1742        let verb = if on { "warm-on" } else { "warm-off" };
1743        let permit = self.permitted()?;
1744        let key = self.named(permit, verb, typed)?;
1745        let subject = key.typed();
1746        let settled = self.settled_to_warm(permit, verb, &subject)?;
1747        self.change(permit, verb, &subject, settled, |settled| {
1748            switch::warm::turn(settled, &key, on).map(|changed| (changed, Vec::new()))
1749        })
1750    }
1751
1752    /// Warms up within `range` of each day, recorded in the activity log as `warm-range` of
1753    /// the span.
1754    pub fn warm_range(
1755        &self,
1756        range: crate::duty::warm::Range,
1757    ) -> Changing<crate::duty::warm::Range> {
1758        let permit = self.permitted()?;
1759        let subject = format!(
1760            "{}-{}",
1761            crate::duty::warm::clock(range.from()),
1762            crate::duty::warm::clock(range.to())
1763        );
1764        let settled = self.settled_to_warm(permit, "warm-range", &subject)?;
1765        self.change(permit, "warm-range", &subject, settled, |settled| {
1766            switch::warm::range(settled, range).map(|range| (range, Vec::new()))
1767        })
1768    }
1769
1770    /// The login left in the file behind Claude Code's store, and what putting it away would
1771    /// do with it, for somebody to confirm: `None` where nothing is behind that store. What
1772    /// `pitboard stow` and the app's sheet ask first. Takes no lock and writes nothing, and
1773    /// asks Anthropic whose the login is, with its own access token, only where neither its
1774    /// fingerprint nor its expiry already says, and then, for an enrolled account, whose the
1775    /// login stored is, as a read asks it, only where that changed since Anthropic last named
1776    /// it.
1777    pub fn left_login(&self) -> Result<Option<switch::Left>> {
1778        let state = state::load(&self.ctx)?;
1779        switch::left_login(&self.ctx, &state)
1780    }
1781
1782    /// Puts away the login left in the file behind Claude Code's store, while the file still
1783    /// holds what `seen` was taken of, as `switch::stow` says. Recorded in the audit log as
1784    /// `stow`, of the account the login was found to be where it is enrolled, with what became
1785    /// of it, or with the error that stopped it. Settled with what a change of Claude Code's
1786    /// login warns of but the file, which is what it puts away.
1787    pub fn stow(&self, seen: &str) -> Changing<switch::Stowed> {
1788        let permit = self.permitted()?;
1789        let tool = ProviderId::Claude;
1790        let (settled, recovered) = self.settled(permit, "stow", "", Some(tool))?;
1791        let warnings = self.overridden(tool).into_iter().chain(recovered).collect();
1792        self.change(permit, "stow", "", (settled, warnings), |settled| {
1793            switch::stow(settled, seen)
1794        })
1795    }
1796
1797    /// What says `tool` signs in with something else than the login Pitboard moves, read from
1798    /// files as well as from this process's environment, so the app, which has no shell
1799    /// environment at all, gets the same answer as the command line.
1800    fn overridden(&self, tool: ProviderId) -> Option<Warning> {
1801        let driver = match crate::provider::of(tool) {
1802            LoginKind::Document(driver) => driver,
1803            LoginKind::Folder(_) => return None,
1804        };
1805        let names = driver.overridden_by(&self.ctx);
1806        (!names.is_empty()).then_some(Warning::AuthOverridden { tool, names })
1807    }
1808
1809    /// What a file behind `tool`'s store says, where one is there. It reads that store, so a
1810    /// read that asks nobody does not ask it.
1811    fn left_behind(&self, tool: ProviderId) -> Option<Warning> {
1812        let driver = match crate::provider::of(tool) {
1813            LoginKind::Document(driver) => driver,
1814            LoginKind::Folder(_) => return None,
1815        };
1816        let behind = driver.behind(&self.ctx)?;
1817        Some(Warning::FallbackLogin {
1818            tool,
1819            path: behind.path,
1820            held: behind.held,
1821        })
1822    }
1823
1824    /// Settles, runs the change, and records it in the audit log.
1825    ///
1826    /// `tool` is the tool whose login the change is about, where it is about one: its own
1827    /// ways of being signed in by something else are what is worth warning about, and a
1828    /// refusal that is one tool's business does not stop a change to another's.
1829    fn changing<T: Audited>(
1830        &self,
1831        permit: Permit,
1832        verb: &str,
1833        subject: &str,
1834        tool: Option<ProviderId>,
1835        run: impl FnOnce(Settled) -> Result<(T, Vec<Warning>)>,
1836    ) -> Changing<T> {
1837        let settled = self.settled_for(permit, verb, subject, tool)?;
1838        self.change(permit, verb, subject, settled, run)
1839    }
1840
1841    /// Runs a change on what is settled for it, beside what settling warned of, and records
1842    /// it in the audit log as `verb`: of the subject the change names where it names one, and
1843    /// otherwise of `subject`.
1844    fn change<T: Audited>(
1845        &self,
1846        permit: Permit,
1847        verb: &str,
1848        subject: &str,
1849        (settled, mut warnings): (Settled, Vec<Warning>),
1850        run: impl FnOnce(Settled) -> Result<(T, Vec<Warning>)>,
1851    ) -> Changing<T> {
1852        match run(settled) {
1853            Ok((value, more)) => {
1854                let subject = value.audit_subject().unwrap_or(subject);
1855                audit::record(&self.ctx, permit, verb, subject, value.audit_code());
1856                warnings.extend(more);
1857                Ok(Done { value, warnings })
1858            }
1859            Err(error) => {
1860                audit::record(&self.ctx, permit, verb, subject, error.code());
1861                Err(Failed::of(error, warnings))
1862            }
1863        }
1864    }
1865
1866    /// Settles for a change, with what to know before it runs: another way `tool` is signed
1867    /// in, a file behind its store, and an interrupted switch settling finished, as
1868    /// [`Pitboard::settled`] records it.
1869    fn settled_for(
1870        &self,
1871        permit: Permit,
1872        verb: &str,
1873        subject: &str,
1874        tool: Option<ProviderId>,
1875    ) -> std::result::Result<(Settled, Vec<Warning>), Failed> {
1876        let (settled, recovered) = self.settled(permit, verb, subject, tool)?;
1877        let mut warnings = Vec::new();
1878        if let Some(tool) = tool {
1879            warnings.extend(self.overridden(tool));
1880            warnings.extend(self.left_behind(tool));
1881        }
1882        warnings.extend(recovered);
1883        Ok((settled, warnings))
1884    }
1885
1886    /// Settles for a change, with an interrupted switch settling finished, which the audit
1887    /// log records as `recover`. A settle that failed is recorded as `verb` of `subject`, with
1888    /// its error.
1889    fn settled(
1890        &self,
1891        permit: Permit,
1892        verb: &str,
1893        subject: &str,
1894        tool: Option<ProviderId>,
1895    ) -> std::result::Result<(Settled, Option<Warning>), Failed> {
1896        let (settled, recovered) = switch::settle(&self.ctx, permit, tool).map_err(|error| {
1897            audit::record(&self.ctx, permit, verb, subject, error.code());
1898            Failed {
1899                error,
1900                warnings: Vec::new(),
1901                said_first: 0,
1902            }
1903        })?;
1904        let recovered = recovered.map(|r| {
1905            audit::record(&self.ctx, permit, "recover", &r.to, r.code());
1906            Warning::Recovered(r)
1907        });
1908        Ok((settled, recovered))
1909    }
1910}
1911
1912/// How a successful change is written in the audit log.
1913trait Audited {
1914    fn audit_code(&self) -> &'static str {
1915        "ok"
1916    }
1917
1918    /// The account it was of, where only running it could tell.
1919    fn audit_subject(&self) -> Option<&str> {
1920        None
1921    }
1922}
1923
1924impl Audited for Outcome {
1925    fn audit_code(&self) -> &'static str {
1926        match self {
1927            Outcome::Switched { .. } | Outcome::Installed { .. } => "ok",
1928            Outcome::AlreadyActive {
1929                config_updated: true,
1930                ..
1931            } => "config_updated",
1932            Outcome::AlreadyActive { .. } => "already_active",
1933        }
1934    }
1935}
1936
1937impl Audited for switch::Reclaimed {}
1938impl Audited for switch::WatchedSignIn {}
1939impl Audited for Enrolled {}
1940impl Audited for String {}
1941impl Audited for bool {}
1942impl Audited for crate::duty::warm::Range {}
1943
1944impl Audited for switch::Stowed {
1945    fn audit_code(&self) -> &'static str {
1946        self.kept.code()
1947    }
1948
1949    fn audit_subject(&self) -> Option<&str> {
1950        self.kept.account()
1951    }
1952}
1953
1954impl Audited for switch::Removed {
1955    fn audit_code(&self) -> &'static str {
1956        if self.pending > 0 {
1957            "parks_pending_removal"
1958        } else {
1959            "ok"
1960        }
1961    }
1962}
1963
1964#[cfg(test)]
1965#[allow(
1966    clippy::disallowed_methods,
1967    reason = "a test sets up its scratch files as a person or a tool would"
1968)]
1969mod tests {
1970    use super::*;
1971    use crate::switch::harness::{
1972        Machine, Tool, audit_lines, codex_machine, hold, machine, signed_in_outside, state_file,
1973    };
1974    use std::collections::BTreeMap;
1975    use std::sync::Arc;
1976
1977    type Make = fn(&str) -> Machine;
1978
1979    /// A name is refused the same way whichever tool it is for.
1980    const MACHINES: [(&str, Make); 2] = [("claude", machine), ("codex", codex_machine)];
1981
1982    #[test]
1983    #[cfg_attr(
1984        windows,
1985        ignore = "Claude Desktop is macOS only: Windows refuses its folder calls"
1986    )]
1987    fn a_read_opens_no_jar_and_lists_no_process() {
1988        let m = switch::harness::desktop_machine("reads");
1989        let pitboard = Pitboard::new(m.ctx.clone());
1990        let (jars, listings) = (m.mem.jars_asked().len(), m.mem.listings());
1991        // Claude Code's own record, of nobody, is this first read's to make.
1992        pitboard.status(true).expect("read");
1993        let before = state_file(&m);
1994        let online = pitboard.status(true).expect("read");
1995        let offline = pitboard.status_offline().expect("read");
1996        let doctor = pitboard.doctor();
1997        pitboard.statusline("{}");
1998        assert_eq!(m.mem.jars_asked().len(), jars, "no jar was opened");
1999        assert_eq!(m.mem.listings(), listings, "no process was listed");
2000        assert_eq!(state_file(&m), before, "nothing was recorded");
2001
2002        // And in the rows a change would open the jar for: 10, 15 and 6.
2003        let data = m.ctx_home().join("claude/data");
2004        let rows: [(&str, &dyn Fn()); 3] = [
2005            ("no session", &|| {
2006                crate::desktop_testing::write_stand_in_jar(&data.join("Cookies"), 24, &[]);
2007            }),
2008            ("another name", &|| {
2009                let settings = data.join("config.json");
2010                let written = std::fs::read_to_string(&settings).expect("settings");
2011                let renamed = written.replace(
2012                    switch::harness::WORK.uuid,
2013                    "0b7e2c44-2222-4d1f-8a35-91c0de77aa01",
2014                );
2015                std::fs::write(&settings, renamed).expect("renamed");
2016            }),
2017            ("no jar", &|| {
2018                std::fs::remove_file(data.join("Cookies")).expect("the jar taken away");
2019            }),
2020        ];
2021        for (row, change) in rows {
2022            change();
2023            pitboard.status(true).expect("read");
2024            pitboard.status_offline().expect("read");
2025            pitboard.doctor();
2026            pitboard.statusline("{}");
2027            assert_eq!(m.mem.jars_asked().len(), jars, "{row}: no jar was opened");
2028            assert_eq!(m.mem.listings(), listings, "{row}: no process was listed");
2029            assert_eq!(state_file(&m), before, "{row}: nothing was recorded");
2030        }
2031
2032        for report in [&online.value, &offline.value] {
2033            let row = report
2034                .rows
2035                .iter()
2036                .find(|row| row.provider == ProviderId::Desktop)
2037                .expect("a row");
2038            assert!(row.signed_in);
2039            assert_eq!(row.read_at, Some(switch::harness::NOW));
2040        }
2041        for warnings in [&online.warnings, &offline.warnings] {
2042            assert!(
2043                warnings
2044                    .iter()
2045                    .all(|w| !w.to_string().contains("Claude Desktop")
2046                        && w.code() != "in_use_unconfirmed"),
2047                "{warnings:?}"
2048            );
2049        }
2050        let codes: Vec<&str> = doctor.checks.iter().map(|c| c.code).collect();
2051        for code in [
2052            "desktop_data_folder",
2053            "desktop_settings",
2054            "desktop_layout",
2055            "desktop_login",
2056            "desktop_parked_login",
2057            "desktop_build",
2058        ] {
2059            assert!(codes.contains(&code), "{code}: {codes:?}");
2060        }
2061        assert!(
2062            doctor
2063                .checks
2064                .iter()
2065                .filter(|c| c.code.starts_with("desktop_"))
2066                .all(|c| c.level == doctor::Level::Ok),
2067            "{:?}",
2068            doctor
2069                .checks
2070                .iter()
2071                .map(|c| (c.code, &c.detail))
2072                .collect::<Vec<_>>()
2073        );
2074    }
2075
2076    // Claude's settings there and unreadable, which a read of them would trip over.
2077    #[test]
2078    #[cfg_attr(
2079        windows,
2080        ignore = "Claude Desktop is macOS only: Windows refuses its folder calls"
2081    )]
2082    fn a_mac_with_no_claude_desktop_account_reads_nothing_of_claude() {
2083        let plain = machine("no-desktop-plain");
2084        let m = machine("no-desktop");
2085        let scratch = m.ctx_home().join("claude");
2086        std::fs::create_dir_all(scratch.join("data")).expect("a data folder");
2087        std::fs::write(scratch.join("data/config.json"), "{").expect("settings");
2088        crate::host::fs::testing::deny_reading(&scratch.join("data/config.json")).expect("000");
2089        let read = |m: &Machine, ctx: Context| {
2090            let pitboard = Pitboard::new(ctx);
2091            let codes = |done: Done<status::Report>| {
2092                let warnings: Vec<&str> = done.warnings.iter().map(Warning::code).collect();
2093                (done.value.rows.len(), format!("{warnings:?}"))
2094            };
2095            let doctor: Vec<&str> = pitboard.doctor().checks.iter().map(|c| c.code).collect();
2096            assert_eq!(m.mem.listings(), 0);
2097            assert!(m.mem.jars_asked().is_empty());
2098            (
2099                codes(pitboard.status(true).expect("read")),
2100                codes(pitboard.status_offline().expect("read")),
2101                format!("{doctor:?}"),
2102            )
2103        };
2104        let with_claude = crate::fault::killing("settings.read", || {
2105            read(&m, m.ctx.clone().with_scratch_claude(scratch))
2106        })
2107        .expect("no read opened Claude's settings");
2108        assert_eq!(
2109            with_claude,
2110            read(&plain, plain.ctx.clone()),
2111            "as with no Claude at all"
2112        );
2113        assert!(!with_claude.2.contains("desktop_"), "{}", with_claude.2);
2114    }
2115
2116    // Forgetting the one account leaves a record of nobody in use, which opts nothing in.
2117    #[test]
2118    #[cfg_attr(
2119        windows,
2120        ignore = "Claude Desktop is macOS only: Windows refuses its folder calls"
2121    )]
2122    fn forgetting_the_last_claude_desktop_account_stops_every_read_of_claude() {
2123        let m = switch::harness::desktop_machine("forgot-last");
2124        let claude = m.claude();
2125        claude.opens();
2126        claude.logs_out();
2127        claude.quits();
2128        let pitboard = Pitboard::new(m.ctx.clone());
2129        pitboard.forget("desktop/work").expect("forgotten");
2130        claude.signed_in_as(&switch::harness::STRANGER);
2131        let state = state::load(&m.ctx).expect("state");
2132        assert!(state.accounts.is_empty());
2133        assert!(state.in_use(ProviderId::Desktop).is_some(), "{state:?}");
2134
2135        let (listings, jars) = (m.mem.listings(), m.mem.jars_asked().len());
2136        let said = crate::fault::killing("settings.read", || {
2137            let codes = |done: Done<status::Report>| -> Vec<&'static str> {
2138                done.warnings.iter().map(Warning::code).collect()
2139            };
2140            let doctor: Vec<&str> = pitboard
2141                .doctor()
2142                .checks
2143                .iter()
2144                .map(|c| c.code)
2145                .filter(|code| code.starts_with("desktop_"))
2146                .collect();
2147            (
2148                codes(pitboard.status(true).expect("read")),
2149                codes(pitboard.status_offline().expect("read")),
2150                doctor,
2151            )
2152        })
2153        .expect("no read opened Claude's settings");
2154        assert_eq!(said, (Vec::new(), Vec::new(), Vec::new()));
2155        assert_eq!(m.mem.listings(), listings);
2156        assert_eq!(m.mem.jars_asked().len(), jars);
2157    }
2158
2159    #[test]
2160    #[cfg_attr(
2161        windows,
2162        ignore = "Claude Desktop is macOS only: Windows refuses its folder calls"
2163    )]
2164    fn what_claudes_settings_say_since_is_a_warning_of_every_read() {
2165        let m = switch::harness::desktop_machine("standing");
2166        let pitboard = Pitboard::new(m.ctx.clone());
2167        let settings = m.ctx_home().join("claude/data/config.json");
2168        let said = |pitboard: &Pitboard| -> Vec<String> {
2169            pitboard
2170                .status_offline()
2171                .expect("read")
2172                .warnings
2173                .iter()
2174                .map(|w| format!("{}: {w}", w.code()))
2175                .collect()
2176        };
2177        assert!(said(&pitboard).is_empty());
2178
2179        let written = std::fs::read_to_string(&settings).expect("settings");
2180        std::fs::write(
2181            &settings,
2182            written.replace(
2183                switch::harness::WORK.uuid,
2184                "0b7e2c44-2222-4d1f-8a35-91c0de77aa01",
2185            ),
2186        )
2187        .expect("renamed");
2188        let warned = said(&pitboard);
2189        assert_eq!(warned.len(), 1, "{warned:?}");
2190        assert!(
2191            warned[0].starts_with(
2192                "in_use_unconfirmed: Claude's settings have named an account Pitboard has not \
2193                 enrolled since Pitboard last read `desktop/work` in use, so Claude may hold \
2194                 another login. Open Claude,"
2195            ),
2196            "{warned:?}"
2197        );
2198
2199        let emptied: String = written
2200            .lines()
2201            .map(|line| match line.split_once(": ") {
2202                Some((key, _)) if key.contains("oauth:tokenCache") => {
2203                    format!("{key}: {},\n", crate::desktop_testing::EMPTIED)
2204                }
2205                _ => format!("{line}\n"),
2206            })
2207            .collect();
2208        std::fs::write(&settings, emptied).expect("emptied");
2209        let warned = said(&pitboard);
2210        assert_eq!(
2211            warned,
2212            [
2213                "looks_signed_out: Claude looks signed out since Pitboard last read `desktop/work` \
2214              in use. The next change to a Claude Desktop account checks."
2215            ]
2216        );
2217        assert_eq!(m.mem.listings(), 1, "the enrolment's look, and no read's");
2218    }
2219
2220    #[test]
2221    #[cfg_attr(
2222        windows,
2223        ignore = "Claude Desktop is macOS only: Windows refuses its folder calls"
2224    )]
2225    fn what_stands_is_judged_from_one_read_of_claudes_settings() {
2226        let m = switch::harness::desktop_machine("one-read");
2227        let pitboard = Pitboard::new(m.ctx.clone());
2228        let state = state::load(&m.ctx).expect("state");
2229        let (warnings, reads) =
2230            crate::fault::counting("settings.read", || pitboard.standing(&state, &[]));
2231        assert!(warnings.is_empty(), "{warnings:?}");
2232        assert_eq!(reads, 1);
2233        let (known, reads) = crate::fault::counting("settings.read", || {
2234            in_use::known(&m.ctx, &state, ProviderId::Desktop)
2235        });
2236        assert_eq!((known.doubt, reads), (None, 1));
2237    }
2238
2239    #[test]
2240    fn a_folder_set_aside_is_said_with_its_account_or_with_no_name() {
2241        use state::SetAside;
2242        let said = |why, label: Option<&str>| {
2243            Warning::SetAside {
2244                tool: ProviderId::Desktop,
2245                path: "/p/parks/x".into(),
2246                label: label.map(str::to_owned),
2247                why,
2248            }
2249            .to_string()
2250        };
2251        assert_eq!(
2252            said(SetAside::Superseded, Some("desktop/home")),
2253            "Pitboard kept `desktop/home`'s earlier parked login in /p/parks/x and deleted \
2254             nothing. `pitboard doctor` lists it."
2255        );
2256        assert_eq!(
2257            said(SetAside::Superseded, None),
2258            "Pitboard kept an earlier parked login in /p/parks/x and deleted nothing. `pitboard \
2259             doctor` lists it."
2260        );
2261        assert_eq!(
2262            said(SetAside::GivenUp, Some("desktop/work")),
2263            "Pitboard moved the parts of `desktop/work`'s login left in Claude's folder into \
2264             /p/parks/x and deleted nothing. `pitboard doctor` lists them."
2265        );
2266        assert_eq!(
2267            said(SetAside::GivenUp, None),
2268            "Pitboard moved the parts of a login left in Claude's folder into /p/parks/x and \
2269             deleted nothing. `pitboard doctor` lists them."
2270        );
2271        for why in [
2272            SetAside::InTheWay,
2273            SetAside::AnotherMachine,
2274            SetAside::Orphan,
2275        ] {
2276            for label in [None, Some("desktop/work")] {
2277                assert_eq!(
2278                    said(why, label),
2279                    "Pitboard moved what it found in the way into /p/parks/x and deleted nothing."
2280                );
2281            }
2282        }
2283        let keys = |label: Option<&str>| Warning::KeysSetAside {
2284            tool: ProviderId::Desktop,
2285            path: "/p/parks/x".into(),
2286            label: label.map(str::to_owned),
2287        };
2288        assert_eq!(keys(None).code(), "set_aside");
2289        assert_eq!(
2290            keys(Some("desktop/home")).to_string(),
2291            "Claude rewrote `desktop/home`'s keys. Pitboard kept the earlier ones in /p/parks/x \
2292             and deleted nothing. `pitboard doctor` lists them."
2293        );
2294        assert_eq!(
2295            keys(None).to_string(),
2296            "Claude rewrote a login's keys. Pitboard kept the earlier ones in /p/parks/x and \
2297             deleted nothing. `pitboard doctor` lists them."
2298        );
2299    }
2300
2301    // What a change did before it refused leads, with a journal recovery, which ran before
2302    // it; what it only found follows.
2303    #[test]
2304    fn a_refused_change_counts_what_it_did_first() {
2305        let found = || Warning::LooksSignedOut {
2306            tool: ProviderId::Claude,
2307            label: "work".into(),
2308        };
2309        let refusal = || Error::AccountNotInUse {
2310            tool: ProviderId::Desktop,
2311            label: "desktop/work".into(),
2312        };
2313        let waiting = || Warning::MoveWaiting {
2314            tool: ProviderId::Desktop,
2315            label: "desktop/work".into(),
2316        };
2317        let recovered = || {
2318            Warning::Recovered(Recovered {
2319                from: "home".into(),
2320                to: "work".into(),
2321                finished: true,
2322            })
2323        };
2324        let codes = |failed: &Failed| -> Vec<&'static str> {
2325            failed.warnings.iter().map(Warning::code).collect()
2326        };
2327        let failed = Failed::of(refusal().after(vec![waiting()]), vec![found()]);
2328        assert_eq!(codes(&failed), ["move_waiting", "looks_signed_out"]);
2329        assert_eq!(failed.said_first, 1);
2330        assert_eq!(failed.error.code(), "account_not_in_use");
2331        let failed = Failed::of(refusal(), vec![found()]);
2332        assert_eq!(codes(&failed), ["looks_signed_out"]);
2333        assert_eq!(failed.said_first, 0);
2334        let failed = Failed::of(refusal(), vec![found(), recovered()]);
2335        assert_eq!(
2336            codes(&failed),
2337            ["interrupted_switch_finished", "looks_signed_out"]
2338        );
2339        assert_eq!(failed.said_first, 1);
2340        let failed = Failed::of(refusal().after(vec![waiting()]), vec![found(), recovered()]);
2341        assert_eq!(
2342            codes(&failed),
2343            [
2344                "interrupted_switch_finished",
2345                "move_waiting",
2346                "looks_signed_out"
2347            ]
2348        );
2349        assert_eq!(failed.said_first, 2);
2350    }
2351
2352    #[test]
2353    #[cfg_attr(
2354        windows,
2355        ignore = "Claude Desktop is macOS only: Windows refuses its folder calls"
2356    )]
2357    fn what_holds_claude_desktops_login_is_said() {
2358        let m = switch::harness::desktop_machine("holding");
2359        let pitboard = Pitboard::new(m.ctx.clone());
2360        let claude = m.claude();
2361        assert!(pitboard.holding(ProviderId::Desktop).is_empty());
2362        claude.opens();
2363        let holding = pitboard.holding(ProviderId::Desktop);
2364        assert_eq!(holding.len(), 1, "{holding:?}");
2365        assert_eq!(holding[0].holder.kind, "claude_desktop");
2366        assert_eq!(holding[0].pids.len(), 2, "its own program and a helper");
2367        claude.crashes();
2368        assert!(pitboard.holding(ProviderId::Desktop).is_empty());
2369        m.mem.without_a_process_list();
2370        assert!(matches!(
2371            switch::still_holding(&m.ctx, ProviderId::Desktop),
2372            switch::StillHolding::Unknown
2373        ));
2374        assert!(pitboard.holding(ProviderId::Desktop).is_empty());
2375    }
2376
2377    #[test]
2378    #[cfg_attr(
2379        windows,
2380        ignore = "Claude Desktop is macOS only: Windows refuses its folder calls"
2381    )]
2382    fn a_desktop_row_says_no_usage_and_asks_nobody() {
2383        use crate::status::Stale;
2384        let m = switch::harness::desktop_machine("rows");
2385        let now = switch::harness::NOW;
2386        let mut state = state::load(&m.ctx).expect("state");
2387        let another = |label: &str, parked, replaced_at| Account {
2388            label: label.into(),
2389            id: format!("desktop_{label}"),
2390            account_uuid: label.into(),
2391            email: String::new(),
2392            parked,
2393            last_used_at: None,
2394            replaced_at,
2395            plan: None,
2396            detail: state::Detail::Desktop {},
2397        };
2398        let park = state::Park {
2399            service: "pitboard-park-desktop_home-1".into(),
2400            kind: state::ParkKind::Folder,
2401            parked_at: now,
2402            login: "0a1b2c3d4e5f6071".into(),
2403            access_expires_at: None,
2404            refresh_expires_at: None,
2405            session_expires_at: Some(now + 20 * 86_400),
2406        };
2407        state.accounts.push(another("home", Some(park), None));
2408        state.accounts.push(another("gone", None, Some(now - 5)));
2409        state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
2410
2411        let pitboard = Pitboard::new(m.ctx.clone());
2412        for report in [
2413            pitboard.status(true).expect("read").value,
2414            pitboard.status_offline().expect("read").value,
2415        ] {
2416            let row = |label: &str| {
2417                report
2418                    .rows
2419                    .iter()
2420                    .find(|row| {
2421                        row.provider == ProviderId::Desktop && row.label.as_deref() == Some(label)
2422                    })
2423                    .unwrap_or_else(|| panic!("a row for {label}"))
2424            };
2425            let (work, home, gone) = (row("work"), row("home"), row("gone"));
2426            assert!(work.signed_in && !home.signed_in && !gone.signed_in);
2427            assert_eq!(work.read_at, Some(now));
2428            assert_eq!((home.read_at, gone.read_at), (None, None));
2429            for row in [work, home] {
2430                assert_eq!(row.stale, Some(Stale::NoUsage));
2431                assert!(row.usage.is_none() && row.plan.is_none() && row.lasts(now).is_none());
2432                assert_eq!(row.email, "");
2433                assert_eq!(row.organization_uuid, None);
2434            }
2435            assert!(home.switchable(now), "no deadline is known");
2436            assert_eq!(gone.stale, Some(Stale::LoginReplaced));
2437            assert_eq!(
2438                gone.stale
2439                    .and_then(|stale| stale.explanation_for(gone.provider)),
2440                Some("its login was signed out or replaced in Claude; sign in again")
2441            );
2442            assert_eq!(
2443                Stale::NoUsage.explanation_for(ProviderId::Desktop),
2444                Some("Pitboard reads no usage for Claude Desktop")
2445            );
2446            assert_eq!(
2447                Stale::Unreachable.explanation_for(ProviderId::Desktop),
2448                None
2449            );
2450        }
2451        assert!(m.api.asked().is_empty(), "{:?}", m.api.asked());
2452        assert!(
2453            m.mem
2454                .trail()
2455                .iter()
2456                .all(|touch| !touch.service.starts_with("pitboard-park-desktop_")),
2457            "{:?}",
2458            m.mem.trail()
2459        );
2460        assert!(
2461            m.mem.jars_asked().len() == 1 && m.mem.listings() == 1,
2462            "the enrolment's own"
2463        );
2464    }
2465
2466    // The app took names with a space before a new name had to be one word.
2467    #[test]
2468    fn an_account_named_before_names_were_one_word_keeps_its_name() {
2469        let m = machine("one-word");
2470        let mut state = state::load(&m.ctx).expect("state");
2471        state
2472            .accounts
2473            .iter_mut()
2474            .find(|a| a.label == "there")
2475            .expect("there")
2476            .label = "over there".into();
2477        state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
2478        let pitboard = Pitboard::new(m.ctx.clone());
2479
2480        assert_eq!(
2481            pitboard.account_to_enroll("over there").map(|a| a.label),
2482            Some("over there".to_owned())
2483        );
2484        let refused = pitboard
2485            .enroll_current("new one")
2486            .expect_err("a new name of two words");
2487        assert_eq!(refused.error.code(), "usage", "{}", refused.error);
2488        assert_eq!(
2489            audit_lines(&m, "enroll"),
2490            [("new one".to_owned(), "label_unusable".to_owned())]
2491        );
2492    }
2493
2494    // An existing account's name keeps it, and a rename gives a new one by the rules all the same.
2495    #[test]
2496    fn a_rename_to_a_name_another_tool_has_follows_the_rules_for_a_new_one() {
2497        let m = codex_machine("rename-one-word");
2498        let mut state = state::load(&m.ctx).expect("state");
2499        state
2500            .accounts
2501            .push(switch::harness::account("my work", "elsewhere", None));
2502        state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
2503        let pitboard = Pitboard::new(m.ctx.clone());
2504
2505        let refused = pitboard
2506            .rename("codex/there", "my work")
2507            .expect_err("a name of two words");
2508        assert_eq!(refused.error.code(), "usage", "{}", refused.error);
2509        assert!(
2510            state::load(&m.ctx)
2511                .expect("state")
2512                .get(&m.key("there"))
2513                .is_some(),
2514            "the Codex account keeps its name"
2515        );
2516    }
2517
2518    type Refuse = fn(&Pitboard, ProviderId) -> Option<Failed>;
2519
2520    /// Every way a change is refused over the name it was given, with the verb the audit
2521    /// log records it under, the code it is refused with and the one the log records: a
2522    /// name nobody enrolled, a name no account could have, whether the account signed in
2523    /// now is being enrolled or one is being signed in to, and a new name that would move
2524    /// an account to another tool.
2525    const REFUSALS: [(&str, &str, &str, &str, Refuse); 4] = [
2526        (
2527            "use",
2528            "use",
2529            "account_unknown",
2530            "account_unknown",
2531            |p, _| p.switch_to("nobody").err(),
2532        ),
2533        ("enroll", "enroll", "usage", "label_unusable", |p, _| {
2534            p.enroll_current("codx/work").err()
2535        }),
2536        ("sign-in", "enroll", "usage", "label_unusable", |p, _| {
2537            p.sign_in("codx/work").err()
2538        }),
2539        ("rename", "rename", "usage", "usage", |p, tool| {
2540            let other = if tool == ProviderId::Claude {
2541                ProviderId::Codex
2542            } else {
2543                ProviderId::Claude
2544            };
2545            p.rename(
2546                &Key::new(tool, "here").qualified(),
2547                &Key::new(other, "moved").qualified(),
2548            )
2549            .err()
2550        }),
2551    ];
2552
2553    /// A switch from `here` to `there` killed after parking `here` and before installing
2554    /// `there`, so its record is all that says it happened.
2555    fn interrupted(make: Make, name: &str) -> Machine {
2556        let m = make(name);
2557        let settled = switch::settle(&m.ctx, Permit::for_a_test(), None)
2558            .expect("nothing to recover yet")
2559            .0;
2560        let died = crate::fault::killing("switch.park_recorded", || {
2561            switch::switch(settled, &m.key("there"))
2562        });
2563        assert_eq!(died.unwrap_err(), "switch.park_recorded");
2564        assert!(switch::interrupted(&m.ctx));
2565        m
2566    }
2567
2568    /// The same switch, which the next change cannot finish: nothing is signed in to the
2569    /// tool any more, so nothing says which side of the switch won. Told apart without
2570    /// asking anyone, for either tool.
2571    fn stuck(make: Make, name: &str) -> Machine {
2572        let m = interrupted(make, name);
2573        m.fault_live(crate::store::memory::Fault::Vanish);
2574        m
2575    }
2576
2577    /// The switch interrupted on a Claude Code machine, which the next change cannot finish
2578    /// either, though only Anthropic could tell: Claude Code has since renewed the login, so
2579    /// it matches neither side the record kept, and its session has expired, so Anthropic
2580    /// will not say whose it is. An access token the scripted service does not know is one
2581    /// it refuses.
2582    fn stuck_unless_asked(name: &str) -> Machine {
2583        let m = interrupted(machine, name);
2584        m.sign_in(&switch::harness::document("renewed-since"));
2585        m
2586    }
2587
2588    /// The warnings a read gives, by code and words.
2589    fn said(read: &Done<status::Report>) -> Vec<(&'static str, String)> {
2590        read.warnings
2591            .iter()
2592            .map(|w| (w.code(), w.to_string()))
2593            .collect()
2594    }
2595
2596    /// Everything a read must leave as it found it: Pitboard's files but the records a
2597    /// live read keeps by design (what it measured and when to ask again), every parked
2598    /// login and the live one.
2599    type Untouched = (
2600        BTreeMap<String, Vec<u8>>,
2601        Vec<(String, Option<String>)>,
2602        Option<serde_json::Value>,
2603    );
2604
2605    fn untouched(m: &Machine) -> Untouched {
2606        let kept_by_a_read = ["usage.json", "usage.lock", "asking.json"];
2607        let mut files = files(m);
2608        files.retain(|path, _| !kept_by_a_read.iter().any(|kept| path.ends_with(kept)));
2609        let vault = m.mem.vault();
2610        let parked = vault
2611            .services()
2612            .into_iter()
2613            .map(|service| {
2614                let held = vault.peek(&service);
2615                (service, held)
2616            })
2617            .collect();
2618        (files, parked, m.live())
2619    }
2620
2621    /// A switch says when Claude Code keeps another login in its fallback file, which a
2622    /// session that cannot read the keychain signs in with and no switch reaches. Codex has
2623    /// no store behind the one in use, so nothing is said of it.
2624    #[test]
2625    fn a_switch_says_a_login_is_left_where_it_does_not_reach() {
2626        let m = machine("switch-fallback-login");
2627        let left = plant_behind(
2628            &m,
2629            &switch::harness::document("left-by-a-sign-in").to_string(),
2630        );
2631        let switched = Pitboard::new(m.ctx.clone())
2632            .switch_to("there")
2633            .expect("a switch");
2634        let said: Vec<String> = switched
2635            .warnings
2636            .iter()
2637            .filter(|w| w.code() == "fallback_login")
2638            .map(ToString::to_string)
2639            .collect();
2640        assert_eq!(said.len(), 1, "{:?}", switched.warnings);
2641        assert!(
2642            said[0].starts_with(&left.display().to_string()),
2643            "{}",
2644            said[0]
2645        );
2646
2647        for (tool, make) in MACHINES {
2648            let m = make(&format!("switch-nothing-left-{tool}"));
2649            let switched = Pitboard::new(m.ctx.clone())
2650                .switch_to(&m.key("there").typed())
2651                .expect("a switch");
2652            assert!(
2653                switched
2654                    .warnings
2655                    .iter()
2656                    .all(|w| w.code() != "fallback_login"),
2657                "{tool}: {:?}",
2658                switched.warnings
2659            );
2660        }
2661    }
2662
2663    /// Plants `contents` in Claude Code's `.credentials.json` behind the keychain, as a
2664    /// sign-in where the keychain could not be read leaves it, and gives back its path.
2665    fn plant_behind(m: &Machine, contents: &str) -> std::path::PathBuf {
2666        let left = crate::provider::claude::live::credential_file(&m.ctx);
2667        m.mem.file_at(left.clone()).plant(
2668            &crate::provider::claude::paths::live_service(&m.ctx),
2669            contents,
2670        );
2671        left
2672    }
2673
2674    /// While a file sits behind the keychain, running sessions keep their account after a
2675    /// switch until their login is next renewed, so a read says it is there, as a change
2676    /// does, and not only somebody about to switch hears of it. A read that asks nobody
2677    /// reads no keychain, and says nothing of what is behind one. Codex has no store behind
2678    /// its own.
2679    #[test]
2680    fn a_read_says_a_file_is_behind_the_keychain() {
2681        let m = machine("read-fallback-login");
2682        let left = plant_behind(
2683            &m,
2684            &switch::harness::document("left-by-a-sign-in").to_string(),
2685        );
2686        let pitboard = Pitboard::new(m.ctx.clone());
2687
2688        let read = pitboard.status(false).expect("a read");
2689        let said: Vec<String> = read
2690            .warnings
2691            .iter()
2692            .filter(|w| w.code() == "fallback_login")
2693            .map(ToString::to_string)
2694            .collect();
2695        assert_eq!(said.len(), 1, "{:?}", read.warnings);
2696        assert!(
2697            said[0].starts_with(&format!(
2698                "{} holds another Claude Code login",
2699                left.display()
2700            )),
2701            "{}",
2702            said[0]
2703        );
2704        assert!(
2705            said[0].contains(
2706                "sessions already running at a switch keep the account they are on until \
2707                 their login is next renewed, or until they are started again"
2708            ),
2709            "{}",
2710            said[0]
2711        );
2712        assert!(
2713            said[0].ends_with(
2714                "`pitboard stow` keeps that login for its account, where Pitboard holds no \
2715                 other it can switch to, then deletes the file."
2716            ),
2717            "{}",
2718            said[0]
2719        );
2720        let offline = pitboard.status_offline().expect("a read of what is known");
2721        assert!(
2722            offline
2723                .warnings
2724                .iter()
2725                .all(|w| w.code() != "fallback_login"),
2726            "{:?}",
2727            offline.warnings
2728        );
2729
2730        for (tool, make) in MACHINES {
2731            let m = make(&format!("read-nothing-left-{tool}"));
2732            let read = Pitboard::new(m.ctx.clone()).status(false).expect("a read");
2733            assert!(
2734                read.warnings.iter().all(|w| w.code() != "fallback_login"),
2735                "{tool}: {:?}",
2736                read.warnings
2737            );
2738        }
2739    }
2740
2741    /// A running session watches the file by its being there, whatever it holds, so one
2742    /// with no login in it holds sessions to their account all the same, and is said too:
2743    /// in words that say so, and with what lets them follow.
2744    #[test]
2745    fn an_empty_file_behind_the_keychain_is_said_too() {
2746        let m = machine("read-empty-fallback");
2747        let left = plant_behind(&m, "{}");
2748        let pitboard = Pitboard::new(m.ctx.clone());
2749
2750        for warnings in [
2751            pitboard.status(false).expect("a read").warnings,
2752            pitboard.switch_to("there").expect("a switch").warnings,
2753        ] {
2754            let said: Vec<String> = warnings
2755                .iter()
2756                .filter(|w| w.code() == "fallback_login")
2757                .map(ToString::to_string)
2758                .collect();
2759            assert_eq!(
2760                said,
2761                [format!(
2762                    "{path} is there with no Claude Code login in it. While it is, Claude \
2763                     Code sessions already running at a switch keep the account they are on \
2764                     until their login is next renewed, or until they are started again. \
2765                     `pitboard stow` deletes it, which lets them follow a switch.",
2766                    path = left.display()
2767                )],
2768                "{warnings:?}"
2769            );
2770        }
2771    }
2772
2773    /// A running session looks at the file and never reads it to decide, so one Pitboard
2774    /// cannot read, as one that is not text or that this user may not read, holds sessions
2775    /// to their account all the same. It is said, in words that do not guess what it holds.
2776    #[test]
2777    fn a_file_behind_the_keychain_that_cannot_be_read_is_said_too() {
2778        let m = machine("read-unreadable-fallback");
2779        let left = plant_behind(&m, "{}");
2780        m.mem.file_at(left.clone()).fault(
2781            &crate::provider::claude::paths::live_service(&m.ctx),
2782            crate::store::memory::Fault::UnreadableContents(
2783                "stream did not contain valid UTF-8".into(),
2784            ),
2785        );
2786        let pitboard = Pitboard::new(m.ctx.clone());
2787
2788        for warnings in [
2789            pitboard.status(false).expect("a read").warnings,
2790            pitboard.switch_to("there").expect("a switch").warnings,
2791        ] {
2792            let said: Vec<String> = warnings
2793                .iter()
2794                .filter(|w| w.code() == "fallback_login")
2795                .map(ToString::to_string)
2796                .collect();
2797            assert_eq!(
2798                said,
2799                [format!(
2800                    "{} is there, and Pitboard could not read it. While it is, Claude Code \
2801                     sessions already running at a switch keep the account they are on until \
2802                     their login is next renewed, or until they are started again. `pitboard \
2803                     doctor` says why, and what to do.",
2804                    left.display()
2805                )],
2806                "{warnings:?}"
2807            );
2808        }
2809    }
2810
2811    /// A switch nothing can finish was said only by a change refused over it. The app looks
2812    /// for it in what a read says, so it never offered to give up on one, and `pitboard
2813    /// status` never said it at all. A read says it now, in the words of the refusal, and
2814    /// changes nothing: the record is kept for whatever finishes it or gives up on it.
2815    #[test]
2816    fn a_read_says_an_interrupted_switch_it_cannot_finish_is_waiting() {
2817        for (tool, make) in MACHINES {
2818            let m = stuck(make, &format!("read-stuck-{tool}"));
2819            let pitboard = Pitboard::new(m.ctx.clone());
2820            let before = untouched(&m);
2821
2822            let read = pitboard
2823                .status(false)
2824                .expect("a read is not refused over it");
2825
2826            assert_eq!(untouched(&m), before, "{tool}: a read changes nothing");
2827            assert!(switch::interrupted(&m.ctx), "{tool}: the record is kept");
2828            let refused = pitboard
2829                .switch_to(&m.key("there").typed())
2830                .expect_err("every change stops at it");
2831            assert_eq!(refused.error.code(), "recovery_undetermined", "{tool}");
2832            assert_eq!(
2833                said(&read),
2834                [("recovery_undetermined", refused.error.to_string())],
2835                "{tool}: said in the refusal's own words"
2836            );
2837        }
2838    }
2839
2840    /// Where only the service can say whose the live login is, a read asks it, as the next
2841    /// change would.
2842    #[test]
2843    fn a_read_asks_whose_the_login_is_where_only_the_service_can_say() {
2844        let m = stuck_unless_asked("read-stuck-asked");
2845        let pitboard = Pitboard::new(m.ctx.clone());
2846        let before = untouched(&m);
2847
2848        let read = pitboard.status(false).expect("a read");
2849
2850        assert_eq!(untouched(&m), before, "a read changes nothing");
2851        let refused = pitboard.switch_to("there").expect_err("it stops at it");
2852        assert_eq!(
2853            said(&read),
2854            [("recovery_undetermined", refused.error.to_string())]
2855        );
2856        // Nothing tried to renew the login here, so nothing says it cannot be renewed.
2857        let said = refused.error.to_string();
2858        assert!(said.contains("session has expired"), "{said}");
2859        assert!(!said.contains("sign in"), "{said}");
2860    }
2861
2862    /// A switch the next change finishes by itself is not one anybody has to do anything
2863    /// about. The rows already say who is signed in, from the tool's login and not from
2864    /// Pitboard's record, and the change that finishes it says what it found.
2865    #[test]
2866    fn a_read_says_nothing_of_a_switch_the_next_change_finishes() {
2867        for (tool, make) in MACHINES {
2868            let m = interrupted(make, &format!("read-settles-{tool}"));
2869            let before = untouched(&m);
2870
2871            let read = Pitboard::new(m.ctx.clone()).status(false).expect("a read");
2872
2873            assert_eq!(said(&read), [], "{tool}");
2874            assert_eq!(untouched(&m), before, "{tool}: a read finishes nothing");
2875            assert!(switch::interrupted(&m.ctx), "{tool}");
2876        }
2877    }
2878
2879    /// The read never says a switch is waiting that the next change would not stop at for
2880    /// that reason, and never offers to give up on one that giving up cannot reach. Under a
2881    /// custom Claude Code endpoint every change is refused over the endpoint before it comes
2882    /// to a Claude Code switch, and giving up on one is refused too. A Codex switch is
2883    /// settled under one as anywhere else, and given up on as anywhere else: giving up
2884    /// refused it, so the read offered a way out that could not be taken.
2885    #[test]
2886    fn a_read_says_a_switch_is_waiting_only_where_a_change_would_stop_at_it() {
2887        for (tool, make) in MACHINES {
2888            let m = stuck(make, &format!("read-stuck-custom-{tool}"));
2889            let mut ctx = m.ctx.clone();
2890            ctx.custom_oauth = true;
2891            let pitboard = Pitboard::new(ctx);
2892
2893            let read = pitboard.status(false).expect("a read");
2894            let known = pitboard.status_offline().expect("a read");
2895            let refused = pitboard
2896                .switch_to(&m.key("there").typed())
2897                .expect_err("refused");
2898
2899            let codes: Vec<&str> = read.warnings.iter().map(Warning::code).collect();
2900            let expected: &[&str] = match Tool::of(m.which) {
2901                Tool::Claude => &[],
2902                Tool::Codex => &["recovery_undetermined"],
2903            };
2904            assert_eq!(codes, expected, "{tool}: {}", refused.error);
2905            let codes: Vec<&str> = known.warnings.iter().map(Warning::code).collect();
2906            assert_eq!(codes, expected, "{tool}: the read that asks nobody too");
2907            assert_eq!(
2908                refused.error.code() == "recovery_undetermined",
2909                !expected.is_empty(),
2910                "{tool}"
2911            );
2912
2913            let gave_up = pitboard.abandon_recovery();
2914            match Tool::of(m.which) {
2915                Tool::Claude => assert_eq!(
2916                    gave_up.expect_err("refused").code(),
2917                    "custom_oauth_endpoint",
2918                    "{tool}"
2919                ),
2920                Tool::Codex => {
2921                    assert!(gave_up.expect("given up").switch.is_some(), "{tool}");
2922                    assert!(!switch::interrupted(&m.ctx), "{tool}: nothing waits");
2923                }
2924            }
2925        }
2926    }
2927
2928    /// Every read says it, the one that asks nobody too: what an app reads each time the
2929    /// account index moves, and what `pitboard status --offline` prints. It sends no request,
2930    /// so it says it wherever the record and the logins tell it without one, in the words of
2931    /// the refusal, and changes nothing. Where only Anthropic could say whose the login is,
2932    /// it does not say what it cannot know.
2933    #[test]
2934    fn a_read_that_asks_nobody_says_a_switch_is_waiting_wherever_it_can_tell() {
2935        for (tool, make) in MACHINES {
2936            let m = stuck(make, &format!("known-stuck-{tool}"));
2937            let pitboard = Pitboard::new(m.ctx.clone());
2938            let (before, asked) = (untouched(&m), m.api.calls());
2939
2940            let read = pitboard.status_offline().expect("a read");
2941
2942            assert_eq!(untouched(&m), before, "{tool}: a read changes nothing");
2943            assert_eq!(m.api.calls(), asked, "{tool}: and asks nobody");
2944            assert!(switch::interrupted(&m.ctx), "{tool}: the record is kept");
2945            let refused = pitboard
2946                .switch_to(&m.key("there").typed())
2947                .expect_err("every change stops at it");
2948            assert_eq!(
2949                said(&read),
2950                [("recovery_undetermined", refused.error.to_string())],
2951                "{tool}"
2952            );
2953        }
2954
2955        let m = stuck_unless_asked("known-stuck-unasked");
2956        let asked = m.api.calls();
2957        let read = Pitboard::new(m.ctx.clone())
2958            .status_offline()
2959            .expect("a read");
2960        assert_eq!(m.api.calls(), asked, "asks nobody");
2961        assert_eq!(said(&read), [], "only Anthropic could tell");
2962    }
2963
2964    /// Claude Code 2.1.294 stamps its usage cache with the account its config names, and
2965    /// asks with the login its session holds, which can still be the account switched away
2966    /// from. Four of five caches captured on one machine were another login's numbers, so
2967    /// no read takes a reading from it, whether or not Anthropic answered this time.
2968    #[test]
2969    fn what_claude_code_cached_is_never_an_accounts_reading() {
2970        use crate::switch::harness::{NOW, cache_usage, usage_answer};
2971        let m = machine("cached-usage");
2972        readings::answered(
2973            &m.ctx,
2974            Permit::for_a_test(),
2975            &[(
2976                "here".into(),
2977                crate::usage::from_usage_object(&usage_answer(10.0), NOW),
2978            )],
2979        );
2980        cache_usage(&m, usage_answer(100.0));
2981        let pitboard = Pitboard::new(m.ctx.clone());
2982        for (how, read) in [
2983            ("offline", pitboard.status_offline()),
2984            ("online", pitboard.status(false)),
2985        ] {
2986            let read = read.expect("a read");
2987            let here = read
2988                .value
2989                .rows
2990                .iter()
2991                .find(|row| row.label.as_deref() == Some("here"))
2992                .expect("here's row");
2993            let shares: Vec<f64> = here
2994                .usage
2995                .iter()
2996                .flat_map(|usage| &usage.windows)
2997                .map(|window| window.percent)
2998                .collect();
2999            assert_eq!(shares, [10.0], "{how}");
3000        }
3001    }
3002
3003    /// A login Anthropic has named is known by its refresh token's fingerprint from then on,
3004    /// so a read asks whose it is only once the login has changed. It asked on every read,
3005    /// one round trip more each time for an answer it already had.
3006    #[test]
3007    fn a_login_already_identified_is_not_asked_about_again() {
3008        use crate::api::scripted::Asked;
3009        let m = machine("read-known-login");
3010
3011        Pitboard::new(m.ctx.clone()).status(false).expect("a read");
3012
3013        let asked = m.api.asked();
3014        assert!(
3015            !asked.contains(&Asked::Owner("access-here-refresh".into())),
3016            "{asked:?}"
3017        );
3018        assert!(
3019            asked.contains(&Asked::Usage("access-here-refresh".into())),
3020            "what it has left is still asked: {asked:?}"
3021        );
3022    }
3023
3024    /// A sign-in outside Pitboard replaced the only login of the account in use. The first
3025    /// read records it and the activity log says so once; every read after, the one that
3026    /// asks nobody too, says it until that account is signed in again or forgotten, and its
3027    /// row says why it has nothing parked. It read as `nothing parked`, and nothing said what
3028    /// had happened.
3029    #[test]
3030    fn a_sign_in_outside_pitboard_is_said_recorded_and_shown_on_its_row() {
3031        use crate::switch::harness::NOW;
3032        let m = signed_in_outside("read-signed-in-outside");
3033        let pitboard = Pitboard::new(m.ctx.clone());
3034        let replaced = "`here`'s login was replaced by a sign-in outside Pitboard: Claude Code \
3035                        now has `elsewhere`'s login stored, and Pitboard holds no login for \
3036                        `here`. Run `pitboard enroll here --sign-in` to sign in to it again.";
3037        let row = |read: &Done<status::Report>, label: &str| {
3038            read.value
3039                .rows
3040                .iter()
3041                .find(|row| row.label.as_deref() == Some(label))
3042                .map(|row| (row.signed_in, row.stale))
3043        };
3044
3045        let read = pitboard.status(false).expect("a read");
3046
3047        let state = state::load(&m.ctx).expect("state");
3048        assert_eq!(
3049            state
3050                .account_in_use(ProviderId::Claude)
3051                .map(|account| account.label.as_str()),
3052            Some("elsewhere")
3053        );
3054        let account = |label: &str| state.get(&m.key(label)).expect("enrolled");
3055        assert_eq!(account("here").replaced_at, Some(NOW));
3056        assert_eq!(account("elsewhere").last_used_at, Some(NOW));
3057        assert!(account("there").parked.is_some(), "`there` keeps its park");
3058        assert_eq!(
3059            row(&read, "elsewhere").map(|(signed_in, _)| signed_in),
3060            Some(true)
3061        );
3062        assert_eq!(
3063            row(&read, "here"),
3064            Some((false, Some(status::Stale::LoginReplaced)))
3065        );
3066        assert_eq!(said(&read), [("login_replaced", replaced.to_string())]);
3067        let noticed = [
3068            ("elsewhere".to_string(), "signed_in_outside".to_string()),
3069            ("here".to_string(), "login_replaced".to_string()),
3070        ];
3071        assert_eq!(audit_lines(&m, "in-use"), noticed);
3072
3073        let recorded = state_file(&m);
3074        let again = pitboard.status(false).expect("a second read");
3075        assert_eq!(said(&again), [("login_replaced", replaced.to_string())]);
3076        assert_eq!(audit_lines(&m, "in-use"), noticed, "recorded once");
3077        assert_eq!(state_file(&m), recorded, "and written once");
3078
3079        let offline = pitboard.status_offline().expect("a read of what is known");
3080        assert_eq!(said(&offline), [("login_replaced", replaced.to_string())]);
3081    }
3082
3083    /// What a replaced login is said to have been replaced with is what the record holds of
3084    /// the store: an account nobody enrolled, by its email, no login at all, or nothing where
3085    /// nothing is recorded of the store.
3086    #[test]
3087    fn a_replaced_login_says_what_the_store_holds_instead_as_far_as_it_is_known() {
3088        use crate::in_use::InUse;
3089        use crate::switch::harness::{account, owner};
3090        let mut state = state::State::default();
3091        state.accounts.push(account("here", "here", None));
3092        state.accounts[0].replaced_at = Some(1);
3093        let said = |state: &state::State| -> Vec<String> {
3094            replaced(state).iter().map(ToString::to_string).collect()
3095        };
3096        let then = "and Pitboard holds no login for `here`. Run `pitboard enroll here \
3097                    --sign-in` to sign in to it again.";
3098
3099        let somebody = InUse {
3100            owner: Some(owner("new")),
3101            login: "new-login".into(),
3102            known_at: 1,
3103            named: None,
3104        };
3105        state
3106            .in_use
3107            .insert(ProviderId::Claude.code().into(), somebody);
3108        assert_eq!(
3109            said(&state),
3110            [format!(
3111                "`here`'s login was replaced by a sign-in outside Pitboard: Claude Code now has \
3112                 new@example.com's login stored, {then}"
3113            )]
3114        );
3115
3116        state.in_use.insert(
3117            ProviderId::Claude.code().into(),
3118            switch::identify::nobody(None, 1).expect("a store holding no login"),
3119        );
3120        assert_eq!(
3121            said(&state),
3122            [format!(
3123                "`here`'s login was replaced by a sign-in outside Pitboard: Claude Code now has \
3124                 no login stored, {then}"
3125            )]
3126        );
3127
3128        state.in_use.clear();
3129        assert_eq!(
3130            said(&state),
3131            [format!(
3132                "`here`'s login was replaced by a sign-in outside Pitboard, {then}"
3133            )]
3134        );
3135
3136        state.accounts[0].parked = Some(state::Park {
3137            service: "pitboard-park-here-1".into(),
3138            parked_at: 1,
3139            kind: state::ParkKind::Vault,
3140            login: "f".into(),
3141            access_expires_at: None,
3142            refresh_expires_at: None,
3143            session_expires_at: None,
3144        });
3145        assert!(
3146            said(&state).is_empty(),
3147            "a login parked for it is a way back"
3148        );
3149    }
3150
3151    /// A read that finds what the record already says writes nothing, so several front ends
3152    /// reading one machine never take turns rewriting its account index.
3153    #[test]
3154    fn a_read_that_finds_nothing_new_writes_nothing() {
3155        let m = machine("read-nothing-new");
3156        let pitboard = Pitboard::new(m.ctx.clone());
3157        let before = state_file(&m);
3158
3159        pitboard.status(false).expect("a read");
3160        pitboard.status(false).expect("another read");
3161
3162        assert_eq!(state_file(&m), before);
3163        assert!(audit_lines(&m, "in-use").is_empty());
3164    }
3165
3166    /// Each `CLAUDE_CONFIG_DIR` has a login of its own stored, and Pitboard keeps whose for
3167    /// each. A read under another one leaves this one's record, so a sign-in outside Pitboard
3168    /// here is still found by the next read here, and reads taking turns under the two write
3169    /// nothing once each has its record.
3170    #[test]
3171    fn a_read_under_another_claude_config_dir_keeps_this_ones_record() {
3172        use crate::switch::harness::{NOW, account, document, owner};
3173        let m = machine("read-two-slots");
3174        m.api
3175            .owned_by("access-elsewhere-refresh", owner("elsewhere"));
3176        m.api.owned_by("access-there-other", owner("there"));
3177        let mut state = state::load(&m.ctx).expect("state");
3178        state.accounts.push(account("elsewhere", "elsewhere", None));
3179        state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
3180        let other = m
3181            .ctx
3182            .clone()
3183            .with_claude_config_dir(m.ctx_home().join("other").to_string_lossy().into_owned());
3184        m.mem.live().plant(
3185            &crate::provider::claude::paths::live_service(&other),
3186            &document("there-other").to_string(),
3187        );
3188
3189        Pitboard::new(other.clone())
3190            .status(false)
3191            .expect("a read under the other");
3192        m.sign_in(&document("elsewhere-refresh"));
3193        Pitboard::new(m.ctx.clone())
3194            .status(false)
3195            .expect("a read here");
3196
3197        let state = state::load(&m.ctx).expect("state");
3198        assert_eq!(
3199            state.get(&m.key("here")).expect("enrolled").replaced_at,
3200            Some(NOW)
3201        );
3202        assert_eq!(
3203            audit_lines(&m, "in-use"),
3204            [
3205                ("elsewhere".to_string(), "signed_in_outside".to_string()),
3206                ("here".to_string(), "login_replaced".to_string()),
3207            ]
3208        );
3209
3210        let recorded = state_file(&m);
3211        for ctx in [&other, &m.ctx, &other] {
3212            Pitboard::new(ctx.clone()).status(false).expect("a read");
3213        }
3214        assert_eq!(state_file(&m), recorded, "reads taking turns write nothing");
3215    }
3216
3217    /// `here` is signed in under two `CLAUDE_CONFIG_DIR`s, and a sign-in outside Pitboard
3218    /// replaces its login under one. Its login under the other is still stored, so no read
3219    /// says it was replaced, least of all one under the slot where it is in use. Once the
3220    /// other slot's is replaced too, it is.
3221    #[test]
3222    fn a_login_still_stored_under_another_claude_config_dir_is_not_said_replaced() {
3223        use crate::switch::harness::{NOW, account, document, owner};
3224        let m = machine("read-two-slots-one-account");
3225        m.api.owned_by("access-here-other", owner("here"));
3226        m.api
3227            .owned_by("access-elsewhere-refresh", owner("elsewhere"));
3228        let mut state = state::load(&m.ctx).expect("state");
3229        state.accounts.push(account("elsewhere", "elsewhere", None));
3230        state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
3231        let other = m
3232            .ctx
3233            .clone()
3234            .with_claude_config_dir(m.ctx_home().join("other").to_string_lossy().into_owned());
3235        let plant_there = |login: &str| {
3236            m.mem.live().plant(
3237                &crate::provider::claude::paths::live_service(&other),
3238                &document(login).to_string(),
3239            );
3240        };
3241        let replaced = |read: &Done<status::Report>| {
3242            said(read)
3243                .into_iter()
3244                .filter(|(code, _)| *code == "login_replaced")
3245                .count()
3246        };
3247
3248        plant_there("here-other");
3249        Pitboard::new(other.clone())
3250            .status(false)
3251            .expect("a read under the other");
3252        plant_there("elsewhere-refresh");
3253        let there = Pitboard::new(other.clone())
3254            .status(false)
3255            .expect("a read under the other after a sign-in there");
3256        let here = Pitboard::new(m.ctx.clone())
3257            .status(false)
3258            .expect("a read here");
3259
3260        assert_eq!((replaced(&there), replaced(&here)), (0, 0));
3261        assert_eq!(
3262            state::load(&m.ctx)
3263                .expect("state")
3264                .get(&m.key("here"))
3265                .expect("enrolled")
3266                .replaced_at,
3267            None
3268        );
3269        assert_eq!(
3270            audit_lines(&m, "in-use"),
3271            [("elsewhere".to_string(), "signed_in_outside".to_string())]
3272        );
3273
3274        m.sign_in(&document("elsewhere-refresh"));
3275        let gone = Pitboard::new(m.ctx.clone())
3276            .status(false)
3277            .expect("a read here after a sign-in here");
3278
3279        assert_eq!(replaced(&gone), 1);
3280        assert_eq!(
3281            state::load(&m.ctx)
3282                .expect("state")
3283                .get(&m.key("here"))
3284                .expect("enrolled")
3285                .replaced_at,
3286            Some(NOW)
3287        );
3288        assert_eq!(
3289            audit_lines(&m, "in-use")[1..],
3290            [
3291                ("elsewhere".to_string(), "signed_in_outside".to_string()),
3292                ("here".to_string(), "login_replaced".to_string()),
3293            ]
3294        );
3295    }
3296
3297    /// Claude Code's config names `here` and Pitboard finds no login where it reads: the
3298    /// login is somewhere Pitboard does not look, which is not a sign-out. A read records
3299    /// nothing, and says of no account that its login was replaced.
3300    #[test]
3301    fn a_read_that_cannot_find_the_login_claude_code_names_records_nothing() {
3302        let m = machine("read-login-elsewhere");
3303        m.mem.live().delete_everything();
3304        let before = state_file(&m);
3305
3306        let read = Pitboard::new(m.ctx.clone()).status(false).expect("a read");
3307
3308        assert_eq!(state_file(&m), before);
3309        assert!(audit_lines(&m, "in-use").is_empty());
3310        let said = said(&read);
3311        assert!(
3312            !said.iter().any(|(code, _)| *code == "login_replaced"),
3313            "{said:?}"
3314        );
3315    }
3316
3317    /// The labels of the rows a read has in use.
3318    fn in_use(read: &Done<status::Report>) -> Vec<&str> {
3319        read.value
3320            .rows
3321            .iter()
3322            .filter(|row| row.signed_in)
3323            .filter_map(|row| row.label.as_deref())
3324            .collect()
3325    }
3326
3327    /// Another Claude Code process that starts or signs in on another login can write that
3328    /// login's account into Claude Code's config, and leaves the keychain's as it was. A read
3329    /// that asks nobody takes the account in use from what Anthropic last said of the login
3330    /// stored, and says the config has moved since. It took the config's account.
3331    #[test]
3332    fn an_offline_read_names_the_account_anthropic_named_whatever_the_config_says() {
3333        let m = machine("offline-config-moved");
3334        switch::harness::config_names(&m, "there");
3335
3336        let read = Pitboard::new(m.ctx.clone())
3337            .status_offline()
3338            .expect("a read");
3339
3340        assert_eq!(in_use(&read), ["here"]);
3341        assert_eq!(
3342            said(&read),
3343            [(
3344                "in_use_unconfirmed",
3345                "Claude Code's config has named `there` since Pitboard last asked Anthropic \
3346                 whose login Claude Code has stored, so another login may be stored. The one \
3347                 stored then was `here`'s. `pitboard status` asks again."
3348                    .to_string()
3349            )]
3350        );
3351        assert_eq!(m.api.calls(), 0);
3352    }
3353
3354    /// A record brought forward from 0.9.0 names the account it last switched to, which
3355    /// Anthropic never named. Where Claude Code's config names another account, a read that
3356    /// asks nobody says the account in use is not confirmed, as doctor and the status line do.
3357    #[test]
3358    fn an_offline_read_of_a_record_never_asked_about_says_the_config_names_another() {
3359        let m = machine("offline-never-asked");
3360        let mut state = state::load(&m.ctx).expect("state");
3361        let here = state.get(&m.key("here")).expect("here").owner();
3362        state.in_use.insert(
3363            ProviderId::Claude.code().into(),
3364            InUse {
3365                owner: Some(here),
3366                login: String::new(),
3367                known_at: 0,
3368                named: None,
3369            },
3370        );
3371        state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
3372        switch::harness::config_names(&m, "there");
3373
3374        let read = Pitboard::new(m.ctx.clone())
3375            .status_offline()
3376            .expect("a read");
3377
3378        assert_eq!(in_use(&read), ["here"]);
3379        assert_eq!(
3380            said(&read),
3381            [(
3382                "in_use_unconfirmed",
3383                "Claude Code's config names `there`, and Pitboard has not asked Anthropic whose \
3384                 login Claude Code has stored since Pitboard was updated, so another login may \
3385                 be stored. The account it last switched to is `here`. `pitboard status` asks."
3386                    .to_string()
3387            )]
3388        );
3389        assert_eq!(m.api.calls(), 0);
3390    }
3391
3392    /// The same record where the config names that account, as on nearly every machine
3393    /// updated from 0.9.0 until its first read that asks: nothing is said, and nobody is asked.
3394    #[test]
3395    fn an_offline_read_of_a_record_never_asked_about_says_nothing_where_the_config_agrees() {
3396        let m = machine("offline-never-asked-agrees");
3397        let mut state = state::load(&m.ctx).expect("state");
3398        let here = state.get(&m.key("here")).expect("here").owner();
3399        state.in_use.insert(
3400            ProviderId::Claude.code().into(),
3401            InUse {
3402                owner: Some(here),
3403                login: String::new(),
3404                known_at: 0,
3405                named: None,
3406            },
3407        );
3408        state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
3409        switch::harness::config_names(&m, "here");
3410
3411        let read = Pitboard::new(m.ctx.clone())
3412            .status_offline()
3413            .expect("a read");
3414
3415        assert_eq!(in_use(&read), ["here"]);
3416        assert_eq!(said(&read), []);
3417        assert_eq!(m.api.calls(), 0);
3418    }
3419
3420    /// A read that asks settles it: the login stored is the one Anthropic named, known by its
3421    /// fingerprint, so nobody is asked whose it is, and what stands is that the config names
3422    /// another account, which `/status` in Claude Code shows. Said by every read after it,
3423    /// the one that asks nobody too, until something writes the config again.
3424    #[test]
3425    fn a_read_settles_it_and_says_the_config_names_another() {
3426        use crate::api::scripted::Asked;
3427        let m = machine("read-config-names-another");
3428        switch::harness::config_names(&m, "there");
3429        let pitboard = Pitboard::new(m.ctx.clone());
3430        let names_another = "Claude Code's config names `there`, and the login Claude Code has \
3431                             stored is `here`'s, as Anthropic said. `/status` in Claude Code \
3432                             shows `there`. `pitboard use here` writes `here` into the config.";
3433
3434        let read = pitboard.status(false).expect("a read");
3435
3436        assert!(
3437            !m.api
3438                .asked()
3439                .contains(&Asked::Owner("access-here-refresh".into())),
3440            "{:?}",
3441            m.api.asked()
3442        );
3443        assert_eq!(in_use(&read), ["here"]);
3444        assert_eq!(
3445            said(&read),
3446            [("config_names_another", names_another.to_string())]
3447        );
3448
3449        let offline = pitboard.status_offline().expect("a read of what is known");
3450        assert_eq!(in_use(&offline), ["here"]);
3451        assert_eq!(
3452            said(&offline),
3453            [("config_names_another", names_another.to_string())]
3454        );
3455    }
3456
3457    /// A tool whose login names its own account is identified from the login, which asks
3458    /// nobody, so a read that sends no request can still tell whether the next change
3459    /// finishes its switch. Here Codex has renewed its login since the switch stopped, so the
3460    /// login matches neither side the record kept, and the copy the switch parked cannot be
3461    /// read. `doctor` and the read that asks nobody said nothing of it, as though only OpenAI
3462    /// could say whose the login is.
3463    #[test]
3464    fn whose_a_codex_login_is_is_told_without_asking_anyone() {
3465        let m = interrupted(codex_machine, "codex-renewed-since");
3466        m.sign_in(&switch::harness::codex_login("here", "renewed-since"));
3467        m.mem.vault().fault(
3468            &reserved(&m),
3469            crate::store::memory::Fault::Unreadable("locked".into()),
3470        );
3471        let pitboard = Pitboard::new(m.ctx.clone());
3472        let asked = m.api.calls();
3473
3474        let read = pitboard.status_offline().expect("a read");
3475        let checks = pitboard.doctor().checks;
3476
3477        assert_eq!(m.api.calls(), asked, "asks nobody");
3478        let refused = pitboard
3479            .switch_to("codex/there")
3480            .expect_err("it stops at it");
3481        assert_eq!(refused.error.code(), "recovery_undetermined");
3482        assert_eq!(
3483            said(&read),
3484            [("recovery_undetermined", refused.error.to_string())]
3485        );
3486        let check = checks
3487            .iter()
3488            .find(|c| c.code == "interrupted_switch")
3489            .expect("said");
3490        assert_eq!(check.detail, refused.error.to_string());
3491    }
3492
3493    /// A Codex store that this machine's `requirements.toml` pins is refused before a sign-in
3494    /// starts, naming that file, as it is for a switch: no line of the person's own changes
3495    /// it, and the `-c` Pitboard's sign-ins give is under it, so the login would be kept where
3496    /// Pitboard cannot read it back. Pitboard used to read only `$CODEX_HOME/config.toml`, and
3497    /// started the sign-in.
3498    #[test]
3499    fn a_codex_store_a_requirement_pins_is_refused_before_a_sign_in() {
3500        let m = codex_machine("codex-pinned");
3501        // Named where nothing is, so a sign-in that got past the refusal would find no
3502        // program to run rather than this machine's own `codex`.
3503        let ctx = m.ctx.clone().with_codex_program("/nowhere/codex".into());
3504        let required = crate::provider::codex::layers::system_file(&ctx, "requirements.toml");
3505        m.mem.administers(
3506            required.clone(),
3507            "cli_auth_credentials_store = \"keyring\"\n",
3508        );
3509        let pitboard = Pitboard::new(ctx);
3510        for refused in [
3511            pitboard.sign_in("codex/new").expect_err("refused"),
3512            pitboard.sign_in_watched("codex/new").expect_err("refused"),
3513        ] {
3514            assert_eq!(refused.error.code(), "live_store_unsupported");
3515            let said = refused.error.to_string();
3516            assert!(
3517                said.contains(&format!("pinned to `keyring` by {}", required.display())),
3518                "{said}"
3519            );
3520            assert!(
3521                said.contains("No line in your own config.toml can change it"),
3522                "{said}"
3523            );
3524        }
3525        let switched = pitboard.switch_to("codex/there").expect_err("refused too");
3526        assert_eq!(switched.error.code(), "live_store_unsupported");
3527        let file = m
3528            .mem
3529            .file_at(crate::provider::codex::paths::auth_file(&m.ctx));
3530        let kept = crate::store::RawStore::read(&file, "auth.json")
3531            .expect("readable")
3532            .expect("there");
3533        let kept: serde_json::Value = serde_json::from_str(&kept).expect("JSON");
3534        assert_eq!(
3535            kept["tokens"]["refresh_token"], "here-refresh",
3536            "the auth.json Codex does not use is left as it was"
3537        );
3538    }
3539
3540    /// The park the interrupted switch reserved, as its record names it.
3541    fn reserved(m: &Machine) -> String {
3542        let raw = std::fs::read_to_string(crate::home::dir(&m.ctx).join("journal.json"))
3543            .expect("a record");
3544        serde_json::from_str::<serde_json::Value>(&raw).expect("a record is JSON")["park_service"]
3545            .as_str()
3546            .expect("a park")
3547            .to_owned()
3548    }
3549
3550    /// `doctor` sends no request, and says what a read says wherever it can tell without
3551    /// one: the refusal, and the way out. Where only the service could tell, it says what
3552    /// it always said, that a switch did not finish.
3553    #[test]
3554    fn doctor_says_a_switch_is_stuck_where_it_can_tell_without_asking() {
3555        for (tool, make) in MACHINES {
3556            let m = stuck(make, &format!("doctor-stuck-{tool}"));
3557            let pitboard = Pitboard::new(m.ctx.clone());
3558            let before = untouched(&m);
3559
3560            let checks = pitboard.doctor().checks;
3561
3562            assert_eq!(untouched(&m), before, "{tool}: doctor changes nothing");
3563            let refused = pitboard
3564                .switch_to(&m.key("there").typed())
3565                .expect_err("refused");
3566            let check = checks
3567                .iter()
3568                .find(|c| c.code == "interrupted_switch")
3569                .expect("said");
3570            assert_eq!(check.level, doctor::Level::Warn, "{tool}");
3571            assert_eq!(check.detail, refused.error.to_string(), "{tool}");
3572            assert!(check.advice.contains("pitboard abandon"), "{tool}");
3573        }
3574
3575        let m = stuck_unless_asked("doctor-stuck-unasked");
3576        let asked = m.api.calls();
3577        let checks = Pitboard::new(m.ctx.clone()).doctor().checks;
3578        assert_eq!(m.api.calls(), asked, "doctor asks nobody");
3579        let check = checks
3580            .iter()
3581            .find(|c| c.code == "interrupted_switch")
3582            .expect("said");
3583        assert_eq!(check.detail, "a switch did not finish");
3584    }
3585
3586    /// Every file in Pitboard's own directory but the audit log.
3587    fn files(m: &Machine) -> BTreeMap<String, Vec<u8>> {
3588        std::fs::read_dir(crate::home::dir(&m.ctx))
3589            .expect("a Pitboard home")
3590            .map(|entry| entry.expect("an entry").path())
3591            .filter(|path| path.file_name() != Some("audit.log".as_ref()))
3592            .map(|path| {
3593                let body = std::fs::read(&path).unwrap_or_default();
3594                (path.display().to_string(), body)
3595            })
3596            .collect()
3597    }
3598
3599    /// The last two lines of the audit log, as verb and outcome.
3600    fn last_audited(m: &Machine) -> Vec<(String, String)> {
3601        audit::read(&m.ctx, 2)
3602            .into_iter()
3603            .map(|entry| (entry.verb, entry.outcome))
3604            .collect()
3605    }
3606
3607    /// Every other change settles an interrupted switch before anything else, and one
3608    /// refused over its name did not, so the switch stayed unrecovered and the refusal was
3609    /// all anybody was told. It is settled now, recorded the way any recovery is, and
3610    /// reported beside the refusal.
3611    #[test]
3612    fn a_change_refused_over_its_name_still_recovers_an_interrupted_switch() {
3613        for (tool, make) in MACHINES {
3614            for (change, verb, code, audited, refuse) in REFUSALS {
3615                let at = format!("{tool}, {change}");
3616                let m = interrupted(make, &format!("refused-{tool}-{change}"));
3617
3618                let failed = refuse(&Pitboard::new(m.ctx.clone()), m.which)
3619                    .unwrap_or_else(|| panic!("{at}: the name must still be refused"));
3620
3621                assert_eq!(failed.error.code(), code, "{at}: {}", failed.error);
3622                let said: Vec<&str> = failed.warnings.iter().map(Warning::code).collect();
3623                assert_eq!(said, ["interrupted_switch_undone"], "{at}");
3624                assert!(!switch::interrupted(&m.ctx), "{at}: the record is resolved");
3625                hold(&m, &at);
3626                assert_eq!(
3627                    last_audited(&m),
3628                    [
3629                        (
3630                            "recover".to_string(),
3631                            "interrupted_switch_undone".to_string()
3632                        ),
3633                        (verb.to_string(), audited.to_string()),
3634                    ],
3635                    "{at}"
3636                );
3637            }
3638        }
3639    }
3640
3641    /// A mistyped name with nothing to recover takes no lock and writes nothing but its
3642    /// line in the audit log.
3643    #[test]
3644    fn a_change_refused_over_its_name_with_nothing_interrupted_changes_nothing() {
3645        for (tool, make) in MACHINES {
3646            for (change, verb, code, audited, refuse) in REFUSALS {
3647                let at = format!("{tool}, {change}");
3648                let m = make(&format!("refused-quietly-{tool}-{change}"));
3649                let (before, parked, live) = (files(&m), m.mem.vault().services(), m.live());
3650
3651                let failed = refuse(&Pitboard::new(m.ctx.clone()), m.which)
3652                    .unwrap_or_else(|| panic!("{at}: the name must still be refused"));
3653
3654                assert_eq!(failed.error.code(), code, "{at}: {}", failed.error);
3655                assert!(failed.warnings.is_empty(), "{at}: {:?}", failed.warnings);
3656                assert_eq!(files(&m), before, "{at}: not even the lock file is made");
3657                assert_eq!(m.mem.vault().services(), parked, "{at}");
3658                assert_eq!(m.live(), live, "{at}");
3659                assert_eq!(
3660                    last_audited(&m).last(),
3661                    Some(&(verb.to_string(), audited.to_string())),
3662                    "{at}"
3663                );
3664            }
3665        }
3666    }
3667
3668    /// A new login that did not hold after it was written is parked rather than lost, and
3669    /// parking a login too big for `security`'s standard input puts it on the argument line.
3670    /// The change then fails, and that is still said beside the failure.
3671    #[test]
3672    fn a_new_login_parked_after_it_did_not_hold_says_how_it_was_parked() {
3673        for (tool, make) in MACHINES {
3674            let m = make(&format!("not-installed-said-{tool}"));
3675            m.mem.vault().takes_on_stdin(64);
3676            let login = switch::harness::signed_in(&m, "here", "here-refresh-2");
3677            m.fault_live(crate::store::memory::Fault::DeletedAfterWrite);
3678
3679            let failed = Pitboard::new(m.ctx.clone())
3680                .enroll_signed_in(&m.key("here").typed(), login)
3681                .expect_err("it did not hold");
3682
3683            assert_eq!(failed.error.code(), "sign_in_not_installed", "{tool}");
3684            let said: Vec<&str> = failed.warnings.iter().map(Warning::code).collect();
3685            assert_eq!(said, ["written_on_the_command_line"], "{tool}");
3686        }
3687    }
3688
3689    /// The recovery settles for the tool whose switch was interrupted, not for no tool in
3690    /// particular, so a custom Claude Code endpoint stops exactly what it stops for any
3691    /// change: the recovery of a Claude Code switch, and not of a Codex one. What it stops
3692    /// is left for a later run, and the refusal is reported as it always was.
3693    #[test]
3694    fn a_custom_claude_endpoint_stops_only_the_recovery_of_a_claude_code_switch() {
3695        let codex = interrupted(codex_machine, "refused-custom-codex");
3696        let mut ctx = codex.ctx.clone();
3697        ctx.custom_oauth = true;
3698        let failed = Pitboard::new(ctx).switch_to("nobody").expect_err("refused");
3699        assert_eq!(failed.error.code(), "account_unknown");
3700        let said: Vec<&str> = failed.warnings.iter().map(Warning::code).collect();
3701        assert_eq!(said, ["interrupted_switch_undone"]);
3702        assert!(!switch::interrupted(&codex.ctx));
3703
3704        let claude = interrupted(machine, "refused-custom-claude");
3705        let mut ctx = claude.ctx.clone();
3706        ctx.custom_oauth = true;
3707        let failed = Pitboard::new(ctx).switch_to("nobody").expect_err("refused");
3708        assert_eq!(
3709            failed.error.code(),
3710            "account_unknown",
3711            "the refusal, not the recovery that could not run"
3712        );
3713        assert!(failed.warnings.is_empty(), "{:?}", failed.warnings);
3714        assert!(
3715            switch::interrupted(&claude.ctx),
3716            "the record is kept for a run that can finish it"
3717        );
3718    }
3719
3720    #[derive(Debug, Clone, Copy)]
3721    enum Refused {
3722        As(Elevation),
3723        On(Floor),
3724    }
3725
3726    const AS_ROOT: Refused = Refused::As(Elevation::Elevated { why: "as root" });
3727    const WITH_SUDO: Refused = Refused::As(Elevation::Elevated { why: "with sudo" });
3728    const SERVICE_ACCOUNT: Refused = Refused::As(Elevation::Elevated {
3729        why: crate::host::token::AS_A_SERVICE_ACCOUNT,
3730    });
3731    const ELEVATION_UNKNOWN: Refused = Refused::As(Elevation::Unknown);
3732    const BELOW_THE_FLOOR: Refused = Refused::On(Floor::Below { build: 22631 });
3733    const FLOOR_UNKNOWN: Refused = Refused::On(Floor::Unknown);
3734
3735    const REFUSED: [Refused; 6] = [
3736        AS_ROOT,
3737        WITH_SUDO,
3738        SERVICE_ACCOUNT,
3739        ELEVATION_UNKNOWN,
3740        BELOW_THE_FLOOR,
3741        FLOOR_UNKNOWN,
3742    ];
3743
3744    impl Refused {
3745        fn said_by(self, m: &Machine) {
3746            match self {
3747                Refused::As(elevation) => m.mem.runs_with(elevation),
3748                Refused::On(floor) => m.mem.runs_on(floor),
3749            }
3750        }
3751
3752        fn lifted(m: &Machine) {
3753            m.mem.runs_with(Elevation::Normal);
3754            m.mem.runs_on(Floor::Met);
3755        }
3756
3757        fn code(self) -> &'static str {
3758            match self {
3759                Refused::As(_) => "elevated",
3760                Refused::On(_) => "system_too_old",
3761            }
3762        }
3763
3764        fn words(self) -> crate::words::ChangesNothing {
3765            match self {
3766                Refused::As(Elevation::Elevated { why }) => {
3767                    crate::words::elevated(crate::host::OS, Some(why))
3768                }
3769                Refused::As(_) => crate::words::elevated(crate::host::OS, None),
3770                Refused::On(Floor::Below { build }) => crate::words::too_old(Some(build)),
3771                Refused::On(_) => crate::words::too_old(None),
3772            }
3773        }
3774    }
3775
3776    /// What one change came to, as a change refused at the gate is reported: its error,
3777    /// and the warnings found on the way, of which there must be none.
3778    fn failed<T>(outcome: Result<T>) -> Option<Failed> {
3779        outcome.err().map(|error| Failed {
3780            error,
3781            warnings: Vec::new(),
3782            said_first: 0,
3783        })
3784    }
3785
3786    type Change = fn(&Pitboard, &Machine) -> Option<Failed>;
3787
3788    /// Every change a front end can ask for, by its name. `enroll_signed_in` is asked apart,
3789    /// since it takes a sign-in made before.
3790    const CHANGES: [(&str, Change); 16] = [
3791        ("switch_to", |p, m| {
3792            p.switch_to(&m.key("there").typed()).err()
3793        }),
3794        ("enroll_current", |p, m| {
3795            p.enroll_current(&m.key("here").typed()).err()
3796        }),
3797        ("sign_in", |p, m| p.sign_in(&m.key("new").typed()).err()),
3798        ("sign_in_watched", |p, m| {
3799            p.sign_in_watched(&m.key("new").typed()).err()
3800        }),
3801        ("forget", |p, m| p.forget(&m.key("there").typed()).err()),
3802        ("rename", |p, m| {
3803            p.rename(&m.key("there").typed(), "elsewhere").err()
3804        }),
3805        ("abandon_recovery", |p, _| failed(p.abandon_recovery())),
3806        ("renew", |p, _| failed(p.renew())),
3807        ("schedule_install", |p, _| failed(p.schedule_install())),
3808        ("schedule_uninstall", |p, _| failed(p.schedule_uninstall())),
3809        ("schedule_repair", |p, _| failed(p.schedule_repair())),
3810        ("adopt", |p, _| failed(p.adopt())),
3811        ("repair", |p, _| p.repair().err()),
3812        ("keep_app_file", |p, _| {
3813            failed(p.keep_app_file(crate::app::AppFile::Preferences, "{}"))
3814        }),
3815        ("uninstall", |p, _| p.uninstall().err()),
3816        ("auto_switch", |p, _| {
3817            p.auto_switch(Threshold::DEFAULT).err()
3818        }),
3819    ];
3820
3821    /// Everything on a machine a change could leave different: every directory and file
3822    /// under its home, the audit log among them and the scheduler's files with them, every
3823    /// item in its keychain, every login parked and the one in use. The private directory
3824    /// a sign-in made before is not counted: dropping that sign-in takes it away again. The
3825    /// lock that sign-in holds counts by its length: Windows lets no other handle read it
3826    /// while it is held, and dropping the sign-in lets it go.
3827    type Everything = (
3828        BTreeMap<String, Option<Vec<u8>>>,
3829        Vec<(String, Option<String>)>,
3830        Vec<(String, Option<String>)>,
3831        Option<serde_json::Value>,
3832    );
3833
3834    fn everything(m: &Machine) -> Everything {
3835        fn walk(dir: &std::path::Path, into: &mut BTreeMap<String, Option<Vec<u8>>>) {
3836            for entry in std::fs::read_dir(dir).expect("a directory to read") {
3837                let path = entry.expect("an entry").path();
3838                if path.ends_with(".pitboard/signin") {
3839                    continue;
3840                }
3841                if path.is_dir() {
3842                    into.insert(path.display().to_string(), None);
3843                    walk(&path, into);
3844                } else if path.ends_with(".pitboard/signin.lock") {
3845                    let len = std::fs::metadata(&path).expect("the lock's length").len();
3846                    into.insert(path.display().to_string(), Some(len.to_le_bytes().to_vec()));
3847                } else {
3848                    let body = std::fs::read(&path)
3849                        .unwrap_or_else(|e| panic!("{} to read: {e}", path.display()));
3850                    into.insert(path.display().to_string(), Some(body));
3851                }
3852            }
3853        }
3854        let mut files = BTreeMap::new();
3855        walk(&m.ctx_home(), &mut files);
3856        let items = |store: &crate::store::memory::MemoryStore| {
3857            store
3858                .services()
3859                .into_iter()
3860                .map(|service| {
3861                    let held = store.peek(&service);
3862                    (service, held)
3863                })
3864                .collect()
3865        };
3866        (files, items(m.mem.live()), items(m.mem.vault()), m.live())
3867    }
3868
3869    /// Running as root or under sudo, Pitboard changed things as root: a state file, a
3870    /// schedule or a lock it made was root's, and a park might be, where the person's own
3871    /// runs might never read or replace it again. Every change is refused now, at one gate,
3872    /// before it reads, locks or records anything, and leaves the machine byte for byte as it
3873    /// was, the audit log included. The machine has an interrupted switch waiting, which every
3874    /// change that settles would otherwise finish, and asks nobody. One test for each tool
3875    /// and each way Pitboard may change nothing, named `<tool>_<way>`.
3876    mod every_change_is_refused_where_pitboard_may_change_nothing_and_changes_nothing {
3877        use super::*;
3878
3879        macro_rules! cases {
3880            ($($case:ident: $make:ident, $refused:ident;)*) => {
3881                const _: () =
3882                    assert!([$(stringify!($case)),*].len() == MACHINES.len() * REFUSED.len());
3883                $(
3884                    #[test]
3885                    fn $case() {
3886                        every_change_is_refused(stringify!($case), $make, $refused);
3887                    }
3888                )*
3889            };
3890        }
3891
3892        cases! {
3893            claude_as_root: machine, AS_ROOT;
3894            claude_with_sudo: machine, WITH_SUDO;
3895            claude_as_a_service_account: machine, SERVICE_ACCOUNT;
3896            claude_elevation_unknown: machine, ELEVATION_UNKNOWN;
3897            claude_below_the_floor: machine, BELOW_THE_FLOOR;
3898            claude_floor_unknown: machine, FLOOR_UNKNOWN;
3899            codex_as_root: codex_machine, AS_ROOT;
3900            codex_with_sudo: codex_machine, WITH_SUDO;
3901            codex_as_a_service_account: codex_machine, SERVICE_ACCOUNT;
3902            codex_elevation_unknown: codex_machine, ELEVATION_UNKNOWN;
3903            codex_below_the_floor: codex_machine, BELOW_THE_FLOOR;
3904            codex_floor_unknown: codex_machine, FLOOR_UNKNOWN;
3905        }
3906    }
3907
3908    fn every_change_is_refused(case: &str, make: Make, refused: Refused) {
3909        for (change, run) in CHANGES {
3910            let at = format!("{case}, {change}");
3911            let m = interrupted(make, &format!("gate-{case}-{change}"));
3912            refused.said_by(&m);
3913            let (before, asked) = (everything(&m), m.api.calls());
3914
3915            let failed =
3916                run(&Pitboard::new(m.ctx.clone()), &m).unwrap_or_else(|| panic!("{at}: refused"));
3917
3918            assert_eq!(
3919                failed.error.code(),
3920                refused.code(),
3921                "{at}: {}",
3922                failed.error
3923            );
3924            assert!(failed.warnings.is_empty(), "{at}: {:?}", failed.warnings);
3925            assert!(everything(&m) == before, "{at}: nothing changes");
3926            assert_eq!(m.api.calls(), asked, "{at}: nobody is asked");
3927        }
3928
3929        let at = format!("{case}, enroll_signed_in");
3930        let m = interrupted(make, &format!("gate-{case}-enroll-signed-in"));
3931        let login = switch::harness::signed_in(&m, "new", "new-refresh");
3932        refused.said_by(&m);
3933        let before = everything(&m);
3934        let failed = Pitboard::new(m.ctx.clone())
3935            .enroll_signed_in(&m.key("new").typed(), login)
3936            .expect_err("refused");
3937        assert_eq!(failed.error.code(), refused.code(), "{at}");
3938        assert!(everything(&m) == before, "{at}: nothing changes");
3939    }
3940
3941    #[test]
3942    fn the_gate_says_why_it_refuses_and_lets_a_person_through() {
3943        let m = machine("gate-words");
3944        let pitboard = Pitboard::new(m.ctx.clone());
3945        assert!(pitboard.permit().is_ok(), "as the person");
3946        for refused in REFUSED {
3947            refused.said_by(&m);
3948            let refusal = pitboard.permit().expect_err("refused");
3949            assert_eq!(refusal.code(), refused.code(), "{refused:?}");
3950            assert_eq!(refusal.exit_code(), 1, "{refused:?}");
3951            assert_eq!(refusal.to_string(), refused.words().refusal, "{refused:?}");
3952            Refused::lifted(&m);
3953            assert!(pitboard.permit().is_ok(), "{refused:?}: lifted");
3954        }
3955    }
3956
3957    #[test]
3958    fn the_gate_lets_windows_11_24h2_through_and_says_an_older_one_first() {
3959        let m = machine("gate-floor");
3960        let pitboard = Pitboard::new(m.ctx.clone());
3961        for build in [26100, 26200] {
3962            m.mem
3963                .runs_on(crate::host::windows_floor(Some((10, 0, build))));
3964            assert!(pitboard.permit().is_ok(), "{build}");
3965        }
3966        m.mem
3967            .runs_on(crate::host::windows_floor(Some((10, 0, 22631))));
3968        let refused = pitboard.permit().expect_err("refused");
3969        assert_eq!(refused.code(), "system_too_old");
3970        assert_eq!(
3971            refused.to_string(),
3972            crate::words::too_old(Some(22631)).refusal
3973        );
3974        m.mem.runs_with(Elevation::Elevated { why: "as root" });
3975        let first = pitboard.permit().expect_err("refused");
3976        assert_eq!(
3977            first.code(),
3978            "system_too_old",
3979            "the system before how it runs"
3980        );
3981    }
3982
3983    /// An empty or relative `HOME` made every path under it lead into the folder Pitboard
3984    /// was run from: its own files where `PITBOARD_HOME` was not set, and the scheduler's
3985    /// and each tool's default folder whatever was. The gate refuses it now, and so does
3986    /// every read of the accounts, with `home_not_absolute`, and nothing changes. Asked of
3987    /// the gate and the reads first, which change nothing whatever they answer.
3988    #[test]
3989    fn every_change_and_read_is_refused_where_the_home_is_not_a_full_path() {
3990        for home in ["", "relative"] {
3991            for (tool, make) in MACHINES {
3992                let at = format!("{tool}, HOME={home:?}");
3993                let m = make(&format!("unplaced-{tool}-{}", home.len()));
3994                let mut ctx = m.ctx.clone();
3995                ctx.home = std::path::PathBuf::from(home);
3996                let pitboard = Pitboard::new(ctx);
3997                let refused = |error: Error| {
3998                    assert_eq!(error.code(), "home_not_absolute", "{at}: {error}");
3999                    assert!(
4000                        error
4001                            .to_string()
4002                            .starts_with(&format!("{} is ", crate::host::OS.home_variable())),
4003                        "{at}: {error}"
4004                    );
4005                };
4006
4007                refused(pitboard.permit().expect_err("the gate refuses"));
4008                refused(pitboard.status_offline().expect_err("refused"));
4009                refused(pitboard.status(false).expect_err("refused"));
4010                let before = (everything(&m), m.api.calls());
4011                for (change, run) in CHANGES {
4012                    let failed = run(&pitboard, &m).unwrap_or_else(|| panic!("{at}: {change}"));
4013                    refused(failed.error);
4014                    assert!(failed.warnings.is_empty(), "{at}, {change}");
4015                }
4016                assert!(
4017                    (everything(&m), m.api.calls()) == before,
4018                    "{at}: nothing changes and nobody is asked"
4019                );
4020            }
4021        }
4022    }
4023
4024    /// What a read says, account by account: enough to tell a live read from one of what
4025    /// was last measured.
4026    type Read = Vec<(
4027        ProviderId,
4028        Option<String>,
4029        bool,
4030        Option<crate::usage::Snapshot>,
4031        Option<status::Stale>,
4032    )>;
4033
4034    fn rows(report: &status::Report) -> Read {
4035        report
4036            .rows
4037            .iter()
4038            .map(|row| {
4039                (
4040                    row.provider,
4041                    row.label.clone(),
4042                    row.signed_in,
4043                    row.usage.clone(),
4044                    row.stale,
4045                )
4046            })
4047            .collect()
4048    }
4049
4050    /// A read renewed every parked login whose access had lapsed, asked each service about
4051    /// every account and wrote what it measured down, as root too. Where this process may
4052    /// change nothing it answers what the read that asks nobody answers, and says why: it
4053    /// renews nothing, asks nobody and writes nothing. A park whose access has lapsed is
4054    /// there to be renewed, so a read that renewed would show it.
4055    #[test]
4056    fn a_read_where_pitboard_may_change_nothing_asks_nobody_and_says_why() {
4057        for (tool, make) in MACHINES {
4058            for refused in REFUSED {
4059                let at = format!("{tool}, {refused:?}");
4060                let m = make(&format!("gate-read-{tool}"));
4061                let later = m
4062                    .ctx
4063                    .clone()
4064                    .with_clock(Arc::new(crate::time::FixedClock::at(
4065                        switch::harness::NOW + 11 * 86_400,
4066                    )));
4067                let pitboard = Pitboard::new(later);
4068                refused.said_by(&m);
4069                let (before, asked) = (everything(&m), m.api.calls());
4070
4071                let read = pitboard.status(false).expect("a read");
4072                let known = pitboard.status_offline().expect("a read");
4073
4074                assert!(everything(&m) == before, "{at}: nothing is written");
4075                assert_eq!(m.api.calls(), asked, "{at}: nobody is asked");
4076                assert_eq!(rows(&read.value), rows(&known.value), "{at}");
4077                let codes: Vec<&str> = read.warnings.iter().map(Warning::code).collect();
4078                assert_eq!(codes.last(), Some(&"read_only"), "{at}");
4079                assert_eq!(read.warnings.len(), known.warnings.len() + 1, "{at}");
4080                let said = read.warnings.last().expect("said").to_string();
4081                let words = refused.words();
4082                assert!(
4083                    said.starts_with(&format!("{}, so it changes nothing", words.because)),
4084                    "{at}: {said}"
4085                );
4086                assert!(said.ends_with(words.to_ask_again), "{at}: {said}");
4087
4088                Refused::lifted(&m);
4089                let live = pitboard.status(false).expect("a read");
4090                assert!(m.api.calls() > asked, "{at}: as the person, it asks");
4091                assert!(
4092                    !live.warnings.iter().any(|w| w.code() == "read_only"),
4093                    "{at}"
4094                );
4095            }
4096        }
4097    }
4098
4099    /// The status line kept what each session passed it and the readings it moved, as root
4100    /// too. Where this process may change nothing it draws the same line from the files as
4101    /// they are, and writes neither.
4102    #[test]
4103    fn the_status_line_where_pitboard_may_change_nothing_draws_the_same_line_and_writes_nothing() {
4104        let session = serde_json::json!({
4105            "session_id": "a-session",
4106            "rate_limits": {
4107                "five_hour": {"used_percentage": 40.0, "resets_at": switch::harness::NOW + 3_600},
4108                "seven_day": {"used_percentage": 20.0, "resets_at": switch::harness::NOW + 86_400},
4109            },
4110        })
4111        .to_string();
4112        for refused in REFUSED {
4113            let m = machine("gate-statusline");
4114            let pitboard = Pitboard::new(m.ctx.clone());
4115            refused.said_by(&m);
4116            let before = everything(&m);
4117
4118            let drawn = pitboard.statusline(&session);
4119
4120            assert!(everything(&m) == before, "{refused:?}: nothing is written");
4121            Refused::lifted(&m);
4122            assert_eq!(pitboard.statusline(&session), drawn, "{refused:?}");
4123            assert!(
4124                crate::home::dir(&m.ctx).join("sessions.json").exists(),
4125                "{refused:?}: as the person, the session is kept"
4126            );
4127        }
4128    }
4129
4130    /// `renew` returned an empty list when it renewed nothing, whatever the reason, so a
4131    /// run that could not renew read as one where nothing was due. Refused, it says so, and
4132    /// asks nobody, with a park that is due to be renewed.
4133    #[test]
4134    fn a_renewal_where_pitboard_may_change_nothing_is_refused_and_asks_nobody() {
4135        for (tool, make) in MACHINES {
4136            for refused in REFUSED {
4137                let at = format!("{tool}, {refused:?}");
4138                let m = make(&format!("gate-renew-{tool}"));
4139                switch::harness::renews(&m, "there-refresh", "there-refresh-2");
4140                let later = m
4141                    .ctx
4142                    .clone()
4143                    .with_clock(Arc::new(crate::time::FixedClock::at(
4144                        switch::harness::NOW + 11 * 86_400,
4145                    )));
4146                let pitboard = Pitboard::new(later);
4147                refused.said_by(&m);
4148                let (before, asked) = (everything(&m), m.api.calls());
4149
4150                let refusal = pitboard.renew().expect_err("refused");
4151
4152                assert_eq!(refusal.code(), refused.code(), "{at}");
4153                assert!(everything(&m) == before, "{at}: nothing changes");
4154                assert_eq!(m.api.calls(), asked, "{at}: nobody is asked");
4155
4156                Refused::lifted(&m);
4157                let renewed = pitboard.renew().expect("as the person, it renews");
4158                let codes: Vec<&str> = renewed.iter().map(|(_, r)| r.code()).collect();
4159                assert_eq!(codes, ["renewed"], "{at}");
4160            }
4161        }
4162    }
4163
4164    /// `doctor` says why every change is refused, first, as a check that fails, and says
4165    /// nothing of it where Pitboard runs as the person.
4166    #[test]
4167    fn doctor_says_why_pitboard_changes_nothing() {
4168        let m = machine("gate-doctor");
4169        let pitboard = Pitboard::new(m.ctx.clone());
4170        let refusing = |checks: &[doctor::Check]| -> Vec<&'static str> {
4171            checks
4172                .iter()
4173                .map(|c| c.code)
4174                .filter(|code| ["elevated", "system_too_old"].contains(code))
4175                .collect()
4176        };
4177        assert!(
4178            refusing(&pitboard.doctor().checks).is_empty(),
4179            "as the person"
4180        );
4181        for refused in REFUSED {
4182            refused.said_by(&m);
4183            let checks = pitboard.doctor().checks;
4184            let check = checks.first().expect("checks");
4185            let words = refused.words();
4186            assert_eq!(check.code, refused.code(), "{refused:?}");
4187            assert_eq!(check.level, doctor::Level::Fail, "{refused:?}");
4188            assert_eq!(check.detail, words.because, "{refused:?}");
4189            assert!(
4190                check.advice.starts_with("Pitboard changes nothing ")
4191                    && check.advice.ends_with(words.way_out),
4192                "{refused:?}: {}",
4193                check.advice
4194            );
4195            Refused::lifted(&m);
4196        }
4197        m.mem.runs_on(Floor::Below { build: 22631 });
4198        m.mem.runs_with(Elevation::Unknown);
4199        assert_eq!(
4200            refusing(&pitboard.doctor().checks),
4201            ["system_too_old", "elevated"]
4202        );
4203    }
4204}