1use crate::error::{Enrolled, Error, Result};
13use crate::provider::ProviderId;
14use crate::state::{Account, Key, State};
15
16pub const SEPARATOR: char = '/';
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum Spec<'a> {
22 Bare(&'a str),
24 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
36pub 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
57pub 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 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
109pub fn qualify(account: &Account) -> String {
111 account.key().qualified()
112}
113
114fn 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#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct Chosen {
144 pub provider: ProviderId,
145 pub label: String,
146}
147
148pub const DEFAULT: ProviderId = ProviderId::Claude;
154
155pub 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 #[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 #[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 #[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 #[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 #[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 #[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}