Skip to main content

pitboard_core/
label.rs

1//! Turning what somebody typed into one account.
2//!
3//! A label was a name unique across the whole machine, because there was one tool and one
4//! set of accounts. With three, `work` is a name somebody will want for their work account
5//! on each of them, and refusing the second one would be pitboard imposing a namespace
6//! nobody asked for.
7//!
8//! So a label is unique within a provider, and `codex/work` says which. A bare `work` still
9//! works and still means what it always did, as long as it names one account; where it
10//! names two, pitboard says so and lists them rather than picking.
11
12use crate::error::{Enrolled, Error, Result};
13use crate::provider::ProviderId;
14use crate::state::{Account, Key, State};
15
16/// The separator between a provider and a label. A label may not contain one.
17pub const SEPARATOR: char = '/';
18
19/// What somebody typed, before it is looked up.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum Spec<'a> {
22    /// `work`: whichever provider has it, if only one does.
23    Bare(&'a str),
24    /// `codex/work`: this provider's, and no other's.
25    Qualified(ProviderId, &'a str),
26}
27
28impl Spec<'_> {
29    pub fn label(&self) -> &str {
30        match self {
31            Spec::Bare(label) | Spec::Qualified(_, label) => label,
32        }
33    }
34}
35
36/// Read `provider/label`, or a bare label.
37///
38/// A prefix that is not a provider is an error rather than a label containing a slash,
39/// because the alternative is `pitboard use codx/work` quietly looking for an account
40/// literally called `codx/work` and reporting it missing.
41pub fn parse(typed: &str) -> Result<Spec<'_>> {
42    match typed.split_once(SEPARATOR) {
43        None => Ok(Spec::Bare(typed)),
44        Some((prefix, label)) => match ProviderId::parse(prefix) {
45            Some(provider) => Ok(Spec::Qualified(provider, label)),
46            None => Err(Error::ProviderUnknown {
47                typed: prefix.to_string(),
48                known: ProviderId::ALL
49                    .iter()
50                    .map(|p| p.code().to_string())
51                    .collect(),
52            }),
53        },
54    }
55}
56
57/// The one account this names.
58///
59/// A label written by 0.1.x could contain a slash, because nothing then separated a tool
60/// from a name. Such an account is still found by what it is called, whole: `team/a` finds
61/// the Claude Code account literally labelled `team/a` when no tool is called `team`, and
62/// when one is and has no such account. Otherwise the app, which passes the label it was
63/// given, could neither switch to it nor forget it.
64pub fn resolve<'a>(state: &'a State, typed: &str) -> Result<&'a Account> {
65    let literal = || state.accounts.iter().find(|a| a.label == typed);
66    let spec = match parse(typed) {
67        Ok(spec) => spec,
68        Err(unknown) => return literal().ok_or(unknown),
69    };
70    if let Spec::Qualified(provider, label) = spec
71        && state.get(&Key::new(provider, label)).is_none()
72        && let Some(account) = literal()
73    {
74        return Ok(account);
75    }
76    match spec {
77        Spec::Qualified(provider, label) => {
78            state
79                .get(&Key::new(provider, label))
80                .ok_or_else(|| Error::AccountUnknown {
81                    label: typed.to_string(),
82                    enrolled: state.labels(provider),
83                })
84        }
85        Spec::Bare(label) => {
86            let mut found = state.accounts.iter().filter(|a| a.label == label);
87            let first = found.next().ok_or_else(|| Error::AccountUnknown {
88                label: label.to_string(),
89                enrolled: qualified(state),
90            })?;
91            match found.next() {
92                None => Ok(first),
93                // Every one of them, not just the two that clash, so the next attempt can
94                // be typed from what is on the screen.
95                Some(_) => Err(Error::LabelAmbiguous {
96                    label: label.to_string(),
97                    matches: state
98                        .accounts
99                        .iter()
100                        .filter(|a| a.label == label)
101                        .map(qualify)
102                        .collect(),
103                }),
104            }
105        }
106    }
107}
108
109/// `claude/work`, as a person would type it back.
110pub fn qualify(account: &Account) -> String {
111    account.key().qualified()
112}
113
114/// Every enrolled label, for a bare label that found nothing.
115///
116/// Qualified only when more than one tool has accounts, because then the reason the label
117/// missed may be that it needs a prefix. With one tool a prefix is noise on every line, and
118/// this message is one people read often.
119fn qualified(state: &State) -> Enrolled {
120    let mut providers = state.accounts.iter().map(Account::provider);
121    let first = providers.next();
122    let one_tool = providers.all(|p| Some(p) == first);
123    Enrolled(
124        state
125            .accounts
126            .iter()
127            .map(|a| {
128                if one_tool {
129                    a.label.clone()
130                } else {
131                    qualify(a)
132                }
133            })
134            .collect(),
135    )
136}
137
138/// A name somebody is choosing for a new account, with the tool it is for.
139///
140/// `pitboard enroll codex/personal --sign-in` says both at once. A bare name means the
141/// default tool, which keeps every command anybody has typed before working unchanged.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct Chosen {
144    pub provider: ProviderId,
145    pub label: String,
146}
147
148/// The tool a bare name is for.
149///
150/// Claude Code, because a bare name is what every pitboard command before this took and it
151/// meant Claude Code. Adding a second tool must not change what somebody's existing script
152/// does.
153pub const DEFAULT: ProviderId = ProviderId::Claude;
154
155/// Read `[provider/]label` as a name for an account that does not exist yet.
156///
157/// Unlike [`resolve`], nothing is enrolled yet to disambiguate against, so a bare name
158/// cannot mean "whichever tool has it". It means [`DEFAULT`].
159pub fn choose(typed: &str) -> std::result::Result<Chosen, String> {
160    let (provider, label) = match typed.split_once(SEPARATOR) {
161        None => (DEFAULT, typed),
162        Some((prefix, label)) => {
163            let provider = ProviderId::parse(prefix).ok_or_else(|| {
164                let known: Vec<&str> = ProviderId::ALL.iter().map(|p| p.code()).collect();
165                format!(
166                    "`{prefix}` is not a tool pitboard knows. It knows: {}",
167                    known.join(", ")
168                )
169            })?;
170            (provider, label)
171        }
172    };
173    if label.is_empty() {
174        return Err(format!(
175            "`{typed}` names a tool but no account. Try `{}{SEPARATOR}work`",
176            provider.code()
177        ));
178    }
179    if label.contains(SEPARATOR) {
180        return Err(format!(
181            "a label cannot contain `{SEPARATOR}`: it separates the tool from the name, so \
182             at most one belongs in `{typed}`"
183        ));
184    }
185    Ok(Chosen {
186        provider,
187        label: label.to_string(),
188    })
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::state::Detail;
195
196    fn account(provider: ProviderId, label: &str) -> Account {
197        let detail = match provider {
198            ProviderId::Claude => Detail::Claude {
199                organization_uuid: String::new(),
200                oauth_account: serde_json::Value::Null,
201            },
202            ProviderId::Codex => Detail::Codex {
203                workspace_id: None,
204                plan: None,
205            },
206        };
207        Account {
208            label: label.into(),
209            account_uuid: format!("{}-{label}", provider.code()),
210            email: format!("{label}@example.com"),
211            parked: None,
212            last_used_at: None,
213            detail,
214        }
215    }
216
217    fn state(labels: &[(ProviderId, &str)]) -> State {
218        let mut state = State::default();
219        for (provider, label) in labels {
220            state.accounts.push(account(*provider, label));
221        }
222        state
223    }
224
225    #[test]
226    fn a_prefix_says_which_provider() {
227        assert_eq!(
228            parse("claude/work").unwrap(),
229            Spec::Qualified(ProviderId::Claude, "work")
230        );
231        assert_eq!(parse("work").unwrap(), Spec::Bare("work"));
232    }
233
234    /// Without this, `pitboard use codx/work` looks for an account literally called
235    /// `codx/work`, fails to find one, and says the label is not enrolled. The person then
236    /// checks their labels, sees `work` is right there, and has no way to tell what
237    /// happened.
238    #[test]
239    fn a_prefix_that_is_not_a_provider_says_so() {
240        let err = parse("codx/work").unwrap_err();
241        assert_eq!(err.code(), "provider_unknown");
242        assert!(err.to_string().contains("codx"), "{err}");
243        assert!(err.to_string().contains("claude"), "{err}");
244    }
245
246    #[test]
247    fn a_bare_label_still_works_when_it_names_one_account() {
248        let state = state(&[(ProviderId::Claude, "work")]);
249        assert_eq!(resolve(&state, "work").unwrap().label, "work");
250        assert_eq!(resolve(&state, "claude/work").unwrap().label, "work");
251    }
252
253    #[test]
254    fn a_label_nothing_has_lists_what_is_enrolled_qualified() {
255        let state = state(&[(ProviderId::Claude, "work")]);
256        let err = resolve(&state, "personal").unwrap_err();
257        assert_eq!(err.code(), "account_unknown");
258        assert!(
259            err.to_string().contains("`work`") && !err.to_string().contains("claude/work"),
260            "one tool enrolled, so a prefix on every line would be noise: {err}"
261        );
262    }
263
264    /// Once two tools have accounts, the prefix is the point: a bare label that missed may
265    /// have missed because it needed one.
266    #[test]
267    fn a_miss_lists_labels_qualified_once_two_tools_have_accounts() {
268        let one = state(&[
269            (ProviderId::Claude, "work"),
270            (ProviderId::Claude, "personal"),
271        ]);
272        assert!(
273            !resolve(&one, "nobody")
274                .unwrap_err()
275                .to_string()
276                .contains("claude/"),
277            "one tool stays bare"
278        );
279
280        let two = state(&[
281            (ProviderId::Claude, "work"),
282            (ProviderId::Codex, "personal"),
283        ]);
284        let err = resolve(&two, "nobody").unwrap_err().to_string();
285        assert!(
286            err.contains("claude/work") && err.contains("codex/personal"),
287            "{err}"
288        );
289    }
290
291    /// A label from before there was a tool prefix is still found by its whole name.
292    #[test]
293    fn a_label_written_with_a_slash_before_prefixes_existed_is_still_found() {
294        let state = state(&[(ProviderId::Claude, "team/a"), (ProviderId::Codex, "a")]);
295        assert_eq!(resolve(&state, "team/a").unwrap().label, "team/a");
296        assert_eq!(
297            resolve(&state, "codex/a").unwrap().provider(),
298            ProviderId::Codex,
299            "a prefix that names a real account of that tool still wins"
300        );
301        assert_eq!(
302            resolve(&state, "codx/a").unwrap_err().code(),
303            "provider_unknown",
304            "and a mistyped prefix is still said to be one"
305        );
306    }
307
308    /// pitboard picking one would switch an account the person did not name.
309    #[test]
310    fn a_bare_label_two_providers_share_is_refused_and_both_are_named() {
311        let state = state(&[(ProviderId::Claude, "work"), (ProviderId::Codex, "work")]);
312
313        let err = resolve(&state, "work").unwrap_err();
314        assert_eq!(err.code(), "label_ambiguous");
315        assert!(err.to_string().contains("claude/work"), "{err}");
316        assert!(err.to_string().contains("codex/work"), "{err}");
317
318        assert_eq!(
319            resolve(&state, "codex/work").unwrap().provider(),
320            ProviderId::Codex,
321            "and a prefix picks exactly the one it names"
322        );
323    }
324
325    #[test]
326    fn a_qualified_miss_lists_only_that_providers_labels() {
327        let state = state(&[(ProviderId::Claude, "work")]);
328        let err = resolve(&state, "claude/personal").unwrap_err();
329        assert!(err.to_string().contains("`work`"), "{err}");
330        assert!(
331            !err.to_string().contains("claude/work"),
332            "inside one provider the prefix is noise: {err}"
333        );
334    }
335
336    /// The syntax for adding an account, which has to carry the tool because nothing is
337    /// enrolled yet to work it out from.
338    #[test]
339    fn a_new_account_can_name_its_tool() {
340        assert_eq!(
341            choose("work").unwrap(),
342            Chosen {
343                provider: DEFAULT,
344                label: "work".into()
345            }
346        );
347        assert_eq!(
348            choose("claude/work").unwrap(),
349            Chosen {
350                provider: ProviderId::Claude,
351                label: "work".into()
352            }
353        );
354    }
355
356    /// A bare name meant Claude Code in every pitboard anybody has run. A second tool must
357    /// not change what a script somebody already wrote does.
358    #[test]
359    fn a_bare_name_still_means_what_it_always_did() {
360        assert_eq!(DEFAULT, ProviderId::Claude);
361        assert_eq!(choose("work").unwrap().provider, ProviderId::Claude);
362    }
363
364    #[test]
365    fn a_new_name_is_refused_when_it_is_not_one() {
366        for (typed, says) in [
367            ("codx/work", "not a tool"),
368            ("claude/", "no account"),
369            ("claude/b/c", "cannot contain"),
370        ] {
371            let refused = choose(typed).unwrap_err();
372            assert!(refused.contains(says), "{typed}: {refused}");
373        }
374    }
375}