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 id: format!("{}-{label}", provider.code()),
210 account_uuid: format!("{}-{label}", provider.code()),
211 email: format!("{label}@example.com"),
212 parked: None,
213 last_used_at: None,
214 detail,
215 }
216 }
217
218 fn state(labels: &[(ProviderId, &str)]) -> State {
219 let mut state = State::default();
220 for (provider, label) in labels {
221 state.accounts.push(account(*provider, label));
222 }
223 state
224 }
225
226 #[test]
227 fn a_prefix_says_which_provider() {
228 assert_eq!(
229 parse("claude/work").unwrap(),
230 Spec::Qualified(ProviderId::Claude, "work")
231 );
232 assert_eq!(parse("work").unwrap(), Spec::Bare("work"));
233 }
234
235 #[test]
240 fn a_prefix_that_is_not_a_provider_says_so() {
241 let err = parse("codx/work").unwrap_err();
242 assert_eq!(err.code(), "provider_unknown");
243 assert!(err.to_string().contains("codx"), "{err}");
244 assert!(err.to_string().contains("claude"), "{err}");
245 }
246
247 #[test]
248 fn a_bare_label_still_works_when_it_names_one_account() {
249 let state = state(&[(ProviderId::Claude, "work")]);
250 assert_eq!(resolve(&state, "work").unwrap().label, "work");
251 assert_eq!(resolve(&state, "claude/work").unwrap().label, "work");
252 }
253
254 #[test]
255 fn a_label_nothing_has_lists_what_is_enrolled_qualified() {
256 let state = state(&[(ProviderId::Claude, "work")]);
257 let err = resolve(&state, "personal").unwrap_err();
258 assert_eq!(err.code(), "account_unknown");
259 assert!(
260 err.to_string().contains("`work`") && !err.to_string().contains("claude/work"),
261 "one tool enrolled, so a prefix on every line would be noise: {err}"
262 );
263 }
264
265 #[test]
268 fn a_miss_lists_labels_qualified_once_two_tools_have_accounts() {
269 let one = state(&[
270 (ProviderId::Claude, "work"),
271 (ProviderId::Claude, "personal"),
272 ]);
273 assert!(
274 !resolve(&one, "nobody")
275 .unwrap_err()
276 .to_string()
277 .contains("claude/"),
278 "one tool stays bare"
279 );
280
281 let two = state(&[
282 (ProviderId::Claude, "work"),
283 (ProviderId::Codex, "personal"),
284 ]);
285 let err = resolve(&two, "nobody").unwrap_err().to_string();
286 assert!(
287 err.contains("claude/work") && err.contains("codex/personal"),
288 "{err}"
289 );
290 }
291
292 #[test]
294 fn a_label_written_with_a_slash_before_prefixes_existed_is_still_found() {
295 let state = state(&[(ProviderId::Claude, "team/a"), (ProviderId::Codex, "a")]);
296 assert_eq!(resolve(&state, "team/a").unwrap().label, "team/a");
297 assert_eq!(
298 resolve(&state, "codex/a").unwrap().provider(),
299 ProviderId::Codex,
300 "a prefix that names a real account of that tool still wins"
301 );
302 assert_eq!(
303 resolve(&state, "codx/a").unwrap_err().code(),
304 "provider_unknown",
305 "and a mistyped prefix is still said to be one"
306 );
307 }
308
309 #[test]
311 fn a_bare_label_two_providers_share_is_refused_and_both_are_named() {
312 let state = state(&[(ProviderId::Claude, "work"), (ProviderId::Codex, "work")]);
313
314 let err = resolve(&state, "work").unwrap_err();
315 assert_eq!(err.code(), "label_ambiguous");
316 assert!(err.to_string().contains("claude/work"), "{err}");
317 assert!(err.to_string().contains("codex/work"), "{err}");
318
319 assert_eq!(
320 resolve(&state, "codex/work").unwrap().provider(),
321 ProviderId::Codex,
322 "and a prefix picks exactly the one it names"
323 );
324 }
325
326 #[test]
327 fn a_qualified_miss_lists_only_that_providers_labels() {
328 let state = state(&[(ProviderId::Claude, "work")]);
329 let err = resolve(&state, "claude/personal").unwrap_err();
330 assert!(err.to_string().contains("`work`"), "{err}");
331 assert!(
332 !err.to_string().contains("claude/work"),
333 "inside one provider the prefix is noise: {err}"
334 );
335 }
336
337 #[test]
340 fn a_new_account_can_name_its_tool() {
341 assert_eq!(
342 choose("work").unwrap(),
343 Chosen {
344 provider: DEFAULT,
345 label: "work".into()
346 }
347 );
348 assert_eq!(
349 choose("claude/work").unwrap(),
350 Chosen {
351 provider: ProviderId::Claude,
352 label: "work".into()
353 }
354 );
355 }
356
357 #[test]
360 fn a_bare_name_still_means_what_it_always_did() {
361 assert_eq!(DEFAULT, ProviderId::Claude);
362 assert_eq!(choose("work").unwrap().provider, ProviderId::Claude);
363 }
364
365 #[test]
366 fn a_new_name_is_refused_when_it_is_not_one() {
367 for (typed, says) in [
368 ("codx/work", "not a tool"),
369 ("claude/", "no account"),
370 ("claude/b/c", "cannot contain"),
371 ] {
372 let refused = choose(typed).unwrap_err();
373 assert!(refused.contains(says), "{typed}: {refused}");
374 }
375 }
376}