use crate::WorkContext;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Explicit {
Org(String),
Context(WorkContext),
Personal,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RepositoryOrg {
Undeclared,
Declared { org: String, declared_in: String },
Unreadable { location: String, error: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Permitted {
Orgs(Vec<String>),
AnyHeld,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentOrganizations {
pub agent: String,
pub permitted: Permitted,
pub default: Option<String>,
pub personal: bool,
}
impl AgentOrganizations {
pub fn personal_only(agent: impl Into<String>) -> Self {
Self {
agent: agent.into(),
permitted: Permitted::Orgs(Vec::new()),
default: None,
personal: true,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Inputs<'a> {
pub inbound: Option<&'a WorkContext>,
pub explicit: Option<&'a Explicit>,
pub repository: &'a RepositoryOrg,
pub agent: &'a AgentOrganizations,
pub user_default: Option<&'a WorkContext>,
pub held: &'a [WorkContext],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
Inbound,
Explicit,
Repository,
AgentDefault,
UserDefault,
Personal,
}
impl Source {
pub fn as_str(&self) -> &'static str {
match self {
Self::Inbound => "inbound",
Self::Explicit => "explicit",
Self::Repository => "repository",
Self::AgentDefault => "agent_default",
Self::UserDefault => "user_default",
Self::Personal => "personal",
}
}
}
impl fmt::Display for Source {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Inbound => "the inbound request",
Self::Explicit => "the explicit choice",
Self::Repository => "the repository",
Self::AgentDefault => "the agent's default",
Self::UserDefault => "your default org",
Self::Personal => "personal work",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolved {
pub context: Option<WorkContext>,
pub source: Source,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refusal {
RepositoryUnreadable {
location: String,
error: String,
},
RepositoryMismatch {
repository_org: String,
declared_in: String,
requested: Option<String>,
source: Source,
},
NotPermitted {
agent: String,
org: String,
source: Source,
},
PersonalNotAllowed {
agent: String,
},
NotHeld {
org: String,
source: Source,
},
Ambiguous {
org: String,
accounts: Vec<String>,
},
ContextNotHeld {
context: WorkContext,
},
DefaultUnavailable {
org: String,
},
Unresolved {
agent: String,
},
}
impl Refusal {
pub fn code(&self) -> &'static str {
match self {
Self::RepositoryUnreadable { .. } => "repository_unreadable",
Self::RepositoryMismatch { .. } => "repository_mismatch",
Self::NotPermitted { .. } => "not_permitted",
Self::PersonalNotAllowed { .. } => "personal_not_allowed",
Self::NotHeld { .. } => "not_held",
Self::Ambiguous { .. } => "ambiguous",
Self::ContextNotHeld { .. } => "context_not_held",
Self::DefaultUnavailable { .. } => "default_unavailable",
Self::Unresolved { .. } => "unresolved",
}
}
}
impl fmt::Display for Refusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::RepositoryUnreadable { location, error } => write!(
f,
"cannot read the repository's organization from {location}: {error}. \
Fix the file before starting work here"
),
Self::RepositoryMismatch {
repository_org,
declared_in,
requested,
source,
} => {
let requested = requested
.as_deref()
.map(|org| format!("org `{org}`"))
.unwrap_or_else(|| "personal work".to_string());
write!(
f,
"this repository belongs to org `{repository_org}` (declared in \
{declared_in}); a session here cannot bind {requested} from {source}. \
Work in `{repository_org}`, or start the session outside this repository"
)
}
Self::NotPermitted { agent, org, source } => write!(
f,
"agent `{agent}` is not permitted in org `{org}` (from {source}). \
Add `{org}` to the agent's permitted organizations, or choose another org"
),
Self::PersonalNotAllowed { agent } => write!(
f,
"agent `{agent}` does not run personal work. Choose one of its organizations"
),
Self::NotHeld { org, source } => write!(
f,
"no signed-in Parslee login belongs to org `{org}` (from {source}). \
Sign in to it with `car auth login --add`"
),
Self::Ambiguous { org, accounts } => write!(
f,
"more than one signed-in login belongs to org `{org}` ({}). \
Name the account explicitly",
accounts.join(", ")
),
Self::ContextNotHeld { context } => write!(
f,
"no signed-in login holds the requested context (account `{}`, org `{}`). \
Sign in to it with `car auth login --add`",
context.account_id,
context.organization_id.as_deref().unwrap_or("personal")
),
Self::DefaultUnavailable { org } => write!(
f,
"your default org `{org}` is no longer held by any signed-in login. \
Choose a new one with `car auth default-org <org>`"
),
Self::Unresolved { agent } => write!(
f,
"no organization applies to this session and agent `{agent}` does not run \
personal work. Name an org, or set a default with `car auth default-org <org>`"
),
}
}
}
impl std::error::Error for Refusal {}
enum Candidate<'a> {
Exact(&'a WorkContext),
Org(&'a str),
Personal,
}
impl Candidate<'_> {
fn org(&self) -> Option<&str> {
match self {
Self::Exact(context) => context.organization_id.as_deref(),
Self::Org(org) => Some(org),
Self::Personal => None,
}
}
}
pub fn resolve(inputs: Inputs<'_>) -> Result<Resolved, Refusal> {
if let RepositoryOrg::Unreadable { location, error } = inputs.repository {
return Err(Refusal::RepositoryUnreadable {
location: location.clone(),
error: error.clone(),
});
}
let (candidate, source) = pick(&inputs)?;
if let RepositoryOrg::Declared { org, declared_in } = inputs.repository {
if candidate.org() != Some(org.as_str()) {
return Err(Refusal::RepositoryMismatch {
repository_org: org.clone(),
declared_in: declared_in.clone(),
requested: candidate.org().map(str::to_string),
source,
});
}
}
let agent = inputs.agent;
if !permits(agent, candidate.org()) {
return Err(match candidate.org() {
None => Refusal::PersonalNotAllowed {
agent: agent.agent.clone(),
},
Some(org) => Refusal::NotPermitted {
agent: agent.agent.clone(),
org: org.to_string(),
source,
},
});
}
let context = match candidate {
Candidate::Personal => None,
Candidate::Exact(context) if context.organization_id.is_none() => None,
Candidate::Exact(context) => {
if !inputs.held.contains(context) {
return Err(if source == Source::UserDefault {
Refusal::DefaultUnavailable {
org: context.organization_id.clone().unwrap_or_default(),
}
} else {
Refusal::ContextNotHeld {
context: context.clone(),
}
});
}
Some(context.clone())
}
Candidate::Org(org) => Some(held_by_one(inputs.held, org, source)?.clone()),
};
Ok(Resolved { context, source })
}
fn pick<'a>(inputs: &Inputs<'a>) -> Result<(Candidate<'a>, Source), Refusal> {
if let Some(context) = inputs.inbound {
return Ok((Candidate::Exact(context), Source::Inbound));
}
if let Some(explicit) = inputs.explicit {
let candidate = match explicit {
Explicit::Org(org) => Candidate::Org(org),
Explicit::Context(context) => Candidate::Exact(context),
Explicit::Personal => Candidate::Personal,
};
return Ok((candidate, Source::Explicit));
}
if let RepositoryOrg::Declared { org, .. } = inputs.repository {
return Ok((Candidate::Org(org), Source::Repository));
}
if let Some(org) = inputs.agent.default.as_deref() {
return Ok((Candidate::Org(org), Source::AgentDefault));
}
if let Some(context) = inputs
.user_default
.filter(|context| permits(inputs.agent, context.organization_id.as_deref()))
{
return Ok((Candidate::Exact(context), Source::UserDefault));
}
if inputs.agent.personal {
return Ok((Candidate::Personal, Source::Personal));
}
Err(Refusal::Unresolved {
agent: inputs.agent.agent.clone(),
})
}
fn permits(agent: &AgentOrganizations, org: Option<&str>) -> bool {
match (org, &agent.permitted) {
(None, _) => agent.personal,
(Some(_), Permitted::AnyHeld) => true,
(Some(org), Permitted::Orgs(orgs)) => orgs.iter().any(|o| o == org),
}
}
fn held_by_one<'h>(
held: &'h [WorkContext],
org: &str,
source: Source,
) -> Result<&'h WorkContext, Refusal> {
let mut matches = held
.iter()
.filter(|context| context.organization_id.as_deref() == Some(org));
let Some(first) = matches.next() else {
return Err(Refusal::NotHeld {
org: org.to_string(),
source,
});
};
let others: Vec<&WorkContext> = matches.filter(|context| *context != first).collect();
if others.is_empty() {
return Ok(first);
}
let mut accounts: Vec<String> = std::iter::once(first)
.chain(others)
.map(|context| context.account_id.clone())
.collect();
accounts.sort();
accounts.dedup();
Err(Refusal::Ambiguous {
org: org.to_string(),
accounts,
})
}
#[cfg(test)]
mod tests {
use super::*;
const BASE: &str = "https://api.parslee.ai";
fn ctx(account: &str, org: &str) -> WorkContext {
WorkContext::new(BASE, account, Some(org.into())).unwrap()
}
fn held() -> Vec<WorkContext> {
vec![
ctx("acc_parslee", "org_parslee"),
ctx("acc_parslee", "org_parslee_steelpath"),
ctx("acc_fly", "org_fly_exclusive"),
ctx("acc_volato", "org_volato"),
]
}
fn agent(permitted: &[&str], default: Option<&str>, personal: bool) -> AgentOrganizations {
AgentOrganizations {
agent: "parslee-core".into(),
permitted: Permitted::Orgs(permitted.iter().map(|s| s.to_string()).collect()),
default: default.map(str::to_string),
personal,
}
}
fn declared(org: &str) -> RepositoryOrg {
RepositoryOrg::Declared {
org: org.into(),
declared_in: "/repo/.car/config.toml".into(),
}
}
struct Case<'a> {
inbound: Option<WorkContext>,
explicit: Option<Explicit>,
repository: RepositoryOrg,
agent: AgentOrganizations,
user_default: Option<WorkContext>,
held: &'a [WorkContext],
}
impl Case<'_> {
fn resolve(&self) -> Result<Resolved, Refusal> {
resolve(Inputs {
inbound: self.inbound.as_ref(),
explicit: self.explicit.as_ref(),
repository: &self.repository,
agent: &self.agent,
user_default: self.user_default.as_ref(),
held: self.held,
})
}
}
fn base(held: &[WorkContext]) -> Case<'_> {
Case {
inbound: None,
explicit: None,
repository: RepositoryOrg::Undeclared,
agent: agent(
&["org_parslee", "org_fly_exclusive", "org_volato"],
None,
true,
),
user_default: Some(ctx("acc_parslee", "org_parslee")),
held,
}
}
fn bound(result: Result<Resolved, Refusal>) -> (Option<String>, Option<String>, Source) {
let resolved = result.expect("expected a binding");
let context = resolved.context;
(
context.as_ref().map(|c| c.account_id.clone()),
context.and_then(|c| c.organization_id),
resolved.source,
)
}
fn some(s: &str) -> Option<String> {
Some(s.to_string())
}
#[test]
fn the_user_default_fills_in_when_nothing_else_applies() {
let held = held();
assert_eq!(
bound(base(&held).resolve()),
(
some("acc_parslee"),
some("org_parslee"),
Source::UserDefault
)
);
}
#[test]
fn sources_apply_in_order() {
let held = held();
let mut case = base(&held);
case.agent.default = some("org_volato");
assert_eq!(
bound(case.resolve()),
(some("acc_volato"), some("org_volato"), Source::AgentDefault)
);
case.repository = declared("org_fly_exclusive");
assert_eq!(
bound(case.resolve()),
(
some("acc_fly"),
some("org_fly_exclusive"),
Source::Repository
)
);
case.explicit = Some(Explicit::Org("org_fly_exclusive".into()));
assert_eq!(bound(case.resolve()).2, Source::Explicit);
case.inbound = Some(ctx("acc_fly", "org_fly_exclusive"));
assert_eq!(bound(case.resolve()).2, Source::Inbound);
}
#[test]
fn the_org_supplies_the_account() {
let held = held();
let mut case = base(&held);
case.explicit = Some(Explicit::Org("org_parslee_steelpath".into()));
case.agent.permitted = Permitted::AnyHeld;
assert_eq!(
bound(case.resolve()),
(
some("acc_parslee"),
some("org_parslee_steelpath"),
Source::Explicit
)
);
}
#[test]
fn a_repository_org_constrains_every_source() {
let held = held();
let mismatch = |case: &Case<'_>, source: Source| match case.resolve() {
Err(Refusal::RepositoryMismatch {
repository_org,
source: got,
..
}) => {
assert_eq!(repository_org, "org_fly_exclusive");
assert_eq!(got, source);
}
other => panic!("expected a repository mismatch, got {other:?}"),
};
let mut case = base(&held);
case.repository = declared("org_fly_exclusive");
case.explicit = Some(Explicit::Org("org_volato".into()));
mismatch(&case, Source::Explicit);
case.explicit = Some(Explicit::Personal);
mismatch(&case, Source::Explicit);
case.explicit = Some(Explicit::Context(ctx("acc_volato", "org_volato")));
mismatch(&case, Source::Explicit);
case.explicit = None;
case.inbound = Some(ctx("acc_volato", "org_volato"));
mismatch(&case, Source::Inbound);
}
#[test]
fn an_unreadable_repository_refuses_instead_of_falling_back() {
let held = held();
let mut case = base(&held);
case.repository = RepositoryOrg::Unreadable {
location: "/repo/.car/config.toml".into(),
error: "invalid TOML".into(),
};
case.explicit = Some(Explicit::Org("org_parslee".into()));
assert!(matches!(
case.resolve(),
Err(Refusal::RepositoryUnreadable { .. })
));
}
#[test]
fn the_permitted_set_binds_every_source() {
let held = held();
let mut case = base(&held);
case.agent = agent(&["org_parslee"], None, true);
case.explicit = Some(Explicit::Org("org_volato".into()));
assert!(matches!(
case.resolve(),
Err(Refusal::NotPermitted {
source: Source::Explicit,
..
})
));
case.explicit = None;
case.repository = declared("org_volato");
assert!(matches!(
case.resolve(),
Err(Refusal::NotPermitted {
source: Source::Repository,
..
})
));
case.repository = RepositoryOrg::Undeclared;
case.inbound = Some(ctx("acc_volato", "org_volato"));
assert!(matches!(
case.resolve(),
Err(Refusal::NotPermitted {
source: Source::Inbound,
..
})
));
}
#[test]
fn the_user_default_applies_only_where_the_agent_is_permitted() {
let held = held();
let mut case = base(&held);
case.agent = agent(&["org_parslee"], None, true);
case.user_default = Some(ctx("acc_volato", "org_volato"));
assert_eq!(bound(case.resolve()), (None, None, Source::Personal));
case.agent.personal = false;
assert!(matches!(case.resolve(), Err(Refusal::Unresolved { .. })));
}
#[test]
fn an_explicit_refusal_never_falls_back_to_a_default() {
let held = held();
let mut case = base(&held);
case.agent = agent(&["org_parslee"], Some("org_parslee"), true);
case.explicit = Some(Explicit::Org("org_volato".into()));
assert!(matches!(
case.resolve(),
Err(Refusal::NotPermitted {
source: Source::Explicit,
..
})
));
}
#[test]
fn personal_work_needs_the_agent_to_allow_it() {
let held = held();
let mut case = base(&held);
case.user_default = None;
assert_eq!(bound(case.resolve()), (None, None, Source::Personal));
case.agent.personal = false;
assert!(matches!(case.resolve(), Err(Refusal::Unresolved { .. })));
case.explicit = Some(Explicit::Personal);
assert!(matches!(
case.resolve(),
Err(Refusal::PersonalNotAllowed { .. })
));
}
#[test]
fn an_agent_with_no_org_settings_stays_personal() {
let held = held();
let mut case = base(&held);
case.agent = AgentOrganizations::personal_only("milo");
case.user_default = None;
assert_eq!(bound(case.resolve()), (None, None, Source::Personal));
case.user_default = Some(ctx("acc_parslee", "org_parslee"));
assert_eq!(bound(case.resolve()), (None, None, Source::Personal));
case.explicit = Some(Explicit::Org("org_parslee".into()));
assert!(matches!(case.resolve(), Err(Refusal::NotPermitted { .. })));
}
#[test]
fn an_org_no_login_holds_is_refused() {
let held = held();
let mut case = base(&held);
case.agent.permitted = Permitted::AnyHeld;
case.explicit = Some(Explicit::Org("org_acme".into()));
assert!(matches!(
case.resolve(),
Err(Refusal::NotHeld {
source: Source::Explicit,
..
})
));
}
#[test]
fn a_stale_user_default_is_refused_by_name() {
let held: Vec<WorkContext> = held()
.into_iter()
.filter(|c| c.account_id != "acc_volato")
.collect();
let mut case = base(&held);
case.user_default = Some(ctx("acc_volato", "org_volato"));
match case.resolve() {
Err(Refusal::DefaultUnavailable { org }) => assert_eq!(org, "org_volato"),
other => panic!("expected DefaultUnavailable, got {other:?}"),
}
}
#[test]
fn an_explicit_context_must_be_held_exactly() {
let held = held();
let mut case = base(&held);
case.explicit = Some(Explicit::Context(ctx("acc_parslee", "org_volato")));
assert!(matches!(
case.resolve(),
Err(Refusal::ContextNotHeld { .. })
));
case.explicit = Some(Explicit::Context(ctx("acc_volato", "org_volato")));
assert_eq!(
bound(case.resolve()),
(some("acc_volato"), some("org_volato"), Source::Explicit)
);
}
#[test]
fn two_logins_in_one_org_is_ambiguous_not_a_guess() {
let mut held = held();
held.push(ctx("acc_parslee", "org_volato"));
let mut case = base(&held);
case.explicit = Some(Explicit::Org("org_volato".into()));
match case.resolve() {
Err(Refusal::Ambiguous { org, accounts }) => {
assert_eq!(org, "org_volato");
assert_eq!(accounts, vec!["acc_parslee", "acc_volato"]);
}
other => panic!("expected Ambiguous, got {other:?}"),
}
case.explicit = Some(Explicit::Context(ctx("acc_volato", "org_volato")));
assert_eq!(bound(case.resolve()).0, some("acc_volato"));
}
#[test]
fn duplicate_index_entries_are_not_ambiguous() {
let mut held = held();
held.push(ctx("acc_volato", "org_volato"));
let mut case = base(&held);
case.explicit = Some(Explicit::Org("org_volato".into()));
assert_eq!(bound(case.resolve()).0, some("acc_volato"));
}
#[test]
fn every_refusal_names_a_next_step() {
let refusals = [
Refusal::RepositoryUnreadable {
location: "x".into(),
error: "y".into(),
},
Refusal::RepositoryMismatch {
repository_org: "a".into(),
declared_in: "x".into(),
requested: None,
source: Source::Explicit,
},
Refusal::NotPermitted {
agent: "a".into(),
org: "o".into(),
source: Source::Explicit,
},
Refusal::PersonalNotAllowed { agent: "a".into() },
Refusal::NotHeld {
org: "o".into(),
source: Source::Explicit,
},
Refusal::Ambiguous {
org: "o".into(),
accounts: vec!["a".into(), "b".into()],
},
Refusal::ContextNotHeld {
context: ctx("a", "o"),
},
Refusal::DefaultUnavailable { org: "o".into() },
Refusal::Unresolved { agent: "a".into() },
];
for refusal in refusals {
let message = refusal.to_string();
let actionable = ["Fix", "Work in", "Add", "Choose", "Sign in", "Name"]
.iter()
.any(|verb| message.contains(verb));
assert!(actionable, "refusal without a next step: {message}");
}
}
}