use crate::error::{Enrolled, Error, Result};
use crate::provider::ProviderId;
use crate::state::{Account, Key, State};
pub const SEPARATOR: char = '/';
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Spec<'a> {
Bare(&'a str),
Qualified(ProviderId, &'a str),
}
impl Spec<'_> {
pub fn label(&self) -> &str {
match self {
Spec::Bare(label) | Spec::Qualified(_, label) => label,
}
}
}
pub fn parse(typed: &str) -> Result<Spec<'_>> {
match typed.split_once(SEPARATOR) {
None => Ok(Spec::Bare(typed)),
Some((prefix, label)) => match ProviderId::parse(prefix) {
Some(provider) => Ok(Spec::Qualified(provider, label)),
None => Err(Error::ProviderUnknown {
typed: prefix.to_string(),
known: ProviderId::ALL
.iter()
.map(|p| p.code().to_string())
.collect(),
}),
},
}
}
pub fn resolve<'a>(state: &'a State, typed: &str) -> Result<&'a Account> {
let literal = || state.accounts.iter().find(|a| a.label == typed);
let spec = match parse(typed) {
Ok(spec) => spec,
Err(unknown) => return literal().ok_or(unknown),
};
if let Spec::Qualified(provider, label) = spec
&& state.get(&Key::new(provider, label)).is_none()
&& let Some(account) = literal()
{
return Ok(account);
}
match spec {
Spec::Qualified(provider, label) => {
state
.get(&Key::new(provider, label))
.ok_or_else(|| Error::AccountUnknown {
label: typed.to_string(),
enrolled: state.labels(provider),
})
}
Spec::Bare(label) => {
let mut found = state.accounts.iter().filter(|a| a.label == label);
let first = found.next().ok_or_else(|| Error::AccountUnknown {
label: label.to_string(),
enrolled: qualified(state),
})?;
match found.next() {
None => Ok(first),
Some(_) => Err(Error::LabelAmbiguous {
label: label.to_string(),
matches: state
.accounts
.iter()
.filter(|a| a.label == label)
.map(qualify)
.collect(),
}),
}
}
}
}
pub fn qualify(account: &Account) -> String {
account.key().qualified()
}
fn qualified(state: &State) -> Enrolled {
let mut providers = state.accounts.iter().map(Account::provider);
let first = providers.next();
let one_tool = providers.all(|p| Some(p) == first);
Enrolled(
state
.accounts
.iter()
.map(|a| {
if one_tool {
a.label.clone()
} else {
qualify(a)
}
})
.collect(),
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Chosen {
pub provider: ProviderId,
pub label: String,
}
pub const DEFAULT: ProviderId = ProviderId::Claude;
pub fn choose(typed: &str) -> std::result::Result<Chosen, String> {
let (provider, label) = match typed.split_once(SEPARATOR) {
None => (DEFAULT, typed),
Some((prefix, label)) => {
let provider = ProviderId::parse(prefix).ok_or_else(|| {
let known: Vec<&str> = ProviderId::ALL.iter().map(|p| p.code()).collect();
format!(
"`{prefix}` is not a tool pitboard knows. It knows: {}",
known.join(", ")
)
})?;
(provider, label)
}
};
if label.is_empty() {
return Err(format!(
"`{typed}` names a tool but no account. Try `{}{SEPARATOR}work`",
provider.code()
));
}
if label.contains(SEPARATOR) {
return Err(format!(
"a label cannot contain `{SEPARATOR}`: it separates the tool from the name, so \
at most one belongs in `{typed}`"
));
}
Ok(Chosen {
provider,
label: label.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::Detail;
fn account(provider: ProviderId, label: &str) -> Account {
let detail = match provider {
ProviderId::Claude => Detail::Claude {
organization_uuid: String::new(),
oauth_account: serde_json::Value::Null,
},
ProviderId::Codex => Detail::Codex {
workspace_id: None,
plan: None,
},
};
Account {
label: label.into(),
account_uuid: format!("{}-{label}", provider.code()),
email: format!("{label}@example.com"),
parked: None,
last_used_at: None,
detail,
}
}
fn state(labels: &[(ProviderId, &str)]) -> State {
let mut state = State::default();
for (provider, label) in labels {
state.accounts.push(account(*provider, label));
}
state
}
#[test]
fn a_prefix_says_which_provider() {
assert_eq!(
parse("claude/work").unwrap(),
Spec::Qualified(ProviderId::Claude, "work")
);
assert_eq!(parse("work").unwrap(), Spec::Bare("work"));
}
#[test]
fn a_prefix_that_is_not_a_provider_says_so() {
let err = parse("codx/work").unwrap_err();
assert_eq!(err.code(), "provider_unknown");
assert!(err.to_string().contains("codx"), "{err}");
assert!(err.to_string().contains("claude"), "{err}");
}
#[test]
fn a_bare_label_still_works_when_it_names_one_account() {
let state = state(&[(ProviderId::Claude, "work")]);
assert_eq!(resolve(&state, "work").unwrap().label, "work");
assert_eq!(resolve(&state, "claude/work").unwrap().label, "work");
}
#[test]
fn a_label_nothing_has_lists_what_is_enrolled_qualified() {
let state = state(&[(ProviderId::Claude, "work")]);
let err = resolve(&state, "personal").unwrap_err();
assert_eq!(err.code(), "account_unknown");
assert!(
err.to_string().contains("`work`") && !err.to_string().contains("claude/work"),
"one tool enrolled, so a prefix on every line would be noise: {err}"
);
}
#[test]
fn a_miss_lists_labels_qualified_once_two_tools_have_accounts() {
let one = state(&[
(ProviderId::Claude, "work"),
(ProviderId::Claude, "personal"),
]);
assert!(
!resolve(&one, "nobody")
.unwrap_err()
.to_string()
.contains("claude/"),
"one tool stays bare"
);
let two = state(&[
(ProviderId::Claude, "work"),
(ProviderId::Codex, "personal"),
]);
let err = resolve(&two, "nobody").unwrap_err().to_string();
assert!(
err.contains("claude/work") && err.contains("codex/personal"),
"{err}"
);
}
#[test]
fn a_label_written_with_a_slash_before_prefixes_existed_is_still_found() {
let state = state(&[(ProviderId::Claude, "team/a"), (ProviderId::Codex, "a")]);
assert_eq!(resolve(&state, "team/a").unwrap().label, "team/a");
assert_eq!(
resolve(&state, "codex/a").unwrap().provider(),
ProviderId::Codex,
"a prefix that names a real account of that tool still wins"
);
assert_eq!(
resolve(&state, "codx/a").unwrap_err().code(),
"provider_unknown",
"and a mistyped prefix is still said to be one"
);
}
#[test]
fn a_bare_label_two_providers_share_is_refused_and_both_are_named() {
let state = state(&[(ProviderId::Claude, "work"), (ProviderId::Codex, "work")]);
let err = resolve(&state, "work").unwrap_err();
assert_eq!(err.code(), "label_ambiguous");
assert!(err.to_string().contains("claude/work"), "{err}");
assert!(err.to_string().contains("codex/work"), "{err}");
assert_eq!(
resolve(&state, "codex/work").unwrap().provider(),
ProviderId::Codex,
"and a prefix picks exactly the one it names"
);
}
#[test]
fn a_qualified_miss_lists_only_that_providers_labels() {
let state = state(&[(ProviderId::Claude, "work")]);
let err = resolve(&state, "claude/personal").unwrap_err();
assert!(err.to_string().contains("`work`"), "{err}");
assert!(
!err.to_string().contains("claude/work"),
"inside one provider the prefix is noise: {err}"
);
}
#[test]
fn a_new_account_can_name_its_tool() {
assert_eq!(
choose("work").unwrap(),
Chosen {
provider: DEFAULT,
label: "work".into()
}
);
assert_eq!(
choose("claude/work").unwrap(),
Chosen {
provider: ProviderId::Claude,
label: "work".into()
}
);
}
#[test]
fn a_bare_name_still_means_what_it_always_did() {
assert_eq!(DEFAULT, ProviderId::Claude);
assert_eq!(choose("work").unwrap().provider, ProviderId::Claude);
}
#[test]
fn a_new_name_is_refused_when_it_is_not_one() {
for (typed, says) in [
("codx/work", "not a tool"),
("claude/", "no account"),
("claude/b/c", "cannot contain"),
] {
let refused = choose(typed).unwrap_err();
assert!(refused.contains(says), "{typed}: {refused}");
}
}
}